- Sidebar Layouts items now link to /design?surface=<surfaceId> - Design page reads ?surface= param and opens that surface directly - DesignPage split into DesignPageInner + Suspense wrapper so useSearchParams works in the Next.js static build Made-with: Cursor
1155 lines
51 KiB
TypeScript
1155 lines
51 KiB
TypeScript
"use client";
|
|
|
|
import { use, useState, useEffect, Suspense } from "react";
|
|
import { useSearchParams } from "next/navigation";
|
|
import { toast } from "sonner";
|
|
import {
|
|
SCAFFOLD_REGISTRY, THEME_REGISTRY,
|
|
type ThemeColor, type DesignConfig, type LibraryStyleOptions,
|
|
} from "@/components/design-scaffolds";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Surface definitions
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface Surface {
|
|
id: string;
|
|
name: string;
|
|
description: string;
|
|
icon: string;
|
|
themes: Theme[];
|
|
}
|
|
|
|
interface Theme {
|
|
id: string;
|
|
name: string;
|
|
description: string;
|
|
tags: string[];
|
|
url: string;
|
|
// Richer metadata shown in the capability card picker
|
|
highlights?: string[]; // 3 key things this library does well
|
|
hasTemplates?: boolean; // pre-built page templates available
|
|
bestFor?: string; // one-line "best for" summary
|
|
darkFirst?: boolean; // dark-mode-first aesthetic
|
|
}
|
|
|
|
const ALL_SURFACES: Surface[] = [
|
|
{
|
|
id: "web-app",
|
|
name: "Web App",
|
|
icon: "⬡",
|
|
description: "The core product users log into — dashboards, features, settings",
|
|
themes: [
|
|
{
|
|
id: "shadcn", name: "shadcn/ui", url: "https://ui.shadcn.com",
|
|
description: "Copy-paste components on Radix primitives. You own the code.",
|
|
tags: ["Tailwind", "Radix", "Copy-paste"],
|
|
bestFor: "Most SaaS apps — you control every pixel",
|
|
highlights: ["Code lives in your repo, not node_modules", "Radix primitives for full accessibility", "New York & Default variants built in"],
|
|
hasTemplates: true,
|
|
},
|
|
{
|
|
id: "mantine", name: "Mantine", url: "https://mantine.dev",
|
|
description: "100+ fully featured components including charts, forms, date pickers.",
|
|
tags: ["React", "Charts", "Forms", "Hooks"],
|
|
bestFor: "Data-heavy apps that need charts and complex forms",
|
|
highlights: ["Built-in charts, tables, and data grid", "60+ hooks (useForm, useLocalStorage, etc.)", "Date/time pickers, rich text editor included"],
|
|
},
|
|
{
|
|
id: "hero-ui", name: "HeroUI", url: "https://heroui.com",
|
|
description: "Smooth animations, blur effects, and dark mode out of the box.",
|
|
tags: ["Tailwind", "Framer Motion", "Dark mode"],
|
|
bestFor: "Modern SaaS with polished animations",
|
|
highlights: ["Blur backdrop navigation built in", "Framer Motion on every interaction", "Dark mode first, light mode included"],
|
|
hasTemplates: true,
|
|
},
|
|
{
|
|
id: "tremor", name: "Tremor", url: "https://tremor.so",
|
|
description: "Purpose-built dashboard components — charts, KPIs, tables.",
|
|
tags: ["Charts", "Dashboard", "Analytics"],
|
|
bestFor: "Analytics products and internal dashboards",
|
|
highlights: ["Chart library built in (area, bar, donut)", "KPI cards, progress bars, trackers", "Area chart with tooltip out of the box"],
|
|
hasTemplates: true,
|
|
},
|
|
],
|
|
},
|
|
{
|
|
id: "marketing",
|
|
name: "Marketing Site",
|
|
icon: "◎",
|
|
description: "Public-facing landing page, blog, pricing — brand expression and conversion",
|
|
themes: [
|
|
{
|
|
id: "daisy-ui", name: "DaisyUI", url: "https://daisyui.com",
|
|
description: "Tailwind plugin with 48 built-in themes — swap the entire look with one word.",
|
|
tags: ["Tailwind", "48 Themes", "Plugin"],
|
|
bestFor: "Teams that want theme variety without custom CSS",
|
|
highlights: ["48 built-in themes (dark, cupcake, synthwave, cyberpunk…)", "One-line theme switching: data-theme=\"synthwave\"", "Hero, Features, Pricing, CTA, Footer all included"],
|
|
hasTemplates: true,
|
|
},
|
|
{
|
|
id: "hero-ui", name: "HeroUI", url: "https://heroui.com",
|
|
description: "Gradient buttons, animated badges, blur nav — high-end SaaS look.",
|
|
tags: ["Tailwind", "Framer Motion", "Gradients"],
|
|
bestFor: "Modern SaaS landing page with a premium feel",
|
|
highlights: ["Gradient & glassmorphism components", "Animated feature cards with hover depth", "Navbar with blur-backdrop effect"],
|
|
hasTemplates: true,
|
|
},
|
|
{
|
|
id: "aceternity", name: "Aceternity UI", url: "https://ui.aceternity.com",
|
|
description: "Signature background effects — beams, meteors, sparkles, wavy gradients.",
|
|
tags: ["Framer Motion", "Background FX", "Dark-first"],
|
|
bestFor: "Premium, visually striking launch pages",
|
|
highlights: ["Background Beams, Meteors, Sparkles effects", "Infinite moving cards, Lamp effect, Wavy BG", "Text generate & typewriter animations"],
|
|
darkFirst: true,
|
|
},
|
|
{
|
|
id: "tailwind-only", name: "Tailwind only", url: "https://tailwindcss.com",
|
|
description: "Zero component library. Full creative control — design exactly what you want.",
|
|
tags: ["Custom", "Zero constraints", "Typography-first"],
|
|
bestFor: "Strong design opinions, no library overhead",
|
|
highlights: ["No component library constraints", "Typography-first editorial layouts", "Fastest builds, smallest bundle"],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
id: "admin",
|
|
name: "Admin Panel",
|
|
icon: "◫",
|
|
description: "Internal tool for managing your business — users, support, billing, analytics",
|
|
themes: [
|
|
{
|
|
id: "mantine", name: "Mantine", url: "https://mantine.dev",
|
|
description: "Comprehensive component set built for admin — tables, forms, charts.",
|
|
tags: ["Tables", "Forms", "Charts", "Date picker"],
|
|
bestFor: "Feature-rich internal tools",
|
|
highlights: ["Data grid with sorting, filtering, pagination", "Complex form handling with validation", "Stats, charts, and KPI components"],
|
|
},
|
|
{
|
|
id: "shadcn", name: "shadcn/ui", url: "https://ui.shadcn.com",
|
|
description: "Clean components that match your main app if it uses shadcn.",
|
|
tags: ["Tailwind", "Consistent", "Copy-paste"],
|
|
bestFor: "Keeping admin and app visually consistent",
|
|
highlights: ["Matches the web app if using shadcn", "Data table with react-table built in", "Command palette, sheets, dialogs"],
|
|
},
|
|
{
|
|
id: "tremor", name: "Tremor", url: "https://tremor.so",
|
|
description: "Analytics-first admin — built for KPI dashboards and data tables.",
|
|
tags: ["Analytics", "Charts", "KPIs"],
|
|
bestFor: "Analytics-heavy operations dashboards",
|
|
highlights: ["KPI cards, area charts, bar lists built in", "Data table with search + filter", "Progress bars, badges, trackers"],
|
|
hasTemplates: true,
|
|
},
|
|
],
|
|
},
|
|
{
|
|
id: "mobile",
|
|
name: "Mobile App",
|
|
icon: "▢",
|
|
description: "iOS and Android companion app — touch-first, native feel",
|
|
themes: [
|
|
{
|
|
id: "nativewind", name: "NativeWind", url: "https://nativewind.dev",
|
|
description: "Use Tailwind classes in React Native. Consistent web + mobile styling.",
|
|
tags: ["Tailwind", "React Native", "Expo"],
|
|
bestFor: "Teams already using Tailwind on web",
|
|
highlights: ["Same Tailwind classes on web and mobile", "Expo compatible, dark mode support", "Platform-specific variants built in"],
|
|
},
|
|
{
|
|
id: "gluestack", name: "Gluestack UI", url: "https://gluestack.io",
|
|
description: "Universal component library for React Native with accessibility baked in.",
|
|
tags: ["Universal", "Accessible", "Expo"],
|
|
bestFor: "Comprehensive native component coverage",
|
|
highlights: ["50+ components (ActionSheet, Toast, FAB…)", "ARIA accessible out of the box", "Works on iOS, Android, and web"],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
id: "email",
|
|
name: "Email",
|
|
icon: "✉",
|
|
description: "Transactional and marketing emails — welcome, billing, notifications",
|
|
themes: [
|
|
{
|
|
id: "react-email", name: "React Email", url: "https://react.email",
|
|
description: "Build beautiful emails with React. Test across 90+ email clients.",
|
|
tags: ["React", "Resend", "Cross-client", "Preview"],
|
|
bestFor: "Teams wanting React-based email workflows",
|
|
highlights: ["Live preview across Gmail, Outlook, Apple Mail", "Works with Resend, SendGrid, Postmark", "50+ pre-built email components"],
|
|
hasTemplates: true,
|
|
},
|
|
],
|
|
},
|
|
{
|
|
id: "docs",
|
|
name: "Docs / Content",
|
|
icon: "☰",
|
|
description: "Documentation, knowledge base, or blog for your product",
|
|
themes: [
|
|
{
|
|
id: "nextra", name: "Nextra", url: "https://nextra.site",
|
|
description: "Next.js-based docs with built-in search, MDX, and versioning.",
|
|
tags: ["Next.js", "MDX", "Search", "Dark mode"],
|
|
bestFor: "Developer-facing documentation",
|
|
highlights: ["Full-text search out of the box (Pagefind)", "MDX — React components inside Markdown", "Auto-generated sidebar from file structure"],
|
|
hasTemplates: true,
|
|
},
|
|
{
|
|
id: "shadcn", name: "shadcn/ui + custom", url: "https://ui.shadcn.com",
|
|
description: "Fully custom docs site that matches your product exactly.",
|
|
tags: ["Custom", "Tailwind", "Flexible"],
|
|
bestFor: "Brand-consistent docs that stand out",
|
|
highlights: ["Matches your app's exact design language", "Full control over layout and navigation", "Blog, changelog, and API ref in one"],
|
|
},
|
|
],
|
|
},
|
|
];
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Library style configurator options — per library, for the Marketing surface
|
|
// (other surfaces can adopt this pattern as needed)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const LIBRARY_STYLE_OPTIONS: Record<string, LibraryStyleOptions> = {
|
|
"daisy-ui": {
|
|
modes: [{ id: "light", label: "Light" }, { id: "dark", label: "Dark" }],
|
|
backgrounds: [
|
|
{ id: "solid", label: "Solid", description: "Clean theme background" },
|
|
{ id: "gradient", label: "Gradient", description: "Radial glow from primary color" },
|
|
{ id: "pattern", label: "Dot grid", description: "Subtle repeating dot pattern" },
|
|
{ id: "noise", label: "Noise", description: "Fine grain texture" },
|
|
],
|
|
navStyles: [
|
|
{ id: "standard", label: "Standard", description: "Bordered top bar" },
|
|
{ id: "transparent", label: "Transparent", description: "Overlaps the hero section" },
|
|
{ id: "pill", label: "Pill", description: "Floating centered nav" },
|
|
],
|
|
headerStyles: [
|
|
{ id: "centered", label: "Centered", description: "Title and CTAs centred" },
|
|
{ id: "split", label: "Split", description: "Left text, right mockup" },
|
|
{ id: "stats", label: "With stats", description: "Metrics row below CTAs" },
|
|
],
|
|
components: [
|
|
{ id: "logos", label: "Logo strip" },
|
|
{ id: "features", label: "Feature cards" },
|
|
{ id: "steps", label: "How it works" },
|
|
{ id: "testimonials", label: "Testimonials" },
|
|
{ id: "pricing", label: "Pricing table" },
|
|
{ id: "faq", label: "FAQ" },
|
|
{ id: "cta", label: "CTA banner" },
|
|
],
|
|
fonts: [
|
|
{ id: "system", label: "System UI" },
|
|
{ id: "plus-jakarta", label: "Plus Jakarta" },
|
|
{ id: "dm-sans", label: "DM Sans" },
|
|
{ id: "geist", label: "Geist" },
|
|
],
|
|
defaultConfig: { mode: "dark", background: "solid", nav: "standard", header: "centered", components: ["logos", "features", "steps", "testimonials", "pricing", "faq", "cta"], font: "system" },
|
|
},
|
|
"hero-ui": {
|
|
modes: [{ id: "light", label: "Light" }, { id: "dark", label: "Dark" }],
|
|
backgrounds: [
|
|
{ id: "clean", label: "Clean", description: "Solid background" },
|
|
{ id: "gradient-mesh", label: "Gradient mesh", description: "Soft multi-colour mesh" },
|
|
{ id: "glass", label: "Glassmorphism", description: "Frosted glass feel" },
|
|
{ id: "aurora", label: "Aurora", description: "Animated colour glow" },
|
|
],
|
|
navStyles: [
|
|
{ id: "blur", label: "Blur backdrop", description: "Frosted glass sticky navbar" },
|
|
{ id: "standard", label: "Standard", description: "Solid bordered bar" },
|
|
{ id: "minimal", label: "Minimal", description: "No border, clean" },
|
|
],
|
|
headerStyles: [
|
|
{ id: "animated-badge", label: "Animated", description: "Chip badge + gradient headline" },
|
|
{ id: "split", label: "Split", description: "Text + dashboard preview" },
|
|
{ id: "gradient", label: "Gradient", description: "Full gradient headline" },
|
|
],
|
|
components: [
|
|
{ id: "features", label: "Feature cards" },
|
|
{ id: "metrics", label: "Metrics grid" },
|
|
{ id: "avatars", label: "Social proof" },
|
|
{ id: "pricing", label: "Pricing" },
|
|
{ id: "testimonials", label: "Testimonials" },
|
|
{ id: "cta", label: "CTA section" },
|
|
],
|
|
fonts: [
|
|
{ id: "system", label: "System UI" },
|
|
{ id: "inter", label: "Inter" },
|
|
{ id: "geist", label: "Geist" },
|
|
{ id: "nunito", label: "Nunito" },
|
|
],
|
|
defaultConfig: { mode: "light", background: "clean", nav: "blur", header: "animated-badge", components: ["features", "metrics", "avatars", "pricing", "testimonials", "cta"], font: "system" },
|
|
},
|
|
"aceternity": {
|
|
modes: [{ id: "dark", label: "Dark" }, { id: "light", label: "Light" }],
|
|
backgrounds: [
|
|
{ id: "gradient", label: "Gradient anim", description: "Animated blob gradient (Background Gradient Animation)" },
|
|
{ id: "shader", label: "Line shader", description: "Bold diagonal gradient: purple→pink→orange→yellow" },
|
|
{ id: "beams", label: "Beams", description: "Radial SVG-style light beams on dark bg" },
|
|
{ id: "meteors", label: "Meteors", description: "Diagonal shooting-star streaks with glow tails" },
|
|
{ id: "sparkles", label: "Sparkles", description: "Black bg with twinkling star particles" },
|
|
{ id: "aurora", label: "Aurora", description: "Soft lavender/blue aurora on light bg" },
|
|
{ id: "wavy", label: "Wavy", description: "Smooth wave shapes" },
|
|
{ id: "dot-grid", label: "Dot grid", description: "Perspective dot matrix" },
|
|
],
|
|
navStyles: [
|
|
{ id: "minimal", label: "Minimal", description: "Barely-there top bar" },
|
|
{ id: "floating", label: "Floating", description: "Centred floating pill" },
|
|
],
|
|
headerStyles: [
|
|
{ id: "gradient-text", label: "Gradient text", description: "Fade-to-transparent heading" },
|
|
{ id: "lamp", label: "Lamp effect", description: "Spotlight cone from above" },
|
|
{ id: "typewriter", label: "Typewriter", description: "Animated text reveal" },
|
|
],
|
|
components: [
|
|
{ id: "badge", label: "Glow badge" },
|
|
{ id: "features", label: "Feature cards" },
|
|
{ id: "moving-cards", label: "Moving cards" },
|
|
{ id: "bento", label: "Bento grid" },
|
|
{ id: "pricing", label: "Pricing" },
|
|
{ id: "cta", label: "CTA section" },
|
|
],
|
|
fonts: [
|
|
{ id: "system", label: "System UI" },
|
|
{ id: "geist", label: "Geist" },
|
|
{ id: "plus-jakarta", label: "Plus Jakarta" },
|
|
],
|
|
defaultConfig: { mode: "dark", background: "gradient", nav: "minimal", header: "gradient-text", components: ["badge", "features", "moving-cards", "bento", "pricing", "cta"], font: "system" },
|
|
},
|
|
"tailwind-only": {
|
|
modes: [{ id: "light", label: "Light" }, { id: "dark", label: "Dark" }],
|
|
backgrounds: [
|
|
{ id: "clean", label: "Clean", description: "Pure white or dark" },
|
|
{ id: "dot-grid", label: "Dot grid", description: "Subtle dot pattern" },
|
|
{ id: "lines", label: "Grid lines", description: "Faint gridlines" },
|
|
{ id: "noise", label: "Noise", description: "Fine grain texture" },
|
|
],
|
|
navStyles: [
|
|
{ id: "minimal", label: "Minimal", description: "Logo + links, no decoration" },
|
|
{ id: "bordered", label: "Bordered", description: "With bottom border" },
|
|
{ id: "spaced", label: "Spread", description: "Logo left, CTA right" },
|
|
],
|
|
headerStyles: [
|
|
{ id: "editorial", label: "Editorial", description: "Big bold display typography" },
|
|
{ id: "split", label: "Split", description: "Text left + terminal preview" },
|
|
{ id: "centered", label: "Centered", description: "Minimal centred layout" },
|
|
],
|
|
components: [
|
|
{ id: "badge", label: "Release badge" },
|
|
{ id: "logos", label: "Logo strip" },
|
|
{ id: "features", label: "Features grid" },
|
|
{ id: "stats", label: "Stats row" },
|
|
{ id: "testimonials", label: "Testimonials" },
|
|
{ id: "pricing", label: "Pricing" },
|
|
{ id: "faq", label: "FAQ" },
|
|
{ id: "cta", label: "CTA" },
|
|
],
|
|
fonts: [
|
|
{ id: "system", label: "System UI" },
|
|
{ id: "inter", label: "Inter" },
|
|
{ id: "plus-jakarta", label: "Plus Jakarta" },
|
|
{ id: "dm-sans", label: "DM Sans" },
|
|
],
|
|
defaultConfig: { mode: "light", background: "clean", nav: "minimal", header: "editorial", components: ["badge", "logos", "features", "stats", "testimonials", "pricing", "faq", "cta"], font: "system" },
|
|
},
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// DesignConfigurator — shows mode/bg/nav/header/components/font pickers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function ConfigRow({ label, children }: { label: string; children: React.ReactNode }) {
|
|
return (
|
|
<div style={{ display: "flex", flexDirection: "column", gap: 7, paddingBottom: 12, borderBottom: "1px solid #f0ece4" }}>
|
|
<span style={{ fontSize: "0.62rem", fontWeight: 700, color: "#a09a90", textTransform: "uppercase", letterSpacing: "0.1em", fontFamily: "Outfit" }}>{label}</span>
|
|
<div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
|
|
{children}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function OptionChip({
|
|
label, description, active, onClick, multi, checked,
|
|
}: {
|
|
label: string; description?: string; active: boolean; onClick: () => void;
|
|
multi?: boolean; checked?: boolean;
|
|
}) {
|
|
return (
|
|
<button
|
|
onClick={onClick}
|
|
title={description}
|
|
style={{
|
|
display: "flex", alignItems: "center", gap: 5,
|
|
padding: multi ? "4px 9px" : "4px 11px",
|
|
borderRadius: 5, border: "1px solid",
|
|
fontSize: "0.72rem", fontFamily: "Outfit", cursor: "pointer",
|
|
transition: "all 0.1s",
|
|
borderColor: active ? "#1a1a1a" : "#e0dcd4",
|
|
background: active ? "#1a1a1a" : "#fff",
|
|
color: active ? "#fff" : "#4a4640",
|
|
}}
|
|
onMouseEnter={e => { if (!active) (e.currentTarget as HTMLElement).style.borderColor = "#c5c0b8"; }}
|
|
onMouseLeave={e => { if (!active) (e.currentTarget as HTMLElement).style.borderColor = "#e0dcd4"; }}
|
|
>
|
|
{multi && (
|
|
<span style={{ width: 11, height: 11, borderRadius: 2, border: `1.5px solid ${active ? "#fff" : "#c5c0b8"}`, background: checked ? (active ? "#fff" : "#1a1a1a") : "transparent", flexShrink: 0, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 7, color: checked ? (active ? "#1a1a1a" : "#fff") : "transparent" }}>✓</span>
|
|
)}
|
|
{label}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function ModeToggle({ value, onChange }: { value: string; onChange: (v: "dark" | "light") => void }) {
|
|
return (
|
|
<div style={{ display: "inline-flex", borderRadius: 6, overflow: "hidden", border: "1px solid #e0dcd4", background: "#f8f5f0" }}>
|
|
{(["Light", "Dark"] as const).map(m => {
|
|
const id = m.toLowerCase() as "light" | "dark";
|
|
const active = value === id;
|
|
return (
|
|
<button
|
|
key={m}
|
|
onClick={() => onChange(id)}
|
|
style={{
|
|
padding: "4px 14px", border: "none", fontSize: "0.72rem", fontFamily: "Outfit",
|
|
cursor: "pointer", fontWeight: active ? 600 : 400,
|
|
background: active ? "#1a1a1a" : "transparent",
|
|
color: active ? "#fff" : "#8a8478",
|
|
transition: "all 0.1s",
|
|
}}
|
|
>
|
|
{m === "Light" ? "☀ Light" : "◗ Dark"}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function DesignConfigurator({
|
|
libraryId,
|
|
config,
|
|
onChange,
|
|
}: {
|
|
libraryId: string;
|
|
config: DesignConfig;
|
|
onChange: (patch: Partial<DesignConfig>) => void;
|
|
}) {
|
|
const opts = LIBRARY_STYLE_OPTIONS[libraryId];
|
|
if (!opts) return null;
|
|
|
|
const toggleComponent = (id: string) => {
|
|
const next = config.components.includes(id)
|
|
? config.components.filter(c => c !== id)
|
|
: [...config.components, id];
|
|
onChange({ components: next });
|
|
};
|
|
|
|
return (
|
|
<div style={{ display: "flex", flexDirection: "column", gap: 10, padding: "14px 0 4px" }}>
|
|
{/* Mode */}
|
|
<ConfigRow label="Mode">
|
|
<ModeToggle value={config.mode} onChange={v => onChange({ mode: v })} />
|
|
</ConfigRow>
|
|
|
|
{/* Background */}
|
|
<ConfigRow label="Background">
|
|
{opts.backgrounds.map(bg => (
|
|
<OptionChip
|
|
key={bg.id} label={bg.label} description={bg.description}
|
|
active={config.background === bg.id}
|
|
onClick={() => onChange({ background: bg.id })}
|
|
/>
|
|
))}
|
|
</ConfigRow>
|
|
|
|
{/* Nav */}
|
|
<ConfigRow label="Nav style">
|
|
{opts.navStyles.map(n => (
|
|
<OptionChip
|
|
key={n.id} label={n.label} description={n.description}
|
|
active={config.nav === n.id}
|
|
onClick={() => onChange({ nav: n.id })}
|
|
/>
|
|
))}
|
|
</ConfigRow>
|
|
|
|
{/* Header */}
|
|
<ConfigRow label="Hero header">
|
|
{opts.headerStyles.map(h => (
|
|
<OptionChip
|
|
key={h.id} label={h.label} description={h.description}
|
|
active={config.header === h.id}
|
|
onClick={() => onChange({ header: h.id })}
|
|
/>
|
|
))}
|
|
</ConfigRow>
|
|
|
|
{/* Sections */}
|
|
<ConfigRow label="Sections">
|
|
{opts.components.map(c => (
|
|
<OptionChip
|
|
key={c.id} label={c.label} multi
|
|
active={config.components.includes(c.id)}
|
|
checked={config.components.includes(c.id)}
|
|
onClick={() => toggleComponent(c.id)}
|
|
/>
|
|
))}
|
|
</ConfigRow>
|
|
|
|
{/* Font */}
|
|
<ConfigRow label="Font">
|
|
{opts.fonts.map(f => (
|
|
<OptionChip
|
|
key={f.id} label={f.label}
|
|
active={config.font === f.id}
|
|
onClick={() => onChange({ font: f.id })}
|
|
/>
|
|
))}
|
|
</ConfigRow>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Surface section
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function SurfaceSection({
|
|
surface,
|
|
selectedThemeId,
|
|
lockedThemeId,
|
|
onSelect,
|
|
onLock,
|
|
onUnlock,
|
|
saving,
|
|
}: {
|
|
surface: Surface;
|
|
selectedThemeId: string | null;
|
|
lockedThemeId: string | null;
|
|
onSelect: (themeId: string) => void;
|
|
onLock: () => void;
|
|
onUnlock: () => void;
|
|
saving: boolean;
|
|
}) {
|
|
const previewId = lockedThemeId ?? selectedThemeId ?? surface.themes[0]?.id ?? null;
|
|
const activeTheme = surface.themes.find(t => t.id === previewId);
|
|
const ScaffoldComponent = previewId ? SCAFFOLD_REGISTRY[surface.id]?.[previewId] : null;
|
|
|
|
const allColorThemes: ThemeColor[] = previewId
|
|
? (THEME_REGISTRY[surface.id]?.[previewId] ?? [])
|
|
: [];
|
|
const [selectedColorTheme, setSelectedColorTheme] = useState<ThemeColor | null>(null);
|
|
|
|
// Filter palettes to match the current mode; untagged themes (themeMode undefined) show in any mode
|
|
const availableColorThemes = allColorThemes.filter(
|
|
ct => !ct.themeMode || ct.themeMode === designConfig.mode
|
|
);
|
|
|
|
// If the selected palette is no longer valid for the new mode, clear it so the
|
|
// first compatible one is auto-selected below
|
|
const selectedIsCompatible = !selectedColorTheme || availableColorThemes.some(ct => ct.id === selectedColorTheme.id);
|
|
const activeColorTheme = (selectedIsCompatible ? selectedColorTheme : null) ?? availableColorThemes[0] ?? null;
|
|
|
|
// Design config — per-library style choices (mode, background, nav, header, sections, font)
|
|
const defaultForLibrary = previewId ? LIBRARY_STYLE_OPTIONS[previewId]?.defaultConfig : undefined;
|
|
const [designConfig, setDesignConfig] = useState<DesignConfig>(
|
|
defaultForLibrary ?? { mode: "light", background: "solid", nav: "standard", header: "centered", components: ["features", "pricing"], font: "system" }
|
|
);
|
|
// Reset config when library changes
|
|
const [lastPreviewId, setLastPreviewId] = useState<string | null>(previewId);
|
|
if (previewId !== lastPreviewId) {
|
|
setLastPreviewId(previewId);
|
|
const def = previewId ? LIBRARY_STYLE_OPTIONS[previewId]?.defaultConfig : undefined;
|
|
if (def) setDesignConfig(def);
|
|
}
|
|
const patchConfig = (patch: Partial<DesignConfig>) => setDesignConfig(prev => ({ ...prev, ...patch }));
|
|
|
|
const opts = previewId ? LIBRARY_STYLE_OPTIONS[previewId] : null;
|
|
const hasConfigurator = !!previewId && !!opts;
|
|
const isLocked = !!lockedThemeId;
|
|
|
|
// Ensure parent always knows the currently-displayed theme (even before user clicks)
|
|
// so Lock In works immediately without requiring an explicit library click first.
|
|
useEffect(() => {
|
|
if (!selectedThemeId && previewId) onSelect(previewId);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [previewId]);
|
|
|
|
const toggleComponent = (id: string) => {
|
|
const next = designConfig.components.includes(id)
|
|
? designConfig.components.filter(c => c !== id)
|
|
: [...designConfig.components, id];
|
|
patchConfig({ components: next });
|
|
};
|
|
|
|
return (
|
|
<div style={{ display: "flex", flexDirection: "row", height: "100%", gap: 0 }}>
|
|
|
|
{/* Center — browser chrome + scaffold */}
|
|
<div style={{
|
|
flex: 1, minWidth: 0, display: "flex", flexDirection: "column",
|
|
padding: "20px 20px 20px 0",
|
|
}}>
|
|
<div style={{
|
|
flex: 1, borderRadius: 10, overflow: "hidden",
|
|
border: "1px solid #e8e4dc",
|
|
boxShadow: "0 1px 4px #1a1a1a06",
|
|
display: "flex", flexDirection: "column",
|
|
minHeight: 0,
|
|
}}>
|
|
{/* Chrome bar */}
|
|
<div style={{
|
|
display: "flex", alignItems: "center", gap: 6,
|
|
padding: "0 14px", height: 34, flexShrink: 0,
|
|
borderBottom: "1px solid #e8e4dc", background: "#faf8f5",
|
|
}}>
|
|
{["#d0ccc4", "#d0ccc4", "#d0ccc4"].map((c, i) => (
|
|
<div key={i} style={{ width: 9, height: 9, borderRadius: "50%", background: c }} />
|
|
))}
|
|
<div style={{
|
|
marginLeft: 12, flex: 1, maxWidth: 280, height: 20, borderRadius: 5,
|
|
background: "#f0ece4", display: "flex", alignItems: "center", padding: "0 10px",
|
|
}}>
|
|
<span style={{ fontSize: "0.65rem", color: "#b5b0a6", fontFamily: "IBM Plex Mono", overflow: "hidden", whiteSpace: "nowrap", textOverflow: "ellipsis" }}>
|
|
{activeTheme ? `/${surface.id} · ${activeTheme.name}${activeColorTheme ? ` · ${activeColorTheme.label}` : ""}` : ""}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Scaffold */}
|
|
<div style={{ flex: 1, overflow: "hidden" }}>
|
|
{ScaffoldComponent
|
|
? <ScaffoldComponent themeColor={activeColorTheme ?? undefined} config={designConfig} />
|
|
: (
|
|
<div style={{ height: "100%", display: "flex", alignItems: "center", justifyContent: "center", color: "#b5b0a6", fontSize: "0.82rem", fontFamily: "Outfit" }}>
|
|
Select a library below to preview
|
|
</div>
|
|
)
|
|
}
|
|
</div>
|
|
</div>
|
|
</div>{/* end center scaffold wrapper */}
|
|
|
|
{/* Right — controls panel */}
|
|
<div style={{
|
|
width: 280, flexShrink: 0, borderLeft: "1px solid #e8e4dc",
|
|
background: "#fff", overflow: "auto",
|
|
display: "flex", flexDirection: "column", gap: 0,
|
|
}}>
|
|
<div style={{ padding: "14px 16px", display: "flex", flexDirection: "column", gap: 12 }}>
|
|
|
|
{/* 1. Lock / unlock */}
|
|
<div style={{ display: "flex", alignItems: "center", gap: 8, paddingBottom: 12, borderBottom: "1px solid #f0ece4" }}>
|
|
{isLocked ? (
|
|
<button
|
|
onClick={onUnlock}
|
|
style={{
|
|
flex: 1, padding: "7px 14px", borderRadius: 7, border: "1px solid #e0dcd4",
|
|
background: "#fff", color: "#1a1a1a", fontSize: "0.76rem", fontWeight: 600,
|
|
fontFamily: "Outfit", cursor: "pointer", transition: "opacity 0.15s",
|
|
}}
|
|
onMouseEnter={e => (e.currentTarget.style.opacity = "0.7")}
|
|
onMouseLeave={e => (e.currentTarget.style.opacity = "1")}
|
|
>✎ Change selection</button>
|
|
) : (
|
|
<button
|
|
onClick={onLock}
|
|
disabled={!previewId || saving}
|
|
style={{
|
|
flex: 1, padding: "7px 14px", borderRadius: 7, border: `1px solid ${previewId && !saving ? "#1a1a1a" : "#e0dcd4"}`,
|
|
background: previewId && !saving ? "#1a1a1a" : "#e0dcd4",
|
|
color: previewId && !saving ? "#fff" : "#b5b0a6",
|
|
fontSize: "0.76rem", fontWeight: 600, fontFamily: "Outfit",
|
|
cursor: !previewId || saving ? "not-allowed" : "pointer",
|
|
transition: "opacity 0.15s",
|
|
}}
|
|
onMouseEnter={e => { if (previewId && !saving) (e.currentTarget.style.opacity = "0.8"); }}
|
|
onMouseLeave={e => { (e.currentTarget.style.opacity = "1"); }}
|
|
>{saving ? "Saving…" : "🔒 Lock in"}</button>
|
|
)}
|
|
{activeTheme && (
|
|
<a href={activeTheme.url} target="_blank" rel="noopener noreferrer"
|
|
style={{ fontSize: "0.72rem", color: "#b5b0a6", textDecoration: "none", fontFamily: "Outfit", flexShrink: 0 }}
|
|
onMouseEnter={e => (e.currentTarget.style.color = "#1a1a1a")}
|
|
onMouseLeave={e => (e.currentTarget.style.color = "#b5b0a6")}
|
|
>Docs ↗</a>
|
|
)}
|
|
</div>
|
|
|
|
{/* 2. Library */}
|
|
<div style={{ display: "flex", flexDirection: "column", gap: 7, paddingBottom: 12, borderBottom: "1px solid #f0ece4" }}>
|
|
<span style={{ fontSize: "0.62rem", fontWeight: 700, color: "#a09a90", textTransform: "uppercase", letterSpacing: "0.1em", fontFamily: "Outfit" }}>Library</span>
|
|
<div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
|
|
{surface.themes.map(theme => {
|
|
const isActive = theme.id === previewId;
|
|
const isThisLocked = theme.id === lockedThemeId;
|
|
const dimmed = isLocked && !isThisLocked;
|
|
return (
|
|
<button
|
|
key={theme.id}
|
|
onClick={() => { if (!isLocked) { onSelect(theme.id); setSelectedColorTheme(null); } }}
|
|
disabled={dimmed}
|
|
style={{
|
|
display: "flex", alignItems: "center", gap: 4,
|
|
padding: "4px 11px", borderRadius: 5, border: "1px solid",
|
|
fontSize: "0.72rem", fontFamily: "Outfit", cursor: dimmed ? "not-allowed" : "pointer",
|
|
transition: "all 0.1s", opacity: dimmed ? 0.35 : 1,
|
|
borderColor: isActive ? "#1a1a1a" : "#e0dcd4",
|
|
background: isActive ? "#1a1a1a" : "#fff",
|
|
color: isActive ? "#fff" : "#4a4640",
|
|
}}
|
|
onMouseEnter={e => { if (!isLocked && !isActive) (e.currentTarget as HTMLElement).style.borderColor = "#c5c0b8"; }}
|
|
onMouseLeave={e => { if (!isLocked && !isActive) (e.currentTarget as HTMLElement).style.borderColor = "#e0dcd4"; }}
|
|
>
|
|
{isThisLocked && <span style={{ fontSize: "0.6rem" }}>🔒</span>}
|
|
{theme.name}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{hasConfigurator && !isLocked && opts && (<>
|
|
|
|
{/* 3. Mode */}
|
|
<ConfigRow label="Mode">
|
|
<ModeToggle value={designConfig.mode} onChange={v => patchConfig({ mode: v })} />
|
|
</ConfigRow>
|
|
|
|
{/* 4. Colour */}
|
|
{availableColorThemes.length > 0 && (
|
|
<div style={{ display: "flex", flexDirection: "column", gap: 7, paddingBottom: 12, borderBottom: "1px solid #f0ece4" }}>
|
|
<span style={{ fontSize: "0.62rem", fontWeight: 700, color: "#a09a90", textTransform: "uppercase", letterSpacing: "0.1em", fontFamily: "Outfit" }}>Colour</span>
|
|
<div style={{ display: "flex", gap: 7, flexWrap: "wrap", alignItems: "center" }}>
|
|
{availableColorThemes.map(ct => (
|
|
<button
|
|
key={ct.id}
|
|
title={ct.label}
|
|
onClick={() => setSelectedColorTheme(ct)}
|
|
style={{
|
|
width: 22, height: 22, borderRadius: "50%", border: "none", cursor: "pointer",
|
|
background: ct.bg ? `linear-gradient(135deg, ${ct.bg} 50%, ${ct.primary} 50%)` : ct.primary,
|
|
outline: activeColorTheme?.id === ct.id ? `2.5px solid ${ct.primary}` : "2px solid transparent",
|
|
outlineOffset: 2, transition: "transform 0.12s",
|
|
boxShadow: "0 1px 3px rgba(0,0,0,0.15)",
|
|
}}
|
|
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.transform = "scale(1.18)"; }}
|
|
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.transform = "scale(1)"; }}
|
|
/>
|
|
))}
|
|
</div>
|
|
{activeColorTheme && (
|
|
<span style={{ fontSize: "0.72rem", color: "#a09a90", fontFamily: "Outfit" }}>{activeColorTheme.label}</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* 5. Font */}
|
|
<ConfigRow label="Font">
|
|
{opts.fonts.map(f => (
|
|
<OptionChip key={f.id} label={f.label}
|
|
active={designConfig.font === f.id}
|
|
onClick={() => patchConfig({ font: f.id })}
|
|
/>
|
|
))}
|
|
</ConfigRow>
|
|
|
|
{/* 6. Background */}
|
|
<ConfigRow label="Background">
|
|
{opts.backgrounds.map(bg => (
|
|
<OptionChip key={bg.id} label={bg.label} description={bg.description}
|
|
active={designConfig.background === bg.id}
|
|
onClick={() => patchConfig({ background: bg.id })}
|
|
/>
|
|
))}
|
|
</ConfigRow>
|
|
|
|
{/* 7. Nav */}
|
|
<ConfigRow label="Nav style">
|
|
{opts.navStyles.map(n => (
|
|
<OptionChip key={n.id} label={n.label} description={n.description}
|
|
active={designConfig.nav === n.id}
|
|
onClick={() => patchConfig({ nav: n.id })}
|
|
/>
|
|
))}
|
|
</ConfigRow>
|
|
|
|
{/* 8. Hero */}
|
|
<ConfigRow label="Hero header">
|
|
{opts.headerStyles.map(h => (
|
|
<OptionChip key={h.id} label={h.label} description={h.description}
|
|
active={designConfig.header === h.id}
|
|
onClick={() => patchConfig({ header: h.id })}
|
|
/>
|
|
))}
|
|
</ConfigRow>
|
|
|
|
{/* 9. Sections */}
|
|
<ConfigRow label="Sections">
|
|
{opts.components.map(c => (
|
|
<OptionChip key={c.id} label={c.label} multi
|
|
active={designConfig.components.includes(c.id)}
|
|
checked={designConfig.components.includes(c.id)}
|
|
onClick={() => toggleComponent(c.id)}
|
|
/>
|
|
))}
|
|
</ConfigRow>
|
|
|
|
</>)}
|
|
|
|
{/* Colour swatches when locked (read-only) */}
|
|
{isLocked && availableColorThemes.length > 0 && (
|
|
<div style={{ display: "flex", flexDirection: "column", gap: 7 }}>
|
|
<span style={{ fontSize: "0.62rem", fontWeight: 700, color: "#a09a90", textTransform: "uppercase", letterSpacing: "0.1em", fontFamily: "Outfit" }}>Colour</span>
|
|
<div style={{ display: "flex", gap: 7, flexWrap: "wrap" }}>
|
|
{availableColorThemes.map(ct => (
|
|
<button key={ct.id} title={ct.label} disabled
|
|
style={{
|
|
width: 22, height: 22, borderRadius: "50%", border: "none",
|
|
background: ct.bg ? `linear-gradient(135deg, ${ct.bg} 50%, ${ct.primary} 50%)` : ct.primary,
|
|
outline: activeColorTheme?.id === ct.id ? `2.5px solid ${ct.primary}` : "2px solid transparent",
|
|
outlineOffset: 2, opacity: 0.4, cursor: "not-allowed",
|
|
boxShadow: "0 1px 3px rgba(0,0,0,0.15)",
|
|
}}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
</div>{/* end inner padding div */}
|
|
</div>{/* end right panel */}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Surface ID fuzzy-match helper — maps AI-generated labels to our surface IDs
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function matchSurfaceId(label: string): string | null {
|
|
const l = label.toLowerCase();
|
|
if (l.includes("web app") || l.includes("webapp") || l.includes("saas") || l.includes("dashboard") || l.includes("pwa")) return "web-app";
|
|
if (l.includes("marketing") || l.includes("landing") || l.includes("site") || l.includes("homepage")) return "marketing";
|
|
if (l.includes("admin") || l.includes("internal") || l.includes("back office") || l.includes("backoffice")) return "admin";
|
|
if (l.includes("mobile") || l.includes("ios") || l.includes("android") || l.includes("native")) return "mobile";
|
|
if (l.includes("email") || l.includes("notification")) return "email";
|
|
if (l.includes("doc") || l.includes("content") || l.includes("blog") || l.includes("knowledge")) return "docs";
|
|
return null;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Phase 1 — Surface picker
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function SurfacePicker({
|
|
onConfirm,
|
|
saving,
|
|
aiSuggested,
|
|
}: {
|
|
onConfirm: (ids: string[]) => void;
|
|
saving: boolean;
|
|
aiSuggested: string[];
|
|
}) {
|
|
const [selected, setSelected] = useState<Set<string>>(new Set(aiSuggested));
|
|
|
|
const toggle = (id: string) => {
|
|
setSelected(prev => {
|
|
const next = new Set(prev);
|
|
next.has(id) ? next.delete(id) : next.add(id);
|
|
return next;
|
|
});
|
|
};
|
|
|
|
return (
|
|
<div style={{ padding: "28px 32px", fontFamily: "Outfit, sans-serif" }}>
|
|
<h3 style={{ fontFamily: "Newsreader, serif", fontSize: "1.2rem", fontWeight: 400, color: "#1a1a1a", marginBottom: 4 }}>
|
|
Design surfaces
|
|
</h3>
|
|
<p style={{ fontSize: "0.8rem", color: "#a09a90", marginBottom: aiSuggested.length > 0 ? 10 : 24 }}>
|
|
Which surfaces does your product need?
|
|
</p>
|
|
|
|
{aiSuggested.length > 0 && (
|
|
<div style={{
|
|
display: "flex", alignItems: "center", gap: 8, marginBottom: 20,
|
|
padding: "10px 14px", background: "#f6f4f0", borderRadius: 8,
|
|
border: "1px solid #e8e4dc",
|
|
}}>
|
|
<span style={{ fontSize: "0.8rem" }}>✦</span>
|
|
<span style={{ fontSize: "0.76rem", color: "#4a4640", lineHeight: 1.5 }}>
|
|
Based on your PRD, the AI pre-selected the surfaces your product needs. Adjust if needed.
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(220px, 1fr))", gap: 8, marginBottom: 24 }}>
|
|
{ALL_SURFACES.map(surface => {
|
|
const isSelected = selected.has(surface.id);
|
|
const isAiPick = aiSuggested.includes(surface.id);
|
|
return (
|
|
<button
|
|
key={surface.id}
|
|
onClick={() => toggle(surface.id)}
|
|
style={{
|
|
display: "flex", alignItems: "flex-start", gap: 14,
|
|
padding: "16px 18px", borderRadius: 10, textAlign: "left",
|
|
border: `1px solid ${isSelected ? "#1a1a1a" : "#e8e4dc"}`,
|
|
background: isSelected ? "#1a1a1a08" : "#fff",
|
|
boxShadow: isSelected ? "0 0 0 1px #1a1a1a" : "0 1px 2px #1a1a1a05",
|
|
cursor: "pointer", transition: "all 0.12s", fontFamily: "Outfit",
|
|
position: "relative",
|
|
}}
|
|
onMouseEnter={e => { if (!isSelected) (e.currentTarget.style.borderColor = "#d0ccc4"); }}
|
|
onMouseLeave={e => { if (!isSelected) (e.currentTarget.style.borderColor = "#e8e4dc"); }}
|
|
>
|
|
{isAiPick && !isSelected && (
|
|
<div style={{
|
|
position: "absolute", top: 8, right: 8,
|
|
fontSize: "0.58rem", color: "#9a7b3a", background: "#d4a04a15",
|
|
border: "1px solid #d4a04a30", padding: "1px 5px", borderRadius: 3,
|
|
fontWeight: 600, letterSpacing: "0.05em",
|
|
}}>AI</div>
|
|
)}
|
|
<div style={{
|
|
width: 34, height: 34, borderRadius: 8, flexShrink: 0, marginTop: 1,
|
|
background: isSelected ? "#1a1a1a" : "#f6f4f0",
|
|
display: "flex", alignItems: "center", justifyContent: "center",
|
|
fontSize: "1rem", color: isSelected ? "#fff" : "#8a8478",
|
|
}}>
|
|
{surface.icon}
|
|
</div>
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<div style={{ fontSize: "0.84rem", fontWeight: 600, color: "#1a1a1a", marginBottom: 3 }}>{surface.name}</div>
|
|
<div style={{ fontSize: "0.74rem", color: "#8a8478", lineHeight: 1.5 }}>{surface.description}</div>
|
|
</div>
|
|
{isSelected && <span style={{ flexShrink: 0, color: "#1a1a1a", fontSize: "0.85rem", marginTop: 2 }}>✓</span>}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<button
|
|
onClick={() => onConfirm(Array.from(selected))}
|
|
disabled={selected.size === 0 || saving}
|
|
style={{
|
|
padding: "9px 20px", borderRadius: 7, border: "none",
|
|
background: selected.size === 0 || saving ? "#e0dcd4" : "#1a1a1a",
|
|
color: selected.size === 0 || saving ? "#b5b0a6" : "#fff",
|
|
fontSize: "0.82rem", fontWeight: 600, fontFamily: "Outfit",
|
|
cursor: selected.size === 0 || saving ? "not-allowed" : "pointer",
|
|
transition: "opacity 0.15s",
|
|
}}
|
|
onMouseEnter={e => { if (selected.size > 0 && !saving) (e.currentTarget.style.opacity = "0.8"); }}
|
|
onMouseLeave={e => { (e.currentTarget.style.opacity = "1"); }}
|
|
>
|
|
{saving ? "Saving…" : `Confirm surfaces (${selected.size})`} →
|
|
</button>
|
|
{selected.size === 0 && (
|
|
<p style={{ display: "inline-block", marginLeft: 12, fontSize: "0.74rem", color: "#b5b0a6", fontFamily: "Outfit" }}>
|
|
Select at least one surface to continue
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Page
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function DesignPageInner({ projectId }: { projectId: string }) {
|
|
const searchParams = useSearchParams();
|
|
const requestedSurface = searchParams.get("surface");
|
|
|
|
const [surfaces, setSurfaces] = useState<string[]>([]);
|
|
const [surfaceThemes, setSurfaceThemes] = useState<Record<string, string>>({});
|
|
const [selectedThemes, setSelectedThemes] = useState<Record<string, string>>({});
|
|
const [activeSurfaceId, setActiveSurfaceId] = useState<string | null>(null);
|
|
const [savingLock, setSavingLock] = useState<string | null>(null);
|
|
const [savingSurfaces, setSavingSurfaces] = useState(false);
|
|
const [loading, setLoading] = useState(true);
|
|
const [aiSuggestedSurfaces, setAiSuggestedSurfaces] = useState<string[]>([]);
|
|
|
|
useEffect(() => {
|
|
// Load saved design surfaces
|
|
const designP = fetch(`/api/projects/${projectId}/design-surfaces`)
|
|
.then(r => r.json())
|
|
.then(d => {
|
|
const loaded = (d.surfaces ?? []) as string[];
|
|
setSurfaces(loaded);
|
|
setSurfaceThemes(d.surfaceThemes ?? {});
|
|
setSelectedThemes(d.surfaceThemes ?? {});
|
|
// Honour ?surface= param if valid, otherwise default to first
|
|
const initial = requestedSurface && loaded.includes(requestedSurface)
|
|
? requestedSurface
|
|
: loaded[0] ?? null;
|
|
setActiveSurfaceId(initial);
|
|
return loaded;
|
|
});
|
|
|
|
// Load architecture to get AI-suggested surfaces
|
|
const archP = fetch(`/api/projects/${projectId}/architecture`)
|
|
.then(r => r.json())
|
|
.then(d => {
|
|
const arch = d.architecture;
|
|
if (arch?.designSurfaces && Array.isArray(arch.designSurfaces)) {
|
|
const matched = (arch.designSurfaces as string[])
|
|
.map(matchSurfaceId)
|
|
.filter((id): id is string => id !== null);
|
|
setAiSuggestedSurfaces([...new Set(matched)]);
|
|
}
|
|
})
|
|
.catch(() => {});
|
|
|
|
Promise.all([designP, archP])
|
|
.catch(() => toast.error("Failed to load design data"))
|
|
.finally(() => setLoading(false));
|
|
}, [projectId]);
|
|
|
|
const handleConfirmSurfaces = async (ids: string[]) => {
|
|
setSavingSurfaces(true);
|
|
try {
|
|
const res = await fetch(`/api/projects/${projectId}/design-surfaces`, {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ surfaces: ids }),
|
|
});
|
|
if (!res.ok) throw new Error();
|
|
setSurfaces(ids);
|
|
setActiveSurfaceId(ids[0] ?? null);
|
|
toast.success("Surfaces saved");
|
|
} catch {
|
|
toast.error("Failed to save surfaces");
|
|
} finally {
|
|
setSavingSurfaces(false);
|
|
}
|
|
};
|
|
|
|
const handleLock = async (surfaceId: string) => {
|
|
const surface = ALL_SURFACES.find(s => s.id === surfaceId);
|
|
const themeId = selectedThemes[surfaceId] ?? surface?.themes[0]?.id;
|
|
if (!themeId) return;
|
|
setSavingLock(surfaceId);
|
|
try {
|
|
const res = await fetch(`/api/projects/${projectId}/design-surfaces`, {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ surface: surfaceId, theme: themeId }),
|
|
});
|
|
if (!res.ok) throw new Error();
|
|
setSurfaceThemes(prev => ({ ...prev, [surfaceId]: themeId }));
|
|
const surface = ALL_SURFACES.find(s => s.id === surfaceId);
|
|
const theme = surface?.themes.find(t => t.id === themeId);
|
|
toast.success(`${surface?.name} → ${theme?.name} locked in`);
|
|
} catch {
|
|
toast.error("Failed to lock in theme");
|
|
} finally {
|
|
setSavingLock(null);
|
|
}
|
|
};
|
|
|
|
const handleUnlock = (surfaceId: string) => {
|
|
setSurfaceThemes(prev => { const next = { ...prev }; delete next[surfaceId]; return next; });
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", fontFamily: "Outfit" }}>
|
|
<div style={{ width: 18, height: 18, borderRadius: "50%", border: "2px solid #e8e4dc", borderTopColor: "#1a1a1a", animation: "spin 0.8s linear infinite" }} />
|
|
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (surfaces.length === 0) {
|
|
return <SurfacePicker onConfirm={handleConfirmSurfaces} saving={savingSurfaces} aiSuggested={aiSuggestedSurfaces} />;
|
|
}
|
|
|
|
const activeSurfaces = ALL_SURFACES.filter(s => surfaces.includes(s.id));
|
|
const currentSurface = activeSurfaces.find(s => s.id === activeSurfaceId) ?? activeSurfaces[0];
|
|
const lockedCount = Object.keys(surfaceThemes).length;
|
|
|
|
return (
|
|
<div style={{ display: "flex", height: "100%", fontFamily: "Outfit, sans-serif" }}>
|
|
|
|
{/* Left nav */}
|
|
<div style={{ width: 180, flexShrink: 0, borderRight: "1px solid #e8e4dc", display: "flex", flexDirection: "column", background: "#fff" }}>
|
|
<div style={{ padding: "16px 18px 10px", borderBottom: "1px solid #f0ece4" }}>
|
|
<div style={{ fontSize: "0.6rem", fontWeight: 600, color: "#a09a90", letterSpacing: "0.1em", textTransform: "uppercase" }}>Surfaces</div>
|
|
</div>
|
|
|
|
<nav style={{ flex: 1, padding: "6px 8px", overflow: "auto" }}>
|
|
{activeSurfaces.map(surface => {
|
|
const isActive = surface.id === currentSurface?.id;
|
|
const isLocked = !!surfaceThemes[surface.id];
|
|
return (
|
|
<button
|
|
key={surface.id}
|
|
onClick={() => setActiveSurfaceId(surface.id)}
|
|
style={{
|
|
display: "flex", alignItems: "center", gap: 8, width: "100%",
|
|
padding: "8px 10px", borderRadius: 6, border: "none", textAlign: "left",
|
|
background: isActive ? "#f6f4f0" : "transparent",
|
|
color: isActive ? "#1a1a1a" : "#6b6560",
|
|
fontSize: "0.8rem", fontWeight: isActive ? 600 : 450,
|
|
cursor: "pointer", transition: "all 0.12s", position: "relative",
|
|
fontFamily: "Outfit",
|
|
}}
|
|
onMouseEnter={e => { if (!isActive) (e.currentTarget.style.background = "#f6f4f0"); }}
|
|
onMouseLeave={e => { if (!isActive) (e.currentTarget.style.background = "transparent"); }}
|
|
>
|
|
{isActive && (
|
|
<div style={{ position: "absolute", left: 0, top: "50%", transform: "translateY(-50%)", width: 2, height: 16, background: "#1a1a1a", borderRadius: "0 2px 2px 0" }} />
|
|
)}
|
|
<span style={{ fontSize: "0.9rem", opacity: 0.5, flexShrink: 0 }}>{surface.icon}</span>
|
|
<span style={{ flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{surface.name}</span>
|
|
{isLocked && <span style={{ fontSize: "0.6rem", flexShrink: 0, opacity: 0.5 }}>🔒</span>}
|
|
</button>
|
|
);
|
|
})}
|
|
</nav>
|
|
|
|
<div style={{ padding: "12px 18px", borderTop: "1px solid #f0ece4" }}>
|
|
{lockedCount === activeSurfaces.length && lockedCount > 0 && (
|
|
<p style={{ fontSize: "0.68rem", color: "#2e7d32", fontFamily: "Outfit", marginBottom: 6 }}>✓ All locked</p>
|
|
)}
|
|
<button
|
|
onClick={() => setSurfaces([])}
|
|
style={{ fontSize: "0.72rem", color: "#a09a90", background: "none", border: "none", cursor: "pointer", fontFamily: "Outfit", padding: 0 }}
|
|
onMouseEnter={e => (e.currentTarget.style.color = "#1a1a1a")}
|
|
onMouseLeave={e => (e.currentTarget.style.color = "#a09a90")}
|
|
>
|
|
+ Edit surfaces
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Main content */}
|
|
<div style={{ flex: 1, overflow: "hidden", display: "flex", flexDirection: "column", padding: "0 0 0 24px" }}>
|
|
{currentSurface && (
|
|
<SurfaceSection
|
|
surface={currentSurface}
|
|
selectedThemeId={selectedThemes[currentSurface.id] ?? null}
|
|
lockedThemeId={surfaceThemes[currentSurface.id] ?? null}
|
|
onSelect={themeId => setSelectedThemes(prev => ({ ...prev, [currentSurface.id]: themeId }))}
|
|
onLock={() => handleLock(currentSurface.id)}
|
|
onUnlock={() => handleUnlock(currentSurface.id)}
|
|
saving={savingLock === currentSurface.id}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function DesignPage({ params }: { params: Promise<{ workspace: string; projectId: string }> }) {
|
|
const { projectId } = use(params);
|
|
return (
|
|
<Suspense fallback={<div style={{ display: "flex", height: "100%", alignItems: "center", justifyContent: "center", color: "#a09a90", fontFamily: "Outfit, sans-serif", fontSize: "0.85rem" }}>Loading…</div>}>
|
|
<DesignPageInner projectId={projectId} />
|
|
</Suspense>
|
|
);
|
|
}
|