@cimplify/cli 0.6.4 → 0.6.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{add-NWGER62A.mjs → add-UQGZNABQ.mjs} +1 -1
- package/dist/{chunk-MGP2FUAK.mjs → chunk-FVYBHC45.mjs} +1 -1
- package/dist/{chunk-TFEUFCEH.mjs → chunk-XZZZS6X2.mjs} +9 -9
- package/dist/{chunk-76TJ56KE.mjs → chunk-ZC2KRCFO.mjs} +1 -1
- package/dist/dispatcher.mjs +9 -9
- package/dist/{doctor-ZGW3VZOG.mjs → doctor-S3GTCQMC.mjs} +2 -2
- package/dist/{explain-4ITTI4FM.mjs → explain-3DSAJBNE.mjs} +1 -1
- package/dist/{introspect-DL2HE7DG.mjs → introspect-56FKN72I.mjs} +2 -2
- package/dist/{list-WNNKVK7V.mjs → list-FPFX4RGB.mjs} +1 -1
- package/dist/{update-EPRLX6FL.mjs → update-MT4OG7FW.mjs} +1 -1
- package/package.json +1 -1
- package/templates/storefront-auto/package.json +1 -1
- package/templates/storefront-bakery/package.json +1 -1
- package/templates/storefront-fashion/package.json +1 -1
- package/templates/storefront-grocery/package.json +1 -1
- package/templates/storefront-pharmacy/package.json +1 -1
- package/templates/storefront-restaurant/package.json +1 -1
- package/templates/storefront-retail/package.json +1 -1
- package/templates/storefront-services/package.json +1 -1
|
@@ -197,7 +197,7 @@ function Field({
|
|
|
197
197
|
font-weight: 600;
|
|
198
198
|
}
|
|
199
199
|
}
|
|
200
|
-
` }, { "path": "app/sitemap-page/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport Link from "next/link";\nimport { cacheTag, cacheLife } from "next/cache";\nimport { getServerClient, tags, type Product } from "@cimplify/sdk/server";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Sitemap \u2014 ${brand.name}`,\n description: "A human-readable index of every page on this site.",\n};\n\ninterface SitemapData {\n products: { slug: string; name: string }[];\n categories: { slug: string; name: string }[];\n collections: { slug: string; name: string }[];\n}\n\nasync function getSitemap(): Promise<SitemapData> {\n "use cache";\n cacheTag(tags.products(), tags.categories(), tags.collections());\n cacheLife("hours");\n\n const client = getServerClient();\n const [pRes, cRes, colRes] = await Promise.all([\n client.catalogue.getProducts({ limit: 500 }),\n client.catalogue.getCategories(),\n client.catalogue.getCollections(),\n ]);\n return {\n products: (pRes.ok ? pRes.value.items : []).map((p: Product) => ({\n slug: p.slug ?? p.id,\n name: p.name,\n })),\n categories: (cRes.ok ? cRes.value : []).map((c) => ({ slug: c.slug, name: c.name })),\n collections: (colRes.ok ? colRes.value : []).map((c) => ({ slug: c.slug, name: c.name })),\n };\n}\n\nconst STATIC_LINKS: { title: string; links: { href: string; label: string }[] }[] = [\n {\n title: "Browse",\n links: [\n { href: "/", label: "Home" },\n { href: "/shop", label: "Shop" },\n { href: "/search", label: "Search" },\n ],\n },\n {\n title: "Account",\n links: [\n { href: "/account", label: "Account" },\n { href: "/account/orders", label: "Orders" },\n { href: "/account/addresses", label: "Addresses" },\n { href: "/account/settings", label: "Settings" },\n { href: "/login", label: "Sign in" },\n { href: "/signup", label: "Create account" },\n { href: "/track-order", label: "Track an order" },\n { href: "/cart", label: "Cart" },\n { href: "/checkout", label: "Checkout" },\n ],\n },\n {\n title: "About",\n links: [\n { href: "/about", label: "About" },\n { href: "/faq", label: "FAQ" },\n { href: "/contact", label: "Contact" },\n ],\n },\n {\n title: "Policies",\n links: [\n { href: "/shipping", label: "Shipping" },\n { href: "/returns", label: "Returns" },\n { href: "/accessibility", label: "Accessibility" },\n { href: "/terms", label: "Terms of Service" },\n { href: "/privacy", label: "Privacy Policy" },\n ],\n },\n {\n title: "Machine-readable",\n links: [\n { href: "/sitemap.xml", label: "sitemap.xml (search engines)" },\n { href: "/llms.txt", label: "llms.txt (LLM agents)" },\n { href: "/robots.txt", label: "robots.txt" },\n { href: "/opensearch.xml", label: "opensearch.xml (browser search)" },\n ],\n },\n];\n\nexport default async function SitemapHtmlPage() {\n const { products, categories, collections } = await getSitemap();\n\n return (\n <article className="max-w-5xl mx-auto px-6 sm:px-8 py-16">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-2">\n Sitemap\n </p>\n <h1 className="text-[clamp(2rem,4vw,2.75rem)] font-semibold mb-2 -tracking-[0.02em]">\n Every page, in one place.\n </h1>\n <p className="text-muted-foreground mb-12">\n For search engines, see <Link href="/sitemap.xml" className="text-primary hover:underline">/sitemap.xml</Link>.\n For LLM agents, see <Link href="/llms.txt" className="text-primary hover:underline">/llms.txt</Link>.\n </p>\n <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-10">\n {STATIC_LINKS.map((s) => (\n <Section key={s.title} title={s.title}>\n {s.links.map((l) => (\n <li key={l.href}>\n <Link href={l.href} className="hover:text-primary transition-colors">\n {l.label}\n </Link>\n </li>\n ))}\n </Section>\n ))}\n {categories.length > 0 && (\n <Section title="Categories">\n {categories.map((c) => (\n <li key={c.slug}>\n <Link href={`/categories/${c.slug}`} className="hover:text-primary transition-colors">\n {c.name}\n </Link>\n </li>\n ))}\n </Section>\n )}\n {collections.length > 0 && (\n <Section title="Collections">\n {collections.map((c) => (\n <li key={c.slug}>\n <Link href={`/collections/${c.slug}`} className="hover:text-primary transition-colors">\n {c.name}\n </Link>\n </li>\n ))}\n </Section>\n )}\n {products.length > 0 && (\n <Section title={`Products (${products.length})`}>\n {products.map((p) => (\n <li key={p.slug}>\n <Link href={`/shop?product=${encodeURIComponent(p.slug)}`} className="hover:text-primary transition-colors">\n {p.name}\n </Link>\n </li>\n ))}\n </Section>\n )}\n </div>\n </article>\n );\n}\n\nfunction Section({ title, children }: { title: string; children: React.ReactNode }) {\n return (\n <div>\n <p className="font-semibold text-[12px] uppercase tracking-[0.12em] text-foreground mb-3">\n {title}\n </p>\n <ul className="space-y-2 m-0 p-0 list-none text-sm text-muted-foreground">\n {children}\n </ul>\n </div>\n );\n}\n' }, { "path": "app/shop/shop-client.tsx", "kind": "text", "content": '"use client";\n\nimport { CataloguePage } from "@cimplify/sdk/react";\nimport type { Category, Product } from "@cimplify/sdk";\nimport { StoreProductCard } from "@/components/store-product-card";\n\n/**\n * Client island for the shop page. Server-side fetches all products and\n * categories (cached via `\'use cache\'` in `app/shop/page.tsx`), then hands\n * them to `<CataloguePage>` which owns the interactive filter / sort state.\n *\n * The page hero (in `app/shop/page.tsx`) already provides the title; we\n * pass an empty `title` and override the SDK heading via className so the\n * page reads as one continuous design.\n */\nexport function ShopClient({\n products,\n categories,\n}: {\n products: Product[];\n categories: Category[];\n}) {\n return (\n <CataloguePage\n title="All products"\n products={products}\n categories={categories}\n renderCard={(p) => <StoreProductCard product={p} />}\n className="max-w-7xl mx-auto px-6 sm:px-8 py-10 sm:py-12"\n />\n );\n}\n' }, { "path": "app/shop/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Suspense } from "react";\nimport { cacheTag, cacheLife } from "next/cache";\nimport { getServerClient, tags } from "@cimplify/sdk/server";\nimport { ShopClient } from "./shop-client";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Shop \u2014 ${brand.name}`,\n description: brand.description,\n};\n\nasync function getShopData() {\n "use cache";\n cacheTag(tags.products(), tags.categories());\n cacheLife("hours");\n\n const client = getServerClient();\n const [p, c] = await Promise.all([\n client.catalogue.getProducts({ limit: 50 }),\n client.catalogue.getCategories(),\n ]);\n return {\n products: p.ok ? p.value.items : [],\n categories: c.ok ? c.value : [],\n };\n}\n\nexport default async function ShopPage() {\n const { products, categories } = await getShopData();\n return (\n <>\n <section className="bg-foreground text-background relative overflow-hidden">\n <div className="absolute inset-0 opacity-[0.04] pointer-events-none [background-image:radial-gradient(circle_at_2px_2px,white_1px,transparent_0)] [background-size:32px_32px]" />\n <div className="relative max-w-7xl mx-auto px-6 sm:px-8 py-12 sm:py-14">\n <p className="text-[11px] font-mono uppercase tracking-[0.2em] text-background/60 mb-2">\n Catalogue \xB7 {products.length} products\n </p>\n <h1 className="text-[clamp(2rem,4vw,3rem)] font-bold m-0 -tracking-[0.025em] leading-[1.05]">\n Every product we stock.\n </h1>\n <p className="mt-3 max-w-xl text-base text-background/75">\n Filter, sort, search. Authorised dealer pricing, two-year warranty,\n same-day Accra delivery on every order.\n </p>\n </div>\n </section>\n <Suspense\n fallback={\n <div className="max-w-7xl mx-auto px-6 sm:px-8 py-10">\n <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3 sm:gap-4">\n {Array.from({ length: 8 }).map((_, i) => (\n <div key={i} className="aspect-[4/3] bg-muted rounded-2xl animate-pulse" />\n ))}\n </div>\n </div>\n }\n >\n <ShopClient products={products} categories={categories} />\n </Suspense>\n </>\n );\n}\n' }, { "path": "app/returns/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\nimport { PolicyPage } from "@/components/policy-page";\n\nexport const metadata: Metadata = {\n title: `${brand.returns.title} \u2014 ${brand.name}`,\n};\n\nexport default function ReturnsPage() {\n return <PolicyPage policy={brand.returns} />;\n}\n' }, { "path": "app/layout.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Inter, JetBrains_Mono } from "next/font/google";\nimport "./globals.css";\nimport { Providers } from "@/components/providers";\nimport { Header } from "@/components/header";\nimport { Footer } from "@/components/footer";\nimport { ProductModal } from "@/components/product-modal";\nimport { CartDrawer } from "@/components/cart-drawer";\nimport { OrganizationJsonLd } from "@/components/json-ld";\nimport { Suspense } from "react";\nimport { brand } from "@/lib/brand";\nimport { getSiteUrl } from "@/lib/site-url";\n\nconst inter = Inter({\n subsets: ["latin"],\n variable: "--font-sans",\n display: "swap",\n});\n\nconst mono = JetBrains_Mono({\n subsets: ["latin"],\n variable: "--font-mono",\n display: "swap",\n});\n\nexport async function generateMetadata(): Promise<Metadata> {\n const siteUrl = await getSiteUrl();\n return {\n metadataBase: new URL(siteUrl),\n title: {\n default: brand.name,\n template: `%s \u2014 ${brand.name}`,\n },\n description: brand.description,\n openGraph: {\n type: "website",\n siteName: brand.name,\n locale: brand.locale,\n },\n twitter: { card: "summary_large_image" },\n };\n}\n\nexport default function RootLayout({ children }: { children: React.ReactNode }) {\n return (\n <html lang="en" suppressHydrationWarning className={`${inter.variable} ${mono.variable}`}>\n <body\n suppressHydrationWarning\n className="min-h-screen flex flex-col bg-background text-foreground font-sans"\n >\n <Suspense fallback={null}>\n <OrganizationJsonLd />\n </Suspense>\n <Providers>\n <Header />\n <main className="flex-1 pb-12 w-full">{children}</main>\n <Footer />\n <Suspense fallback={null}>\n <ProductModal />\n </Suspense>\n <CartDrawer />\n </Providers>\n </body>\n </html>\n );\n}\n' }, { "path": "app/checkout/page.tsx", "kind": "text", "content": '"use client";\n\nimport { useRouter } from "next/navigation";\nimport { CheckoutPage as SdkCheckoutPage } from "@cimplify/sdk/react";\n\nexport default function CheckoutPage() {\n const router = useRouter();\n return (\n <SdkCheckoutPage\n onComplete={(result) => {\n if (result.success && result.order) {\n router.push(`/orders/${result.order.id}`);\n }\n }}\n />\n );\n}\n' }, { "path": "app/privacy/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Privacy Policy \u2014 ${brand.name}`,\n description: `How ${brand.name} collects, uses, and protects your personal data.`,\n};\n\nexport default function PrivacyPage() {\n const p = brand.privacy;\n return (\n <article className="max-w-3xl mx-auto px-6 sm:px-8 py-16 prose prose-lg max-w-none">\n <p className="text-[11px] font-mono uppercase tracking-[0.16em] text-primary mb-2 not-prose">\n {p.eyebrow}\n </p>\n <h1 className="text-[clamp(2.25rem,5vw,3.5rem)] font-bold mb-2 -tracking-[0.025em]">\n {p.title}\n </h1>\n <p className="text-sm text-muted-foreground not-prose mb-10">\n Last updated: {p.lastUpdated}\n </p>\n\n <section className="space-y-5 leading-relaxed text-foreground/90">\n {p.sections.map((s) => (\n <div key={s.heading}>\n <h2 className="text-2xl font-semibold mt-0 -tracking-[0.02em]">{s.heading}</h2>\n {typeof s.body === "string" ? (\n <p>{s.body}</p>\n ) : (\n <>\n <p>{s.body.intro}</p>\n <ul className="list-disc pl-6 space-y-2">\n {s.body.bullets.map((b) => (\n <li key={b}>{b}</li>\n ))}\n </ul>\n </>\n )}\n </div>\n ))}\n </section>\n </article>\n );\n}\n' }, { "path": "app/collections/[slug]/listing-client.tsx", "kind": "text", "content": '"use client";\n\nimport { ProductGrid } from "@cimplify/sdk/react";\nimport type { Product } from "@cimplify/sdk";\nimport { StoreProductCard } from "@/components/store-product-card";\n\n/**\n * Client island for the collection listing. Receives server-fetched\n * products as props (serializable) and owns the `renderCard` function\n * (which can\'t cross the server/client boundary).\n */\nexport function ListingClient({ products }: { products: Product[] }) {\n return (\n <ProductGrid\n products={products}\n emptyMessage="No products in this collection yet."\n renderCard={(p) => <StoreProductCard product={p} />}\n />\n );\n}\n' }, { "path": "app/collections/[slug]/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Suspense } from "react";\nimport Link from "next/link";\nimport { notFound } from "next/navigation";\nimport { cacheTag, cacheLife } from "next/cache";\nimport {\n getServerClient,\n tags,\n type Collection,\n type Product,\n} from "@cimplify/sdk/server";\nimport { ListingClient } from "./listing-client";\nimport { brand } from "@/lib/brand";\n\ninterface CollectionData {\n collection: Collection;\n products: Product[];\n}\n\nasync function getCollection(slug: string): Promise<CollectionData | null> {\n "use cache";\n cacheTag(tags.collections());\n cacheLife("hours");\n\n const client = getServerClient();\n const colRes = await client.catalogue.getCollectionBySlug(slug);\n if (!colRes.ok) return null;\n\n cacheTag(tags.collection(colRes.value.id), tags.collectionProducts(colRes.value.id));\n const r = await client.catalogue.getCollectionProducts(colRes.value.id);\n const products = r.ok\n ? ((r.value as { items?: Product[] }).items ?? (r.value as Product[]))\n : [];\n return { collection: colRes.value, products };\n}\n\nexport async function generateMetadata({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}): Promise<Metadata> {\n const { slug } = await params;\n const data = await getCollection(slug);\n if (!data) return {};\n return {\n title: `${data.collection.name} \u2014 ${brand.name}`,\n description: data.collection.description ?? undefined,\n };\n}\n\nexport default async function CollectionPage({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}) {\n return (\n <Suspense fallback={<CollectionSkeleton />}>\n <CollectionContent params={params} />\n </Suspense>\n );\n}\n\nasync function CollectionContent({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}) {\n const { slug } = await params;\n const data = await getCollection(slug);\n if (!data) notFound();\n\n const { collection, products } = data;\n return (\n <>\n <section className="bg-foreground text-background relative overflow-hidden">\n <div className="absolute inset-0 opacity-[0.04] pointer-events-none [background-image:radial-gradient(circle_at_2px_2px,white_1px,transparent_0)] [background-size:32px_32px]" />\n <div className="relative max-w-7xl mx-auto px-6 sm:px-8 py-12 sm:py-14">\n <nav className="text-[12px] font-mono text-background/60 mb-3 flex items-center gap-2">\n <Link href="/" className="hover:text-background transition-colors">Home</Link>\n <span>/</span>\n <span className="text-background/90">Collections</span>\n <span>/</span>\n <span className="text-background/90">{collection.name}</span>\n </nav>\n <h1 className="text-[clamp(2rem,4vw,3rem)] font-bold m-0 -tracking-[0.025em]">\n {collection.name}\n </h1>\n {collection.description && (\n <p className="mt-3 max-w-2xl text-base text-background/75">\n {collection.description}\n </p>\n )}\n <p className="mt-4 text-[11px] font-mono uppercase tracking-[0.16em] text-background/60 tabular-nums">\n {products.length} {products.length === 1 ? "product" : "products"}\n </p>\n </div>\n </section>\n <section className="max-w-7xl mx-auto px-6 sm:px-8 py-10 sm:py-12">\n <ListingClient products={products} />\n {products.length === 0 && (\n <p className="text-center mt-8">\n <Link href="/shop" className="text-primary font-semibold hover:underline">\n \u2190 Browse all products\n </Link>\n </p>\n )}\n </section>\n </>\n );\n}\n\nfunction CollectionSkeleton() {\n return (\n <>\n <section className="bg-foreground py-12 sm:py-14">\n <div className="max-w-7xl mx-auto px-6 sm:px-8">\n <div className="h-3 w-32 bg-background/20 rounded mb-3 animate-pulse" />\n <div className="h-12 w-72 bg-background/20 rounded animate-pulse" />\n </div>\n </section>\n <section className="max-w-7xl mx-auto px-6 sm:px-8 py-10 sm:py-12">\n <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3 sm:gap-4">\n {Array.from({ length: 8 }).map((_, i) => (\n <div key={i} className="aspect-[4/3] bg-muted rounded-2xl animate-pulse" />\n ))}\n </div>\n </section>\n </>\n );\n}\n' }, { "path": "package.json", "kind": "text", "content": '{\n "name": "__STOREFRONT_NAME__",\n "private": true,\n "version": "0.0.0",\n "scripts": {\n "dev": "concurrently -k -n mock,next -c blue,magenta \\"cimplify-mock --seed retail --quiet\\" \\"next dev\\"",\n "dev:storefront": "next dev",\n "dev:mock": "cimplify-mock --seed retail",\n "build": "next build",\n "start": "next start",\n "typecheck": "tsc --noEmit",\n "test": "vitest",\n "test:run": "vitest run",\n "check:brand": "vitest run __tests__/brand.test.ts",\n "check:cart": "vitest run __tests__/cart-flow.test.ts",\n "check:contract": "vitest run __tests__/contract.test.ts",\n "check": "bun run typecheck && bun run test:run"\n },\n "dependencies": {\n "@cimplify/sdk": "^0.48.0",\n "next": "^16.2.4",\n "react": "^19.0.0",\n "react-dom": "^19.0.0"\n },\n "devDependencies": {\n "@tailwindcss/postcss": "^4.2.4",\n "@types/node": "^22.10.0",\n "@types/react": "^19.0.0",\n "@types/react-dom": "^19.0.0",\n "concurrently": "^9.0.0",\n "tailwindcss": "^4.2.4",\n "typescript": "^5.9.3",\n "vitest": "^4.1.5"\n }\n}\n' }, { "path": "lib/brand.ts", "kind": "text", "content": `/**
|
|
200
|
+
` }, { "path": "app/sitemap-page/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport Link from "next/link";\nimport { cacheTag, cacheLife } from "next/cache";\nimport { getServerClient, tags, type Product } from "@cimplify/sdk/server";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Sitemap \u2014 ${brand.name}`,\n description: "A human-readable index of every page on this site.",\n};\n\ninterface SitemapData {\n products: { slug: string; name: string }[];\n categories: { slug: string; name: string }[];\n collections: { slug: string; name: string }[];\n}\n\nasync function getSitemap(): Promise<SitemapData> {\n "use cache";\n cacheTag(tags.products(), tags.categories(), tags.collections());\n cacheLife("hours");\n\n const client = getServerClient();\n const [pRes, cRes, colRes] = await Promise.all([\n client.catalogue.getProducts({ limit: 500 }),\n client.catalogue.getCategories(),\n client.catalogue.getCollections(),\n ]);\n return {\n products: (pRes.ok ? pRes.value.items : []).map((p: Product) => ({\n slug: p.slug ?? p.id,\n name: p.name,\n })),\n categories: (cRes.ok ? cRes.value : []).map((c) => ({ slug: c.slug, name: c.name })),\n collections: (colRes.ok ? colRes.value : []).map((c) => ({ slug: c.slug, name: c.name })),\n };\n}\n\nconst STATIC_LINKS: { title: string; links: { href: string; label: string }[] }[] = [\n {\n title: "Browse",\n links: [\n { href: "/", label: "Home" },\n { href: "/shop", label: "Shop" },\n { href: "/search", label: "Search" },\n ],\n },\n {\n title: "Account",\n links: [\n { href: "/account", label: "Account" },\n { href: "/account/orders", label: "Orders" },\n { href: "/account/addresses", label: "Addresses" },\n { href: "/account/settings", label: "Settings" },\n { href: "/login", label: "Sign in" },\n { href: "/signup", label: "Create account" },\n { href: "/track-order", label: "Track an order" },\n { href: "/cart", label: "Cart" },\n { href: "/checkout", label: "Checkout" },\n ],\n },\n {\n title: "About",\n links: [\n { href: "/about", label: "About" },\n { href: "/faq", label: "FAQ" },\n { href: "/contact", label: "Contact" },\n ],\n },\n {\n title: "Policies",\n links: [\n { href: "/shipping", label: "Shipping" },\n { href: "/returns", label: "Returns" },\n { href: "/accessibility", label: "Accessibility" },\n { href: "/terms", label: "Terms of Service" },\n { href: "/privacy", label: "Privacy Policy" },\n ],\n },\n {\n title: "Machine-readable",\n links: [\n { href: "/sitemap.xml", label: "sitemap.xml (search engines)" },\n { href: "/llms.txt", label: "llms.txt (LLM agents)" },\n { href: "/robots.txt", label: "robots.txt" },\n { href: "/opensearch.xml", label: "opensearch.xml (browser search)" },\n ],\n },\n];\n\nexport default async function SitemapHtmlPage() {\n const { products, categories, collections } = await getSitemap();\n\n return (\n <article className="max-w-5xl mx-auto px-6 sm:px-8 py-16">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-2">\n Sitemap\n </p>\n <h1 className="text-[clamp(2rem,4vw,2.75rem)] font-semibold mb-2 -tracking-[0.02em]">\n Every page, in one place.\n </h1>\n <p className="text-muted-foreground mb-12">\n For search engines, see <Link href="/sitemap.xml" className="text-primary hover:underline">/sitemap.xml</Link>.\n For LLM agents, see <Link href="/llms.txt" className="text-primary hover:underline">/llms.txt</Link>.\n </p>\n <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-10">\n {STATIC_LINKS.map((s) => (\n <Section key={s.title} title={s.title}>\n {s.links.map((l) => (\n <li key={l.href}>\n <Link href={l.href} className="hover:text-primary transition-colors">\n {l.label}\n </Link>\n </li>\n ))}\n </Section>\n ))}\n {categories.length > 0 && (\n <Section title="Categories">\n {categories.map((c) => (\n <li key={c.slug}>\n <Link href={`/categories/${c.slug}`} className="hover:text-primary transition-colors">\n {c.name}\n </Link>\n </li>\n ))}\n </Section>\n )}\n {collections.length > 0 && (\n <Section title="Collections">\n {collections.map((c) => (\n <li key={c.slug}>\n <Link href={`/collections/${c.slug}`} className="hover:text-primary transition-colors">\n {c.name}\n </Link>\n </li>\n ))}\n </Section>\n )}\n {products.length > 0 && (\n <Section title={`Products (${products.length})`}>\n {products.map((p) => (\n <li key={p.slug}>\n <Link href={`/shop?product=${encodeURIComponent(p.slug)}`} className="hover:text-primary transition-colors">\n {p.name}\n </Link>\n </li>\n ))}\n </Section>\n )}\n </div>\n </article>\n );\n}\n\nfunction Section({ title, children }: { title: string; children: React.ReactNode }) {\n return (\n <div>\n <p className="font-semibold text-[12px] uppercase tracking-[0.12em] text-foreground mb-3">\n {title}\n </p>\n <ul className="space-y-2 m-0 p-0 list-none text-sm text-muted-foreground">\n {children}\n </ul>\n </div>\n );\n}\n' }, { "path": "app/shop/shop-client.tsx", "kind": "text", "content": '"use client";\n\nimport { CataloguePage } from "@cimplify/sdk/react";\nimport type { Category, Product } from "@cimplify/sdk";\nimport { StoreProductCard } from "@/components/store-product-card";\n\n/**\n * Client island for the shop page. Server-side fetches all products and\n * categories (cached via `\'use cache\'` in `app/shop/page.tsx`), then hands\n * them to `<CataloguePage>` which owns the interactive filter / sort state.\n *\n * The page hero (in `app/shop/page.tsx`) already provides the title; we\n * pass an empty `title` and override the SDK heading via className so the\n * page reads as one continuous design.\n */\nexport function ShopClient({\n products,\n categories,\n}: {\n products: Product[];\n categories: Category[];\n}) {\n return (\n <CataloguePage\n title="All products"\n products={products}\n categories={categories}\n renderCard={(p) => <StoreProductCard product={p} />}\n className="max-w-7xl mx-auto px-6 sm:px-8 py-10 sm:py-12"\n />\n );\n}\n' }, { "path": "app/shop/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Suspense } from "react";\nimport { cacheTag, cacheLife } from "next/cache";\nimport { getServerClient, tags } from "@cimplify/sdk/server";\nimport { ShopClient } from "./shop-client";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Shop \u2014 ${brand.name}`,\n description: brand.description,\n};\n\nasync function getShopData() {\n "use cache";\n cacheTag(tags.products(), tags.categories());\n cacheLife("hours");\n\n const client = getServerClient();\n const [p, c] = await Promise.all([\n client.catalogue.getProducts({ limit: 50 }),\n client.catalogue.getCategories(),\n ]);\n return {\n products: p.ok ? p.value.items : [],\n categories: c.ok ? c.value : [],\n };\n}\n\nexport default async function ShopPage() {\n const { products, categories } = await getShopData();\n return (\n <>\n <section className="bg-foreground text-background relative overflow-hidden">\n <div className="absolute inset-0 opacity-[0.04] pointer-events-none [background-image:radial-gradient(circle_at_2px_2px,white_1px,transparent_0)] [background-size:32px_32px]" />\n <div className="relative max-w-7xl mx-auto px-6 sm:px-8 py-12 sm:py-14">\n <p className="text-[11px] font-mono uppercase tracking-[0.2em] text-background/60 mb-2">\n Catalogue \xB7 {products.length} products\n </p>\n <h1 className="text-[clamp(2rem,4vw,3rem)] font-bold m-0 -tracking-[0.025em] leading-[1.05]">\n Every product we stock.\n </h1>\n <p className="mt-3 max-w-xl text-base text-background/75">\n Filter, sort, search. Authorised dealer pricing, two-year warranty,\n same-day Accra delivery on every order.\n </p>\n </div>\n </section>\n <Suspense\n fallback={\n <div className="max-w-7xl mx-auto px-6 sm:px-8 py-10">\n <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3 sm:gap-4">\n {Array.from({ length: 8 }).map((_, i) => (\n <div key={i} className="aspect-[4/3] bg-muted rounded-2xl animate-pulse" />\n ))}\n </div>\n </div>\n }\n >\n <ShopClient products={products} categories={categories} />\n </Suspense>\n </>\n );\n}\n' }, { "path": "app/returns/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\nimport { PolicyPage } from "@/components/policy-page";\n\nexport const metadata: Metadata = {\n title: `${brand.returns.title} \u2014 ${brand.name}`,\n};\n\nexport default function ReturnsPage() {\n return <PolicyPage policy={brand.returns} />;\n}\n' }, { "path": "app/layout.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Inter, JetBrains_Mono } from "next/font/google";\nimport "./globals.css";\nimport { Providers } from "@/components/providers";\nimport { Header } from "@/components/header";\nimport { Footer } from "@/components/footer";\nimport { ProductModal } from "@/components/product-modal";\nimport { CartDrawer } from "@/components/cart-drawer";\nimport { OrganizationJsonLd } from "@/components/json-ld";\nimport { Suspense } from "react";\nimport { brand } from "@/lib/brand";\nimport { getSiteUrl } from "@/lib/site-url";\n\nconst inter = Inter({\n subsets: ["latin"],\n variable: "--font-sans",\n display: "swap",\n});\n\nconst mono = JetBrains_Mono({\n subsets: ["latin"],\n variable: "--font-mono",\n display: "swap",\n});\n\nexport async function generateMetadata(): Promise<Metadata> {\n const siteUrl = await getSiteUrl();\n return {\n metadataBase: new URL(siteUrl),\n title: {\n default: brand.name,\n template: `%s \u2014 ${brand.name}`,\n },\n description: brand.description,\n openGraph: {\n type: "website",\n siteName: brand.name,\n locale: brand.locale,\n },\n twitter: { card: "summary_large_image" },\n };\n}\n\nexport default function RootLayout({ children }: { children: React.ReactNode }) {\n return (\n <html lang="en" suppressHydrationWarning className={`${inter.variable} ${mono.variable}`}>\n <body\n suppressHydrationWarning\n className="min-h-screen flex flex-col bg-background text-foreground font-sans"\n >\n <Suspense fallback={null}>\n <OrganizationJsonLd />\n </Suspense>\n <Providers>\n <Header />\n <main className="flex-1 pb-12 w-full">{children}</main>\n <Footer />\n <Suspense fallback={null}>\n <ProductModal />\n </Suspense>\n <CartDrawer />\n </Providers>\n </body>\n </html>\n );\n}\n' }, { "path": "app/checkout/page.tsx", "kind": "text", "content": '"use client";\n\nimport { useRouter } from "next/navigation";\nimport { CheckoutPage as SdkCheckoutPage } from "@cimplify/sdk/react";\n\nexport default function CheckoutPage() {\n const router = useRouter();\n return (\n <SdkCheckoutPage\n onComplete={(result) => {\n if (result.success && result.order) {\n router.push(`/orders/${result.order.id}`);\n }\n }}\n />\n );\n}\n' }, { "path": "app/privacy/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Privacy Policy \u2014 ${brand.name}`,\n description: `How ${brand.name} collects, uses, and protects your personal data.`,\n};\n\nexport default function PrivacyPage() {\n const p = brand.privacy;\n return (\n <article className="max-w-3xl mx-auto px-6 sm:px-8 py-16 prose prose-lg max-w-none">\n <p className="text-[11px] font-mono uppercase tracking-[0.16em] text-primary mb-2 not-prose">\n {p.eyebrow}\n </p>\n <h1 className="text-[clamp(2.25rem,5vw,3.5rem)] font-bold mb-2 -tracking-[0.025em]">\n {p.title}\n </h1>\n <p className="text-sm text-muted-foreground not-prose mb-10">\n Last updated: {p.lastUpdated}\n </p>\n\n <section className="space-y-5 leading-relaxed text-foreground/90">\n {p.sections.map((s) => (\n <div key={s.heading}>\n <h2 className="text-2xl font-semibold mt-0 -tracking-[0.02em]">{s.heading}</h2>\n {typeof s.body === "string" ? (\n <p>{s.body}</p>\n ) : (\n <>\n <p>{s.body.intro}</p>\n <ul className="list-disc pl-6 space-y-2">\n {s.body.bullets.map((b) => (\n <li key={b}>{b}</li>\n ))}\n </ul>\n </>\n )}\n </div>\n ))}\n </section>\n </article>\n );\n}\n' }, { "path": "app/collections/[slug]/listing-client.tsx", "kind": "text", "content": '"use client";\n\nimport { ProductGrid } from "@cimplify/sdk/react";\nimport type { Product } from "@cimplify/sdk";\nimport { StoreProductCard } from "@/components/store-product-card";\n\n/**\n * Client island for the collection listing. Receives server-fetched\n * products as props (serializable) and owns the `renderCard` function\n * (which can\'t cross the server/client boundary).\n */\nexport function ListingClient({ products }: { products: Product[] }) {\n return (\n <ProductGrid\n products={products}\n emptyMessage="No products in this collection yet."\n renderCard={(p) => <StoreProductCard product={p} />}\n />\n );\n}\n' }, { "path": "app/collections/[slug]/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Suspense } from "react";\nimport Link from "next/link";\nimport { notFound } from "next/navigation";\nimport { cacheTag, cacheLife } from "next/cache";\nimport {\n getServerClient,\n tags,\n type Collection,\n type Product,\n} from "@cimplify/sdk/server";\nimport { ListingClient } from "./listing-client";\nimport { brand } from "@/lib/brand";\n\ninterface CollectionData {\n collection: Collection;\n products: Product[];\n}\n\nasync function getCollection(slug: string): Promise<CollectionData | null> {\n "use cache";\n cacheTag(tags.collections());\n cacheLife("hours");\n\n const client = getServerClient();\n const colRes = await client.catalogue.getCollectionBySlug(slug);\n if (!colRes.ok) return null;\n\n cacheTag(tags.collection(colRes.value.id), tags.collectionProducts(colRes.value.id));\n const r = await client.catalogue.getCollectionProducts(colRes.value.id);\n const products = r.ok\n ? ((r.value as { items?: Product[] }).items ?? (r.value as Product[]))\n : [];\n return { collection: colRes.value, products };\n}\n\nexport async function generateMetadata({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}): Promise<Metadata> {\n const { slug } = await params;\n const data = await getCollection(slug);\n if (!data) return {};\n return {\n title: `${data.collection.name} \u2014 ${brand.name}`,\n description: data.collection.description ?? undefined,\n };\n}\n\nexport default async function CollectionPage({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}) {\n return (\n <Suspense fallback={<CollectionSkeleton />}>\n <CollectionContent params={params} />\n </Suspense>\n );\n}\n\nasync function CollectionContent({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}) {\n const { slug } = await params;\n const data = await getCollection(slug);\n if (!data) notFound();\n\n const { collection, products } = data;\n return (\n <>\n <section className="bg-foreground text-background relative overflow-hidden">\n <div className="absolute inset-0 opacity-[0.04] pointer-events-none [background-image:radial-gradient(circle_at_2px_2px,white_1px,transparent_0)] [background-size:32px_32px]" />\n <div className="relative max-w-7xl mx-auto px-6 sm:px-8 py-12 sm:py-14">\n <nav className="text-[12px] font-mono text-background/60 mb-3 flex items-center gap-2">\n <Link href="/" className="hover:text-background transition-colors">Home</Link>\n <span>/</span>\n <span className="text-background/90">Collections</span>\n <span>/</span>\n <span className="text-background/90">{collection.name}</span>\n </nav>\n <h1 className="text-[clamp(2rem,4vw,3rem)] font-bold m-0 -tracking-[0.025em]">\n {collection.name}\n </h1>\n {collection.description && (\n <p className="mt-3 max-w-2xl text-base text-background/75">\n {collection.description}\n </p>\n )}\n <p className="mt-4 text-[11px] font-mono uppercase tracking-[0.16em] text-background/60 tabular-nums">\n {products.length} {products.length === 1 ? "product" : "products"}\n </p>\n </div>\n </section>\n <section className="max-w-7xl mx-auto px-6 sm:px-8 py-10 sm:py-12">\n <ListingClient products={products} />\n {products.length === 0 && (\n <p className="text-center mt-8">\n <Link href="/shop" className="text-primary font-semibold hover:underline">\n \u2190 Browse all products\n </Link>\n </p>\n )}\n </section>\n </>\n );\n}\n\nfunction CollectionSkeleton() {\n return (\n <>\n <section className="bg-foreground py-12 sm:py-14">\n <div className="max-w-7xl mx-auto px-6 sm:px-8">\n <div className="h-3 w-32 bg-background/20 rounded mb-3 animate-pulse" />\n <div className="h-12 w-72 bg-background/20 rounded animate-pulse" />\n </div>\n </section>\n <section className="max-w-7xl mx-auto px-6 sm:px-8 py-10 sm:py-12">\n <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3 sm:gap-4">\n {Array.from({ length: 8 }).map((_, i) => (\n <div key={i} className="aspect-[4/3] bg-muted rounded-2xl animate-pulse" />\n ))}\n </div>\n </section>\n </>\n );\n}\n' }, { "path": "package.json", "kind": "text", "content": '{\n "name": "__STOREFRONT_NAME__",\n "private": true,\n "version": "0.0.0",\n "scripts": {\n "dev": "concurrently -k -n mock,next -c blue,magenta \\"cimplify-mock --seed retail --quiet\\" \\"next dev\\"",\n "dev:storefront": "next dev",\n "dev:mock": "cimplify-mock --seed retail",\n "build": "next build",\n "start": "next start",\n "typecheck": "tsc --noEmit",\n "test": "vitest",\n "test:run": "vitest run",\n "check:brand": "vitest run __tests__/brand.test.ts",\n "check:cart": "vitest run __tests__/cart-flow.test.ts",\n "check:contract": "vitest run __tests__/contract.test.ts",\n "check": "bun run typecheck && bun run test:run"\n },\n "dependencies": {\n "@cimplify/sdk": "^0.48.1",\n "next": "^16.2.4",\n "react": "^19.0.0",\n "react-dom": "^19.0.0"\n },\n "devDependencies": {\n "@tailwindcss/postcss": "^4.2.4",\n "@types/node": "^22.10.0",\n "@types/react": "^19.0.0",\n "@types/react-dom": "^19.0.0",\n "concurrently": "^9.0.0",\n "tailwindcss": "^4.2.4",\n "typescript": "^5.9.3",\n "vitest": "^4.1.5"\n }\n}\n' }, { "path": "lib/brand.ts", "kind": "text", "content": `/**
|
|
201
201
|
* Brand & content configuration \u2014 the **single source of truth** for every
|
|
202
202
|
* visible string in this storefront.
|
|
203
203
|
*
|
|
@@ -1011,7 +1011,7 @@ function Field({
|
|
|
1011
1011
|
</div>
|
|
1012
1012
|
);
|
|
1013
1013
|
}
|
|
1014
|
-
` }, { "path": "app/terms/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Terms of Service \u2014 ${brand.name}`,\n description: `The rules of the road for ordering from ${brand.name}.`,\n};\n\nexport default function TermsPage() {\n const t = brand.terms;\n return (\n <article className="max-w-3xl mx-auto px-6 sm:px-8 py-16 prose prose-lg max-w-none">\n <p className="text-[11px] font-mono uppercase tracking-[0.16em] text-primary mb-2 not-prose">\n {t.eyebrow}\n </p>\n <h1 className="text-[clamp(2.25rem,5vw,3.5rem)] font-bold mb-2 -tracking-[0.025em]">\n {t.title}\n </h1>\n <p className="text-sm text-muted-foreground not-prose mb-10">\n Last updated: {t.lastUpdated}\n </p>\n\n <section className="space-y-5 leading-relaxed text-foreground/90">\n {t.sections.map((s) => (\n <div key={s.heading}>\n <h2 className="text-2xl font-semibold mt-0 -tracking-[0.02em]">{s.heading}</h2>\n {typeof s.body === "string" ? (\n <p>{s.body}</p>\n ) : (\n <>\n <p>{s.body.intro}</p>\n <ul className="list-disc pl-6 space-y-2">\n {s.body.bullets.map((b) => (\n <li key={b}>{b}</li>\n ))}\n </ul>\n </>\n )}\n </div>\n ))}\n </section>\n </article>\n );\n}\n' }, { "path": "app/not-found.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport Link from "next/link";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Page not found \u2014 ${brand.name}`,\n description: "We couldn\'t find that page.",\n};\n\nexport default function NotFound() {\n return (\n <section className="max-w-2xl mx-auto px-6 sm:px-8 py-20 text-center">\n <p className="text-[11px] font-mono uppercase tracking-[0.16em] text-primary mb-3">\n 404\n </p>\n <h1 className="text-[clamp(2.5rem,5vw,4rem)] font-bold mb-4 -tracking-[0.03em]">\n Page not found.\n </h1>\n <p className="text-muted-foreground leading-relaxed mb-8">\n The URL you followed might be old, a typo, or pointing at a product\n that's no longer in stock. Try the menu or head back home.\n </p>\n <div className="flex flex-wrap items-center justify-center gap-3">\n <Link\n href="/shop"\n className="inline-flex items-center gap-2 px-5 py-2.5 rounded-md bg-primary text-primary-foreground font-semibold text-sm hover:bg-primary/90 transition-colors"\n >\n Browse the shop\n </Link>\n <Link\n href="/"\n className="inline-flex items-center gap-2 px-5 py-2.5 rounded-md border border-border hover:bg-muted transition-colors text-sm font-medium"\n >\n Back home\n </Link>\n </div>\n </section>\n );\n}\n' }, { "path": "app/orders/[id]/page.tsx", "kind": "text", "content": 'import Link from "next/link";\n\nexport default async function OrderPage({ params }: { params: Promise<{ id: string }> }) {\n const { id } = await params;\n return (\n <section className="max-w-2xl mx-auto px-6 sm:px-8 py-20 text-center">\n <h1 className="text-3xl mt-0 mb-3 font-bold -tracking-[0.025em]">\n Order confirmed.\n </h1>\n <p className="text-muted-foreground">\n Order <code className="font-mono text-foreground">{id}</code> \u2014 you'll\n get an SMS with tracking within 30 minutes.\n </p>\n <p className="mt-6">\n <Link\n href="/"\n className="inline-block px-6 py-3 rounded-md bg-primary text-primary-foreground font-semibold text-sm transition-colors hover:bg-primary/90"\n >\n Continue shopping\n </Link>\n </p>\n </section>\n );\n}\n' }, { "path": "app/products/[slug]/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Suspense } from "react";\nimport { notFound } from "next/navigation";\nimport Link from "next/link";\nimport { cacheTag, cacheLife } from "next/cache";\nimport {\n getServerClient,\n tags,\n type Product,\n type ProductWithDetails,\n} from "@cimplify/sdk/server";\nimport { ProductDetail } from "./product-detail";\nimport { brand } from "@/lib/brand";\nimport { getSiteUrl } from "@/lib/site-url";\n\nfunction productLd(product: ProductWithDetails, SITE_URL: string) {\n const image = product.image_url ?? product.images?.[0];\n const inStock = product.inventory_status?.in_stock !== false;\n return {\n "@context": "https://schema.org",\n "@type": "Product",\n name: product.name,\n description: product.description ?? undefined,\n image: image ? [image] : undefined,\n sku: product.id,\n brand: { "@type": "Brand", name: brand.name },\n offers: {\n "@type": "Offer",\n price: product.default_price,\n priceCurrency: brand.currency,\n availability: inStock\n ? "https://schema.org/InStock"\n : "https://schema.org/OutOfStock",\n url: `${SITE_URL}/products/${product.slug ?? product.id}`,\n },\n };\n}\n\ninterface ProductData {\n product: ProductWithDetails;\n related: Product[];\n}\n\nasync function getProduct(slug: string): Promise<ProductData | null> {\n "use cache";\n cacheTag(tags.product(slug), tags.products());\n cacheLife("hours");\n\n const client = getServerClient();\n const r = await client.catalogue.getProductBySlug(slug);\n if (!r.ok) return null;\n\n const related = r.value.category_id\n ? await client.catalogue\n .getCategoryProducts(r.value.category_id)\n .then((res) =>\n res.ok\n ? (\n ((res.value as { items?: Product[] }).items ??\n (res.value as Product[])) as Product[]\n ).filter((p) => p.id !== r.value.id).slice(0, 4)\n : [],\n )\n : [];\n\n return { product: r.value, related };\n}\n\nexport async function generateMetadata({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}): Promise<Metadata> {\n const { slug } = await params;\n const siteUrl = await getSiteUrl();\n const data = await getProduct(slug);\n if (!data) return {};\n const { product } = data;\n const image = product.image_url ?? product.images?.[0];\n return {\n title: `${product.name} \u2014 ${brand.name}`,\n description: product.description ?? undefined,\n openGraph: {\n title: product.name,\n description: product.description ?? undefined,\n images: image ? [{ url: image }] : undefined,\n type: "website",\n },\n };\n}\n\nexport default async function ProductPage({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}) {\n return (\n <Suspense fallback={<ProductSkeleton />}>\n <ProductContent params={params} />\n </Suspense>\n );\n}\n\nasync function ProductContent({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}) {\n const { slug } = await params;\n const siteUrl = await getSiteUrl();\n const data = await getProduct(slug);\n if (!data) notFound();\n\n return (\n <>\n <script\n type="application/ld+json"\n dangerouslySetInnerHTML={{ __html: JSON.stringify(productLd(data.product, siteUrl)) }}\n />\n <nav\n aria-label="Breadcrumb"\n className="max-w-7xl mx-auto px-6 sm:px-8 pt-6 text-[12px] font-mono text-muted-foreground flex items-center gap-2"\n >\n <Link href="/" className="hover:text-foreground transition-colors">\n Home\n </Link>\n <span>/</span>\n <Link href="/shop" className="hover:text-foreground transition-colors">\n Shop\n </Link>\n {data.product.category?.slug && (\n <>\n <span>/</span>\n <Link\n href={`/categories/${data.product.category.slug}`}\n className="hover:text-foreground transition-colors"\n >\n {data.product.category.name}\n </Link>\n </>\n )}\n <span>/</span>\n <span className="text-foreground/90 truncate">{data.product.name}</span>\n </nav>\n <ProductDetail product={data.product} related={data.related} />\n </>\n );\n}\n\nfunction ProductSkeleton() {\n return (\n <section className="max-w-7xl mx-auto px-6 sm:px-8 py-10">\n <div className="grid grid-cols-1 lg:grid-cols-[1.1fr_1fr] gap-10 items-start">\n <div className="aspect-square bg-muted rounded-3xl animate-pulse" />\n <div className="space-y-4">\n <div className="h-3 w-24 bg-muted rounded animate-pulse" />\n <div className="h-10 w-3/4 bg-muted rounded animate-pulse" />\n <div className="h-7 w-32 bg-muted rounded animate-pulse" />\n <div className="h-20 w-full bg-muted rounded animate-pulse mt-6" />\n <div className="h-12 w-full bg-muted rounded animate-pulse mt-4" />\n </div>\n </div>\n </section>\n );\n}\n' }, { "path": "app/products/[slug]/product-detail.tsx", "kind": "text", "content": '"use client";\n\nimport Image from "next/image";\nimport { ProductPage as SdkProductPage, useCart } from "@cimplify/sdk/react";\nimport type { Product, ProductWithDetails } from "@cimplify/sdk";\nimport { StoreProductCard } from "@/components/store-product-card";\n\n/**\n * Client island for the product detail page.\n *\n * - Receives a server-fetched `ProductWithDetails` (no client refetch).\n * - Renders the SDK `<ProductPage>` which picks the right layout\n * (Default / Wholesale / Service / Bundle / Composite) automatically.\n * - On add-to-cart success, routes to `/cart`.\n * - Custom Next.js Image renderer for optimised, lazy-loaded gallery shots.\n * - Renders a "You may also like" rail of in-category products below.\n */\nexport function ProductDetail({\n product,\n related,\n}: {\n product: ProductWithDetails;\n related: Product[];\n}) {\n const { addItem } = useCart();\n\n return (\n <>\n <SdkProductPage\n product={product}\n showRelated={false}\n onAddToCart={async (p, qty, options) => {\n await addItem(p, qty, options);\n }}\n renderImage={({ src, alt, className }) => (\n <Image\n src={src}\n alt={alt}\n width={1200}\n height={1200}\n className={className}\n style={{ width: "100%", height: "auto", objectFit: "cover" }}\n priority\n />\n )}\n className="max-w-7xl mx-auto px-6 sm:px-8 py-8 sm:py-10"\n />\n\n {related.length > 0 && (\n <section className="max-w-7xl mx-auto px-6 sm:px-8 py-14 sm:py-16 border-t border-border mt-8">\n <div className="flex items-end justify-between gap-6 mb-8">\n <div>\n <p className="text-[11px] font-mono uppercase tracking-[0.16em] text-primary mb-2">\n You may also like\n </p>\n <h2 className="text-[clamp(1.5rem,2.5vw,2rem)] font-bold m-0 -tracking-[0.025em]">\n More from this category.\n </h2>\n </div>\n </div>\n <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3 sm:gap-4">\n {related.map((p) => (\n <StoreProductCard key={p.id} product={p} />\n ))}\n </div>\n </section>\n )}\n </>\n );\n}\n' }, { "path": "app/login/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { redirect } from "next/navigation";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Sign in \u2014 ${brand.name}`,\n description: brand.account.loginSubtitle,\n};\n\n/**\n * Cimplify Link (the iframe in `<CimplifyAccount>`) handles sign-in\n * automatically when no session exists. We just bounce /login to /account\n * so consumers landing here get the right UI without a duplicate form.\n */\nexport default function LoginPage(): never {\n redirect("/account");\n}\n' }, { "path": "app/accessibility/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\nimport { PolicyPage } from "@/components/policy-page";\n\nexport const metadata: Metadata = {\n title: `${brand.accessibility.title} \u2014 ${brand.name}`,\n};\n\nexport default function AccessibilityPage() {\n return <PolicyPage policy={brand.accessibility} />;\n}\n' }, { "path": "app/shipping/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\nimport { PolicyPage } from "@/components/policy-page";\n\nexport const metadata: Metadata = {\n title: `${brand.shipping.title} \u2014 ${brand.name}`,\n description: brand.shipping.sections[0]?.body\n ? typeof brand.shipping.sections[0].body === "string"\n ? brand.shipping.sections[0].body\n : brand.shipping.sections[0].body.intro\n : undefined,\n};\n\nexport default function ShippingPage() {\n return <PolicyPage policy={brand.shipping} />;\n}\n' }, { "path": "app/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Suspense } from "react";\nimport { cacheTag, cacheLife } from "next/cache";\nimport { getServerClient, tags, type Collection, type Product } from "@cimplify/sdk/server";\nimport { FeatureHero } from "@/components/feature-hero";\nimport { CategoryTiles } from "@/components/category-tiles";\nimport { CollectionStrip } from "@/components/collection-strip";\nimport { PromoBanner } from "@/components/promo-banner";\nimport { TrustBar } from "@/components/trust-bar";\nimport { BrandMarquee } from "@/components/brand-marquee";\nimport { TradeInCta } from "@/components/trade-in-cta";\nimport { Newsletter } from "@/components/newsletter";\nimport { SectionHeading } from "@/components/section-heading";\nimport { StoreProductCard } from "@/components/store-product-card";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: brand.hero.title,\n description: brand.description,\n};\n\ninterface CollectionWithProducts {\n collection: Collection;\n products: Product[];\n}\n\nasync function getHomeData() {\n "use cache";\n cacheTag(tags.collections(), tags.categories(), tags.products());\n cacheLife("hours");\n\n const client = getServerClient();\n const [colRes, catRes, productsRes] = await Promise.all([\n client.catalogue.getCollections(),\n client.catalogue.getCategories(),\n client.catalogue.getProducts({ limit: 12 }),\n ]);\n const collections = colRes.ok ? colRes.value : [];\n const categories = catRes.ok ? catRes.value : [];\n const allProducts = productsRes.ok ? productsRes.value.items : [];\n\n const collectionsWithProducts: CollectionWithProducts[] = await Promise.all(\n collections.map(async (col) => {\n const r = await client.catalogue.getCollectionProducts(col.id);\n const items = r.ok\n ? ((r.value as { items?: Product[] }).items ?? (r.value as Product[]))\n : [];\n return { collection: col, products: items };\n }),\n );\n\n return {\n collections: collectionsWithProducts.filter((x) => x.products.length > 0),\n categories,\n featured: allProducts.slice(0, 4),\n newArrivals: allProducts.slice(4, 12),\n };\n}\n\n// Verified-reachable Unsplash fashion editorial \u2014 replace with your own asset.\nconst HERO_FALLBACK_IMAGE =\n "https://images.unsplash.com/photo-1490481651871-ab68de25d43d?w=2000&h=1400&fit=crop&auto=format&q=85";\n\nexport default async function HomePage() {\n const { collections, categories, featured, newArrivals } = await getHomeData();\n const heroProduct = featured[0];\n\n return (\n <>\n <FeatureHero\n eyebrow="Featured \xB7 in stock"\n badge="Just landed"\n title={brand.hero.title}\n description={heroProduct?.description ?? brand.hero.subtitle}\n primaryCta={{\n label: heroProduct ? `Shop ${heroProduct.name}` : brand.hero.primaryCtaLabel,\n href: heroProduct ? `/products/${heroProduct.slug ?? heroProduct.id}` : "/shop",\n }}\n secondaryCta={\n brand.hero.secondaryCtaLabel && brand.hero.secondaryCtaHref\n ? { label: brand.hero.secondaryCtaLabel, href: brand.hero.secondaryCtaHref }\n : undefined\n }\n imageUrl={heroProduct?.image_url ?? heroProduct?.images?.[0] ?? HERO_FALLBACK_IMAGE}\n imageAlt={heroProduct?.name ?? "Featured product"}\n />\n\n <TrustBar />\n\n <Suspense fallback={<CategoryTilesSkeleton />}>\n <CategoryTiles categories={categories} />\n </Suspense>\n\n <PromoBanner />\n\n <section className="max-w-7xl mx-auto px-6 sm:px-8 py-14 sm:py-20">\n <SectionHeading\n eyebrow="Just dropped"\n title="New this week."\n description="Hand-picked launches and freshly restocked staples."\n link={{ label: "Browse all", href: "/shop" }}\n />\n <Suspense fallback={<GridSkeleton count={4} />}>\n <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3 sm:gap-4">\n {newArrivals.slice(0, 4).map((p) => (\n <StoreProductCard key={p.id} product={p} />\n ))}\n </div>\n </Suspense>\n </section>\n\n <BrandMarquee />\n\n {collections.map(({ collection, products }) => (\n <Suspense\n key={collection.id}\n fallback={<StripSkeleton title={collection.name} />}\n >\n <CollectionStrip\n collection={collection}\n products={products}\n collectionHref={`/collections/${collection.slug}`}\n />\n </Suspense>\n ))}\n\n <TradeInCta />\n\n <section className="max-w-7xl mx-auto px-6 sm:px-8 py-14 sm:py-20">\n <SectionHeading\n eyebrow="Best sellers"\n title="What everyone's buying."\n link={{ label: "See more", href: "/shop" }}\n />\n <Suspense fallback={<GridSkeleton count={4} />}>\n <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3 sm:gap-4">\n {newArrivals.slice(4, 8).map((p) => (\n <StoreProductCard key={p.id} product={p} />\n ))}\n </div>\n </Suspense>\n </section>\n\n <Newsletter />\n </>\n );\n}\n\nfunction CategoryTilesSkeleton() {\n return (\n <section className="max-w-7xl mx-auto px-6 sm:px-8 py-14 sm:py-20">\n <div className="h-8 w-64 bg-muted rounded mb-8 animate-pulse" />\n <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3 sm:gap-4">\n {Array.from({ length: 5 }).map((_, i) => (\n <div key={i} className="h-40 bg-muted rounded-2xl animate-pulse" />\n ))}\n </div>\n </section>\n );\n}\n\nfunction GridSkeleton({ count }: { count: number }) {\n return (\n <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3 sm:gap-4">\n {Array.from({ length: count }).map((_, i) => (\n <div key={i} className="aspect-[4/3] bg-muted rounded-2xl animate-pulse" />\n ))}\n </div>\n );\n}\n\nfunction StripSkeleton({ title }: { title: string }) {\n return (\n <section className="max-w-7xl mx-auto px-6 sm:px-8 py-12">\n <h2 className="text-[26px] font-semibold m-0 mb-5 -tracking-[0.02em]">{title}</h2>\n <div className="grid grid-flow-col auto-cols-[minmax(220px,1fr)] gap-4 overflow-x-auto pb-2">\n {Array.from({ length: 5 }).map((_, i) => (\n <div key={i} className="aspect-square bg-muted rounded-2xl animate-pulse" />\n ))}\n </div>\n </section>\n );\n}\n' }, { "path": "app/categories/[slug]/listing-client.tsx", "kind": "text", "content": '"use client";\n\nimport { ProductGrid } from "@cimplify/sdk/react";\nimport type { Product } from "@cimplify/sdk";\nimport { StoreProductCard } from "@/components/store-product-card";\n\n/**\n * Client island for the category listing. Receives server-fetched products\n * as props (serializable) and owns the `renderCard` function.\n */\nexport function ListingClient({ products }: { products: Product[] }) {\n return (\n <ProductGrid\n products={products}\n emptyMessage="No products in this category yet."\n renderCard={(p) => <StoreProductCard product={p} />}\n />\n );\n}\n' }, { "path": "app/categories/[slug]/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Suspense } from "react";\nimport Link from "next/link";\nimport { notFound } from "next/navigation";\nimport { cacheTag, cacheLife } from "next/cache";\nimport {\n getServerClient,\n tags,\n type Category,\n type Product,\n} from "@cimplify/sdk/server";\nimport { ListingClient } from "./listing-client";\nimport { brand } from "@/lib/brand";\n\ninterface CategoryData {\n category: Category;\n products: Product[];\n}\n\nasync function getCategory(slug: string): Promise<CategoryData | null> {\n "use cache";\n cacheTag(tags.categories());\n cacheLife("hours");\n\n const client = getServerClient();\n const catRes = await client.catalogue.getCategoryBySlug(slug);\n if (!catRes.ok) return null;\n\n cacheTag(tags.category(catRes.value.id), tags.categoryProducts(catRes.value.id));\n const r = await client.catalogue.getCategoryProducts(catRes.value.id);\n const products = r.ok\n ? ((r.value as { items?: Product[] }).items ?? (r.value as Product[]))\n : [];\n return { category: catRes.value, products };\n}\n\nexport async function generateMetadata({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}): Promise<Metadata> {\n const { slug } = await params;\n const data = await getCategory(slug);\n if (!data) return {};\n return {\n title: `${data.category.name} \u2014 ${brand.name}`,\n description: data.category.description ?? undefined,\n };\n}\n\nexport default async function CategoryPage({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}) {\n return (\n <Suspense fallback={<CategorySkeleton />}>\n <CategoryContent params={params} />\n </Suspense>\n );\n}\n\nasync function CategoryContent({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}) {\n const { slug } = await params;\n const data = await getCategory(slug);\n if (!data) notFound();\n\n const { category, products } = data;\n return (\n <>\n <section className="bg-foreground text-background relative overflow-hidden">\n <div className="absolute inset-0 opacity-[0.04] pointer-events-none [background-image:radial-gradient(circle_at_2px_2px,white_1px,transparent_0)] [background-size:32px_32px]" />\n <div className="relative max-w-7xl mx-auto px-6 sm:px-8 py-12 sm:py-14">\n <nav className="text-[12px] font-mono text-background/60 mb-3 flex items-center gap-2">\n <Link href="/" className="hover:text-background transition-colors">Home</Link>\n <span>/</span>\n <Link href="/shop" className="hover:text-background transition-colors">Shop</Link>\n <span>/</span>\n <span className="text-background/90">{category.name}</span>\n </nav>\n <h1 className="text-[clamp(2rem,4vw,3rem)] font-bold m-0 -tracking-[0.025em]">\n {category.name}\n </h1>\n {category.description && (\n <p className="mt-3 max-w-2xl text-base text-background/75">\n {category.description}\n </p>\n )}\n <p className="mt-4 text-[11px] font-mono uppercase tracking-[0.16em] text-background/60 tabular-nums">\n {products.length} {products.length === 1 ? "product" : "products"}\n </p>\n </div>\n </section>\n <section className="max-w-7xl mx-auto px-6 sm:px-8 py-10 sm:py-12">\n <ListingClient products={products} />\n {products.length === 0 && (\n <p className="text-center mt-8">\n <Link href="/shop" className="text-primary font-semibold hover:underline">\n \u2190 Browse all products\n </Link>\n </p>\n )}\n </section>\n </>\n );\n}\n\nfunction CategorySkeleton() {\n return (\n <>\n <section className="bg-foreground py-12 sm:py-14">\n <div className="max-w-7xl mx-auto px-6 sm:px-8">\n <div className="h-3 w-32 bg-background/20 rounded mb-3 animate-pulse" />\n <div className="h-12 w-72 bg-background/20 rounded animate-pulse" />\n </div>\n </section>\n <section className="max-w-7xl mx-auto px-6 sm:px-8 py-10 sm:py-12">\n <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3 sm:gap-4">\n {Array.from({ length: 8 }).map((_, i) => (\n <div key={i} className="aspect-[4/3] bg-muted rounded-2xl animate-pulse" />\n ))}\n </div>\n </section>\n </>\n );\n}\n' }, { "path": "app/error.tsx", "kind": "text", "content": '"use client";\n\nimport { useEffect } from "react";\nimport Link from "next/link";\n\n/**\n * Root error boundary. Next.js calls this whenever a thrown error escapes\n * a Server Component without being caught by a closer `error.tsx`.\n *\n * Wire `reportError` (Sentry, Datadog, etc.) here so production failures\n * surface \u2014 silently recovering hides real bugs.\n */\nexport default function GlobalError({\n error,\n reset,\n}: {\n error: Error & { digest?: string };\n reset: () => void;\n}) {\n useEffect(() => {\n // TODO: replace with your error reporter\n // eslint-disable-next-line no-console\n console.error("Storefront error:", error);\n }, [error]);\n\n return (\n <section className="max-w-2xl mx-auto px-6 sm:px-8 py-20 text-center">\n <p className="text-[11px] font-mono uppercase tracking-[0.16em] text-primary mb-3">\n Something broke\n </p>\n <h1 className="text-[clamp(2rem,4vw,3rem)] font-bold mb-4 -tracking-[0.025em]">\n That wasn't supposed to happen.\n </h1>\n <p className="text-muted-foreground leading-relaxed mb-8">\n We've been notified. Refresh the page, or head back home \u2014 most\n of the time the second try just works.\n </p>\n {error.digest && (\n <p className="text-xs font-mono text-muted-foreground mb-6">\n Reference:{" "}\n <code className="text-foreground">{error.digest}</code>\n </p>\n )}\n <div className="flex flex-wrap items-center justify-center gap-3">\n <button\n type="button"\n onClick={reset}\n className="inline-flex items-center gap-2 px-5 py-2.5 rounded-md bg-primary text-primary-foreground font-semibold text-sm hover:bg-primary/90 transition-colors"\n >\n Try again\n </button>\n <Link\n href="/"\n className="inline-flex items-center gap-2 px-5 py-2.5 rounded-md border border-border hover:bg-muted transition-colors text-sm font-medium"\n >\n Back home\n </Link>\n </div>\n </section>\n );\n}\n' }, { "path": "app/globals.css", "kind": "text", "content": '@import "tailwindcss";\n\n@source "../node_modules/@cimplify/sdk/dist";\n\n/* Studio FRX \u2014 Nike/Aritzia-style streetwear palette. High-contrast\n neutrals with one electric accent. Tweak primary to retheme everything. */\n@theme {\n --color-background: oklch(0.99 0 0);\n --color-foreground: oklch(0.12 0 0);\n --color-card: oklch(1 0 0);\n --color-card-foreground: oklch(0.12 0 0);\n --color-popover: oklch(1 0 0);\n --color-popover-foreground: oklch(0.12 0 0);\n --color-primary: oklch(0.7 0.24 30);\n --color-primary-foreground: oklch(0.99 0 0);\n --color-secondary: oklch(0.96 0 0);\n --color-secondary-foreground: oklch(0.18 0 0);\n --color-muted: oklch(0.96 0 0);\n --color-muted-foreground: oklch(0.45 0 0);\n --color-accent: oklch(0.92 0 0);\n --color-accent-foreground: oklch(0.18 0 0);\n --color-destructive: oklch(0.58 0.24 27);\n --color-destructive-foreground: oklch(0.99 0 0);\n --color-border: oklch(0.9 0 0);\n --color-input: oklch(0.93 0 0);\n --color-ring: oklch(0.7 0.24 30);\n --radius: 0.125rem;\n}\n\n@layer base {\n html, body {\n margin: 0;\n padding: 0;\n background: var(--color-background);\n color: var(--color-foreground);\n -webkit-font-smoothing: antialiased;\n -moz-osx-font-smoothing: grayscale;\n }\n a { color: inherit; text-decoration: none; }\n h1, h2, h3, h4 {\n font-family: var(--font-display);\n letter-spacing: -0.04em;\n font-weight: 800;\n line-height: 0.95;\n }\n}\n' }, { "path": "app/sitemap-page/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport Link from "next/link";\nimport { cacheTag, cacheLife } from "next/cache";\nimport { getServerClient, tags, type Product } from "@cimplify/sdk/server";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Sitemap \u2014 ${brand.name}`,\n description: "A human-readable index of every page on this site.",\n};\n\ninterface SitemapData {\n products: { slug: string; name: string }[];\n categories: { slug: string; name: string }[];\n collections: { slug: string; name: string }[];\n}\n\nasync function getSitemap(): Promise<SitemapData> {\n "use cache";\n cacheTag(tags.products(), tags.categories(), tags.collections());\n cacheLife("hours");\n\n const client = getServerClient();\n const [pRes, cRes, colRes] = await Promise.all([\n client.catalogue.getProducts({ limit: 500 }),\n client.catalogue.getCategories(),\n client.catalogue.getCollections(),\n ]);\n return {\n products: (pRes.ok ? pRes.value.items : []).map((p: Product) => ({\n slug: p.slug ?? p.id,\n name: p.name,\n })),\n categories: (cRes.ok ? cRes.value : []).map((c) => ({ slug: c.slug, name: c.name })),\n collections: (colRes.ok ? colRes.value : []).map((c) => ({ slug: c.slug, name: c.name })),\n };\n}\n\nconst STATIC_LINKS: { title: string; links: { href: string; label: string }[] }[] = [\n {\n title: "Browse",\n links: [\n { href: "/", label: "Home" },\n { href: "/shop", label: "Shop" },\n { href: "/search", label: "Search" },\n ],\n },\n {\n title: "Account",\n links: [\n { href: "/account", label: "Account" },\n { href: "/account/orders", label: "Orders" },\n { href: "/account/addresses", label: "Addresses" },\n { href: "/account/settings", label: "Settings" },\n { href: "/login", label: "Sign in" },\n { href: "/signup", label: "Create account" },\n { href: "/track-order", label: "Track an order" },\n { href: "/cart", label: "Cart" },\n { href: "/checkout", label: "Checkout" },\n ],\n },\n {\n title: "About",\n links: [\n { href: "/about", label: "About" },\n { href: "/faq", label: "FAQ" },\n { href: "/contact", label: "Contact" },\n ],\n },\n {\n title: "Policies",\n links: [\n { href: "/shipping", label: "Shipping" },\n { href: "/returns", label: "Returns" },\n { href: "/accessibility", label: "Accessibility" },\n { href: "/terms", label: "Terms of Service" },\n { href: "/privacy", label: "Privacy Policy" },\n ],\n },\n {\n title: "Machine-readable",\n links: [\n { href: "/sitemap.xml", label: "sitemap.xml (search engines)" },\n { href: "/llms.txt", label: "llms.txt (LLM agents)" },\n { href: "/robots.txt", label: "robots.txt" },\n { href: "/opensearch.xml", label: "opensearch.xml (browser search)" },\n ],\n },\n];\n\nexport default async function SitemapHtmlPage() {\n const { products, categories, collections } = await getSitemap();\n\n return (\n <article className="max-w-5xl mx-auto px-6 sm:px-8 py-16">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-2">\n Sitemap\n </p>\n <h1 className="text-[clamp(2rem,4vw,2.75rem)] font-semibold mb-2 -tracking-[0.02em]">\n Every page, in one place.\n </h1>\n <p className="text-muted-foreground mb-12">\n For search engines, see <Link href="/sitemap.xml" className="text-primary hover:underline">/sitemap.xml</Link>.\n For LLM agents, see <Link href="/llms.txt" className="text-primary hover:underline">/llms.txt</Link>.\n </p>\n <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-10">\n {STATIC_LINKS.map((s) => (\n <Section key={s.title} title={s.title}>\n {s.links.map((l) => (\n <li key={l.href}>\n <Link href={l.href} className="hover:text-primary transition-colors">\n {l.label}\n </Link>\n </li>\n ))}\n </Section>\n ))}\n {categories.length > 0 && (\n <Section title="Categories">\n {categories.map((c) => (\n <li key={c.slug}>\n <Link href={`/categories/${c.slug}`} className="hover:text-primary transition-colors">\n {c.name}\n </Link>\n </li>\n ))}\n </Section>\n )}\n {collections.length > 0 && (\n <Section title="Collections">\n {collections.map((c) => (\n <li key={c.slug}>\n <Link href={`/collections/${c.slug}`} className="hover:text-primary transition-colors">\n {c.name}\n </Link>\n </li>\n ))}\n </Section>\n )}\n {products.length > 0 && (\n <Section title={`Products (${products.length})`}>\n {products.map((p) => (\n <li key={p.slug}>\n <Link href={`/shop?product=${encodeURIComponent(p.slug)}`} className="hover:text-primary transition-colors">\n {p.name}\n </Link>\n </li>\n ))}\n </Section>\n )}\n </div>\n </article>\n );\n}\n\nfunction Section({ title, children }: { title: string; children: React.ReactNode }) {\n return (\n <div>\n <p className="font-semibold text-[12px] uppercase tracking-[0.12em] text-foreground mb-3">\n {title}\n </p>\n <ul className="space-y-2 m-0 p-0 list-none text-sm text-muted-foreground">\n {children}\n </ul>\n </div>\n );\n}\n' }, { "path": "app/shop/shop-client.tsx", "kind": "text", "content": '"use client";\n\nimport { CataloguePage } from "@cimplify/sdk/react";\nimport type { Category, Product } from "@cimplify/sdk";\nimport { StoreProductCard } from "@/components/store-product-card";\n\n/**\n * Client island for the shop page. Server-side fetches all products and\n * categories (cached via `\'use cache\'` in `app/shop/page.tsx`), then hands\n * them to `<CataloguePage>` which owns the interactive filter / sort state.\n *\n * The page hero (in `app/shop/page.tsx`) already provides the title; we\n * pass an empty `title` and override the SDK heading via className so the\n * page reads as one continuous design.\n */\nexport function ShopClient({\n products,\n categories,\n}: {\n products: Product[];\n categories: Category[];\n}) {\n return (\n <CataloguePage\n title="All products"\n products={products}\n categories={categories}\n renderCard={(p) => <StoreProductCard product={p} />}\n className="max-w-7xl mx-auto px-6 sm:px-8 py-10 sm:py-12"\n />\n );\n}\n' }, { "path": "app/shop/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Suspense } from "react";\nimport { cacheTag, cacheLife } from "next/cache";\nimport { getServerClient, tags } from "@cimplify/sdk/server";\nimport { ShopClient } from "./shop-client";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Shop \u2014 ${brand.name}`,\n description: brand.description,\n};\n\nasync function getShopData() {\n "use cache";\n cacheTag(tags.products(), tags.categories());\n cacheLife("hours");\n\n const client = getServerClient();\n const [p, c] = await Promise.all([\n client.catalogue.getProducts({ limit: 50 }),\n client.catalogue.getCategories(),\n ]);\n return {\n products: p.ok ? p.value.items : [],\n categories: c.ok ? c.value : [],\n };\n}\n\nexport default async function ShopPage() {\n const { products, categories } = await getShopData();\n return (\n <>\n <section className="bg-foreground text-background relative overflow-hidden">\n <div className="absolute inset-0 opacity-[0.04] pointer-events-none [background-image:radial-gradient(circle_at_2px_2px,white_1px,transparent_0)] [background-size:32px_32px]" />\n <div className="relative max-w-7xl mx-auto px-6 sm:px-8 py-12 sm:py-14">\n <p className="text-[11px] font-mono uppercase tracking-[0.2em] text-background/60 mb-2">\n Catalogue \xB7 {products.length} products\n </p>\n <h1 className="text-[clamp(2rem,4vw,3rem)] font-bold m-0 -tracking-[0.025em] leading-[1.05]">\n Every product we stock.\n </h1>\n <p className="mt-3 max-w-xl text-base text-background/75">\n Filter, sort, search. Authorised dealer pricing, two-year warranty,\n same-day Accra delivery on every order.\n </p>\n </div>\n </section>\n <Suspense\n fallback={\n <div className="max-w-7xl mx-auto px-6 sm:px-8 py-10">\n <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3 sm:gap-4">\n {Array.from({ length: 8 }).map((_, i) => (\n <div key={i} className="aspect-[4/3] bg-muted rounded-2xl animate-pulse" />\n ))}\n </div>\n </div>\n }\n >\n <ShopClient products={products} categories={categories} />\n </Suspense>\n </>\n );\n}\n' }, { "path": "app/returns/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\nimport { PolicyPage } from "@/components/policy-page";\n\nexport const metadata: Metadata = {\n title: `${brand.returns.title} \u2014 ${brand.name}`,\n};\n\nexport default function ReturnsPage() {\n return <PolicyPage policy={brand.returns} />;\n}\n' }, { "path": "app/layout.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Inter, Anton } from "next/font/google";\nimport "./globals.css";\nimport { Providers } from "@/components/providers";\nimport { Header } from "@/components/header";\nimport { Footer } from "@/components/footer";\nimport { ProductModal } from "@/components/product-modal";\nimport { CartDrawer } from "@/components/cart-drawer";\nimport { OrganizationJsonLd } from "@/components/json-ld";\nimport { Suspense } from "react";\nimport { brand } from "@/lib/brand";\nimport { getSiteUrl } from "@/lib/site-url";\n\nconst inter = Inter({\n subsets: ["latin"],\n variable: "--font-sans",\n display: "swap",\n});\n\nconst anton = Anton({\n subsets: ["latin"],\n variable: "--font-display",\n weight: "400",\n display: "swap",\n});\n\nexport async function generateMetadata(): Promise<Metadata> {\n const siteUrl = await getSiteUrl();\n return {\n metadataBase: new URL(siteUrl),\n title: {\n default: brand.name,\n template: `%s \u2014 ${brand.name}`,\n },\n description: brand.description,\n openGraph: {\n type: "website",\n siteName: brand.name,\n locale: brand.locale,\n },\n twitter: { card: "summary_large_image" },\n };\n}\n\nexport default function RootLayout({ children }: { children: React.ReactNode }) {\n return (\n <html lang="en" suppressHydrationWarning className={`${inter.variable} ${anton.variable}`}>\n <body\n suppressHydrationWarning\n className="min-h-screen flex flex-col bg-background text-foreground font-sans"\n >\n <Suspense fallback={null}>\n <OrganizationJsonLd />\n </Suspense>\n <Providers>\n <Header />\n <main className="flex-1 pb-12 w-full">{children}</main>\n <Footer />\n <Suspense fallback={null}>\n <ProductModal />\n </Suspense>\n <CartDrawer />\n </Providers>\n </body>\n </html>\n );\n}\n' }, { "path": "app/checkout/page.tsx", "kind": "text", "content": '"use client";\n\nimport { useRouter } from "next/navigation";\nimport { CheckoutPage as SdkCheckoutPage } from "@cimplify/sdk/react";\n\nexport default function CheckoutPage() {\n const router = useRouter();\n return (\n <SdkCheckoutPage\n onComplete={(result) => {\n if (result.success && result.order) {\n router.push(`/orders/${result.order.id}`);\n }\n }}\n />\n );\n}\n' }, { "path": "app/privacy/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Privacy Policy \u2014 ${brand.name}`,\n description: `How ${brand.name} collects, uses, and protects your personal data.`,\n};\n\nexport default function PrivacyPage() {\n const p = brand.privacy;\n return (\n <article className="max-w-3xl mx-auto px-6 sm:px-8 py-16 prose prose-lg max-w-none">\n <p className="text-[11px] font-mono uppercase tracking-[0.16em] text-primary mb-2 not-prose">\n {p.eyebrow}\n </p>\n <h1 className="text-[clamp(2.25rem,5vw,3.5rem)] font-bold mb-2 -tracking-[0.025em]">\n {p.title}\n </h1>\n <p className="text-sm text-muted-foreground not-prose mb-10">\n Last updated: {p.lastUpdated}\n </p>\n\n <section className="space-y-5 leading-relaxed text-foreground/90">\n {p.sections.map((s) => (\n <div key={s.heading}>\n <h2 className="text-2xl font-semibold mt-0 -tracking-[0.02em]">{s.heading}</h2>\n {typeof s.body === "string" ? (\n <p>{s.body}</p>\n ) : (\n <>\n <p>{s.body.intro}</p>\n <ul className="list-disc pl-6 space-y-2">\n {s.body.bullets.map((b) => (\n <li key={b}>{b}</li>\n ))}\n </ul>\n </>\n )}\n </div>\n ))}\n </section>\n </article>\n );\n}\n' }, { "path": "app/size-guide/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Size Guide \u2014 ${brand.name}`,\n description: "Chest, length, and shoulder measurements for every Studio FRX piece.",\n};\n\ninterface SizeRow {\n size: string;\n chest: string;\n length: string;\n shoulder: string;\n}\n\nconst TOPS_CHART: SizeRow[] = [\n { size: "XS", chest: "104 cm / 41 in", length: "68 cm / 26.8 in", shoulder: "50 cm / 19.7 in" },\n { size: "S", chest: "110 cm / 43.3 in", length: "70 cm / 27.5 in", shoulder: "52 cm / 20.5 in" },\n { size: "M", chest: "116 cm / 45.7 in", length: "72 cm / 28.3 in", shoulder: "54 cm / 21.3 in" },\n { size: "L", chest: "122 cm / 48 in", length: "74 cm / 29.1 in", shoulder: "56 cm / 22 in" },\n { size: "XL", chest: "128 cm / 50.4 in", length: "76 cm / 29.9 in", shoulder: "58 cm / 22.8 in" },\n { size: "2XL", chest: "134 cm / 52.7 in", length: "78 cm / 30.7 in", shoulder: "60 cm / 23.6 in" },\n { size: "3XL", chest: "140 cm / 55.1 in", length: "80 cm / 31.5 in", shoulder: "62 cm / 24.4 in" },\n];\n\nconst BOTTOMS_CHART: { size: string; waist: string; hip: string; inseam: string }[] = [\n { size: "XS", waist: "72 cm / 28 in", hip: "92 cm / 36 in", inseam: "78 cm / 30.7 in" },\n { size: "S", waist: "76 cm / 30 in", hip: "96 cm / 38 in", inseam: "78 cm / 30.7 in" },\n { size: "M", waist: "82 cm / 32 in", hip: "102 cm / 40 in", inseam: "80 cm / 31.5 in" },\n { size: "L", waist: "88 cm / 35 in", hip: "108 cm / 42.5 in", inseam: "80 cm / 31.5 in" },\n { size: "XL", waist: "94 cm / 37 in", hip: "114 cm / 45 in", inseam: "82 cm / 32.3 in" },\n { size: "2XL", waist: "102 cm / 40 in", hip: "122 cm / 48 in", inseam: "82 cm / 32.3 in" },\n];\n\nexport default function SizeGuidePage() {\n return (\n <article className="max-w-4xl mx-auto px-6 sm:px-8 py-16">\n <p className="text-[11px] font-mono uppercase tracking-[0.2em] text-primary mb-2">\n Size guide\n </p>\n <h1 className="font-display text-[clamp(2.5rem,7vw,5rem)] uppercase mb-4 -tracking-[0.04em] leading-[0.95]">\n Find your fit.\n </h1>\n <p className="text-base text-muted-foreground leading-relaxed max-w-2xl mb-12">\n Our pieces are cut for an oversized, dropped-shoulder fit. Order true to size for\n the intended fit; size down for a regular cut. All measurements are flat-laid\n garment measurements.\n </p>\n\n <section className="mb-14">\n <h2 className="text-2xl font-bold mb-2 -tracking-[0.025em]">Tops & outerwear</h2>\n <p className="text-sm text-muted-foreground mb-4">\n Hoodies, tees, sweats, jackets. Chest measured pit-to-pit, doubled. Length\n measured from highest shoulder seam to hem.\n </p>\n <SizeTable\n headings={["Size", "Chest", "Length", "Shoulder"]}\n rows={TOPS_CHART.map((r) => [r.size, r.chest, r.length, r.shoulder])}\n />\n </section>\n\n <section className="mb-14">\n <h2 className="text-2xl font-bold mb-2 -tracking-[0.025em]">Bottoms</h2>\n <p className="text-sm text-muted-foreground mb-4">\n Trousers, shorts, sweats. Waist measured flat across the top of the\n waistband, doubled.\n </p>\n <SizeTable\n headings={["Size", "Waist", "Hip", "Inseam"]}\n rows={BOTTOMS_CHART.map((r) => [r.size, r.waist, r.hip, r.inseam])}\n />\n </section>\n\n <section className="rounded-2xl bg-foreground text-background p-8">\n <h2 className="text-xl font-bold mb-3 -tracking-[0.025em]">How to measure yourself</h2>\n <ul className="space-y-3 text-background/85 leading-relaxed">\n <li>\n <strong className="text-background">Chest</strong> \u2014 Wrap a soft tape around\n the fullest part of your chest, under the arms, keeping the tape level.\n </li>\n <li>\n <strong className="text-background">Waist</strong> \u2014 Around the narrowest part\n of your waist, just above the navel.\n </li>\n <li>\n <strong className="text-background">Hip</strong> \u2014 Around the fullest part of\n your hips, feet together.\n </li>\n <li>\n <strong className="text-background">Inseam</strong> \u2014 From the very top of the\n inside of your leg down to the floor (no shoes).\n </li>\n </ul>\n </section>\n\n <p className="mt-12 text-sm text-muted-foreground">\n Still unsure?{" "}\n <a\n href={`mailto:${brand.faq.contactEmail}`}\n className="text-primary font-semibold hover:underline"\n >\n Email us your usual size in another brand\n </a>{" "}\n and we\'ll recommend a fit.\n </p>\n </article>\n );\n}\n\nfunction SizeTable({\n headings,\n rows,\n}: {\n headings: string[];\n rows: string[][];\n}) {\n return (\n <div className="overflow-x-auto rounded-2xl border border-border">\n <table className="w-full text-sm">\n <thead className="bg-muted">\n <tr>\n {headings.map((h) => (\n <th\n key={h}\n className="text-left font-semibold uppercase tracking-wider text-[11px] text-muted-foreground px-4 py-3"\n >\n {h}\n </th>\n ))}\n </tr>\n </thead>\n <tbody>\n {rows.map((r, i) => (\n <tr\n key={r[0]}\n className={i % 2 === 0 ? "bg-background" : "bg-muted/30"}\n >\n {r.map((cell, j) => (\n <td\n key={j}\n className={[\n "px-4 py-3 tabular-nums",\n j === 0 ? "font-semibold text-foreground" : "text-foreground/80",\n ].join(" ")}\n >\n {cell}\n </td>\n ))}\n </tr>\n ))}\n </tbody>\n </table>\n </div>\n );\n}\n' }, { "path": "app/collections/[slug]/listing-client.tsx", "kind": "text", "content": '"use client";\n\nimport { ProductGrid } from "@cimplify/sdk/react";\nimport type { Product } from "@cimplify/sdk";\nimport { StoreProductCard } from "@/components/store-product-card";\n\n/**\n * Client island for the collection listing. Receives server-fetched\n * products as props (serializable) and owns the `renderCard` function\n * (which can\'t cross the server/client boundary).\n */\nexport function ListingClient({ products }: { products: Product[] }) {\n return (\n <ProductGrid\n products={products}\n emptyMessage="No products in this collection yet."\n renderCard={(p) => <StoreProductCard product={p} />}\n />\n );\n}\n' }, { "path": "app/collections/[slug]/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Suspense } from "react";\nimport Link from "next/link";\nimport { notFound } from "next/navigation";\nimport { cacheTag, cacheLife } from "next/cache";\nimport {\n getServerClient,\n tags,\n type Collection,\n type Product,\n} from "@cimplify/sdk/server";\nimport { ListingClient } from "./listing-client";\nimport { brand } from "@/lib/brand";\n\ninterface CollectionData {\n collection: Collection;\n products: Product[];\n}\n\nasync function getCollection(slug: string): Promise<CollectionData | null> {\n "use cache";\n cacheTag(tags.collections());\n cacheLife("hours");\n\n const client = getServerClient();\n const colRes = await client.catalogue.getCollectionBySlug(slug);\n if (!colRes.ok) return null;\n\n cacheTag(tags.collection(colRes.value.id), tags.collectionProducts(colRes.value.id));\n const r = await client.catalogue.getCollectionProducts(colRes.value.id);\n const products = r.ok\n ? ((r.value as { items?: Product[] }).items ?? (r.value as Product[]))\n : [];\n return { collection: colRes.value, products };\n}\n\nexport async function generateMetadata({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}): Promise<Metadata> {\n const { slug } = await params;\n const data = await getCollection(slug);\n if (!data) return {};\n return {\n title: `${data.collection.name} \u2014 ${brand.name}`,\n description: data.collection.description ?? undefined,\n };\n}\n\nexport default async function CollectionPage({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}) {\n return (\n <Suspense fallback={<CollectionSkeleton />}>\n <CollectionContent params={params} />\n </Suspense>\n );\n}\n\nasync function CollectionContent({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}) {\n const { slug } = await params;\n const data = await getCollection(slug);\n if (!data) notFound();\n\n const { collection, products } = data;\n return (\n <>\n <section className="bg-foreground text-background relative overflow-hidden">\n <div className="absolute inset-0 opacity-[0.04] pointer-events-none [background-image:radial-gradient(circle_at_2px_2px,white_1px,transparent_0)] [background-size:32px_32px]" />\n <div className="relative max-w-7xl mx-auto px-6 sm:px-8 py-12 sm:py-14">\n <nav className="text-[12px] font-mono text-background/60 mb-3 flex items-center gap-2">\n <Link href="/" className="hover:text-background transition-colors">Home</Link>\n <span>/</span>\n <span className="text-background/90">Collections</span>\n <span>/</span>\n <span className="text-background/90">{collection.name}</span>\n </nav>\n <h1 className="text-[clamp(2rem,4vw,3rem)] font-bold m-0 -tracking-[0.025em]">\n {collection.name}\n </h1>\n {collection.description && (\n <p className="mt-3 max-w-2xl text-base text-background/75">\n {collection.description}\n </p>\n )}\n <p className="mt-4 text-[11px] font-mono uppercase tracking-[0.16em] text-background/60 tabular-nums">\n {products.length} {products.length === 1 ? "product" : "products"}\n </p>\n </div>\n </section>\n <section className="max-w-7xl mx-auto px-6 sm:px-8 py-10 sm:py-12">\n <ListingClient products={products} />\n {products.length === 0 && (\n <p className="text-center mt-8">\n <Link href="/shop" className="text-primary font-semibold hover:underline">\n \u2190 Browse all products\n </Link>\n </p>\n )}\n </section>\n </>\n );\n}\n\nfunction CollectionSkeleton() {\n return (\n <>\n <section className="bg-foreground py-12 sm:py-14">\n <div className="max-w-7xl mx-auto px-6 sm:px-8">\n <div className="h-3 w-32 bg-background/20 rounded mb-3 animate-pulse" />\n <div className="h-12 w-72 bg-background/20 rounded animate-pulse" />\n </div>\n </section>\n <section className="max-w-7xl mx-auto px-6 sm:px-8 py-10 sm:py-12">\n <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3 sm:gap-4">\n {Array.from({ length: 8 }).map((_, i) => (\n <div key={i} className="aspect-[4/3] bg-muted rounded-2xl animate-pulse" />\n ))}\n </div>\n </section>\n </>\n );\n}\n' }, { "path": "package.json", "kind": "text", "content": '{\n "name": "__STOREFRONT_NAME__",\n "private": true,\n "version": "0.0.0",\n "scripts": {\n "dev": "concurrently -k -n mock,next -c blue,magenta \\"cimplify-mock --seed fashion --quiet\\" \\"next dev\\"",\n "dev:storefront": "next dev",\n "dev:mock": "cimplify-mock --seed fashion",\n "build": "next build",\n "start": "next start",\n "typecheck": "tsc --noEmit",\n "test": "vitest",\n "test:run": "vitest run",\n "check:brand": "vitest run __tests__/brand.test.ts",\n "check:cart": "vitest run __tests__/cart-flow.test.ts",\n "check:contract": "vitest run __tests__/contract.test.ts",\n "check:visual": "playwright test",\n "check:visual:update": "playwright test --update-snapshots",\n "check": "bun run typecheck && bun run test:run"\n },\n "dependencies": {\n "@cimplify/sdk": "^0.48.0",\n "next": "^16.2.4",\n "react": "^19.0.0",\n "react-dom": "^19.0.0"\n },\n "devDependencies": {\n "@playwright/test": "^1.50.0",\n "@tailwindcss/postcss": "^4.2.4",\n "@types/node": "^22.10.0",\n "@types/react": "^19.0.0",\n "@types/react-dom": "^19.0.0",\n "concurrently": "^9.0.0",\n "tailwindcss": "^4.2.4",\n "typescript": "^5.9.3",\n "vitest": "^4.1.5"\n }\n}\n' }, { "path": "lib/brand.ts", "kind": "text", "content": `/**
|
|
1014
|
+
` }, { "path": "app/terms/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Terms of Service \u2014 ${brand.name}`,\n description: `The rules of the road for ordering from ${brand.name}.`,\n};\n\nexport default function TermsPage() {\n const t = brand.terms;\n return (\n <article className="max-w-3xl mx-auto px-6 sm:px-8 py-16 prose prose-lg max-w-none">\n <p className="text-[11px] font-mono uppercase tracking-[0.16em] text-primary mb-2 not-prose">\n {t.eyebrow}\n </p>\n <h1 className="text-[clamp(2.25rem,5vw,3.5rem)] font-bold mb-2 -tracking-[0.025em]">\n {t.title}\n </h1>\n <p className="text-sm text-muted-foreground not-prose mb-10">\n Last updated: {t.lastUpdated}\n </p>\n\n <section className="space-y-5 leading-relaxed text-foreground/90">\n {t.sections.map((s) => (\n <div key={s.heading}>\n <h2 className="text-2xl font-semibold mt-0 -tracking-[0.02em]">{s.heading}</h2>\n {typeof s.body === "string" ? (\n <p>{s.body}</p>\n ) : (\n <>\n <p>{s.body.intro}</p>\n <ul className="list-disc pl-6 space-y-2">\n {s.body.bullets.map((b) => (\n <li key={b}>{b}</li>\n ))}\n </ul>\n </>\n )}\n </div>\n ))}\n </section>\n </article>\n );\n}\n' }, { "path": "app/not-found.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport Link from "next/link";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Page not found \u2014 ${brand.name}`,\n description: "We couldn\'t find that page.",\n};\n\nexport default function NotFound() {\n return (\n <section className="max-w-2xl mx-auto px-6 sm:px-8 py-20 text-center">\n <p className="text-[11px] font-mono uppercase tracking-[0.16em] text-primary mb-3">\n 404\n </p>\n <h1 className="text-[clamp(2.5rem,5vw,4rem)] font-bold mb-4 -tracking-[0.03em]">\n Page not found.\n </h1>\n <p className="text-muted-foreground leading-relaxed mb-8">\n The URL you followed might be old, a typo, or pointing at a product\n that's no longer in stock. Try the menu or head back home.\n </p>\n <div className="flex flex-wrap items-center justify-center gap-3">\n <Link\n href="/shop"\n className="inline-flex items-center gap-2 px-5 py-2.5 rounded-md bg-primary text-primary-foreground font-semibold text-sm hover:bg-primary/90 transition-colors"\n >\n Browse the shop\n </Link>\n <Link\n href="/"\n className="inline-flex items-center gap-2 px-5 py-2.5 rounded-md border border-border hover:bg-muted transition-colors text-sm font-medium"\n >\n Back home\n </Link>\n </div>\n </section>\n );\n}\n' }, { "path": "app/orders/[id]/page.tsx", "kind": "text", "content": 'import Link from "next/link";\n\nexport default async function OrderPage({ params }: { params: Promise<{ id: string }> }) {\n const { id } = await params;\n return (\n <section className="max-w-2xl mx-auto px-6 sm:px-8 py-20 text-center">\n <h1 className="text-3xl mt-0 mb-3 font-bold -tracking-[0.025em]">\n Order confirmed.\n </h1>\n <p className="text-muted-foreground">\n Order <code className="font-mono text-foreground">{id}</code> \u2014 you'll\n get an SMS with tracking within 30 minutes.\n </p>\n <p className="mt-6">\n <Link\n href="/"\n className="inline-block px-6 py-3 rounded-md bg-primary text-primary-foreground font-semibold text-sm transition-colors hover:bg-primary/90"\n >\n Continue shopping\n </Link>\n </p>\n </section>\n );\n}\n' }, { "path": "app/products/[slug]/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Suspense } from "react";\nimport { notFound } from "next/navigation";\nimport Link from "next/link";\nimport { cacheTag, cacheLife } from "next/cache";\nimport {\n getServerClient,\n tags,\n type Product,\n type ProductWithDetails,\n} from "@cimplify/sdk/server";\nimport { ProductDetail } from "./product-detail";\nimport { brand } from "@/lib/brand";\nimport { getSiteUrl } from "@/lib/site-url";\n\nfunction productLd(product: ProductWithDetails, SITE_URL: string) {\n const image = product.image_url ?? product.images?.[0];\n const inStock = product.inventory_status?.in_stock !== false;\n return {\n "@context": "https://schema.org",\n "@type": "Product",\n name: product.name,\n description: product.description ?? undefined,\n image: image ? [image] : undefined,\n sku: product.id,\n brand: { "@type": "Brand", name: brand.name },\n offers: {\n "@type": "Offer",\n price: product.default_price,\n priceCurrency: brand.currency,\n availability: inStock\n ? "https://schema.org/InStock"\n : "https://schema.org/OutOfStock",\n url: `${SITE_URL}/products/${product.slug ?? product.id}`,\n },\n };\n}\n\ninterface ProductData {\n product: ProductWithDetails;\n related: Product[];\n}\n\nasync function getProduct(slug: string): Promise<ProductData | null> {\n "use cache";\n cacheTag(tags.product(slug), tags.products());\n cacheLife("hours");\n\n const client = getServerClient();\n const r = await client.catalogue.getProductBySlug(slug);\n if (!r.ok) return null;\n\n const related = r.value.category_id\n ? await client.catalogue\n .getCategoryProducts(r.value.category_id)\n .then((res) =>\n res.ok\n ? (\n ((res.value as { items?: Product[] }).items ??\n (res.value as Product[])) as Product[]\n ).filter((p) => p.id !== r.value.id).slice(0, 4)\n : [],\n )\n : [];\n\n return { product: r.value, related };\n}\n\nexport async function generateMetadata({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}): Promise<Metadata> {\n const { slug } = await params;\n const siteUrl = await getSiteUrl();\n const data = await getProduct(slug);\n if (!data) return {};\n const { product } = data;\n const image = product.image_url ?? product.images?.[0];\n return {\n title: `${product.name} \u2014 ${brand.name}`,\n description: product.description ?? undefined,\n openGraph: {\n title: product.name,\n description: product.description ?? undefined,\n images: image ? [{ url: image }] : undefined,\n type: "website",\n },\n };\n}\n\nexport default async function ProductPage({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}) {\n return (\n <Suspense fallback={<ProductSkeleton />}>\n <ProductContent params={params} />\n </Suspense>\n );\n}\n\nasync function ProductContent({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}) {\n const { slug } = await params;\n const siteUrl = await getSiteUrl();\n const data = await getProduct(slug);\n if (!data) notFound();\n\n return (\n <>\n <script\n type="application/ld+json"\n dangerouslySetInnerHTML={{ __html: JSON.stringify(productLd(data.product, siteUrl)) }}\n />\n <nav\n aria-label="Breadcrumb"\n className="max-w-7xl mx-auto px-6 sm:px-8 pt-6 text-[12px] font-mono text-muted-foreground flex items-center gap-2"\n >\n <Link href="/" className="hover:text-foreground transition-colors">\n Home\n </Link>\n <span>/</span>\n <Link href="/shop" className="hover:text-foreground transition-colors">\n Shop\n </Link>\n {data.product.category?.slug && (\n <>\n <span>/</span>\n <Link\n href={`/categories/${data.product.category.slug}`}\n className="hover:text-foreground transition-colors"\n >\n {data.product.category.name}\n </Link>\n </>\n )}\n <span>/</span>\n <span className="text-foreground/90 truncate">{data.product.name}</span>\n </nav>\n <ProductDetail product={data.product} related={data.related} />\n </>\n );\n}\n\nfunction ProductSkeleton() {\n return (\n <section className="max-w-7xl mx-auto px-6 sm:px-8 py-10">\n <div className="grid grid-cols-1 lg:grid-cols-[1.1fr_1fr] gap-10 items-start">\n <div className="aspect-square bg-muted rounded-3xl animate-pulse" />\n <div className="space-y-4">\n <div className="h-3 w-24 bg-muted rounded animate-pulse" />\n <div className="h-10 w-3/4 bg-muted rounded animate-pulse" />\n <div className="h-7 w-32 bg-muted rounded animate-pulse" />\n <div className="h-20 w-full bg-muted rounded animate-pulse mt-6" />\n <div className="h-12 w-full bg-muted rounded animate-pulse mt-4" />\n </div>\n </div>\n </section>\n );\n}\n' }, { "path": "app/products/[slug]/product-detail.tsx", "kind": "text", "content": '"use client";\n\nimport Image from "next/image";\nimport { ProductPage as SdkProductPage, useCart } from "@cimplify/sdk/react";\nimport type { Product, ProductWithDetails } from "@cimplify/sdk";\nimport { StoreProductCard } from "@/components/store-product-card";\n\n/**\n * Client island for the product detail page.\n *\n * - Receives a server-fetched `ProductWithDetails` (no client refetch).\n * - Renders the SDK `<ProductPage>` which picks the right layout\n * (Default / Wholesale / Service / Bundle / Composite) automatically.\n * - On add-to-cart success, routes to `/cart`.\n * - Custom Next.js Image renderer for optimised, lazy-loaded gallery shots.\n * - Renders a "You may also like" rail of in-category products below.\n */\nexport function ProductDetail({\n product,\n related,\n}: {\n product: ProductWithDetails;\n related: Product[];\n}) {\n const { addItem } = useCart();\n\n return (\n <>\n <SdkProductPage\n product={product}\n showRelated={false}\n onAddToCart={async (p, qty, options) => {\n await addItem(p, qty, options);\n }}\n renderImage={({ src, alt, className }) => (\n <Image\n src={src}\n alt={alt}\n width={1200}\n height={1200}\n className={className}\n style={{ width: "100%", height: "auto", objectFit: "cover" }}\n priority\n />\n )}\n className="max-w-7xl mx-auto px-6 sm:px-8 py-8 sm:py-10"\n />\n\n {related.length > 0 && (\n <section className="max-w-7xl mx-auto px-6 sm:px-8 py-14 sm:py-16 border-t border-border mt-8">\n <div className="flex items-end justify-between gap-6 mb-8">\n <div>\n <p className="text-[11px] font-mono uppercase tracking-[0.16em] text-primary mb-2">\n You may also like\n </p>\n <h2 className="text-[clamp(1.5rem,2.5vw,2rem)] font-bold m-0 -tracking-[0.025em]">\n More from this category.\n </h2>\n </div>\n </div>\n <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3 sm:gap-4">\n {related.map((p) => (\n <StoreProductCard key={p.id} product={p} />\n ))}\n </div>\n </section>\n )}\n </>\n );\n}\n' }, { "path": "app/login/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { redirect } from "next/navigation";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Sign in \u2014 ${brand.name}`,\n description: brand.account.loginSubtitle,\n};\n\n/**\n * Cimplify Link (the iframe in `<CimplifyAccount>`) handles sign-in\n * automatically when no session exists. We just bounce /login to /account\n * so consumers landing here get the right UI without a duplicate form.\n */\nexport default function LoginPage(): never {\n redirect("/account");\n}\n' }, { "path": "app/accessibility/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\nimport { PolicyPage } from "@/components/policy-page";\n\nexport const metadata: Metadata = {\n title: `${brand.accessibility.title} \u2014 ${brand.name}`,\n};\n\nexport default function AccessibilityPage() {\n return <PolicyPage policy={brand.accessibility} />;\n}\n' }, { "path": "app/shipping/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\nimport { PolicyPage } from "@/components/policy-page";\n\nexport const metadata: Metadata = {\n title: `${brand.shipping.title} \u2014 ${brand.name}`,\n description: brand.shipping.sections[0]?.body\n ? typeof brand.shipping.sections[0].body === "string"\n ? brand.shipping.sections[0].body\n : brand.shipping.sections[0].body.intro\n : undefined,\n};\n\nexport default function ShippingPage() {\n return <PolicyPage policy={brand.shipping} />;\n}\n' }, { "path": "app/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Suspense } from "react";\nimport { cacheTag, cacheLife } from "next/cache";\nimport { getServerClient, tags, type Collection, type Product } from "@cimplify/sdk/server";\nimport { FeatureHero } from "@/components/feature-hero";\nimport { CategoryTiles } from "@/components/category-tiles";\nimport { CollectionStrip } from "@/components/collection-strip";\nimport { PromoBanner } from "@/components/promo-banner";\nimport { TrustBar } from "@/components/trust-bar";\nimport { BrandMarquee } from "@/components/brand-marquee";\nimport { TradeInCta } from "@/components/trade-in-cta";\nimport { Newsletter } from "@/components/newsletter";\nimport { SectionHeading } from "@/components/section-heading";\nimport { StoreProductCard } from "@/components/store-product-card";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: brand.hero.title,\n description: brand.description,\n};\n\ninterface CollectionWithProducts {\n collection: Collection;\n products: Product[];\n}\n\nasync function getHomeData() {\n "use cache";\n cacheTag(tags.collections(), tags.categories(), tags.products());\n cacheLife("hours");\n\n const client = getServerClient();\n const [colRes, catRes, productsRes] = await Promise.all([\n client.catalogue.getCollections(),\n client.catalogue.getCategories(),\n client.catalogue.getProducts({ limit: 12 }),\n ]);\n const collections = colRes.ok ? colRes.value : [];\n const categories = catRes.ok ? catRes.value : [];\n const allProducts = productsRes.ok ? productsRes.value.items : [];\n\n const collectionsWithProducts: CollectionWithProducts[] = await Promise.all(\n collections.map(async (col) => {\n const r = await client.catalogue.getCollectionProducts(col.id);\n const items = r.ok\n ? ((r.value as { items?: Product[] }).items ?? (r.value as Product[]))\n : [];\n return { collection: col, products: items };\n }),\n );\n\n return {\n collections: collectionsWithProducts.filter((x) => x.products.length > 0),\n categories,\n featured: allProducts.slice(0, 4),\n newArrivals: allProducts.slice(4, 12),\n };\n}\n\n// Verified-reachable Unsplash fashion editorial \u2014 replace with your own asset.\nconst HERO_FALLBACK_IMAGE =\n "https://images.unsplash.com/photo-1490481651871-ab68de25d43d?w=2000&h=1400&fit=crop&auto=format&q=85";\n\nexport default async function HomePage() {\n const { collections, categories, featured, newArrivals } = await getHomeData();\n const heroProduct = featured[0];\n\n return (\n <>\n <FeatureHero\n eyebrow="Featured \xB7 in stock"\n badge="Just landed"\n title={brand.hero.title}\n description={heroProduct?.description ?? brand.hero.subtitle}\n primaryCta={{\n label: heroProduct ? `Shop ${heroProduct.name}` : brand.hero.primaryCtaLabel,\n href: heroProduct ? `/products/${heroProduct.slug ?? heroProduct.id}` : "/shop",\n }}\n secondaryCta={\n brand.hero.secondaryCtaLabel && brand.hero.secondaryCtaHref\n ? { label: brand.hero.secondaryCtaLabel, href: brand.hero.secondaryCtaHref }\n : undefined\n }\n imageUrl={heroProduct?.image_url ?? heroProduct?.images?.[0] ?? HERO_FALLBACK_IMAGE}\n imageAlt={heroProduct?.name ?? "Featured product"}\n />\n\n <TrustBar />\n\n <Suspense fallback={<CategoryTilesSkeleton />}>\n <CategoryTiles categories={categories} />\n </Suspense>\n\n <PromoBanner />\n\n <section className="max-w-7xl mx-auto px-6 sm:px-8 py-14 sm:py-20">\n <SectionHeading\n eyebrow="Just dropped"\n title="New this week."\n description="Hand-picked launches and freshly restocked staples."\n link={{ label: "Browse all", href: "/shop" }}\n />\n <Suspense fallback={<GridSkeleton count={4} />}>\n <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3 sm:gap-4">\n {newArrivals.slice(0, 4).map((p) => (\n <StoreProductCard key={p.id} product={p} />\n ))}\n </div>\n </Suspense>\n </section>\n\n <BrandMarquee />\n\n {collections.map(({ collection, products }) => (\n <Suspense\n key={collection.id}\n fallback={<StripSkeleton title={collection.name} />}\n >\n <CollectionStrip\n collection={collection}\n products={products}\n collectionHref={`/collections/${collection.slug}`}\n />\n </Suspense>\n ))}\n\n <TradeInCta />\n\n <section className="max-w-7xl mx-auto px-6 sm:px-8 py-14 sm:py-20">\n <SectionHeading\n eyebrow="Best sellers"\n title="What everyone's buying."\n link={{ label: "See more", href: "/shop" }}\n />\n <Suspense fallback={<GridSkeleton count={4} />}>\n <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3 sm:gap-4">\n {newArrivals.slice(4, 8).map((p) => (\n <StoreProductCard key={p.id} product={p} />\n ))}\n </div>\n </Suspense>\n </section>\n\n <Newsletter />\n </>\n );\n}\n\nfunction CategoryTilesSkeleton() {\n return (\n <section className="max-w-7xl mx-auto px-6 sm:px-8 py-14 sm:py-20">\n <div className="h-8 w-64 bg-muted rounded mb-8 animate-pulse" />\n <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3 sm:gap-4">\n {Array.from({ length: 5 }).map((_, i) => (\n <div key={i} className="h-40 bg-muted rounded-2xl animate-pulse" />\n ))}\n </div>\n </section>\n );\n}\n\nfunction GridSkeleton({ count }: { count: number }) {\n return (\n <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3 sm:gap-4">\n {Array.from({ length: count }).map((_, i) => (\n <div key={i} className="aspect-[4/3] bg-muted rounded-2xl animate-pulse" />\n ))}\n </div>\n );\n}\n\nfunction StripSkeleton({ title }: { title: string }) {\n return (\n <section className="max-w-7xl mx-auto px-6 sm:px-8 py-12">\n <h2 className="text-[26px] font-semibold m-0 mb-5 -tracking-[0.02em]">{title}</h2>\n <div className="grid grid-flow-col auto-cols-[minmax(220px,1fr)] gap-4 overflow-x-auto pb-2">\n {Array.from({ length: 5 }).map((_, i) => (\n <div key={i} className="aspect-square bg-muted rounded-2xl animate-pulse" />\n ))}\n </div>\n </section>\n );\n}\n' }, { "path": "app/categories/[slug]/listing-client.tsx", "kind": "text", "content": '"use client";\n\nimport { ProductGrid } from "@cimplify/sdk/react";\nimport type { Product } from "@cimplify/sdk";\nimport { StoreProductCard } from "@/components/store-product-card";\n\n/**\n * Client island for the category listing. Receives server-fetched products\n * as props (serializable) and owns the `renderCard` function.\n */\nexport function ListingClient({ products }: { products: Product[] }) {\n return (\n <ProductGrid\n products={products}\n emptyMessage="No products in this category yet."\n renderCard={(p) => <StoreProductCard product={p} />}\n />\n );\n}\n' }, { "path": "app/categories/[slug]/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Suspense } from "react";\nimport Link from "next/link";\nimport { notFound } from "next/navigation";\nimport { cacheTag, cacheLife } from "next/cache";\nimport {\n getServerClient,\n tags,\n type Category,\n type Product,\n} from "@cimplify/sdk/server";\nimport { ListingClient } from "./listing-client";\nimport { brand } from "@/lib/brand";\n\ninterface CategoryData {\n category: Category;\n products: Product[];\n}\n\nasync function getCategory(slug: string): Promise<CategoryData | null> {\n "use cache";\n cacheTag(tags.categories());\n cacheLife("hours");\n\n const client = getServerClient();\n const catRes = await client.catalogue.getCategoryBySlug(slug);\n if (!catRes.ok) return null;\n\n cacheTag(tags.category(catRes.value.id), tags.categoryProducts(catRes.value.id));\n const r = await client.catalogue.getCategoryProducts(catRes.value.id);\n const products = r.ok\n ? ((r.value as { items?: Product[] }).items ?? (r.value as Product[]))\n : [];\n return { category: catRes.value, products };\n}\n\nexport async function generateMetadata({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}): Promise<Metadata> {\n const { slug } = await params;\n const data = await getCategory(slug);\n if (!data) return {};\n return {\n title: `${data.category.name} \u2014 ${brand.name}`,\n description: data.category.description ?? undefined,\n };\n}\n\nexport default async function CategoryPage({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}) {\n return (\n <Suspense fallback={<CategorySkeleton />}>\n <CategoryContent params={params} />\n </Suspense>\n );\n}\n\nasync function CategoryContent({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}) {\n const { slug } = await params;\n const data = await getCategory(slug);\n if (!data) notFound();\n\n const { category, products } = data;\n return (\n <>\n <section className="bg-foreground text-background relative overflow-hidden">\n <div className="absolute inset-0 opacity-[0.04] pointer-events-none [background-image:radial-gradient(circle_at_2px_2px,white_1px,transparent_0)] [background-size:32px_32px]" />\n <div className="relative max-w-7xl mx-auto px-6 sm:px-8 py-12 sm:py-14">\n <nav className="text-[12px] font-mono text-background/60 mb-3 flex items-center gap-2">\n <Link href="/" className="hover:text-background transition-colors">Home</Link>\n <span>/</span>\n <Link href="/shop" className="hover:text-background transition-colors">Shop</Link>\n <span>/</span>\n <span className="text-background/90">{category.name}</span>\n </nav>\n <h1 className="text-[clamp(2rem,4vw,3rem)] font-bold m-0 -tracking-[0.025em]">\n {category.name}\n </h1>\n {category.description && (\n <p className="mt-3 max-w-2xl text-base text-background/75">\n {category.description}\n </p>\n )}\n <p className="mt-4 text-[11px] font-mono uppercase tracking-[0.16em] text-background/60 tabular-nums">\n {products.length} {products.length === 1 ? "product" : "products"}\n </p>\n </div>\n </section>\n <section className="max-w-7xl mx-auto px-6 sm:px-8 py-10 sm:py-12">\n <ListingClient products={products} />\n {products.length === 0 && (\n <p className="text-center mt-8">\n <Link href="/shop" className="text-primary font-semibold hover:underline">\n \u2190 Browse all products\n </Link>\n </p>\n )}\n </section>\n </>\n );\n}\n\nfunction CategorySkeleton() {\n return (\n <>\n <section className="bg-foreground py-12 sm:py-14">\n <div className="max-w-7xl mx-auto px-6 sm:px-8">\n <div className="h-3 w-32 bg-background/20 rounded mb-3 animate-pulse" />\n <div className="h-12 w-72 bg-background/20 rounded animate-pulse" />\n </div>\n </section>\n <section className="max-w-7xl mx-auto px-6 sm:px-8 py-10 sm:py-12">\n <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3 sm:gap-4">\n {Array.from({ length: 8 }).map((_, i) => (\n <div key={i} className="aspect-[4/3] bg-muted rounded-2xl animate-pulse" />\n ))}\n </div>\n </section>\n </>\n );\n}\n' }, { "path": "app/error.tsx", "kind": "text", "content": '"use client";\n\nimport { useEffect } from "react";\nimport Link from "next/link";\n\n/**\n * Root error boundary. Next.js calls this whenever a thrown error escapes\n * a Server Component without being caught by a closer `error.tsx`.\n *\n * Wire `reportError` (Sentry, Datadog, etc.) here so production failures\n * surface \u2014 silently recovering hides real bugs.\n */\nexport default function GlobalError({\n error,\n reset,\n}: {\n error: Error & { digest?: string };\n reset: () => void;\n}) {\n useEffect(() => {\n // TODO: replace with your error reporter\n // eslint-disable-next-line no-console\n console.error("Storefront error:", error);\n }, [error]);\n\n return (\n <section className="max-w-2xl mx-auto px-6 sm:px-8 py-20 text-center">\n <p className="text-[11px] font-mono uppercase tracking-[0.16em] text-primary mb-3">\n Something broke\n </p>\n <h1 className="text-[clamp(2rem,4vw,3rem)] font-bold mb-4 -tracking-[0.025em]">\n That wasn't supposed to happen.\n </h1>\n <p className="text-muted-foreground leading-relaxed mb-8">\n We've been notified. Refresh the page, or head back home \u2014 most\n of the time the second try just works.\n </p>\n {error.digest && (\n <p className="text-xs font-mono text-muted-foreground mb-6">\n Reference:{" "}\n <code className="text-foreground">{error.digest}</code>\n </p>\n )}\n <div className="flex flex-wrap items-center justify-center gap-3">\n <button\n type="button"\n onClick={reset}\n className="inline-flex items-center gap-2 px-5 py-2.5 rounded-md bg-primary text-primary-foreground font-semibold text-sm hover:bg-primary/90 transition-colors"\n >\n Try again\n </button>\n <Link\n href="/"\n className="inline-flex items-center gap-2 px-5 py-2.5 rounded-md border border-border hover:bg-muted transition-colors text-sm font-medium"\n >\n Back home\n </Link>\n </div>\n </section>\n );\n}\n' }, { "path": "app/globals.css", "kind": "text", "content": '@import "tailwindcss";\n\n@source "../node_modules/@cimplify/sdk/dist";\n\n/* Studio FRX \u2014 Nike/Aritzia-style streetwear palette. High-contrast\n neutrals with one electric accent. Tweak primary to retheme everything. */\n@theme {\n --color-background: oklch(0.99 0 0);\n --color-foreground: oklch(0.12 0 0);\n --color-card: oklch(1 0 0);\n --color-card-foreground: oklch(0.12 0 0);\n --color-popover: oklch(1 0 0);\n --color-popover-foreground: oklch(0.12 0 0);\n --color-primary: oklch(0.7 0.24 30);\n --color-primary-foreground: oklch(0.99 0 0);\n --color-secondary: oklch(0.96 0 0);\n --color-secondary-foreground: oklch(0.18 0 0);\n --color-muted: oklch(0.96 0 0);\n --color-muted-foreground: oklch(0.45 0 0);\n --color-accent: oklch(0.92 0 0);\n --color-accent-foreground: oklch(0.18 0 0);\n --color-destructive: oklch(0.58 0.24 27);\n --color-destructive-foreground: oklch(0.99 0 0);\n --color-border: oklch(0.9 0 0);\n --color-input: oklch(0.93 0 0);\n --color-ring: oklch(0.7 0.24 30);\n --radius: 0.125rem;\n}\n\n@layer base {\n html, body {\n margin: 0;\n padding: 0;\n background: var(--color-background);\n color: var(--color-foreground);\n -webkit-font-smoothing: antialiased;\n -moz-osx-font-smoothing: grayscale;\n }\n a { color: inherit; text-decoration: none; }\n h1, h2, h3, h4 {\n font-family: var(--font-display);\n letter-spacing: -0.04em;\n font-weight: 800;\n line-height: 0.95;\n }\n}\n' }, { "path": "app/sitemap-page/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport Link from "next/link";\nimport { cacheTag, cacheLife } from "next/cache";\nimport { getServerClient, tags, type Product } from "@cimplify/sdk/server";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Sitemap \u2014 ${brand.name}`,\n description: "A human-readable index of every page on this site.",\n};\n\ninterface SitemapData {\n products: { slug: string; name: string }[];\n categories: { slug: string; name: string }[];\n collections: { slug: string; name: string }[];\n}\n\nasync function getSitemap(): Promise<SitemapData> {\n "use cache";\n cacheTag(tags.products(), tags.categories(), tags.collections());\n cacheLife("hours");\n\n const client = getServerClient();\n const [pRes, cRes, colRes] = await Promise.all([\n client.catalogue.getProducts({ limit: 500 }),\n client.catalogue.getCategories(),\n client.catalogue.getCollections(),\n ]);\n return {\n products: (pRes.ok ? pRes.value.items : []).map((p: Product) => ({\n slug: p.slug ?? p.id,\n name: p.name,\n })),\n categories: (cRes.ok ? cRes.value : []).map((c) => ({ slug: c.slug, name: c.name })),\n collections: (colRes.ok ? colRes.value : []).map((c) => ({ slug: c.slug, name: c.name })),\n };\n}\n\nconst STATIC_LINKS: { title: string; links: { href: string; label: string }[] }[] = [\n {\n title: "Browse",\n links: [\n { href: "/", label: "Home" },\n { href: "/shop", label: "Shop" },\n { href: "/search", label: "Search" },\n ],\n },\n {\n title: "Account",\n links: [\n { href: "/account", label: "Account" },\n { href: "/account/orders", label: "Orders" },\n { href: "/account/addresses", label: "Addresses" },\n { href: "/account/settings", label: "Settings" },\n { href: "/login", label: "Sign in" },\n { href: "/signup", label: "Create account" },\n { href: "/track-order", label: "Track an order" },\n { href: "/cart", label: "Cart" },\n { href: "/checkout", label: "Checkout" },\n ],\n },\n {\n title: "About",\n links: [\n { href: "/about", label: "About" },\n { href: "/faq", label: "FAQ" },\n { href: "/contact", label: "Contact" },\n ],\n },\n {\n title: "Policies",\n links: [\n { href: "/shipping", label: "Shipping" },\n { href: "/returns", label: "Returns" },\n { href: "/accessibility", label: "Accessibility" },\n { href: "/terms", label: "Terms of Service" },\n { href: "/privacy", label: "Privacy Policy" },\n ],\n },\n {\n title: "Machine-readable",\n links: [\n { href: "/sitemap.xml", label: "sitemap.xml (search engines)" },\n { href: "/llms.txt", label: "llms.txt (LLM agents)" },\n { href: "/robots.txt", label: "robots.txt" },\n { href: "/opensearch.xml", label: "opensearch.xml (browser search)" },\n ],\n },\n];\n\nexport default async function SitemapHtmlPage() {\n const { products, categories, collections } = await getSitemap();\n\n return (\n <article className="max-w-5xl mx-auto px-6 sm:px-8 py-16">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-2">\n Sitemap\n </p>\n <h1 className="text-[clamp(2rem,4vw,2.75rem)] font-semibold mb-2 -tracking-[0.02em]">\n Every page, in one place.\n </h1>\n <p className="text-muted-foreground mb-12">\n For search engines, see <Link href="/sitemap.xml" className="text-primary hover:underline">/sitemap.xml</Link>.\n For LLM agents, see <Link href="/llms.txt" className="text-primary hover:underline">/llms.txt</Link>.\n </p>\n <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-10">\n {STATIC_LINKS.map((s) => (\n <Section key={s.title} title={s.title}>\n {s.links.map((l) => (\n <li key={l.href}>\n <Link href={l.href} className="hover:text-primary transition-colors">\n {l.label}\n </Link>\n </li>\n ))}\n </Section>\n ))}\n {categories.length > 0 && (\n <Section title="Categories">\n {categories.map((c) => (\n <li key={c.slug}>\n <Link href={`/categories/${c.slug}`} className="hover:text-primary transition-colors">\n {c.name}\n </Link>\n </li>\n ))}\n </Section>\n )}\n {collections.length > 0 && (\n <Section title="Collections">\n {collections.map((c) => (\n <li key={c.slug}>\n <Link href={`/collections/${c.slug}`} className="hover:text-primary transition-colors">\n {c.name}\n </Link>\n </li>\n ))}\n </Section>\n )}\n {products.length > 0 && (\n <Section title={`Products (${products.length})`}>\n {products.map((p) => (\n <li key={p.slug}>\n <Link href={`/shop?product=${encodeURIComponent(p.slug)}`} className="hover:text-primary transition-colors">\n {p.name}\n </Link>\n </li>\n ))}\n </Section>\n )}\n </div>\n </article>\n );\n}\n\nfunction Section({ title, children }: { title: string; children: React.ReactNode }) {\n return (\n <div>\n <p className="font-semibold text-[12px] uppercase tracking-[0.12em] text-foreground mb-3">\n {title}\n </p>\n <ul className="space-y-2 m-0 p-0 list-none text-sm text-muted-foreground">\n {children}\n </ul>\n </div>\n );\n}\n' }, { "path": "app/shop/shop-client.tsx", "kind": "text", "content": '"use client";\n\nimport { CataloguePage } from "@cimplify/sdk/react";\nimport type { Category, Product } from "@cimplify/sdk";\nimport { StoreProductCard } from "@/components/store-product-card";\n\n/**\n * Client island for the shop page. Server-side fetches all products and\n * categories (cached via `\'use cache\'` in `app/shop/page.tsx`), then hands\n * them to `<CataloguePage>` which owns the interactive filter / sort state.\n *\n * The page hero (in `app/shop/page.tsx`) already provides the title; we\n * pass an empty `title` and override the SDK heading via className so the\n * page reads as one continuous design.\n */\nexport function ShopClient({\n products,\n categories,\n}: {\n products: Product[];\n categories: Category[];\n}) {\n return (\n <CataloguePage\n title="All products"\n products={products}\n categories={categories}\n renderCard={(p) => <StoreProductCard product={p} />}\n className="max-w-7xl mx-auto px-6 sm:px-8 py-10 sm:py-12"\n />\n );\n}\n' }, { "path": "app/shop/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Suspense } from "react";\nimport { cacheTag, cacheLife } from "next/cache";\nimport { getServerClient, tags } from "@cimplify/sdk/server";\nimport { ShopClient } from "./shop-client";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Shop \u2014 ${brand.name}`,\n description: brand.description,\n};\n\nasync function getShopData() {\n "use cache";\n cacheTag(tags.products(), tags.categories());\n cacheLife("hours");\n\n const client = getServerClient();\n const [p, c] = await Promise.all([\n client.catalogue.getProducts({ limit: 50 }),\n client.catalogue.getCategories(),\n ]);\n return {\n products: p.ok ? p.value.items : [],\n categories: c.ok ? c.value : [],\n };\n}\n\nexport default async function ShopPage() {\n const { products, categories } = await getShopData();\n return (\n <>\n <section className="bg-foreground text-background relative overflow-hidden">\n <div className="absolute inset-0 opacity-[0.04] pointer-events-none [background-image:radial-gradient(circle_at_2px_2px,white_1px,transparent_0)] [background-size:32px_32px]" />\n <div className="relative max-w-7xl mx-auto px-6 sm:px-8 py-12 sm:py-14">\n <p className="text-[11px] font-mono uppercase tracking-[0.2em] text-background/60 mb-2">\n Catalogue \xB7 {products.length} products\n </p>\n <h1 className="text-[clamp(2rem,4vw,3rem)] font-bold m-0 -tracking-[0.025em] leading-[1.05]">\n Every product we stock.\n </h1>\n <p className="mt-3 max-w-xl text-base text-background/75">\n Filter, sort, search. Authorised dealer pricing, two-year warranty,\n same-day Accra delivery on every order.\n </p>\n </div>\n </section>\n <Suspense\n fallback={\n <div className="max-w-7xl mx-auto px-6 sm:px-8 py-10">\n <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3 sm:gap-4">\n {Array.from({ length: 8 }).map((_, i) => (\n <div key={i} className="aspect-[4/3] bg-muted rounded-2xl animate-pulse" />\n ))}\n </div>\n </div>\n }\n >\n <ShopClient products={products} categories={categories} />\n </Suspense>\n </>\n );\n}\n' }, { "path": "app/returns/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\nimport { PolicyPage } from "@/components/policy-page";\n\nexport const metadata: Metadata = {\n title: `${brand.returns.title} \u2014 ${brand.name}`,\n};\n\nexport default function ReturnsPage() {\n return <PolicyPage policy={brand.returns} />;\n}\n' }, { "path": "app/layout.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Inter, Anton } from "next/font/google";\nimport "./globals.css";\nimport { Providers } from "@/components/providers";\nimport { Header } from "@/components/header";\nimport { Footer } from "@/components/footer";\nimport { ProductModal } from "@/components/product-modal";\nimport { CartDrawer } from "@/components/cart-drawer";\nimport { OrganizationJsonLd } from "@/components/json-ld";\nimport { Suspense } from "react";\nimport { brand } from "@/lib/brand";\nimport { getSiteUrl } from "@/lib/site-url";\n\nconst inter = Inter({\n subsets: ["latin"],\n variable: "--font-sans",\n display: "swap",\n});\n\nconst anton = Anton({\n subsets: ["latin"],\n variable: "--font-display",\n weight: "400",\n display: "swap",\n});\n\nexport async function generateMetadata(): Promise<Metadata> {\n const siteUrl = await getSiteUrl();\n return {\n metadataBase: new URL(siteUrl),\n title: {\n default: brand.name,\n template: `%s \u2014 ${brand.name}`,\n },\n description: brand.description,\n openGraph: {\n type: "website",\n siteName: brand.name,\n locale: brand.locale,\n },\n twitter: { card: "summary_large_image" },\n };\n}\n\nexport default function RootLayout({ children }: { children: React.ReactNode }) {\n return (\n <html lang="en" suppressHydrationWarning className={`${inter.variable} ${anton.variable}`}>\n <body\n suppressHydrationWarning\n className="min-h-screen flex flex-col bg-background text-foreground font-sans"\n >\n <Suspense fallback={null}>\n <OrganizationJsonLd />\n </Suspense>\n <Providers>\n <Header />\n <main className="flex-1 pb-12 w-full">{children}</main>\n <Footer />\n <Suspense fallback={null}>\n <ProductModal />\n </Suspense>\n <CartDrawer />\n </Providers>\n </body>\n </html>\n );\n}\n' }, { "path": "app/checkout/page.tsx", "kind": "text", "content": '"use client";\n\nimport { useRouter } from "next/navigation";\nimport { CheckoutPage as SdkCheckoutPage } from "@cimplify/sdk/react";\n\nexport default function CheckoutPage() {\n const router = useRouter();\n return (\n <SdkCheckoutPage\n onComplete={(result) => {\n if (result.success && result.order) {\n router.push(`/orders/${result.order.id}`);\n }\n }}\n />\n );\n}\n' }, { "path": "app/privacy/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Privacy Policy \u2014 ${brand.name}`,\n description: `How ${brand.name} collects, uses, and protects your personal data.`,\n};\n\nexport default function PrivacyPage() {\n const p = brand.privacy;\n return (\n <article className="max-w-3xl mx-auto px-6 sm:px-8 py-16 prose prose-lg max-w-none">\n <p className="text-[11px] font-mono uppercase tracking-[0.16em] text-primary mb-2 not-prose">\n {p.eyebrow}\n </p>\n <h1 className="text-[clamp(2.25rem,5vw,3.5rem)] font-bold mb-2 -tracking-[0.025em]">\n {p.title}\n </h1>\n <p className="text-sm text-muted-foreground not-prose mb-10">\n Last updated: {p.lastUpdated}\n </p>\n\n <section className="space-y-5 leading-relaxed text-foreground/90">\n {p.sections.map((s) => (\n <div key={s.heading}>\n <h2 className="text-2xl font-semibold mt-0 -tracking-[0.02em]">{s.heading}</h2>\n {typeof s.body === "string" ? (\n <p>{s.body}</p>\n ) : (\n <>\n <p>{s.body.intro}</p>\n <ul className="list-disc pl-6 space-y-2">\n {s.body.bullets.map((b) => (\n <li key={b}>{b}</li>\n ))}\n </ul>\n </>\n )}\n </div>\n ))}\n </section>\n </article>\n );\n}\n' }, { "path": "app/size-guide/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Size Guide \u2014 ${brand.name}`,\n description: "Chest, length, and shoulder measurements for every Studio FRX piece.",\n};\n\ninterface SizeRow {\n size: string;\n chest: string;\n length: string;\n shoulder: string;\n}\n\nconst TOPS_CHART: SizeRow[] = [\n { size: "XS", chest: "104 cm / 41 in", length: "68 cm / 26.8 in", shoulder: "50 cm / 19.7 in" },\n { size: "S", chest: "110 cm / 43.3 in", length: "70 cm / 27.5 in", shoulder: "52 cm / 20.5 in" },\n { size: "M", chest: "116 cm / 45.7 in", length: "72 cm / 28.3 in", shoulder: "54 cm / 21.3 in" },\n { size: "L", chest: "122 cm / 48 in", length: "74 cm / 29.1 in", shoulder: "56 cm / 22 in" },\n { size: "XL", chest: "128 cm / 50.4 in", length: "76 cm / 29.9 in", shoulder: "58 cm / 22.8 in" },\n { size: "2XL", chest: "134 cm / 52.7 in", length: "78 cm / 30.7 in", shoulder: "60 cm / 23.6 in" },\n { size: "3XL", chest: "140 cm / 55.1 in", length: "80 cm / 31.5 in", shoulder: "62 cm / 24.4 in" },\n];\n\nconst BOTTOMS_CHART: { size: string; waist: string; hip: string; inseam: string }[] = [\n { size: "XS", waist: "72 cm / 28 in", hip: "92 cm / 36 in", inseam: "78 cm / 30.7 in" },\n { size: "S", waist: "76 cm / 30 in", hip: "96 cm / 38 in", inseam: "78 cm / 30.7 in" },\n { size: "M", waist: "82 cm / 32 in", hip: "102 cm / 40 in", inseam: "80 cm / 31.5 in" },\n { size: "L", waist: "88 cm / 35 in", hip: "108 cm / 42.5 in", inseam: "80 cm / 31.5 in" },\n { size: "XL", waist: "94 cm / 37 in", hip: "114 cm / 45 in", inseam: "82 cm / 32.3 in" },\n { size: "2XL", waist: "102 cm / 40 in", hip: "122 cm / 48 in", inseam: "82 cm / 32.3 in" },\n];\n\nexport default function SizeGuidePage() {\n return (\n <article className="max-w-4xl mx-auto px-6 sm:px-8 py-16">\n <p className="text-[11px] font-mono uppercase tracking-[0.2em] text-primary mb-2">\n Size guide\n </p>\n <h1 className="font-display text-[clamp(2.5rem,7vw,5rem)] uppercase mb-4 -tracking-[0.04em] leading-[0.95]">\n Find your fit.\n </h1>\n <p className="text-base text-muted-foreground leading-relaxed max-w-2xl mb-12">\n Our pieces are cut for an oversized, dropped-shoulder fit. Order true to size for\n the intended fit; size down for a regular cut. All measurements are flat-laid\n garment measurements.\n </p>\n\n <section className="mb-14">\n <h2 className="text-2xl font-bold mb-2 -tracking-[0.025em]">Tops & outerwear</h2>\n <p className="text-sm text-muted-foreground mb-4">\n Hoodies, tees, sweats, jackets. Chest measured pit-to-pit, doubled. Length\n measured from highest shoulder seam to hem.\n </p>\n <SizeTable\n headings={["Size", "Chest", "Length", "Shoulder"]}\n rows={TOPS_CHART.map((r) => [r.size, r.chest, r.length, r.shoulder])}\n />\n </section>\n\n <section className="mb-14">\n <h2 className="text-2xl font-bold mb-2 -tracking-[0.025em]">Bottoms</h2>\n <p className="text-sm text-muted-foreground mb-4">\n Trousers, shorts, sweats. Waist measured flat across the top of the\n waistband, doubled.\n </p>\n <SizeTable\n headings={["Size", "Waist", "Hip", "Inseam"]}\n rows={BOTTOMS_CHART.map((r) => [r.size, r.waist, r.hip, r.inseam])}\n />\n </section>\n\n <section className="rounded-2xl bg-foreground text-background p-8">\n <h2 className="text-xl font-bold mb-3 -tracking-[0.025em]">How to measure yourself</h2>\n <ul className="space-y-3 text-background/85 leading-relaxed">\n <li>\n <strong className="text-background">Chest</strong> \u2014 Wrap a soft tape around\n the fullest part of your chest, under the arms, keeping the tape level.\n </li>\n <li>\n <strong className="text-background">Waist</strong> \u2014 Around the narrowest part\n of your waist, just above the navel.\n </li>\n <li>\n <strong className="text-background">Hip</strong> \u2014 Around the fullest part of\n your hips, feet together.\n </li>\n <li>\n <strong className="text-background">Inseam</strong> \u2014 From the very top of the\n inside of your leg down to the floor (no shoes).\n </li>\n </ul>\n </section>\n\n <p className="mt-12 text-sm text-muted-foreground">\n Still unsure?{" "}\n <a\n href={`mailto:${brand.faq.contactEmail}`}\n className="text-primary font-semibold hover:underline"\n >\n Email us your usual size in another brand\n </a>{" "}\n and we\'ll recommend a fit.\n </p>\n </article>\n );\n}\n\nfunction SizeTable({\n headings,\n rows,\n}: {\n headings: string[];\n rows: string[][];\n}) {\n return (\n <div className="overflow-x-auto rounded-2xl border border-border">\n <table className="w-full text-sm">\n <thead className="bg-muted">\n <tr>\n {headings.map((h) => (\n <th\n key={h}\n className="text-left font-semibold uppercase tracking-wider text-[11px] text-muted-foreground px-4 py-3"\n >\n {h}\n </th>\n ))}\n </tr>\n </thead>\n <tbody>\n {rows.map((r, i) => (\n <tr\n key={r[0]}\n className={i % 2 === 0 ? "bg-background" : "bg-muted/30"}\n >\n {r.map((cell, j) => (\n <td\n key={j}\n className={[\n "px-4 py-3 tabular-nums",\n j === 0 ? "font-semibold text-foreground" : "text-foreground/80",\n ].join(" ")}\n >\n {cell}\n </td>\n ))}\n </tr>\n ))}\n </tbody>\n </table>\n </div>\n );\n}\n' }, { "path": "app/collections/[slug]/listing-client.tsx", "kind": "text", "content": '"use client";\n\nimport { ProductGrid } from "@cimplify/sdk/react";\nimport type { Product } from "@cimplify/sdk";\nimport { StoreProductCard } from "@/components/store-product-card";\n\n/**\n * Client island for the collection listing. Receives server-fetched\n * products as props (serializable) and owns the `renderCard` function\n * (which can\'t cross the server/client boundary).\n */\nexport function ListingClient({ products }: { products: Product[] }) {\n return (\n <ProductGrid\n products={products}\n emptyMessage="No products in this collection yet."\n renderCard={(p) => <StoreProductCard product={p} />}\n />\n );\n}\n' }, { "path": "app/collections/[slug]/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Suspense } from "react";\nimport Link from "next/link";\nimport { notFound } from "next/navigation";\nimport { cacheTag, cacheLife } from "next/cache";\nimport {\n getServerClient,\n tags,\n type Collection,\n type Product,\n} from "@cimplify/sdk/server";\nimport { ListingClient } from "./listing-client";\nimport { brand } from "@/lib/brand";\n\ninterface CollectionData {\n collection: Collection;\n products: Product[];\n}\n\nasync function getCollection(slug: string): Promise<CollectionData | null> {\n "use cache";\n cacheTag(tags.collections());\n cacheLife("hours");\n\n const client = getServerClient();\n const colRes = await client.catalogue.getCollectionBySlug(slug);\n if (!colRes.ok) return null;\n\n cacheTag(tags.collection(colRes.value.id), tags.collectionProducts(colRes.value.id));\n const r = await client.catalogue.getCollectionProducts(colRes.value.id);\n const products = r.ok\n ? ((r.value as { items?: Product[] }).items ?? (r.value as Product[]))\n : [];\n return { collection: colRes.value, products };\n}\n\nexport async function generateMetadata({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}): Promise<Metadata> {\n const { slug } = await params;\n const data = await getCollection(slug);\n if (!data) return {};\n return {\n title: `${data.collection.name} \u2014 ${brand.name}`,\n description: data.collection.description ?? undefined,\n };\n}\n\nexport default async function CollectionPage({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}) {\n return (\n <Suspense fallback={<CollectionSkeleton />}>\n <CollectionContent params={params} />\n </Suspense>\n );\n}\n\nasync function CollectionContent({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}) {\n const { slug } = await params;\n const data = await getCollection(slug);\n if (!data) notFound();\n\n const { collection, products } = data;\n return (\n <>\n <section className="bg-foreground text-background relative overflow-hidden">\n <div className="absolute inset-0 opacity-[0.04] pointer-events-none [background-image:radial-gradient(circle_at_2px_2px,white_1px,transparent_0)] [background-size:32px_32px]" />\n <div className="relative max-w-7xl mx-auto px-6 sm:px-8 py-12 sm:py-14">\n <nav className="text-[12px] font-mono text-background/60 mb-3 flex items-center gap-2">\n <Link href="/" className="hover:text-background transition-colors">Home</Link>\n <span>/</span>\n <span className="text-background/90">Collections</span>\n <span>/</span>\n <span className="text-background/90">{collection.name}</span>\n </nav>\n <h1 className="text-[clamp(2rem,4vw,3rem)] font-bold m-0 -tracking-[0.025em]">\n {collection.name}\n </h1>\n {collection.description && (\n <p className="mt-3 max-w-2xl text-base text-background/75">\n {collection.description}\n </p>\n )}\n <p className="mt-4 text-[11px] font-mono uppercase tracking-[0.16em] text-background/60 tabular-nums">\n {products.length} {products.length === 1 ? "product" : "products"}\n </p>\n </div>\n </section>\n <section className="max-w-7xl mx-auto px-6 sm:px-8 py-10 sm:py-12">\n <ListingClient products={products} />\n {products.length === 0 && (\n <p className="text-center mt-8">\n <Link href="/shop" className="text-primary font-semibold hover:underline">\n \u2190 Browse all products\n </Link>\n </p>\n )}\n </section>\n </>\n );\n}\n\nfunction CollectionSkeleton() {\n return (\n <>\n <section className="bg-foreground py-12 sm:py-14">\n <div className="max-w-7xl mx-auto px-6 sm:px-8">\n <div className="h-3 w-32 bg-background/20 rounded mb-3 animate-pulse" />\n <div className="h-12 w-72 bg-background/20 rounded animate-pulse" />\n </div>\n </section>\n <section className="max-w-7xl mx-auto px-6 sm:px-8 py-10 sm:py-12">\n <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3 sm:gap-4">\n {Array.from({ length: 8 }).map((_, i) => (\n <div key={i} className="aspect-[4/3] bg-muted rounded-2xl animate-pulse" />\n ))}\n </div>\n </section>\n </>\n );\n}\n' }, { "path": "package.json", "kind": "text", "content": '{\n "name": "__STOREFRONT_NAME__",\n "private": true,\n "version": "0.0.0",\n "scripts": {\n "dev": "concurrently -k -n mock,next -c blue,magenta \\"cimplify-mock --seed fashion --quiet\\" \\"next dev\\"",\n "dev:storefront": "next dev",\n "dev:mock": "cimplify-mock --seed fashion",\n "build": "next build",\n "start": "next start",\n "typecheck": "tsc --noEmit",\n "test": "vitest",\n "test:run": "vitest run",\n "check:brand": "vitest run __tests__/brand.test.ts",\n "check:cart": "vitest run __tests__/cart-flow.test.ts",\n "check:contract": "vitest run __tests__/contract.test.ts",\n "check:visual": "playwright test",\n "check:visual:update": "playwright test --update-snapshots",\n "check": "bun run typecheck && bun run test:run"\n },\n "dependencies": {\n "@cimplify/sdk": "^0.48.1",\n "next": "^16.2.4",\n "react": "^19.0.0",\n "react-dom": "^19.0.0"\n },\n "devDependencies": {\n "@playwright/test": "^1.50.0",\n "@tailwindcss/postcss": "^4.2.4",\n "@types/node": "^22.10.0",\n "@types/react": "^19.0.0",\n "@types/react-dom": "^19.0.0",\n "concurrently": "^9.0.0",\n "tailwindcss": "^4.2.4",\n "typescript": "^5.9.3",\n "vitest": "^4.1.5"\n }\n}\n' }, { "path": "lib/brand.ts", "kind": "text", "content": `/**
|
|
1015
1015
|
* Brand & content configuration \u2014 single source of truth for every visible
|
|
1016
1016
|
* string. Edit this file to rebrand. See ../AGENTS.md.
|
|
1017
1017
|
*/
|
|
@@ -1683,7 +1683,7 @@ function Field({
|
|
|
1683
1683
|
font-weight: 700;
|
|
1684
1684
|
}
|
|
1685
1685
|
}
|
|
1686
|
-
` }, { "path": "app/sitemap-page/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport Link from "next/link";\nimport { cacheTag, cacheLife } from "next/cache";\nimport { getServerClient, tags, type Product } from "@cimplify/sdk/server";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Sitemap \u2014 ${brand.name}`,\n description: "A human-readable index of every page on this site.",\n};\n\ninterface SitemapData {\n products: { slug: string; name: string }[];\n categories: { slug: string; name: string }[];\n collections: { slug: string; name: string }[];\n}\n\nasync function getSitemap(): Promise<SitemapData> {\n "use cache";\n cacheTag(tags.products(), tags.categories(), tags.collections());\n cacheLife("hours");\n\n const client = getServerClient();\n const [pRes, cRes, colRes] = await Promise.all([\n client.catalogue.getProducts({ limit: 500 }),\n client.catalogue.getCategories(),\n client.catalogue.getCollections(),\n ]);\n return {\n products: (pRes.ok ? pRes.value.items : []).map((p: Product) => ({\n slug: p.slug ?? p.id,\n name: p.name,\n })),\n categories: (cRes.ok ? cRes.value : []).map((c) => ({ slug: c.slug, name: c.name })),\n collections: (colRes.ok ? colRes.value : []).map((c) => ({ slug: c.slug, name: c.name })),\n };\n}\n\nconst STATIC_LINKS: { title: string; links: { href: string; label: string }[] }[] = [\n {\n title: "Browse",\n links: [\n { href: "/", label: "Home" },\n { href: "/shop", label: "Shop" },\n { href: "/search", label: "Search" },\n ],\n },\n {\n title: "Account",\n links: [\n { href: "/account", label: "Account" },\n { href: "/account/orders", label: "Orders" },\n { href: "/account/addresses", label: "Addresses" },\n { href: "/account/settings", label: "Settings" },\n { href: "/login", label: "Sign in" },\n { href: "/signup", label: "Create account" },\n { href: "/track-order", label: "Track an order" },\n { href: "/cart", label: "Cart" },\n { href: "/checkout", label: "Checkout" },\n ],\n },\n {\n title: "About",\n links: [\n { href: "/about", label: "About" },\n { href: "/faq", label: "FAQ" },\n { href: "/contact", label: "Contact" },\n ],\n },\n {\n title: "Policies",\n links: [\n { href: "/shipping", label: "Shipping" },\n { href: "/returns", label: "Returns" },\n { href: "/accessibility", label: "Accessibility" },\n { href: "/terms", label: "Terms of Service" },\n { href: "/privacy", label: "Privacy Policy" },\n ],\n },\n {\n title: "Machine-readable",\n links: [\n { href: "/sitemap.xml", label: "sitemap.xml (search engines)" },\n { href: "/llms.txt", label: "llms.txt (LLM agents)" },\n { href: "/robots.txt", label: "robots.txt" },\n { href: "/opensearch.xml", label: "opensearch.xml (browser search)" },\n ],\n },\n];\n\nexport default async function SitemapHtmlPage() {\n const { products, categories, collections } = await getSitemap();\n\n return (\n <article className="max-w-5xl mx-auto px-6 sm:px-8 py-16">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-2">\n Sitemap\n </p>\n <h1 className="text-[clamp(2rem,4vw,2.75rem)] font-semibold mb-2 -tracking-[0.02em]">\n Every page, in one place.\n </h1>\n <p className="text-muted-foreground mb-12">\n For search engines, see <Link href="/sitemap.xml" className="text-primary hover:underline">/sitemap.xml</Link>.\n For LLM agents, see <Link href="/llms.txt" className="text-primary hover:underline">/llms.txt</Link>.\n </p>\n <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-10">\n {STATIC_LINKS.map((s) => (\n <Section key={s.title} title={s.title}>\n {s.links.map((l) => (\n <li key={l.href}>\n <Link href={l.href} className="hover:text-primary transition-colors">\n {l.label}\n </Link>\n </li>\n ))}\n </Section>\n ))}\n {categories.length > 0 && (\n <Section title="Categories">\n {categories.map((c) => (\n <li key={c.slug}>\n <Link href={`/categories/${c.slug}`} className="hover:text-primary transition-colors">\n {c.name}\n </Link>\n </li>\n ))}\n </Section>\n )}\n {collections.length > 0 && (\n <Section title="Collections">\n {collections.map((c) => (\n <li key={c.slug}>\n <Link href={`/collections/${c.slug}`} className="hover:text-primary transition-colors">\n {c.name}\n </Link>\n </li>\n ))}\n </Section>\n )}\n {products.length > 0 && (\n <Section title={`Products (${products.length})`}>\n {products.map((p) => (\n <li key={p.slug}>\n <Link href={`/shop?product=${encodeURIComponent(p.slug)}`} className="hover:text-primary transition-colors">\n {p.name}\n </Link>\n </li>\n ))}\n </Section>\n )}\n </div>\n </article>\n );\n}\n\nfunction Section({ title, children }: { title: string; children: React.ReactNode }) {\n return (\n <div>\n <p className="font-semibold text-[12px] uppercase tracking-[0.12em] text-foreground mb-3">\n {title}\n </p>\n <ul className="space-y-2 m-0 p-0 list-none text-sm text-muted-foreground">\n {children}\n </ul>\n </div>\n );\n}\n' }, { "path": "app/shop/shop-client.tsx", "kind": "text", "content": '"use client";\n\nimport { CataloguePage } from "@cimplify/sdk/react";\nimport type { Category, Product } from "@cimplify/sdk";\nimport { StoreProductCard } from "@/components/store-product-card";\n\n/**\n * Client island for the shop page. Server-side fetches all products and\n * categories (cached via `\'use cache\'` in `app/shop/page.tsx`), then hands\n * them to `<CataloguePage>` which owns the interactive filter / sort state.\n */\nexport function ShopClient({\n products,\n categories,\n}: {\n products: Product[];\n categories: Category[];\n}) {\n return (\n <CataloguePage\n title="The Menu"\n products={products}\n categories={categories}\n renderCard={(p) => <StoreProductCard product={p} />}\n />\n );\n}\n' }, { "path": "app/shop/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { cacheTag, cacheLife } from "next/cache";\nimport { getServerClient, tags } from "@cimplify/sdk/server";\nimport { ShopClient } from "./shop-client";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Shop \u2014 ${brand.name}`,\n description: brand.description,\n};\n\nasync function getShopData() {\n "use cache";\n cacheTag(tags.products(), tags.categories());\n cacheLife("hours");\n\n const client = getServerClient();\n const [p, c] = await Promise.all([\n client.catalogue.getProducts({ limit: 50 }),\n client.catalogue.getCategories(),\n ]);\n return {\n products: p.ok ? p.value.items : [],\n categories: c.ok ? c.value : [],\n };\n}\n\nexport default async function ShopPage() {\n const { products, categories } = await getShopData();\n return <ShopClient products={products} categories={categories} />;\n}\n' }, { "path": "app/returns/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\nimport { PolicyPage } from "@/components/policy-page";\n\nexport const metadata: Metadata = {\n title: `${brand.returns.title} \u2014 ${brand.name}`,\n};\n\nexport default function ReturnsPage() {\n return <PolicyPage policy={brand.returns} />;\n}\n' }, { "path": "app/layout.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Inter } from "next/font/google";\nimport "./globals.css";\nimport { Providers } from "@/components/providers";\nimport { Header } from "@/components/header";\nimport { Footer } from "@/components/footer";\nimport { ProductModal } from "@/components/product-modal";\nimport { CartDrawer } from "@/components/cart-drawer";\nimport { OrganizationJsonLd } from "@/components/json-ld";\nimport { Suspense } from "react";\nimport { brand } from "@/lib/brand";\nimport { getSiteUrl } from "@/lib/site-url";\n\nconst inter = Inter({\n subsets: ["latin"],\n variable: "--font-sans",\n display: "swap",\n});\n\nexport async function generateMetadata(): Promise<Metadata> {\n const siteUrl = await getSiteUrl();\n return {\n metadataBase: new URL(siteUrl),\n title: {\n default: brand.name,\n template: `%s \u2014 ${brand.name}`,\n },\n description: brand.description,\n openGraph: {\n type: "website",\n siteName: brand.name,\n locale: brand.locale,\n },\n twitter: { card: "summary_large_image" },\n };\n}\n\nexport default function RootLayout({ children }: { children: React.ReactNode }) {\n return (\n <html lang="en" suppressHydrationWarning className={inter.variable}>\n <body\n suppressHydrationWarning\n className="min-h-screen flex flex-col bg-background text-foreground font-sans"\n >\n <Suspense fallback={null}>\n <OrganizationJsonLd />\n </Suspense>\n <Providers>\n <Header />\n <main className="flex-1 pb-12 w-full">{children}</main>\n <Footer />\n <Suspense fallback={null}>\n <ProductModal />\n </Suspense>\n <CartDrawer />\n </Providers>\n </body>\n </html>\n );\n}\n' }, { "path": "app/checkout/page.tsx", "kind": "text", "content": '"use client";\n\nimport { useRouter } from "next/navigation";\nimport { CheckoutPage as SdkCheckoutPage } from "@cimplify/sdk/react";\n\nexport default function CheckoutPage() {\n const router = useRouter();\n return (\n <SdkCheckoutPage\n onComplete={(result) => {\n if (result.success && result.order) {\n router.push(`/orders/${result.order.id}`);\n }\n }}\n />\n );\n}\n' }, { "path": "app/privacy/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Privacy Policy \u2014 ${brand.name}`,\n description: `How ${brand.name} collects, uses, and protects your personal data.`,\n};\n\nexport default function PrivacyPage() {\n const p = brand.privacy;\n return (\n <article className="max-w-3xl mx-auto px-6 sm:px-8 py-16 prose prose-lg max-w-none">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-2 not-prose">\n {p.eyebrow}\n </p>\n <h1 className="font-serif text-[clamp(2.25rem,5vw,3.5rem)] font-semibold mb-2 -tracking-[0.02em]">\n {p.title}\n </h1>\n <p className="text-sm text-muted-foreground not-prose mb-10">\n Last updated: {p.lastUpdated}\n </p>\n\n <section className="space-y-5 leading-relaxed text-foreground/90">\n {p.sections.map((s) => (\n <div key={s.heading}>\n <h2 className="font-serif text-2xl font-semibold mt-0">{s.heading}</h2>\n {typeof s.body === "string" ? (\n <p>{s.body}</p>\n ) : (\n <>\n <p>{s.body.intro}</p>\n <ul className="list-disc pl-6 space-y-2">\n {s.body.bullets.map((b) => (\n <li key={b}>{b}</li>\n ))}\n </ul>\n </>\n )}\n </div>\n ))}\n </section>\n </article>\n );\n}\n' }, { "path": "app/collections/[slug]/listing-client.tsx", "kind": "text", "content": '"use client";\n\nimport { ProductGrid } from "@cimplify/sdk/react";\nimport type { Product } from "@cimplify/sdk";\nimport { StoreProductCard } from "@/components/store-product-card";\n\n/**\n * Client island for the collection listing. Receives server-fetched\n * products as props (serializable) and owns the `renderCard` function\n * (which can\'t cross the server/client boundary).\n */\nexport function ListingClient({ products }: { products: Product[] }) {\n return (\n <ProductGrid\n products={products}\n emptyMessage="No products in this collection yet."\n renderCard={(p) => <StoreProductCard product={p} />}\n />\n );\n}\n' }, { "path": "app/collections/[slug]/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Suspense } from "react";\nimport Link from "next/link";\nimport { notFound } from "next/navigation";\nimport { cacheTag, cacheLife } from "next/cache";\nimport {\n getServerClient,\n tags,\n type Collection,\n type Product,\n} from "@cimplify/sdk/server";\nimport { ListingClient } from "./listing-client";\nimport { brand } from "@/lib/brand";\n\ninterface CollectionData {\n collection: Collection;\n products: Product[];\n}\n\nasync function getCollection(slug: string): Promise<CollectionData | null> {\n "use cache";\n cacheTag(tags.collections());\n cacheLife("hours");\n\n const client = getServerClient();\n const colRes = await client.catalogue.getCollectionBySlug(slug);\n if (!colRes.ok) return null;\n\n cacheTag(tags.collection(colRes.value.id), tags.collectionProducts(colRes.value.id));\n const r = await client.catalogue.getCollectionProducts(colRes.value.id);\n const products = r.ok\n ? ((r.value as { items?: Product[] }).items ?? (r.value as Product[]))\n : [];\n return { collection: colRes.value, products };\n}\n\nexport async function generateMetadata({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}): Promise<Metadata> {\n const { slug } = await params;\n const data = await getCollection(slug);\n if (!data) return {};\n return {\n title: `${data.collection.name} \u2014 ${brand.name}`,\n description: data.collection.description ?? undefined,\n };\n}\n\nexport default async function CollectionPage({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}) {\n return (\n <Suspense fallback={<CollectionSkeleton />}>\n <CollectionContent params={params} />\n </Suspense>\n );\n}\n\nasync function CollectionContent({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}) {\n const { slug } = await params;\n const data = await getCollection(slug);\n if (!data) notFound();\n\n const { collection, products } = data;\n return (\n <section className="max-w-7xl mx-auto px-6 sm:px-8 pt-12">\n <header className="mb-8 text-center">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-2">\n Collection\n </p>\n <h1 className="font-serif text-[clamp(2rem,4vw,2.75rem)] font-semibold mb-2">\n {collection.name}\n </h1>\n {collection.description && (\n <p className="mx-auto mb-2 max-w-xl text-muted-foreground">\n {collection.description}\n </p>\n )}\n <p className="text-sm text-muted-foreground">\n {products.length} item{products.length === 1 ? "" : "s"}\n </p>\n </header>\n <ListingClient products={products} />\n {products.length === 0 && (\n <p className="text-center mt-8">\n <Link href="/" className="text-primary font-semibold">\n \u2190 Back home\n </Link>\n </p>\n )}\n </section>\n );\n}\n\nfunction CollectionSkeleton() {\n return (\n <section className="max-w-7xl mx-auto px-6 sm:px-8 pt-12">\n <header className="mb-8 text-center">\n <div className="mx-auto h-3 w-20 bg-muted rounded mb-2 animate-pulse" />\n <div className="mx-auto h-10 w-64 bg-muted rounded mb-2 animate-pulse" />\n <div className="mx-auto h-4 w-80 bg-muted rounded animate-pulse" />\n </header>\n <div className="grid grid-cols-2 md:grid-cols-3 gap-4">\n {Array.from({ length: 6 }).map((_, i) => (\n <div key={i} className="aspect-square bg-muted rounded-2xl animate-pulse" />\n ))}\n </div>\n </section>\n );\n}\n' }, { "path": "package.json", "kind": "text", "content": '{\n "name": "__STOREFRONT_NAME__",\n "private": true,\n "version": "0.0.0",\n "scripts": {\n "dev": "concurrently -k -n mock,next -c blue,magenta \\"cimplify-mock --seed grocery --quiet\\" \\"next dev\\"",\n "dev:storefront": "next dev",\n "dev:mock": "cimplify-mock --seed grocery",\n "build": "next build",\n "start": "next start",\n "typecheck": "tsc --noEmit",\n "test": "vitest",\n "test:run": "vitest run",\n "check:brand": "vitest run __tests__/brand.test.ts",\n "check:cart": "vitest run __tests__/cart-flow.test.ts",\n "check:contract": "vitest run __tests__/contract.test.ts",\n "check": "bun run typecheck && bun run test:run"\n },\n "dependencies": {\n "@cimplify/sdk": "^0.48.0",\n "next": "^16.2.4",\n "react": "^19.0.0",\n "react-dom": "^19.0.0"\n },\n "devDependencies": {\n "@tailwindcss/postcss": "^4.2.4",\n "@types/node": "^22.10.0",\n "@types/react": "^19.0.0",\n "@types/react-dom": "^19.0.0",\n "concurrently": "^9.0.0",\n "tailwindcss": "^4.2.4",\n "typescript": "^5.9.3",\n "vitest": "^4.1.5"\n }\n}\n' }, { "path": "lib/brand.ts", "kind": "text", "content": `/**
|
|
1686
|
+
` }, { "path": "app/sitemap-page/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport Link from "next/link";\nimport { cacheTag, cacheLife } from "next/cache";\nimport { getServerClient, tags, type Product } from "@cimplify/sdk/server";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Sitemap \u2014 ${brand.name}`,\n description: "A human-readable index of every page on this site.",\n};\n\ninterface SitemapData {\n products: { slug: string; name: string }[];\n categories: { slug: string; name: string }[];\n collections: { slug: string; name: string }[];\n}\n\nasync function getSitemap(): Promise<SitemapData> {\n "use cache";\n cacheTag(tags.products(), tags.categories(), tags.collections());\n cacheLife("hours");\n\n const client = getServerClient();\n const [pRes, cRes, colRes] = await Promise.all([\n client.catalogue.getProducts({ limit: 500 }),\n client.catalogue.getCategories(),\n client.catalogue.getCollections(),\n ]);\n return {\n products: (pRes.ok ? pRes.value.items : []).map((p: Product) => ({\n slug: p.slug ?? p.id,\n name: p.name,\n })),\n categories: (cRes.ok ? cRes.value : []).map((c) => ({ slug: c.slug, name: c.name })),\n collections: (colRes.ok ? colRes.value : []).map((c) => ({ slug: c.slug, name: c.name })),\n };\n}\n\nconst STATIC_LINKS: { title: string; links: { href: string; label: string }[] }[] = [\n {\n title: "Browse",\n links: [\n { href: "/", label: "Home" },\n { href: "/shop", label: "Shop" },\n { href: "/search", label: "Search" },\n ],\n },\n {\n title: "Account",\n links: [\n { href: "/account", label: "Account" },\n { href: "/account/orders", label: "Orders" },\n { href: "/account/addresses", label: "Addresses" },\n { href: "/account/settings", label: "Settings" },\n { href: "/login", label: "Sign in" },\n { href: "/signup", label: "Create account" },\n { href: "/track-order", label: "Track an order" },\n { href: "/cart", label: "Cart" },\n { href: "/checkout", label: "Checkout" },\n ],\n },\n {\n title: "About",\n links: [\n { href: "/about", label: "About" },\n { href: "/faq", label: "FAQ" },\n { href: "/contact", label: "Contact" },\n ],\n },\n {\n title: "Policies",\n links: [\n { href: "/shipping", label: "Shipping" },\n { href: "/returns", label: "Returns" },\n { href: "/accessibility", label: "Accessibility" },\n { href: "/terms", label: "Terms of Service" },\n { href: "/privacy", label: "Privacy Policy" },\n ],\n },\n {\n title: "Machine-readable",\n links: [\n { href: "/sitemap.xml", label: "sitemap.xml (search engines)" },\n { href: "/llms.txt", label: "llms.txt (LLM agents)" },\n { href: "/robots.txt", label: "robots.txt" },\n { href: "/opensearch.xml", label: "opensearch.xml (browser search)" },\n ],\n },\n];\n\nexport default async function SitemapHtmlPage() {\n const { products, categories, collections } = await getSitemap();\n\n return (\n <article className="max-w-5xl mx-auto px-6 sm:px-8 py-16">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-2">\n Sitemap\n </p>\n <h1 className="text-[clamp(2rem,4vw,2.75rem)] font-semibold mb-2 -tracking-[0.02em]">\n Every page, in one place.\n </h1>\n <p className="text-muted-foreground mb-12">\n For search engines, see <Link href="/sitemap.xml" className="text-primary hover:underline">/sitemap.xml</Link>.\n For LLM agents, see <Link href="/llms.txt" className="text-primary hover:underline">/llms.txt</Link>.\n </p>\n <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-10">\n {STATIC_LINKS.map((s) => (\n <Section key={s.title} title={s.title}>\n {s.links.map((l) => (\n <li key={l.href}>\n <Link href={l.href} className="hover:text-primary transition-colors">\n {l.label}\n </Link>\n </li>\n ))}\n </Section>\n ))}\n {categories.length > 0 && (\n <Section title="Categories">\n {categories.map((c) => (\n <li key={c.slug}>\n <Link href={`/categories/${c.slug}`} className="hover:text-primary transition-colors">\n {c.name}\n </Link>\n </li>\n ))}\n </Section>\n )}\n {collections.length > 0 && (\n <Section title="Collections">\n {collections.map((c) => (\n <li key={c.slug}>\n <Link href={`/collections/${c.slug}`} className="hover:text-primary transition-colors">\n {c.name}\n </Link>\n </li>\n ))}\n </Section>\n )}\n {products.length > 0 && (\n <Section title={`Products (${products.length})`}>\n {products.map((p) => (\n <li key={p.slug}>\n <Link href={`/shop?product=${encodeURIComponent(p.slug)}`} className="hover:text-primary transition-colors">\n {p.name}\n </Link>\n </li>\n ))}\n </Section>\n )}\n </div>\n </article>\n );\n}\n\nfunction Section({ title, children }: { title: string; children: React.ReactNode }) {\n return (\n <div>\n <p className="font-semibold text-[12px] uppercase tracking-[0.12em] text-foreground mb-3">\n {title}\n </p>\n <ul className="space-y-2 m-0 p-0 list-none text-sm text-muted-foreground">\n {children}\n </ul>\n </div>\n );\n}\n' }, { "path": "app/shop/shop-client.tsx", "kind": "text", "content": '"use client";\n\nimport { CataloguePage } from "@cimplify/sdk/react";\nimport type { Category, Product } from "@cimplify/sdk";\nimport { StoreProductCard } from "@/components/store-product-card";\n\n/**\n * Client island for the shop page. Server-side fetches all products and\n * categories (cached via `\'use cache\'` in `app/shop/page.tsx`), then hands\n * them to `<CataloguePage>` which owns the interactive filter / sort state.\n */\nexport function ShopClient({\n products,\n categories,\n}: {\n products: Product[];\n categories: Category[];\n}) {\n return (\n <CataloguePage\n title="The Menu"\n products={products}\n categories={categories}\n renderCard={(p) => <StoreProductCard product={p} />}\n />\n );\n}\n' }, { "path": "app/shop/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { cacheTag, cacheLife } from "next/cache";\nimport { getServerClient, tags } from "@cimplify/sdk/server";\nimport { ShopClient } from "./shop-client";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Shop \u2014 ${brand.name}`,\n description: brand.description,\n};\n\nasync function getShopData() {\n "use cache";\n cacheTag(tags.products(), tags.categories());\n cacheLife("hours");\n\n const client = getServerClient();\n const [p, c] = await Promise.all([\n client.catalogue.getProducts({ limit: 50 }),\n client.catalogue.getCategories(),\n ]);\n return {\n products: p.ok ? p.value.items : [],\n categories: c.ok ? c.value : [],\n };\n}\n\nexport default async function ShopPage() {\n const { products, categories } = await getShopData();\n return <ShopClient products={products} categories={categories} />;\n}\n' }, { "path": "app/returns/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\nimport { PolicyPage } from "@/components/policy-page";\n\nexport const metadata: Metadata = {\n title: `${brand.returns.title} \u2014 ${brand.name}`,\n};\n\nexport default function ReturnsPage() {\n return <PolicyPage policy={brand.returns} />;\n}\n' }, { "path": "app/layout.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Inter } from "next/font/google";\nimport "./globals.css";\nimport { Providers } from "@/components/providers";\nimport { Header } from "@/components/header";\nimport { Footer } from "@/components/footer";\nimport { ProductModal } from "@/components/product-modal";\nimport { CartDrawer } from "@/components/cart-drawer";\nimport { OrganizationJsonLd } from "@/components/json-ld";\nimport { Suspense } from "react";\nimport { brand } from "@/lib/brand";\nimport { getSiteUrl } from "@/lib/site-url";\n\nconst inter = Inter({\n subsets: ["latin"],\n variable: "--font-sans",\n display: "swap",\n});\n\nexport async function generateMetadata(): Promise<Metadata> {\n const siteUrl = await getSiteUrl();\n return {\n metadataBase: new URL(siteUrl),\n title: {\n default: brand.name,\n template: `%s \u2014 ${brand.name}`,\n },\n description: brand.description,\n openGraph: {\n type: "website",\n siteName: brand.name,\n locale: brand.locale,\n },\n twitter: { card: "summary_large_image" },\n };\n}\n\nexport default function RootLayout({ children }: { children: React.ReactNode }) {\n return (\n <html lang="en" suppressHydrationWarning className={inter.variable}>\n <body\n suppressHydrationWarning\n className="min-h-screen flex flex-col bg-background text-foreground font-sans"\n >\n <Suspense fallback={null}>\n <OrganizationJsonLd />\n </Suspense>\n <Providers>\n <Header />\n <main className="flex-1 pb-12 w-full">{children}</main>\n <Footer />\n <Suspense fallback={null}>\n <ProductModal />\n </Suspense>\n <CartDrawer />\n </Providers>\n </body>\n </html>\n );\n}\n' }, { "path": "app/checkout/page.tsx", "kind": "text", "content": '"use client";\n\nimport { useRouter } from "next/navigation";\nimport { CheckoutPage as SdkCheckoutPage } from "@cimplify/sdk/react";\n\nexport default function CheckoutPage() {\n const router = useRouter();\n return (\n <SdkCheckoutPage\n onComplete={(result) => {\n if (result.success && result.order) {\n router.push(`/orders/${result.order.id}`);\n }\n }}\n />\n );\n}\n' }, { "path": "app/privacy/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Privacy Policy \u2014 ${brand.name}`,\n description: `How ${brand.name} collects, uses, and protects your personal data.`,\n};\n\nexport default function PrivacyPage() {\n const p = brand.privacy;\n return (\n <article className="max-w-3xl mx-auto px-6 sm:px-8 py-16 prose prose-lg max-w-none">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-2 not-prose">\n {p.eyebrow}\n </p>\n <h1 className="font-serif text-[clamp(2.25rem,5vw,3.5rem)] font-semibold mb-2 -tracking-[0.02em]">\n {p.title}\n </h1>\n <p className="text-sm text-muted-foreground not-prose mb-10">\n Last updated: {p.lastUpdated}\n </p>\n\n <section className="space-y-5 leading-relaxed text-foreground/90">\n {p.sections.map((s) => (\n <div key={s.heading}>\n <h2 className="font-serif text-2xl font-semibold mt-0">{s.heading}</h2>\n {typeof s.body === "string" ? (\n <p>{s.body}</p>\n ) : (\n <>\n <p>{s.body.intro}</p>\n <ul className="list-disc pl-6 space-y-2">\n {s.body.bullets.map((b) => (\n <li key={b}>{b}</li>\n ))}\n </ul>\n </>\n )}\n </div>\n ))}\n </section>\n </article>\n );\n}\n' }, { "path": "app/collections/[slug]/listing-client.tsx", "kind": "text", "content": '"use client";\n\nimport { ProductGrid } from "@cimplify/sdk/react";\nimport type { Product } from "@cimplify/sdk";\nimport { StoreProductCard } from "@/components/store-product-card";\n\n/**\n * Client island for the collection listing. Receives server-fetched\n * products as props (serializable) and owns the `renderCard` function\n * (which can\'t cross the server/client boundary).\n */\nexport function ListingClient({ products }: { products: Product[] }) {\n return (\n <ProductGrid\n products={products}\n emptyMessage="No products in this collection yet."\n renderCard={(p) => <StoreProductCard product={p} />}\n />\n );\n}\n' }, { "path": "app/collections/[slug]/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Suspense } from "react";\nimport Link from "next/link";\nimport { notFound } from "next/navigation";\nimport { cacheTag, cacheLife } from "next/cache";\nimport {\n getServerClient,\n tags,\n type Collection,\n type Product,\n} from "@cimplify/sdk/server";\nimport { ListingClient } from "./listing-client";\nimport { brand } from "@/lib/brand";\n\ninterface CollectionData {\n collection: Collection;\n products: Product[];\n}\n\nasync function getCollection(slug: string): Promise<CollectionData | null> {\n "use cache";\n cacheTag(tags.collections());\n cacheLife("hours");\n\n const client = getServerClient();\n const colRes = await client.catalogue.getCollectionBySlug(slug);\n if (!colRes.ok) return null;\n\n cacheTag(tags.collection(colRes.value.id), tags.collectionProducts(colRes.value.id));\n const r = await client.catalogue.getCollectionProducts(colRes.value.id);\n const products = r.ok\n ? ((r.value as { items?: Product[] }).items ?? (r.value as Product[]))\n : [];\n return { collection: colRes.value, products };\n}\n\nexport async function generateMetadata({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}): Promise<Metadata> {\n const { slug } = await params;\n const data = await getCollection(slug);\n if (!data) return {};\n return {\n title: `${data.collection.name} \u2014 ${brand.name}`,\n description: data.collection.description ?? undefined,\n };\n}\n\nexport default async function CollectionPage({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}) {\n return (\n <Suspense fallback={<CollectionSkeleton />}>\n <CollectionContent params={params} />\n </Suspense>\n );\n}\n\nasync function CollectionContent({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}) {\n const { slug } = await params;\n const data = await getCollection(slug);\n if (!data) notFound();\n\n const { collection, products } = data;\n return (\n <section className="max-w-7xl mx-auto px-6 sm:px-8 pt-12">\n <header className="mb-8 text-center">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-2">\n Collection\n </p>\n <h1 className="font-serif text-[clamp(2rem,4vw,2.75rem)] font-semibold mb-2">\n {collection.name}\n </h1>\n {collection.description && (\n <p className="mx-auto mb-2 max-w-xl text-muted-foreground">\n {collection.description}\n </p>\n )}\n <p className="text-sm text-muted-foreground">\n {products.length} item{products.length === 1 ? "" : "s"}\n </p>\n </header>\n <ListingClient products={products} />\n {products.length === 0 && (\n <p className="text-center mt-8">\n <Link href="/" className="text-primary font-semibold">\n \u2190 Back home\n </Link>\n </p>\n )}\n </section>\n );\n}\n\nfunction CollectionSkeleton() {\n return (\n <section className="max-w-7xl mx-auto px-6 sm:px-8 pt-12">\n <header className="mb-8 text-center">\n <div className="mx-auto h-3 w-20 bg-muted rounded mb-2 animate-pulse" />\n <div className="mx-auto h-10 w-64 bg-muted rounded mb-2 animate-pulse" />\n <div className="mx-auto h-4 w-80 bg-muted rounded animate-pulse" />\n </header>\n <div className="grid grid-cols-2 md:grid-cols-3 gap-4">\n {Array.from({ length: 6 }).map((_, i) => (\n <div key={i} className="aspect-square bg-muted rounded-2xl animate-pulse" />\n ))}\n </div>\n </section>\n );\n}\n' }, { "path": "package.json", "kind": "text", "content": '{\n "name": "__STOREFRONT_NAME__",\n "private": true,\n "version": "0.0.0",\n "scripts": {\n "dev": "concurrently -k -n mock,next -c blue,magenta \\"cimplify-mock --seed grocery --quiet\\" \\"next dev\\"",\n "dev:storefront": "next dev",\n "dev:mock": "cimplify-mock --seed grocery",\n "build": "next build",\n "start": "next start",\n "typecheck": "tsc --noEmit",\n "test": "vitest",\n "test:run": "vitest run",\n "check:brand": "vitest run __tests__/brand.test.ts",\n "check:cart": "vitest run __tests__/cart-flow.test.ts",\n "check:contract": "vitest run __tests__/contract.test.ts",\n "check": "bun run typecheck && bun run test:run"\n },\n "dependencies": {\n "@cimplify/sdk": "^0.48.1",\n "next": "^16.2.4",\n "react": "^19.0.0",\n "react-dom": "^19.0.0"\n },\n "devDependencies": {\n "@tailwindcss/postcss": "^4.2.4",\n "@types/node": "^22.10.0",\n "@types/react": "^19.0.0",\n "@types/react-dom": "^19.0.0",\n "concurrently": "^9.0.0",\n "tailwindcss": "^4.2.4",\n "typescript": "^5.9.3",\n "vitest": "^4.1.5"\n }\n}\n' }, { "path": "lib/brand.ts", "kind": "text", "content": `/**
|
|
1687
1687
|
* Brand & content configuration \u2014 single source of truth for every visible
|
|
1688
1688
|
* string. Edit this file to rebrand. See ../AGENTS.md.
|
|
1689
1689
|
*/
|
|
@@ -2255,7 +2255,7 @@ function Field({
|
|
|
2255
2255
|
font-weight: 600;
|
|
2256
2256
|
}
|
|
2257
2257
|
}
|
|
2258
|
-
` }, { "path": "app/sitemap-page/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport Link from "next/link";\nimport { cacheTag, cacheLife } from "next/cache";\nimport { getServerClient, tags, type Product } from "@cimplify/sdk/server";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Sitemap \u2014 ${brand.name}`,\n description: "A human-readable index of every page on this site.",\n};\n\ninterface SitemapData {\n products: { slug: string; name: string }[];\n categories: { slug: string; name: string }[];\n collections: { slug: string; name: string }[];\n}\n\nasync function getSitemap(): Promise<SitemapData> {\n "use cache";\n cacheTag(tags.products(), tags.categories(), tags.collections());\n cacheLife("hours");\n\n const client = getServerClient();\n const [pRes, cRes, colRes] = await Promise.all([\n client.catalogue.getProducts({ limit: 500 }),\n client.catalogue.getCategories(),\n client.catalogue.getCollections(),\n ]);\n return {\n products: (pRes.ok ? pRes.value.items : []).map((p: Product) => ({\n slug: p.slug ?? p.id,\n name: p.name,\n })),\n categories: (cRes.ok ? cRes.value : []).map((c) => ({ slug: c.slug, name: c.name })),\n collections: (colRes.ok ? colRes.value : []).map((c) => ({ slug: c.slug, name: c.name })),\n };\n}\n\nconst STATIC_LINKS: { title: string; links: { href: string; label: string }[] }[] = [\n {\n title: "Browse",\n links: [\n { href: "/", label: "Home" },\n { href: "/shop", label: "Shop" },\n { href: "/search", label: "Search" },\n ],\n },\n {\n title: "Account",\n links: [\n { href: "/account", label: "Account" },\n { href: "/account/orders", label: "Orders" },\n { href: "/account/addresses", label: "Addresses" },\n { href: "/account/settings", label: "Settings" },\n { href: "/login", label: "Sign in" },\n { href: "/signup", label: "Create account" },\n { href: "/track-order", label: "Track an order" },\n { href: "/cart", label: "Cart" },\n { href: "/checkout", label: "Checkout" },\n ],\n },\n {\n title: "About",\n links: [\n { href: "/about", label: "About" },\n { href: "/faq", label: "FAQ" },\n { href: "/contact", label: "Contact" },\n ],\n },\n {\n title: "Policies",\n links: [\n { href: "/shipping", label: "Shipping" },\n { href: "/returns", label: "Returns" },\n { href: "/accessibility", label: "Accessibility" },\n { href: "/terms", label: "Terms of Service" },\n { href: "/privacy", label: "Privacy Policy" },\n ],\n },\n {\n title: "Machine-readable",\n links: [\n { href: "/sitemap.xml", label: "sitemap.xml (search engines)" },\n { href: "/llms.txt", label: "llms.txt (LLM agents)" },\n { href: "/robots.txt", label: "robots.txt" },\n { href: "/opensearch.xml", label: "opensearch.xml (browser search)" },\n ],\n },\n];\n\nexport default async function SitemapHtmlPage() {\n const { products, categories, collections } = await getSitemap();\n\n return (\n <article className="max-w-5xl mx-auto px-6 sm:px-8 py-16">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-2">\n Sitemap\n </p>\n <h1 className="text-[clamp(2rem,4vw,2.75rem)] font-semibold mb-2 -tracking-[0.02em]">\n Every page, in one place.\n </h1>\n <p className="text-muted-foreground mb-12">\n For search engines, see <Link href="/sitemap.xml" className="text-primary hover:underline">/sitemap.xml</Link>.\n For LLM agents, see <Link href="/llms.txt" className="text-primary hover:underline">/llms.txt</Link>.\n </p>\n <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-10">\n {STATIC_LINKS.map((s) => (\n <Section key={s.title} title={s.title}>\n {s.links.map((l) => (\n <li key={l.href}>\n <Link href={l.href} className="hover:text-primary transition-colors">\n {l.label}\n </Link>\n </li>\n ))}\n </Section>\n ))}\n {categories.length > 0 && (\n <Section title="Categories">\n {categories.map((c) => (\n <li key={c.slug}>\n <Link href={`/categories/${c.slug}`} className="hover:text-primary transition-colors">\n {c.name}\n </Link>\n </li>\n ))}\n </Section>\n )}\n {collections.length > 0 && (\n <Section title="Collections">\n {collections.map((c) => (\n <li key={c.slug}>\n <Link href={`/collections/${c.slug}`} className="hover:text-primary transition-colors">\n {c.name}\n </Link>\n </li>\n ))}\n </Section>\n )}\n {products.length > 0 && (\n <Section title={`Products (${products.length})`}>\n {products.map((p) => (\n <li key={p.slug}>\n <Link href={`/shop?product=${encodeURIComponent(p.slug)}`} className="hover:text-primary transition-colors">\n {p.name}\n </Link>\n </li>\n ))}\n </Section>\n )}\n </div>\n </article>\n );\n}\n\nfunction Section({ title, children }: { title: string; children: React.ReactNode }) {\n return (\n <div>\n <p className="font-semibold text-[12px] uppercase tracking-[0.12em] text-foreground mb-3">\n {title}\n </p>\n <ul className="space-y-2 m-0 p-0 list-none text-sm text-muted-foreground">\n {children}\n </ul>\n </div>\n );\n}\n' }, { "path": "app/shop/shop-client.tsx", "kind": "text", "content": '"use client";\n\nimport { CataloguePage } from "@cimplify/sdk/react";\nimport type { Category, Product } from "@cimplify/sdk";\nimport { StoreProductCard } from "@/components/store-product-card";\n\n/**\n * Client island for the shop page. Server-side fetches all products and\n * categories (cached via `\'use cache\'` in `app/shop/page.tsx`), then hands\n * them to `<CataloguePage>` which owns the interactive filter / sort state.\n *\n * The page hero (in `app/shop/page.tsx`) already provides the title; we\n * pass an empty `title` and override the SDK heading via className so the\n * page reads as one continuous design.\n */\nexport function ShopClient({\n products,\n categories,\n}: {\n products: Product[];\n categories: Category[];\n}) {\n return (\n <CataloguePage\n title="All products"\n products={products}\n categories={categories}\n renderCard={(p) => <StoreProductCard product={p} />}\n className="max-w-7xl mx-auto px-6 sm:px-8 py-10 sm:py-12"\n />\n );\n}\n' }, { "path": "app/shop/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Suspense } from "react";\nimport { cacheTag, cacheLife } from "next/cache";\nimport { getServerClient, tags } from "@cimplify/sdk/server";\nimport { ShopClient } from "./shop-client";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Shop \u2014 ${brand.name}`,\n description: brand.description,\n};\n\nasync function getShopData() {\n "use cache";\n cacheTag(tags.products(), tags.categories());\n cacheLife("hours");\n\n const client = getServerClient();\n const [p, c] = await Promise.all([\n client.catalogue.getProducts({ limit: 50 }),\n client.catalogue.getCategories(),\n ]);\n return {\n products: p.ok ? p.value.items : [],\n categories: c.ok ? c.value : [],\n };\n}\n\nexport default async function ShopPage() {\n const { products, categories } = await getShopData();\n return (\n <>\n <section className="bg-foreground text-background relative overflow-hidden">\n <div className="absolute inset-0 opacity-[0.04] pointer-events-none [background-image:radial-gradient(circle_at_2px_2px,white_1px,transparent_0)] [background-size:32px_32px]" />\n <div className="relative max-w-7xl mx-auto px-6 sm:px-8 py-12 sm:py-14">\n <p className="text-[11px] font-mono uppercase tracking-[0.2em] text-background/60 mb-2">\n Catalogue \xB7 {products.length} products\n </p>\n <h1 className="text-[clamp(2rem,4vw,3rem)] font-bold m-0 -tracking-[0.025em] leading-[1.05]">\n Every product we stock.\n </h1>\n <p className="mt-3 max-w-xl text-base text-background/75">\n Filter, sort, search. Authorised dealer pricing, two-year warranty,\n same-day Accra delivery on every order.\n </p>\n </div>\n </section>\n <Suspense\n fallback={\n <div className="max-w-7xl mx-auto px-6 sm:px-8 py-10">\n <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3 sm:gap-4">\n {Array.from({ length: 8 }).map((_, i) => (\n <div key={i} className="aspect-[4/3] bg-muted rounded-2xl animate-pulse" />\n ))}\n </div>\n </div>\n }\n >\n <ShopClient products={products} categories={categories} />\n </Suspense>\n </>\n );\n}\n' }, { "path": "app/returns/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\nimport { PolicyPage } from "@/components/policy-page";\n\nexport const metadata: Metadata = {\n title: `${brand.returns.title} \u2014 ${brand.name}`,\n};\n\nexport default function ReturnsPage() {\n return <PolicyPage policy={brand.returns} />;\n}\n' }, { "path": "app/layout.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Inter, JetBrains_Mono } from "next/font/google";\nimport "./globals.css";\nimport { Providers } from "@/components/providers";\nimport { Header } from "@/components/header";\nimport { Footer } from "@/components/footer";\nimport { ProductModal } from "@/components/product-modal";\nimport { CartDrawer } from "@/components/cart-drawer";\nimport { OrganizationJsonLd } from "@/components/json-ld";\nimport { Suspense } from "react";\nimport { brand } from "@/lib/brand";\nimport { getSiteUrl } from "@/lib/site-url";\n\nconst inter = Inter({\n subsets: ["latin"],\n variable: "--font-sans",\n display: "swap",\n});\n\nconst mono = JetBrains_Mono({\n subsets: ["latin"],\n variable: "--font-mono",\n display: "swap",\n});\n\nexport async function generateMetadata(): Promise<Metadata> {\n const siteUrl = await getSiteUrl();\n return {\n metadataBase: new URL(siteUrl),\n title: {\n default: brand.name,\n template: `%s \u2014 ${brand.name}`,\n },\n description: brand.description,\n openGraph: {\n type: "website",\n siteName: brand.name,\n locale: brand.locale,\n },\n twitter: { card: "summary_large_image" },\n };\n}\n\nexport default function RootLayout({ children }: { children: React.ReactNode }) {\n return (\n <html lang="en" suppressHydrationWarning className={`${inter.variable} ${mono.variable}`}>\n <body\n suppressHydrationWarning\n className="min-h-screen flex flex-col bg-background text-foreground font-sans"\n >\n <Suspense fallback={null}>\n <OrganizationJsonLd />\n </Suspense>\n <Providers>\n <Header />\n <main className="flex-1 pb-12 w-full">{children}</main>\n <Footer />\n <Suspense fallback={null}>\n <ProductModal />\n </Suspense>\n <CartDrawer />\n </Providers>\n </body>\n </html>\n );\n}\n' }, { "path": "app/checkout/page.tsx", "kind": "text", "content": '"use client";\n\nimport { useRouter } from "next/navigation";\nimport { CheckoutPage as SdkCheckoutPage } from "@cimplify/sdk/react";\n\nexport default function CheckoutPage() {\n const router = useRouter();\n return (\n <SdkCheckoutPage\n onComplete={(result) => {\n if (result.success && result.order) {\n router.push(`/orders/${result.order.id}`);\n }\n }}\n />\n );\n}\n' }, { "path": "app/privacy/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Privacy Policy \u2014 ${brand.name}`,\n description: `How ${brand.name} collects, uses, and protects your personal data.`,\n};\n\nexport default function PrivacyPage() {\n const p = brand.privacy;\n return (\n <article className="max-w-3xl mx-auto px-6 sm:px-8 py-16 prose prose-lg max-w-none">\n <p className="text-[11px] font-mono uppercase tracking-[0.16em] text-primary mb-2 not-prose">\n {p.eyebrow}\n </p>\n <h1 className="text-[clamp(2.25rem,5vw,3.5rem)] font-bold mb-2 -tracking-[0.025em]">\n {p.title}\n </h1>\n <p className="text-sm text-muted-foreground not-prose mb-10">\n Last updated: {p.lastUpdated}\n </p>\n\n <section className="space-y-5 leading-relaxed text-foreground/90">\n {p.sections.map((s) => (\n <div key={s.heading}>\n <h2 className="text-2xl font-semibold mt-0 -tracking-[0.02em]">{s.heading}</h2>\n {typeof s.body === "string" ? (\n <p>{s.body}</p>\n ) : (\n <>\n <p>{s.body.intro}</p>\n <ul className="list-disc pl-6 space-y-2">\n {s.body.bullets.map((b) => (\n <li key={b}>{b}</li>\n ))}\n </ul>\n </>\n )}\n </div>\n ))}\n </section>\n </article>\n );\n}\n' }, { "path": "app/collections/[slug]/listing-client.tsx", "kind": "text", "content": '"use client";\n\nimport { ProductGrid } from "@cimplify/sdk/react";\nimport type { Product } from "@cimplify/sdk";\nimport { StoreProductCard } from "@/components/store-product-card";\n\n/**\n * Client island for the collection listing. Receives server-fetched\n * products as props (serializable) and owns the `renderCard` function\n * (which can\'t cross the server/client boundary).\n */\nexport function ListingClient({ products }: { products: Product[] }) {\n return (\n <ProductGrid\n products={products}\n emptyMessage="No products in this collection yet."\n renderCard={(p) => <StoreProductCard product={p} />}\n />\n );\n}\n' }, { "path": "app/collections/[slug]/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Suspense } from "react";\nimport Link from "next/link";\nimport { notFound } from "next/navigation";\nimport { cacheTag, cacheLife } from "next/cache";\nimport {\n getServerClient,\n tags,\n type Collection,\n type Product,\n} from "@cimplify/sdk/server";\nimport { ListingClient } from "./listing-client";\nimport { brand } from "@/lib/brand";\n\ninterface CollectionData {\n collection: Collection;\n products: Product[];\n}\n\nasync function getCollection(slug: string): Promise<CollectionData | null> {\n "use cache";\n cacheTag(tags.collections());\n cacheLife("hours");\n\n const client = getServerClient();\n const colRes = await client.catalogue.getCollectionBySlug(slug);\n if (!colRes.ok) return null;\n\n cacheTag(tags.collection(colRes.value.id), tags.collectionProducts(colRes.value.id));\n const r = await client.catalogue.getCollectionProducts(colRes.value.id);\n const products = r.ok\n ? ((r.value as { items?: Product[] }).items ?? (r.value as Product[]))\n : [];\n return { collection: colRes.value, products };\n}\n\nexport async function generateMetadata({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}): Promise<Metadata> {\n const { slug } = await params;\n const data = await getCollection(slug);\n if (!data) return {};\n return {\n title: `${data.collection.name} \u2014 ${brand.name}`,\n description: data.collection.description ?? undefined,\n };\n}\n\nexport default async function CollectionPage({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}) {\n return (\n <Suspense fallback={<CollectionSkeleton />}>\n <CollectionContent params={params} />\n </Suspense>\n );\n}\n\nasync function CollectionContent({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}) {\n const { slug } = await params;\n const data = await getCollection(slug);\n if (!data) notFound();\n\n const { collection, products } = data;\n return (\n <>\n <section className="bg-foreground text-background relative overflow-hidden">\n <div className="absolute inset-0 opacity-[0.04] pointer-events-none [background-image:radial-gradient(circle_at_2px_2px,white_1px,transparent_0)] [background-size:32px_32px]" />\n <div className="relative max-w-7xl mx-auto px-6 sm:px-8 py-12 sm:py-14">\n <nav className="text-[12px] font-mono text-background/60 mb-3 flex items-center gap-2">\n <Link href="/" className="hover:text-background transition-colors">Home</Link>\n <span>/</span>\n <span className="text-background/90">Collections</span>\n <span>/</span>\n <span className="text-background/90">{collection.name}</span>\n </nav>\n <h1 className="text-[clamp(2rem,4vw,3rem)] font-bold m-0 -tracking-[0.025em]">\n {collection.name}\n </h1>\n {collection.description && (\n <p className="mt-3 max-w-2xl text-base text-background/75">\n {collection.description}\n </p>\n )}\n <p className="mt-4 text-[11px] font-mono uppercase tracking-[0.16em] text-background/60 tabular-nums">\n {products.length} {products.length === 1 ? "product" : "products"}\n </p>\n </div>\n </section>\n <section className="max-w-7xl mx-auto px-6 sm:px-8 py-10 sm:py-12">\n <ListingClient products={products} />\n {products.length === 0 && (\n <p className="text-center mt-8">\n <Link href="/shop" className="text-primary font-semibold hover:underline">\n \u2190 Browse all products\n </Link>\n </p>\n )}\n </section>\n </>\n );\n}\n\nfunction CollectionSkeleton() {\n return (\n <>\n <section className="bg-foreground py-12 sm:py-14">\n <div className="max-w-7xl mx-auto px-6 sm:px-8">\n <div className="h-3 w-32 bg-background/20 rounded mb-3 animate-pulse" />\n <div className="h-12 w-72 bg-background/20 rounded animate-pulse" />\n </div>\n </section>\n <section className="max-w-7xl mx-auto px-6 sm:px-8 py-10 sm:py-12">\n <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3 sm:gap-4">\n {Array.from({ length: 8 }).map((_, i) => (\n <div key={i} className="aspect-[4/3] bg-muted rounded-2xl animate-pulse" />\n ))}\n </div>\n </section>\n </>\n );\n}\n' }, { "path": "package.json", "kind": "text", "content": '{\n "name": "__STOREFRONT_NAME__",\n "private": true,\n "version": "0.0.0",\n "scripts": {\n "dev": "concurrently -k -n mock,next -c blue,magenta \\"cimplify-mock --seed auto --quiet\\" \\"next dev\\"",\n "dev:storefront": "next dev",\n "dev:mock": "cimplify-mock --seed auto",\n "build": "next build",\n "start": "next start",\n "typecheck": "tsc --noEmit",\n "test": "vitest",\n "test:run": "vitest run",\n "check:brand": "vitest run __tests__/brand.test.ts",\n "check:cart": "vitest run __tests__/cart-flow.test.ts",\n "check:contract": "vitest run __tests__/contract.test.ts",\n "check": "bun run typecheck && bun run test:run"\n },\n "dependencies": {\n "@cimplify/sdk": "^0.48.0",\n "next": "^16.2.4",\n "react": "^19.0.0",\n "react-dom": "^19.0.0"\n },\n "devDependencies": {\n "@tailwindcss/postcss": "^4.2.4",\n "@types/node": "^22.10.0",\n "@types/react": "^19.0.0",\n "@types/react-dom": "^19.0.0",\n "concurrently": "^9.0.0",\n "tailwindcss": "^4.2.4",\n "typescript": "^5.9.3",\n "vitest": "^4.1.5"\n }\n}\n' }, { "path": "lib/brand.ts", "kind": "text", "content": `/**
|
|
2258
|
+
` }, { "path": "app/sitemap-page/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport Link from "next/link";\nimport { cacheTag, cacheLife } from "next/cache";\nimport { getServerClient, tags, type Product } from "@cimplify/sdk/server";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Sitemap \u2014 ${brand.name}`,\n description: "A human-readable index of every page on this site.",\n};\n\ninterface SitemapData {\n products: { slug: string; name: string }[];\n categories: { slug: string; name: string }[];\n collections: { slug: string; name: string }[];\n}\n\nasync function getSitemap(): Promise<SitemapData> {\n "use cache";\n cacheTag(tags.products(), tags.categories(), tags.collections());\n cacheLife("hours");\n\n const client = getServerClient();\n const [pRes, cRes, colRes] = await Promise.all([\n client.catalogue.getProducts({ limit: 500 }),\n client.catalogue.getCategories(),\n client.catalogue.getCollections(),\n ]);\n return {\n products: (pRes.ok ? pRes.value.items : []).map((p: Product) => ({\n slug: p.slug ?? p.id,\n name: p.name,\n })),\n categories: (cRes.ok ? cRes.value : []).map((c) => ({ slug: c.slug, name: c.name })),\n collections: (colRes.ok ? colRes.value : []).map((c) => ({ slug: c.slug, name: c.name })),\n };\n}\n\nconst STATIC_LINKS: { title: string; links: { href: string; label: string }[] }[] = [\n {\n title: "Browse",\n links: [\n { href: "/", label: "Home" },\n { href: "/shop", label: "Shop" },\n { href: "/search", label: "Search" },\n ],\n },\n {\n title: "Account",\n links: [\n { href: "/account", label: "Account" },\n { href: "/account/orders", label: "Orders" },\n { href: "/account/addresses", label: "Addresses" },\n { href: "/account/settings", label: "Settings" },\n { href: "/login", label: "Sign in" },\n { href: "/signup", label: "Create account" },\n { href: "/track-order", label: "Track an order" },\n { href: "/cart", label: "Cart" },\n { href: "/checkout", label: "Checkout" },\n ],\n },\n {\n title: "About",\n links: [\n { href: "/about", label: "About" },\n { href: "/faq", label: "FAQ" },\n { href: "/contact", label: "Contact" },\n ],\n },\n {\n title: "Policies",\n links: [\n { href: "/shipping", label: "Shipping" },\n { href: "/returns", label: "Returns" },\n { href: "/accessibility", label: "Accessibility" },\n { href: "/terms", label: "Terms of Service" },\n { href: "/privacy", label: "Privacy Policy" },\n ],\n },\n {\n title: "Machine-readable",\n links: [\n { href: "/sitemap.xml", label: "sitemap.xml (search engines)" },\n { href: "/llms.txt", label: "llms.txt (LLM agents)" },\n { href: "/robots.txt", label: "robots.txt" },\n { href: "/opensearch.xml", label: "opensearch.xml (browser search)" },\n ],\n },\n];\n\nexport default async function SitemapHtmlPage() {\n const { products, categories, collections } = await getSitemap();\n\n return (\n <article className="max-w-5xl mx-auto px-6 sm:px-8 py-16">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-2">\n Sitemap\n </p>\n <h1 className="text-[clamp(2rem,4vw,2.75rem)] font-semibold mb-2 -tracking-[0.02em]">\n Every page, in one place.\n </h1>\n <p className="text-muted-foreground mb-12">\n For search engines, see <Link href="/sitemap.xml" className="text-primary hover:underline">/sitemap.xml</Link>.\n For LLM agents, see <Link href="/llms.txt" className="text-primary hover:underline">/llms.txt</Link>.\n </p>\n <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-10">\n {STATIC_LINKS.map((s) => (\n <Section key={s.title} title={s.title}>\n {s.links.map((l) => (\n <li key={l.href}>\n <Link href={l.href} className="hover:text-primary transition-colors">\n {l.label}\n </Link>\n </li>\n ))}\n </Section>\n ))}\n {categories.length > 0 && (\n <Section title="Categories">\n {categories.map((c) => (\n <li key={c.slug}>\n <Link href={`/categories/${c.slug}`} className="hover:text-primary transition-colors">\n {c.name}\n </Link>\n </li>\n ))}\n </Section>\n )}\n {collections.length > 0 && (\n <Section title="Collections">\n {collections.map((c) => (\n <li key={c.slug}>\n <Link href={`/collections/${c.slug}`} className="hover:text-primary transition-colors">\n {c.name}\n </Link>\n </li>\n ))}\n </Section>\n )}\n {products.length > 0 && (\n <Section title={`Products (${products.length})`}>\n {products.map((p) => (\n <li key={p.slug}>\n <Link href={`/shop?product=${encodeURIComponent(p.slug)}`} className="hover:text-primary transition-colors">\n {p.name}\n </Link>\n </li>\n ))}\n </Section>\n )}\n </div>\n </article>\n );\n}\n\nfunction Section({ title, children }: { title: string; children: React.ReactNode }) {\n return (\n <div>\n <p className="font-semibold text-[12px] uppercase tracking-[0.12em] text-foreground mb-3">\n {title}\n </p>\n <ul className="space-y-2 m-0 p-0 list-none text-sm text-muted-foreground">\n {children}\n </ul>\n </div>\n );\n}\n' }, { "path": "app/shop/shop-client.tsx", "kind": "text", "content": '"use client";\n\nimport { CataloguePage } from "@cimplify/sdk/react";\nimport type { Category, Product } from "@cimplify/sdk";\nimport { StoreProductCard } from "@/components/store-product-card";\n\n/**\n * Client island for the shop page. Server-side fetches all products and\n * categories (cached via `\'use cache\'` in `app/shop/page.tsx`), then hands\n * them to `<CataloguePage>` which owns the interactive filter / sort state.\n *\n * The page hero (in `app/shop/page.tsx`) already provides the title; we\n * pass an empty `title` and override the SDK heading via className so the\n * page reads as one continuous design.\n */\nexport function ShopClient({\n products,\n categories,\n}: {\n products: Product[];\n categories: Category[];\n}) {\n return (\n <CataloguePage\n title="All products"\n products={products}\n categories={categories}\n renderCard={(p) => <StoreProductCard product={p} />}\n className="max-w-7xl mx-auto px-6 sm:px-8 py-10 sm:py-12"\n />\n );\n}\n' }, { "path": "app/shop/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Suspense } from "react";\nimport { cacheTag, cacheLife } from "next/cache";\nimport { getServerClient, tags } from "@cimplify/sdk/server";\nimport { ShopClient } from "./shop-client";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Shop \u2014 ${brand.name}`,\n description: brand.description,\n};\n\nasync function getShopData() {\n "use cache";\n cacheTag(tags.products(), tags.categories());\n cacheLife("hours");\n\n const client = getServerClient();\n const [p, c] = await Promise.all([\n client.catalogue.getProducts({ limit: 50 }),\n client.catalogue.getCategories(),\n ]);\n return {\n products: p.ok ? p.value.items : [],\n categories: c.ok ? c.value : [],\n };\n}\n\nexport default async function ShopPage() {\n const { products, categories } = await getShopData();\n return (\n <>\n <section className="bg-foreground text-background relative overflow-hidden">\n <div className="absolute inset-0 opacity-[0.04] pointer-events-none [background-image:radial-gradient(circle_at_2px_2px,white_1px,transparent_0)] [background-size:32px_32px]" />\n <div className="relative max-w-7xl mx-auto px-6 sm:px-8 py-12 sm:py-14">\n <p className="text-[11px] font-mono uppercase tracking-[0.2em] text-background/60 mb-2">\n Catalogue \xB7 {products.length} products\n </p>\n <h1 className="text-[clamp(2rem,4vw,3rem)] font-bold m-0 -tracking-[0.025em] leading-[1.05]">\n Every product we stock.\n </h1>\n <p className="mt-3 max-w-xl text-base text-background/75">\n Filter, sort, search. Authorised dealer pricing, two-year warranty,\n same-day Accra delivery on every order.\n </p>\n </div>\n </section>\n <Suspense\n fallback={\n <div className="max-w-7xl mx-auto px-6 sm:px-8 py-10">\n <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3 sm:gap-4">\n {Array.from({ length: 8 }).map((_, i) => (\n <div key={i} className="aspect-[4/3] bg-muted rounded-2xl animate-pulse" />\n ))}\n </div>\n </div>\n }\n >\n <ShopClient products={products} categories={categories} />\n </Suspense>\n </>\n );\n}\n' }, { "path": "app/returns/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\nimport { PolicyPage } from "@/components/policy-page";\n\nexport const metadata: Metadata = {\n title: `${brand.returns.title} \u2014 ${brand.name}`,\n};\n\nexport default function ReturnsPage() {\n return <PolicyPage policy={brand.returns} />;\n}\n' }, { "path": "app/layout.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Inter, JetBrains_Mono } from "next/font/google";\nimport "./globals.css";\nimport { Providers } from "@/components/providers";\nimport { Header } from "@/components/header";\nimport { Footer } from "@/components/footer";\nimport { ProductModal } from "@/components/product-modal";\nimport { CartDrawer } from "@/components/cart-drawer";\nimport { OrganizationJsonLd } from "@/components/json-ld";\nimport { Suspense } from "react";\nimport { brand } from "@/lib/brand";\nimport { getSiteUrl } from "@/lib/site-url";\n\nconst inter = Inter({\n subsets: ["latin"],\n variable: "--font-sans",\n display: "swap",\n});\n\nconst mono = JetBrains_Mono({\n subsets: ["latin"],\n variable: "--font-mono",\n display: "swap",\n});\n\nexport async function generateMetadata(): Promise<Metadata> {\n const siteUrl = await getSiteUrl();\n return {\n metadataBase: new URL(siteUrl),\n title: {\n default: brand.name,\n template: `%s \u2014 ${brand.name}`,\n },\n description: brand.description,\n openGraph: {\n type: "website",\n siteName: brand.name,\n locale: brand.locale,\n },\n twitter: { card: "summary_large_image" },\n };\n}\n\nexport default function RootLayout({ children }: { children: React.ReactNode }) {\n return (\n <html lang="en" suppressHydrationWarning className={`${inter.variable} ${mono.variable}`}>\n <body\n suppressHydrationWarning\n className="min-h-screen flex flex-col bg-background text-foreground font-sans"\n >\n <Suspense fallback={null}>\n <OrganizationJsonLd />\n </Suspense>\n <Providers>\n <Header />\n <main className="flex-1 pb-12 w-full">{children}</main>\n <Footer />\n <Suspense fallback={null}>\n <ProductModal />\n </Suspense>\n <CartDrawer />\n </Providers>\n </body>\n </html>\n );\n}\n' }, { "path": "app/checkout/page.tsx", "kind": "text", "content": '"use client";\n\nimport { useRouter } from "next/navigation";\nimport { CheckoutPage as SdkCheckoutPage } from "@cimplify/sdk/react";\n\nexport default function CheckoutPage() {\n const router = useRouter();\n return (\n <SdkCheckoutPage\n onComplete={(result) => {\n if (result.success && result.order) {\n router.push(`/orders/${result.order.id}`);\n }\n }}\n />\n );\n}\n' }, { "path": "app/privacy/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Privacy Policy \u2014 ${brand.name}`,\n description: `How ${brand.name} collects, uses, and protects your personal data.`,\n};\n\nexport default function PrivacyPage() {\n const p = brand.privacy;\n return (\n <article className="max-w-3xl mx-auto px-6 sm:px-8 py-16 prose prose-lg max-w-none">\n <p className="text-[11px] font-mono uppercase tracking-[0.16em] text-primary mb-2 not-prose">\n {p.eyebrow}\n </p>\n <h1 className="text-[clamp(2.25rem,5vw,3.5rem)] font-bold mb-2 -tracking-[0.025em]">\n {p.title}\n </h1>\n <p className="text-sm text-muted-foreground not-prose mb-10">\n Last updated: {p.lastUpdated}\n </p>\n\n <section className="space-y-5 leading-relaxed text-foreground/90">\n {p.sections.map((s) => (\n <div key={s.heading}>\n <h2 className="text-2xl font-semibold mt-0 -tracking-[0.02em]">{s.heading}</h2>\n {typeof s.body === "string" ? (\n <p>{s.body}</p>\n ) : (\n <>\n <p>{s.body.intro}</p>\n <ul className="list-disc pl-6 space-y-2">\n {s.body.bullets.map((b) => (\n <li key={b}>{b}</li>\n ))}\n </ul>\n </>\n )}\n </div>\n ))}\n </section>\n </article>\n );\n}\n' }, { "path": "app/collections/[slug]/listing-client.tsx", "kind": "text", "content": '"use client";\n\nimport { ProductGrid } from "@cimplify/sdk/react";\nimport type { Product } from "@cimplify/sdk";\nimport { StoreProductCard } from "@/components/store-product-card";\n\n/**\n * Client island for the collection listing. Receives server-fetched\n * products as props (serializable) and owns the `renderCard` function\n * (which can\'t cross the server/client boundary).\n */\nexport function ListingClient({ products }: { products: Product[] }) {\n return (\n <ProductGrid\n products={products}\n emptyMessage="No products in this collection yet."\n renderCard={(p) => <StoreProductCard product={p} />}\n />\n );\n}\n' }, { "path": "app/collections/[slug]/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Suspense } from "react";\nimport Link from "next/link";\nimport { notFound } from "next/navigation";\nimport { cacheTag, cacheLife } from "next/cache";\nimport {\n getServerClient,\n tags,\n type Collection,\n type Product,\n} from "@cimplify/sdk/server";\nimport { ListingClient } from "./listing-client";\nimport { brand } from "@/lib/brand";\n\ninterface CollectionData {\n collection: Collection;\n products: Product[];\n}\n\nasync function getCollection(slug: string): Promise<CollectionData | null> {\n "use cache";\n cacheTag(tags.collections());\n cacheLife("hours");\n\n const client = getServerClient();\n const colRes = await client.catalogue.getCollectionBySlug(slug);\n if (!colRes.ok) return null;\n\n cacheTag(tags.collection(colRes.value.id), tags.collectionProducts(colRes.value.id));\n const r = await client.catalogue.getCollectionProducts(colRes.value.id);\n const products = r.ok\n ? ((r.value as { items?: Product[] }).items ?? (r.value as Product[]))\n : [];\n return { collection: colRes.value, products };\n}\n\nexport async function generateMetadata({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}): Promise<Metadata> {\n const { slug } = await params;\n const data = await getCollection(slug);\n if (!data) return {};\n return {\n title: `${data.collection.name} \u2014 ${brand.name}`,\n description: data.collection.description ?? undefined,\n };\n}\n\nexport default async function CollectionPage({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}) {\n return (\n <Suspense fallback={<CollectionSkeleton />}>\n <CollectionContent params={params} />\n </Suspense>\n );\n}\n\nasync function CollectionContent({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}) {\n const { slug } = await params;\n const data = await getCollection(slug);\n if (!data) notFound();\n\n const { collection, products } = data;\n return (\n <>\n <section className="bg-foreground text-background relative overflow-hidden">\n <div className="absolute inset-0 opacity-[0.04] pointer-events-none [background-image:radial-gradient(circle_at_2px_2px,white_1px,transparent_0)] [background-size:32px_32px]" />\n <div className="relative max-w-7xl mx-auto px-6 sm:px-8 py-12 sm:py-14">\n <nav className="text-[12px] font-mono text-background/60 mb-3 flex items-center gap-2">\n <Link href="/" className="hover:text-background transition-colors">Home</Link>\n <span>/</span>\n <span className="text-background/90">Collections</span>\n <span>/</span>\n <span className="text-background/90">{collection.name}</span>\n </nav>\n <h1 className="text-[clamp(2rem,4vw,3rem)] font-bold m-0 -tracking-[0.025em]">\n {collection.name}\n </h1>\n {collection.description && (\n <p className="mt-3 max-w-2xl text-base text-background/75">\n {collection.description}\n </p>\n )}\n <p className="mt-4 text-[11px] font-mono uppercase tracking-[0.16em] text-background/60 tabular-nums">\n {products.length} {products.length === 1 ? "product" : "products"}\n </p>\n </div>\n </section>\n <section className="max-w-7xl mx-auto px-6 sm:px-8 py-10 sm:py-12">\n <ListingClient products={products} />\n {products.length === 0 && (\n <p className="text-center mt-8">\n <Link href="/shop" className="text-primary font-semibold hover:underline">\n \u2190 Browse all products\n </Link>\n </p>\n )}\n </section>\n </>\n );\n}\n\nfunction CollectionSkeleton() {\n return (\n <>\n <section className="bg-foreground py-12 sm:py-14">\n <div className="max-w-7xl mx-auto px-6 sm:px-8">\n <div className="h-3 w-32 bg-background/20 rounded mb-3 animate-pulse" />\n <div className="h-12 w-72 bg-background/20 rounded animate-pulse" />\n </div>\n </section>\n <section className="max-w-7xl mx-auto px-6 sm:px-8 py-10 sm:py-12">\n <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3 sm:gap-4">\n {Array.from({ length: 8 }).map((_, i) => (\n <div key={i} className="aspect-[4/3] bg-muted rounded-2xl animate-pulse" />\n ))}\n </div>\n </section>\n </>\n );\n}\n' }, { "path": "package.json", "kind": "text", "content": '{\n "name": "__STOREFRONT_NAME__",\n "private": true,\n "version": "0.0.0",\n "scripts": {\n "dev": "concurrently -k -n mock,next -c blue,magenta \\"cimplify-mock --seed auto --quiet\\" \\"next dev\\"",\n "dev:storefront": "next dev",\n "dev:mock": "cimplify-mock --seed auto",\n "build": "next build",\n "start": "next start",\n "typecheck": "tsc --noEmit",\n "test": "vitest",\n "test:run": "vitest run",\n "check:brand": "vitest run __tests__/brand.test.ts",\n "check:cart": "vitest run __tests__/cart-flow.test.ts",\n "check:contract": "vitest run __tests__/contract.test.ts",\n "check": "bun run typecheck && bun run test:run"\n },\n "dependencies": {\n "@cimplify/sdk": "^0.48.1",\n "next": "^16.2.4",\n "react": "^19.0.0",\n "react-dom": "^19.0.0"\n },\n "devDependencies": {\n "@tailwindcss/postcss": "^4.2.4",\n "@types/node": "^22.10.0",\n "@types/react": "^19.0.0",\n "@types/react-dom": "^19.0.0",\n "concurrently": "^9.0.0",\n "tailwindcss": "^4.2.4",\n "typescript": "^5.9.3",\n "vitest": "^4.1.5"\n }\n}\n' }, { "path": "lib/brand.ts", "kind": "text", "content": `/**
|
|
2259
2259
|
* Brand & content configuration \u2014 the **single source of truth** for every
|
|
2260
2260
|
* visible string in this storefront.
|
|
2261
2261
|
*
|
|
@@ -3152,7 +3152,7 @@ function Field({
|
|
|
3152
3152
|
letter-spacing: -0.012em;
|
|
3153
3153
|
}
|
|
3154
3154
|
}
|
|
3155
|
-
` }, { "path": "app/sitemap-page/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport Link from "next/link";\nimport { cacheTag, cacheLife } from "next/cache";\nimport { getServerClient, tags, type Product } from "@cimplify/sdk/server";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Sitemap \u2014 ${brand.name}`,\n description: "A human-readable index of every page on this site.",\n};\n\ninterface SitemapData {\n products: { slug: string; name: string }[];\n categories: { slug: string; name: string }[];\n collections: { slug: string; name: string }[];\n}\n\nasync function getSitemap(): Promise<SitemapData> {\n "use cache";\n cacheTag(tags.products(), tags.categories(), tags.collections());\n cacheLife("hours");\n\n const client = getServerClient();\n const [pRes, cRes, colRes] = await Promise.all([\n client.catalogue.getProducts({ limit: 500 }),\n client.catalogue.getCategories(),\n client.catalogue.getCollections(),\n ]);\n return {\n products: (pRes.ok ? pRes.value.items : []).map((p: Product) => ({\n slug: p.slug ?? p.id,\n name: p.name,\n })),\n categories: (cRes.ok ? cRes.value : []).map((c) => ({ slug: c.slug, name: c.name })),\n collections: (colRes.ok ? colRes.value : []).map((c) => ({ slug: c.slug, name: c.name })),\n };\n}\n\nconst STATIC_LINKS: { title: string; links: { href: string; label: string }[] }[] = [\n {\n title: "Browse",\n links: [\n { href: "/", label: "Home" },\n { href: "/shop", label: "Shop" },\n { href: "/search", label: "Search" },\n ],\n },\n {\n title: "Account",\n links: [\n { href: "/account", label: "Account" },\n { href: "/account/orders", label: "Orders" },\n { href: "/account/addresses", label: "Addresses" },\n { href: "/account/settings", label: "Settings" },\n { href: "/login", label: "Sign in" },\n { href: "/signup", label: "Create account" },\n { href: "/track-order", label: "Track an order" },\n { href: "/cart", label: "Cart" },\n { href: "/checkout", label: "Checkout" },\n ],\n },\n {\n title: "About",\n links: [\n { href: "/about", label: "About" },\n { href: "/faq", label: "FAQ" },\n { href: "/contact", label: "Contact" },\n ],\n },\n {\n title: "Policies",\n links: [\n { href: "/shipping", label: "Shipping" },\n { href: "/returns", label: "Returns" },\n { href: "/accessibility", label: "Accessibility" },\n { href: "/terms", label: "Terms of Service" },\n { href: "/privacy", label: "Privacy Policy" },\n ],\n },\n {\n title: "Machine-readable",\n links: [\n { href: "/sitemap.xml", label: "sitemap.xml (search engines)" },\n { href: "/llms.txt", label: "llms.txt (LLM agents)" },\n { href: "/robots.txt", label: "robots.txt" },\n { href: "/opensearch.xml", label: "opensearch.xml (browser search)" },\n ],\n },\n];\n\nexport default async function SitemapHtmlPage() {\n const { products, categories, collections } = await getSitemap();\n\n return (\n <article className="max-w-5xl mx-auto px-6 sm:px-8 py-16">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-2">\n Sitemap\n </p>\n <h1 className="text-[clamp(2rem,4vw,2.75rem)] font-semibold mb-2 -tracking-[0.02em]">\n Every page, in one place.\n </h1>\n <p className="text-muted-foreground mb-12">\n For search engines, see <Link href="/sitemap.xml" className="text-primary hover:underline">/sitemap.xml</Link>.\n For LLM agents, see <Link href="/llms.txt" className="text-primary hover:underline">/llms.txt</Link>.\n </p>\n <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-10">\n {STATIC_LINKS.map((s) => (\n <Section key={s.title} title={s.title}>\n {s.links.map((l) => (\n <li key={l.href}>\n <Link href={l.href} className="hover:text-primary transition-colors">\n {l.label}\n </Link>\n </li>\n ))}\n </Section>\n ))}\n {categories.length > 0 && (\n <Section title="Categories">\n {categories.map((c) => (\n <li key={c.slug}>\n <Link href={`/categories/${c.slug}`} className="hover:text-primary transition-colors">\n {c.name}\n </Link>\n </li>\n ))}\n </Section>\n )}\n {collections.length > 0 && (\n <Section title="Collections">\n {collections.map((c) => (\n <li key={c.slug}>\n <Link href={`/collections/${c.slug}`} className="hover:text-primary transition-colors">\n {c.name}\n </Link>\n </li>\n ))}\n </Section>\n )}\n {products.length > 0 && (\n <Section title={`Products (${products.length})`}>\n {products.map((p) => (\n <li key={p.slug}>\n <Link href={`/shop?product=${encodeURIComponent(p.slug)}`} className="hover:text-primary transition-colors">\n {p.name}\n </Link>\n </li>\n ))}\n </Section>\n )}\n </div>\n </article>\n );\n}\n\nfunction Section({ title, children }: { title: string; children: React.ReactNode }) {\n return (\n <div>\n <p className="font-semibold text-[12px] uppercase tracking-[0.12em] text-foreground mb-3">\n {title}\n </p>\n <ul className="space-y-2 m-0 p-0 list-none text-sm text-muted-foreground">\n {children}\n </ul>\n </div>\n );\n}\n' }, { "path": "app/shop/shop-client.tsx", "kind": "text", "content": '"use client";\n\nimport { CataloguePage } from "@cimplify/sdk/react";\nimport type { Category, Product } from "@cimplify/sdk";\nimport { StoreProductCard } from "@/components/store-product-card";\n\n/**\n * Client island for the shop page. Server-side fetches all products and\n * categories (cached via `\'use cache\'` in `app/shop/page.tsx`), then hands\n * them to `<CataloguePage>` which owns the interactive filter / sort state.\n */\nexport function ShopClient({\n products,\n categories,\n}: {\n products: Product[];\n categories: Category[];\n}) {\n return (\n <CataloguePage\n title="The Menu"\n products={products}\n categories={categories}\n renderCard={(p) => <StoreProductCard product={p} />}\n />\n );\n}\n' }, { "path": "app/shop/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { cacheTag, cacheLife } from "next/cache";\nimport { getServerClient, tags } from "@cimplify/sdk/server";\nimport { ShopClient } from "./shop-client";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Shop \u2014 ${brand.name}`,\n description: brand.description,\n};\n\nasync function getShopData() {\n "use cache";\n cacheTag(tags.products(), tags.categories());\n cacheLife("hours");\n\n const client = getServerClient();\n const [p, c] = await Promise.all([\n client.catalogue.getProducts({ limit: 50 }),\n client.catalogue.getCategories(),\n ]);\n return {\n products: p.ok ? p.value.items : [],\n categories: c.ok ? c.value : [],\n };\n}\n\nexport default async function ShopPage() {\n const { products, categories } = await getShopData();\n return <ShopClient products={products} categories={categories} />;\n}\n' }, { "path": "app/returns/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\nimport { PolicyPage } from "@/components/policy-page";\n\nexport const metadata: Metadata = {\n title: `${brand.returns.title} \u2014 ${brand.name}`,\n};\n\nexport default function ReturnsPage() {\n return <PolicyPage policy={brand.returns} />;\n}\n' }, { "path": "app/layout.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Inter, Lora } from "next/font/google";\nimport "./globals.css";\nimport { Providers } from "@/components/providers";\nimport { Header } from "@/components/header";\nimport { Footer } from "@/components/footer";\nimport { ProductModal } from "@/components/product-modal";\nimport { CartDrawer } from "@/components/cart-drawer";\nimport { OrganizationJsonLd } from "@/components/json-ld";\nimport { Suspense } from "react";\nimport { brand } from "@/lib/brand";\nimport { getSiteUrl } from "@/lib/site-url";\n\nconst inter = Inter({\n subsets: ["latin"],\n variable: "--font-sans",\n display: "swap",\n});\n\nconst lora = Lora({\n subsets: ["latin"],\n variable: "--font-serif",\n display: "swap",\n});\n\nexport async function generateMetadata(): Promise<Metadata> {\n const siteUrl = await getSiteUrl();\n return {\n metadataBase: new URL(siteUrl),\n title: {\n default: brand.name,\n template: `%s \u2014 ${brand.name}`,\n },\n description: brand.description,\n openGraph: {\n type: "website",\n siteName: brand.name,\n locale: brand.locale,\n },\n twitter: { card: "summary_large_image" },\n };\n}\n\nexport default function RootLayout({ children }: { children: React.ReactNode }) {\n return (\n <html lang="en" suppressHydrationWarning className={`${inter.variable} ${lora.variable}`}>\n <body\n suppressHydrationWarning\n className="min-h-screen flex flex-col bg-background text-foreground font-sans"\n >\n <Suspense fallback={null}>\n <OrganizationJsonLd />\n </Suspense>\n <Providers>\n <Header />\n <main className="flex-1 pb-12 w-full">{children}</main>\n <Footer />\n <Suspense fallback={null}>\n <ProductModal />\n </Suspense>\n <CartDrawer />\n </Providers>\n </body>\n </html>\n );\n}\n' }, { "path": "app/checkout/page.tsx", "kind": "text", "content": '"use client";\n\nimport { useRouter } from "next/navigation";\nimport { CheckoutPage as SdkCheckoutPage } from "@cimplify/sdk/react";\n\nexport default function CheckoutPage() {\n const router = useRouter();\n return (\n <SdkCheckoutPage\n onComplete={(result) => {\n if (result.success && result.order) {\n router.push(`/orders/${result.order.id}`);\n }\n }}\n />\n );\n}\n' }, { "path": "app/privacy/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Privacy Policy \u2014 ${brand.name}`,\n description: `How ${brand.name} collects, uses, and protects your personal data.`,\n};\n\nexport default function PrivacyPage() {\n const p = brand.privacy;\n return (\n <article className="max-w-3xl mx-auto px-6 sm:px-8 py-16 prose prose-lg max-w-none">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-2 not-prose">\n {p.eyebrow}\n </p>\n <h1 className="font-serif text-[clamp(2.25rem,5vw,3.5rem)] font-semibold mb-2 -tracking-[0.02em]">\n {p.title}\n </h1>\n <p className="text-sm text-muted-foreground not-prose mb-10">\n Last updated: {p.lastUpdated}\n </p>\n\n <section className="space-y-5 leading-relaxed text-foreground/90">\n {p.sections.map((s) => (\n <div key={s.heading}>\n <h2 className="font-serif text-2xl font-semibold mt-0">{s.heading}</h2>\n {typeof s.body === "string" ? (\n <p>{s.body}</p>\n ) : (\n <>\n <p>{s.body.intro}</p>\n <ul className="list-disc pl-6 space-y-2">\n {s.body.bullets.map((b) => (\n <li key={b}>{b}</li>\n ))}\n </ul>\n </>\n )}\n </div>\n ))}\n </section>\n </article>\n );\n}\n' }, { "path": "app/collections/[slug]/listing-client.tsx", "kind": "text", "content": '"use client";\n\nimport { ProductGrid } from "@cimplify/sdk/react";\nimport type { Product } from "@cimplify/sdk";\nimport { StoreProductCard } from "@/components/store-product-card";\n\n/**\n * Client island for the collection listing. Receives server-fetched\n * products as props (serializable) and owns the `renderCard` function\n * (which can\'t cross the server/client boundary).\n */\nexport function ListingClient({ products }: { products: Product[] }) {\n return (\n <ProductGrid\n products={products}\n emptyMessage="No products in this collection yet."\n renderCard={(p) => <StoreProductCard product={p} />}\n />\n );\n}\n' }, { "path": "app/collections/[slug]/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Suspense } from "react";\nimport Link from "next/link";\nimport { notFound } from "next/navigation";\nimport { cacheTag, cacheLife } from "next/cache";\nimport {\n getServerClient,\n tags,\n type Collection,\n type Product,\n} from "@cimplify/sdk/server";\nimport { ListingClient } from "./listing-client";\nimport { brand } from "@/lib/brand";\n\ninterface CollectionData {\n collection: Collection;\n products: Product[];\n}\n\nasync function getCollection(slug: string): Promise<CollectionData | null> {\n "use cache";\n cacheTag(tags.collections());\n cacheLife("hours");\n\n const client = getServerClient();\n const colRes = await client.catalogue.getCollectionBySlug(slug);\n if (!colRes.ok) return null;\n\n cacheTag(tags.collection(colRes.value.id), tags.collectionProducts(colRes.value.id));\n const r = await client.catalogue.getCollectionProducts(colRes.value.id);\n const products = r.ok\n ? ((r.value as { items?: Product[] }).items ?? (r.value as Product[]))\n : [];\n return { collection: colRes.value, products };\n}\n\nexport async function generateMetadata({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}): Promise<Metadata> {\n const { slug } = await params;\n const data = await getCollection(slug);\n if (!data) return {};\n return {\n title: `${data.collection.name} \u2014 ${brand.name}`,\n description: data.collection.description ?? undefined,\n };\n}\n\nexport default async function CollectionPage({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}) {\n return (\n <Suspense fallback={<CollectionSkeleton />}>\n <CollectionContent params={params} />\n </Suspense>\n );\n}\n\nasync function CollectionContent({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}) {\n const { slug } = await params;\n const data = await getCollection(slug);\n if (!data) notFound();\n\n const { collection, products } = data;\n return (\n <section className="max-w-7xl mx-auto px-6 sm:px-8 pt-12">\n <header className="mb-8 text-center">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-2">\n Collection\n </p>\n <h1 className="font-serif text-[clamp(2rem,4vw,2.75rem)] font-semibold mb-2">\n {collection.name}\n </h1>\n {collection.description && (\n <p className="mx-auto mb-2 max-w-xl text-muted-foreground">\n {collection.description}\n </p>\n )}\n <p className="text-sm text-muted-foreground">\n {products.length} item{products.length === 1 ? "" : "s"}\n </p>\n </header>\n <ListingClient products={products} />\n {products.length === 0 && (\n <p className="text-center mt-8">\n <Link href="/" className="text-primary font-semibold">\n \u2190 Back home\n </Link>\n </p>\n )}\n </section>\n );\n}\n\nfunction CollectionSkeleton() {\n return (\n <section className="max-w-7xl mx-auto px-6 sm:px-8 pt-12">\n <header className="mb-8 text-center">\n <div className="mx-auto h-3 w-20 bg-muted rounded mb-2 animate-pulse" />\n <div className="mx-auto h-10 w-64 bg-muted rounded mb-2 animate-pulse" />\n <div className="mx-auto h-4 w-80 bg-muted rounded animate-pulse" />\n </header>\n <div className="grid grid-cols-2 md:grid-cols-3 gap-4">\n {Array.from({ length: 6 }).map((_, i) => (\n <div key={i} className="aspect-square bg-muted rounded-2xl animate-pulse" />\n ))}\n </div>\n </section>\n );\n}\n' }, { "path": "package.json", "kind": "text", "content": '{\n "name": "__STOREFRONT_NAME__",\n "private": true,\n "version": "0.0.0",\n "scripts": {\n "dev": "concurrently -k -n mock,next -c blue,magenta \\"cimplify-mock --seed restaurant --quiet\\" \\"next dev\\"",\n "dev:storefront": "next dev",\n "dev:mock": "cimplify-mock --seed restaurant",\n "build": "next build",\n "start": "next start",\n "typecheck": "tsc --noEmit",\n "test": "vitest",\n "test:run": "vitest run",\n "check:brand": "vitest run __tests__/brand.test.ts",\n "check:cart": "vitest run __tests__/cart-flow.test.ts",\n "check:contract": "vitest run __tests__/contract.test.ts",\n "check": "bun run typecheck && bun run test:run"\n },\n "dependencies": {\n "@cimplify/sdk": "^0.48.0",\n "next": "^16.2.4",\n "react": "^19.0.0",\n "react-dom": "^19.0.0"\n },\n "devDependencies": {\n "@tailwindcss/postcss": "^4.2.4",\n "@types/node": "^22.10.0",\n "@types/react": "^19.0.0",\n "@types/react-dom": "^19.0.0",\n "concurrently": "^9.0.0",\n "tailwindcss": "^4.2.4",\n "typescript": "^5.9.3",\n "vitest": "^4.1.5"\n }\n}\n' }, { "path": "lib/brand.ts", "kind": "text", "content": `/**
|
|
3155
|
+
` }, { "path": "app/sitemap-page/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport Link from "next/link";\nimport { cacheTag, cacheLife } from "next/cache";\nimport { getServerClient, tags, type Product } from "@cimplify/sdk/server";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Sitemap \u2014 ${brand.name}`,\n description: "A human-readable index of every page on this site.",\n};\n\ninterface SitemapData {\n products: { slug: string; name: string }[];\n categories: { slug: string; name: string }[];\n collections: { slug: string; name: string }[];\n}\n\nasync function getSitemap(): Promise<SitemapData> {\n "use cache";\n cacheTag(tags.products(), tags.categories(), tags.collections());\n cacheLife("hours");\n\n const client = getServerClient();\n const [pRes, cRes, colRes] = await Promise.all([\n client.catalogue.getProducts({ limit: 500 }),\n client.catalogue.getCategories(),\n client.catalogue.getCollections(),\n ]);\n return {\n products: (pRes.ok ? pRes.value.items : []).map((p: Product) => ({\n slug: p.slug ?? p.id,\n name: p.name,\n })),\n categories: (cRes.ok ? cRes.value : []).map((c) => ({ slug: c.slug, name: c.name })),\n collections: (colRes.ok ? colRes.value : []).map((c) => ({ slug: c.slug, name: c.name })),\n };\n}\n\nconst STATIC_LINKS: { title: string; links: { href: string; label: string }[] }[] = [\n {\n title: "Browse",\n links: [\n { href: "/", label: "Home" },\n { href: "/shop", label: "Shop" },\n { href: "/search", label: "Search" },\n ],\n },\n {\n title: "Account",\n links: [\n { href: "/account", label: "Account" },\n { href: "/account/orders", label: "Orders" },\n { href: "/account/addresses", label: "Addresses" },\n { href: "/account/settings", label: "Settings" },\n { href: "/login", label: "Sign in" },\n { href: "/signup", label: "Create account" },\n { href: "/track-order", label: "Track an order" },\n { href: "/cart", label: "Cart" },\n { href: "/checkout", label: "Checkout" },\n ],\n },\n {\n title: "About",\n links: [\n { href: "/about", label: "About" },\n { href: "/faq", label: "FAQ" },\n { href: "/contact", label: "Contact" },\n ],\n },\n {\n title: "Policies",\n links: [\n { href: "/shipping", label: "Shipping" },\n { href: "/returns", label: "Returns" },\n { href: "/accessibility", label: "Accessibility" },\n { href: "/terms", label: "Terms of Service" },\n { href: "/privacy", label: "Privacy Policy" },\n ],\n },\n {\n title: "Machine-readable",\n links: [\n { href: "/sitemap.xml", label: "sitemap.xml (search engines)" },\n { href: "/llms.txt", label: "llms.txt (LLM agents)" },\n { href: "/robots.txt", label: "robots.txt" },\n { href: "/opensearch.xml", label: "opensearch.xml (browser search)" },\n ],\n },\n];\n\nexport default async function SitemapHtmlPage() {\n const { products, categories, collections } = await getSitemap();\n\n return (\n <article className="max-w-5xl mx-auto px-6 sm:px-8 py-16">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-2">\n Sitemap\n </p>\n <h1 className="text-[clamp(2rem,4vw,2.75rem)] font-semibold mb-2 -tracking-[0.02em]">\n Every page, in one place.\n </h1>\n <p className="text-muted-foreground mb-12">\n For search engines, see <Link href="/sitemap.xml" className="text-primary hover:underline">/sitemap.xml</Link>.\n For LLM agents, see <Link href="/llms.txt" className="text-primary hover:underline">/llms.txt</Link>.\n </p>\n <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-10">\n {STATIC_LINKS.map((s) => (\n <Section key={s.title} title={s.title}>\n {s.links.map((l) => (\n <li key={l.href}>\n <Link href={l.href} className="hover:text-primary transition-colors">\n {l.label}\n </Link>\n </li>\n ))}\n </Section>\n ))}\n {categories.length > 0 && (\n <Section title="Categories">\n {categories.map((c) => (\n <li key={c.slug}>\n <Link href={`/categories/${c.slug}`} className="hover:text-primary transition-colors">\n {c.name}\n </Link>\n </li>\n ))}\n </Section>\n )}\n {collections.length > 0 && (\n <Section title="Collections">\n {collections.map((c) => (\n <li key={c.slug}>\n <Link href={`/collections/${c.slug}`} className="hover:text-primary transition-colors">\n {c.name}\n </Link>\n </li>\n ))}\n </Section>\n )}\n {products.length > 0 && (\n <Section title={`Products (${products.length})`}>\n {products.map((p) => (\n <li key={p.slug}>\n <Link href={`/shop?product=${encodeURIComponent(p.slug)}`} className="hover:text-primary transition-colors">\n {p.name}\n </Link>\n </li>\n ))}\n </Section>\n )}\n </div>\n </article>\n );\n}\n\nfunction Section({ title, children }: { title: string; children: React.ReactNode }) {\n return (\n <div>\n <p className="font-semibold text-[12px] uppercase tracking-[0.12em] text-foreground mb-3">\n {title}\n </p>\n <ul className="space-y-2 m-0 p-0 list-none text-sm text-muted-foreground">\n {children}\n </ul>\n </div>\n );\n}\n' }, { "path": "app/shop/shop-client.tsx", "kind": "text", "content": '"use client";\n\nimport { CataloguePage } from "@cimplify/sdk/react";\nimport type { Category, Product } from "@cimplify/sdk";\nimport { StoreProductCard } from "@/components/store-product-card";\n\n/**\n * Client island for the shop page. Server-side fetches all products and\n * categories (cached via `\'use cache\'` in `app/shop/page.tsx`), then hands\n * them to `<CataloguePage>` which owns the interactive filter / sort state.\n */\nexport function ShopClient({\n products,\n categories,\n}: {\n products: Product[];\n categories: Category[];\n}) {\n return (\n <CataloguePage\n title="The Menu"\n products={products}\n categories={categories}\n renderCard={(p) => <StoreProductCard product={p} />}\n />\n );\n}\n' }, { "path": "app/shop/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { cacheTag, cacheLife } from "next/cache";\nimport { getServerClient, tags } from "@cimplify/sdk/server";\nimport { ShopClient } from "./shop-client";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Shop \u2014 ${brand.name}`,\n description: brand.description,\n};\n\nasync function getShopData() {\n "use cache";\n cacheTag(tags.products(), tags.categories());\n cacheLife("hours");\n\n const client = getServerClient();\n const [p, c] = await Promise.all([\n client.catalogue.getProducts({ limit: 50 }),\n client.catalogue.getCategories(),\n ]);\n return {\n products: p.ok ? p.value.items : [],\n categories: c.ok ? c.value : [],\n };\n}\n\nexport default async function ShopPage() {\n const { products, categories } = await getShopData();\n return <ShopClient products={products} categories={categories} />;\n}\n' }, { "path": "app/returns/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\nimport { PolicyPage } from "@/components/policy-page";\n\nexport const metadata: Metadata = {\n title: `${brand.returns.title} \u2014 ${brand.name}`,\n};\n\nexport default function ReturnsPage() {\n return <PolicyPage policy={brand.returns} />;\n}\n' }, { "path": "app/layout.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Inter, Lora } from "next/font/google";\nimport "./globals.css";\nimport { Providers } from "@/components/providers";\nimport { Header } from "@/components/header";\nimport { Footer } from "@/components/footer";\nimport { ProductModal } from "@/components/product-modal";\nimport { CartDrawer } from "@/components/cart-drawer";\nimport { OrganizationJsonLd } from "@/components/json-ld";\nimport { Suspense } from "react";\nimport { brand } from "@/lib/brand";\nimport { getSiteUrl } from "@/lib/site-url";\n\nconst inter = Inter({\n subsets: ["latin"],\n variable: "--font-sans",\n display: "swap",\n});\n\nconst lora = Lora({\n subsets: ["latin"],\n variable: "--font-serif",\n display: "swap",\n});\n\nexport async function generateMetadata(): Promise<Metadata> {\n const siteUrl = await getSiteUrl();\n return {\n metadataBase: new URL(siteUrl),\n title: {\n default: brand.name,\n template: `%s \u2014 ${brand.name}`,\n },\n description: brand.description,\n openGraph: {\n type: "website",\n siteName: brand.name,\n locale: brand.locale,\n },\n twitter: { card: "summary_large_image" },\n };\n}\n\nexport default function RootLayout({ children }: { children: React.ReactNode }) {\n return (\n <html lang="en" suppressHydrationWarning className={`${inter.variable} ${lora.variable}`}>\n <body\n suppressHydrationWarning\n className="min-h-screen flex flex-col bg-background text-foreground font-sans"\n >\n <Suspense fallback={null}>\n <OrganizationJsonLd />\n </Suspense>\n <Providers>\n <Header />\n <main className="flex-1 pb-12 w-full">{children}</main>\n <Footer />\n <Suspense fallback={null}>\n <ProductModal />\n </Suspense>\n <CartDrawer />\n </Providers>\n </body>\n </html>\n );\n}\n' }, { "path": "app/checkout/page.tsx", "kind": "text", "content": '"use client";\n\nimport { useRouter } from "next/navigation";\nimport { CheckoutPage as SdkCheckoutPage } from "@cimplify/sdk/react";\n\nexport default function CheckoutPage() {\n const router = useRouter();\n return (\n <SdkCheckoutPage\n onComplete={(result) => {\n if (result.success && result.order) {\n router.push(`/orders/${result.order.id}`);\n }\n }}\n />\n );\n}\n' }, { "path": "app/privacy/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Privacy Policy \u2014 ${brand.name}`,\n description: `How ${brand.name} collects, uses, and protects your personal data.`,\n};\n\nexport default function PrivacyPage() {\n const p = brand.privacy;\n return (\n <article className="max-w-3xl mx-auto px-6 sm:px-8 py-16 prose prose-lg max-w-none">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-2 not-prose">\n {p.eyebrow}\n </p>\n <h1 className="font-serif text-[clamp(2.25rem,5vw,3.5rem)] font-semibold mb-2 -tracking-[0.02em]">\n {p.title}\n </h1>\n <p className="text-sm text-muted-foreground not-prose mb-10">\n Last updated: {p.lastUpdated}\n </p>\n\n <section className="space-y-5 leading-relaxed text-foreground/90">\n {p.sections.map((s) => (\n <div key={s.heading}>\n <h2 className="font-serif text-2xl font-semibold mt-0">{s.heading}</h2>\n {typeof s.body === "string" ? (\n <p>{s.body}</p>\n ) : (\n <>\n <p>{s.body.intro}</p>\n <ul className="list-disc pl-6 space-y-2">\n {s.body.bullets.map((b) => (\n <li key={b}>{b}</li>\n ))}\n </ul>\n </>\n )}\n </div>\n ))}\n </section>\n </article>\n );\n}\n' }, { "path": "app/collections/[slug]/listing-client.tsx", "kind": "text", "content": '"use client";\n\nimport { ProductGrid } from "@cimplify/sdk/react";\nimport type { Product } from "@cimplify/sdk";\nimport { StoreProductCard } from "@/components/store-product-card";\n\n/**\n * Client island for the collection listing. Receives server-fetched\n * products as props (serializable) and owns the `renderCard` function\n * (which can\'t cross the server/client boundary).\n */\nexport function ListingClient({ products }: { products: Product[] }) {\n return (\n <ProductGrid\n products={products}\n emptyMessage="No products in this collection yet."\n renderCard={(p) => <StoreProductCard product={p} />}\n />\n );\n}\n' }, { "path": "app/collections/[slug]/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Suspense } from "react";\nimport Link from "next/link";\nimport { notFound } from "next/navigation";\nimport { cacheTag, cacheLife } from "next/cache";\nimport {\n getServerClient,\n tags,\n type Collection,\n type Product,\n} from "@cimplify/sdk/server";\nimport { ListingClient } from "./listing-client";\nimport { brand } from "@/lib/brand";\n\ninterface CollectionData {\n collection: Collection;\n products: Product[];\n}\n\nasync function getCollection(slug: string): Promise<CollectionData | null> {\n "use cache";\n cacheTag(tags.collections());\n cacheLife("hours");\n\n const client = getServerClient();\n const colRes = await client.catalogue.getCollectionBySlug(slug);\n if (!colRes.ok) return null;\n\n cacheTag(tags.collection(colRes.value.id), tags.collectionProducts(colRes.value.id));\n const r = await client.catalogue.getCollectionProducts(colRes.value.id);\n const products = r.ok\n ? ((r.value as { items?: Product[] }).items ?? (r.value as Product[]))\n : [];\n return { collection: colRes.value, products };\n}\n\nexport async function generateMetadata({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}): Promise<Metadata> {\n const { slug } = await params;\n const data = await getCollection(slug);\n if (!data) return {};\n return {\n title: `${data.collection.name} \u2014 ${brand.name}`,\n description: data.collection.description ?? undefined,\n };\n}\n\nexport default async function CollectionPage({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}) {\n return (\n <Suspense fallback={<CollectionSkeleton />}>\n <CollectionContent params={params} />\n </Suspense>\n );\n}\n\nasync function CollectionContent({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}) {\n const { slug } = await params;\n const data = await getCollection(slug);\n if (!data) notFound();\n\n const { collection, products } = data;\n return (\n <section className="max-w-7xl mx-auto px-6 sm:px-8 pt-12">\n <header className="mb-8 text-center">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-2">\n Collection\n </p>\n <h1 className="font-serif text-[clamp(2rem,4vw,2.75rem)] font-semibold mb-2">\n {collection.name}\n </h1>\n {collection.description && (\n <p className="mx-auto mb-2 max-w-xl text-muted-foreground">\n {collection.description}\n </p>\n )}\n <p className="text-sm text-muted-foreground">\n {products.length} item{products.length === 1 ? "" : "s"}\n </p>\n </header>\n <ListingClient products={products} />\n {products.length === 0 && (\n <p className="text-center mt-8">\n <Link href="/" className="text-primary font-semibold">\n \u2190 Back home\n </Link>\n </p>\n )}\n </section>\n );\n}\n\nfunction CollectionSkeleton() {\n return (\n <section className="max-w-7xl mx-auto px-6 sm:px-8 pt-12">\n <header className="mb-8 text-center">\n <div className="mx-auto h-3 w-20 bg-muted rounded mb-2 animate-pulse" />\n <div className="mx-auto h-10 w-64 bg-muted rounded mb-2 animate-pulse" />\n <div className="mx-auto h-4 w-80 bg-muted rounded animate-pulse" />\n </header>\n <div className="grid grid-cols-2 md:grid-cols-3 gap-4">\n {Array.from({ length: 6 }).map((_, i) => (\n <div key={i} className="aspect-square bg-muted rounded-2xl animate-pulse" />\n ))}\n </div>\n </section>\n );\n}\n' }, { "path": "package.json", "kind": "text", "content": '{\n "name": "__STOREFRONT_NAME__",\n "private": true,\n "version": "0.0.0",\n "scripts": {\n "dev": "concurrently -k -n mock,next -c blue,magenta \\"cimplify-mock --seed restaurant --quiet\\" \\"next dev\\"",\n "dev:storefront": "next dev",\n "dev:mock": "cimplify-mock --seed restaurant",\n "build": "next build",\n "start": "next start",\n "typecheck": "tsc --noEmit",\n "test": "vitest",\n "test:run": "vitest run",\n "check:brand": "vitest run __tests__/brand.test.ts",\n "check:cart": "vitest run __tests__/cart-flow.test.ts",\n "check:contract": "vitest run __tests__/contract.test.ts",\n "check": "bun run typecheck && bun run test:run"\n },\n "dependencies": {\n "@cimplify/sdk": "^0.48.1",\n "next": "^16.2.4",\n "react": "^19.0.0",\n "react-dom": "^19.0.0"\n },\n "devDependencies": {\n "@tailwindcss/postcss": "^4.2.4",\n "@types/node": "^22.10.0",\n "@types/react": "^19.0.0",\n "@types/react-dom": "^19.0.0",\n "concurrently": "^9.0.0",\n "tailwindcss": "^4.2.4",\n "typescript": "^5.9.3",\n "vitest": "^4.1.5"\n }\n}\n' }, { "path": "lib/brand.ts", "kind": "text", "content": `/**
|
|
3156
3156
|
* Brand & content configuration \u2014 single source of truth for every visible
|
|
3157
3157
|
* string. Edit this file to rebrand. See ../AGENTS.md.
|
|
3158
3158
|
*/
|
|
@@ -3638,7 +3638,7 @@ function Field({
|
|
|
3638
3638
|
</div>
|
|
3639
3639
|
);
|
|
3640
3640
|
}
|
|
3641
|
-
` }, { "path": "app/terms/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Terms of Service \u2014 ${brand.name}`,\n description: `The rules of the road for ordering from ${brand.name}.`,\n};\n\nexport default function TermsPage() {\n const t = brand.terms;\n return (\n <article className="max-w-3xl mx-auto px-6 sm:px-8 py-16 prose prose-lg max-w-none">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-2 not-prose">\n {t.eyebrow}\n </p>\n <h1 className="font-serif text-[clamp(2.25rem,5vw,3.5rem)] font-semibold mb-2 -tracking-[0.02em]">\n {t.title}\n </h1>\n <p className="text-sm text-muted-foreground not-prose mb-10">\n Last updated: {t.lastUpdated}\n </p>\n\n <section className="space-y-5 leading-relaxed text-foreground/90">\n {t.sections.map((s) => (\n <div key={s.heading}>\n <h2 className="font-serif text-2xl font-semibold mt-0">{s.heading}</h2>\n {typeof s.body === "string" ? (\n <p>{s.body}</p>\n ) : (\n <>\n <p>{s.body.intro}</p>\n <ul className="list-disc pl-6 space-y-2">\n {s.body.bullets.map((b) => (\n <li key={b}>{b}</li>\n ))}\n </ul>\n </>\n )}\n </div>\n ))}\n </section>\n </article>\n );\n}\n' }, { "path": "app/not-found.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport Link from "next/link";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Page not found \u2014 ${brand.name}`,\n description: "We couldn\'t find that page.",\n};\n\nexport default function NotFound() {\n return (\n <section className="max-w-2xl mx-auto px-6 sm:px-8 py-20 text-center">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-3">\n 404\n </p>\n <h1 className="font-serif text-[clamp(2.5rem,5vw,3.5rem)] font-semibold mb-4">\n Page not found.\n </h1>\n <p className="text-muted-foreground leading-relaxed mb-8">\n The URL you followed might be old, a typo, or pointing at a product\n that's sold out for the day. Try the menu or head back home.\n </p>\n <div className="flex flex-wrap items-center justify-center gap-3">\n <Link\n href="/shop"\n className="inline-block px-6 py-3 rounded-full bg-primary text-primary-foreground font-semibold text-sm transition-colors hover:bg-primary/90"\n >\n Browse the menu\n </Link>\n <Link\n href="/"\n className="inline-block px-6 py-3 rounded-full border border-border hover:bg-muted transition-colors text-sm font-medium"\n >\n Back home\n </Link>\n </div>\n </section>\n );\n}\n' }, { "path": "app/orders/[id]/page.tsx", "kind": "text", "content": 'import Link from "next/link";\n\nexport default async function OrderPage({ params }: { params: Promise<{ id: string }> }) {\n const { id } = await params;\n return (\n <section className="max-w-2xl mx-auto px-6 sm:px-8 py-20 text-center">\n <h1 className="font-serif text-3xl mt-0 mb-3">Thanks \u2014 your order is confirmed</h1>\n <p className="text-muted-foreground">\n Order <code className="font-mono text-foreground">{id}</code>\n </p>\n <p className="mt-6">\n <Link\n href="/"\n className="inline-block px-6 py-3 rounded-full bg-primary text-primary-foreground font-semibold text-sm transition-colors hover:bg-primary/90"\n >\n Continue shopping\n </Link>\n </p>\n </section>\n );\n}\n' }, { "path": "app/book/book-client.tsx", "kind": "text", "content": '"use client";\n\nimport { useState } from "react";\nimport { useRouter } from "next/navigation";\nimport type { Product } from "@cimplify/sdk";\nimport type { AvailableSlot } from "@cimplify/sdk";\nimport { useCart, DateSlotPicker } from "@cimplify/sdk/react";\n\n/**\n * Booking flow:\n * 1. Pick a treatment (left rail) \u2014 drives the SDK availability fetch.\n * 2. SDK <DateSlotPicker> handles date + slot selection, fetching real\n * availability via `useServiceAvailability` against the configured\n * backend (mock in dev, Cimplify scheduling API in prod).\n * 3. Add to cart with the chosen slot as a cart-item note;\n * Cimplify Checkout finalises the booking.\n */\nexport function BookClient({ treatments }: { treatments: Product[] }) {\n const router = useRouter();\n const { addItem } = useCart();\n const [selectedTreatment, setSelectedTreatment] = useState<Product | undefined>(\n treatments[0],\n );\n const [selectedSlot, setSelectedSlot] = useState<AvailableSlot | null>(null);\n const [submitting, setSubmitting] = useState(false);\n\n async function confirm() {\n if (!selectedTreatment || !selectedSlot) return;\n setSubmitting(true);\n try {\n const slotLabel = new Date(selectedSlot.start_time).toLocaleString(undefined, {\n weekday: "short",\n month: "short",\n day: "numeric",\n hour: "2-digit",\n minute: "2-digit",\n });\n await addItem(selectedTreatment, 1, {\n specialInstructions: `Booked for ${slotLabel}`,\n });\n router.push("/checkout");\n } catch {\n setSubmitting(false);\n }\n }\n\n if (treatments.length === 0) {\n return (\n <p className="text-muted-foreground">\n No bookable treatments yet. Add a Service-type product to your catalogue first.\n </p>\n );\n }\n\n return (\n <div className="grid grid-cols-1 lg:grid-cols-[1fr_1.4fr] gap-8">\n {/* Treatments */}\n <div>\n <p className="text-[11px] font-mono uppercase tracking-[0.16em] text-muted-foreground mb-3">\n Treatment\n </p>\n <div className="space-y-2">\n {treatments.map((t) => {\n const active = selectedTreatment?.id === t.id;\n return (\n <button\n key={t.id}\n type="button"\n onClick={() => {\n setSelectedTreatment(t);\n setSelectedSlot(null);\n }}\n className={[\n "w-full text-left rounded-2xl border p-4 transition-colors",\n active\n ? "border-primary bg-primary/5"\n : "border-border bg-card hover:border-foreground/30",\n ].join(" ")}\n >\n <div className="flex items-center justify-between gap-3">\n <div className="min-w-0">\n <p className="font-semibold text-sm m-0 truncate">{t.name}</p>\n <p className="text-xs text-muted-foreground m-0">\n {t.duration_minutes ? `${t.duration_minutes} min \xB7 ` : ""}\n {t.currency ?? "GHS"} {t.default_price}\n </p>\n </div>\n {active && (\n <span className="grid place-items-center w-6 h-6 rounded-full bg-primary text-primary-foreground text-xs">\n \u2713\n </span>\n )}\n </div>\n </button>\n );\n })}\n </div>\n </div>\n\n {/* Date + slots \u2014 SDK <DateSlotPicker> fetches real availability */}\n <div className="rounded-2xl border border-border bg-card p-4 sm:p-6">\n {selectedTreatment ? (\n <DateSlotPicker\n serviceId={selectedTreatment.id}\n selectedSlot={selectedSlot}\n onSlotSelect={(slot) => setSelectedSlot(slot)}\n daysToShow={selectedTreatment.scheduling_mode === "multi_day" ? 14 : 7}\n schedulingMode={selectedTreatment.scheduling_mode}\n durationUnit={selectedTreatment.duration_unit}\n durationValue={selectedTreatment.duration_value}\n />\n ) : (\n <p className="text-sm text-muted-foreground">Pick a treatment to see availability.</p>\n )}\n\n <button\n type="button"\n onClick={confirm}\n disabled={!selectedTreatment || !selectedSlot || submitting}\n className="w-full mt-6 inline-flex items-center justify-center gap-2 px-5 py-3 rounded-md bg-primary text-primary-foreground font-semibold text-sm hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"\n >\n {submitting\n ? "Confirming\u2026"\n : selectedSlot\n ? `Book ${selectedTreatment?.name} at ${new Date(selectedSlot.start_time).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" })}`\n : "Pick a slot to book"}\n </button>\n </div>\n </div>\n );\n}\n' }, { "path": "app/book/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Suspense } from "react";\nimport { cacheTag, cacheLife } from "next/cache";\nimport { getServerClient, tags, type Product } from "@cimplify/sdk/server";\nimport { BookClient } from "./book-client";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Book a treatment \u2014 ${brand.name}`,\n description: "Pick a treatment, pick a slot, you\'re booked.",\n};\n\nasync function getTreatments(): Promise<Product[]> {\n "use cache";\n cacheTag(tags.products());\n cacheLife("hours");\n\n const client = getServerClient();\n const r = await client.catalogue.getProducts({ limit: 50 });\n if (!r.ok) return [];\n // Booking flow only handles service-typed products.\n return r.value.items.filter((p) => p.type === "service");\n}\n\nexport default async function BookPage() {\n const treatments = await getTreatments();\n return (\n <article className="max-w-5xl mx-auto px-6 sm:px-8 py-14">\n <p className="text-[11px] font-mono uppercase tracking-[0.16em] text-primary mb-2">\n Book a treatment\n </p>\n <h1 className="text-[clamp(2rem,5vw,3rem)] font-semibold mb-3 -tracking-[0.02em]">\n Pick a treatment.<br />Pick a slot.\n </h1>\n <p className="text-muted-foreground leading-relaxed mb-10 max-w-xl">\n Available windows for the next two weeks. Confirmation by SMS within a minute of\n booking. Free cancellation up to 24 hours before.\n </p>\n\n <Suspense fallback={<BookSkeleton />}>\n <BookClient treatments={treatments} />\n </Suspense>\n </article>\n );\n}\n\nfunction BookSkeleton() {\n return (\n <div className="grid grid-cols-1 lg:grid-cols-[1fr_1.4fr] gap-8">\n <div className="space-y-2">\n {Array.from({ length: 6 }).map((_, i) => (\n <div key={i} className="h-16 bg-muted rounded-2xl animate-pulse" />\n ))}\n </div>\n <div className="rounded-2xl border border-border bg-card p-8">\n <div className="h-5 w-40 bg-muted rounded mb-4 animate-pulse" />\n <div className="grid grid-cols-3 gap-2">\n {Array.from({ length: 9 }).map((_, i) => (\n <div key={i} className="h-10 bg-muted rounded animate-pulse" />\n ))}\n </div>\n </div>\n </div>\n );\n}\n' }, { "path": "app/login/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { redirect } from "next/navigation";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Sign in \u2014 ${brand.name}`,\n description: brand.account.loginSubtitle,\n};\n\n/**\n * Cimplify Link (the iframe in `<CimplifyAccount>`) handles sign-in\n * automatically when no session exists. We just bounce /login to /account\n * so consumers landing here get the right UI without a duplicate form.\n */\nexport default function LoginPage(): never {\n redirect("/account");\n}\n' }, { "path": "app/accessibility/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\nimport { PolicyPage } from "@/components/policy-page";\n\nexport const metadata: Metadata = {\n title: `${brand.accessibility.title} \u2014 ${brand.name}`,\n};\n\nexport default function AccessibilityPage() {\n return <PolicyPage policy={brand.accessibility} />;\n}\n' }, { "path": "app/shipping/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\nimport { PolicyPage } from "@/components/policy-page";\n\nexport const metadata: Metadata = {\n title: `${brand.shipping.title} \u2014 ${brand.name}`,\n description: brand.shipping.sections[0]?.body\n ? typeof brand.shipping.sections[0].body === "string"\n ? brand.shipping.sections[0].body\n : brand.shipping.sections[0].body.intro\n : undefined,\n};\n\nexport default function ShippingPage() {\n return <PolicyPage policy={brand.shipping} />;\n}\n' }, { "path": "app/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Suspense } from "react";\nimport { cacheTag, cacheLife } from "next/cache";\nimport { getServerClient, tags, type Product } from "@cimplify/sdk/server";\nimport { Hero } from "@/components/hero";\nimport { CollectionStrip } from "@/components/collection-strip";\nimport { CategoryGrid } from "@/components/category-grid";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `${brand.name} \u2014 ${brand.hero.title}`,\n description: brand.description,\n};\n\nasync function getHomeData() {\n "use cache";\n cacheTag(tags.collections(), tags.categories(), tags.products());\n cacheLife("hours");\n\n const client = getServerClient();\n const [colRes, catRes] = await Promise.all([\n client.catalogue.getCollections(),\n client.catalogue.getCategories(),\n ]);\n const collections = colRes.ok ? colRes.value : [];\n const categories = catRes.ok ? catRes.value : [];\n\n const collectionsWithProducts = await Promise.all(\n collections.map(async (col) => {\n const r = await client.catalogue.getCollectionProducts(col.id);\n const items = r.ok\n ? ((r.value as { items?: Product[] }).items ?? (r.value as Product[]))\n : [];\n return { collection: col, products: items };\n }),\n );\n\n return {\n collections: collectionsWithProducts.filter((x) => x.products.length > 0),\n categories,\n };\n}\n\nexport default async function HomePage() {\n const { collections, categories } = await getHomeData();\n\n return (\n <>\n <Hero\n badge={brand.hero.badge}\n title={brand.hero.title}\n subtitle={brand.hero.subtitle}\n />\n {collections.map(({ collection, products }) => (\n <Suspense\n key={collection.id}\n fallback={<StripSkeleton title={collection.name} />}\n >\n <CollectionStrip\n collection={collection}\n products={products}\n collectionHref={`/collections/${collection.slug}`}\n />\n </Suspense>\n ))}\n <Suspense fallback={<CategoryGridSkeleton />}>\n <CategoryGrid categories={categories} />\n </Suspense>\n </>\n );\n}\n\nfunction StripSkeleton({ title }: { title: string }) {\n return (\n <section className="max-w-7xl mx-auto px-6 sm:px-8 pt-12">\n <h2 className="font-serif text-[28px] font-semibold m-0 mb-5">{title}</h2>\n <div className="grid grid-flow-col auto-cols-[minmax(220px,1fr)] gap-4 overflow-x-auto pb-2">\n {Array.from({ length: 5 }).map((_, i) => (\n <div key={i} className="aspect-square bg-muted rounded-2xl animate-pulse" />\n ))}\n </div>\n </section>\n );\n}\n\nfunction CategoryGridSkeleton() {\n return (\n <section className="max-w-7xl mx-auto px-6 sm:px-8 pt-14">\n <div className="h-8 w-48 bg-muted rounded mb-5 animate-pulse" />\n <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">\n {Array.from({ length: 6 }).map((_, i) => (\n <div key={i} className="h-32 bg-muted rounded-2xl animate-pulse" />\n ))}\n </div>\n </section>\n );\n}\n' }, { "path": "app/categories/[slug]/listing-client.tsx", "kind": "text", "content": '"use client";\n\nimport { ProductGrid } from "@cimplify/sdk/react";\nimport type { Product } from "@cimplify/sdk";\nimport { StoreProductCard } from "@/components/store-product-card";\n\n/**\n * Client island for the category listing. Receives server-fetched products\n * as props (serializable) and owns the `renderCard` function.\n */\nexport function ListingClient({ products }: { products: Product[] }) {\n return (\n <ProductGrid\n products={products}\n emptyMessage="No products in this category yet."\n renderCard={(p) => <StoreProductCard product={p} />}\n />\n );\n}\n' }, { "path": "app/categories/[slug]/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Suspense } from "react";\nimport Link from "next/link";\nimport { notFound } from "next/navigation";\nimport { cacheTag, cacheLife } from "next/cache";\nimport {\n getServerClient,\n tags,\n type Category,\n type Product,\n} from "@cimplify/sdk/server";\nimport { ListingClient } from "./listing-client";\nimport { brand } from "@/lib/brand";\n\ninterface CategoryData {\n category: Category;\n products: Product[];\n}\n\nasync function getCategory(slug: string): Promise<CategoryData | null> {\n "use cache";\n cacheTag(tags.categories());\n cacheLife("hours");\n\n const client = getServerClient();\n const catRes = await client.catalogue.getCategoryBySlug(slug);\n if (!catRes.ok) return null;\n\n cacheTag(tags.category(catRes.value.id), tags.categoryProducts(catRes.value.id));\n const r = await client.catalogue.getCategoryProducts(catRes.value.id);\n const products = r.ok\n ? ((r.value as { items?: Product[] }).items ?? (r.value as Product[]))\n : [];\n return { category: catRes.value, products };\n}\n\nexport async function generateMetadata({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}): Promise<Metadata> {\n const { slug } = await params;\n const data = await getCategory(slug);\n if (!data) return {};\n return {\n title: `${data.category.name} \u2014 ${brand.name}`,\n description: data.category.description ?? undefined,\n };\n}\n\nexport default async function CategoryPage({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}) {\n return (\n <Suspense fallback={<CategorySkeleton />}>\n <CategoryContent params={params} />\n </Suspense>\n );\n}\n\nasync function CategoryContent({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}) {\n const { slug } = await params;\n const data = await getCategory(slug);\n if (!data) notFound();\n\n const { category, products } = data;\n return (\n <section className="max-w-7xl mx-auto px-6 sm:px-8 pt-12">\n <header className="mb-8 text-center">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-2">\n Category\n </p>\n <h1 className="font-serif text-[clamp(2rem,4vw,2.75rem)] font-semibold mb-2">\n {category.name}\n </h1>\n {category.description && (\n <p className="mx-auto mb-2 max-w-xl text-muted-foreground">\n {category.description}\n </p>\n )}\n <p className="text-sm text-muted-foreground">\n {products.length} item{products.length === 1 ? "" : "s"}\n </p>\n </header>\n <ListingClient products={products} />\n {products.length === 0 && (\n <p className="text-center mt-8">\n <Link href="/" className="text-primary font-semibold">\n \u2190 Back home\n </Link>\n </p>\n )}\n </section>\n );\n}\n\nfunction CategorySkeleton() {\n return (\n <section className="max-w-7xl mx-auto px-6 sm:px-8 pt-12">\n <header className="mb-8 text-center">\n <div className="mx-auto h-3 w-20 bg-muted rounded mb-2 animate-pulse" />\n <div className="mx-auto h-10 w-64 bg-muted rounded mb-2 animate-pulse" />\n <div className="mx-auto h-4 w-80 bg-muted rounded animate-pulse" />\n </header>\n <div className="grid grid-cols-2 md:grid-cols-3 gap-4">\n {Array.from({ length: 6 }).map((_, i) => (\n <div key={i} className="aspect-square bg-muted rounded-2xl animate-pulse" />\n ))}\n </div>\n </section>\n );\n}\n' }, { "path": "app/error.tsx", "kind": "text", "content": '"use client";\n\nimport { useEffect } from "react";\nimport Link from "next/link";\n\n/**\n * Root error boundary. Next.js calls this whenever a thrown error escapes\n * a Server Component without being caught by a closer `error.tsx`.\n *\n * Wire `reportError` (Sentry, Datadog, etc.) here so production failures\n * surface \u2014 silently recovering hides real bugs.\n */\nexport default function GlobalError({\n error,\n reset,\n}: {\n error: Error & { digest?: string };\n reset: () => void;\n}) {\n useEffect(() => {\n // TODO: replace with your error reporter\n // eslint-disable-next-line no-console\n console.error("Storefront error:", error);\n }, [error]);\n\n return (\n <section className="max-w-2xl mx-auto px-6 sm:px-8 py-20 text-center">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-3">\n Something broke\n </p>\n <h1 className="font-serif text-[clamp(2rem,4vw,2.75rem)] font-semibold mb-4">\n That wasn't supposed to happen.\n </h1>\n <p className="text-muted-foreground leading-relaxed mb-8">\n We've been notified. Refresh the page, or head back home \u2014\n most of the time the second try just works.\n </p>\n {error.digest && (\n <p className="text-xs font-mono text-muted-foreground mb-6">\n Reference: <code className="text-foreground">{error.digest}</code>\n </p>\n )}\n <div className="flex flex-wrap items-center justify-center gap-3">\n <button\n type="button"\n onClick={reset}\n className="inline-block px-6 py-3 rounded-full bg-primary text-primary-foreground font-semibold text-sm transition-colors hover:bg-primary/90"\n >\n Try again\n </button>\n <Link\n href="/"\n className="inline-block px-6 py-3 rounded-full border border-border hover:bg-muted transition-colors text-sm font-medium"\n >\n Back home\n </Link>\n </div>\n </section>\n );\n}\n' }, { "path": "app/globals.css", "kind": "text", "content": '@import "tailwindcss";\n\n@source "../node_modules/@cimplify/sdk/dist";\n\n/* Serene Spa \u2014 calm sand + sage palette, soft contrast.\n Tweak primary to retheme everything. */\n@theme {\n --color-background: oklch(0.98 0.012 90);\n --color-foreground: oklch(0.22 0.02 140);\n --color-card: oklch(1 0 0);\n --color-card-foreground: oklch(0.22 0.02 140);\n --color-popover: oklch(1 0 0);\n --color-popover-foreground: oklch(0.22 0.02 140);\n --color-primary: oklch(0.46 0.06 150);\n --color-primary-foreground: oklch(0.99 0.01 90);\n --color-secondary: oklch(0.93 0.025 90);\n --color-secondary-foreground: oklch(0.3 0.04 140);\n --color-muted: oklch(0.95 0.018 90);\n --color-muted-foreground: oklch(0.5 0.02 140);\n --color-accent: oklch(0.9 0.035 90);\n --color-accent-foreground: oklch(0.3 0.04 140);\n --color-destructive: oklch(0.58 0.24 27);\n --color-destructive-foreground: oklch(0.99 0 0);\n --color-border: oklch(0.91 0.018 90);\n --color-input: oklch(0.93 0.018 90);\n --color-ring: oklch(0.46 0.06 150);\n --radius: 1rem;\n}\n\n@layer base {\n html, body {\n margin: 0;\n padding: 0;\n background: var(--color-background);\n color: var(--color-foreground);\n -webkit-font-smoothing: antialiased;\n -moz-osx-font-smoothing: grayscale;\n }\n a { color: inherit; text-decoration: none; }\n h1, h2, h3, h4 {\n font-family: var(--font-serif);\n letter-spacing: -0.015em;\n font-weight: 500;\n }\n}\n' }, { "path": "app/sitemap-page/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport Link from "next/link";\nimport { cacheTag, cacheLife } from "next/cache";\nimport { getServerClient, tags, type Product } from "@cimplify/sdk/server";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Sitemap \u2014 ${brand.name}`,\n description: "A human-readable index of every page on this site.",\n};\n\ninterface SitemapData {\n products: { slug: string; name: string }[];\n categories: { slug: string; name: string }[];\n collections: { slug: string; name: string }[];\n}\n\nasync function getSitemap(): Promise<SitemapData> {\n "use cache";\n cacheTag(tags.products(), tags.categories(), tags.collections());\n cacheLife("hours");\n\n const client = getServerClient();\n const [pRes, cRes, colRes] = await Promise.all([\n client.catalogue.getProducts({ limit: 500 }),\n client.catalogue.getCategories(),\n client.catalogue.getCollections(),\n ]);\n return {\n products: (pRes.ok ? pRes.value.items : []).map((p: Product) => ({\n slug: p.slug ?? p.id,\n name: p.name,\n })),\n categories: (cRes.ok ? cRes.value : []).map((c) => ({ slug: c.slug, name: c.name })),\n collections: (colRes.ok ? colRes.value : []).map((c) => ({ slug: c.slug, name: c.name })),\n };\n}\n\nconst STATIC_LINKS: { title: string; links: { href: string; label: string }[] }[] = [\n {\n title: "Browse",\n links: [\n { href: "/", label: "Home" },\n { href: "/shop", label: "Shop" },\n { href: "/search", label: "Search" },\n ],\n },\n {\n title: "Account",\n links: [\n { href: "/account", label: "Account" },\n { href: "/account/orders", label: "Orders" },\n { href: "/account/addresses", label: "Addresses" },\n { href: "/account/settings", label: "Settings" },\n { href: "/login", label: "Sign in" },\n { href: "/signup", label: "Create account" },\n { href: "/track-order", label: "Track an order" },\n { href: "/cart", label: "Cart" },\n { href: "/checkout", label: "Checkout" },\n ],\n },\n {\n title: "About",\n links: [\n { href: "/about", label: "About" },\n { href: "/faq", label: "FAQ" },\n { href: "/contact", label: "Contact" },\n ],\n },\n {\n title: "Policies",\n links: [\n { href: "/shipping", label: "Shipping" },\n { href: "/returns", label: "Returns" },\n { href: "/accessibility", label: "Accessibility" },\n { href: "/terms", label: "Terms of Service" },\n { href: "/privacy", label: "Privacy Policy" },\n ],\n },\n {\n title: "Machine-readable",\n links: [\n { href: "/sitemap.xml", label: "sitemap.xml (search engines)" },\n { href: "/llms.txt", label: "llms.txt (LLM agents)" },\n { href: "/robots.txt", label: "robots.txt" },\n { href: "/opensearch.xml", label: "opensearch.xml (browser search)" },\n ],\n },\n];\n\nexport default async function SitemapHtmlPage() {\n const { products, categories, collections } = await getSitemap();\n\n return (\n <article className="max-w-5xl mx-auto px-6 sm:px-8 py-16">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-2">\n Sitemap\n </p>\n <h1 className="text-[clamp(2rem,4vw,2.75rem)] font-semibold mb-2 -tracking-[0.02em]">\n Every page, in one place.\n </h1>\n <p className="text-muted-foreground mb-12">\n For search engines, see <Link href="/sitemap.xml" className="text-primary hover:underline">/sitemap.xml</Link>.\n For LLM agents, see <Link href="/llms.txt" className="text-primary hover:underline">/llms.txt</Link>.\n </p>\n <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-10">\n {STATIC_LINKS.map((s) => (\n <Section key={s.title} title={s.title}>\n {s.links.map((l) => (\n <li key={l.href}>\n <Link href={l.href} className="hover:text-primary transition-colors">\n {l.label}\n </Link>\n </li>\n ))}\n </Section>\n ))}\n {categories.length > 0 && (\n <Section title="Categories">\n {categories.map((c) => (\n <li key={c.slug}>\n <Link href={`/categories/${c.slug}`} className="hover:text-primary transition-colors">\n {c.name}\n </Link>\n </li>\n ))}\n </Section>\n )}\n {collections.length > 0 && (\n <Section title="Collections">\n {collections.map((c) => (\n <li key={c.slug}>\n <Link href={`/collections/${c.slug}`} className="hover:text-primary transition-colors">\n {c.name}\n </Link>\n </li>\n ))}\n </Section>\n )}\n {products.length > 0 && (\n <Section title={`Products (${products.length})`}>\n {products.map((p) => (\n <li key={p.slug}>\n <Link href={`/shop?product=${encodeURIComponent(p.slug)}`} className="hover:text-primary transition-colors">\n {p.name}\n </Link>\n </li>\n ))}\n </Section>\n )}\n </div>\n </article>\n );\n}\n\nfunction Section({ title, children }: { title: string; children: React.ReactNode }) {\n return (\n <div>\n <p className="font-semibold text-[12px] uppercase tracking-[0.12em] text-foreground mb-3">\n {title}\n </p>\n <ul className="space-y-2 m-0 p-0 list-none text-sm text-muted-foreground">\n {children}\n </ul>\n </div>\n );\n}\n' }, { "path": "app/shop/shop-client.tsx", "kind": "text", "content": '"use client";\n\nimport { CataloguePage } from "@cimplify/sdk/react";\nimport type { Category, Product } from "@cimplify/sdk";\nimport { StoreProductCard } from "@/components/store-product-card";\n\n/**\n * Client island for the shop page. Server-side fetches all products and\n * categories (cached via `\'use cache\'` in `app/shop/page.tsx`), then hands\n * them to `<CataloguePage>` which owns the interactive filter / sort state.\n */\nexport function ShopClient({\n products,\n categories,\n}: {\n products: Product[];\n categories: Category[];\n}) {\n return (\n <CataloguePage\n title="The Menu"\n products={products}\n categories={categories}\n renderCard={(p) => <StoreProductCard product={p} />}\n />\n );\n}\n' }, { "path": "app/shop/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { cacheTag, cacheLife } from "next/cache";\nimport { getServerClient, tags } from "@cimplify/sdk/server";\nimport { ShopClient } from "./shop-client";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Shop \u2014 ${brand.name}`,\n description: brand.description,\n};\n\nasync function getShopData() {\n "use cache";\n cacheTag(tags.products(), tags.categories());\n cacheLife("hours");\n\n const client = getServerClient();\n const [p, c] = await Promise.all([\n client.catalogue.getProducts({ limit: 50 }),\n client.catalogue.getCategories(),\n ]);\n return {\n products: p.ok ? p.value.items : [],\n categories: c.ok ? c.value : [],\n };\n}\n\nexport default async function ShopPage() {\n const { products, categories } = await getShopData();\n return <ShopClient products={products} categories={categories} />;\n}\n' }, { "path": "app/returns/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\nimport { PolicyPage } from "@/components/policy-page";\n\nexport const metadata: Metadata = {\n title: `${brand.returns.title} \u2014 ${brand.name}`,\n};\n\nexport default function ReturnsPage() {\n return <PolicyPage policy={brand.returns} />;\n}\n' }, { "path": "app/layout.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Inter, Fraunces } from "next/font/google";\nimport "./globals.css";\nimport { Providers } from "@/components/providers";\nimport { Header } from "@/components/header";\nimport { Footer } from "@/components/footer";\nimport { ProductModal } from "@/components/product-modal";\nimport { CartDrawer } from "@/components/cart-drawer";\nimport { OrganizationJsonLd } from "@/components/json-ld";\nimport { Suspense } from "react";\nimport { brand } from "@/lib/brand";\nimport { getSiteUrl } from "@/lib/site-url";\n\nconst inter = Inter({\n subsets: ["latin"],\n variable: "--font-sans",\n display: "swap",\n});\n\nconst fraunces = Fraunces({\n subsets: ["latin"],\n variable: "--font-serif",\n display: "swap",\n});\n\nexport async function generateMetadata(): Promise<Metadata> {\n const siteUrl = await getSiteUrl();\n return {\n metadataBase: new URL(siteUrl),\n title: {\n default: brand.name,\n template: `%s \u2014 ${brand.name}`,\n },\n description: brand.description,\n openGraph: {\n type: "website",\n siteName: brand.name,\n locale: brand.locale,\n },\n twitter: { card: "summary_large_image" },\n };\n}\n\nexport default function RootLayout({ children }: { children: React.ReactNode }) {\n return (\n <html lang="en" suppressHydrationWarning className={`${inter.variable} ${fraunces.variable}`}>\n <body\n suppressHydrationWarning\n className="min-h-screen flex flex-col bg-background text-foreground font-sans"\n >\n <Suspense fallback={null}>\n <OrganizationJsonLd />\n </Suspense>\n <Providers>\n <Header />\n <main className="flex-1 pb-12 w-full">{children}</main>\n <Footer />\n <Suspense fallback={null}>\n <ProductModal />\n </Suspense>\n <CartDrawer />\n </Providers>\n </body>\n </html>\n );\n}\n' }, { "path": "app/checkout/page.tsx", "kind": "text", "content": '"use client";\n\nimport { useRouter } from "next/navigation";\nimport { CheckoutPage as SdkCheckoutPage } from "@cimplify/sdk/react";\n\nexport default function CheckoutPage() {\n const router = useRouter();\n return (\n <SdkCheckoutPage\n onComplete={(result) => {\n if (result.success && result.order) {\n router.push(`/orders/${result.order.id}`);\n }\n }}\n />\n );\n}\n' }, { "path": "app/privacy/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Privacy Policy \u2014 ${brand.name}`,\n description: `How ${brand.name} collects, uses, and protects your personal data.`,\n};\n\nexport default function PrivacyPage() {\n const p = brand.privacy;\n return (\n <article className="max-w-3xl mx-auto px-6 sm:px-8 py-16 prose prose-lg max-w-none">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-2 not-prose">\n {p.eyebrow}\n </p>\n <h1 className="font-serif text-[clamp(2.25rem,5vw,3.5rem)] font-semibold mb-2 -tracking-[0.02em]">\n {p.title}\n </h1>\n <p className="text-sm text-muted-foreground not-prose mb-10">\n Last updated: {p.lastUpdated}\n </p>\n\n <section className="space-y-5 leading-relaxed text-foreground/90">\n {p.sections.map((s) => (\n <div key={s.heading}>\n <h2 className="font-serif text-2xl font-semibold mt-0">{s.heading}</h2>\n {typeof s.body === "string" ? (\n <p>{s.body}</p>\n ) : (\n <>\n <p>{s.body.intro}</p>\n <ul className="list-disc pl-6 space-y-2">\n {s.body.bullets.map((b) => (\n <li key={b}>{b}</li>\n ))}\n </ul>\n </>\n )}\n </div>\n ))}\n </section>\n </article>\n );\n}\n' }, { "path": "app/collections/[slug]/listing-client.tsx", "kind": "text", "content": '"use client";\n\nimport { ProductGrid } from "@cimplify/sdk/react";\nimport type { Product } from "@cimplify/sdk";\nimport { StoreProductCard } from "@/components/store-product-card";\n\n/**\n * Client island for the collection listing. Receives server-fetched\n * products as props (serializable) and owns the `renderCard` function\n * (which can\'t cross the server/client boundary).\n */\nexport function ListingClient({ products }: { products: Product[] }) {\n return (\n <ProductGrid\n products={products}\n emptyMessage="No products in this collection yet."\n renderCard={(p) => <StoreProductCard product={p} />}\n />\n );\n}\n' }, { "path": "app/collections/[slug]/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Suspense } from "react";\nimport Link from "next/link";\nimport { notFound } from "next/navigation";\nimport { cacheTag, cacheLife } from "next/cache";\nimport {\n getServerClient,\n tags,\n type Collection,\n type Product,\n} from "@cimplify/sdk/server";\nimport { ListingClient } from "./listing-client";\nimport { brand } from "@/lib/brand";\n\ninterface CollectionData {\n collection: Collection;\n products: Product[];\n}\n\nasync function getCollection(slug: string): Promise<CollectionData | null> {\n "use cache";\n cacheTag(tags.collections());\n cacheLife("hours");\n\n const client = getServerClient();\n const colRes = await client.catalogue.getCollectionBySlug(slug);\n if (!colRes.ok) return null;\n\n cacheTag(tags.collection(colRes.value.id), tags.collectionProducts(colRes.value.id));\n const r = await client.catalogue.getCollectionProducts(colRes.value.id);\n const products = r.ok\n ? ((r.value as { items?: Product[] }).items ?? (r.value as Product[]))\n : [];\n return { collection: colRes.value, products };\n}\n\nexport async function generateMetadata({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}): Promise<Metadata> {\n const { slug } = await params;\n const data = await getCollection(slug);\n if (!data) return {};\n return {\n title: `${data.collection.name} \u2014 ${brand.name}`,\n description: data.collection.description ?? undefined,\n };\n}\n\nexport default async function CollectionPage({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}) {\n return (\n <Suspense fallback={<CollectionSkeleton />}>\n <CollectionContent params={params} />\n </Suspense>\n );\n}\n\nasync function CollectionContent({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}) {\n const { slug } = await params;\n const data = await getCollection(slug);\n if (!data) notFound();\n\n const { collection, products } = data;\n return (\n <section className="max-w-7xl mx-auto px-6 sm:px-8 pt-12">\n <header className="mb-8 text-center">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-2">\n Collection\n </p>\n <h1 className="font-serif text-[clamp(2rem,4vw,2.75rem)] font-semibold mb-2">\n {collection.name}\n </h1>\n {collection.description && (\n <p className="mx-auto mb-2 max-w-xl text-muted-foreground">\n {collection.description}\n </p>\n )}\n <p className="text-sm text-muted-foreground">\n {products.length} item{products.length === 1 ? "" : "s"}\n </p>\n </header>\n <ListingClient products={products} />\n {products.length === 0 && (\n <p className="text-center mt-8">\n <Link href="/" className="text-primary font-semibold">\n \u2190 Back home\n </Link>\n </p>\n )}\n </section>\n );\n}\n\nfunction CollectionSkeleton() {\n return (\n <section className="max-w-7xl mx-auto px-6 sm:px-8 pt-12">\n <header className="mb-8 text-center">\n <div className="mx-auto h-3 w-20 bg-muted rounded mb-2 animate-pulse" />\n <div className="mx-auto h-10 w-64 bg-muted rounded mb-2 animate-pulse" />\n <div className="mx-auto h-4 w-80 bg-muted rounded animate-pulse" />\n </header>\n <div className="grid grid-cols-2 md:grid-cols-3 gap-4">\n {Array.from({ length: 6 }).map((_, i) => (\n <div key={i} className="aspect-square bg-muted rounded-2xl animate-pulse" />\n ))}\n </div>\n </section>\n );\n}\n' }, { "path": "package.json", "kind": "text", "content": '{\n "name": "__STOREFRONT_NAME__",\n "private": true,\n "version": "0.0.0",\n "scripts": {\n "dev": "concurrently -k -n mock,next -c blue,magenta \\"cimplify-mock --seed services --quiet\\" \\"next dev\\"",\n "dev:storefront": "next dev",\n "dev:mock": "cimplify-mock --seed services",\n "build": "next build",\n "start": "next start",\n "typecheck": "tsc --noEmit",\n "test": "vitest",\n "test:run": "vitest run",\n "check:brand": "vitest run __tests__/brand.test.ts",\n "check:cart": "vitest run __tests__/cart-flow.test.ts",\n "check:contract": "vitest run __tests__/contract.test.ts",\n "check": "bun run typecheck && bun run test:run"\n },\n "dependencies": {\n "@cimplify/sdk": "^0.48.0",\n "next": "^16.2.4",\n "react": "^19.0.0",\n "react-dom": "^19.0.0"\n },\n "devDependencies": {\n "@tailwindcss/postcss": "^4.2.4",\n "@types/node": "^22.10.0",\n "@types/react": "^19.0.0",\n "@types/react-dom": "^19.0.0",\n "concurrently": "^9.0.0",\n "tailwindcss": "^4.2.4",\n "typescript": "^5.9.3",\n "vitest": "^4.1.5"\n }\n}\n' }, { "path": "lib/brand.ts", "kind": "text", "content": `/**
|
|
3641
|
+
` }, { "path": "app/terms/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Terms of Service \u2014 ${brand.name}`,\n description: `The rules of the road for ordering from ${brand.name}.`,\n};\n\nexport default function TermsPage() {\n const t = brand.terms;\n return (\n <article className="max-w-3xl mx-auto px-6 sm:px-8 py-16 prose prose-lg max-w-none">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-2 not-prose">\n {t.eyebrow}\n </p>\n <h1 className="font-serif text-[clamp(2.25rem,5vw,3.5rem)] font-semibold mb-2 -tracking-[0.02em]">\n {t.title}\n </h1>\n <p className="text-sm text-muted-foreground not-prose mb-10">\n Last updated: {t.lastUpdated}\n </p>\n\n <section className="space-y-5 leading-relaxed text-foreground/90">\n {t.sections.map((s) => (\n <div key={s.heading}>\n <h2 className="font-serif text-2xl font-semibold mt-0">{s.heading}</h2>\n {typeof s.body === "string" ? (\n <p>{s.body}</p>\n ) : (\n <>\n <p>{s.body.intro}</p>\n <ul className="list-disc pl-6 space-y-2">\n {s.body.bullets.map((b) => (\n <li key={b}>{b}</li>\n ))}\n </ul>\n </>\n )}\n </div>\n ))}\n </section>\n </article>\n );\n}\n' }, { "path": "app/not-found.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport Link from "next/link";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Page not found \u2014 ${brand.name}`,\n description: "We couldn\'t find that page.",\n};\n\nexport default function NotFound() {\n return (\n <section className="max-w-2xl mx-auto px-6 sm:px-8 py-20 text-center">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-3">\n 404\n </p>\n <h1 className="font-serif text-[clamp(2.5rem,5vw,3.5rem)] font-semibold mb-4">\n Page not found.\n </h1>\n <p className="text-muted-foreground leading-relaxed mb-8">\n The URL you followed might be old, a typo, or pointing at a product\n that's sold out for the day. Try the menu or head back home.\n </p>\n <div className="flex flex-wrap items-center justify-center gap-3">\n <Link\n href="/shop"\n className="inline-block px-6 py-3 rounded-full bg-primary text-primary-foreground font-semibold text-sm transition-colors hover:bg-primary/90"\n >\n Browse the menu\n </Link>\n <Link\n href="/"\n className="inline-block px-6 py-3 rounded-full border border-border hover:bg-muted transition-colors text-sm font-medium"\n >\n Back home\n </Link>\n </div>\n </section>\n );\n}\n' }, { "path": "app/orders/[id]/page.tsx", "kind": "text", "content": 'import Link from "next/link";\n\nexport default async function OrderPage({ params }: { params: Promise<{ id: string }> }) {\n const { id } = await params;\n return (\n <section className="max-w-2xl mx-auto px-6 sm:px-8 py-20 text-center">\n <h1 className="font-serif text-3xl mt-0 mb-3">Thanks \u2014 your order is confirmed</h1>\n <p className="text-muted-foreground">\n Order <code className="font-mono text-foreground">{id}</code>\n </p>\n <p className="mt-6">\n <Link\n href="/"\n className="inline-block px-6 py-3 rounded-full bg-primary text-primary-foreground font-semibold text-sm transition-colors hover:bg-primary/90"\n >\n Continue shopping\n </Link>\n </p>\n </section>\n );\n}\n' }, { "path": "app/book/book-client.tsx", "kind": "text", "content": '"use client";\n\nimport { useState } from "react";\nimport { useRouter } from "next/navigation";\nimport type { Product } from "@cimplify/sdk";\nimport type { AvailableSlot } from "@cimplify/sdk";\nimport { useCart, DateSlotPicker } from "@cimplify/sdk/react";\n\n/**\n * Booking flow:\n * 1. Pick a treatment (left rail) \u2014 drives the SDK availability fetch.\n * 2. SDK <DateSlotPicker> handles date + slot selection, fetching real\n * availability via `useServiceAvailability` against the configured\n * backend (mock in dev, Cimplify scheduling API in prod).\n * 3. Add to cart with the chosen slot as a cart-item note;\n * Cimplify Checkout finalises the booking.\n */\nexport function BookClient({ treatments }: { treatments: Product[] }) {\n const router = useRouter();\n const { addItem } = useCart();\n const [selectedTreatment, setSelectedTreatment] = useState<Product | undefined>(\n treatments[0],\n );\n const [selectedSlot, setSelectedSlot] = useState<AvailableSlot | null>(null);\n const [submitting, setSubmitting] = useState(false);\n\n async function confirm() {\n if (!selectedTreatment || !selectedSlot) return;\n setSubmitting(true);\n try {\n const slotLabel = new Date(selectedSlot.start_time).toLocaleString(undefined, {\n weekday: "short",\n month: "short",\n day: "numeric",\n hour: "2-digit",\n minute: "2-digit",\n });\n await addItem(selectedTreatment, 1, {\n specialInstructions: `Booked for ${slotLabel}`,\n });\n router.push("/checkout");\n } catch {\n setSubmitting(false);\n }\n }\n\n if (treatments.length === 0) {\n return (\n <p className="text-muted-foreground">\n No bookable treatments yet. Add a Service-type product to your catalogue first.\n </p>\n );\n }\n\n return (\n <div className="grid grid-cols-1 lg:grid-cols-[1fr_1.4fr] gap-8">\n {/* Treatments */}\n <div>\n <p className="text-[11px] font-mono uppercase tracking-[0.16em] text-muted-foreground mb-3">\n Treatment\n </p>\n <div className="space-y-2">\n {treatments.map((t) => {\n const active = selectedTreatment?.id === t.id;\n return (\n <button\n key={t.id}\n type="button"\n onClick={() => {\n setSelectedTreatment(t);\n setSelectedSlot(null);\n }}\n className={[\n "w-full text-left rounded-2xl border p-4 transition-colors",\n active\n ? "border-primary bg-primary/5"\n : "border-border bg-card hover:border-foreground/30",\n ].join(" ")}\n >\n <div className="flex items-center justify-between gap-3">\n <div className="min-w-0">\n <p className="font-semibold text-sm m-0 truncate">{t.name}</p>\n <p className="text-xs text-muted-foreground m-0">\n {t.duration_minutes ? `${t.duration_minutes} min \xB7 ` : ""}\n {t.currency ?? "GHS"} {t.default_price}\n </p>\n </div>\n {active && (\n <span className="grid place-items-center w-6 h-6 rounded-full bg-primary text-primary-foreground text-xs">\n \u2713\n </span>\n )}\n </div>\n </button>\n );\n })}\n </div>\n </div>\n\n {/* Date + slots \u2014 SDK <DateSlotPicker> fetches real availability */}\n <div className="rounded-2xl border border-border bg-card p-4 sm:p-6">\n {selectedTreatment ? (\n <DateSlotPicker\n serviceId={selectedTreatment.id}\n selectedSlot={selectedSlot}\n onSlotSelect={(slot) => setSelectedSlot(slot)}\n daysToShow={selectedTreatment.scheduling_mode === "multi_day" ? 14 : 7}\n schedulingMode={selectedTreatment.scheduling_mode}\n durationUnit={selectedTreatment.duration_unit}\n durationValue={selectedTreatment.duration_value}\n />\n ) : (\n <p className="text-sm text-muted-foreground">Pick a treatment to see availability.</p>\n )}\n\n <button\n type="button"\n onClick={confirm}\n disabled={!selectedTreatment || !selectedSlot || submitting}\n className="w-full mt-6 inline-flex items-center justify-center gap-2 px-5 py-3 rounded-md bg-primary text-primary-foreground font-semibold text-sm hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"\n >\n {submitting\n ? "Confirming\u2026"\n : selectedSlot\n ? `Book ${selectedTreatment?.name} at ${new Date(selectedSlot.start_time).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" })}`\n : "Pick a slot to book"}\n </button>\n </div>\n </div>\n );\n}\n' }, { "path": "app/book/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Suspense } from "react";\nimport { cacheTag, cacheLife } from "next/cache";\nimport { getServerClient, tags, type Product } from "@cimplify/sdk/server";\nimport { BookClient } from "./book-client";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Book a treatment \u2014 ${brand.name}`,\n description: "Pick a treatment, pick a slot, you\'re booked.",\n};\n\nasync function getTreatments(): Promise<Product[]> {\n "use cache";\n cacheTag(tags.products());\n cacheLife("hours");\n\n const client = getServerClient();\n const r = await client.catalogue.getProducts({ limit: 50 });\n if (!r.ok) return [];\n // Booking flow only handles service-typed products.\n return r.value.items.filter((p) => p.type === "service");\n}\n\nexport default async function BookPage() {\n const treatments = await getTreatments();\n return (\n <article className="max-w-5xl mx-auto px-6 sm:px-8 py-14">\n <p className="text-[11px] font-mono uppercase tracking-[0.16em] text-primary mb-2">\n Book a treatment\n </p>\n <h1 className="text-[clamp(2rem,5vw,3rem)] font-semibold mb-3 -tracking-[0.02em]">\n Pick a treatment.<br />Pick a slot.\n </h1>\n <p className="text-muted-foreground leading-relaxed mb-10 max-w-xl">\n Available windows for the next two weeks. Confirmation by SMS within a minute of\n booking. Free cancellation up to 24 hours before.\n </p>\n\n <Suspense fallback={<BookSkeleton />}>\n <BookClient treatments={treatments} />\n </Suspense>\n </article>\n );\n}\n\nfunction BookSkeleton() {\n return (\n <div className="grid grid-cols-1 lg:grid-cols-[1fr_1.4fr] gap-8">\n <div className="space-y-2">\n {Array.from({ length: 6 }).map((_, i) => (\n <div key={i} className="h-16 bg-muted rounded-2xl animate-pulse" />\n ))}\n </div>\n <div className="rounded-2xl border border-border bg-card p-8">\n <div className="h-5 w-40 bg-muted rounded mb-4 animate-pulse" />\n <div className="grid grid-cols-3 gap-2">\n {Array.from({ length: 9 }).map((_, i) => (\n <div key={i} className="h-10 bg-muted rounded animate-pulse" />\n ))}\n </div>\n </div>\n </div>\n );\n}\n' }, { "path": "app/login/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { redirect } from "next/navigation";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Sign in \u2014 ${brand.name}`,\n description: brand.account.loginSubtitle,\n};\n\n/**\n * Cimplify Link (the iframe in `<CimplifyAccount>`) handles sign-in\n * automatically when no session exists. We just bounce /login to /account\n * so consumers landing here get the right UI without a duplicate form.\n */\nexport default function LoginPage(): never {\n redirect("/account");\n}\n' }, { "path": "app/accessibility/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\nimport { PolicyPage } from "@/components/policy-page";\n\nexport const metadata: Metadata = {\n title: `${brand.accessibility.title} \u2014 ${brand.name}`,\n};\n\nexport default function AccessibilityPage() {\n return <PolicyPage policy={brand.accessibility} />;\n}\n' }, { "path": "app/shipping/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\nimport { PolicyPage } from "@/components/policy-page";\n\nexport const metadata: Metadata = {\n title: `${brand.shipping.title} \u2014 ${brand.name}`,\n description: brand.shipping.sections[0]?.body\n ? typeof brand.shipping.sections[0].body === "string"\n ? brand.shipping.sections[0].body\n : brand.shipping.sections[0].body.intro\n : undefined,\n};\n\nexport default function ShippingPage() {\n return <PolicyPage policy={brand.shipping} />;\n}\n' }, { "path": "app/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Suspense } from "react";\nimport { cacheTag, cacheLife } from "next/cache";\nimport { getServerClient, tags, type Product } from "@cimplify/sdk/server";\nimport { Hero } from "@/components/hero";\nimport { CollectionStrip } from "@/components/collection-strip";\nimport { CategoryGrid } from "@/components/category-grid";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `${brand.name} \u2014 ${brand.hero.title}`,\n description: brand.description,\n};\n\nasync function getHomeData() {\n "use cache";\n cacheTag(tags.collections(), tags.categories(), tags.products());\n cacheLife("hours");\n\n const client = getServerClient();\n const [colRes, catRes] = await Promise.all([\n client.catalogue.getCollections(),\n client.catalogue.getCategories(),\n ]);\n const collections = colRes.ok ? colRes.value : [];\n const categories = catRes.ok ? catRes.value : [];\n\n const collectionsWithProducts = await Promise.all(\n collections.map(async (col) => {\n const r = await client.catalogue.getCollectionProducts(col.id);\n const items = r.ok\n ? ((r.value as { items?: Product[] }).items ?? (r.value as Product[]))\n : [];\n return { collection: col, products: items };\n }),\n );\n\n return {\n collections: collectionsWithProducts.filter((x) => x.products.length > 0),\n categories,\n };\n}\n\nexport default async function HomePage() {\n const { collections, categories } = await getHomeData();\n\n return (\n <>\n <Hero\n badge={brand.hero.badge}\n title={brand.hero.title}\n subtitle={brand.hero.subtitle}\n />\n {collections.map(({ collection, products }) => (\n <Suspense\n key={collection.id}\n fallback={<StripSkeleton title={collection.name} />}\n >\n <CollectionStrip\n collection={collection}\n products={products}\n collectionHref={`/collections/${collection.slug}`}\n />\n </Suspense>\n ))}\n <Suspense fallback={<CategoryGridSkeleton />}>\n <CategoryGrid categories={categories} />\n </Suspense>\n </>\n );\n}\n\nfunction StripSkeleton({ title }: { title: string }) {\n return (\n <section className="max-w-7xl mx-auto px-6 sm:px-8 pt-12">\n <h2 className="font-serif text-[28px] font-semibold m-0 mb-5">{title}</h2>\n <div className="grid grid-flow-col auto-cols-[minmax(220px,1fr)] gap-4 overflow-x-auto pb-2">\n {Array.from({ length: 5 }).map((_, i) => (\n <div key={i} className="aspect-square bg-muted rounded-2xl animate-pulse" />\n ))}\n </div>\n </section>\n );\n}\n\nfunction CategoryGridSkeleton() {\n return (\n <section className="max-w-7xl mx-auto px-6 sm:px-8 pt-14">\n <div className="h-8 w-48 bg-muted rounded mb-5 animate-pulse" />\n <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">\n {Array.from({ length: 6 }).map((_, i) => (\n <div key={i} className="h-32 bg-muted rounded-2xl animate-pulse" />\n ))}\n </div>\n </section>\n );\n}\n' }, { "path": "app/categories/[slug]/listing-client.tsx", "kind": "text", "content": '"use client";\n\nimport { ProductGrid } from "@cimplify/sdk/react";\nimport type { Product } from "@cimplify/sdk";\nimport { StoreProductCard } from "@/components/store-product-card";\n\n/**\n * Client island for the category listing. Receives server-fetched products\n * as props (serializable) and owns the `renderCard` function.\n */\nexport function ListingClient({ products }: { products: Product[] }) {\n return (\n <ProductGrid\n products={products}\n emptyMessage="No products in this category yet."\n renderCard={(p) => <StoreProductCard product={p} />}\n />\n );\n}\n' }, { "path": "app/categories/[slug]/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Suspense } from "react";\nimport Link from "next/link";\nimport { notFound } from "next/navigation";\nimport { cacheTag, cacheLife } from "next/cache";\nimport {\n getServerClient,\n tags,\n type Category,\n type Product,\n} from "@cimplify/sdk/server";\nimport { ListingClient } from "./listing-client";\nimport { brand } from "@/lib/brand";\n\ninterface CategoryData {\n category: Category;\n products: Product[];\n}\n\nasync function getCategory(slug: string): Promise<CategoryData | null> {\n "use cache";\n cacheTag(tags.categories());\n cacheLife("hours");\n\n const client = getServerClient();\n const catRes = await client.catalogue.getCategoryBySlug(slug);\n if (!catRes.ok) return null;\n\n cacheTag(tags.category(catRes.value.id), tags.categoryProducts(catRes.value.id));\n const r = await client.catalogue.getCategoryProducts(catRes.value.id);\n const products = r.ok\n ? ((r.value as { items?: Product[] }).items ?? (r.value as Product[]))\n : [];\n return { category: catRes.value, products };\n}\n\nexport async function generateMetadata({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}): Promise<Metadata> {\n const { slug } = await params;\n const data = await getCategory(slug);\n if (!data) return {};\n return {\n title: `${data.category.name} \u2014 ${brand.name}`,\n description: data.category.description ?? undefined,\n };\n}\n\nexport default async function CategoryPage({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}) {\n return (\n <Suspense fallback={<CategorySkeleton />}>\n <CategoryContent params={params} />\n </Suspense>\n );\n}\n\nasync function CategoryContent({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}) {\n const { slug } = await params;\n const data = await getCategory(slug);\n if (!data) notFound();\n\n const { category, products } = data;\n return (\n <section className="max-w-7xl mx-auto px-6 sm:px-8 pt-12">\n <header className="mb-8 text-center">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-2">\n Category\n </p>\n <h1 className="font-serif text-[clamp(2rem,4vw,2.75rem)] font-semibold mb-2">\n {category.name}\n </h1>\n {category.description && (\n <p className="mx-auto mb-2 max-w-xl text-muted-foreground">\n {category.description}\n </p>\n )}\n <p className="text-sm text-muted-foreground">\n {products.length} item{products.length === 1 ? "" : "s"}\n </p>\n </header>\n <ListingClient products={products} />\n {products.length === 0 && (\n <p className="text-center mt-8">\n <Link href="/" className="text-primary font-semibold">\n \u2190 Back home\n </Link>\n </p>\n )}\n </section>\n );\n}\n\nfunction CategorySkeleton() {\n return (\n <section className="max-w-7xl mx-auto px-6 sm:px-8 pt-12">\n <header className="mb-8 text-center">\n <div className="mx-auto h-3 w-20 bg-muted rounded mb-2 animate-pulse" />\n <div className="mx-auto h-10 w-64 bg-muted rounded mb-2 animate-pulse" />\n <div className="mx-auto h-4 w-80 bg-muted rounded animate-pulse" />\n </header>\n <div className="grid grid-cols-2 md:grid-cols-3 gap-4">\n {Array.from({ length: 6 }).map((_, i) => (\n <div key={i} className="aspect-square bg-muted rounded-2xl animate-pulse" />\n ))}\n </div>\n </section>\n );\n}\n' }, { "path": "app/error.tsx", "kind": "text", "content": '"use client";\n\nimport { useEffect } from "react";\nimport Link from "next/link";\n\n/**\n * Root error boundary. Next.js calls this whenever a thrown error escapes\n * a Server Component without being caught by a closer `error.tsx`.\n *\n * Wire `reportError` (Sentry, Datadog, etc.) here so production failures\n * surface \u2014 silently recovering hides real bugs.\n */\nexport default function GlobalError({\n error,\n reset,\n}: {\n error: Error & { digest?: string };\n reset: () => void;\n}) {\n useEffect(() => {\n // TODO: replace with your error reporter\n // eslint-disable-next-line no-console\n console.error("Storefront error:", error);\n }, [error]);\n\n return (\n <section className="max-w-2xl mx-auto px-6 sm:px-8 py-20 text-center">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-3">\n Something broke\n </p>\n <h1 className="font-serif text-[clamp(2rem,4vw,2.75rem)] font-semibold mb-4">\n That wasn't supposed to happen.\n </h1>\n <p className="text-muted-foreground leading-relaxed mb-8">\n We've been notified. Refresh the page, or head back home \u2014\n most of the time the second try just works.\n </p>\n {error.digest && (\n <p className="text-xs font-mono text-muted-foreground mb-6">\n Reference: <code className="text-foreground">{error.digest}</code>\n </p>\n )}\n <div className="flex flex-wrap items-center justify-center gap-3">\n <button\n type="button"\n onClick={reset}\n className="inline-block px-6 py-3 rounded-full bg-primary text-primary-foreground font-semibold text-sm transition-colors hover:bg-primary/90"\n >\n Try again\n </button>\n <Link\n href="/"\n className="inline-block px-6 py-3 rounded-full border border-border hover:bg-muted transition-colors text-sm font-medium"\n >\n Back home\n </Link>\n </div>\n </section>\n );\n}\n' }, { "path": "app/globals.css", "kind": "text", "content": '@import "tailwindcss";\n\n@source "../node_modules/@cimplify/sdk/dist";\n\n/* Serene Spa \u2014 calm sand + sage palette, soft contrast.\n Tweak primary to retheme everything. */\n@theme {\n --color-background: oklch(0.98 0.012 90);\n --color-foreground: oklch(0.22 0.02 140);\n --color-card: oklch(1 0 0);\n --color-card-foreground: oklch(0.22 0.02 140);\n --color-popover: oklch(1 0 0);\n --color-popover-foreground: oklch(0.22 0.02 140);\n --color-primary: oklch(0.46 0.06 150);\n --color-primary-foreground: oklch(0.99 0.01 90);\n --color-secondary: oklch(0.93 0.025 90);\n --color-secondary-foreground: oklch(0.3 0.04 140);\n --color-muted: oklch(0.95 0.018 90);\n --color-muted-foreground: oklch(0.5 0.02 140);\n --color-accent: oklch(0.9 0.035 90);\n --color-accent-foreground: oklch(0.3 0.04 140);\n --color-destructive: oklch(0.58 0.24 27);\n --color-destructive-foreground: oklch(0.99 0 0);\n --color-border: oklch(0.91 0.018 90);\n --color-input: oklch(0.93 0.018 90);\n --color-ring: oklch(0.46 0.06 150);\n --radius: 1rem;\n}\n\n@layer base {\n html, body {\n margin: 0;\n padding: 0;\n background: var(--color-background);\n color: var(--color-foreground);\n -webkit-font-smoothing: antialiased;\n -moz-osx-font-smoothing: grayscale;\n }\n a { color: inherit; text-decoration: none; }\n h1, h2, h3, h4 {\n font-family: var(--font-serif);\n letter-spacing: -0.015em;\n font-weight: 500;\n }\n}\n' }, { "path": "app/sitemap-page/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport Link from "next/link";\nimport { cacheTag, cacheLife } from "next/cache";\nimport { getServerClient, tags, type Product } from "@cimplify/sdk/server";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Sitemap \u2014 ${brand.name}`,\n description: "A human-readable index of every page on this site.",\n};\n\ninterface SitemapData {\n products: { slug: string; name: string }[];\n categories: { slug: string; name: string }[];\n collections: { slug: string; name: string }[];\n}\n\nasync function getSitemap(): Promise<SitemapData> {\n "use cache";\n cacheTag(tags.products(), tags.categories(), tags.collections());\n cacheLife("hours");\n\n const client = getServerClient();\n const [pRes, cRes, colRes] = await Promise.all([\n client.catalogue.getProducts({ limit: 500 }),\n client.catalogue.getCategories(),\n client.catalogue.getCollections(),\n ]);\n return {\n products: (pRes.ok ? pRes.value.items : []).map((p: Product) => ({\n slug: p.slug ?? p.id,\n name: p.name,\n })),\n categories: (cRes.ok ? cRes.value : []).map((c) => ({ slug: c.slug, name: c.name })),\n collections: (colRes.ok ? colRes.value : []).map((c) => ({ slug: c.slug, name: c.name })),\n };\n}\n\nconst STATIC_LINKS: { title: string; links: { href: string; label: string }[] }[] = [\n {\n title: "Browse",\n links: [\n { href: "/", label: "Home" },\n { href: "/shop", label: "Shop" },\n { href: "/search", label: "Search" },\n ],\n },\n {\n title: "Account",\n links: [\n { href: "/account", label: "Account" },\n { href: "/account/orders", label: "Orders" },\n { href: "/account/addresses", label: "Addresses" },\n { href: "/account/settings", label: "Settings" },\n { href: "/login", label: "Sign in" },\n { href: "/signup", label: "Create account" },\n { href: "/track-order", label: "Track an order" },\n { href: "/cart", label: "Cart" },\n { href: "/checkout", label: "Checkout" },\n ],\n },\n {\n title: "About",\n links: [\n { href: "/about", label: "About" },\n { href: "/faq", label: "FAQ" },\n { href: "/contact", label: "Contact" },\n ],\n },\n {\n title: "Policies",\n links: [\n { href: "/shipping", label: "Shipping" },\n { href: "/returns", label: "Returns" },\n { href: "/accessibility", label: "Accessibility" },\n { href: "/terms", label: "Terms of Service" },\n { href: "/privacy", label: "Privacy Policy" },\n ],\n },\n {\n title: "Machine-readable",\n links: [\n { href: "/sitemap.xml", label: "sitemap.xml (search engines)" },\n { href: "/llms.txt", label: "llms.txt (LLM agents)" },\n { href: "/robots.txt", label: "robots.txt" },\n { href: "/opensearch.xml", label: "opensearch.xml (browser search)" },\n ],\n },\n];\n\nexport default async function SitemapHtmlPage() {\n const { products, categories, collections } = await getSitemap();\n\n return (\n <article className="max-w-5xl mx-auto px-6 sm:px-8 py-16">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-2">\n Sitemap\n </p>\n <h1 className="text-[clamp(2rem,4vw,2.75rem)] font-semibold mb-2 -tracking-[0.02em]">\n Every page, in one place.\n </h1>\n <p className="text-muted-foreground mb-12">\n For search engines, see <Link href="/sitemap.xml" className="text-primary hover:underline">/sitemap.xml</Link>.\n For LLM agents, see <Link href="/llms.txt" className="text-primary hover:underline">/llms.txt</Link>.\n </p>\n <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-10">\n {STATIC_LINKS.map((s) => (\n <Section key={s.title} title={s.title}>\n {s.links.map((l) => (\n <li key={l.href}>\n <Link href={l.href} className="hover:text-primary transition-colors">\n {l.label}\n </Link>\n </li>\n ))}\n </Section>\n ))}\n {categories.length > 0 && (\n <Section title="Categories">\n {categories.map((c) => (\n <li key={c.slug}>\n <Link href={`/categories/${c.slug}`} className="hover:text-primary transition-colors">\n {c.name}\n </Link>\n </li>\n ))}\n </Section>\n )}\n {collections.length > 0 && (\n <Section title="Collections">\n {collections.map((c) => (\n <li key={c.slug}>\n <Link href={`/collections/${c.slug}`} className="hover:text-primary transition-colors">\n {c.name}\n </Link>\n </li>\n ))}\n </Section>\n )}\n {products.length > 0 && (\n <Section title={`Products (${products.length})`}>\n {products.map((p) => (\n <li key={p.slug}>\n <Link href={`/shop?product=${encodeURIComponent(p.slug)}`} className="hover:text-primary transition-colors">\n {p.name}\n </Link>\n </li>\n ))}\n </Section>\n )}\n </div>\n </article>\n );\n}\n\nfunction Section({ title, children }: { title: string; children: React.ReactNode }) {\n return (\n <div>\n <p className="font-semibold text-[12px] uppercase tracking-[0.12em] text-foreground mb-3">\n {title}\n </p>\n <ul className="space-y-2 m-0 p-0 list-none text-sm text-muted-foreground">\n {children}\n </ul>\n </div>\n );\n}\n' }, { "path": "app/shop/shop-client.tsx", "kind": "text", "content": '"use client";\n\nimport { CataloguePage } from "@cimplify/sdk/react";\nimport type { Category, Product } from "@cimplify/sdk";\nimport { StoreProductCard } from "@/components/store-product-card";\n\n/**\n * Client island for the shop page. Server-side fetches all products and\n * categories (cached via `\'use cache\'` in `app/shop/page.tsx`), then hands\n * them to `<CataloguePage>` which owns the interactive filter / sort state.\n */\nexport function ShopClient({\n products,\n categories,\n}: {\n products: Product[];\n categories: Category[];\n}) {\n return (\n <CataloguePage\n title="The Menu"\n products={products}\n categories={categories}\n renderCard={(p) => <StoreProductCard product={p} />}\n />\n );\n}\n' }, { "path": "app/shop/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { cacheTag, cacheLife } from "next/cache";\nimport { getServerClient, tags } from "@cimplify/sdk/server";\nimport { ShopClient } from "./shop-client";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Shop \u2014 ${brand.name}`,\n description: brand.description,\n};\n\nasync function getShopData() {\n "use cache";\n cacheTag(tags.products(), tags.categories());\n cacheLife("hours");\n\n const client = getServerClient();\n const [p, c] = await Promise.all([\n client.catalogue.getProducts({ limit: 50 }),\n client.catalogue.getCategories(),\n ]);\n return {\n products: p.ok ? p.value.items : [],\n categories: c.ok ? c.value : [],\n };\n}\n\nexport default async function ShopPage() {\n const { products, categories } = await getShopData();\n return <ShopClient products={products} categories={categories} />;\n}\n' }, { "path": "app/returns/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\nimport { PolicyPage } from "@/components/policy-page";\n\nexport const metadata: Metadata = {\n title: `${brand.returns.title} \u2014 ${brand.name}`,\n};\n\nexport default function ReturnsPage() {\n return <PolicyPage policy={brand.returns} />;\n}\n' }, { "path": "app/layout.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Inter, Fraunces } from "next/font/google";\nimport "./globals.css";\nimport { Providers } from "@/components/providers";\nimport { Header } from "@/components/header";\nimport { Footer } from "@/components/footer";\nimport { ProductModal } from "@/components/product-modal";\nimport { CartDrawer } from "@/components/cart-drawer";\nimport { OrganizationJsonLd } from "@/components/json-ld";\nimport { Suspense } from "react";\nimport { brand } from "@/lib/brand";\nimport { getSiteUrl } from "@/lib/site-url";\n\nconst inter = Inter({\n subsets: ["latin"],\n variable: "--font-sans",\n display: "swap",\n});\n\nconst fraunces = Fraunces({\n subsets: ["latin"],\n variable: "--font-serif",\n display: "swap",\n});\n\nexport async function generateMetadata(): Promise<Metadata> {\n const siteUrl = await getSiteUrl();\n return {\n metadataBase: new URL(siteUrl),\n title: {\n default: brand.name,\n template: `%s \u2014 ${brand.name}`,\n },\n description: brand.description,\n openGraph: {\n type: "website",\n siteName: brand.name,\n locale: brand.locale,\n },\n twitter: { card: "summary_large_image" },\n };\n}\n\nexport default function RootLayout({ children }: { children: React.ReactNode }) {\n return (\n <html lang="en" suppressHydrationWarning className={`${inter.variable} ${fraunces.variable}`}>\n <body\n suppressHydrationWarning\n className="min-h-screen flex flex-col bg-background text-foreground font-sans"\n >\n <Suspense fallback={null}>\n <OrganizationJsonLd />\n </Suspense>\n <Providers>\n <Header />\n <main className="flex-1 pb-12 w-full">{children}</main>\n <Footer />\n <Suspense fallback={null}>\n <ProductModal />\n </Suspense>\n <CartDrawer />\n </Providers>\n </body>\n </html>\n );\n}\n' }, { "path": "app/checkout/page.tsx", "kind": "text", "content": '"use client";\n\nimport { useRouter } from "next/navigation";\nimport { CheckoutPage as SdkCheckoutPage } from "@cimplify/sdk/react";\n\nexport default function CheckoutPage() {\n const router = useRouter();\n return (\n <SdkCheckoutPage\n onComplete={(result) => {\n if (result.success && result.order) {\n router.push(`/orders/${result.order.id}`);\n }\n }}\n />\n );\n}\n' }, { "path": "app/privacy/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Privacy Policy \u2014 ${brand.name}`,\n description: `How ${brand.name} collects, uses, and protects your personal data.`,\n};\n\nexport default function PrivacyPage() {\n const p = brand.privacy;\n return (\n <article className="max-w-3xl mx-auto px-6 sm:px-8 py-16 prose prose-lg max-w-none">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-2 not-prose">\n {p.eyebrow}\n </p>\n <h1 className="font-serif text-[clamp(2.25rem,5vw,3.5rem)] font-semibold mb-2 -tracking-[0.02em]">\n {p.title}\n </h1>\n <p className="text-sm text-muted-foreground not-prose mb-10">\n Last updated: {p.lastUpdated}\n </p>\n\n <section className="space-y-5 leading-relaxed text-foreground/90">\n {p.sections.map((s) => (\n <div key={s.heading}>\n <h2 className="font-serif text-2xl font-semibold mt-0">{s.heading}</h2>\n {typeof s.body === "string" ? (\n <p>{s.body}</p>\n ) : (\n <>\n <p>{s.body.intro}</p>\n <ul className="list-disc pl-6 space-y-2">\n {s.body.bullets.map((b) => (\n <li key={b}>{b}</li>\n ))}\n </ul>\n </>\n )}\n </div>\n ))}\n </section>\n </article>\n );\n}\n' }, { "path": "app/collections/[slug]/listing-client.tsx", "kind": "text", "content": '"use client";\n\nimport { ProductGrid } from "@cimplify/sdk/react";\nimport type { Product } from "@cimplify/sdk";\nimport { StoreProductCard } from "@/components/store-product-card";\n\n/**\n * Client island for the collection listing. Receives server-fetched\n * products as props (serializable) and owns the `renderCard` function\n * (which can\'t cross the server/client boundary).\n */\nexport function ListingClient({ products }: { products: Product[] }) {\n return (\n <ProductGrid\n products={products}\n emptyMessage="No products in this collection yet."\n renderCard={(p) => <StoreProductCard product={p} />}\n />\n );\n}\n' }, { "path": "app/collections/[slug]/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Suspense } from "react";\nimport Link from "next/link";\nimport { notFound } from "next/navigation";\nimport { cacheTag, cacheLife } from "next/cache";\nimport {\n getServerClient,\n tags,\n type Collection,\n type Product,\n} from "@cimplify/sdk/server";\nimport { ListingClient } from "./listing-client";\nimport { brand } from "@/lib/brand";\n\ninterface CollectionData {\n collection: Collection;\n products: Product[];\n}\n\nasync function getCollection(slug: string): Promise<CollectionData | null> {\n "use cache";\n cacheTag(tags.collections());\n cacheLife("hours");\n\n const client = getServerClient();\n const colRes = await client.catalogue.getCollectionBySlug(slug);\n if (!colRes.ok) return null;\n\n cacheTag(tags.collection(colRes.value.id), tags.collectionProducts(colRes.value.id));\n const r = await client.catalogue.getCollectionProducts(colRes.value.id);\n const products = r.ok\n ? ((r.value as { items?: Product[] }).items ?? (r.value as Product[]))\n : [];\n return { collection: colRes.value, products };\n}\n\nexport async function generateMetadata({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}): Promise<Metadata> {\n const { slug } = await params;\n const data = await getCollection(slug);\n if (!data) return {};\n return {\n title: `${data.collection.name} \u2014 ${brand.name}`,\n description: data.collection.description ?? undefined,\n };\n}\n\nexport default async function CollectionPage({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}) {\n return (\n <Suspense fallback={<CollectionSkeleton />}>\n <CollectionContent params={params} />\n </Suspense>\n );\n}\n\nasync function CollectionContent({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}) {\n const { slug } = await params;\n const data = await getCollection(slug);\n if (!data) notFound();\n\n const { collection, products } = data;\n return (\n <section className="max-w-7xl mx-auto px-6 sm:px-8 pt-12">\n <header className="mb-8 text-center">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-2">\n Collection\n </p>\n <h1 className="font-serif text-[clamp(2rem,4vw,2.75rem)] font-semibold mb-2">\n {collection.name}\n </h1>\n {collection.description && (\n <p className="mx-auto mb-2 max-w-xl text-muted-foreground">\n {collection.description}\n </p>\n )}\n <p className="text-sm text-muted-foreground">\n {products.length} item{products.length === 1 ? "" : "s"}\n </p>\n </header>\n <ListingClient products={products} />\n {products.length === 0 && (\n <p className="text-center mt-8">\n <Link href="/" className="text-primary font-semibold">\n \u2190 Back home\n </Link>\n </p>\n )}\n </section>\n );\n}\n\nfunction CollectionSkeleton() {\n return (\n <section className="max-w-7xl mx-auto px-6 sm:px-8 pt-12">\n <header className="mb-8 text-center">\n <div className="mx-auto h-3 w-20 bg-muted rounded mb-2 animate-pulse" />\n <div className="mx-auto h-10 w-64 bg-muted rounded mb-2 animate-pulse" />\n <div className="mx-auto h-4 w-80 bg-muted rounded animate-pulse" />\n </header>\n <div className="grid grid-cols-2 md:grid-cols-3 gap-4">\n {Array.from({ length: 6 }).map((_, i) => (\n <div key={i} className="aspect-square bg-muted rounded-2xl animate-pulse" />\n ))}\n </div>\n </section>\n );\n}\n' }, { "path": "package.json", "kind": "text", "content": '{\n "name": "__STOREFRONT_NAME__",\n "private": true,\n "version": "0.0.0",\n "scripts": {\n "dev": "concurrently -k -n mock,next -c blue,magenta \\"cimplify-mock --seed services --quiet\\" \\"next dev\\"",\n "dev:storefront": "next dev",\n "dev:mock": "cimplify-mock --seed services",\n "build": "next build",\n "start": "next start",\n "typecheck": "tsc --noEmit",\n "test": "vitest",\n "test:run": "vitest run",\n "check:brand": "vitest run __tests__/brand.test.ts",\n "check:cart": "vitest run __tests__/cart-flow.test.ts",\n "check:contract": "vitest run __tests__/contract.test.ts",\n "check": "bun run typecheck && bun run test:run"\n },\n "dependencies": {\n "@cimplify/sdk": "^0.48.1",\n "next": "^16.2.4",\n "react": "^19.0.0",\n "react-dom": "^19.0.0"\n },\n "devDependencies": {\n "@tailwindcss/postcss": "^4.2.4",\n "@types/node": "^22.10.0",\n "@types/react": "^19.0.0",\n "@types/react-dom": "^19.0.0",\n "concurrently": "^9.0.0",\n "tailwindcss": "^4.2.4",\n "typescript": "^5.9.3",\n "vitest": "^4.1.5"\n }\n}\n' }, { "path": "lib/brand.ts", "kind": "text", "content": `/**
|
|
3642
3642
|
* Brand & content configuration \u2014 single source of truth for every visible
|
|
3643
3643
|
* string. Edit this file to rebrand. See ../AGENTS.md.
|
|
3644
3644
|
*/
|
|
@@ -4143,7 +4143,7 @@ function Field({
|
|
|
4143
4143
|
</div>
|
|
4144
4144
|
);
|
|
4145
4145
|
}
|
|
4146
|
-
` }, { "path": "app/terms/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Terms of Service \u2014 ${brand.name}`,\n description: `The rules of the road for ordering from ${brand.name}.`,\n};\n\nexport default function TermsPage() {\n const t = brand.terms;\n return (\n <article className="max-w-3xl mx-auto px-6 sm:px-8 py-16 prose prose-lg max-w-none">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-2 not-prose">\n {t.eyebrow}\n </p>\n <h1 className="font-serif text-[clamp(2.25rem,5vw,3.5rem)] font-semibold mb-2 -tracking-[0.02em]">\n {t.title}\n </h1>\n <p className="text-sm text-muted-foreground not-prose mb-10">\n Last updated: {t.lastUpdated}\n </p>\n\n <section className="space-y-5 leading-relaxed text-foreground/90">\n {t.sections.map((s) => (\n <div key={s.heading}>\n <h2 className="font-serif text-2xl font-semibold mt-0">{s.heading}</h2>\n {typeof s.body === "string" ? (\n <p>{s.body}</p>\n ) : (\n <>\n <p>{s.body.intro}</p>\n <ul className="list-disc pl-6 space-y-2">\n {s.body.bullets.map((b) => (\n <li key={b}>{b}</li>\n ))}\n </ul>\n </>\n )}\n </div>\n ))}\n </section>\n </article>\n );\n}\n' }, { "path": "app/not-found.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport Link from "next/link";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Page not found \u2014 ${brand.name}`,\n description: "We couldn\'t find that page.",\n};\n\nexport default function NotFound() {\n return (\n <section className="max-w-2xl mx-auto px-6 sm:px-8 py-20 text-center">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-3">\n 404\n </p>\n <h1 className="font-serif text-[clamp(2.5rem,5vw,3.5rem)] font-semibold mb-4">\n Page not found.\n </h1>\n <p className="text-muted-foreground leading-relaxed mb-8">\n The URL you followed might be old, a typo, or pointing at a product\n that's sold out for the day. Try the menu or head back home.\n </p>\n <div className="flex flex-wrap items-center justify-center gap-3">\n <Link\n href="/shop"\n className="inline-block px-6 py-3 rounded-full bg-primary text-primary-foreground font-semibold text-sm transition-colors hover:bg-primary/90"\n >\n Browse the menu\n </Link>\n <Link\n href="/"\n className="inline-block px-6 py-3 rounded-full border border-border hover:bg-muted transition-colors text-sm font-medium"\n >\n Back home\n </Link>\n </div>\n </section>\n );\n}\n' }, { "path": "app/orders/[id]/page.tsx", "kind": "text", "content": 'import Link from "next/link";\n\nexport default async function OrderPage({ params }: { params: Promise<{ id: string }> }) {\n const { id } = await params;\n return (\n <section className="max-w-2xl mx-auto px-6 sm:px-8 py-20 text-center">\n <h1 className="font-serif text-3xl mt-0 mb-3">Thanks \u2014 your order is confirmed</h1>\n <p className="text-muted-foreground">\n Order <code className="font-mono text-foreground">{id}</code>\n </p>\n <p className="mt-6">\n <Link\n href="/"\n className="inline-block px-6 py-3 rounded-full bg-primary text-primary-foreground font-semibold text-sm transition-colors hover:bg-primary/90"\n >\n Continue shopping\n </Link>\n </p>\n </section>\n );\n}\n' }, { "path": "app/login/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { redirect } from "next/navigation";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Sign in \u2014 ${brand.name}`,\n description: brand.account.loginSubtitle,\n};\n\n/**\n * Cimplify Link (the iframe in `<CimplifyAccount>`) handles sign-in\n * automatically when no session exists. We just bounce /login to /account\n * so consumers landing here get the right UI without a duplicate form.\n */\nexport default function LoginPage(): never {\n redirect("/account");\n}\n' }, { "path": "app/accessibility/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\nimport { PolicyPage } from "@/components/policy-page";\n\nexport const metadata: Metadata = {\n title: `${brand.accessibility.title} \u2014 ${brand.name}`,\n};\n\nexport default function AccessibilityPage() {\n return <PolicyPage policy={brand.accessibility} />;\n}\n' }, { "path": "app/shipping/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\nimport { PolicyPage } from "@/components/policy-page";\n\nexport const metadata: Metadata = {\n title: `${brand.shipping.title} \u2014 ${brand.name}`,\n description: brand.shipping.sections[0]?.body\n ? typeof brand.shipping.sections[0].body === "string"\n ? brand.shipping.sections[0].body\n : brand.shipping.sections[0].body.intro\n : undefined,\n};\n\nexport default function ShippingPage() {\n return <PolicyPage policy={brand.shipping} />;\n}\n' }, { "path": "app/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Suspense } from "react";\nimport { cacheTag, cacheLife } from "next/cache";\nimport { getServerClient, tags, type Product } from "@cimplify/sdk/server";\nimport { Hero } from "@/components/hero";\nimport { CollectionStrip } from "@/components/collection-strip";\nimport { CategoryGrid } from "@/components/category-grid";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `${brand.name} \u2014 ${brand.hero.title}`,\n description: brand.description,\n};\n\nasync function getHomeData() {\n "use cache";\n cacheTag(tags.collections(), tags.categories(), tags.products());\n cacheLife("hours");\n\n const client = getServerClient();\n const [colRes, catRes] = await Promise.all([\n client.catalogue.getCollections(),\n client.catalogue.getCategories(),\n ]);\n const collections = colRes.ok ? colRes.value : [];\n const categories = catRes.ok ? catRes.value : [];\n\n const collectionsWithProducts = await Promise.all(\n collections.map(async (col) => {\n const r = await client.catalogue.getCollectionProducts(col.id);\n const items = r.ok\n ? ((r.value as { items?: Product[] }).items ?? (r.value as Product[]))\n : [];\n return { collection: col, products: items };\n }),\n );\n\n return {\n collections: collectionsWithProducts.filter((x) => x.products.length > 0),\n categories,\n };\n}\n\nexport default async function HomePage() {\n const { collections, categories } = await getHomeData();\n\n return (\n <>\n <Hero\n badge={brand.hero.badge}\n title={brand.hero.title}\n subtitle={brand.hero.subtitle}\n />\n {collections.map(({ collection, products }) => (\n <Suspense\n key={collection.id}\n fallback={<StripSkeleton title={collection.name} />}\n >\n <CollectionStrip\n collection={collection}\n products={products}\n collectionHref={`/collections/${collection.slug}`}\n />\n </Suspense>\n ))}\n <Suspense fallback={<CategoryGridSkeleton />}>\n <CategoryGrid categories={categories} />\n </Suspense>\n </>\n );\n}\n\nfunction StripSkeleton({ title }: { title: string }) {\n return (\n <section className="max-w-7xl mx-auto px-6 sm:px-8 pt-12">\n <h2 className="font-serif text-[28px] font-semibold m-0 mb-5">{title}</h2>\n <div className="grid grid-flow-col auto-cols-[minmax(220px,1fr)] gap-4 overflow-x-auto pb-2">\n {Array.from({ length: 5 }).map((_, i) => (\n <div key={i} className="aspect-square bg-muted rounded-2xl animate-pulse" />\n ))}\n </div>\n </section>\n );\n}\n\nfunction CategoryGridSkeleton() {\n return (\n <section className="max-w-7xl mx-auto px-6 sm:px-8 pt-14">\n <div className="h-8 w-48 bg-muted rounded mb-5 animate-pulse" />\n <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">\n {Array.from({ length: 6 }).map((_, i) => (\n <div key={i} className="h-32 bg-muted rounded-2xl animate-pulse" />\n ))}\n </div>\n </section>\n );\n}\n' }, { "path": "app/categories/[slug]/listing-client.tsx", "kind": "text", "content": '"use client";\n\nimport { ProductGrid } from "@cimplify/sdk/react";\nimport type { Product } from "@cimplify/sdk";\nimport { StoreProductCard } from "@/components/store-product-card";\n\n/**\n * Client island for the category listing. Receives server-fetched products\n * as props (serializable) and owns the `renderCard` function.\n */\nexport function ListingClient({ products }: { products: Product[] }) {\n return (\n <ProductGrid\n products={products}\n emptyMessage="No products in this category yet."\n renderCard={(p) => <StoreProductCard product={p} />}\n />\n );\n}\n' }, { "path": "app/categories/[slug]/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Suspense } from "react";\nimport Link from "next/link";\nimport { notFound } from "next/navigation";\nimport { cacheTag, cacheLife } from "next/cache";\nimport {\n getServerClient,\n tags,\n type Category,\n type Product,\n} from "@cimplify/sdk/server";\nimport { ListingClient } from "./listing-client";\nimport { brand } from "@/lib/brand";\n\ninterface CategoryData {\n category: Category;\n products: Product[];\n}\n\nasync function getCategory(slug: string): Promise<CategoryData | null> {\n "use cache";\n cacheTag(tags.categories());\n cacheLife("hours");\n\n const client = getServerClient();\n const catRes = await client.catalogue.getCategoryBySlug(slug);\n if (!catRes.ok) return null;\n\n cacheTag(tags.category(catRes.value.id), tags.categoryProducts(catRes.value.id));\n const r = await client.catalogue.getCategoryProducts(catRes.value.id);\n const products = r.ok\n ? ((r.value as { items?: Product[] }).items ?? (r.value as Product[]))\n : [];\n return { category: catRes.value, products };\n}\n\nexport async function generateMetadata({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}): Promise<Metadata> {\n const { slug } = await params;\n const data = await getCategory(slug);\n if (!data) return {};\n return {\n title: `${data.category.name} \u2014 ${brand.name}`,\n description: data.category.description ?? undefined,\n };\n}\n\nexport default async function CategoryPage({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}) {\n return (\n <Suspense fallback={<CategorySkeleton />}>\n <CategoryContent params={params} />\n </Suspense>\n );\n}\n\nasync function CategoryContent({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}) {\n const { slug } = await params;\n const data = await getCategory(slug);\n if (!data) notFound();\n\n const { category, products } = data;\n return (\n <section className="max-w-7xl mx-auto px-6 sm:px-8 pt-12">\n <header className="mb-8 text-center">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-2">\n Category\n </p>\n <h1 className="font-serif text-[clamp(2rem,4vw,2.75rem)] font-semibold mb-2">\n {category.name}\n </h1>\n {category.description && (\n <p className="mx-auto mb-2 max-w-xl text-muted-foreground">\n {category.description}\n </p>\n )}\n <p className="text-sm text-muted-foreground">\n {products.length} item{products.length === 1 ? "" : "s"}\n </p>\n </header>\n <ListingClient products={products} />\n {products.length === 0 && (\n <p className="text-center mt-8">\n <Link href="/" className="text-primary font-semibold">\n \u2190 Back home\n </Link>\n </p>\n )}\n </section>\n );\n}\n\nfunction CategorySkeleton() {\n return (\n <section className="max-w-7xl mx-auto px-6 sm:px-8 pt-12">\n <header className="mb-8 text-center">\n <div className="mx-auto h-3 w-20 bg-muted rounded mb-2 animate-pulse" />\n <div className="mx-auto h-10 w-64 bg-muted rounded mb-2 animate-pulse" />\n <div className="mx-auto h-4 w-80 bg-muted rounded animate-pulse" />\n </header>\n <div className="grid grid-cols-2 md:grid-cols-3 gap-4">\n {Array.from({ length: 6 }).map((_, i) => (\n <div key={i} className="aspect-square bg-muted rounded-2xl animate-pulse" />\n ))}\n </div>\n </section>\n );\n}\n' }, { "path": "app/error.tsx", "kind": "text", "content": '"use client";\n\nimport { useEffect } from "react";\nimport Link from "next/link";\n\n/**\n * Root error boundary. Next.js calls this whenever a thrown error escapes\n * a Server Component without being caught by a closer `error.tsx`.\n *\n * Wire `reportError` (Sentry, Datadog, etc.) here so production failures\n * surface \u2014 silently recovering hides real bugs.\n */\nexport default function GlobalError({\n error,\n reset,\n}: {\n error: Error & { digest?: string };\n reset: () => void;\n}) {\n useEffect(() => {\n // TODO: replace with your error reporter\n // eslint-disable-next-line no-console\n console.error("Storefront error:", error);\n }, [error]);\n\n return (\n <section className="max-w-2xl mx-auto px-6 sm:px-8 py-20 text-center">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-3">\n Something broke\n </p>\n <h1 className="font-serif text-[clamp(2rem,4vw,2.75rem)] font-semibold mb-4">\n That wasn't supposed to happen.\n </h1>\n <p className="text-muted-foreground leading-relaxed mb-8">\n We've been notified. Refresh the page, or head back home \u2014\n most of the time the second try just works.\n </p>\n {error.digest && (\n <p className="text-xs font-mono text-muted-foreground mb-6">\n Reference: <code className="text-foreground">{error.digest}</code>\n </p>\n )}\n <div className="flex flex-wrap items-center justify-center gap-3">\n <button\n type="button"\n onClick={reset}\n className="inline-block px-6 py-3 rounded-full bg-primary text-primary-foreground font-semibold text-sm transition-colors hover:bg-primary/90"\n >\n Try again\n </button>\n <Link\n href="/"\n className="inline-block px-6 py-3 rounded-full border border-border hover:bg-muted transition-colors text-sm font-medium"\n >\n Back home\n </Link>\n </div>\n </section>\n );\n}\n' }, { "path": "app/globals.css", "kind": "text", "content": '@import "tailwindcss";\n\n/* Scan the SDK\'s bundled JS so utility classes used inside its components\n land in the compiled stylesheet alongside our own. */\n@source "../node_modules/@cimplify/sdk/dist";\n\n/* The SDK declares its design tokens with `@theme reference`, meaning the\n consumer must supply the actual values. Warm bakery palette below \u2014 swap\n these to retheme the entire SDK + storefront in one place. */\n@theme {\n --color-background: oklch(0.985 0.007 80);\n --color-foreground: oklch(0.18 0.01 60);\n --color-card: oklch(1 0 0);\n --color-card-foreground: oklch(0.18 0.01 60);\n --color-popover: oklch(1 0 0);\n --color-popover-foreground: oklch(0.18 0.01 60);\n --color-primary: oklch(0.45 0.16 40);\n --color-primary-foreground: oklch(0.99 0 0);\n --color-secondary: oklch(0.95 0.02 80);\n --color-secondary-foreground: oklch(0.25 0.04 50);\n --color-muted: oklch(0.96 0.01 80);\n --color-muted-foreground: oklch(0.5 0.02 60);\n --color-accent: oklch(0.92 0.05 70);\n --color-accent-foreground: oklch(0.3 0.06 40);\n --color-destructive: oklch(0.58 0.24 27);\n --color-destructive-foreground: oklch(0.99 0 0);\n --color-border: oklch(0.9 0.02 80);\n --color-input: oklch(0.92 0.02 80);\n --color-ring: oklch(0.6 0.1 40);\n --radius: 0.75rem;\n}\n\n@layer base {\n html, body {\n margin: 0;\n padding: 0;\n background: var(--color-background);\n color: var(--color-foreground);\n -webkit-font-smoothing: antialiased;\n -moz-osx-font-smoothing: grayscale;\n }\n a { color: inherit; text-decoration: none; }\n h1, h2, h3, h4 {\n font-family: var(--font-serif);\n letter-spacing: -0.01em;\n }\n}\n' }, { "path": "app/sitemap-page/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport Link from "next/link";\nimport { cacheTag, cacheLife } from "next/cache";\nimport { getServerClient, tags, type Product } from "@cimplify/sdk/server";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Sitemap \u2014 ${brand.name}`,\n description: "A human-readable index of every page on this site.",\n};\n\ninterface SitemapData {\n products: { slug: string; name: string }[];\n categories: { slug: string; name: string }[];\n collections: { slug: string; name: string }[];\n}\n\nasync function getSitemap(): Promise<SitemapData> {\n "use cache";\n cacheTag(tags.products(), tags.categories(), tags.collections());\n cacheLife("hours");\n\n const client = getServerClient();\n const [pRes, cRes, colRes] = await Promise.all([\n client.catalogue.getProducts({ limit: 500 }),\n client.catalogue.getCategories(),\n client.catalogue.getCollections(),\n ]);\n return {\n products: (pRes.ok ? pRes.value.items : []).map((p: Product) => ({\n slug: p.slug ?? p.id,\n name: p.name,\n })),\n categories: (cRes.ok ? cRes.value : []).map((c) => ({ slug: c.slug, name: c.name })),\n collections: (colRes.ok ? colRes.value : []).map((c) => ({ slug: c.slug, name: c.name })),\n };\n}\n\nconst STATIC_LINKS: { title: string; links: { href: string; label: string }[] }[] = [\n {\n title: "Browse",\n links: [\n { href: "/", label: "Home" },\n { href: "/shop", label: "Shop" },\n { href: "/search", label: "Search" },\n ],\n },\n {\n title: "Account",\n links: [\n { href: "/account", label: "Account" },\n { href: "/account/orders", label: "Orders" },\n { href: "/account/addresses", label: "Addresses" },\n { href: "/account/settings", label: "Settings" },\n { href: "/login", label: "Sign in" },\n { href: "/signup", label: "Create account" },\n { href: "/track-order", label: "Track an order" },\n { href: "/cart", label: "Cart" },\n { href: "/checkout", label: "Checkout" },\n ],\n },\n {\n title: "About",\n links: [\n { href: "/about", label: "About" },\n { href: "/faq", label: "FAQ" },\n { href: "/contact", label: "Contact" },\n ],\n },\n {\n title: "Policies",\n links: [\n { href: "/shipping", label: "Shipping" },\n { href: "/returns", label: "Returns" },\n { href: "/accessibility", label: "Accessibility" },\n { href: "/terms", label: "Terms of Service" },\n { href: "/privacy", label: "Privacy Policy" },\n ],\n },\n {\n title: "Machine-readable",\n links: [\n { href: "/sitemap.xml", label: "sitemap.xml (search engines)" },\n { href: "/llms.txt", label: "llms.txt (LLM agents)" },\n { href: "/robots.txt", label: "robots.txt" },\n { href: "/opensearch.xml", label: "opensearch.xml (browser search)" },\n ],\n },\n];\n\nexport default async function SitemapHtmlPage() {\n const { products, categories, collections } = await getSitemap();\n\n return (\n <article className="max-w-5xl mx-auto px-6 sm:px-8 py-16">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-2">\n Sitemap\n </p>\n <h1 className="text-[clamp(2rem,4vw,2.75rem)] font-semibold mb-2 -tracking-[0.02em]">\n Every page, in one place.\n </h1>\n <p className="text-muted-foreground mb-12">\n For search engines, see <Link href="/sitemap.xml" className="text-primary hover:underline">/sitemap.xml</Link>.\n For LLM agents, see <Link href="/llms.txt" className="text-primary hover:underline">/llms.txt</Link>.\n </p>\n <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-10">\n {STATIC_LINKS.map((s) => (\n <Section key={s.title} title={s.title}>\n {s.links.map((l) => (\n <li key={l.href}>\n <Link href={l.href} className="hover:text-primary transition-colors">\n {l.label}\n </Link>\n </li>\n ))}\n </Section>\n ))}\n {categories.length > 0 && (\n <Section title="Categories">\n {categories.map((c) => (\n <li key={c.slug}>\n <Link href={`/categories/${c.slug}`} className="hover:text-primary transition-colors">\n {c.name}\n </Link>\n </li>\n ))}\n </Section>\n )}\n {collections.length > 0 && (\n <Section title="Collections">\n {collections.map((c) => (\n <li key={c.slug}>\n <Link href={`/collections/${c.slug}`} className="hover:text-primary transition-colors">\n {c.name}\n </Link>\n </li>\n ))}\n </Section>\n )}\n {products.length > 0 && (\n <Section title={`Products (${products.length})`}>\n {products.map((p) => (\n <li key={p.slug}>\n <Link href={`/shop?product=${encodeURIComponent(p.slug)}`} className="hover:text-primary transition-colors">\n {p.name}\n </Link>\n </li>\n ))}\n </Section>\n )}\n </div>\n </article>\n );\n}\n\nfunction Section({ title, children }: { title: string; children: React.ReactNode }) {\n return (\n <div>\n <p className="font-semibold text-[12px] uppercase tracking-[0.12em] text-foreground mb-3">\n {title}\n </p>\n <ul className="space-y-2 m-0 p-0 list-none text-sm text-muted-foreground">\n {children}\n </ul>\n </div>\n );\n}\n' }, { "path": "app/shop/shop-client.tsx", "kind": "text", "content": '"use client";\n\nimport { CataloguePage } from "@cimplify/sdk/react";\nimport type { Category, Product } from "@cimplify/sdk";\nimport { StoreProductCard } from "@/components/store-product-card";\n\n/**\n * Client island for the shop page. Server-side fetches all products and\n * categories (cached via `\'use cache\'` in `app/shop/page.tsx`), then hands\n * them to `<CataloguePage>` which owns the interactive filter / sort state.\n */\nexport function ShopClient({\n products,\n categories,\n}: {\n products: Product[];\n categories: Category[];\n}) {\n return (\n <CataloguePage\n title="The Menu"\n products={products}\n categories={categories}\n renderCard={(p) => <StoreProductCard product={p} />}\n />\n );\n}\n' }, { "path": "app/shop/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { cacheTag, cacheLife } from "next/cache";\nimport { getServerClient, tags } from "@cimplify/sdk/server";\nimport { ShopClient } from "./shop-client";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Shop \u2014 ${brand.name}`,\n description: brand.description,\n};\n\nasync function getShopData() {\n "use cache";\n cacheTag(tags.products(), tags.categories());\n cacheLife("hours");\n\n const client = getServerClient();\n const [p, c] = await Promise.all([\n client.catalogue.getProducts({ limit: 50 }),\n client.catalogue.getCategories(),\n ]);\n return {\n products: p.ok ? p.value.items : [],\n categories: c.ok ? c.value : [],\n };\n}\n\nexport default async function ShopPage() {\n const { products, categories } = await getShopData();\n return <ShopClient products={products} categories={categories} />;\n}\n' }, { "path": "app/returns/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\nimport { PolicyPage } from "@/components/policy-page";\n\nexport const metadata: Metadata = {\n title: `${brand.returns.title} \u2014 ${brand.name}`,\n};\n\nexport default function ReturnsPage() {\n return <PolicyPage policy={brand.returns} />;\n}\n' }, { "path": "app/layout.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Inter, Playfair_Display } from "next/font/google";\nimport "./globals.css";\nimport { Providers } from "@/components/providers";\nimport { Header } from "@/components/header";\nimport { Footer } from "@/components/footer";\nimport { ProductModal } from "@/components/product-modal";\nimport { CartDrawer } from "@/components/cart-drawer";\nimport { OrganizationJsonLd } from "@/components/json-ld";\nimport { Suspense } from "react";\nimport { brand } from "@/lib/brand";\nimport { getSiteUrl } from "@/lib/site-url";\n\nconst inter = Inter({\n subsets: ["latin"],\n variable: "--font-sans",\n display: "swap",\n});\n\nconst playfair = Playfair_Display({\n subsets: ["latin"],\n variable: "--font-serif",\n display: "swap",\n});\n\nexport async function generateMetadata(): Promise<Metadata> {\n const siteUrl = await getSiteUrl();\n return {\n metadataBase: new URL(siteUrl),\n title: {\n default: brand.name,\n template: `%s \u2014 ${brand.name}`,\n },\n description: brand.description,\n openGraph: {\n type: "website",\n siteName: brand.name,\n locale: brand.locale,\n },\n twitter: { card: "summary_large_image" },\n };\n}\n\nexport default function RootLayout({ children }: { children: React.ReactNode }) {\n return (\n <html lang="en" suppressHydrationWarning className={`${inter.variable} ${playfair.variable}`}>\n <body\n suppressHydrationWarning\n className="min-h-screen flex flex-col bg-background text-foreground font-sans"\n >\n <Suspense fallback={null}>\n <OrganizationJsonLd />\n </Suspense>\n <Providers>\n <Header />\n <main className="flex-1 pb-12 w-full">{children}</main>\n <Footer />\n <Suspense fallback={null}>\n <ProductModal />\n </Suspense>\n <CartDrawer />\n </Providers>\n </body>\n </html>\n );\n}\n' }, { "path": "app/checkout/page.tsx", "kind": "text", "content": '"use client";\n\nimport { useRouter } from "next/navigation";\nimport { CheckoutPage as SdkCheckoutPage } from "@cimplify/sdk/react";\n\nexport default function CheckoutPage() {\n const router = useRouter();\n return (\n <SdkCheckoutPage\n onComplete={(result) => {\n if (result.success && result.order) {\n router.push(`/orders/${result.order.id}`);\n }\n }}\n />\n );\n}\n' }, { "path": "app/privacy/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Privacy Policy \u2014 ${brand.name}`,\n description: `How ${brand.name} collects, uses, and protects your personal data.`,\n};\n\nexport default function PrivacyPage() {\n const p = brand.privacy;\n return (\n <article className="max-w-3xl mx-auto px-6 sm:px-8 py-16 prose prose-lg max-w-none">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-2 not-prose">\n {p.eyebrow}\n </p>\n <h1 className="font-serif text-[clamp(2.25rem,5vw,3.5rem)] font-semibold mb-2 -tracking-[0.02em]">\n {p.title}\n </h1>\n <p className="text-sm text-muted-foreground not-prose mb-10">\n Last updated: {p.lastUpdated}\n </p>\n\n <section className="space-y-5 leading-relaxed text-foreground/90">\n {p.sections.map((s) => (\n <div key={s.heading}>\n <h2 className="font-serif text-2xl font-semibold mt-0">{s.heading}</h2>\n {typeof s.body === "string" ? (\n <p>{s.body}</p>\n ) : (\n <>\n <p>{s.body.intro}</p>\n <ul className="list-disc pl-6 space-y-2">\n {s.body.bullets.map((b) => (\n <li key={b}>{b}</li>\n ))}\n </ul>\n </>\n )}\n </div>\n ))}\n </section>\n </article>\n );\n}\n' }, { "path": "app/collections/[slug]/listing-client.tsx", "kind": "text", "content": '"use client";\n\nimport { ProductGrid } from "@cimplify/sdk/react";\nimport type { Product } from "@cimplify/sdk";\nimport { StoreProductCard } from "@/components/store-product-card";\n\n/**\n * Client island for the collection listing. Receives server-fetched\n * products as props (serializable) and owns the `renderCard` function\n * (which can\'t cross the server/client boundary).\n */\nexport function ListingClient({ products }: { products: Product[] }) {\n return (\n <ProductGrid\n products={products}\n emptyMessage="No products in this collection yet."\n renderCard={(p) => <StoreProductCard product={p} />}\n />\n );\n}\n' }, { "path": "app/collections/[slug]/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Suspense } from "react";\nimport Link from "next/link";\nimport { notFound } from "next/navigation";\nimport { cacheTag, cacheLife } from "next/cache";\nimport {\n getServerClient,\n tags,\n type Collection,\n type Product,\n} from "@cimplify/sdk/server";\nimport { ListingClient } from "./listing-client";\nimport { brand } from "@/lib/brand";\n\ninterface CollectionData {\n collection: Collection;\n products: Product[];\n}\n\nasync function getCollection(slug: string): Promise<CollectionData | null> {\n "use cache";\n cacheTag(tags.collections());\n cacheLife("hours");\n\n const client = getServerClient();\n const colRes = await client.catalogue.getCollectionBySlug(slug);\n if (!colRes.ok) return null;\n\n cacheTag(tags.collection(colRes.value.id), tags.collectionProducts(colRes.value.id));\n const r = await client.catalogue.getCollectionProducts(colRes.value.id);\n const products = r.ok\n ? ((r.value as { items?: Product[] }).items ?? (r.value as Product[]))\n : [];\n return { collection: colRes.value, products };\n}\n\nexport async function generateMetadata({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}): Promise<Metadata> {\n const { slug } = await params;\n const data = await getCollection(slug);\n if (!data) return {};\n return {\n title: `${data.collection.name} \u2014 ${brand.name}`,\n description: data.collection.description ?? undefined,\n };\n}\n\nexport default async function CollectionPage({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}) {\n return (\n <Suspense fallback={<CollectionSkeleton />}>\n <CollectionContent params={params} />\n </Suspense>\n );\n}\n\nasync function CollectionContent({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}) {\n const { slug } = await params;\n const data = await getCollection(slug);\n if (!data) notFound();\n\n const { collection, products } = data;\n return (\n <section className="max-w-7xl mx-auto px-6 sm:px-8 pt-12">\n <header className="mb-8 text-center">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-2">\n Collection\n </p>\n <h1 className="font-serif text-[clamp(2rem,4vw,2.75rem)] font-semibold mb-2">\n {collection.name}\n </h1>\n {collection.description && (\n <p className="mx-auto mb-2 max-w-xl text-muted-foreground">\n {collection.description}\n </p>\n )}\n <p className="text-sm text-muted-foreground">\n {products.length} item{products.length === 1 ? "" : "s"}\n </p>\n </header>\n <ListingClient products={products} />\n {products.length === 0 && (\n <p className="text-center mt-8">\n <Link href="/" className="text-primary font-semibold">\n \u2190 Back home\n </Link>\n </p>\n )}\n </section>\n );\n}\n\nfunction CollectionSkeleton() {\n return (\n <section className="max-w-7xl mx-auto px-6 sm:px-8 pt-12">\n <header className="mb-8 text-center">\n <div className="mx-auto h-3 w-20 bg-muted rounded mb-2 animate-pulse" />\n <div className="mx-auto h-10 w-64 bg-muted rounded mb-2 animate-pulse" />\n <div className="mx-auto h-4 w-80 bg-muted rounded animate-pulse" />\n </header>\n <div className="grid grid-cols-2 md:grid-cols-3 gap-4">\n {Array.from({ length: 6 }).map((_, i) => (\n <div key={i} className="aspect-square bg-muted rounded-2xl animate-pulse" />\n ))}\n </div>\n </section>\n );\n}\n' }, { "path": "package.json", "kind": "text", "content": '{\n "name": "__STOREFRONT_NAME__",\n "private": true,\n "version": "0.0.0",\n "scripts": {\n "dev": "concurrently -k -n mock,next -c blue,magenta \\"cimplify-mock --seed default --quiet\\" \\"next dev\\"",\n "dev:storefront": "next dev",\n "dev:mock": "cimplify-mock --seed default",\n "build": "next build",\n "start": "next start",\n "typecheck": "tsc --noEmit",\n "test": "vitest",\n "test:run": "vitest run",\n "check:brand": "vitest run __tests__/brand.test.ts",\n "check:cart": "vitest run __tests__/cart-flow.test.ts",\n "check:contract": "vitest run __tests__/contract.test.ts",\n "check": "bun run typecheck && bun run test:run"\n },\n "dependencies": {\n "@cimplify/sdk": "^0.48.0",\n "next": "^16.2.4",\n "react": "^19.0.0",\n "react-dom": "^19.0.0"\n },\n "devDependencies": {\n "@tailwindcss/postcss": "^4.2.4",\n "@types/node": "^22.10.0",\n "@types/react": "^19.0.0",\n "@types/react-dom": "^19.0.0",\n "concurrently": "^9.0.0",\n "tailwindcss": "^4.2.4",\n "typescript": "^5.9.3",\n "vitest": "^4.1.5"\n }\n}\n' }, { "path": "lib/brand.ts", "kind": "text", "content": `/**
|
|
4146
|
+
` }, { "path": "app/terms/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Terms of Service \u2014 ${brand.name}`,\n description: `The rules of the road for ordering from ${brand.name}.`,\n};\n\nexport default function TermsPage() {\n const t = brand.terms;\n return (\n <article className="max-w-3xl mx-auto px-6 sm:px-8 py-16 prose prose-lg max-w-none">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-2 not-prose">\n {t.eyebrow}\n </p>\n <h1 className="font-serif text-[clamp(2.25rem,5vw,3.5rem)] font-semibold mb-2 -tracking-[0.02em]">\n {t.title}\n </h1>\n <p className="text-sm text-muted-foreground not-prose mb-10">\n Last updated: {t.lastUpdated}\n </p>\n\n <section className="space-y-5 leading-relaxed text-foreground/90">\n {t.sections.map((s) => (\n <div key={s.heading}>\n <h2 className="font-serif text-2xl font-semibold mt-0">{s.heading}</h2>\n {typeof s.body === "string" ? (\n <p>{s.body}</p>\n ) : (\n <>\n <p>{s.body.intro}</p>\n <ul className="list-disc pl-6 space-y-2">\n {s.body.bullets.map((b) => (\n <li key={b}>{b}</li>\n ))}\n </ul>\n </>\n )}\n </div>\n ))}\n </section>\n </article>\n );\n}\n' }, { "path": "app/not-found.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport Link from "next/link";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Page not found \u2014 ${brand.name}`,\n description: "We couldn\'t find that page.",\n};\n\nexport default function NotFound() {\n return (\n <section className="max-w-2xl mx-auto px-6 sm:px-8 py-20 text-center">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-3">\n 404\n </p>\n <h1 className="font-serif text-[clamp(2.5rem,5vw,3.5rem)] font-semibold mb-4">\n Page not found.\n </h1>\n <p className="text-muted-foreground leading-relaxed mb-8">\n The URL you followed might be old, a typo, or pointing at a product\n that's sold out for the day. Try the menu or head back home.\n </p>\n <div className="flex flex-wrap items-center justify-center gap-3">\n <Link\n href="/shop"\n className="inline-block px-6 py-3 rounded-full bg-primary text-primary-foreground font-semibold text-sm transition-colors hover:bg-primary/90"\n >\n Browse the menu\n </Link>\n <Link\n href="/"\n className="inline-block px-6 py-3 rounded-full border border-border hover:bg-muted transition-colors text-sm font-medium"\n >\n Back home\n </Link>\n </div>\n </section>\n );\n}\n' }, { "path": "app/orders/[id]/page.tsx", "kind": "text", "content": 'import Link from "next/link";\n\nexport default async function OrderPage({ params }: { params: Promise<{ id: string }> }) {\n const { id } = await params;\n return (\n <section className="max-w-2xl mx-auto px-6 sm:px-8 py-20 text-center">\n <h1 className="font-serif text-3xl mt-0 mb-3">Thanks \u2014 your order is confirmed</h1>\n <p className="text-muted-foreground">\n Order <code className="font-mono text-foreground">{id}</code>\n </p>\n <p className="mt-6">\n <Link\n href="/"\n className="inline-block px-6 py-3 rounded-full bg-primary text-primary-foreground font-semibold text-sm transition-colors hover:bg-primary/90"\n >\n Continue shopping\n </Link>\n </p>\n </section>\n );\n}\n' }, { "path": "app/login/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { redirect } from "next/navigation";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Sign in \u2014 ${brand.name}`,\n description: brand.account.loginSubtitle,\n};\n\n/**\n * Cimplify Link (the iframe in `<CimplifyAccount>`) handles sign-in\n * automatically when no session exists. We just bounce /login to /account\n * so consumers landing here get the right UI without a duplicate form.\n */\nexport default function LoginPage(): never {\n redirect("/account");\n}\n' }, { "path": "app/accessibility/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\nimport { PolicyPage } from "@/components/policy-page";\n\nexport const metadata: Metadata = {\n title: `${brand.accessibility.title} \u2014 ${brand.name}`,\n};\n\nexport default function AccessibilityPage() {\n return <PolicyPage policy={brand.accessibility} />;\n}\n' }, { "path": "app/shipping/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\nimport { PolicyPage } from "@/components/policy-page";\n\nexport const metadata: Metadata = {\n title: `${brand.shipping.title} \u2014 ${brand.name}`,\n description: brand.shipping.sections[0]?.body\n ? typeof brand.shipping.sections[0].body === "string"\n ? brand.shipping.sections[0].body\n : brand.shipping.sections[0].body.intro\n : undefined,\n};\n\nexport default function ShippingPage() {\n return <PolicyPage policy={brand.shipping} />;\n}\n' }, { "path": "app/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Suspense } from "react";\nimport { cacheTag, cacheLife } from "next/cache";\nimport { getServerClient, tags, type Product } from "@cimplify/sdk/server";\nimport { Hero } from "@/components/hero";\nimport { CollectionStrip } from "@/components/collection-strip";\nimport { CategoryGrid } from "@/components/category-grid";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `${brand.name} \u2014 ${brand.hero.title}`,\n description: brand.description,\n};\n\nasync function getHomeData() {\n "use cache";\n cacheTag(tags.collections(), tags.categories(), tags.products());\n cacheLife("hours");\n\n const client = getServerClient();\n const [colRes, catRes] = await Promise.all([\n client.catalogue.getCollections(),\n client.catalogue.getCategories(),\n ]);\n const collections = colRes.ok ? colRes.value : [];\n const categories = catRes.ok ? catRes.value : [];\n\n const collectionsWithProducts = await Promise.all(\n collections.map(async (col) => {\n const r = await client.catalogue.getCollectionProducts(col.id);\n const items = r.ok\n ? ((r.value as { items?: Product[] }).items ?? (r.value as Product[]))\n : [];\n return { collection: col, products: items };\n }),\n );\n\n return {\n collections: collectionsWithProducts.filter((x) => x.products.length > 0),\n categories,\n };\n}\n\nexport default async function HomePage() {\n const { collections, categories } = await getHomeData();\n\n return (\n <>\n <Hero\n badge={brand.hero.badge}\n title={brand.hero.title}\n subtitle={brand.hero.subtitle}\n />\n {collections.map(({ collection, products }) => (\n <Suspense\n key={collection.id}\n fallback={<StripSkeleton title={collection.name} />}\n >\n <CollectionStrip\n collection={collection}\n products={products}\n collectionHref={`/collections/${collection.slug}`}\n />\n </Suspense>\n ))}\n <Suspense fallback={<CategoryGridSkeleton />}>\n <CategoryGrid categories={categories} />\n </Suspense>\n </>\n );\n}\n\nfunction StripSkeleton({ title }: { title: string }) {\n return (\n <section className="max-w-7xl mx-auto px-6 sm:px-8 pt-12">\n <h2 className="font-serif text-[28px] font-semibold m-0 mb-5">{title}</h2>\n <div className="grid grid-flow-col auto-cols-[minmax(220px,1fr)] gap-4 overflow-x-auto pb-2">\n {Array.from({ length: 5 }).map((_, i) => (\n <div key={i} className="aspect-square bg-muted rounded-2xl animate-pulse" />\n ))}\n </div>\n </section>\n );\n}\n\nfunction CategoryGridSkeleton() {\n return (\n <section className="max-w-7xl mx-auto px-6 sm:px-8 pt-14">\n <div className="h-8 w-48 bg-muted rounded mb-5 animate-pulse" />\n <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">\n {Array.from({ length: 6 }).map((_, i) => (\n <div key={i} className="h-32 bg-muted rounded-2xl animate-pulse" />\n ))}\n </div>\n </section>\n );\n}\n' }, { "path": "app/categories/[slug]/listing-client.tsx", "kind": "text", "content": '"use client";\n\nimport { ProductGrid } from "@cimplify/sdk/react";\nimport type { Product } from "@cimplify/sdk";\nimport { StoreProductCard } from "@/components/store-product-card";\n\n/**\n * Client island for the category listing. Receives server-fetched products\n * as props (serializable) and owns the `renderCard` function.\n */\nexport function ListingClient({ products }: { products: Product[] }) {\n return (\n <ProductGrid\n products={products}\n emptyMessage="No products in this category yet."\n renderCard={(p) => <StoreProductCard product={p} />}\n />\n );\n}\n' }, { "path": "app/categories/[slug]/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Suspense } from "react";\nimport Link from "next/link";\nimport { notFound } from "next/navigation";\nimport { cacheTag, cacheLife } from "next/cache";\nimport {\n getServerClient,\n tags,\n type Category,\n type Product,\n} from "@cimplify/sdk/server";\nimport { ListingClient } from "./listing-client";\nimport { brand } from "@/lib/brand";\n\ninterface CategoryData {\n category: Category;\n products: Product[];\n}\n\nasync function getCategory(slug: string): Promise<CategoryData | null> {\n "use cache";\n cacheTag(tags.categories());\n cacheLife("hours");\n\n const client = getServerClient();\n const catRes = await client.catalogue.getCategoryBySlug(slug);\n if (!catRes.ok) return null;\n\n cacheTag(tags.category(catRes.value.id), tags.categoryProducts(catRes.value.id));\n const r = await client.catalogue.getCategoryProducts(catRes.value.id);\n const products = r.ok\n ? ((r.value as { items?: Product[] }).items ?? (r.value as Product[]))\n : [];\n return { category: catRes.value, products };\n}\n\nexport async function generateMetadata({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}): Promise<Metadata> {\n const { slug } = await params;\n const data = await getCategory(slug);\n if (!data) return {};\n return {\n title: `${data.category.name} \u2014 ${brand.name}`,\n description: data.category.description ?? undefined,\n };\n}\n\nexport default async function CategoryPage({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}) {\n return (\n <Suspense fallback={<CategorySkeleton />}>\n <CategoryContent params={params} />\n </Suspense>\n );\n}\n\nasync function CategoryContent({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}) {\n const { slug } = await params;\n const data = await getCategory(slug);\n if (!data) notFound();\n\n const { category, products } = data;\n return (\n <section className="max-w-7xl mx-auto px-6 sm:px-8 pt-12">\n <header className="mb-8 text-center">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-2">\n Category\n </p>\n <h1 className="font-serif text-[clamp(2rem,4vw,2.75rem)] font-semibold mb-2">\n {category.name}\n </h1>\n {category.description && (\n <p className="mx-auto mb-2 max-w-xl text-muted-foreground">\n {category.description}\n </p>\n )}\n <p className="text-sm text-muted-foreground">\n {products.length} item{products.length === 1 ? "" : "s"}\n </p>\n </header>\n <ListingClient products={products} />\n {products.length === 0 && (\n <p className="text-center mt-8">\n <Link href="/" className="text-primary font-semibold">\n \u2190 Back home\n </Link>\n </p>\n )}\n </section>\n );\n}\n\nfunction CategorySkeleton() {\n return (\n <section className="max-w-7xl mx-auto px-6 sm:px-8 pt-12">\n <header className="mb-8 text-center">\n <div className="mx-auto h-3 w-20 bg-muted rounded mb-2 animate-pulse" />\n <div className="mx-auto h-10 w-64 bg-muted rounded mb-2 animate-pulse" />\n <div className="mx-auto h-4 w-80 bg-muted rounded animate-pulse" />\n </header>\n <div className="grid grid-cols-2 md:grid-cols-3 gap-4">\n {Array.from({ length: 6 }).map((_, i) => (\n <div key={i} className="aspect-square bg-muted rounded-2xl animate-pulse" />\n ))}\n </div>\n </section>\n );\n}\n' }, { "path": "app/error.tsx", "kind": "text", "content": '"use client";\n\nimport { useEffect } from "react";\nimport Link from "next/link";\n\n/**\n * Root error boundary. Next.js calls this whenever a thrown error escapes\n * a Server Component without being caught by a closer `error.tsx`.\n *\n * Wire `reportError` (Sentry, Datadog, etc.) here so production failures\n * surface \u2014 silently recovering hides real bugs.\n */\nexport default function GlobalError({\n error,\n reset,\n}: {\n error: Error & { digest?: string };\n reset: () => void;\n}) {\n useEffect(() => {\n // TODO: replace with your error reporter\n // eslint-disable-next-line no-console\n console.error("Storefront error:", error);\n }, [error]);\n\n return (\n <section className="max-w-2xl mx-auto px-6 sm:px-8 py-20 text-center">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-3">\n Something broke\n </p>\n <h1 className="font-serif text-[clamp(2rem,4vw,2.75rem)] font-semibold mb-4">\n That wasn't supposed to happen.\n </h1>\n <p className="text-muted-foreground leading-relaxed mb-8">\n We've been notified. Refresh the page, or head back home \u2014\n most of the time the second try just works.\n </p>\n {error.digest && (\n <p className="text-xs font-mono text-muted-foreground mb-6">\n Reference: <code className="text-foreground">{error.digest}</code>\n </p>\n )}\n <div className="flex flex-wrap items-center justify-center gap-3">\n <button\n type="button"\n onClick={reset}\n className="inline-block px-6 py-3 rounded-full bg-primary text-primary-foreground font-semibold text-sm transition-colors hover:bg-primary/90"\n >\n Try again\n </button>\n <Link\n href="/"\n className="inline-block px-6 py-3 rounded-full border border-border hover:bg-muted transition-colors text-sm font-medium"\n >\n Back home\n </Link>\n </div>\n </section>\n );\n}\n' }, { "path": "app/globals.css", "kind": "text", "content": '@import "tailwindcss";\n\n/* Scan the SDK\'s bundled JS so utility classes used inside its components\n land in the compiled stylesheet alongside our own. */\n@source "../node_modules/@cimplify/sdk/dist";\n\n/* The SDK declares its design tokens with `@theme reference`, meaning the\n consumer must supply the actual values. Warm bakery palette below \u2014 swap\n these to retheme the entire SDK + storefront in one place. */\n@theme {\n --color-background: oklch(0.985 0.007 80);\n --color-foreground: oklch(0.18 0.01 60);\n --color-card: oklch(1 0 0);\n --color-card-foreground: oklch(0.18 0.01 60);\n --color-popover: oklch(1 0 0);\n --color-popover-foreground: oklch(0.18 0.01 60);\n --color-primary: oklch(0.45 0.16 40);\n --color-primary-foreground: oklch(0.99 0 0);\n --color-secondary: oklch(0.95 0.02 80);\n --color-secondary-foreground: oklch(0.25 0.04 50);\n --color-muted: oklch(0.96 0.01 80);\n --color-muted-foreground: oklch(0.5 0.02 60);\n --color-accent: oklch(0.92 0.05 70);\n --color-accent-foreground: oklch(0.3 0.06 40);\n --color-destructive: oklch(0.58 0.24 27);\n --color-destructive-foreground: oklch(0.99 0 0);\n --color-border: oklch(0.9 0.02 80);\n --color-input: oklch(0.92 0.02 80);\n --color-ring: oklch(0.6 0.1 40);\n --radius: 0.75rem;\n}\n\n@layer base {\n html, body {\n margin: 0;\n padding: 0;\n background: var(--color-background);\n color: var(--color-foreground);\n -webkit-font-smoothing: antialiased;\n -moz-osx-font-smoothing: grayscale;\n }\n a { color: inherit; text-decoration: none; }\n h1, h2, h3, h4 {\n font-family: var(--font-serif);\n letter-spacing: -0.01em;\n }\n}\n' }, { "path": "app/sitemap-page/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport Link from "next/link";\nimport { cacheTag, cacheLife } from "next/cache";\nimport { getServerClient, tags, type Product } from "@cimplify/sdk/server";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Sitemap \u2014 ${brand.name}`,\n description: "A human-readable index of every page on this site.",\n};\n\ninterface SitemapData {\n products: { slug: string; name: string }[];\n categories: { slug: string; name: string }[];\n collections: { slug: string; name: string }[];\n}\n\nasync function getSitemap(): Promise<SitemapData> {\n "use cache";\n cacheTag(tags.products(), tags.categories(), tags.collections());\n cacheLife("hours");\n\n const client = getServerClient();\n const [pRes, cRes, colRes] = await Promise.all([\n client.catalogue.getProducts({ limit: 500 }),\n client.catalogue.getCategories(),\n client.catalogue.getCollections(),\n ]);\n return {\n products: (pRes.ok ? pRes.value.items : []).map((p: Product) => ({\n slug: p.slug ?? p.id,\n name: p.name,\n })),\n categories: (cRes.ok ? cRes.value : []).map((c) => ({ slug: c.slug, name: c.name })),\n collections: (colRes.ok ? colRes.value : []).map((c) => ({ slug: c.slug, name: c.name })),\n };\n}\n\nconst STATIC_LINKS: { title: string; links: { href: string; label: string }[] }[] = [\n {\n title: "Browse",\n links: [\n { href: "/", label: "Home" },\n { href: "/shop", label: "Shop" },\n { href: "/search", label: "Search" },\n ],\n },\n {\n title: "Account",\n links: [\n { href: "/account", label: "Account" },\n { href: "/account/orders", label: "Orders" },\n { href: "/account/addresses", label: "Addresses" },\n { href: "/account/settings", label: "Settings" },\n { href: "/login", label: "Sign in" },\n { href: "/signup", label: "Create account" },\n { href: "/track-order", label: "Track an order" },\n { href: "/cart", label: "Cart" },\n { href: "/checkout", label: "Checkout" },\n ],\n },\n {\n title: "About",\n links: [\n { href: "/about", label: "About" },\n { href: "/faq", label: "FAQ" },\n { href: "/contact", label: "Contact" },\n ],\n },\n {\n title: "Policies",\n links: [\n { href: "/shipping", label: "Shipping" },\n { href: "/returns", label: "Returns" },\n { href: "/accessibility", label: "Accessibility" },\n { href: "/terms", label: "Terms of Service" },\n { href: "/privacy", label: "Privacy Policy" },\n ],\n },\n {\n title: "Machine-readable",\n links: [\n { href: "/sitemap.xml", label: "sitemap.xml (search engines)" },\n { href: "/llms.txt", label: "llms.txt (LLM agents)" },\n { href: "/robots.txt", label: "robots.txt" },\n { href: "/opensearch.xml", label: "opensearch.xml (browser search)" },\n ],\n },\n];\n\nexport default async function SitemapHtmlPage() {\n const { products, categories, collections } = await getSitemap();\n\n return (\n <article className="max-w-5xl mx-auto px-6 sm:px-8 py-16">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-2">\n Sitemap\n </p>\n <h1 className="text-[clamp(2rem,4vw,2.75rem)] font-semibold mb-2 -tracking-[0.02em]">\n Every page, in one place.\n </h1>\n <p className="text-muted-foreground mb-12">\n For search engines, see <Link href="/sitemap.xml" className="text-primary hover:underline">/sitemap.xml</Link>.\n For LLM agents, see <Link href="/llms.txt" className="text-primary hover:underline">/llms.txt</Link>.\n </p>\n <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-10">\n {STATIC_LINKS.map((s) => (\n <Section key={s.title} title={s.title}>\n {s.links.map((l) => (\n <li key={l.href}>\n <Link href={l.href} className="hover:text-primary transition-colors">\n {l.label}\n </Link>\n </li>\n ))}\n </Section>\n ))}\n {categories.length > 0 && (\n <Section title="Categories">\n {categories.map((c) => (\n <li key={c.slug}>\n <Link href={`/categories/${c.slug}`} className="hover:text-primary transition-colors">\n {c.name}\n </Link>\n </li>\n ))}\n </Section>\n )}\n {collections.length > 0 && (\n <Section title="Collections">\n {collections.map((c) => (\n <li key={c.slug}>\n <Link href={`/collections/${c.slug}`} className="hover:text-primary transition-colors">\n {c.name}\n </Link>\n </li>\n ))}\n </Section>\n )}\n {products.length > 0 && (\n <Section title={`Products (${products.length})`}>\n {products.map((p) => (\n <li key={p.slug}>\n <Link href={`/shop?product=${encodeURIComponent(p.slug)}`} className="hover:text-primary transition-colors">\n {p.name}\n </Link>\n </li>\n ))}\n </Section>\n )}\n </div>\n </article>\n );\n}\n\nfunction Section({ title, children }: { title: string; children: React.ReactNode }) {\n return (\n <div>\n <p className="font-semibold text-[12px] uppercase tracking-[0.12em] text-foreground mb-3">\n {title}\n </p>\n <ul className="space-y-2 m-0 p-0 list-none text-sm text-muted-foreground">\n {children}\n </ul>\n </div>\n );\n}\n' }, { "path": "app/shop/shop-client.tsx", "kind": "text", "content": '"use client";\n\nimport { CataloguePage } from "@cimplify/sdk/react";\nimport type { Category, Product } from "@cimplify/sdk";\nimport { StoreProductCard } from "@/components/store-product-card";\n\n/**\n * Client island for the shop page. Server-side fetches all products and\n * categories (cached via `\'use cache\'` in `app/shop/page.tsx`), then hands\n * them to `<CataloguePage>` which owns the interactive filter / sort state.\n */\nexport function ShopClient({\n products,\n categories,\n}: {\n products: Product[];\n categories: Category[];\n}) {\n return (\n <CataloguePage\n title="The Menu"\n products={products}\n categories={categories}\n renderCard={(p) => <StoreProductCard product={p} />}\n />\n );\n}\n' }, { "path": "app/shop/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { cacheTag, cacheLife } from "next/cache";\nimport { getServerClient, tags } from "@cimplify/sdk/server";\nimport { ShopClient } from "./shop-client";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Shop \u2014 ${brand.name}`,\n description: brand.description,\n};\n\nasync function getShopData() {\n "use cache";\n cacheTag(tags.products(), tags.categories());\n cacheLife("hours");\n\n const client = getServerClient();\n const [p, c] = await Promise.all([\n client.catalogue.getProducts({ limit: 50 }),\n client.catalogue.getCategories(),\n ]);\n return {\n products: p.ok ? p.value.items : [],\n categories: c.ok ? c.value : [],\n };\n}\n\nexport default async function ShopPage() {\n const { products, categories } = await getShopData();\n return <ShopClient products={products} categories={categories} />;\n}\n' }, { "path": "app/returns/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\nimport { PolicyPage } from "@/components/policy-page";\n\nexport const metadata: Metadata = {\n title: `${brand.returns.title} \u2014 ${brand.name}`,\n};\n\nexport default function ReturnsPage() {\n return <PolicyPage policy={brand.returns} />;\n}\n' }, { "path": "app/layout.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Inter, Playfair_Display } from "next/font/google";\nimport "./globals.css";\nimport { Providers } from "@/components/providers";\nimport { Header } from "@/components/header";\nimport { Footer } from "@/components/footer";\nimport { ProductModal } from "@/components/product-modal";\nimport { CartDrawer } from "@/components/cart-drawer";\nimport { OrganizationJsonLd } from "@/components/json-ld";\nimport { Suspense } from "react";\nimport { brand } from "@/lib/brand";\nimport { getSiteUrl } from "@/lib/site-url";\n\nconst inter = Inter({\n subsets: ["latin"],\n variable: "--font-sans",\n display: "swap",\n});\n\nconst playfair = Playfair_Display({\n subsets: ["latin"],\n variable: "--font-serif",\n display: "swap",\n});\n\nexport async function generateMetadata(): Promise<Metadata> {\n const siteUrl = await getSiteUrl();\n return {\n metadataBase: new URL(siteUrl),\n title: {\n default: brand.name,\n template: `%s \u2014 ${brand.name}`,\n },\n description: brand.description,\n openGraph: {\n type: "website",\n siteName: brand.name,\n locale: brand.locale,\n },\n twitter: { card: "summary_large_image" },\n };\n}\n\nexport default function RootLayout({ children }: { children: React.ReactNode }) {\n return (\n <html lang="en" suppressHydrationWarning className={`${inter.variable} ${playfair.variable}`}>\n <body\n suppressHydrationWarning\n className="min-h-screen flex flex-col bg-background text-foreground font-sans"\n >\n <Suspense fallback={null}>\n <OrganizationJsonLd />\n </Suspense>\n <Providers>\n <Header />\n <main className="flex-1 pb-12 w-full">{children}</main>\n <Footer />\n <Suspense fallback={null}>\n <ProductModal />\n </Suspense>\n <CartDrawer />\n </Providers>\n </body>\n </html>\n );\n}\n' }, { "path": "app/checkout/page.tsx", "kind": "text", "content": '"use client";\n\nimport { useRouter } from "next/navigation";\nimport { CheckoutPage as SdkCheckoutPage } from "@cimplify/sdk/react";\n\nexport default function CheckoutPage() {\n const router = useRouter();\n return (\n <SdkCheckoutPage\n onComplete={(result) => {\n if (result.success && result.order) {\n router.push(`/orders/${result.order.id}`);\n }\n }}\n />\n );\n}\n' }, { "path": "app/privacy/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Privacy Policy \u2014 ${brand.name}`,\n description: `How ${brand.name} collects, uses, and protects your personal data.`,\n};\n\nexport default function PrivacyPage() {\n const p = brand.privacy;\n return (\n <article className="max-w-3xl mx-auto px-6 sm:px-8 py-16 prose prose-lg max-w-none">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-2 not-prose">\n {p.eyebrow}\n </p>\n <h1 className="font-serif text-[clamp(2.25rem,5vw,3.5rem)] font-semibold mb-2 -tracking-[0.02em]">\n {p.title}\n </h1>\n <p className="text-sm text-muted-foreground not-prose mb-10">\n Last updated: {p.lastUpdated}\n </p>\n\n <section className="space-y-5 leading-relaxed text-foreground/90">\n {p.sections.map((s) => (\n <div key={s.heading}>\n <h2 className="font-serif text-2xl font-semibold mt-0">{s.heading}</h2>\n {typeof s.body === "string" ? (\n <p>{s.body}</p>\n ) : (\n <>\n <p>{s.body.intro}</p>\n <ul className="list-disc pl-6 space-y-2">\n {s.body.bullets.map((b) => (\n <li key={b}>{b}</li>\n ))}\n </ul>\n </>\n )}\n </div>\n ))}\n </section>\n </article>\n );\n}\n' }, { "path": "app/collections/[slug]/listing-client.tsx", "kind": "text", "content": '"use client";\n\nimport { ProductGrid } from "@cimplify/sdk/react";\nimport type { Product } from "@cimplify/sdk";\nimport { StoreProductCard } from "@/components/store-product-card";\n\n/**\n * Client island for the collection listing. Receives server-fetched\n * products as props (serializable) and owns the `renderCard` function\n * (which can\'t cross the server/client boundary).\n */\nexport function ListingClient({ products }: { products: Product[] }) {\n return (\n <ProductGrid\n products={products}\n emptyMessage="No products in this collection yet."\n renderCard={(p) => <StoreProductCard product={p} />}\n />\n );\n}\n' }, { "path": "app/collections/[slug]/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Suspense } from "react";\nimport Link from "next/link";\nimport { notFound } from "next/navigation";\nimport { cacheTag, cacheLife } from "next/cache";\nimport {\n getServerClient,\n tags,\n type Collection,\n type Product,\n} from "@cimplify/sdk/server";\nimport { ListingClient } from "./listing-client";\nimport { brand } from "@/lib/brand";\n\ninterface CollectionData {\n collection: Collection;\n products: Product[];\n}\n\nasync function getCollection(slug: string): Promise<CollectionData | null> {\n "use cache";\n cacheTag(tags.collections());\n cacheLife("hours");\n\n const client = getServerClient();\n const colRes = await client.catalogue.getCollectionBySlug(slug);\n if (!colRes.ok) return null;\n\n cacheTag(tags.collection(colRes.value.id), tags.collectionProducts(colRes.value.id));\n const r = await client.catalogue.getCollectionProducts(colRes.value.id);\n const products = r.ok\n ? ((r.value as { items?: Product[] }).items ?? (r.value as Product[]))\n : [];\n return { collection: colRes.value, products };\n}\n\nexport async function generateMetadata({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}): Promise<Metadata> {\n const { slug } = await params;\n const data = await getCollection(slug);\n if (!data) return {};\n return {\n title: `${data.collection.name} \u2014 ${brand.name}`,\n description: data.collection.description ?? undefined,\n };\n}\n\nexport default async function CollectionPage({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}) {\n return (\n <Suspense fallback={<CollectionSkeleton />}>\n <CollectionContent params={params} />\n </Suspense>\n );\n}\n\nasync function CollectionContent({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}) {\n const { slug } = await params;\n const data = await getCollection(slug);\n if (!data) notFound();\n\n const { collection, products } = data;\n return (\n <section className="max-w-7xl mx-auto px-6 sm:px-8 pt-12">\n <header className="mb-8 text-center">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-2">\n Collection\n </p>\n <h1 className="font-serif text-[clamp(2rem,4vw,2.75rem)] font-semibold mb-2">\n {collection.name}\n </h1>\n {collection.description && (\n <p className="mx-auto mb-2 max-w-xl text-muted-foreground">\n {collection.description}\n </p>\n )}\n <p className="text-sm text-muted-foreground">\n {products.length} item{products.length === 1 ? "" : "s"}\n </p>\n </header>\n <ListingClient products={products} />\n {products.length === 0 && (\n <p className="text-center mt-8">\n <Link href="/" className="text-primary font-semibold">\n \u2190 Back home\n </Link>\n </p>\n )}\n </section>\n );\n}\n\nfunction CollectionSkeleton() {\n return (\n <section className="max-w-7xl mx-auto px-6 sm:px-8 pt-12">\n <header className="mb-8 text-center">\n <div className="mx-auto h-3 w-20 bg-muted rounded mb-2 animate-pulse" />\n <div className="mx-auto h-10 w-64 bg-muted rounded mb-2 animate-pulse" />\n <div className="mx-auto h-4 w-80 bg-muted rounded animate-pulse" />\n </header>\n <div className="grid grid-cols-2 md:grid-cols-3 gap-4">\n {Array.from({ length: 6 }).map((_, i) => (\n <div key={i} className="aspect-square bg-muted rounded-2xl animate-pulse" />\n ))}\n </div>\n </section>\n );\n}\n' }, { "path": "package.json", "kind": "text", "content": '{\n "name": "__STOREFRONT_NAME__",\n "private": true,\n "version": "0.0.0",\n "scripts": {\n "dev": "concurrently -k -n mock,next -c blue,magenta \\"cimplify-mock --seed default --quiet\\" \\"next dev\\"",\n "dev:storefront": "next dev",\n "dev:mock": "cimplify-mock --seed default",\n "build": "next build",\n "start": "next start",\n "typecheck": "tsc --noEmit",\n "test": "vitest",\n "test:run": "vitest run",\n "check:brand": "vitest run __tests__/brand.test.ts",\n "check:cart": "vitest run __tests__/cart-flow.test.ts",\n "check:contract": "vitest run __tests__/contract.test.ts",\n "check": "bun run typecheck && bun run test:run"\n },\n "dependencies": {\n "@cimplify/sdk": "^0.48.1",\n "next": "^16.2.4",\n "react": "^19.0.0",\n "react-dom": "^19.0.0"\n },\n "devDependencies": {\n "@tailwindcss/postcss": "^4.2.4",\n "@types/node": "^22.10.0",\n "@types/react": "^19.0.0",\n "@types/react-dom": "^19.0.0",\n "concurrently": "^9.0.0",\n "tailwindcss": "^4.2.4",\n "typescript": "^5.9.3",\n "vitest": "^4.1.5"\n }\n}\n' }, { "path": "lib/brand.ts", "kind": "text", "content": `/**
|
|
4147
4147
|
* Brand & content configuration \u2014 the **single source of truth** for every
|
|
4148
4148
|
* visible string in this storefront.
|
|
4149
4149
|
*
|
|
@@ -4910,7 +4910,7 @@ function Field({
|
|
|
4910
4910
|
font-weight: 600;
|
|
4911
4911
|
}
|
|
4912
4912
|
}
|
|
4913
|
-
` }, { "path": "app/sitemap-page/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport Link from "next/link";\nimport { cacheTag, cacheLife } from "next/cache";\nimport { getServerClient, tags, type Product } from "@cimplify/sdk/server";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Sitemap \u2014 ${brand.name}`,\n description: "A human-readable index of every page on this site.",\n};\n\ninterface SitemapData {\n products: { slug: string; name: string }[];\n categories: { slug: string; name: string }[];\n collections: { slug: string; name: string }[];\n}\n\nasync function getSitemap(): Promise<SitemapData> {\n "use cache";\n cacheTag(tags.products(), tags.categories(), tags.collections());\n cacheLife("hours");\n\n const client = getServerClient();\n const [pRes, cRes, colRes] = await Promise.all([\n client.catalogue.getProducts({ limit: 500 }),\n client.catalogue.getCategories(),\n client.catalogue.getCollections(),\n ]);\n return {\n products: (pRes.ok ? pRes.value.items : []).map((p: Product) => ({\n slug: p.slug ?? p.id,\n name: p.name,\n })),\n categories: (cRes.ok ? cRes.value : []).map((c) => ({ slug: c.slug, name: c.name })),\n collections: (colRes.ok ? colRes.value : []).map((c) => ({ slug: c.slug, name: c.name })),\n };\n}\n\nconst STATIC_LINKS: { title: string; links: { href: string; label: string }[] }[] = [\n {\n title: "Browse",\n links: [\n { href: "/", label: "Home" },\n { href: "/shop", label: "Shop" },\n { href: "/search", label: "Search" },\n ],\n },\n {\n title: "Account",\n links: [\n { href: "/account", label: "Account" },\n { href: "/account/orders", label: "Orders" },\n { href: "/account/addresses", label: "Addresses" },\n { href: "/account/settings", label: "Settings" },\n { href: "/login", label: "Sign in" },\n { href: "/signup", label: "Create account" },\n { href: "/track-order", label: "Track an order" },\n { href: "/cart", label: "Cart" },\n { href: "/checkout", label: "Checkout" },\n ],\n },\n {\n title: "About",\n links: [\n { href: "/about", label: "About" },\n { href: "/faq", label: "FAQ" },\n { href: "/contact", label: "Contact" },\n ],\n },\n {\n title: "Policies",\n links: [\n { href: "/shipping", label: "Shipping" },\n { href: "/returns", label: "Returns" },\n { href: "/accessibility", label: "Accessibility" },\n { href: "/terms", label: "Terms of Service" },\n { href: "/privacy", label: "Privacy Policy" },\n ],\n },\n {\n title: "Machine-readable",\n links: [\n { href: "/sitemap.xml", label: "sitemap.xml (search engines)" },\n { href: "/llms.txt", label: "llms.txt (LLM agents)" },\n { href: "/robots.txt", label: "robots.txt" },\n { href: "/opensearch.xml", label: "opensearch.xml (browser search)" },\n ],\n },\n];\n\nexport default async function SitemapHtmlPage() {\n const { products, categories, collections } = await getSitemap();\n\n return (\n <article className="max-w-5xl mx-auto px-6 sm:px-8 py-16">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-2">\n Sitemap\n </p>\n <h1 className="text-[clamp(2rem,4vw,2.75rem)] font-semibold mb-2 -tracking-[0.02em]">\n Every page, in one place.\n </h1>\n <p className="text-muted-foreground mb-12">\n For search engines, see <Link href="/sitemap.xml" className="text-primary hover:underline">/sitemap.xml</Link>.\n For LLM agents, see <Link href="/llms.txt" className="text-primary hover:underline">/llms.txt</Link>.\n </p>\n <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-10">\n {STATIC_LINKS.map((s) => (\n <Section key={s.title} title={s.title}>\n {s.links.map((l) => (\n <li key={l.href}>\n <Link href={l.href} className="hover:text-primary transition-colors">\n {l.label}\n </Link>\n </li>\n ))}\n </Section>\n ))}\n {categories.length > 0 && (\n <Section title="Categories">\n {categories.map((c) => (\n <li key={c.slug}>\n <Link href={`/categories/${c.slug}`} className="hover:text-primary transition-colors">\n {c.name}\n </Link>\n </li>\n ))}\n </Section>\n )}\n {collections.length > 0 && (\n <Section title="Collections">\n {collections.map((c) => (\n <li key={c.slug}>\n <Link href={`/collections/${c.slug}`} className="hover:text-primary transition-colors">\n {c.name}\n </Link>\n </li>\n ))}\n </Section>\n )}\n {products.length > 0 && (\n <Section title={`Products (${products.length})`}>\n {products.map((p) => (\n <li key={p.slug}>\n <Link href={`/shop?product=${encodeURIComponent(p.slug)}`} className="hover:text-primary transition-colors">\n {p.name}\n </Link>\n </li>\n ))}\n </Section>\n )}\n </div>\n </article>\n );\n}\n\nfunction Section({ title, children }: { title: string; children: React.ReactNode }) {\n return (\n <div>\n <p className="font-semibold text-[12px] uppercase tracking-[0.12em] text-foreground mb-3">\n {title}\n </p>\n <ul className="space-y-2 m-0 p-0 list-none text-sm text-muted-foreground">\n {children}\n </ul>\n </div>\n );\n}\n' }, { "path": "app/shop/shop-client.tsx", "kind": "text", "content": '"use client";\n\nimport { CataloguePage } from "@cimplify/sdk/react";\nimport type { Category, Product } from "@cimplify/sdk";\nimport { StoreProductCard } from "@/components/store-product-card";\n\n/**\n * Client island for the shop page. Server-side fetches all products and\n * categories (cached via `\'use cache\'` in `app/shop/page.tsx`), then hands\n * them to `<CataloguePage>` which owns the interactive filter / sort state.\n *\n * The page hero (in `app/shop/page.tsx`) already provides the title; we\n * pass an empty `title` and override the SDK heading via className so the\n * page reads as one continuous design.\n */\nexport function ShopClient({\n products,\n categories,\n}: {\n products: Product[];\n categories: Category[];\n}) {\n return (\n <CataloguePage\n title="All products"\n products={products}\n categories={categories}\n renderCard={(p) => <StoreProductCard product={p} />}\n className="max-w-7xl mx-auto px-6 sm:px-8 py-10 sm:py-12"\n />\n );\n}\n' }, { "path": "app/shop/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Suspense } from "react";\nimport { cacheTag, cacheLife } from "next/cache";\nimport { getServerClient, tags } from "@cimplify/sdk/server";\nimport { ShopClient } from "./shop-client";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Shop \u2014 ${brand.name}`,\n description: brand.description,\n};\n\nasync function getShopData() {\n "use cache";\n cacheTag(tags.products(), tags.categories());\n cacheLife("hours");\n\n const client = getServerClient();\n const [p, c] = await Promise.all([\n client.catalogue.getProducts({ limit: 50 }),\n client.catalogue.getCategories(),\n ]);\n return {\n products: p.ok ? p.value.items : [],\n categories: c.ok ? c.value : [],\n };\n}\n\nexport default async function ShopPage() {\n const { products, categories } = await getShopData();\n return (\n <>\n <section className="bg-foreground text-background relative overflow-hidden">\n <div className="absolute inset-0 opacity-[0.04] pointer-events-none [background-image:radial-gradient(circle_at_2px_2px,white_1px,transparent_0)] [background-size:32px_32px]" />\n <div className="relative max-w-7xl mx-auto px-6 sm:px-8 py-12 sm:py-14">\n <p className="text-[11px] font-mono uppercase tracking-[0.2em] text-background/60 mb-2">\n Catalogue \xB7 {products.length} products\n </p>\n <h1 className="text-[clamp(2rem,4vw,3rem)] font-bold m-0 -tracking-[0.025em] leading-[1.05]">\n Every product we stock.\n </h1>\n <p className="mt-3 max-w-xl text-base text-background/75">\n Filter, sort, search. Authorised dealer pricing, two-year warranty,\n same-day Accra delivery on every order.\n </p>\n </div>\n </section>\n <Suspense\n fallback={\n <div className="max-w-7xl mx-auto px-6 sm:px-8 py-10">\n <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3 sm:gap-4">\n {Array.from({ length: 8 }).map((_, i) => (\n <div key={i} className="aspect-[4/3] bg-muted rounded-2xl animate-pulse" />\n ))}\n </div>\n </div>\n }\n >\n <ShopClient products={products} categories={categories} />\n </Suspense>\n </>\n );\n}\n' }, { "path": "app/returns/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\nimport { PolicyPage } from "@/components/policy-page";\n\nexport const metadata: Metadata = {\n title: `${brand.returns.title} \u2014 ${brand.name}`,\n};\n\nexport default function ReturnsPage() {\n return <PolicyPage policy={brand.returns} />;\n}\n' }, { "path": "app/layout.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Inter, JetBrains_Mono } from "next/font/google";\nimport "./globals.css";\nimport { Providers } from "@/components/providers";\nimport { Header } from "@/components/header";\nimport { Footer } from "@/components/footer";\nimport { ProductModal } from "@/components/product-modal";\nimport { CartDrawer } from "@/components/cart-drawer";\nimport { OrganizationJsonLd } from "@/components/json-ld";\nimport { Suspense } from "react";\nimport { brand } from "@/lib/brand";\nimport { getSiteUrl } from "@/lib/site-url";\n\nconst inter = Inter({\n subsets: ["latin"],\n variable: "--font-sans",\n display: "swap",\n});\n\nconst mono = JetBrains_Mono({\n subsets: ["latin"],\n variable: "--font-mono",\n display: "swap",\n});\n\nexport async function generateMetadata(): Promise<Metadata> {\n const siteUrl = await getSiteUrl();\n return {\n metadataBase: new URL(siteUrl),\n title: {\n default: brand.name,\n template: `%s \u2014 ${brand.name}`,\n },\n description: brand.description,\n openGraph: {\n type: "website",\n siteName: brand.name,\n locale: brand.locale,\n },\n twitter: { card: "summary_large_image" },\n };\n}\n\nexport default function RootLayout({ children }: { children: React.ReactNode }) {\n return (\n <html lang="en" suppressHydrationWarning className={`${inter.variable} ${mono.variable}`}>\n <body\n suppressHydrationWarning\n className="min-h-screen flex flex-col bg-background text-foreground font-sans"\n >\n <Suspense fallback={null}>\n <OrganizationJsonLd />\n </Suspense>\n <Providers>\n <Header />\n <main className="flex-1 pb-12 w-full">{children}</main>\n <Footer />\n <Suspense fallback={null}>\n <ProductModal />\n </Suspense>\n <CartDrawer />\n </Providers>\n </body>\n </html>\n );\n}\n' }, { "path": "app/checkout/page.tsx", "kind": "text", "content": '"use client";\n\nimport { useRouter } from "next/navigation";\nimport { CheckoutPage as SdkCheckoutPage } from "@cimplify/sdk/react";\n\nexport default function CheckoutPage() {\n const router = useRouter();\n return (\n <SdkCheckoutPage\n onComplete={(result) => {\n if (result.success && result.order) {\n router.push(`/orders/${result.order.id}`);\n }\n }}\n />\n );\n}\n' }, { "path": "app/privacy/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Privacy Policy \u2014 ${brand.name}`,\n description: `How ${brand.name} collects, uses, and protects your personal data.`,\n};\n\nexport default function PrivacyPage() {\n const p = brand.privacy;\n return (\n <article className="max-w-3xl mx-auto px-6 sm:px-8 py-16 prose prose-lg max-w-none">\n <p className="text-[11px] font-mono uppercase tracking-[0.16em] text-primary mb-2 not-prose">\n {p.eyebrow}\n </p>\n <h1 className="text-[clamp(2.25rem,5vw,3.5rem)] font-bold mb-2 -tracking-[0.025em]">\n {p.title}\n </h1>\n <p className="text-sm text-muted-foreground not-prose mb-10">\n Last updated: {p.lastUpdated}\n </p>\n\n <section className="space-y-5 leading-relaxed text-foreground/90">\n {p.sections.map((s) => (\n <div key={s.heading}>\n <h2 className="text-2xl font-semibold mt-0 -tracking-[0.02em]">{s.heading}</h2>\n {typeof s.body === "string" ? (\n <p>{s.body}</p>\n ) : (\n <>\n <p>{s.body.intro}</p>\n <ul className="list-disc pl-6 space-y-2">\n {s.body.bullets.map((b) => (\n <li key={b}>{b}</li>\n ))}\n </ul>\n </>\n )}\n </div>\n ))}\n </section>\n </article>\n );\n}\n' }, { "path": "app/collections/[slug]/listing-client.tsx", "kind": "text", "content": '"use client";\n\nimport { ProductGrid } from "@cimplify/sdk/react";\nimport type { Product } from "@cimplify/sdk";\nimport { StoreProductCard } from "@/components/store-product-card";\n\n/**\n * Client island for the collection listing. Receives server-fetched\n * products as props (serializable) and owns the `renderCard` function\n * (which can\'t cross the server/client boundary).\n */\nexport function ListingClient({ products }: { products: Product[] }) {\n return (\n <ProductGrid\n products={products}\n emptyMessage="No products in this collection yet."\n renderCard={(p) => <StoreProductCard product={p} />}\n />\n );\n}\n' }, { "path": "app/collections/[slug]/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Suspense } from "react";\nimport Link from "next/link";\nimport { notFound } from "next/navigation";\nimport { cacheTag, cacheLife } from "next/cache";\nimport {\n getServerClient,\n tags,\n type Collection,\n type Product,\n} from "@cimplify/sdk/server";\nimport { ListingClient } from "./listing-client";\nimport { brand } from "@/lib/brand";\n\ninterface CollectionData {\n collection: Collection;\n products: Product[];\n}\n\nasync function getCollection(slug: string): Promise<CollectionData | null> {\n "use cache";\n cacheTag(tags.collections());\n cacheLife("hours");\n\n const client = getServerClient();\n const colRes = await client.catalogue.getCollectionBySlug(slug);\n if (!colRes.ok) return null;\n\n cacheTag(tags.collection(colRes.value.id), tags.collectionProducts(colRes.value.id));\n const r = await client.catalogue.getCollectionProducts(colRes.value.id);\n const products = r.ok\n ? ((r.value as { items?: Product[] }).items ?? (r.value as Product[]))\n : [];\n return { collection: colRes.value, products };\n}\n\nexport async function generateMetadata({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}): Promise<Metadata> {\n const { slug } = await params;\n const data = await getCollection(slug);\n if (!data) return {};\n return {\n title: `${data.collection.name} \u2014 ${brand.name}`,\n description: data.collection.description ?? undefined,\n };\n}\n\nexport default async function CollectionPage({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}) {\n return (\n <Suspense fallback={<CollectionSkeleton />}>\n <CollectionContent params={params} />\n </Suspense>\n );\n}\n\nasync function CollectionContent({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}) {\n const { slug } = await params;\n const data = await getCollection(slug);\n if (!data) notFound();\n\n const { collection, products } = data;\n return (\n <>\n <section className="bg-foreground text-background relative overflow-hidden">\n <div className="absolute inset-0 opacity-[0.04] pointer-events-none [background-image:radial-gradient(circle_at_2px_2px,white_1px,transparent_0)] [background-size:32px_32px]" />\n <div className="relative max-w-7xl mx-auto px-6 sm:px-8 py-12 sm:py-14">\n <nav className="text-[12px] font-mono text-background/60 mb-3 flex items-center gap-2">\n <Link href="/" className="hover:text-background transition-colors">Home</Link>\n <span>/</span>\n <span className="text-background/90">Collections</span>\n <span>/</span>\n <span className="text-background/90">{collection.name}</span>\n </nav>\n <h1 className="text-[clamp(2rem,4vw,3rem)] font-bold m-0 -tracking-[0.025em]">\n {collection.name}\n </h1>\n {collection.description && (\n <p className="mt-3 max-w-2xl text-base text-background/75">\n {collection.description}\n </p>\n )}\n <p className="mt-4 text-[11px] font-mono uppercase tracking-[0.16em] text-background/60 tabular-nums">\n {products.length} {products.length === 1 ? "product" : "products"}\n </p>\n </div>\n </section>\n <section className="max-w-7xl mx-auto px-6 sm:px-8 py-10 sm:py-12">\n <ListingClient products={products} />\n {products.length === 0 && (\n <p className="text-center mt-8">\n <Link href="/shop" className="text-primary font-semibold hover:underline">\n \u2190 Browse all products\n </Link>\n </p>\n )}\n </section>\n </>\n );\n}\n\nfunction CollectionSkeleton() {\n return (\n <>\n <section className="bg-foreground py-12 sm:py-14">\n <div className="max-w-7xl mx-auto px-6 sm:px-8">\n <div className="h-3 w-32 bg-background/20 rounded mb-3 animate-pulse" />\n <div className="h-12 w-72 bg-background/20 rounded animate-pulse" />\n </div>\n </section>\n <section className="max-w-7xl mx-auto px-6 sm:px-8 py-10 sm:py-12">\n <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3 sm:gap-4">\n {Array.from({ length: 8 }).map((_, i) => (\n <div key={i} className="aspect-[4/3] bg-muted rounded-2xl animate-pulse" />\n ))}\n </div>\n </section>\n </>\n );\n}\n' }, { "path": "package.json", "kind": "text", "content": '{\n "name": "__STOREFRONT_NAME__",\n "private": true,\n "version": "0.0.0",\n "scripts": {\n "dev": "concurrently -k -n mock,next -c blue,magenta \\"cimplify-mock --seed pharmacy --quiet\\" \\"next dev\\"",\n "dev:storefront": "next dev",\n "dev:mock": "cimplify-mock --seed pharmacy",\n "build": "next build",\n "start": "next start",\n "typecheck": "tsc --noEmit",\n "test": "vitest",\n "test:run": "vitest run",\n "check:brand": "vitest run __tests__/brand.test.ts",\n "check:cart": "vitest run __tests__/cart-flow.test.ts",\n "check:contract": "vitest run __tests__/contract.test.ts",\n "check": "bun run typecheck && bun run test:run"\n },\n "dependencies": {\n "@cimplify/sdk": "^0.48.0",\n "next": "^16.2.4",\n "react": "^19.0.0",\n "react-dom": "^19.0.0"\n },\n "devDependencies": {\n "@tailwindcss/postcss": "^4.2.4",\n "@types/node": "^22.10.0",\n "@types/react": "^19.0.0",\n "@types/react-dom": "^19.0.0",\n "concurrently": "^9.0.0",\n "tailwindcss": "^4.2.4",\n "typescript": "^5.9.3",\n "vitest": "^4.1.5"\n }\n}\n' }, { "path": "lib/brand.ts", "kind": "text", "content": `/**
|
|
4913
|
+
` }, { "path": "app/sitemap-page/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport Link from "next/link";\nimport { cacheTag, cacheLife } from "next/cache";\nimport { getServerClient, tags, type Product } from "@cimplify/sdk/server";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Sitemap \u2014 ${brand.name}`,\n description: "A human-readable index of every page on this site.",\n};\n\ninterface SitemapData {\n products: { slug: string; name: string }[];\n categories: { slug: string; name: string }[];\n collections: { slug: string; name: string }[];\n}\n\nasync function getSitemap(): Promise<SitemapData> {\n "use cache";\n cacheTag(tags.products(), tags.categories(), tags.collections());\n cacheLife("hours");\n\n const client = getServerClient();\n const [pRes, cRes, colRes] = await Promise.all([\n client.catalogue.getProducts({ limit: 500 }),\n client.catalogue.getCategories(),\n client.catalogue.getCollections(),\n ]);\n return {\n products: (pRes.ok ? pRes.value.items : []).map((p: Product) => ({\n slug: p.slug ?? p.id,\n name: p.name,\n })),\n categories: (cRes.ok ? cRes.value : []).map((c) => ({ slug: c.slug, name: c.name })),\n collections: (colRes.ok ? colRes.value : []).map((c) => ({ slug: c.slug, name: c.name })),\n };\n}\n\nconst STATIC_LINKS: { title: string; links: { href: string; label: string }[] }[] = [\n {\n title: "Browse",\n links: [\n { href: "/", label: "Home" },\n { href: "/shop", label: "Shop" },\n { href: "/search", label: "Search" },\n ],\n },\n {\n title: "Account",\n links: [\n { href: "/account", label: "Account" },\n { href: "/account/orders", label: "Orders" },\n { href: "/account/addresses", label: "Addresses" },\n { href: "/account/settings", label: "Settings" },\n { href: "/login", label: "Sign in" },\n { href: "/signup", label: "Create account" },\n { href: "/track-order", label: "Track an order" },\n { href: "/cart", label: "Cart" },\n { href: "/checkout", label: "Checkout" },\n ],\n },\n {\n title: "About",\n links: [\n { href: "/about", label: "About" },\n { href: "/faq", label: "FAQ" },\n { href: "/contact", label: "Contact" },\n ],\n },\n {\n title: "Policies",\n links: [\n { href: "/shipping", label: "Shipping" },\n { href: "/returns", label: "Returns" },\n { href: "/accessibility", label: "Accessibility" },\n { href: "/terms", label: "Terms of Service" },\n { href: "/privacy", label: "Privacy Policy" },\n ],\n },\n {\n title: "Machine-readable",\n links: [\n { href: "/sitemap.xml", label: "sitemap.xml (search engines)" },\n { href: "/llms.txt", label: "llms.txt (LLM agents)" },\n { href: "/robots.txt", label: "robots.txt" },\n { href: "/opensearch.xml", label: "opensearch.xml (browser search)" },\n ],\n },\n];\n\nexport default async function SitemapHtmlPage() {\n const { products, categories, collections } = await getSitemap();\n\n return (\n <article className="max-w-5xl mx-auto px-6 sm:px-8 py-16">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-2">\n Sitemap\n </p>\n <h1 className="text-[clamp(2rem,4vw,2.75rem)] font-semibold mb-2 -tracking-[0.02em]">\n Every page, in one place.\n </h1>\n <p className="text-muted-foreground mb-12">\n For search engines, see <Link href="/sitemap.xml" className="text-primary hover:underline">/sitemap.xml</Link>.\n For LLM agents, see <Link href="/llms.txt" className="text-primary hover:underline">/llms.txt</Link>.\n </p>\n <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-10">\n {STATIC_LINKS.map((s) => (\n <Section key={s.title} title={s.title}>\n {s.links.map((l) => (\n <li key={l.href}>\n <Link href={l.href} className="hover:text-primary transition-colors">\n {l.label}\n </Link>\n </li>\n ))}\n </Section>\n ))}\n {categories.length > 0 && (\n <Section title="Categories">\n {categories.map((c) => (\n <li key={c.slug}>\n <Link href={`/categories/${c.slug}`} className="hover:text-primary transition-colors">\n {c.name}\n </Link>\n </li>\n ))}\n </Section>\n )}\n {collections.length > 0 && (\n <Section title="Collections">\n {collections.map((c) => (\n <li key={c.slug}>\n <Link href={`/collections/${c.slug}`} className="hover:text-primary transition-colors">\n {c.name}\n </Link>\n </li>\n ))}\n </Section>\n )}\n {products.length > 0 && (\n <Section title={`Products (${products.length})`}>\n {products.map((p) => (\n <li key={p.slug}>\n <Link href={`/shop?product=${encodeURIComponent(p.slug)}`} className="hover:text-primary transition-colors">\n {p.name}\n </Link>\n </li>\n ))}\n </Section>\n )}\n </div>\n </article>\n );\n}\n\nfunction Section({ title, children }: { title: string; children: React.ReactNode }) {\n return (\n <div>\n <p className="font-semibold text-[12px] uppercase tracking-[0.12em] text-foreground mb-3">\n {title}\n </p>\n <ul className="space-y-2 m-0 p-0 list-none text-sm text-muted-foreground">\n {children}\n </ul>\n </div>\n );\n}\n' }, { "path": "app/shop/shop-client.tsx", "kind": "text", "content": '"use client";\n\nimport { CataloguePage } from "@cimplify/sdk/react";\nimport type { Category, Product } from "@cimplify/sdk";\nimport { StoreProductCard } from "@/components/store-product-card";\n\n/**\n * Client island for the shop page. Server-side fetches all products and\n * categories (cached via `\'use cache\'` in `app/shop/page.tsx`), then hands\n * them to `<CataloguePage>` which owns the interactive filter / sort state.\n *\n * The page hero (in `app/shop/page.tsx`) already provides the title; we\n * pass an empty `title` and override the SDK heading via className so the\n * page reads as one continuous design.\n */\nexport function ShopClient({\n products,\n categories,\n}: {\n products: Product[];\n categories: Category[];\n}) {\n return (\n <CataloguePage\n title="All products"\n products={products}\n categories={categories}\n renderCard={(p) => <StoreProductCard product={p} />}\n className="max-w-7xl mx-auto px-6 sm:px-8 py-10 sm:py-12"\n />\n );\n}\n' }, { "path": "app/shop/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Suspense } from "react";\nimport { cacheTag, cacheLife } from "next/cache";\nimport { getServerClient, tags } from "@cimplify/sdk/server";\nimport { ShopClient } from "./shop-client";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Shop \u2014 ${brand.name}`,\n description: brand.description,\n};\n\nasync function getShopData() {\n "use cache";\n cacheTag(tags.products(), tags.categories());\n cacheLife("hours");\n\n const client = getServerClient();\n const [p, c] = await Promise.all([\n client.catalogue.getProducts({ limit: 50 }),\n client.catalogue.getCategories(),\n ]);\n return {\n products: p.ok ? p.value.items : [],\n categories: c.ok ? c.value : [],\n };\n}\n\nexport default async function ShopPage() {\n const { products, categories } = await getShopData();\n return (\n <>\n <section className="bg-foreground text-background relative overflow-hidden">\n <div className="absolute inset-0 opacity-[0.04] pointer-events-none [background-image:radial-gradient(circle_at_2px_2px,white_1px,transparent_0)] [background-size:32px_32px]" />\n <div className="relative max-w-7xl mx-auto px-6 sm:px-8 py-12 sm:py-14">\n <p className="text-[11px] font-mono uppercase tracking-[0.2em] text-background/60 mb-2">\n Catalogue \xB7 {products.length} products\n </p>\n <h1 className="text-[clamp(2rem,4vw,3rem)] font-bold m-0 -tracking-[0.025em] leading-[1.05]">\n Every product we stock.\n </h1>\n <p className="mt-3 max-w-xl text-base text-background/75">\n Filter, sort, search. Authorised dealer pricing, two-year warranty,\n same-day Accra delivery on every order.\n </p>\n </div>\n </section>\n <Suspense\n fallback={\n <div className="max-w-7xl mx-auto px-6 sm:px-8 py-10">\n <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3 sm:gap-4">\n {Array.from({ length: 8 }).map((_, i) => (\n <div key={i} className="aspect-[4/3] bg-muted rounded-2xl animate-pulse" />\n ))}\n </div>\n </div>\n }\n >\n <ShopClient products={products} categories={categories} />\n </Suspense>\n </>\n );\n}\n' }, { "path": "app/returns/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\nimport { PolicyPage } from "@/components/policy-page";\n\nexport const metadata: Metadata = {\n title: `${brand.returns.title} \u2014 ${brand.name}`,\n};\n\nexport default function ReturnsPage() {\n return <PolicyPage policy={brand.returns} />;\n}\n' }, { "path": "app/layout.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Inter, JetBrains_Mono } from "next/font/google";\nimport "./globals.css";\nimport { Providers } from "@/components/providers";\nimport { Header } from "@/components/header";\nimport { Footer } from "@/components/footer";\nimport { ProductModal } from "@/components/product-modal";\nimport { CartDrawer } from "@/components/cart-drawer";\nimport { OrganizationJsonLd } from "@/components/json-ld";\nimport { Suspense } from "react";\nimport { brand } from "@/lib/brand";\nimport { getSiteUrl } from "@/lib/site-url";\n\nconst inter = Inter({\n subsets: ["latin"],\n variable: "--font-sans",\n display: "swap",\n});\n\nconst mono = JetBrains_Mono({\n subsets: ["latin"],\n variable: "--font-mono",\n display: "swap",\n});\n\nexport async function generateMetadata(): Promise<Metadata> {\n const siteUrl = await getSiteUrl();\n return {\n metadataBase: new URL(siteUrl),\n title: {\n default: brand.name,\n template: `%s \u2014 ${brand.name}`,\n },\n description: brand.description,\n openGraph: {\n type: "website",\n siteName: brand.name,\n locale: brand.locale,\n },\n twitter: { card: "summary_large_image" },\n };\n}\n\nexport default function RootLayout({ children }: { children: React.ReactNode }) {\n return (\n <html lang="en" suppressHydrationWarning className={`${inter.variable} ${mono.variable}`}>\n <body\n suppressHydrationWarning\n className="min-h-screen flex flex-col bg-background text-foreground font-sans"\n >\n <Suspense fallback={null}>\n <OrganizationJsonLd />\n </Suspense>\n <Providers>\n <Header />\n <main className="flex-1 pb-12 w-full">{children}</main>\n <Footer />\n <Suspense fallback={null}>\n <ProductModal />\n </Suspense>\n <CartDrawer />\n </Providers>\n </body>\n </html>\n );\n}\n' }, { "path": "app/checkout/page.tsx", "kind": "text", "content": '"use client";\n\nimport { useRouter } from "next/navigation";\nimport { CheckoutPage as SdkCheckoutPage } from "@cimplify/sdk/react";\n\nexport default function CheckoutPage() {\n const router = useRouter();\n return (\n <SdkCheckoutPage\n onComplete={(result) => {\n if (result.success && result.order) {\n router.push(`/orders/${result.order.id}`);\n }\n }}\n />\n );\n}\n' }, { "path": "app/privacy/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Privacy Policy \u2014 ${brand.name}`,\n description: `How ${brand.name} collects, uses, and protects your personal data.`,\n};\n\nexport default function PrivacyPage() {\n const p = brand.privacy;\n return (\n <article className="max-w-3xl mx-auto px-6 sm:px-8 py-16 prose prose-lg max-w-none">\n <p className="text-[11px] font-mono uppercase tracking-[0.16em] text-primary mb-2 not-prose">\n {p.eyebrow}\n </p>\n <h1 className="text-[clamp(2.25rem,5vw,3.5rem)] font-bold mb-2 -tracking-[0.025em]">\n {p.title}\n </h1>\n <p className="text-sm text-muted-foreground not-prose mb-10">\n Last updated: {p.lastUpdated}\n </p>\n\n <section className="space-y-5 leading-relaxed text-foreground/90">\n {p.sections.map((s) => (\n <div key={s.heading}>\n <h2 className="text-2xl font-semibold mt-0 -tracking-[0.02em]">{s.heading}</h2>\n {typeof s.body === "string" ? (\n <p>{s.body}</p>\n ) : (\n <>\n <p>{s.body.intro}</p>\n <ul className="list-disc pl-6 space-y-2">\n {s.body.bullets.map((b) => (\n <li key={b}>{b}</li>\n ))}\n </ul>\n </>\n )}\n </div>\n ))}\n </section>\n </article>\n );\n}\n' }, { "path": "app/collections/[slug]/listing-client.tsx", "kind": "text", "content": '"use client";\n\nimport { ProductGrid } from "@cimplify/sdk/react";\nimport type { Product } from "@cimplify/sdk";\nimport { StoreProductCard } from "@/components/store-product-card";\n\n/**\n * Client island for the collection listing. Receives server-fetched\n * products as props (serializable) and owns the `renderCard` function\n * (which can\'t cross the server/client boundary).\n */\nexport function ListingClient({ products }: { products: Product[] }) {\n return (\n <ProductGrid\n products={products}\n emptyMessage="No products in this collection yet."\n renderCard={(p) => <StoreProductCard product={p} />}\n />\n );\n}\n' }, { "path": "app/collections/[slug]/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Suspense } from "react";\nimport Link from "next/link";\nimport { notFound } from "next/navigation";\nimport { cacheTag, cacheLife } from "next/cache";\nimport {\n getServerClient,\n tags,\n type Collection,\n type Product,\n} from "@cimplify/sdk/server";\nimport { ListingClient } from "./listing-client";\nimport { brand } from "@/lib/brand";\n\ninterface CollectionData {\n collection: Collection;\n products: Product[];\n}\n\nasync function getCollection(slug: string): Promise<CollectionData | null> {\n "use cache";\n cacheTag(tags.collections());\n cacheLife("hours");\n\n const client = getServerClient();\n const colRes = await client.catalogue.getCollectionBySlug(slug);\n if (!colRes.ok) return null;\n\n cacheTag(tags.collection(colRes.value.id), tags.collectionProducts(colRes.value.id));\n const r = await client.catalogue.getCollectionProducts(colRes.value.id);\n const products = r.ok\n ? ((r.value as { items?: Product[] }).items ?? (r.value as Product[]))\n : [];\n return { collection: colRes.value, products };\n}\n\nexport async function generateMetadata({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}): Promise<Metadata> {\n const { slug } = await params;\n const data = await getCollection(slug);\n if (!data) return {};\n return {\n title: `${data.collection.name} \u2014 ${brand.name}`,\n description: data.collection.description ?? undefined,\n };\n}\n\nexport default async function CollectionPage({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}) {\n return (\n <Suspense fallback={<CollectionSkeleton />}>\n <CollectionContent params={params} />\n </Suspense>\n );\n}\n\nasync function CollectionContent({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}) {\n const { slug } = await params;\n const data = await getCollection(slug);\n if (!data) notFound();\n\n const { collection, products } = data;\n return (\n <>\n <section className="bg-foreground text-background relative overflow-hidden">\n <div className="absolute inset-0 opacity-[0.04] pointer-events-none [background-image:radial-gradient(circle_at_2px_2px,white_1px,transparent_0)] [background-size:32px_32px]" />\n <div className="relative max-w-7xl mx-auto px-6 sm:px-8 py-12 sm:py-14">\n <nav className="text-[12px] font-mono text-background/60 mb-3 flex items-center gap-2">\n <Link href="/" className="hover:text-background transition-colors">Home</Link>\n <span>/</span>\n <span className="text-background/90">Collections</span>\n <span>/</span>\n <span className="text-background/90">{collection.name}</span>\n </nav>\n <h1 className="text-[clamp(2rem,4vw,3rem)] font-bold m-0 -tracking-[0.025em]">\n {collection.name}\n </h1>\n {collection.description && (\n <p className="mt-3 max-w-2xl text-base text-background/75">\n {collection.description}\n </p>\n )}\n <p className="mt-4 text-[11px] font-mono uppercase tracking-[0.16em] text-background/60 tabular-nums">\n {products.length} {products.length === 1 ? "product" : "products"}\n </p>\n </div>\n </section>\n <section className="max-w-7xl mx-auto px-6 sm:px-8 py-10 sm:py-12">\n <ListingClient products={products} />\n {products.length === 0 && (\n <p className="text-center mt-8">\n <Link href="/shop" className="text-primary font-semibold hover:underline">\n \u2190 Browse all products\n </Link>\n </p>\n )}\n </section>\n </>\n );\n}\n\nfunction CollectionSkeleton() {\n return (\n <>\n <section className="bg-foreground py-12 sm:py-14">\n <div className="max-w-7xl mx-auto px-6 sm:px-8">\n <div className="h-3 w-32 bg-background/20 rounded mb-3 animate-pulse" />\n <div className="h-12 w-72 bg-background/20 rounded animate-pulse" />\n </div>\n </section>\n <section className="max-w-7xl mx-auto px-6 sm:px-8 py-10 sm:py-12">\n <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3 sm:gap-4">\n {Array.from({ length: 8 }).map((_, i) => (\n <div key={i} className="aspect-[4/3] bg-muted rounded-2xl animate-pulse" />\n ))}\n </div>\n </section>\n </>\n );\n}\n' }, { "path": "package.json", "kind": "text", "content": '{\n "name": "__STOREFRONT_NAME__",\n "private": true,\n "version": "0.0.0",\n "scripts": {\n "dev": "concurrently -k -n mock,next -c blue,magenta \\"cimplify-mock --seed pharmacy --quiet\\" \\"next dev\\"",\n "dev:storefront": "next dev",\n "dev:mock": "cimplify-mock --seed pharmacy",\n "build": "next build",\n "start": "next start",\n "typecheck": "tsc --noEmit",\n "test": "vitest",\n "test:run": "vitest run",\n "check:brand": "vitest run __tests__/brand.test.ts",\n "check:cart": "vitest run __tests__/cart-flow.test.ts",\n "check:contract": "vitest run __tests__/contract.test.ts",\n "check": "bun run typecheck && bun run test:run"\n },\n "dependencies": {\n "@cimplify/sdk": "^0.48.1",\n "next": "^16.2.4",\n "react": "^19.0.0",\n "react-dom": "^19.0.0"\n },\n "devDependencies": {\n "@tailwindcss/postcss": "^4.2.4",\n "@types/node": "^22.10.0",\n "@types/react": "^19.0.0",\n "@types/react-dom": "^19.0.0",\n "concurrently": "^9.0.0",\n "tailwindcss": "^4.2.4",\n "typescript": "^5.9.3",\n "vitest": "^4.1.5"\n }\n}\n' }, { "path": "lib/brand.ts", "kind": "text", "content": `/**
|
|
4914
4914
|
* Brand & content configuration \u2014 the **single source of truth** for every
|
|
4915
4915
|
* visible string in this storefront.
|
|
4916
4916
|
*
|
|
@@ -5701,7 +5701,7 @@ export const brand: Brand = {
|
|
|
5701
5701
|
},
|
|
5702
5702
|
};
|
|
5703
5703
|
` }, { "path": "lib/cimplify-loader.ts", "kind": "text", "content": 'import type { ImageLoader } from "next/image";\n\nimport { assetUrl, isCimplifyAsset } from "@cimplify/sdk";\n\nconst cdnBase = process.env.NEXT_PUBLIC_CIMPLIFY_CDN_URL?.trim() || undefined;\n\nconst cimplifyImageLoader: ImageLoader = ({ src, width, quality }) => {\n if (isCimplifyAsset(src, cdnBase)) {\n return assetUrl(src, {\n base: cdnBase,\n w: width,\n quality,\n format: "auto",\n });\n }\n return src;\n};\n\nexport default cimplifyImageLoader;\n' }, { "path": "lib/cart.ts", "kind": "text", "content": '"use client";\n\nimport { useCart } from "@cimplify/sdk/react";\n\n/**\n * Reactive cart count for the header pill. Subscribes to the SDK cart\n * state \u2014 updates immediately on add / remove / quantity change.\n */\nexport function useCartCount(): { count: number } {\n const { itemCount } = useCart();\n return { count: itemCount };\n}\n' }, { "path": "lib/site-url.ts", "kind": "text", "content": 'import { headers } from "next/headers";\n\n/**\n * Canonical absolute URL for this storefront, derived at request time.\n *\n * Resolution order:\n * 1. `NEXT_PUBLIC_SITE_URL` \u2014 set when you need a fixed canonical\n * (multiple domains, www-vs-apex preference, etc.).\n * 2. The request `Host` header \u2014 works automatically on every deploy,\n * including preview URLs and custom domains.\n * 3. `https://example.com` \u2014 only ever returned during prerender of\n * a page that has no live request (rare in App Router).\n */\nexport async function getSiteUrl(): Promise<string> {\n const explicit = process.env.NEXT_PUBLIC_SITE_URL?.trim();\n if (explicit) return explicit;\n\n try {\n const h = await headers();\n const host = h.get("host");\n if (host) {\n const isLocal = host.startsWith("localhost") || host.startsWith("127.");\n return `${isLocal ? "http" : "https"}://${host}`;\n }\n } catch {\n // `headers()` is unavailable in some build contexts \u2014 fall through.\n }\n return "https://example.com";\n}\n' }, { "path": "metadata.json", "kind": "text", "content": '{\n "id": "pharmacy",\n "name": "Pharmacy",\n "tagline": "Licensed community pharmacy with prescription uploads, pharmacist consults, and OTC catalog.",\n "industry": "healthcare",\n "tags": ["healthcare", "regulated", "prescription"],\n "stability": "stable",\n "schemaType": "Pharmacy",\n "mock": {\n "seedName": "pharmacy",\n "seedBusinessId": "bus_wellspring_pharmacy"\n }\n}\n' }, { "path": "tsconfig.json", "kind": "text", "content": '{\n "compilerOptions": {\n "target": "ES2022",\n "lib": ["dom", "dom.iterable", "esnext"],\n "allowJs": false,\n "skipLibCheck": true,\n "strict": true,\n "noEmit": true,\n "esModuleInterop": true,\n "module": "esnext",\n "moduleResolution": "bundler",\n "resolveJsonModule": true,\n "isolatedModules": true,\n "jsx": "preserve",\n "incremental": true,\n "plugins": [{ "name": "next" }],\n "paths": {\n "@/*": ["./*"]\n }\n },\n "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts"],\n "exclude": ["node_modules"]\n}\n' }, { "path": "CLAUDE.md", "kind": "text", "content": "# CLAUDE.md\n\nThis project was scaffolded from a Cimplify storefront template (`cimplify init`).\n\n## Read these first\n\n- **`AGENTS.md`** at the project root \u2014 the file \u2194 `brand.X` field map for *this template's* industry, plus the architectural rules.\n- **`.claude/skills/cimplify-storefront/SKILL.md`** \u2014 the playbook for common tasks (rebrand, add a section, wire a Server Action, deploy, eject a component). Invoke it whenever you're asked to change content, palette, copy, or routing.\n\n## The two-line summary\n\n1. **Edit `lib/brand.ts`** for any visible string.\n2. **Edit `app/globals.css` `@theme` block** for any palette / radius / font change.\n\nThat covers ~95% of what merchants ask for. For anything else, follow the `cimplify-storefront` skill.\n\n## Don't do\n\n- Hardcode strings in pages or components.\n- Disable `cacheComponents: true` in `next.config.ts`.\n- Use `unstable_cache` \u2014 Next 16's canonical primitive is `'use cache'`.\n- Run `bun test` (Bun's `vi` shim is incomplete) \u2014 use `bun run test:run`.\n" }, { "path": "README.md", "kind": "text", "content": '# __STOREFRONT_NAME__\n\nA Cimplify storefront scaffolded from the **pharmacy** template \u2014 Next.js 16 (App Router), React 19, Tailwind v4. Clinical white + calm teal palette, Inter + JetBrains Mono typography, trustworthy community-pharmacy aesthetic.\n\n## Run\n\n```bash\nbun install\nbun dev\n```\n\nTwo things start in parallel:\n\n- `cimplify-mock --seed pharmacy` \u2014 the Cimplify mock API on `http://127.0.0.1:8787`, seeded with Wellspring Pharmacy.\n- `next dev` \u2014 this storefront on `http://localhost:3000`.\n\nOpen the storefront in your browser. Edit `app/page.tsx` to start customising.\n\n## Structure\n\n```\napp/\n layout.tsx # root layout, fonts, providers, header/footer/modal\n page.tsx # home\n shop/page.tsx # full catalogue (SDK <CataloguePage/>)\n collections/[slug]/ # collection landing\n categories/[slug]/ # category landing\n cart/page.tsx # SDK <CartPage/>\n checkout/page.tsx # SDK <CheckoutPage/>\n orders/[id]/page.tsx # post-checkout thank-you\n about, faq, terms, privacy\n globals.css # Tailwind import + theme tokens\ncomponents/\n providers.tsx # CimplifyProvider client wrapper\n header.tsx, footer.tsx, hero.tsx\n store-product-card.tsx # SDK <ProductCard/>\n trade-in-cta.tsx # repurposed as a prescription-upload CTA\n collection-strip.tsx # horizontal product strip\n category-tiles.tsx # SDK category grid\nlib/\n brand.ts # every visible string (pharmacy copy)\n cart.ts # useCartCount() for the header pill\n```\n\n## Pharmacy-specific behaviour\n\n- Prescription-required products (insulin, amoxicillin, etc.) ship with `input_fields` on the catalogue payload \u2014 the SDK product page renders prescription upload + consent signature + DOB inputs automatically. Look at how `paracetamol`, `amoxicillin-rx`, and `baby-formula` differ in the mock seed.\n- `brand.tradeIn` is repurposed as the "Send us your script" CTA on the home page.\n- `Pharmacy` schema.org `@type` is set in `brand.schemaType` so JSON-LD on every page identifies the business correctly.\n\n## Switch the seed\n\nThis template is wired to the `pharmacy` seed. To preview a different industry without re-scaffolding:\n\n```bash\ncimplify-mock --seed restaurant # Mama\'s Kitchen\ncimplify-mock --seed services # Serene Spa\ncimplify-mock --seed grocery # FreshMart\ncimplify-mock --seed retail # Currents Electronics\n```\n\nFor a fresh scaffold with another design altogether:\n\n```bash\ncimplify init my-store --template bakery\ncimplify init my-store --template restaurant\ncimplify init my-store --template services\ncimplify init my-store --template grocery\ncimplify init my-store --template fashion\ncimplify init my-store --template retail\n```\n\n## Go live\n\n```diff\n# .env.local\n- NEXT_PUBLIC_CIMPLIFY_PUBLIC_KEY=mock-dev\n+ NEXT_PUBLIC_CIMPLIFY_PUBLIC_KEY=<your tenant key>\n```\n\nDeploy with `cimplify deploy --prod` after linking the project. See [`cimplify` CLI docs](https://www.cimplify.dev/docs/cli). `next.config.ts` already whitelists the SDK image hosts under `images.remotePatterns`.\n' }, { "path": ".env.example", "kind": "text", "content": "# Your tenant public key. Get it from the desk's Developers tab in\n# production; the mock accepts any value during local dev.\nNEXT_PUBLIC_CIMPLIFY_PUBLIC_KEY=mock-dev\n\n# Optional. Forces a fixed canonical URL for sitemap.xml, robots.txt,\n# OpenGraph, and llms.txt. Leave unset and the storefront derives the\n# canonical from the request `Host` header automatically.\n# NEXT_PUBLIC_SITE_URL=\n" }] };
|
|
5704
|
-
var REGISTRY = { "order-detail-page": { "name": "order-detail-page", "title": "OrderDetailPage", "description": "Single order detail view with live status polling.", "type": "component", "registryDependencies": ["order-summary", "cn"], "files": [{ "path": "order-detail-page.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { Order, LineItem, OrderStatus } from "@cimplify/sdk";\nimport { OrderSummary } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface OrderDetailPageClassNames {\n root?: string;\n header?: string;\n title?: string;\n backButton?: string;\n summary?: string;\n}\n\nexport interface OrderDetailPageProps {\n /** Order ID to display. */\n orderId: string;\n /** Pre-fetched order for SSR. */\n order?: Order;\n /** Poll for status updates. Default: true. */\n poll?: boolean;\n /** Called when back button is clicked. */\n onBack?: () => void;\n /** Custom line item renderer. */\n renderLineItem?: (item: LineItem) => React.ReactNode;\n /** Called when the reorder button is clicked. */\n onReorder?: (order: Order) => void;\n /** Called when the order status changes during polling. */\n onStatusChange?: (previousStatus: OrderStatus, newStatus: OrderStatus, order: Order) => void;\n /** Back button label. */\n backLabel?: string;\n className?: string;\n classNames?: OrderDetailPageClassNames;\n}\n\n/**\n * OrderDetailPage \u2014 single order detail view with live status polling.\n *\n * SSR-friendly: pass `order` prop for server rendering.\n */\nexport function OrderDetailPage({\n orderId,\n order,\n poll = true,\n onBack,\n renderLineItem,\n onReorder,\n onStatusChange,\n backLabel = "Back to orders",\n className,\n classNames,\n}: OrderDetailPageProps): React.ReactElement {\n return (\n <div data-cimplify-order-detail-page className={cn(className, classNames?.root)}>\n {/* Header */}\n <div data-cimplify-order-detail-header className={classNames?.header}>\n {onBack && (\n <button\n type="button"\n onClick={onBack}\n data-cimplify-order-detail-back\n className={classNames?.backButton}\n >\n {backLabel}\n </button>\n )}\n <h1 data-cimplify-order-detail-title className={classNames?.title}>\n Order Details\n </h1>\n </div>\n\n {/* Order summary */}\n <div data-cimplify-order-detail-content className={classNames?.summary}>\n <OrderSummary\n order={order}\n orderId={order ? undefined : orderId}\n poll={poll}\n renderLineItem={renderLineItem}\n onReorder={onReorder}\n onStatusChange={onStatusChange}\n />\n </div>\n </div>\n );\n}\n' }] }, "cart-drawer": { "name": "cart-drawer", "title": "CartDrawer", "description": "Slide-in side cart drawer with provider context, free-shipping progress, animated subtotal, and empty state. Auto-opens on add-to-cart.", "type": "component", "registryDependencies": ["cart-summary", "price", "cn"], "files": [{ "path": "cart-drawer.tsx", "content": '"use client";\n\nimport React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";\nimport { CartSummary } from "./cart-summary";\nimport { Price } from "@cimplify/sdk/react";\nimport { useCart } from "@cimplify/sdk/react";\nimport { parsePrice } from "@cimplify/sdk";\nimport { cn } from "@cimplify/sdk/react";\n\ninterface CartDrawerCtx {\n isOpen: boolean;\n open: () => void;\n close: () => void;\n toggle: () => void;\n}\n\nconst CartDrawerContext = createContext<CartDrawerCtx | null>(null);\n\nexport function useCartDrawer(): CartDrawerCtx {\n const ctx = useContext(CartDrawerContext);\n if (!ctx) {\n throw new Error("useCartDrawer must be used within <CartDrawerProvider>");\n }\n return ctx;\n}\n\ninterface ProviderProps {\n children: React.ReactNode;\n /** Auto-open the drawer whenever the cart\'s pendingOpCount goes from 0 \u2192 >0. Default: true. */\n openOnAdd?: boolean;\n}\n\nexport function CartDrawerProvider({ children, openOnAdd = true }: ProviderProps): React.ReactElement {\n const [isOpen, setIsOpen] = useState(false);\n const cart = useCart();\n const lastPendingRef = useRef(0);\n\n const open = useCallback(() => setIsOpen(true), []);\n const close = useCallback(() => setIsOpen(false), []);\n const toggle = useCallback(() => setIsOpen((v) => !v), []);\n\n useEffect(() => {\n if (!openOnAdd) return;\n if (cart.pendingOpCount > lastPendingRef.current && cart.pendingOpCount > 0) {\n setIsOpen(true);\n }\n lastPendingRef.current = cart.pendingOpCount;\n }, [cart.pendingOpCount, openOnAdd]);\n\n const value = useMemo<CartDrawerCtx>(() => ({ isOpen, open, close, toggle }), [isOpen, open, close, toggle]);\n return <CartDrawerContext.Provider value={value}>{children}</CartDrawerContext.Provider>;\n}\n\nexport interface CartDrawerProps {\n /** Called when "Checkout" is clicked. Drawer auto-closes first. */\n onCheckout?: () => void;\n /** Called when "Continue Shopping" is clicked. Defaults to closing. */\n onContinueShopping?: () => void;\n /** Called when the empty state\'s CTA is clicked. */\n onShop?: () => void;\n /** Heading. */\n title?: string;\n /** Free-shipping threshold (in business currency, numeric). 0 disables the progress bar. */\n freeShippingThreshold?: number;\n /** Custom class on the panel. */\n className?: string;\n}\n\n/**\n * Animate a number toward `target` over ~250ms. Used for subtotal so it\n * feels alive when items are added/removed.\n */\nfunction useAnimatedNumber(target: number, durationMs = 250) {\n const [value, setValue] = useState(target);\n const fromRef = useRef(target);\n const startRef = useRef<number | null>(null);\n useEffect(() => {\n fromRef.current = value;\n startRef.current = null;\n let raf = 0;\n const tick = (t: number) => {\n if (startRef.current == null) startRef.current = t;\n const p = Math.min(1, (t - startRef.current) / durationMs);\n const eased = 1 - Math.pow(1 - p, 3);\n setValue(fromRef.current + (target - fromRef.current) * eased);\n if (p < 1) raf = requestAnimationFrame(tick);\n };\n raf = requestAnimationFrame(tick);\n return () => cancelAnimationFrame(raf);\n // value intentionally omitted \u2014 only re-trigger on target change\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [target, durationMs]);\n return value;\n}\n\nexport function CartDrawer({\n onCheckout,\n onContinueShopping,\n onShop,\n title = "Cart",\n freeShippingThreshold = 0,\n className,\n}: CartDrawerProps): React.ReactElement {\n const { isOpen, close } = useCartDrawer();\n const cart = useCart();\n const subtotalNum = parsePrice(cart.subtotal);\n const animatedSubtotal = useAnimatedNumber(subtotalNum);\n\n // Lock body scroll + close on Escape while open.\n useEffect(() => {\n if (!isOpen) return;\n const original = document.body.style.overflow;\n document.body.style.overflow = "hidden";\n const onKey = (e: KeyboardEvent) => {\n if (e.key === "Escape") close();\n };\n window.addEventListener("keydown", onKey);\n return () => {\n document.body.style.overflow = original;\n window.removeEventListener("keydown", onKey);\n };\n }, [isOpen, close]);\n\n const handleCheckout = () => {\n close();\n onCheckout?.();\n };\n\n const handleContinue = () => {\n onContinueShopping?.();\n close();\n };\n\n const handleShop = () => {\n onShop?.();\n close();\n };\n\n // Free-shipping progress\n const showShippingBar = freeShippingThreshold > 0 && !cart.isEmpty;\n const remainingForShipping = Math.max(0, freeShippingThreshold - subtotalNum);\n const shippingProgress = freeShippingThreshold > 0 ? Math.min(100, (subtotalNum / freeShippingThreshold) * 100) : 0;\n const shippingUnlocked = subtotalNum >= freeShippingThreshold && freeShippingThreshold > 0;\n\n return (\n <div\n data-cimplify-cart-drawer\n data-open={isOpen ? "true" : "false"}\n aria-hidden={!isOpen}\n className={cn(\n "fixed inset-0 z-[200]",\n isOpen ? "pointer-events-auto" : "pointer-events-none",\n )}\n >\n {/* Backdrop */}\n <div\n onClick={close}\n className={cn(\n "absolute inset-0 bg-foreground/40 backdrop-blur-sm transition-opacity duration-300",\n isOpen ? "opacity-100" : "opacity-0",\n )}\n />\n\n {/* Panel */}\n <aside\n role="dialog"\n aria-modal="true"\n aria-label={title}\n className={cn(\n "absolute top-0 right-0 h-full w-full sm:max-w-[480px] bg-background shadow-2xl flex flex-col",\n "transition-transform duration-300",\n isOpen ? "translate-x-0" : "translate-x-full",\n // ease-out cubic-bezier for a "pull" feel\n "[transition-timing-function:cubic-bezier(0.16,1,0.3,1)]",\n className,\n )}\n >\n {/* Header */}\n <header className="relative flex items-center justify-between gap-4 px-6 py-5 shrink-0">\n <div className="flex items-baseline gap-2">\n <h2 className="text-xl font-bold tracking-tight m-0">{title}</h2>\n {cart.itemCount > 0 && (\n <span className="text-sm text-muted-foreground tabular-nums">\n {cart.itemCount} {cart.itemCount === 1 ? "item" : "items"}\n </span>\n )}\n </div>\n <button\n type="button"\n onClick={close}\n aria-label="Close cart"\n className="grid place-items-center w-9 h-9 rounded-full hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"\n >\n <svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24" aria-hidden>\n <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />\n </svg>\n </button>\n </header>\n\n {/* Free-shipping bar */}\n {showShippingBar && (\n <div data-cimplify-cart-shipping-bar className="px-6 pb-4 shrink-0">\n <div className="flex items-baseline justify-between gap-2 mb-1.5 text-xs">\n {shippingUnlocked ? (\n <span className="font-medium text-primary">\u2713 Free shipping unlocked</span>\n ) : (\n <span className="text-muted-foreground">\n <Price amount={remainingForShipping} className="font-semibold text-foreground" /> away from free shipping\n </span>\n )}\n </div>\n <div className="h-1 rounded-full bg-muted overflow-hidden">\n <div\n className="h-full bg-primary transition-[width] duration-500 ease-out"\n style={{ width: `${shippingProgress}%` }}\n />\n </div>\n </div>\n )}\n\n {/* Items */}\n <div className="flex-1 overflow-y-auto px-6">\n {cart.isEmpty ? (\n <EmptyState onShop={handleShop} />\n ) : (\n <CartSummary showTotals={false} showCheckoutButton={false} />\n )}\n </div>\n\n {/* Footer */}\n {!cart.isEmpty && (\n <footer className="border-t border-border px-6 py-5 shrink-0 space-y-3 bg-background">\n <div className="flex items-baseline justify-between">\n <span className="text-sm text-muted-foreground">Subtotal</span>\n <Price amount={animatedSubtotal} className="text-lg font-bold tabular-nums" />\n </div>\n <p className="text-[11px] text-muted-foreground">\n Tax and shipping calculated at checkout.\n </p>\n <button\n type="button"\n onClick={handleCheckout}\n className="w-full h-12 rounded-full bg-foreground text-background font-semibold text-sm hover:bg-foreground/90 active:scale-[0.99] transition-all"\n >\n Checkout \u2014 <Price amount={cart.subtotal} className="tabular-nums" />\n </button>\n <button\n type="button"\n onClick={handleContinue}\n className="w-full text-xs font-medium text-muted-foreground hover:text-foreground transition-colors"\n >\n Continue shopping\n </button>\n <div className="flex items-center justify-center gap-1.5 text-[10px] text-muted-foreground/70 uppercase tracking-wider pt-1">\n <svg className="w-3 h-3" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24" aria-hidden>\n <path strokeLinecap="round" strokeLinejoin="round" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />\n </svg>\n <span>Secure checkout</span>\n </div>\n </footer>\n )}\n </aside>\n </div>\n );\n}\n\nfunction EmptyState({ onShop }: { onShop: () => void }) {\n return (\n <div data-cimplify-cart-empty className="h-full grid place-items-center text-center py-16">\n <div className="space-y-5 max-w-[260px]">\n <div className="relative w-20 h-20 mx-auto">\n <div className="absolute inset-0 rounded-full bg-muted" />\n <svg\n className="relative w-10 h-10 m-auto top-1/2 -translate-y-1/2 text-muted-foreground/60"\n fill="none"\n stroke="currentColor"\n strokeWidth="1.5"\n viewBox="0 0 24 24"\n aria-hidden\n >\n <path strokeLinecap="round" strokeLinejoin="round" d="M16 11V7a4 4 0 00-8 0v4M5 9h14l-1 12H6L5 9z" />\n </svg>\n </div>\n <div>\n <p className="text-base font-semibold m-0">Your cart is empty</p>\n <p className="text-sm text-muted-foreground mt-1">\n Discover something you\'ll love.\n </p>\n </div>\n <button\n type="button"\n onClick={onShop}\n className="inline-flex items-center gap-1.5 h-11 px-6 rounded-full bg-foreground text-background text-sm font-semibold hover:bg-foreground/90 active:scale-[0.99] transition-all"\n >\n Shop now\n <svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth="2.5" viewBox="0 0 24 24" aria-hidden>\n <path strokeLinecap="round" strokeLinejoin="round" d="M14 5l7 7m0 0l-7 7m7-7H3" />\n </svg>\n </button>\n </div>\n </div>\n );\n}\n' }] }, "booking-card": { "name": "booking-card", "title": "BookingCard", "description": "Single booking display with status, time, and action buttons.", "type": "component", "registryDependencies": ["price", "cn"], "files": [{ "path": "booking-card.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { CustomerBooking, BookingStatus } from "@cimplify/sdk";\nimport { Price } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface BookingCardClassNames {\n root?: string;\n service?: string;\n status?: string;\n time?: string;\n confirmationCode?: string;\n total?: string;\n actions?: string;\n cancelButton?: string;\n rescheduleButton?: string;\n}\n\nexport interface BookingCardProps {\n /** The booking to display. */\n booking: CustomerBooking;\n /** Called when cancel is clicked. */\n onCancel?: (booking: CustomerBooking) => void;\n /** Called when reschedule is clicked. */\n onReschedule?: (booking: CustomerBooking) => void;\n /** Custom renderer for the booking. */\n renderBooking?: (booking: CustomerBooking) => React.ReactNode;\n className?: string;\n classNames?: BookingCardClassNames;\n}\n\nconst STATUS_LABELS: Record<string, string> = {\n pending: "Pending",\n confirmed: "Confirmed",\n in_progress: "In Progress",\n completed: "Completed",\n cancelled: "Cancelled",\n no_show: "No Show",\n};\n\nfunction isActiveStatus(status: BookingStatus): boolean {\n const s = String(status).toLowerCase();\n return s !== "completed" && s !== "cancelled" && s !== "no_show";\n}\n\nfunction getFirstServiceItem(booking: CustomerBooking) {\n return booking.service_items[0] ?? null;\n}\n\nfunction formatScheduledTime(start?: string | null, end?: string | null): string | null {\n if (!start) return null;\n try {\n const startDate = new Date(start);\n const options: Intl.DateTimeFormatOptions = {\n month: "short",\n day: "numeric",\n hour: "numeric",\n minute: "2-digit",\n };\n let formatted = startDate.toLocaleString(undefined, options);\n if (end) {\n const endDate = new Date(end);\n formatted += ` \u2013 ${endDate.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" })}`;\n }\n return formatted;\n } catch {\n return start;\n }\n}\n\nexport function BookingCard({\n booking,\n onCancel,\n onReschedule,\n renderBooking,\n className,\n classNames,\n}: BookingCardProps): React.ReactElement {\n if (renderBooking) {\n return (\n <div\n data-cimplify-booking-card\n data-status={booking.status}\n className={cn(className, classNames?.root)}\n >\n {renderBooking(booking)}\n </div>\n );\n }\n\n const firstItem = getFirstServiceItem(booking);\n const active = isActiveStatus(booking.status);\n const scheduledTime = firstItem\n ? formatScheduledTime(firstItem.scheduled_start, firstItem.scheduled_end)\n : null;\n\n return (\n <div\n data-cimplify-booking-card\n data-status={booking.status}\n className={cn(\n "rounded-xl border border-border bg-background p-4 flex flex-col gap-3",\n className,\n classNames?.root,\n )}\n >\n <div data-cimplify-booking-main className="flex items-center justify-between gap-2">\n <span data-cimplify-booking-service className={cn("font-semibold text-foreground", classNames?.service)}>\n Booking #{booking.order_id.slice(0, 8)}\n </span>\n <span\n data-cimplify-booking-status\n data-status={booking.status}\n className={cn(\n "text-xs font-medium px-2 py-0.5 rounded-full",\n active ? "bg-primary/10 text-primary" : "bg-muted text-muted-foreground",\n classNames?.status,\n )}\n >\n {STATUS_LABELS[booking.status] ?? booking.status}\n </span>\n </div>\n\n {firstItem?.confirmation_code && (\n <span data-cimplify-booking-code className={cn("text-xs font-mono text-muted-foreground tracking-wide", classNames?.confirmationCode)}>\n {firstItem.confirmation_code}\n </span>\n )}\n\n {scheduledTime && (\n <time\n data-cimplify-booking-time\n dateTime={firstItem?.scheduled_start ?? undefined}\n className={cn("text-sm text-foreground", classNames?.time)}\n >\n {scheduledTime}\n </time>\n )}\n\n <span data-cimplify-booking-total className={cn("text-sm font-medium text-foreground", classNames?.total)}>\n <Price amount={booking.total_price} />\n </span>\n\n {active && (onCancel || onReschedule) && (\n <div data-cimplify-booking-actions className={cn("flex gap-2 pt-1 border-t border-border mt-1", classNames?.actions)}>\n {onReschedule && (\n <button\n type="button"\n onClick={() => onReschedule(booking)}\n data-cimplify-booking-reschedule\n className={cn(\n "flex-1 text-sm font-medium py-2 rounded-lg border border-border bg-background text-foreground transition-colors hover:bg-muted cursor-pointer",\n classNames?.rescheduleButton,\n )}\n >\n Reschedule\n </button>\n )}\n {onCancel && (\n <button\n type="button"\n onClick={() => onCancel(booking)}\n data-cimplify-booking-cancel\n className={cn(\n "flex-1 text-sm font-medium py-2 rounded-lg border border-border bg-background text-destructive transition-colors hover:bg-destructive/10 cursor-pointer",\n classNames?.cancelButton,\n )}\n >\n Cancel\n </button>\n )}\n </div>\n )}\n </div>\n );\n}\n' }] }, "order-history": { "name": "order-history", "title": "OrderHistory", "description": "List of past orders with status and totals.", "type": "component", "registryDependencies": ["price", "cn"], "files": [{ "path": "order-history.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { Order, OrderStatus } from "@cimplify/sdk";\nimport { useOrders } from "@cimplify/sdk/react";\nimport { Price } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface OrderHistoryClassNames {\n root?: string;\n item?: string;\n orderId?: string;\n status?: string;\n date?: string;\n total?: string;\n empty?: string;\n loading?: string;\n reorderButton?: string;\n}\n\nexport interface OrderHistoryProps {\n /** Override orders (skips fetch). For SSR, pass pre-fetched orders. */\n orders?: Order[];\n /** Filter by status. */\n status?: OrderStatus;\n /** Max orders to display. */\n limit?: number;\n /** Called when an order row is clicked. */\n onOrderClick?: (order: Order) => void;\n /** Custom order row renderer. */\n renderOrder?: (order: Order) => React.ReactNode;\n /** Called when the reorder button is clicked. If provided, a reorder button is rendered. */\n onReorder?: (order: Order) => void;\n /** Text shown when empty. */\n emptyMessage?: string;\n className?: string;\n classNames?: OrderHistoryClassNames;\n}\n\nconst STATUS_LABELS: Record<OrderStatus, string> = {\n pending: "Pending",\n created: "Created",\n confirmed: "Confirmed",\n in_preparation: "In Preparation",\n ready_to_serve: "Ready",\n partially_served: "Partially Served",\n served: "Served",\n delivered: "Delivered",\n picked_up: "Picked Up",\n completed: "Completed",\n cancelled: "Cancelled",\n refunded: "Refunded",\n};\n\n/**\n * OrderHistory \u2014 list of past orders with status and totals.\n *\n * Fetches via `useOrders` unless pre-loaded orders are passed.\n */\nexport function OrderHistory({\n orders: ordersProp,\n status,\n limit,\n onOrderClick,\n renderOrder,\n onReorder,\n emptyMessage = "No orders yet",\n className,\n classNames,\n}: OrderHistoryProps): React.ReactElement {\n const { orders: fetched, isLoading } = useOrders({\n status,\n limit,\n enabled: ordersProp === undefined,\n });\n\n const orders = ordersProp ?? fetched;\n\n if (isLoading && orders.length === 0) {\n return (\n <div\n data-cimplify-order-history\n aria-busy="true"\n className={cn(className, classNames?.root, classNames?.loading)}\n />\n );\n }\n\n if (orders.length === 0) {\n return (\n <div\n data-cimplify-order-history\n data-empty\n className={cn(className, classNames?.root, classNames?.empty)}\n >\n <p>{emptyMessage}</p>\n </div>\n );\n }\n\n return (\n <div data-cimplify-order-history className={cn(className, classNames?.root)}>\n {orders.map((order) => (\n <button\n key={order.id}\n type="button"\n onClick={() => onOrderClick?.(order)}\n data-cimplify-order-history-item\n data-status={order.status}\n className={classNames?.item}\n >\n {renderOrder ? (\n renderOrder(order)\n ) : (\n <>\n <div data-cimplify-order-history-main>\n <span data-cimplify-order-history-id className={classNames?.orderId}>\n #{order.user_friendly_id}\n </span>\n <span\n data-cimplify-order-history-status\n data-status={order.status}\n className={classNames?.status}\n >\n {STATUS_LABELS[order.status] ?? order.status}\n </span>\n </div>\n <div data-cimplify-order-history-details>\n <time\n data-cimplify-order-history-date\n dateTime={order.created_at}\n className={classNames?.date}\n >\n {new Date(order.created_at).toLocaleDateString()}\n </time>\n <span data-cimplify-order-history-items>\n {order.total_quantity} {order.total_quantity === 1 ? "item" : "items"}\n </span>\n <span data-cimplify-order-history-total className={classNames?.total}>\n <Price amount={order.total_price} />\n </span>\n </div>\n {onReorder && (\n <button\n type="button"\n onClick={(e) => {\n e.stopPropagation();\n onReorder(order);\n }}\n data-cimplify-order-reorder\n className={classNames?.reorderButton}\n >\n Reorder\n </button>\n )}\n </>\n )}\n </button>\n ))}\n </div>\n );\n}\n' }] }, "compact-service-card": { "name": "compact-service-card", "title": "CompactServiceCard", "description": "Horizontal service card with thumbnail for list views.", "type": "component", "registryDependencies": ["price", "cn"], "files": [{ "path": "cards/compact-service-card.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { ServiceCardLayoutProps } from "./standard-service-card";\nimport { Price } from "@cimplify/sdk/react";\nimport { parsePrice } from "@cimplify/sdk";\nimport { cn } from "@cimplify/sdk/react";\n\nfunction formatDuration(minutes: number, unit?: string): string {\n if (unit && unit !== "minutes") return `${minutes} ${unit}`;\n if (minutes >= 60) {\n const h = Math.floor(minutes / 60);\n const m = minutes % 60;\n return m > 0 ? `${h}h ${m}m` : `${h} hr`;\n }\n return `${minutes} min`;\n}\n\nexport function CompactServiceCard({\n product,\n renderImage,\n renderLink,\n className,\n}: ServiceCardLayoutProps): React.ReactElement {\n const image = product.image_url || product.images?.[0] || "";\n const hasDeposit = product.deposit_type && product.deposit_type !== "none" && product.deposit_amount;\n const hasBillingPlans = product.billing_plans && product.billing_plans.length > 0;\n const href = `/products/${product.slug}`;\n\n const content = (\n <div className="flex items-center gap-4 p-3">\n {/* Thumbnail */}\n <div className="w-[72px] h-[72px] rounded-xl overflow-hidden bg-muted shrink-0">\n {renderImage ? (\n renderImage({ src: image, alt: product.name, className: "w-full h-full object-cover" })\n ) : (\n <img src={image} alt={product.name} className="w-full h-full object-cover" />\n )}\n </div>\n\n {/* Info */}\n <div className="flex-1 min-w-0">\n <h3 className="text-[14px] font-semibold text-foreground leading-tight truncate">\n {product.name}\n </h3>\n {product.description && (\n <p className="text-[12px] text-muted-foreground mt-0.5 truncate">\n {product.description}\n </p>\n )}\n <div className="flex items-center gap-2 mt-2 flex-wrap">\n {product.duration_minutes != null && (\n <span className="text-[10.5px] font-medium text-muted-foreground px-1.5 py-0.5 bg-muted rounded">\n {formatDuration(product.duration_minutes, product.duration_unit)}\n </span>\n )}\n <span className="flex items-center gap-1 text-[10.5px] font-medium text-emerald-600">\n <span className="w-[6px] h-[6px] rounded-full bg-emerald-500" />\n Available\n </span>\n {hasDeposit && (\n <span className="text-[10.5px] font-medium text-amber-600">\n <Price amount={product.deposit_amount!} /> deposit\n </span>\n )}\n {hasBillingPlans && (\n <span className="inline-flex items-center gap-0.5 text-[10.5px] font-semibold text-primary px-1.5 py-0.5 bg-primary/10 rounded">\n Subscription\n </span>\n )}\n </div>\n </div>\n\n {/* Price + Chevron */}\n <div className="flex items-center gap-2 shrink-0">\n <Price amount={product.default_price} className="text-sm font-bold" />\n <svg className="w-4 h-4 text-muted-foreground group-hover:text-primary transition-colors" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">\n <path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />\n </svg>\n </div>\n </div>\n );\n\n const shellClass = cn(\n "group block text-left cursor-pointer bg-card rounded-[14px] overflow-hidden",\n "shadow-[0_1px_3px_rgba(0,0,0,0.04),0_6px_24px_rgba(0,0,0,0.06)]",\n "border border-transparent",\n "transition-all duration-300 ease-[cubic-bezier(0.19,1,0.22,1)]",\n "hover:-translate-y-[1px] hover:shadow-[0_2px_6px_rgba(0,0,0,0.04),0_12px_40px_rgba(0,0,0,0.10)] hover:border-primary/20",\n className,\n );\n\n if (renderLink) {\n return renderLink({ href, className: shellClass, children: content });\n }\n\n return <a href={href} className={shellClass} data-cimplify-card>{content}</a>;\n}\n' }] }, "volume-pricing": { "name": "volume-pricing", "title": "VolumePricing", "description": "Collapsible volume pricing tier table.", "type": "component", "registryDependencies": ["price", "cn"], "files": [{ "path": "volume-pricing.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { QuantityPricingTier } from "@cimplify/sdk";\nimport { Price } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface VolumePricingClassNames {\n root?: string;\n trigger?: string;\n triggerIcon?: string;\n panel?: string;\n tier?: string;\n tierActive?: string;\n tierRange?: string;\n tierPrice?: string;\n}\n\nexport interface VolumePricingProps {\n tiers: QuantityPricingTier[];\n currentQuantity?: number;\n defaultOpen?: boolean;\n className?: string;\n classNames?: VolumePricingClassNames;\n}\n\nfunction formatRange(tier: QuantityPricingTier): string {\n if (tier.max_quantity != null) {\n return `${tier.min_quantity}\u2013${tier.max_quantity} units`;\n }\n return `${tier.min_quantity}+ units`;\n}\n\nfunction isActiveTier(tier: QuantityPricingTier, quantity: number): boolean {\n if (quantity < tier.min_quantity) return false;\n if (tier.max_quantity != null && quantity > tier.max_quantity) return false;\n return true;\n}\n\nexport function VolumePricing({\n tiers,\n currentQuantity,\n defaultOpen = false,\n className,\n classNames,\n}: VolumePricingProps): React.ReactElement | null {\n if (tiers.length < 2) return null;\n\n const sorted = [...tiers].sort((a, b) => a.min_quantity - b.min_quantity);\n\n return (\n <details\n open={defaultOpen || undefined}\n data-cimplify-volume-pricing\n className={cn("border border-border", className, classNames?.root)}\n >\n <summary\n data-cimplify-volume-pricing-trigger\n className={cn(\n "flex items-center justify-between px-4 py-3 cursor-pointer select-none text-sm font-medium list-none [&::-webkit-details-marker]:hidden",\n classNames?.trigger,\n )}\n >\n Volume pricing\n <svg\n viewBox="0 0 12 12"\n fill="none"\n aria-hidden="true"\n className={cn(\n "w-3.5 h-3.5 text-muted-foreground transition-transform [[open]>&]:rotate-180",\n classNames?.triggerIcon,\n )}\n >\n <path d="M2 4.5l4 4 4-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />\n </svg>\n </summary>\n <div\n data-cimplify-volume-pricing-panel\n className={cn("border-t border-border divide-y divide-border", classNames?.panel)}\n >\n {sorted.map((tier, i) => {\n const active = currentQuantity != null && isActiveTier(tier, currentQuantity);\n return (\n <div\n key={i}\n data-cimplify-volume-pricing-tier\n data-active={active || undefined}\n className={cn(\n "flex items-center justify-between px-4 py-2.5 text-sm",\n active\n ? cn("bg-primary/5 font-medium", classNames?.tierActive)\n : classNames?.tier,\n )}\n >\n <span\n data-cimplify-volume-pricing-range\n className={cn("text-muted-foreground", classNames?.tierRange)}\n >\n {formatRange(tier)}\n </span>\n <span data-cimplify-volume-pricing-price className={classNames?.tierPrice}>\n <Price amount={tier.unit_price} prefix="" /> /ea\n </span>\n </div>\n );\n })}\n </div>\n </details>\n );\n}\n' }] }, "delivery-estimate": { "name": "delivery-estimate", "title": "DeliveryEstimate", "description": "Delivery fee + ETA preview at the cart/checkout edge, sourced from /delivery/fee with location.", "type": "component", "registryDependencies": ["price", "cn"], "files": [{ "path": "delivery-estimate.tsx", "content": '"use client";\n\nimport React, { useState, useEffect, useRef } from "react";\nimport type { DeliveryFeeResponse } from "../delivery";\nimport type { CimplifyError } from "@cimplify/sdk";\nimport { Price } from "@cimplify/sdk/react";\nimport { useOptionalCimplifyClient } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface DeliveryEstimateClassNames {\n root?: string;\n fee?: string;\n currency?: string;\n notServiceable?: string;\n loading?: string;\n error?: string;\n}\n\nexport interface DeliveryEstimateProps {\n /** Drop-off latitude. */\n latitude: number;\n /** Drop-off longitude. */\n longitude: number;\n /** Country code for regional fee lookup. */\n country?: string;\n /** Called when the delivery fee is successfully calculated. */\n onFeeCalculated?: (fee: DeliveryFeeResponse) => void;\n className?: string;\n classNames?: DeliveryEstimateClassNames;\n}\n\n/**\n * DeliveryEstimate \u2014 displays a pre-checkout delivery fee estimate.\n *\n * Fetches the delivery fee from `client.delivery.getFee` when coordinates change.\n * Shows fee amount with currency, a "not serviceable" message, or loading state.\n */\nexport function DeliveryEstimate({\n latitude,\n longitude,\n country,\n onFeeCalculated,\n className,\n classNames,\n}: DeliveryEstimateProps): React.ReactElement | null {\n const context = useOptionalCimplifyClient();\n const client = context?.client ?? null;\n\n const [result, setResult] = useState<DeliveryFeeResponse | null>(null);\n const [isLoading, setIsLoading] = useState(true);\n const [error, setError] = useState<CimplifyError | null>(null);\n const requestIdRef = useRef(0);\n\n useEffect(() => {\n if (!client) return;\n\n const nextRequestId = ++requestIdRef.current;\n setIsLoading(true);\n setError(null);\n\n void (async () => {\n const res = await client.delivery.getFee(latitude, longitude, country);\n\n if (nextRequestId !== requestIdRef.current) return;\n\n if (!res.ok) {\n setError(res.error);\n setResult(null);\n setIsLoading(false);\n return;\n }\n\n setResult(res.value);\n setError(null);\n setIsLoading(false);\n onFeeCalculated?.(res.value);\n })();\n }, [client, latitude, longitude, country]); // eslint-disable-line react-hooks/exhaustive-deps -- onFeeCalculated is a callback prop, not a reactive dependency\n\n if (!client) {\n return null;\n }\n\n if (isLoading) {\n return (\n <div\n data-cimplify-delivery-estimate\n data-serviceable="false"\n aria-busy="true"\n className={cn(className, classNames?.root, classNames?.loading)}\n />\n );\n }\n\n if (error) {\n return (\n <div\n data-cimplify-delivery-estimate\n data-serviceable="false"\n className={cn(className, classNames?.root, classNames?.error)}\n >\n <span>{error.message}</span>\n </div>\n );\n }\n\n if (!result || !result.serviceable) {\n return (\n <div\n data-cimplify-delivery-estimate\n data-serviceable="false"\n className={cn(className, classNames?.root, classNames?.notServiceable)}\n >\n <span>Not serviceable</span>\n </div>\n );\n }\n\n return (\n <div\n data-cimplify-delivery-estimate\n data-serviceable="true"\n className={cn(className, classNames?.root)}\n >\n {result.fee != null && (\n <span data-cimplify-delivery-fee className={classNames?.fee}>\n <Price amount={result.fee} />\n </span>\n )}\n {result.currency && (\n <span data-cimplify-delivery-currency className={classNames?.currency}>\n {result.currency}\n </span>\n )}\n </div>\n );\n}\n' }] }, "retail-product-card": { "name": "retail-product-card", "title": "RetailProductCard", "description": "Product card for retail with color swatches, sale badge, wishlist, and sold-out overlay.", "type": "component", "registryDependencies": ["price", "cn"], "files": [{ "path": "cards/retail-product-card.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { Product, ProductWithDetails } from "@cimplify/sdk";\nimport type { CardLayoutProps } from "@cimplify/sdk/react";\nimport { CardShell, CardImage, WishlistButton, SoldOutOverlay, SaleBadge, LowStockBadge } from "@cimplify/sdk/react";\nimport { Price } from "@cimplify/sdk/react";\nimport { parsePrice, isOnSale, getBasePrice, getDiscountPercentage } from "@cimplify/sdk";\nimport { cn } from "@cimplify/sdk/react";\n\nfunction getColorSwatches(product: Product): string[] {\n const variants = (product as ProductWithDetails).variants;\n if (!variants || variants.length === 0) return [];\n\n const seen = new Set<string>();\n for (const v of variants) {\n if (v.display_attributes) {\n for (const attr of v.display_attributes) {\n if (attr.axis_name.toLowerCase() === "color" && !seen.has(attr.value_name)) {\n seen.add(attr.value_name);\n }\n }\n }\n }\n\n return Array.from(seen).slice(0, 5);\n}\n\nexport function RetailProductCard({\n product,\n renderImage,\n renderLink,\n className,\n}: CardLayoutProps): React.ReactElement {\n const image = product.image_url || product.images?.[0] || "";\n const outOfStock = product.inventory_status?.in_stock === false;\n const onSale = isOnSale(product);\n const swatches = getColorSwatches(product);\n\n return (\n <CardShell product={product} renderLink={renderLink} disabled={outOfStock} className={className}>\n {outOfStock && <SoldOutOverlay />}\n\n <CardImage src={image} alt={product.name} aspectRatio="3/4" renderImage={renderImage}>\n {/* Sale badge */}\n {onSale && (\n <div className="absolute top-3 left-3">\n <SaleBadge product={product} />\n </div>\n )}\n\n {/* Wishlist */}\n <WishlistButton className="absolute top-3 right-3" />\n\n {/* Low stock */}\n {product.inventory_status?.low_stock && product.inventory_status?.stock_level && (\n <span className="absolute bottom-3 left-3 text-[11px] font-semibold bg-amber-50 text-amber-700 border border-amber-200/60 px-2 py-0.5 rounded-md">\n Only {product.inventory_status.stock_level} left\n </span>\n )}\n </CardImage>\n\n <div className="p-3.5">\n {/* Brand */}\n {product.vendor && (\n <p className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">\n {product.vendor}\n </p>\n )}\n\n {/* Name */}\n <h3 className="text-[14.5px] font-semibold text-foreground leading-snug tracking-tight mt-1 truncate">\n {product.name}\n </h3>\n\n {/* Price */}\n <div className="flex items-center gap-2 mt-2">\n <Price amount={product.default_price} className="text-sm font-bold" />\n {onSale && (\n <Price amount={getBasePrice(product)} className="text-xs text-muted-foreground line-through" />\n )}\n </div>\n\n {/* Color swatches */}\n {swatches.length > 0 && (\n <div className="flex gap-1.5 mt-2.5">\n {swatches.map((name) => (\n <span\n key={name}\n title={name}\n className="text-[9px] font-medium text-muted-foreground px-1.5 py-0.5 bg-muted rounded"\n >\n {name}\n </span>\n ))}\n </div>\n )}\n </div>\n </CardShell>\n );\n}\n' }] }, "collection-page": { "name": "collection-page", "title": "CollectionPage", "description": "Curated product collection with header and grid.", "type": "component", "registryDependencies": ["product-grid", "cn"], "files": [{ "path": "collection-page.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { Product, Collection } from "@cimplify/sdk";\nimport { useCollection } from "@cimplify/sdk/react";\nimport type { CollectionLayoutProps } from "./collection-layouts/shared";\nimport { DefaultCollectionLayout } from "./collection-layouts/default-collection-layout";\nimport { FeaturedCollectionLayout } from "./collection-layouts/featured-collection-layout";\nimport { CatalogueCollectionLayout } from "./collection-layouts/catalogue-collection-layout";\nimport { cn } from "@cimplify/sdk/react";\n\nexport type { CollectionLayoutProps };\n\nexport enum CollectionTemplate {\n Default = "default",\n Featured = "featured",\n Catalogue = "catalogue",\n}\n\nexport interface CollectionPageClassNames {\n root?: string;\n loading?: string;\n}\n\nexport interface CollectionPageProps {\n /** Collection slug or ID. */\n collectionId?: string;\n /** Pre-fetched collection for SSR. */\n collection?: Collection;\n /** Pre-fetched products for SSR. */\n products?: Product[];\n /** Per-slug page map. Highest priority \u2014 maps a collection slug to a custom layout. */\n pages?: Record<string, React.ComponentType<CollectionLayoutProps>>;\n /** Per-type template map. Overrides built-in layouts. */\n templates?: Partial<Record<CollectionTemplate | string, React.ComponentType<CollectionLayoutProps>>>;\n /** Custom card renderer. */\n renderCard?: (product: Product) => React.ReactNode;\n /** Custom image renderer. */\n renderImage?: (props: { src: string; alt: string; className?: string }) => React.ReactNode;\n /** Custom link renderer. */\n renderLink?: (props: { href: string; className?: string; children: React.ReactNode }) => React.ReactElement;\n /** Quick add handler for cards. */\n onQuickAdd?: (product: Product) => void;\n className?: string;\n classNames?: CollectionPageClassNames;\n}\n\nconst BUILT_IN_LAYOUTS: Record<string, React.ComponentType<CollectionLayoutProps>> = {\n [CollectionTemplate.Default]: DefaultCollectionLayout,\n [CollectionTemplate.Featured]: FeaturedCollectionLayout,\n [CollectionTemplate.Catalogue]: CatalogueCollectionLayout,\n};\n\nconst LARGE_COLLECTION_THRESHOLD = 30;\n\nfunction resolveTemplateKey(collection: Collection, productCount: number): CollectionTemplate | string {\n const metaTemplate = collection.metadata?.page_template;\n if (typeof metaTemplate === "string" && metaTemplate.trim()) {\n return metaTemplate.trim();\n }\n\n if (collection.tags?.includes("featured") || collection.metadata?.is_featured === true) {\n return CollectionTemplate.Featured;\n }\n\n if (productCount > LARGE_COLLECTION_THRESHOLD) {\n return CollectionTemplate.Catalogue;\n }\n\n return CollectionTemplate.Default;\n}\n\nfunction resolveLayout(\n collection: Collection,\n productCount: number,\n pages?: Record<string, React.ComponentType<CollectionLayoutProps>>,\n templates?: Partial<Record<string, React.ComponentType<CollectionLayoutProps>>>,\n): React.ComponentType<CollectionLayoutProps> {\n if (pages?.[collection.slug]) {\n return pages[collection.slug];\n }\n\n const key = resolveTemplateKey(collection, productCount);\n\n if (templates?.[key]) {\n return templates[key]!;\n }\n\n if (BUILT_IN_LAYOUTS[key]) {\n return BUILT_IN_LAYOUTS[key];\n }\n\n return DefaultCollectionLayout;\n}\n\nexport function CollectionPage({\n collectionId,\n collection: collectionProp,\n products: productsProp,\n pages,\n templates,\n renderCard,\n renderImage,\n renderLink,\n onQuickAdd,\n className,\n classNames,\n}: CollectionPageProps): React.ReactElement {\n const resolvedId = collectionId || collectionProp?.slug || collectionProp?.id || "";\n const {\n collection: fetchedCollection,\n products: fetchedProducts,\n isLoading,\n } = useCollection(collectionProp ? null : resolvedId, {\n enabled: !collectionProp && resolvedId.length > 0,\n });\n\n const collection = collectionProp ?? fetchedCollection;\n const products = productsProp ?? fetchedProducts;\n\n if (isLoading && !collection) {\n return (\n <div\n data-cimplify-collection-page\n aria-busy="true"\n className={cn(className, classNames?.root, classNames?.loading)}\n >\n <div className="animate-pulse space-y-6">\n <div className="h-64 lg:h-80 bg-muted rounded-2xl" />\n <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">\n {Array.from({ length: 8 }).map((_, i) => (\n <div key={i}>\n <div className="aspect-square bg-muted rounded-[14px]" />\n <div className="mt-3 space-y-2">\n <div className="h-4 w-3/4 bg-muted rounded" />\n <div className="h-3 w-1/2 bg-muted rounded" />\n </div>\n </div>\n ))}\n </div>\n </div>\n </div>\n );\n }\n\n if (!collection) {\n return (\n <div data-cimplify-collection-page className={cn(className, classNames?.root)}>\n <p className="text-muted-foreground">Collection not found.</p>\n </div>\n );\n }\n\n const Layout = resolveLayout(collection, products.length, pages, templates);\n\n return (\n <div data-cimplify-collection-page className={cn(className, classNames?.root)}>\n <Layout\n collection={collection}\n products={products}\n renderCard={renderCard}\n renderImage={renderImage}\n renderLink={renderLink}\n onQuickAdd={onQuickAdd}\n />\n </div>\n );\n}\n' }] }, "deals-page": { "name": "deals-page", "title": "DealsPage", "description": "Promotions landing page with deal banners and on-sale products.", "type": "component", "registryDependencies": ["deal-banner", "product-grid", "sale-badge", "product-card", "cn"], "files": [{ "path": "deals-page.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { Product, Deal, ProductDealInfo } from "@cimplify/sdk";\nimport { useDeals, useProductsOnSale } from "@cimplify/sdk/react";\nimport { DealBanner } from "@cimplify/sdk/react";\nimport { ProductGrid } from "./product-grid";\nimport { SaleBadge } from "./sale-badge";\nimport { ProductCard } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface DealsPageClassNames {\n root?: string;\n header?: string;\n title?: string;\n bannerSection?: string;\n productsSection?: string;\n productsTitle?: string;\n empty?: string;\n loading?: string;\n}\n\nexport interface DealsPageProps {\n /** Page title. */\n title?: string;\n /** Pre-fetched deals for SSR. */\n deals?: Deal[];\n /** Pre-fetched products on sale for SSR. */\n productsOnSale?: ProductDealInfo[];\n /** Pre-fetched product objects for rendering. */\n products?: Product[];\n /** Location ID for location-specific deals. */\n locationId?: string;\n /** Custom image renderer. */\n renderImage?: (props: { src: string; alt: string; className?: string }) => React.ReactNode;\n /** Grid column config. */\n columns?: { sm?: number; md?: number; lg?: number; xl?: number };\n className?: string;\n classNames?: DealsPageClassNames;\n}\n\n/**\n * DealsPage \u2014 promotions landing page with deal banners and on-sale products.\n *\n * SSR-friendly: pass `deals` and `productsOnSale` props for server rendering.\n */\nexport function DealsPage({\n title = "Deals & Offers",\n deals: dealsProp,\n productsOnSale: productsOnSaleProp,\n products: productsProp,\n locationId,\n renderImage,\n columns,\n className,\n classNames,\n}: DealsPageProps): React.ReactElement {\n const { deals: fetchedDeals, isLoading: dealsLoading } = useDeals({\n locationId,\n enabled: dealsProp === undefined,\n });\n const { products: fetchedOnSale, isLoading: productsLoading } = useProductsOnSale({\n enabled: productsOnSaleProp === undefined,\n });\n\n const deals = dealsProp ?? fetchedDeals;\n const onSale = productsOnSaleProp ?? fetchedOnSale;\n const isLoading = dealsLoading || productsLoading;\n\n const isEmpty = deals.length === 0 && onSale.length === 0;\n\n if (isLoading && isEmpty) {\n return (\n <div\n data-cimplify-deals-page\n aria-busy="true"\n className={cn(className, classNames?.root, classNames?.loading)}\n />\n );\n }\n\n return (\n <div data-cimplify-deals-page className={cn(className, classNames?.root)}>\n {/* Header */}\n <div data-cimplify-deals-header className={classNames?.header}>\n <h1 data-cimplify-deals-title className={classNames?.title}>\n {title}\n </h1>\n </div>\n\n {/* Deal banners */}\n {deals.length > 0 && (\n <div data-cimplify-deals-banner-section className={classNames?.bannerSection}>\n <DealBanner deals={deals} />\n </div>\n )}\n\n {/* Products on sale */}\n {(onSale.length > 0 || (productsProp && productsProp.length > 0)) && (\n <div data-cimplify-deals-products className={classNames?.productsSection}>\n <h2 data-cimplify-deals-products-title className={classNames?.productsTitle}>\n On Sale Now\n </h2>\n {productsProp ? (\n <ProductGrid\n products={productsProp}\n renderImage={renderImage}\n columns={columns}\n />\n ) : (\n <div data-cimplify-deals-sale-list>\n {onSale.map((dealInfo: ProductDealInfo) => (\n <div key={`${dealInfo.product_id}-${dealInfo.deal_id}`} data-cimplify-deals-sale-item>\n <SaleBadge\n product={{ id: dealInfo.product_id, name: dealInfo.deal_name } as Product}\n dealInfo={dealInfo}\n showOriginalPrice\n />\n </div>\n ))}\n </div>\n )}\n </div>\n )}\n\n {/* Empty state */}\n {isEmpty && (\n <div data-cimplify-deals-empty className={classNames?.empty}>\n <p>No deals available right now. Check back soon!</p>\n </div>\n )}\n </div>\n );\n}\n' }] }, "wholesale-product-layout": { "name": "wholesale-product-layout", "title": "WholesaleProductLayout", "description": "B2B wholesale layout with price range, volume pricing, MOQ, and inventory.", "type": "component", "registryDependencies": ["product-image-gallery", "product-customizer", "price", "price-range", "volume-pricing", "cn"], "files": [{ "path": "layouts/wholesale-product-layout.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { ProductLayoutProps } from "@cimplify/sdk/react";\nimport {\n TwoColumnGrid,\n RelatedProductsSection,\n CustomAttributesTable,\n PropertiesTable,\n TagPills,\n InventoryBadge,\n} from "@cimplify/sdk/react";\nimport { ProductImageGallery } from "@cimplify/sdk/react";\nimport { ProductCustomizer } from "@cimplify/sdk/react";\nimport { PriceRange } from "@cimplify/sdk/react";\nimport { Price } from "@cimplify/sdk/react";\nimport { VolumePricing } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport function WholesaleProductLayout({\n product,\n onAddToCart,\n renderBreadcrumb,\n relatedProducts,\n showRelated = true,\n className,\n}: ProductLayoutProps): React.ReactElement {\n const images = product.images || (product.image_url ? [product.image_url] : []);\n const hasTiers = product.quantity_pricing && product.quantity_pricing.length > 1;\n\n return (\n <div data-cimplify-product-layout="wholesale" className={cn("space-y-8", className)}>\n {renderBreadcrumb?.(product)}\n\n <TwoColumnGrid\n left={\n <ProductImageGallery\n images={images}\n productName={product.name}\n />\n }\n right={\n <div className="space-y-6">\n {/* Tags */}\n <TagPills tags={product.tags || []} />\n\n {/* SKU + Name */}\n <div>\n {product.sku && (\n <p className="text-xs text-muted-foreground font-medium uppercase tracking-wider mb-1">\n SKU: {product.sku}\n </p>\n )}\n <h1 data-cimplify-product-layout-name className="text-3xl lg:text-4xl font-extrabold tracking-tight">\n {product.name}\n </h1>\n </div>\n\n {/* Price range */}\n <div>\n {hasTiers ? (\n <PriceRange product={product} className="text-2xl font-extrabold" />\n ) : (\n <Price amount={product.default_price} className="text-2xl font-semibold" />\n )}\n </div>\n\n {/* Inventory */}\n <InventoryBadge product={product} />\n\n {/* Min order notice */}\n {product.min_order_quantity != null && product.min_order_quantity > 1 && (\n <div data-cimplify-product-layout-moq className="flex items-center gap-3 p-3 bg-amber-50 border border-amber-200 text-sm text-amber-800">\n Minimum order quantity: <strong>{product.min_order_quantity} units</strong>\n </div>\n )}\n\n {/* Description */}\n {product.description && (\n <p data-cimplify-product-layout-description className="text-muted-foreground leading-relaxed">\n {product.description}\n </p>\n )}\n\n {/* Volume pricing \u2014 open by default for wholesale */}\n {hasTiers && (\n <VolumePricing\n tiers={product.quantity_pricing!}\n defaultOpen\n />\n )}\n\n {/* Product details */}\n {(product.sku || product.barcode || product.vendor) && (\n <div data-cimplify-product-layout-details className="space-y-1 text-sm text-muted-foreground">\n {product.barcode && <p>Barcode: <span className="font-mono text-foreground">{product.barcode}</span></p>}\n {product.vendor && <p>Supplier: <span className="text-foreground">{product.vendor}</span></p>}\n {product.hs_code && <p>HS Code: <span className="font-mono text-foreground">{product.hs_code}</span></p>}\n </div>\n )}\n\n {/* Custom Attributes */}\n <CustomAttributesTable attributes={product.custom_attributes || []} />\n\n {/* Properties */}\n <PropertiesTable properties={product.properties || []} />\n\n {/* Customizer (variants, billing plans, quantity, add to cart) */}\n <ProductCustomizer\n product={product}\n onAddToCart={onAddToCart}\n />\n </div>\n }\n />\n\n {showRelated && relatedProducts && relatedProducts.length > 0 && (\n <RelatedProductsSection\n products={relatedProducts}\n title="Related products"\n />\n )}\n </div>\n );\n}\n' }] }, "category-filter": { "name": "category-filter", "title": "CategoryFilter", "description": "Selectable category chips for filtering products.", "type": "component", "registryDependencies": ["cn"], "files": [{ "path": "category-filter.tsx", "content": '"use client";\n\nimport React, { useCallback } from "react";\nimport { Tabs } from "@base-ui/react/tabs";\nimport type { Category } from "@cimplify/sdk";\nimport { useCategories } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface CategoryFilterClassNames {\n root?: string;\n item?: string;\n allButton?: string;\n count?: string;\n}\n\nexport interface CategoryFilterProps {\n /** Currently selected category ID. Null means "all". */\n selectedId?: string | null;\n /** Called when a category is selected. Null means "all". */\n onSelect: (categoryId: string | null) => void;\n /** Label for the "all" option. Default: "All". */\n allLabel?: string;\n /** Show product counts per category. Default: true. */\n showCounts?: boolean;\n className?: string;\n classNames?: CategoryFilterClassNames;\n}\n\n/** Sentinel value representing the "all" tab (no category filter). */\nconst ALL_VALUE = "__all__";\n\n/**\n * CategoryFilter \u2014 horizontal or vertical list of category chips.\n *\n * Fetches categories via `useCategories` and renders selectable buttons.\n * The parent controls selection state via `selectedId` + `onSelect`.\n *\n * Built on Base UI Tabs for accessible keyboard navigation and ARIA roles.\n */\nexport function CategoryFilter({\n selectedId = null,\n onSelect,\n allLabel = "All",\n showCounts = true,\n className,\n classNames,\n}: CategoryFilterProps): React.ReactElement {\n const { categories, isLoading } = useCategories();\n\n const handleValueChange = useCallback(\n (value: string | number | null) => {\n onSelect(value === ALL_VALUE ? null : String(value));\n },\n [onSelect],\n );\n\n if (isLoading) {\n return (\n <div\n data-cimplify-category-filter\n aria-busy="true"\n className={cn(className, classNames?.root)}\n />\n );\n }\n\n return (\n <Tabs.Root\n value={selectedId ?? ALL_VALUE}\n onValueChange={handleValueChange}\n >\n <Tabs.List\n data-cimplify-category-filter\n aria-label="Filter by category"\n className={cn(className, classNames?.root)}\n >\n <Tabs.Tab\n value={ALL_VALUE}\n data-cimplify-category-filter-item\n data-selected={selectedId === null || undefined}\n className={cn(classNames?.item, classNames?.allButton)}\n >\n {allLabel}\n </Tabs.Tab>\n\n {categories.map((category: Category) => (\n <Tabs.Tab\n key={category.id}\n value={category.id}\n data-cimplify-category-filter-item\n data-selected={selectedId === category.id || undefined}\n className={classNames?.item}\n >\n {category.name}\n {showCounts && category.product_count != null && (\n <span data-cimplify-category-count className={classNames?.count}>\n {category.product_count}\n </span>\n )}\n </Tabs.Tab>\n ))}\n </Tabs.List>\n </Tabs.Root>\n );\n}\n' }] }, "add-on-selector": { "name": "add-on-selector", "title": "AddOnSelector", "description": "Modifier groups with single-select or multi-select options.", "type": "component", "registryDependencies": ["price"], "files": [{ "path": "add-on-selector.tsx", "content": '"use client";\n\nimport React, { useCallback, useMemo } from "react";\nimport { Checkbox } from "@base-ui/react/checkbox";\nimport type { AddOnWithOptions } from "@cimplify/sdk";\nimport { Price } from "@cimplify/sdk/react";\nimport { parsePrice } from "@cimplify/sdk";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface AddOnSelectorClassNames {\n root?: string;\n group?: string;\n header?: string;\n name?: string;\n required?: string;\n constraint?: string;\n validation?: string;\n options?: string;\n option?: string;\n optionSelected?: string;\n optionName?: string;\n optionDescription?: string;\n}\n\nexport interface AddOnSelectorProps {\n addOns: AddOnWithOptions[];\n selectedOptions: string[];\n onOptionsChange: (optionIds: string[]) => void;\n className?: string;\n classNames?: AddOnSelectorClassNames;\n}\n\nexport function AddOnSelector({\n addOns,\n selectedOptions,\n onOptionsChange,\n className,\n classNames,\n}: AddOnSelectorProps): React.ReactElement | null {\n const selectedSet = useMemo(() => new Set(selectedOptions), [selectedOptions]);\n\n const isOptionSelected = useCallback(\n (optionId: string) => selectedSet.has(optionId),\n [selectedSet],\n );\n\n const handleCheckedChange = useCallback(\n (addOn: AddOnWithOptions, optionId: string, checked: boolean) => {\n const isSelected = selectedSet.has(optionId);\n\n if (addOn.is_mutually_exclusive || !addOn.is_multiple_allowed) {\n const groupOptionIds = new Set(addOn.options.map((o) => o.id));\n const withoutGroup = selectedOptions.filter((id) => !groupOptionIds.has(id));\n\n if (isSelected) {\n if (!addOn.is_required) {\n onOptionsChange(withoutGroup);\n }\n } else {\n onOptionsChange([...withoutGroup, optionId]);\n }\n } else {\n if (isSelected) {\n onOptionsChange(selectedOptions.filter((id) => id !== optionId));\n } else {\n let currentCount = 0;\n for (const option of addOn.options) {\n if (selectedSet.has(option.id)) {\n currentCount += 1;\n }\n }\n\n if (addOn.max_selections && currentCount >= addOn.max_selections) {\n return;\n }\n\n onOptionsChange([...selectedOptions, optionId]);\n }\n }\n },\n [selectedOptions, selectedSet, onOptionsChange],\n );\n\n if (!addOns || addOns.length === 0) {\n return null;\n }\n\n return (\n <div data-cimplify-addon-selector className={cn("space-y-6", className, classNames?.root)}>\n {addOns.map((addOn) => {\n let currentSelections = 0;\n for (const option of addOn.options) {\n if (selectedSet.has(option.id)) {\n currentSelections += 1;\n }\n }\n const minMet = !addOn.min_selections || currentSelections >= addOn.min_selections;\n const isSingleSelect = addOn.is_mutually_exclusive || !addOn.is_multiple_allowed;\n\n return (\n <div\n key={addOn.id}\n data-cimplify-addon-group\n className={cn(classNames?.group)}\n >\n <div\n data-cimplify-addon-header\n className={cn("flex items-center justify-between py-3", classNames?.header)}\n >\n <div>\n <span\n data-cimplify-addon-name\n className={cn("text-base font-bold", classNames?.name)}\n >\n {addOn.name}\n </span>\n {(addOn.min_selections || addOn.max_selections) && (\n <span\n data-cimplify-addon-constraint\n className={cn("block text-xs text-muted-foreground mt-0.5", classNames?.constraint)}\n >\n {addOn.min_selections && addOn.max_selections\n ? `Choose ${addOn.min_selections}\\u2013${addOn.max_selections}`\n : addOn.min_selections\n ? `Choose at least ${addOn.min_selections}`\n : `Choose up to ${addOn.max_selections}`}\n </span>\n )}\n </div>\n {(addOn.is_required || !minMet) && (\n <span\n data-cimplify-addon-required\n className={cn(\n "text-xs font-semibold px-2.5 py-1 rounded",\n !minMet\n ? "text-destructive bg-destructive/10"\n : "text-destructive bg-destructive/10",\n classNames?.required,\n )}\n >\n Required\n </span>\n )}\n </div>\n\n <div\n data-cimplify-addon-options\n className={cn("divide-y divide-border", classNames?.options)}\n >\n {addOn.options.map((option) => {\n const isSelected = isOptionSelected(option.id);\n\n return (\n <Checkbox.Root\n key={option.id}\n checked={isSelected}\n onCheckedChange={(checked) =>\n handleCheckedChange(addOn, option.id, checked)\n }\n value={option.id}\n data-cimplify-addon-option\n data-selected={isSelected || undefined}\n className={cn(\n "w-full flex items-center gap-3 py-4 transition-colors text-left cursor-pointer",\n isSelected ? classNames?.optionSelected : classNames?.option,\n )}\n >\n <Checkbox.Indicator\n className="hidden"\n keepMounted={false}\n />\n\n {/* Visual indicator: radio circle for single-select, checkbox square for multi-select */}\n {isSingleSelect ? (\n <span\n data-cimplify-addon-radio\n className={cn(\n "w-5 h-5 rounded-full border-2 flex items-center justify-center shrink-0 transition-colors",\n isSelected ? "border-primary" : "border-muted-foreground/30",\n )}\n >\n {isSelected && <span className="w-2.5 h-2.5 rounded-full bg-primary" />}\n </span>\n ) : (\n <span\n data-cimplify-addon-checkbox\n className={cn(\n "w-5 h-5 rounded-sm border-2 flex items-center justify-center shrink-0 transition-colors",\n isSelected ? "border-primary bg-primary" : "border-muted-foreground/30",\n )}\n >\n {isSelected && (\n <svg viewBox="0 0 12 12" className="w-3 h-3 text-primary-foreground" fill="none">\n <path d="M2 6l3 3 5-5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>\n </svg>\n )}\n </span>\n )}\n\n <span\n data-cimplify-addon-option-name\n className={cn(\n "flex-1 min-w-0 text-sm",\n classNames?.optionName,\n )}\n >\n {option.name}\n </span>\n\n {option.default_price != null && (\n <span className="text-sm text-muted-foreground">\n +<Price amount={option.default_price} />\n </span>\n )}\n </Checkbox.Root>\n );\n })}\n </div>\n </div>\n );\n })}\n </div>\n );\n}\n' }] }, "cart-page": { "name": "cart-page", "title": "CartPage", "description": "Full-page cart with summary, discount input, and checkout.", "type": "component", "registryDependencies": ["cart-summary", "discount-input", "cn"], "files": [{ "path": "cart-page.tsx", "content": '"use client";\n\nimport React from "react";\nimport { useCart } from "@cimplify/sdk/react";\nimport type { DiscountValidation } from "@cimplify/sdk";\nimport type { CartLayoutProps } from "./cart-layouts/shared";\nimport { DefaultCartLayout } from "./cart-layouts/default-cart-layout";\nimport { CompactCartLayout } from "./cart-layouts/compact-cart-layout";\nimport { cn } from "@cimplify/sdk/react";\n\nexport type { CartLayoutProps };\n\nexport enum CartTemplate {\n Default = "default",\n Compact = "compact",\n}\n\nexport interface CartPageClassNames {\n root?: string;\n}\n\nexport interface CartPageProps {\n /** Explicit template. */\n template?: CartTemplate;\n /** Layout map for overrides. */\n layouts?: Partial<Record<CartTemplate | string, React.ComponentType<CartLayoutProps>>>;\n /** Page title. */\n title?: string;\n /** Called when checkout is initiated. */\n onCheckout?: () => void;\n /** Called when "continue shopping" is clicked. */\n onContinueShopping?: () => void;\n /** Called when a valid discount is applied. */\n onDiscountApply?: (validation: DiscountValidation) => void;\n /** Called when discount is removed. */\n onDiscountClear?: () => void;\n /** Show discount code input. */\n showDiscount?: boolean;\n /** Checkout button text. */\n checkoutLabel?: string;\n className?: string;\n classNames?: CartPageClassNames;\n}\n\nconst BUILT_IN_LAYOUTS: Record<string, React.ComponentType<CartLayoutProps>> = {\n [CartTemplate.Default]: DefaultCartLayout,\n [CartTemplate.Compact]: CompactCartLayout,\n};\n\nexport function CartPage({\n template,\n layouts,\n onCheckout,\n onContinueShopping,\n onDiscountApply,\n onDiscountClear,\n showDiscount = true,\n checkoutLabel,\n className,\n classNames,\n}: CartPageProps): React.ReactElement {\n const cart = useCart();\n\n const key = template ?? CartTemplate.Default;\n const Layout = layouts?.[key] ?? BUILT_IN_LAYOUTS[key] ?? DefaultCartLayout;\n\n return (\n <div data-cimplify-cart-page className={cn(className, classNames?.root)}>\n <Layout\n cart={cart}\n onCheckout={onCheckout}\n onContinueShopping={onContinueShopping}\n onDiscountApply={onDiscountApply}\n onDiscountClear={onDiscountClear}\n showDiscount={showDiscount}\n checkoutLabel={checkoutLabel}\n />\n </div>\n );\n}\n' }] }, "chat-widget": { "name": "chat-widget", "title": "ChatWidget", "description": "Embeddable chat widget with AI shopping assistant powered by the support channel.", "type": "component", "registryDependencies": ["cn"], "files": [{ "path": "chat-widget.tsx", "content": '"use client";\n\nimport React, {\n useState,\n useRef,\n useEffect,\n useCallback,\n type KeyboardEvent,\n} from "react";\nimport type { CimplifyClient } from "../client";\nimport type { CimplifyError } from "@cimplify/sdk";\nimport type { ChatMessage, ChatWidgetStarter } from "../types/support";\nimport { useChat } from "./hooks/use-chat";\nimport { useOptionalCimplifyClient } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\n/* \u2500\u2500\u2500 Public API \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\nexport interface ChatWidgetClassNames {\n root?: string;\n bubble?: string;\n panel?: string;\n header?: string;\n messages?: string;\n input?: string;\n welcome?: string;\n}\n\nexport interface ChatWidgetProps {\n client?: CimplifyClient;\n /** Business display name shown in the header */\n businessName?: string;\n /** Greeting text on the welcome screen */\n greeting?: string;\n /** Subtitle on the welcome screen */\n subtitle?: string;\n /** Conversation starters */\n starters?: ChatWidgetStarter[];\n /** Input placeholder */\n placeholder?: string;\n /** Position of the bubble */\n position?: "bottom-right" | "bottom-left";\n /** Start with the panel open */\n defaultOpen?: boolean;\n /** Poll interval in ms (default: 3000) */\n pollInterval?: number;\n className?: string;\n classNames?: ChatWidgetClassNames;\n}\n\nexport function ChatWidget({\n client: clientProp,\n businessName = "Support",\n greeting = "Hi there!",\n subtitle = "Ask us anything \u2014 we typically reply in a few seconds.",\n starters,\n placeholder = "Type a message\\u2026",\n position = "bottom-right",\n defaultOpen = false,\n pollInterval,\n className,\n classNames,\n}: ChatWidgetProps) {\n const context = useOptionalCimplifyClient();\n const client = clientProp ?? context?.client;\n\n const [open, setOpen] = useState(defaultOpen);\n const [unread, setUnread] = useState(0);\n\n const { messages, isLoading, isSending, error, isActive, send, startConversation } =\n useChat({ client, starters, pollInterval });\n\n const prevCountRef = useRef(messages.length);\n\n // Track unread when panel is closed\n useEffect(() => {\n if (!open && messages.length > prevCountRef.current) {\n const newMessages = messages.slice(prevCountRef.current);\n const incomingCount = newMessages.filter(\n (m) => m.sender_type !== "customer",\n ).length;\n if (incomingCount > 0) setUnread((n) => n + incomingCount);\n }\n prevCountRef.current = messages.length;\n }, [messages, open]);\n\n const handleOpen = useCallback(() => {\n setOpen(true);\n setUnread(0);\n }, []);\n\n const handleClose = useCallback(() => setOpen(false), []);\n\n const isLeft = position === "bottom-left";\n\n return (\n <div\n className={cn(\n "fixed bottom-6 z-[9999]",\n isLeft ? "left-6" : "right-6",\n className,\n classNames?.root,\n )}\n >\n {/* Bubble */}\n {!open && (\n <Bubble\n unread={unread}\n onClick={handleOpen}\n className={classNames?.bubble}\n />\n )}\n\n {/* Panel */}\n {open && (\n <Panel\n businessName={businessName}\n greeting={greeting}\n subtitle={subtitle}\n starters={starters}\n placeholder={placeholder}\n messages={messages}\n isLoading={isLoading}\n isSending={isSending}\n error={error}\n isActive={isActive}\n isLeft={isLeft}\n onClose={handleClose}\n onSend={send}\n onStartConversation={startConversation}\n classNames={classNames}\n />\n )}\n </div>\n );\n}\n\n/* \u2500\u2500\u2500 Bubble \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\nfunction Bubble({\n unread,\n onClick,\n className,\n}: {\n unread: number;\n onClick: () => void;\n className?: string;\n}) {\n return (\n <button\n type="button"\n onClick={onClick}\n aria-label="Open chat"\n className={cn(\n "relative flex h-14 w-14 items-center justify-center rounded-full",\n "bg-foreground text-background shadow-lg",\n "transition-transform duration-300 ease-out",\n "hover:scale-[1.08] active:scale-95",\n className,\n )}\n >\n <ChatIcon className="h-6 w-6" />\n {unread > 0 && (\n <span\n className={cn(\n "absolute -right-0.5 -top-0.5 flex h-5 w-5 items-center justify-center",\n "rounded-full bg-red-500 text-[10px] font-bold text-white",\n "ring-2 ring-background",\n )}\n >\n {unread > 9 ? "9+" : unread}\n </span>\n )}\n </button>\n );\n}\n\n/* \u2500\u2500\u2500 Panel \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\nfunction Panel({\n businessName,\n greeting,\n subtitle,\n starters,\n placeholder,\n messages,\n isLoading,\n isSending,\n error,\n isActive,\n isLeft,\n onClose,\n onSend,\n onStartConversation,\n classNames,\n}: {\n businessName: string;\n greeting: string;\n subtitle: string;\n starters?: ChatWidgetStarter[];\n placeholder: string;\n messages: ChatMessage[];\n isLoading: boolean;\n isSending: boolean;\n error: CimplifyError | null;\n isActive: boolean;\n isLeft: boolean;\n onClose: () => void;\n onSend: (content: string) => Promise<void>;\n onStartConversation: (text?: string) => Promise<void>;\n classNames?: ChatWidgetClassNames;\n}) {\n return (\n <div\n className={cn(\n "flex flex-col overflow-hidden rounded-2xl bg-background shadow-2xl",\n "w-[400px] max-w-[calc(100vw-3rem)]",\n "h-[min(600px,calc(100vh-6rem))]",\n "animate-in fade-in slide-in-from-bottom-4 duration-300",\n isLeft ? "origin-bottom-left" : "origin-bottom-right",\n classNames?.panel,\n )}\n >\n {/* Header */}\n <Header\n businessName={businessName}\n onClose={onClose}\n className={classNames?.header}\n />\n\n {/* Body */}\n {!isActive && !isLoading ? (\n <Welcome\n greeting={greeting}\n subtitle={subtitle}\n starters={starters}\n onStarter={onStartConversation}\n className={classNames?.welcome}\n />\n ) : (\n <MessageList\n messages={messages}\n isLoading={isLoading}\n isSending={isSending}\n className={classNames?.messages}\n />\n )}\n\n {/* Error */}\n {error != null && (\n <div className="border-t border-red-200 bg-red-50 px-4 py-2 text-xs text-red-600">\n Something went wrong. Please try again.\n </div>\n )}\n\n {/* Input \u2014 shown once conversation is active or loading */}\n {(isActive || isLoading) && (\n <ChatInput\n placeholder={placeholder}\n isSending={isSending}\n onSend={onSend}\n className={classNames?.input}\n />\n )}\n\n {/* Footer */}\n <div className="flex-shrink-0 border-t border-border bg-background px-4 py-1.5 text-center text-[10px] text-muted-foreground">\n Powered by{" "}\n <span className="font-semibold text-foreground">Cimplify</span>\n </div>\n </div>\n );\n}\n\n/* \u2500\u2500\u2500 Header \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\nfunction Header({\n businessName,\n onClose,\n className,\n}: {\n businessName: string;\n onClose: () => void;\n className?: string;\n}) {\n return (\n <div\n className={cn(\n "flex flex-shrink-0 items-center gap-3 bg-foreground px-5 py-4 text-background",\n className,\n )}\n >\n <div className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-[10px] bg-white/15">\n <ChatIcon className="h-5 w-5" />\n </div>\n <div className="min-w-0 flex-1">\n <div className="truncate text-[15px] font-semibold leading-tight">\n {businessName}\n </div>\n <div className="flex items-center gap-1.5 text-xs text-white/60">\n <span className="h-[7px] w-[7px] flex-shrink-0 rounded-full bg-emerald-400" />\n Online\n </div>\n </div>\n <button\n type="button"\n onClick={onClose}\n aria-label="Close chat"\n className={cn(\n "flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg",\n "bg-white/10 text-white/70 transition-colors hover:bg-white/20 hover:text-white",\n )}\n >\n <CloseIcon className="h-[18px] w-[18px]" />\n </button>\n </div>\n );\n}\n\n/* \u2500\u2500\u2500 Welcome \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\nfunction Welcome({\n greeting,\n subtitle,\n starters,\n onStarter,\n className,\n}: {\n greeting: string;\n subtitle: string;\n starters?: ChatWidgetStarter[];\n onStarter: (text?: string) => Promise<void>;\n className?: string;\n}) {\n const defaultStarters: ChatWidgetStarter[] = [\n { icon: "\\ud83d\\udd0d", text: "Help me find a product" },\n { icon: "\\ud83d\\uded2", text: "Check my cart" },\n { icon: "\\ud83d\\udce6", text: "Track my order" },\n ];\n\n const items = starters ?? defaultStarters;\n\n return (\n <div\n className={cn(\n "flex flex-1 flex-col items-center justify-center overflow-y-auto bg-muted/50 px-8 py-10 text-center",\n className,\n )}\n >\n <div className="mb-5 flex h-16 w-16 items-center justify-center rounded-2xl bg-muted">\n <ChatIcon className="h-7 w-7 text-muted-foreground" />\n </div>\n <h3 className="mb-2 text-xl font-bold text-foreground">{greeting}</h3>\n <p className="mb-6 max-w-[280px] text-sm leading-relaxed text-muted-foreground">\n {subtitle}\n </p>\n <div className="flex w-full max-w-[300px] flex-col gap-2">\n {items.map((s) => (\n <button\n key={s.text}\n type="button"\n onClick={() => onStarter(s.text)}\n className={cn(\n "flex items-center gap-3 rounded-xl border border-border bg-background px-4 py-3",\n "text-left text-[13px] font-medium text-foreground",\n "transition-all hover:-translate-y-px hover:border-foreground hover:shadow-sm",\n )}\n >\n {s.icon && (\n <span className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-muted text-[15px]">\n {s.icon}\n </span>\n )}\n <span>{s.text}</span>\n </button>\n ))}\n </div>\n </div>\n );\n}\n\n/* \u2500\u2500\u2500 Messages \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\nfunction MessageList({\n messages,\n isLoading,\n isSending,\n className,\n}: {\n messages: ChatMessage[];\n isLoading: boolean;\n isSending: boolean;\n className?: string;\n}) {\n const bottomRef = useRef<HTMLDivElement>(null);\n\n useEffect(() => {\n bottomRef.current?.scrollIntoView({ behavior: "smooth" });\n }, [messages.length, isSending]);\n\n return (\n <div\n className={cn(\n "flex flex-1 flex-col gap-1 overflow-y-auto bg-muted/50 px-5 py-5",\n className,\n )}\n >\n {isLoading && messages.length === 0 && (\n <div className="flex flex-1 items-center justify-center">\n <TypingDots />\n </div>\n )}\n\n {messages.map((msg, i) => {\n const isCustomer = msg.sender_type === "customer";\n const prev = messages[i - 1];\n const sameSenderAsPrev = prev?.sender_type === msg.sender_type;\n const isOptimistic = msg.id.startsWith("opt_");\n\n return (\n <div key={msg.id}>\n {!sameSenderAsPrev && i > 0 && <div className="h-3" />}\n <div\n className={cn(\n "flex max-w-[85%]",\n isCustomer ? "ml-auto" : "mr-auto",\n )}\n >\n <div\n className={cn(\n "rounded-2xl px-3.5 py-2.5 text-sm leading-relaxed",\n isCustomer\n ? "bg-foreground text-background"\n : "border border-border bg-background text-foreground",\n isCustomer\n ? sameSenderAsPrev\n ? "rounded-tr"\n : "rounded-br"\n : sameSenderAsPrev\n ? "rounded-tl"\n : "rounded-bl",\n isOptimistic && "opacity-70",\n )}\n >\n {!isCustomer && !sameSenderAsPrev && (\n <span className="mb-1 inline-flex items-center gap-1 rounded bg-violet-100 px-1.5 py-0.5 text-[10px] font-semibold text-violet-600">\n <SparkleIcon className="h-[11px] w-[11px]" />\n AI\n </span>\n )}\n <div className="whitespace-pre-wrap">{msg.content}</div>\n </div>\n </div>\n </div>\n );\n })}\n\n {isSending && (\n <div className="mr-auto mt-1">\n <TypingDots />\n </div>\n )}\n\n <div ref={bottomRef} />\n </div>\n );\n}\n\n/* \u2500\u2500\u2500 Input \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\nfunction ChatInput({\n placeholder,\n isSending,\n onSend,\n className,\n}: {\n placeholder: string;\n isSending: boolean;\n onSend: (content: string) => Promise<void>;\n className?: string;\n}) {\n const [value, setValue] = useState("");\n const textareaRef = useRef<HTMLTextAreaElement>(null);\n\n const handleSend = useCallback(() => {\n const trimmed = value.trim();\n if (!trimmed || isSending) return;\n setValue("");\n if (textareaRef.current) {\n textareaRef.current.style.height = "auto";\n }\n onSend(trimmed);\n }, [value, isSending, onSend]);\n\n const handleKeyDown = useCallback(\n (e: KeyboardEvent<HTMLTextAreaElement>) => {\n if (e.key === "Enter" && !e.shiftKey) {\n e.preventDefault();\n handleSend();\n }\n },\n [handleSend],\n );\n\n const handleInput = useCallback(() => {\n const el = textareaRef.current;\n if (!el) return;\n el.style.height = "auto";\n el.style.height = `${Math.min(el.scrollHeight, 100)}px`;\n }, []);\n\n const hasContent = value.trim().length > 0;\n\n return (\n <div\n className={cn(\n "flex flex-shrink-0 items-end gap-2 border-t border-border bg-background px-4 py-3",\n className,\n )}\n >\n <div\n className={cn(\n "flex flex-1 items-end rounded-xl border-[1.5px] border-transparent bg-muted px-1",\n "transition-colors focus-within:border-foreground focus-within:bg-background",\n )}\n >\n <textarea\n ref={textareaRef}\n value={value}\n onChange={(e) => setValue(e.target.value)}\n onKeyDown={handleKeyDown}\n onInput={handleInput}\n placeholder={placeholder}\n rows={1}\n className={cn(\n "max-h-[100px] min-h-[20px] flex-1 resize-none bg-transparent px-2.5 py-2",\n "text-sm text-foreground outline-none placeholder:text-muted-foreground",\n )}\n />\n </div>\n\n <button\n type="button"\n onClick={handleSend}\n disabled={!hasContent || isSending}\n aria-label="Send message"\n className={cn(\n "flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-[10px]",\n "transition-all duration-200",\n hasContent && !isSending\n ? "bg-foreground text-background hover:scale-105 active:scale-95"\n : "bg-muted text-muted-foreground",\n )}\n >\n <SendIcon className="h-[18px] w-[18px]" />\n </button>\n </div>\n );\n}\n\n/* \u2500\u2500\u2500 Typing dots \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\nfunction TypingDots() {\n return (\n <div className="flex items-center gap-1 px-4 py-3">\n {[0, 1, 2].map((i) => (\n <span\n key={i}\n className="h-[7px] w-[7px] animate-bounce rounded-full bg-muted-foreground/40"\n style={{ animationDelay: `${i * 160}ms` }}\n />\n ))}\n </div>\n );\n}\n\n/* \u2500\u2500\u2500 Icons (inline SVG \u2014 no icon library) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\nfunction ChatIcon({ className }: { className?: string }) {\n return (\n <svg\n className={className}\n viewBox="0 0 24 24"\n fill="none"\n stroke="currentColor"\n strokeWidth={2}\n strokeLinecap="round"\n strokeLinejoin="round"\n >\n <path d="M21 11.5a8.38 8.38 0 01-.9 3.8 8.5 8.5 0 01-7.6 4.7 8.38 8.38 0 01-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 01-.9-3.8 8.5 8.5 0 014.7-7.6 8.38 8.38 0 013.8-.9h.5a8.48 8.48 0 018 8v.5z" />\n </svg>\n );\n}\n\nfunction CloseIcon({ className }: { className?: string }) {\n return (\n <svg\n className={className}\n viewBox="0 0 24 24"\n fill="none"\n stroke="currentColor"\n strokeWidth={2}\n strokeLinecap="round"\n strokeLinejoin="round"\n >\n <path d="M18 6L6 18M6 6l12 12" />\n </svg>\n );\n}\n\nfunction SendIcon({ className }: { className?: string }) {\n return (\n <svg\n className={className}\n viewBox="0 0 24 24"\n fill="none"\n stroke="currentColor"\n strokeWidth={2}\n strokeLinecap="round"\n strokeLinejoin="round"\n >\n <line x1="22" y1="2" x2="11" y2="13" />\n <polygon points="22 2 15 22 11 13 2 9 22 2" />\n </svg>\n );\n}\n\nfunction SparkleIcon({ className }: { className?: string }) {\n return (\n <svg\n className={className}\n viewBox="0 0 24 24"\n fill="none"\n stroke="currentColor"\n strokeWidth={2.5}\n >\n <path d="M12 2v4m0 12v4M4.93 4.93l2.83 2.83m8.48 8.48l2.83 2.83M2 12h4m12 0h4M4.93 19.07l2.83-2.83m8.48-8.48l2.83-2.83" />\n </svg>\n );\n}\n' }] }, "product-customizer": { "name": "product-customizer", "title": "ProductCustomizer", "description": "Full product configuration with variants, add-ons, and add-to-cart.", "type": "component", "registryDependencies": ["price", "quantity-selector", "variant-selector", "add-on-selector", "composite-selector", "bundle-selector"], "files": [{ "path": "product-customizer.tsx", "content": '"use client";\n\nimport React, { useState, useCallback, useMemo, useEffect, useRef } from "react";\nimport { Button } from "@base-ui/react/button";\nimport type {\n ProductWithDetails,\n VariantView,\n AddOnOption,\n ComponentSelectionInput,\n CompositePriceResult,\n} from "@cimplify/sdk";\nimport type { ProductBillingPlan } from "@cimplify/sdk";\nimport type { BundleSelectionInput } from "@cimplify/sdk";\nimport { parsePrice, getUnitPriceAtQuantity } from "@cimplify/sdk";\nimport { formatDuration } from "./utils/format-duration";\nimport { useCart, useQuote } from "@cimplify/sdk/react";\nimport type { AddToCartOptions } from "@cimplify/sdk/react";\nimport { Price } from "@cimplify/sdk/react";\nimport { QuantitySelector } from "@cimplify/sdk/react";\nimport { VariantSelector } from "@cimplify/sdk/react";\nimport { AddOnSelector } from "@cimplify/sdk/react";\nimport { CompositeSelector } from "@cimplify/sdk/react";\nimport { BundleSelector } from "@cimplify/sdk/react";\nimport { BillingPlanSelector } from "@cimplify/sdk/react";\nimport { VolumePricing } from "@cimplify/sdk/react";\nimport { CustomerInputFields } from "./customer-input-fields";\nimport { DateSlotPicker } from "@cimplify/sdk/react";\nimport type { AvailableSlot } from "@cimplify/sdk";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface ProductCustomizerClassNames {\n root?: string;\n actions?: string;\n submitButton?: string;\n submitButtonAdded?: string;\n validation?: string;\n specialInstructions?: string;\n depositInfo?: string;\n allergens?: string;\n duration?: string;\n}\n\nexport interface ProductCustomizerProps {\n product: ProductWithDetails;\n onAddToCart?: (\n product: ProductWithDetails,\n quantity: number,\n options: AddToCartOptions,\n ) => void | Promise<void>;\n /** Lets the parent swap its gallery for `variant.images` on selection. */\n onVariantChange?: (\n variantId: string | undefined,\n variant: VariantView | undefined,\n ) => void;\n showSpecialInstructions?: boolean;\n className?: string;\n classNames?: ProductCustomizerClassNames;\n}\n\nexport function ProductCustomizer({\n product,\n onAddToCart,\n onVariantChange,\n showSpecialInstructions = true,\n className,\n classNames,\n}: ProductCustomizerProps): React.ReactElement {\n const [quantity, setQuantity] = useState(product.min_order_quantity ?? 1);\n const [isAdded, setIsAdded] = useState(false);\n const [isSubmitting, setIsSubmitting] = useState(false);\n const [selectedVariantId, setSelectedVariantId] = useState<string | undefined>();\n const [selectedVariant, setSelectedVariant] = useState<VariantView | undefined>();\n const [selectedAddOnOptionIds, setSelectedAddOnOptionIds] = useState<string[]>([]);\n\n const [compositeSelections, setCompositeSelections] = useState<ComponentSelectionInput[]>([]);\n const [compositePrice, setCompositePrice] = useState<CompositePriceResult | null>(null);\n const [compositeReady, setCompositeReady] = useState(false);\n\n const [bundleSelections, setBundleSelections] = useState<BundleSelectionInput[]>([]);\n const [bundleTotalPrice, setBundleTotalPrice] = useState<number | null>(null);\n const [bundleReady, setBundleReady] = useState(false);\n const [selectedBillingPlan, setSelectedBillingPlan] = useState<ProductBillingPlan | null>(null);\n const [customerInputValues, setCustomerInputValues] = useState<Record<string, unknown>>({});\n const [specialInstructions, setSpecialInstructions] = useState("");\n const [selectedSlot, setSelectedSlot] = useState<AvailableSlot | null>(null);\n\n const cart = useCart();\n\n const productType = product.type || "product";\n const isComposite = productType === "composite";\n const isBundle = productType === "bundle";\n const isDigital = productType === "digital";\n const isService = productType === "service";\n const isStandard = !isComposite && !isBundle;\n\n const hasVariants = isStandard && product.variants && product.variants.length > 0;\n const hasAddOns = isStandard && product.add_ons && product.add_ons.length > 0;\n\n useEffect(() => {\n setQuantity(product.min_order_quantity ?? 1);\n setIsAdded(false);\n setIsSubmitting(false);\n setSelectedVariantId(undefined);\n setSelectedVariant(undefined);\n setSelectedAddOnOptionIds([]);\n setCompositeSelections([]);\n setCompositePrice(null);\n setCompositeReady(false);\n setBundleSelections([]);\n setBundleTotalPrice(null);\n setBundleReady(false);\n setSelectedBillingPlan(null);\n setCustomerInputValues({});\n setSpecialInstructions("");\n setSelectedSlot(null);\n }, [product.id, product.min_order_quantity]);\n\n const selectedAddOnOptions = useMemo(() => {\n if (!product.add_ons) return [];\n const options: AddOnOption[] = [];\n for (const addOn of product.add_ons) {\n for (const option of addOn.options) {\n if (selectedAddOnOptionIds.includes(option.id)) {\n options.push(option);\n }\n }\n }\n return options;\n }, [product.add_ons, selectedAddOnOptionIds]);\n\n const normalizedAddOnOptionIds = useMemo(() => {\n if (selectedAddOnOptionIds.length === 0) return [];\n return Array.from(new Set(selectedAddOnOptionIds.map((id) => id.trim()).filter(Boolean))).sort();\n }, [selectedAddOnOptionIds]);\n\n const localTotalPrice = useMemo(() => {\n if (isComposite && compositePrice) {\n return parsePrice(compositePrice.final_price) * quantity;\n }\n\n if (isBundle && bundleTotalPrice != null) {\n return bundleTotalPrice * quantity;\n }\n\n let price = parsePrice(product.default_price);\n\n if (selectedVariant?.price_adjustment) {\n price += parsePrice(selectedVariant.price_adjustment);\n }\n\n for (const option of selectedAddOnOptions) {\n if (option.default_price) {\n price += parsePrice(option.default_price);\n }\n }\n\n return price * quantity;\n }, [product.default_price, selectedVariant, selectedAddOnOptions, quantity, isComposite, compositePrice, isBundle, bundleTotalPrice]);\n\n const requiredAddOnsSatisfied = useMemo(() => {\n if (!product.add_ons) return true;\n\n for (const addOn of product.add_ons) {\n if (addOn.is_required) {\n const selectedInGroup = selectedAddOnOptionIds.filter((id) =>\n addOn.options.some((opt) => opt.id === id),\n ).length;\n\n const minRequired = addOn.min_selections || 1;\n if (selectedInGroup < minRequired) {\n return false;\n }\n }\n }\n return true;\n }, [product.add_ons, selectedAddOnOptionIds]);\n\n const quoteInput = useMemo(\n () => ({\n productId: product.id,\n quantity,\n variantId: selectedVariantId,\n addOnOptionIds: normalizedAddOnOptionIds.length > 0 ? normalizedAddOnOptionIds : undefined,\n bundleSelections: isBundle && bundleSelections.length > 0 ? bundleSelections : undefined,\n compositeSelections: isComposite && compositeSelections.length > 0 ? compositeSelections : undefined,\n }),\n [product.id, quantity, selectedVariantId, normalizedAddOnOptionIds, isBundle, bundleSelections, isComposite, compositeSelections],\n );\n\n const requiredInputsSatisfied = useMemo(() => {\n if (!product.input_fields || product.input_fields.length === 0) return true;\n return product.input_fields.every((field) => {\n if (!field.is_required) return true;\n const val = customerInputValues[field.id];\n return val !== undefined && val !== "" && val !== null;\n });\n }, [product.input_fields, customerInputValues]);\n\n const quoteEnabled = isComposite\n ? compositeReady && requiredInputsSatisfied\n : isBundle\n ? bundleReady && requiredInputsSatisfied\n : requiredAddOnsSatisfied && requiredInputsSatisfied;\n\n const { quote } = useQuote(quoteInput, {\n enabled: quoteEnabled,\n });\n\n const quoteId = quote?.quote_id;\n const quotedTotalPrice = useMemo(() => {\n if (!quote) return undefined;\n\n const priceInfo = quote.quoted_total_price_info ?? quote.final_price_info;\n const perUnit = priceInfo.pre_tax_price;\n if (perUnit === undefined || perUnit === null) return undefined;\n return parsePrice(perUnit) * quantity;\n }, [quote, quantity]);\n\n const displayTotalPrice = quotedTotalPrice ?? localTotalPrice;\n\n const handleVariantChange = useCallback(\n (variantId: string | undefined, variant: VariantView | undefined) => {\n setSelectedVariantId(variantId);\n setSelectedVariant(variant);\n onVariantChange?.(variantId, variant);\n },\n [onVariantChange],\n );\n\n const addedTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);\n useEffect(() => () => clearTimeout(addedTimerRef.current), []);\n\n const handleAddToCart = useCallback(async () => {\n if (isSubmitting) return;\n\n setIsSubmitting(true);\n\n const options: AddToCartOptions = {\n variantId: selectedVariantId,\n variant: selectedVariant\n ? { id: selectedVariant.id, name: selectedVariant.name || "", price_adjustment: selectedVariant.price_adjustment }\n : undefined,\n quoteId,\n addOnOptionIds: normalizedAddOnOptionIds.length > 0 ? normalizedAddOnOptionIds : undefined,\n addOnOptions: selectedAddOnOptions.length > 0\n ? selectedAddOnOptions.map((opt) => ({\n id: opt.id,\n name: opt.name,\n add_on_id: opt.add_on_id,\n default_price: opt.default_price,\n }))\n : undefined,\n compositeSelections: isComposite && compositeSelections.length > 0 ? compositeSelections : undefined,\n bundleSelections: isBundle && bundleSelections.length > 0 ? bundleSelections : undefined,\n billingPlanId: selectedBillingPlan?.id,\n ...(isService && selectedSlot\n ? {\n scheduledStart: selectedSlot.start_time,\n scheduledEnd: selectedSlot.end_time,\n staffId: selectedSlot.available_staff?.[0]?.staff_id,\n resourceId: selectedSlot.available_resources?.[0]?.resource_id,\n }\n : {}),\n customerInputs: Object.keys(customerInputValues).length > 0\n ? Object.entries(customerInputValues)\n .filter(([, v]) => v !== undefined && v !== "")\n .map(([fieldId, value]) => ({ field_id: fieldId, value }))\n : undefined,\n specialInstructions: specialInstructions.trim() || undefined,\n };\n\n try {\n if (onAddToCart) {\n await onAddToCart(product, quantity, options);\n } else {\n await cart.addItem(product, quantity, options);\n }\n setIsAdded(true);\n clearTimeout(addedTimerRef.current);\n addedTimerRef.current = setTimeout(() => {\n setIsAdded(false);\n setQuantity(product.min_order_quantity ?? 1);\n }, 2000);\n } catch {\n // Caller handles errors via onAddToCart or cart hook error state\n } finally {\n setIsSubmitting(false);\n }\n }, [product, quantity, selectedVariantId, selectedVariant, quoteId, normalizedAddOnOptionIds, selectedAddOnOptions, isComposite, compositeSelections, isBundle, bundleSelections, selectedBillingPlan, customerInputValues, specialInstructions, isService, selectedSlot, isSubmitting, onAddToCart, cart]);\n\n return (\n <div data-cimplify-customizer className={cn("space-y-6", className, classNames?.root)}>\n {isComposite && product.groups && product.composite_id && (\n <CompositeSelector\n compositeId={product.composite_id}\n groups={product.groups}\n onSelectionsChange={setCompositeSelections}\n onPriceChange={setCompositePrice}\n onReady={setCompositeReady}\n skipPriceFetch\n />\n )}\n\n {isBundle && product.components && (\n <BundleSelector\n components={product.components}\n bundlePrice={product.bundle_price}\n discountValue={product.discount_value}\n pricingType={product.pricing_type}\n onSelectionsChange={setBundleSelections}\n onPriceChange={setBundleTotalPrice}\n onReady={setBundleReady}\n />\n )}\n\n {isService && (\n <DateSlotPicker\n serviceId={product.id}\n selectedSlot={selectedSlot}\n onSlotSelect={(slot) => setSelectedSlot(slot)}\n participantCount={quantity}\n />\n )}\n\n {hasVariants && (\n <VariantSelector\n variants={product.variants!}\n variantAxes={product.variant_axes}\n basePrice={product.default_price}\n selectedVariantId={selectedVariantId}\n onVariantChange={handleVariantChange}\n productName={product.name}\n />\n )}\n\n {hasAddOns && (\n <AddOnSelector\n addOns={product.add_ons!}\n selectedOptions={selectedAddOnOptionIds}\n onOptionsChange={setSelectedAddOnOptionIds}\n />\n )}\n\n {/* Billing plans */}\n {product.billing_plans && product.billing_plans.length > 0 && (\n <BillingPlanSelector\n productId={product.id}\n plans={product.billing_plans}\n onPlanSelect={setSelectedBillingPlan}\n selectedPlanId={selectedBillingPlan?.id ?? null}\n showOneTimePurchase\n />\n )}\n\n {/* Volume pricing tiers */}\n {product.quantity_pricing && product.quantity_pricing.length > 1 && (\n <VolumePricing\n tiers={product.quantity_pricing}\n currentQuantity={quantity}\n />\n )}\n\n {/* Customer input fields */}\n {product.input_fields && product.input_fields.length > 0 && (\n <CustomerInputFields\n fields={product.input_fields}\n values={customerInputValues}\n onChange={setCustomerInputValues}\n />\n )}\n\n {/* Deposit info for service products */}\n {product.deposit_type && product.deposit_type !== "none" && product.deposit_amount && (\n <div data-cimplify-customizer-deposit className={cn("text-sm text-muted-foreground", classNames?.depositInfo)}>\n {product.deposit_type === "fixed" ? (\n <span>Deposit required: <Price amount={product.deposit_amount} /></span>\n ) : (\n <span>Deposit required: {parsePrice(product.deposit_amount)}%</span>\n )}\n </div>\n )}\n\n {/* Allergens */}\n {product.allergies && product.allergies.length > 0 && (\n <div data-cimplify-customizer-allergens className={cn("flex flex-wrap gap-1.5", classNames?.allergens)}>\n {product.allergies.map((allergen) => (\n <span\n key={allergen}\n data-cimplify-allergen-tag\n className="inline-block text-xs px-2 py-0.5 rounded-full bg-muted text-muted-foreground"\n >\n {allergen}\n </span>\n ))}\n </div>\n )}\n\n {/* Service duration */}\n {isService && product.duration_minutes != null && (\n <div data-cimplify-customizer-duration className={cn("text-sm text-muted-foreground", classNames?.duration)}>\n Duration: {formatDuration(product.duration_minutes, product.duration_unit)}\n </div>\n )}\n\n {/* Special instructions */}\n {showSpecialInstructions && !isDigital && (\n <div data-cimplify-customizer-special-instructions>\n <textarea\n value={specialInstructions}\n onChange={(e) => setSpecialInstructions(e.target.value)}\n placeholder="Special instructions (e.g., no onions, extra sauce)"\n rows={2}\n data-cimplify-customizer-textarea\n className={cn(\n "w-full rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring resize-none",\n classNames?.specialInstructions,\n )}\n />\n </div>\n )}\n\n <div\n data-cimplify-customizer-actions\n className={cn("pt-4 border-t border-border", classNames?.actions)}\n >\n {!quoteEnabled && (\n <p\n id="cimplify-customizer-validation"\n data-cimplify-customizer-validation\n className={cn("text-sm text-destructive mb-3", classNames?.validation)}\n >\n Please select all required options\n </p>\n )}\n <div className="flex items-center gap-4">\n <div className="flex flex-col gap-1">\n <QuantitySelector\n value={quantity}\n onChange={setQuantity}\n min={product.min_order_quantity ?? 1}\n />\n {product.min_order_quantity && product.min_order_quantity > 1 && (\n <span data-cimplify-customizer-min-hint className="text-xs text-muted-foreground text-center">\n Min. {product.min_order_quantity}\n </span>\n )}\n </div>\n\n {product.quantity_pricing && product.quantity_pricing.length > 0 && (\n <div data-cimplify-customizer-unit-price className="text-sm text-muted-foreground text-right shrink-0">\n <Price amount={getUnitPriceAtQuantity(product.quantity_pricing, quantity, parsePrice(product.default_price))} className="font-medium text-foreground" /> ea.\n </div>\n )}\n\n <Button\n onClick={handleAddToCart}\n disabled={isAdded || isSubmitting || !quoteEnabled}\n aria-describedby={!quoteEnabled ? "cimplify-customizer-validation" : undefined}\n data-cimplify-customizer-submit\n className={cn(\n "flex-1 h-14 text-base bg-primary text-primary-foreground font-medium hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed rounded-full",\n isAdded && classNames?.submitButtonAdded,\n classNames?.submitButton,\n )}\n >\n {isAdded ? "Added to Cart" : (\n <>\n Add to Cart · <Price amount={displayTotalPrice} />\n </>\n )}\n </Button>\n </div>\n </div>\n\n </div>\n );\n}\n' }] }, "cart-summary": { "name": "cart-summary", "title": "CartSummary", "description": "Cart line items with quantity controls and totals.", "type": "component", "registryDependencies": ["price", "quantity-selector"], "files": [{ "path": "cart-summary.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { UseCartItem } from "@cimplify/sdk/react";\nimport type { DisplayCart } from "@cimplify/sdk";\nimport { useCart } from "@cimplify/sdk/react";\nimport { getVariantDisplayName } from "@cimplify/sdk";\nimport { INPUT_FIELD_TYPE } from "@cimplify/sdk";\nimport { parsePrice } from "@cimplify/sdk";\nimport { Price } from "@cimplify/sdk/react";\nimport { QuantitySelector } from "@cimplify/sdk/react";\nimport { useProductPrice } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface CartSummaryClassNames {\n root?: string;\n item?: string;\n itemName?: string;\n itemPrice?: string;\n quantity?: string;\n removeButton?: string;\n totals?: string;\n subtotal?: string;\n tax?: string;\n deliveryFee?: string;\n serviceCharge?: string;\n discount?: string;\n total?: string;\n checkout?: string;\n empty?: string;\n serviceInfo?: string;\n digitalInfo?: string;\n bundleBreakdown?: string;\n compositeBreakdown?: string;\n customerInputs?: string;\n}\n\nexport interface CartSummaryProps {\n /** Optional server cart for extended pricing details (delivery fee, service charge, discounts). */\n cart?: DisplayCart;\n onCheckout?: () => void;\n onItemRemove?: (itemId: string) => void;\n onQuantityChange?: (itemId: string, quantity: number) => void;\n emptyMessage?: string;\n /** Render the totals block (subtotal/tax/total) below the items. Default true. Hosts that show their own totals (drawer, page sidebar) should pass false. */\n showTotals?: boolean;\n /** Render the "Proceed to Checkout" button. Defaults to true if `onCheckout` is provided. */\n showCheckoutButton?: boolean;\n className?: string;\n classNames?: CartSummaryClassNames;\n}\n\nfunction CartLineItemRow({\n item,\n onRemove,\n onQuantityChange,\n classNames,\n}: {\n item: UseCartItem;\n onRemove: (itemId: string) => void;\n onQuantityChange: (itemId: string, qty: number) => void;\n classNames?: CartSummaryClassNames;\n}): React.ReactElement {\n const { unitPrice } = useProductPrice({\n product: item.product,\n variant: item.variant,\n addOnOptions: item.addOnOptions,\n });\n const hasComposite = item.compositeSelections && item.compositeSelections.length > 0;\n const hasBundle = item.bundleSelections && item.bundleSelections.length > 0;\n\n const isOptimistic = (item as { isOptimistic?: boolean }).isOptimistic === true;\n\n const lineTotal = parsePrice(unitPrice) * item.quantity;\n const imageUrl = item.product.image_url;\n const variantLabel = item.variant ? getVariantDisplayName(item.variant, item.product.name) : null;\n\n return (\n <div\n data-cimplify-cart-item\n data-optimistic={isOptimistic ? "true" : undefined}\n className={cn(\n "group relative flex gap-3 py-4 border-b border-border last:border-b-0 transition-opacity",\n isOptimistic && "opacity-60",\n classNames?.item,\n )}\n >\n {/* Thumbnail */}\n <div\n data-cimplify-cart-item-image\n className="relative w-20 h-20 shrink-0 rounded-md overflow-hidden bg-muted"\n >\n {imageUrl ? (\n <img\n src={imageUrl}\n alt=""\n className="w-full h-full object-cover"\n loading="lazy"\n />\n ) : (\n <div className="w-full h-full grid place-items-center text-muted-foreground/40">\n <svg className="w-6 h-6" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24" aria-hidden>\n <path strokeLinecap="round" strokeLinejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 001.5-1.5V6a1.5 1.5 0 00-1.5-1.5H3.75A1.5 1.5 0 002.25 6v12a1.5 1.5 0 001.5 1.5zm10.5-11.25h.008v.008h-.008V8.25zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z" />\n </svg>\n </div>\n )}\n </div>\n\n {/* Info column */}\n <div data-cimplify-cart-item-info className="flex-1 min-w-0 flex flex-col">\n <div className="flex items-start justify-between gap-3">\n <h3\n data-cimplify-cart-item-name\n className={cn("text-sm font-medium leading-tight m-0 truncate", classNames?.itemName)}\n >\n {item.product.name}\n </h3>\n <button\n type="button"\n onClick={() => onRemove(item.id)}\n data-cimplify-cart-item-remove\n className={cn(\n "shrink-0 -mr-1 -mt-1 grid place-items-center w-7 h-7 rounded-full text-muted-foreground/60 hover:text-foreground hover:bg-muted transition-colors",\n classNames?.removeButton,\n )}\n aria-label={`Remove ${item.product.name}`}\n >\n <svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24" aria-hidden>\n <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />\n </svg>\n </button>\n </div>\n\n {/* Pills row: variant, add-ons, badges */}\n {(variantLabel || (item.addOnOptions && item.addOnOptions.length > 0) || hasComposite || hasBundle) && (\n <div data-cimplify-cart-item-pills className="flex flex-wrap gap-1.5 mt-1.5">\n {variantLabel && (\n <span\n data-cimplify-cart-item-variant\n className="inline-flex items-center px-2 py-0.5 rounded-full bg-muted text-[11px] font-medium text-foreground"\n >\n {variantLabel}\n </span>\n )}\n {item.addOnOptions?.map((opt) => (\n <span\n key={opt.id}\n data-cimplify-cart-item-addon\n className="inline-flex items-center px-2 py-0.5 rounded-full bg-muted text-[11px] font-medium text-muted-foreground"\n >\n + {opt.name}\n </span>\n ))}\n {(hasComposite || hasBundle) && (\n <span\n data-cimplify-cart-item-badge\n className="inline-flex items-center px-2 py-0.5 rounded-full bg-primary/10 text-[11px] font-semibold uppercase tracking-wider text-primary"\n >\n {hasComposite ? "Custom" : "Bundle"}\n </span>\n )}\n </div>\n )}\n\n {/* Special instructions */}\n {item.specialInstructions && (\n <p\n data-cimplify-cart-item-instructions\n className="mt-1.5 text-xs italic text-muted-foreground line-clamp-2"\n >\n \\u201C{item.specialInstructions}\\u201D\n </p>\n )}\n\n {/* Customer input values */}\n {item.customerInputs && item.customerInputs.length > 0 && (\n <dl data-cimplify-cart-customer-inputs className={cn("mt-1.5 text-xs space-y-0.5", classNames?.customerInputs)}>\n {item.customerInputs.map((input) => (\n <div key={input.field_id} data-cimplify-cart-input className="flex gap-1.5">\n <dt data-cimplify-cart-input-label className="text-muted-foreground">{input.field_name}:</dt>\n <dd data-cimplify-cart-input-value className="text-foreground m-0 truncate">\n {input.field_type === INPUT_FIELD_TYPE.File || input.field_type === INPUT_FIELD_TYPE.Image\n ? String(input.value).split("/").pop() || "Uploaded file"\n : String(input.value)}\n </dd>\n </div>\n ))}\n </dl>\n )}\n\n {/* Service: scheduled details */}\n {(item.lineType === "service" || item.product.type === "service") && item.scheduledStart && (\n <div data-cimplify-cart-service-info className={cn("mt-1.5 flex items-center gap-1.5 text-xs text-muted-foreground", classNames?.serviceInfo)}>\n <svg className="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24" aria-hidden>\n <path strokeLinecap="round" strokeLinejoin="round" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />\n </svg>\n <span>\n {new Date(item.scheduledStart).toLocaleDateString(undefined, { month: "short", day: "numeric" })} at{" "}\n {new Date(item.scheduledStart).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}\n </span>\n {item.serviceStatus && (\n <span data-status={item.serviceStatus} className="ml-auto px-1.5 py-0.5 rounded-full bg-muted text-[10px] uppercase tracking-wider">\n {item.serviceStatus}\n </span>\n )}\n </div>\n )}\n\n {/* Digital: instant-delivery badge */}\n {(item.lineType === "digital" || item.product.type === "digital") && (\n <div data-cimplify-cart-digital-info className={cn("mt-1.5 flex items-center gap-1 text-xs text-muted-foreground", classNames?.digitalInfo)}>\n <svg className="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24" aria-hidden>\n <path strokeLinecap="round" strokeLinejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" />\n </svg>\n <span>Instant digital delivery</span>\n </div>\n )}\n\n {/* Bundle / composite component breakdown */}\n {(item.bundleResolved || item.compositeResolved) && (\n <ul\n data-cimplify-cart-composite-breakdown={item.compositeResolved ? "" : undefined}\n data-cimplify-cart-bundle-breakdown={item.bundleResolved ? "" : undefined}\n className={cn("mt-1.5 pl-3 border-l border-border space-y-0.5 text-xs text-muted-foreground", item.compositeResolved ? classNames?.compositeBreakdown : classNames?.bundleBreakdown)}\n >\n {((item.bundleResolved?.selections ?? item.compositeResolved?.selections ?? []) as Array<{\n component_id?: string;\n product_name?: string;\n component_name?: string;\n quantity: number;\n product_type?: string;\n }>).map((sel) => {\n const name = sel.product_name ?? sel.component_name ?? "";\n const compId = sel.component_id ?? `${name}-${sel.quantity}`;\n return (\n <li key={compId} data-product-type={sel.product_type} className="flex items-baseline gap-1.5 m-0">\n <span className="font-medium text-foreground/80">{sel.quantity}\\u00D7</span>\n <span className="truncate">{name}</span>\n </li>\n );\n })}\n </ul>\n )}\n\n {/* Bottom row: line price + qty stepper */}\n <div className="mt-3 flex items-center justify-between gap-3">\n <Price\n amount={lineTotal}\n className={cn("text-sm font-semibold tabular-nums", classNames?.itemPrice)}\n />\n <div data-cimplify-cart-item-controls className={classNames?.quantity}>\n <QuantitySelector\n value={item.quantity}\n onChange={(qty) => onQuantityChange(item.id, qty)}\n min={0}\n />\n </div>\n </div>\n </div>\n </div>\n );\n}\n\n/**\n * CartSummary \u2014 renders cart line items + totals.\n *\n * NOT a drawer or modal \u2014 just the cart content. Templates wrap this in\n * their own drawer/modal shell with animations.\n */\nexport function CartSummary({\n cart: serverCart,\n onCheckout,\n onItemRemove,\n onQuantityChange,\n emptyMessage = "Your cart is empty",\n showTotals = true,\n showCheckoutButton,\n className,\n classNames,\n}: CartSummaryProps): React.ReactElement {\n const {\n items,\n itemCount,\n subtotal,\n tax,\n total,\n isEmpty,\n isOptimistic,\n pendingOpCount,\n removeItem,\n updateQuantity,\n } = useCart();\n\n const deliveryFee = serverCart?.delivery_fee;\n const serviceCharge = serverCart?.service_charge;\n const totalDiscounts = serverCart?.total_discounts;\n\n const handleRemove = (itemId: string) => {\n if (onItemRemove) {\n onItemRemove(itemId);\n } else {\n void removeItem(itemId);\n }\n };\n\n const handleQuantityChange = (itemId: string, qty: number) => {\n if (onQuantityChange) {\n onQuantityChange(itemId, qty);\n } else {\n void updateQuantity(itemId, qty);\n }\n };\n\n return (\n <div\n data-cimplify-cart-summary\n data-optimistic={isOptimistic ? "true" : undefined}\n data-pending-ops={pendingOpCount > 0 ? pendingOpCount : undefined}\n className={cn(className, classNames?.root)}\n >\n {isEmpty ? (\n <div data-cimplify-cart-empty className={classNames?.empty}>\n <p>{emptyMessage}</p>\n </div>\n ) : (\n <>\n {/* Line items */}\n <div data-cimplify-cart-items>\n {items.map((item) => (\n <CartLineItemRow\n key={item.id}\n item={item}\n onRemove={handleRemove}\n onQuantityChange={handleQuantityChange}\n classNames={classNames}\n />\n ))}\n </div>\n\n {/* Totals */}\n {showTotals && (\n <div\n data-cimplify-cart-totals\n className={cn("mt-6 pt-4 border-t border-border space-y-2 text-sm", classNames?.totals)}\n >\n <div data-cimplify-cart-subtotal className={cn("flex items-baseline justify-between", classNames?.subtotal)}>\n <span className="text-muted-foreground">\n Subtotal ({itemCount} {itemCount === 1 ? "item" : "items"})\n </span>\n <Price amount={subtotal} className="tabular-nums" />\n </div>\n\n {totalDiscounts != null && parsePrice(totalDiscounts) > 0 && (\n <div data-cimplify-cart-discount className={cn("flex items-baseline justify-between", classNames?.discount)}>\n <span className="text-muted-foreground">Discount</span>\n <Price amount={totalDiscounts} prefix="-" className="tabular-nums text-primary" />\n </div>\n )}\n\n {deliveryFee != null && (\n <div data-cimplify-cart-delivery-fee className={cn("flex items-baseline justify-between", classNames?.deliveryFee)}>\n <span className="text-muted-foreground">Delivery</span>\n {parsePrice(deliveryFee) > 0 ? (\n <Price amount={deliveryFee} className="tabular-nums" />\n ) : (\n <span className="font-medium text-primary">Free</span>\n )}\n </div>\n )}\n\n {serviceCharge != null && parsePrice(serviceCharge) > 0 && (\n <div data-cimplify-cart-service-charge className={cn("flex items-baseline justify-between", classNames?.serviceCharge)}>\n <span className="text-muted-foreground">Service charge</span>\n <Price amount={serviceCharge} className="tabular-nums" />\n </div>\n )}\n\n <div data-cimplify-cart-tax className={cn("flex items-baseline justify-between", classNames?.tax)}>\n <span className="text-muted-foreground">Tax</span>\n <Price amount={tax} className="tabular-nums" />\n </div>\n\n <div data-cimplify-cart-total className={cn("flex items-baseline justify-between pt-3 mt-1 border-t border-border", classNames?.total)}>\n <span className="font-semibold">Total</span>\n <Price amount={total} className="text-base font-bold tabular-nums" />\n </div>\n </div>\n )}\n\n {(showCheckoutButton ?? Boolean(onCheckout)) && onCheckout && (\n <button\n type="button"\n onClick={onCheckout}\n data-cimplify-cart-checkout\n className={cn(\n "mt-4 w-full h-12 rounded-full bg-foreground text-background font-semibold text-sm hover:bg-foreground/90 active:scale-[0.99] transition-all",\n classNames?.checkout,\n )}\n >\n Proceed to Checkout\n </button>\n )}\n </>\n )}\n </div>\n );\n}\n' }] }, "cn": { "name": "cn", "title": "cn", "description": "Utility that merges Tailwind CSS classes with clsx + tailwind-merge.", "type": "utility", "registryDependencies": [], "files": [{ "path": "utils/cn.ts", "content": 'import { clsx, type ClassValue } from "clsx";\nimport { twMerge } from "tailwind-merge";\n\nexport function cn(...inputs: ClassValue[]): string {\n return twMerge(clsx(inputs));\n}\n' }] }, "search-input": { "name": "search-input", "title": "SearchInput", "description": "Search bar with debounced results dropdown.", "type": "component", "registryDependencies": ["cn"], "files": [{ "path": "search-input.tsx", "content": '"use client";\n\nimport React, { useCallback, useMemo } from "react";\nimport { Field } from "@base-ui/react/field";\nimport { Input } from "@base-ui/react/input";\nimport { useSearch } from "@cimplify/sdk/react";\nimport type { UseSearchOptions } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\nimport { formatPrice } from "@cimplify/sdk";\n\nexport interface SearchInputClassNames {\n root?: string;\n input?: string;\n clearButton?: string;\n results?: string;\n resultItem?: string;\n empty?: string;\n loading?: string;\n resultImage?: string;\n resultName?: string;\n resultPrice?: string;\n categoryFilter?: string;\n}\n\nexport interface SearchInputProps {\n /** Placeholder text for the input. */\n placeholder?: string;\n /** Search options forwarded to useSearch. */\n searchOptions?: UseSearchOptions;\n /** Called when a product result is clicked. */\n onResultClick?: (product: import("../types/product").Product) => void;\n /** Custom result item renderer. */\n renderResult?: (product: import("../types/product").Product) => React.ReactNode;\n /** Show inline results dropdown. Default: true. */\n showResults?: boolean;\n /** Optional category ID to scope search results. */\n categoryId?: string;\n /** Display name for the active category filter. */\n categoryName?: string;\n className?: string;\n classNames?: SearchInputClassNames;\n}\n\nconst SearchIcon = () => (\n <svg\n xmlns="http://www.w3.org/2000/svg"\n width="16"\n height="16"\n viewBox="0 0 24 24"\n fill="none"\n stroke="currentColor"\n strokeWidth="2"\n strokeLinecap="round"\n strokeLinejoin="round"\n aria-hidden="true"\n data-cimplify-search-icon\n >\n <circle cx="11" cy="11" r="8" />\n <line x1="21" y1="21" x2="16.65" y2="16.65" />\n </svg>\n);\n\n/**\n * SearchInput \u2014 search bar with debounced results dropdown.\n *\n * Wraps `useSearch` with a controlled input and optional inline results list.\n * Uses Base UI Field.Root + Input for accessible form semantics.\n */\nexport function SearchInput({\n placeholder = "Search products...",\n searchOptions,\n onResultClick,\n renderResult,\n showResults = true,\n categoryId,\n categoryName,\n className,\n classNames,\n}: SearchInputProps): React.ReactElement {\n const mergedOptions = useMemo<UseSearchOptions>(\n () => (categoryId ? { ...searchOptions, category: categoryId } : searchOptions ?? {}),\n [searchOptions, categoryId],\n );\n const { results, isLoading, query, setQuery, clear } = useSearch(mergedOptions);\n\n const handleValueChange = useCallback(\n (value: string) => {\n setQuery(value);\n },\n [setQuery],\n );\n\n return (\n <Field.Root\n data-cimplify-search\n className={cn(className, classNames?.root)}\n style={{ position: "relative" }}\n >\n <Field.Label className="sr-only">Search products</Field.Label>\n\n <div style={{ position: "relative", display: "flex", alignItems: "center" }}>\n <span\n data-cimplify-search-icon-wrapper\n style={{\n position: "absolute",\n left: "0.5rem",\n pointerEvents: "none",\n display: "flex",\n alignItems: "center",\n }}\n >\n <SearchIcon />\n </span>\n\n <Input\n type="search"\n value={query}\n onValueChange={handleValueChange}\n placeholder={placeholder}\n data-cimplify-search-input\n className={classNames?.input}\n />\n\n {query.length > 0 && (\n <button\n type="button"\n onClick={clear}\n data-cimplify-search-clear\n className={classNames?.clearButton}\n aria-label="Clear search"\n >\n ×\n </button>\n )}\n </div>\n\n {categoryId && (\n <div data-cimplify-search-category-filter className={classNames?.categoryFilter}>\n Filtered by: {categoryName || categoryId}\n </div>\n )}\n\n {showResults && query.length > 0 && (\n <div data-cimplify-search-results className={classNames?.results}>\n {isLoading && (\n <div data-cimplify-search-loading className={classNames?.loading} aria-busy="true">\n Searching...\n </div>\n )}\n\n {!isLoading && results.length === 0 && query.length >= 2 && (\n <div data-cimplify-search-empty className={classNames?.empty}>\n No results found\n </div>\n )}\n\n {!isLoading &&\n results.map((product) => (\n <button\n key={product.id}\n type="button"\n onClick={() => onResultClick?.(product)}\n data-cimplify-search-result\n className={classNames?.resultItem}\n style={!renderResult ? { display: "flex", alignItems: "center", gap: "0.5rem", width: "100%" } : undefined}\n >\n {renderResult ? (\n renderResult(product)\n ) : (\n <>\n {(product.images?.[0] || product.image_url) && (\n <img\n src={product.images?.[0] || product.image_url}\n alt=""\n data-cimplify-search-result-image\n className={classNames?.resultImage}\n style={{ width: "2rem", height: "2rem", objectFit: "cover", borderRadius: "0.25rem", flexShrink: 0 }}\n />\n )}\n <span data-cimplify-search-result-name className={classNames?.resultName} style={{ flex: 1, textAlign: "left" }}>\n {product.name}\n </span>\n {product.default_price !== undefined && (\n <span data-cimplify-search-result-price className={classNames?.resultPrice} style={{ flexShrink: 0 }}>\n {formatPrice(product.default_price)}\n </span>\n )}\n </>\n )}\n </button>\n ))}\n </div>\n )}\n </Field.Root>\n );\n}\n' }] }, "food-product-card": { "name": "food-product-card", "title": "FoodProductCard", "description": "Product card for food items with tags, badges, and quick-add.", "type": "component", "registryDependencies": ["price", "cn"], "files": [{ "path": "cards/food-product-card.tsx", "content": '"use client";\n\nimport React from "react";\nimport { PRODUCT_TYPE } from "@cimplify/sdk";\nimport type { CardLayoutProps } from "@cimplify/sdk/react";\nimport { CardShell, CardImage, QuickAddButton } from "@cimplify/sdk/react";\nimport { Price } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\n\nexport function FoodProductCard({\n product,\n onQuickAdd,\n renderImage,\n renderLink,\n className,\n}: CardLayoutProps): React.ReactElement {\n const image = product.image_url || product.images?.[0] || "";\n const tags = product.tags || [];\n const isSignature = product.metadata?.is_signature === true;\n const isNew = product.metadata?.is_new === true;\n const isComposite = product.type === PRODUCT_TYPE.Composite;\n\n return (\n <CardShell product={product} renderLink={renderLink} className={className}>\n <CardImage src={image} alt={product.name} aspectRatio="square" renderImage={renderImage}>\n {/* Badges */}\n {isSignature && (\n <span className="absolute top-3 left-3 inline-flex items-center gap-1 text-[11px] font-semibold tracking-wide bg-amber-50 text-amber-700 border border-amber-200/60 px-2 py-0.5 rounded-md">\n <svg className="w-3 h-3 opacity-70" fill="currentColor" viewBox="0 0 16 16"><path d="M8 .75l1.85 4.95L15 6.2l-3.7 3.2 1.1 5.1L8 12.15 3.6 14.5l1.1-5.1L1 6.2l5.15-.5z" /></svg>\n Signature\n </span>\n )}\n {isNew && !isSignature && (\n <span className="absolute top-3 left-3 text-[11px] font-semibold tracking-wide bg-emerald-50 text-emerald-700 border border-emerald-200/60 px-2 py-0.5 rounded-md">\n New\n </span>\n )}\n {isComposite && (\n <span className="absolute top-3 left-3 text-[11px] font-semibold tracking-wide bg-violet-50 text-violet-700 border border-violet-200/60 px-2 py-0.5 rounded-md">\n Customizable\n </span>\n )}\n\n {/* Quick add */}\n <QuickAddButton\n onClick={() => onQuickAdd?.(product)}\n className="absolute bottom-3 right-3"\n />\n </CardImage>\n\n <div className="p-3.5">\n <h3 className="text-[14.5px] font-semibold text-foreground leading-snug tracking-tight truncate">\n {product.name}\n </h3>\n {product.description && (\n <p className="text-[12.5px] text-muted-foreground mt-1 leading-relaxed line-clamp-2">\n {product.description}\n </p>\n )}\n <div className="flex items-center justify-between mt-3">\n {isComposite ? (\n <span className="text-sm font-bold">From <Price amount={product.default_price} /></span>\n ) : (\n <Price amount={product.default_price} className="text-sm font-bold" />\n )}\n {tags.length > 0 && (\n <div className="flex gap-1">\n {tags.slice(0, 2).map((tag) => (\n <span key={tag} className="text-[10px] font-medium text-muted-foreground px-1.5 py-0.5 bg-muted rounded">\n {tag}\n </span>\n ))}\n </div>\n )}\n </div>\n </div>\n </CardShell>\n );\n}\n' }] }, "search-page": { "name": "search-page", "title": "SearchPage", "description": "Dedicated search page with input and results grid.", "type": "component", "registryDependencies": ["product-grid", "cn"], "files": [{ "path": "search-page.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { Product, Category, PropertyFacet } from "@cimplify/sdk";\nimport { useSearch } from "@cimplify/sdk/react";\nimport type { UseSearchOptions } from "./hooks/use-search";\nimport type { SearchLayoutProps } from "./search-layouts/shared";\nimport { DefaultSearchLayout } from "./search-layouts/default-search-layout";\nimport { CompactSearchLayout } from "./search-layouts/compact-search-layout";\nimport { cn } from "@cimplify/sdk/react";\n\nexport type { SearchLayoutProps };\n\nexport enum SearchTemplate {\n Default = "default",\n Compact = "compact",\n}\n\nexport interface SearchPageClassNames {\n root?: string;\n}\n\nexport interface SearchPageProps {\n /** Explicit search template. */\n template?: SearchTemplate;\n /** Layout map for overrides. AI or developer can provide custom layouts keyed by template name. */\n layouts?: Partial<Record<SearchTemplate | string, React.ComponentType<SearchLayoutProps>>>;\n /** Pre-fetched categories for filters. */\n categories?: Category[];\n /** Pre-fetched property facets for sidebar. */\n facets?: PropertyFacet[];\n /** Search options passed to useSearch. */\n searchOptions?: UseSearchOptions;\n /** Placeholder text for search input. */\n placeholder?: string;\n /** Custom card renderer. */\n renderCard?: (product: Product) => React.ReactNode;\n /** Custom image renderer. */\n renderImage?: (props: { src: string; alt: string; className?: string }) => React.ReactNode;\n /** Custom link renderer. */\n renderLink?: (props: { href: string; className?: string; children: React.ReactNode }) => React.ReactElement;\n /** Quick-add handler. */\n onQuickAdd?: (product: Product) => void;\n className?: string;\n classNames?: SearchPageClassNames;\n}\n\nconst BUILT_IN_LAYOUTS: Record<string, React.ComponentType<SearchLayoutProps>> = {\n [SearchTemplate.Default]: DefaultSearchLayout,\n [SearchTemplate.Compact]: CompactSearchLayout,\n};\n\nexport function SearchPage({\n template,\n layouts,\n categories,\n facets,\n searchOptions,\n placeholder,\n renderCard,\n renderImage,\n renderLink,\n onQuickAdd,\n className,\n classNames,\n}: SearchPageProps): React.ReactElement {\n const search = useSearch(searchOptions);\n\n const key = template ?? SearchTemplate.Default;\n const Layout = layouts?.[key] ?? BUILT_IN_LAYOUTS[key] ?? DefaultSearchLayout;\n\n return (\n <div data-cimplify-search-page className={cn(className, classNames?.root)}>\n <Layout\n search={search}\n facets={facets}\n categories={categories}\n renderCard={renderCard}\n renderImage={renderImage}\n renderLink={renderLink}\n onQuickAdd={onQuickAdd}\n placeholder={placeholder}\n />\n </div>\n );\n}\n' }] }, "food-product-layout": { "name": "food-product-layout", "title": "FoodProductLayout", "description": "Restaurant product layout with allergens, ingredients, pairings, and dietary tags.", "type": "component", "registryDependencies": ["product-image-gallery", "product-customizer", "price", "cn"], "files": [{ "path": "layouts/food-product-layout.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { ProductLayoutProps } from "@cimplify/sdk/react";\nimport {\n TwoColumnGrid,\n RelatedProductsSection,\n MetadataStringList,\n CustomAttributesTable,\n TagPills,\n getMetadataStringList,\n} from "@cimplify/sdk/react";\nimport { ProductImageGallery } from "@cimplify/sdk/react";\nimport { ProductCustomizer } from "@cimplify/sdk/react";\nimport { Price } from "@cimplify/sdk/react";\nimport { parsePrice } from "@cimplify/sdk";\nimport { cn } from "@cimplify/sdk/react";\n\n\nexport function FoodProductLayout({\n product,\n onAddToCart,\n renderBreadcrumb,\n relatedProducts,\n showRelated = true,\n className,\n}: ProductLayoutProps): React.ReactElement {\n const images = product.images || (product.image_url ? [product.image_url] : []);\n const tags = product.tags || [];\n const ingredients = getMetadataStringList(product.metadata, "ingredients");\n const pairings = getMetadataStringList(product.metadata, "pairings");\n\n return (\n <div data-cimplify-product-layout="food" className={cn("space-y-8", className)}>\n {renderBreadcrumb?.(product)}\n\n <TwoColumnGrid\n left={\n <ProductImageGallery\n images={images}\n productName={product.name}\n />\n }\n right={\n <div className="space-y-6">\n {/* Tags + Allergens */}\n <div className="flex flex-wrap gap-1.5">\n {tags.map((tag) => (\n <span key={tag} className="inline-flex items-center gap-1 px-2.5 py-1 bg-green-100 text-green-800 text-xs font-medium rounded-full">\n {tag}\n </span>\n ))}\n {product.allergies && product.allergies.map((allergy) => (\n <span key={allergy} className="inline-flex items-center gap-1 px-2.5 py-1 bg-amber-100 text-amber-800 text-xs font-medium rounded-full">\n Contains {allergy}\n </span>\n ))}\n </div>\n\n {/* Name + Price */}\n <div>\n <h1 data-cimplify-product-layout-name className="text-3xl lg:text-4xl font-bold tracking-tight">\n {product.name}\n </h1>\n <Price amount={product.default_price} className="text-2xl font-semibold mt-2 block" />\n </div>\n\n {/* Quick stats */}\n {(product.calories != null || product.preparation_time_minutes != null) && (\n <div data-cimplify-product-layout-stats className="flex flex-wrap gap-4 py-3 border-y border-border">\n {product.preparation_time_minutes != null && (\n <span className="flex items-center gap-1.5 text-sm text-muted-foreground">\n <strong className="text-foreground">{product.preparation_time_minutes} min</strong> prep time\n </span>\n )}\n {product.calories != null && (\n <span className="flex items-center gap-1.5 text-sm text-muted-foreground">\n <strong className="text-foreground">{product.calories}</strong> calories\n </span>\n )}\n </div>\n )}\n\n {/* Description */}\n {product.description && (\n <p data-cimplify-product-layout-description className="text-muted-foreground leading-relaxed">\n {product.description}\n </p>\n )}\n\n {/* Ingredients + Pairings */}\n {(ingredients.length > 0 || pairings.length > 0) && (\n <div className="grid sm:grid-cols-2 gap-4">\n <MetadataStringList items={ingredients} label="Key Ingredients" />\n <MetadataStringList items={pairings} label="Pairings" />\n </div>\n )}\n\n {/* Custom Attributes */}\n <CustomAttributesTable attributes={product.custom_attributes || []} />\n\n {/* Customizer (variants, add-ons, composites, bundles, billing, volume) */}\n <ProductCustomizer\n product={product}\n onAddToCart={onAddToCart}\n />\n </div>\n }\n />\n\n {showRelated && relatedProducts && relatedProducts.length > 0 && (\n <RelatedProductsSection\n products={relatedProducts}\n title="You may also enjoy"\n />\n )}\n </div>\n );\n}\n' }] }, "catalogue-page": { "name": "catalogue-page", "title": "CataloguePage", "description": "Browse all products with category filtering and search.", "type": "component", "registryDependencies": ["product-grid", "category-filter", "search-input", "cn"], "files": [{ "path": "catalogue-page.tsx", "content": '"use client";\n\nimport React, { useState, useCallback } from "react";\nimport type { Product, Category, PropertyFacet } from "@cimplify/sdk";\nimport { useProducts, useCategories } from "@cimplify/sdk/react";\nimport type { CatalogueLayoutProps } from "./catalogue-layouts/shared";\nimport { DefaultCatalogueLayout } from "./catalogue-layouts/default-catalogue-layout";\nimport { CompactCatalogueLayout } from "./catalogue-layouts/compact-catalogue-layout";\nimport { cn } from "@cimplify/sdk/react";\n\nexport type { CatalogueLayoutProps };\n\nexport enum CatalogueTemplate {\n Default = "default",\n Compact = "compact",\n}\n\nexport interface CataloguePageClassNames {\n root?: string;\n}\n\nexport interface CataloguePageProps {\n /** Explicit template. */\n template?: CatalogueTemplate;\n /** Layout map for overrides. */\n layouts?: Partial<Record<CatalogueTemplate | string, React.ComponentType<CatalogueLayoutProps>>>;\n /** Page title. */\n title?: string;\n /** Pre-fetched products for SSR. */\n products?: Product[];\n /** Pre-fetched categories for SSR. */\n categories?: Category[];\n /** Pre-fetched property facets for sidebar. */\n facets?: PropertyFacet[];\n /** Available tags for filtering. */\n availableTags?: string[];\n /** Products per page. */\n pageSize?: number;\n /** Default sort. */\n defaultSort?: { by: string; order: string };\n /** Custom card renderer. */\n renderCard?: (product: Product) => React.ReactNode;\n /** Custom image renderer. */\n renderImage?: (props: { src: string; alt: string; className?: string }) => React.ReactNode;\n /** Custom link renderer. */\n renderLink?: (props: { href: string; className?: string; children: React.ReactNode }) => React.ReactElement;\n /** Quick-add handler. */\n onQuickAdd?: (product: Product) => void;\n /** Product click handler. */\n onProductClick?: (product: Product) => void;\n className?: string;\n classNames?: CataloguePageClassNames;\n}\n\nconst BUILT_IN_LAYOUTS: Record<string, React.ComponentType<CatalogueLayoutProps>> = {\n [CatalogueTemplate.Default]: DefaultCatalogueLayout,\n [CatalogueTemplate.Compact]: CompactCatalogueLayout,\n};\n\nfunction useDebounce(value: string, delay: number): string {\n const [debounced, setDebounced] = React.useState(value);\n React.useEffect(() => {\n const timer = setTimeout(() => setDebounced(value), delay);\n return () => clearTimeout(timer);\n }, [value, delay]);\n return debounced;\n}\n\nexport function CataloguePage({\n template,\n layouts,\n title,\n products: productsProp,\n categories: categoriesProp,\n facets,\n availableTags,\n pageSize,\n defaultSort,\n renderCard,\n renderImage,\n renderLink,\n onQuickAdd,\n onProductClick,\n className,\n classNames,\n}: CataloguePageProps): React.ReactElement {\n const [selectedCategory, setSelectedCategory] = useState<string | null>(null);\n const [searchQuery, setSearchQuery] = useState("");\n const [sortBy, setSortBy] = useState(defaultSort?.by ?? "created_at");\n const [sortOrder, setSortOrder] = useState(defaultSort?.order ?? "desc");\n const [inStockOnly, setInStockOnly] = useState(false);\n const [selectedTags, setSelectedTags] = useState<string[]>([]);\n const [minPrice, setMinPrice] = useState("");\n const [maxPrice, setMaxPrice] = useState("");\n const [page, setPage] = useState(1);\n\n const debouncedSearch = useDebounce(searchQuery, 300);\n\n const { products: fetchedProducts, isLoading, pagination } = useProducts({\n enabled: productsProp === undefined,\n category: selectedCategory ?? undefined,\n search: debouncedSearch.length >= 2 ? debouncedSearch : undefined,\n sort_by: sortBy as "name" | "price" | "created_at" | "updated_at",\n sort_order: sortOrder as "asc" | "desc",\n in_stock: inStockOnly || undefined,\n tags: selectedTags.length > 0 ? selectedTags : undefined,\n min_price: minPrice ? parseFloat(minPrice) : undefined,\n max_price: maxPrice ? parseFloat(maxPrice) : undefined,\n page,\n limit: pageSize,\n });\n\n const { categories: fetchedCategories } = useCategories({\n enabled: categoriesProp === undefined,\n });\n\n const products = productsProp ?? fetchedProducts;\n const categories = categoriesProp ?? fetchedCategories;\n const totalPages = pagination?.total_pages ?? 1;\n\n const handleCategoryChange = useCallback((id: string | null) => {\n setSelectedCategory(id);\n setPage(1);\n }, []);\n\n const handleSortChange = useCallback((by: string, order: string) => {\n setSortBy(by);\n setSortOrder(order);\n setPage(1);\n }, []);\n\n const handleTagToggle = useCallback((tag: string) => {\n setSelectedTags((prev) =>\n prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag],\n );\n setPage(1);\n }, []);\n\n const handlePriceRangeChange = useCallback((min: string, max: string) => {\n setMinPrice(min);\n setMaxPrice(max);\n setPage(1);\n }, []);\n\n const key = template ?? CatalogueTemplate.Default;\n const Layout = layouts?.[key] ?? BUILT_IN_LAYOUTS[key] ?? DefaultCatalogueLayout;\n\n return (\n <div data-cimplify-catalogue-page className={cn(className, classNames?.root)}>\n <Layout\n products={products}\n categories={categories}\n facets={facets}\n isLoading={isLoading}\n selectedCategory={selectedCategory}\n onCategoryChange={handleCategoryChange}\n searchQuery={searchQuery}\n onSearchChange={setSearchQuery}\n sortBy={sortBy}\n sortOrder={sortOrder}\n onSortChange={handleSortChange}\n inStockOnly={inStockOnly}\n onInStockChange={setInStockOnly}\n selectedTags={selectedTags}\n availableTags={availableTags}\n onTagToggle={handleTagToggle}\n minPrice={minPrice}\n maxPrice={maxPrice}\n onPriceRangeChange={handlePriceRangeChange}\n page={page}\n totalPages={totalPages}\n onPageChange={setPage}\n renderCard={renderCard}\n renderImage={renderImage}\n renderLink={renderLink}\n onQuickAdd={onQuickAdd}\n onProductClick={onProductClick}\n title={title}\n />\n </div>\n );\n}\n' }] }, "default-product-layout": { "name": "default-product-layout", "title": "DefaultProductLayout", "description": "Two-column product layout for retail/physical products with sale badges, specs, and properties.", "type": "component", "registryDependencies": ["product-image-gallery", "product-customizer", "price", "price-range", "availability-badge", "sale-badge", "cn"], "files": [{ "path": "layouts/default-product-layout.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { ProductLayoutProps } from "@cimplify/sdk/react";\nimport {\n TwoColumnGrid,\n RelatedProductsSection,\n CustomAttributesTable,\n PropertiesTable,\n TagPills,\n InventoryBadge,\n} from "@cimplify/sdk/react";\nimport { ProductImageGallery } from "@cimplify/sdk/react";\nimport { ProductCustomizer } from "@cimplify/sdk/react";\nimport { Price } from "@cimplify/sdk/react";\nimport { PriceRange } from "@cimplify/sdk/react";\nimport { AvailabilityBadge } from "@cimplify/sdk/react";\nimport { SaleBadge } from "@cimplify/sdk/react";\nimport { parsePrice, isOnSale, getBasePrice, getDiscountPercentage } from "@cimplify/sdk";\nimport { cn } from "@cimplify/sdk/react";\n\nexport function DefaultProductLayout({\n product,\n onAddToCart,\n renderBreadcrumb,\n relatedProducts,\n showRelated = true,\n className,\n}: ProductLayoutProps): React.ReactElement {\n const images = product.images || (product.image_url ? [product.image_url] : []);\n const hasRange = (product.quantity_pricing && product.quantity_pricing.length > 1)\n || (product.variants && product.variants.length > 1);\n const onSale = isOnSale(product);\n const hasPhysicalDetails = product.sku || product.vendor || product.material;\n\n return (\n <div data-cimplify-product-layout="default" className={cn("space-y-8", className)}>\n {renderBreadcrumb?.(product)}\n\n <TwoColumnGrid\n left={\n <ProductImageGallery\n images={images}\n productName={product.name}\n />\n }\n right={\n <div className="space-y-6">\n {/* Brand + Tags */}\n <div className="flex flex-wrap items-center gap-2">\n {product.vendor && (\n <span className="text-xs font-semibold text-muted-foreground uppercase tracking-widest">\n {product.vendor}\n </span>\n )}\n <TagPills tags={product.tags || []} />\n </div>\n\n {/* Name */}\n <h1 data-cimplify-product-layout-name className="text-3xl lg:text-4xl font-extrabold tracking-tight">\n {product.name}\n </h1>\n\n {/* Price */}\n <div className="flex items-baseline gap-3">\n {hasRange ? (\n <PriceRange product={product} className="text-2xl font-extrabold" />\n ) : onSale ? (\n <>\n <Price amount={product.default_price} className="text-2xl font-extrabold" />\n <Price amount={getBasePrice(product)} className="text-lg text-muted-foreground line-through" />\n <span className="text-sm font-bold text-destructive bg-destructive/10 px-2 py-0.5">\n Save {getDiscountPercentage(product)}%\n </span>\n </>\n ) : (\n <Price amount={product.default_price} className="text-2xl font-semibold" />\n )}\n </div>\n\n {/* Inventory */}\n <InventoryBadge product={product} />\n\n {/* Description */}\n {product.description && (\n <p data-cimplify-product-layout-description className="text-muted-foreground leading-relaxed">\n {product.description}\n </p>\n )}\n\n {/* Physical product details */}\n {hasPhysicalDetails && (\n <div data-cimplify-product-layout-details className="space-y-1 text-sm text-muted-foreground">\n {product.sku && <p>SKU: <span className="font-mono text-foreground">{product.sku}</span></p>}\n {product.barcode && <p>Barcode: <span className="font-mono text-foreground">{product.barcode}</span></p>}\n {product.material && <p>Material: <span className="text-foreground">{product.material}</span></p>}\n {product.vendor && <p>Brand: <span className="text-foreground">{product.vendor}</span></p>}\n {(product.length_mm || product.width_mm || product.height_mm) && (\n <p>Dimensions: <span className="text-foreground">\n {[product.length_mm, product.width_mm, product.height_mm]\n .filter(Boolean)\n .map((d) => `${(d! / 10).toFixed(1)} cm`)\n .join(" x ")}\n </span></p>\n )}\n {product.item_condition && product.item_condition !== "new" && (\n <p>Condition: <span className="text-foreground capitalize">{product.item_condition}</span></p>\n )}\n </div>\n )}\n\n {/* Custom Attributes */}\n <CustomAttributesTable attributes={product.custom_attributes || []} />\n\n {/* Properties */}\n <PropertiesTable properties={product.properties || []} />\n\n {/* Customizer */}\n <ProductCustomizer\n product={product}\n onAddToCart={onAddToCart}\n />\n </div>\n }\n />\n\n {showRelated && relatedProducts && relatedProducts.length > 0 && (\n <RelatedProductsSection\n products={relatedProducts}\n />\n )}\n </div>\n );\n}\n' }] }, "account": { "name": "account", "title": "CimplifyAccount", "description": "Iframe wrapper for the Cimplify account portal \u2014 sign-in, orders, addresses, settings.", "type": "component", "registryDependencies": ["cn"], "files": [{ "path": "account.tsx", "content": '"use client";\n\nimport React, { useEffect, useRef, useState, useMemo } from "react";\nimport type { CimplifyClient } from "../client";\nimport { useOptionalCimplify } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface CimplifyAccountProps {\n /** CimplifyClient instance. Falls back to provider context. */\n client?: CimplifyClient;\n /** Override the Link base URL. */\n linkUrl?: string;\n /** Initial section to show: "dashboard" | "orders" | "addresses" | "payment-methods" | "sessions" | "settings". */\n section?: string;\n /** Appearance variables \u2014 passed to Link for theming. */\n appearance?: {\n theme?: "light" | "dark";\n variables?: {\n primaryColor?: string;\n fontFamily?: string;\n borderRadius?: string;\n };\n };\n /** Called when the user logs out from the account portal. */\n onLogout?: () => void;\n /** Additional CSS class for the container. */\n className?: string;\n}\n\nconst DEFAULT_LINK_URL = "https://link.cimplify.io";\n\nexport function CimplifyAccount({\n client: clientProp,\n linkUrl,\n section,\n appearance,\n onLogout,\n className,\n}: CimplifyAccountProps): React.ReactElement {\n const context = useOptionalCimplify();\n const client = clientProp ?? context?.client;\n const resolvedLinkUrl = linkUrl || DEFAULT_LINK_URL;\n\n const iframeRef = useRef<HTMLIFrameElement | null>(null);\n const [height, setHeight] = useState(400);\n const [isReady, setIsReady] = useState(false);\n\n const iframeSrc = useMemo(() => {\n const path = section ? `/elements/account/${section}` : "/elements/account";\n const url = new URL(path, resolvedLinkUrl);\n if (client) {\n const businessId = client.getBusinessId?.() ?? "";\n if (businessId) url.searchParams.set("businessId", businessId);\n }\n return url.toString();\n }, [resolvedLinkUrl, section, client]);\n\n // Listen for messages from the iframe\n useEffect(() => {\n function handleMessage(event: MessageEvent) {\n if (!event.data || typeof event.data !== "object") return;\n\n // Only accept messages from our iframe\n if (iframeRef.current?.contentWindow && event.source !== iframeRef.current.contentWindow) {\n return;\n }\n\n switch (event.data.type) {\n case "ready":\n setIsReady(true);\n if (typeof event.data.height === "number") {\n setHeight(event.data.height);\n }\n // Send init with token + appearance\n sendInit();\n break;\n\n case "height_change":\n if (typeof event.data.height === "number") {\n setHeight(event.data.height);\n }\n break;\n\n case "logout_complete":\n onLogout?.();\n break;\n }\n }\n\n window.addEventListener("message", handleMessage);\n return () => window.removeEventListener("message", handleMessage);\n }, [appearance, client, onLogout]);\n\n function sendInit() {\n const contentWindow = iframeRef.current?.contentWindow;\n if (!contentWindow) return;\n\n const token = client?.getAccessToken?.();\n contentWindow.postMessage(\n {\n type: "init",\n token: token ?? undefined,\n appearance: appearance ?? undefined,\n },\n resolvedLinkUrl,\n );\n }\n\n return (\n <div\n data-cimplify-account\n className={cn("relative overflow-hidden", className)}\n >\n {!isReady && (\n <div className="flex items-center justify-center py-16">\n <div className="w-6 h-6 border-2 border-border border-t-foreground rounded-full animate-spin" />\n </div>\n )}\n <iframe\n ref={iframeRef}\n src={iframeSrc}\n style={{\n border: "none",\n width: "100%",\n height: `${height}px`,\n display: isReady ? "block" : "none",\n overflow: "hidden",\n background: "transparent",\n }}\n allow="geolocation"\n sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox"\n />\n </div>\n );\n}\n' }] }, "subscription-card": { "name": "subscription-card", "title": "SubscriptionCard", "description": "Subscription card with billing plan options, trial badge, and setup fee.", "type": "component", "registryDependencies": ["price", "cn"], "files": [{ "path": "cards/subscription-card.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { ServiceCardLayoutProps } from "./standard-service-card";\nimport { CardShell, CardImage } from "@cimplify/sdk/react";\nimport { Price } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nconst FREQUENCY_LABELS: Record<string, string> = {\n weekly: "/week",\n biweekly: "/2wk",\n monthly: "/mo",\n quarterly: "/qtr",\n annually: "/yr",\n};\n\nexport function SubscriptionCard({\n product,\n onBook,\n renderImage,\n renderLink,\n className,\n}: ServiceCardLayoutProps): React.ReactElement {\n const image = product.image_url || product.images?.[0] || "";\n const plans = product.billing_plans || [];\n const primaryPlan = plans[0];\n const frequency = primaryPlan?.frequency\n ? FREQUENCY_LABELS[primaryPlan.frequency] || `/${primaryPlan.frequency}`\n : "/mo";\n const hasTrial = primaryPlan?.trial_days != null && primaryPlan.trial_days > 0;\n const tags = product.tags || [];\n\n return (\n <CardShell product={product} renderLink={renderLink} className={className}>\n <CardImage src={image} alt={product.name} aspectRatio="16/9" renderImage={renderImage}>\n <div className="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent pointer-events-none" />\n\n {/* Price on image */}\n <span className="absolute bottom-3 right-3 text-white font-bold text-lg drop-shadow-sm">\n <Price amount={product.default_price} />{frequency}\n </span>\n\n {/* Trial badge */}\n {hasTrial && (\n <span className="absolute top-3 left-3 text-[11px] font-semibold tracking-wide bg-emerald-50/90 text-emerald-700 border border-emerald-200/50 backdrop-blur px-2 py-0.5 rounded-md">\n {primaryPlan!.trial_days}-day free trial\n </span>\n )}\n </CardImage>\n\n <div className="p-4">\n <h3 className="text-[14.5px] font-bold text-foreground leading-snug tracking-tight">\n {product.name}\n </h3>\n {product.description && (\n <p className="text-[12.5px] text-muted-foreground mt-1 leading-relaxed line-clamp-2">\n {product.description}\n </p>\n )}\n\n {/* Tags */}\n {tags.length > 0 && (\n <div className="flex flex-wrap gap-1 mt-2.5">\n {tags.slice(0, 3).map((tag) => (\n <span key={tag} className="text-[10px] font-medium text-muted-foreground px-1.5 py-0.5 bg-muted rounded">\n {tag}\n </span>\n ))}\n </div>\n )}\n\n {/* Billing plan options */}\n {plans.length > 1 && (\n <div className="mt-3 space-y-1">\n {plans.slice(0, 3).map((plan) => {\n const label = plan.frequency\n ? plan.frequency.charAt(0).toUpperCase() + plan.frequency.slice(1)\n : "Standard";\n const hasMarkup = plan.markup_type && plan.markup_amount;\n const isSavings = plan.markup_type === "percentage" && plan.markup_amount != null && plan.markup_amount < 0;\n\n return (\n <div key={plan.id} className="flex items-center justify-between text-[11px]">\n <span className="text-muted-foreground">{label}</span>\n <span className="font-medium">\n {hasMarkup && isSavings && (\n <span className="text-emerald-600 mr-1">Save {Math.abs(plan.markup_amount!)}%</span>\n )}\n </span>\n </div>\n );\n })}\n </div>\n )}\n\n <div className="flex items-center justify-between mt-3 pt-3 border-t border-border">\n <div className="flex items-center gap-2 text-[11px] text-muted-foreground">\n {hasTrial && (\n <span className="text-emerald-600 font-medium">Free trial</span>\n )}\n {primaryPlan?.setup_fee != null && primaryPlan.setup_fee > 0 && (\n <span><Price amount={primaryPlan.setup_fee} /> setup</span>\n )}\n </div>\n <button\n onClick={(e) => { e.preventDefault(); e.stopPropagation(); onBook?.(product); }}\n className="text-[13px] font-semibold text-primary hover:text-primary/80 transition-colors"\n >\n Subscribe →\n </button>\n </div>\n </div>\n </CardShell>\n );\n}\n' }] }, "product-image-gallery": { "name": "product-image-gallery", "title": "ProductImageGallery", "description": "Main image with thumbnail strip for product images.", "type": "component", "registryDependencies": [], "files": [{ "path": "product-image-gallery.tsx", "content": '"use client";\n\nimport React, { useEffect, useMemo, useState } from "react";\nimport { RadioGroup } from "@base-ui/react/radio-group";\nimport { Radio } from "@base-ui/react/radio";\n\nexport interface ProductImageGalleryProps {\n images: string[];\n productName: string;\n aspectRatio?: "square" | "4/3" | "16/10" | "3/4";\n className?: string;\n}\n\nconst ASPECT_STYLES: Record<string, React.CSSProperties> = {\n square: { aspectRatio: "1/1" },\n "4/3": { aspectRatio: "4/3" },\n "16/10": { aspectRatio: "16/10" },\n "3/4": { aspectRatio: "3/4" },\n};\n\n/**\n * ProductImageGallery \u2014 main image + thumbnail strip.\n *\n * Uses plain `<img>` for framework-agnostic rendering (not Next.js Image).\n */\nexport function ProductImageGallery({\n images,\n productName,\n aspectRatio = "4/3",\n className,\n}: ProductImageGalleryProps): React.ReactElement | null {\n const normalizedImages = useMemo(\n () =>\n images.filter(\n (image): image is string =>\n typeof image === "string" && image.trim().length > 0,\n ),\n [images],\n );\n\n const [selectedImage, setSelectedImage] = useState(0);\n\n useEffect(() => {\n setSelectedImage(0);\n }, [normalizedImages.length, productName]);\n\n if (normalizedImages.length === 0) {\n return null;\n }\n\n const activeImage = normalizedImages[selectedImage] || normalizedImages[0];\n\n return (\n <div data-cimplify-image-gallery className={className}>\n <div\n data-cimplify-image-gallery-main\n style={{ position: "relative", overflow: "hidden", ...ASPECT_STYLES[aspectRatio] }}\n >\n <img\n src={activeImage}\n alt={productName}\n style={{ width: "100%", height: "100%", objectFit: "cover" }}\n data-cimplify-image-gallery-active\n />\n </div>\n\n {normalizedImages.length > 1 && (\n <RadioGroup\n aria-label={`${productName} image thumbnails`}\n value={String(selectedImage)}\n onValueChange={(value) => setSelectedImage(Number(value))}\n data-cimplify-image-gallery-thumbnails\n style={{ display: "flex", gap: "0.5rem", marginTop: "0.75rem" }}\n >\n {normalizedImages.map((image, index) => {\n const isSelected = selectedImage === index;\n return (\n <Radio.Root\n key={`${image}-${index}`}\n value={String(index)}\n data-cimplify-image-gallery-thumb\n data-selected={isSelected || undefined}\n style={{\n width: "4rem",\n height: "4rem",\n overflow: "hidden",\n padding: 0,\n border: "none",\n cursor: "pointer",\n }}\n >\n <img\n src={image}\n alt=""\n style={{ width: "100%", height: "100%", objectFit: "cover" }}\n />\n </Radio.Root>\n );\n })}\n </RadioGroup>\n )}\n </div>\n );\n}\n' }] }, "order-summary": { "name": "order-summary", "title": "OrderSummary", "description": "Single order detail view with line items and totals.", "type": "component", "registryDependencies": ["price", "cn"], "files": [{ "path": "order-summary.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { Order, LineItem, OrderStatus, PaymentState } from "@cimplify/sdk";\nimport { useOrder } from "@cimplify/sdk/react";\nimport { Price } from "@cimplify/sdk/react";\nimport { parsePrice } from "@cimplify/sdk";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface OrderSummaryClassNames {\n root?: string;\n header?: string;\n orderId?: string;\n status?: string;\n paymentState?: string;\n fulfillmentDetails?: string;\n deliveryAddress?: string;\n items?: string;\n lineItem?: string;\n notes?: string;\n totals?: string;\n customer?: string;\n loading?: string;\n serviceInfo?: string;\n digitalInfo?: string;\n bundleBreakdown?: string;\n compositeBreakdown?: string;\n trackingLink?: string;\n reorderButton?: string;\n}\n\nexport interface OrderSummaryProps {\n /** Pass an Order object directly (skips fetch). */\n order?: Order;\n /** Or pass an order ID to fetch via useOrder. */\n orderId?: string;\n /** Poll for status updates. */\n poll?: boolean;\n /** Custom line item renderer. */\n renderLineItem?: (item: LineItem) => React.ReactNode;\n /** Called when the reorder button is clicked. */\n onReorder?: (order: Order) => void;\n /** Called when the order status changes during polling. */\n onStatusChange?: (previousStatus: OrderStatus, newStatus: OrderStatus, order: Order) => void;\n className?: string;\n classNames?: OrderSummaryClassNames;\n}\n\nconst STATUS_LABELS: Record<OrderStatus, string> = {\n pending: "Pending",\n created: "Created",\n confirmed: "Confirmed",\n in_preparation: "In Preparation",\n ready_to_serve: "Ready",\n partially_served: "Partially Served",\n served: "Served",\n delivered: "Delivered",\n picked_up: "Picked Up",\n completed: "Completed",\n cancelled: "Cancelled",\n refunded: "Refunded",\n};\n\nconst PAYMENT_STATE_LABELS: Record<PaymentState, string> = {\n not_paid: "Not Paid",\n paid: "Paid",\n partially_refunded: "Partially Refunded",\n refunded: "Refunded",\n};\n\n/**\n * OrderSummary \u2014 displays a single order\'s details, line items, and totals.\n *\n * Accepts either a pre-loaded `order` object or an `orderId` to fetch.\n * Supports polling for live status updates.\n */\nexport function OrderSummary({\n order: orderProp,\n orderId,\n poll = false,\n renderLineItem,\n onReorder,\n onStatusChange,\n className,\n classNames,\n}: OrderSummaryProps): React.ReactElement {\n const { order: fetched, isLoading } = useOrder(orderProp ? null : orderId, {\n enabled: !orderProp && !!orderId,\n poll,\n onStatusChange,\n });\n\n const order = orderProp ?? fetched;\n\n if (isLoading && !order) {\n return (\n <div\n data-cimplify-order-summary\n aria-busy="true"\n className={cn(className, classNames?.root, classNames?.loading)}\n >\n <div data-cimplify-order-summary-skeleton />\n </div>\n );\n }\n\n if (!order) {\n return (\n <div data-cimplify-order-summary className={cn(className, classNames?.root)}>\n <p>Order not found.</p>\n </div>\n );\n }\n\n return (\n <div data-cimplify-order-summary className={cn(className, classNames?.root)}>\n {/* Header */}\n <div data-cimplify-order-header className={classNames?.header}>\n <span data-cimplify-order-id className={classNames?.orderId}>\n Order #{order.user_friendly_id}\n </span>\n <span\n data-cimplify-order-status\n data-status={order.status}\n className={classNames?.status}\n >\n {STATUS_LABELS[order.status] ?? order.status}\n </span>\n {order.payment_state && (\n <span\n data-cimplify-order-payment-state\n data-payment-state={order.payment_state}\n className={classNames?.paymentState}\n >\n {PAYMENT_STATE_LABELS[order.payment_state] ?? order.payment_state}\n </span>\n )}\n </div>\n\n {/* Fulfillment details */}\n {order.order_type === "pickup" && order.pickup_time && (\n <div data-cimplify-order-fulfillment className={classNames?.fulfillmentDetails}>\n <span>Pickup time: {new Date(order.pickup_time).toLocaleString()}</span>\n </div>\n )}\n {order.order_type === "dine-in" && order.table_number && (\n <div data-cimplify-order-fulfillment className={classNames?.fulfillmentDetails}>\n <span>Table: {order.table_number}</span>\n </div>\n )}\n\n {/* Date */}\n <time data-cimplify-order-date dateTime={order.created_at}>\n {new Date(order.created_at).toLocaleDateString(undefined, {\n year: "numeric",\n month: "long",\n day: "numeric",\n hour: "2-digit",\n minute: "2-digit",\n })}\n </time>\n\n {/* Customer info */}\n {(order.customer_name || order.customer_email) && (\n <div data-cimplify-order-customer className={classNames?.customer}>\n {order.customer_name && (\n <span data-cimplify-order-customer-name>{order.customer_name}</span>\n )}\n {order.customer_email && (\n <span data-cimplify-order-customer-email>{order.customer_email}</span>\n )}\n </div>\n )}\n\n {/* Delivery address */}\n {order.delivery_address && (\n <div data-cimplify-order-delivery-address className={classNames?.deliveryAddress}>\n <span>Delivery address</span>\n <p>{order.delivery_address}</p>\n </div>\n )}\n\n {/* Line items */}\n <div data-cimplify-order-items className={classNames?.items}>\n {order.items.map((item) =>\n renderLineItem ? (\n <React.Fragment key={item.id}>{renderLineItem(item)}</React.Fragment>\n ) : (\n <div key={item.id} data-cimplify-order-line-item className={classNames?.lineItem}>\n <div data-cimplify-order-line-info>\n <span data-cimplify-order-line-qty>{item.quantity}×</span>\n <span data-cimplify-order-line-key>{item.line_key}</span>\n </div>\n <Price amount={item.price} />\n {item.fulfillment_type === "digital" && item.fulfillment_id && (\n <a\n href={item.fulfillment_id}\n target="_blank"\n rel="noopener noreferrer"\n data-cimplify-order-line-download\n className="text-sm text-primary underline"\n >\n Download\n </a>\n )}\n\n {/* Service items: show scheduling and confirmation details */}\n {item.configuration.type === "service" && (\n <div data-cimplify-order-service-info className={classNames?.serviceInfo}>\n {item.configuration.scheduled_start && (\n <span>{"\\u{1F4C5}"} {new Date(item.configuration.scheduled_start).toLocaleDateString()} at {new Date(item.configuration.scheduled_start).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}</span>\n )}\n {item.configuration.confirmation_code && (\n <span>Confirmation: {item.configuration.confirmation_code}</span>\n )}\n {item.configuration.service_status && (\n <span data-status={item.configuration.service_status}>{item.configuration.service_status}</span>\n )}\n </div>\n )}\n\n {/* Digital items: instant delivery badge */}\n {item.configuration.type === "digital" && (\n <div data-cimplify-order-digital-info className={classNames?.digitalInfo}>\n <span>{"\\u26A1"} Instant digital delivery</span>\n </div>\n )}\n\n {/* Bundle items: component breakdown */}\n {item.configuration.type === "bundle" && item.configuration.resolved && (\n <div data-cimplify-order-bundle-breakdown className={classNames?.bundleBreakdown}>\n {item.configuration.resolved.selections.map((sel) => (\n <div key={sel.component_id} data-product-type={sel.product_type}>\n <span>{sel.quantity}× {sel.product_name}</span>\n {sel.product_type === "service" && sel.scheduling?.scheduled_start && (\n <span> — {new Date(sel.scheduling.scheduled_start).toLocaleDateString()}</span>\n )}\n {sel.product_type === "digital" && <span> — Digital</span>}\n </div>\n ))}\n </div>\n )}\n\n {/* Composite items: component breakdown with pricing */}\n {item.configuration.type === "composite" && item.configuration.resolved && (\n <div data-cimplify-order-composite-breakdown className={classNames?.compositeBreakdown}>\n {item.configuration.resolved.selections.map((sel) => (\n <div key={sel.component_id} data-product-type={sel.product_type}>\n <span>{sel.quantity}× {sel.component_name}</span>\n {sel.product_type === "service" && sel.scheduling?.scheduled_start && (\n <span> — {new Date(sel.scheduling.scheduled_start).toLocaleDateString()}</span>\n )}\n <Price amount={sel.unit_price} />\n </div>\n ))}\n </div>\n )}\n </div>\n ),\n )}\n </div>\n\n {/* Customer notes / special instructions */}\n {order.customer_notes && order.customer_notes.length > 0 && (\n <div data-cimplify-order-notes className={classNames?.notes}>\n <span>Notes</span>\n {order.customer_notes.map((note, index) => (\n <p key={index} data-cimplify-order-note>{note}</p>\n ))}\n </div>\n )}\n\n {/* Totals */}\n <div data-cimplify-order-totals className={classNames?.totals}>\n {order.total_discount != null && parsePrice(order.total_discount) !== 0 && (\n <div data-cimplify-order-discount>\n <span>Discount</span>\n <Price amount={order.total_discount} prefix="-" />\n </div>\n )}\n {order.delivery_fee != null && parsePrice(order.delivery_fee) > 0 && (\n <div data-cimplify-order-delivery-fee>\n <span>Delivery fee</span>\n <Price amount={order.delivery_fee} />\n </div>\n )}\n {order.service_charge != null && parsePrice(order.service_charge) !== 0 && (\n <div data-cimplify-order-service-charge>\n <span>Service charge</span>\n <Price amount={order.service_charge} />\n </div>\n )}\n {order.tax != null && parsePrice(order.tax) !== 0 && (\n <div data-cimplify-order-tax>\n <span>Tax</span>\n <Price amount={order.tax} />\n </div>\n )}\n <div data-cimplify-order-total>\n <span>Total</span>\n <Price amount={order.total_price} />\n </div>\n </div>\n\n {/* Tracking */}\n {order.tracking_link && (\n <a\n href={order.tracking_link}\n target="_blank"\n rel="noopener noreferrer"\n data-cimplify-order-tracking\n className={classNames?.trackingLink}\n >\n Track your order\n </a>\n )}\n\n {onReorder && (\n <button\n type="button"\n onClick={() => onReorder(order)}\n data-cimplify-order-reorder\n className={classNames?.reorderButton}\n >\n Reorder\n </button>\n )}\n </div>\n );\n}\n' }] }, "schedule-service-card": { "name": "schedule-service-card", "title": "ScheduleServiceCard", "description": "Service card with next available time slot pills.", "type": "component", "registryDependencies": ["price", "cn"], "files": [{ "path": "cards/schedule-service-card.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { ServiceCardLayoutProps } from "./standard-service-card";\nimport { Price } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\nimport { formatDuration } from "../utils/format-duration";\n\nexport function ScheduleServiceCard({\n product,\n slots,\n onBook,\n renderImage,\n renderLink,\n className,\n}: ServiceCardLayoutProps): React.ReactElement {\n const image = product.image_url || product.images?.[0] || "";\n const href = `/products/${product.slug}`;\n const durationLabel = product.duration_minutes != null\n ? formatDuration(product.duration_minutes, product.duration_unit)\n : null;\n\n const displaySlots = (slots || []).slice(0, 3);\n\n const shellClass = cn(\n "group block text-left cursor-pointer bg-card rounded-[14px] overflow-hidden",\n "shadow-[0_1px_3px_rgba(0,0,0,0.04),0_6px_24px_rgba(0,0,0,0.06)]",\n "transition-all duration-300 ease-[cubic-bezier(0.19,1,0.22,1)]",\n "hover:-translate-y-1 hover:shadow-[0_2px_6px_rgba(0,0,0,0.04),0_12px_40px_rgba(0,0,0,0.10)]",\n className,\n );\n\n const inner = (\n <div className="p-4">\n <div className="flex items-start justify-between gap-3">\n <div className="flex-1 min-w-0">\n <h3 className="text-[14.5px] font-bold text-foreground leading-snug tracking-tight">\n {product.name}\n </h3>\n <p className="text-[12.5px] text-muted-foreground mt-0.5">\n {durationLabel && <>{durationLabel} \xB7 </>}\n <Price amount={product.default_price} />\n </p>\n </div>\n {image && (\n <div className="w-14 h-14 rounded-xl overflow-hidden bg-muted shrink-0">\n {renderImage ? (\n renderImage({ src: image, alt: product.name, className: "w-full h-full object-cover" })\n ) : (\n <img src={image} alt={product.name} className="w-full h-full object-cover" />\n )}\n </div>\n )}\n </div>\n\n {product.description && (\n <p className="text-[12px] text-muted-foreground mt-2 line-clamp-2 leading-relaxed">\n {product.description}\n </p>\n )}\n\n {displaySlots.length > 0 && (\n <div className="mt-4 pt-3 border-t border-border">\n <p className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest mb-2">\n {product.general_service_capacity != null && product.general_service_capacity > 1\n ? "Upcoming classes"\n : "Next available"\n }\n </p>\n <div className="flex gap-2 flex-wrap">\n {displaySlots.map((slot, i) => (\n <button\n key={i}\n onClick={(e) => { e.preventDefault(); e.stopPropagation(); onBook?.(product); }}\n className={cn(\n "text-[12.5px] font-medium px-3 py-1.5 rounded-lg transition-all duration-200 ease-out hover:scale-[1.04]",\n i === 0\n ? "bg-primary text-primary-foreground font-semibold shadow-sm"\n : "bg-muted text-foreground hover:bg-muted/80",\n )}\n >\n {new Date(slot.start_time).toLocaleString(undefined, {\n weekday: "short",\n hour: "numeric",\n minute: "2-digit",\n })}\n </button>\n ))}\n </div>\n </div>\n )}\n </div>\n );\n\n if (renderLink) {\n return renderLink({ href, className: shellClass, children: inner });\n }\n\n return <a href={href} className={shellClass} data-cimplify-card>{inner}</a>;\n}\n' }] }, "recently-viewed": { "name": "recently-viewed", "title": "RecentlyViewed", "description": "Horizontally scrollable rail of recently viewed products, hydrated from local activity state.", "type": "component", "registryDependencies": ["product-card", "cn"], "files": [{ "path": "recently-viewed.tsx", "content": '"use client";\n\nimport React from "react";\nimport { useActivityState } from "./hooks/use-activity-state";\nimport { cn } from "@cimplify/sdk/react";\n\ninterface ViewedProduct {\n product_id: string;\n product_name?: string;\n category_id?: string;\n}\n\nexport interface RecentlyViewedClassNames {\n root?: string;\n item?: string;\n empty?: string;\n loading?: string;\n}\n\nexport interface RecentlyViewedProps {\n /** Maximum number of viewed products to display. */\n limit?: number;\n /** Called when a viewed product is clicked. */\n onProductClick?: (product: ViewedProduct) => void;\n /** Custom product renderer. */\n renderProduct?: (product: ViewedProduct) => React.ReactNode;\n /** Text shown when no viewed products exist. */\n emptyMessage?: string;\n className?: string;\n classNames?: RecentlyViewedClassNames;\n}\n\n/**\n * RecentlyViewed \u2014 displays products the user has recently viewed during their session.\n *\n * Extracts `state.activity.viewed_products` from the activity state.\n * Returns `null` when there are no viewed products.\n */\nexport function RecentlyViewed({\n limit,\n onProductClick,\n renderProduct,\n emptyMessage,\n className,\n classNames,\n}: RecentlyViewedProps): React.ReactElement | null {\n const { state, isLoading } = useActivityState();\n\n const rawViewed = state?.activity?.viewed_products ?? [];\n const viewed = limit ? rawViewed.slice(0, limit) : rawViewed;\n\n if (isLoading && viewed.length === 0) {\n return (\n <div\n data-cimplify-recently-viewed\n aria-busy="true"\n className={cn(className, classNames?.root, classNames?.loading)}\n />\n );\n }\n\n if (viewed.length === 0) {\n return null;\n }\n\n return (\n <div data-cimplify-recently-viewed className={cn(className, classNames?.root)}>\n {viewed.map((product) => (\n <button\n key={product.product_id}\n type="button"\n onClick={() => onProductClick?.(product)}\n data-cimplify-recently-viewed-item\n className={classNames?.item}\n >\n {renderProduct ? (\n renderProduct(product)\n ) : (\n <span>{product.product_name ?? product.product_id}</span>\n )}\n </button>\n ))}\n </div>\n );\n}\n' }] }, "bundle-selector": { "name": "bundle-selector", "title": "BundleSelector", "description": "Bundle component picker with variant choices and price summary.", "type": "component", "registryDependencies": ["price"], "files": [{ "path": "bundle-selector.tsx", "content": '"use client";\n\nimport React, { useState, useCallback, useMemo, useEffect, useRef, useId } from "react";\nimport { RadioGroup } from "@base-ui/react/radio-group";\nimport { Radio } from "@base-ui/react/radio";\nimport type { BundleComponentView, BundleComponentVariantView, BundlePriceType, DurationUnit } from "@cimplify/sdk";\nimport type { Money } from "@cimplify/sdk";\nimport type { BundleSelectionInput } from "@cimplify/sdk";\nimport { parsePrice } from "@cimplify/sdk";\nimport { Price } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface BundleSelectorClassNames {\n root?: string;\n heading?: string;\n components?: string;\n component?: string;\n componentHeader?: string;\n componentQty?: string;\n componentName?: string;\n typeBadge?: string;\n serviceBadge?: string;\n digitalBadge?: string;\n variantPicker?: string;\n variantOption?: string;\n variantOptionSelected?: string;\n variantAdjustment?: string;\n summary?: string;\n savings?: string;\n}\n\nexport interface BundleSelectorProps {\n components: BundleComponentView[];\n bundlePrice?: Money;\n discountValue?: Money;\n pricingType?: BundlePriceType;\n onSelectionsChange: (selections: BundleSelectionInput[]) => void;\n onPriceChange?: (price: number) => void;\n onReady?: (ready: boolean) => void;\n className?: string;\n classNames?: BundleSelectorClassNames;\n}\n\nexport function BundleSelector({\n components,\n bundlePrice,\n discountValue,\n pricingType,\n onSelectionsChange,\n onPriceChange,\n onReady,\n className,\n classNames,\n}: BundleSelectorProps): React.ReactElement | null {\n const [variantChoices, setVariantChoices] = useState<Record<string, string>>({});\n const lastComponentIds = useRef("");\n\n useEffect(() => {\n const ids = components.map((c) => c.id).sort().join();\n if (ids === lastComponentIds.current) return;\n lastComponentIds.current = ids;\n\n const defaults: Record<string, string> = {};\n for (const comp of components) {\n if (comp.variant_id) {\n defaults[comp.id] = comp.variant_id;\n } else if (comp.available_variants.length > 0) {\n const defaultVariant =\n comp.available_variants.find((v) => v.is_default) || comp.available_variants[0];\n if (defaultVariant) {\n defaults[comp.id] = defaultVariant.id;\n }\n }\n }\n setVariantChoices(defaults);\n }, [components]);\n\n const selections = useMemo((): BundleSelectionInput[] => {\n return components.map((comp) => ({\n component_id: comp.id,\n variant_id: variantChoices[comp.id],\n quantity: comp.quantity,\n }));\n }, [components, variantChoices]);\n\n useEffect(() => {\n onSelectionsChange(selections);\n }, [selections, onSelectionsChange]);\n\n useEffect(() => {\n onReady?.(components.length > 0 && selections.length > 0);\n }, [components, selections, onReady]);\n\n const totalPrice = useMemo(() => {\n if (pricingType === "fixed" && bundlePrice) {\n return parsePrice(bundlePrice);\n }\n const componentsTotal = components.reduce((sum, comp) => {\n return sum + getComponentPrice(comp, variantChoices[comp.id]) * comp.quantity;\n }, 0);\n if (pricingType === "percentage_discount" && discountValue) {\n return componentsTotal * (1 - parsePrice(discountValue) / 100);\n }\n if (pricingType === "fixed_discount" && discountValue) {\n return componentsTotal - parsePrice(discountValue);\n }\n return componentsTotal;\n }, [components, variantChoices, pricingType, bundlePrice, discountValue]);\n\n useEffect(() => {\n onPriceChange?.(totalPrice);\n }, [totalPrice, onPriceChange]);\n\n const handleVariantChange = useCallback(\n (componentId: string, variantId: string) => {\n setVariantChoices((prev) => ({ ...prev, [componentId]: variantId }));\n },\n [],\n );\n\n if (components.length === 0) {\n return null;\n }\n\n return (\n <div data-cimplify-bundle-selector className={cn("space-y-4", className, classNames?.root)}>\n <div\n data-cimplify-bundle-heading\n className={cn("flex items-center justify-between py-3", classNames?.heading)}\n >\n <span className="text-base font-bold">Included in this bundle</span>\n </div>\n\n <div data-cimplify-bundle-components className={cn("divide-y divide-border", classNames?.components)}>\n {components.map((comp) => (\n <BundleComponentCard\n key={comp.id}\n component={comp}\n selectedVariantId={variantChoices[comp.id]}\n onVariantChange={(variantId) =>\n handleVariantChange(comp.id, variantId)\n }\n classNames={classNames}\n />\n ))}\n </div>\n\n {bundlePrice && (\n <div\n data-cimplify-bundle-summary\n className={cn("border-t border-border pt-4 flex justify-between text-sm", classNames?.summary)}\n >\n <span className="text-muted-foreground">Bundle price</span>\n <Price amount={bundlePrice} className="font-medium text-primary" />\n </div>\n )}\n {discountValue && (\n <div\n data-cimplify-bundle-savings\n className={cn("flex justify-between text-sm", classNames?.savings)}\n >\n <span className="text-muted-foreground">You save</span>\n <Price amount={discountValue} className="text-green-600 font-medium" />\n </div>\n )}\n </div>\n );\n}\n\nfunction getComponentPrice(\n component: BundleComponentView,\n selectedVariantId: string | undefined,\n): number {\n if (!selectedVariantId || component.available_variants.length === 0) {\n return parsePrice(component.effective_price);\n }\n if (selectedVariantId === component.variant_id) {\n return parsePrice(component.effective_price);\n }\n const bakedAdj = component.variant_id\n ? component.available_variants.find((v) => v.id === component.variant_id)\n : undefined;\n const selectedAdj = component.available_variants.find((v) => v.id === selectedVariantId);\n if (!selectedAdj) return parsePrice(component.effective_price);\n return parsePrice(component.effective_price)\n - parsePrice(bakedAdj?.price_adjustment ?? "0")\n + parsePrice(selectedAdj.price_adjustment);\n}\n\nfunction formatDuration(minutes: number, unit?: DurationUnit): string {\n if (unit === "hours" || (!unit && minutes >= 60 && minutes % 60 === 0)) {\n const h = Math.round(minutes / 60);\n return `${h}h`;\n }\n if (unit === "days" || unit === "nights") {\n const d = Math.round(minutes / 1440);\n return `${d}${unit === "nights" ? "n" : "d"}`;\n }\n return `${minutes}min`;\n}\n\ninterface BundleComponentCardProps {\n component: BundleComponentView;\n selectedVariantId?: string;\n onVariantChange: (variantId: string) => void;\n classNames?: BundleSelectorClassNames;\n}\n\nfunction BundleComponentCard({\n component,\n selectedVariantId,\n onVariantChange,\n classNames,\n}: BundleComponentCardProps): React.ReactElement {\n const idPrefix = useId();\n const showVariantPicker =\n component.allow_variant_choice && component.available_variants.length > 1;\n\n const displayPrice = useMemo(\n () => getComponentPrice(component, selectedVariantId),\n [component, selectedVariantId],\n );\n\n const labelId = `${idPrefix}-bundle-component-${component.id}`;\n\n return (\n <div\n data-cimplify-bundle-component\n className={cn("py-4", classNames?.component)}\n >\n <div\n data-cimplify-bundle-component-header\n className={cn("flex items-center justify-between gap-3", classNames?.componentHeader)}\n >\n <div className="flex items-center gap-2">\n {component.quantity > 1 && (\n <span\n data-cimplify-bundle-component-qty\n className={cn("text-xs font-medium text-primary bg-primary/10 px-1.5 py-0.5 rounded", classNames?.componentQty)}\n >\n ×{component.quantity}\n </span>\n )}\n <span\n id={labelId}\n data-cimplify-bundle-component-name\n className={cn("text-sm", classNames?.componentName)}\n >\n {component.product_name}\n </span>\n {component.product_type === "service" && (\n <span\n data-cimplify-bundle-type-badge="service"\n className={cn(\n "text-[10px] uppercase tracking-wider font-medium text-blue-600",\n classNames?.typeBadge,\n classNames?.serviceBadge,\n )}\n >\n <svg viewBox="0 0 16 16" fill="none" className="inline-block w-3 h-3 mr-0.5 -mt-px" aria-hidden="true">\n <circle cx="8" cy="8" r="7" stroke="currentColor" strokeWidth="1.5"/>\n <path d="M8 4v4l2.5 1.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>\n </svg>\n Service{component.duration_minutes != null && (\n <> · {formatDuration(component.duration_minutes, component.duration_unit)}</>\n )}\n </span>\n )}\n {component.product_type === "digital" && (\n <span\n data-cimplify-bundle-type-badge="digital"\n className={cn(\n "text-[10px] uppercase tracking-wider font-medium text-violet-600",\n classNames?.typeBadge,\n classNames?.digitalBadge,\n )}\n >\n <svg viewBox="0 0 16 16" fill="none" className="inline-block w-3 h-3 mr-0.5 -mt-px" aria-hidden="true">\n <path d="M9 2L4 9h4l-1 5 5-7H8l1-5z" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>\n </svg>\n Digital\n </span>\n )}\n </div>\n <span className="text-sm text-muted-foreground">\n <Price amount={displayPrice} />\n </span>\n </div>\n\n {showVariantPicker && (\n <RadioGroup\n aria-labelledby={labelId}\n value={selectedVariantId ?? ""}\n onValueChange={(value) => {\n onVariantChange(value);\n }}\n data-cimplify-bundle-variant-picker\n className={cn("mt-3 divide-y divide-border", classNames?.variantPicker)}\n >\n {component.available_variants.map((variant: BundleComponentVariantView) => {\n const isSelected = selectedVariantId === variant.id;\n const adjustment = parsePrice(variant.price_adjustment);\n\n return (\n <Radio.Root\n key={variant.id}\n value={variant.id}\n data-cimplify-bundle-variant-option\n data-selected={isSelected || undefined}\n className={cn(\n "w-full flex items-center gap-3 py-3 transition-colors cursor-pointer",\n isSelected ? classNames?.variantOptionSelected : classNames?.variantOption,\n )}\n >\n <span\n className={cn(\n "w-5 h-5 rounded-full border-2 flex items-center justify-center shrink-0 transition-colors",\n isSelected ? "border-primary" : "border-muted-foreground/30",\n )}\n >\n {isSelected && <span className="w-2.5 h-2.5 rounded-full bg-primary" />}\n </span>\n <span className="flex-1 text-sm">\n {variant.display_name}\n </span>\n {adjustment !== 0 && (\n <span\n data-cimplify-bundle-variant-adjustment\n className={cn("text-sm text-muted-foreground", classNames?.variantAdjustment)}\n >\n {adjustment > 0 ? "+" : ""}\n <Price amount={variant.price_adjustment} />\n </span>\n )}\n </Radio.Root>\n );\n })}\n </RadioGroup>\n )}\n </div>\n );\n}\n' }] }, "accommodation-card": { "name": "accommodation-card", "title": "AccommodationCard", "description": "Hotel/accommodation card with per-night pricing, amenities, capacity, and cancellation.", "type": "component", "registryDependencies": ["price", "volume-pricing", "cn"], "files": [{ "path": "cards/accommodation-card.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { ServiceCardLayoutProps } from "./standard-service-card";\nimport { CardShell, CardImage } from "@cimplify/sdk/react";\nimport { Price } from "@cimplify/sdk/react";\nimport { VolumePricing } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport function AccommodationCard({\n product,\n onBook,\n renderImage,\n renderLink,\n className,\n}: ServiceCardLayoutProps): React.ReactElement {\n const image = product.image_url || product.images?.[0] || "";\n const hasTiers = product.quantity_pricing && product.quantity_pricing.length > 1;\n const cancellationHours = product.cancellation_window_minutes\n ? Math.floor(product.cancellation_window_minutes / 60)\n : null;\n const tags = product.tags || [];\n\n return (\n <CardShell product={product} renderLink={renderLink} className={className}>\n <CardImage src={image} alt={product.name} aspectRatio="16/9" renderImage={renderImage}>\n <div className="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent pointer-events-none" />\n\n <span className="absolute bottom-3 left-3 inline-flex items-center text-[12px] font-semibold px-2.5 py-1 rounded-lg bg-background/92 backdrop-blur-xl text-foreground shadow-sm">\n Per night\n </span>\n\n <span className="absolute bottom-3 right-3 text-white font-bold text-lg drop-shadow-sm">\n <Price amount={product.default_price} />/night\n </span>\n </CardImage>\n\n <div className="p-4">\n <h3 className="text-[14.5px] font-bold text-foreground leading-snug tracking-tight">\n {product.name}\n </h3>\n {product.description && (\n <p className="text-[12.5px] text-muted-foreground mt-1 leading-relaxed line-clamp-2">\n {product.description}\n </p>\n )}\n\n {/* Amenity / tag pills */}\n {tags.length > 0 && (\n <div className="flex flex-wrap gap-1 mt-2.5">\n {tags.slice(0, 4).map((tag) => (\n <span key={tag} className="text-[10px] font-medium text-muted-foreground px-1.5 py-0.5 bg-muted rounded">\n {tag}\n </span>\n ))}\n </div>\n )}\n\n {/* Guest capacity */}\n {product.general_service_capacity != null && (\n <div className="flex items-center gap-1.5 mt-2 text-[11px] text-muted-foreground">\n <svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24">\n <path strokeLinecap="round" strokeLinejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0" />\n </svg>\n {product.general_service_capacity} guest{product.general_service_capacity > 1 ? "s" : ""}\n </div>\n )}\n\n {/* Extended stay pricing */}\n {hasTiers && (\n <div className="mt-3">\n <VolumePricing tiers={product.quantity_pricing!} />\n </div>\n )}\n\n <div className="flex items-center justify-between mt-3 pt-3 border-t border-border">\n <div className="flex items-center gap-2 text-[11px] text-muted-foreground">\n {cancellationHours != null && (\n <span className="text-emerald-600 font-medium">\n Free cancellation {cancellationHours}h\n </span>\n )}\n {product.min_order_quantity != null && product.min_order_quantity > 1 && (\n <span>Min. {product.min_order_quantity} nights</span>\n )}\n </div>\n <button\n onClick={(e) => { e.preventDefault(); e.stopPropagation(); onBook?.(product); }}\n className="text-[13px] font-semibold text-primary hover:text-primary/80 transition-colors"\n >\n Book room →\n </button>\n </div>\n </div>\n </CardShell>\n );\n}\n' }] }, "booking-list": { "name": "booking-list", "title": "BookingList", "description": "List of booking cards with optional self-fetching.", "type": "component", "registryDependencies": ["booking-card", "cn"], "files": [{ "path": "booking-list.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { CustomerBooking } from "@cimplify/sdk";\nimport { useBookings } from "@cimplify/sdk/react";\nimport { BookingCard } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface BookingListClassNames {\n root?: string;\n item?: string;\n loading?: string;\n empty?: string;\n}\n\nexport interface BookingListProps {\n /** Pre-fetched bookings (skips fetch). */\n bookings?: CustomerBooking[];\n /** Filter: "all", "upcoming", or "past". */\n filter?: "all" | "upcoming" | "past";\n /** Called when cancel is clicked on a booking. */\n onCancel?: (booking: CustomerBooking) => void;\n /** Called when reschedule is clicked on a booking. */\n onReschedule?: (booking: CustomerBooking) => void;\n /** Called when a booking is clicked. */\n onBookingClick?: (booking: CustomerBooking) => void;\n /** Custom booking renderer. */\n renderBooking?: (booking: CustomerBooking) => React.ReactNode;\n /** Text shown when empty. */\n emptyMessage?: string;\n className?: string;\n classNames?: BookingListClassNames;\n}\n\nexport function BookingList({\n bookings: bookingsProp,\n filter,\n onCancel,\n onReschedule,\n onBookingClick,\n renderBooking,\n emptyMessage = "No bookings yet",\n className,\n classNames,\n}: BookingListProps): React.ReactElement {\n const { bookings: fetched, isLoading } = useBookings({\n filter,\n enabled: bookingsProp === undefined,\n });\n\n const bookings = bookingsProp ?? fetched;\n\n if (isLoading && bookings.length === 0) {\n return (\n <div\n data-cimplify-booking-list\n aria-busy="true"\n className={cn(className, classNames?.root, classNames?.loading)}\n />\n );\n }\n\n if (bookings.length === 0) {\n return (\n <div\n data-cimplify-booking-list\n data-empty\n className={cn(className, classNames?.root, classNames?.empty)}\n >\n <p>{emptyMessage}</p>\n </div>\n );\n }\n\n return (\n <div data-cimplify-booking-list className={cn(className, classNames?.root)}>\n {bookings.map((booking) => (\n <div\n key={booking.order_id}\n data-cimplify-booking-list-item\n className={classNames?.item}\n onClick={() => onBookingClick?.(booking)}\n role={onBookingClick ? "button" : undefined}\n tabIndex={onBookingClick ? 0 : undefined}\n onKeyDown={\n onBookingClick\n ? (e) => {\n if (e.key === "Enter" || e.key === " ") {\n e.preventDefault();\n onBookingClick(booking);\n }\n }\n : undefined\n }\n >\n <BookingCard\n booking={booking}\n onCancel={onCancel}\n onReschedule={onReschedule}\n renderBooking={renderBooking}\n />\n </div>\n ))}\n </div>\n );\n}\n' }] }, "lease-service-card": { "name": "lease-service-card", "title": "LeaseServiceCard", "description": "Long-term lease card with per-month/year pricing, volume tiers, and billing.", "type": "component", "registryDependencies": ["price", "volume-pricing", "cn"], "files": [{ "path": "cards/lease-service-card.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { ServiceCardLayoutProps } from "./standard-service-card";\nimport { CardShell, CardImage } from "@cimplify/sdk/react";\nimport { Price } from "@cimplify/sdk/react";\nimport { VolumePricing } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nconst UNIT_LABELS: Record<string, string> = {\n weeks: "week",\n months: "mo",\n years: "yr",\n};\n\nexport function LeaseServiceCard({\n product,\n onBook,\n renderImage,\n renderLink,\n className,\n}: ServiceCardLayoutProps): React.ReactElement {\n const image = product.image_url || product.images?.[0] || "";\n const hasDeposit = product.deposit_type && product.deposit_type !== "none" && product.deposit_amount;\n const hasTiers = product.quantity_pricing && product.quantity_pricing.length > 1;\n const unit = product.duration_unit ? UNIT_LABELS[product.duration_unit] || product.duration_unit : "mo";\n const tags = product.tags || [];\n const cancellationHours = product.cancellation_window_minutes\n ? Math.floor(product.cancellation_window_minutes / 60)\n : null;\n\n return (\n <CardShell product={product} renderLink={renderLink} className={className}>\n <CardImage src={image} alt={product.name} aspectRatio="16/9" renderImage={renderImage}>\n <div className="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent pointer-events-none" />\n\n <span className="absolute bottom-3 left-3 inline-flex items-center text-[12px] font-semibold px-2.5 py-1 rounded-lg bg-background/92 backdrop-blur-xl text-foreground shadow-sm">\n Per {unit}\n </span>\n\n <span className="absolute bottom-3 right-3 text-white font-bold text-lg drop-shadow-sm">\n <Price amount={product.default_price} />/{unit}\n </span>\n </CardImage>\n\n <div className="p-4">\n <h3 className="text-[14.5px] font-bold text-foreground leading-snug tracking-tight">\n {product.name}\n </h3>\n {product.description && (\n <p className="text-[12.5px] text-muted-foreground mt-1 leading-relaxed line-clamp-2">\n {product.description}\n </p>\n )}\n\n {/* Tags as amenity pills */}\n {tags.length > 0 && (\n <div className="flex flex-wrap gap-1 mt-2.5">\n {tags.slice(0, 4).map((tag) => (\n <span key={tag} className="text-[10px] font-medium text-muted-foreground px-1.5 py-0.5 bg-muted rounded">\n {tag}\n </span>\n ))}\n </div>\n )}\n\n {/* Volume tiers (lease terms) */}\n {hasTiers && (\n <div className="mt-3">\n <VolumePricing tiers={product.quantity_pricing!} />\n </div>\n )}\n\n {/* Info row */}\n <div className="flex flex-wrap items-center gap-2 mt-3">\n {product.min_order_quantity != null && product.min_order_quantity > 1 && (\n <span className="text-[10.5px] font-medium text-muted-foreground px-1.5 py-0.5 bg-muted rounded">\n Min. {product.min_order_quantity} {unit}{product.min_order_quantity > 1 ? "s" : ""}\n </span>\n )}\n {hasDeposit && (\n <span className="text-[10.5px] font-medium text-amber-600">\n <Price amount={product.deposit_amount!} /> deposit\n </span>\n )}\n {cancellationHours != null && (\n <span className="text-[10.5px] font-medium text-muted-foreground">\n {cancellationHours >= 720 ? `${Math.floor(cancellationHours / 720)} month notice` : `${cancellationHours}h notice`}\n </span>\n )}\n </div>\n\n <div className="flex items-center justify-between mt-3 pt-3 border-t border-border">\n {product.billing_plans && product.billing_plans.length > 0 && (\n <span className="text-[10.5px] font-semibold text-primary px-1.5 py-0.5 bg-primary/10 rounded">\n {product.billing_plans[0].frequency} billing\n </span>\n )}\n <button\n onClick={(e) => { e.preventDefault(); e.stopPropagation(); onBook?.(product); }}\n className="text-[13px] font-semibold text-primary hover:text-primary/80 transition-colors ml-auto"\n >\n Enquire now →\n </button>\n </div>\n </div>\n </CardShell>\n );\n}\n' }] }, "billing-plan-selector": { "name": "billing-plan-selector", "title": "BillingPlanSelector", "description": "Subscription / billing-plan picker \u2014 surfaces eligible plans with pricing and trial periods.", "type": "component", "registryDependencies": ["price", "cn"], "files": [{ "path": "billing-plan-selector.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { ProductBillingPlan, BillingFrequency } from "@cimplify/sdk";\nimport { useBillingPlans } from "./hooks/use-billing-plans";\nimport { Price } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface BillingPlanSelectorClassNames {\n root?: string;\n plan?: string;\n activePlan?: string;\n frequency?: string;\n price?: string;\n setupFee?: string;\n trialBadge?: string;\n empty?: string;\n loading?: string;\n}\n\nexport interface BillingPlanSelectorProps {\n /** Product ID to fetch billing plans for. */\n productId: string;\n /** Override plans (skips fetch). For SSR, pass pre-fetched plans. */\n plans?: ProductBillingPlan[];\n /** Called when a plan is selected. */\n onPlanSelect?: (plan: ProductBillingPlan | null) => void;\n /** Currently selected plan ID. */\n selectedPlanId?: string | null;\n /** Show a "One-time purchase" option at the top. */\n showOneTimePurchase?: boolean;\n /** Label for the one-time purchase option. Default: "One-time purchase". */\n oneTimePurchaseLabel?: string;\n /** Custom plan card renderer. */\n renderPlan?: (plan: ProductBillingPlan, isActive: boolean) => React.ReactNode;\n className?: string;\n classNames?: BillingPlanSelectorClassNames;\n}\n\nconst FREQUENCY_LABELS: Record<BillingFrequency, string> = {\n weekly: "Weekly",\n biweekly: "Biweekly",\n monthly: "Monthly",\n quarterly: "Quarterly",\n annually: "Annually",\n};\n\nfunction formatMarkup(plan: ProductBillingPlan): string | null {\n if (!plan.markup_type || plan.markup_amount == null) return null;\n if (plan.markup_type === "percentage") return `+${plan.markup_amount}%`;\n return null;\n}\n\n/**\n * BillingPlanSelector \u2014 subscription/installment plan comparison and selection.\n *\n * Renders plan cards with frequency, markup, trial, and setup fee details.\n * Returns `null` when no plans are available (and one-time purchase is hidden).\n */\nexport function BillingPlanSelector({\n productId,\n plans: plansProp,\n onPlanSelect,\n selectedPlanId,\n showOneTimePurchase = false,\n oneTimePurchaseLabel = "One-time purchase",\n renderPlan,\n className,\n classNames,\n}: BillingPlanSelectorProps): React.ReactElement | null {\n const { plans: fetched, isLoading } = useBillingPlans(productId, {\n enabled: plansProp === undefined,\n });\n\n const plans = plansProp ?? fetched;\n\n if (isLoading && plans.length === 0) {\n return (\n <div\n data-cimplify-billing-plan-selector\n aria-busy="true"\n className={cn(className, classNames?.root, classNames?.loading)}\n />\n );\n }\n\n if (plans.length === 0 && !showOneTimePurchase) {\n return null;\n }\n\n const isOneTimePurchaseSelected = selectedPlanId === null;\n\n return (\n <div data-cimplify-billing-plan-selector className={cn(className, classNames?.root)}>\n {showOneTimePurchase && (\n <button\n type="button"\n onClick={() => onPlanSelect?.(null)}\n data-cimplify-billing-plan\n data-plan-type="one-time"\n data-active={isOneTimePurchaseSelected || undefined}\n aria-pressed={isOneTimePurchaseSelected}\n className={cn(\n classNames?.plan,\n isOneTimePurchaseSelected && classNames?.activePlan,\n )}\n >\n <span data-cimplify-billing-frequency className={classNames?.frequency}>\n {oneTimePurchaseLabel}\n </span>\n </button>\n )}\n\n {plans.map((plan) => {\n const isActive = selectedPlanId === plan.id;\n const markup = formatMarkup(plan);\n\n return (\n <button\n key={plan.id}\n type="button"\n onClick={() => onPlanSelect?.(plan)}\n data-cimplify-billing-plan\n data-plan-type={plan.plan_type}\n data-active={isActive || undefined}\n aria-pressed={isActive}\n className={cn(classNames?.plan, isActive && classNames?.activePlan)}\n >\n {renderPlan ? (\n renderPlan(plan, isActive)\n ) : (\n <>\n <span data-cimplify-billing-frequency className={classNames?.frequency}>\n {FREQUENCY_LABELS[plan.frequency]}\n </span>\n\n {markup && (\n <span data-cimplify-billing-markup className={classNames?.price}>\n {markup}\n </span>\n )}\n\n {plan.markup_type === "fixed" && plan.markup_amount != null && (\n <span data-cimplify-billing-markup className={classNames?.price}>\n +<Price amount={plan.markup_amount} />\n </span>\n )}\n\n {plan.trial_days > 0 && (\n <span data-cimplify-billing-trial className={classNames?.trialBadge}>\n {plan.trial_days}-day trial\n </span>\n )}\n\n {plan.setup_fee > 0 && (\n <span data-cimplify-billing-setup-fee className={classNames?.setupFee}>\n Setup fee: <Price amount={plan.setup_fee} />\n </span>\n )}\n\n {plan.plan_type === "installment" && plan.installment_periods != null && (\n <span data-cimplify-billing-periods>\n {plan.installment_periods} payments\n </span>\n )}\n </>\n )}\n </button>\n );\n })}\n </div>\n );\n}\n' }] }, "digital-product-card": { "name": "digital-product-card", "title": "DigitalProductCard", "description": "Digital product card with type badge, file info, and event details.", "type": "component", "registryDependencies": ["price", "cn"], "files": [{ "path": "cards/digital-product-card.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { CardLayoutProps } from "@cimplify/sdk/react";\nimport { CardShell, CardImage } from "@cimplify/sdk/react";\nimport { Price } from "@cimplify/sdk/react";\n\nconst TYPE_BADGES: Record<string, { label: string; color: string }> = {\n download: { label: "Download", color: "bg-sky-50 text-sky-700 border-sky-200/60" },\n license: { label: "License", color: "bg-blue-50 text-blue-700 border-blue-200/60" },\n event_ticket: { label: "Event", color: "bg-violet-50 text-violet-700 border-violet-200/60" },\n access_pass: { label: "Access", color: "bg-emerald-50 text-emerald-700 border-emerald-200/60" },\n gift_card: { label: "Gift Card", color: "bg-amber-50 text-amber-700 border-amber-200/60" },\n};\n\nexport function DigitalProductCard({\n product,\n renderImage,\n renderLink,\n className,\n}: CardLayoutProps): React.ReactElement {\n const image = product.image_url || product.images?.[0] || "";\n const typeBadge = product.digital_type ? TYPE_BADGES[product.digital_type] : null;\n const isTicket = product.digital_type === "event_ticket";\n\n return (\n <CardShell product={product} renderLink={renderLink} className={className}>\n <CardImage src={image} alt={product.name} aspectRatio="4/3" renderImage={renderImage}>\n {typeBadge && (\n <span className={`absolute top-3 left-3 text-[11px] font-semibold tracking-wide border px-2 py-0.5 rounded-md ${typeBadge.color}`}>\n {typeBadge.label}\n </span>\n )}\n </CardImage>\n\n <div className="p-3.5">\n <h3 className="text-[14.5px] font-semibold text-foreground leading-snug tracking-tight truncate">\n {product.name}\n </h3>\n\n {/* Event: date + venue */}\n {isTicket && (product.event_date || product.venue) && (\n <p className="text-[12px] text-muted-foreground mt-1 truncate">\n {product.event_date && new Date(product.event_date).toLocaleDateString(undefined, { month: "short", day: "numeric" })}\n {product.event_date && product.venue && " \xB7 "}\n {product.venue}\n </p>\n )}\n\n {/* Non-event: description */}\n {!isTicket && product.description && (\n <p className="text-[12px] text-muted-foreground mt-1 line-clamp-1">\n {product.description}\n </p>\n )}\n\n <div className="flex items-center justify-between mt-2.5">\n <Price amount={product.default_price} className="text-sm font-bold" />\n\n {/* File info pill */}\n {product.file_type && (\n <span className="text-[10px] text-muted-foreground bg-muted px-1.5 py-0.5 rounded">\n {product.file_type.toUpperCase()}\n {product.file_size_mb != null && ` \xB7 ${product.file_size_mb >= 1024 ? `${(product.file_size_mb / 1024).toFixed(1)}GB` : `${product.file_size_mb}MB`}`}\n </span>\n )}\n </div>\n </div>\n </CardShell>\n );\n}\n' }] }, "price-range": { "name": "price-range", "title": "PriceRange", "description": "Displays min-max price range for products with variants or tiers.", "type": "component", "registryDependencies": [], "files": [{ "path": "price-range.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { Product, ProductWithDetails } from "@cimplify/sdk";\nimport type { CurrencyCode } from "@cimplify/sdk";\nimport { getPriceRange, formatPriceRange } from "@cimplify/sdk";\nimport { useOptionalCimplify } from "@cimplify/sdk/react";\n\nexport interface PriceRangeProps {\n product: Product | ProductWithDetails;\n currency?: CurrencyCode;\n className?: string;\n}\n\nexport function PriceRange({ product, currency, className }: PriceRangeProps): React.ReactElement | null {\n const context = useOptionalCimplify();\n const resolvedCurrency = currency ?? (context?.displayCurrency as CurrencyCode | undefined) ?? "USD";\n const range = getPriceRange(product);\n\n if (!range) return null;\n\n return (\n <span data-cimplify-price-range className={className}>\n {formatPriceRange(range.min, range.max, resolvedCurrency)}\n </span>\n );\n}\n' }] }, "order-history-page": { "name": "order-history-page", "title": "OrderHistoryPage", "description": "Order list with status filtering and inline detail view.", "type": "component", "registryDependencies": ["order-history", "order-summary", "cn"], "files": [{ "path": "order-history-page.tsx", "content": '"use client";\n\nimport React, { useState, useCallback } from "react";\nimport { Tabs } from "@base-ui/react/tabs";\nimport type { Order, OrderStatus } from "@cimplify/sdk";\nimport { OrderHistory } from "@cimplify/sdk/react";\nimport { OrderSummary } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface OrderHistoryPageClassNames {\n root?: string;\n header?: string;\n title?: string;\n filters?: string;\n filterButton?: string;\n list?: string;\n detail?: string;\n backButton?: string;\n}\n\nexport interface OrderHistoryPageProps {\n /** Page title. */\n title?: string;\n /** Pre-fetched orders for SSR. */\n orders?: Order[];\n /** Max orders to show per page. */\n limit?: number;\n /** Called when navigating to an order detail (e.g. for routing). */\n onOrderNavigate?: (order: Order) => void;\n /** Called when the reorder button is clicked. */\n onReorder?: (order: Order) => void;\n /** Show status filter tabs. Default: true. */\n showFilters?: boolean;\n /** Custom order row renderer. */\n renderOrder?: (order: Order) => React.ReactNode;\n className?: string;\n classNames?: OrderHistoryPageClassNames;\n}\n\nconst STATUS_FILTERS: { label: string; value: OrderStatus | undefined; tabValue: string }[] = [\n { label: "All", value: undefined, tabValue: "all" },\n { label: "Active", value: "confirmed", tabValue: "confirmed" },\n { label: "Completed", value: "completed", tabValue: "completed" },\n { label: "Cancelled", value: "cancelled", tabValue: "cancelled" },\n];\n\n/**\n * OrderHistoryPage \u2014 order list with inline detail view and status filtering.\n *\n * SSR-friendly: pass `orders` prop for server rendering.\n * Supports both inline detail view and external navigation via `onOrderNavigate`.\n */\nexport function OrderHistoryPage({\n title = "Order History",\n orders: ordersProp,\n limit = 20,\n onOrderNavigate,\n onReorder,\n showFilters = true,\n renderOrder,\n className,\n classNames,\n}: OrderHistoryPageProps): React.ReactElement {\n const [statusFilter, setStatusFilter] = useState<OrderStatus | undefined>(undefined);\n const [selectedOrder, setSelectedOrder] = useState<Order | null>(null);\n\n const handleTabChange = useCallback((value: string | number | null) => {\n const filter = STATUS_FILTERS.find((f) => f.tabValue === value);\n setStatusFilter(filter?.value);\n }, []);\n\n const handleOrderClick = useCallback(\n (order: Order) => {\n if (onOrderNavigate) {\n onOrderNavigate(order);\n } else {\n setSelectedOrder(order);\n }\n },\n [onOrderNavigate],\n );\n\n const handleBack = useCallback(() => {\n setSelectedOrder(null);\n }, []);\n\n const activeTabValue = STATUS_FILTERS.find((f) => f.value === statusFilter)?.tabValue ?? "all";\n\n // Inline detail view\n if (selectedOrder && !onOrderNavigate) {\n return (\n <div data-cimplify-order-history-page className={cn(className, classNames?.root)}>\n <div data-cimplify-order-history-detail className={classNames?.detail}>\n <button\n type="button"\n onClick={handleBack}\n data-cimplify-order-history-back\n className={classNames?.backButton}\n >\n Back to orders\n </button>\n <OrderSummary order={selectedOrder} onReorder={onReorder} />\n </div>\n </div>\n );\n }\n\n return (\n <div data-cimplify-order-history-page className={cn(className, classNames?.root)}>\n {/* Header */}\n <div data-cimplify-order-history-header className={classNames?.header}>\n <h1 data-cimplify-order-history-title className={classNames?.title}>\n {title}\n </h1>\n </div>\n\n {/* Status filter */}\n {showFilters && (\n <Tabs.Root value={activeTabValue} onValueChange={handleTabChange}>\n <Tabs.List data-cimplify-order-history-filters className={classNames?.filters}>\n {STATUS_FILTERS.map((filter) => (\n <Tabs.Tab\n key={filter.tabValue}\n value={filter.tabValue}\n data-cimplify-order-filter\n data-selected={statusFilter === filter.value || undefined}\n className={classNames?.filterButton}\n >\n {filter.label}\n </Tabs.Tab>\n ))}\n </Tabs.List>\n </Tabs.Root>\n )}\n\n {/* Order list */}\n <div data-cimplify-order-history-list className={classNames?.list}>\n <OrderHistory\n orders={ordersProp}\n status={statusFilter}\n limit={limit}\n onOrderClick={handleOrderClick}\n renderOrder={renderOrder}\n onReorder={onReorder}\n />\n </div>\n </div>\n );\n}\n' }] }, "wholesale-product-card": { "name": "wholesale-product-card", "title": "WholesaleProductCard", "description": "B2B product card with price range, MOQ badge, and stock count.", "type": "component", "registryDependencies": ["price", "price-range", "cn"], "files": [{ "path": "cards/wholesale-product-card.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { CardLayoutProps } from "@cimplify/sdk/react";\nimport { CardShell, CardImage, QuickAddButton } from "@cimplify/sdk/react";\nimport { Price } from "@cimplify/sdk/react";\nimport { PriceRange } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport function WholesaleProductCard({\n product,\n onQuickAdd,\n renderImage,\n renderLink,\n className,\n}: CardLayoutProps): React.ReactElement {\n const image = product.image_url || product.images?.[0] || "";\n const hasTiers = product.quantity_pricing && product.quantity_pricing.length > 1;\n const status = product.inventory_status;\n\n return (\n <CardShell product={product} renderLink={renderLink} className={className}>\n <CardImage src={image} alt={product.name} aspectRatio="square" renderImage={renderImage}>\n {/* MOQ badge */}\n {product.min_order_quantity != null && product.min_order_quantity > 1 && (\n <span className="absolute top-3 left-3 text-[11px] font-semibold tracking-wide bg-zinc-800 text-white px-2 py-0.5 rounded-md">\n MOQ: {product.min_order_quantity}\n </span>\n )}\n\n <QuickAddButton\n onClick={() => onQuickAdd?.(product)}\n className="absolute bottom-3 right-3"\n />\n </CardImage>\n\n <div className="p-3.5">\n {/* SKU */}\n {product.sku && (\n <p className="text-[10px] text-muted-foreground font-mono">\n {product.sku}\n </p>\n )}\n\n {/* Name */}\n <h3 className="text-[14.5px] font-semibold text-foreground leading-snug tracking-tight mt-1 truncate">\n {product.name}\n </h3>\n\n {/* Price range or single */}\n <div className="mt-2">\n {hasTiers ? (\n <PriceRange product={product} className="text-sm font-bold" />\n ) : (\n <Price amount={product.default_price} className="text-sm font-bold" />\n )}\n </div>\n\n {/* Stock */}\n {status && (\n <div className="flex items-center gap-1.5 mt-2">\n <span className={cn(\n "w-[7px] h-[7px] rounded-full shrink-0",\n status.in_stock\n ? status.low_stock ? "bg-amber-500" : "bg-emerald-500"\n : "bg-red-500",\n )} />\n <span className={cn(\n "text-[10.5px] font-medium",\n status.in_stock\n ? status.low_stock ? "text-amber-600" : "text-emerald-600"\n : "text-red-600",\n )}>\n {!status.in_stock\n ? "Out of stock"\n : status.stock_level != null\n ? `${status.stock_level.toLocaleString()} in stock`\n : "In stock"\n }\n </span>\n </div>\n )}\n </div>\n </CardShell>\n );\n}\n' }] }, "availability-badge": { "name": "availability-badge", "title": "AvailabilityBadge", "description": "Displays in-stock / out-of-stock status for tracked products.", "type": "component", "registryDependencies": ["cn"], "files": [{ "path": "availability-badge.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { Product } from "@cimplify/sdk";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface AvailabilityBadgeClassNames {\n root?: string;\n dot?: string;\n label?: string;\n lowStock?: string;\n stockCount?: string;\n}\n\nexport interface AvailabilityBadgeProps {\n /** The product to check availability for. */\n product: Product;\n /** Override availability (e.g. from location_availability). */\n isAvailable?: boolean;\n /** Override stock status (e.g. from location_availability). */\n isInStock?: boolean;\n /** Current stock quantity. When provided, enables "Only X left" display. */\n stockQuantity?: number;\n /** Threshold at which stock is considered low. Default: 5. */\n lowStockThreshold?: number;\n /** Location context for availability checks. Passed through as a data attribute. */\n locationId?: string;\n className?: string;\n classNames?: AvailabilityBadgeClassNames;\n}\n\n/**\n * AvailabilityBadge \u2014 displays in-stock / out-of-stock status for tracked products.\n *\n * Returns `null` for products that don\'t have inventory tracking enabled,\n * since there\'s no meaningful stock state to show.\n */\nexport function AvailabilityBadge({\n product,\n isAvailable,\n isInStock,\n stockQuantity,\n lowStockThreshold = 5,\n locationId,\n className,\n classNames,\n}: AvailabilityBadgeProps): React.ReactElement | null {\n if (product.is_tracked !== true) {\n return null;\n }\n\n const outOfStock =\n isInStock === false ||\n isAvailable === false ||\n (stockQuantity !== undefined && stockQuantity <= 0);\n const isLowStock =\n !outOfStock &&\n stockQuantity !== undefined &&\n stockQuantity > 0 &&\n stockQuantity <= lowStockThreshold;\n\n const stockState = outOfStock\n ? "out_of_stock"\n : isLowStock\n ? "low_stock"\n : "in_stock";\n const label = outOfStock ? "Out of Stock" : "In Stock";\n\n return (\n <span\n data-cimplify-availability-badge\n data-stock-state={stockState}\n {...(locationId ? { "data-location-id": locationId } : undefined)}\n className={cn(className, classNames?.root, isLowStock && classNames?.lowStock)}\n style={{ display: "inline-flex", alignItems: "center", gap: "0.375rem" }}\n >\n <span\n data-cimplify-availability-dot\n className={classNames?.dot}\n style={{\n display: "inline-block",\n width: "0.5rem",\n height: "0.5rem",\n borderRadius: "9999px",\n }}\n />\n <span data-cimplify-availability-label className={classNames?.label}>\n {label}\n </span>\n {stockQuantity !== undefined && isLowStock && (\n <span data-cimplify-availability-stock-count className={classNames?.stockCount}>\n Only {stockQuantity} left\n </span>\n )}\n </span>\n );\n}\n' }] }, "product-sheet": { "name": "product-sheet", "title": "ProductSheet", "description": "Full product detail view with gallery, header, and customizer.", "type": "component", "registryDependencies": ["price", "product-image-gallery", "product-customizer", "cn"], "files": [{ "path": "product-sheet.tsx", "content": '"use client";\n\nimport React, { useState } from "react";\nimport type { Product, ProductWithDetails, VariantView } from "@cimplify/sdk";\nimport type { AddToCartOptions } from "@cimplify/sdk/react";\nimport { useProduct } from "@cimplify/sdk/react";\nimport { Price } from "@cimplify/sdk/react";\nimport { ProductImageGallery } from "@cimplify/sdk/react";\nimport { ProductCustomizer } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface ProductSheetClassNames {\n root?: string;\n image?: string;\n header?: string;\n name?: string;\n price?: string;\n description?: string;\n customizer?: string;\n loading?: string;\n}\n\nexport interface ProductSheetProps {\n /** A slim Product (triggers lazy-fetch) or a fully-loaded ProductWithDetails. */\n product: Product | ProductWithDetails;\n /** Called when the sheet should close. */\n onClose?: () => void;\n /** Override the default add-to-cart behavior. */\n onAddToCart?: (\n product: ProductWithDetails,\n quantity: number,\n options: AddToCartOptions,\n ) => void | Promise<void>;\n /** Custom image renderer (e.g. Next.js Image). */\n renderImage?: (props: {\n src: string;\n alt: string;\n className?: string;\n }) => React.ReactNode;\n className?: string;\n classNames?: ProductSheetClassNames;\n}\n\nfunction isProductWithDetails(\n product: Product | ProductWithDetails,\n): product is ProductWithDetails {\n return "variants" in product;\n}\n\n/**\n * ProductSheet \u2014 full product detail view composing gallery, header, and customizer.\n *\n * When given a slim `Product`, it lazy-fetches the full details via `useProduct`.\n * When given a `ProductWithDetails`, it skips the fetch entirely.\n */\nexport function ProductSheet({\n product,\n onClose,\n onAddToCart,\n renderImage,\n className,\n classNames,\n}: ProductSheetProps): React.ReactElement {\n const needsFetch = !isProductWithDetails(product);\n const { product: fetched, isLoading } = useProduct(\n product.slug ?? product.id,\n { enabled: needsFetch },\n );\n const fullProduct = needsFetch ? fetched : (product as ProductWithDetails);\n\n // Loading state\n if (isLoading && !fullProduct) {\n return (\n <div\n data-cimplify-product-sheet\n aria-busy="true"\n className={cn(className, classNames?.root, classNames?.loading)}\n >\n <div\n data-cimplify-product-sheet-skeleton\n style={{\n display: "flex",\n flexDirection: "column",\n gap: "1rem",\n }}\n >\n <div\n style={{\n aspectRatio: "4/3",\n backgroundColor: "rgba(0,0,0,0.06)",\n borderRadius: "0.5rem",\n }}\n />\n <div\n style={{\n height: "1.5rem",\n width: "60%",\n backgroundColor: "rgba(0,0,0,0.06)",\n borderRadius: "0.25rem",\n }}\n />\n <div\n style={{\n height: "1rem",\n width: "30%",\n backgroundColor: "rgba(0,0,0,0.06)",\n borderRadius: "0.25rem",\n }}\n />\n </div>\n </div>\n );\n }\n\n // Error state\n if (!fullProduct) {\n return (\n <div\n data-cimplify-product-sheet\n className={cn(className, classNames?.root)}\n >\n <p>Product not found.</p>\n </div>\n );\n }\n\n const [selectedVariant, setSelectedVariant] = useState<VariantView | undefined>(\n undefined,\n );\n\n const variantImages = selectedVariant?.images?.filter(Boolean) ?? [];\n const productImages: string[] = [];\n if (fullProduct.images && fullProduct.images.length > 0) {\n productImages.push(...fullProduct.images.filter(Boolean));\n } else if (fullProduct.image_url) {\n productImages.push(fullProduct.image_url);\n }\n const images: string[] =\n variantImages.length > 0 ? variantImages : productImages;\n\n const hasMultipleImages = images.length > 1;\n const singleImage = images[0];\n\n return (\n <div\n data-cimplify-product-sheet\n className={cn(className, classNames?.root)}\n style={{ display: "flex", flexDirection: "column", gap: "1rem" }}\n >\n {/* Image area */}\n {hasMultipleImages ? (\n <ProductImageGallery\n images={images}\n productName={fullProduct.name}\n className={classNames?.image}\n />\n ) : singleImage ? (\n <div data-cimplify-product-sheet-image className={classNames?.image}>\n {renderImage ? (\n renderImage({ src: singleImage, alt: fullProduct.name })\n ) : (\n <img\n src={singleImage}\n alt={fullProduct.name}\n style={{ width: "100%", height: "auto", objectFit: "cover" }}\n />\n )}\n </div>\n ) : null}\n\n {/* Header */}\n <div data-cimplify-product-sheet-header className={classNames?.header}>\n <h2\n data-cimplify-product-sheet-name\n className={classNames?.name}\n style={{ margin: 0 }}\n >\n {fullProduct.name}\n </h2>\n <Price\n amount={fullProduct.default_price}\n className={classNames?.price}\n />\n </div>\n\n {/* Description */}\n {fullProduct.description && (\n <p\n data-cimplify-product-sheet-description\n className={classNames?.description}\n style={{ margin: 0 }}\n >\n {fullProduct.description}\n </p>\n )}\n\n {/* Customizer */}\n <ProductCustomizer\n product={fullProduct}\n onAddToCart={onAddToCart}\n onVariantChange={(_id, variant) => setSelectedVariant(variant)}\n className={classNames?.customizer}\n />\n </div>\n );\n}\n' }] }, "bookings-page": { "name": "bookings-page", "title": "BookingsPage", "description": "Account-area page listing a customer's bookings with filters and detail view.", "type": "component", "registryDependencies": ["booking-list", "booking-card", "cn"], "files": [{ "path": "bookings-page.tsx", "content": '"use client";\n\nimport React, { useState, useCallback } from "react";\nimport { Tabs } from "@base-ui/react/tabs";\nimport type { CustomerBooking } from "@cimplify/sdk";\nimport { BookingList } from "@cimplify/sdk/react";\nimport { BookingCard } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface BookingsPageClassNames {\n root?: string;\n header?: string;\n title?: string;\n filters?: string;\n filterButton?: string;\n list?: string;\n detail?: string;\n backButton?: string;\n}\n\nexport interface BookingsPageProps {\n /** Page title. */\n title?: string;\n /** Pre-fetched bookings for SSR. */\n bookings?: CustomerBooking[];\n /** Called when navigating to a booking detail (e.g. for routing). */\n onBookingNavigate?: (booking: CustomerBooking) => void;\n /** Called when cancel is clicked. */\n onCancel?: (booking: CustomerBooking) => void;\n /** Called when reschedule is clicked. */\n onReschedule?: (booking: CustomerBooking) => void;\n /** Show filter tabs. Default: true. */\n showFilters?: boolean;\n /** Custom booking renderer. */\n renderBooking?: (booking: CustomerBooking) => React.ReactNode;\n className?: string;\n classNames?: BookingsPageClassNames;\n}\n\nconst BOOKING_FILTERS: { label: string; value: "all" | "upcoming" | "past" }[] = [\n { label: "All", value: "all" },\n { label: "Upcoming", value: "upcoming" },\n { label: "Past", value: "past" },\n];\n\nexport function BookingsPage({\n title = "My Bookings",\n bookings: bookingsProp,\n onBookingNavigate,\n onCancel,\n onReschedule,\n showFilters = true,\n renderBooking,\n className,\n classNames,\n}: BookingsPageProps): React.ReactElement {\n const [filter, setFilter] = useState<"all" | "upcoming" | "past">("all");\n const [selectedBooking, setSelectedBooking] = useState<CustomerBooking | null>(null);\n\n const handleBookingClick = useCallback(\n (booking: CustomerBooking) => {\n if (onBookingNavigate) {\n onBookingNavigate(booking);\n } else {\n setSelectedBooking(booking);\n }\n },\n [onBookingNavigate],\n );\n\n const handleBack = useCallback(() => {\n setSelectedBooking(null);\n }, []);\n\n if (selectedBooking && !onBookingNavigate) {\n return (\n <div data-cimplify-bookings-page className={cn(className, classNames?.root)}>\n <div data-cimplify-bookings-detail className={classNames?.detail}>\n <button\n type="button"\n onClick={handleBack}\n data-cimplify-bookings-back\n className={classNames?.backButton}\n >\n Back to bookings\n </button>\n <BookingCard\n booking={selectedBooking}\n onCancel={onCancel}\n onReschedule={onReschedule}\n />\n </div>\n </div>\n );\n }\n\n return (\n <div data-cimplify-bookings-page className={cn(className, classNames?.root)}>\n <div data-cimplify-bookings-header className={classNames?.header}>\n <h1 data-cimplify-bookings-title className={classNames?.title}>\n {title}\n </h1>\n </div>\n\n {showFilters && (\n <Tabs.Root\n value={filter}\n onValueChange={(value) => setFilter(value as "all" | "upcoming" | "past")}\n >\n <Tabs.List data-cimplify-bookings-filters className={classNames?.filters}>\n {BOOKING_FILTERS.map((f) => (\n <Tabs.Tab\n key={f.value}\n value={f.value}\n data-cimplify-booking-filter\n data-selected={filter === f.value || undefined}\n className={classNames?.filterButton}\n >\n {f.label}\n </Tabs.Tab>\n ))}\n </Tabs.List>\n </Tabs.Root>\n )}\n\n <div data-cimplify-bookings-list className={classNames?.list}>\n <BookingList\n bookings={bookingsProp}\n filter={filter}\n onCancel={onCancel}\n onReschedule={onReschedule}\n onBookingClick={handleBookingClick}\n renderBooking={renderBooking}\n />\n </div>\n </div>\n );\n}\n' }] }, "currency-selector": { "name": "currency-selector", "title": "CurrencySelector", "description": "Multi-currency switcher backed by the FX provider \u2014 locks display currency and quote ID.", "type": "component", "registryDependencies": ["cn"], "files": [{ "path": "currency-selector.tsx", "content": '"use client";\n\nimport React, { useState, useEffect, useRef } from "react";\nimport type { CurrencyCode } from "@cimplify/sdk";\nimport type { CimplifyError } from "@cimplify/sdk";\nimport type { FxRateResponse } from "../types/fx";\nimport { useOptionalCimplifyClient } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface CurrencySelectorClassNames {\n root?: string;\n option?: string;\n activeOption?: string;\n rate?: string;\n label?: string;\n loading?: string;\n}\n\nexport interface CurrencySelectorProps {\n /** Available currencies to display. */\n currencies: CurrencyCode[];\n /** Currently selected currency. */\n currentCurrency?: CurrencyCode;\n /** Called when a currency is selected. */\n onCurrencyChange?: (currency: CurrencyCode) => void;\n /** Show the exchange rate next to each currency. Requires `baseCurrency`. */\n showRate?: boolean;\n /** Base currency used for rate display. */\n baseCurrency?: CurrencyCode;\n /** Custom currency option renderer. */\n renderCurrency?: (\n currency: CurrencyCode,\n isActive: boolean,\n rate: FxRateResponse | null,\n ) => React.ReactNode;\n className?: string;\n classNames?: CurrencySelectorClassNames;\n}\n\n/**\n * CurrencySelector \u2014 currency switcher for international customers.\n *\n * Renders selectable currency options with optional exchange rate display.\n * Returns `null` when there is only one currency (no selector needed).\n */\nexport function CurrencySelector({\n currencies,\n currentCurrency,\n onCurrencyChange,\n showRate = false,\n baseCurrency,\n renderCurrency,\n className,\n classNames,\n}: CurrencySelectorProps): React.ReactElement | null {\n const context = useOptionalCimplifyClient();\n const client = context?.client ?? null;\n\n const [rates, setRates] = useState<Record<string, FxRateResponse>>({});\n const [isLoadingRates, setIsLoadingRates] = useState(false);\n const requestIdRef = useRef(0);\n\n useEffect(() => {\n if (!showRate || !baseCurrency || !client) return;\n\n const targets = currencies.filter((c) => c !== baseCurrency);\n if (targets.length === 0) return;\n\n const nextRequestId = ++requestIdRef.current;\n setIsLoadingRates(true);\n\n void (async () => {\n const fetched: Record<string, FxRateResponse> = {};\n\n await Promise.all(\n targets.map(async (currency) => {\n const res = await client.fx.getRate(baseCurrency, currency);\n if (res.ok) {\n fetched[currency] = res.value;\n }\n }),\n );\n\n if (nextRequestId !== requestIdRef.current) return;\n\n setRates(fetched);\n setIsLoadingRates(false);\n })();\n }, [client, showRate, baseCurrency, currencies]);\n\n if (currencies.length <= 1) {\n return null;\n }\n\n return (\n <div data-cimplify-currency-selector className={cn(className, classNames?.root)}>\n {currencies.map((currency) => {\n const isActive = currentCurrency === currency;\n const rate = rates[currency] ?? null;\n\n return (\n <button\n key={currency}\n type="button"\n onClick={() => onCurrencyChange?.(currency)}\n data-cimplify-currency-option\n data-active={isActive || undefined}\n aria-pressed={isActive}\n className={cn(\n classNames?.option,\n isActive && classNames?.activeOption,\n )}\n >\n {renderCurrency ? (\n renderCurrency(currency, isActive, rate)\n ) : (\n <>\n <span data-cimplify-currency-label className={classNames?.label}>\n {currency}\n </span>\n {showRate && baseCurrency && currency !== baseCurrency && (\n <span data-cimplify-currency-rate className={classNames?.rate}>\n {isLoadingRates\n ? "..."\n : rate\n ? `1 ${baseCurrency} = ${rate.rate.toFixed(4)} ${currency}`\n : null}\n </span>\n )}\n </>\n )}\n </button>\n );\n })}\n </div>\n );\n}\n' }] }, "store-nav": { "name": "store-nav", "title": "StoreNav", "description": "Top navigation bar with brand, categories, cart badge, and search.", "type": "component", "registryDependencies": ["cn"], "files": [{ "path": "store-nav.tsx", "content": '"use client";\n\nimport React from "react";\nimport { useCart, useCategories } from "@cimplify/sdk/react";\nimport type { Category } from "@cimplify/sdk";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface StoreNavClassNames {\n root?: string;\n brand?: string;\n categories?: string;\n categoryLink?: string;\n actions?: string;\n cartButton?: string;\n cartCount?: string;\n searchButton?: string;\n}\n\nexport interface StoreNavProps {\n /** Store/brand name. */\n storeName?: string;\n /** Custom brand element (logo, etc.). Overrides storeName. */\n renderBrand?: () => React.ReactNode;\n /** Override categories (skips fetch). */\n categories?: Category[];\n /** Called when a category link is clicked. */\n onCategoryClick?: (category: Category) => void;\n /** Called when the cart button is clicked. */\n onCartClick?: () => void;\n /** Called when the search button is clicked. */\n onSearchClick?: () => void;\n /** Hide category navigation. */\n hideCategories?: boolean;\n /** Hide the cart button. */\n hideCart?: boolean;\n /** Hide the search button. */\n hideSearch?: boolean;\n className?: string;\n classNames?: StoreNavClassNames;\n}\n\n/**\n * StoreNav \u2014 top navigation bar with brand, category links, cart badge, and search.\n *\n * Fetches categories via `useCategories` and cart count via `useCart`.\n * Renders as a semantic `<nav>` element.\n */\nexport function StoreNav({\n storeName,\n renderBrand,\n categories: categoriesProp,\n onCategoryClick,\n onCartClick,\n onSearchClick,\n hideCategories = false,\n hideCart = false,\n hideSearch = false,\n className,\n classNames,\n}: StoreNavProps): React.ReactElement {\n const { categories: fetched } = useCategories({\n enabled: !hideCategories && categoriesProp === undefined,\n });\n const { itemCount } = useCart();\n\n const categories = categoriesProp ?? fetched;\n\n return (\n <nav data-cimplify-store-nav className={cn(className, classNames?.root)}>\n {/* Brand */}\n <div data-cimplify-store-nav-brand className={classNames?.brand}>\n {renderBrand ? renderBrand() : storeName && <span>{storeName}</span>}\n </div>\n\n {/* Category links */}\n {!hideCategories && categories.length > 0 && (\n <div data-cimplify-store-nav-categories className={classNames?.categories}>\n {categories.map((category: Category) => (\n <button\n key={category.id}\n type="button"\n onClick={() => onCategoryClick?.(category)}\n data-cimplify-store-nav-category\n className={classNames?.categoryLink}\n >\n {category.name}\n </button>\n ))}\n </div>\n )}\n\n {/* Actions */}\n <div data-cimplify-store-nav-actions className={classNames?.actions}>\n {!hideSearch && (\n <button\n type="button"\n onClick={onSearchClick}\n data-cimplify-store-nav-search\n className={classNames?.searchButton}\n aria-label="Search"\n >\n Search\n </button>\n )}\n\n {!hideCart && (\n <button\n type="button"\n onClick={onCartClick}\n data-cimplify-store-nav-cart\n className={classNames?.cartButton}\n aria-label={`Cart (${itemCount} items)`}\n >\n Cart\n {itemCount > 0 && (\n <span data-cimplify-store-nav-cart-count className={classNames?.cartCount}>\n {itemCount}\n </span>\n )}\n </button>\n )}\n </div>\n </nav>\n );\n}\n' }] }, "deal-banner": { "name": "deal-banner", "title": "DealBanner", "description": "Displays active deals and promotions.", "type": "component", "registryDependencies": ["price", "cn"], "files": [{ "path": "deal-banner.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { Deal } from "@cimplify/sdk";\nimport { useDeals } from "@cimplify/sdk/react";\nimport { Price } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface DealBannerClassNames {\n root?: string;\n item?: string;\n description?: string;\n value?: string;\n badge?: string;\n empty?: string;\n}\n\nexport interface DealBannerProps {\n /** Override deals (skips useDeals fetch). For SSR, pass pre-fetched deals. */\n deals?: Deal[];\n /** Location ID for location-specific deals. */\n locationId?: string;\n /** Called when a deal is clicked. */\n onDealClick?: (deal: Deal) => void;\n /** Custom deal card renderer. */\n renderDeal?: (deal: Deal) => React.ReactNode;\n /** Maximum deals to show. Default: all. */\n limit?: number;\n className?: string;\n classNames?: DealBannerClassNames;\n}\n\nfunction formatBenefitLabel(deal: Deal): string {\n switch (deal.benefit_type) {\n case "percentage":\n return `${deal.value}% off`;\n case "fixed":\n return `Save`;\n case "free_item":\n return "Free item";\n case "buy_x_get_y_free":\n return `Buy ${deal.buy_quantity ?? ""} get ${deal.get_quantity ?? 1} free`;\n case "points":\n return `Earn ${deal.value} points`;\n default:\n return "Special offer";\n }\n}\n\n/**\n * DealBanner \u2014 displays active deals/promotions.\n *\n * Renders as a horizontal scrollable strip or grid of deal cards.\n */\nexport function DealBanner({\n deals: dealsProp,\n locationId,\n onDealClick,\n renderDeal,\n limit,\n className,\n classNames,\n}: DealBannerProps): React.ReactElement | null {\n const { deals: fetched, isLoading } = useDeals({\n locationId,\n enabled: dealsProp === undefined,\n });\n\n const allDeals = dealsProp ?? fetched;\n const deals = limit ? allDeals.slice(0, limit) : allDeals;\n\n if (isLoading && deals.length === 0) {\n return (\n <div\n data-cimplify-deal-banner\n aria-busy="true"\n className={cn(className, classNames?.root)}\n />\n );\n }\n\n if (deals.length === 0) {\n return null;\n }\n\n return (\n <div data-cimplify-deal-banner className={cn(className, classNames?.root)}>\n {deals.map((deal) => (\n <button\n key={deal.id}\n type="button"\n onClick={() => onDealClick?.(deal)}\n data-cimplify-deal-item\n data-benefit-type={deal.benefit_type}\n className={classNames?.item}\n >\n {renderDeal ? (\n renderDeal(deal)\n ) : (\n <>\n <span data-cimplify-deal-badge className={classNames?.badge}>\n {formatBenefitLabel(deal)}\n </span>\n <span data-cimplify-deal-description className={classNames?.description}>\n {deal.description}\n </span>\n {deal.benefit_type === "fixed" && (\n <span data-cimplify-deal-value className={classNames?.value}>\n <Price amount={deal.value} />\n </span>\n )}\n {deal.min_order_value && (\n <span data-cimplify-deal-minimum>\n Min. order <Price amount={deal.min_order_value} />\n </span>\n )}\n </>\n )}\n </button>\n ))}\n </div>\n );\n}\n' }] }, "date-slot-picker": { "name": "date-slot-picker", "title": "DateSlotPicker", "description": "Horizontal date strip with slot picker for service scheduling.", "type": "component", "registryDependencies": ["slot-picker", "cn"], "files": [{ "path": "date-slot-picker.tsx", "content": '"use client";\n\nimport React, { useState, useMemo, useCallback } from "react";\nimport { Tabs } from "@base-ui/react/tabs";\nimport type { AvailableSlot, DayAvailability } from "@cimplify/sdk";\nimport type { DurationUnit, SchedulingMode } from "@cimplify/sdk";\nimport { useServiceAvailability } from "@cimplify/sdk/react";\nimport { SlotPicker } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface DateSlotPickerClassNames {\n root?: string;\n dateStrip?: string;\n dateButton?: string;\n nav?: string;\n navButton?: string;\n slots?: string;\n loading?: string;\n}\n\nexport interface DateSlotPickerProps {\n /** Service ID to fetch availability and slots for. */\n serviceId: string;\n /** Number of days to show in the date strip. Default: 7. */\n daysToShow?: number;\n /** Number of participants. */\n participantCount?: number;\n /** Currently selected slot. */\n selectedSlot?: AvailableSlot | null;\n /** Called when a slot is selected. */\n onSlotSelect?: (slot: AvailableSlot, date: string) => void;\n /** Pre-fetched availability data (skips fetch). */\n availability?: DayAvailability[];\n /** Show price on slots. Default: true. */\n showPrice?: boolean;\n /** Forwarded to `<SlotPicker>` to render multi-day stay labels. */\n schedulingMode?: SchedulingMode;\n /** Forwarded to `<SlotPicker>` \u2014 unit for the stay summary in multi-day mode. */\n durationUnit?: DurationUnit;\n /** Forwarded to `<SlotPicker>` \u2014 value for the stay summary in multi-day mode. */\n durationValue?: number;\n className?: string;\n classNames?: DateSlotPickerClassNames;\n}\n\nfunction formatDate(dateStr: string): string {\n const date = new Date(dateStr + "T00:00:00");\n return date.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" });\n}\n\nfunction toDateString(date: Date): string {\n return date.toISOString().split("T")[0];\n}\n\nfunction addDays(date: Date, days: number): Date {\n const result = new Date(date);\n result.setDate(result.getDate() + days);\n return result;\n}\n\nexport function DateSlotPicker({\n serviceId,\n daysToShow = 7,\n participantCount,\n selectedSlot,\n onSlotSelect,\n availability: availabilityProp,\n showPrice = true,\n schedulingMode,\n durationUnit,\n durationValue,\n className,\n classNames,\n}: DateSlotPickerProps): React.ReactElement {\n const [offset, setOffset] = useState(0);\n const [selectedDate, setSelectedDate] = useState<string>(toDateString(new Date()));\n\n const dateRange = useMemo(() => {\n const today = new Date();\n const start = addDays(today, offset);\n const dates: string[] = [];\n for (let i = 0; i < daysToShow; i++) {\n dates.push(toDateString(addDays(start, i)));\n }\n return {\n dates,\n startDate: dates[0],\n endDate: dates[dates.length - 1],\n };\n }, [offset, daysToShow]);\n\n const { days: fetchedDays, isLoading: availabilityLoading } = useServiceAvailability(\n serviceId,\n dateRange.startDate,\n dateRange.endDate,\n {\n participantCount,\n enabled: availabilityProp === undefined,\n },\n );\n\n const days = availabilityProp ?? fetchedDays;\n\n const availabilityMap = useMemo(() => {\n const map = new Map<string, DayAvailability>();\n for (const day of days) {\n map.set(day.date, day);\n }\n return map;\n }, [days]);\n\n const handlePrev = useCallback(() => {\n setOffset((prev) => Math.max(0, prev - daysToShow));\n }, [daysToShow]);\n\n const handleNext = useCallback(() => {\n setOffset((prev) => prev + daysToShow);\n }, [daysToShow]);\n\n const handleDateChange = useCallback((value: string | number | null) => {\n if (typeof value === "string") {\n setSelectedDate(value);\n }\n }, []);\n\n const handleSlotSelect = useCallback(\n (slot: AvailableSlot) => {\n onSlotSelect?.(slot, selectedDate);\n },\n [onSlotSelect, selectedDate],\n );\n\n return (\n <Tabs.Root\n value={selectedDate}\n onValueChange={handleDateChange}\n data-cimplify-date-slot-picker\n className={cn(className, classNames?.root)}\n >\n <div data-cimplify-date-nav className={classNames?.nav}>\n <button\n type="button"\n onClick={handlePrev}\n disabled={offset === 0}\n data-cimplify-date-nav-prev\n className={classNames?.navButton}\n >\n ←\n </button>\n <button\n type="button"\n onClick={handleNext}\n data-cimplify-date-nav-next\n className={classNames?.navButton}\n >\n →\n </button>\n </div>\n\n <Tabs.List data-cimplify-date-strip className={classNames?.dateStrip}>\n {dateRange.dates.map((date) => {\n const dayInfo = availabilityMap.get(date);\n const hasAvailability = dayInfo?.has_availability !== false;\n const isSelected = selectedDate === date;\n return (\n <Tabs.Tab\n key={date}\n value={date}\n data-cimplify-date-button\n data-selected={isSelected || undefined}\n data-available={hasAvailability || undefined}\n data-fully-booked={(!hasAvailability) || undefined}\n className={classNames?.dateButton}\n >\n {formatDate(date)}\n </Tabs.Tab>\n );\n })}\n </Tabs.List>\n\n {availabilityLoading && (\n <div\n data-cimplify-date-slot-loading\n aria-busy="true"\n className={classNames?.loading}\n />\n )}\n\n <div data-cimplify-date-slots className={classNames?.slots}>\n <SlotPicker\n serviceId={serviceId}\n date={selectedDate}\n participantCount={participantCount}\n selectedSlot={selectedSlot}\n onSlotSelect={handleSlotSelect}\n showPrice={showPrice}\n schedulingMode={schedulingMode}\n durationUnit={durationUnit}\n durationValue={durationValue}\n />\n </div>\n </Tabs.Root>\n );\n}\n' }] }, "product-page": { "name": "product-page", "title": "ProductPage", "description": "Smart product page resolver with per-slug and per-type template routing.", "type": "component", "registryDependencies": ["default-product-layout", "food-product-layout", "wholesale-product-layout", "service-product-layout", "digital-product-layout", "cn"], "files": [{ "path": "product-page.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { Product, ProductWithDetails } from "@cimplify/sdk";\nimport { PRODUCT_TYPE, RENDER_HINT } from "@cimplify/sdk";\nimport type { ProductLayoutProps } from "@cimplify/sdk/react";\nimport { DefaultProductLayout } from "@cimplify/sdk/react";\nimport { FoodProductLayout } from "@cimplify/sdk/react";\nimport { WholesaleProductLayout } from "@cimplify/sdk/react";\nimport { ServiceProductLayout } from "@cimplify/sdk/react";\nimport { DigitalProductLayout } from "@cimplify/sdk/react";\nimport { BundleProductLayout } from "./layouts/bundle-product-layout";\nimport { CompositeProductLayout } from "./layouts/composite-product-layout";\nimport type { AddToCartOptions } from "@cimplify/sdk/react";\nimport { useProduct } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport type { ProductLayoutProps };\n\nexport enum ProductTemplate {\n Default = "default",\n Food = "food",\n Wholesale = "wholesale",\n Service = "service",\n Digital = "digital",\n Bundle = "bundle",\n Composite = "composite",\n Physical = "physical",\n}\n\nexport interface ProductPageClassNames {\n root?: string;\n loading?: string;\n}\n\nexport interface ProductPageProps {\n /** Product slug or ID \u2014 used for client-side fetch when `product` is not provided. */\n productId?: string;\n /** Pre-fetched product for SSR. Skips client-side fetch when provided. */\n product?: ProductWithDetails;\n /** Per-slug page map. Highest priority \u2014 maps a product slug to a custom component. */\n pages?: Record<string, React.ComponentType<ProductLayoutProps>>;\n /** Per-type template map. Overrides built-in layouts for specific template keys. */\n templates?: Partial<Record<ProductTemplate | string, React.ComponentType<ProductLayoutProps>>>;\n /** Override add-to-cart behavior. */\n onAddToCart?: (\n product: ProductWithDetails,\n quantity: number,\n options: AddToCartOptions,\n ) => void | Promise<void>;\n /** Custom image renderer (e.g. Next.js Image). */\n renderImage?: (props: { src: string; alt: string; className?: string }) => React.ReactNode;\n /** Breadcrumb renderer. Receives the product for context. */\n renderBreadcrumb?: (product: ProductWithDetails) => React.ReactNode;\n /** Pre-fetched related products. */\n relatedProducts?: Product[];\n /** Show related products section. Default: true. */\n showRelated?: boolean;\n className?: string;\n classNames?: ProductPageClassNames;\n}\n\nconst BUILT_IN_LAYOUTS: Record<string, React.ComponentType<ProductLayoutProps>> = {\n [ProductTemplate.Food]: FoodProductLayout,\n [ProductTemplate.Wholesale]: WholesaleProductLayout,\n [ProductTemplate.Service]: ServiceProductLayout,\n [ProductTemplate.Digital]: DigitalProductLayout,\n [ProductTemplate.Bundle]: BundleProductLayout,\n [ProductTemplate.Composite]: CompositeProductLayout,\n [ProductTemplate.Default]: DefaultProductLayout,\n};\n\nfunction resolveTemplateKey(product: ProductWithDetails): ProductTemplate | string {\n const metaTemplate = product.metadata?.page_template;\n if (typeof metaTemplate === "string" && metaTemplate.trim()) {\n return metaTemplate.trim();\n }\n\n if (product.type === PRODUCT_TYPE.Bundle) {\n return ProductTemplate.Bundle;\n }\n\n if (product.type === PRODUCT_TYPE.Composite) {\n return ProductTemplate.Composite;\n }\n\n if (product.quantity_pricing && product.quantity_pricing.length > 1) {\n return ProductTemplate.Wholesale;\n }\n\n if (product.type === PRODUCT_TYPE.Service) {\n return ProductTemplate.Service;\n }\n\n if (product.type === PRODUCT_TYPE.Digital) {\n return ProductTemplate.Digital;\n }\n\n if (product.render_hint === RENDER_HINT.Food) {\n return ProductTemplate.Food;\n }\n\n if (product.render_hint === RENDER_HINT.Physical) {\n return ProductTemplate.Physical;\n }\n\n return ProductTemplate.Default;\n}\n\nfunction resolveLayout(\n product: ProductWithDetails,\n pages?: Record<string, React.ComponentType<ProductLayoutProps>>,\n templates?: Partial<Record<string, React.ComponentType<ProductLayoutProps>>>,\n): React.ComponentType<ProductLayoutProps> {\n // 1. Per-slug page (AI-generated, highest priority)\n if (pages?.[product.slug]) {\n return pages[product.slug];\n }\n\n const key = resolveTemplateKey(product);\n\n // 2. Consumer-provided template override\n if (templates?.[key]) {\n return templates[key];\n }\n\n // 3. Built-in layout\n if (BUILT_IN_LAYOUTS[key]) {\n return BUILT_IN_LAYOUTS[key];\n }\n\n // 4. Fallback\n return DefaultProductLayout;\n}\n\nexport function ProductPage({\n productId,\n product: productProp,\n pages,\n templates,\n onAddToCart,\n renderImage,\n renderBreadcrumb,\n relatedProducts,\n showRelated = true,\n className,\n classNames,\n}: ProductPageProps): React.ReactElement {\n const resolvedId = productId || productProp?.slug || productProp?.id || "";\n const { product: fetched, isLoading } = useProduct(resolvedId, {\n enabled: !productProp && resolvedId.length > 0,\n });\n const product = productProp ?? fetched;\n\n if (isLoading && !product) {\n return (\n <div\n data-cimplify-product-page\n aria-busy="true"\n className={cn(className, classNames?.root, classNames?.loading)}\n >\n <div className="grid lg:grid-cols-2 gap-8 lg:gap-12 animate-pulse">\n <div className="aspect-square bg-muted" />\n <div className="space-y-4 py-4">\n <div className="h-8 w-3/5 bg-muted" />\n <div className="h-6 w-2/5 bg-muted" />\n <div className="h-4 w-4/5 bg-muted" />\n <div className="h-4 w-3/5 bg-muted" />\n <div className="h-14 w-full bg-muted mt-8" />\n </div>\n </div>\n </div>\n );\n }\n\n if (!product) {\n return (\n <div data-cimplify-product-page className={cn(className, classNames?.root)}>\n <p className="text-muted-foreground">Product not found.</p>\n </div>\n );\n }\n\n const Layout = resolveLayout(product, pages, templates);\n\n return (\n <div data-cimplify-product-page className={cn(className, classNames?.root)}>\n <Layout\n product={product}\n onAddToCart={onAddToCart}\n renderImage={renderImage}\n renderBreadcrumb={renderBreadcrumb}\n relatedProducts={relatedProducts}\n showRelated={showRelated}\n />\n </div>\n );\n}\n' }] }, "rental-service-card": { "name": "rental-service-card", "title": "RentalServiceCard", "description": "Rental card with per-day/hour pricing, deposit, and availability count.", "type": "component", "registryDependencies": ["price", "cn"], "files": [{ "path": "cards/rental-service-card.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { ServiceCardLayoutProps } from "./standard-service-card";\nimport { CardShell, CardImage, QuickAddButton } from "@cimplify/sdk/react";\nimport { Price } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nconst UNIT_LABELS: Record<string, string> = {\n minutes: "min",\n hours: "hr",\n days: "day",\n nights: "night",\n weeks: "week",\n months: "mo",\n years: "yr",\n};\n\nexport function RentalServiceCard({\n product,\n onBook,\n renderImage,\n renderLink,\n className,\n}: ServiceCardLayoutProps): React.ReactElement {\n const image = product.image_url || product.images?.[0] || "";\n const hasDeposit = product.deposit_type && product.deposit_type !== "none" && product.deposit_amount;\n const unit = product.duration_unit ? UNIT_LABELS[product.duration_unit] || product.duration_unit : "day";\n const status = product.inventory_status;\n\n return (\n <CardShell product={product} renderLink={renderLink} className={className}>\n <CardImage src={image} alt={product.name} aspectRatio="16/9" renderImage={renderImage}>\n <div className="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent pointer-events-none" />\n\n {/* Per-unit pill */}\n <span className="absolute bottom-3 left-3 inline-flex items-center text-[12px] font-semibold px-2.5 py-1 rounded-lg bg-background/92 backdrop-blur-xl text-foreground shadow-sm">\n Per {unit}\n </span>\n\n {/* Price */}\n <span className="absolute bottom-3 right-3 text-white font-bold text-lg drop-shadow-sm">\n <Price amount={product.default_price} />/{unit}\n </span>\n\n {/* Capacity badge */}\n {product.general_service_capacity != null && product.general_service_capacity > 1 && (\n <span className="absolute top-3 left-3 text-[11px] font-semibold tracking-wide bg-blue-50/90 text-blue-700 border border-blue-200/50 backdrop-blur px-2 py-0.5 rounded-md">\n Up to {product.general_service_capacity} guests\n </span>\n )}\n </CardImage>\n\n <div className="p-4">\n <h3 className="text-[14.5px] font-bold text-foreground leading-snug tracking-tight">\n {product.name}\n </h3>\n {product.description && (\n <p className="text-[12.5px] text-muted-foreground mt-1 leading-relaxed line-clamp-2">\n {product.description}\n </p>\n )}\n\n {/* Info pills */}\n <div className="flex flex-wrap items-center gap-2 mt-3">\n {product.min_order_quantity != null && product.min_order_quantity > 1 && (\n <span className="text-[10.5px] font-medium text-muted-foreground px-1.5 py-0.5 bg-muted rounded">\n Min. {product.min_order_quantity} {unit}{product.min_order_quantity > 1 ? "s" : ""}\n </span>\n )}\n {hasDeposit && (\n <span className="text-[10.5px] font-medium text-amber-600">\n <Price amount={product.deposit_amount!} /> deposit\n </span>\n )}\n </div>\n\n <div className="flex items-center justify-between mt-3 pt-3 border-t border-border">\n {/* Availability */}\n {status && (\n <div className="flex items-center gap-1.5 text-[12px]">\n <span className={cn(\n "w-[7px] h-[7px] rounded-full shrink-0",\n status.in_stock ? "bg-emerald-500" : "bg-red-500",\n )} />\n <span className={status.in_stock ? "text-muted-foreground" : "text-red-600"}>\n {status.in_stock\n ? status.stock_level != null\n ? `${status.stock_level} available`\n : "Available"\n : "Unavailable"\n }\n </span>\n </div>\n )}\n <button\n onClick={(e) => { e.preventDefault(); e.stopPropagation(); onBook?.(product); }}\n className="text-[13px] font-semibold text-primary hover:text-primary/80 transition-colors"\n >\n Reserve now →\n </button>\n </div>\n </div>\n </CardShell>\n );\n}\n' }] }, "variant-selector": { "name": "variant-selector", "title": "VariantSelector", "description": "Select product variants via axis chips or direct list.", "type": "component", "registryDependencies": ["price"], "files": [{ "path": "variant-selector.tsx", "content": '"use client";\n\nimport React, { useState, useEffect, useRef, useId } from "react";\nimport { RadioGroup } from "@base-ui/react/radio-group";\nimport { Radio } from "@base-ui/react/radio";\nimport type { VariantView, VariantAxisWithValues } from "@cimplify/sdk";\nimport type { Money } from "@cimplify/sdk";\nimport { parsePrice } from "@cimplify/sdk";\nimport { getVariantDisplayName } from "@cimplify/sdk";\nimport { Price } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface VariantSelectorClassNames {\n root?: string;\n axisLabel?: string;\n axisOptions?: string;\n option?: string;\n optionSelected?: string;\n listLabel?: string;\n list?: string;\n name?: string;\n pricing?: string;\n adjustment?: string;\n}\n\nexport interface VariantSelectorProps {\n variants: VariantView[];\n variantAxes?: VariantAxisWithValues[];\n basePrice?: Money;\n selectedVariantId?: string;\n onVariantChange: (variantId: string | undefined, variant: VariantView | undefined) => void;\n productName?: string;\n className?: string;\n classNames?: VariantSelectorClassNames;\n}\n\nexport function VariantSelector({\n variants,\n variantAxes,\n basePrice,\n selectedVariantId,\n onVariantChange,\n productName,\n className,\n classNames,\n}: VariantSelectorProps): React.ReactElement | null {\n const [axisSelections, setAxisSelections] = useState<Record<string, string>>({});\n const initialized = useRef(false);\n const idPrefix = useId();\n\n useEffect(() => {\n initialized.current = false;\n }, [variants]);\n\n useEffect(() => {\n if (initialized.current) return;\n if (!variants || variants.length === 0) return;\n\n const defaultVariant = variants.find((v) => v.is_default) || variants[0];\n if (!defaultVariant) return;\n\n initialized.current = true;\n onVariantChange(defaultVariant.id, defaultVariant);\n\n if (defaultVariant.display_attributes) {\n const initial: Record<string, string> = {};\n for (const attr of defaultVariant.display_attributes) {\n initial[attr.axis_id] = attr.value_id;\n }\n setAxisSelections(initial);\n }\n }, [variants, onVariantChange]);\n\n useEffect(() => {\n if (!initialized.current) return;\n if (!variantAxes || variantAxes.length === 0) return;\n\n const match = variants.find((v) => {\n if (!v.display_attributes) return false;\n return v.display_attributes.every(\n (attr) => axisSelections[attr.axis_id] === attr.value_id,\n );\n });\n\n if (match && match.id !== selectedVariantId) {\n onVariantChange(match.id, match);\n }\n }, [axisSelections, variants, variantAxes, selectedVariantId, onVariantChange]);\n\n if (!variants || variants.length <= 1) {\n return null;\n }\n\n const basePriceNum = basePrice != null ? parsePrice(basePrice) : 0;\n\n if (variantAxes && variantAxes.length > 0) {\n return (\n <div data-cimplify-variant-selector className={cn("space-y-5", className, classNames?.root)}>\n {variantAxes.map((axis) => {\n const labelId = `${idPrefix}-axis-${axis.id}`;\n return (\n <div key={axis.id} data-cimplify-variant-axis>\n <label\n id={labelId}\n data-cimplify-variant-axis-label\n className={cn("block text-xs font-medium uppercase tracking-wider text-muted-foreground mb-3", classNames?.axisLabel)}\n >\n {axis.name}\n </label>\n <RadioGroup\n aria-labelledby={labelId}\n value={axisSelections[axis.id] ?? ""}\n onValueChange={(value) => {\n setAxisSelections((prev) => ({\n ...prev,\n [axis.id]: value,\n }));\n }}\n data-cimplify-variant-axis-options\n className={cn("flex flex-wrap gap-2", classNames?.axisOptions)}\n >\n {axis.values.map((value) => {\n const isSelected = axisSelections[axis.id] === value.id;\n return (\n <Radio.Root\n key={value.id}\n value={value.id}\n data-cimplify-variant-option\n data-selected={isSelected || undefined}\n className={cn(\n "px-4 py-2 border text-sm font-medium transition-colors border-border hover:border-primary/50",\n isSelected && "bg-primary text-primary-foreground border-primary",\n isSelected ? classNames?.optionSelected : classNames?.option,\n )}\n >\n {value.name}\n </Radio.Root>\n );\n })}\n </RadioGroup>\n </div>\n );\n })}\n </div>\n );\n }\n\n const listLabelId = `${idPrefix}-variant-list`;\n\n return (\n <div data-cimplify-variant-selector className={cn(className, classNames?.root)}>\n <div\n data-cimplify-variant-list-header\n className={cn("flex items-center justify-between py-3", classNames?.listLabel)}\n >\n <label id={listLabelId} className="text-base font-bold">\n Options\n </label>\n <span className="text-xs font-semibold text-destructive bg-destructive/10 px-2.5 py-1 rounded">\n Required\n </span>\n </div>\n <RadioGroup\n aria-labelledby={listLabelId}\n value={selectedVariantId ?? ""}\n onValueChange={(value) => {\n const variant = variants.find((v) => v.id === value);\n onVariantChange(variant?.id, variant);\n }}\n data-cimplify-variant-list\n className={cn("divide-y divide-border", classNames?.list)}\n >\n {variants.map((variant) => {\n const isSelected = selectedVariantId === variant.id;\n const adjustment = parsePrice(variant.price_adjustment);\n\n return (\n <Radio.Root\n key={variant.id}\n value={variant.id}\n data-cimplify-variant-option\n data-selected={isSelected || undefined}\n className={cn(\n "w-full flex items-center gap-3 py-4 transition-colors cursor-pointer",\n isSelected ? classNames?.optionSelected : classNames?.option,\n )}\n >\n <span\n data-cimplify-variant-radio\n className={cn(\n "w-5 h-5 rounded-full border-2 flex items-center justify-center shrink-0 transition-colors",\n isSelected ? "border-primary" : "border-muted-foreground/30",\n )}\n >\n {isSelected && <span className="w-2.5 h-2.5 rounded-full bg-primary" />}\n </span>\n <span\n data-cimplify-variant-name\n className={cn("flex-1 min-w-0 text-sm", classNames?.name)}\n >\n {getVariantDisplayName(variant, productName)}\n </span>\n <span data-cimplify-variant-pricing className={cn("text-sm text-muted-foreground", classNames?.pricing)}>\n {adjustment > 0 ? "+" : adjustment < 0 ? "" : "+"}\n <Price amount={variant.price_adjustment} />\n </span>\n </Radio.Root>\n );\n })}\n </RadioGroup>\n </div>\n );\n}\n' }] }, "price": { "name": "price", "title": "Price", "description": "Renders a formatted price in the display currency.", "type": "component", "registryDependencies": [], "files": [{ "path": "price.tsx", "content": '"use client";\n\nimport React from "react";\nimport { formatPrice } from "@cimplify/sdk";\nimport type { CurrencyCode } from "@cimplify/sdk";\nimport { useOptionalCimplify } from "@cimplify/sdk/react";\n\nexport interface PriceProps {\n /** The amount in base (business) currency. */\n amount: number | string;\n /** Explicit currency \u2014 skips provider lookup when provided. */\n currency?: CurrencyCode;\n /** Optional CSS class name for the wrapping span. */\n className?: string;\n /** Optional prefix rendered before the formatted price (e.g. "+"). */\n prefix?: string;\n}\n\n/**\n * Price \u2014 renders a formatted price value.\n *\n * When used inside CimplifyProvider: reads displayCurrency and convertPrice\n * from context for FX conversion.\n *\n * When used outside a provider (or with explicit `currency` prop): formats\n * directly \u2014 no provider required. Works in any React environment.\n */\nexport function Price({ amount, currency, className, prefix }: PriceProps): React.ReactElement {\n const context = useOptionalCimplify();\n const resolvedCurrency = currency ?? (context?.displayCurrency as CurrencyCode | undefined) ?? "USD";\n const convertedAmount = !currency && context?.convertPrice ? context.convertPrice(amount) : amount;\n const resolvedAmount = typeof convertedAmount === "string" ? parseFloat(convertedAmount) || 0 : convertedAmount;\n\n return (\n <span className={className}>\n {prefix}\n {formatPrice(resolvedAmount, resolvedCurrency)}\n </span>\n );\n}\n' }] }, "customer-input-fields": { "name": "customer-input-fields", "title": "CustomerInputFields", "description": "Per-product custom input fields \u2014 text, number, date, time, file upload, image upload, single/multi-select.", "type": "component", "registryDependencies": ["cn"], "files": [{ "path": "customer-input-fields.tsx", "content": '"use client";\n\nimport React, { useCallback } from "react";\nimport { INPUT_FIELD_TYPE, type AddressValue, type DateRangeValue, type LocationValue, type PhoneValue, type ProductInputField, type SignatureValue } from "@cimplify/sdk";\nimport { parsePrice } from "@cimplify/sdk";\nimport { Price } from "@cimplify/sdk/react";\nimport { useOptionalCimplifyClient } from "@cimplify/sdk/react";\nimport { DatePicker } from "./date-picker";\nimport { TimePicker } from "./time-picker";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface CustomerInputFieldsClassNames {\n root?: string;\n field?: string;\n label?: string;\n required?: string;\n helpText?: string;\n input?: string;\n select?: string;\n textarea?: string;\n fileInput?: string;\n colorInput?: string;\n radioGroup?: string;\n radioOption?: string;\n checkboxLabel?: string;\n priceAdjustment?: string;\n addressInput?: string;\n phoneInput?: string;\n signatureCanvas?: string;\n multiSelectGroup?: string;\n multiSelectOption?: string;\n dateRangeInput?: string;\n locationInput?: string;\n}\n\nexport interface CustomerInputFieldsProps {\n fields: ProductInputField[];\n values: Record<string, unknown>;\n onChange: (values: Record<string, unknown>) => void;\n onFileUpload?: (file: File, field: ProductInputField) => Promise<string>;\n className?: string;\n classNames?: CustomerInputFieldsClassNames;\n}\n\nexport function CustomerInputFields({\n fields,\n values,\n onChange,\n onFileUpload,\n className,\n classNames,\n}: CustomerInputFieldsProps): React.ReactElement | null {\n const clientContext = useOptionalCimplifyClient();\n\n if (fields.length === 0) return null;\n\n const sorted = [...fields].sort((a, b) => a.display_order - b.display_order);\n\n const setValue = useCallback(\n (fieldId: string, value: unknown) => {\n onChange({ ...values, [fieldId]: value });\n },\n [values, onChange],\n );\n\n const defaultFileUpload = useCallback(\n async (file: File): Promise<string> => {\n if (onFileUpload) return onFileUpload(file, {} as ProductInputField);\n if (!clientContext?.client) throw new Error("No upload provider available");\n const result = await clientContext.client.uploads.upload(file);\n if (!result.ok) throw new Error(result.error.message);\n return result.value.url;\n },\n [onFileUpload, clientContext],\n );\n\n return (\n <div data-cimplify-customer-inputs className={cn("space-y-4", className, classNames?.root)}>\n {sorted.map((field) => (\n <InputField\n key={field.id}\n field={field}\n value={values[field.id]}\n onValueChange={(value) => setValue(field.id, value)}\n onFileUpload={onFileUpload ?? (clientContext?.client ? (_file, _field) => defaultFileUpload(_file) : undefined)}\n classNames={classNames}\n />\n ))}\n </div>\n );\n}\n\nfunction InputField({\n field,\n value,\n onValueChange,\n onFileUpload,\n classNames,\n}: {\n field: ProductInputField;\n value: unknown;\n onValueChange: (value: unknown) => void;\n onFileUpload?: (file: File, field: ProductInputField) => Promise<string>;\n classNames?: CustomerInputFieldsClassNames;\n}): React.ReactElement {\n const hasPriceAdjustment = field.price_adjustment != null && parsePrice(field.price_adjustment) > 0;\n\n return (\n <div data-cimplify-customer-input data-field-type={field.field_type} className={classNames?.field}>\n <div className="flex items-center gap-2 mb-1.5">\n <label className={cn("text-sm font-semibold", classNames?.label)}>\n {field.name}\n </label>\n {field.is_required && (\n <span className={cn("text-xs text-destructive", classNames?.required)}>Required</span>\n )}\n {hasPriceAdjustment && (\n <span className={cn("text-xs text-muted-foreground", classNames?.priceAdjustment)}>\n +<Price amount={field.price_adjustment!} />\n </span>\n )}\n </div>\n\n {field.help_text && (\n <p className={cn("text-xs text-muted-foreground mb-1.5", classNames?.helpText)}>\n {field.help_text}\n </p>\n )}\n\n <FieldInput\n field={field}\n value={value}\n onValueChange={onValueChange}\n onFileUpload={onFileUpload}\n classNames={classNames}\n />\n </div>\n );\n}\n\nfunction FieldInput({\n field,\n value,\n onValueChange,\n onFileUpload,\n classNames,\n}: {\n field: ProductInputField;\n value: unknown;\n onValueChange: (value: unknown) => void;\n onFileUpload?: (file: File, field: ProductInputField) => Promise<string>;\n classNames?: CustomerInputFieldsClassNames;\n}): React.ReactElement {\n const inputClass = cn(\n "w-full rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring",\n classNames?.input,\n );\n\n switch (field.field_type) {\n case INPUT_FIELD_TYPE.Text:\n case INPUT_FIELD_TYPE.Url:\n return (\n <input\n type={field.field_type === INPUT_FIELD_TYPE.Url ? "url" : "text"}\n value={typeof value === "string" ? value : ""}\n onChange={(e) => onValueChange(e.target.value)}\n placeholder={field.placeholder}\n required={field.is_required}\n maxLength={field.validation?.max_length ?? undefined}\n className={inputClass}\n />\n );\n\n case INPUT_FIELD_TYPE.Textarea:\n return (\n <textarea\n value={typeof value === "string" ? value : ""}\n onChange={(e) => onValueChange(e.target.value)}\n placeholder={field.placeholder}\n required={field.is_required}\n maxLength={field.validation?.max_length ?? undefined}\n rows={3}\n className={cn(inputClass, "resize-none", classNames?.textarea)}\n />\n );\n\n case INPUT_FIELD_TYPE.Number:\n return (\n <input\n type="number"\n value={typeof value === "number" ? value : ""}\n onChange={(e) => onValueChange(e.target.value ? parseFloat(e.target.value) : undefined)}\n placeholder={field.placeholder}\n required={field.is_required}\n min={field.validation?.min_value ?? undefined}\n max={field.validation?.max_value ?? undefined}\n className={inputClass}\n />\n );\n\n case INPUT_FIELD_TYPE.Select:\n return (\n <select\n value={typeof value === "string" ? value : ""}\n onChange={(e) => onValueChange(e.target.value || undefined)}\n required={field.is_required}\n className={cn(inputClass, classNames?.select)}\n >\n <option value="">{field.placeholder || "Select..."}</option>\n {(field.options || []).map((opt) => (\n <option key={opt} value={opt}>{opt}</option>\n ))}\n </select>\n );\n\n case INPUT_FIELD_TYPE.Radio:\n return (\n <div className={cn("space-y-2", classNames?.radioGroup)}>\n {(field.options || []).map((opt) => (\n <label\n key={opt}\n className={cn(\n "flex items-center gap-2 text-sm cursor-pointer",\n classNames?.radioOption,\n )}\n >\n <input\n type="radio"\n name={field.id}\n value={opt}\n checked={value === opt}\n onChange={() => onValueChange(opt)}\n className="accent-primary"\n />\n {opt}\n </label>\n ))}\n </div>\n );\n\n case INPUT_FIELD_TYPE.Checkbox:\n return (\n <label className={cn("flex items-center gap-2 text-sm cursor-pointer", classNames?.checkboxLabel)}>\n <input\n type="checkbox"\n checked={value === true}\n onChange={(e) => onValueChange(e.target.checked)}\n className="accent-primary w-4 h-4"\n />\n {field.placeholder || field.name}\n </label>\n );\n\n case INPUT_FIELD_TYPE.Color:\n return (\n <input\n type="color"\n value={typeof value === "string" ? value : "#000000"}\n onChange={(e) => onValueChange(e.target.value)}\n className={cn("w-12 h-10 rounded-md border border-input cursor-pointer", classNames?.colorInput)}\n />\n );\n\n case INPUT_FIELD_TYPE.Date:\n return (\n <DatePicker\n value={typeof value === "string" ? value : ""}\n onChange={(next) => onValueChange(next || undefined)}\n required={field.is_required}\n placeholder={field.placeholder ?? "Select a date"}\n aria-label={field.name}\n />\n );\n\n case INPUT_FIELD_TYPE.File:\n case INPUT_FIELD_TYPE.Image:\n return (\n <FileUploadInput\n field={field}\n value={value}\n onValueChange={onValueChange}\n onFileUpload={onFileUpload}\n classNames={classNames}\n />\n );\n\n case INPUT_FIELD_TYPE.Email:\n return (\n <input\n type="email"\n value={typeof value === "string" ? value : ""}\n onChange={(e) => onValueChange(e.target.value)}\n placeholder={field.placeholder || "email@example.com"}\n required={field.is_required}\n className={inputClass}\n />\n );\n\n case INPUT_FIELD_TYPE.DateTime: {\n const stringValue = typeof value === "string" ? value : "";\n const [datePart, timePartRaw] = stringValue.includes("T")\n ? stringValue.split("T", 2)\n : [stringValue, ""];\n const timePart = (timePartRaw ?? "").slice(0, 5);\n const commit = (nextDate: string, nextTime: string): void => {\n if (!nextDate && !nextTime) {\n onValueChange(undefined);\n return;\n }\n if (!nextDate) {\n onValueChange(`${nextTime}`);\n return;\n }\n const combined = `${nextDate}T${nextTime || "00:00"}`;\n const parsed = new Date(combined);\n onValueChange(Number.isNaN(parsed.getTime()) ? combined : parsed.toISOString());\n };\n return (\n <div className="grid grid-cols-1 sm:grid-cols-[1fr_auto] gap-2">\n <DatePicker\n value={datePart ?? ""}\n onChange={(next) => commit(next, timePart)}\n placeholder={field.placeholder ?? "Select a date"}\n aria-label={`${field.name} date`}\n required={field.is_required}\n />\n <TimePicker\n value={timePart}\n onChange={(next) => commit(datePart ?? "", next)}\n placeholder="Time"\n aria-label={`${field.name} time`}\n />\n </div>\n );\n }\n\n case INPUT_FIELD_TYPE.Time:\n return (\n <TimePicker\n value={typeof value === "string" ? value : ""}\n onChange={(next) => onValueChange(next || undefined)}\n required={field.is_required}\n placeholder={field.placeholder ?? "Select a time"}\n aria-label={field.name}\n />\n );\n\n case INPUT_FIELD_TYPE.MultiSelect:\n return (\n <MultiSelectInput\n field={field}\n value={value}\n onValueChange={onValueChange}\n classNames={classNames}\n />\n );\n\n case INPUT_FIELD_TYPE.DateRange:\n return (\n <DateRangeInput\n field={field}\n value={value}\n onValueChange={onValueChange}\n classNames={classNames}\n />\n );\n\n case INPUT_FIELD_TYPE.Address:\n return (\n <AddressInput\n field={field}\n value={value}\n onValueChange={onValueChange}\n classNames={classNames}\n inputClass={inputClass}\n />\n );\n\n case INPUT_FIELD_TYPE.Location:\n return (\n <LocationInput\n field={field}\n value={value}\n onValueChange={onValueChange}\n classNames={classNames}\n inputClass={inputClass}\n />\n );\n\n case INPUT_FIELD_TYPE.Phone:\n return (\n <PhoneInput\n field={field}\n value={value}\n onValueChange={onValueChange}\n classNames={classNames}\n inputClass={inputClass}\n />\n );\n\n case INPUT_FIELD_TYPE.Signature:\n return (\n <SignatureInput\n field={field}\n value={value}\n onValueChange={onValueChange}\n classNames={classNames}\n />\n );\n\n default:\n return (\n <input\n type="text"\n value={typeof value === "string" ? value : ""}\n onChange={(e) => onValueChange(e.target.value)}\n placeholder={field.placeholder}\n className={inputClass}\n />\n );\n }\n}\n\nfunction FileUploadInput({\n field,\n value,\n onValueChange,\n onFileUpload,\n classNames,\n}: {\n field: ProductInputField;\n value: unknown;\n onValueChange: (value: unknown) => void;\n onFileUpload?: (file: File, field: ProductInputField) => Promise<string>;\n classNames?: CustomerInputFieldsClassNames;\n}): React.ReactElement {\n const [isUploading, setIsUploading] = React.useState(false);\n const fileUrl = typeof value === "string" ? value : undefined;\n const acceptedFormats = field.validation?.accepted_formats;\n const accept = acceptedFormats\n ? acceptedFormats.map((f) => `.${f}`).join(",")\n : field.field_type === INPUT_FIELD_TYPE.Image\n ? "image/*"\n : undefined;\n\n const handleFile = async (file: File) => {\n if (!onFileUpload) return;\n\n if (field.validation?.max_size_mb) {\n const maxBytes = field.validation.max_size_mb * 1024 * 1024;\n if (file.size > maxBytes) {\n return;\n }\n }\n\n setIsUploading(true);\n try {\n const url = await onFileUpload(file, field);\n onValueChange(url);\n } finally {\n setIsUploading(false);\n }\n };\n\n return (\n <div className={cn("space-y-2", classNames?.fileInput)}>\n {fileUrl ? (\n <div className="flex items-center gap-3">\n {field.field_type === INPUT_FIELD_TYPE.Image && (\n <img src={fileUrl} alt="Uploaded" className="w-16 h-16 object-cover rounded-md border border-border" />\n )}\n <div className="flex-1 min-w-0">\n <p className="text-sm text-foreground truncate">{fileUrl.split("/").pop()}</p>\n </div>\n <button\n type="button"\n onClick={() => onValueChange(undefined)}\n className="text-xs text-muted-foreground hover:text-destructive transition-colors"\n >\n Remove\n </button>\n </div>\n ) : (\n <label className="flex flex-col items-center justify-center w-full h-24 border-2 border-dashed border-border rounded-lg cursor-pointer hover:border-primary/40 transition-colors">\n <input\n type="file"\n accept={accept}\n onChange={(e) => {\n const file = e.target.files?.[0];\n if (file) void handleFile(file);\n }}\n className="hidden"\n disabled={isUploading}\n />\n {isUploading ? (\n <span className="text-sm text-muted-foreground">Uploading...</span>\n ) : (\n <>\n <span className="text-sm text-muted-foreground">\n {field.placeholder || `Upload ${field.field_type === INPUT_FIELD_TYPE.Image ? "image" : "file"}`}\n </span>\n {acceptedFormats && (\n <span className="text-xs text-muted-foreground/60 mt-1">\n {acceptedFormats.map((f) => f.toUpperCase()).join(", ")}\n {field.validation?.max_size_mb && ` \xB7 Max ${field.validation.max_size_mb}MB`}\n </span>\n )}\n </>\n )}\n </label>\n )}\n </div>\n );\n}\n\nfunction MultiSelectInput({\n field,\n value,\n onValueChange,\n classNames,\n}: {\n field: ProductInputField;\n value: unknown;\n onValueChange: (value: unknown) => void;\n classNames?: CustomerInputFieldsClassNames;\n}): React.ReactElement {\n const selected = Array.isArray(value) ? (value as string[]) : [];\n const options = field.options || [];\n const maxSelections = field.validation?.max_selections;\n\n const toggle = (opt: string) => {\n const next = selected.includes(opt)\n ? selected.filter((s) => s !== opt)\n : maxSelections && selected.length >= maxSelections\n ? selected\n : [...selected, opt];\n onValueChange(next.length > 0 ? next : undefined);\n };\n\n return (\n <div className={cn("space-y-2", classNames?.multiSelectGroup)}>\n {options.map((opt) => (\n <label\n key={opt}\n className={cn(\n "flex items-center gap-2 text-sm cursor-pointer",\n classNames?.multiSelectOption,\n )}\n >\n <input\n type="checkbox"\n checked={selected.includes(opt)}\n onChange={() => toggle(opt)}\n className="accent-primary w-4 h-4"\n />\n {opt}\n </label>\n ))}\n {maxSelections && (\n <p className="text-xs text-muted-foreground">\n {selected.length}/{maxSelections} selected\n </p>\n )}\n </div>\n );\n}\n\nfunction DateRangeInput({\n field,\n value,\n onValueChange,\n classNames,\n}: {\n field: ProductInputField;\n value: unknown;\n onValueChange: (value: unknown) => void;\n classNames?: CustomerInputFieldsClassNames;\n}): React.ReactElement {\n const range = (value && typeof value === "object" ? value : {}) as DateRangeValue;\n\n const update = (key: "start" | "end", v: string) => {\n const next = { ...range, [key]: v };\n onValueChange(next.start || next.end ? next : undefined);\n };\n\n return (\n <div className={cn("grid grid-cols-1 sm:grid-cols-2 gap-3", classNames?.dateRangeInput)}>\n <div>\n <label className="text-xs text-muted-foreground mb-1 block">Start</label>\n <DatePicker\n value={range.start || ""}\n onChange={(next) => update("start", next)}\n required={field.is_required}\n placeholder="Start date"\n aria-label={`${field.name} start date`}\n maxDate={range.end ? new Date(`${range.end}T00:00`) : undefined}\n />\n </div>\n <div>\n <label className="text-xs text-muted-foreground mb-1 block">End</label>\n <DatePicker\n value={range.end || ""}\n onChange={(next) => update("end", next)}\n required={field.is_required}\n placeholder="End date"\n aria-label={`${field.name} end date`}\n minDate={range.start ? new Date(`${range.start}T00:00`) : undefined}\n />\n </div>\n </div>\n );\n}\n\nfunction AddressInput({\n field,\n value,\n onValueChange,\n classNames,\n inputClass,\n}: {\n field: ProductInputField;\n value: unknown;\n onValueChange: (value: unknown) => void;\n classNames?: CustomerInputFieldsClassNames;\n inputClass: string;\n}): React.ReactElement {\n const clientContext = useOptionalCimplifyClient();\n const addr = (value && typeof value === "object" ? value : null) as AddressValue | null;\n const [inputText, setInputText] = React.useState(addr?.formatted_address ?? "");\n const [suggestions, setSuggestions] = React.useState<Array<{ description: string; place_id: string }>>([]);\n const [isOpen, setIsOpen] = React.useState(false);\n const [highlightedIndex, setHighlightedIndex] = React.useState(-1);\n const debounceRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);\n const seqRef = React.useRef(0);\n const sessionTokenRef = React.useRef(createSessionToken());\n const blurTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);\n\n React.useEffect(() => {\n return () => {\n if (debounceRef.current) clearTimeout(debounceRef.current);\n if (blurTimeoutRef.current) clearTimeout(blurTimeoutRef.current);\n seqRef.current += 1;\n };\n }, []);\n\n const fetchSuggestions = React.useCallback(async (query: string) => {\n const client = clientContext?.client;\n if (!client || query.length < 3) {\n setSuggestions([]);\n setIsOpen(false);\n return;\n }\n\n const seq = ++seqRef.current;\n const result = await client.places.autocomplete(query, sessionTokenRef.current);\n if (seqRef.current !== seq) return;\n\n if (result.ok && result.value.predictions.length > 0) {\n setSuggestions(result.value.predictions);\n setHighlightedIndex(0);\n setIsOpen(true);\n } else {\n setSuggestions([]);\n setIsOpen(false);\n }\n }, [clientContext]);\n\n const handleInputChange = (nextValue: string) => {\n setInputText(nextValue);\n if (debounceRef.current) clearTimeout(debounceRef.current);\n debounceRef.current = setTimeout(() => void fetchSuggestions(nextValue.trim()), 300);\n };\n\n const selectSuggestion = React.useCallback(async (suggestion: { description: string; place_id: string }) => {\n if (debounceRef.current) clearTimeout(debounceRef.current);\n if (blurTimeoutRef.current) clearTimeout(blurTimeoutRef.current);\n\n const fallback = suggestion.description.split(",")[0]?.trim() || suggestion.description;\n setInputText(fallback);\n setSuggestions([]);\n setIsOpen(false);\n\n const client = clientContext?.client;\n if (!client) return;\n\n const result = await client.places.details(suggestion.place_id, sessionTokenRef.current);\n sessionTokenRef.current = createSessionToken();\n\n if (result.ok) {\n const d = result.value;\n const addressValue: AddressValue = {\n formatted_address: d.formatted_address || fallback,\n street_address: d.street_address,\n apartment: d.apartment,\n city: d.city,\n region: d.region,\n postal_code: d.postal_code,\n country: d.country,\n latitude: d.latitude,\n longitude: d.longitude,\n place_id: d.place_id,\n };\n setInputText(addressValue.formatted_address);\n onValueChange(addressValue);\n }\n }, [clientContext, onValueChange]);\n\n return (\n <div className={cn("space-y-2 relative", classNames?.addressInput)}>\n <input\n type="text"\n value={inputText}\n onChange={(e) => handleInputChange(e.target.value)}\n onFocus={() => { if (suggestions.length > 0) setIsOpen(true); }}\n onBlur={() => { blurTimeoutRef.current = setTimeout(() => setIsOpen(false), 120); }}\n onKeyDown={(e) => {\n if (e.key === "Escape") { setIsOpen(false); return; }\n if (!isOpen || suggestions.length === 0) return;\n if (e.key === "ArrowDown") { e.preventDefault(); setHighlightedIndex((i) => i < suggestions.length - 1 ? i + 1 : 0); }\n else if (e.key === "ArrowUp") { e.preventDefault(); setHighlightedIndex((i) => i > 0 ? i - 1 : suggestions.length - 1); }\n else if (e.key === "Enter" && highlightedIndex >= 0) { e.preventDefault(); void selectSuggestion(suggestions[highlightedIndex]); }\n }}\n placeholder={field.placeholder || "Search for an address..."}\n required={field.is_required}\n autoComplete="off"\n aria-autocomplete="list"\n aria-expanded={isOpen && suggestions.length > 0}\n className={inputClass}\n />\n {isOpen && suggestions.length > 0 && (\n <div role="listbox" className="absolute left-0 right-0 top-full z-20 mt-1 overflow-hidden rounded-md border border-border bg-background shadow-lg">\n {suggestions.map((s, i) => (\n <button\n key={s.place_id}\n type="button"\n role="option"\n aria-selected={i === highlightedIndex}\n onMouseDown={(e) => { e.preventDefault(); void selectSuggestion(s); }}\n className={cn("block w-full px-3 py-2.5 text-left text-sm transition-colors", i === highlightedIndex ? "bg-muted text-foreground" : "text-foreground hover:bg-muted/70")}\n >\n {s.description}\n </button>\n ))}\n </div>\n )}\n {addr && (\n <input\n type="text"\n value={addr.apartment || ""}\n onChange={(e) => onValueChange({ ...addr, apartment: e.target.value || undefined })}\n placeholder="Apt, suite, unit (optional)"\n className={inputClass}\n />\n )}\n </div>\n );\n}\n\nfunction LocationInput({\n field,\n value,\n onValueChange,\n classNames,\n inputClass,\n}: {\n field: ProductInputField;\n value: unknown;\n onValueChange: (value: unknown) => void;\n classNames?: CustomerInputFieldsClassNames;\n inputClass: string;\n}): React.ReactElement {\n const clientContext = useOptionalCimplifyClient();\n const loc = (value && typeof value === "object" ? value : null) as LocationValue | null;\n const [inputText, setInputText] = React.useState(loc?.label ?? "");\n const [suggestions, setSuggestions] = React.useState<Array<{ description: string; place_id: string }>>([]);\n const [isOpen, setIsOpen] = React.useState(false);\n const [highlightedIndex, setHighlightedIndex] = React.useState(-1);\n const debounceRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);\n const seqRef = React.useRef(0);\n const sessionTokenRef = React.useRef(createSessionToken());\n const blurTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);\n\n React.useEffect(() => {\n return () => {\n if (debounceRef.current) clearTimeout(debounceRef.current);\n if (blurTimeoutRef.current) clearTimeout(blurTimeoutRef.current);\n seqRef.current += 1;\n };\n }, []);\n\n const fetchSuggestions = React.useCallback(async (query: string) => {\n const client = clientContext?.client;\n if (!client || query.length < 3) {\n setSuggestions([]);\n setIsOpen(false);\n return;\n }\n\n const seq = ++seqRef.current;\n const result = await client.places.autocomplete(query, sessionTokenRef.current);\n if (seqRef.current !== seq) return;\n\n if (result.ok && result.value.predictions.length > 0) {\n setSuggestions(result.value.predictions);\n setHighlightedIndex(0);\n setIsOpen(true);\n } else {\n setSuggestions([]);\n setIsOpen(false);\n }\n }, [clientContext]);\n\n const handleInputChange = (nextValue: string) => {\n setInputText(nextValue);\n if (debounceRef.current) clearTimeout(debounceRef.current);\n debounceRef.current = setTimeout(() => void fetchSuggestions(nextValue.trim()), 300);\n };\n\n const selectSuggestion = React.useCallback(async (suggestion: { description: string; place_id: string }) => {\n if (debounceRef.current) clearTimeout(debounceRef.current);\n if (blurTimeoutRef.current) clearTimeout(blurTimeoutRef.current);\n\n const label = suggestion.description.split(",")[0]?.trim() || suggestion.description;\n setInputText(label);\n setSuggestions([]);\n setIsOpen(false);\n\n const client = clientContext?.client;\n if (!client) return;\n\n const result = await client.places.details(suggestion.place_id, sessionTokenRef.current);\n sessionTokenRef.current = createSessionToken();\n\n if (result.ok) {\n const d = result.value;\n onValueChange({\n latitude: d.latitude,\n longitude: d.longitude,\n label: d.formatted_address || label,\n } satisfies LocationValue);\n setInputText(d.formatted_address || label);\n }\n }, [clientContext, onValueChange]);\n\n return (\n <div className={cn("relative", classNames?.locationInput)}>\n <input\n type="text"\n value={inputText}\n onChange={(e) => handleInputChange(e.target.value)}\n onFocus={() => { if (suggestions.length > 0) setIsOpen(true); }}\n onBlur={() => { blurTimeoutRef.current = setTimeout(() => setIsOpen(false), 120); }}\n onKeyDown={(e) => {\n if (e.key === "Escape") { setIsOpen(false); return; }\n if (!isOpen || suggestions.length === 0) return;\n if (e.key === "ArrowDown") { e.preventDefault(); setHighlightedIndex((i) => i < suggestions.length - 1 ? i + 1 : 0); }\n else if (e.key === "ArrowUp") { e.preventDefault(); setHighlightedIndex((i) => i > 0 ? i - 1 : suggestions.length - 1); }\n else if (e.key === "Enter" && highlightedIndex >= 0) { e.preventDefault(); void selectSuggestion(suggestions[highlightedIndex]); }\n }}\n placeholder={field.placeholder || "Search for a location..."}\n required={field.is_required}\n autoComplete="off"\n aria-autocomplete="list"\n aria-expanded={isOpen && suggestions.length > 0}\n className={inputClass}\n />\n {isOpen && suggestions.length > 0 && (\n <div role="listbox" className="absolute left-0 right-0 top-full z-20 mt-1 overflow-hidden rounded-md border border-border bg-background shadow-lg">\n {suggestions.map((s, i) => (\n <button\n key={s.place_id}\n type="button"\n role="option"\n aria-selected={i === highlightedIndex}\n onMouseDown={(e) => { e.preventDefault(); void selectSuggestion(s); }}\n className={cn("block w-full px-3 py-2.5 text-left text-sm transition-colors", i === highlightedIndex ? "bg-muted text-foreground" : "text-foreground hover:bg-muted/70")}\n >\n {s.description}\n </button>\n ))}\n </div>\n )}\n {loc && (\n <p className="text-xs text-muted-foreground mt-1">\n {loc.latitude.toFixed(4)}, {loc.longitude.toFixed(4)}\n </p>\n )}\n </div>\n );\n}\n\nfunction PhoneInput({\n field,\n value,\n onValueChange,\n classNames,\n inputClass,\n}: {\n field: ProductInputField;\n value: unknown;\n onValueChange: (value: unknown) => void;\n classNames?: CustomerInputFieldsClassNames;\n inputClass: string;\n}): React.ReactElement {\n const phone = (value && typeof value === "object" ? value : null) as PhoneValue | null;\n const [code, setCode] = React.useState(phone?.country_code ?? "+1");\n const [number, setNumber] = React.useState(phone?.number ?? "");\n\n const update = (nextCode: string, nextNumber: string) => {\n setCode(nextCode);\n setNumber(nextNumber);\n if (nextNumber) {\n onValueChange({\n country_code: nextCode,\n number: nextNumber,\n formatted: `${nextCode} ${nextNumber}`,\n } satisfies PhoneValue);\n } else {\n onValueChange(undefined);\n }\n };\n\n return (\n <div className={cn("flex gap-2", classNames?.phoneInput)}>\n <input\n type="text"\n value={code}\n onChange={(e) => update(e.target.value, number)}\n placeholder="+1"\n className={cn(inputClass, "w-20 shrink-0")}\n />\n <input\n type="tel"\n value={number}\n onChange={(e) => update(code, e.target.value)}\n placeholder={field.placeholder || "Phone number"}\n required={field.is_required}\n className={inputClass}\n />\n </div>\n );\n}\n\nfunction SignatureInput({\n field,\n value,\n onValueChange,\n classNames,\n}: {\n field: ProductInputField;\n value: unknown;\n onValueChange: (value: unknown) => void;\n classNames?: CustomerInputFieldsClassNames;\n}): React.ReactElement {\n const sig = (value && typeof value === "object" ? value : null) as SignatureValue | null;\n const canvasRef = React.useRef<HTMLCanvasElement>(null);\n const isDrawing = React.useRef(false);\n\n const getCtx = () => canvasRef.current?.getContext("2d") ?? null;\n\n const startDraw = (e: React.PointerEvent) => {\n const ctx = getCtx();\n if (!ctx) return;\n isDrawing.current = true;\n const rect = canvasRef.current!.getBoundingClientRect();\n ctx.beginPath();\n ctx.moveTo(e.clientX - rect.left, e.clientY - rect.top);\n canvasRef.current!.setPointerCapture(e.pointerId);\n };\n\n const draw = (e: React.PointerEvent) => {\n if (!isDrawing.current) return;\n const ctx = getCtx();\n if (!ctx) return;\n const rect = canvasRef.current!.getBoundingClientRect();\n ctx.lineWidth = 2;\n ctx.lineCap = "round";\n const style = getComputedStyle(canvasRef.current!);\n ctx.strokeStyle = style.color || "#000";\n ctx.lineTo(e.clientX - rect.left, e.clientY - rect.top);\n ctx.stroke();\n };\n\n const endDraw = () => {\n if (!isDrawing.current) return;\n isDrawing.current = false;\n const canvas = canvasRef.current;\n if (!canvas) return;\n onValueChange({\n data_url: canvas.toDataURL("image/png"),\n signer_name: sig?.signer_name,\n } satisfies SignatureValue);\n };\n\n const clear = () => {\n const canvas = canvasRef.current;\n if (!canvas) return;\n const ctx = canvas.getContext("2d");\n if (ctx) ctx.clearRect(0, 0, canvas.width, canvas.height);\n onValueChange(undefined);\n };\n\n return (\n <div className={cn("space-y-2", classNames?.signatureCanvas)}>\n <div className="relative rounded-md border border-input overflow-hidden">\n <canvas\n ref={canvasRef}\n width={400}\n height={150}\n className="w-full touch-none cursor-crosshair bg-background text-foreground"\n onPointerDown={startDraw}\n onPointerMove={draw}\n onPointerUp={endDraw}\n onPointerLeave={endDraw}\n />\n {sig?.data_url && (\n <button\n type="button"\n onClick={clear}\n className="absolute top-1.5 right-1.5 text-xs text-muted-foreground hover:text-destructive transition-colors"\n >\n Clear\n </button>\n )}\n </div>\n {!sig?.data_url && (\n <p className="text-xs text-muted-foreground">\n {field.placeholder || "Draw your signature above"}\n </p>\n )}\n </div>\n );\n}\n\nfunction createSessionToken(): string {\n if (typeof crypto !== "undefined" && crypto.randomUUID) {\n return crypto.randomUUID();\n }\n return `places_${Date.now()}_${Math.random().toString(16).slice(2)}`;\n}\n' }] }, "staff-picker": { "name": "staff-picker", "title": "StaffPicker", "description": "Staff member selection list with avatar and bio.", "type": "component", "registryDependencies": ["cn"], "files": [{ "path": "staff-picker.tsx", "content": '"use client";\n\nimport { Radio } from "@base-ui/react/radio";\nimport { RadioGroup } from "@base-ui/react/radio-group";\nimport React from "react";\nimport type { Staff } from "@cimplify/sdk";\nimport { cn } from "@cimplify/sdk/react";\n\nconst ANY_VALUE = "__any__";\n\nexport interface StaffPickerClassNames {\n root?: string;\n option?: string;\n avatar?: string;\n name?: string;\n bio?: string;\n}\n\nexport interface StaffPickerProps {\n /** List of available staff members. */\n staff: Staff[];\n /** Currently selected staff ID, or null for "Any available". */\n selectedStaffId?: string | null;\n /** Called when a staff member is selected. Passes null for "Any available". */\n onStaffSelect?: (staffId: string | null) => void;\n /** Show "Any available" option. Default: true. */\n showAnyOption?: boolean;\n /** Label for the "Any available" option. */\n anyLabel?: string;\n className?: string;\n classNames?: StaffPickerClassNames;\n}\n\nexport function StaffPicker({\n staff,\n selectedStaffId,\n onStaffSelect,\n showAnyOption = true,\n anyLabel = "Any available",\n className,\n classNames,\n}: StaffPickerProps): React.ReactElement {\n const groupValue =\n selectedStaffId === null ? ANY_VALUE : (selectedStaffId ?? "");\n\n return (\n <RadioGroup\n data-cimplify-staff-picker\n className={cn(className, classNames?.root)}\n value={groupValue}\n onValueChange={(value) => {\n onStaffSelect?.(value === ANY_VALUE ? null : value);\n }}\n >\n {showAnyOption && (\n <Radio.Root\n value={ANY_VALUE}\n data-cimplify-staff-option\n data-selected={selectedStaffId === null || undefined}\n data-any\n className={classNames?.option}\n >\n <span data-cimplify-staff-name className={classNames?.name}>\n {anyLabel}\n </span>\n </Radio.Root>\n )}\n {staff.map((member) => (\n <Radio.Root\n key={member.id}\n value={member.id}\n data-cimplify-staff-option\n data-selected={selectedStaffId === member.id || undefined}\n className={classNames?.option}\n >\n {member.avatar_url && (\n <img\n src={member.avatar_url}\n alt={member.name}\n data-cimplify-staff-avatar\n className={classNames?.avatar}\n />\n )}\n <span data-cimplify-staff-name className={classNames?.name}>\n {member.name}\n </span>\n {member.bio && (\n <span data-cimplify-staff-bio className={classNames?.bio}>\n {member.bio}\n </span>\n )}\n </Radio.Root>\n ))}\n </RadioGroup>\n );\n}\n' }] }, "checkout-page": { "name": "checkout-page", "title": "CheckoutPage", "description": "Multi-step checkout with auth, address, and payment.", "type": "component", "registryDependencies": ["cn"], "files": [{ "path": "checkout-page.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { ProcessCheckoutResult } from "@cimplify/sdk";\nimport { useCimplifyClient } from "@cimplify/sdk/react";\nimport { CimplifyCheckout } from "@cimplify/sdk/react";\nimport type { CimplifyCheckoutProps } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface CheckoutPageClassNames {\n root?: string;\n header?: string;\n title?: string;\n checkout?: string;\n}\n\nexport interface CheckoutPageProps {\n /** Page title. */\n title?: string;\n /** Called after successful checkout. */\n onComplete: (result: ProcessCheckoutResult) => void;\n /** Called on checkout failure. */\n onError?: (error: { code: string; message: string }) => void;\n /** Props forwarded to CimplifyCheckout. */\n checkoutProps?: Partial<\n Omit<CimplifyCheckoutProps, "client" | "onComplete" | "onError">\n >;\n className?: string;\n classNames?: CheckoutPageClassNames;\n}\n\n/**\n * CheckoutPage \u2014 thin page shell around CimplifyCheckout.\n *\n * Reads the CimplifyClient from CimplifyProvider context,\n * renders a page header, and delegates all checkout logic\n * to CimplifyCheckout.\n */\nexport function CheckoutPage({\n title = "Checkout",\n onComplete,\n onError,\n checkoutProps,\n className,\n classNames,\n}: CheckoutPageProps): React.ReactElement {\n const { client } = useCimplifyClient();\n\n return (\n <div data-cimplify-checkout-page className={cn(className, classNames?.root)}>\n <div data-cimplify-checkout-header className={classNames?.header}>\n <h1 data-cimplify-checkout-title className={classNames?.title}>\n {title}\n </h1>\n </div>\n\n <CimplifyCheckout\n client={client}\n onComplete={onComplete}\n onError={onError}\n className={classNames?.checkout}\n {...checkoutProps}\n />\n </div>\n );\n}\n' }] }, "session-message-banner": { "name": "session-message-banner", "title": "SessionMessageBanner", "description": "Top-of-page banner for session-scoped messages (promo nudges, abandoned cart prompts, low-stock alerts) with dismiss tracking.", "type": "component", "registryDependencies": ["cn"], "files": [{ "path": "session-message-banner.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { SessionMessage } from "../activity";\nimport { useActivityState } from "./hooks/use-activity-state";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface SessionMessageBannerClassNames {\n root?: string;\n message?: string;\n text?: string;\n dismissButton?: string;\n empty?: string;\n}\n\nexport interface SessionMessageBannerProps {\n /** Override messages (skips fetch). For SSR, pass pre-fetched messages. */\n messages?: SessionMessage[];\n /** Called when a message is dismissed. Receives the message code. */\n onDismiss?: (code: string) => void;\n /** Custom message renderer. */\n renderMessage?: (message: SessionMessage) => React.ReactNode;\n className?: string;\n classNames?: SessionMessageBannerClassNames;\n}\n\n/**\n * SessionMessageBanner \u2014 renders activity-based session messages.\n *\n * Displays dismissible message cards with level-based styling (info, promotion, urgency, suggestion).\n * Returns `null` when there are no messages.\n */\nexport function SessionMessageBanner({\n messages: messagesProp,\n onDismiss,\n renderMessage,\n className,\n classNames,\n}: SessionMessageBannerProps): React.ReactElement | null {\n const { messages: fetched, isLoading, dismissMessage } = useActivityState({\n enabled: messagesProp === undefined,\n });\n\n const messages = messagesProp ?? fetched;\n\n const handleDismiss = async (code: string): Promise<void> => {\n if (onDismiss) {\n onDismiss(code);\n } else {\n await dismissMessage(code);\n }\n };\n\n if (isLoading && messages.length === 0) {\n return (\n <div\n data-cimplify-session-message-banner\n aria-busy="true"\n className={cn(className, classNames?.root)}\n />\n );\n }\n\n if (messages.length === 0) {\n return null;\n }\n\n return (\n <div data-cimplify-session-message-banner className={cn(className, classNames?.root)}>\n {messages.map((msg) => (\n <div\n key={msg.code}\n data-cimplify-session-message\n data-level={msg.level}\n className={classNames?.message}\n >\n {renderMessage ? (\n renderMessage(msg)\n ) : (\n <>\n <span data-cimplify-session-message-text className={classNames?.text}>\n {msg.text}\n </span>\n {msg.dismissible && (\n <button\n type="button"\n onClick={() => void handleDismiss(msg.code)}\n data-cimplify-session-message-dismiss\n className={classNames?.dismissButton}\n aria-label="Dismiss message"\n >\n ×\n </button>\n )}\n </>\n )}\n </div>\n ))}\n </div>\n );\n}\n' }] }, "resource-picker": { "name": "resource-picker", "title": "ResourcePicker", "description": "Staff / room / resource picker for bookable services \u2014 used by services and restaurant reservation flows.", "type": "component", "registryDependencies": ["cn"], "files": [{ "path": "resource-picker.tsx", "content": '"use client";\n\nimport { Radio } from "@base-ui/react/radio";\nimport { RadioGroup } from "@base-ui/react/radio-group";\nimport React from "react";\nimport type { Room } from "../types/business";\nimport { cn } from "@cimplify/sdk/react";\n\nconst ANY_VALUE = "__any__";\n\nexport interface Resource {\n id: string;\n name: string;\n description?: string;\n capacity?: number;\n floor?: string;\n image_url?: string;\n is_available?: boolean;\n}\n\nexport interface ResourcePickerClassNames {\n root?: string;\n option?: string;\n image?: string;\n name?: string;\n description?: string;\n meta?: string;\n capacity?: string;\n floor?: string;\n unavailable?: string;\n}\n\nexport interface ResourcePickerProps {\n /** List of available resources (rooms, equipment, etc.). */\n resources: Resource[];\n /** Currently selected resource ID, or null for "Any available". */\n selectedResourceId?: string | null;\n /** Called when a resource is selected. Passes null for "Any available". */\n onResourceSelect?: (resourceId: string | null) => void;\n /** Show "Any available" option. Default: true. */\n showAnyOption?: boolean;\n /** Label for the "Any available" option. */\n anyLabel?: string;\n /** Custom image renderer (e.g. Next.js Image). */\n renderImage?: (props: {\n src: string;\n alt: string;\n className?: string;\n }) => React.ReactNode;\n className?: string;\n classNames?: ResourcePickerClassNames;\n}\n\nexport function roomToResource(room: Room): Resource {\n return {\n id: room.id,\n name: room.name,\n capacity: room.capacity,\n floor: room.floor,\n is_available: room.status === "available",\n };\n}\n\nexport function ResourcePicker({\n resources,\n selectedResourceId,\n onResourceSelect,\n showAnyOption = true,\n anyLabel = "Any available",\n renderImage,\n className,\n classNames,\n}: ResourcePickerProps): React.ReactElement {\n const groupValue =\n selectedResourceId === null ? ANY_VALUE : (selectedResourceId ?? "");\n\n return (\n <RadioGroup\n data-cimplify-resource-picker\n className={cn("flex flex-col gap-2", className, classNames?.root)}\n value={groupValue}\n onValueChange={(value) => {\n onResourceSelect?.(value === ANY_VALUE ? null : value);\n }}\n >\n {showAnyOption && (\n <Radio.Root\n value={ANY_VALUE}\n data-cimplify-resource-option\n data-selected={selectedResourceId === null || undefined}\n data-any\n className={cn(\n "flex items-center gap-3 rounded-lg border border-border p-3 transition-colors cursor-pointer",\n "hover:bg-muted data-[checked]:border-primary data-[checked]:bg-primary/5",\n classNames?.option,\n )}\n >\n <span data-cimplify-resource-name className={cn("font-medium text-foreground", classNames?.name)}>\n {anyLabel}\n </span>\n </Radio.Root>\n )}\n {resources.map((resource) => {\n const unavailable = resource.is_available === false;\n return (\n <Radio.Root\n key={resource.id}\n value={resource.id}\n disabled={unavailable}\n data-cimplify-resource-option\n data-selected={selectedResourceId === resource.id || undefined}\n data-unavailable={unavailable || undefined}\n className={cn(\n "flex items-center gap-3 rounded-lg border border-border p-3 transition-colors cursor-pointer",\n "hover:bg-muted data-[checked]:border-primary data-[checked]:bg-primary/5",\n unavailable && "opacity-50 cursor-not-allowed",\n classNames?.option,\n unavailable ? classNames?.unavailable : undefined,\n )}\n >\n {resource.image_url && (\n renderImage ? (\n renderImage({\n src: resource.image_url,\n alt: resource.name,\n className: classNames?.image,\n })\n ) : (\n <img\n src={resource.image_url}\n alt={resource.name}\n data-cimplify-resource-image\n className={cn("w-10 h-10 rounded-lg object-cover", classNames?.image)}\n />\n )\n )}\n <div className="flex flex-col gap-0.5 flex-1 min-w-0">\n <span data-cimplify-resource-name className={cn("font-medium text-foreground", classNames?.name)}>\n {resource.name}\n </span>\n {resource.description && (\n <span data-cimplify-resource-description className={cn("text-sm text-muted-foreground truncate", classNames?.description)}>\n {resource.description}\n </span>\n )}\n <div data-cimplify-resource-meta className={cn("flex items-center gap-2 text-xs text-muted-foreground", classNames?.meta)}>\n {resource.capacity !== undefined && (\n <span data-cimplify-resource-capacity className={classNames?.capacity}>\n Up to {resource.capacity}\n </span>\n )}\n {resource.floor && (\n <span data-cimplify-resource-floor className={classNames?.floor}>\n {resource.floor}\n </span>\n )}\n </div>\n </div>\n </Radio.Root>\n );\n })}\n </RadioGroup>\n );\n}\n' }] }, "category-grid": { "name": "category-grid", "title": "CategoryGrid", "description": "Responsive grid of category cards.", "type": "component", "registryDependencies": ["cn"], "files": [{ "path": "category-grid.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { Category } from "@cimplify/sdk";\nimport { useCategories } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface CategoryGridClassNames {\n root?: string;\n item?: string;\n name?: string;\n description?: string;\n count?: string;\n empty?: string;\n}\n\nexport interface CategoryGridProps {\n /** Override categories (skips useCategories fetch). */\n categories?: Category[];\n /** Called when a category card is clicked. */\n onSelect?: (category: Category) => void;\n /** Custom card renderer. */\n renderCard?: (category: Category) => React.ReactNode;\n /** Responsive column counts. */\n columns?: { sm?: number; md?: number; lg?: number };\n /** Text shown when empty. */\n emptyMessage?: string;\n className?: string;\n classNames?: CategoryGridClassNames;\n}\n\n/**\n * CategoryGrid \u2014 responsive grid of category cards.\n *\n * Fetches categories via `useCategories` unless overridden.\n * Uses `React.useId()` for hydration-safe responsive CSS.\n */\nexport function CategoryGrid({\n categories: categoriesProp,\n onSelect,\n renderCard,\n columns,\n emptyMessage,\n className,\n classNames,\n}: CategoryGridProps): React.ReactElement {\n const { categories: fetched, isLoading } = useCategories({\n enabled: categoriesProp === undefined,\n });\n const categories = categoriesProp ?? fetched;\n\n const rawId = React.useId();\n const gridId = `cimplify-cat-grid-${rawId.replace(/:/g, "")}`;\n\n const sm = columns?.sm ?? 2;\n const md = columns?.md ?? 3;\n const lg = columns?.lg ?? 4;\n\n if (isLoading && categories.length === 0) {\n return (\n <div\n data-cimplify-category-grid\n aria-busy="true"\n className={cn(className, classNames?.root)}\n />\n );\n }\n\n if (categories.length === 0) {\n return (\n <div\n data-cimplify-category-grid\n data-empty\n className={cn(className, classNames?.root, classNames?.empty)}\n >\n <p>{emptyMessage ?? "No categories found"}</p>\n </div>\n );\n }\n\n const css = [\n `#${gridId}{display:grid;grid-template-columns:repeat(${sm},1fr);gap:1rem}`,\n `@media(min-width:768px){#${gridId}{grid-template-columns:repeat(${md},1fr)}}`,\n `@media(min-width:1024px){#${gridId}{grid-template-columns:repeat(${lg},1fr)}}`,\n ].join("");\n\n return (\n <>\n <style dangerouslySetInnerHTML={{ __html: css }} />\n <div\n id={gridId}\n data-cimplify-category-grid\n className={cn(className, classNames?.root)}\n >\n {categories.map((category: Category) => (\n <button\n key={category.id}\n type="button"\n onClick={() => onSelect?.(category)}\n data-cimplify-category-card\n className={classNames?.item}\n >\n {renderCard ? (\n renderCard(category)\n ) : (\n <>\n <span data-cimplify-category-name className={classNames?.name}>\n {category.name}\n </span>\n {category.description && (\n <span data-cimplify-category-description className={classNames?.description}>\n {category.description}\n </span>\n )}\n {category.product_count != null && (\n <span data-cimplify-category-count className={classNames?.count}>\n {category.product_count} {category.product_count === 1 ? "product" : "products"}\n </span>\n )}\n </>\n )}\n </button>\n ))}\n </div>\n </>\n );\n}\n' }] }, "booking-page": { "name": "booking-page", "title": "BookingPage", "description": "Multi-step booking flow: service, staff, resource, date/slot, confirmation.", "type": "component", "registryDependencies": ["date-slot-picker", "staff-picker", "resource-picker", "price", "cn"], "files": [{ "path": "booking-page.tsx", "content": '"use client";\n\nimport React, { useState, useCallback } from "react";\nimport type { Product } from "@cimplify/sdk";\nimport type { Service, Staff, AvailableSlot, CustomerBooking } from "@cimplify/sdk";\nimport type { Resource } from "./resource-picker";\nimport { useCart } from "@cimplify/sdk/react";\nimport { useCimplifyClient } from "@cimplify/sdk/react";\nimport { DateSlotPicker } from "@cimplify/sdk/react";\nimport { StaffPicker } from "@cimplify/sdk/react";\nimport { ResourcePicker } from "./resource-picker";\nimport { Price } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\nimport { formatDuration } from "./utils/format-duration";\nimport { parsePrice } from "@cimplify/sdk";\n\nexport interface BookingPageClassNames {\n root?: string;\n header?: string;\n title?: string;\n serviceInfo?: string;\n step?: string;\n stepTitle?: string;\n summary?: string;\n summaryRow?: string;\n confirmButton?: string;\n backButton?: string;\n error?: string;\n rescheduleInfo?: string;\n depositInfo?: string;\n cancellationPolicy?: string;\n resourceStep?: string;\n}\n\nexport interface BookingPageProps {\n /** The service being booked. */\n service: Service;\n /** The underlying product (for deposit/cancellation fields). Falls back to service fields. */\n product?: Product;\n /** Optional staff list for staff selection step. */\n staff?: Staff[];\n /** Optional resources for resource selection step (rooms, equipment, etc.). */\n resources?: Resource[];\n /** Number of participants. */\n participantCount?: number;\n /** Page title. */\n title?: string;\n /** Called after successfully adding to cart. */\n onBooked?: (slot: AvailableSlot, staffId: string | null) => void;\n /** Called when user wants to go back. */\n onBack?: () => void;\n /** Existing booking for reschedule mode. */\n existingBooking?: CustomerBooking;\n /** Called after a successful reschedule. */\n onRescheduled?: (booking: CustomerBooking, newSlot: AvailableSlot) => void;\n /** Show price on slots. Default: true. */\n showPrice?: boolean;\n className?: string;\n classNames?: BookingPageClassNames;\n}\n\nconst STEP = {\n SELECT_SLOT: "select-slot",\n SELECT_RESOURCE: "select-resource",\n SELECT_STAFF: "select-staff",\n CONFIRM: "confirm",\n} as const;\n\ntype BookingStep = (typeof STEP)[keyof typeof STEP];\n\nexport function BookingPage({\n service,\n product,\n staff,\n resources,\n participantCount,\n title,\n onBooked,\n onBack,\n existingBooking,\n onRescheduled,\n showPrice = true,\n className,\n classNames,\n}: BookingPageProps): React.ReactElement {\n const { addItem } = useCart();\n const { client } = useCimplifyClient();\n\n const isReschedule = existingBooking !== undefined;\n\n const [step, setStep] = useState<BookingStep>(STEP.SELECT_SLOT);\n const [selectedSlot, setSelectedSlot] = useState<AvailableSlot | null>(null);\n const [selectedDate, setSelectedDate] = useState<string | null>(null);\n const [selectedStaffId, setSelectedStaffId] = useState<string | null>(null);\n const [selectedResourceId, setSelectedResourceId] = useState<string | null>(null);\n const [isSubmitting, setIsSubmitting] = useState(false);\n const [error, setError] = useState<string | null>(null);\n\n const hasResourceStep = resources && resources.length > 0;\n const hasStaffStep = staff && staff.length > 0;\n\n // Deposit info \u2014 prefer product fields, then fall back to service metadata\n const depositType = product?.deposit_type;\n const depositAmount = product?.deposit_amount;\n const hasDeposit = depositType !== undefined && depositType !== "none" && depositAmount !== undefined && parsePrice(depositAmount) !== 0;\n\n // Cancellation policy\n const cancellationMinutes = product?.cancellation_window_minutes;\n const noShowFee = product?.no_show_fee;\n\n const handleSlotSelect = useCallback(\n (slot: AvailableSlot, date: string) => {\n setSelectedSlot(slot);\n setSelectedDate(date);\n setError(null);\n if (hasResourceStep) {\n setStep(STEP.SELECT_RESOURCE);\n } else if (hasStaffStep) {\n setStep(STEP.SELECT_STAFF);\n } else {\n setStep(STEP.CONFIRM);\n }\n },\n [hasResourceStep, hasStaffStep],\n );\n\n const handleResourceSelect = useCallback(\n (resourceId: string | null) => {\n setSelectedResourceId(resourceId);\n if (hasStaffStep) {\n setStep(STEP.SELECT_STAFF);\n } else {\n setStep(STEP.CONFIRM);\n }\n },\n [hasStaffStep],\n );\n\n const handleStaffSelect = useCallback((staffId: string | null) => {\n setSelectedStaffId(staffId);\n setStep(STEP.CONFIRM);\n }, []);\n\n const handleBack = useCallback(() => {\n setError(null);\n if (step === STEP.CONFIRM && hasStaffStep) {\n setStep(STEP.SELECT_STAFF);\n } else if (step === STEP.CONFIRM && hasResourceStep) {\n setStep(STEP.SELECT_RESOURCE);\n } else if (step === STEP.SELECT_STAFF && hasResourceStep) {\n setStep(STEP.SELECT_RESOURCE);\n } else if (step === STEP.CONFIRM || step === STEP.SELECT_STAFF || step === STEP.SELECT_RESOURCE) {\n setStep(STEP.SELECT_SLOT);\n } else {\n onBack?.();\n }\n }, [step, hasStaffStep, hasResourceStep, onBack]);\n\n const handleConfirm = useCallback(async () => {\n if (!selectedSlot || !selectedDate) return;\n\n setIsSubmitting(true);\n setError(null);\n\n try {\n if (isReschedule && existingBooking) {\n // Reschedule mode \u2014 call the scheduling API instead of adding to cart\n const serviceItem = existingBooking.service_items[0];\n const result = await client.scheduling.rescheduleBooking({\n order_id: existingBooking.order_id,\n line_item_id: serviceItem?.service_id ?? existingBooking.order_id,\n new_start_time: selectedSlot.start_time,\n new_end_time: selectedSlot.end_time,\n new_staff_id: selectedStaffId || undefined,\n reschedule_type: "customer",\n });\n\n if (!result.ok) {\n throw result.error;\n }\n\n onRescheduled?.(existingBooking, selectedSlot);\n } else {\n // Normal booking mode \u2014 add to cart\n const serviceProduct = {\n id: service.product_id || service.id,\n business_id: service.business_id || "",\n category_id: service.category_id || undefined,\n name: service.name,\n slug: service.id,\n description: service.description || undefined,\n image_url: service.image_url || undefined,\n default_price: (service.price || "0") as Product["default_price"],\n type: "service" as const,\n inventory_type: "none" as const,\n variant_strategy: "fetch_all" as const,\n is_active: service.is_available,\n created_at: new Date().toISOString(),\n updated_at: new Date().toISOString(),\n };\n\n await addItem(serviceProduct, 1, {\n scheduledStart: selectedSlot.start_time,\n scheduledEnd: selectedSlot.end_time,\n staffId: selectedStaffId || undefined,\n resourceId: selectedResourceId || undefined,\n });\n\n onBooked?.(selectedSlot, selectedStaffId);\n }\n } catch (err) {\n const fallbackMessage = isReschedule ? "Failed to reschedule booking" : "Failed to add booking to cart";\n setError(err instanceof Error ? err.message : fallbackMessage);\n } finally {\n setIsSubmitting(false);\n }\n }, [selectedSlot, selectedDate, selectedStaffId, service, addItem, onBooked, isReschedule, existingBooking, client, onRescheduled]);\n\n return (\n <div data-cimplify-booking-page className={cn(className, classNames?.root)}>\n <div data-cimplify-booking-page-header className={classNames?.header}>\n {onBack && step === STEP.SELECT_SLOT && (\n <button\n type="button"\n onClick={onBack}\n data-cimplify-booking-page-back\n className={classNames?.backButton}\n >\n Back\n </button>\n )}\n {step !== STEP.SELECT_SLOT && (\n <button\n type="button"\n onClick={handleBack}\n data-cimplify-booking-page-back\n className={classNames?.backButton}\n >\n Back\n </button>\n )}\n <h1 data-cimplify-booking-page-title className={classNames?.title}>\n {title || (isReschedule ? "Reschedule Booking" : `Book ${service.name}`)}\n </h1>\n </div>\n\n {isReschedule && existingBooking && (\n <div data-cimplify-booking-reschedule-info className={classNames?.rescheduleInfo}>\n <span>Rescheduling booking from {new Date(existingBooking.service_items[0]?.scheduled_start ?? existingBooking.created_at).toLocaleDateString()}</span>\n </div>\n )}\n\n <div data-cimplify-booking-service-info className={classNames?.serviceInfo}>\n <span data-cimplify-booking-service-name>{service.name}</span>\n <span data-cimplify-booking-service-duration>{formatDuration(service.duration_minutes, service.duration_unit)}</span>\n {service.price && (\n <span data-cimplify-booking-service-price>\n <Price amount={service.price} />\n </span>\n )}\n {hasDeposit && (\n <span data-cimplify-booking-deposit-info className={classNames?.depositInfo}>\n Deposit: {depositType === "percentage" ? `${parsePrice(depositAmount!)}%` : <Price amount={depositAmount!} />}\n </span>\n )}\n {cancellationMinutes !== undefined && cancellationMinutes > 0 && (\n <span data-cimplify-booking-cancellation-policy className={classNames?.cancellationPolicy}>\n Free cancellation up to {cancellationMinutes >= 60 ? `${Math.floor(cancellationMinutes / 60)} hour${Math.floor(cancellationMinutes / 60) !== 1 ? "s" : ""}` : `${cancellationMinutes} minute${cancellationMinutes !== 1 ? "s" : ""}`} before\n </span>\n )}\n {noShowFee !== undefined && parsePrice(noShowFee) !== 0 && (\n <span data-cimplify-booking-no-show-fee className={classNames?.cancellationPolicy}>\n No-show fee: <Price amount={noShowFee} />\n </span>\n )}\n </div>\n\n {error && (\n <div data-cimplify-booking-error className={classNames?.error}>\n {error}\n </div>\n )}\n\n {step === STEP.SELECT_SLOT && (\n <div data-cimplify-booking-step="select-slot" className={classNames?.step}>\n <h2 data-cimplify-booking-step-title className={classNames?.stepTitle}>\n Select a date & time\n </h2>\n <DateSlotPicker\n serviceId={service.id}\n participantCount={participantCount}\n selectedSlot={selectedSlot}\n onSlotSelect={handleSlotSelect}\n showPrice={showPrice}\n />\n </div>\n )}\n\n {step === STEP.SELECT_RESOURCE && resources && (\n <div data-cimplify-booking-step="select-resource" className={cn(classNames?.step, classNames?.resourceStep)}>\n <h2 data-cimplify-booking-step-title className={classNames?.stepTitle}>\n Choose a room\n </h2>\n <ResourcePicker\n resources={resources}\n selectedResourceId={selectedResourceId}\n onResourceSelect={handleResourceSelect}\n />\n </div>\n )}\n\n {step === STEP.SELECT_STAFF && staff && (\n <div data-cimplify-booking-step="select-staff" className={classNames?.step}>\n <h2 data-cimplify-booking-step-title className={classNames?.stepTitle}>\n Choose a provider\n </h2>\n <StaffPicker\n staff={staff}\n selectedStaffId={selectedStaffId}\n onStaffSelect={handleStaffSelect}\n />\n </div>\n )}\n\n {step === STEP.CONFIRM && selectedSlot && (\n <div data-cimplify-booking-step="confirm" className={classNames?.step}>\n <h2 data-cimplify-booking-step-title className={classNames?.stepTitle}>\n Confirm your booking\n </h2>\n <div data-cimplify-booking-summary className={classNames?.summary}>\n <div data-cimplify-booking-summary-row className={classNames?.summaryRow}>\n <span>Service</span>\n <span>{service.name}</span>\n </div>\n <div data-cimplify-booking-summary-row className={classNames?.summaryRow}>\n <span>Date</span>\n <span>{selectedDate}</span>\n </div>\n <div data-cimplify-booking-summary-row className={classNames?.summaryRow}>\n <span>Time</span>\n <span>\n {new Date(selectedSlot.start_time).toLocaleTimeString(undefined, {\n hour: "numeric",\n minute: "2-digit",\n })}\n </span>\n </div>\n <div data-cimplify-booking-summary-row className={classNames?.summaryRow}>\n <span>Duration</span>\n <span>{formatDuration(service.duration_minutes, service.duration_unit)}</span>\n </div>\n {selectedResourceId && resources && (\n <div data-cimplify-booking-summary-row className={classNames?.summaryRow}>\n <span>Room</span>\n <span>{resources.find((r) => r.id === selectedResourceId)?.name ?? "Selected"}</span>\n </div>\n )}\n {selectedStaffId && staff && (\n <div data-cimplify-booking-summary-row className={classNames?.summaryRow}>\n <span>Provider</span>\n <span>{staff.find((s) => s.id === selectedStaffId)?.name ?? "Selected"}</span>\n </div>\n )}\n {(selectedSlot.price || service.price) && (\n <div data-cimplify-booking-summary-row className={classNames?.summaryRow}>\n <span>Price</span>\n <span>\n <Price amount={selectedSlot.price || service.price!} />\n </span>\n </div>\n )}\n {hasDeposit && (\n <div data-cimplify-booking-summary-row className={cn(classNames?.summaryRow, classNames?.depositInfo)}>\n <span>Deposit</span>\n <span>\n {depositType === "percentage" ? `${parsePrice(depositAmount!)}%` : <Price amount={depositAmount!} />}\n </span>\n </div>\n )}\n </div>\n <button\n type="button"\n onClick={handleConfirm}\n disabled={isSubmitting}\n data-cimplify-booking-confirm\n className={classNames?.confirmButton}\n >\n {isSubmitting\n ? (isReschedule ? "Rescheduling\u2026" : "Adding to cart\u2026")\n : (isReschedule ? "Reschedule" : "Confirm Booking")}\n </button>\n </div>\n )}\n </div>\n );\n}\n' }] }, "product-card": { "name": "product-card", "title": "ProductCard", "description": "Product display card with modal or link mode.", "type": "component", "registryDependencies": ["price", "product-sheet", "cn"], "files": [{ "path": "product-card.tsx", "content": '"use client";\n\nimport React, { useCallback, useState } from "react";\nimport { Dialog } from "@base-ui/react/dialog";\nimport type { Product, ProductWithDetails } from "@cimplify/sdk";\nimport { PRODUCT_TYPE, RENDER_HINT, DURATION_UNIT } from "@cimplify/sdk";\nimport { useProduct } from "@cimplify/sdk/react";\nimport { Price } from "@cimplify/sdk/react";\nimport { ProductSheet } from "@cimplify/sdk/react";\nimport { CardVariant } from "@cimplify/sdk/react";\nimport type { CardLayoutProps } from "@cimplify/sdk/react";\nimport { FoodProductCard } from "@cimplify/sdk/react";\nimport { RetailProductCard } from "@cimplify/sdk/react";\nimport { WholesaleProductCard } from "@cimplify/sdk/react";\nimport { DigitalProductCard } from "@cimplify/sdk/react";\nimport { StandardServiceCard } from "@cimplify/sdk/react";\nimport { RentalServiceCard } from "@cimplify/sdk/react";\nimport { AccommodationCard } from "@cimplify/sdk/react";\nimport { LeaseServiceCard } from "@cimplify/sdk/react";\nimport { SubscriptionCard } from "@cimplify/sdk/react";\nimport { BundleProductCard } from "./cards/bundle-product-card";\nimport { CompositeProductCard } from "./cards/composite-product-card";\nimport { cn } from "@cimplify/sdk/react";\n\nconst ASPECT_STYLES: Record<string, React.CSSProperties> = {\n square: { aspectRatio: "1/1" },\n "4/3": { aspectRatio: "4/3" },\n "16/10": { aspectRatio: "16/10" },\n "3/4": { aspectRatio: "3/4" },\n};\n\nexport interface ProductCardClassNames {\n root?: string;\n imageContainer?: string;\n image?: string;\n body?: string;\n name?: string;\n description?: string;\n price?: string;\n badges?: string;\n badge?: string;\n modal?: string;\n modalOverlay?: string;\n}\n\nexport interface ProductCardProps {\n /** The product to display. */\n product: Product;\n /** Display mode: "card" opens a modal, "page" renders as a link. Auto-detected from product.display_mode. */\n displayMode?: "card" | "page";\n /** Explicit card variant. Auto-detected from product data when omitted. */\n variant?: CardVariant;\n /** Per-slug card map. Highest priority \u2014 maps a product slug to a custom card component. */\n cards?: Record<string, React.ComponentType<CardLayoutProps>>;\n /** Link href for page mode. Default: `/menu/${product.slug}` */\n href?: string;\n /** Custom modal content renderer. Receives the fully-loaded product and a close callback. */\n renderModal?: (product: ProductWithDetails, onClose: () => void) => React.ReactNode;\n /** Custom image renderer (e.g. Next.js Image). */\n renderImage?: (props: {\n src: string;\n alt: string;\n className?: string;\n }) => React.ReactNode;\n /** Custom link renderer for page mode (e.g. Next.js Link). */\n renderLink?: (props: {\n href: string;\n className?: string;\n children: React.ReactNode;\n }) => React.ReactElement;\n /** Called when quick-add button is clicked (if the resolved card supports it). */\n onQuickAdd?: (product: Product) => void;\n /** Replace the entire default card body. */\n children?: React.ReactNode;\n /** Image aspect ratio. Default: "4/3". */\n aspectRatio?: "square" | "4/3" | "16/10" | "3/4";\n className?: string;\n classNames?: ProductCardClassNames;\n}\n\nconst BUILT_IN_CARDS: Record<string, React.ComponentType<CardLayoutProps>> = {\n [CardVariant.Food]: FoodProductCard,\n [CardVariant.Retail]: RetailProductCard,\n [CardVariant.Wholesale]: WholesaleProductCard,\n [CardVariant.Digital]: DigitalProductCard,\n [CardVariant.Standard]: StandardServiceCard,\n [CardVariant.Rental]: RentalServiceCard,\n [CardVariant.Accommodation]: AccommodationCard,\n [CardVariant.Lease]: LeaseServiceCard,\n [CardVariant.Bundle]: BundleProductCard,\n [CardVariant.Composite]: CompositeProductCard,\n [CardVariant.Subscription]: SubscriptionCard,\n};\n\nconst RENTAL_UNITS = new Set<string>([DURATION_UNIT.Days, DURATION_UNIT.Hours]);\nconst LEASE_UNITS = new Set<string>([DURATION_UNIT.Weeks, DURATION_UNIT.Months, DURATION_UNIT.Years]);\n\nfunction resolveCardVariant(product: Product): CardVariant {\n if (product.type === PRODUCT_TYPE.Bundle) {\n return CardVariant.Bundle;\n }\n if (product.type === PRODUCT_TYPE.Composite) {\n return CardVariant.Composite;\n }\n if (product.quantity_pricing && product.quantity_pricing.length > 1) {\n return CardVariant.Wholesale;\n }\n if (product.type === PRODUCT_TYPE.Digital) {\n return CardVariant.Digital;\n }\n if (product.type === PRODUCT_TYPE.Service) {\n if (product.duration_unit && RENTAL_UNITS.has(product.duration_unit)) {\n return CardVariant.Rental;\n }\n if (product.duration_unit === DURATION_UNIT.Nights) {\n return CardVariant.Accommodation;\n }\n if (product.duration_unit && LEASE_UNITS.has(product.duration_unit)) {\n return CardVariant.Lease;\n }\n if (product.billing_plans && product.billing_plans.length > 0 && !product.duration_minutes) {\n return CardVariant.Subscription;\n }\n return CardVariant.Standard;\n }\n if (product.render_hint === RENDER_HINT.Food) {\n return CardVariant.Food;\n }\n if (product.render_hint === RENDER_HINT.Physical) {\n return CardVariant.Retail;\n }\n return CardVariant.Retail;\n}\n\n/**\n * ProductCard \u2014 a product display card with two modes:\n *\n * - **card** (default): clickable button that opens a Base UI Dialog modal\n * - **page**: a plain `<a>` link for SEO-friendly product pages\n */\nexport function ProductCard({\n product,\n displayMode,\n variant,\n cards,\n href,\n renderModal,\n renderImage,\n renderLink,\n onQuickAdd,\n children,\n aspectRatio = "4/3",\n className,\n classNames,\n}: ProductCardProps): React.ReactElement {\n const mode = displayMode ?? product.display_mode ?? "card";\n const [isOpen, setIsOpen] = useState(false);\n const [shouldFetch, setShouldFetch] = useState(false);\n\n // Prefetch on pointer enter, always fetch when open\n const { product: productDetails } = useProduct(\n product.slug ?? product.id,\n { enabled: shouldFetch || isOpen },\n );\n\n const handlePrefetch = useCallback(() => {\n setShouldFetch(true);\n }, []);\n\n const handleOpenChange = useCallback((open: boolean) => {\n setIsOpen(open);\n if (open) {\n setShouldFetch(true);\n }\n }, []);\n\n const handleClose = useCallback(() => {\n setIsOpen(false);\n }, []);\n\n const imageUrl = product.image_url || product.images?.[0];\n\n const cardBody = (() => {\n if (children) return children;\n\n // Resolve card variant: slug map \u2192 explicit variant \u2192 auto-detect\n const SlugCard = cards?.[product.slug];\n if (SlugCard) {\n return <SlugCard product={product} onQuickAdd={onQuickAdd} renderImage={renderImage} renderLink={renderLink} />;\n }\n\n const key = variant ?? resolveCardVariant(product);\n const ResolvedCard = BUILT_IN_CARDS[key];\n if (ResolvedCard) {\n return <ResolvedCard product={product} onQuickAdd={onQuickAdd} renderImage={renderImage} renderLink={renderLink} />;\n }\n\n // Fallback: minimal default\n return (\n <>\n {imageUrl && (\n <div\n data-cimplify-product-card-image-container\n className={cn("overflow-hidden rounded-t-xl", classNames?.imageContainer)}\n style={ASPECT_STYLES[aspectRatio]}\n >\n {renderImage ? (\n renderImage({ src: imageUrl, alt: product.name, className: classNames?.image })\n ) : (\n <img\n src={imageUrl}\n alt={product.name}\n className={cn("w-full h-full object-cover transition-transform duration-300 group-hover:scale-105", classNames?.image)}\n data-cimplify-product-card-image\n />\n )}\n </div>\n )}\n <div data-cimplify-product-card-body className={cn("p-4 flex flex-col gap-1", classNames?.body)}>\n <span data-cimplify-product-card-name className={cn("font-semibold text-foreground leading-tight", classNames?.name)}>\n {product.name}\n </span>\n {product.description && (\n <span data-cimplify-product-card-description className={cn("text-sm text-muted-foreground line-clamp-2", classNames?.description)}>\n {product.description}\n </span>\n )}\n <Price amount={product.default_price} className={cn("text-sm font-medium text-foreground mt-1", classNames?.price)} />\n </div>\n </>\n );\n })();\n\n // Page mode \u2014 render as a link\n if (mode === "page") {\n const linkHref = href ?? `/menu/${product.slug}`;\n const linkClassName = cn("group block no-underline text-[inherit] rounded-xl border border-border bg-background transition-shadow duration-200 hover:shadow-md", className, classNames?.root);\n\n if (renderLink) {\n return renderLink({ href: linkHref, className: linkClassName, children: cardBody });\n }\n\n return (\n <a\n href={linkHref}\n data-cimplify-product-card\n data-display-mode="page"\n className={linkClassName}\n >\n {cardBody}\n </a>\n );\n }\n\n // Card mode \u2014 render as Base UI Dialog\n return (\n <Dialog.Root open={isOpen} onOpenChange={handleOpenChange}>\n <Dialog.Trigger\n onPointerEnter={handlePrefetch}\n data-cimplify-product-card\n data-display-mode="card"\n className={cn(\n "group block w-full text-left p-0 cursor-pointer font-[inherit] text-[inherit] rounded-xl border border-border bg-background transition-shadow duration-200 hover:shadow-md",\n className,\n classNames?.root,\n )}\n >\n {cardBody}\n </Dialog.Trigger>\n\n <Dialog.Portal>\n <Dialog.Backdrop\n data-cimplify-product-card-backdrop\n className={cn(\n "fixed inset-0 z-50 bg-black/50 backdrop-blur-sm transition-opacity",\n classNames?.modalOverlay,\n )}\n />\n <Dialog.Popup\n data-cimplify-product-card-modal\n className={cn(\n "fixed z-50 rounded-2xl p-0 max-w-lg w-full h-fit max-h-[85vh] overflow-auto bg-background shadow-2xl outline-none",\n classNames?.modal,\n )}\n >\n {isOpen && (\n productDetails ? (\n renderModal ? (\n renderModal(productDetails, handleClose)\n ) : (\n <ProductSheet\n product={productDetails}\n onClose={handleClose}\n renderImage={renderImage}\n />\n )\n ) : (\n <div\n data-cimplify-product-card-modal-loading\n aria-busy="true"\n className="flex flex-col"\n >\n <div className="aspect-[5/2] bg-muted animate-pulse" />\n <div className="p-6 space-y-3">\n <div className="h-5 w-3/5 bg-muted rounded animate-pulse" />\n <div className="h-7 w-2/5 bg-muted rounded animate-pulse" />\n <div className="h-4 w-4/5 bg-muted rounded animate-pulse" />\n </div>\n <div className="mx-6 border-t border-border pt-4 pb-6">\n <div className="h-14 bg-muted rounded-full animate-pulse" />\n </div>\n </div>\n )\n )}\n </Dialog.Popup>\n </Dialog.Portal>\n </Dialog.Root>\n );\n}\n' }] }, "service-product-layout": { "name": "service-product-layout", "title": "ServiceProductLayout", "description": "Service booking layout with duration, deposit, staff requirements, and cancellation policy.", "type": "component", "registryDependencies": ["product-image-gallery", "product-customizer", "price", "cn"], "files": [{ "path": "layouts/service-product-layout.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { ProductLayoutProps } from "@cimplify/sdk/react";\nimport {\n TwoColumnGrid,\n RelatedProductsSection,\n CustomAttributesTable,\n TagPills,\n} from "@cimplify/sdk/react";\nimport { ProductImageGallery } from "@cimplify/sdk/react";\nimport { ProductCustomizer } from "@cimplify/sdk/react";\nimport { Price } from "@cimplify/sdk/react";\nimport { parsePrice } from "@cimplify/sdk";\nimport { cn } from "@cimplify/sdk/react";\nimport { formatDuration } from "../utils/format-duration";\n\nexport function ServiceProductLayout({\n product,\n onAddToCart,\n renderBreadcrumb,\n relatedProducts,\n showRelated = true,\n className,\n}: ProductLayoutProps): React.ReactElement {\n const images = product.images || (product.image_url ? [product.image_url] : []);\n const hasDeposit = product.deposit_type && product.deposit_type !== "none" && product.deposit_amount;\n const depositAmount = hasDeposit ? parsePrice(product.deposit_amount!) : 0;\n const cancellationHours = product.cancellation_window_minutes\n ? Math.floor(product.cancellation_window_minutes / 60)\n : null;\n\n return (\n <div data-cimplify-product-layout="service" className={cn("space-y-8", className)}>\n {renderBreadcrumb?.(product)}\n\n <TwoColumnGrid\n left={\n <ProductImageGallery\n images={images}\n productName={product.name}\n />\n }\n right={\n <div className="space-y-6">\n {/* Tags */}\n <TagPills tags={product.tags || []} />\n\n {/* Name + Price */}\n <div>\n <h1 data-cimplify-product-layout-name className="text-3xl lg:text-4xl font-extrabold tracking-tight">\n {product.name}\n </h1>\n <Price amount={product.default_price} className="text-2xl font-bold text-primary mt-2 block" />\n </div>\n\n {/* Service info pills */}\n <div data-cimplify-product-layout-service-info className="flex flex-wrap gap-2">\n {product.duration_minutes != null && (\n <span className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-muted text-sm text-muted-foreground rounded-full">\n {formatDuration(product.duration_minutes, product.duration_unit)}\n </span>\n )}\n {hasDeposit && (\n <span className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-muted text-sm text-muted-foreground rounded-full">\n <Price amount={depositAmount} /> deposit\n </span>\n )}\n {product.general_service_capacity != null && product.general_service_capacity > 1 && (\n <span className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-muted text-sm text-muted-foreground rounded-full">\n Up to {product.general_service_capacity} people\n </span>\n )}\n {product.buffer_before_minutes != null && product.buffer_before_minutes > 0 && (\n <span className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-muted text-sm text-muted-foreground rounded-full">\n {product.buffer_before_minutes} min buffer\n </span>\n )}\n </div>\n\n {/* Description */}\n {product.description && (\n <p data-cimplify-product-layout-description className="text-muted-foreground leading-relaxed">\n {product.description}\n </p>\n )}\n\n {/* Staff & Resource requirements */}\n {(product.requires_specific_staff || product.requires_specific_resource) && (\n <div data-cimplify-product-layout-requirements className="text-sm text-muted-foreground space-y-1">\n {product.requires_specific_staff && (\n <p>Staff selection required</p>\n )}\n {product.requires_specific_resource && (\n <p>Resource selection required</p>\n )}\n </div>\n )}\n\n {/* Custom Attributes */}\n <CustomAttributesTable attributes={product.custom_attributes || []} />\n\n {/* Customizer (variants, scheduling, add to cart) */}\n <ProductCustomizer\n product={product}\n onAddToCart={onAddToCart}\n showSpecialInstructions={false}\n />\n\n {/* Cancellation policy */}\n {(cancellationHours != null || product.no_show_fee) && (\n <div data-cimplify-product-layout-cancellation className="border border-border p-4 space-y-2 text-sm">\n <h3 className="font-semibold">Cancellation Policy</h3>\n {cancellationHours != null && (\n <p className="text-muted-foreground">\n Free cancellation up to <strong className="text-foreground">{cancellationHours} hours</strong> before the booking\n </p>\n )}\n {product.no_show_fee && (\n <p className="text-muted-foreground">\n No-show fee: <Price amount={product.no_show_fee} className="font-medium text-foreground" />\n </p>\n )}\n {hasDeposit && (\n <p className="text-muted-foreground">\n {product.deposit_type === "fixed"\n ? <><Price amount={depositAmount} className="font-medium text-foreground" /> deposit charged at booking</>\n : <>{parsePrice(product.deposit_amount!)}% deposit charged at booking</>\n }\n </p>\n )}\n </div>\n )}\n </div>\n }\n />\n\n {showRelated && relatedProducts && relatedProducts.length > 0 && (\n <RelatedProductsSection\n products={relatedProducts}\n title="Other services"\n />\n )}\n </div>\n );\n}\n' }] }, "product-grid": { "name": "product-grid", "title": "ProductGrid", "description": "Responsive CSS grid that renders ProductCards.", "type": "component", "registryDependencies": ["product-card", "cn"], "files": [{ "path": "product-grid.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { Product } from "@cimplify/sdk";\nimport { ProductCard } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface ProductGridClassNames {\n root?: string;\n item?: string;\n empty?: string;\n}\n\nexport interface ProductGridProps {\n /** Products to display in the grid. */\n products: Product[];\n /** Responsive column counts at each breakpoint. */\n columns?: { sm?: number; md?: number; lg?: number; xl?: number };\n /** Custom card renderer per product. */\n renderCard?: (product: Product) => React.ReactNode;\n /** Custom image renderer passed to default ProductCards. */\n renderImage?: (props: {\n src: string;\n alt: string;\n className?: string;\n }) => React.ReactNode;\n /** Text shown when `products` is empty. */\n emptyMessage?: string;\n className?: string;\n classNames?: ProductGridClassNames;\n}\n\n/**\n * ProductGrid \u2014 responsive CSS grid that renders ProductCards.\n *\n * Injects an inline `<style>` tag with media queries for responsive columns.\n * Uses `React.useId()` for a hydration-safe, collision-free CSS selector.\n */\nexport function ProductGrid({\n products,\n columns,\n renderCard,\n renderImage,\n emptyMessage,\n className,\n classNames,\n}: ProductGridProps): React.ReactElement {\n const rawId = React.useId();\n // CSS selectors can\'t contain colons, so strip them from the React-generated ID\n const gridId = `cimplify-grid-${rawId.replace(/:/g, "")}`;\n\n const sm = columns?.sm ?? 1;\n const md = columns?.md ?? 2;\n const lg = columns?.lg ?? 3;\n const xl = columns?.xl ?? 4;\n const css = React.useMemo(\n () =>\n [\n `#${gridId}{display:grid;grid-template-columns:repeat(${sm},1fr);gap:1rem}`,\n `@media(min-width:768px){#${gridId}{grid-template-columns:repeat(${md},1fr)}}`,\n `@media(min-width:1024px){#${gridId}{grid-template-columns:repeat(${lg},1fr)}}`,\n `@media(min-width:1280px){#${gridId}{grid-template-columns:repeat(${xl},1fr)}}`,\n ].join(""),\n [gridId, sm, md, lg, xl],\n );\n\n if (products.length === 0) {\n return (\n <div\n data-cimplify-product-grid\n data-empty\n className={cn(className, classNames?.root, classNames?.empty)}\n >\n <p>{emptyMessage ?? "No products found"}</p>\n </div>\n );\n }\n\n return (\n <>\n <style dangerouslySetInnerHTML={{ __html: css }} />\n <div\n id={gridId}\n data-cimplify-product-grid\n className={cn(className, classNames?.root)}\n >\n {products.map((product) => (\n <div\n key={product.id}\n data-cimplify-product-grid-item\n className={classNames?.item}\n >\n {renderCard\n ? renderCard(product)\n : (\n <ProductCard\n product={product}\n renderImage={renderImage}\n />\n )}\n </div>\n ))}\n </div>\n </>\n );\n}\n' }] }, "composite-selector": { "name": "composite-selector", "title": "CompositeSelector", "description": "Composite product builder with group constraints and live pricing.", "type": "component", "registryDependencies": ["price"], "files": [{ "path": "composite-selector.tsx", "content": '"use client";\n\nimport React, { useState, useCallback, useMemo, useEffect } from "react";\nimport { Checkbox } from "@base-ui/react/checkbox";\nimport { NumberField } from "@base-ui/react/number-field";\nimport type {\n CompositeGroupView,\n CompositeComponentView,\n ComponentSelectionInput,\n CompositePriceResult,\n} from "@cimplify/sdk";\nimport { Price } from "@cimplify/sdk/react";\nimport { parsePrice } from "@cimplify/sdk";\nimport { cn } from "@cimplify/sdk/react";\nimport { useCimplifyClient } from "@cimplify/sdk/react";\n\nexport interface CompositeSelectorClassNames {\n root?: string;\n group?: string;\n groupHeader?: string;\n groupName?: string;\n required?: string;\n groupDescription?: string;\n groupConstraint?: string;\n validation?: string;\n components?: string;\n component?: string;\n componentSelected?: string;\n componentInfo?: string;\n componentName?: string;\n typeBadge?: string;\n serviceBadge?: string;\n digitalBadge?: string;\n badgePopular?: string;\n badgePremium?: string;\n componentDescription?: string;\n componentCalories?: string;\n qty?: string;\n qtyButton?: string;\n qtyValue?: string;\n summary?: string;\n summaryLine?: string;\n summaryTotal?: string;\n calculating?: string;\n priceError?: string;\n}\n\nexport interface CompositeSelectorProps {\n compositeId: string;\n groups: CompositeGroupView[];\n onSelectionsChange: (selections: ComponentSelectionInput[]) => void;\n onPriceChange?: (price: CompositePriceResult | null) => void;\n onReady?: (ready: boolean) => void;\n skipPriceFetch?: boolean;\n className?: string;\n classNames?: CompositeSelectorClassNames;\n}\n\nexport function CompositeSelector({\n compositeId,\n groups,\n onSelectionsChange,\n onPriceChange,\n onReady,\n skipPriceFetch,\n className,\n classNames,\n}: CompositeSelectorProps): React.ReactElement | null {\n const { client } = useCimplifyClient();\n\n const [groupSelections, setGroupSelections] = useState<\n Record<string, Record<string, number>>\n >({});\n const [priceResult, setPriceResult] = useState<CompositePriceResult | null>(null);\n const [isPriceLoading, setIsPriceLoading] = useState(false);\n const [priceError, setPriceError] = useState(false);\n\n const selections = useMemo((): ComponentSelectionInput[] => {\n const result: ComponentSelectionInput[] = [];\n for (const groupSels of Object.values(groupSelections)) {\n for (const [componentId, qty] of Object.entries(groupSels)) {\n if (qty > 0) {\n result.push({ component_id: componentId, quantity: qty });\n }\n }\n }\n return result;\n }, [groupSelections]);\n\n useEffect(() => {\n onSelectionsChange(selections);\n }, [selections, onSelectionsChange]);\n\n useEffect(() => {\n onPriceChange?.(priceResult);\n }, [priceResult, onPriceChange]);\n\n const allGroupsSatisfied = useMemo(() => {\n for (const group of groups) {\n const groupSels = groupSelections[group.id] || {};\n const totalSelected = Object.values(groupSels).reduce((sum, q) => sum + q, 0);\n if (totalSelected < group.min_selections) return false;\n }\n return true;\n }, [groups, groupSelections]);\n\n const sortedGroups = useMemo(\n () =>\n [...groups]\n .sort((a, b) => a.display_order - b.display_order)\n .map((group) => ({\n ...group,\n _sortedComponents: group.components\n .filter((component) => component.is_available && !component.is_archived)\n .sort((a, b) => a.display_order - b.display_order),\n })),\n [groups],\n );\n\n useEffect(() => {\n onReady?.(allGroupsSatisfied);\n }, [allGroupsSatisfied, onReady]);\n\n useEffect(() => {\n if (skipPriceFetch || !allGroupsSatisfied || selections.length === 0) return;\n\n let cancelled = false;\n const timer = setTimeout(() => {\n void (async () => {\n setIsPriceLoading(true);\n setPriceError(false);\n try {\n const result = await client.catalogue.calculateCompositePrice(compositeId, selections);\n if (cancelled) return;\n if (result.ok) {\n setPriceResult(result.value);\n } else {\n setPriceError(true);\n }\n } catch {\n if (!cancelled) setPriceError(true);\n } finally {\n if (!cancelled) setIsPriceLoading(false);\n }\n })();\n }, 300);\n\n return () => {\n cancelled = true;\n clearTimeout(timer);\n };\n }, [selections, allGroupsSatisfied, compositeId, client, skipPriceFetch]);\n\n const toggleComponent = useCallback(\n (group: CompositeGroupView, component: CompositeComponentView) => {\n setGroupSelections((prev) => {\n const groupSels = { ...(prev[group.id] || {}) };\n const currentQty = groupSels[component.id] || 0;\n\n if (currentQty > 0) {\n if (group.min_selections > 0) {\n const totalOthers = Object.entries(groupSels)\n .filter(([id]) => id !== component.id)\n .reduce((sum, [, q]) => sum + q, 0);\n if (totalOthers < group.min_selections) {\n return prev;\n }\n }\n delete groupSels[component.id];\n } else {\n const totalSelected = Object.values(groupSels).reduce((sum, q) => sum + q, 0);\n if (group.max_selections && totalSelected >= group.max_selections) {\n if (group.max_selections === 1) {\n return { ...prev, [group.id]: { [component.id]: 1 } };\n }\n return prev;\n }\n groupSels[component.id] = 1;\n }\n\n return { ...prev, [group.id]: groupSels };\n });\n },\n [],\n );\n\n const updateQuantity = useCallback(\n (group: CompositeGroupView, componentId: string, newValue: number) => {\n setGroupSelections((prev) => {\n const groupSels = { ...(prev[group.id] || {}) };\n const current = groupSels[componentId] || 0;\n const next = Math.max(0, newValue);\n\n if (next === current) return prev;\n\n const delta = next - current;\n\n if (group.max_quantity_per_component && next > group.max_quantity_per_component) {\n return prev;\n }\n\n const totalAfter = Object.entries(groupSels)\n .reduce((sum, [id, q]) => sum + (id === componentId ? next : q), 0);\n\n if (delta > 0 && group.max_selections && totalAfter > group.max_selections) {\n return prev;\n }\n\n if (delta < 0 && totalAfter < group.min_selections) {\n return prev;\n }\n\n if (next === 0) {\n delete groupSels[componentId];\n } else {\n groupSels[componentId] = next;\n }\n\n return { ...prev, [group.id]: groupSels };\n });\n },\n [],\n );\n\n if (groups.length === 0) {\n return null;\n }\n\n return (\n <div data-cimplify-composite-selector className={cn("space-y-6", className, classNames?.root)}>\n {sortedGroups.map((group) => {\n const groupSels = groupSelections[group.id] || {};\n const totalSelected = Object.values(groupSels).reduce((sum, q) => sum + q, 0);\n const minMet = totalSelected >= group.min_selections;\n const isSingleSelect = group.max_selections === 1;\n\n return (\n <div\n key={group.id}\n data-cimplify-composite-group\n className={cn(classNames?.group)}\n >\n <div\n data-cimplify-composite-group-header\n className={cn("flex items-center justify-between py-3", classNames?.groupHeader)}\n >\n <div>\n <span\n data-cimplify-composite-group-name\n className={cn("text-base font-bold", classNames?.groupName)}\n >\n {group.name}\n </span>\n {group.description && (\n <span\n data-cimplify-composite-group-description\n className={cn("block text-xs text-muted-foreground mt-0.5", classNames?.groupDescription)}\n >\n {group.description}\n </span>\n )}\n <span\n data-cimplify-composite-group-constraint\n className={cn("block text-xs text-muted-foreground mt-0.5", classNames?.groupConstraint)}\n >\n {group.min_selections > 0 && group.max_selections\n ? `Choose ${group.min_selections}\\u2013${group.max_selections}`\n : group.min_selections > 0\n ? `Choose at least ${group.min_selections}`\n : group.max_selections\n ? `Choose up to ${group.max_selections}`\n : "Choose as many as you like"}\n </span>\n </div>\n {group.min_selections > 0 && (\n <span\n data-cimplify-composite-required\n className={cn(\n "text-xs font-semibold px-2.5 py-1 rounded shrink-0",\n !minMet\n ? "text-destructive bg-destructive/10"\n : "text-destructive bg-destructive/10",\n classNames?.required,\n )}\n >\n Required\n </span>\n )}\n </div>\n\n <div\n data-cimplify-composite-components\n role={isSingleSelect ? "radiogroup" : "group"}\n aria-label={group.name}\n className={cn("divide-y divide-border", classNames?.components)}\n >\n {group._sortedComponents.map((component) => {\n const qty = groupSels[component.id] || 0;\n const isSelected = qty > 0;\n const displayName = component.display_name || component.id;\n\n return (\n <Checkbox.Root\n key={component.id}\n checked={isSelected}\n onCheckedChange={() => toggleComponent(group, component)}\n value={component.id}\n data-cimplify-composite-component\n data-selected={isSelected || undefined}\n className={cn(\n "w-full flex items-center gap-3 py-4 transition-colors text-left cursor-pointer",\n isSelected ? classNames?.componentSelected : classNames?.component,\n )}\n >\n <Checkbox.Indicator\n className="hidden"\n keepMounted={false}\n />\n\n {/* Visual indicator: radio circle for single-select, checkbox square for multi-select */}\n {isSingleSelect ? (\n <span\n data-cimplify-composite-radio\n className={cn(\n "w-5 h-5 rounded-full border-2 flex items-center justify-center shrink-0 transition-colors",\n isSelected ? "border-primary" : "border-muted-foreground/30",\n )}\n >\n {isSelected && <span className="w-2.5 h-2.5 rounded-full bg-primary" />}\n </span>\n ) : (\n <span\n data-cimplify-composite-checkbox\n className={cn(\n "w-5 h-5 rounded-sm border-2 flex items-center justify-center shrink-0 transition-colors",\n isSelected ? "border-primary bg-primary" : "border-muted-foreground/30",\n )}\n >\n {isSelected && (\n <svg viewBox="0 0 12 12" className="w-3 h-3 text-primary-foreground" fill="none">\n <path d="M2 6l3 3 5-5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>\n </svg>\n )}\n </span>\n )}\n\n <div\n data-cimplify-composite-component-info\n className={cn("flex-1 min-w-0", classNames?.componentInfo)}\n >\n <span\n data-cimplify-composite-component-name\n className={cn("text-sm", classNames?.componentName)}\n >\n {displayName}\n </span>\n {component.product_type === "service" && (\n <span\n data-cimplify-composite-type-badge="service"\n className={cn(\n "text-[10px] uppercase tracking-wider font-medium text-blue-600",\n classNames?.typeBadge,\n classNames?.serviceBadge,\n )}\n >\n <svg viewBox="0 0 16 16" fill="none" className="inline-block w-3 h-3 mr-0.5 -mt-px" aria-hidden="true">\n <circle cx="8" cy="8" r="7" stroke="currentColor" strokeWidth="1.5"/>\n <path d="M8 4v4l2.5 1.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>\n </svg>\n Service\n </span>\n )}\n {component.product_type === "digital" && (\n <span\n data-cimplify-composite-type-badge="digital"\n className={cn(\n "text-[10px] uppercase tracking-wider font-medium text-violet-600",\n classNames?.typeBadge,\n classNames?.digitalBadge,\n )}\n >\n <svg viewBox="0 0 16 16" fill="none" className="inline-block w-3 h-3 mr-0.5 -mt-px" aria-hidden="true">\n <path d="M9 2L4 9h4l-1 5 5-7H8l1-5z" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>\n </svg>\n Digital\n </span>\n )}\n {component.is_popular && (\n <span\n data-cimplify-composite-badge="popular"\n className={cn("text-[10px] uppercase tracking-wider text-primary font-medium", classNames?.badgePopular)}\n >\n Popular\n </span>\n )}\n {component.is_premium && (\n <span\n data-cimplify-composite-badge="premium"\n className={cn("text-[10px] uppercase tracking-wider text-amber-600 font-medium", classNames?.badgePremium)}\n >\n Premium\n </span>\n )}\n {component.display_description && (\n <span\n data-cimplify-composite-component-description\n className={cn("block text-xs text-muted-foreground truncate", classNames?.componentDescription)}\n >\n {component.display_description}\n </span>\n )}\n {component.calories != null && (\n <span\n data-cimplify-composite-component-calories\n className={cn("block text-xs text-muted-foreground/60", classNames?.componentCalories)}\n >\n {component.calories} cal\n </span>\n )}\n </div>\n\n {group.allow_quantity && isSelected && (\n <NumberField.Root\n value={qty}\n onValueChange={(val) => {\n if (val != null) {\n updateQuantity(group, component.id, val);\n }\n }}\n min={0}\n max={group.max_quantity_per_component || undefined}\n step={1}\n >\n <NumberField.Group\n data-cimplify-composite-qty\n onClick={(e: React.MouseEvent) => e.stopPropagation()}\n className={cn("flex items-center gap-2", classNames?.qty)}\n >\n <NumberField.Decrement\n aria-label={`Decrease ${displayName} quantity`}\n className={cn("w-6 h-6 border border-border flex items-center justify-center text-xs hover:bg-muted disabled:opacity-30", classNames?.qtyButton)}\n >\n −\n </NumberField.Decrement>\n <NumberField.Input\n readOnly\n className={cn("w-4 text-center text-sm font-medium bg-transparent border-none outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none", classNames?.qtyValue)}\n />\n <NumberField.Increment\n aria-label={`Increase ${displayName} quantity`}\n className={cn("w-6 h-6 border border-border flex items-center justify-center text-xs hover:bg-muted disabled:opacity-30", classNames?.qtyButton)}\n >\n +\n </NumberField.Increment>\n </NumberField.Group>\n </NumberField.Root>\n )}\n\n {component.price != null && (\n <span className="text-sm text-muted-foreground shrink-0">\n +<Price amount={component.price} />\n </span>\n )}\n </Checkbox.Root>\n );\n })}\n </div>\n </div>\n );\n })}\n\n {priceResult && (\n <div\n data-cimplify-composite-summary\n className={cn("border-t border-border pt-4 space-y-1 text-sm", classNames?.summary)}\n >\n {parsePrice(priceResult.base_price) !== 0 && (\n <div\n data-cimplify-composite-summary-line\n className={cn("flex justify-between text-muted-foreground", classNames?.summaryLine)}\n >\n <span>Base</span>\n <Price amount={priceResult.base_price} />\n </div>\n )}\n {parsePrice(priceResult.components_total) !== 0 && (\n <div\n data-cimplify-composite-summary-line\n className={cn("flex justify-between text-muted-foreground", classNames?.summaryLine)}\n >\n <span>Selections</span>\n <Price amount={priceResult.components_total} />\n </div>\n )}\n <div\n data-cimplify-composite-summary-total\n className={cn("flex justify-between font-medium pt-1 border-t border-border", classNames?.summaryTotal)}\n >\n <span>Total</span>\n <Price amount={priceResult.final_price} className="text-primary" />\n </div>\n </div>\n )}\n\n {isPriceLoading && (\n <div\n data-cimplify-composite-calculating\n className={cn("flex items-center gap-2 text-sm text-muted-foreground", classNames?.calculating)}\n >\n Calculating price...\n </div>\n )}\n\n {priceError && !isPriceLoading && (\n <div\n data-cimplify-composite-price-error\n className={cn("text-sm text-destructive", classNames?.priceError)}\n >\n Unable to calculate price\n </div>\n )}\n </div>\n );\n}\n' }] }, "location-picker": { "name": "location-picker", "title": "LocationPicker", "description": "Branch / pickup-point selector for businesses with multiple locations.", "type": "component", "registryDependencies": ["cn"], "files": [{ "path": "location-picker.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { Location } from "../types/business";\nimport { useLocations } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface LocationPickerClassNames {\n root?: string;\n item?: string;\n activeItem?: string;\n empty?: string;\n loading?: string;\n}\n\nexport interface LocationPickerProps {\n /** Override locations (skips useLocations fetch). For SSR, pass pre-fetched locations. */\n locations?: Location[];\n /** Override the current location. */\n currentLocation?: Location | null;\n /** Called when a location is selected. */\n onLocationChange?: (location: Location) => void;\n /** Custom location renderer. */\n renderLocation?: (location: Location, isActive: boolean) => React.ReactNode;\n /** Text shown when no locations are available. */\n emptyMessage?: string;\n className?: string;\n classNames?: LocationPickerClassNames;\n}\n\n/**\n * LocationPicker \u2014 renders a location selector for multi-location businesses.\n *\n * Uses `useLocations` to fetch locations unless overridden via props.\n * Returns `null` when there is only one location (no picker needed).\n */\nexport function LocationPicker({\n locations: locationsProp,\n currentLocation: currentLocationProp,\n onLocationChange,\n renderLocation,\n emptyMessage,\n className,\n classNames,\n}: LocationPickerProps): React.ReactElement | null {\n const {\n locations: fetched,\n currentLocation: fetchedCurrent,\n setCurrentLocation,\n isLoading,\n } = useLocations();\n\n const locations = locationsProp ?? fetched;\n const currentLocation = currentLocationProp ?? fetchedCurrent;\n\n const handleSelect = (location: Location): void => {\n if (onLocationChange) {\n onLocationChange(location);\n } else {\n setCurrentLocation(location);\n }\n };\n\n if (isLoading && locations.length === 0) {\n return (\n <div\n data-cimplify-location-picker\n aria-busy="true"\n className={cn(className, classNames?.root, classNames?.loading)}\n />\n );\n }\n\n // No picker needed for single location\n if (locations.length <= 1) {\n return null;\n }\n\n if (locations.length === 0) {\n return (\n <div\n data-cimplify-location-picker\n data-empty\n className={cn(className, classNames?.root, classNames?.empty)}\n >\n <p>{emptyMessage ?? "No locations available"}</p>\n </div>\n );\n }\n\n return (\n <div data-cimplify-location-picker className={cn(className, classNames?.root)}>\n {locations.map((location) => {\n const isActive = currentLocation?.id === location.id;\n return (\n <button\n key={location.id}\n type="button"\n onClick={() => handleSelect(location)}\n data-cimplify-location-item\n data-active={isActive || undefined}\n aria-pressed={isActive}\n className={cn(classNames?.item, isActive && classNames?.activeItem)}\n >\n {renderLocation ? (\n renderLocation(location, isActive)\n ) : (\n <span>{location.name}</span>\n )}\n </button>\n );\n })}\n </div>\n );\n}\n' }] }, "recommendation-carousel": { "name": "recommendation-carousel", "title": "RecommendationCarousel", "description": "Personalized product carousel powered by /activity/recommendations \u2014 frequently bought, related, trending.", "type": "component", "registryDependencies": ["product-card", "cn"], "files": [{ "path": "recommendation-carousel.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { ActivityRecommendation } from "../activity";\nimport { useRecommendations } from "./hooks/use-recommendations";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface RecommendationCarouselClassNames {\n root?: string;\n item?: string;\n reason?: string;\n empty?: string;\n loading?: string;\n}\n\nexport interface RecommendationCarouselProps {\n /** Override recommendations (skips fetch). For SSR, pass pre-fetched data. */\n recommendations?: ActivityRecommendation[];\n /** Location ID for location-specific recommendations. */\n locationId?: string;\n /** Maximum recommendations to fetch. */\n limit?: number;\n /** Called when a product recommendation is clicked. */\n onProductClick?: (recommendation: ActivityRecommendation) => void;\n /** Custom product card renderer. */\n renderProduct?: (recommendation: ActivityRecommendation) => React.ReactNode;\n /** Text shown when no recommendations are available. */\n emptyMessage?: string;\n className?: string;\n classNames?: RecommendationCarouselClassNames;\n}\n\n/**\n * RecommendationCarousel \u2014 displays personalized product recommendations.\n *\n * Renders a horizontal scrollable list of product cards with reason labels.\n * Returns `null` when there are no recommendations.\n */\nexport function RecommendationCarousel({\n recommendations: recommendationsProp,\n locationId,\n limit,\n onProductClick,\n renderProduct,\n emptyMessage,\n className,\n classNames,\n}: RecommendationCarouselProps): React.ReactElement | null {\n const { recommendations: fetched, isLoading } = useRecommendations({\n locationId,\n limit,\n enabled: recommendationsProp === undefined,\n });\n\n const recommendations = recommendationsProp ?? fetched;\n\n if (isLoading && recommendations.length === 0) {\n return (\n <div\n data-cimplify-recommendation-carousel\n aria-busy="true"\n className={cn(className, classNames?.root, classNames?.loading)}\n />\n );\n }\n\n if (recommendations.length === 0) {\n return null;\n }\n\n return (\n <div\n data-cimplify-recommendation-carousel\n className={cn(className, classNames?.root)}\n style={{ display: "flex", overflowX: "auto", gap: "1rem" }}\n >\n {recommendations.map((rec, index) => {\n const productId = rec.product?.id ?? String(index);\n return (\n <button\n key={String(productId)}\n type="button"\n onClick={() => onProductClick?.(rec)}\n data-cimplify-recommendation-item\n className={classNames?.item}\n style={{ flexShrink: 0 }}\n >\n {renderProduct ? (\n renderProduct(rec)\n ) : (\n <>\n <span data-cimplify-recommendation-name>\n {rec.product?.name ?? rec.product?.id ?? "Product"}\n </span>\n {rec.reason && (\n <span data-cimplify-recommendation-reason className={classNames?.reason}>\n {rec.reason}\n </span>\n )}\n </>\n )}\n </button>\n );\n })}\n </div>\n );\n}\n' }] }, "discount-input": { "name": "discount-input", "title": "DiscountInput", "description": "Discount code input with inline validation.", "type": "component", "registryDependencies": ["price", "cn"], "files": [{ "path": "discount-input.tsx", "content": '"use client";\n\nimport React, { useState, useCallback } from "react";\nimport { Field } from "@base-ui/react/field";\nimport { Input } from "@base-ui/react/input";\nimport type { DiscountValidation } from "@cimplify/sdk";\nimport { useValidateDiscount } from "@cimplify/sdk/react";\nimport { Price } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface DiscountInputClassNames {\n root?: string;\n input?: string;\n button?: string;\n result?: string;\n error?: string;\n success?: string;\n}\n\nexport interface DiscountInputProps {\n /** Current order subtotal for validation. */\n orderSubtotal: string;\n /** Location ID for location-specific discounts. */\n locationId?: string;\n /** Called when a valid discount is applied. */\n onApply?: (validation: DiscountValidation) => void;\n /** Called when discount is cleared. */\n onClear?: () => void;\n /** Placeholder text. */\n placeholder?: string;\n className?: string;\n classNames?: DiscountInputClassNames;\n}\n\n/**\n * DiscountInput \u2014 discount code input with inline validation.\n *\n * Wraps `useValidateDiscount` with a Base UI Field + Input and apply button.\n * Shows validation result inline (success with amount, or error via Field.Error).\n */\nexport function DiscountInput({\n orderSubtotal,\n locationId,\n onApply,\n onClear,\n placeholder = "Discount code",\n className,\n classNames,\n}: DiscountInputProps): React.ReactElement {\n const [code, setCode] = useState("");\n const [appliedValidation, setAppliedValidation] =\n useState<DiscountValidation | null>(null);\n const { validate, isValidating, error } = useValidateDiscount();\n\n const handleApply = useCallback(async () => {\n const trimmed = code.trim();\n if (!trimmed) return;\n\n const result = await validate(trimmed, orderSubtotal, locationId);\n if (result) {\n if (result.is_eligible) {\n setAppliedValidation(result);\n onApply?.(result);\n } else {\n setAppliedValidation(result);\n }\n }\n }, [code, validate, orderSubtotal, locationId, onApply]);\n\n const handleClear = useCallback(() => {\n setCode("");\n setAppliedValidation(null);\n onClear?.();\n }, [onClear]);\n\n const handleKeyDown = useCallback(\n (e: React.KeyboardEvent) => {\n if (e.key === "Enter") {\n void handleApply();\n }\n },\n [handleApply],\n );\n\n const isApplied = appliedValidation?.is_eligible === true;\n const hasError =\n !!error || (!!appliedValidation && !appliedValidation.is_eligible);\n\n const errorMessage = error\n ? error.message\n : appliedValidation && !appliedValidation.is_eligible\n ? (appliedValidation.ineligibility_reason ?? "This code is not valid.")\n : undefined;\n\n return (\n <Field.Root\n data-cimplify-discount\n invalid={hasError}\n disabled={isApplied}\n className={cn(className, classNames?.root)}\n >\n <div data-cimplify-discount-form>\n <Input\n type="text"\n value={code}\n onValueChange={(value) => setCode(value)}\n onKeyDown={handleKeyDown}\n placeholder={placeholder}\n data-cimplify-discount-input\n className={classNames?.input}\n aria-label="Discount code"\n />\n {isApplied ? (\n <button\n type="button"\n onClick={handleClear}\n data-cimplify-discount-clear\n className={classNames?.button}\n >\n Remove\n </button>\n ) : (\n <button\n type="button"\n onClick={handleApply}\n disabled={isValidating || code.trim().length === 0}\n data-cimplify-discount-apply\n className={classNames?.button}\n >\n {isValidating ? "Checking..." : "Apply"}\n </button>\n )}\n </div>\n\n {hasError && errorMessage && (\n <Field.Error\n match={true}\n data-cimplify-discount-error\n className={classNames?.error}\n >\n {errorMessage}\n </Field.Error>\n )}\n\n {isApplied && appliedValidation.discount_amount && (\n <Field.Description\n data-cimplify-discount-success\n className={classNames?.success}\n >\n <span>Discount applied</span>\n <Price amount={appliedValidation.discount_amount} prefix="-" />\n </Field.Description>\n )}\n </Field.Root>\n );\n}\n' }] }, "standard-service-card": { "name": "standard-service-card", "title": "StandardServiceCard", "description": "Service card with hero image, duration, deposit, and availability.", "type": "component", "registryDependencies": ["price", "cn"], "files": [{ "path": "cards/standard-service-card.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { Product } from "@cimplify/sdk";\nimport type { AvailableSlot } from "@cimplify/sdk";\nimport type { CardLayoutProps } from "@cimplify/sdk/react";\nimport { CardShell, CardImage } from "@cimplify/sdk/react";\nimport { Price } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface ServiceCardLayoutProps extends CardLayoutProps {\n slots?: AvailableSlot[];\n onBook?: (product: Product) => void;\n}\n\nfunction formatDuration(minutes: number, unit?: string): string {\n if (unit && unit !== "minutes") return `${minutes} ${unit}`;\n if (minutes >= 60) {\n const h = Math.floor(minutes / 60);\n const m = minutes % 60;\n return m > 0 ? `${h}h ${m}m` : `${h} hr${h > 1 ? "s" : ""}`;\n }\n return `${minutes} min`;\n}\n\nexport function StandardServiceCard({\n product,\n onBook,\n renderImage,\n renderLink,\n className,\n}: ServiceCardLayoutProps): React.ReactElement {\n const image = product.image_url || product.images?.[0] || "";\n const hasDeposit = product.deposit_type && product.deposit_type !== "none" && product.deposit_amount;\n\n return (\n <CardShell product={product} renderLink={renderLink} className={className}>\n <CardImage src={image} alt={product.name} aspectRatio="16/9" renderImage={renderImage}>\n <div className="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent pointer-events-none" />\n\n {/* Duration pill */}\n {product.duration_minutes != null && (\n <span className="absolute bottom-3 left-3 inline-flex items-center gap-1 text-[12px] font-semibold px-2.5 py-1 rounded-lg bg-background/92 backdrop-blur-xl text-foreground shadow-sm">\n {formatDuration(product.duration_minutes, product.duration_unit)}\n </span>\n )}\n\n {/* Price on image */}\n <span className="absolute bottom-3 right-3 text-white font-bold text-lg drop-shadow-sm">\n <Price amount={product.default_price} />\n </span>\n </CardImage>\n\n <div className="p-4">\n <h3 className="text-[14.5px] font-bold text-foreground leading-snug tracking-tight">\n {product.name}\n </h3>\n {product.description && (\n <p className="text-[12.5px] text-muted-foreground mt-1 leading-relaxed line-clamp-2">\n {product.description}\n </p>\n )}\n\n <div className="flex items-center justify-between mt-3 pt-3 border-t border-border">\n <div className="flex items-center gap-2 text-[12px] text-muted-foreground">\n <span className="w-[7px] h-[7px] rounded-full bg-emerald-500 animate-pulse" />\n <span>Available</span>\n {hasDeposit && (\n <>\n <span className="text-border">\xB7</span>\n <span><Price amount={product.deposit_amount!} /> deposit</span>\n </>\n )}\n </div>\n <button\n onClick={(e) => { e.preventDefault(); e.stopPropagation(); onBook?.(product); }}\n className="text-[13px] font-semibold text-primary hover:text-primary/80 transition-colors"\n >\n Book now →\n </button>\n </div>\n </div>\n </CardShell>\n );\n}\n' }] }, "quantity-selector": { "name": "quantity-selector", "title": "QuantitySelector", "description": "Controlled increment/decrement quantity input.", "type": "component", "registryDependencies": [], "files": [{ "path": "quantity-selector.tsx", "content": '"use client";\n\nimport React from "react";\nimport { NumberField } from "@base-ui/react/number-field";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface QuantitySelectorClassNames {\n root?: string;\n button?: string;\n value?: string;\n}\n\nexport interface QuantitySelectorProps {\n value: number;\n onChange: (value: number) => void;\n min?: number;\n max?: number;\n className?: string;\n classNames?: QuantitySelectorClassNames;\n}\n\nexport function QuantitySelector({\n value,\n onChange,\n min = 1,\n max,\n className,\n classNames,\n}: QuantitySelectorProps): React.ReactElement {\n return (\n <NumberField.Root\n value={value}\n onValueChange={(val) => {\n if (val != null) {\n onChange(val);\n }\n }}\n min={min}\n max={max}\n step={1}\n >\n <NumberField.Group\n data-cimplify-quantity\n className={cn("inline-flex items-center gap-3 border border-border px-2", className, classNames?.root)}\n >\n <NumberField.Decrement\n aria-label="Decrease quantity"\n data-cimplify-quantity-decrement\n className={cn(\n "w-10 h-10 flex items-center justify-center hover:text-primary transition-colors disabled:opacity-30",\n classNames?.button,\n )}\n >\n −\n </NumberField.Decrement>\n <NumberField.Input\n data-cimplify-quantity-value\n aria-live="polite"\n readOnly\n className={cn("w-8 text-center font-medium bg-transparent border-none outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none", classNames?.value)}\n />\n <NumberField.Increment\n aria-label="Increase quantity"\n data-cimplify-quantity-increment\n className={cn(\n "w-10 h-10 flex items-center justify-center hover:text-primary transition-colors disabled:opacity-30",\n classNames?.button,\n )}\n >\n +\n </NumberField.Increment>\n </NumberField.Group>\n </NumberField.Root>\n );\n}\n' }] }, "digital-product-layout": { "name": "digital-product-layout", "title": "DigitalProductLayout", "description": "Digital product layout with file details, download limits, event info, and access passes.", "type": "component", "registryDependencies": ["product-image-gallery", "product-customizer", "price", "cn"], "files": [{ "path": "layouts/digital-product-layout.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { ProductLayoutProps } from "@cimplify/sdk/react";\nimport {\n TwoColumnGrid,\n RelatedProductsSection,\n CustomAttributesTable,\n TagPills,\n} from "@cimplify/sdk/react";\nimport { ProductImageGallery } from "@cimplify/sdk/react";\nimport { ProductCustomizer } from "@cimplify/sdk/react";\nimport { Price } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nconst DIGITAL_TYPE_LABELS: Record<string, string> = {\n download: "Digital download",\n license: "Software license",\n event_ticket: "Event ticket",\n access_pass: "Access pass",\n gift_card: "Gift card",\n};\n\nexport function DigitalProductLayout({\n product,\n onAddToCart,\n renderBreadcrumb,\n relatedProducts,\n showRelated = true,\n className,\n}: ProductLayoutProps): React.ReactElement {\n const images = product.images || (product.image_url ? [product.image_url] : []);\n const typeLabel = product.digital_type\n ? DIGITAL_TYPE_LABELS[product.digital_type] || product.digital_type\n : "Digital product";\n\n const isTicket = product.digital_type === "event_ticket";\n const isAccessPass = product.digital_type === "access_pass";\n const isDownload = product.digital_type === "download" || product.digital_type === "license";\n\n return (\n <div data-cimplify-product-layout="digital" className={cn("space-y-8", className)}>\n {renderBreadcrumb?.(product)}\n\n <TwoColumnGrid\n left={\n <ProductImageGallery\n images={images}\n productName={product.name}\n />\n }\n right={\n <div className="space-y-6">\n {/* Type badge + tags */}\n <div className="flex flex-wrap items-center gap-2">\n <span data-cimplify-product-layout-badge className="text-xs font-semibold uppercase tracking-wider text-primary bg-primary/10 px-2.5 py-1 rounded-full">\n {typeLabel}\n </span>\n <TagPills tags={product.tags || []} />\n </div>\n\n {/* Name + Price */}\n <div>\n <h1 data-cimplify-product-layout-name className="text-3xl lg:text-4xl font-extrabold tracking-tight">\n {product.name}\n </h1>\n <Price amount={product.default_price} className="text-2xl font-bold mt-2 block" />\n </div>\n\n {/* Description */}\n {product.description && (\n <p data-cimplify-product-layout-description className="text-muted-foreground leading-relaxed">\n {product.description}\n </p>\n )}\n\n {/* Delivery notice */}\n {isDownload && (\n <div data-cimplify-product-layout-delivery className="flex items-center gap-3 px-4 py-3 bg-muted rounded-lg">\n <svg className="w-5 h-5 text-primary shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">\n <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />\n </svg>\n <div>\n <p className="text-sm font-medium">Instant delivery</p>\n <p className="text-xs text-muted-foreground">Download immediately after purchase</p>\n </div>\n </div>\n )}\n\n {/* Event info */}\n {isTicket && (product.event_date || product.venue) && (\n <div data-cimplify-product-layout-event className="border border-border p-4 space-y-2">\n {product.event_date && (\n <div className="flex items-center gap-2 text-sm">\n <span className="text-muted-foreground">Date:</span>\n <span className="font-medium">{new Date(product.event_date).toLocaleDateString(undefined, { weekday: "long", year: "numeric", month: "long", day: "numeric" })}</span>\n </div>\n )}\n {product.venue && (\n <div className="flex items-center gap-2 text-sm">\n <span className="text-muted-foreground">Venue:</span>\n <span className="font-medium">{product.venue}</span>\n </div>\n )}\n {product.ticket_type && (\n <div className="flex items-center gap-2 text-sm">\n <span className="text-muted-foreground">Type:</span>\n <span className="font-medium capitalize">{product.ticket_type}</span>\n </div>\n )}\n </div>\n )}\n\n {/* Access pass info */}\n {isAccessPass && (product.access_type || product.access_duration_days) && (\n <div data-cimplify-product-layout-access className="border border-border p-4 space-y-2">\n {product.access_type && (\n <div className="flex items-center gap-2 text-sm">\n <span className="text-muted-foreground">Access:</span>\n <span className="font-medium capitalize">{product.access_type}</span>\n </div>\n )}\n {product.access_level && (\n <div className="flex items-center gap-2 text-sm">\n <span className="text-muted-foreground">Level:</span>\n <span className="font-medium capitalize">{product.access_level}</span>\n </div>\n )}\n {product.access_duration_days && (\n <div className="flex items-center gap-2 text-sm">\n <span className="text-muted-foreground">Duration:</span>\n <span className="font-medium">{product.access_duration_days} days</span>\n </div>\n )}\n </div>\n )}\n\n {/* File details */}\n {isDownload && (product.file_size_mb || product.file_type || product.version || product.max_downloads) && (\n <div data-cimplify-product-layout-file-details className="grid grid-cols-2 gap-3">\n {product.file_type && (\n <div className="px-3 py-2 bg-muted text-center">\n <p className="text-xs text-muted-foreground">Format</p>\n <p className="text-sm font-semibold">{product.file_type.toUpperCase()}</p>\n </div>\n )}\n {product.file_size_mb != null && (\n <div className="px-3 py-2 bg-muted text-center">\n <p className="text-xs text-muted-foreground">Size</p>\n <p className="text-sm font-semibold">{product.file_size_mb >= 1024 ? `${(product.file_size_mb / 1024).toFixed(1)} GB` : `${product.file_size_mb} MB`}</p>\n </div>\n )}\n {product.max_downloads != null && (\n <div className="px-3 py-2 bg-muted text-center">\n <p className="text-xs text-muted-foreground">Downloads</p>\n <p className="text-sm font-semibold">{product.max_downloads}</p>\n </div>\n )}\n {product.version && (\n <div className="px-3 py-2 bg-muted text-center">\n <p className="text-xs text-muted-foreground">Version</p>\n <p className="text-sm font-semibold">v{product.version}</p>\n </div>\n )}\n {product.download_expires_days != null && (\n <div className="px-3 py-2 bg-muted text-center">\n <p className="text-xs text-muted-foreground">Expires</p>\n <p className="text-sm font-semibold">{product.download_expires_days} days</p>\n </div>\n )}\n {product.max_activations != null && (\n <div className="px-3 py-2 bg-muted text-center">\n <p className="text-xs text-muted-foreground">Activations</p>\n <p className="text-sm font-semibold">{product.max_activations}</p>\n </div>\n )}\n </div>\n )}\n\n {/* Custom Attributes */}\n <CustomAttributesTable attributes={product.custom_attributes || []} />\n\n {/* Customizer (license variants, billing plans, add to cart) */}\n <ProductCustomizer\n product={product}\n onAddToCart={onAddToCart}\n showSpecialInstructions={false}\n />\n </div>\n }\n />\n\n {showRelated && relatedProducts && relatedProducts.length > 0 && (\n <RelatedProductsSection\n products={relatedProducts}\n title="More digital products"\n />\n )}\n </div>\n );\n}\n' }] }, "slot-picker": { "name": "slot-picker", "title": "SlotPicker", "description": "Time slot grid for a single day with morning/afternoon/evening grouping.", "type": "component", "registryDependencies": ["price", "cn"], "files": [{ "path": "slot-picker.tsx", "content": '"use client";\n\nimport { Radio } from "@base-ui/react/radio";\nimport { RadioGroup } from "@base-ui/react/radio-group";\nimport React from "react";\nimport type { AvailableSlot } from "@cimplify/sdk";\nimport type { DurationUnit, SchedulingMode } from "@cimplify/sdk";\nimport { useAvailableSlots } from "@cimplify/sdk/react";\nimport { Price } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface SlotPickerClassNames {\n root?: string;\n group?: string;\n groupLabel?: string;\n slot?: string;\n slotTime?: string;\n slotPrice?: string;\n loading?: string;\n empty?: string;\n}\n\nexport interface SlotPickerProps {\n /** Pre-fetched slots (skips fetch). */\n slots?: AvailableSlot[];\n /** Service ID \u2014 used to fetch slots when `slots` prop is not provided. */\n serviceId?: string;\n /** Date string (YYYY-MM-DD) \u2014 used to fetch slots when `slots` prop is not provided. */\n date?: string;\n /** Number of participants for capacity-based availability. */\n participantCount?: number;\n /** Currently selected slot. */\n selectedSlot?: AvailableSlot | null;\n /** Called when a slot is selected. */\n onSlotSelect?: (slot: AvailableSlot) => void;\n /** Whether to group slots by time of day. Default: true. Ignored when `schedulingMode` is `"multi_day"`. */\n groupByTimeOfDay?: boolean;\n /** Show price on each slot. Default: true. */\n showPrice?: boolean;\n /**\n * Service scheduling mode. When `"multi_day"`, each slot renders as a\n * stay summary (`"3 nights: Fri Apr 5, 3:00 PM \u2192 Mon Apr 8, 11:00 AM"`)\n * instead of the time-of-day label. Defaults to `"intraday"`.\n */\n schedulingMode?: SchedulingMode;\n /** Service duration unit \u2014 used for the stay summary in multi-day mode. */\n durationUnit?: DurationUnit;\n /** Service duration value \u2014 used for the stay summary in multi-day mode. */\n durationValue?: number;\n /** Text shown when no slots available. */\n emptyMessage?: string;\n className?: string;\n classNames?: SlotPickerClassNames;\n}\n\ninterface SlotGroup {\n label: string;\n slots: AvailableSlot[];\n}\n\nfunction getTimeOfDay(timeStr: string): "morning" | "afternoon" | "evening" {\n const hour = parseInt(timeStr.split("T").pop()?.split(":")[0] ?? timeStr.split(":")[0], 10);\n if (hour < 12) return "morning";\n if (hour < 17) return "afternoon";\n return "evening";\n}\n\nconst TIME_OF_DAY_LABELS: Record<string, string> = {\n morning: "Morning",\n afternoon: "Afternoon",\n evening: "Evening",\n};\n\nfunction groupSlots(slots: AvailableSlot[]): SlotGroup[] {\n const groups: Record<string, AvailableSlot[]> = {};\n for (const slot of slots) {\n const tod = getTimeOfDay(slot.start_time);\n if (!groups[tod]) groups[tod] = [];\n groups[tod].push(slot);\n }\n return (["morning", "afternoon", "evening"] as const)\n .filter((tod) => groups[tod]?.length)\n .map((tod) => ({ label: TIME_OF_DAY_LABELS[tod], slots: groups[tod] }));\n}\n\nfunction formatTime(timeStr: string): string {\n try {\n const date = new Date(timeStr);\n if (!isNaN(date.getTime())) {\n return date.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" });\n }\n } catch {\n // noop\n }\n\n const parts = timeStr.split(":");\n if (parts.length >= 2) {\n const hour = parseInt(parts[0], 10);\n const minute = parts[1];\n const ampm = hour >= 12 ? "PM" : "AM";\n const displayHour = hour % 12 || 12;\n return `${displayHour}:${minute} ${ampm}`;\n }\n return timeStr;\n}\n\nfunction pluralizeUnit(unit: DurationUnit | undefined, value: number | undefined): string {\n if (!unit) return value === 1 ? "day" : "days";\n const v = value ?? 1;\n if (unit === "minutes") return v === 1 ? "minute" : "minutes";\n if (unit === "hours") return v === 1 ? "hour" : "hours";\n if (unit === "days") return v === 1 ? "day" : "days";\n if (unit === "weeks") return v === 1 ? "week" : "weeks";\n if (unit === "months") return v === 1 ? "month" : "months";\n return unit;\n}\n\nfunction formatStaySummary(\n slot: AvailableSlot,\n durationUnit: DurationUnit | undefined,\n durationValue: number | undefined,\n): string {\n const start = new Date(slot.start_time);\n const end = new Date(slot.end_time);\n const startLabel = start.toLocaleString(undefined, {\n weekday: "short",\n month: "short",\n day: "numeric",\n hour: "numeric",\n minute: "2-digit",\n });\n const endLabel = end.toLocaleString(undefined, {\n weekday: "short",\n month: "short",\n day: "numeric",\n hour: "numeric",\n minute: "2-digit",\n });\n const unitLabel = pluralizeUnit(durationUnit, durationValue);\n if (durationValue !== undefined) {\n return `${durationValue} ${unitLabel}: ${startLabel} \u2192 ${endLabel}`;\n }\n return `${startLabel} \u2192 ${endLabel}`;\n}\n\nfunction slotToValue(slot: AvailableSlot): string {\n return `${slot.start_time}|${slot.end_time}`;\n}\n\nexport function SlotPicker({\n slots: slotsProp,\n serviceId,\n date,\n participantCount,\n selectedSlot,\n onSlotSelect,\n groupByTimeOfDay = true,\n showPrice = true,\n schedulingMode = "intraday",\n durationUnit,\n durationValue,\n emptyMessage = "No available slots",\n className,\n classNames,\n}: SlotPickerProps): React.ReactElement {\n const isMultiDay = schedulingMode === "multi_day";\n const { slots: fetched, isLoading } = useAvailableSlots(\n serviceId ?? null,\n date ?? null,\n {\n participantCount,\n enabled: slotsProp === undefined && !!serviceId && !!date,\n },\n );\n\n const slots = slotsProp ?? fetched;\n\n if (isLoading && slots.length === 0) {\n return (\n <div\n data-cimplify-slot-picker\n aria-busy="true"\n className={cn(className, classNames?.root, classNames?.loading)}\n />\n );\n }\n\n if (slots.length === 0) {\n return (\n <div\n data-cimplify-slot-picker\n data-empty\n className={cn(className, classNames?.root, classNames?.empty)}\n >\n <p>{emptyMessage}</p>\n </div>\n );\n }\n\n const groups = groupByTimeOfDay && !isMultiDay\n ? groupSlots(slots)\n : [{ label: "", slots }];\n\n const slotsByValue = new Map<string, AvailableSlot>();\n for (const slot of slots) {\n slotsByValue.set(slotToValue(slot), slot);\n }\n\n const selectedValue = selectedSlot ? slotToValue(selectedSlot) : "";\n\n return (\n <RadioGroup\n data-cimplify-slot-picker\n className={cn(className, classNames?.root)}\n value={selectedValue}\n onValueChange={(value: string) => {\n const slot = slotsByValue.get(value);\n if (slot?.is_available) {\n onSlotSelect?.(slot);\n }\n }}\n >\n {groups.map((group) => (\n <div key={group.label || "all"} data-cimplify-slot-group className={classNames?.group}>\n {group.label && (\n <div data-cimplify-slot-group-label className={classNames?.groupLabel}>\n {group.label}\n </div>\n )}\n {group.slots.map((slot) => {\n const value = slotToValue(slot);\n const isSelected =\n selectedSlot?.start_time === slot.start_time &&\n selectedSlot?.end_time === slot.end_time;\n return (\n <Radio.Root\n key={value}\n value={value}\n disabled={!slot.is_available}\n data-cimplify-slot\n data-selected={isSelected || undefined}\n data-unavailable={!slot.is_available || undefined}\n className={classNames?.slot}\n >\n <span data-cimplify-slot-time className={classNames?.slotTime}>\n {isMultiDay\n ? formatStaySummary(slot, durationUnit, durationValue)\n : formatTime(slot.start_time)}\n </span>\n {showPrice && slot.price && (\n <span data-cimplify-slot-price className={classNames?.slotPrice}>\n <Price amount={slot.price} />\n </span>\n )}\n </Radio.Root>\n );\n })}\n </div>\n ))}\n </RadioGroup>\n );\n}\n' }] }, "sale-badge": { "name": "sale-badge", "title": "SaleBadge", "description": "Sale/discount indicator with percentage and original price.", "type": "component", "registryDependencies": ["price", "cn"], "files": [{ "path": "sale-badge.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { Product, ProductDealInfo } from "@cimplify/sdk";\nimport type { ProductWithPrice } from "@cimplify/sdk";\nimport {\n isOnSale,\n getDiscountPercentage,\n getBasePrice,\n parsePrice,\n} from "@cimplify/sdk";\nimport { Price } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface SaleBadgeClassNames {\n root?: string;\n percentage?: string;\n label?: string;\n originalPrice?: string;\n}\n\nexport interface SaleBadgeProps {\n /** Product, optionally enriched with price_info for sale detection. */\n product: Product & Partial<ProductWithPrice>;\n /** Deal info from useProductDeals / useProductsOnSale. */\n dealInfo?: ProductDealInfo;\n /** Override badge text entirely. */\n label?: string;\n /** Show the original (pre-discount) price with strikethrough styling. */\n showOriginalPrice?: boolean;\n /** Show the percentage off. Default: true. */\n showPercentage?: boolean;\n className?: string;\n classNames?: SaleBadgeClassNames;\n}\n\n/**\n * SaleBadge \u2014 shows a sale/discount indicator for a product.\n *\n * Returns `null` when there\'s no deal, no sale price difference, and no label override,\n * so it\'s safe to render unconditionally \u2014 it simply won\'t show for non-sale products.\n */\nexport function SaleBadge({\n product,\n dealInfo,\n label,\n showOriginalPrice = false,\n showPercentage = true,\n className,\n classNames,\n}: SaleBadgeProps): React.ReactElement | null {\n const onSale = isOnSale(product);\n const hasDeal = dealInfo !== undefined;\n\n if (!hasDeal && !onSale && !label) {\n return null;\n }\n\n // Percentage: prefer dealInfo when it\'s a percentage benefit, else compute from prices\n let percentage: number | null = null;\n if (hasDeal && dealInfo.benefit_type === "percentage") {\n percentage = parsePrice(dealInfo.value);\n } else if (onSale) {\n percentage = getDiscountPercentage(product);\n }\n\n // Badge text: explicit label > deal label > computed "X% off"\n const badgeText =\n label ??\n dealInfo?.label ??\n (percentage != null && percentage > 0 ? `${percentage}% off` : null);\n\n if (!badgeText) {\n return null;\n }\n\n return (\n <span\n data-cimplify-sale-badge\n className={cn(className, classNames?.root)}\n style={{ display: "inline-flex", alignItems: "center", gap: "0.375rem" }}\n >\n {showPercentage && percentage != null && percentage > 0 && (\n <span data-cimplify-sale-percentage className={classNames?.percentage}>\n -{percentage}%\n </span>\n )}\n <span data-cimplify-sale-label className={classNames?.label}>\n {badgeText}\n </span>\n {showOriginalPrice && onSale && (\n <span data-cimplify-sale-original-price className={classNames?.originalPrice}>\n <Price amount={getBasePrice(product)} />\n </span>\n )}\n </span>\n );\n}\n' }] } };
|
|
5704
|
+
var REGISTRY = { "order-detail-page": { "name": "order-detail-page", "title": "OrderDetailPage", "description": "Single order detail view with live status polling.", "type": "component", "registryDependencies": ["order-summary", "cn"], "files": [{ "path": "order-detail-page.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { Order, LineItem, OrderStatus } from "@cimplify/sdk";\nimport { OrderSummary } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface OrderDetailPageClassNames {\n root?: string;\n header?: string;\n title?: string;\n backButton?: string;\n summary?: string;\n}\n\nexport interface OrderDetailPageProps {\n /** Order ID to display. */\n orderId: string;\n /** Pre-fetched order for SSR. */\n order?: Order;\n /** Poll for status updates. Default: true. */\n poll?: boolean;\n /** Called when back button is clicked. */\n onBack?: () => void;\n /** Custom line item renderer. */\n renderLineItem?: (item: LineItem) => React.ReactNode;\n /** Called when the reorder button is clicked. */\n onReorder?: (order: Order) => void;\n /** Called when the order status changes during polling. */\n onStatusChange?: (previousStatus: OrderStatus, newStatus: OrderStatus, order: Order) => void;\n /** Back button label. */\n backLabel?: string;\n className?: string;\n classNames?: OrderDetailPageClassNames;\n}\n\n/**\n * OrderDetailPage \u2014 single order detail view with live status polling.\n *\n * SSR-friendly: pass `order` prop for server rendering.\n */\nexport function OrderDetailPage({\n orderId,\n order,\n poll = true,\n onBack,\n renderLineItem,\n onReorder,\n onStatusChange,\n backLabel = "Back to orders",\n className,\n classNames,\n}: OrderDetailPageProps): React.ReactElement {\n return (\n <div data-cimplify-order-detail-page className={cn(className, classNames?.root)}>\n {/* Header */}\n <div data-cimplify-order-detail-header className={classNames?.header}>\n {onBack && (\n <button\n type="button"\n onClick={onBack}\n data-cimplify-order-detail-back\n className={classNames?.backButton}\n >\n {backLabel}\n </button>\n )}\n <h1 data-cimplify-order-detail-title className={classNames?.title}>\n Order Details\n </h1>\n </div>\n\n {/* Order summary */}\n <div data-cimplify-order-detail-content className={classNames?.summary}>\n <OrderSummary\n order={order}\n orderId={order ? undefined : orderId}\n poll={poll}\n renderLineItem={renderLineItem}\n onReorder={onReorder}\n onStatusChange={onStatusChange}\n />\n </div>\n </div>\n );\n}\n' }] }, "cart-drawer": { "name": "cart-drawer", "title": "CartDrawer", "description": "Slide-in side cart drawer with provider context, free-shipping progress, animated subtotal, and empty state. Auto-opens on add-to-cart.", "type": "component", "registryDependencies": ["cart-summary", "price", "cn"], "files": [{ "path": "cart-drawer.tsx", "content": '"use client";\n\nimport React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";\nimport { CartSummary } from "./cart-summary";\nimport { Price } from "@cimplify/sdk/react";\nimport { useCart } from "@cimplify/sdk/react";\nimport { parsePrice } from "@cimplify/sdk";\nimport { cn } from "@cimplify/sdk/react";\n\ninterface CartDrawerCtx {\n isOpen: boolean;\n open: () => void;\n close: () => void;\n toggle: () => void;\n}\n\nconst CartDrawerContext = createContext<CartDrawerCtx | null>(null);\n\nexport function useCartDrawer(): CartDrawerCtx {\n const ctx = useContext(CartDrawerContext);\n if (!ctx) {\n throw new Error("useCartDrawer must be used within <CartDrawerProvider>");\n }\n return ctx;\n}\n\ninterface ProviderProps {\n children: React.ReactNode;\n /** Auto-open the drawer whenever the cart\'s pendingOpCount goes from 0 \u2192 >0. Default: true. */\n openOnAdd?: boolean;\n}\n\nexport function CartDrawerProvider({ children, openOnAdd = true }: ProviderProps): React.ReactElement {\n const [isOpen, setIsOpen] = useState(false);\n const cart = useCart();\n const lastPendingRef = useRef(0);\n\n const open = useCallback(() => setIsOpen(true), []);\n const close = useCallback(() => setIsOpen(false), []);\n const toggle = useCallback(() => setIsOpen((v) => !v), []);\n\n useEffect(() => {\n if (!openOnAdd) return;\n if (cart.pendingOpCount > lastPendingRef.current && cart.pendingOpCount > 0) {\n setIsOpen(true);\n }\n lastPendingRef.current = cart.pendingOpCount;\n }, [cart.pendingOpCount, openOnAdd]);\n\n const value = useMemo<CartDrawerCtx>(() => ({ isOpen, open, close, toggle }), [isOpen, open, close, toggle]);\n return <CartDrawerContext.Provider value={value}>{children}</CartDrawerContext.Provider>;\n}\n\nexport interface CartDrawerProps {\n /** Called when "Checkout" is clicked. Drawer auto-closes first. */\n onCheckout?: () => void;\n /** Called when "Continue Shopping" is clicked. Defaults to closing. */\n onContinueShopping?: () => void;\n /** Called when the empty state\'s CTA is clicked. */\n onShop?: () => void;\n /** Heading. */\n title?: string;\n /** Free-shipping threshold (in business currency, numeric). 0 disables the progress bar. */\n freeShippingThreshold?: number;\n /** Custom class on the panel. */\n className?: string;\n}\n\n/**\n * Animate a number toward `target` over ~250ms. Used for subtotal so it\n * feels alive when items are added/removed.\n */\nfunction useAnimatedNumber(target: number, durationMs = 250) {\n const [value, setValue] = useState(target);\n const fromRef = useRef(target);\n const startRef = useRef<number | null>(null);\n useEffect(() => {\n fromRef.current = value;\n startRef.current = null;\n let raf = 0;\n const tick = (t: number) => {\n if (startRef.current == null) startRef.current = t;\n const p = Math.min(1, (t - startRef.current) / durationMs);\n const eased = 1 - Math.pow(1 - p, 3);\n setValue(fromRef.current + (target - fromRef.current) * eased);\n if (p < 1) raf = requestAnimationFrame(tick);\n };\n raf = requestAnimationFrame(tick);\n return () => cancelAnimationFrame(raf);\n // value intentionally omitted \u2014 only re-trigger on target change\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [target, durationMs]);\n return value;\n}\n\nexport function CartDrawer({\n onCheckout,\n onContinueShopping,\n onShop,\n title = "Cart",\n freeShippingThreshold = 0,\n className,\n}: CartDrawerProps): React.ReactElement {\n const { isOpen, close } = useCartDrawer();\n const cart = useCart();\n const subtotalNum = parsePrice(cart.subtotal);\n const animatedSubtotal = useAnimatedNumber(subtotalNum);\n\n // Lock body scroll + close on Escape while open.\n useEffect(() => {\n if (!isOpen) return;\n const original = document.body.style.overflow;\n document.body.style.overflow = "hidden";\n const onKey = (e: KeyboardEvent) => {\n if (e.key === "Escape") close();\n };\n window.addEventListener("keydown", onKey);\n return () => {\n document.body.style.overflow = original;\n window.removeEventListener("keydown", onKey);\n };\n }, [isOpen, close]);\n\n const handleCheckout = () => {\n close();\n onCheckout?.();\n };\n\n const handleContinue = () => {\n onContinueShopping?.();\n close();\n };\n\n const handleShop = () => {\n onShop?.();\n close();\n };\n\n // Free-shipping progress\n const showShippingBar = freeShippingThreshold > 0 && !cart.isEmpty;\n const remainingForShipping = Math.max(0, freeShippingThreshold - subtotalNum);\n const shippingProgress = freeShippingThreshold > 0 ? Math.min(100, (subtotalNum / freeShippingThreshold) * 100) : 0;\n const shippingUnlocked = subtotalNum >= freeShippingThreshold && freeShippingThreshold > 0;\n\n return (\n <div\n data-cimplify-cart-drawer\n data-open={isOpen ? "true" : "false"}\n aria-hidden={!isOpen}\n className={cn(\n "fixed inset-0 z-[200]",\n isOpen ? "pointer-events-auto" : "pointer-events-none",\n )}\n >\n {/* Backdrop */}\n <div\n onClick={close}\n className={cn(\n "absolute inset-0 bg-foreground/40 backdrop-blur-sm transition-opacity duration-300",\n isOpen ? "opacity-100" : "opacity-0",\n )}\n />\n\n {/* Panel */}\n <aside\n role="dialog"\n aria-modal="true"\n aria-label={title}\n className={cn(\n "absolute top-0 right-0 h-full w-full sm:max-w-[480px] bg-background shadow-2xl flex flex-col",\n "transition-transform duration-300",\n isOpen ? "translate-x-0" : "translate-x-full",\n // ease-out cubic-bezier for a "pull" feel\n "[transition-timing-function:cubic-bezier(0.16,1,0.3,1)]",\n className,\n )}\n >\n {/* Header */}\n <header className="relative flex items-center justify-between gap-4 px-6 py-5 shrink-0">\n <div className="flex items-baseline gap-2">\n <h2 className="text-xl font-bold tracking-tight m-0">{title}</h2>\n {cart.itemCount > 0 && (\n <span className="text-sm text-muted-foreground tabular-nums">\n {cart.itemCount} {cart.itemCount === 1 ? "item" : "items"}\n </span>\n )}\n </div>\n <button\n type="button"\n onClick={close}\n aria-label="Close cart"\n className="grid place-items-center w-9 h-9 rounded-full hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"\n >\n <svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24" aria-hidden>\n <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />\n </svg>\n </button>\n </header>\n\n {/* Free-shipping bar */}\n {showShippingBar && (\n <div data-cimplify-cart-shipping-bar className="px-6 pb-4 shrink-0">\n <div className="flex items-baseline justify-between gap-2 mb-1.5 text-xs">\n {shippingUnlocked ? (\n <span className="font-medium text-primary">\u2713 Free shipping unlocked</span>\n ) : (\n <span className="text-muted-foreground">\n <Price amount={remainingForShipping} className="font-semibold text-foreground" /> away from free shipping\n </span>\n )}\n </div>\n <div className="h-1 rounded-full bg-muted overflow-hidden">\n <div\n className="h-full bg-primary transition-[width] duration-500 ease-out"\n style={{ width: `${shippingProgress}%` }}\n />\n </div>\n </div>\n )}\n\n {/* Items */}\n <div className="flex-1 overflow-y-auto px-6">\n {cart.isEmpty ? (\n <EmptyState onShop={handleShop} />\n ) : (\n <CartSummary showTotals={false} showCheckoutButton={false} />\n )}\n </div>\n\n {/* Footer */}\n {!cart.isEmpty && (\n <footer className="border-t border-border px-6 py-5 shrink-0 space-y-3 bg-background">\n <div className="flex items-baseline justify-between">\n <span className="text-sm text-muted-foreground">Subtotal</span>\n <Price amount={animatedSubtotal} className="text-lg font-bold tabular-nums" />\n </div>\n <p className="text-[11px] text-muted-foreground">\n Tax and shipping calculated at checkout.\n </p>\n <button\n type="button"\n onClick={handleCheckout}\n className="w-full h-12 rounded-full bg-foreground text-background font-semibold text-sm hover:bg-foreground/90 active:scale-[0.99] transition-all"\n >\n Checkout \u2014 <Price amount={cart.subtotal} className="tabular-nums" />\n </button>\n <button\n type="button"\n onClick={handleContinue}\n className="w-full text-xs font-medium text-muted-foreground hover:text-foreground transition-colors"\n >\n Continue shopping\n </button>\n <div className="flex items-center justify-center gap-1.5 text-[10px] text-muted-foreground/70 uppercase tracking-wider pt-1">\n <svg className="w-3 h-3" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24" aria-hidden>\n <path strokeLinecap="round" strokeLinejoin="round" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />\n </svg>\n <span>Secure checkout</span>\n </div>\n </footer>\n )}\n </aside>\n </div>\n );\n}\n\nfunction EmptyState({ onShop }: { onShop: () => void }) {\n return (\n <div data-cimplify-cart-empty className="h-full grid place-items-center text-center py-16">\n <div className="space-y-5 max-w-[260px]">\n <div className="relative w-20 h-20 mx-auto">\n <div className="absolute inset-0 rounded-full bg-muted" />\n <svg\n className="relative w-10 h-10 m-auto top-1/2 -translate-y-1/2 text-muted-foreground/60"\n fill="none"\n stroke="currentColor"\n strokeWidth="1.5"\n viewBox="0 0 24 24"\n aria-hidden\n >\n <path strokeLinecap="round" strokeLinejoin="round" d="M16 11V7a4 4 0 00-8 0v4M5 9h14l-1 12H6L5 9z" />\n </svg>\n </div>\n <div>\n <p className="text-base font-semibold m-0">Your cart is empty</p>\n <p className="text-sm text-muted-foreground mt-1">\n Discover something you\'ll love.\n </p>\n </div>\n <button\n type="button"\n onClick={onShop}\n className="inline-flex items-center gap-1.5 h-11 px-6 rounded-full bg-foreground text-background text-sm font-semibold hover:bg-foreground/90 active:scale-[0.99] transition-all"\n >\n Shop now\n <svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth="2.5" viewBox="0 0 24 24" aria-hidden>\n <path strokeLinecap="round" strokeLinejoin="round" d="M14 5l7 7m0 0l-7 7m7-7H3" />\n </svg>\n </button>\n </div>\n </div>\n );\n}\n' }] }, "booking-card": { "name": "booking-card", "title": "BookingCard", "description": "Single booking display with status, time, and action buttons.", "type": "component", "registryDependencies": ["price", "cn"], "files": [{ "path": "booking-card.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { CustomerBooking, BookingStatus } from "@cimplify/sdk";\nimport { Price } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface BookingCardClassNames {\n root?: string;\n service?: string;\n status?: string;\n time?: string;\n confirmationCode?: string;\n total?: string;\n actions?: string;\n cancelButton?: string;\n rescheduleButton?: string;\n}\n\nexport interface BookingCardProps {\n /** The booking to display. */\n booking: CustomerBooking;\n /** Called when cancel is clicked. */\n onCancel?: (booking: CustomerBooking) => void;\n /** Called when reschedule is clicked. */\n onReschedule?: (booking: CustomerBooking) => void;\n /** Custom renderer for the booking. */\n renderBooking?: (booking: CustomerBooking) => React.ReactNode;\n className?: string;\n classNames?: BookingCardClassNames;\n}\n\nconst STATUS_LABELS: Record<string, string> = {\n pending: "Pending",\n confirmed: "Confirmed",\n in_progress: "In Progress",\n completed: "Completed",\n cancelled: "Cancelled",\n no_show: "No Show",\n};\n\nfunction isActiveStatus(status: BookingStatus): boolean {\n const s = String(status).toLowerCase();\n return s !== "completed" && s !== "cancelled" && s !== "no_show";\n}\n\nfunction getFirstServiceItem(booking: CustomerBooking) {\n return booking.service_items[0] ?? null;\n}\n\nfunction formatScheduledTime(start?: string | null, end?: string | null): string | null {\n if (!start) return null;\n try {\n const startDate = new Date(start);\n const options: Intl.DateTimeFormatOptions = {\n month: "short",\n day: "numeric",\n hour: "numeric",\n minute: "2-digit",\n };\n let formatted = startDate.toLocaleString(undefined, options);\n if (end) {\n const endDate = new Date(end);\n formatted += ` \u2013 ${endDate.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" })}`;\n }\n return formatted;\n } catch {\n return start;\n }\n}\n\nexport function BookingCard({\n booking,\n onCancel,\n onReschedule,\n renderBooking,\n className,\n classNames,\n}: BookingCardProps): React.ReactElement {\n if (renderBooking) {\n return (\n <div\n data-cimplify-booking-card\n data-status={booking.status}\n className={cn(className, classNames?.root)}\n >\n {renderBooking(booking)}\n </div>\n );\n }\n\n const firstItem = getFirstServiceItem(booking);\n const active = isActiveStatus(booking.status);\n const scheduledTime = firstItem\n ? formatScheduledTime(firstItem.scheduled_start, firstItem.scheduled_end)\n : null;\n\n return (\n <div\n data-cimplify-booking-card\n data-status={booking.status}\n className={cn(\n "rounded-xl border border-border bg-background p-4 flex flex-col gap-3",\n className,\n classNames?.root,\n )}\n >\n <div data-cimplify-booking-main className="flex items-center justify-between gap-2">\n <span data-cimplify-booking-service className={cn("font-semibold text-foreground", classNames?.service)}>\n Booking #{booking.order_id.slice(0, 8)}\n </span>\n <span\n data-cimplify-booking-status\n data-status={booking.status}\n className={cn(\n "text-xs font-medium px-2 py-0.5 rounded-full",\n active ? "bg-primary/10 text-primary" : "bg-muted text-muted-foreground",\n classNames?.status,\n )}\n >\n {STATUS_LABELS[booking.status] ?? booking.status}\n </span>\n </div>\n\n {firstItem?.confirmation_code && (\n <span data-cimplify-booking-code className={cn("text-xs font-mono text-muted-foreground tracking-wide", classNames?.confirmationCode)}>\n {firstItem.confirmation_code}\n </span>\n )}\n\n {scheduledTime && (\n <time\n data-cimplify-booking-time\n dateTime={firstItem?.scheduled_start ?? undefined}\n className={cn("text-sm text-foreground", classNames?.time)}\n >\n {scheduledTime}\n </time>\n )}\n\n <span data-cimplify-booking-total className={cn("text-sm font-medium text-foreground", classNames?.total)}>\n <Price amount={booking.total_price} />\n </span>\n\n {active && (onCancel || onReschedule) && (\n <div data-cimplify-booking-actions className={cn("flex gap-2 pt-1 border-t border-border mt-1", classNames?.actions)}>\n {onReschedule && (\n <button\n type="button"\n onClick={() => onReschedule(booking)}\n data-cimplify-booking-reschedule\n className={cn(\n "flex-1 text-sm font-medium py-2 rounded-lg border border-border bg-background text-foreground transition-colors hover:bg-muted cursor-pointer",\n classNames?.rescheduleButton,\n )}\n >\n Reschedule\n </button>\n )}\n {onCancel && (\n <button\n type="button"\n onClick={() => onCancel(booking)}\n data-cimplify-booking-cancel\n className={cn(\n "flex-1 text-sm font-medium py-2 rounded-lg border border-border bg-background text-destructive transition-colors hover:bg-destructive/10 cursor-pointer",\n classNames?.cancelButton,\n )}\n >\n Cancel\n </button>\n )}\n </div>\n )}\n </div>\n );\n}\n' }] }, "order-history": { "name": "order-history", "title": "OrderHistory", "description": "List of past orders with status and totals.", "type": "component", "registryDependencies": ["price", "cn"], "files": [{ "path": "order-history.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { Order, OrderStatus } from "@cimplify/sdk";\nimport { useOrders } from "@cimplify/sdk/react";\nimport { Price } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface OrderHistoryClassNames {\n root?: string;\n item?: string;\n orderId?: string;\n status?: string;\n date?: string;\n total?: string;\n empty?: string;\n loading?: string;\n reorderButton?: string;\n}\n\nexport interface OrderHistoryProps {\n /** Override orders (skips fetch). For SSR, pass pre-fetched orders. */\n orders?: Order[];\n /** Filter by status. */\n status?: OrderStatus;\n /** Max orders to display. */\n limit?: number;\n /** Called when an order row is clicked. */\n onOrderClick?: (order: Order) => void;\n /** Custom order row renderer. */\n renderOrder?: (order: Order) => React.ReactNode;\n /** Called when the reorder button is clicked. If provided, a reorder button is rendered. */\n onReorder?: (order: Order) => void;\n /** Text shown when empty. */\n emptyMessage?: string;\n className?: string;\n classNames?: OrderHistoryClassNames;\n}\n\nconst STATUS_LABELS: Record<OrderStatus, string> = {\n pending: "Pending",\n created: "Created",\n confirmed: "Confirmed",\n in_preparation: "In Preparation",\n ready_to_serve: "Ready",\n partially_served: "Partially Served",\n served: "Served",\n delivered: "Delivered",\n picked_up: "Picked Up",\n completed: "Completed",\n cancelled: "Cancelled",\n refunded: "Refunded",\n};\n\n/**\n * OrderHistory \u2014 list of past orders with status and totals.\n *\n * Fetches via `useOrders` unless pre-loaded orders are passed.\n */\nexport function OrderHistory({\n orders: ordersProp,\n status,\n limit,\n onOrderClick,\n renderOrder,\n onReorder,\n emptyMessage = "No orders yet",\n className,\n classNames,\n}: OrderHistoryProps): React.ReactElement {\n const { orders: fetched, isLoading } = useOrders({\n status,\n limit,\n enabled: ordersProp === undefined,\n });\n\n const orders = ordersProp ?? fetched;\n\n if (isLoading && orders.length === 0) {\n return (\n <div\n data-cimplify-order-history\n aria-busy="true"\n className={cn(className, classNames?.root, classNames?.loading)}\n />\n );\n }\n\n if (orders.length === 0) {\n return (\n <div\n data-cimplify-order-history\n data-empty\n className={cn(className, classNames?.root, classNames?.empty)}\n >\n <p>{emptyMessage}</p>\n </div>\n );\n }\n\n return (\n <div data-cimplify-order-history className={cn(className, classNames?.root)}>\n {orders.map((order) => (\n <button\n key={order.id}\n type="button"\n onClick={() => onOrderClick?.(order)}\n data-cimplify-order-history-item\n data-status={order.status}\n className={classNames?.item}\n >\n {renderOrder ? (\n renderOrder(order)\n ) : (\n <>\n <div data-cimplify-order-history-main>\n <span data-cimplify-order-history-id className={classNames?.orderId}>\n #{order.user_friendly_id}\n </span>\n <span\n data-cimplify-order-history-status\n data-status={order.status}\n className={classNames?.status}\n >\n {STATUS_LABELS[order.status] ?? order.status}\n </span>\n </div>\n <div data-cimplify-order-history-details>\n <time\n data-cimplify-order-history-date\n dateTime={order.created_at}\n className={classNames?.date}\n >\n {new Date(order.created_at).toLocaleDateString()}\n </time>\n <span data-cimplify-order-history-items>\n {order.total_quantity} {order.total_quantity === 1 ? "item" : "items"}\n </span>\n <span data-cimplify-order-history-total className={classNames?.total}>\n <Price amount={order.total_price} />\n </span>\n </div>\n {onReorder && (\n <button\n type="button"\n onClick={(e) => {\n e.stopPropagation();\n onReorder(order);\n }}\n data-cimplify-order-reorder\n className={classNames?.reorderButton}\n >\n Reorder\n </button>\n )}\n </>\n )}\n </button>\n ))}\n </div>\n );\n}\n' }] }, "compact-service-card": { "name": "compact-service-card", "title": "CompactServiceCard", "description": "Horizontal service card with thumbnail for list views.", "type": "component", "registryDependencies": ["price", "cn"], "files": [{ "path": "cards/compact-service-card.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { ServiceCardLayoutProps } from "./standard-service-card";\nimport { Price } from "@cimplify/sdk/react";\nimport { parsePrice } from "@cimplify/sdk";\nimport { cn } from "@cimplify/sdk/react";\n\nfunction formatDuration(minutes: number, unit?: string): string {\n if (unit && unit !== "minutes") return `${minutes} ${unit}`;\n if (minutes >= 60) {\n const h = Math.floor(minutes / 60);\n const m = minutes % 60;\n return m > 0 ? `${h}h ${m}m` : `${h} hr`;\n }\n return `${minutes} min`;\n}\n\nexport function CompactServiceCard({\n product,\n renderImage,\n renderLink,\n className,\n}: ServiceCardLayoutProps): React.ReactElement {\n const image = product.image_url || product.images?.[0] || "";\n const hasDeposit = product.deposit_type && product.deposit_type !== "none" && product.deposit_amount;\n const hasBillingPlans = product.billing_plans && product.billing_plans.length > 0;\n const href = `/products/${product.slug}`;\n\n const content = (\n <div className="flex items-center gap-4 p-3">\n {/* Thumbnail */}\n <div className="w-[72px] h-[72px] rounded-xl overflow-hidden bg-muted shrink-0">\n {renderImage ? (\n renderImage({ src: image, alt: product.name, className: "w-full h-full object-cover" })\n ) : image ? (\n <img src={image} alt={product.name} className="w-full h-full object-cover" />\n ) : null}\n </div>\n\n {/* Info */}\n <div className="flex-1 min-w-0">\n <h3 className="text-[14px] font-semibold text-foreground leading-tight truncate">\n {product.name}\n </h3>\n {product.description && (\n <p className="text-[12px] text-muted-foreground mt-0.5 truncate">\n {product.description}\n </p>\n )}\n <div className="flex items-center gap-2 mt-2 flex-wrap">\n {product.duration_minutes != null && (\n <span className="text-[10.5px] font-medium text-muted-foreground px-1.5 py-0.5 bg-muted rounded">\n {formatDuration(product.duration_minutes, product.duration_unit)}\n </span>\n )}\n <span className="flex items-center gap-1 text-[10.5px] font-medium text-emerald-600">\n <span className="w-[6px] h-[6px] rounded-full bg-emerald-500" />\n Available\n </span>\n {hasDeposit && (\n <span className="text-[10.5px] font-medium text-amber-600">\n <Price amount={product.deposit_amount!} /> deposit\n </span>\n )}\n {hasBillingPlans && (\n <span className="inline-flex items-center gap-0.5 text-[10.5px] font-semibold text-primary px-1.5 py-0.5 bg-primary/10 rounded">\n Subscription\n </span>\n )}\n </div>\n </div>\n\n {/* Price + Chevron */}\n <div className="flex items-center gap-2 shrink-0">\n <Price amount={product.default_price} className="text-sm font-bold" />\n <svg className="w-4 h-4 text-muted-foreground group-hover:text-primary transition-colors" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">\n <path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />\n </svg>\n </div>\n </div>\n );\n\n const shellClass = cn(\n "group block text-left cursor-pointer bg-card rounded-[14px] overflow-hidden",\n "shadow-[0_1px_3px_rgba(0,0,0,0.04),0_6px_24px_rgba(0,0,0,0.06)]",\n "border border-transparent",\n "transition-all duration-300 ease-[cubic-bezier(0.19,1,0.22,1)]",\n "hover:-translate-y-[1px] hover:shadow-[0_2px_6px_rgba(0,0,0,0.04),0_12px_40px_rgba(0,0,0,0.10)] hover:border-primary/20",\n className,\n );\n\n if (renderLink) {\n return renderLink({ href, className: shellClass, children: content });\n }\n\n return <a href={href} className={shellClass} data-cimplify-card>{content}</a>;\n}\n' }] }, "volume-pricing": { "name": "volume-pricing", "title": "VolumePricing", "description": "Collapsible volume pricing tier table.", "type": "component", "registryDependencies": ["price", "cn"], "files": [{ "path": "volume-pricing.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { QuantityPricingTier } from "@cimplify/sdk";\nimport { Price } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface VolumePricingClassNames {\n root?: string;\n trigger?: string;\n triggerIcon?: string;\n panel?: string;\n tier?: string;\n tierActive?: string;\n tierRange?: string;\n tierPrice?: string;\n}\n\nexport interface VolumePricingProps {\n tiers: QuantityPricingTier[];\n currentQuantity?: number;\n defaultOpen?: boolean;\n className?: string;\n classNames?: VolumePricingClassNames;\n}\n\nfunction formatRange(tier: QuantityPricingTier): string {\n if (tier.max_quantity != null) {\n return `${tier.min_quantity}\u2013${tier.max_quantity} units`;\n }\n return `${tier.min_quantity}+ units`;\n}\n\nfunction isActiveTier(tier: QuantityPricingTier, quantity: number): boolean {\n if (quantity < tier.min_quantity) return false;\n if (tier.max_quantity != null && quantity > tier.max_quantity) return false;\n return true;\n}\n\nexport function VolumePricing({\n tiers,\n currentQuantity,\n defaultOpen = false,\n className,\n classNames,\n}: VolumePricingProps): React.ReactElement | null {\n if (tiers.length < 2) return null;\n\n const sorted = [...tiers].sort((a, b) => a.min_quantity - b.min_quantity);\n\n return (\n <details\n open={defaultOpen || undefined}\n data-cimplify-volume-pricing\n className={cn("border border-border", className, classNames?.root)}\n >\n <summary\n data-cimplify-volume-pricing-trigger\n className={cn(\n "flex items-center justify-between px-4 py-3 cursor-pointer select-none text-sm font-medium list-none [&::-webkit-details-marker]:hidden",\n classNames?.trigger,\n )}\n >\n Volume pricing\n <svg\n viewBox="0 0 12 12"\n fill="none"\n aria-hidden="true"\n className={cn(\n "w-3.5 h-3.5 text-muted-foreground transition-transform [[open]>&]:rotate-180",\n classNames?.triggerIcon,\n )}\n >\n <path d="M2 4.5l4 4 4-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />\n </svg>\n </summary>\n <div\n data-cimplify-volume-pricing-panel\n className={cn("border-t border-border divide-y divide-border", classNames?.panel)}\n >\n {sorted.map((tier, i) => {\n const active = currentQuantity != null && isActiveTier(tier, currentQuantity);\n return (\n <div\n key={i}\n data-cimplify-volume-pricing-tier\n data-active={active || undefined}\n className={cn(\n "flex items-center justify-between px-4 py-2.5 text-sm",\n active\n ? cn("bg-primary/5 font-medium", classNames?.tierActive)\n : classNames?.tier,\n )}\n >\n <span\n data-cimplify-volume-pricing-range\n className={cn("text-muted-foreground", classNames?.tierRange)}\n >\n {formatRange(tier)}\n </span>\n <span data-cimplify-volume-pricing-price className={classNames?.tierPrice}>\n <Price amount={tier.unit_price} prefix="" /> /ea\n </span>\n </div>\n );\n })}\n </div>\n </details>\n );\n}\n' }] }, "delivery-estimate": { "name": "delivery-estimate", "title": "DeliveryEstimate", "description": "Delivery fee + ETA preview at the cart/checkout edge, sourced from /delivery/fee with location.", "type": "component", "registryDependencies": ["price", "cn"], "files": [{ "path": "delivery-estimate.tsx", "content": '"use client";\n\nimport React, { useState, useEffect, useRef } from "react";\nimport type { DeliveryFeeResponse } from "../delivery";\nimport type { CimplifyError } from "@cimplify/sdk";\nimport { Price } from "@cimplify/sdk/react";\nimport { useOptionalCimplifyClient } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface DeliveryEstimateClassNames {\n root?: string;\n fee?: string;\n currency?: string;\n notServiceable?: string;\n loading?: string;\n error?: string;\n}\n\nexport interface DeliveryEstimateProps {\n /** Drop-off latitude. */\n latitude: number;\n /** Drop-off longitude. */\n longitude: number;\n /** Country code for regional fee lookup. */\n country?: string;\n /** Called when the delivery fee is successfully calculated. */\n onFeeCalculated?: (fee: DeliveryFeeResponse) => void;\n className?: string;\n classNames?: DeliveryEstimateClassNames;\n}\n\n/**\n * DeliveryEstimate \u2014 displays a pre-checkout delivery fee estimate.\n *\n * Fetches the delivery fee from `client.delivery.getFee` when coordinates change.\n * Shows fee amount with currency, a "not serviceable" message, or loading state.\n */\nexport function DeliveryEstimate({\n latitude,\n longitude,\n country,\n onFeeCalculated,\n className,\n classNames,\n}: DeliveryEstimateProps): React.ReactElement | null {\n const context = useOptionalCimplifyClient();\n const client = context?.client ?? null;\n\n const [result, setResult] = useState<DeliveryFeeResponse | null>(null);\n const [isLoading, setIsLoading] = useState(true);\n const [error, setError] = useState<CimplifyError | null>(null);\n const requestIdRef = useRef(0);\n\n useEffect(() => {\n if (!client) return;\n\n const nextRequestId = ++requestIdRef.current;\n setIsLoading(true);\n setError(null);\n\n void (async () => {\n const res = await client.delivery.getFee(latitude, longitude, country);\n\n if (nextRequestId !== requestIdRef.current) return;\n\n if (!res.ok) {\n setError(res.error);\n setResult(null);\n setIsLoading(false);\n return;\n }\n\n setResult(res.value);\n setError(null);\n setIsLoading(false);\n onFeeCalculated?.(res.value);\n })();\n }, [client, latitude, longitude, country]); // eslint-disable-line react-hooks/exhaustive-deps -- onFeeCalculated is a callback prop, not a reactive dependency\n\n if (!client) {\n return null;\n }\n\n if (isLoading) {\n return (\n <div\n data-cimplify-delivery-estimate\n data-serviceable="false"\n aria-busy="true"\n className={cn(className, classNames?.root, classNames?.loading)}\n />\n );\n }\n\n if (error) {\n return (\n <div\n data-cimplify-delivery-estimate\n data-serviceable="false"\n className={cn(className, classNames?.root, classNames?.error)}\n >\n <span>{error.message}</span>\n </div>\n );\n }\n\n if (!result || !result.serviceable) {\n return (\n <div\n data-cimplify-delivery-estimate\n data-serviceable="false"\n className={cn(className, classNames?.root, classNames?.notServiceable)}\n >\n <span>Not serviceable</span>\n </div>\n );\n }\n\n return (\n <div\n data-cimplify-delivery-estimate\n data-serviceable="true"\n className={cn(className, classNames?.root)}\n >\n {result.fee != null && (\n <span data-cimplify-delivery-fee className={classNames?.fee}>\n <Price amount={result.fee} />\n </span>\n )}\n {result.currency && (\n <span data-cimplify-delivery-currency className={classNames?.currency}>\n {result.currency}\n </span>\n )}\n </div>\n );\n}\n' }] }, "retail-product-card": { "name": "retail-product-card", "title": "RetailProductCard", "description": "Product card for retail with color swatches, sale badge, wishlist, and sold-out overlay.", "type": "component", "registryDependencies": ["price", "cn"], "files": [{ "path": "cards/retail-product-card.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { Product, ProductWithDetails } from "@cimplify/sdk";\nimport type { CardLayoutProps } from "@cimplify/sdk/react";\nimport { CardShell, CardImage, WishlistButton, SoldOutOverlay, SaleBadge, LowStockBadge } from "@cimplify/sdk/react";\nimport { Price } from "@cimplify/sdk/react";\nimport { parsePrice, isOnSale, getBasePrice, getDiscountPercentage } from "@cimplify/sdk";\nimport { cn } from "@cimplify/sdk/react";\n\nfunction getColorSwatches(product: Product): string[] {\n const variants = (product as ProductWithDetails).variants;\n if (!variants || variants.length === 0) return [];\n\n const seen = new Set<string>();\n for (const v of variants) {\n if (v.display_attributes) {\n for (const attr of v.display_attributes) {\n if (attr.axis_name.toLowerCase() === "color" && !seen.has(attr.value_name)) {\n seen.add(attr.value_name);\n }\n }\n }\n }\n\n return Array.from(seen).slice(0, 5);\n}\n\nexport function RetailProductCard({\n product,\n renderImage,\n renderLink,\n className,\n}: CardLayoutProps): React.ReactElement {\n const image = product.image_url || product.images?.[0] || "";\n const outOfStock = product.inventory_status?.in_stock === false;\n const onSale = isOnSale(product);\n const swatches = getColorSwatches(product);\n\n return (\n <CardShell product={product} renderLink={renderLink} disabled={outOfStock} className={className}>\n {outOfStock && <SoldOutOverlay />}\n\n <CardImage src={image} alt={product.name} aspectRatio="3/4" renderImage={renderImage}>\n {/* Sale badge */}\n {onSale && (\n <div className="absolute top-3 left-3">\n <SaleBadge product={product} />\n </div>\n )}\n\n {/* Wishlist */}\n <WishlistButton className="absolute top-3 right-3" />\n\n {/* Low stock */}\n {product.inventory_status?.low_stock && product.inventory_status?.stock_level && (\n <span className="absolute bottom-3 left-3 text-[11px] font-semibold bg-amber-50 text-amber-700 border border-amber-200/60 px-2 py-0.5 rounded-md">\n Only {product.inventory_status.stock_level} left\n </span>\n )}\n </CardImage>\n\n <div className="p-3.5">\n {/* Brand */}\n {product.vendor && (\n <p className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">\n {product.vendor}\n </p>\n )}\n\n {/* Name */}\n <h3 className="text-[14.5px] font-semibold text-foreground leading-snug tracking-tight mt-1 truncate">\n {product.name}\n </h3>\n\n {/* Price */}\n <div className="flex items-center gap-2 mt-2">\n <Price amount={product.default_price} className="text-sm font-bold" />\n {onSale && (\n <Price amount={getBasePrice(product)} className="text-xs text-muted-foreground line-through" />\n )}\n </div>\n\n {/* Color swatches */}\n {swatches.length > 0 && (\n <div className="flex gap-1.5 mt-2.5">\n {swatches.map((name) => (\n <span\n key={name}\n title={name}\n className="text-[9px] font-medium text-muted-foreground px-1.5 py-0.5 bg-muted rounded"\n >\n {name}\n </span>\n ))}\n </div>\n )}\n </div>\n </CardShell>\n );\n}\n' }] }, "collection-page": { "name": "collection-page", "title": "CollectionPage", "description": "Curated product collection with header and grid.", "type": "component", "registryDependencies": ["product-grid", "cn"], "files": [{ "path": "collection-page.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { Product, Collection } from "@cimplify/sdk";\nimport { useCollection } from "@cimplify/sdk/react";\nimport type { CollectionLayoutProps } from "./collection-layouts/shared";\nimport { DefaultCollectionLayout } from "./collection-layouts/default-collection-layout";\nimport { FeaturedCollectionLayout } from "./collection-layouts/featured-collection-layout";\nimport { CatalogueCollectionLayout } from "./collection-layouts/catalogue-collection-layout";\nimport { cn } from "@cimplify/sdk/react";\n\nexport type { CollectionLayoutProps };\n\nexport enum CollectionTemplate {\n Default = "default",\n Featured = "featured",\n Catalogue = "catalogue",\n}\n\nexport interface CollectionPageClassNames {\n root?: string;\n loading?: string;\n}\n\nexport interface CollectionPageProps {\n /** Collection slug or ID. */\n collectionId?: string;\n /** Pre-fetched collection for SSR. */\n collection?: Collection;\n /** Pre-fetched products for SSR. */\n products?: Product[];\n /** Per-slug page map. Highest priority \u2014 maps a collection slug to a custom layout. */\n pages?: Record<string, React.ComponentType<CollectionLayoutProps>>;\n /** Per-type template map. Overrides built-in layouts. */\n templates?: Partial<Record<CollectionTemplate | string, React.ComponentType<CollectionLayoutProps>>>;\n /** Custom card renderer. */\n renderCard?: (product: Product) => React.ReactNode;\n /** Custom image renderer. */\n renderImage?: (props: { src: string; alt: string; className?: string }) => React.ReactNode;\n /** Custom link renderer. */\n renderLink?: (props: { href: string; className?: string; children: React.ReactNode }) => React.ReactElement;\n /** Quick add handler for cards. */\n onQuickAdd?: (product: Product) => void;\n className?: string;\n classNames?: CollectionPageClassNames;\n}\n\nconst BUILT_IN_LAYOUTS: Record<string, React.ComponentType<CollectionLayoutProps>> = {\n [CollectionTemplate.Default]: DefaultCollectionLayout,\n [CollectionTemplate.Featured]: FeaturedCollectionLayout,\n [CollectionTemplate.Catalogue]: CatalogueCollectionLayout,\n};\n\nconst LARGE_COLLECTION_THRESHOLD = 30;\n\nfunction resolveTemplateKey(collection: Collection, productCount: number): CollectionTemplate | string {\n const metaTemplate = collection.metadata?.page_template;\n if (typeof metaTemplate === "string" && metaTemplate.trim()) {\n return metaTemplate.trim();\n }\n\n if (collection.tags?.includes("featured") || collection.metadata?.is_featured === true) {\n return CollectionTemplate.Featured;\n }\n\n if (productCount > LARGE_COLLECTION_THRESHOLD) {\n return CollectionTemplate.Catalogue;\n }\n\n return CollectionTemplate.Default;\n}\n\nfunction resolveLayout(\n collection: Collection,\n productCount: number,\n pages?: Record<string, React.ComponentType<CollectionLayoutProps>>,\n templates?: Partial<Record<string, React.ComponentType<CollectionLayoutProps>>>,\n): React.ComponentType<CollectionLayoutProps> {\n if (pages?.[collection.slug]) {\n return pages[collection.slug];\n }\n\n const key = resolveTemplateKey(collection, productCount);\n\n if (templates?.[key]) {\n return templates[key]!;\n }\n\n if (BUILT_IN_LAYOUTS[key]) {\n return BUILT_IN_LAYOUTS[key];\n }\n\n return DefaultCollectionLayout;\n}\n\nexport function CollectionPage({\n collectionId,\n collection: collectionProp,\n products: productsProp,\n pages,\n templates,\n renderCard,\n renderImage,\n renderLink,\n onQuickAdd,\n className,\n classNames,\n}: CollectionPageProps): React.ReactElement {\n const resolvedId = collectionId || collectionProp?.slug || collectionProp?.id || "";\n const {\n collection: fetchedCollection,\n products: fetchedProducts,\n isLoading,\n } = useCollection(collectionProp ? null : resolvedId, {\n enabled: !collectionProp && resolvedId.length > 0,\n });\n\n const collection = collectionProp ?? fetchedCollection;\n const products = productsProp ?? fetchedProducts;\n\n if (isLoading && !collection) {\n return (\n <div\n data-cimplify-collection-page\n aria-busy="true"\n className={cn(className, classNames?.root, classNames?.loading)}\n >\n <div className="animate-pulse space-y-6">\n <div className="h-64 lg:h-80 bg-muted rounded-2xl" />\n <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">\n {Array.from({ length: 8 }).map((_, i) => (\n <div key={i}>\n <div className="aspect-square bg-muted rounded-[14px]" />\n <div className="mt-3 space-y-2">\n <div className="h-4 w-3/4 bg-muted rounded" />\n <div className="h-3 w-1/2 bg-muted rounded" />\n </div>\n </div>\n ))}\n </div>\n </div>\n </div>\n );\n }\n\n if (!collection) {\n return (\n <div data-cimplify-collection-page className={cn(className, classNames?.root)}>\n <p className="text-muted-foreground">Collection not found.</p>\n </div>\n );\n }\n\n const Layout = resolveLayout(collection, products.length, pages, templates);\n\n return (\n <div data-cimplify-collection-page className={cn(className, classNames?.root)}>\n <Layout\n collection={collection}\n products={products}\n renderCard={renderCard}\n renderImage={renderImage}\n renderLink={renderLink}\n onQuickAdd={onQuickAdd}\n />\n </div>\n );\n}\n' }] }, "deals-page": { "name": "deals-page", "title": "DealsPage", "description": "Promotions landing page with deal banners and on-sale products.", "type": "component", "registryDependencies": ["deal-banner", "product-grid", "sale-badge", "product-card", "cn"], "files": [{ "path": "deals-page.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { Product, Deal, ProductDealInfo } from "@cimplify/sdk";\nimport { useDeals, useProductsOnSale } from "@cimplify/sdk/react";\nimport { DealBanner } from "@cimplify/sdk/react";\nimport { ProductGrid } from "./product-grid";\nimport { SaleBadge } from "./sale-badge";\nimport { ProductCard } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface DealsPageClassNames {\n root?: string;\n header?: string;\n title?: string;\n bannerSection?: string;\n productsSection?: string;\n productsTitle?: string;\n empty?: string;\n loading?: string;\n}\n\nexport interface DealsPageProps {\n /** Page title. */\n title?: string;\n /** Pre-fetched deals for SSR. */\n deals?: Deal[];\n /** Pre-fetched products on sale for SSR. */\n productsOnSale?: ProductDealInfo[];\n /** Pre-fetched product objects for rendering. */\n products?: Product[];\n /** Location ID for location-specific deals. */\n locationId?: string;\n /** Custom image renderer. */\n renderImage?: (props: { src: string; alt: string; className?: string }) => React.ReactNode;\n /** Grid column config. */\n columns?: { sm?: number; md?: number; lg?: number; xl?: number };\n className?: string;\n classNames?: DealsPageClassNames;\n}\n\n/**\n * DealsPage \u2014 promotions landing page with deal banners and on-sale products.\n *\n * SSR-friendly: pass `deals` and `productsOnSale` props for server rendering.\n */\nexport function DealsPage({\n title = "Deals & Offers",\n deals: dealsProp,\n productsOnSale: productsOnSaleProp,\n products: productsProp,\n locationId,\n renderImage,\n columns,\n className,\n classNames,\n}: DealsPageProps): React.ReactElement {\n const { deals: fetchedDeals, isLoading: dealsLoading } = useDeals({\n locationId,\n enabled: dealsProp === undefined,\n });\n const { products: fetchedOnSale, isLoading: productsLoading } = useProductsOnSale({\n enabled: productsOnSaleProp === undefined,\n });\n\n const deals = dealsProp ?? fetchedDeals;\n const onSale = productsOnSaleProp ?? fetchedOnSale;\n const isLoading = dealsLoading || productsLoading;\n\n const isEmpty = deals.length === 0 && onSale.length === 0;\n\n if (isLoading && isEmpty) {\n return (\n <div\n data-cimplify-deals-page\n aria-busy="true"\n className={cn(className, classNames?.root, classNames?.loading)}\n />\n );\n }\n\n return (\n <div data-cimplify-deals-page className={cn(className, classNames?.root)}>\n {/* Header */}\n <div data-cimplify-deals-header className={classNames?.header}>\n <h1 data-cimplify-deals-title className={classNames?.title}>\n {title}\n </h1>\n </div>\n\n {/* Deal banners */}\n {deals.length > 0 && (\n <div data-cimplify-deals-banner-section className={classNames?.bannerSection}>\n <DealBanner deals={deals} />\n </div>\n )}\n\n {/* Products on sale */}\n {(onSale.length > 0 || (productsProp && productsProp.length > 0)) && (\n <div data-cimplify-deals-products className={classNames?.productsSection}>\n <h2 data-cimplify-deals-products-title className={classNames?.productsTitle}>\n On Sale Now\n </h2>\n {productsProp ? (\n <ProductGrid\n products={productsProp}\n renderImage={renderImage}\n columns={columns}\n />\n ) : (\n <div data-cimplify-deals-sale-list>\n {onSale.map((dealInfo: ProductDealInfo) => (\n <div key={`${dealInfo.product_id}-${dealInfo.deal_id}`} data-cimplify-deals-sale-item>\n <SaleBadge\n product={{ id: dealInfo.product_id, name: dealInfo.deal_name } as Product}\n dealInfo={dealInfo}\n showOriginalPrice\n />\n </div>\n ))}\n </div>\n )}\n </div>\n )}\n\n {/* Empty state */}\n {isEmpty && (\n <div data-cimplify-deals-empty className={classNames?.empty}>\n <p>No deals available right now. Check back soon!</p>\n </div>\n )}\n </div>\n );\n}\n' }] }, "wholesale-product-layout": { "name": "wholesale-product-layout", "title": "WholesaleProductLayout", "description": "B2B wholesale layout with price range, volume pricing, MOQ, and inventory.", "type": "component", "registryDependencies": ["product-image-gallery", "product-customizer", "price", "price-range", "volume-pricing", "cn"], "files": [{ "path": "layouts/wholesale-product-layout.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { ProductLayoutProps } from "@cimplify/sdk/react";\nimport {\n TwoColumnGrid,\n RelatedProductsSection,\n CustomAttributesTable,\n PropertiesTable,\n TagPills,\n InventoryBadge,\n} from "@cimplify/sdk/react";\nimport { ProductImageGallery } from "@cimplify/sdk/react";\nimport { ProductCustomizer } from "@cimplify/sdk/react";\nimport { PriceRange } from "@cimplify/sdk/react";\nimport { Price } from "@cimplify/sdk/react";\nimport { VolumePricing } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport function WholesaleProductLayout({\n product,\n onAddToCart,\n renderBreadcrumb,\n relatedProducts,\n showRelated = true,\n className,\n}: ProductLayoutProps): React.ReactElement {\n const images = product.images || (product.image_url ? [product.image_url] : []);\n const hasTiers = product.quantity_pricing && product.quantity_pricing.length > 1;\n\n return (\n <div data-cimplify-product-layout="wholesale" className={cn("space-y-8", className)}>\n {renderBreadcrumb?.(product)}\n\n <TwoColumnGrid\n left={\n <ProductImageGallery\n images={images}\n productName={product.name}\n />\n }\n right={\n <div className="space-y-6">\n {/* Tags */}\n <TagPills tags={product.tags || []} />\n\n {/* SKU + Name */}\n <div>\n {product.sku && (\n <p className="text-xs text-muted-foreground font-medium uppercase tracking-wider mb-1">\n SKU: {product.sku}\n </p>\n )}\n <h1 data-cimplify-product-layout-name className="text-3xl lg:text-4xl font-extrabold tracking-tight">\n {product.name}\n </h1>\n </div>\n\n {/* Price range */}\n <div>\n {hasTiers ? (\n <PriceRange product={product} className="text-2xl font-extrabold" />\n ) : (\n <Price amount={product.default_price} className="text-2xl font-semibold" />\n )}\n </div>\n\n {/* Inventory */}\n <InventoryBadge product={product} />\n\n {/* Min order notice */}\n {product.min_order_quantity != null && product.min_order_quantity > 1 && (\n <div data-cimplify-product-layout-moq className="flex items-center gap-3 p-3 bg-amber-50 border border-amber-200 text-sm text-amber-800">\n Minimum order quantity: <strong>{product.min_order_quantity} units</strong>\n </div>\n )}\n\n {/* Description */}\n {product.description && (\n <p data-cimplify-product-layout-description className="text-muted-foreground leading-relaxed">\n {product.description}\n </p>\n )}\n\n {/* Volume pricing \u2014 open by default for wholesale */}\n {hasTiers && (\n <VolumePricing\n tiers={product.quantity_pricing!}\n defaultOpen\n />\n )}\n\n {/* Product details */}\n {(product.sku || product.barcode || product.vendor) && (\n <div data-cimplify-product-layout-details className="space-y-1 text-sm text-muted-foreground">\n {product.barcode && <p>Barcode: <span className="font-mono text-foreground">{product.barcode}</span></p>}\n {product.vendor && <p>Supplier: <span className="text-foreground">{product.vendor}</span></p>}\n {product.hs_code && <p>HS Code: <span className="font-mono text-foreground">{product.hs_code}</span></p>}\n </div>\n )}\n\n {/* Custom Attributes */}\n <CustomAttributesTable attributes={product.custom_attributes || []} />\n\n {/* Properties */}\n <PropertiesTable properties={product.properties || []} />\n\n {/* Customizer (variants, billing plans, quantity, add to cart) */}\n <ProductCustomizer\n product={product}\n onAddToCart={onAddToCart}\n />\n </div>\n }\n />\n\n {showRelated && relatedProducts && relatedProducts.length > 0 && (\n <RelatedProductsSection\n products={relatedProducts}\n title="Related products"\n />\n )}\n </div>\n );\n}\n' }] }, "category-filter": { "name": "category-filter", "title": "CategoryFilter", "description": "Selectable category chips for filtering products.", "type": "component", "registryDependencies": ["cn"], "files": [{ "path": "category-filter.tsx", "content": '"use client";\n\nimport React, { useCallback } from "react";\nimport { Tabs } from "@base-ui/react/tabs";\nimport type { Category } from "@cimplify/sdk";\nimport { useCategories } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface CategoryFilterClassNames {\n root?: string;\n item?: string;\n allButton?: string;\n count?: string;\n}\n\nexport interface CategoryFilterProps {\n /** Currently selected category ID. Null means "all". */\n selectedId?: string | null;\n /** Called when a category is selected. Null means "all". */\n onSelect: (categoryId: string | null) => void;\n /** Label for the "all" option. Default: "All". */\n allLabel?: string;\n /** Show product counts per category. Default: true. */\n showCounts?: boolean;\n className?: string;\n classNames?: CategoryFilterClassNames;\n}\n\n/** Sentinel value representing the "all" tab (no category filter). */\nconst ALL_VALUE = "__all__";\n\n/**\n * CategoryFilter \u2014 horizontal or vertical list of category chips.\n *\n * Fetches categories via `useCategories` and renders selectable buttons.\n * The parent controls selection state via `selectedId` + `onSelect`.\n *\n * Built on Base UI Tabs for accessible keyboard navigation and ARIA roles.\n */\nexport function CategoryFilter({\n selectedId = null,\n onSelect,\n allLabel = "All",\n showCounts = true,\n className,\n classNames,\n}: CategoryFilterProps): React.ReactElement {\n const { categories, isLoading } = useCategories();\n\n const handleValueChange = useCallback(\n (value: string | number | null) => {\n onSelect(value === ALL_VALUE ? null : String(value));\n },\n [onSelect],\n );\n\n if (isLoading) {\n return (\n <div\n data-cimplify-category-filter\n aria-busy="true"\n className={cn(className, classNames?.root)}\n />\n );\n }\n\n return (\n <Tabs.Root\n value={selectedId ?? ALL_VALUE}\n onValueChange={handleValueChange}\n >\n <Tabs.List\n data-cimplify-category-filter\n aria-label="Filter by category"\n className={cn(className, classNames?.root)}\n >\n <Tabs.Tab\n value={ALL_VALUE}\n data-cimplify-category-filter-item\n data-selected={selectedId === null || undefined}\n className={cn(classNames?.item, classNames?.allButton)}\n >\n {allLabel}\n </Tabs.Tab>\n\n {categories.map((category: Category) => (\n <Tabs.Tab\n key={category.id}\n value={category.id}\n data-cimplify-category-filter-item\n data-selected={selectedId === category.id || undefined}\n className={classNames?.item}\n >\n {category.name}\n {showCounts && category.product_count != null && (\n <span data-cimplify-category-count className={classNames?.count}>\n {category.product_count}\n </span>\n )}\n </Tabs.Tab>\n ))}\n </Tabs.List>\n </Tabs.Root>\n );\n}\n' }] }, "add-on-selector": { "name": "add-on-selector", "title": "AddOnSelector", "description": "Modifier groups with single-select or multi-select options.", "type": "component", "registryDependencies": ["price"], "files": [{ "path": "add-on-selector.tsx", "content": '"use client";\n\nimport React, { useCallback, useMemo } from "react";\nimport { Checkbox } from "@base-ui/react/checkbox";\nimport type { AddOnWithOptions } from "@cimplify/sdk";\nimport { Price } from "@cimplify/sdk/react";\nimport { parsePrice } from "@cimplify/sdk";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface AddOnSelectorClassNames {\n root?: string;\n group?: string;\n header?: string;\n name?: string;\n required?: string;\n constraint?: string;\n validation?: string;\n options?: string;\n option?: string;\n optionSelected?: string;\n optionName?: string;\n optionDescription?: string;\n}\n\nexport interface AddOnSelectorProps {\n addOns: AddOnWithOptions[];\n selectedOptions: string[];\n onOptionsChange: (optionIds: string[]) => void;\n className?: string;\n classNames?: AddOnSelectorClassNames;\n}\n\nexport function AddOnSelector({\n addOns,\n selectedOptions,\n onOptionsChange,\n className,\n classNames,\n}: AddOnSelectorProps): React.ReactElement | null {\n const selectedSet = useMemo(() => new Set(selectedOptions), [selectedOptions]);\n\n const isOptionSelected = useCallback(\n (optionId: string) => selectedSet.has(optionId),\n [selectedSet],\n );\n\n const handleCheckedChange = useCallback(\n (addOn: AddOnWithOptions, optionId: string, checked: boolean) => {\n const isSelected = selectedSet.has(optionId);\n\n if (addOn.is_mutually_exclusive || !addOn.is_multiple_allowed) {\n const groupOptionIds = new Set(addOn.options.map((o) => o.id));\n const withoutGroup = selectedOptions.filter((id) => !groupOptionIds.has(id));\n\n if (isSelected) {\n if (!addOn.is_required) {\n onOptionsChange(withoutGroup);\n }\n } else {\n onOptionsChange([...withoutGroup, optionId]);\n }\n } else {\n if (isSelected) {\n onOptionsChange(selectedOptions.filter((id) => id !== optionId));\n } else {\n let currentCount = 0;\n for (const option of addOn.options) {\n if (selectedSet.has(option.id)) {\n currentCount += 1;\n }\n }\n\n if (addOn.max_selections && currentCount >= addOn.max_selections) {\n return;\n }\n\n onOptionsChange([...selectedOptions, optionId]);\n }\n }\n },\n [selectedOptions, selectedSet, onOptionsChange],\n );\n\n if (!addOns || addOns.length === 0) {\n return null;\n }\n\n return (\n <div data-cimplify-addon-selector className={cn("space-y-6", className, classNames?.root)}>\n {addOns.map((addOn) => {\n let currentSelections = 0;\n for (const option of addOn.options) {\n if (selectedSet.has(option.id)) {\n currentSelections += 1;\n }\n }\n const minMet = !addOn.min_selections || currentSelections >= addOn.min_selections;\n const isSingleSelect = addOn.is_mutually_exclusive || !addOn.is_multiple_allowed;\n\n return (\n <div\n key={addOn.id}\n data-cimplify-addon-group\n className={cn(classNames?.group)}\n >\n <div\n data-cimplify-addon-header\n className={cn("flex items-center justify-between py-3", classNames?.header)}\n >\n <div>\n <span\n data-cimplify-addon-name\n className={cn("text-base font-bold", classNames?.name)}\n >\n {addOn.name}\n </span>\n {(addOn.min_selections || addOn.max_selections) && (\n <span\n data-cimplify-addon-constraint\n className={cn("block text-xs text-muted-foreground mt-0.5", classNames?.constraint)}\n >\n {addOn.min_selections && addOn.max_selections\n ? `Choose ${addOn.min_selections}\\u2013${addOn.max_selections}`\n : addOn.min_selections\n ? `Choose at least ${addOn.min_selections}`\n : `Choose up to ${addOn.max_selections}`}\n </span>\n )}\n </div>\n {(addOn.is_required || !minMet) && (\n <span\n data-cimplify-addon-required\n className={cn(\n "text-xs font-semibold px-2.5 py-1 rounded",\n !minMet\n ? "text-destructive bg-destructive/10"\n : "text-destructive bg-destructive/10",\n classNames?.required,\n )}\n >\n Required\n </span>\n )}\n </div>\n\n <div\n data-cimplify-addon-options\n className={cn("divide-y divide-border", classNames?.options)}\n >\n {addOn.options.map((option) => {\n const isSelected = isOptionSelected(option.id);\n\n return (\n <Checkbox.Root\n key={option.id}\n checked={isSelected}\n onCheckedChange={(checked) =>\n handleCheckedChange(addOn, option.id, checked)\n }\n value={option.id}\n data-cimplify-addon-option\n data-selected={isSelected || undefined}\n className={cn(\n "w-full flex items-center gap-3 py-4 transition-colors text-left cursor-pointer",\n isSelected ? classNames?.optionSelected : classNames?.option,\n )}\n >\n <Checkbox.Indicator\n className="hidden"\n keepMounted={false}\n />\n\n {/* Visual indicator: radio circle for single-select, checkbox square for multi-select */}\n {isSingleSelect ? (\n <span\n data-cimplify-addon-radio\n className={cn(\n "w-5 h-5 rounded-full border-2 flex items-center justify-center shrink-0 transition-colors",\n isSelected ? "border-primary" : "border-muted-foreground/30",\n )}\n >\n {isSelected && <span className="w-2.5 h-2.5 rounded-full bg-primary" />}\n </span>\n ) : (\n <span\n data-cimplify-addon-checkbox\n className={cn(\n "w-5 h-5 rounded-sm border-2 flex items-center justify-center shrink-0 transition-colors",\n isSelected ? "border-primary bg-primary" : "border-muted-foreground/30",\n )}\n >\n {isSelected && (\n <svg viewBox="0 0 12 12" className="w-3 h-3 text-primary-foreground" fill="none">\n <path d="M2 6l3 3 5-5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>\n </svg>\n )}\n </span>\n )}\n\n <span\n data-cimplify-addon-option-name\n className={cn(\n "flex-1 min-w-0 text-sm",\n classNames?.optionName,\n )}\n >\n {option.name}\n </span>\n\n {option.default_price != null && (\n <span className="text-sm text-muted-foreground">\n +<Price amount={option.default_price} />\n </span>\n )}\n </Checkbox.Root>\n );\n })}\n </div>\n </div>\n );\n })}\n </div>\n );\n}\n' }] }, "cart-page": { "name": "cart-page", "title": "CartPage", "description": "Full-page cart with summary, discount input, and checkout.", "type": "component", "registryDependencies": ["cart-summary", "discount-input", "cn"], "files": [{ "path": "cart-page.tsx", "content": '"use client";\n\nimport React from "react";\nimport { useCart } from "@cimplify/sdk/react";\nimport type { DiscountValidation } from "@cimplify/sdk";\nimport type { CartLayoutProps } from "./cart-layouts/shared";\nimport { DefaultCartLayout } from "./cart-layouts/default-cart-layout";\nimport { CompactCartLayout } from "./cart-layouts/compact-cart-layout";\nimport { cn } from "@cimplify/sdk/react";\n\nexport type { CartLayoutProps };\n\nexport enum CartTemplate {\n Default = "default",\n Compact = "compact",\n}\n\nexport interface CartPageClassNames {\n root?: string;\n}\n\nexport interface CartPageProps {\n /** Explicit template. */\n template?: CartTemplate;\n /** Layout map for overrides. */\n layouts?: Partial<Record<CartTemplate | string, React.ComponentType<CartLayoutProps>>>;\n /** Page title. */\n title?: string;\n /** Called when checkout is initiated. */\n onCheckout?: () => void;\n /** Called when "continue shopping" is clicked. */\n onContinueShopping?: () => void;\n /** Called when a valid discount is applied. */\n onDiscountApply?: (validation: DiscountValidation) => void;\n /** Called when discount is removed. */\n onDiscountClear?: () => void;\n /** Show discount code input. */\n showDiscount?: boolean;\n /** Checkout button text. */\n checkoutLabel?: string;\n className?: string;\n classNames?: CartPageClassNames;\n}\n\nconst BUILT_IN_LAYOUTS: Record<string, React.ComponentType<CartLayoutProps>> = {\n [CartTemplate.Default]: DefaultCartLayout,\n [CartTemplate.Compact]: CompactCartLayout,\n};\n\nexport function CartPage({\n template,\n layouts,\n onCheckout,\n onContinueShopping,\n onDiscountApply,\n onDiscountClear,\n showDiscount = true,\n checkoutLabel,\n className,\n classNames,\n}: CartPageProps): React.ReactElement {\n const cart = useCart();\n\n const key = template ?? CartTemplate.Default;\n const Layout = layouts?.[key] ?? BUILT_IN_LAYOUTS[key] ?? DefaultCartLayout;\n\n return (\n <div data-cimplify-cart-page className={cn(className, classNames?.root)}>\n <Layout\n cart={cart}\n onCheckout={onCheckout}\n onContinueShopping={onContinueShopping}\n onDiscountApply={onDiscountApply}\n onDiscountClear={onDiscountClear}\n showDiscount={showDiscount}\n checkoutLabel={checkoutLabel}\n />\n </div>\n );\n}\n' }] }, "chat-widget": { "name": "chat-widget", "title": "ChatWidget", "description": "Embeddable chat widget with AI shopping assistant powered by the support channel.", "type": "component", "registryDependencies": ["cn"], "files": [{ "path": "chat-widget.tsx", "content": '"use client";\n\nimport React, {\n useState,\n useRef,\n useEffect,\n useCallback,\n type KeyboardEvent,\n} from "react";\nimport type { CimplifyClient } from "../client";\nimport type { CimplifyError } from "@cimplify/sdk";\nimport type { ChatMessage, ChatWidgetStarter } from "../types/support";\nimport { useChat } from "./hooks/use-chat";\nimport { useOptionalCimplifyClient } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\n/* \u2500\u2500\u2500 Public API \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\nexport interface ChatWidgetClassNames {\n root?: string;\n bubble?: string;\n panel?: string;\n header?: string;\n messages?: string;\n input?: string;\n welcome?: string;\n}\n\nexport interface ChatWidgetProps {\n client?: CimplifyClient;\n /** Business display name shown in the header */\n businessName?: string;\n /** Greeting text on the welcome screen */\n greeting?: string;\n /** Subtitle on the welcome screen */\n subtitle?: string;\n /** Conversation starters */\n starters?: ChatWidgetStarter[];\n /** Input placeholder */\n placeholder?: string;\n /** Position of the bubble */\n position?: "bottom-right" | "bottom-left";\n /** Start with the panel open */\n defaultOpen?: boolean;\n /** Poll interval in ms (default: 3000) */\n pollInterval?: number;\n className?: string;\n classNames?: ChatWidgetClassNames;\n}\n\nexport function ChatWidget({\n client: clientProp,\n businessName = "Support",\n greeting = "Hi there!",\n subtitle = "Ask us anything \u2014 we typically reply in a few seconds.",\n starters,\n placeholder = "Type a message\\u2026",\n position = "bottom-right",\n defaultOpen = false,\n pollInterval,\n className,\n classNames,\n}: ChatWidgetProps) {\n const context = useOptionalCimplifyClient();\n const client = clientProp ?? context?.client;\n\n const [open, setOpen] = useState(defaultOpen);\n const [unread, setUnread] = useState(0);\n\n const { messages, isLoading, isSending, error, isActive, send, startConversation } =\n useChat({ client, starters, pollInterval });\n\n const prevCountRef = useRef(messages.length);\n\n // Track unread when panel is closed\n useEffect(() => {\n if (!open && messages.length > prevCountRef.current) {\n const newMessages = messages.slice(prevCountRef.current);\n const incomingCount = newMessages.filter(\n (m) => m.sender_type !== "customer",\n ).length;\n if (incomingCount > 0) setUnread((n) => n + incomingCount);\n }\n prevCountRef.current = messages.length;\n }, [messages, open]);\n\n const handleOpen = useCallback(() => {\n setOpen(true);\n setUnread(0);\n }, []);\n\n const handleClose = useCallback(() => setOpen(false), []);\n\n const isLeft = position === "bottom-left";\n\n return (\n <div\n className={cn(\n "fixed bottom-6 z-[9999]",\n isLeft ? "left-6" : "right-6",\n className,\n classNames?.root,\n )}\n >\n {/* Bubble */}\n {!open && (\n <Bubble\n unread={unread}\n onClick={handleOpen}\n className={classNames?.bubble}\n />\n )}\n\n {/* Panel */}\n {open && (\n <Panel\n businessName={businessName}\n greeting={greeting}\n subtitle={subtitle}\n starters={starters}\n placeholder={placeholder}\n messages={messages}\n isLoading={isLoading}\n isSending={isSending}\n error={error}\n isActive={isActive}\n isLeft={isLeft}\n onClose={handleClose}\n onSend={send}\n onStartConversation={startConversation}\n classNames={classNames}\n />\n )}\n </div>\n );\n}\n\n/* \u2500\u2500\u2500 Bubble \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\nfunction Bubble({\n unread,\n onClick,\n className,\n}: {\n unread: number;\n onClick: () => void;\n className?: string;\n}) {\n return (\n <button\n type="button"\n onClick={onClick}\n aria-label="Open chat"\n className={cn(\n "relative flex h-14 w-14 items-center justify-center rounded-full",\n "bg-foreground text-background shadow-lg",\n "transition-transform duration-300 ease-out",\n "hover:scale-[1.08] active:scale-95",\n className,\n )}\n >\n <ChatIcon className="h-6 w-6" />\n {unread > 0 && (\n <span\n className={cn(\n "absolute -right-0.5 -top-0.5 flex h-5 w-5 items-center justify-center",\n "rounded-full bg-red-500 text-[10px] font-bold text-white",\n "ring-2 ring-background",\n )}\n >\n {unread > 9 ? "9+" : unread}\n </span>\n )}\n </button>\n );\n}\n\n/* \u2500\u2500\u2500 Panel \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\nfunction Panel({\n businessName,\n greeting,\n subtitle,\n starters,\n placeholder,\n messages,\n isLoading,\n isSending,\n error,\n isActive,\n isLeft,\n onClose,\n onSend,\n onStartConversation,\n classNames,\n}: {\n businessName: string;\n greeting: string;\n subtitle: string;\n starters?: ChatWidgetStarter[];\n placeholder: string;\n messages: ChatMessage[];\n isLoading: boolean;\n isSending: boolean;\n error: CimplifyError | null;\n isActive: boolean;\n isLeft: boolean;\n onClose: () => void;\n onSend: (content: string) => Promise<void>;\n onStartConversation: (text?: string) => Promise<void>;\n classNames?: ChatWidgetClassNames;\n}) {\n return (\n <div\n className={cn(\n "flex flex-col overflow-hidden rounded-2xl bg-background shadow-2xl",\n "w-[400px] max-w-[calc(100vw-3rem)]",\n "h-[min(600px,calc(100vh-6rem))]",\n "animate-in fade-in slide-in-from-bottom-4 duration-300",\n isLeft ? "origin-bottom-left" : "origin-bottom-right",\n classNames?.panel,\n )}\n >\n {/* Header */}\n <Header\n businessName={businessName}\n onClose={onClose}\n className={classNames?.header}\n />\n\n {/* Body */}\n {!isActive && !isLoading ? (\n <Welcome\n greeting={greeting}\n subtitle={subtitle}\n starters={starters}\n onStarter={onStartConversation}\n className={classNames?.welcome}\n />\n ) : (\n <MessageList\n messages={messages}\n isLoading={isLoading}\n isSending={isSending}\n className={classNames?.messages}\n />\n )}\n\n {/* Error */}\n {error != null && (\n <div className="border-t border-red-200 bg-red-50 px-4 py-2 text-xs text-red-600">\n Something went wrong. Please try again.\n </div>\n )}\n\n {/* Input \u2014 shown once conversation is active or loading */}\n {(isActive || isLoading) && (\n <ChatInput\n placeholder={placeholder}\n isSending={isSending}\n onSend={onSend}\n className={classNames?.input}\n />\n )}\n\n {/* Footer */}\n <div className="flex-shrink-0 border-t border-border bg-background px-4 py-1.5 text-center text-[10px] text-muted-foreground">\n Powered by{" "}\n <span className="font-semibold text-foreground">Cimplify</span>\n </div>\n </div>\n );\n}\n\n/* \u2500\u2500\u2500 Header \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\nfunction Header({\n businessName,\n onClose,\n className,\n}: {\n businessName: string;\n onClose: () => void;\n className?: string;\n}) {\n return (\n <div\n className={cn(\n "flex flex-shrink-0 items-center gap-3 bg-foreground px-5 py-4 text-background",\n className,\n )}\n >\n <div className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-[10px] bg-white/15">\n <ChatIcon className="h-5 w-5" />\n </div>\n <div className="min-w-0 flex-1">\n <div className="truncate text-[15px] font-semibold leading-tight">\n {businessName}\n </div>\n <div className="flex items-center gap-1.5 text-xs text-white/60">\n <span className="h-[7px] w-[7px] flex-shrink-0 rounded-full bg-emerald-400" />\n Online\n </div>\n </div>\n <button\n type="button"\n onClick={onClose}\n aria-label="Close chat"\n className={cn(\n "flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg",\n "bg-white/10 text-white/70 transition-colors hover:bg-white/20 hover:text-white",\n )}\n >\n <CloseIcon className="h-[18px] w-[18px]" />\n </button>\n </div>\n );\n}\n\n/* \u2500\u2500\u2500 Welcome \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\nfunction Welcome({\n greeting,\n subtitle,\n starters,\n onStarter,\n className,\n}: {\n greeting: string;\n subtitle: string;\n starters?: ChatWidgetStarter[];\n onStarter: (text?: string) => Promise<void>;\n className?: string;\n}) {\n const defaultStarters: ChatWidgetStarter[] = [\n { icon: "\\ud83d\\udd0d", text: "Help me find a product" },\n { icon: "\\ud83d\\uded2", text: "Check my cart" },\n { icon: "\\ud83d\\udce6", text: "Track my order" },\n ];\n\n const items = starters ?? defaultStarters;\n\n return (\n <div\n className={cn(\n "flex flex-1 flex-col items-center justify-center overflow-y-auto bg-muted/50 px-8 py-10 text-center",\n className,\n )}\n >\n <div className="mb-5 flex h-16 w-16 items-center justify-center rounded-2xl bg-muted">\n <ChatIcon className="h-7 w-7 text-muted-foreground" />\n </div>\n <h3 className="mb-2 text-xl font-bold text-foreground">{greeting}</h3>\n <p className="mb-6 max-w-[280px] text-sm leading-relaxed text-muted-foreground">\n {subtitle}\n </p>\n <div className="flex w-full max-w-[300px] flex-col gap-2">\n {items.map((s) => (\n <button\n key={s.text}\n type="button"\n onClick={() => onStarter(s.text)}\n className={cn(\n "flex items-center gap-3 rounded-xl border border-border bg-background px-4 py-3",\n "text-left text-[13px] font-medium text-foreground",\n "transition-all hover:-translate-y-px hover:border-foreground hover:shadow-sm",\n )}\n >\n {s.icon && (\n <span className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-muted text-[15px]">\n {s.icon}\n </span>\n )}\n <span>{s.text}</span>\n </button>\n ))}\n </div>\n </div>\n );\n}\n\n/* \u2500\u2500\u2500 Messages \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\nfunction MessageList({\n messages,\n isLoading,\n isSending,\n className,\n}: {\n messages: ChatMessage[];\n isLoading: boolean;\n isSending: boolean;\n className?: string;\n}) {\n const bottomRef = useRef<HTMLDivElement>(null);\n\n useEffect(() => {\n bottomRef.current?.scrollIntoView({ behavior: "smooth" });\n }, [messages.length, isSending]);\n\n return (\n <div\n className={cn(\n "flex flex-1 flex-col gap-1 overflow-y-auto bg-muted/50 px-5 py-5",\n className,\n )}\n >\n {isLoading && messages.length === 0 && (\n <div className="flex flex-1 items-center justify-center">\n <TypingDots />\n </div>\n )}\n\n {messages.map((msg, i) => {\n const isCustomer = msg.sender_type === "customer";\n const prev = messages[i - 1];\n const sameSenderAsPrev = prev?.sender_type === msg.sender_type;\n const isOptimistic = msg.id.startsWith("opt_");\n\n return (\n <div key={msg.id}>\n {!sameSenderAsPrev && i > 0 && <div className="h-3" />}\n <div\n className={cn(\n "flex max-w-[85%]",\n isCustomer ? "ml-auto" : "mr-auto",\n )}\n >\n <div\n className={cn(\n "rounded-2xl px-3.5 py-2.5 text-sm leading-relaxed",\n isCustomer\n ? "bg-foreground text-background"\n : "border border-border bg-background text-foreground",\n isCustomer\n ? sameSenderAsPrev\n ? "rounded-tr"\n : "rounded-br"\n : sameSenderAsPrev\n ? "rounded-tl"\n : "rounded-bl",\n isOptimistic && "opacity-70",\n )}\n >\n {!isCustomer && !sameSenderAsPrev && (\n <span className="mb-1 inline-flex items-center gap-1 rounded bg-violet-100 px-1.5 py-0.5 text-[10px] font-semibold text-violet-600">\n <SparkleIcon className="h-[11px] w-[11px]" />\n AI\n </span>\n )}\n <div className="whitespace-pre-wrap">{msg.content}</div>\n </div>\n </div>\n </div>\n );\n })}\n\n {isSending && (\n <div className="mr-auto mt-1">\n <TypingDots />\n </div>\n )}\n\n <div ref={bottomRef} />\n </div>\n );\n}\n\n/* \u2500\u2500\u2500 Input \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\nfunction ChatInput({\n placeholder,\n isSending,\n onSend,\n className,\n}: {\n placeholder: string;\n isSending: boolean;\n onSend: (content: string) => Promise<void>;\n className?: string;\n}) {\n const [value, setValue] = useState("");\n const textareaRef = useRef<HTMLTextAreaElement>(null);\n\n const handleSend = useCallback(() => {\n const trimmed = value.trim();\n if (!trimmed || isSending) return;\n setValue("");\n if (textareaRef.current) {\n textareaRef.current.style.height = "auto";\n }\n onSend(trimmed);\n }, [value, isSending, onSend]);\n\n const handleKeyDown = useCallback(\n (e: KeyboardEvent<HTMLTextAreaElement>) => {\n if (e.key === "Enter" && !e.shiftKey) {\n e.preventDefault();\n handleSend();\n }\n },\n [handleSend],\n );\n\n const handleInput = useCallback(() => {\n const el = textareaRef.current;\n if (!el) return;\n el.style.height = "auto";\n el.style.height = `${Math.min(el.scrollHeight, 100)}px`;\n }, []);\n\n const hasContent = value.trim().length > 0;\n\n return (\n <div\n className={cn(\n "flex flex-shrink-0 items-end gap-2 border-t border-border bg-background px-4 py-3",\n className,\n )}\n >\n <div\n className={cn(\n "flex flex-1 items-end rounded-xl border-[1.5px] border-transparent bg-muted px-1",\n "transition-colors focus-within:border-foreground focus-within:bg-background",\n )}\n >\n <textarea\n ref={textareaRef}\n value={value}\n onChange={(e) => setValue(e.target.value)}\n onKeyDown={handleKeyDown}\n onInput={handleInput}\n placeholder={placeholder}\n rows={1}\n className={cn(\n "max-h-[100px] min-h-[20px] flex-1 resize-none bg-transparent px-2.5 py-2",\n "text-sm text-foreground outline-none placeholder:text-muted-foreground",\n )}\n />\n </div>\n\n <button\n type="button"\n onClick={handleSend}\n disabled={!hasContent || isSending}\n aria-label="Send message"\n className={cn(\n "flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-[10px]",\n "transition-all duration-200",\n hasContent && !isSending\n ? "bg-foreground text-background hover:scale-105 active:scale-95"\n : "bg-muted text-muted-foreground",\n )}\n >\n <SendIcon className="h-[18px] w-[18px]" />\n </button>\n </div>\n );\n}\n\n/* \u2500\u2500\u2500 Typing dots \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\nfunction TypingDots() {\n return (\n <div className="flex items-center gap-1 px-4 py-3">\n {[0, 1, 2].map((i) => (\n <span\n key={i}\n className="h-[7px] w-[7px] animate-bounce rounded-full bg-muted-foreground/40"\n style={{ animationDelay: `${i * 160}ms` }}\n />\n ))}\n </div>\n );\n}\n\n/* \u2500\u2500\u2500 Icons (inline SVG \u2014 no icon library) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\nfunction ChatIcon({ className }: { className?: string }) {\n return (\n <svg\n className={className}\n viewBox="0 0 24 24"\n fill="none"\n stroke="currentColor"\n strokeWidth={2}\n strokeLinecap="round"\n strokeLinejoin="round"\n >\n <path d="M21 11.5a8.38 8.38 0 01-.9 3.8 8.5 8.5 0 01-7.6 4.7 8.38 8.38 0 01-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 01-.9-3.8 8.5 8.5 0 014.7-7.6 8.38 8.38 0 013.8-.9h.5a8.48 8.48 0 018 8v.5z" />\n </svg>\n );\n}\n\nfunction CloseIcon({ className }: { className?: string }) {\n return (\n <svg\n className={className}\n viewBox="0 0 24 24"\n fill="none"\n stroke="currentColor"\n strokeWidth={2}\n strokeLinecap="round"\n strokeLinejoin="round"\n >\n <path d="M18 6L6 18M6 6l12 12" />\n </svg>\n );\n}\n\nfunction SendIcon({ className }: { className?: string }) {\n return (\n <svg\n className={className}\n viewBox="0 0 24 24"\n fill="none"\n stroke="currentColor"\n strokeWidth={2}\n strokeLinecap="round"\n strokeLinejoin="round"\n >\n <line x1="22" y1="2" x2="11" y2="13" />\n <polygon points="22 2 15 22 11 13 2 9 22 2" />\n </svg>\n );\n}\n\nfunction SparkleIcon({ className }: { className?: string }) {\n return (\n <svg\n className={className}\n viewBox="0 0 24 24"\n fill="none"\n stroke="currentColor"\n strokeWidth={2.5}\n >\n <path d="M12 2v4m0 12v4M4.93 4.93l2.83 2.83m8.48 8.48l2.83 2.83M2 12h4m12 0h4M4.93 19.07l2.83-2.83m8.48-8.48l2.83-2.83" />\n </svg>\n );\n}\n' }] }, "product-customizer": { "name": "product-customizer", "title": "ProductCustomizer", "description": "Full product configuration with variants, add-ons, and add-to-cart.", "type": "component", "registryDependencies": ["price", "quantity-selector", "variant-selector", "add-on-selector", "composite-selector", "bundle-selector"], "files": [{ "path": "product-customizer.tsx", "content": '"use client";\n\nimport React, { useState, useCallback, useMemo, useEffect, useRef } from "react";\nimport { Button } from "@base-ui/react/button";\nimport type {\n ProductWithDetails,\n VariantView,\n AddOnOption,\n ComponentSelectionInput,\n CompositePriceResult,\n} from "@cimplify/sdk";\nimport type { ProductBillingPlan } from "@cimplify/sdk";\nimport type { BundleSelectionInput } from "@cimplify/sdk";\nimport { parsePrice, getUnitPriceAtQuantity } from "@cimplify/sdk";\nimport { formatDuration } from "./utils/format-duration";\nimport { useCart, useQuote } from "@cimplify/sdk/react";\nimport type { AddToCartOptions } from "@cimplify/sdk/react";\nimport { Price } from "@cimplify/sdk/react";\nimport { QuantitySelector } from "@cimplify/sdk/react";\nimport { VariantSelector } from "@cimplify/sdk/react";\nimport { AddOnSelector } from "@cimplify/sdk/react";\nimport { CompositeSelector } from "@cimplify/sdk/react";\nimport { BundleSelector } from "@cimplify/sdk/react";\nimport { BillingPlanSelector } from "@cimplify/sdk/react";\nimport { VolumePricing } from "@cimplify/sdk/react";\nimport { CustomerInputFields } from "./customer-input-fields";\nimport { DateSlotPicker } from "@cimplify/sdk/react";\nimport type { AvailableSlot } from "@cimplify/sdk";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface ProductCustomizerClassNames {\n root?: string;\n actions?: string;\n submitButton?: string;\n submitButtonAdded?: string;\n validation?: string;\n specialInstructions?: string;\n depositInfo?: string;\n allergens?: string;\n duration?: string;\n}\n\nexport interface ProductCustomizerProps {\n product: ProductWithDetails;\n onAddToCart?: (\n product: ProductWithDetails,\n quantity: number,\n options: AddToCartOptions,\n ) => void | Promise<void>;\n /** Lets the parent swap its gallery for `variant.images` on selection. */\n onVariantChange?: (\n variantId: string | undefined,\n variant: VariantView | undefined,\n ) => void;\n showSpecialInstructions?: boolean;\n className?: string;\n classNames?: ProductCustomizerClassNames;\n}\n\nexport function ProductCustomizer({\n product,\n onAddToCart,\n onVariantChange,\n showSpecialInstructions = true,\n className,\n classNames,\n}: ProductCustomizerProps): React.ReactElement {\n const [quantity, setQuantity] = useState(product.min_order_quantity ?? 1);\n const [isAdded, setIsAdded] = useState(false);\n const [isSubmitting, setIsSubmitting] = useState(false);\n const [selectedVariantId, setSelectedVariantId] = useState<string | undefined>();\n const [selectedVariant, setSelectedVariant] = useState<VariantView | undefined>();\n const [selectedAddOnOptionIds, setSelectedAddOnOptionIds] = useState<string[]>([]);\n\n const [compositeSelections, setCompositeSelections] = useState<ComponentSelectionInput[]>([]);\n const [compositePrice, setCompositePrice] = useState<CompositePriceResult | null>(null);\n const [compositeReady, setCompositeReady] = useState(false);\n\n const [bundleSelections, setBundleSelections] = useState<BundleSelectionInput[]>([]);\n const [bundleTotalPrice, setBundleTotalPrice] = useState<number | null>(null);\n const [bundleReady, setBundleReady] = useState(false);\n const [selectedBillingPlan, setSelectedBillingPlan] = useState<ProductBillingPlan | null>(null);\n const [customerInputValues, setCustomerInputValues] = useState<Record<string, unknown>>({});\n const [specialInstructions, setSpecialInstructions] = useState("");\n const [selectedSlot, setSelectedSlot] = useState<AvailableSlot | null>(null);\n\n const cart = useCart();\n\n const productType = product.type || "product";\n const isComposite = productType === "composite";\n const isBundle = productType === "bundle";\n const isDigital = productType === "digital";\n const isService = productType === "service";\n const isStandard = !isComposite && !isBundle;\n\n const hasVariants = isStandard && product.variants && product.variants.length > 0;\n const hasAddOns = isStandard && product.add_ons && product.add_ons.length > 0;\n\n useEffect(() => {\n setQuantity(product.min_order_quantity ?? 1);\n setIsAdded(false);\n setIsSubmitting(false);\n setSelectedVariantId(undefined);\n setSelectedVariant(undefined);\n setSelectedAddOnOptionIds([]);\n setCompositeSelections([]);\n setCompositePrice(null);\n setCompositeReady(false);\n setBundleSelections([]);\n setBundleTotalPrice(null);\n setBundleReady(false);\n setSelectedBillingPlan(null);\n setCustomerInputValues({});\n setSpecialInstructions("");\n setSelectedSlot(null);\n }, [product.id, product.min_order_quantity]);\n\n const selectedAddOnOptions = useMemo(() => {\n if (!product.add_ons) return [];\n const options: AddOnOption[] = [];\n for (const addOn of product.add_ons) {\n for (const option of addOn.options) {\n if (selectedAddOnOptionIds.includes(option.id)) {\n options.push(option);\n }\n }\n }\n return options;\n }, [product.add_ons, selectedAddOnOptionIds]);\n\n const normalizedAddOnOptionIds = useMemo(() => {\n if (selectedAddOnOptionIds.length === 0) return [];\n return Array.from(new Set(selectedAddOnOptionIds.map((id) => id.trim()).filter(Boolean))).sort();\n }, [selectedAddOnOptionIds]);\n\n const localTotalPrice = useMemo(() => {\n if (isComposite && compositePrice) {\n return parsePrice(compositePrice.final_price) * quantity;\n }\n\n if (isBundle && bundleTotalPrice != null) {\n return bundleTotalPrice * quantity;\n }\n\n let price = parsePrice(product.default_price);\n\n if (selectedVariant?.price_adjustment) {\n price += parsePrice(selectedVariant.price_adjustment);\n }\n\n for (const option of selectedAddOnOptions) {\n if (option.default_price) {\n price += parsePrice(option.default_price);\n }\n }\n\n return price * quantity;\n }, [product.default_price, selectedVariant, selectedAddOnOptions, quantity, isComposite, compositePrice, isBundle, bundleTotalPrice]);\n\n const requiredAddOnsSatisfied = useMemo(() => {\n if (!product.add_ons) return true;\n\n for (const addOn of product.add_ons) {\n if (addOn.is_required) {\n const selectedInGroup = selectedAddOnOptionIds.filter((id) =>\n addOn.options.some((opt) => opt.id === id),\n ).length;\n\n const minRequired = addOn.min_selections || 1;\n if (selectedInGroup < minRequired) {\n return false;\n }\n }\n }\n return true;\n }, [product.add_ons, selectedAddOnOptionIds]);\n\n const quoteInput = useMemo(\n () => ({\n productId: product.id,\n quantity,\n variantId: selectedVariantId,\n addOnOptionIds: normalizedAddOnOptionIds.length > 0 ? normalizedAddOnOptionIds : undefined,\n bundleSelections: isBundle && bundleSelections.length > 0 ? bundleSelections : undefined,\n compositeSelections: isComposite && compositeSelections.length > 0 ? compositeSelections : undefined,\n }),\n [product.id, quantity, selectedVariantId, normalizedAddOnOptionIds, isBundle, bundleSelections, isComposite, compositeSelections],\n );\n\n const requiredInputsSatisfied = useMemo(() => {\n if (!product.input_fields || product.input_fields.length === 0) return true;\n return product.input_fields.every((field) => {\n if (!field.is_required) return true;\n const val = customerInputValues[field.id];\n return val !== undefined && val !== "" && val !== null;\n });\n }, [product.input_fields, customerInputValues]);\n\n const quoteEnabled = isComposite\n ? compositeReady && requiredInputsSatisfied\n : isBundle\n ? bundleReady && requiredInputsSatisfied\n : requiredAddOnsSatisfied && requiredInputsSatisfied;\n\n const { quote } = useQuote(quoteInput, {\n enabled: quoteEnabled,\n });\n\n const quoteId = quote?.quote_id;\n const quotedTotalPrice = useMemo(() => {\n if (!quote) return undefined;\n\n const priceInfo = quote.quoted_total_price_info ?? quote.final_price_info;\n const perUnit = priceInfo.pre_tax_price;\n if (perUnit === undefined || perUnit === null) return undefined;\n return parsePrice(perUnit) * quantity;\n }, [quote, quantity]);\n\n const displayTotalPrice = quotedTotalPrice ?? localTotalPrice;\n\n const handleVariantChange = useCallback(\n (variantId: string | undefined, variant: VariantView | undefined) => {\n setSelectedVariantId(variantId);\n setSelectedVariant(variant);\n onVariantChange?.(variantId, variant);\n },\n [onVariantChange],\n );\n\n const addedTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);\n useEffect(() => () => clearTimeout(addedTimerRef.current), []);\n\n const handleAddToCart = useCallback(async () => {\n if (isSubmitting) return;\n\n setIsSubmitting(true);\n\n const options: AddToCartOptions = {\n variantId: selectedVariantId,\n variant: selectedVariant\n ? { id: selectedVariant.id, name: selectedVariant.name || "", price_adjustment: selectedVariant.price_adjustment }\n : undefined,\n quoteId,\n addOnOptionIds: normalizedAddOnOptionIds.length > 0 ? normalizedAddOnOptionIds : undefined,\n addOnOptions: selectedAddOnOptions.length > 0\n ? selectedAddOnOptions.map((opt) => ({\n id: opt.id,\n name: opt.name,\n add_on_id: opt.add_on_id,\n default_price: opt.default_price,\n }))\n : undefined,\n compositeSelections: isComposite && compositeSelections.length > 0 ? compositeSelections : undefined,\n bundleSelections: isBundle && bundleSelections.length > 0 ? bundleSelections : undefined,\n billingPlanId: selectedBillingPlan?.id,\n ...(isService && selectedSlot\n ? {\n scheduledStart: selectedSlot.start_time,\n scheduledEnd: selectedSlot.end_time,\n staffId: selectedSlot.available_staff?.[0]?.staff_id,\n resourceId: selectedSlot.available_resources?.[0]?.resource_id,\n }\n : {}),\n customerInputs: Object.keys(customerInputValues).length > 0\n ? Object.entries(customerInputValues)\n .filter(([, v]) => v !== undefined && v !== "")\n .map(([fieldId, value]) => ({ field_id: fieldId, value }))\n : undefined,\n specialInstructions: specialInstructions.trim() || undefined,\n };\n\n try {\n if (onAddToCart) {\n await onAddToCart(product, quantity, options);\n } else {\n await cart.addItem(product, quantity, options);\n }\n setIsAdded(true);\n clearTimeout(addedTimerRef.current);\n addedTimerRef.current = setTimeout(() => {\n setIsAdded(false);\n setQuantity(product.min_order_quantity ?? 1);\n }, 2000);\n } catch {\n // Caller handles errors via onAddToCart or cart hook error state\n } finally {\n setIsSubmitting(false);\n }\n }, [product, quantity, selectedVariantId, selectedVariant, quoteId, normalizedAddOnOptionIds, selectedAddOnOptions, isComposite, compositeSelections, isBundle, bundleSelections, selectedBillingPlan, customerInputValues, specialInstructions, isService, selectedSlot, isSubmitting, onAddToCart, cart]);\n\n return (\n <div data-cimplify-customizer className={cn("space-y-6", className, classNames?.root)}>\n {isComposite && product.groups && product.composite_id && (\n <CompositeSelector\n compositeId={product.composite_id}\n groups={product.groups}\n onSelectionsChange={setCompositeSelections}\n onPriceChange={setCompositePrice}\n onReady={setCompositeReady}\n skipPriceFetch\n />\n )}\n\n {isBundle && product.components && (\n <BundleSelector\n components={product.components}\n bundlePrice={product.bundle_price}\n discountValue={product.discount_value}\n pricingType={product.pricing_type}\n onSelectionsChange={setBundleSelections}\n onPriceChange={setBundleTotalPrice}\n onReady={setBundleReady}\n />\n )}\n\n {isService && (\n <DateSlotPicker\n serviceId={product.id}\n selectedSlot={selectedSlot}\n onSlotSelect={(slot) => setSelectedSlot(slot)}\n participantCount={quantity}\n />\n )}\n\n {hasVariants && (\n <VariantSelector\n variants={product.variants!}\n variantAxes={product.variant_axes}\n basePrice={product.default_price}\n selectedVariantId={selectedVariantId}\n onVariantChange={handleVariantChange}\n productName={product.name}\n />\n )}\n\n {hasAddOns && (\n <AddOnSelector\n addOns={product.add_ons!}\n selectedOptions={selectedAddOnOptionIds}\n onOptionsChange={setSelectedAddOnOptionIds}\n />\n )}\n\n {/* Billing plans */}\n {product.billing_plans && product.billing_plans.length > 0 && (\n <BillingPlanSelector\n productId={product.id}\n plans={product.billing_plans}\n onPlanSelect={setSelectedBillingPlan}\n selectedPlanId={selectedBillingPlan?.id ?? null}\n showOneTimePurchase\n />\n )}\n\n {/* Volume pricing tiers */}\n {product.quantity_pricing && product.quantity_pricing.length > 1 && (\n <VolumePricing\n tiers={product.quantity_pricing}\n currentQuantity={quantity}\n />\n )}\n\n {/* Customer input fields */}\n {product.input_fields && product.input_fields.length > 0 && (\n <CustomerInputFields\n fields={product.input_fields}\n values={customerInputValues}\n onChange={setCustomerInputValues}\n />\n )}\n\n {/* Deposit info for service products */}\n {product.deposit_type && product.deposit_type !== "none" && product.deposit_amount && (\n <div data-cimplify-customizer-deposit className={cn("text-sm text-muted-foreground", classNames?.depositInfo)}>\n {product.deposit_type === "fixed" ? (\n <span>Deposit required: <Price amount={product.deposit_amount} /></span>\n ) : (\n <span>Deposit required: {parsePrice(product.deposit_amount)}%</span>\n )}\n </div>\n )}\n\n {/* Allergens */}\n {product.allergies && product.allergies.length > 0 && (\n <div data-cimplify-customizer-allergens className={cn("flex flex-wrap gap-1.5", classNames?.allergens)}>\n {product.allergies.map((allergen) => (\n <span\n key={allergen}\n data-cimplify-allergen-tag\n className="inline-block text-xs px-2 py-0.5 rounded-full bg-muted text-muted-foreground"\n >\n {allergen}\n </span>\n ))}\n </div>\n )}\n\n {/* Service duration */}\n {isService && product.duration_minutes != null && (\n <div data-cimplify-customizer-duration className={cn("text-sm text-muted-foreground", classNames?.duration)}>\n Duration: {formatDuration(product.duration_minutes, product.duration_unit)}\n </div>\n )}\n\n {/* Special instructions */}\n {showSpecialInstructions && !isDigital && (\n <div data-cimplify-customizer-special-instructions>\n <textarea\n value={specialInstructions}\n onChange={(e) => setSpecialInstructions(e.target.value)}\n placeholder="Special instructions (e.g., no onions, extra sauce)"\n rows={2}\n data-cimplify-customizer-textarea\n className={cn(\n "w-full rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring resize-none",\n classNames?.specialInstructions,\n )}\n />\n </div>\n )}\n\n <div\n data-cimplify-customizer-actions\n className={cn("pt-4 border-t border-border", classNames?.actions)}\n >\n {!quoteEnabled && (\n <p\n id="cimplify-customizer-validation"\n data-cimplify-customizer-validation\n className={cn("text-sm text-destructive mb-3", classNames?.validation)}\n >\n Please select all required options\n </p>\n )}\n <div className="flex items-center gap-4">\n <div className="flex flex-col gap-1">\n <QuantitySelector\n value={quantity}\n onChange={setQuantity}\n min={product.min_order_quantity ?? 1}\n />\n {product.min_order_quantity && product.min_order_quantity > 1 && (\n <span data-cimplify-customizer-min-hint className="text-xs text-muted-foreground text-center">\n Min. {product.min_order_quantity}\n </span>\n )}\n </div>\n\n {product.quantity_pricing && product.quantity_pricing.length > 0 && (\n <div data-cimplify-customizer-unit-price className="text-sm text-muted-foreground text-right shrink-0">\n <Price amount={getUnitPriceAtQuantity(product.quantity_pricing, quantity, parsePrice(product.default_price))} className="font-medium text-foreground" /> ea.\n </div>\n )}\n\n <Button\n onClick={handleAddToCart}\n disabled={isAdded || isSubmitting || !quoteEnabled}\n aria-describedby={!quoteEnabled ? "cimplify-customizer-validation" : undefined}\n data-cimplify-customizer-submit\n className={cn(\n "flex-1 h-14 text-base bg-primary text-primary-foreground font-medium hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed rounded-full",\n isAdded && classNames?.submitButtonAdded,\n classNames?.submitButton,\n )}\n >\n {isAdded ? "Added to Cart" : (\n <>\n Add to Cart · <Price amount={displayTotalPrice} />\n </>\n )}\n </Button>\n </div>\n </div>\n\n </div>\n );\n}\n' }] }, "cart-summary": { "name": "cart-summary", "title": "CartSummary", "description": "Cart line items with quantity controls and totals.", "type": "component", "registryDependencies": ["price", "quantity-selector"], "files": [{ "path": "cart-summary.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { UseCartItem } from "@cimplify/sdk/react";\nimport type { DisplayCart } from "@cimplify/sdk";\nimport { useCart } from "@cimplify/sdk/react";\nimport { getVariantDisplayName } from "@cimplify/sdk";\nimport { INPUT_FIELD_TYPE } from "@cimplify/sdk";\nimport { parsePrice } from "@cimplify/sdk";\nimport { Price } from "@cimplify/sdk/react";\nimport { QuantitySelector } from "@cimplify/sdk/react";\nimport { useProductPrice } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface CartSummaryClassNames {\n root?: string;\n item?: string;\n itemName?: string;\n itemPrice?: string;\n quantity?: string;\n removeButton?: string;\n totals?: string;\n subtotal?: string;\n tax?: string;\n deliveryFee?: string;\n serviceCharge?: string;\n discount?: string;\n total?: string;\n checkout?: string;\n empty?: string;\n serviceInfo?: string;\n digitalInfo?: string;\n bundleBreakdown?: string;\n compositeBreakdown?: string;\n customerInputs?: string;\n}\n\nexport interface CartSummaryProps {\n /** Optional server cart for extended pricing details (delivery fee, service charge, discounts). */\n cart?: DisplayCart;\n onCheckout?: () => void;\n onItemRemove?: (itemId: string) => void;\n onQuantityChange?: (itemId: string, quantity: number) => void;\n emptyMessage?: string;\n /** Render the totals block (subtotal/tax/total) below the items. Default true. Hosts that show their own totals (drawer, page sidebar) should pass false. */\n showTotals?: boolean;\n /** Render the "Proceed to Checkout" button. Defaults to true if `onCheckout` is provided. */\n showCheckoutButton?: boolean;\n className?: string;\n classNames?: CartSummaryClassNames;\n}\n\nfunction CartLineItemRow({\n item,\n onRemove,\n onQuantityChange,\n classNames,\n}: {\n item: UseCartItem;\n onRemove: (itemId: string) => void;\n onQuantityChange: (itemId: string, qty: number) => void;\n classNames?: CartSummaryClassNames;\n}): React.ReactElement {\n const { unitPrice } = useProductPrice({\n product: item.product,\n variant: item.variant,\n addOnOptions: item.addOnOptions,\n });\n const hasComposite = item.compositeSelections && item.compositeSelections.length > 0;\n const hasBundle = item.bundleSelections && item.bundleSelections.length > 0;\n\n const isOptimistic = (item as { isOptimistic?: boolean }).isOptimistic === true;\n\n const lineTotal = parsePrice(unitPrice) * item.quantity;\n const imageUrl = item.product.image_url;\n const variantLabel = item.variant ? getVariantDisplayName(item.variant, item.product.name) : null;\n\n return (\n <div\n data-cimplify-cart-item\n data-optimistic={isOptimistic ? "true" : undefined}\n className={cn(\n "group relative flex gap-3 py-4 border-b border-border last:border-b-0 transition-opacity",\n isOptimistic && "opacity-60",\n classNames?.item,\n )}\n >\n {/* Thumbnail */}\n <div\n data-cimplify-cart-item-image\n className="relative w-20 h-20 shrink-0 rounded-md overflow-hidden bg-muted"\n >\n {imageUrl ? (\n <img\n src={imageUrl}\n alt=""\n className="w-full h-full object-cover"\n loading="lazy"\n />\n ) : (\n <div className="w-full h-full grid place-items-center text-muted-foreground/40">\n <svg className="w-6 h-6" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24" aria-hidden>\n <path strokeLinecap="round" strokeLinejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 001.5-1.5V6a1.5 1.5 0 00-1.5-1.5H3.75A1.5 1.5 0 002.25 6v12a1.5 1.5 0 001.5 1.5zm10.5-11.25h.008v.008h-.008V8.25zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z" />\n </svg>\n </div>\n )}\n </div>\n\n {/* Info column */}\n <div data-cimplify-cart-item-info className="flex-1 min-w-0 flex flex-col">\n <div className="flex items-start justify-between gap-3">\n <h3\n data-cimplify-cart-item-name\n className={cn("text-sm font-medium leading-tight m-0 truncate", classNames?.itemName)}\n >\n {item.product.name}\n </h3>\n <button\n type="button"\n onClick={() => onRemove(item.id)}\n data-cimplify-cart-item-remove\n className={cn(\n "shrink-0 -mr-1 -mt-1 grid place-items-center w-7 h-7 rounded-full text-muted-foreground/60 hover:text-foreground hover:bg-muted transition-colors",\n classNames?.removeButton,\n )}\n aria-label={`Remove ${item.product.name}`}\n >\n <svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24" aria-hidden>\n <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />\n </svg>\n </button>\n </div>\n\n {/* Pills row: variant, add-ons, badges */}\n {(variantLabel || (item.addOnOptions && item.addOnOptions.length > 0) || hasComposite || hasBundle) && (\n <div data-cimplify-cart-item-pills className="flex flex-wrap gap-1.5 mt-1.5">\n {variantLabel && (\n <span\n data-cimplify-cart-item-variant\n className="inline-flex items-center px-2 py-0.5 rounded-full bg-muted text-[11px] font-medium text-foreground"\n >\n {variantLabel}\n </span>\n )}\n {item.addOnOptions?.map((opt) => (\n <span\n key={opt.id}\n data-cimplify-cart-item-addon\n className="inline-flex items-center px-2 py-0.5 rounded-full bg-muted text-[11px] font-medium text-muted-foreground"\n >\n + {opt.name}\n </span>\n ))}\n {(hasComposite || hasBundle) && (\n <span\n data-cimplify-cart-item-badge\n className="inline-flex items-center px-2 py-0.5 rounded-full bg-primary/10 text-[11px] font-semibold uppercase tracking-wider text-primary"\n >\n {hasComposite ? "Custom" : "Bundle"}\n </span>\n )}\n </div>\n )}\n\n {/* Special instructions */}\n {item.specialInstructions && (\n <p\n data-cimplify-cart-item-instructions\n className="mt-1.5 text-xs italic text-muted-foreground line-clamp-2"\n >\n \\u201C{item.specialInstructions}\\u201D\n </p>\n )}\n\n {/* Customer input values */}\n {item.customerInputs && item.customerInputs.length > 0 && (\n <dl data-cimplify-cart-customer-inputs className={cn("mt-1.5 text-xs space-y-0.5", classNames?.customerInputs)}>\n {item.customerInputs.map((input) => (\n <div key={input.field_id} data-cimplify-cart-input className="flex gap-1.5">\n <dt data-cimplify-cart-input-label className="text-muted-foreground">{input.field_name}:</dt>\n <dd data-cimplify-cart-input-value className="text-foreground m-0 truncate">\n {input.field_type === INPUT_FIELD_TYPE.File || input.field_type === INPUT_FIELD_TYPE.Image\n ? String(input.value).split("/").pop() || "Uploaded file"\n : String(input.value)}\n </dd>\n </div>\n ))}\n </dl>\n )}\n\n {/* Service: scheduled details */}\n {(item.lineType === "service" || item.product.type === "service") && item.scheduledStart && (\n <div data-cimplify-cart-service-info className={cn("mt-1.5 flex items-center gap-1.5 text-xs text-muted-foreground", classNames?.serviceInfo)}>\n <svg className="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24" aria-hidden>\n <path strokeLinecap="round" strokeLinejoin="round" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />\n </svg>\n <span>\n {new Date(item.scheduledStart).toLocaleDateString(undefined, { month: "short", day: "numeric" })} at{" "}\n {new Date(item.scheduledStart).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}\n </span>\n {item.serviceStatus && (\n <span data-status={item.serviceStatus} className="ml-auto px-1.5 py-0.5 rounded-full bg-muted text-[10px] uppercase tracking-wider">\n {item.serviceStatus}\n </span>\n )}\n </div>\n )}\n\n {/* Digital: instant-delivery badge */}\n {(item.lineType === "digital" || item.product.type === "digital") && (\n <div data-cimplify-cart-digital-info className={cn("mt-1.5 flex items-center gap-1 text-xs text-muted-foreground", classNames?.digitalInfo)}>\n <svg className="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24" aria-hidden>\n <path strokeLinecap="round" strokeLinejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" />\n </svg>\n <span>Instant digital delivery</span>\n </div>\n )}\n\n {/* Bundle / composite component breakdown */}\n {(item.bundleResolved || item.compositeResolved) && (\n <ul\n data-cimplify-cart-composite-breakdown={item.compositeResolved ? "" : undefined}\n data-cimplify-cart-bundle-breakdown={item.bundleResolved ? "" : undefined}\n className={cn("mt-1.5 pl-3 border-l border-border space-y-0.5 text-xs text-muted-foreground", item.compositeResolved ? classNames?.compositeBreakdown : classNames?.bundleBreakdown)}\n >\n {((item.bundleResolved?.selections ?? item.compositeResolved?.selections ?? []) as Array<{\n component_id?: string;\n product_name?: string;\n component_name?: string;\n quantity: number;\n product_type?: string;\n }>).map((sel) => {\n const name = sel.product_name ?? sel.component_name ?? "";\n const compId = sel.component_id ?? `${name}-${sel.quantity}`;\n return (\n <li key={compId} data-product-type={sel.product_type} className="flex items-baseline gap-1.5 m-0">\n <span className="font-medium text-foreground/80">{sel.quantity}\\u00D7</span>\n <span className="truncate">{name}</span>\n </li>\n );\n })}\n </ul>\n )}\n\n {/* Bottom row: line price + qty stepper */}\n <div className="mt-3 flex items-center justify-between gap-3">\n <Price\n amount={lineTotal}\n className={cn("text-sm font-semibold tabular-nums", classNames?.itemPrice)}\n />\n <div data-cimplify-cart-item-controls className={classNames?.quantity}>\n <QuantitySelector\n value={item.quantity}\n onChange={(qty) => onQuantityChange(item.id, qty)}\n min={0}\n />\n </div>\n </div>\n </div>\n </div>\n );\n}\n\n/**\n * CartSummary \u2014 renders cart line items + totals.\n *\n * NOT a drawer or modal \u2014 just the cart content. Templates wrap this in\n * their own drawer/modal shell with animations.\n */\nexport function CartSummary({\n cart: serverCart,\n onCheckout,\n onItemRemove,\n onQuantityChange,\n emptyMessage = "Your cart is empty",\n showTotals = true,\n showCheckoutButton,\n className,\n classNames,\n}: CartSummaryProps): React.ReactElement {\n const {\n items,\n itemCount,\n subtotal,\n tax,\n total,\n isEmpty,\n isOptimistic,\n pendingOpCount,\n removeItem,\n updateQuantity,\n } = useCart();\n\n const deliveryFee = serverCart?.delivery_fee;\n const serviceCharge = serverCart?.service_charge;\n const totalDiscounts = serverCart?.total_discounts;\n\n const handleRemove = (itemId: string) => {\n if (onItemRemove) {\n onItemRemove(itemId);\n } else {\n void removeItem(itemId);\n }\n };\n\n const handleQuantityChange = (itemId: string, qty: number) => {\n if (onQuantityChange) {\n onQuantityChange(itemId, qty);\n } else {\n void updateQuantity(itemId, qty);\n }\n };\n\n return (\n <div\n data-cimplify-cart-summary\n data-optimistic={isOptimistic ? "true" : undefined}\n data-pending-ops={pendingOpCount > 0 ? pendingOpCount : undefined}\n className={cn(className, classNames?.root)}\n >\n {isEmpty ? (\n <div data-cimplify-cart-empty className={classNames?.empty}>\n <p>{emptyMessage}</p>\n </div>\n ) : (\n <>\n {/* Line items */}\n <div data-cimplify-cart-items>\n {items.map((item) => (\n <CartLineItemRow\n key={item.id}\n item={item}\n onRemove={handleRemove}\n onQuantityChange={handleQuantityChange}\n classNames={classNames}\n />\n ))}\n </div>\n\n {/* Totals */}\n {showTotals && (\n <div\n data-cimplify-cart-totals\n className={cn("mt-6 pt-4 border-t border-border space-y-2 text-sm", classNames?.totals)}\n >\n <div data-cimplify-cart-subtotal className={cn("flex items-baseline justify-between", classNames?.subtotal)}>\n <span className="text-muted-foreground">\n Subtotal ({itemCount} {itemCount === 1 ? "item" : "items"})\n </span>\n <Price amount={subtotal} className="tabular-nums" />\n </div>\n\n {totalDiscounts != null && parsePrice(totalDiscounts) > 0 && (\n <div data-cimplify-cart-discount className={cn("flex items-baseline justify-between", classNames?.discount)}>\n <span className="text-muted-foreground">Discount</span>\n <Price amount={totalDiscounts} prefix="-" className="tabular-nums text-primary" />\n </div>\n )}\n\n {deliveryFee != null && (\n <div data-cimplify-cart-delivery-fee className={cn("flex items-baseline justify-between", classNames?.deliveryFee)}>\n <span className="text-muted-foreground">Delivery</span>\n {parsePrice(deliveryFee) > 0 ? (\n <Price amount={deliveryFee} className="tabular-nums" />\n ) : (\n <span className="font-medium text-primary">Free</span>\n )}\n </div>\n )}\n\n {serviceCharge != null && parsePrice(serviceCharge) > 0 && (\n <div data-cimplify-cart-service-charge className={cn("flex items-baseline justify-between", classNames?.serviceCharge)}>\n <span className="text-muted-foreground">Service charge</span>\n <Price amount={serviceCharge} className="tabular-nums" />\n </div>\n )}\n\n <div data-cimplify-cart-tax className={cn("flex items-baseline justify-between", classNames?.tax)}>\n <span className="text-muted-foreground">Tax</span>\n <Price amount={tax} className="tabular-nums" />\n </div>\n\n <div data-cimplify-cart-total className={cn("flex items-baseline justify-between pt-3 mt-1 border-t border-border", classNames?.total)}>\n <span className="font-semibold">Total</span>\n <Price amount={total} className="text-base font-bold tabular-nums" />\n </div>\n </div>\n )}\n\n {(showCheckoutButton ?? Boolean(onCheckout)) && onCheckout && (\n <button\n type="button"\n onClick={onCheckout}\n data-cimplify-cart-checkout\n className={cn(\n "mt-4 w-full h-12 rounded-full bg-foreground text-background font-semibold text-sm hover:bg-foreground/90 active:scale-[0.99] transition-all",\n classNames?.checkout,\n )}\n >\n Proceed to Checkout\n </button>\n )}\n </>\n )}\n </div>\n );\n}\n' }] }, "cn": { "name": "cn", "title": "cn", "description": "Utility that merges Tailwind CSS classes with clsx + tailwind-merge.", "type": "utility", "registryDependencies": [], "files": [{ "path": "utils/cn.ts", "content": 'import { clsx, type ClassValue } from "clsx";\nimport { twMerge } from "tailwind-merge";\n\nexport function cn(...inputs: ClassValue[]): string {\n return twMerge(clsx(inputs));\n}\n' }] }, "search-input": { "name": "search-input", "title": "SearchInput", "description": "Search bar with debounced results dropdown.", "type": "component", "registryDependencies": ["cn"], "files": [{ "path": "search-input.tsx", "content": '"use client";\n\nimport React, { useCallback, useMemo } from "react";\nimport { Field } from "@base-ui/react/field";\nimport { Input } from "@base-ui/react/input";\nimport { useSearch } from "@cimplify/sdk/react";\nimport type { UseSearchOptions } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\nimport { formatPrice } from "@cimplify/sdk";\n\nexport interface SearchInputClassNames {\n root?: string;\n input?: string;\n clearButton?: string;\n results?: string;\n resultItem?: string;\n empty?: string;\n loading?: string;\n resultImage?: string;\n resultName?: string;\n resultPrice?: string;\n categoryFilter?: string;\n}\n\nexport interface SearchInputProps {\n /** Placeholder text for the input. */\n placeholder?: string;\n /** Search options forwarded to useSearch. */\n searchOptions?: UseSearchOptions;\n /** Called when a product result is clicked. */\n onResultClick?: (product: import("../types/product").Product) => void;\n /** Custom result item renderer. */\n renderResult?: (product: import("../types/product").Product) => React.ReactNode;\n /** Show inline results dropdown. Default: true. */\n showResults?: boolean;\n /** Optional category ID to scope search results. */\n categoryId?: string;\n /** Display name for the active category filter. */\n categoryName?: string;\n className?: string;\n classNames?: SearchInputClassNames;\n}\n\nconst SearchIcon = () => (\n <svg\n xmlns="http://www.w3.org/2000/svg"\n width="16"\n height="16"\n viewBox="0 0 24 24"\n fill="none"\n stroke="currentColor"\n strokeWidth="2"\n strokeLinecap="round"\n strokeLinejoin="round"\n aria-hidden="true"\n data-cimplify-search-icon\n >\n <circle cx="11" cy="11" r="8" />\n <line x1="21" y1="21" x2="16.65" y2="16.65" />\n </svg>\n);\n\n/**\n * SearchInput \u2014 search bar with debounced results dropdown.\n *\n * Wraps `useSearch` with a controlled input and optional inline results list.\n * Uses Base UI Field.Root + Input for accessible form semantics.\n */\nexport function SearchInput({\n placeholder = "Search products...",\n searchOptions,\n onResultClick,\n renderResult,\n showResults = true,\n categoryId,\n categoryName,\n className,\n classNames,\n}: SearchInputProps): React.ReactElement {\n const mergedOptions = useMemo<UseSearchOptions>(\n () => (categoryId ? { ...searchOptions, category: categoryId } : searchOptions ?? {}),\n [searchOptions, categoryId],\n );\n const { results, isLoading, query, setQuery, clear } = useSearch(mergedOptions);\n\n const handleValueChange = useCallback(\n (value: string) => {\n setQuery(value);\n },\n [setQuery],\n );\n\n return (\n <Field.Root\n data-cimplify-search\n className={cn(className, classNames?.root)}\n style={{ position: "relative" }}\n >\n <Field.Label className="sr-only">Search products</Field.Label>\n\n <div style={{ position: "relative", display: "flex", alignItems: "center" }}>\n <span\n data-cimplify-search-icon-wrapper\n style={{\n position: "absolute",\n left: "0.5rem",\n pointerEvents: "none",\n display: "flex",\n alignItems: "center",\n }}\n >\n <SearchIcon />\n </span>\n\n <Input\n type="search"\n value={query}\n onValueChange={handleValueChange}\n placeholder={placeholder}\n data-cimplify-search-input\n className={classNames?.input}\n />\n\n {query.length > 0 && (\n <button\n type="button"\n onClick={clear}\n data-cimplify-search-clear\n className={classNames?.clearButton}\n aria-label="Clear search"\n >\n ×\n </button>\n )}\n </div>\n\n {categoryId && (\n <div data-cimplify-search-category-filter className={classNames?.categoryFilter}>\n Filtered by: {categoryName || categoryId}\n </div>\n )}\n\n {showResults && query.length > 0 && (\n <div data-cimplify-search-results className={classNames?.results}>\n {isLoading && (\n <div data-cimplify-search-loading className={classNames?.loading} aria-busy="true">\n Searching...\n </div>\n )}\n\n {!isLoading && results.length === 0 && query.length >= 2 && (\n <div data-cimplify-search-empty className={classNames?.empty}>\n No results found\n </div>\n )}\n\n {!isLoading &&\n results.map((product) => (\n <button\n key={product.id}\n type="button"\n onClick={() => onResultClick?.(product)}\n data-cimplify-search-result\n className={classNames?.resultItem}\n style={!renderResult ? { display: "flex", alignItems: "center", gap: "0.5rem", width: "100%" } : undefined}\n >\n {renderResult ? (\n renderResult(product)\n ) : (\n <>\n {(product.images?.[0] || product.image_url) && (\n <img\n src={product.images?.[0] || product.image_url}\n alt=""\n data-cimplify-search-result-image\n className={classNames?.resultImage}\n style={{ width: "2rem", height: "2rem", objectFit: "cover", borderRadius: "0.25rem", flexShrink: 0 }}\n />\n )}\n <span data-cimplify-search-result-name className={classNames?.resultName} style={{ flex: 1, textAlign: "left" }}>\n {product.name}\n </span>\n {product.default_price !== undefined && (\n <span data-cimplify-search-result-price className={classNames?.resultPrice} style={{ flexShrink: 0 }}>\n {formatPrice(product.default_price)}\n </span>\n )}\n </>\n )}\n </button>\n ))}\n </div>\n )}\n </Field.Root>\n );\n}\n' }] }, "food-product-card": { "name": "food-product-card", "title": "FoodProductCard", "description": "Product card for food items with tags, badges, and quick-add.", "type": "component", "registryDependencies": ["price", "cn"], "files": [{ "path": "cards/food-product-card.tsx", "content": '"use client";\n\nimport React from "react";\nimport { PRODUCT_TYPE } from "@cimplify/sdk";\nimport type { CardLayoutProps } from "@cimplify/sdk/react";\nimport { CardShell, CardImage, QuickAddButton } from "@cimplify/sdk/react";\nimport { Price } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\n\nexport function FoodProductCard({\n product,\n onQuickAdd,\n renderImage,\n renderLink,\n className,\n}: CardLayoutProps): React.ReactElement {\n const image = product.image_url || product.images?.[0] || "";\n const tags = product.tags || [];\n const isSignature = product.metadata?.is_signature === true;\n const isNew = product.metadata?.is_new === true;\n const isComposite = product.type === PRODUCT_TYPE.Composite;\n\n return (\n <CardShell product={product} renderLink={renderLink} className={className}>\n <CardImage src={image} alt={product.name} aspectRatio="square" renderImage={renderImage}>\n {/* Badges */}\n {isSignature && (\n <span className="absolute top-3 left-3 inline-flex items-center gap-1 text-[11px] font-semibold tracking-wide bg-amber-50 text-amber-700 border border-amber-200/60 px-2 py-0.5 rounded-md">\n <svg className="w-3 h-3 opacity-70" fill="currentColor" viewBox="0 0 16 16"><path d="M8 .75l1.85 4.95L15 6.2l-3.7 3.2 1.1 5.1L8 12.15 3.6 14.5l1.1-5.1L1 6.2l5.15-.5z" /></svg>\n Signature\n </span>\n )}\n {isNew && !isSignature && (\n <span className="absolute top-3 left-3 text-[11px] font-semibold tracking-wide bg-emerald-50 text-emerald-700 border border-emerald-200/60 px-2 py-0.5 rounded-md">\n New\n </span>\n )}\n {isComposite && (\n <span className="absolute top-3 left-3 text-[11px] font-semibold tracking-wide bg-violet-50 text-violet-700 border border-violet-200/60 px-2 py-0.5 rounded-md">\n Customizable\n </span>\n )}\n\n {/* Quick add */}\n <QuickAddButton\n onClick={() => onQuickAdd?.(product)}\n className="absolute bottom-3 right-3"\n />\n </CardImage>\n\n <div className="p-3.5">\n <h3 className="text-[14.5px] font-semibold text-foreground leading-snug tracking-tight truncate">\n {product.name}\n </h3>\n {product.description && (\n <p className="text-[12.5px] text-muted-foreground mt-1 leading-relaxed line-clamp-2">\n {product.description}\n </p>\n )}\n <div className="flex items-center justify-between mt-3">\n {isComposite ? (\n <span className="text-sm font-bold">From <Price amount={product.default_price} /></span>\n ) : (\n <Price amount={product.default_price} className="text-sm font-bold" />\n )}\n {tags.length > 0 && (\n <div className="flex gap-1">\n {tags.slice(0, 2).map((tag) => (\n <span key={tag} className="text-[10px] font-medium text-muted-foreground px-1.5 py-0.5 bg-muted rounded">\n {tag}\n </span>\n ))}\n </div>\n )}\n </div>\n </div>\n </CardShell>\n );\n}\n' }] }, "search-page": { "name": "search-page", "title": "SearchPage", "description": "Dedicated search page with input and results grid.", "type": "component", "registryDependencies": ["product-grid", "cn"], "files": [{ "path": "search-page.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { Product, Category, PropertyFacet } from "@cimplify/sdk";\nimport { useSearch } from "@cimplify/sdk/react";\nimport type { UseSearchOptions } from "./hooks/use-search";\nimport type { SearchLayoutProps } from "./search-layouts/shared";\nimport { DefaultSearchLayout } from "./search-layouts/default-search-layout";\nimport { CompactSearchLayout } from "./search-layouts/compact-search-layout";\nimport { cn } from "@cimplify/sdk/react";\n\nexport type { SearchLayoutProps };\n\nexport enum SearchTemplate {\n Default = "default",\n Compact = "compact",\n}\n\nexport interface SearchPageClassNames {\n root?: string;\n}\n\nexport interface SearchPageProps {\n /** Explicit search template. */\n template?: SearchTemplate;\n /** Layout map for overrides. AI or developer can provide custom layouts keyed by template name. */\n layouts?: Partial<Record<SearchTemplate | string, React.ComponentType<SearchLayoutProps>>>;\n /** Pre-fetched categories for filters. */\n categories?: Category[];\n /** Pre-fetched property facets for sidebar. */\n facets?: PropertyFacet[];\n /** Search options passed to useSearch. */\n searchOptions?: UseSearchOptions;\n /** Placeholder text for search input. */\n placeholder?: string;\n /** Custom card renderer. */\n renderCard?: (product: Product) => React.ReactNode;\n /** Custom image renderer. */\n renderImage?: (props: { src: string; alt: string; className?: string }) => React.ReactNode;\n /** Custom link renderer. */\n renderLink?: (props: { href: string; className?: string; children: React.ReactNode }) => React.ReactElement;\n /** Quick-add handler. */\n onQuickAdd?: (product: Product) => void;\n className?: string;\n classNames?: SearchPageClassNames;\n}\n\nconst BUILT_IN_LAYOUTS: Record<string, React.ComponentType<SearchLayoutProps>> = {\n [SearchTemplate.Default]: DefaultSearchLayout,\n [SearchTemplate.Compact]: CompactSearchLayout,\n};\n\nexport function SearchPage({\n template,\n layouts,\n categories,\n facets,\n searchOptions,\n placeholder,\n renderCard,\n renderImage,\n renderLink,\n onQuickAdd,\n className,\n classNames,\n}: SearchPageProps): React.ReactElement {\n const search = useSearch(searchOptions);\n\n const key = template ?? SearchTemplate.Default;\n const Layout = layouts?.[key] ?? BUILT_IN_LAYOUTS[key] ?? DefaultSearchLayout;\n\n return (\n <div data-cimplify-search-page className={cn(className, classNames?.root)}>\n <Layout\n search={search}\n facets={facets}\n categories={categories}\n renderCard={renderCard}\n renderImage={renderImage}\n renderLink={renderLink}\n onQuickAdd={onQuickAdd}\n placeholder={placeholder}\n />\n </div>\n );\n}\n' }] }, "food-product-layout": { "name": "food-product-layout", "title": "FoodProductLayout", "description": "Restaurant product layout with allergens, ingredients, pairings, and dietary tags.", "type": "component", "registryDependencies": ["product-image-gallery", "product-customizer", "price", "cn"], "files": [{ "path": "layouts/food-product-layout.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { ProductLayoutProps } from "@cimplify/sdk/react";\nimport {\n TwoColumnGrid,\n RelatedProductsSection,\n MetadataStringList,\n CustomAttributesTable,\n TagPills,\n getMetadataStringList,\n} from "@cimplify/sdk/react";\nimport { ProductImageGallery } from "@cimplify/sdk/react";\nimport { ProductCustomizer } from "@cimplify/sdk/react";\nimport { Price } from "@cimplify/sdk/react";\nimport { parsePrice } from "@cimplify/sdk";\nimport { cn } from "@cimplify/sdk/react";\n\n\nexport function FoodProductLayout({\n product,\n onAddToCart,\n renderBreadcrumb,\n relatedProducts,\n showRelated = true,\n className,\n}: ProductLayoutProps): React.ReactElement {\n const images = product.images || (product.image_url ? [product.image_url] : []);\n const tags = product.tags || [];\n const ingredients = getMetadataStringList(product.metadata, "ingredients");\n const pairings = getMetadataStringList(product.metadata, "pairings");\n\n return (\n <div data-cimplify-product-layout="food" className={cn("space-y-8", className)}>\n {renderBreadcrumb?.(product)}\n\n <TwoColumnGrid\n left={\n <ProductImageGallery\n images={images}\n productName={product.name}\n />\n }\n right={\n <div className="space-y-6">\n {/* Tags + Allergens */}\n <div className="flex flex-wrap gap-1.5">\n {tags.map((tag) => (\n <span key={tag} className="inline-flex items-center gap-1 px-2.5 py-1 bg-green-100 text-green-800 text-xs font-medium rounded-full">\n {tag}\n </span>\n ))}\n {product.allergies && product.allergies.map((allergy) => (\n <span key={allergy} className="inline-flex items-center gap-1 px-2.5 py-1 bg-amber-100 text-amber-800 text-xs font-medium rounded-full">\n Contains {allergy}\n </span>\n ))}\n </div>\n\n {/* Name + Price */}\n <div>\n <h1 data-cimplify-product-layout-name className="text-3xl lg:text-4xl font-bold tracking-tight">\n {product.name}\n </h1>\n <Price amount={product.default_price} className="text-2xl font-semibold mt-2 block" />\n </div>\n\n {/* Quick stats */}\n {(product.calories != null || product.preparation_time_minutes != null) && (\n <div data-cimplify-product-layout-stats className="flex flex-wrap gap-4 py-3 border-y border-border">\n {product.preparation_time_minutes != null && (\n <span className="flex items-center gap-1.5 text-sm text-muted-foreground">\n <strong className="text-foreground">{product.preparation_time_minutes} min</strong> prep time\n </span>\n )}\n {product.calories != null && (\n <span className="flex items-center gap-1.5 text-sm text-muted-foreground">\n <strong className="text-foreground">{product.calories}</strong> calories\n </span>\n )}\n </div>\n )}\n\n {/* Description */}\n {product.description && (\n <p data-cimplify-product-layout-description className="text-muted-foreground leading-relaxed">\n {product.description}\n </p>\n )}\n\n {/* Ingredients + Pairings */}\n {(ingredients.length > 0 || pairings.length > 0) && (\n <div className="grid sm:grid-cols-2 gap-4">\n <MetadataStringList items={ingredients} label="Key Ingredients" />\n <MetadataStringList items={pairings} label="Pairings" />\n </div>\n )}\n\n {/* Custom Attributes */}\n <CustomAttributesTable attributes={product.custom_attributes || []} />\n\n {/* Customizer (variants, add-ons, composites, bundles, billing, volume) */}\n <ProductCustomizer\n product={product}\n onAddToCart={onAddToCart}\n />\n </div>\n }\n />\n\n {showRelated && relatedProducts && relatedProducts.length > 0 && (\n <RelatedProductsSection\n products={relatedProducts}\n title="You may also enjoy"\n />\n )}\n </div>\n );\n}\n' }] }, "catalogue-page": { "name": "catalogue-page", "title": "CataloguePage", "description": "Browse all products with category filtering and search.", "type": "component", "registryDependencies": ["product-grid", "category-filter", "search-input", "cn"], "files": [{ "path": "catalogue-page.tsx", "content": '"use client";\n\nimport React, { useState, useCallback } from "react";\nimport type { Product, Category, PropertyFacet } from "@cimplify/sdk";\nimport { useProducts, useCategories } from "@cimplify/sdk/react";\nimport type { CatalogueLayoutProps } from "./catalogue-layouts/shared";\nimport { DefaultCatalogueLayout } from "./catalogue-layouts/default-catalogue-layout";\nimport { CompactCatalogueLayout } from "./catalogue-layouts/compact-catalogue-layout";\nimport { cn } from "@cimplify/sdk/react";\n\nexport type { CatalogueLayoutProps };\n\nexport enum CatalogueTemplate {\n Default = "default",\n Compact = "compact",\n}\n\nexport interface CataloguePageClassNames {\n root?: string;\n}\n\nexport interface CataloguePageProps {\n /** Explicit template. */\n template?: CatalogueTemplate;\n /** Layout map for overrides. */\n layouts?: Partial<Record<CatalogueTemplate | string, React.ComponentType<CatalogueLayoutProps>>>;\n /** Page title. */\n title?: string;\n /** Pre-fetched products for SSR. */\n products?: Product[];\n /** Pre-fetched categories for SSR. */\n categories?: Category[];\n /** Pre-fetched property facets for sidebar. */\n facets?: PropertyFacet[];\n /** Available tags for filtering. */\n availableTags?: string[];\n /** Products per page. */\n pageSize?: number;\n /** Default sort. */\n defaultSort?: { by: string; order: string };\n /** Custom card renderer. */\n renderCard?: (product: Product) => React.ReactNode;\n /** Custom image renderer. */\n renderImage?: (props: { src: string; alt: string; className?: string }) => React.ReactNode;\n /** Custom link renderer. */\n renderLink?: (props: { href: string; className?: string; children: React.ReactNode }) => React.ReactElement;\n /** Quick-add handler. */\n onQuickAdd?: (product: Product) => void;\n /** Product click handler. */\n onProductClick?: (product: Product) => void;\n className?: string;\n classNames?: CataloguePageClassNames;\n}\n\nconst BUILT_IN_LAYOUTS: Record<string, React.ComponentType<CatalogueLayoutProps>> = {\n [CatalogueTemplate.Default]: DefaultCatalogueLayout,\n [CatalogueTemplate.Compact]: CompactCatalogueLayout,\n};\n\nfunction useDebounce(value: string, delay: number): string {\n const [debounced, setDebounced] = React.useState(value);\n React.useEffect(() => {\n const timer = setTimeout(() => setDebounced(value), delay);\n return () => clearTimeout(timer);\n }, [value, delay]);\n return debounced;\n}\n\nexport function CataloguePage({\n template,\n layouts,\n title,\n products: productsProp,\n categories: categoriesProp,\n facets,\n availableTags,\n pageSize,\n defaultSort,\n renderCard,\n renderImage,\n renderLink,\n onQuickAdd,\n onProductClick,\n className,\n classNames,\n}: CataloguePageProps): React.ReactElement {\n const [selectedCategory, setSelectedCategory] = useState<string | null>(null);\n const [searchQuery, setSearchQuery] = useState("");\n const [sortBy, setSortBy] = useState(defaultSort?.by ?? "created_at");\n const [sortOrder, setSortOrder] = useState(defaultSort?.order ?? "desc");\n const [inStockOnly, setInStockOnly] = useState(false);\n const [selectedTags, setSelectedTags] = useState<string[]>([]);\n const [minPrice, setMinPrice] = useState("");\n const [maxPrice, setMaxPrice] = useState("");\n const [page, setPage] = useState(1);\n\n const debouncedSearch = useDebounce(searchQuery, 300);\n\n const { products: fetchedProducts, isLoading, pagination } = useProducts({\n enabled: productsProp === undefined,\n category: selectedCategory ?? undefined,\n search: debouncedSearch.length >= 2 ? debouncedSearch : undefined,\n sort_by: sortBy as "name" | "price" | "created_at" | "updated_at",\n sort_order: sortOrder as "asc" | "desc",\n in_stock: inStockOnly || undefined,\n tags: selectedTags.length > 0 ? selectedTags : undefined,\n min_price: minPrice ? parseFloat(minPrice) : undefined,\n max_price: maxPrice ? parseFloat(maxPrice) : undefined,\n page,\n limit: pageSize,\n });\n\n const { categories: fetchedCategories } = useCategories({\n enabled: categoriesProp === undefined,\n });\n\n const products = productsProp ?? fetchedProducts;\n const categories = categoriesProp ?? fetchedCategories;\n const totalPages = pagination?.total_pages ?? 1;\n\n const handleCategoryChange = useCallback((id: string | null) => {\n setSelectedCategory(id);\n setPage(1);\n }, []);\n\n const handleSortChange = useCallback((by: string, order: string) => {\n setSortBy(by);\n setSortOrder(order);\n setPage(1);\n }, []);\n\n const handleTagToggle = useCallback((tag: string) => {\n setSelectedTags((prev) =>\n prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag],\n );\n setPage(1);\n }, []);\n\n const handlePriceRangeChange = useCallback((min: string, max: string) => {\n setMinPrice(min);\n setMaxPrice(max);\n setPage(1);\n }, []);\n\n const key = template ?? CatalogueTemplate.Default;\n const Layout = layouts?.[key] ?? BUILT_IN_LAYOUTS[key] ?? DefaultCatalogueLayout;\n\n return (\n <div data-cimplify-catalogue-page className={cn(className, classNames?.root)}>\n <Layout\n products={products}\n categories={categories}\n facets={facets}\n isLoading={isLoading}\n selectedCategory={selectedCategory}\n onCategoryChange={handleCategoryChange}\n searchQuery={searchQuery}\n onSearchChange={setSearchQuery}\n sortBy={sortBy}\n sortOrder={sortOrder}\n onSortChange={handleSortChange}\n inStockOnly={inStockOnly}\n onInStockChange={setInStockOnly}\n selectedTags={selectedTags}\n availableTags={availableTags}\n onTagToggle={handleTagToggle}\n minPrice={minPrice}\n maxPrice={maxPrice}\n onPriceRangeChange={handlePriceRangeChange}\n page={page}\n totalPages={totalPages}\n onPageChange={setPage}\n renderCard={renderCard}\n renderImage={renderImage}\n renderLink={renderLink}\n onQuickAdd={onQuickAdd}\n onProductClick={onProductClick}\n title={title}\n />\n </div>\n );\n}\n' }] }, "default-product-layout": { "name": "default-product-layout", "title": "DefaultProductLayout", "description": "Two-column product layout for retail/physical products with sale badges, specs, and properties.", "type": "component", "registryDependencies": ["product-image-gallery", "product-customizer", "price", "price-range", "availability-badge", "sale-badge", "cn"], "files": [{ "path": "layouts/default-product-layout.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { ProductLayoutProps } from "@cimplify/sdk/react";\nimport {\n TwoColumnGrid,\n RelatedProductsSection,\n CustomAttributesTable,\n PropertiesTable,\n TagPills,\n InventoryBadge,\n} from "@cimplify/sdk/react";\nimport { ProductImageGallery } from "@cimplify/sdk/react";\nimport { ProductCustomizer } from "@cimplify/sdk/react";\nimport { Price } from "@cimplify/sdk/react";\nimport { PriceRange } from "@cimplify/sdk/react";\nimport { AvailabilityBadge } from "@cimplify/sdk/react";\nimport { SaleBadge } from "@cimplify/sdk/react";\nimport { parsePrice, isOnSale, getBasePrice, getDiscountPercentage } from "@cimplify/sdk";\nimport { cn } from "@cimplify/sdk/react";\n\nexport function DefaultProductLayout({\n product,\n onAddToCart,\n renderBreadcrumb,\n relatedProducts,\n showRelated = true,\n className,\n}: ProductLayoutProps): React.ReactElement {\n const images = product.images || (product.image_url ? [product.image_url] : []);\n const hasRange = (product.quantity_pricing && product.quantity_pricing.length > 1)\n || (product.variants && product.variants.length > 1);\n const onSale = isOnSale(product);\n const hasPhysicalDetails = product.sku || product.vendor || product.material;\n\n return (\n <div data-cimplify-product-layout="default" className={cn("space-y-8", className)}>\n {renderBreadcrumb?.(product)}\n\n <TwoColumnGrid\n left={\n <ProductImageGallery\n images={images}\n productName={product.name}\n />\n }\n right={\n <div className="space-y-6">\n {/* Brand + Tags */}\n <div className="flex flex-wrap items-center gap-2">\n {product.vendor && (\n <span className="text-xs font-semibold text-muted-foreground uppercase tracking-widest">\n {product.vendor}\n </span>\n )}\n <TagPills tags={product.tags || []} />\n </div>\n\n {/* Name */}\n <h1 data-cimplify-product-layout-name className="text-3xl lg:text-4xl font-extrabold tracking-tight">\n {product.name}\n </h1>\n\n {/* Price */}\n <div className="flex items-baseline gap-3">\n {hasRange ? (\n <PriceRange product={product} className="text-2xl font-extrabold" />\n ) : onSale ? (\n <>\n <Price amount={product.default_price} className="text-2xl font-extrabold" />\n <Price amount={getBasePrice(product)} className="text-lg text-muted-foreground line-through" />\n <span className="text-sm font-bold text-destructive bg-destructive/10 px-2 py-0.5">\n Save {getDiscountPercentage(product)}%\n </span>\n </>\n ) : (\n <Price amount={product.default_price} className="text-2xl font-semibold" />\n )}\n </div>\n\n {/* Inventory */}\n <InventoryBadge product={product} />\n\n {/* Description */}\n {product.description && (\n <p data-cimplify-product-layout-description className="text-muted-foreground leading-relaxed">\n {product.description}\n </p>\n )}\n\n {/* Physical product details */}\n {hasPhysicalDetails && (\n <div data-cimplify-product-layout-details className="space-y-1 text-sm text-muted-foreground">\n {product.sku && <p>SKU: <span className="font-mono text-foreground">{product.sku}</span></p>}\n {product.barcode && <p>Barcode: <span className="font-mono text-foreground">{product.barcode}</span></p>}\n {product.material && <p>Material: <span className="text-foreground">{product.material}</span></p>}\n {product.vendor && <p>Brand: <span className="text-foreground">{product.vendor}</span></p>}\n {(product.length_mm || product.width_mm || product.height_mm) && (\n <p>Dimensions: <span className="text-foreground">\n {[product.length_mm, product.width_mm, product.height_mm]\n .filter(Boolean)\n .map((d) => `${(d! / 10).toFixed(1)} cm`)\n .join(" x ")}\n </span></p>\n )}\n {product.item_condition && product.item_condition !== "new" && (\n <p>Condition: <span className="text-foreground capitalize">{product.item_condition}</span></p>\n )}\n </div>\n )}\n\n {/* Custom Attributes */}\n <CustomAttributesTable attributes={product.custom_attributes || []} />\n\n {/* Properties */}\n <PropertiesTable properties={product.properties || []} />\n\n {/* Customizer */}\n <ProductCustomizer\n product={product}\n onAddToCart={onAddToCart}\n />\n </div>\n }\n />\n\n {showRelated && relatedProducts && relatedProducts.length > 0 && (\n <RelatedProductsSection\n products={relatedProducts}\n />\n )}\n </div>\n );\n}\n' }] }, "account": { "name": "account", "title": "CimplifyAccount", "description": "Iframe wrapper for the Cimplify account portal \u2014 sign-in, orders, addresses, settings.", "type": "component", "registryDependencies": ["cn"], "files": [{ "path": "account.tsx", "content": '"use client";\n\nimport React, { useEffect, useRef, useState, useMemo } from "react";\nimport type { CimplifyClient } from "../client";\nimport { useOptionalCimplify } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface CimplifyAccountProps {\n /** CimplifyClient instance. Falls back to provider context. */\n client?: CimplifyClient;\n /** Override the Link base URL. */\n linkUrl?: string;\n /** Initial section to show: "dashboard" | "orders" | "addresses" | "payment-methods" | "sessions" | "settings". */\n section?: string;\n /** Appearance variables \u2014 passed to Link for theming. */\n appearance?: {\n theme?: "light" | "dark";\n variables?: {\n primaryColor?: string;\n fontFamily?: string;\n borderRadius?: string;\n };\n };\n /** Called when the user logs out from the account portal. */\n onLogout?: () => void;\n /** Additional CSS class for the container. */\n className?: string;\n}\n\nconst DEFAULT_LINK_URL = "https://link.cimplify.io";\n\nexport function CimplifyAccount({\n client: clientProp,\n linkUrl,\n section,\n appearance,\n onLogout,\n className,\n}: CimplifyAccountProps): React.ReactElement {\n const context = useOptionalCimplify();\n const client = clientProp ?? context?.client;\n const resolvedLinkUrl = linkUrl || DEFAULT_LINK_URL;\n\n const iframeRef = useRef<HTMLIFrameElement | null>(null);\n const [height, setHeight] = useState(400);\n const [isReady, setIsReady] = useState(false);\n\n const iframeSrc = useMemo(() => {\n const path = section ? `/elements/account/${section}` : "/elements/account";\n const url = new URL(path, resolvedLinkUrl);\n if (client) {\n const businessId = client.getBusinessId?.() ?? "";\n if (businessId) url.searchParams.set("businessId", businessId);\n }\n return url.toString();\n }, [resolvedLinkUrl, section, client]);\n\n // Listen for messages from the iframe\n useEffect(() => {\n function handleMessage(event: MessageEvent) {\n if (!event.data || typeof event.data !== "object") return;\n\n // Only accept messages from our iframe\n if (iframeRef.current?.contentWindow && event.source !== iframeRef.current.contentWindow) {\n return;\n }\n\n switch (event.data.type) {\n case "ready":\n setIsReady(true);\n if (typeof event.data.height === "number") {\n setHeight(event.data.height);\n }\n // Send init with token + appearance\n sendInit();\n break;\n\n case "height_change":\n if (typeof event.data.height === "number") {\n setHeight(event.data.height);\n }\n break;\n\n case "logout_complete":\n onLogout?.();\n break;\n }\n }\n\n window.addEventListener("message", handleMessage);\n return () => window.removeEventListener("message", handleMessage);\n }, [appearance, client, onLogout]);\n\n function sendInit() {\n const contentWindow = iframeRef.current?.contentWindow;\n if (!contentWindow) return;\n\n const token = client?.getAccessToken?.();\n contentWindow.postMessage(\n {\n type: "init",\n token: token ?? undefined,\n appearance: appearance ?? undefined,\n },\n resolvedLinkUrl,\n );\n }\n\n return (\n <div\n data-cimplify-account\n className={cn("relative overflow-hidden", className)}\n >\n {!isReady && (\n <div className="flex items-center justify-center py-16">\n <div className="w-6 h-6 border-2 border-border border-t-foreground rounded-full animate-spin" />\n </div>\n )}\n <iframe\n ref={iframeRef}\n src={iframeSrc}\n style={{\n border: "none",\n width: "100%",\n height: `${height}px`,\n display: isReady ? "block" : "none",\n overflow: "hidden",\n background: "transparent",\n }}\n allow="geolocation"\n sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox"\n />\n </div>\n );\n}\n' }] }, "subscription-card": { "name": "subscription-card", "title": "SubscriptionCard", "description": "Subscription card with billing plan options, trial badge, and setup fee.", "type": "component", "registryDependencies": ["price", "cn"], "files": [{ "path": "cards/subscription-card.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { ServiceCardLayoutProps } from "./standard-service-card";\nimport { CardShell, CardImage } from "@cimplify/sdk/react";\nimport { Price } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nconst FREQUENCY_LABELS: Record<string, string> = {\n weekly: "/week",\n biweekly: "/2wk",\n monthly: "/mo",\n quarterly: "/qtr",\n annually: "/yr",\n};\n\nexport function SubscriptionCard({\n product,\n onBook,\n renderImage,\n renderLink,\n className,\n}: ServiceCardLayoutProps): React.ReactElement {\n const image = product.image_url || product.images?.[0] || "";\n const plans = product.billing_plans || [];\n const primaryPlan = plans[0];\n const frequency = primaryPlan?.frequency\n ? FREQUENCY_LABELS[primaryPlan.frequency] || `/${primaryPlan.frequency}`\n : "/mo";\n const hasTrial = primaryPlan?.trial_days != null && primaryPlan.trial_days > 0;\n const tags = product.tags || [];\n\n return (\n <CardShell product={product} renderLink={renderLink} className={className}>\n <CardImage src={image} alt={product.name} aspectRatio="16/9" renderImage={renderImage}>\n <div className="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent pointer-events-none" />\n\n {/* Price on image */}\n <span className="absolute bottom-3 right-3 text-white font-bold text-lg drop-shadow-sm">\n <Price amount={product.default_price} />{frequency}\n </span>\n\n {/* Trial badge */}\n {hasTrial && (\n <span className="absolute top-3 left-3 text-[11px] font-semibold tracking-wide bg-emerald-50/90 text-emerald-700 border border-emerald-200/50 backdrop-blur px-2 py-0.5 rounded-md">\n {primaryPlan!.trial_days}-day free trial\n </span>\n )}\n </CardImage>\n\n <div className="p-4">\n <h3 className="text-[14.5px] font-bold text-foreground leading-snug tracking-tight">\n {product.name}\n </h3>\n {product.description && (\n <p className="text-[12.5px] text-muted-foreground mt-1 leading-relaxed line-clamp-2">\n {product.description}\n </p>\n )}\n\n {/* Tags */}\n {tags.length > 0 && (\n <div className="flex flex-wrap gap-1 mt-2.5">\n {tags.slice(0, 3).map((tag) => (\n <span key={tag} className="text-[10px] font-medium text-muted-foreground px-1.5 py-0.5 bg-muted rounded">\n {tag}\n </span>\n ))}\n </div>\n )}\n\n {/* Billing plan options */}\n {plans.length > 1 && (\n <div className="mt-3 space-y-1">\n {plans.slice(0, 3).map((plan) => {\n const label = plan.frequency\n ? plan.frequency.charAt(0).toUpperCase() + plan.frequency.slice(1)\n : "Standard";\n const hasMarkup = plan.markup_type && plan.markup_amount;\n const isSavings = plan.markup_type === "percentage" && plan.markup_amount != null && plan.markup_amount < 0;\n\n return (\n <div key={plan.id} className="flex items-center justify-between text-[11px]">\n <span className="text-muted-foreground">{label}</span>\n <span className="font-medium">\n {hasMarkup && isSavings && (\n <span className="text-emerald-600 mr-1">Save {Math.abs(plan.markup_amount!)}%</span>\n )}\n </span>\n </div>\n );\n })}\n </div>\n )}\n\n <div className="flex items-center justify-between mt-3 pt-3 border-t border-border">\n <div className="flex items-center gap-2 text-[11px] text-muted-foreground">\n {hasTrial && (\n <span className="text-emerald-600 font-medium">Free trial</span>\n )}\n {primaryPlan?.setup_fee != null && primaryPlan.setup_fee > 0 && (\n <span><Price amount={primaryPlan.setup_fee} /> setup</span>\n )}\n </div>\n <button\n onClick={(e) => { e.preventDefault(); e.stopPropagation(); onBook?.(product); }}\n className="text-[13px] font-semibold text-primary hover:text-primary/80 transition-colors"\n >\n Subscribe →\n </button>\n </div>\n </div>\n </CardShell>\n );\n}\n' }] }, "product-image-gallery": { "name": "product-image-gallery", "title": "ProductImageGallery", "description": "Main image with thumbnail strip for product images.", "type": "component", "registryDependencies": [], "files": [{ "path": "product-image-gallery.tsx", "content": '"use client";\n\nimport React, { useEffect, useMemo, useState } from "react";\nimport { RadioGroup } from "@base-ui/react/radio-group";\nimport { Radio } from "@base-ui/react/radio";\n\nexport interface ProductImageGalleryProps {\n images: string[];\n productName: string;\n aspectRatio?: "square" | "4/3" | "16/10" | "3/4";\n className?: string;\n}\n\nconst ASPECT_STYLES: Record<string, React.CSSProperties> = {\n square: { aspectRatio: "1/1" },\n "4/3": { aspectRatio: "4/3" },\n "16/10": { aspectRatio: "16/10" },\n "3/4": { aspectRatio: "3/4" },\n};\n\n/**\n * ProductImageGallery \u2014 main image + thumbnail strip.\n *\n * Uses plain `<img>` for framework-agnostic rendering (not Next.js Image).\n */\nexport function ProductImageGallery({\n images,\n productName,\n aspectRatio = "4/3",\n className,\n}: ProductImageGalleryProps): React.ReactElement | null {\n const normalizedImages = useMemo(\n () =>\n images.filter(\n (image): image is string =>\n typeof image === "string" && image.trim().length > 0,\n ),\n [images],\n );\n\n const [selectedImage, setSelectedImage] = useState(0);\n\n useEffect(() => {\n setSelectedImage(0);\n }, [normalizedImages.length, productName]);\n\n if (normalizedImages.length === 0) {\n return null;\n }\n\n const activeImage = normalizedImages[selectedImage] || normalizedImages[0];\n\n return (\n <div data-cimplify-image-gallery className={className}>\n <div\n data-cimplify-image-gallery-main\n style={{ position: "relative", overflow: "hidden", ...ASPECT_STYLES[aspectRatio] }}\n >\n <img\n src={activeImage}\n alt={productName}\n style={{ width: "100%", height: "100%", objectFit: "cover" }}\n data-cimplify-image-gallery-active\n />\n </div>\n\n {normalizedImages.length > 1 && (\n <RadioGroup\n aria-label={`${productName} image thumbnails`}\n value={String(selectedImage)}\n onValueChange={(value) => setSelectedImage(Number(value))}\n data-cimplify-image-gallery-thumbnails\n style={{ display: "flex", gap: "0.5rem", marginTop: "0.75rem" }}\n >\n {normalizedImages.map((image, index) => {\n const isSelected = selectedImage === index;\n return (\n <Radio.Root\n key={`${image}-${index}`}\n value={String(index)}\n data-cimplify-image-gallery-thumb\n data-selected={isSelected || undefined}\n style={{\n width: "4rem",\n height: "4rem",\n overflow: "hidden",\n padding: 0,\n border: "none",\n cursor: "pointer",\n }}\n >\n <img\n src={image}\n alt=""\n style={{ width: "100%", height: "100%", objectFit: "cover" }}\n />\n </Radio.Root>\n );\n })}\n </RadioGroup>\n )}\n </div>\n );\n}\n' }] }, "order-summary": { "name": "order-summary", "title": "OrderSummary", "description": "Single order detail view with line items and totals.", "type": "component", "registryDependencies": ["price", "cn"], "files": [{ "path": "order-summary.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { Order, LineItem, OrderStatus, PaymentState } from "@cimplify/sdk";\nimport { useOrder } from "@cimplify/sdk/react";\nimport { Price } from "@cimplify/sdk/react";\nimport { parsePrice } from "@cimplify/sdk";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface OrderSummaryClassNames {\n root?: string;\n header?: string;\n orderId?: string;\n status?: string;\n paymentState?: string;\n fulfillmentDetails?: string;\n deliveryAddress?: string;\n items?: string;\n lineItem?: string;\n notes?: string;\n totals?: string;\n customer?: string;\n loading?: string;\n serviceInfo?: string;\n digitalInfo?: string;\n bundleBreakdown?: string;\n compositeBreakdown?: string;\n trackingLink?: string;\n reorderButton?: string;\n}\n\nexport interface OrderSummaryProps {\n /** Pass an Order object directly (skips fetch). */\n order?: Order;\n /** Or pass an order ID to fetch via useOrder. */\n orderId?: string;\n /** Poll for status updates. */\n poll?: boolean;\n /** Custom line item renderer. */\n renderLineItem?: (item: LineItem) => React.ReactNode;\n /** Called when the reorder button is clicked. */\n onReorder?: (order: Order) => void;\n /** Called when the order status changes during polling. */\n onStatusChange?: (previousStatus: OrderStatus, newStatus: OrderStatus, order: Order) => void;\n className?: string;\n classNames?: OrderSummaryClassNames;\n}\n\nconst STATUS_LABELS: Record<OrderStatus, string> = {\n pending: "Pending",\n created: "Created",\n confirmed: "Confirmed",\n in_preparation: "In Preparation",\n ready_to_serve: "Ready",\n partially_served: "Partially Served",\n served: "Served",\n delivered: "Delivered",\n picked_up: "Picked Up",\n completed: "Completed",\n cancelled: "Cancelled",\n refunded: "Refunded",\n};\n\nconst PAYMENT_STATE_LABELS: Record<PaymentState, string> = {\n not_paid: "Not Paid",\n paid: "Paid",\n partially_refunded: "Partially Refunded",\n refunded: "Refunded",\n};\n\n/**\n * OrderSummary \u2014 displays a single order\'s details, line items, and totals.\n *\n * Accepts either a pre-loaded `order` object or an `orderId` to fetch.\n * Supports polling for live status updates.\n */\nexport function OrderSummary({\n order: orderProp,\n orderId,\n poll = false,\n renderLineItem,\n onReorder,\n onStatusChange,\n className,\n classNames,\n}: OrderSummaryProps): React.ReactElement {\n const { order: fetched, isLoading } = useOrder(orderProp ? null : orderId, {\n enabled: !orderProp && !!orderId,\n poll,\n onStatusChange,\n });\n\n const order = orderProp ?? fetched;\n\n if (isLoading && !order) {\n return (\n <div\n data-cimplify-order-summary\n aria-busy="true"\n className={cn(className, classNames?.root, classNames?.loading)}\n >\n <div data-cimplify-order-summary-skeleton />\n </div>\n );\n }\n\n if (!order) {\n return (\n <div data-cimplify-order-summary className={cn(className, classNames?.root)}>\n <p>Order not found.</p>\n </div>\n );\n }\n\n return (\n <div data-cimplify-order-summary className={cn(className, classNames?.root)}>\n {/* Header */}\n <div data-cimplify-order-header className={classNames?.header}>\n <span data-cimplify-order-id className={classNames?.orderId}>\n Order #{order.user_friendly_id}\n </span>\n <span\n data-cimplify-order-status\n data-status={order.status}\n className={classNames?.status}\n >\n {STATUS_LABELS[order.status] ?? order.status}\n </span>\n {order.payment_state && (\n <span\n data-cimplify-order-payment-state\n data-payment-state={order.payment_state}\n className={classNames?.paymentState}\n >\n {PAYMENT_STATE_LABELS[order.payment_state] ?? order.payment_state}\n </span>\n )}\n </div>\n\n {/* Fulfillment details */}\n {order.order_type === "pickup" && order.pickup_time && (\n <div data-cimplify-order-fulfillment className={classNames?.fulfillmentDetails}>\n <span>Pickup time: {new Date(order.pickup_time).toLocaleString()}</span>\n </div>\n )}\n {order.order_type === "dine-in" && order.table_number && (\n <div data-cimplify-order-fulfillment className={classNames?.fulfillmentDetails}>\n <span>Table: {order.table_number}</span>\n </div>\n )}\n\n {/* Date */}\n <time data-cimplify-order-date dateTime={order.created_at}>\n {new Date(order.created_at).toLocaleDateString(undefined, {\n year: "numeric",\n month: "long",\n day: "numeric",\n hour: "2-digit",\n minute: "2-digit",\n })}\n </time>\n\n {/* Customer info */}\n {(order.customer_name || order.customer_email) && (\n <div data-cimplify-order-customer className={classNames?.customer}>\n {order.customer_name && (\n <span data-cimplify-order-customer-name>{order.customer_name}</span>\n )}\n {order.customer_email && (\n <span data-cimplify-order-customer-email>{order.customer_email}</span>\n )}\n </div>\n )}\n\n {/* Delivery address */}\n {order.delivery_address && (\n <div data-cimplify-order-delivery-address className={classNames?.deliveryAddress}>\n <span>Delivery address</span>\n <p>{order.delivery_address}</p>\n </div>\n )}\n\n {/* Line items */}\n <div data-cimplify-order-items className={classNames?.items}>\n {order.items.map((item) =>\n renderLineItem ? (\n <React.Fragment key={item.id}>{renderLineItem(item)}</React.Fragment>\n ) : (\n <div key={item.id} data-cimplify-order-line-item className={classNames?.lineItem}>\n <div data-cimplify-order-line-info>\n <span data-cimplify-order-line-qty>{item.quantity}×</span>\n <span data-cimplify-order-line-key>{item.line_key}</span>\n </div>\n <Price amount={item.price} />\n {item.fulfillment_type === "digital" && item.fulfillment_id && (\n <a\n href={item.fulfillment_id}\n target="_blank"\n rel="noopener noreferrer"\n data-cimplify-order-line-download\n className="text-sm text-primary underline"\n >\n Download\n </a>\n )}\n\n {/* Service items: show scheduling and confirmation details */}\n {item.configuration.type === "service" && (\n <div data-cimplify-order-service-info className={classNames?.serviceInfo}>\n {item.configuration.scheduled_start && (\n <span>{"\\u{1F4C5}"} {new Date(item.configuration.scheduled_start).toLocaleDateString()} at {new Date(item.configuration.scheduled_start).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}</span>\n )}\n {item.configuration.confirmation_code && (\n <span>Confirmation: {item.configuration.confirmation_code}</span>\n )}\n {item.configuration.service_status && (\n <span data-status={item.configuration.service_status}>{item.configuration.service_status}</span>\n )}\n </div>\n )}\n\n {/* Digital items: instant delivery badge */}\n {item.configuration.type === "digital" && (\n <div data-cimplify-order-digital-info className={classNames?.digitalInfo}>\n <span>{"\\u26A1"} Instant digital delivery</span>\n </div>\n )}\n\n {/* Bundle items: component breakdown */}\n {item.configuration.type === "bundle" && item.configuration.resolved && (\n <div data-cimplify-order-bundle-breakdown className={classNames?.bundleBreakdown}>\n {item.configuration.resolved.selections.map((sel) => (\n <div key={sel.component_id} data-product-type={sel.product_type}>\n <span>{sel.quantity}× {sel.product_name}</span>\n {sel.product_type === "service" && sel.scheduling?.scheduled_start && (\n <span> — {new Date(sel.scheduling.scheduled_start).toLocaleDateString()}</span>\n )}\n {sel.product_type === "digital" && <span> — Digital</span>}\n </div>\n ))}\n </div>\n )}\n\n {/* Composite items: component breakdown with pricing */}\n {item.configuration.type === "composite" && item.configuration.resolved && (\n <div data-cimplify-order-composite-breakdown className={classNames?.compositeBreakdown}>\n {item.configuration.resolved.selections.map((sel) => (\n <div key={sel.component_id} data-product-type={sel.product_type}>\n <span>{sel.quantity}× {sel.component_name}</span>\n {sel.product_type === "service" && sel.scheduling?.scheduled_start && (\n <span> — {new Date(sel.scheduling.scheduled_start).toLocaleDateString()}</span>\n )}\n <Price amount={sel.unit_price} />\n </div>\n ))}\n </div>\n )}\n </div>\n ),\n )}\n </div>\n\n {/* Customer notes / special instructions */}\n {order.customer_notes && order.customer_notes.length > 0 && (\n <div data-cimplify-order-notes className={classNames?.notes}>\n <span>Notes</span>\n {order.customer_notes.map((note, index) => (\n <p key={index} data-cimplify-order-note>{note}</p>\n ))}\n </div>\n )}\n\n {/* Totals */}\n <div data-cimplify-order-totals className={classNames?.totals}>\n {order.total_discount != null && parsePrice(order.total_discount) !== 0 && (\n <div data-cimplify-order-discount>\n <span>Discount</span>\n <Price amount={order.total_discount} prefix="-" />\n </div>\n )}\n {order.delivery_fee != null && parsePrice(order.delivery_fee) > 0 && (\n <div data-cimplify-order-delivery-fee>\n <span>Delivery fee</span>\n <Price amount={order.delivery_fee} />\n </div>\n )}\n {order.service_charge != null && parsePrice(order.service_charge) !== 0 && (\n <div data-cimplify-order-service-charge>\n <span>Service charge</span>\n <Price amount={order.service_charge} />\n </div>\n )}\n {order.tax != null && parsePrice(order.tax) !== 0 && (\n <div data-cimplify-order-tax>\n <span>Tax</span>\n <Price amount={order.tax} />\n </div>\n )}\n <div data-cimplify-order-total>\n <span>Total</span>\n <Price amount={order.total_price} />\n </div>\n </div>\n\n {/* Tracking */}\n {order.tracking_link && (\n <a\n href={order.tracking_link}\n target="_blank"\n rel="noopener noreferrer"\n data-cimplify-order-tracking\n className={classNames?.trackingLink}\n >\n Track your order\n </a>\n )}\n\n {onReorder && (\n <button\n type="button"\n onClick={() => onReorder(order)}\n data-cimplify-order-reorder\n className={classNames?.reorderButton}\n >\n Reorder\n </button>\n )}\n </div>\n );\n}\n' }] }, "schedule-service-card": { "name": "schedule-service-card", "title": "ScheduleServiceCard", "description": "Service card with next available time slot pills.", "type": "component", "registryDependencies": ["price", "cn"], "files": [{ "path": "cards/schedule-service-card.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { ServiceCardLayoutProps } from "./standard-service-card";\nimport { Price } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\nimport { formatDuration } from "../utils/format-duration";\n\nexport function ScheduleServiceCard({\n product,\n slots,\n onBook,\n renderImage,\n renderLink,\n className,\n}: ServiceCardLayoutProps): React.ReactElement {\n const image = product.image_url || product.images?.[0] || "";\n const href = `/products/${product.slug}`;\n const durationLabel = product.duration_minutes != null\n ? formatDuration(product.duration_minutes, product.duration_unit)\n : null;\n\n const displaySlots = (slots || []).slice(0, 3);\n\n const shellClass = cn(\n "group block text-left cursor-pointer bg-card rounded-[14px] overflow-hidden",\n "shadow-[0_1px_3px_rgba(0,0,0,0.04),0_6px_24px_rgba(0,0,0,0.06)]",\n "transition-all duration-300 ease-[cubic-bezier(0.19,1,0.22,1)]",\n "hover:-translate-y-1 hover:shadow-[0_2px_6px_rgba(0,0,0,0.04),0_12px_40px_rgba(0,0,0,0.10)]",\n className,\n );\n\n const inner = (\n <div className="p-4">\n <div className="flex items-start justify-between gap-3">\n <div className="flex-1 min-w-0">\n <h3 className="text-[14.5px] font-bold text-foreground leading-snug tracking-tight">\n {product.name}\n </h3>\n <p className="text-[12.5px] text-muted-foreground mt-0.5">\n {durationLabel && <>{durationLabel} \xB7 </>}\n <Price amount={product.default_price} />\n </p>\n </div>\n {image && (\n <div className="w-14 h-14 rounded-xl overflow-hidden bg-muted shrink-0">\n {renderImage ? (\n renderImage({ src: image, alt: product.name, className: "w-full h-full object-cover" })\n ) : (\n <img src={image} alt={product.name} className="w-full h-full object-cover" />\n )}\n </div>\n )}\n </div>\n\n {product.description && (\n <p className="text-[12px] text-muted-foreground mt-2 line-clamp-2 leading-relaxed">\n {product.description}\n </p>\n )}\n\n {displaySlots.length > 0 && (\n <div className="mt-4 pt-3 border-t border-border">\n <p className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest mb-2">\n {product.general_service_capacity != null && product.general_service_capacity > 1\n ? "Upcoming classes"\n : "Next available"\n }\n </p>\n <div className="flex gap-2 flex-wrap">\n {displaySlots.map((slot, i) => (\n <button\n key={i}\n onClick={(e) => { e.preventDefault(); e.stopPropagation(); onBook?.(product); }}\n className={cn(\n "text-[12.5px] font-medium px-3 py-1.5 rounded-lg transition-all duration-200 ease-out hover:scale-[1.04]",\n i === 0\n ? "bg-primary text-primary-foreground font-semibold shadow-sm"\n : "bg-muted text-foreground hover:bg-muted/80",\n )}\n >\n {new Date(slot.start_time).toLocaleString(undefined, {\n weekday: "short",\n hour: "numeric",\n minute: "2-digit",\n })}\n </button>\n ))}\n </div>\n </div>\n )}\n </div>\n );\n\n if (renderLink) {\n return renderLink({ href, className: shellClass, children: inner });\n }\n\n return <a href={href} className={shellClass} data-cimplify-card>{inner}</a>;\n}\n' }] }, "recently-viewed": { "name": "recently-viewed", "title": "RecentlyViewed", "description": "Horizontally scrollable rail of recently viewed products, hydrated from local activity state.", "type": "component", "registryDependencies": ["product-card", "cn"], "files": [{ "path": "recently-viewed.tsx", "content": '"use client";\n\nimport React from "react";\nimport { useActivityState } from "./hooks/use-activity-state";\nimport { cn } from "@cimplify/sdk/react";\n\ninterface ViewedProduct {\n product_id: string;\n product_name?: string;\n category_id?: string;\n}\n\nexport interface RecentlyViewedClassNames {\n root?: string;\n item?: string;\n empty?: string;\n loading?: string;\n}\n\nexport interface RecentlyViewedProps {\n /** Maximum number of viewed products to display. */\n limit?: number;\n /** Called when a viewed product is clicked. */\n onProductClick?: (product: ViewedProduct) => void;\n /** Custom product renderer. */\n renderProduct?: (product: ViewedProduct) => React.ReactNode;\n /** Text shown when no viewed products exist. */\n emptyMessage?: string;\n className?: string;\n classNames?: RecentlyViewedClassNames;\n}\n\n/**\n * RecentlyViewed \u2014 displays products the user has recently viewed during their session.\n *\n * Extracts `state.activity.viewed_products` from the activity state.\n * Returns `null` when there are no viewed products.\n */\nexport function RecentlyViewed({\n limit,\n onProductClick,\n renderProduct,\n emptyMessage,\n className,\n classNames,\n}: RecentlyViewedProps): React.ReactElement | null {\n const { state, isLoading } = useActivityState();\n\n const rawViewed = state?.activity?.viewed_products ?? [];\n const viewed = limit ? rawViewed.slice(0, limit) : rawViewed;\n\n if (isLoading && viewed.length === 0) {\n return (\n <div\n data-cimplify-recently-viewed\n aria-busy="true"\n className={cn(className, classNames?.root, classNames?.loading)}\n />\n );\n }\n\n if (viewed.length === 0) {\n return null;\n }\n\n return (\n <div data-cimplify-recently-viewed className={cn(className, classNames?.root)}>\n {viewed.map((product) => (\n <button\n key={product.product_id}\n type="button"\n onClick={() => onProductClick?.(product)}\n data-cimplify-recently-viewed-item\n className={classNames?.item}\n >\n {renderProduct ? (\n renderProduct(product)\n ) : (\n <span>{product.product_name ?? product.product_id}</span>\n )}\n </button>\n ))}\n </div>\n );\n}\n' }] }, "bundle-selector": { "name": "bundle-selector", "title": "BundleSelector", "description": "Bundle component picker with variant choices and price summary.", "type": "component", "registryDependencies": ["price"], "files": [{ "path": "bundle-selector.tsx", "content": '"use client";\n\nimport React, { useState, useCallback, useMemo, useEffect, useRef, useId } from "react";\nimport { RadioGroup } from "@base-ui/react/radio-group";\nimport { Radio } from "@base-ui/react/radio";\nimport type { BundleComponentView, BundleComponentVariantView, BundlePriceType, DurationUnit } from "@cimplify/sdk";\nimport type { Money } from "@cimplify/sdk";\nimport type { BundleSelectionInput } from "@cimplify/sdk";\nimport { parsePrice } from "@cimplify/sdk";\nimport { Price } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface BundleSelectorClassNames {\n root?: string;\n heading?: string;\n components?: string;\n component?: string;\n componentHeader?: string;\n componentQty?: string;\n componentName?: string;\n typeBadge?: string;\n serviceBadge?: string;\n digitalBadge?: string;\n variantPicker?: string;\n variantOption?: string;\n variantOptionSelected?: string;\n variantAdjustment?: string;\n summary?: string;\n savings?: string;\n}\n\nexport interface BundleSelectorProps {\n components: BundleComponentView[];\n bundlePrice?: Money;\n discountValue?: Money;\n pricingType?: BundlePriceType;\n onSelectionsChange: (selections: BundleSelectionInput[]) => void;\n onPriceChange?: (price: number) => void;\n onReady?: (ready: boolean) => void;\n className?: string;\n classNames?: BundleSelectorClassNames;\n}\n\nexport function BundleSelector({\n components,\n bundlePrice,\n discountValue,\n pricingType,\n onSelectionsChange,\n onPriceChange,\n onReady,\n className,\n classNames,\n}: BundleSelectorProps): React.ReactElement | null {\n const [variantChoices, setVariantChoices] = useState<Record<string, string>>({});\n const lastComponentIds = useRef("");\n\n useEffect(() => {\n const ids = components.map((c) => c.id).sort().join();\n if (ids === lastComponentIds.current) return;\n lastComponentIds.current = ids;\n\n const defaults: Record<string, string> = {};\n for (const comp of components) {\n if (comp.variant_id) {\n defaults[comp.id] = comp.variant_id;\n } else if (comp.available_variants.length > 0) {\n const defaultVariant =\n comp.available_variants.find((v) => v.is_default) || comp.available_variants[0];\n if (defaultVariant) {\n defaults[comp.id] = defaultVariant.id;\n }\n }\n }\n setVariantChoices(defaults);\n }, [components]);\n\n const selections = useMemo((): BundleSelectionInput[] => {\n return components.map((comp) => ({\n component_id: comp.id,\n variant_id: variantChoices[comp.id],\n quantity: comp.quantity,\n }));\n }, [components, variantChoices]);\n\n useEffect(() => {\n onSelectionsChange(selections);\n }, [selections, onSelectionsChange]);\n\n useEffect(() => {\n onReady?.(components.length > 0 && selections.length > 0);\n }, [components, selections, onReady]);\n\n const totalPrice = useMemo(() => {\n if (pricingType === "fixed" && bundlePrice) {\n return parsePrice(bundlePrice);\n }\n const componentsTotal = components.reduce((sum, comp) => {\n return sum + getComponentPrice(comp, variantChoices[comp.id]) * comp.quantity;\n }, 0);\n if (pricingType === "percentage_discount" && discountValue) {\n return componentsTotal * (1 - parsePrice(discountValue) / 100);\n }\n if (pricingType === "fixed_discount" && discountValue) {\n return componentsTotal - parsePrice(discountValue);\n }\n return componentsTotal;\n }, [components, variantChoices, pricingType, bundlePrice, discountValue]);\n\n useEffect(() => {\n onPriceChange?.(totalPrice);\n }, [totalPrice, onPriceChange]);\n\n const handleVariantChange = useCallback(\n (componentId: string, variantId: string) => {\n setVariantChoices((prev) => ({ ...prev, [componentId]: variantId }));\n },\n [],\n );\n\n if (components.length === 0) {\n return null;\n }\n\n return (\n <div data-cimplify-bundle-selector className={cn("space-y-4", className, classNames?.root)}>\n <div\n data-cimplify-bundle-heading\n className={cn("flex items-center justify-between py-3", classNames?.heading)}\n >\n <span className="text-base font-bold">Included in this bundle</span>\n </div>\n\n <div data-cimplify-bundle-components className={cn("divide-y divide-border", classNames?.components)}>\n {components.map((comp) => (\n <BundleComponentCard\n key={comp.id}\n component={comp}\n selectedVariantId={variantChoices[comp.id]}\n onVariantChange={(variantId) =>\n handleVariantChange(comp.id, variantId)\n }\n classNames={classNames}\n />\n ))}\n </div>\n\n {bundlePrice && (\n <div\n data-cimplify-bundle-summary\n className={cn("border-t border-border pt-4 flex justify-between text-sm", classNames?.summary)}\n >\n <span className="text-muted-foreground">Bundle price</span>\n <Price amount={bundlePrice} className="font-medium text-primary" />\n </div>\n )}\n {discountValue && (\n <div\n data-cimplify-bundle-savings\n className={cn("flex justify-between text-sm", classNames?.savings)}\n >\n <span className="text-muted-foreground">You save</span>\n <Price amount={discountValue} className="text-green-600 font-medium" />\n </div>\n )}\n </div>\n );\n}\n\nfunction getComponentPrice(\n component: BundleComponentView,\n selectedVariantId: string | undefined,\n): number {\n if (!selectedVariantId || component.available_variants.length === 0) {\n return parsePrice(component.effective_price);\n }\n if (selectedVariantId === component.variant_id) {\n return parsePrice(component.effective_price);\n }\n const bakedAdj = component.variant_id\n ? component.available_variants.find((v) => v.id === component.variant_id)\n : undefined;\n const selectedAdj = component.available_variants.find((v) => v.id === selectedVariantId);\n if (!selectedAdj) return parsePrice(component.effective_price);\n return parsePrice(component.effective_price)\n - parsePrice(bakedAdj?.price_adjustment ?? "0")\n + parsePrice(selectedAdj.price_adjustment);\n}\n\nfunction formatDuration(minutes: number, unit?: DurationUnit): string {\n if (unit === "hours" || (!unit && minutes >= 60 && minutes % 60 === 0)) {\n const h = Math.round(minutes / 60);\n return `${h}h`;\n }\n if (unit === "days" || unit === "nights") {\n const d = Math.round(minutes / 1440);\n return `${d}${unit === "nights" ? "n" : "d"}`;\n }\n return `${minutes}min`;\n}\n\ninterface BundleComponentCardProps {\n component: BundleComponentView;\n selectedVariantId?: string;\n onVariantChange: (variantId: string) => void;\n classNames?: BundleSelectorClassNames;\n}\n\nfunction BundleComponentCard({\n component,\n selectedVariantId,\n onVariantChange,\n classNames,\n}: BundleComponentCardProps): React.ReactElement {\n const idPrefix = useId();\n const showVariantPicker =\n component.allow_variant_choice && component.available_variants.length > 1;\n\n const displayPrice = useMemo(\n () => getComponentPrice(component, selectedVariantId),\n [component, selectedVariantId],\n );\n\n const labelId = `${idPrefix}-bundle-component-${component.id}`;\n\n return (\n <div\n data-cimplify-bundle-component\n className={cn("py-4", classNames?.component)}\n >\n <div\n data-cimplify-bundle-component-header\n className={cn("flex items-center justify-between gap-3", classNames?.componentHeader)}\n >\n <div className="flex items-center gap-2">\n {component.quantity > 1 && (\n <span\n data-cimplify-bundle-component-qty\n className={cn("text-xs font-medium text-primary bg-primary/10 px-1.5 py-0.5 rounded", classNames?.componentQty)}\n >\n ×{component.quantity}\n </span>\n )}\n <span\n id={labelId}\n data-cimplify-bundle-component-name\n className={cn("text-sm", classNames?.componentName)}\n >\n {component.product_name}\n </span>\n {component.product_type === "service" && (\n <span\n data-cimplify-bundle-type-badge="service"\n className={cn(\n "text-[10px] uppercase tracking-wider font-medium text-blue-600",\n classNames?.typeBadge,\n classNames?.serviceBadge,\n )}\n >\n <svg viewBox="0 0 16 16" fill="none" className="inline-block w-3 h-3 mr-0.5 -mt-px" aria-hidden="true">\n <circle cx="8" cy="8" r="7" stroke="currentColor" strokeWidth="1.5"/>\n <path d="M8 4v4l2.5 1.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>\n </svg>\n Service{component.duration_minutes != null && (\n <> · {formatDuration(component.duration_minutes, component.duration_unit)}</>\n )}\n </span>\n )}\n {component.product_type === "digital" && (\n <span\n data-cimplify-bundle-type-badge="digital"\n className={cn(\n "text-[10px] uppercase tracking-wider font-medium text-violet-600",\n classNames?.typeBadge,\n classNames?.digitalBadge,\n )}\n >\n <svg viewBox="0 0 16 16" fill="none" className="inline-block w-3 h-3 mr-0.5 -mt-px" aria-hidden="true">\n <path d="M9 2L4 9h4l-1 5 5-7H8l1-5z" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>\n </svg>\n Digital\n </span>\n )}\n </div>\n <span className="text-sm text-muted-foreground">\n <Price amount={displayPrice} />\n </span>\n </div>\n\n {showVariantPicker && (\n <RadioGroup\n aria-labelledby={labelId}\n value={selectedVariantId ?? ""}\n onValueChange={(value) => {\n onVariantChange(value);\n }}\n data-cimplify-bundle-variant-picker\n className={cn("mt-3 divide-y divide-border", classNames?.variantPicker)}\n >\n {component.available_variants.map((variant: BundleComponentVariantView) => {\n const isSelected = selectedVariantId === variant.id;\n const adjustment = parsePrice(variant.price_adjustment);\n\n return (\n <Radio.Root\n key={variant.id}\n value={variant.id}\n data-cimplify-bundle-variant-option\n data-selected={isSelected || undefined}\n className={cn(\n "w-full flex items-center gap-3 py-3 transition-colors cursor-pointer",\n isSelected ? classNames?.variantOptionSelected : classNames?.variantOption,\n )}\n >\n <span\n className={cn(\n "w-5 h-5 rounded-full border-2 flex items-center justify-center shrink-0 transition-colors",\n isSelected ? "border-primary" : "border-muted-foreground/30",\n )}\n >\n {isSelected && <span className="w-2.5 h-2.5 rounded-full bg-primary" />}\n </span>\n <span className="flex-1 text-sm">\n {variant.display_name}\n </span>\n {adjustment !== 0 && (\n <span\n data-cimplify-bundle-variant-adjustment\n className={cn("text-sm text-muted-foreground", classNames?.variantAdjustment)}\n >\n {adjustment > 0 ? "+" : ""}\n <Price amount={variant.price_adjustment} />\n </span>\n )}\n </Radio.Root>\n );\n })}\n </RadioGroup>\n )}\n </div>\n );\n}\n' }] }, "accommodation-card": { "name": "accommodation-card", "title": "AccommodationCard", "description": "Hotel/accommodation card with per-night pricing, amenities, capacity, and cancellation.", "type": "component", "registryDependencies": ["price", "volume-pricing", "cn"], "files": [{ "path": "cards/accommodation-card.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { ServiceCardLayoutProps } from "./standard-service-card";\nimport { CardShell, CardImage } from "@cimplify/sdk/react";\nimport { Price } from "@cimplify/sdk/react";\nimport { VolumePricing } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport function AccommodationCard({\n product,\n onBook,\n renderImage,\n renderLink,\n className,\n}: ServiceCardLayoutProps): React.ReactElement {\n const image = product.image_url || product.images?.[0] || "";\n const hasTiers = product.quantity_pricing && product.quantity_pricing.length > 1;\n const cancellationHours = product.cancellation_window_minutes\n ? Math.floor(product.cancellation_window_minutes / 60)\n : null;\n const tags = product.tags || [];\n\n return (\n <CardShell product={product} renderLink={renderLink} className={className}>\n <CardImage src={image} alt={product.name} aspectRatio="16/9" renderImage={renderImage}>\n <div className="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent pointer-events-none" />\n\n <span className="absolute bottom-3 left-3 inline-flex items-center text-[12px] font-semibold px-2.5 py-1 rounded-lg bg-background/92 backdrop-blur-xl text-foreground shadow-sm">\n Per night\n </span>\n\n <span className="absolute bottom-3 right-3 text-white font-bold text-lg drop-shadow-sm">\n <Price amount={product.default_price} />/night\n </span>\n </CardImage>\n\n <div className="p-4">\n <h3 className="text-[14.5px] font-bold text-foreground leading-snug tracking-tight">\n {product.name}\n </h3>\n {product.description && (\n <p className="text-[12.5px] text-muted-foreground mt-1 leading-relaxed line-clamp-2">\n {product.description}\n </p>\n )}\n\n {/* Amenity / tag pills */}\n {tags.length > 0 && (\n <div className="flex flex-wrap gap-1 mt-2.5">\n {tags.slice(0, 4).map((tag) => (\n <span key={tag} className="text-[10px] font-medium text-muted-foreground px-1.5 py-0.5 bg-muted rounded">\n {tag}\n </span>\n ))}\n </div>\n )}\n\n {/* Guest capacity */}\n {product.general_service_capacity != null && (\n <div className="flex items-center gap-1.5 mt-2 text-[11px] text-muted-foreground">\n <svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" strokeWidth="1.5" viewBox="0 0 24 24">\n <path strokeLinecap="round" strokeLinejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0" />\n </svg>\n {product.general_service_capacity} guest{product.general_service_capacity > 1 ? "s" : ""}\n </div>\n )}\n\n {/* Extended stay pricing */}\n {hasTiers && (\n <div className="mt-3">\n <VolumePricing tiers={product.quantity_pricing!} />\n </div>\n )}\n\n <div className="flex items-center justify-between mt-3 pt-3 border-t border-border">\n <div className="flex items-center gap-2 text-[11px] text-muted-foreground">\n {cancellationHours != null && (\n <span className="text-emerald-600 font-medium">\n Free cancellation {cancellationHours}h\n </span>\n )}\n {product.min_order_quantity != null && product.min_order_quantity > 1 && (\n <span>Min. {product.min_order_quantity} nights</span>\n )}\n </div>\n <button\n onClick={(e) => { e.preventDefault(); e.stopPropagation(); onBook?.(product); }}\n className="text-[13px] font-semibold text-primary hover:text-primary/80 transition-colors"\n >\n Book room →\n </button>\n </div>\n </div>\n </CardShell>\n );\n}\n' }] }, "booking-list": { "name": "booking-list", "title": "BookingList", "description": "List of booking cards with optional self-fetching.", "type": "component", "registryDependencies": ["booking-card", "cn"], "files": [{ "path": "booking-list.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { CustomerBooking } from "@cimplify/sdk";\nimport { useBookings } from "@cimplify/sdk/react";\nimport { BookingCard } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface BookingListClassNames {\n root?: string;\n item?: string;\n loading?: string;\n empty?: string;\n}\n\nexport interface BookingListProps {\n /** Pre-fetched bookings (skips fetch). */\n bookings?: CustomerBooking[];\n /** Filter: "all", "upcoming", or "past". */\n filter?: "all" | "upcoming" | "past";\n /** Called when cancel is clicked on a booking. */\n onCancel?: (booking: CustomerBooking) => void;\n /** Called when reschedule is clicked on a booking. */\n onReschedule?: (booking: CustomerBooking) => void;\n /** Called when a booking is clicked. */\n onBookingClick?: (booking: CustomerBooking) => void;\n /** Custom booking renderer. */\n renderBooking?: (booking: CustomerBooking) => React.ReactNode;\n /** Text shown when empty. */\n emptyMessage?: string;\n className?: string;\n classNames?: BookingListClassNames;\n}\n\nexport function BookingList({\n bookings: bookingsProp,\n filter,\n onCancel,\n onReschedule,\n onBookingClick,\n renderBooking,\n emptyMessage = "No bookings yet",\n className,\n classNames,\n}: BookingListProps): React.ReactElement {\n const { bookings: fetched, isLoading } = useBookings({\n filter,\n enabled: bookingsProp === undefined,\n });\n\n const bookings = bookingsProp ?? fetched;\n\n if (isLoading && bookings.length === 0) {\n return (\n <div\n data-cimplify-booking-list\n aria-busy="true"\n className={cn(className, classNames?.root, classNames?.loading)}\n />\n );\n }\n\n if (bookings.length === 0) {\n return (\n <div\n data-cimplify-booking-list\n data-empty\n className={cn(className, classNames?.root, classNames?.empty)}\n >\n <p>{emptyMessage}</p>\n </div>\n );\n }\n\n return (\n <div data-cimplify-booking-list className={cn(className, classNames?.root)}>\n {bookings.map((booking) => (\n <div\n key={booking.order_id}\n data-cimplify-booking-list-item\n className={classNames?.item}\n onClick={() => onBookingClick?.(booking)}\n role={onBookingClick ? "button" : undefined}\n tabIndex={onBookingClick ? 0 : undefined}\n onKeyDown={\n onBookingClick\n ? (e) => {\n if (e.key === "Enter" || e.key === " ") {\n e.preventDefault();\n onBookingClick(booking);\n }\n }\n : undefined\n }\n >\n <BookingCard\n booking={booking}\n onCancel={onCancel}\n onReschedule={onReschedule}\n renderBooking={renderBooking}\n />\n </div>\n ))}\n </div>\n );\n}\n' }] }, "lease-service-card": { "name": "lease-service-card", "title": "LeaseServiceCard", "description": "Long-term lease card with per-month/year pricing, volume tiers, and billing.", "type": "component", "registryDependencies": ["price", "volume-pricing", "cn"], "files": [{ "path": "cards/lease-service-card.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { ServiceCardLayoutProps } from "./standard-service-card";\nimport { CardShell, CardImage } from "@cimplify/sdk/react";\nimport { Price } from "@cimplify/sdk/react";\nimport { VolumePricing } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nconst UNIT_LABELS: Record<string, string> = {\n weeks: "week",\n months: "mo",\n years: "yr",\n};\n\nexport function LeaseServiceCard({\n product,\n onBook,\n renderImage,\n renderLink,\n className,\n}: ServiceCardLayoutProps): React.ReactElement {\n const image = product.image_url || product.images?.[0] || "";\n const hasDeposit = product.deposit_type && product.deposit_type !== "none" && product.deposit_amount;\n const hasTiers = product.quantity_pricing && product.quantity_pricing.length > 1;\n const unit = product.duration_unit ? UNIT_LABELS[product.duration_unit] || product.duration_unit : "mo";\n const tags = product.tags || [];\n const cancellationHours = product.cancellation_window_minutes\n ? Math.floor(product.cancellation_window_minutes / 60)\n : null;\n\n return (\n <CardShell product={product} renderLink={renderLink} className={className}>\n <CardImage src={image} alt={product.name} aspectRatio="16/9" renderImage={renderImage}>\n <div className="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent pointer-events-none" />\n\n <span className="absolute bottom-3 left-3 inline-flex items-center text-[12px] font-semibold px-2.5 py-1 rounded-lg bg-background/92 backdrop-blur-xl text-foreground shadow-sm">\n Per {unit}\n </span>\n\n <span className="absolute bottom-3 right-3 text-white font-bold text-lg drop-shadow-sm">\n <Price amount={product.default_price} />/{unit}\n </span>\n </CardImage>\n\n <div className="p-4">\n <h3 className="text-[14.5px] font-bold text-foreground leading-snug tracking-tight">\n {product.name}\n </h3>\n {product.description && (\n <p className="text-[12.5px] text-muted-foreground mt-1 leading-relaxed line-clamp-2">\n {product.description}\n </p>\n )}\n\n {/* Tags as amenity pills */}\n {tags.length > 0 && (\n <div className="flex flex-wrap gap-1 mt-2.5">\n {tags.slice(0, 4).map((tag) => (\n <span key={tag} className="text-[10px] font-medium text-muted-foreground px-1.5 py-0.5 bg-muted rounded">\n {tag}\n </span>\n ))}\n </div>\n )}\n\n {/* Volume tiers (lease terms) */}\n {hasTiers && (\n <div className="mt-3">\n <VolumePricing tiers={product.quantity_pricing!} />\n </div>\n )}\n\n {/* Info row */}\n <div className="flex flex-wrap items-center gap-2 mt-3">\n {product.min_order_quantity != null && product.min_order_quantity > 1 && (\n <span className="text-[10.5px] font-medium text-muted-foreground px-1.5 py-0.5 bg-muted rounded">\n Min. {product.min_order_quantity} {unit}{product.min_order_quantity > 1 ? "s" : ""}\n </span>\n )}\n {hasDeposit && (\n <span className="text-[10.5px] font-medium text-amber-600">\n <Price amount={product.deposit_amount!} /> deposit\n </span>\n )}\n {cancellationHours != null && (\n <span className="text-[10.5px] font-medium text-muted-foreground">\n {cancellationHours >= 720 ? `${Math.floor(cancellationHours / 720)} month notice` : `${cancellationHours}h notice`}\n </span>\n )}\n </div>\n\n <div className="flex items-center justify-between mt-3 pt-3 border-t border-border">\n {product.billing_plans && product.billing_plans.length > 0 && (\n <span className="text-[10.5px] font-semibold text-primary px-1.5 py-0.5 bg-primary/10 rounded">\n {product.billing_plans[0].frequency} billing\n </span>\n )}\n <button\n onClick={(e) => { e.preventDefault(); e.stopPropagation(); onBook?.(product); }}\n className="text-[13px] font-semibold text-primary hover:text-primary/80 transition-colors ml-auto"\n >\n Enquire now →\n </button>\n </div>\n </div>\n </CardShell>\n );\n}\n' }] }, "billing-plan-selector": { "name": "billing-plan-selector", "title": "BillingPlanSelector", "description": "Subscription / billing-plan picker \u2014 surfaces eligible plans with pricing and trial periods.", "type": "component", "registryDependencies": ["price", "cn"], "files": [{ "path": "billing-plan-selector.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { ProductBillingPlan, BillingFrequency } from "@cimplify/sdk";\nimport { useBillingPlans } from "./hooks/use-billing-plans";\nimport { Price } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface BillingPlanSelectorClassNames {\n root?: string;\n plan?: string;\n activePlan?: string;\n frequency?: string;\n price?: string;\n setupFee?: string;\n trialBadge?: string;\n empty?: string;\n loading?: string;\n}\n\nexport interface BillingPlanSelectorProps {\n /** Product ID to fetch billing plans for. */\n productId: string;\n /** Override plans (skips fetch). For SSR, pass pre-fetched plans. */\n plans?: ProductBillingPlan[];\n /** Called when a plan is selected. */\n onPlanSelect?: (plan: ProductBillingPlan | null) => void;\n /** Currently selected plan ID. */\n selectedPlanId?: string | null;\n /** Show a "One-time purchase" option at the top. */\n showOneTimePurchase?: boolean;\n /** Label for the one-time purchase option. Default: "One-time purchase". */\n oneTimePurchaseLabel?: string;\n /** Custom plan card renderer. */\n renderPlan?: (plan: ProductBillingPlan, isActive: boolean) => React.ReactNode;\n className?: string;\n classNames?: BillingPlanSelectorClassNames;\n}\n\nconst FREQUENCY_LABELS: Record<BillingFrequency, string> = {\n weekly: "Weekly",\n biweekly: "Biweekly",\n monthly: "Monthly",\n quarterly: "Quarterly",\n annually: "Annually",\n};\n\nfunction formatMarkup(plan: ProductBillingPlan): string | null {\n if (!plan.markup_type || plan.markup_amount == null) return null;\n if (plan.markup_type === "percentage") return `+${plan.markup_amount}%`;\n return null;\n}\n\n/**\n * BillingPlanSelector \u2014 subscription/installment plan comparison and selection.\n *\n * Renders plan cards with frequency, markup, trial, and setup fee details.\n * Returns `null` when no plans are available (and one-time purchase is hidden).\n */\nexport function BillingPlanSelector({\n productId,\n plans: plansProp,\n onPlanSelect,\n selectedPlanId,\n showOneTimePurchase = false,\n oneTimePurchaseLabel = "One-time purchase",\n renderPlan,\n className,\n classNames,\n}: BillingPlanSelectorProps): React.ReactElement | null {\n const { plans: fetched, isLoading } = useBillingPlans(productId, {\n enabled: plansProp === undefined,\n });\n\n const plans = plansProp ?? fetched;\n\n if (isLoading && plans.length === 0) {\n return (\n <div\n data-cimplify-billing-plan-selector\n aria-busy="true"\n className={cn(className, classNames?.root, classNames?.loading)}\n />\n );\n }\n\n if (plans.length === 0 && !showOneTimePurchase) {\n return null;\n }\n\n const isOneTimePurchaseSelected = selectedPlanId === null;\n\n return (\n <div data-cimplify-billing-plan-selector className={cn(className, classNames?.root)}>\n {showOneTimePurchase && (\n <button\n type="button"\n onClick={() => onPlanSelect?.(null)}\n data-cimplify-billing-plan\n data-plan-type="one-time"\n data-active={isOneTimePurchaseSelected || undefined}\n aria-pressed={isOneTimePurchaseSelected}\n className={cn(\n classNames?.plan,\n isOneTimePurchaseSelected && classNames?.activePlan,\n )}\n >\n <span data-cimplify-billing-frequency className={classNames?.frequency}>\n {oneTimePurchaseLabel}\n </span>\n </button>\n )}\n\n {plans.map((plan) => {\n const isActive = selectedPlanId === plan.id;\n const markup = formatMarkup(plan);\n\n return (\n <button\n key={plan.id}\n type="button"\n onClick={() => onPlanSelect?.(plan)}\n data-cimplify-billing-plan\n data-plan-type={plan.plan_type}\n data-active={isActive || undefined}\n aria-pressed={isActive}\n className={cn(classNames?.plan, isActive && classNames?.activePlan)}\n >\n {renderPlan ? (\n renderPlan(plan, isActive)\n ) : (\n <>\n <span data-cimplify-billing-frequency className={classNames?.frequency}>\n {FREQUENCY_LABELS[plan.frequency]}\n </span>\n\n {markup && (\n <span data-cimplify-billing-markup className={classNames?.price}>\n {markup}\n </span>\n )}\n\n {plan.markup_type === "fixed" && plan.markup_amount != null && (\n <span data-cimplify-billing-markup className={classNames?.price}>\n +<Price amount={plan.markup_amount} />\n </span>\n )}\n\n {plan.trial_days > 0 && (\n <span data-cimplify-billing-trial className={classNames?.trialBadge}>\n {plan.trial_days}-day trial\n </span>\n )}\n\n {plan.setup_fee > 0 && (\n <span data-cimplify-billing-setup-fee className={classNames?.setupFee}>\n Setup fee: <Price amount={plan.setup_fee} />\n </span>\n )}\n\n {plan.plan_type === "installment" && plan.installment_periods != null && (\n <span data-cimplify-billing-periods>\n {plan.installment_periods} payments\n </span>\n )}\n </>\n )}\n </button>\n );\n })}\n </div>\n );\n}\n' }] }, "digital-product-card": { "name": "digital-product-card", "title": "DigitalProductCard", "description": "Digital product card with type badge, file info, and event details.", "type": "component", "registryDependencies": ["price", "cn"], "files": [{ "path": "cards/digital-product-card.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { CardLayoutProps } from "@cimplify/sdk/react";\nimport { CardShell, CardImage } from "@cimplify/sdk/react";\nimport { Price } from "@cimplify/sdk/react";\n\nconst TYPE_BADGES: Record<string, { label: string; color: string }> = {\n download: { label: "Download", color: "bg-sky-50 text-sky-700 border-sky-200/60" },\n license: { label: "License", color: "bg-blue-50 text-blue-700 border-blue-200/60" },\n event_ticket: { label: "Event", color: "bg-violet-50 text-violet-700 border-violet-200/60" },\n access_pass: { label: "Access", color: "bg-emerald-50 text-emerald-700 border-emerald-200/60" },\n gift_card: { label: "Gift Card", color: "bg-amber-50 text-amber-700 border-amber-200/60" },\n};\n\nexport function DigitalProductCard({\n product,\n renderImage,\n renderLink,\n className,\n}: CardLayoutProps): React.ReactElement {\n const image = product.image_url || product.images?.[0] || "";\n const typeBadge = product.digital_type ? TYPE_BADGES[product.digital_type] : null;\n const isTicket = product.digital_type === "event_ticket";\n\n return (\n <CardShell product={product} renderLink={renderLink} className={className}>\n <CardImage src={image} alt={product.name} aspectRatio="4/3" renderImage={renderImage}>\n {typeBadge && (\n <span className={`absolute top-3 left-3 text-[11px] font-semibold tracking-wide border px-2 py-0.5 rounded-md ${typeBadge.color}`}>\n {typeBadge.label}\n </span>\n )}\n </CardImage>\n\n <div className="p-3.5">\n <h3 className="text-[14.5px] font-semibold text-foreground leading-snug tracking-tight truncate">\n {product.name}\n </h3>\n\n {/* Event: date + venue */}\n {isTicket && (product.event_date || product.venue) && (\n <p className="text-[12px] text-muted-foreground mt-1 truncate">\n {product.event_date && new Date(product.event_date).toLocaleDateString(undefined, { month: "short", day: "numeric" })}\n {product.event_date && product.venue && " \xB7 "}\n {product.venue}\n </p>\n )}\n\n {/* Non-event: description */}\n {!isTicket && product.description && (\n <p className="text-[12px] text-muted-foreground mt-1 line-clamp-1">\n {product.description}\n </p>\n )}\n\n <div className="flex items-center justify-between mt-2.5">\n <Price amount={product.default_price} className="text-sm font-bold" />\n\n {/* File info pill */}\n {product.file_type && (\n <span className="text-[10px] text-muted-foreground bg-muted px-1.5 py-0.5 rounded">\n {product.file_type.toUpperCase()}\n {product.file_size_mb != null && ` \xB7 ${product.file_size_mb >= 1024 ? `${(product.file_size_mb / 1024).toFixed(1)}GB` : `${product.file_size_mb}MB`}`}\n </span>\n )}\n </div>\n </div>\n </CardShell>\n );\n}\n' }] }, "price-range": { "name": "price-range", "title": "PriceRange", "description": "Displays min-max price range for products with variants or tiers.", "type": "component", "registryDependencies": [], "files": [{ "path": "price-range.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { Product, ProductWithDetails } from "@cimplify/sdk";\nimport type { CurrencyCode } from "@cimplify/sdk";\nimport { getPriceRange, formatPriceRange } from "@cimplify/sdk";\nimport { useOptionalCimplify } from "@cimplify/sdk/react";\n\nexport interface PriceRangeProps {\n product: Product | ProductWithDetails;\n currency?: CurrencyCode;\n className?: string;\n}\n\nexport function PriceRange({ product, currency, className }: PriceRangeProps): React.ReactElement | null {\n const context = useOptionalCimplify();\n const resolvedCurrency = currency ?? (context?.displayCurrency as CurrencyCode | undefined) ?? "USD";\n const range = getPriceRange(product);\n\n if (!range) return null;\n\n return (\n <span data-cimplify-price-range className={className}>\n {formatPriceRange(range.min, range.max, resolvedCurrency)}\n </span>\n );\n}\n' }] }, "order-history-page": { "name": "order-history-page", "title": "OrderHistoryPage", "description": "Order list with status filtering and inline detail view.", "type": "component", "registryDependencies": ["order-history", "order-summary", "cn"], "files": [{ "path": "order-history-page.tsx", "content": '"use client";\n\nimport React, { useState, useCallback } from "react";\nimport { Tabs } from "@base-ui/react/tabs";\nimport type { Order, OrderStatus } from "@cimplify/sdk";\nimport { OrderHistory } from "@cimplify/sdk/react";\nimport { OrderSummary } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface OrderHistoryPageClassNames {\n root?: string;\n header?: string;\n title?: string;\n filters?: string;\n filterButton?: string;\n list?: string;\n detail?: string;\n backButton?: string;\n}\n\nexport interface OrderHistoryPageProps {\n /** Page title. */\n title?: string;\n /** Pre-fetched orders for SSR. */\n orders?: Order[];\n /** Max orders to show per page. */\n limit?: number;\n /** Called when navigating to an order detail (e.g. for routing). */\n onOrderNavigate?: (order: Order) => void;\n /** Called when the reorder button is clicked. */\n onReorder?: (order: Order) => void;\n /** Show status filter tabs. Default: true. */\n showFilters?: boolean;\n /** Custom order row renderer. */\n renderOrder?: (order: Order) => React.ReactNode;\n className?: string;\n classNames?: OrderHistoryPageClassNames;\n}\n\nconst STATUS_FILTERS: { label: string; value: OrderStatus | undefined; tabValue: string }[] = [\n { label: "All", value: undefined, tabValue: "all" },\n { label: "Active", value: "confirmed", tabValue: "confirmed" },\n { label: "Completed", value: "completed", tabValue: "completed" },\n { label: "Cancelled", value: "cancelled", tabValue: "cancelled" },\n];\n\n/**\n * OrderHistoryPage \u2014 order list with inline detail view and status filtering.\n *\n * SSR-friendly: pass `orders` prop for server rendering.\n * Supports both inline detail view and external navigation via `onOrderNavigate`.\n */\nexport function OrderHistoryPage({\n title = "Order History",\n orders: ordersProp,\n limit = 20,\n onOrderNavigate,\n onReorder,\n showFilters = true,\n renderOrder,\n className,\n classNames,\n}: OrderHistoryPageProps): React.ReactElement {\n const [statusFilter, setStatusFilter] = useState<OrderStatus | undefined>(undefined);\n const [selectedOrder, setSelectedOrder] = useState<Order | null>(null);\n\n const handleTabChange = useCallback((value: string | number | null) => {\n const filter = STATUS_FILTERS.find((f) => f.tabValue === value);\n setStatusFilter(filter?.value);\n }, []);\n\n const handleOrderClick = useCallback(\n (order: Order) => {\n if (onOrderNavigate) {\n onOrderNavigate(order);\n } else {\n setSelectedOrder(order);\n }\n },\n [onOrderNavigate],\n );\n\n const handleBack = useCallback(() => {\n setSelectedOrder(null);\n }, []);\n\n const activeTabValue = STATUS_FILTERS.find((f) => f.value === statusFilter)?.tabValue ?? "all";\n\n // Inline detail view\n if (selectedOrder && !onOrderNavigate) {\n return (\n <div data-cimplify-order-history-page className={cn(className, classNames?.root)}>\n <div data-cimplify-order-history-detail className={classNames?.detail}>\n <button\n type="button"\n onClick={handleBack}\n data-cimplify-order-history-back\n className={classNames?.backButton}\n >\n Back to orders\n </button>\n <OrderSummary order={selectedOrder} onReorder={onReorder} />\n </div>\n </div>\n );\n }\n\n return (\n <div data-cimplify-order-history-page className={cn(className, classNames?.root)}>\n {/* Header */}\n <div data-cimplify-order-history-header className={classNames?.header}>\n <h1 data-cimplify-order-history-title className={classNames?.title}>\n {title}\n </h1>\n </div>\n\n {/* Status filter */}\n {showFilters && (\n <Tabs.Root value={activeTabValue} onValueChange={handleTabChange}>\n <Tabs.List data-cimplify-order-history-filters className={classNames?.filters}>\n {STATUS_FILTERS.map((filter) => (\n <Tabs.Tab\n key={filter.tabValue}\n value={filter.tabValue}\n data-cimplify-order-filter\n data-selected={statusFilter === filter.value || undefined}\n className={classNames?.filterButton}\n >\n {filter.label}\n </Tabs.Tab>\n ))}\n </Tabs.List>\n </Tabs.Root>\n )}\n\n {/* Order list */}\n <div data-cimplify-order-history-list className={classNames?.list}>\n <OrderHistory\n orders={ordersProp}\n status={statusFilter}\n limit={limit}\n onOrderClick={handleOrderClick}\n renderOrder={renderOrder}\n onReorder={onReorder}\n />\n </div>\n </div>\n );\n}\n' }] }, "wholesale-product-card": { "name": "wholesale-product-card", "title": "WholesaleProductCard", "description": "B2B product card with price range, MOQ badge, and stock count.", "type": "component", "registryDependencies": ["price", "price-range", "cn"], "files": [{ "path": "cards/wholesale-product-card.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { CardLayoutProps } from "@cimplify/sdk/react";\nimport { CardShell, CardImage, QuickAddButton } from "@cimplify/sdk/react";\nimport { Price } from "@cimplify/sdk/react";\nimport { PriceRange } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport function WholesaleProductCard({\n product,\n onQuickAdd,\n renderImage,\n renderLink,\n className,\n}: CardLayoutProps): React.ReactElement {\n const image = product.image_url || product.images?.[0] || "";\n const hasTiers = product.quantity_pricing && product.quantity_pricing.length > 1;\n const status = product.inventory_status;\n\n return (\n <CardShell product={product} renderLink={renderLink} className={className}>\n <CardImage src={image} alt={product.name} aspectRatio="square" renderImage={renderImage}>\n {/* MOQ badge */}\n {product.min_order_quantity != null && product.min_order_quantity > 1 && (\n <span className="absolute top-3 left-3 text-[11px] font-semibold tracking-wide bg-zinc-800 text-white px-2 py-0.5 rounded-md">\n MOQ: {product.min_order_quantity}\n </span>\n )}\n\n <QuickAddButton\n onClick={() => onQuickAdd?.(product)}\n className="absolute bottom-3 right-3"\n />\n </CardImage>\n\n <div className="p-3.5">\n {/* SKU */}\n {product.sku && (\n <p className="text-[10px] text-muted-foreground font-mono">\n {product.sku}\n </p>\n )}\n\n {/* Name */}\n <h3 className="text-[14.5px] font-semibold text-foreground leading-snug tracking-tight mt-1 truncate">\n {product.name}\n </h3>\n\n {/* Price range or single */}\n <div className="mt-2">\n {hasTiers ? (\n <PriceRange product={product} className="text-sm font-bold" />\n ) : (\n <Price amount={product.default_price} className="text-sm font-bold" />\n )}\n </div>\n\n {/* Stock */}\n {status && (\n <div className="flex items-center gap-1.5 mt-2">\n <span className={cn(\n "w-[7px] h-[7px] rounded-full shrink-0",\n status.in_stock\n ? status.low_stock ? "bg-amber-500" : "bg-emerald-500"\n : "bg-red-500",\n )} />\n <span className={cn(\n "text-[10.5px] font-medium",\n status.in_stock\n ? status.low_stock ? "text-amber-600" : "text-emerald-600"\n : "text-red-600",\n )}>\n {!status.in_stock\n ? "Out of stock"\n : status.stock_level != null\n ? `${status.stock_level.toLocaleString()} in stock`\n : "In stock"\n }\n </span>\n </div>\n )}\n </div>\n </CardShell>\n );\n}\n' }] }, "availability-badge": { "name": "availability-badge", "title": "AvailabilityBadge", "description": "Displays in-stock / out-of-stock status for tracked products.", "type": "component", "registryDependencies": ["cn"], "files": [{ "path": "availability-badge.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { Product } from "@cimplify/sdk";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface AvailabilityBadgeClassNames {\n root?: string;\n dot?: string;\n label?: string;\n lowStock?: string;\n stockCount?: string;\n}\n\nexport interface AvailabilityBadgeProps {\n /** The product to check availability for. */\n product: Product;\n /** Override availability (e.g. from location_availability). */\n isAvailable?: boolean;\n /** Override stock status (e.g. from location_availability). */\n isInStock?: boolean;\n /** Current stock quantity. When provided, enables "Only X left" display. */\n stockQuantity?: number;\n /** Threshold at which stock is considered low. Default: 5. */\n lowStockThreshold?: number;\n /** Location context for availability checks. Passed through as a data attribute. */\n locationId?: string;\n className?: string;\n classNames?: AvailabilityBadgeClassNames;\n}\n\n/**\n * AvailabilityBadge \u2014 displays in-stock / out-of-stock status for tracked products.\n *\n * Returns `null` for products that don\'t have inventory tracking enabled,\n * since there\'s no meaningful stock state to show.\n */\nexport function AvailabilityBadge({\n product,\n isAvailable,\n isInStock,\n stockQuantity,\n lowStockThreshold = 5,\n locationId,\n className,\n classNames,\n}: AvailabilityBadgeProps): React.ReactElement | null {\n if (product.is_tracked !== true) {\n return null;\n }\n\n const outOfStock =\n isInStock === false ||\n isAvailable === false ||\n (stockQuantity !== undefined && stockQuantity <= 0);\n const isLowStock =\n !outOfStock &&\n stockQuantity !== undefined &&\n stockQuantity > 0 &&\n stockQuantity <= lowStockThreshold;\n\n const stockState = outOfStock\n ? "out_of_stock"\n : isLowStock\n ? "low_stock"\n : "in_stock";\n const label = outOfStock ? "Out of Stock" : "In Stock";\n\n return (\n <span\n data-cimplify-availability-badge\n data-stock-state={stockState}\n {...(locationId ? { "data-location-id": locationId } : undefined)}\n className={cn(className, classNames?.root, isLowStock && classNames?.lowStock)}\n style={{ display: "inline-flex", alignItems: "center", gap: "0.375rem" }}\n >\n <span\n data-cimplify-availability-dot\n className={classNames?.dot}\n style={{\n display: "inline-block",\n width: "0.5rem",\n height: "0.5rem",\n borderRadius: "9999px",\n }}\n />\n <span data-cimplify-availability-label className={classNames?.label}>\n {label}\n </span>\n {stockQuantity !== undefined && isLowStock && (\n <span data-cimplify-availability-stock-count className={classNames?.stockCount}>\n Only {stockQuantity} left\n </span>\n )}\n </span>\n );\n}\n' }] }, "product-sheet": { "name": "product-sheet", "title": "ProductSheet", "description": "Full product detail view with gallery, header, and customizer.", "type": "component", "registryDependencies": ["price", "product-image-gallery", "product-customizer", "cn"], "files": [{ "path": "product-sheet.tsx", "content": '"use client";\n\nimport React, { useState } from "react";\nimport type { Product, ProductWithDetails, VariantView } from "@cimplify/sdk";\nimport type { AddToCartOptions } from "@cimplify/sdk/react";\nimport { useProduct } from "@cimplify/sdk/react";\nimport { Price } from "@cimplify/sdk/react";\nimport { ProductImageGallery } from "@cimplify/sdk/react";\nimport { ProductCustomizer } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface ProductSheetClassNames {\n root?: string;\n image?: string;\n header?: string;\n name?: string;\n price?: string;\n description?: string;\n customizer?: string;\n loading?: string;\n}\n\nexport interface ProductSheetProps {\n /** A slim Product (triggers lazy-fetch) or a fully-loaded ProductWithDetails. */\n product: Product | ProductWithDetails;\n /** Called when the sheet should close. */\n onClose?: () => void;\n /** Override the default add-to-cart behavior. */\n onAddToCart?: (\n product: ProductWithDetails,\n quantity: number,\n options: AddToCartOptions,\n ) => void | Promise<void>;\n /** Custom image renderer (e.g. Next.js Image). */\n renderImage?: (props: {\n src: string;\n alt: string;\n className?: string;\n }) => React.ReactNode;\n className?: string;\n classNames?: ProductSheetClassNames;\n}\n\nfunction isProductWithDetails(\n product: Product | ProductWithDetails,\n): product is ProductWithDetails {\n return "variants" in product;\n}\n\n/**\n * ProductSheet \u2014 full product detail view composing gallery, header, and customizer.\n *\n * When given a slim `Product`, it lazy-fetches the full details via `useProduct`.\n * When given a `ProductWithDetails`, it skips the fetch entirely.\n */\nexport function ProductSheet({\n product,\n onClose,\n onAddToCart,\n renderImage,\n className,\n classNames,\n}: ProductSheetProps): React.ReactElement {\n const needsFetch = !isProductWithDetails(product);\n const { product: fetched, isLoading } = useProduct(\n product.slug ?? product.id,\n { enabled: needsFetch },\n );\n const fullProduct = needsFetch ? fetched : (product as ProductWithDetails);\n\n // Loading state\n if (isLoading && !fullProduct) {\n return (\n <div\n data-cimplify-product-sheet\n aria-busy="true"\n className={cn(className, classNames?.root, classNames?.loading)}\n >\n <div\n data-cimplify-product-sheet-skeleton\n style={{\n display: "flex",\n flexDirection: "column",\n gap: "1rem",\n }}\n >\n <div\n style={{\n aspectRatio: "4/3",\n backgroundColor: "rgba(0,0,0,0.06)",\n borderRadius: "0.5rem",\n }}\n />\n <div\n style={{\n height: "1.5rem",\n width: "60%",\n backgroundColor: "rgba(0,0,0,0.06)",\n borderRadius: "0.25rem",\n }}\n />\n <div\n style={{\n height: "1rem",\n width: "30%",\n backgroundColor: "rgba(0,0,0,0.06)",\n borderRadius: "0.25rem",\n }}\n />\n </div>\n </div>\n );\n }\n\n // Error state\n if (!fullProduct) {\n return (\n <div\n data-cimplify-product-sheet\n className={cn(className, classNames?.root)}\n >\n <p>Product not found.</p>\n </div>\n );\n }\n\n const [selectedVariant, setSelectedVariant] = useState<VariantView | undefined>(\n undefined,\n );\n\n const variantImages = selectedVariant?.images?.filter(Boolean) ?? [];\n const productImages: string[] = [];\n if (fullProduct.images && fullProduct.images.length > 0) {\n productImages.push(...fullProduct.images.filter(Boolean));\n } else if (fullProduct.image_url) {\n productImages.push(fullProduct.image_url);\n }\n const images: string[] =\n variantImages.length > 0 ? variantImages : productImages;\n\n const hasMultipleImages = images.length > 1;\n const singleImage = images[0];\n\n return (\n <div\n data-cimplify-product-sheet\n className={cn(className, classNames?.root)}\n style={{ display: "flex", flexDirection: "column", gap: "1rem" }}\n >\n {/* Image area */}\n {hasMultipleImages ? (\n <ProductImageGallery\n images={images}\n productName={fullProduct.name}\n className={classNames?.image}\n />\n ) : singleImage ? (\n <div data-cimplify-product-sheet-image className={classNames?.image}>\n {renderImage ? (\n renderImage({ src: singleImage, alt: fullProduct.name })\n ) : (\n <img\n src={singleImage}\n alt={fullProduct.name}\n style={{ width: "100%", height: "auto", objectFit: "cover" }}\n />\n )}\n </div>\n ) : null}\n\n {/* Header */}\n <div data-cimplify-product-sheet-header className={classNames?.header}>\n <h2\n data-cimplify-product-sheet-name\n className={classNames?.name}\n style={{ margin: 0 }}\n >\n {fullProduct.name}\n </h2>\n <Price\n amount={fullProduct.default_price}\n className={classNames?.price}\n />\n </div>\n\n {/* Description \u2014 merchant-authored HTML from the catalogue. */}\n {fullProduct.description && (\n <div\n data-cimplify-product-sheet-description\n className={cn("text-sm leading-relaxed text-muted-foreground [&_p]:m-0 [&_p+p]:mt-2", classNames?.description)}\n dangerouslySetInnerHTML={{ __html: fullProduct.description }}\n />\n )}\n\n {/* Customizer */}\n <ProductCustomizer\n product={fullProduct}\n onAddToCart={onAddToCart}\n onVariantChange={(_id, variant) => setSelectedVariant(variant)}\n className={classNames?.customizer}\n />\n </div>\n );\n}\n' }] }, "bookings-page": { "name": "bookings-page", "title": "BookingsPage", "description": "Account-area page listing a customer's bookings with filters and detail view.", "type": "component", "registryDependencies": ["booking-list", "booking-card", "cn"], "files": [{ "path": "bookings-page.tsx", "content": '"use client";\n\nimport React, { useState, useCallback } from "react";\nimport { Tabs } from "@base-ui/react/tabs";\nimport type { CustomerBooking } from "@cimplify/sdk";\nimport { BookingList } from "@cimplify/sdk/react";\nimport { BookingCard } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface BookingsPageClassNames {\n root?: string;\n header?: string;\n title?: string;\n filters?: string;\n filterButton?: string;\n list?: string;\n detail?: string;\n backButton?: string;\n}\n\nexport interface BookingsPageProps {\n /** Page title. */\n title?: string;\n /** Pre-fetched bookings for SSR. */\n bookings?: CustomerBooking[];\n /** Called when navigating to a booking detail (e.g. for routing). */\n onBookingNavigate?: (booking: CustomerBooking) => void;\n /** Called when cancel is clicked. */\n onCancel?: (booking: CustomerBooking) => void;\n /** Called when reschedule is clicked. */\n onReschedule?: (booking: CustomerBooking) => void;\n /** Show filter tabs. Default: true. */\n showFilters?: boolean;\n /** Custom booking renderer. */\n renderBooking?: (booking: CustomerBooking) => React.ReactNode;\n className?: string;\n classNames?: BookingsPageClassNames;\n}\n\nconst BOOKING_FILTERS: { label: string; value: "all" | "upcoming" | "past" }[] = [\n { label: "All", value: "all" },\n { label: "Upcoming", value: "upcoming" },\n { label: "Past", value: "past" },\n];\n\nexport function BookingsPage({\n title = "My Bookings",\n bookings: bookingsProp,\n onBookingNavigate,\n onCancel,\n onReschedule,\n showFilters = true,\n renderBooking,\n className,\n classNames,\n}: BookingsPageProps): React.ReactElement {\n const [filter, setFilter] = useState<"all" | "upcoming" | "past">("all");\n const [selectedBooking, setSelectedBooking] = useState<CustomerBooking | null>(null);\n\n const handleBookingClick = useCallback(\n (booking: CustomerBooking) => {\n if (onBookingNavigate) {\n onBookingNavigate(booking);\n } else {\n setSelectedBooking(booking);\n }\n },\n [onBookingNavigate],\n );\n\n const handleBack = useCallback(() => {\n setSelectedBooking(null);\n }, []);\n\n if (selectedBooking && !onBookingNavigate) {\n return (\n <div data-cimplify-bookings-page className={cn(className, classNames?.root)}>\n <div data-cimplify-bookings-detail className={classNames?.detail}>\n <button\n type="button"\n onClick={handleBack}\n data-cimplify-bookings-back\n className={classNames?.backButton}\n >\n Back to bookings\n </button>\n <BookingCard\n booking={selectedBooking}\n onCancel={onCancel}\n onReschedule={onReschedule}\n />\n </div>\n </div>\n );\n }\n\n return (\n <div data-cimplify-bookings-page className={cn(className, classNames?.root)}>\n <div data-cimplify-bookings-header className={classNames?.header}>\n <h1 data-cimplify-bookings-title className={classNames?.title}>\n {title}\n </h1>\n </div>\n\n {showFilters && (\n <Tabs.Root\n value={filter}\n onValueChange={(value) => setFilter(value as "all" | "upcoming" | "past")}\n >\n <Tabs.List data-cimplify-bookings-filters className={classNames?.filters}>\n {BOOKING_FILTERS.map((f) => (\n <Tabs.Tab\n key={f.value}\n value={f.value}\n data-cimplify-booking-filter\n data-selected={filter === f.value || undefined}\n className={classNames?.filterButton}\n >\n {f.label}\n </Tabs.Tab>\n ))}\n </Tabs.List>\n </Tabs.Root>\n )}\n\n <div data-cimplify-bookings-list className={classNames?.list}>\n <BookingList\n bookings={bookingsProp}\n filter={filter}\n onCancel={onCancel}\n onReschedule={onReschedule}\n onBookingClick={handleBookingClick}\n renderBooking={renderBooking}\n />\n </div>\n </div>\n );\n}\n' }] }, "currency-selector": { "name": "currency-selector", "title": "CurrencySelector", "description": "Multi-currency switcher backed by the FX provider \u2014 locks display currency and quote ID.", "type": "component", "registryDependencies": ["cn"], "files": [{ "path": "currency-selector.tsx", "content": '"use client";\n\nimport React, { useState, useEffect, useRef } from "react";\nimport type { CurrencyCode } from "@cimplify/sdk";\nimport type { CimplifyError } from "@cimplify/sdk";\nimport type { FxRateResponse } from "../types/fx";\nimport { useOptionalCimplifyClient } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface CurrencySelectorClassNames {\n root?: string;\n option?: string;\n activeOption?: string;\n rate?: string;\n label?: string;\n loading?: string;\n}\n\nexport interface CurrencySelectorProps {\n /** Available currencies to display. */\n currencies: CurrencyCode[];\n /** Currently selected currency. */\n currentCurrency?: CurrencyCode;\n /** Called when a currency is selected. */\n onCurrencyChange?: (currency: CurrencyCode) => void;\n /** Show the exchange rate next to each currency. Requires `baseCurrency`. */\n showRate?: boolean;\n /** Base currency used for rate display. */\n baseCurrency?: CurrencyCode;\n /** Custom currency option renderer. */\n renderCurrency?: (\n currency: CurrencyCode,\n isActive: boolean,\n rate: FxRateResponse | null,\n ) => React.ReactNode;\n className?: string;\n classNames?: CurrencySelectorClassNames;\n}\n\n/**\n * CurrencySelector \u2014 currency switcher for international customers.\n *\n * Renders selectable currency options with optional exchange rate display.\n * Returns `null` when there is only one currency (no selector needed).\n */\nexport function CurrencySelector({\n currencies,\n currentCurrency,\n onCurrencyChange,\n showRate = false,\n baseCurrency,\n renderCurrency,\n className,\n classNames,\n}: CurrencySelectorProps): React.ReactElement | null {\n const context = useOptionalCimplifyClient();\n const client = context?.client ?? null;\n\n const [rates, setRates] = useState<Record<string, FxRateResponse>>({});\n const [isLoadingRates, setIsLoadingRates] = useState(false);\n const requestIdRef = useRef(0);\n\n useEffect(() => {\n if (!showRate || !baseCurrency || !client) return;\n\n const targets = currencies.filter((c) => c !== baseCurrency);\n if (targets.length === 0) return;\n\n const nextRequestId = ++requestIdRef.current;\n setIsLoadingRates(true);\n\n void (async () => {\n const fetched: Record<string, FxRateResponse> = {};\n\n await Promise.all(\n targets.map(async (currency) => {\n const res = await client.fx.getRate(baseCurrency, currency);\n if (res.ok) {\n fetched[currency] = res.value;\n }\n }),\n );\n\n if (nextRequestId !== requestIdRef.current) return;\n\n setRates(fetched);\n setIsLoadingRates(false);\n })();\n }, [client, showRate, baseCurrency, currencies]);\n\n if (currencies.length <= 1) {\n return null;\n }\n\n return (\n <div data-cimplify-currency-selector className={cn(className, classNames?.root)}>\n {currencies.map((currency) => {\n const isActive = currentCurrency === currency;\n const rate = rates[currency] ?? null;\n\n return (\n <button\n key={currency}\n type="button"\n onClick={() => onCurrencyChange?.(currency)}\n data-cimplify-currency-option\n data-active={isActive || undefined}\n aria-pressed={isActive}\n className={cn(\n classNames?.option,\n isActive && classNames?.activeOption,\n )}\n >\n {renderCurrency ? (\n renderCurrency(currency, isActive, rate)\n ) : (\n <>\n <span data-cimplify-currency-label className={classNames?.label}>\n {currency}\n </span>\n {showRate && baseCurrency && currency !== baseCurrency && (\n <span data-cimplify-currency-rate className={classNames?.rate}>\n {isLoadingRates\n ? "..."\n : rate\n ? `1 ${baseCurrency} = ${rate.rate.toFixed(4)} ${currency}`\n : null}\n </span>\n )}\n </>\n )}\n </button>\n );\n })}\n </div>\n );\n}\n' }] }, "store-nav": { "name": "store-nav", "title": "StoreNav", "description": "Top navigation bar with brand, categories, cart badge, and search.", "type": "component", "registryDependencies": ["cn"], "files": [{ "path": "store-nav.tsx", "content": '"use client";\n\nimport React from "react";\nimport { useCart, useCategories } from "@cimplify/sdk/react";\nimport type { Category } from "@cimplify/sdk";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface StoreNavClassNames {\n root?: string;\n brand?: string;\n categories?: string;\n categoryLink?: string;\n actions?: string;\n cartButton?: string;\n cartCount?: string;\n searchButton?: string;\n}\n\nexport interface StoreNavProps {\n /** Store/brand name. */\n storeName?: string;\n /** Custom brand element (logo, etc.). Overrides storeName. */\n renderBrand?: () => React.ReactNode;\n /** Override categories (skips fetch). */\n categories?: Category[];\n /** Called when a category link is clicked. */\n onCategoryClick?: (category: Category) => void;\n /** Called when the cart button is clicked. */\n onCartClick?: () => void;\n /** Called when the search button is clicked. */\n onSearchClick?: () => void;\n /** Hide category navigation. */\n hideCategories?: boolean;\n /** Hide the cart button. */\n hideCart?: boolean;\n /** Hide the search button. */\n hideSearch?: boolean;\n className?: string;\n classNames?: StoreNavClassNames;\n}\n\n/**\n * StoreNav \u2014 top navigation bar with brand, category links, cart badge, and search.\n *\n * Fetches categories via `useCategories` and cart count via `useCart`.\n * Renders as a semantic `<nav>` element.\n */\nexport function StoreNav({\n storeName,\n renderBrand,\n categories: categoriesProp,\n onCategoryClick,\n onCartClick,\n onSearchClick,\n hideCategories = false,\n hideCart = false,\n hideSearch = false,\n className,\n classNames,\n}: StoreNavProps): React.ReactElement {\n const { categories: fetched } = useCategories({\n enabled: !hideCategories && categoriesProp === undefined,\n });\n const { itemCount } = useCart();\n\n const categories = categoriesProp ?? fetched;\n\n return (\n <nav data-cimplify-store-nav className={cn(className, classNames?.root)}>\n {/* Brand */}\n <div data-cimplify-store-nav-brand className={classNames?.brand}>\n {renderBrand ? renderBrand() : storeName && <span>{storeName}</span>}\n </div>\n\n {/* Category links */}\n {!hideCategories && categories.length > 0 && (\n <div data-cimplify-store-nav-categories className={classNames?.categories}>\n {categories.map((category: Category) => (\n <button\n key={category.id}\n type="button"\n onClick={() => onCategoryClick?.(category)}\n data-cimplify-store-nav-category\n className={classNames?.categoryLink}\n >\n {category.name}\n </button>\n ))}\n </div>\n )}\n\n {/* Actions */}\n <div data-cimplify-store-nav-actions className={classNames?.actions}>\n {!hideSearch && (\n <button\n type="button"\n onClick={onSearchClick}\n data-cimplify-store-nav-search\n className={classNames?.searchButton}\n aria-label="Search"\n >\n Search\n </button>\n )}\n\n {!hideCart && (\n <button\n type="button"\n onClick={onCartClick}\n data-cimplify-store-nav-cart\n className={classNames?.cartButton}\n aria-label={`Cart (${itemCount} items)`}\n >\n Cart\n {itemCount > 0 && (\n <span data-cimplify-store-nav-cart-count className={classNames?.cartCount}>\n {itemCount}\n </span>\n )}\n </button>\n )}\n </div>\n </nav>\n );\n}\n' }] }, "deal-banner": { "name": "deal-banner", "title": "DealBanner", "description": "Displays active deals and promotions.", "type": "component", "registryDependencies": ["price", "cn"], "files": [{ "path": "deal-banner.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { Deal } from "@cimplify/sdk";\nimport { useDeals } from "@cimplify/sdk/react";\nimport { Price } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface DealBannerClassNames {\n root?: string;\n item?: string;\n description?: string;\n value?: string;\n badge?: string;\n empty?: string;\n}\n\nexport interface DealBannerProps {\n /** Override deals (skips useDeals fetch). For SSR, pass pre-fetched deals. */\n deals?: Deal[];\n /** Location ID for location-specific deals. */\n locationId?: string;\n /** Called when a deal is clicked. */\n onDealClick?: (deal: Deal) => void;\n /** Custom deal card renderer. */\n renderDeal?: (deal: Deal) => React.ReactNode;\n /** Maximum deals to show. Default: all. */\n limit?: number;\n className?: string;\n classNames?: DealBannerClassNames;\n}\n\nfunction formatBenefitLabel(deal: Deal): string {\n switch (deal.benefit_type) {\n case "percentage":\n return `${deal.value}% off`;\n case "fixed":\n return `Save`;\n case "free_item":\n return "Free item";\n case "buy_x_get_y_free":\n return `Buy ${deal.buy_quantity ?? ""} get ${deal.get_quantity ?? 1} free`;\n case "points":\n return `Earn ${deal.value} points`;\n default:\n return "Special offer";\n }\n}\n\n/**\n * DealBanner \u2014 displays active deals/promotions.\n *\n * Renders as a horizontal scrollable strip or grid of deal cards.\n */\nexport function DealBanner({\n deals: dealsProp,\n locationId,\n onDealClick,\n renderDeal,\n limit,\n className,\n classNames,\n}: DealBannerProps): React.ReactElement | null {\n const { deals: fetched, isLoading } = useDeals({\n locationId,\n enabled: dealsProp === undefined,\n });\n\n const allDeals = dealsProp ?? fetched;\n const deals = limit ? allDeals.slice(0, limit) : allDeals;\n\n if (isLoading && deals.length === 0) {\n return (\n <div\n data-cimplify-deal-banner\n aria-busy="true"\n className={cn(className, classNames?.root)}\n />\n );\n }\n\n if (deals.length === 0) {\n return null;\n }\n\n return (\n <div data-cimplify-deal-banner className={cn(className, classNames?.root)}>\n {deals.map((deal) => (\n <button\n key={deal.id}\n type="button"\n onClick={() => onDealClick?.(deal)}\n data-cimplify-deal-item\n data-benefit-type={deal.benefit_type}\n className={classNames?.item}\n >\n {renderDeal ? (\n renderDeal(deal)\n ) : (\n <>\n <span data-cimplify-deal-badge className={classNames?.badge}>\n {formatBenefitLabel(deal)}\n </span>\n <span data-cimplify-deal-description className={classNames?.description}>\n {deal.description}\n </span>\n {deal.benefit_type === "fixed" && (\n <span data-cimplify-deal-value className={classNames?.value}>\n <Price amount={deal.value} />\n </span>\n )}\n {deal.min_order_value && (\n <span data-cimplify-deal-minimum>\n Min. order <Price amount={deal.min_order_value} />\n </span>\n )}\n </>\n )}\n </button>\n ))}\n </div>\n );\n}\n' }] }, "date-slot-picker": { "name": "date-slot-picker", "title": "DateSlotPicker", "description": "Horizontal date strip with slot picker for service scheduling.", "type": "component", "registryDependencies": ["slot-picker", "cn"], "files": [{ "path": "date-slot-picker.tsx", "content": '"use client";\n\nimport React, { useState, useMemo, useCallback } from "react";\nimport { Tabs } from "@base-ui/react/tabs";\nimport type { AvailableSlot, DayAvailability } from "@cimplify/sdk";\nimport type { DurationUnit, SchedulingMode } from "@cimplify/sdk";\nimport { useServiceAvailability } from "@cimplify/sdk/react";\nimport { SlotPicker } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface DateSlotPickerClassNames {\n root?: string;\n dateStrip?: string;\n dateButton?: string;\n nav?: string;\n navButton?: string;\n slots?: string;\n loading?: string;\n}\n\nexport interface DateSlotPickerProps {\n /** Service ID to fetch availability and slots for. */\n serviceId: string;\n /** Number of days to show in the date strip. Default: 7. */\n daysToShow?: number;\n /** Number of participants. */\n participantCount?: number;\n /** Currently selected slot. */\n selectedSlot?: AvailableSlot | null;\n /** Called when a slot is selected. */\n onSlotSelect?: (slot: AvailableSlot, date: string) => void;\n /** Pre-fetched availability data (skips fetch). */\n availability?: DayAvailability[];\n /** Show price on slots. Default: true. */\n showPrice?: boolean;\n /** Forwarded to `<SlotPicker>` to render multi-day stay labels. */\n schedulingMode?: SchedulingMode;\n /** Forwarded to `<SlotPicker>` \u2014 unit for the stay summary in multi-day mode. */\n durationUnit?: DurationUnit;\n /** Forwarded to `<SlotPicker>` \u2014 value for the stay summary in multi-day mode. */\n durationValue?: number;\n className?: string;\n classNames?: DateSlotPickerClassNames;\n}\n\nfunction formatDate(dateStr: string): string {\n const date = new Date(dateStr + "T00:00:00");\n return date.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" });\n}\n\nfunction toDateString(date: Date): string {\n return date.toISOString().split("T")[0];\n}\n\nfunction addDays(date: Date, days: number): Date {\n const result = new Date(date);\n result.setDate(result.getDate() + days);\n return result;\n}\n\nexport function DateSlotPicker({\n serviceId,\n daysToShow = 7,\n participantCount,\n selectedSlot,\n onSlotSelect,\n availability: availabilityProp,\n showPrice = true,\n schedulingMode,\n durationUnit,\n durationValue,\n className,\n classNames,\n}: DateSlotPickerProps): React.ReactElement {\n const [offset, setOffset] = useState(0);\n const [selectedDate, setSelectedDate] = useState<string>(toDateString(new Date()));\n\n const dateRange = useMemo(() => {\n const today = new Date();\n const start = addDays(today, offset);\n const dates: string[] = [];\n for (let i = 0; i < daysToShow; i++) {\n dates.push(toDateString(addDays(start, i)));\n }\n return {\n dates,\n startDate: dates[0],\n endDate: dates[dates.length - 1],\n };\n }, [offset, daysToShow]);\n\n const { days: fetchedDays, isLoading: availabilityLoading } = useServiceAvailability(\n serviceId,\n dateRange.startDate,\n dateRange.endDate,\n {\n participantCount,\n enabled: availabilityProp === undefined,\n },\n );\n\n const days = availabilityProp ?? fetchedDays;\n\n const availabilityMap = useMemo(() => {\n const map = new Map<string, DayAvailability>();\n for (const day of days) {\n map.set(day.date, day);\n }\n return map;\n }, [days]);\n\n const handlePrev = useCallback(() => {\n setOffset((prev) => Math.max(0, prev - daysToShow));\n }, [daysToShow]);\n\n const handleNext = useCallback(() => {\n setOffset((prev) => prev + daysToShow);\n }, [daysToShow]);\n\n const handleDateChange = useCallback((value: string | number | null) => {\n if (typeof value === "string") {\n setSelectedDate(value);\n }\n }, []);\n\n const handleSlotSelect = useCallback(\n (slot: AvailableSlot) => {\n onSlotSelect?.(slot, selectedDate);\n },\n [onSlotSelect, selectedDate],\n );\n\n return (\n <Tabs.Root\n value={selectedDate}\n onValueChange={handleDateChange}\n data-cimplify-date-slot-picker\n className={cn("flex flex-col gap-4", className, classNames?.root)}\n >\n <div\n data-cimplify-date-nav\n className={cn("flex items-center justify-end gap-2", classNames?.nav)}\n >\n <button\n type="button"\n onClick={handlePrev}\n disabled={offset === 0}\n aria-label="Previous dates"\n data-cimplify-date-nav-prev\n className={cn(\n "grid place-items-center w-8 h-8 rounded-md border border-border text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:cursor-not-allowed disabled:opacity-40",\n classNames?.navButton,\n )}\n >\n ←\n </button>\n <button\n type="button"\n onClick={handleNext}\n aria-label="Next dates"\n data-cimplify-date-nav-next\n className={cn(\n "grid place-items-center w-8 h-8 rounded-md border border-border text-muted-foreground transition-colors hover:bg-muted hover:text-foreground",\n classNames?.navButton,\n )}\n >\n →\n </button>\n </div>\n\n <Tabs.List\n data-cimplify-date-strip\n className={cn("grid grid-cols-7 gap-1 sm:gap-2", classNames?.dateStrip)}\n >\n {dateRange.dates.map((date) => {\n const dayInfo = availabilityMap.get(date);\n const hasAvailability = dayInfo?.has_availability !== false;\n const isSelected = selectedDate === date;\n return (\n <Tabs.Tab\n key={date}\n value={date}\n data-cimplify-date-button\n data-selected={isSelected || undefined}\n data-available={hasAvailability || undefined}\n data-fully-booked={(!hasAvailability) || undefined}\n className={cn(\n "flex flex-col items-center justify-center rounded-md border border-border bg-background px-1 py-2 text-center text-xs font-medium text-foreground transition-colors hover:border-foreground/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring data-[selected]:border-foreground data-[selected]:bg-foreground data-[selected]:text-background data-[fully-booked]:cursor-not-allowed data-[fully-booked]:opacity-40",\n classNames?.dateButton,\n )}\n >\n {formatDate(date)}\n </Tabs.Tab>\n );\n })}\n </Tabs.List>\n\n {availabilityLoading && (\n <div\n data-cimplify-date-slot-loading\n aria-busy="true"\n className={cn("h-32 rounded-md bg-muted/40 animate-pulse", classNames?.loading)}\n />\n )}\n\n <div data-cimplify-date-slots className={classNames?.slots}>\n <SlotPicker\n serviceId={serviceId}\n date={selectedDate}\n participantCount={participantCount}\n selectedSlot={selectedSlot}\n onSlotSelect={handleSlotSelect}\n showPrice={showPrice}\n schedulingMode={schedulingMode}\n durationUnit={durationUnit}\n durationValue={durationValue}\n />\n </div>\n </Tabs.Root>\n );\n}\n' }] }, "product-page": { "name": "product-page", "title": "ProductPage", "description": "Smart product page resolver with per-slug and per-type template routing.", "type": "component", "registryDependencies": ["default-product-layout", "food-product-layout", "wholesale-product-layout", "service-product-layout", "digital-product-layout", "cn"], "files": [{ "path": "product-page.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { Product, ProductWithDetails } from "@cimplify/sdk";\nimport { PRODUCT_TYPE, RENDER_HINT } from "@cimplify/sdk";\nimport type { ProductLayoutProps } from "@cimplify/sdk/react";\nimport { DefaultProductLayout } from "@cimplify/sdk/react";\nimport { FoodProductLayout } from "@cimplify/sdk/react";\nimport { WholesaleProductLayout } from "@cimplify/sdk/react";\nimport { ServiceProductLayout } from "@cimplify/sdk/react";\nimport { DigitalProductLayout } from "@cimplify/sdk/react";\nimport { BundleProductLayout } from "./layouts/bundle-product-layout";\nimport { CompositeProductLayout } from "./layouts/composite-product-layout";\nimport type { AddToCartOptions } from "@cimplify/sdk/react";\nimport { useProduct } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport type { ProductLayoutProps };\n\nexport enum ProductTemplate {\n Default = "default",\n Food = "food",\n Wholesale = "wholesale",\n Service = "service",\n Digital = "digital",\n Bundle = "bundle",\n Composite = "composite",\n Physical = "physical",\n}\n\nexport interface ProductPageClassNames {\n root?: string;\n loading?: string;\n}\n\nexport interface ProductPageProps {\n /** Product slug or ID \u2014 used for client-side fetch when `product` is not provided. */\n productId?: string;\n /** Pre-fetched product for SSR. Skips client-side fetch when provided. */\n product?: ProductWithDetails;\n /** Per-slug page map. Highest priority \u2014 maps a product slug to a custom component. */\n pages?: Record<string, React.ComponentType<ProductLayoutProps>>;\n /** Per-type template map. Overrides built-in layouts for specific template keys. */\n templates?: Partial<Record<ProductTemplate | string, React.ComponentType<ProductLayoutProps>>>;\n /** Override add-to-cart behavior. */\n onAddToCart?: (\n product: ProductWithDetails,\n quantity: number,\n options: AddToCartOptions,\n ) => void | Promise<void>;\n /** Custom image renderer (e.g. Next.js Image). */\n renderImage?: (props: { src: string; alt: string; className?: string }) => React.ReactNode;\n /** Breadcrumb renderer. Receives the product for context. */\n renderBreadcrumb?: (product: ProductWithDetails) => React.ReactNode;\n /** Pre-fetched related products. */\n relatedProducts?: Product[];\n /** Show related products section. Default: true. */\n showRelated?: boolean;\n className?: string;\n classNames?: ProductPageClassNames;\n}\n\nconst BUILT_IN_LAYOUTS: Record<string, React.ComponentType<ProductLayoutProps>> = {\n [ProductTemplate.Food]: FoodProductLayout,\n [ProductTemplate.Wholesale]: WholesaleProductLayout,\n [ProductTemplate.Service]: ServiceProductLayout,\n [ProductTemplate.Digital]: DigitalProductLayout,\n [ProductTemplate.Bundle]: BundleProductLayout,\n [ProductTemplate.Composite]: CompositeProductLayout,\n [ProductTemplate.Default]: DefaultProductLayout,\n};\n\nfunction resolveTemplateKey(product: ProductWithDetails): ProductTemplate | string {\n const metaTemplate = product.metadata?.page_template;\n if (typeof metaTemplate === "string" && metaTemplate.trim()) {\n return metaTemplate.trim();\n }\n\n if (product.type === PRODUCT_TYPE.Bundle) {\n return ProductTemplate.Bundle;\n }\n\n if (product.type === PRODUCT_TYPE.Composite) {\n return ProductTemplate.Composite;\n }\n\n if (product.quantity_pricing && product.quantity_pricing.length > 1) {\n return ProductTemplate.Wholesale;\n }\n\n if (product.type === PRODUCT_TYPE.Service) {\n return ProductTemplate.Service;\n }\n\n if (product.type === PRODUCT_TYPE.Digital) {\n return ProductTemplate.Digital;\n }\n\n if (product.render_hint === RENDER_HINT.Food) {\n return ProductTemplate.Food;\n }\n\n if (product.render_hint === RENDER_HINT.Physical) {\n return ProductTemplate.Physical;\n }\n\n return ProductTemplate.Default;\n}\n\nfunction resolveLayout(\n product: ProductWithDetails,\n pages?: Record<string, React.ComponentType<ProductLayoutProps>>,\n templates?: Partial<Record<string, React.ComponentType<ProductLayoutProps>>>,\n): React.ComponentType<ProductLayoutProps> {\n // 1. Per-slug page (AI-generated, highest priority)\n if (pages?.[product.slug]) {\n return pages[product.slug];\n }\n\n const key = resolveTemplateKey(product);\n\n // 2. Consumer-provided template override\n if (templates?.[key]) {\n return templates[key];\n }\n\n // 3. Built-in layout\n if (BUILT_IN_LAYOUTS[key]) {\n return BUILT_IN_LAYOUTS[key];\n }\n\n // 4. Fallback\n return DefaultProductLayout;\n}\n\nexport function ProductPage({\n productId,\n product: productProp,\n pages,\n templates,\n onAddToCart,\n renderImage,\n renderBreadcrumb,\n relatedProducts,\n showRelated = true,\n className,\n classNames,\n}: ProductPageProps): React.ReactElement {\n const resolvedId = productId || productProp?.slug || productProp?.id || "";\n const { product: fetched, isLoading } = useProduct(resolvedId, {\n enabled: !productProp && resolvedId.length > 0,\n });\n const product = productProp ?? fetched;\n\n if (isLoading && !product) {\n return (\n <div\n data-cimplify-product-page\n aria-busy="true"\n className={cn(className, classNames?.root, classNames?.loading)}\n >\n <div className="grid lg:grid-cols-2 gap-8 lg:gap-12 animate-pulse">\n <div className="aspect-square bg-muted" />\n <div className="space-y-4 py-4">\n <div className="h-8 w-3/5 bg-muted" />\n <div className="h-6 w-2/5 bg-muted" />\n <div className="h-4 w-4/5 bg-muted" />\n <div className="h-4 w-3/5 bg-muted" />\n <div className="h-14 w-full bg-muted mt-8" />\n </div>\n </div>\n </div>\n );\n }\n\n if (!product) {\n return (\n <div data-cimplify-product-page className={cn(className, classNames?.root)}>\n <p className="text-muted-foreground">Product not found.</p>\n </div>\n );\n }\n\n const Layout = resolveLayout(product, pages, templates);\n\n return (\n <div data-cimplify-product-page className={cn(className, classNames?.root)}>\n <Layout\n product={product}\n onAddToCart={onAddToCart}\n renderImage={renderImage}\n renderBreadcrumb={renderBreadcrumb}\n relatedProducts={relatedProducts}\n showRelated={showRelated}\n />\n </div>\n );\n}\n' }] }, "rental-service-card": { "name": "rental-service-card", "title": "RentalServiceCard", "description": "Rental card with per-day/hour pricing, deposit, and availability count.", "type": "component", "registryDependencies": ["price", "cn"], "files": [{ "path": "cards/rental-service-card.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { ServiceCardLayoutProps } from "./standard-service-card";\nimport { CardShell, CardImage, QuickAddButton } from "@cimplify/sdk/react";\nimport { Price } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nconst UNIT_LABELS: Record<string, string> = {\n minutes: "min",\n hours: "hr",\n days: "day",\n nights: "night",\n weeks: "week",\n months: "mo",\n years: "yr",\n};\n\nexport function RentalServiceCard({\n product,\n onBook,\n renderImage,\n renderLink,\n className,\n}: ServiceCardLayoutProps): React.ReactElement {\n const image = product.image_url || product.images?.[0] || "";\n const hasDeposit = product.deposit_type && product.deposit_type !== "none" && product.deposit_amount;\n const unit = product.duration_unit ? UNIT_LABELS[product.duration_unit] || product.duration_unit : "day";\n const status = product.inventory_status;\n\n return (\n <CardShell product={product} renderLink={renderLink} className={className}>\n <CardImage src={image} alt={product.name} aspectRatio="16/9" renderImage={renderImage}>\n <div className="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent pointer-events-none" />\n\n {/* Per-unit pill */}\n <span className="absolute bottom-3 left-3 inline-flex items-center text-[12px] font-semibold px-2.5 py-1 rounded-lg bg-background/92 backdrop-blur-xl text-foreground shadow-sm">\n Per {unit}\n </span>\n\n {/* Price */}\n <span className="absolute bottom-3 right-3 text-white font-bold text-lg drop-shadow-sm">\n <Price amount={product.default_price} />/{unit}\n </span>\n\n {/* Capacity badge */}\n {product.general_service_capacity != null && product.general_service_capacity > 1 && (\n <span className="absolute top-3 left-3 text-[11px] font-semibold tracking-wide bg-blue-50/90 text-blue-700 border border-blue-200/50 backdrop-blur px-2 py-0.5 rounded-md">\n Up to {product.general_service_capacity} guests\n </span>\n )}\n </CardImage>\n\n <div className="p-4">\n <h3 className="text-[14.5px] font-bold text-foreground leading-snug tracking-tight">\n {product.name}\n </h3>\n {product.description && (\n <p className="text-[12.5px] text-muted-foreground mt-1 leading-relaxed line-clamp-2">\n {product.description}\n </p>\n )}\n\n {/* Info pills */}\n <div className="flex flex-wrap items-center gap-2 mt-3">\n {product.min_order_quantity != null && product.min_order_quantity > 1 && (\n <span className="text-[10.5px] font-medium text-muted-foreground px-1.5 py-0.5 bg-muted rounded">\n Min. {product.min_order_quantity} {unit}{product.min_order_quantity > 1 ? "s" : ""}\n </span>\n )}\n {hasDeposit && (\n <span className="text-[10.5px] font-medium text-amber-600">\n <Price amount={product.deposit_amount!} /> deposit\n </span>\n )}\n </div>\n\n <div className="flex items-center justify-between mt-3 pt-3 border-t border-border">\n {/* Availability */}\n {status && (\n <div className="flex items-center gap-1.5 text-[12px]">\n <span className={cn(\n "w-[7px] h-[7px] rounded-full shrink-0",\n status.in_stock ? "bg-emerald-500" : "bg-red-500",\n )} />\n <span className={status.in_stock ? "text-muted-foreground" : "text-red-600"}>\n {status.in_stock\n ? status.stock_level != null\n ? `${status.stock_level} available`\n : "Available"\n : "Unavailable"\n }\n </span>\n </div>\n )}\n <button\n onClick={(e) => { e.preventDefault(); e.stopPropagation(); onBook?.(product); }}\n className="text-[13px] font-semibold text-primary hover:text-primary/80 transition-colors"\n >\n Reserve now →\n </button>\n </div>\n </div>\n </CardShell>\n );\n}\n' }] }, "variant-selector": { "name": "variant-selector", "title": "VariantSelector", "description": "Select product variants via axis chips or direct list.", "type": "component", "registryDependencies": ["price"], "files": [{ "path": "variant-selector.tsx", "content": '"use client";\n\nimport React, { useState, useEffect, useRef, useId } from "react";\nimport { RadioGroup } from "@base-ui/react/radio-group";\nimport { Radio } from "@base-ui/react/radio";\nimport type { VariantView, VariantAxisWithValues } from "@cimplify/sdk";\nimport type { Money } from "@cimplify/sdk";\nimport { parsePrice } from "@cimplify/sdk";\nimport { getVariantDisplayName } from "@cimplify/sdk";\nimport { Price } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface VariantSelectorClassNames {\n root?: string;\n axisLabel?: string;\n axisOptions?: string;\n option?: string;\n optionSelected?: string;\n listLabel?: string;\n list?: string;\n name?: string;\n pricing?: string;\n adjustment?: string;\n}\n\nexport interface VariantSelectorProps {\n variants: VariantView[];\n variantAxes?: VariantAxisWithValues[];\n basePrice?: Money;\n selectedVariantId?: string;\n onVariantChange: (variantId: string | undefined, variant: VariantView | undefined) => void;\n productName?: string;\n className?: string;\n classNames?: VariantSelectorClassNames;\n}\n\nexport function VariantSelector({\n variants,\n variantAxes,\n basePrice,\n selectedVariantId,\n onVariantChange,\n productName,\n className,\n classNames,\n}: VariantSelectorProps): React.ReactElement | null {\n const [axisSelections, setAxisSelections] = useState<Record<string, string>>({});\n const initialized = useRef(false);\n const idPrefix = useId();\n\n useEffect(() => {\n initialized.current = false;\n }, [variants]);\n\n useEffect(() => {\n if (initialized.current) return;\n if (!variants || variants.length === 0) return;\n\n const defaultVariant = variants.find((v) => v.is_default) || variants[0];\n if (!defaultVariant) return;\n\n initialized.current = true;\n onVariantChange(defaultVariant.id, defaultVariant);\n\n if (defaultVariant.display_attributes) {\n const initial: Record<string, string> = {};\n for (const attr of defaultVariant.display_attributes) {\n initial[attr.axis_id] = attr.value_id;\n }\n setAxisSelections(initial);\n }\n }, [variants, onVariantChange]);\n\n useEffect(() => {\n if (!initialized.current) return;\n if (!variantAxes || variantAxes.length === 0) return;\n\n const match = variants.find((v) => {\n if (!v.display_attributes) return false;\n return v.display_attributes.every(\n (attr) => axisSelections[attr.axis_id] === attr.value_id,\n );\n });\n\n if (match && match.id !== selectedVariantId) {\n onVariantChange(match.id, match);\n }\n }, [axisSelections, variants, variantAxes, selectedVariantId, onVariantChange]);\n\n if (!variants || variants.length <= 1) {\n return null;\n }\n\n const basePriceNum = basePrice != null ? parsePrice(basePrice) : 0;\n\n if (variantAxes && variantAxes.length > 0) {\n return (\n <div data-cimplify-variant-selector className={cn("space-y-5", className, classNames?.root)}>\n {variantAxes.map((axis) => {\n const labelId = `${idPrefix}-axis-${axis.id}`;\n return (\n <div key={axis.id} data-cimplify-variant-axis>\n <label\n id={labelId}\n data-cimplify-variant-axis-label\n className={cn("block text-xs font-medium uppercase tracking-wider text-muted-foreground mb-3", classNames?.axisLabel)}\n >\n {axis.name}\n </label>\n <RadioGroup\n aria-labelledby={labelId}\n value={axisSelections[axis.id] ?? ""}\n onValueChange={(value) => {\n setAxisSelections((prev) => ({\n ...prev,\n [axis.id]: value,\n }));\n }}\n data-cimplify-variant-axis-options\n className={cn("flex flex-wrap gap-2", classNames?.axisOptions)}\n >\n {axis.values.map((value) => {\n const isSelected = axisSelections[axis.id] === value.id;\n return (\n <Radio.Root\n key={value.id}\n value={value.id}\n data-cimplify-variant-option\n data-selected={isSelected || undefined}\n className={cn(\n "px-4 py-2 border text-sm font-medium transition-colors border-border hover:border-primary/50",\n isSelected && "bg-primary text-primary-foreground border-primary",\n isSelected ? classNames?.optionSelected : classNames?.option,\n )}\n >\n {value.name}\n </Radio.Root>\n );\n })}\n </RadioGroup>\n </div>\n );\n })}\n </div>\n );\n }\n\n const listLabelId = `${idPrefix}-variant-list`;\n\n return (\n <div data-cimplify-variant-selector className={cn(className, classNames?.root)}>\n <div\n data-cimplify-variant-list-header\n className={cn("flex items-center justify-between py-3", classNames?.listLabel)}\n >\n <label id={listLabelId} className="text-base font-bold">\n Options\n </label>\n <span className="text-xs font-semibold text-destructive bg-destructive/10 px-2.5 py-1 rounded">\n Required\n </span>\n </div>\n <RadioGroup\n aria-labelledby={listLabelId}\n value={selectedVariantId ?? ""}\n onValueChange={(value) => {\n const variant = variants.find((v) => v.id === value);\n onVariantChange(variant?.id, variant);\n }}\n data-cimplify-variant-list\n className={cn("divide-y divide-border", classNames?.list)}\n >\n {variants.map((variant) => {\n const isSelected = selectedVariantId === variant.id;\n const adjustment = parsePrice(variant.price_adjustment);\n\n return (\n <Radio.Root\n key={variant.id}\n value={variant.id}\n data-cimplify-variant-option\n data-selected={isSelected || undefined}\n className={cn(\n "w-full flex items-center gap-3 py-4 transition-colors cursor-pointer",\n isSelected ? classNames?.optionSelected : classNames?.option,\n )}\n >\n <span\n data-cimplify-variant-radio\n className={cn(\n "w-5 h-5 rounded-full border-2 flex items-center justify-center shrink-0 transition-colors",\n isSelected ? "border-primary" : "border-muted-foreground/30",\n )}\n >\n {isSelected && <span className="w-2.5 h-2.5 rounded-full bg-primary" />}\n </span>\n <span\n data-cimplify-variant-name\n className={cn("flex-1 min-w-0 text-sm", classNames?.name)}\n >\n {getVariantDisplayName(variant, productName)}\n </span>\n <span data-cimplify-variant-pricing className={cn("text-sm text-muted-foreground", classNames?.pricing)}>\n {adjustment > 0 ? "+" : adjustment < 0 ? "" : "+"}\n <Price amount={variant.price_adjustment} />\n </span>\n </Radio.Root>\n );\n })}\n </RadioGroup>\n </div>\n );\n}\n' }] }, "price": { "name": "price", "title": "Price", "description": "Renders a formatted price in the display currency.", "type": "component", "registryDependencies": [], "files": [{ "path": "price.tsx", "content": '"use client";\n\nimport React from "react";\nimport { formatPrice } from "@cimplify/sdk";\nimport type { CurrencyCode } from "@cimplify/sdk";\nimport { useOptionalCimplify } from "@cimplify/sdk/react";\n\nexport interface PriceProps {\n /** The amount in base (business) currency. */\n amount: number | string;\n /** Explicit currency \u2014 skips provider lookup when provided. */\n currency?: CurrencyCode;\n /** Optional CSS class name for the wrapping span. */\n className?: string;\n /** Optional prefix rendered before the formatted price (e.g. "+"). */\n prefix?: string;\n}\n\n/**\n * Price \u2014 renders a formatted price value.\n *\n * When used inside CimplifyProvider: reads displayCurrency and convertPrice\n * from context for FX conversion.\n *\n * When used outside a provider (or with explicit `currency` prop): formats\n * directly \u2014 no provider required. Works in any React environment.\n */\nexport function Price({ amount, currency, className, prefix }: PriceProps): React.ReactElement {\n const context = useOptionalCimplify();\n const resolvedCurrency = currency ?? (context?.displayCurrency as CurrencyCode | undefined) ?? "USD";\n const convertedAmount = !currency && context?.convertPrice ? context.convertPrice(amount) : amount;\n const resolvedAmount = typeof convertedAmount === "string" ? parseFloat(convertedAmount) || 0 : convertedAmount;\n\n return (\n <span className={className}>\n {prefix}\n {formatPrice(resolvedAmount, resolvedCurrency)}\n </span>\n );\n}\n' }] }, "customer-input-fields": { "name": "customer-input-fields", "title": "CustomerInputFields", "description": "Per-product custom input fields \u2014 text, number, date, time, file upload, image upload, single/multi-select.", "type": "component", "registryDependencies": ["cn"], "files": [{ "path": "customer-input-fields.tsx", "content": '"use client";\n\nimport React, { useCallback } from "react";\nimport { INPUT_FIELD_TYPE, type AddressValue, type DateRangeValue, type LocationValue, type PhoneValue, type ProductInputField, type SignatureValue } from "@cimplify/sdk";\nimport { parsePrice } from "@cimplify/sdk";\nimport { Price } from "@cimplify/sdk/react";\nimport { useOptionalCimplifyClient } from "@cimplify/sdk/react";\nimport { DatePicker } from "./date-picker";\nimport { TimePicker } from "./time-picker";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface CustomerInputFieldsClassNames {\n root?: string;\n field?: string;\n label?: string;\n required?: string;\n helpText?: string;\n input?: string;\n select?: string;\n textarea?: string;\n fileInput?: string;\n colorInput?: string;\n radioGroup?: string;\n radioOption?: string;\n checkboxLabel?: string;\n priceAdjustment?: string;\n addressInput?: string;\n phoneInput?: string;\n signatureCanvas?: string;\n multiSelectGroup?: string;\n multiSelectOption?: string;\n dateRangeInput?: string;\n locationInput?: string;\n}\n\nexport interface CustomerInputFieldsProps {\n fields: ProductInputField[];\n values: Record<string, unknown>;\n onChange: (values: Record<string, unknown>) => void;\n onFileUpload?: (file: File, field: ProductInputField) => Promise<string>;\n className?: string;\n classNames?: CustomerInputFieldsClassNames;\n}\n\nexport function CustomerInputFields({\n fields,\n values,\n onChange,\n onFileUpload,\n className,\n classNames,\n}: CustomerInputFieldsProps): React.ReactElement | null {\n const clientContext = useOptionalCimplifyClient();\n\n if (fields.length === 0) return null;\n\n const sorted = [...fields].sort((a, b) => a.display_order - b.display_order);\n\n const setValue = useCallback(\n (fieldId: string, value: unknown) => {\n onChange({ ...values, [fieldId]: value });\n },\n [values, onChange],\n );\n\n const defaultFileUpload = useCallback(\n async (file: File): Promise<string> => {\n if (onFileUpload) return onFileUpload(file, {} as ProductInputField);\n if (!clientContext?.client) throw new Error("No upload provider available");\n const result = await clientContext.client.uploads.upload(file);\n if (!result.ok) throw new Error(result.error.message);\n return result.value.url;\n },\n [onFileUpload, clientContext],\n );\n\n return (\n <div data-cimplify-customer-inputs className={cn("space-y-4", className, classNames?.root)}>\n {sorted.map((field) => (\n <InputField\n key={field.id}\n field={field}\n value={values[field.id]}\n onValueChange={(value) => setValue(field.id, value)}\n onFileUpload={onFileUpload ?? (clientContext?.client ? (_file, _field) => defaultFileUpload(_file) : undefined)}\n classNames={classNames}\n />\n ))}\n </div>\n );\n}\n\nfunction InputField({\n field,\n value,\n onValueChange,\n onFileUpload,\n classNames,\n}: {\n field: ProductInputField;\n value: unknown;\n onValueChange: (value: unknown) => void;\n onFileUpload?: (file: File, field: ProductInputField) => Promise<string>;\n classNames?: CustomerInputFieldsClassNames;\n}): React.ReactElement {\n const hasPriceAdjustment = field.price_adjustment != null && parsePrice(field.price_adjustment) > 0;\n\n return (\n <div data-cimplify-customer-input data-field-type={field.field_type} className={classNames?.field}>\n <div className="flex items-center gap-2 mb-1.5">\n <label className={cn("text-sm font-semibold", classNames?.label)}>\n {field.name}\n </label>\n {field.is_required && (\n <span className={cn("text-xs text-destructive", classNames?.required)}>Required</span>\n )}\n {hasPriceAdjustment && (\n <span className={cn("text-xs text-muted-foreground", classNames?.priceAdjustment)}>\n +<Price amount={field.price_adjustment!} />\n </span>\n )}\n </div>\n\n {field.help_text && (\n <p className={cn("text-xs text-muted-foreground mb-1.5", classNames?.helpText)}>\n {field.help_text}\n </p>\n )}\n\n <FieldInput\n field={field}\n value={value}\n onValueChange={onValueChange}\n onFileUpload={onFileUpload}\n classNames={classNames}\n />\n </div>\n );\n}\n\nfunction FieldInput({\n field,\n value,\n onValueChange,\n onFileUpload,\n classNames,\n}: {\n field: ProductInputField;\n value: unknown;\n onValueChange: (value: unknown) => void;\n onFileUpload?: (file: File, field: ProductInputField) => Promise<string>;\n classNames?: CustomerInputFieldsClassNames;\n}): React.ReactElement {\n const inputClass = cn(\n "w-full rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring",\n classNames?.input,\n );\n\n switch (field.field_type) {\n case INPUT_FIELD_TYPE.Text:\n case INPUT_FIELD_TYPE.Url:\n return (\n <input\n type={field.field_type === INPUT_FIELD_TYPE.Url ? "url" : "text"}\n value={typeof value === "string" ? value : ""}\n onChange={(e) => onValueChange(e.target.value)}\n placeholder={field.placeholder}\n required={field.is_required}\n maxLength={field.validation?.max_length ?? undefined}\n className={inputClass}\n />\n );\n\n case INPUT_FIELD_TYPE.Textarea:\n return (\n <textarea\n value={typeof value === "string" ? value : ""}\n onChange={(e) => onValueChange(e.target.value)}\n placeholder={field.placeholder}\n required={field.is_required}\n maxLength={field.validation?.max_length ?? undefined}\n rows={3}\n className={cn(inputClass, "resize-none", classNames?.textarea)}\n />\n );\n\n case INPUT_FIELD_TYPE.Number:\n return (\n <input\n type="number"\n value={typeof value === "number" ? value : ""}\n onChange={(e) => onValueChange(e.target.value ? parseFloat(e.target.value) : undefined)}\n placeholder={field.placeholder}\n required={field.is_required}\n min={field.validation?.min_value ?? undefined}\n max={field.validation?.max_value ?? undefined}\n className={inputClass}\n />\n );\n\n case INPUT_FIELD_TYPE.Select:\n return (\n <select\n value={typeof value === "string" ? value : ""}\n onChange={(e) => onValueChange(e.target.value || undefined)}\n required={field.is_required}\n className={cn(inputClass, classNames?.select)}\n >\n <option value="">{field.placeholder || "Select..."}</option>\n {(field.options || []).map((opt) => (\n <option key={opt} value={opt}>{opt}</option>\n ))}\n </select>\n );\n\n case INPUT_FIELD_TYPE.Radio:\n return (\n <div className={cn("space-y-2", classNames?.radioGroup)}>\n {(field.options || []).map((opt) => (\n <label\n key={opt}\n className={cn(\n "flex items-center gap-2 text-sm cursor-pointer",\n classNames?.radioOption,\n )}\n >\n <input\n type="radio"\n name={field.id}\n value={opt}\n checked={value === opt}\n onChange={() => onValueChange(opt)}\n className="accent-primary"\n />\n {opt}\n </label>\n ))}\n </div>\n );\n\n case INPUT_FIELD_TYPE.Checkbox:\n return (\n <label className={cn("flex items-center gap-2 text-sm cursor-pointer", classNames?.checkboxLabel)}>\n <input\n type="checkbox"\n checked={value === true}\n onChange={(e) => onValueChange(e.target.checked)}\n className="accent-primary w-4 h-4"\n />\n {field.placeholder || field.name}\n </label>\n );\n\n case INPUT_FIELD_TYPE.Color:\n return (\n <input\n type="color"\n value={typeof value === "string" ? value : "#000000"}\n onChange={(e) => onValueChange(e.target.value)}\n className={cn("w-12 h-10 rounded-md border border-input cursor-pointer", classNames?.colorInput)}\n />\n );\n\n case INPUT_FIELD_TYPE.Date:\n return (\n <DatePicker\n value={typeof value === "string" ? value : ""}\n onChange={(next) => onValueChange(next || undefined)}\n required={field.is_required}\n placeholder={field.placeholder ?? "Select a date"}\n aria-label={field.name}\n />\n );\n\n case INPUT_FIELD_TYPE.File:\n case INPUT_FIELD_TYPE.Image:\n return (\n <FileUploadInput\n field={field}\n value={value}\n onValueChange={onValueChange}\n onFileUpload={onFileUpload}\n classNames={classNames}\n />\n );\n\n case INPUT_FIELD_TYPE.Email:\n return (\n <input\n type="email"\n value={typeof value === "string" ? value : ""}\n onChange={(e) => onValueChange(e.target.value)}\n placeholder={field.placeholder || "email@example.com"}\n required={field.is_required}\n className={inputClass}\n />\n );\n\n case INPUT_FIELD_TYPE.DateTime: {\n const stringValue = typeof value === "string" ? value : "";\n const [datePart, timePartRaw] = stringValue.includes("T")\n ? stringValue.split("T", 2)\n : [stringValue, ""];\n const timePart = (timePartRaw ?? "").slice(0, 5);\n const commit = (nextDate: string, nextTime: string): void => {\n if (!nextDate && !nextTime) {\n onValueChange(undefined);\n return;\n }\n if (!nextDate) {\n onValueChange(`${nextTime}`);\n return;\n }\n const combined = `${nextDate}T${nextTime || "00:00"}`;\n const parsed = new Date(combined);\n onValueChange(Number.isNaN(parsed.getTime()) ? combined : parsed.toISOString());\n };\n return (\n <div className="grid grid-cols-1 sm:grid-cols-[1fr_auto] gap-2">\n <DatePicker\n value={datePart ?? ""}\n onChange={(next) => commit(next, timePart)}\n placeholder={field.placeholder ?? "Select a date"}\n aria-label={`${field.name} date`}\n required={field.is_required}\n />\n <TimePicker\n value={timePart}\n onChange={(next) => commit(datePart ?? "", next)}\n placeholder="Time"\n aria-label={`${field.name} time`}\n />\n </div>\n );\n }\n\n case INPUT_FIELD_TYPE.Time:\n return (\n <TimePicker\n value={typeof value === "string" ? value : ""}\n onChange={(next) => onValueChange(next || undefined)}\n required={field.is_required}\n placeholder={field.placeholder ?? "Select a time"}\n aria-label={field.name}\n />\n );\n\n case INPUT_FIELD_TYPE.MultiSelect:\n return (\n <MultiSelectInput\n field={field}\n value={value}\n onValueChange={onValueChange}\n classNames={classNames}\n />\n );\n\n case INPUT_FIELD_TYPE.DateRange:\n return (\n <DateRangeInput\n field={field}\n value={value}\n onValueChange={onValueChange}\n classNames={classNames}\n />\n );\n\n case INPUT_FIELD_TYPE.Address:\n return (\n <AddressInput\n field={field}\n value={value}\n onValueChange={onValueChange}\n classNames={classNames}\n inputClass={inputClass}\n />\n );\n\n case INPUT_FIELD_TYPE.Location:\n return (\n <LocationInput\n field={field}\n value={value}\n onValueChange={onValueChange}\n classNames={classNames}\n inputClass={inputClass}\n />\n );\n\n case INPUT_FIELD_TYPE.Phone:\n return (\n <PhoneInput\n field={field}\n value={value}\n onValueChange={onValueChange}\n classNames={classNames}\n inputClass={inputClass}\n />\n );\n\n case INPUT_FIELD_TYPE.Signature:\n return (\n <SignatureInput\n field={field}\n value={value}\n onValueChange={onValueChange}\n classNames={classNames}\n />\n );\n\n default:\n return (\n <input\n type="text"\n value={typeof value === "string" ? value : ""}\n onChange={(e) => onValueChange(e.target.value)}\n placeholder={field.placeholder}\n className={inputClass}\n />\n );\n }\n}\n\nfunction FileUploadInput({\n field,\n value,\n onValueChange,\n onFileUpload,\n classNames,\n}: {\n field: ProductInputField;\n value: unknown;\n onValueChange: (value: unknown) => void;\n onFileUpload?: (file: File, field: ProductInputField) => Promise<string>;\n classNames?: CustomerInputFieldsClassNames;\n}): React.ReactElement {\n const [isUploading, setIsUploading] = React.useState(false);\n const fileUrl = typeof value === "string" ? value : undefined;\n const acceptedFormats = field.validation?.accepted_formats;\n const accept = acceptedFormats\n ? acceptedFormats.map((f) => `.${f}`).join(",")\n : field.field_type === INPUT_FIELD_TYPE.Image\n ? "image/*"\n : undefined;\n\n const handleFile = async (file: File) => {\n if (!onFileUpload) return;\n\n if (field.validation?.max_size_mb) {\n const maxBytes = field.validation.max_size_mb * 1024 * 1024;\n if (file.size > maxBytes) {\n return;\n }\n }\n\n setIsUploading(true);\n try {\n const url = await onFileUpload(file, field);\n onValueChange(url);\n } finally {\n setIsUploading(false);\n }\n };\n\n return (\n <div className={cn("space-y-2", classNames?.fileInput)}>\n {fileUrl ? (\n <div className="flex items-center gap-3">\n {field.field_type === INPUT_FIELD_TYPE.Image && (\n <img src={fileUrl} alt="Uploaded" className="w-16 h-16 object-cover rounded-md border border-border" />\n )}\n <div className="flex-1 min-w-0">\n <p className="text-sm text-foreground truncate">{fileUrl.split("/").pop()}</p>\n </div>\n <button\n type="button"\n onClick={() => onValueChange(undefined)}\n className="text-xs text-muted-foreground hover:text-destructive transition-colors"\n >\n Remove\n </button>\n </div>\n ) : (\n <label className="flex flex-col items-center justify-center w-full h-24 border-2 border-dashed border-border rounded-lg cursor-pointer hover:border-primary/40 transition-colors">\n <input\n type="file"\n accept={accept}\n onChange={(e) => {\n const file = e.target.files?.[0];\n if (file) void handleFile(file);\n }}\n className="hidden"\n disabled={isUploading}\n />\n {isUploading ? (\n <span className="text-sm text-muted-foreground">Uploading...</span>\n ) : (\n <>\n <span className="text-sm text-muted-foreground">\n {field.placeholder || `Upload ${field.field_type === INPUT_FIELD_TYPE.Image ? "image" : "file"}`}\n </span>\n {acceptedFormats && (\n <span className="text-xs text-muted-foreground/60 mt-1">\n {acceptedFormats.map((f) => f.toUpperCase()).join(", ")}\n {field.validation?.max_size_mb && ` \xB7 Max ${field.validation.max_size_mb}MB`}\n </span>\n )}\n </>\n )}\n </label>\n )}\n </div>\n );\n}\n\nfunction MultiSelectInput({\n field,\n value,\n onValueChange,\n classNames,\n}: {\n field: ProductInputField;\n value: unknown;\n onValueChange: (value: unknown) => void;\n classNames?: CustomerInputFieldsClassNames;\n}): React.ReactElement {\n const selected = Array.isArray(value) ? (value as string[]) : [];\n const options = field.options || [];\n const maxSelections = field.validation?.max_selections;\n\n const toggle = (opt: string) => {\n const next = selected.includes(opt)\n ? selected.filter((s) => s !== opt)\n : maxSelections && selected.length >= maxSelections\n ? selected\n : [...selected, opt];\n onValueChange(next.length > 0 ? next : undefined);\n };\n\n return (\n <div className={cn("space-y-2", classNames?.multiSelectGroup)}>\n {options.map((opt) => (\n <label\n key={opt}\n className={cn(\n "flex items-center gap-2 text-sm cursor-pointer",\n classNames?.multiSelectOption,\n )}\n >\n <input\n type="checkbox"\n checked={selected.includes(opt)}\n onChange={() => toggle(opt)}\n className="accent-primary w-4 h-4"\n />\n {opt}\n </label>\n ))}\n {maxSelections && (\n <p className="text-xs text-muted-foreground">\n {selected.length}/{maxSelections} selected\n </p>\n )}\n </div>\n );\n}\n\nfunction DateRangeInput({\n field,\n value,\n onValueChange,\n classNames,\n}: {\n field: ProductInputField;\n value: unknown;\n onValueChange: (value: unknown) => void;\n classNames?: CustomerInputFieldsClassNames;\n}): React.ReactElement {\n const range = (value && typeof value === "object" ? value : {}) as DateRangeValue;\n\n const update = (key: "start" | "end", v: string) => {\n const next = { ...range, [key]: v };\n onValueChange(next.start || next.end ? next : undefined);\n };\n\n return (\n <div className={cn("grid grid-cols-1 sm:grid-cols-2 gap-3", classNames?.dateRangeInput)}>\n <div>\n <label className="text-xs text-muted-foreground mb-1 block">Start</label>\n <DatePicker\n value={range.start || ""}\n onChange={(next) => update("start", next)}\n required={field.is_required}\n placeholder="Start date"\n aria-label={`${field.name} start date`}\n maxDate={range.end ? new Date(`${range.end}T00:00`) : undefined}\n />\n </div>\n <div>\n <label className="text-xs text-muted-foreground mb-1 block">End</label>\n <DatePicker\n value={range.end || ""}\n onChange={(next) => update("end", next)}\n required={field.is_required}\n placeholder="End date"\n aria-label={`${field.name} end date`}\n minDate={range.start ? new Date(`${range.start}T00:00`) : undefined}\n />\n </div>\n </div>\n );\n}\n\nfunction AddressInput({\n field,\n value,\n onValueChange,\n classNames,\n inputClass,\n}: {\n field: ProductInputField;\n value: unknown;\n onValueChange: (value: unknown) => void;\n classNames?: CustomerInputFieldsClassNames;\n inputClass: string;\n}): React.ReactElement {\n const clientContext = useOptionalCimplifyClient();\n const addr = (value && typeof value === "object" ? value : null) as AddressValue | null;\n const [inputText, setInputText] = React.useState(addr?.formatted_address ?? "");\n const [suggestions, setSuggestions] = React.useState<Array<{ description: string; place_id: string }>>([]);\n const [isOpen, setIsOpen] = React.useState(false);\n const [highlightedIndex, setHighlightedIndex] = React.useState(-1);\n const debounceRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);\n const seqRef = React.useRef(0);\n const sessionTokenRef = React.useRef(createSessionToken());\n const blurTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);\n\n React.useEffect(() => {\n return () => {\n if (debounceRef.current) clearTimeout(debounceRef.current);\n if (blurTimeoutRef.current) clearTimeout(blurTimeoutRef.current);\n seqRef.current += 1;\n };\n }, []);\n\n const fetchSuggestions = React.useCallback(async (query: string) => {\n const client = clientContext?.client;\n if (!client || query.length < 3) {\n setSuggestions([]);\n setIsOpen(false);\n return;\n }\n\n const seq = ++seqRef.current;\n const result = await client.places.autocomplete(query, sessionTokenRef.current);\n if (seqRef.current !== seq) return;\n\n if (result.ok && result.value.predictions.length > 0) {\n setSuggestions(result.value.predictions);\n setHighlightedIndex(0);\n setIsOpen(true);\n } else {\n setSuggestions([]);\n setIsOpen(false);\n }\n }, [clientContext]);\n\n const handleInputChange = (nextValue: string) => {\n setInputText(nextValue);\n if (debounceRef.current) clearTimeout(debounceRef.current);\n debounceRef.current = setTimeout(() => void fetchSuggestions(nextValue.trim()), 300);\n };\n\n const selectSuggestion = React.useCallback(async (suggestion: { description: string; place_id: string }) => {\n if (debounceRef.current) clearTimeout(debounceRef.current);\n if (blurTimeoutRef.current) clearTimeout(blurTimeoutRef.current);\n\n const fallback = suggestion.description.split(",")[0]?.trim() || suggestion.description;\n setInputText(fallback);\n setSuggestions([]);\n setIsOpen(false);\n\n const client = clientContext?.client;\n if (!client) return;\n\n const result = await client.places.details(suggestion.place_id, sessionTokenRef.current);\n sessionTokenRef.current = createSessionToken();\n\n if (result.ok) {\n const d = result.value;\n const addressValue: AddressValue = {\n formatted_address: d.formatted_address || fallback,\n street_address: d.street_address,\n apartment: d.apartment,\n city: d.city,\n region: d.region,\n postal_code: d.postal_code,\n country: d.country,\n latitude: d.latitude,\n longitude: d.longitude,\n place_id: d.place_id,\n };\n setInputText(addressValue.formatted_address);\n onValueChange(addressValue);\n }\n }, [clientContext, onValueChange]);\n\n return (\n <div className={cn("space-y-2 relative", classNames?.addressInput)}>\n <input\n type="text"\n value={inputText}\n onChange={(e) => handleInputChange(e.target.value)}\n onFocus={() => { if (suggestions.length > 0) setIsOpen(true); }}\n onBlur={() => { blurTimeoutRef.current = setTimeout(() => setIsOpen(false), 120); }}\n onKeyDown={(e) => {\n if (e.key === "Escape") { setIsOpen(false); return; }\n if (!isOpen || suggestions.length === 0) return;\n if (e.key === "ArrowDown") { e.preventDefault(); setHighlightedIndex((i) => i < suggestions.length - 1 ? i + 1 : 0); }\n else if (e.key === "ArrowUp") { e.preventDefault(); setHighlightedIndex((i) => i > 0 ? i - 1 : suggestions.length - 1); }\n else if (e.key === "Enter" && highlightedIndex >= 0) { e.preventDefault(); void selectSuggestion(suggestions[highlightedIndex]); }\n }}\n placeholder={field.placeholder || "Search for an address..."}\n required={field.is_required}\n autoComplete="off"\n aria-autocomplete="list"\n aria-expanded={isOpen && suggestions.length > 0}\n className={inputClass}\n />\n {isOpen && suggestions.length > 0 && (\n <div role="listbox" className="absolute left-0 right-0 top-full z-20 mt-1 overflow-hidden rounded-md border border-border bg-background shadow-lg">\n {suggestions.map((s, i) => (\n <button\n key={s.place_id}\n type="button"\n role="option"\n aria-selected={i === highlightedIndex}\n onMouseDown={(e) => { e.preventDefault(); void selectSuggestion(s); }}\n className={cn("block w-full px-3 py-2.5 text-left text-sm transition-colors", i === highlightedIndex ? "bg-muted text-foreground" : "text-foreground hover:bg-muted/70")}\n >\n {s.description}\n </button>\n ))}\n </div>\n )}\n {addr && (\n <input\n type="text"\n value={addr.apartment || ""}\n onChange={(e) => onValueChange({ ...addr, apartment: e.target.value || undefined })}\n placeholder="Apt, suite, unit (optional)"\n className={inputClass}\n />\n )}\n </div>\n );\n}\n\nfunction LocationInput({\n field,\n value,\n onValueChange,\n classNames,\n inputClass,\n}: {\n field: ProductInputField;\n value: unknown;\n onValueChange: (value: unknown) => void;\n classNames?: CustomerInputFieldsClassNames;\n inputClass: string;\n}): React.ReactElement {\n const clientContext = useOptionalCimplifyClient();\n const loc = (value && typeof value === "object" ? value : null) as LocationValue | null;\n const [inputText, setInputText] = React.useState(loc?.label ?? "");\n const [suggestions, setSuggestions] = React.useState<Array<{ description: string; place_id: string }>>([]);\n const [isOpen, setIsOpen] = React.useState(false);\n const [highlightedIndex, setHighlightedIndex] = React.useState(-1);\n const debounceRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);\n const seqRef = React.useRef(0);\n const sessionTokenRef = React.useRef(createSessionToken());\n const blurTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);\n\n React.useEffect(() => {\n return () => {\n if (debounceRef.current) clearTimeout(debounceRef.current);\n if (blurTimeoutRef.current) clearTimeout(blurTimeoutRef.current);\n seqRef.current += 1;\n };\n }, []);\n\n const fetchSuggestions = React.useCallback(async (query: string) => {\n const client = clientContext?.client;\n if (!client || query.length < 3) {\n setSuggestions([]);\n setIsOpen(false);\n return;\n }\n\n const seq = ++seqRef.current;\n const result = await client.places.autocomplete(query, sessionTokenRef.current);\n if (seqRef.current !== seq) return;\n\n if (result.ok && result.value.predictions.length > 0) {\n setSuggestions(result.value.predictions);\n setHighlightedIndex(0);\n setIsOpen(true);\n } else {\n setSuggestions([]);\n setIsOpen(false);\n }\n }, [clientContext]);\n\n const handleInputChange = (nextValue: string) => {\n setInputText(nextValue);\n if (debounceRef.current) clearTimeout(debounceRef.current);\n debounceRef.current = setTimeout(() => void fetchSuggestions(nextValue.trim()), 300);\n };\n\n const selectSuggestion = React.useCallback(async (suggestion: { description: string; place_id: string }) => {\n if (debounceRef.current) clearTimeout(debounceRef.current);\n if (blurTimeoutRef.current) clearTimeout(blurTimeoutRef.current);\n\n const label = suggestion.description.split(",")[0]?.trim() || suggestion.description;\n setInputText(label);\n setSuggestions([]);\n setIsOpen(false);\n\n const client = clientContext?.client;\n if (!client) return;\n\n const result = await client.places.details(suggestion.place_id, sessionTokenRef.current);\n sessionTokenRef.current = createSessionToken();\n\n if (result.ok) {\n const d = result.value;\n onValueChange({\n latitude: d.latitude,\n longitude: d.longitude,\n label: d.formatted_address || label,\n } satisfies LocationValue);\n setInputText(d.formatted_address || label);\n }\n }, [clientContext, onValueChange]);\n\n return (\n <div className={cn("relative", classNames?.locationInput)}>\n <input\n type="text"\n value={inputText}\n onChange={(e) => handleInputChange(e.target.value)}\n onFocus={() => { if (suggestions.length > 0) setIsOpen(true); }}\n onBlur={() => { blurTimeoutRef.current = setTimeout(() => setIsOpen(false), 120); }}\n onKeyDown={(e) => {\n if (e.key === "Escape") { setIsOpen(false); return; }\n if (!isOpen || suggestions.length === 0) return;\n if (e.key === "ArrowDown") { e.preventDefault(); setHighlightedIndex((i) => i < suggestions.length - 1 ? i + 1 : 0); }\n else if (e.key === "ArrowUp") { e.preventDefault(); setHighlightedIndex((i) => i > 0 ? i - 1 : suggestions.length - 1); }\n else if (e.key === "Enter" && highlightedIndex >= 0) { e.preventDefault(); void selectSuggestion(suggestions[highlightedIndex]); }\n }}\n placeholder={field.placeholder || "Search for a location..."}\n required={field.is_required}\n autoComplete="off"\n aria-autocomplete="list"\n aria-expanded={isOpen && suggestions.length > 0}\n className={inputClass}\n />\n {isOpen && suggestions.length > 0 && (\n <div role="listbox" className="absolute left-0 right-0 top-full z-20 mt-1 overflow-hidden rounded-md border border-border bg-background shadow-lg">\n {suggestions.map((s, i) => (\n <button\n key={s.place_id}\n type="button"\n role="option"\n aria-selected={i === highlightedIndex}\n onMouseDown={(e) => { e.preventDefault(); void selectSuggestion(s); }}\n className={cn("block w-full px-3 py-2.5 text-left text-sm transition-colors", i === highlightedIndex ? "bg-muted text-foreground" : "text-foreground hover:bg-muted/70")}\n >\n {s.description}\n </button>\n ))}\n </div>\n )}\n {loc && (\n <p className="text-xs text-muted-foreground mt-1">\n {loc.latitude.toFixed(4)}, {loc.longitude.toFixed(4)}\n </p>\n )}\n </div>\n );\n}\n\nfunction PhoneInput({\n field,\n value,\n onValueChange,\n classNames,\n inputClass,\n}: {\n field: ProductInputField;\n value: unknown;\n onValueChange: (value: unknown) => void;\n classNames?: CustomerInputFieldsClassNames;\n inputClass: string;\n}): React.ReactElement {\n const phone = (value && typeof value === "object" ? value : null) as PhoneValue | null;\n const [code, setCode] = React.useState(phone?.country_code ?? "+1");\n const [number, setNumber] = React.useState(phone?.number ?? "");\n\n const update = (nextCode: string, nextNumber: string) => {\n setCode(nextCode);\n setNumber(nextNumber);\n if (nextNumber) {\n onValueChange({\n country_code: nextCode,\n number: nextNumber,\n formatted: `${nextCode} ${nextNumber}`,\n } satisfies PhoneValue);\n } else {\n onValueChange(undefined);\n }\n };\n\n return (\n <div className={cn("flex gap-2", classNames?.phoneInput)}>\n <input\n type="text"\n value={code}\n onChange={(e) => update(e.target.value, number)}\n placeholder="+1"\n className={cn(inputClass, "w-20 shrink-0")}\n />\n <input\n type="tel"\n value={number}\n onChange={(e) => update(code, e.target.value)}\n placeholder={field.placeholder || "Phone number"}\n required={field.is_required}\n className={inputClass}\n />\n </div>\n );\n}\n\nfunction SignatureInput({\n field,\n value,\n onValueChange,\n classNames,\n}: {\n field: ProductInputField;\n value: unknown;\n onValueChange: (value: unknown) => void;\n classNames?: CustomerInputFieldsClassNames;\n}): React.ReactElement {\n const sig = (value && typeof value === "object" ? value : null) as SignatureValue | null;\n const canvasRef = React.useRef<HTMLCanvasElement>(null);\n const isDrawing = React.useRef(false);\n\n const getCtx = () => canvasRef.current?.getContext("2d") ?? null;\n\n const startDraw = (e: React.PointerEvent) => {\n const ctx = getCtx();\n if (!ctx) return;\n isDrawing.current = true;\n const rect = canvasRef.current!.getBoundingClientRect();\n ctx.beginPath();\n ctx.moveTo(e.clientX - rect.left, e.clientY - rect.top);\n canvasRef.current!.setPointerCapture(e.pointerId);\n };\n\n const draw = (e: React.PointerEvent) => {\n if (!isDrawing.current) return;\n const ctx = getCtx();\n if (!ctx) return;\n const rect = canvasRef.current!.getBoundingClientRect();\n ctx.lineWidth = 2;\n ctx.lineCap = "round";\n const style = getComputedStyle(canvasRef.current!);\n ctx.strokeStyle = style.color || "#000";\n ctx.lineTo(e.clientX - rect.left, e.clientY - rect.top);\n ctx.stroke();\n };\n\n const endDraw = () => {\n if (!isDrawing.current) return;\n isDrawing.current = false;\n const canvas = canvasRef.current;\n if (!canvas) return;\n onValueChange({\n data_url: canvas.toDataURL("image/png"),\n signer_name: sig?.signer_name,\n } satisfies SignatureValue);\n };\n\n const clear = () => {\n const canvas = canvasRef.current;\n if (!canvas) return;\n const ctx = canvas.getContext("2d");\n if (ctx) ctx.clearRect(0, 0, canvas.width, canvas.height);\n onValueChange(undefined);\n };\n\n return (\n <div className={cn("space-y-2", classNames?.signatureCanvas)}>\n <div className="relative rounded-md border border-input overflow-hidden">\n <canvas\n ref={canvasRef}\n width={400}\n height={150}\n className="w-full touch-none cursor-crosshair bg-background text-foreground"\n onPointerDown={startDraw}\n onPointerMove={draw}\n onPointerUp={endDraw}\n onPointerLeave={endDraw}\n />\n {sig?.data_url && (\n <button\n type="button"\n onClick={clear}\n className="absolute top-1.5 right-1.5 text-xs text-muted-foreground hover:text-destructive transition-colors"\n >\n Clear\n </button>\n )}\n </div>\n {!sig?.data_url && (\n <p className="text-xs text-muted-foreground">\n {field.placeholder || "Draw your signature above"}\n </p>\n )}\n </div>\n );\n}\n\nfunction createSessionToken(): string {\n if (typeof crypto !== "undefined" && crypto.randomUUID) {\n return crypto.randomUUID();\n }\n return `places_${Date.now()}_${Math.random().toString(16).slice(2)}`;\n}\n' }] }, "staff-picker": { "name": "staff-picker", "title": "StaffPicker", "description": "Staff member selection list with avatar and bio.", "type": "component", "registryDependencies": ["cn"], "files": [{ "path": "staff-picker.tsx", "content": '"use client";\n\nimport { Radio } from "@base-ui/react/radio";\nimport { RadioGroup } from "@base-ui/react/radio-group";\nimport React from "react";\nimport type { Staff } from "@cimplify/sdk";\nimport { cn } from "@cimplify/sdk/react";\n\nconst ANY_VALUE = "__any__";\n\nexport interface StaffPickerClassNames {\n root?: string;\n option?: string;\n avatar?: string;\n name?: string;\n bio?: string;\n}\n\nexport interface StaffPickerProps {\n /** List of available staff members. */\n staff: Staff[];\n /** Currently selected staff ID, or null for "Any available". */\n selectedStaffId?: string | null;\n /** Called when a staff member is selected. Passes null for "Any available". */\n onStaffSelect?: (staffId: string | null) => void;\n /** Show "Any available" option. Default: true. */\n showAnyOption?: boolean;\n /** Label for the "Any available" option. */\n anyLabel?: string;\n className?: string;\n classNames?: StaffPickerClassNames;\n}\n\nexport function StaffPicker({\n staff,\n selectedStaffId,\n onStaffSelect,\n showAnyOption = true,\n anyLabel = "Any available",\n className,\n classNames,\n}: StaffPickerProps): React.ReactElement {\n const groupValue =\n selectedStaffId === null ? ANY_VALUE : (selectedStaffId ?? "");\n\n return (\n <RadioGroup\n data-cimplify-staff-picker\n className={cn(className, classNames?.root)}\n value={groupValue}\n onValueChange={(value) => {\n onStaffSelect?.(value === ANY_VALUE ? null : value);\n }}\n >\n {showAnyOption && (\n <Radio.Root\n value={ANY_VALUE}\n data-cimplify-staff-option\n data-selected={selectedStaffId === null || undefined}\n data-any\n className={classNames?.option}\n >\n <span data-cimplify-staff-name className={classNames?.name}>\n {anyLabel}\n </span>\n </Radio.Root>\n )}\n {staff.map((member) => (\n <Radio.Root\n key={member.id}\n value={member.id}\n data-cimplify-staff-option\n data-selected={selectedStaffId === member.id || undefined}\n className={classNames?.option}\n >\n {member.avatar_url && (\n <img\n src={member.avatar_url}\n alt={member.name}\n data-cimplify-staff-avatar\n className={classNames?.avatar}\n />\n )}\n <span data-cimplify-staff-name className={classNames?.name}>\n {member.name}\n </span>\n {member.bio && (\n <span data-cimplify-staff-bio className={classNames?.bio}>\n {member.bio}\n </span>\n )}\n </Radio.Root>\n ))}\n </RadioGroup>\n );\n}\n' }] }, "checkout-page": { "name": "checkout-page", "title": "CheckoutPage", "description": "Multi-step checkout with auth, address, and payment.", "type": "component", "registryDependencies": ["cn"], "files": [{ "path": "checkout-page.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { ProcessCheckoutResult } from "@cimplify/sdk";\nimport { useCimplifyClient } from "@cimplify/sdk/react";\nimport { CimplifyCheckout } from "@cimplify/sdk/react";\nimport type { CimplifyCheckoutProps } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface CheckoutPageClassNames {\n root?: string;\n header?: string;\n title?: string;\n checkout?: string;\n}\n\nexport interface CheckoutPageProps {\n /** Page title. */\n title?: string;\n /** Called after successful checkout. */\n onComplete: (result: ProcessCheckoutResult) => void;\n /** Called on checkout failure. */\n onError?: (error: { code: string; message: string }) => void;\n /** Props forwarded to CimplifyCheckout. */\n checkoutProps?: Partial<\n Omit<CimplifyCheckoutProps, "client" | "onComplete" | "onError">\n >;\n className?: string;\n classNames?: CheckoutPageClassNames;\n}\n\n/**\n * CheckoutPage \u2014 thin page shell around CimplifyCheckout.\n *\n * Reads the CimplifyClient from CimplifyProvider context,\n * renders a page header, and delegates all checkout logic\n * to CimplifyCheckout.\n */\nexport function CheckoutPage({\n title = "Checkout",\n onComplete,\n onError,\n checkoutProps,\n className,\n classNames,\n}: CheckoutPageProps): React.ReactElement {\n const { client } = useCimplifyClient();\n\n return (\n <div data-cimplify-checkout-page className={cn(className, classNames?.root)}>\n <div data-cimplify-checkout-header className={classNames?.header}>\n <h1 data-cimplify-checkout-title className={classNames?.title}>\n {title}\n </h1>\n </div>\n\n <CimplifyCheckout\n client={client}\n onComplete={onComplete}\n onError={onError}\n className={classNames?.checkout}\n {...checkoutProps}\n />\n </div>\n );\n}\n' }] }, "session-message-banner": { "name": "session-message-banner", "title": "SessionMessageBanner", "description": "Top-of-page banner for session-scoped messages (promo nudges, abandoned cart prompts, low-stock alerts) with dismiss tracking.", "type": "component", "registryDependencies": ["cn"], "files": [{ "path": "session-message-banner.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { SessionMessage } from "../activity";\nimport { useActivityState } from "./hooks/use-activity-state";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface SessionMessageBannerClassNames {\n root?: string;\n message?: string;\n text?: string;\n dismissButton?: string;\n empty?: string;\n}\n\nexport interface SessionMessageBannerProps {\n /** Override messages (skips fetch). For SSR, pass pre-fetched messages. */\n messages?: SessionMessage[];\n /** Called when a message is dismissed. Receives the message code. */\n onDismiss?: (code: string) => void;\n /** Custom message renderer. */\n renderMessage?: (message: SessionMessage) => React.ReactNode;\n className?: string;\n classNames?: SessionMessageBannerClassNames;\n}\n\n/**\n * SessionMessageBanner \u2014 renders activity-based session messages.\n *\n * Displays dismissible message cards with level-based styling (info, promotion, urgency, suggestion).\n * Returns `null` when there are no messages.\n */\nexport function SessionMessageBanner({\n messages: messagesProp,\n onDismiss,\n renderMessage,\n className,\n classNames,\n}: SessionMessageBannerProps): React.ReactElement | null {\n const { messages: fetched, isLoading, dismissMessage } = useActivityState({\n enabled: messagesProp === undefined,\n });\n\n const messages = messagesProp ?? fetched;\n\n const handleDismiss = async (code: string): Promise<void> => {\n if (onDismiss) {\n onDismiss(code);\n } else {\n await dismissMessage(code);\n }\n };\n\n if (isLoading && messages.length === 0) {\n return (\n <div\n data-cimplify-session-message-banner\n aria-busy="true"\n className={cn(className, classNames?.root)}\n />\n );\n }\n\n if (messages.length === 0) {\n return null;\n }\n\n return (\n <div data-cimplify-session-message-banner className={cn(className, classNames?.root)}>\n {messages.map((msg) => (\n <div\n key={msg.code}\n data-cimplify-session-message\n data-level={msg.level}\n className={classNames?.message}\n >\n {renderMessage ? (\n renderMessage(msg)\n ) : (\n <>\n <span data-cimplify-session-message-text className={classNames?.text}>\n {msg.text}\n </span>\n {msg.dismissible && (\n <button\n type="button"\n onClick={() => void handleDismiss(msg.code)}\n data-cimplify-session-message-dismiss\n className={classNames?.dismissButton}\n aria-label="Dismiss message"\n >\n ×\n </button>\n )}\n </>\n )}\n </div>\n ))}\n </div>\n );\n}\n' }] }, "resource-picker": { "name": "resource-picker", "title": "ResourcePicker", "description": "Staff / room / resource picker for bookable services \u2014 used by services and restaurant reservation flows.", "type": "component", "registryDependencies": ["cn"], "files": [{ "path": "resource-picker.tsx", "content": '"use client";\n\nimport { Radio } from "@base-ui/react/radio";\nimport { RadioGroup } from "@base-ui/react/radio-group";\nimport React from "react";\nimport type { Room } from "../types/business";\nimport { cn } from "@cimplify/sdk/react";\n\nconst ANY_VALUE = "__any__";\n\nexport interface Resource {\n id: string;\n name: string;\n description?: string;\n capacity?: number;\n floor?: string;\n image_url?: string;\n is_available?: boolean;\n}\n\nexport interface ResourcePickerClassNames {\n root?: string;\n option?: string;\n image?: string;\n name?: string;\n description?: string;\n meta?: string;\n capacity?: string;\n floor?: string;\n unavailable?: string;\n}\n\nexport interface ResourcePickerProps {\n /** List of available resources (rooms, equipment, etc.). */\n resources: Resource[];\n /** Currently selected resource ID, or null for "Any available". */\n selectedResourceId?: string | null;\n /** Called when a resource is selected. Passes null for "Any available". */\n onResourceSelect?: (resourceId: string | null) => void;\n /** Show "Any available" option. Default: true. */\n showAnyOption?: boolean;\n /** Label for the "Any available" option. */\n anyLabel?: string;\n /** Custom image renderer (e.g. Next.js Image). */\n renderImage?: (props: {\n src: string;\n alt: string;\n className?: string;\n }) => React.ReactNode;\n className?: string;\n classNames?: ResourcePickerClassNames;\n}\n\nexport function roomToResource(room: Room): Resource {\n return {\n id: room.id,\n name: room.name,\n capacity: room.capacity,\n floor: room.floor,\n is_available: room.status === "available",\n };\n}\n\nexport function ResourcePicker({\n resources,\n selectedResourceId,\n onResourceSelect,\n showAnyOption = true,\n anyLabel = "Any available",\n renderImage,\n className,\n classNames,\n}: ResourcePickerProps): React.ReactElement {\n const groupValue =\n selectedResourceId === null ? ANY_VALUE : (selectedResourceId ?? "");\n\n return (\n <RadioGroup\n data-cimplify-resource-picker\n className={cn("flex flex-col gap-2", className, classNames?.root)}\n value={groupValue}\n onValueChange={(value) => {\n onResourceSelect?.(value === ANY_VALUE ? null : value);\n }}\n >\n {showAnyOption && (\n <Radio.Root\n value={ANY_VALUE}\n data-cimplify-resource-option\n data-selected={selectedResourceId === null || undefined}\n data-any\n className={cn(\n "flex items-center gap-3 rounded-lg border border-border p-3 transition-colors cursor-pointer",\n "hover:bg-muted data-[checked]:border-primary data-[checked]:bg-primary/5",\n classNames?.option,\n )}\n >\n <span data-cimplify-resource-name className={cn("font-medium text-foreground", classNames?.name)}>\n {anyLabel}\n </span>\n </Radio.Root>\n )}\n {resources.map((resource) => {\n const unavailable = resource.is_available === false;\n return (\n <Radio.Root\n key={resource.id}\n value={resource.id}\n disabled={unavailable}\n data-cimplify-resource-option\n data-selected={selectedResourceId === resource.id || undefined}\n data-unavailable={unavailable || undefined}\n className={cn(\n "flex items-center gap-3 rounded-lg border border-border p-3 transition-colors cursor-pointer",\n "hover:bg-muted data-[checked]:border-primary data-[checked]:bg-primary/5",\n unavailable && "opacity-50 cursor-not-allowed",\n classNames?.option,\n unavailable ? classNames?.unavailable : undefined,\n )}\n >\n {resource.image_url && (\n renderImage ? (\n renderImage({\n src: resource.image_url,\n alt: resource.name,\n className: classNames?.image,\n })\n ) : (\n <img\n src={resource.image_url}\n alt={resource.name}\n data-cimplify-resource-image\n className={cn("w-10 h-10 rounded-lg object-cover", classNames?.image)}\n />\n )\n )}\n <div className="flex flex-col gap-0.5 flex-1 min-w-0">\n <span data-cimplify-resource-name className={cn("font-medium text-foreground", classNames?.name)}>\n {resource.name}\n </span>\n {resource.description && (\n <span data-cimplify-resource-description className={cn("text-sm text-muted-foreground truncate", classNames?.description)}>\n {resource.description}\n </span>\n )}\n <div data-cimplify-resource-meta className={cn("flex items-center gap-2 text-xs text-muted-foreground", classNames?.meta)}>\n {resource.capacity !== undefined && (\n <span data-cimplify-resource-capacity className={classNames?.capacity}>\n Up to {resource.capacity}\n </span>\n )}\n {resource.floor && (\n <span data-cimplify-resource-floor className={classNames?.floor}>\n {resource.floor}\n </span>\n )}\n </div>\n </div>\n </Radio.Root>\n );\n })}\n </RadioGroup>\n );\n}\n' }] }, "category-grid": { "name": "category-grid", "title": "CategoryGrid", "description": "Responsive grid of category cards.", "type": "component", "registryDependencies": ["cn"], "files": [{ "path": "category-grid.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { Category } from "@cimplify/sdk";\nimport { useCategories } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface CategoryGridClassNames {\n root?: string;\n item?: string;\n name?: string;\n description?: string;\n count?: string;\n empty?: string;\n}\n\nexport interface CategoryGridProps {\n /** Override categories (skips useCategories fetch). */\n categories?: Category[];\n /** Called when a category card is clicked. */\n onSelect?: (category: Category) => void;\n /** Custom card renderer. */\n renderCard?: (category: Category) => React.ReactNode;\n /** Responsive column counts. */\n columns?: { sm?: number; md?: number; lg?: number };\n /** Text shown when empty. */\n emptyMessage?: string;\n className?: string;\n classNames?: CategoryGridClassNames;\n}\n\n/**\n * CategoryGrid \u2014 responsive grid of category cards.\n *\n * Fetches categories via `useCategories` unless overridden.\n * Uses `React.useId()` for hydration-safe responsive CSS.\n */\nexport function CategoryGrid({\n categories: categoriesProp,\n onSelect,\n renderCard,\n columns,\n emptyMessage,\n className,\n classNames,\n}: CategoryGridProps): React.ReactElement {\n const { categories: fetched, isLoading } = useCategories({\n enabled: categoriesProp === undefined,\n });\n const categories = categoriesProp ?? fetched;\n\n const rawId = React.useId();\n const gridId = `cimplify-cat-grid-${rawId.replace(/:/g, "")}`;\n\n const sm = columns?.sm ?? 2;\n const md = columns?.md ?? 3;\n const lg = columns?.lg ?? 4;\n\n if (isLoading && categories.length === 0) {\n return (\n <div\n data-cimplify-category-grid\n aria-busy="true"\n className={cn(className, classNames?.root)}\n />\n );\n }\n\n if (categories.length === 0) {\n return (\n <div\n data-cimplify-category-grid\n data-empty\n className={cn(className, classNames?.root, classNames?.empty)}\n >\n <p>{emptyMessage ?? "No categories found"}</p>\n </div>\n );\n }\n\n const css = [\n `#${gridId}{display:grid;grid-template-columns:repeat(${sm},1fr);gap:1rem}`,\n `@media(min-width:768px){#${gridId}{grid-template-columns:repeat(${md},1fr)}}`,\n `@media(min-width:1024px){#${gridId}{grid-template-columns:repeat(${lg},1fr)}}`,\n ].join("");\n\n return (\n <>\n <style dangerouslySetInnerHTML={{ __html: css }} />\n <div\n id={gridId}\n data-cimplify-category-grid\n className={cn(className, classNames?.root)}\n >\n {categories.map((category: Category) => (\n <button\n key={category.id}\n type="button"\n onClick={() => onSelect?.(category)}\n data-cimplify-category-card\n className={classNames?.item}\n >\n {renderCard ? (\n renderCard(category)\n ) : (\n <>\n <span data-cimplify-category-name className={classNames?.name}>\n {category.name}\n </span>\n {category.description && (\n <span data-cimplify-category-description className={classNames?.description}>\n {category.description}\n </span>\n )}\n {category.product_count != null && (\n <span data-cimplify-category-count className={classNames?.count}>\n {category.product_count} {category.product_count === 1 ? "product" : "products"}\n </span>\n )}\n </>\n )}\n </button>\n ))}\n </div>\n </>\n );\n}\n' }] }, "booking-page": { "name": "booking-page", "title": "BookingPage", "description": "Multi-step booking flow: service, staff, resource, date/slot, confirmation.", "type": "component", "registryDependencies": ["date-slot-picker", "staff-picker", "resource-picker", "price", "cn"], "files": [{ "path": "booking-page.tsx", "content": '"use client";\n\nimport React, { useState, useCallback } from "react";\nimport type { Product } from "@cimplify/sdk";\nimport type { Service, Staff, AvailableSlot, CustomerBooking } from "@cimplify/sdk";\nimport type { Resource } from "./resource-picker";\nimport { useCart } from "@cimplify/sdk/react";\nimport { useCimplifyClient } from "@cimplify/sdk/react";\nimport { DateSlotPicker } from "@cimplify/sdk/react";\nimport { StaffPicker } from "@cimplify/sdk/react";\nimport { ResourcePicker } from "./resource-picker";\nimport { Price } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\nimport { formatDuration } from "./utils/format-duration";\nimport { parsePrice } from "@cimplify/sdk";\n\nexport interface BookingPageClassNames {\n root?: string;\n header?: string;\n title?: string;\n serviceInfo?: string;\n step?: string;\n stepTitle?: string;\n summary?: string;\n summaryRow?: string;\n confirmButton?: string;\n backButton?: string;\n error?: string;\n rescheduleInfo?: string;\n depositInfo?: string;\n cancellationPolicy?: string;\n resourceStep?: string;\n}\n\nexport interface BookingPageProps {\n /** The service being booked. */\n service: Service;\n /** The underlying product (for deposit/cancellation fields). Falls back to service fields. */\n product?: Product;\n /** Optional staff list for staff selection step. */\n staff?: Staff[];\n /** Optional resources for resource selection step (rooms, equipment, etc.). */\n resources?: Resource[];\n /** Number of participants. */\n participantCount?: number;\n /** Page title. */\n title?: string;\n /** Called after successfully adding to cart. */\n onBooked?: (slot: AvailableSlot, staffId: string | null) => void;\n /** Called when user wants to go back. */\n onBack?: () => void;\n /** Existing booking for reschedule mode. */\n existingBooking?: CustomerBooking;\n /** Called after a successful reschedule. */\n onRescheduled?: (booking: CustomerBooking, newSlot: AvailableSlot) => void;\n /** Show price on slots. Default: true. */\n showPrice?: boolean;\n className?: string;\n classNames?: BookingPageClassNames;\n}\n\nconst STEP = {\n SELECT_SLOT: "select-slot",\n SELECT_RESOURCE: "select-resource",\n SELECT_STAFF: "select-staff",\n CONFIRM: "confirm",\n} as const;\n\ntype BookingStep = (typeof STEP)[keyof typeof STEP];\n\nexport function BookingPage({\n service,\n product,\n staff,\n resources,\n participantCount,\n title,\n onBooked,\n onBack,\n existingBooking,\n onRescheduled,\n showPrice = true,\n className,\n classNames,\n}: BookingPageProps): React.ReactElement {\n const { addItem } = useCart();\n const { client } = useCimplifyClient();\n\n const isReschedule = existingBooking !== undefined;\n\n const [step, setStep] = useState<BookingStep>(STEP.SELECT_SLOT);\n const [selectedSlot, setSelectedSlot] = useState<AvailableSlot | null>(null);\n const [selectedDate, setSelectedDate] = useState<string | null>(null);\n const [selectedStaffId, setSelectedStaffId] = useState<string | null>(null);\n const [selectedResourceId, setSelectedResourceId] = useState<string | null>(null);\n const [isSubmitting, setIsSubmitting] = useState(false);\n const [error, setError] = useState<string | null>(null);\n\n const hasResourceStep = resources && resources.length > 0;\n const hasStaffStep = staff && staff.length > 0;\n\n // Deposit info \u2014 prefer product fields, then fall back to service metadata\n const depositType = product?.deposit_type;\n const depositAmount = product?.deposit_amount;\n const hasDeposit = depositType !== undefined && depositType !== "none" && depositAmount !== undefined && parsePrice(depositAmount) !== 0;\n\n // Cancellation policy\n const cancellationMinutes = product?.cancellation_window_minutes;\n const noShowFee = product?.no_show_fee;\n\n const handleSlotSelect = useCallback(\n (slot: AvailableSlot, date: string) => {\n setSelectedSlot(slot);\n setSelectedDate(date);\n setError(null);\n if (hasResourceStep) {\n setStep(STEP.SELECT_RESOURCE);\n } else if (hasStaffStep) {\n setStep(STEP.SELECT_STAFF);\n } else {\n setStep(STEP.CONFIRM);\n }\n },\n [hasResourceStep, hasStaffStep],\n );\n\n const handleResourceSelect = useCallback(\n (resourceId: string | null) => {\n setSelectedResourceId(resourceId);\n if (hasStaffStep) {\n setStep(STEP.SELECT_STAFF);\n } else {\n setStep(STEP.CONFIRM);\n }\n },\n [hasStaffStep],\n );\n\n const handleStaffSelect = useCallback((staffId: string | null) => {\n setSelectedStaffId(staffId);\n setStep(STEP.CONFIRM);\n }, []);\n\n const handleBack = useCallback(() => {\n setError(null);\n if (step === STEP.CONFIRM && hasStaffStep) {\n setStep(STEP.SELECT_STAFF);\n } else if (step === STEP.CONFIRM && hasResourceStep) {\n setStep(STEP.SELECT_RESOURCE);\n } else if (step === STEP.SELECT_STAFF && hasResourceStep) {\n setStep(STEP.SELECT_RESOURCE);\n } else if (step === STEP.CONFIRM || step === STEP.SELECT_STAFF || step === STEP.SELECT_RESOURCE) {\n setStep(STEP.SELECT_SLOT);\n } else {\n onBack?.();\n }\n }, [step, hasStaffStep, hasResourceStep, onBack]);\n\n const handleConfirm = useCallback(async () => {\n if (!selectedSlot || !selectedDate) return;\n\n setIsSubmitting(true);\n setError(null);\n\n try {\n if (isReschedule && existingBooking) {\n // Reschedule mode \u2014 call the scheduling API instead of adding to cart\n const serviceItem = existingBooking.service_items[0];\n const result = await client.scheduling.rescheduleBooking({\n order_id: existingBooking.order_id,\n line_item_id: serviceItem?.service_id ?? existingBooking.order_id,\n new_start_time: selectedSlot.start_time,\n new_end_time: selectedSlot.end_time,\n new_staff_id: selectedStaffId || undefined,\n reschedule_type: "customer",\n });\n\n if (!result.ok) {\n throw result.error;\n }\n\n onRescheduled?.(existingBooking, selectedSlot);\n } else {\n // Normal booking mode \u2014 add to cart\n const serviceProduct = {\n id: service.product_id || service.id,\n business_id: service.business_id || "",\n category_id: service.category_id || undefined,\n name: service.name,\n slug: service.id,\n description: service.description || undefined,\n image_url: service.image_url || undefined,\n default_price: (service.price || "0") as Product["default_price"],\n type: "service" as const,\n inventory_type: "none" as const,\n variant_strategy: "fetch_all" as const,\n is_active: service.is_available,\n created_at: new Date().toISOString(),\n updated_at: new Date().toISOString(),\n };\n\n await addItem(serviceProduct, 1, {\n scheduledStart: selectedSlot.start_time,\n scheduledEnd: selectedSlot.end_time,\n staffId: selectedStaffId || undefined,\n resourceId: selectedResourceId || undefined,\n });\n\n onBooked?.(selectedSlot, selectedStaffId);\n }\n } catch (err) {\n const fallbackMessage = isReschedule ? "Failed to reschedule booking" : "Failed to add booking to cart";\n setError(err instanceof Error ? err.message : fallbackMessage);\n } finally {\n setIsSubmitting(false);\n }\n }, [selectedSlot, selectedDate, selectedStaffId, service, addItem, onBooked, isReschedule, existingBooking, client, onRescheduled]);\n\n return (\n <div data-cimplify-booking-page className={cn(className, classNames?.root)}>\n <div data-cimplify-booking-page-header className={classNames?.header}>\n {onBack && step === STEP.SELECT_SLOT && (\n <button\n type="button"\n onClick={onBack}\n data-cimplify-booking-page-back\n className={classNames?.backButton}\n >\n Back\n </button>\n )}\n {step !== STEP.SELECT_SLOT && (\n <button\n type="button"\n onClick={handleBack}\n data-cimplify-booking-page-back\n className={classNames?.backButton}\n >\n Back\n </button>\n )}\n <h1 data-cimplify-booking-page-title className={classNames?.title}>\n {title || (isReschedule ? "Reschedule Booking" : `Book ${service.name}`)}\n </h1>\n </div>\n\n {isReschedule && existingBooking && (\n <div data-cimplify-booking-reschedule-info className={classNames?.rescheduleInfo}>\n <span>Rescheduling booking from {new Date(existingBooking.service_items[0]?.scheduled_start ?? existingBooking.created_at).toLocaleDateString()}</span>\n </div>\n )}\n\n <div data-cimplify-booking-service-info className={classNames?.serviceInfo}>\n <span data-cimplify-booking-service-name>{service.name}</span>\n <span data-cimplify-booking-service-duration>{formatDuration(service.duration_minutes, service.duration_unit)}</span>\n {service.price && (\n <span data-cimplify-booking-service-price>\n <Price amount={service.price} />\n </span>\n )}\n {hasDeposit && (\n <span data-cimplify-booking-deposit-info className={classNames?.depositInfo}>\n Deposit: {depositType === "percentage" ? `${parsePrice(depositAmount!)}%` : <Price amount={depositAmount!} />}\n </span>\n )}\n {cancellationMinutes !== undefined && cancellationMinutes > 0 && (\n <span data-cimplify-booking-cancellation-policy className={classNames?.cancellationPolicy}>\n Free cancellation up to {cancellationMinutes >= 60 ? `${Math.floor(cancellationMinutes / 60)} hour${Math.floor(cancellationMinutes / 60) !== 1 ? "s" : ""}` : `${cancellationMinutes} minute${cancellationMinutes !== 1 ? "s" : ""}`} before\n </span>\n )}\n {noShowFee !== undefined && parsePrice(noShowFee) !== 0 && (\n <span data-cimplify-booking-no-show-fee className={classNames?.cancellationPolicy}>\n No-show fee: <Price amount={noShowFee} />\n </span>\n )}\n </div>\n\n {error && (\n <div data-cimplify-booking-error className={classNames?.error}>\n {error}\n </div>\n )}\n\n {step === STEP.SELECT_SLOT && (\n <div data-cimplify-booking-step="select-slot" className={classNames?.step}>\n <h2 data-cimplify-booking-step-title className={classNames?.stepTitle}>\n Select a date & time\n </h2>\n <DateSlotPicker\n serviceId={service.id}\n participantCount={participantCount}\n selectedSlot={selectedSlot}\n onSlotSelect={handleSlotSelect}\n showPrice={showPrice}\n />\n </div>\n )}\n\n {step === STEP.SELECT_RESOURCE && resources && (\n <div data-cimplify-booking-step="select-resource" className={cn(classNames?.step, classNames?.resourceStep)}>\n <h2 data-cimplify-booking-step-title className={classNames?.stepTitle}>\n Choose a room\n </h2>\n <ResourcePicker\n resources={resources}\n selectedResourceId={selectedResourceId}\n onResourceSelect={handleResourceSelect}\n />\n </div>\n )}\n\n {step === STEP.SELECT_STAFF && staff && (\n <div data-cimplify-booking-step="select-staff" className={classNames?.step}>\n <h2 data-cimplify-booking-step-title className={classNames?.stepTitle}>\n Choose a provider\n </h2>\n <StaffPicker\n staff={staff}\n selectedStaffId={selectedStaffId}\n onStaffSelect={handleStaffSelect}\n />\n </div>\n )}\n\n {step === STEP.CONFIRM && selectedSlot && (\n <div data-cimplify-booking-step="confirm" className={classNames?.step}>\n <h2 data-cimplify-booking-step-title className={classNames?.stepTitle}>\n Confirm your booking\n </h2>\n <div data-cimplify-booking-summary className={classNames?.summary}>\n <div data-cimplify-booking-summary-row className={classNames?.summaryRow}>\n <span>Service</span>\n <span>{service.name}</span>\n </div>\n <div data-cimplify-booking-summary-row className={classNames?.summaryRow}>\n <span>Date</span>\n <span>{selectedDate}</span>\n </div>\n <div data-cimplify-booking-summary-row className={classNames?.summaryRow}>\n <span>Time</span>\n <span>\n {new Date(selectedSlot.start_time).toLocaleTimeString(undefined, {\n hour: "numeric",\n minute: "2-digit",\n })}\n </span>\n </div>\n <div data-cimplify-booking-summary-row className={classNames?.summaryRow}>\n <span>Duration</span>\n <span>{formatDuration(service.duration_minutes, service.duration_unit)}</span>\n </div>\n {selectedResourceId && resources && (\n <div data-cimplify-booking-summary-row className={classNames?.summaryRow}>\n <span>Room</span>\n <span>{resources.find((r) => r.id === selectedResourceId)?.name ?? "Selected"}</span>\n </div>\n )}\n {selectedStaffId && staff && (\n <div data-cimplify-booking-summary-row className={classNames?.summaryRow}>\n <span>Provider</span>\n <span>{staff.find((s) => s.id === selectedStaffId)?.name ?? "Selected"}</span>\n </div>\n )}\n {(selectedSlot.price || service.price) && (\n <div data-cimplify-booking-summary-row className={classNames?.summaryRow}>\n <span>Price</span>\n <span>\n <Price amount={selectedSlot.price || service.price!} />\n </span>\n </div>\n )}\n {hasDeposit && (\n <div data-cimplify-booking-summary-row className={cn(classNames?.summaryRow, classNames?.depositInfo)}>\n <span>Deposit</span>\n <span>\n {depositType === "percentage" ? `${parsePrice(depositAmount!)}%` : <Price amount={depositAmount!} />}\n </span>\n </div>\n )}\n </div>\n <button\n type="button"\n onClick={handleConfirm}\n disabled={isSubmitting}\n data-cimplify-booking-confirm\n className={classNames?.confirmButton}\n >\n {isSubmitting\n ? (isReschedule ? "Rescheduling\u2026" : "Adding to cart\u2026")\n : (isReschedule ? "Reschedule" : "Confirm Booking")}\n </button>\n </div>\n )}\n </div>\n );\n}\n' }] }, "product-card": { "name": "product-card", "title": "ProductCard", "description": "Product display card with modal or link mode.", "type": "component", "registryDependencies": ["price", "product-sheet", "cn"], "files": [{ "path": "product-card.tsx", "content": '"use client";\n\nimport React, { useCallback, useState } from "react";\nimport { Dialog } from "@base-ui/react/dialog";\nimport type { Product, ProductWithDetails } from "@cimplify/sdk";\nimport { PRODUCT_TYPE, RENDER_HINT, DURATION_UNIT } from "@cimplify/sdk";\nimport { useProduct } from "@cimplify/sdk/react";\nimport { Price } from "@cimplify/sdk/react";\nimport { ProductSheet } from "@cimplify/sdk/react";\nimport { CardVariant } from "@cimplify/sdk/react";\nimport type { CardLayoutProps } from "@cimplify/sdk/react";\nimport { FoodProductCard } from "@cimplify/sdk/react";\nimport { RetailProductCard } from "@cimplify/sdk/react";\nimport { WholesaleProductCard } from "@cimplify/sdk/react";\nimport { DigitalProductCard } from "@cimplify/sdk/react";\nimport { StandardServiceCard } from "@cimplify/sdk/react";\nimport { RentalServiceCard } from "@cimplify/sdk/react";\nimport { AccommodationCard } from "@cimplify/sdk/react";\nimport { LeaseServiceCard } from "@cimplify/sdk/react";\nimport { SubscriptionCard } from "@cimplify/sdk/react";\nimport { BundleProductCard } from "./cards/bundle-product-card";\nimport { CompositeProductCard } from "./cards/composite-product-card";\nimport { cn } from "@cimplify/sdk/react";\n\nconst ASPECT_STYLES: Record<string, React.CSSProperties> = {\n square: { aspectRatio: "1/1" },\n "4/3": { aspectRatio: "4/3" },\n "16/10": { aspectRatio: "16/10" },\n "3/4": { aspectRatio: "3/4" },\n};\n\nexport interface ProductCardClassNames {\n root?: string;\n imageContainer?: string;\n image?: string;\n body?: string;\n name?: string;\n description?: string;\n price?: string;\n badges?: string;\n badge?: string;\n modal?: string;\n modalOverlay?: string;\n}\n\nexport interface ProductCardProps {\n /** The product to display. */\n product: Product;\n /** Display mode: "card" opens a modal, "page" renders as a link. Auto-detected from product.display_mode. */\n displayMode?: "card" | "page";\n /** Explicit card variant. Auto-detected from product data when omitted. */\n variant?: CardVariant;\n /** Per-slug card map. Highest priority \u2014 maps a product slug to a custom card component. */\n cards?: Record<string, React.ComponentType<CardLayoutProps>>;\n /** Link href for page mode. Default: `/menu/${product.slug}` */\n href?: string;\n /** Custom modal content renderer. Receives the fully-loaded product and a close callback. */\n renderModal?: (product: ProductWithDetails, onClose: () => void) => React.ReactNode;\n /** Custom image renderer (e.g. Next.js Image). */\n renderImage?: (props: {\n src: string;\n alt: string;\n className?: string;\n }) => React.ReactNode;\n /** Custom link renderer for page mode (e.g. Next.js Link). */\n renderLink?: (props: {\n href: string;\n className?: string;\n children: React.ReactNode;\n }) => React.ReactElement;\n /** Called when quick-add button is clicked (if the resolved card supports it). */\n onQuickAdd?: (product: Product) => void;\n /** Replace the entire default card body. */\n children?: React.ReactNode;\n /** Image aspect ratio. Default: "4/3". */\n aspectRatio?: "square" | "4/3" | "16/10" | "3/4";\n className?: string;\n classNames?: ProductCardClassNames;\n}\n\nconst BUILT_IN_CARDS: Record<string, React.ComponentType<CardLayoutProps>> = {\n [CardVariant.Food]: FoodProductCard,\n [CardVariant.Retail]: RetailProductCard,\n [CardVariant.Wholesale]: WholesaleProductCard,\n [CardVariant.Digital]: DigitalProductCard,\n [CardVariant.Standard]: StandardServiceCard,\n [CardVariant.Rental]: RentalServiceCard,\n [CardVariant.Accommodation]: AccommodationCard,\n [CardVariant.Lease]: LeaseServiceCard,\n [CardVariant.Bundle]: BundleProductCard,\n [CardVariant.Composite]: CompositeProductCard,\n [CardVariant.Subscription]: SubscriptionCard,\n};\n\nconst RENTAL_UNITS = new Set<string>([DURATION_UNIT.Days, DURATION_UNIT.Hours]);\nconst LEASE_UNITS = new Set<string>([DURATION_UNIT.Weeks, DURATION_UNIT.Months, DURATION_UNIT.Years]);\n\nfunction resolveCardVariant(product: Product): CardVariant {\n if (product.type === PRODUCT_TYPE.Bundle) {\n return CardVariant.Bundle;\n }\n if (product.type === PRODUCT_TYPE.Composite) {\n return CardVariant.Composite;\n }\n if (product.quantity_pricing && product.quantity_pricing.length > 1) {\n return CardVariant.Wholesale;\n }\n if (product.type === PRODUCT_TYPE.Digital) {\n return CardVariant.Digital;\n }\n if (product.type === PRODUCT_TYPE.Service) {\n if (product.duration_unit && RENTAL_UNITS.has(product.duration_unit)) {\n return CardVariant.Rental;\n }\n if (product.duration_unit === DURATION_UNIT.Nights) {\n return CardVariant.Accommodation;\n }\n if (product.duration_unit && LEASE_UNITS.has(product.duration_unit)) {\n return CardVariant.Lease;\n }\n if (product.billing_plans && product.billing_plans.length > 0 && !product.duration_minutes) {\n return CardVariant.Subscription;\n }\n return CardVariant.Standard;\n }\n if (product.render_hint === RENDER_HINT.Food) {\n return CardVariant.Food;\n }\n if (product.render_hint === RENDER_HINT.Physical) {\n return CardVariant.Retail;\n }\n return CardVariant.Retail;\n}\n\n/**\n * ProductCard \u2014 a product display card with two modes:\n *\n * - **card** (default): clickable button that opens a Base UI Dialog modal\n * - **page**: a plain `<a>` link for SEO-friendly product pages\n */\nexport function ProductCard({\n product,\n displayMode,\n variant,\n cards,\n href,\n renderModal,\n renderImage,\n renderLink,\n onQuickAdd,\n children,\n aspectRatio = "4/3",\n className,\n classNames,\n}: ProductCardProps): React.ReactElement {\n const mode = displayMode ?? product.display_mode ?? "card";\n const [isOpen, setIsOpen] = useState(false);\n const [shouldFetch, setShouldFetch] = useState(false);\n\n // Prefetch on pointer enter, always fetch when open\n const { product: productDetails } = useProduct(\n product.slug ?? product.id,\n { enabled: shouldFetch || isOpen },\n );\n\n const handlePrefetch = useCallback(() => {\n setShouldFetch(true);\n }, []);\n\n const handleOpenChange = useCallback((open: boolean) => {\n setIsOpen(open);\n if (open) {\n setShouldFetch(true);\n }\n }, []);\n\n const handleClose = useCallback(() => {\n setIsOpen(false);\n }, []);\n\n const imageUrl = product.image_url || product.images?.[0];\n\n const cardBody = (() => {\n if (children) return children;\n\n // Resolve card variant: slug map \u2192 explicit variant \u2192 auto-detect\n const SlugCard = cards?.[product.slug];\n if (SlugCard) {\n return <SlugCard product={product} onQuickAdd={onQuickAdd} renderImage={renderImage} renderLink={renderLink} />;\n }\n\n const key = variant ?? resolveCardVariant(product);\n const ResolvedCard = BUILT_IN_CARDS[key];\n if (ResolvedCard) {\n return <ResolvedCard product={product} onQuickAdd={onQuickAdd} renderImage={renderImage} renderLink={renderLink} />;\n }\n\n // Fallback: minimal default\n return (\n <>\n {imageUrl && (\n <div\n data-cimplify-product-card-image-container\n className={cn("overflow-hidden rounded-t-xl", classNames?.imageContainer)}\n style={ASPECT_STYLES[aspectRatio]}\n >\n {renderImage ? (\n renderImage({ src: imageUrl, alt: product.name, className: classNames?.image })\n ) : (\n <img\n src={imageUrl}\n alt={product.name}\n className={cn("w-full h-full object-cover transition-transform duration-300 group-hover:scale-105", classNames?.image)}\n data-cimplify-product-card-image\n />\n )}\n </div>\n )}\n <div data-cimplify-product-card-body className={cn("p-4 flex flex-col gap-1", classNames?.body)}>\n <span data-cimplify-product-card-name className={cn("font-semibold text-foreground leading-tight", classNames?.name)}>\n {product.name}\n </span>\n {product.description && (\n <span data-cimplify-product-card-description className={cn("text-sm text-muted-foreground line-clamp-2", classNames?.description)}>\n {product.description}\n </span>\n )}\n <Price amount={product.default_price} className={cn("text-sm font-medium text-foreground mt-1", classNames?.price)} />\n </div>\n </>\n );\n })();\n\n // Page mode \u2014 render as a link\n if (mode === "page") {\n const linkHref = href ?? `/menu/${product.slug}`;\n const linkClassName = cn("group block no-underline text-[inherit] rounded-xl border border-border bg-background transition-shadow duration-200 hover:shadow-md", className, classNames?.root);\n\n if (renderLink) {\n return renderLink({ href: linkHref, className: linkClassName, children: cardBody });\n }\n\n return (\n <a\n href={linkHref}\n data-cimplify-product-card\n data-display-mode="page"\n className={linkClassName}\n >\n {cardBody}\n </a>\n );\n }\n\n // Card mode \u2014 render as Base UI Dialog\n return (\n <Dialog.Root open={isOpen} onOpenChange={handleOpenChange}>\n <Dialog.Trigger\n onPointerEnter={handlePrefetch}\n data-cimplify-product-card\n data-display-mode="card"\n className={cn(\n "group block w-full text-left p-0 cursor-pointer font-[inherit] text-[inherit] rounded-xl border border-border bg-background transition-shadow duration-200 hover:shadow-md",\n className,\n classNames?.root,\n )}\n >\n {cardBody}\n </Dialog.Trigger>\n\n <Dialog.Portal>\n <Dialog.Backdrop\n data-cimplify-product-card-backdrop\n className={cn(\n "fixed inset-0 z-50 bg-black/50 backdrop-blur-sm transition-opacity",\n classNames?.modalOverlay,\n )}\n />\n <Dialog.Popup\n data-cimplify-product-card-modal\n className={cn(\n "fixed z-50 rounded-2xl p-0 max-w-lg w-full h-fit max-h-[85vh] overflow-auto bg-background shadow-2xl outline-none",\n classNames?.modal,\n )}\n >\n {isOpen && (\n productDetails ? (\n renderModal ? (\n renderModal(productDetails, handleClose)\n ) : (\n <ProductSheet\n product={productDetails}\n onClose={handleClose}\n renderImage={renderImage}\n />\n )\n ) : (\n <div\n data-cimplify-product-card-modal-loading\n aria-busy="true"\n className="flex flex-col"\n >\n <div className="aspect-[5/2] bg-muted animate-pulse" />\n <div className="p-6 space-y-3">\n <div className="h-5 w-3/5 bg-muted rounded animate-pulse" />\n <div className="h-7 w-2/5 bg-muted rounded animate-pulse" />\n <div className="h-4 w-4/5 bg-muted rounded animate-pulse" />\n </div>\n <div className="mx-6 border-t border-border pt-4 pb-6">\n <div className="h-14 bg-muted rounded-full animate-pulse" />\n </div>\n </div>\n )\n )}\n </Dialog.Popup>\n </Dialog.Portal>\n </Dialog.Root>\n );\n}\n' }] }, "service-product-layout": { "name": "service-product-layout", "title": "ServiceProductLayout", "description": "Service booking layout with duration, deposit, staff requirements, and cancellation policy.", "type": "component", "registryDependencies": ["product-image-gallery", "product-customizer", "price", "cn"], "files": [{ "path": "layouts/service-product-layout.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { ProductLayoutProps } from "@cimplify/sdk/react";\nimport {\n TwoColumnGrid,\n RelatedProductsSection,\n CustomAttributesTable,\n TagPills,\n} from "@cimplify/sdk/react";\nimport { ProductImageGallery } from "@cimplify/sdk/react";\nimport { ProductCustomizer } from "@cimplify/sdk/react";\nimport { Price } from "@cimplify/sdk/react";\nimport { parsePrice } from "@cimplify/sdk";\nimport { cn } from "@cimplify/sdk/react";\nimport { formatDuration } from "../utils/format-duration";\n\nexport function ServiceProductLayout({\n product,\n onAddToCart,\n renderBreadcrumb,\n relatedProducts,\n showRelated = true,\n className,\n}: ProductLayoutProps): React.ReactElement {\n const images = product.images || (product.image_url ? [product.image_url] : []);\n const hasDeposit = product.deposit_type && product.deposit_type !== "none" && product.deposit_amount;\n const depositAmount = hasDeposit ? parsePrice(product.deposit_amount!) : 0;\n const cancellationHours = product.cancellation_window_minutes\n ? Math.floor(product.cancellation_window_minutes / 60)\n : null;\n\n return (\n <div data-cimplify-product-layout="service" className={cn("space-y-8", className)}>\n {renderBreadcrumb?.(product)}\n\n <TwoColumnGrid\n left={\n <ProductImageGallery\n images={images}\n productName={product.name}\n />\n }\n right={\n <div className="space-y-6">\n {/* Tags */}\n <TagPills tags={product.tags || []} />\n\n {/* Name + Price */}\n <div>\n <h1 data-cimplify-product-layout-name className="text-3xl lg:text-4xl font-extrabold tracking-tight">\n {product.name}\n </h1>\n <Price amount={product.default_price} className="text-2xl font-bold text-primary mt-2 block" />\n </div>\n\n {/* Service info pills */}\n <div data-cimplify-product-layout-service-info className="flex flex-wrap gap-2">\n {product.duration_minutes != null && (\n <span className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-muted text-sm text-muted-foreground rounded-full">\n {formatDuration(product.duration_minutes, product.duration_unit)}\n </span>\n )}\n {hasDeposit && (\n <span className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-muted text-sm text-muted-foreground rounded-full">\n <Price amount={depositAmount} /> deposit\n </span>\n )}\n {product.general_service_capacity != null && product.general_service_capacity > 1 && (\n <span className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-muted text-sm text-muted-foreground rounded-full">\n Up to {product.general_service_capacity} people\n </span>\n )}\n {product.buffer_before_minutes != null && product.buffer_before_minutes > 0 && (\n <span className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-muted text-sm text-muted-foreground rounded-full">\n {product.buffer_before_minutes} min buffer\n </span>\n )}\n </div>\n\n {/* Description */}\n {product.description && (\n <p data-cimplify-product-layout-description className="text-muted-foreground leading-relaxed">\n {product.description}\n </p>\n )}\n\n {/* Staff & Resource requirements */}\n {(product.requires_specific_staff || product.requires_specific_resource) && (\n <div data-cimplify-product-layout-requirements className="text-sm text-muted-foreground space-y-1">\n {product.requires_specific_staff && (\n <p>Staff selection required</p>\n )}\n {product.requires_specific_resource && (\n <p>Resource selection required</p>\n )}\n </div>\n )}\n\n {/* Custom Attributes */}\n <CustomAttributesTable attributes={product.custom_attributes || []} />\n\n {/* Customizer (variants, scheduling, add to cart) */}\n <ProductCustomizer\n product={product}\n onAddToCart={onAddToCart}\n showSpecialInstructions={false}\n />\n\n {/* Cancellation policy */}\n {(cancellationHours != null || product.no_show_fee) && (\n <div data-cimplify-product-layout-cancellation className="border border-border p-4 space-y-2 text-sm">\n <h3 className="font-semibold">Cancellation Policy</h3>\n {cancellationHours != null && (\n <p className="text-muted-foreground">\n Free cancellation up to <strong className="text-foreground">{cancellationHours} hours</strong> before the booking\n </p>\n )}\n {product.no_show_fee && (\n <p className="text-muted-foreground">\n No-show fee: <Price amount={product.no_show_fee} className="font-medium text-foreground" />\n </p>\n )}\n {hasDeposit && (\n <p className="text-muted-foreground">\n {product.deposit_type === "fixed"\n ? <><Price amount={depositAmount} className="font-medium text-foreground" /> deposit charged at booking</>\n : <>{parsePrice(product.deposit_amount!)}% deposit charged at booking</>\n }\n </p>\n )}\n </div>\n )}\n </div>\n }\n />\n\n {showRelated && relatedProducts && relatedProducts.length > 0 && (\n <RelatedProductsSection\n products={relatedProducts}\n title="Other services"\n />\n )}\n </div>\n );\n}\n' }] }, "product-grid": { "name": "product-grid", "title": "ProductGrid", "description": "Responsive CSS grid that renders ProductCards.", "type": "component", "registryDependencies": ["product-card", "cn"], "files": [{ "path": "product-grid.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { Product } from "@cimplify/sdk";\nimport { ProductCard } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface ProductGridClassNames {\n root?: string;\n item?: string;\n empty?: string;\n}\n\nexport interface ProductGridProps {\n /** Products to display in the grid. */\n products: Product[];\n /** Responsive column counts at each breakpoint. */\n columns?: { sm?: number; md?: number; lg?: number; xl?: number };\n /** Custom card renderer per product. */\n renderCard?: (product: Product) => React.ReactNode;\n /** Custom image renderer passed to default ProductCards. */\n renderImage?: (props: {\n src: string;\n alt: string;\n className?: string;\n }) => React.ReactNode;\n /** Text shown when `products` is empty. */\n emptyMessage?: string;\n className?: string;\n classNames?: ProductGridClassNames;\n}\n\n/**\n * ProductGrid \u2014 responsive CSS grid that renders ProductCards.\n *\n * Injects an inline `<style>` tag with media queries for responsive columns.\n * Uses `React.useId()` for a hydration-safe, collision-free CSS selector.\n */\nexport function ProductGrid({\n products,\n columns,\n renderCard,\n renderImage,\n emptyMessage,\n className,\n classNames,\n}: ProductGridProps): React.ReactElement {\n const rawId = React.useId();\n // CSS selectors can\'t contain colons, so strip them from the React-generated ID\n const gridId = `cimplify-grid-${rawId.replace(/:/g, "")}`;\n\n const sm = columns?.sm ?? 1;\n const md = columns?.md ?? 2;\n const lg = columns?.lg ?? 3;\n const xl = columns?.xl ?? 4;\n const css = React.useMemo(\n () =>\n [\n `#${gridId}{display:grid;grid-template-columns:repeat(${sm},1fr);gap:1rem}`,\n `@media(min-width:768px){#${gridId}{grid-template-columns:repeat(${md},1fr)}}`,\n `@media(min-width:1024px){#${gridId}{grid-template-columns:repeat(${lg},1fr)}}`,\n `@media(min-width:1280px){#${gridId}{grid-template-columns:repeat(${xl},1fr)}}`,\n ].join(""),\n [gridId, sm, md, lg, xl],\n );\n\n if (products.length === 0) {\n return (\n <div\n data-cimplify-product-grid\n data-empty\n className={cn(className, classNames?.root, classNames?.empty)}\n >\n <p>{emptyMessage ?? "No products found"}</p>\n </div>\n );\n }\n\n return (\n <>\n <style dangerouslySetInnerHTML={{ __html: css }} />\n <div\n id={gridId}\n data-cimplify-product-grid\n className={cn(className, classNames?.root)}\n >\n {products.map((product) => (\n <div\n key={product.id}\n data-cimplify-product-grid-item\n className={classNames?.item}\n >\n {renderCard\n ? renderCard(product)\n : (\n <ProductCard\n product={product}\n renderImage={renderImage}\n />\n )}\n </div>\n ))}\n </div>\n </>\n );\n}\n' }] }, "composite-selector": { "name": "composite-selector", "title": "CompositeSelector", "description": "Composite product builder with group constraints and live pricing.", "type": "component", "registryDependencies": ["price"], "files": [{ "path": "composite-selector.tsx", "content": '"use client";\n\nimport React, { useState, useCallback, useMemo, useEffect } from "react";\nimport { Checkbox } from "@base-ui/react/checkbox";\nimport { NumberField } from "@base-ui/react/number-field";\nimport type {\n CompositeGroupView,\n CompositeComponentView,\n ComponentSelectionInput,\n CompositePriceResult,\n} from "@cimplify/sdk";\nimport { Price } from "@cimplify/sdk/react";\nimport { parsePrice } from "@cimplify/sdk";\nimport { cn } from "@cimplify/sdk/react";\nimport { useCimplifyClient } from "@cimplify/sdk/react";\n\nexport interface CompositeSelectorClassNames {\n root?: string;\n group?: string;\n groupHeader?: string;\n groupName?: string;\n required?: string;\n groupDescription?: string;\n groupConstraint?: string;\n validation?: string;\n components?: string;\n component?: string;\n componentSelected?: string;\n componentInfo?: string;\n componentName?: string;\n typeBadge?: string;\n serviceBadge?: string;\n digitalBadge?: string;\n badgePopular?: string;\n badgePremium?: string;\n componentDescription?: string;\n componentCalories?: string;\n qty?: string;\n qtyButton?: string;\n qtyValue?: string;\n summary?: string;\n summaryLine?: string;\n summaryTotal?: string;\n calculating?: string;\n priceError?: string;\n}\n\nexport interface CompositeSelectorProps {\n compositeId: string;\n groups: CompositeGroupView[];\n onSelectionsChange: (selections: ComponentSelectionInput[]) => void;\n onPriceChange?: (price: CompositePriceResult | null) => void;\n onReady?: (ready: boolean) => void;\n skipPriceFetch?: boolean;\n className?: string;\n classNames?: CompositeSelectorClassNames;\n}\n\nexport function CompositeSelector({\n compositeId,\n groups,\n onSelectionsChange,\n onPriceChange,\n onReady,\n skipPriceFetch,\n className,\n classNames,\n}: CompositeSelectorProps): React.ReactElement | null {\n const { client } = useCimplifyClient();\n\n const [groupSelections, setGroupSelections] = useState<\n Record<string, Record<string, number>>\n >({});\n const [priceResult, setPriceResult] = useState<CompositePriceResult | null>(null);\n const [isPriceLoading, setIsPriceLoading] = useState(false);\n const [priceError, setPriceError] = useState(false);\n\n const selections = useMemo((): ComponentSelectionInput[] => {\n const result: ComponentSelectionInput[] = [];\n for (const groupSels of Object.values(groupSelections)) {\n for (const [componentId, qty] of Object.entries(groupSels)) {\n if (qty > 0) {\n result.push({ component_id: componentId, quantity: qty });\n }\n }\n }\n return result;\n }, [groupSelections]);\n\n useEffect(() => {\n onSelectionsChange(selections);\n }, [selections, onSelectionsChange]);\n\n useEffect(() => {\n onPriceChange?.(priceResult);\n }, [priceResult, onPriceChange]);\n\n const allGroupsSatisfied = useMemo(() => {\n for (const group of groups) {\n const groupSels = groupSelections[group.id] || {};\n const totalSelected = Object.values(groupSels).reduce((sum, q) => sum + q, 0);\n if (totalSelected < group.min_selections) return false;\n }\n return true;\n }, [groups, groupSelections]);\n\n const sortedGroups = useMemo(\n () =>\n [...groups]\n .sort((a, b) => a.display_order - b.display_order)\n .map((group) => ({\n ...group,\n _sortedComponents: group.components\n .filter((component) => component.is_available && !component.is_archived)\n .sort((a, b) => a.display_order - b.display_order),\n })),\n [groups],\n );\n\n useEffect(() => {\n onReady?.(allGroupsSatisfied);\n }, [allGroupsSatisfied, onReady]);\n\n useEffect(() => {\n if (skipPriceFetch || !allGroupsSatisfied || selections.length === 0) return;\n\n let cancelled = false;\n const timer = setTimeout(() => {\n void (async () => {\n setIsPriceLoading(true);\n setPriceError(false);\n try {\n const result = await client.catalogue.calculateCompositePrice(compositeId, selections);\n if (cancelled) return;\n if (result.ok) {\n setPriceResult(result.value);\n } else {\n setPriceError(true);\n }\n } catch {\n if (!cancelled) setPriceError(true);\n } finally {\n if (!cancelled) setIsPriceLoading(false);\n }\n })();\n }, 300);\n\n return () => {\n cancelled = true;\n clearTimeout(timer);\n };\n }, [selections, allGroupsSatisfied, compositeId, client, skipPriceFetch]);\n\n const toggleComponent = useCallback(\n (group: CompositeGroupView, component: CompositeComponentView) => {\n setGroupSelections((prev) => {\n const groupSels = { ...(prev[group.id] || {}) };\n const currentQty = groupSels[component.id] || 0;\n\n if (currentQty > 0) {\n if (group.min_selections > 0) {\n const totalOthers = Object.entries(groupSels)\n .filter(([id]) => id !== component.id)\n .reduce((sum, [, q]) => sum + q, 0);\n if (totalOthers < group.min_selections) {\n return prev;\n }\n }\n delete groupSels[component.id];\n } else {\n const totalSelected = Object.values(groupSels).reduce((sum, q) => sum + q, 0);\n if (group.max_selections && totalSelected >= group.max_selections) {\n if (group.max_selections === 1) {\n return { ...prev, [group.id]: { [component.id]: 1 } };\n }\n return prev;\n }\n groupSels[component.id] = 1;\n }\n\n return { ...prev, [group.id]: groupSels };\n });\n },\n [],\n );\n\n const updateQuantity = useCallback(\n (group: CompositeGroupView, componentId: string, newValue: number) => {\n setGroupSelections((prev) => {\n const groupSels = { ...(prev[group.id] || {}) };\n const current = groupSels[componentId] || 0;\n const next = Math.max(0, newValue);\n\n if (next === current) return prev;\n\n const delta = next - current;\n\n if (group.max_quantity_per_component && next > group.max_quantity_per_component) {\n return prev;\n }\n\n const totalAfter = Object.entries(groupSels)\n .reduce((sum, [id, q]) => sum + (id === componentId ? next : q), 0);\n\n if (delta > 0 && group.max_selections && totalAfter > group.max_selections) {\n return prev;\n }\n\n if (delta < 0 && totalAfter < group.min_selections) {\n return prev;\n }\n\n if (next === 0) {\n delete groupSels[componentId];\n } else {\n groupSels[componentId] = next;\n }\n\n return { ...prev, [group.id]: groupSels };\n });\n },\n [],\n );\n\n if (groups.length === 0) {\n return null;\n }\n\n return (\n <div data-cimplify-composite-selector className={cn("space-y-6", className, classNames?.root)}>\n {sortedGroups.map((group) => {\n const groupSels = groupSelections[group.id] || {};\n const totalSelected = Object.values(groupSels).reduce((sum, q) => sum + q, 0);\n const minMet = totalSelected >= group.min_selections;\n const isSingleSelect = group.max_selections === 1;\n\n return (\n <div\n key={group.id}\n data-cimplify-composite-group\n className={cn(classNames?.group)}\n >\n <div\n data-cimplify-composite-group-header\n className={cn("flex items-center justify-between py-3", classNames?.groupHeader)}\n >\n <div>\n <span\n data-cimplify-composite-group-name\n className={cn("text-base font-bold", classNames?.groupName)}\n >\n {group.name}\n </span>\n {group.description && (\n <span\n data-cimplify-composite-group-description\n className={cn("block text-xs text-muted-foreground mt-0.5", classNames?.groupDescription)}\n >\n {group.description}\n </span>\n )}\n <span\n data-cimplify-composite-group-constraint\n className={cn("block text-xs text-muted-foreground mt-0.5", classNames?.groupConstraint)}\n >\n {group.min_selections > 0 && group.max_selections\n ? `Choose ${group.min_selections}\\u2013${group.max_selections}`\n : group.min_selections > 0\n ? `Choose at least ${group.min_selections}`\n : group.max_selections\n ? `Choose up to ${group.max_selections}`\n : "Choose as many as you like"}\n </span>\n </div>\n {group.min_selections > 0 && (\n <span\n data-cimplify-composite-required\n className={cn(\n "text-xs font-semibold px-2.5 py-1 rounded shrink-0",\n !minMet\n ? "text-destructive bg-destructive/10"\n : "text-destructive bg-destructive/10",\n classNames?.required,\n )}\n >\n Required\n </span>\n )}\n </div>\n\n <div\n data-cimplify-composite-components\n role={isSingleSelect ? "radiogroup" : "group"}\n aria-label={group.name}\n className={cn("divide-y divide-border", classNames?.components)}\n >\n {group._sortedComponents.map((component) => {\n const qty = groupSels[component.id] || 0;\n const isSelected = qty > 0;\n const displayName = component.display_name || component.id;\n\n return (\n <Checkbox.Root\n key={component.id}\n checked={isSelected}\n onCheckedChange={() => toggleComponent(group, component)}\n value={component.id}\n data-cimplify-composite-component\n data-selected={isSelected || undefined}\n className={cn(\n "w-full flex items-center gap-3 py-4 transition-colors text-left cursor-pointer",\n isSelected ? classNames?.componentSelected : classNames?.component,\n )}\n >\n <Checkbox.Indicator\n className="hidden"\n keepMounted={false}\n />\n\n {/* Visual indicator: radio circle for single-select, checkbox square for multi-select */}\n {isSingleSelect ? (\n <span\n data-cimplify-composite-radio\n className={cn(\n "w-5 h-5 rounded-full border-2 flex items-center justify-center shrink-0 transition-colors",\n isSelected ? "border-primary" : "border-muted-foreground/30",\n )}\n >\n {isSelected && <span className="w-2.5 h-2.5 rounded-full bg-primary" />}\n </span>\n ) : (\n <span\n data-cimplify-composite-checkbox\n className={cn(\n "w-5 h-5 rounded-sm border-2 flex items-center justify-center shrink-0 transition-colors",\n isSelected ? "border-primary bg-primary" : "border-muted-foreground/30",\n )}\n >\n {isSelected && (\n <svg viewBox="0 0 12 12" className="w-3 h-3 text-primary-foreground" fill="none">\n <path d="M2 6l3 3 5-5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>\n </svg>\n )}\n </span>\n )}\n\n <div\n data-cimplify-composite-component-info\n className={cn("flex-1 min-w-0", classNames?.componentInfo)}\n >\n <span\n data-cimplify-composite-component-name\n className={cn("text-sm", classNames?.componentName)}\n >\n {displayName}\n </span>\n {component.product_type === "service" && (\n <span\n data-cimplify-composite-type-badge="service"\n className={cn(\n "text-[10px] uppercase tracking-wider font-medium text-blue-600",\n classNames?.typeBadge,\n classNames?.serviceBadge,\n )}\n >\n <svg viewBox="0 0 16 16" fill="none" className="inline-block w-3 h-3 mr-0.5 -mt-px" aria-hidden="true">\n <circle cx="8" cy="8" r="7" stroke="currentColor" strokeWidth="1.5"/>\n <path d="M8 4v4l2.5 1.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>\n </svg>\n Service\n </span>\n )}\n {component.product_type === "digital" && (\n <span\n data-cimplify-composite-type-badge="digital"\n className={cn(\n "text-[10px] uppercase tracking-wider font-medium text-violet-600",\n classNames?.typeBadge,\n classNames?.digitalBadge,\n )}\n >\n <svg viewBox="0 0 16 16" fill="none" className="inline-block w-3 h-3 mr-0.5 -mt-px" aria-hidden="true">\n <path d="M9 2L4 9h4l-1 5 5-7H8l1-5z" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>\n </svg>\n Digital\n </span>\n )}\n {component.is_popular && (\n <span\n data-cimplify-composite-badge="popular"\n className={cn("text-[10px] uppercase tracking-wider text-primary font-medium", classNames?.badgePopular)}\n >\n Popular\n </span>\n )}\n {component.is_premium && (\n <span\n data-cimplify-composite-badge="premium"\n className={cn("text-[10px] uppercase tracking-wider text-amber-600 font-medium", classNames?.badgePremium)}\n >\n Premium\n </span>\n )}\n {component.display_description && (\n <span\n data-cimplify-composite-component-description\n className={cn("block text-xs text-muted-foreground truncate", classNames?.componentDescription)}\n >\n {component.display_description}\n </span>\n )}\n {component.calories != null && (\n <span\n data-cimplify-composite-component-calories\n className={cn("block text-xs text-muted-foreground/60", classNames?.componentCalories)}\n >\n {component.calories} cal\n </span>\n )}\n </div>\n\n {group.allow_quantity && isSelected && (\n <NumberField.Root\n value={qty}\n onValueChange={(val) => {\n if (val != null) {\n updateQuantity(group, component.id, val);\n }\n }}\n min={0}\n max={group.max_quantity_per_component || undefined}\n step={1}\n >\n <NumberField.Group\n data-cimplify-composite-qty\n onClick={(e: React.MouseEvent) => e.stopPropagation()}\n className={cn("flex items-center gap-2", classNames?.qty)}\n >\n <NumberField.Decrement\n aria-label={`Decrease ${displayName} quantity`}\n className={cn("w-6 h-6 border border-border flex items-center justify-center text-xs hover:bg-muted disabled:opacity-30", classNames?.qtyButton)}\n >\n −\n </NumberField.Decrement>\n <NumberField.Input\n readOnly\n className={cn("w-4 text-center text-sm font-medium bg-transparent border-none outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none", classNames?.qtyValue)}\n />\n <NumberField.Increment\n aria-label={`Increase ${displayName} quantity`}\n className={cn("w-6 h-6 border border-border flex items-center justify-center text-xs hover:bg-muted disabled:opacity-30", classNames?.qtyButton)}\n >\n +\n </NumberField.Increment>\n </NumberField.Group>\n </NumberField.Root>\n )}\n\n {component.price != null && (\n <span className="text-sm text-muted-foreground shrink-0">\n +<Price amount={component.price} />\n </span>\n )}\n </Checkbox.Root>\n );\n })}\n </div>\n </div>\n );\n })}\n\n {priceResult && (\n <div\n data-cimplify-composite-summary\n className={cn("border-t border-border pt-4 space-y-1 text-sm", classNames?.summary)}\n >\n {parsePrice(priceResult.base_price) !== 0 && (\n <div\n data-cimplify-composite-summary-line\n className={cn("flex justify-between text-muted-foreground", classNames?.summaryLine)}\n >\n <span>Base</span>\n <Price amount={priceResult.base_price} />\n </div>\n )}\n {parsePrice(priceResult.components_total) !== 0 && (\n <div\n data-cimplify-composite-summary-line\n className={cn("flex justify-between text-muted-foreground", classNames?.summaryLine)}\n >\n <span>Selections</span>\n <Price amount={priceResult.components_total} />\n </div>\n )}\n <div\n data-cimplify-composite-summary-total\n className={cn("flex justify-between font-medium pt-1 border-t border-border", classNames?.summaryTotal)}\n >\n <span>Total</span>\n <Price amount={priceResult.final_price} className="text-primary" />\n </div>\n </div>\n )}\n\n {isPriceLoading && (\n <div\n data-cimplify-composite-calculating\n className={cn("flex items-center gap-2 text-sm text-muted-foreground", classNames?.calculating)}\n >\n Calculating price...\n </div>\n )}\n\n {priceError && !isPriceLoading && (\n <div\n data-cimplify-composite-price-error\n className={cn("text-sm text-destructive", classNames?.priceError)}\n >\n Unable to calculate price\n </div>\n )}\n </div>\n );\n}\n' }] }, "location-picker": { "name": "location-picker", "title": "LocationPicker", "description": "Branch / pickup-point selector for businesses with multiple locations.", "type": "component", "registryDependencies": ["cn"], "files": [{ "path": "location-picker.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { Location } from "../types/business";\nimport { useLocations } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface LocationPickerClassNames {\n root?: string;\n item?: string;\n activeItem?: string;\n empty?: string;\n loading?: string;\n}\n\nexport interface LocationPickerProps {\n /** Override locations (skips useLocations fetch). For SSR, pass pre-fetched locations. */\n locations?: Location[];\n /** Override the current location. */\n currentLocation?: Location | null;\n /** Called when a location is selected. */\n onLocationChange?: (location: Location) => void;\n /** Custom location renderer. */\n renderLocation?: (location: Location, isActive: boolean) => React.ReactNode;\n /** Text shown when no locations are available. */\n emptyMessage?: string;\n className?: string;\n classNames?: LocationPickerClassNames;\n}\n\n/**\n * LocationPicker \u2014 renders a location selector for multi-location businesses.\n *\n * Uses `useLocations` to fetch locations unless overridden via props.\n * Returns `null` when there is only one location (no picker needed).\n */\nexport function LocationPicker({\n locations: locationsProp,\n currentLocation: currentLocationProp,\n onLocationChange,\n renderLocation,\n emptyMessage,\n className,\n classNames,\n}: LocationPickerProps): React.ReactElement | null {\n const {\n locations: fetched,\n currentLocation: fetchedCurrent,\n setCurrentLocation,\n isLoading,\n } = useLocations();\n\n const locations = locationsProp ?? fetched;\n const currentLocation = currentLocationProp ?? fetchedCurrent;\n\n const handleSelect = (location: Location): void => {\n if (onLocationChange) {\n onLocationChange(location);\n } else {\n setCurrentLocation(location);\n }\n };\n\n if (isLoading && locations.length === 0) {\n return (\n <div\n data-cimplify-location-picker\n aria-busy="true"\n className={cn(className, classNames?.root, classNames?.loading)}\n />\n );\n }\n\n // No picker needed for single location\n if (locations.length <= 1) {\n return null;\n }\n\n if (locations.length === 0) {\n return (\n <div\n data-cimplify-location-picker\n data-empty\n className={cn(className, classNames?.root, classNames?.empty)}\n >\n <p>{emptyMessage ?? "No locations available"}</p>\n </div>\n );\n }\n\n return (\n <div data-cimplify-location-picker className={cn(className, classNames?.root)}>\n {locations.map((location) => {\n const isActive = currentLocation?.id === location.id;\n return (\n <button\n key={location.id}\n type="button"\n onClick={() => handleSelect(location)}\n data-cimplify-location-item\n data-active={isActive || undefined}\n aria-pressed={isActive}\n className={cn(classNames?.item, isActive && classNames?.activeItem)}\n >\n {renderLocation ? (\n renderLocation(location, isActive)\n ) : (\n <span>{location.name}</span>\n )}\n </button>\n );\n })}\n </div>\n );\n}\n' }] }, "recommendation-carousel": { "name": "recommendation-carousel", "title": "RecommendationCarousel", "description": "Personalized product carousel powered by /activity/recommendations \u2014 frequently bought, related, trending.", "type": "component", "registryDependencies": ["product-card", "cn"], "files": [{ "path": "recommendation-carousel.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { ActivityRecommendation } from "../activity";\nimport { useRecommendations } from "./hooks/use-recommendations";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface RecommendationCarouselClassNames {\n root?: string;\n item?: string;\n reason?: string;\n empty?: string;\n loading?: string;\n}\n\nexport interface RecommendationCarouselProps {\n /** Override recommendations (skips fetch). For SSR, pass pre-fetched data. */\n recommendations?: ActivityRecommendation[];\n /** Location ID for location-specific recommendations. */\n locationId?: string;\n /** Maximum recommendations to fetch. */\n limit?: number;\n /** Called when a product recommendation is clicked. */\n onProductClick?: (recommendation: ActivityRecommendation) => void;\n /** Custom product card renderer. */\n renderProduct?: (recommendation: ActivityRecommendation) => React.ReactNode;\n /** Text shown when no recommendations are available. */\n emptyMessage?: string;\n className?: string;\n classNames?: RecommendationCarouselClassNames;\n}\n\n/**\n * RecommendationCarousel \u2014 displays personalized product recommendations.\n *\n * Renders a horizontal scrollable list of product cards with reason labels.\n * Returns `null` when there are no recommendations.\n */\nexport function RecommendationCarousel({\n recommendations: recommendationsProp,\n locationId,\n limit,\n onProductClick,\n renderProduct,\n emptyMessage,\n className,\n classNames,\n}: RecommendationCarouselProps): React.ReactElement | null {\n const { recommendations: fetched, isLoading } = useRecommendations({\n locationId,\n limit,\n enabled: recommendationsProp === undefined,\n });\n\n const recommendations = recommendationsProp ?? fetched;\n\n if (isLoading && recommendations.length === 0) {\n return (\n <div\n data-cimplify-recommendation-carousel\n aria-busy="true"\n className={cn(className, classNames?.root, classNames?.loading)}\n />\n );\n }\n\n if (recommendations.length === 0) {\n return null;\n }\n\n return (\n <div\n data-cimplify-recommendation-carousel\n className={cn(className, classNames?.root)}\n style={{ display: "flex", overflowX: "auto", gap: "1rem" }}\n >\n {recommendations.map((rec, index) => {\n const productId = rec.product?.id ?? String(index);\n return (\n <button\n key={String(productId)}\n type="button"\n onClick={() => onProductClick?.(rec)}\n data-cimplify-recommendation-item\n className={classNames?.item}\n style={{ flexShrink: 0 }}\n >\n {renderProduct ? (\n renderProduct(rec)\n ) : (\n <>\n <span data-cimplify-recommendation-name>\n {rec.product?.name ?? rec.product?.id ?? "Product"}\n </span>\n {rec.reason && (\n <span data-cimplify-recommendation-reason className={classNames?.reason}>\n {rec.reason}\n </span>\n )}\n </>\n )}\n </button>\n );\n })}\n </div>\n );\n}\n' }] }, "discount-input": { "name": "discount-input", "title": "DiscountInput", "description": "Discount code input with inline validation.", "type": "component", "registryDependencies": ["price", "cn"], "files": [{ "path": "discount-input.tsx", "content": '"use client";\n\nimport React, { useState, useCallback } from "react";\nimport { Field } from "@base-ui/react/field";\nimport { Input } from "@base-ui/react/input";\nimport type { DiscountValidation } from "@cimplify/sdk";\nimport { useValidateDiscount } from "@cimplify/sdk/react";\nimport { Price } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface DiscountInputClassNames {\n root?: string;\n input?: string;\n button?: string;\n result?: string;\n error?: string;\n success?: string;\n}\n\nexport interface DiscountInputProps {\n /** Current order subtotal for validation. */\n orderSubtotal: string;\n /** Location ID for location-specific discounts. */\n locationId?: string;\n /** Called when a valid discount is applied. */\n onApply?: (validation: DiscountValidation) => void;\n /** Called when discount is cleared. */\n onClear?: () => void;\n /** Placeholder text. */\n placeholder?: string;\n className?: string;\n classNames?: DiscountInputClassNames;\n}\n\n/**\n * DiscountInput \u2014 discount code input with inline validation.\n *\n * Wraps `useValidateDiscount` with a Base UI Field + Input and apply button.\n * Shows validation result inline (success with amount, or error via Field.Error).\n */\nexport function DiscountInput({\n orderSubtotal,\n locationId,\n onApply,\n onClear,\n placeholder = "Discount code",\n className,\n classNames,\n}: DiscountInputProps): React.ReactElement {\n const [code, setCode] = useState("");\n const [appliedValidation, setAppliedValidation] =\n useState<DiscountValidation | null>(null);\n const { validate, isValidating, error } = useValidateDiscount();\n\n const handleApply = useCallback(async () => {\n const trimmed = code.trim();\n if (!trimmed) return;\n\n const result = await validate(trimmed, orderSubtotal, locationId);\n if (result) {\n if (result.is_eligible) {\n setAppliedValidation(result);\n onApply?.(result);\n } else {\n setAppliedValidation(result);\n }\n }\n }, [code, validate, orderSubtotal, locationId, onApply]);\n\n const handleClear = useCallback(() => {\n setCode("");\n setAppliedValidation(null);\n onClear?.();\n }, [onClear]);\n\n const handleKeyDown = useCallback(\n (e: React.KeyboardEvent) => {\n if (e.key === "Enter") {\n void handleApply();\n }\n },\n [handleApply],\n );\n\n const isApplied = appliedValidation?.is_eligible === true;\n const hasError =\n !!error || (!!appliedValidation && !appliedValidation.is_eligible);\n\n const errorMessage = error\n ? error.message\n : appliedValidation && !appliedValidation.is_eligible\n ? (appliedValidation.ineligibility_reason ?? "This code is not valid.")\n : undefined;\n\n return (\n <Field.Root\n data-cimplify-discount\n invalid={hasError}\n disabled={isApplied}\n className={cn(className, classNames?.root)}\n >\n <div data-cimplify-discount-form>\n <Input\n type="text"\n value={code}\n onValueChange={(value) => setCode(value)}\n onKeyDown={handleKeyDown}\n placeholder={placeholder}\n data-cimplify-discount-input\n className={classNames?.input}\n aria-label="Discount code"\n />\n {isApplied ? (\n <button\n type="button"\n onClick={handleClear}\n data-cimplify-discount-clear\n className={classNames?.button}\n >\n Remove\n </button>\n ) : (\n <button\n type="button"\n onClick={handleApply}\n disabled={isValidating || code.trim().length === 0}\n data-cimplify-discount-apply\n className={classNames?.button}\n >\n {isValidating ? "Checking..." : "Apply"}\n </button>\n )}\n </div>\n\n {hasError && errorMessage && (\n <Field.Error\n match={true}\n data-cimplify-discount-error\n className={classNames?.error}\n >\n {errorMessage}\n </Field.Error>\n )}\n\n {isApplied && appliedValidation.discount_amount && (\n <Field.Description\n data-cimplify-discount-success\n className={classNames?.success}\n >\n <span>Discount applied</span>\n <Price amount={appliedValidation.discount_amount} prefix="-" />\n </Field.Description>\n )}\n </Field.Root>\n );\n}\n' }] }, "standard-service-card": { "name": "standard-service-card", "title": "StandardServiceCard", "description": "Service card with hero image, duration, deposit, and availability.", "type": "component", "registryDependencies": ["price", "cn"], "files": [{ "path": "cards/standard-service-card.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { Product } from "@cimplify/sdk";\nimport type { AvailableSlot } from "@cimplify/sdk";\nimport type { CardLayoutProps } from "@cimplify/sdk/react";\nimport { CardShell, CardImage } from "@cimplify/sdk/react";\nimport { Price } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface ServiceCardLayoutProps extends CardLayoutProps {\n slots?: AvailableSlot[];\n onBook?: (product: Product) => void;\n}\n\nfunction formatDuration(minutes: number, unit?: string): string {\n if (unit && unit !== "minutes") return `${minutes} ${unit}`;\n if (minutes >= 60) {\n const h = Math.floor(minutes / 60);\n const m = minutes % 60;\n return m > 0 ? `${h}h ${m}m` : `${h} hr${h > 1 ? "s" : ""}`;\n }\n return `${minutes} min`;\n}\n\nexport function StandardServiceCard({\n product,\n onBook,\n renderImage,\n renderLink,\n className,\n}: ServiceCardLayoutProps): React.ReactElement {\n const image = product.image_url || product.images?.[0] || "";\n const hasDeposit = product.deposit_type && product.deposit_type !== "none" && product.deposit_amount;\n\n return (\n <CardShell product={product} renderLink={renderLink} className={className}>\n <CardImage src={image} alt={product.name} aspectRatio="16/9" renderImage={renderImage}>\n <div className="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent pointer-events-none" />\n\n {/* Duration pill */}\n {product.duration_minutes != null && (\n <span className="absolute bottom-3 left-3 inline-flex items-center gap-1 text-[12px] font-semibold px-2.5 py-1 rounded-lg bg-background/92 backdrop-blur-xl text-foreground shadow-sm">\n {formatDuration(product.duration_minutes, product.duration_unit)}\n </span>\n )}\n\n {/* Price on image */}\n <span className="absolute bottom-3 right-3 text-white font-bold text-lg drop-shadow-sm">\n <Price amount={product.default_price} />\n </span>\n </CardImage>\n\n <div className="p-4">\n <h3 className="text-[14.5px] font-bold text-foreground leading-snug tracking-tight">\n {product.name}\n </h3>\n {product.description && (\n <p className="text-[12.5px] text-muted-foreground mt-1 leading-relaxed line-clamp-2">\n {product.description}\n </p>\n )}\n\n <div className="flex items-center justify-between mt-3 pt-3 border-t border-border">\n <div className="flex items-center gap-2 text-[12px] text-muted-foreground">\n <span className="w-[7px] h-[7px] rounded-full bg-emerald-500 animate-pulse" />\n <span>Available</span>\n {hasDeposit && (\n <>\n <span className="text-border">\xB7</span>\n <span><Price amount={product.deposit_amount!} /> deposit</span>\n </>\n )}\n </div>\n <button\n onClick={(e) => { e.preventDefault(); e.stopPropagation(); onBook?.(product); }}\n className="text-[13px] font-semibold text-primary hover:text-primary/80 transition-colors"\n >\n Book now →\n </button>\n </div>\n </div>\n </CardShell>\n );\n}\n' }] }, "quantity-selector": { "name": "quantity-selector", "title": "QuantitySelector", "description": "Controlled increment/decrement quantity input.", "type": "component", "registryDependencies": [], "files": [{ "path": "quantity-selector.tsx", "content": '"use client";\n\nimport React from "react";\nimport { NumberField } from "@base-ui/react/number-field";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface QuantitySelectorClassNames {\n root?: string;\n button?: string;\n value?: string;\n}\n\nexport interface QuantitySelectorProps {\n value: number;\n onChange: (value: number) => void;\n min?: number;\n max?: number;\n className?: string;\n classNames?: QuantitySelectorClassNames;\n}\n\nexport function QuantitySelector({\n value,\n onChange,\n min = 1,\n max,\n className,\n classNames,\n}: QuantitySelectorProps): React.ReactElement {\n return (\n <NumberField.Root\n value={value}\n onValueChange={(val) => {\n if (val != null) {\n onChange(val);\n }\n }}\n min={min}\n max={max}\n step={1}\n >\n <NumberField.Group\n data-cimplify-quantity\n className={cn("inline-flex items-center gap-3 border border-border px-2", className, classNames?.root)}\n >\n <NumberField.Decrement\n aria-label="Decrease quantity"\n data-cimplify-quantity-decrement\n className={cn(\n "w-10 h-10 flex items-center justify-center hover:text-primary transition-colors disabled:opacity-30",\n classNames?.button,\n )}\n >\n −\n </NumberField.Decrement>\n <NumberField.Input\n data-cimplify-quantity-value\n aria-live="polite"\n readOnly\n className={cn("w-8 text-center font-medium bg-transparent border-none outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none", classNames?.value)}\n />\n <NumberField.Increment\n aria-label="Increase quantity"\n data-cimplify-quantity-increment\n className={cn(\n "w-10 h-10 flex items-center justify-center hover:text-primary transition-colors disabled:opacity-30",\n classNames?.button,\n )}\n >\n +\n </NumberField.Increment>\n </NumberField.Group>\n </NumberField.Root>\n );\n}\n' }] }, "digital-product-layout": { "name": "digital-product-layout", "title": "DigitalProductLayout", "description": "Digital product layout with file details, download limits, event info, and access passes.", "type": "component", "registryDependencies": ["product-image-gallery", "product-customizer", "price", "cn"], "files": [{ "path": "layouts/digital-product-layout.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { ProductLayoutProps } from "@cimplify/sdk/react";\nimport {\n TwoColumnGrid,\n RelatedProductsSection,\n CustomAttributesTable,\n TagPills,\n} from "@cimplify/sdk/react";\nimport { ProductImageGallery } from "@cimplify/sdk/react";\nimport { ProductCustomizer } from "@cimplify/sdk/react";\nimport { Price } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nconst DIGITAL_TYPE_LABELS: Record<string, string> = {\n download: "Digital download",\n license: "Software license",\n event_ticket: "Event ticket",\n access_pass: "Access pass",\n gift_card: "Gift card",\n};\n\nexport function DigitalProductLayout({\n product,\n onAddToCart,\n renderBreadcrumb,\n relatedProducts,\n showRelated = true,\n className,\n}: ProductLayoutProps): React.ReactElement {\n const images = product.images || (product.image_url ? [product.image_url] : []);\n const typeLabel = product.digital_type\n ? DIGITAL_TYPE_LABELS[product.digital_type] || product.digital_type\n : "Digital product";\n\n const isTicket = product.digital_type === "event_ticket";\n const isAccessPass = product.digital_type === "access_pass";\n const isDownload = product.digital_type === "download" || product.digital_type === "license";\n\n return (\n <div data-cimplify-product-layout="digital" className={cn("space-y-8", className)}>\n {renderBreadcrumb?.(product)}\n\n <TwoColumnGrid\n left={\n <ProductImageGallery\n images={images}\n productName={product.name}\n />\n }\n right={\n <div className="space-y-6">\n {/* Type badge + tags */}\n <div className="flex flex-wrap items-center gap-2">\n <span data-cimplify-product-layout-badge className="text-xs font-semibold uppercase tracking-wider text-primary bg-primary/10 px-2.5 py-1 rounded-full">\n {typeLabel}\n </span>\n <TagPills tags={product.tags || []} />\n </div>\n\n {/* Name + Price */}\n <div>\n <h1 data-cimplify-product-layout-name className="text-3xl lg:text-4xl font-extrabold tracking-tight">\n {product.name}\n </h1>\n <Price amount={product.default_price} className="text-2xl font-bold mt-2 block" />\n </div>\n\n {/* Description */}\n {product.description && (\n <p data-cimplify-product-layout-description className="text-muted-foreground leading-relaxed">\n {product.description}\n </p>\n )}\n\n {/* Delivery notice */}\n {isDownload && (\n <div data-cimplify-product-layout-delivery className="flex items-center gap-3 px-4 py-3 bg-muted rounded-lg">\n <svg className="w-5 h-5 text-primary shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">\n <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />\n </svg>\n <div>\n <p className="text-sm font-medium">Instant delivery</p>\n <p className="text-xs text-muted-foreground">Download immediately after purchase</p>\n </div>\n </div>\n )}\n\n {/* Event info */}\n {isTicket && (product.event_date || product.venue) && (\n <div data-cimplify-product-layout-event className="border border-border p-4 space-y-2">\n {product.event_date && (\n <div className="flex items-center gap-2 text-sm">\n <span className="text-muted-foreground">Date:</span>\n <span className="font-medium">{new Date(product.event_date).toLocaleDateString(undefined, { weekday: "long", year: "numeric", month: "long", day: "numeric" })}</span>\n </div>\n )}\n {product.venue && (\n <div className="flex items-center gap-2 text-sm">\n <span className="text-muted-foreground">Venue:</span>\n <span className="font-medium">{product.venue}</span>\n </div>\n )}\n {product.ticket_type && (\n <div className="flex items-center gap-2 text-sm">\n <span className="text-muted-foreground">Type:</span>\n <span className="font-medium capitalize">{product.ticket_type}</span>\n </div>\n )}\n </div>\n )}\n\n {/* Access pass info */}\n {isAccessPass && (product.access_type || product.access_duration_days) && (\n <div data-cimplify-product-layout-access className="border border-border p-4 space-y-2">\n {product.access_type && (\n <div className="flex items-center gap-2 text-sm">\n <span className="text-muted-foreground">Access:</span>\n <span className="font-medium capitalize">{product.access_type}</span>\n </div>\n )}\n {product.access_level && (\n <div className="flex items-center gap-2 text-sm">\n <span className="text-muted-foreground">Level:</span>\n <span className="font-medium capitalize">{product.access_level}</span>\n </div>\n )}\n {product.access_duration_days && (\n <div className="flex items-center gap-2 text-sm">\n <span className="text-muted-foreground">Duration:</span>\n <span className="font-medium">{product.access_duration_days} days</span>\n </div>\n )}\n </div>\n )}\n\n {/* File details */}\n {isDownload && (product.file_size_mb || product.file_type || product.version || product.max_downloads) && (\n <div data-cimplify-product-layout-file-details className="grid grid-cols-2 gap-3">\n {product.file_type && (\n <div className="px-3 py-2 bg-muted text-center">\n <p className="text-xs text-muted-foreground">Format</p>\n <p className="text-sm font-semibold">{product.file_type.toUpperCase()}</p>\n </div>\n )}\n {product.file_size_mb != null && (\n <div className="px-3 py-2 bg-muted text-center">\n <p className="text-xs text-muted-foreground">Size</p>\n <p className="text-sm font-semibold">{product.file_size_mb >= 1024 ? `${(product.file_size_mb / 1024).toFixed(1)} GB` : `${product.file_size_mb} MB`}</p>\n </div>\n )}\n {product.max_downloads != null && (\n <div className="px-3 py-2 bg-muted text-center">\n <p className="text-xs text-muted-foreground">Downloads</p>\n <p className="text-sm font-semibold">{product.max_downloads}</p>\n </div>\n )}\n {product.version && (\n <div className="px-3 py-2 bg-muted text-center">\n <p className="text-xs text-muted-foreground">Version</p>\n <p className="text-sm font-semibold">v{product.version}</p>\n </div>\n )}\n {product.download_expires_days != null && (\n <div className="px-3 py-2 bg-muted text-center">\n <p className="text-xs text-muted-foreground">Expires</p>\n <p className="text-sm font-semibold">{product.download_expires_days} days</p>\n </div>\n )}\n {product.max_activations != null && (\n <div className="px-3 py-2 bg-muted text-center">\n <p className="text-xs text-muted-foreground">Activations</p>\n <p className="text-sm font-semibold">{product.max_activations}</p>\n </div>\n )}\n </div>\n )}\n\n {/* Custom Attributes */}\n <CustomAttributesTable attributes={product.custom_attributes || []} />\n\n {/* Customizer (license variants, billing plans, add to cart) */}\n <ProductCustomizer\n product={product}\n onAddToCart={onAddToCart}\n showSpecialInstructions={false}\n />\n </div>\n }\n />\n\n {showRelated && relatedProducts && relatedProducts.length > 0 && (\n <RelatedProductsSection\n products={relatedProducts}\n title="More digital products"\n />\n )}\n </div>\n );\n}\n' }] }, "slot-picker": { "name": "slot-picker", "title": "SlotPicker", "description": "Time slot grid for a single day with morning/afternoon/evening grouping.", "type": "component", "registryDependencies": ["price", "cn"], "files": [{ "path": "slot-picker.tsx", "content": '"use client";\n\nimport { Radio } from "@base-ui/react/radio";\nimport { RadioGroup } from "@base-ui/react/radio-group";\nimport React from "react";\nimport type { AvailableSlot } from "@cimplify/sdk";\nimport type { DurationUnit, SchedulingMode } from "@cimplify/sdk";\nimport { useAvailableSlots } from "@cimplify/sdk/react";\nimport { Price } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface SlotPickerClassNames {\n root?: string;\n group?: string;\n groupLabel?: string;\n slot?: string;\n slotTime?: string;\n slotPrice?: string;\n loading?: string;\n empty?: string;\n}\n\nexport interface SlotPickerProps {\n /** Pre-fetched slots (skips fetch). */\n slots?: AvailableSlot[];\n /** Service ID \u2014 used to fetch slots when `slots` prop is not provided. */\n serviceId?: string;\n /** Date string (YYYY-MM-DD) \u2014 used to fetch slots when `slots` prop is not provided. */\n date?: string;\n /** Number of participants for capacity-based availability. */\n participantCount?: number;\n /** Currently selected slot. */\n selectedSlot?: AvailableSlot | null;\n /** Called when a slot is selected. */\n onSlotSelect?: (slot: AvailableSlot) => void;\n /** Whether to group slots by time of day. Default: true. Ignored when `schedulingMode` is `"multi_day"`. */\n groupByTimeOfDay?: boolean;\n /** Show price on each slot. Default: true. */\n showPrice?: boolean;\n /**\n * Service scheduling mode. When `"multi_day"`, each slot renders as a\n * stay summary (`"3 nights: Fri Apr 5, 3:00 PM \u2192 Mon Apr 8, 11:00 AM"`)\n * instead of the time-of-day label. Defaults to `"intraday"`.\n */\n schedulingMode?: SchedulingMode;\n /** Service duration unit \u2014 used for the stay summary in multi-day mode. */\n durationUnit?: DurationUnit;\n /** Service duration value \u2014 used for the stay summary in multi-day mode. */\n durationValue?: number;\n /** Text shown when no slots available. */\n emptyMessage?: string;\n className?: string;\n classNames?: SlotPickerClassNames;\n}\n\ninterface SlotGroup {\n label: string;\n slots: AvailableSlot[];\n}\n\nfunction getTimeOfDay(timeStr: string): "morning" | "afternoon" | "evening" {\n const hour = parseInt(timeStr.split("T").pop()?.split(":")[0] ?? timeStr.split(":")[0], 10);\n if (hour < 12) return "morning";\n if (hour < 17) return "afternoon";\n return "evening";\n}\n\nconst TIME_OF_DAY_LABELS: Record<string, string> = {\n morning: "Morning",\n afternoon: "Afternoon",\n evening: "Evening",\n};\n\nfunction groupSlots(slots: AvailableSlot[]): SlotGroup[] {\n const groups: Record<string, AvailableSlot[]> = {};\n for (const slot of slots) {\n const tod = getTimeOfDay(slot.start_time);\n if (!groups[tod]) groups[tod] = [];\n groups[tod].push(slot);\n }\n return (["morning", "afternoon", "evening"] as const)\n .filter((tod) => groups[tod]?.length)\n .map((tod) => ({ label: TIME_OF_DAY_LABELS[tod], slots: groups[tod] }));\n}\n\nfunction formatTime(timeStr: string): string {\n try {\n const date = new Date(timeStr);\n if (!isNaN(date.getTime())) {\n return date.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" });\n }\n } catch {\n // noop\n }\n\n const parts = timeStr.split(":");\n if (parts.length >= 2) {\n const hour = parseInt(parts[0], 10);\n const minute = parts[1];\n const ampm = hour >= 12 ? "PM" : "AM";\n const displayHour = hour % 12 || 12;\n return `${displayHour}:${minute} ${ampm}`;\n }\n return timeStr;\n}\n\nfunction pluralizeUnit(unit: DurationUnit | undefined, value: number | undefined): string {\n if (!unit) return value === 1 ? "day" : "days";\n const v = value ?? 1;\n if (unit === "minutes") return v === 1 ? "minute" : "minutes";\n if (unit === "hours") return v === 1 ? "hour" : "hours";\n if (unit === "days") return v === 1 ? "day" : "days";\n if (unit === "weeks") return v === 1 ? "week" : "weeks";\n if (unit === "months") return v === 1 ? "month" : "months";\n return unit;\n}\n\nfunction formatStaySummary(\n slot: AvailableSlot,\n durationUnit: DurationUnit | undefined,\n durationValue: number | undefined,\n): string {\n const start = new Date(slot.start_time);\n const end = new Date(slot.end_time);\n const startLabel = start.toLocaleString(undefined, {\n weekday: "short",\n month: "short",\n day: "numeric",\n hour: "numeric",\n minute: "2-digit",\n });\n const endLabel = end.toLocaleString(undefined, {\n weekday: "short",\n month: "short",\n day: "numeric",\n hour: "numeric",\n minute: "2-digit",\n });\n const unitLabel = pluralizeUnit(durationUnit, durationValue);\n if (durationValue !== undefined) {\n return `${durationValue} ${unitLabel}: ${startLabel} \u2192 ${endLabel}`;\n }\n return `${startLabel} \u2192 ${endLabel}`;\n}\n\nfunction slotToValue(slot: AvailableSlot): string {\n return `${slot.start_time}|${slot.end_time}`;\n}\n\nexport function SlotPicker({\n slots: slotsProp,\n serviceId,\n date,\n participantCount,\n selectedSlot,\n onSlotSelect,\n groupByTimeOfDay = true,\n showPrice = true,\n schedulingMode = "intraday",\n durationUnit,\n durationValue,\n emptyMessage = "No available slots",\n className,\n classNames,\n}: SlotPickerProps): React.ReactElement {\n const isMultiDay = schedulingMode === "multi_day";\n const { slots: fetched, isLoading } = useAvailableSlots(\n serviceId ?? null,\n date ?? null,\n {\n participantCount,\n enabled: slotsProp === undefined && !!serviceId && !!date,\n },\n );\n\n const slots = slotsProp ?? fetched;\n\n if (isLoading && slots.length === 0) {\n return (\n <div\n data-cimplify-slot-picker\n aria-busy="true"\n className={cn(className, classNames?.root, classNames?.loading)}\n />\n );\n }\n\n if (slots.length === 0) {\n return (\n <div\n data-cimplify-slot-picker\n data-empty\n className={cn(className, classNames?.root, classNames?.empty)}\n >\n <p>{emptyMessage}</p>\n </div>\n );\n }\n\n const groups = groupByTimeOfDay && !isMultiDay\n ? groupSlots(slots)\n : [{ label: "", slots }];\n\n const slotsByValue = new Map<string, AvailableSlot>();\n for (const slot of slots) {\n slotsByValue.set(slotToValue(slot), slot);\n }\n\n const selectedValue = selectedSlot ? slotToValue(selectedSlot) : "";\n\n return (\n <RadioGroup\n data-cimplify-slot-picker\n className={cn("flex flex-col gap-4", className, classNames?.root)}\n value={selectedValue}\n onValueChange={(value: string) => {\n const slot = slotsByValue.get(value);\n // Slots default to available; treat as unavailable only when the\n // backend explicitly returns `is_available: false`.\n if (slot && slot.is_available !== false) {\n onSlotSelect?.(slot);\n }\n }}\n >\n {groups.map((group) => (\n <div\n key={group.label || "all"}\n data-cimplify-slot-group\n className={cn("flex flex-col gap-2", classNames?.group)}\n >\n {group.label && (\n <div\n data-cimplify-slot-group-label\n className={cn(\n "text-xs font-medium uppercase tracking-[0.12em] text-muted-foreground",\n classNames?.groupLabel,\n )}\n >\n {group.label}\n </div>\n )}\n <div\n className={cn(\n isMultiDay\n ? "flex flex-col gap-2"\n : "grid grid-cols-3 sm:grid-cols-4 gap-2",\n )}\n >\n {group.slots.map((slot) => {\n const value = slotToValue(slot);\n const isSelected =\n selectedSlot?.start_time === slot.start_time &&\n selectedSlot?.end_time === slot.end_time;\n return (\n <Radio.Root\n key={value}\n value={value}\n disabled={slot.is_available === false}\n data-cimplify-slot\n data-selected={isSelected || undefined}\n data-unavailable={slot.is_available === false || undefined}\n className={cn(\n "inline-flex items-center justify-center gap-2 rounded-md border border-border bg-background px-3 py-2 text-sm font-medium text-foreground transition-colors hover:border-foreground/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring data-[selected]:border-foreground data-[selected]:bg-foreground data-[selected]:text-background data-[unavailable]:cursor-not-allowed data-[unavailable]:opacity-40 data-[unavailable]:line-through",\n isMultiDay && "justify-between text-left",\n classNames?.slot,\n )}\n >\n <span data-cimplify-slot-time className={classNames?.slotTime}>\n {isMultiDay\n ? formatStaySummary(slot, durationUnit, durationValue)\n : formatTime(slot.start_time)}\n </span>\n {showPrice && slot.price && (\n <span\n data-cimplify-slot-price\n className={cn("text-xs opacity-70", classNames?.slotPrice)}\n >\n <Price amount={slot.price} />\n </span>\n )}\n </Radio.Root>\n );\n })}\n </div>\n </div>\n ))}\n </RadioGroup>\n );\n}\n' }] }, "sale-badge": { "name": "sale-badge", "title": "SaleBadge", "description": "Sale/discount indicator with percentage and original price.", "type": "component", "registryDependencies": ["price", "cn"], "files": [{ "path": "sale-badge.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { Product, ProductDealInfo } from "@cimplify/sdk";\nimport type { ProductWithPrice } from "@cimplify/sdk";\nimport {\n isOnSale,\n getDiscountPercentage,\n getBasePrice,\n parsePrice,\n} from "@cimplify/sdk";\nimport { Price } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface SaleBadgeClassNames {\n root?: string;\n percentage?: string;\n label?: string;\n originalPrice?: string;\n}\n\nexport interface SaleBadgeProps {\n /** Product, optionally enriched with price_info for sale detection. */\n product: Product & Partial<ProductWithPrice>;\n /** Deal info from useProductDeals / useProductsOnSale. */\n dealInfo?: ProductDealInfo;\n /** Override badge text entirely. */\n label?: string;\n /** Show the original (pre-discount) price with strikethrough styling. */\n showOriginalPrice?: boolean;\n /** Show the percentage off. Default: true. */\n showPercentage?: boolean;\n className?: string;\n classNames?: SaleBadgeClassNames;\n}\n\n/**\n * SaleBadge \u2014 shows a sale/discount indicator for a product.\n *\n * Returns `null` when there\'s no deal, no sale price difference, and no label override,\n * so it\'s safe to render unconditionally \u2014 it simply won\'t show for non-sale products.\n */\nexport function SaleBadge({\n product,\n dealInfo,\n label,\n showOriginalPrice = false,\n showPercentage = true,\n className,\n classNames,\n}: SaleBadgeProps): React.ReactElement | null {\n const onSale = isOnSale(product);\n const hasDeal = dealInfo !== undefined;\n\n if (!hasDeal && !onSale && !label) {\n return null;\n }\n\n // Percentage: prefer dealInfo when it\'s a percentage benefit, else compute from prices\n let percentage: number | null = null;\n if (hasDeal && dealInfo.benefit_type === "percentage") {\n percentage = parsePrice(dealInfo.value);\n } else if (onSale) {\n percentage = getDiscountPercentage(product);\n }\n\n // Badge text: explicit label > deal label > computed "X% off"\n const badgeText =\n label ??\n dealInfo?.label ??\n (percentage != null && percentage > 0 ? `${percentage}% off` : null);\n\n if (!badgeText) {\n return null;\n }\n\n return (\n <span\n data-cimplify-sale-badge\n className={cn(className, classNames?.root)}\n style={{ display: "inline-flex", alignItems: "center", gap: "0.375rem" }}\n >\n {showPercentage && percentage != null && percentage > 0 && (\n <span data-cimplify-sale-percentage className={classNames?.percentage}>\n -{percentage}%\n </span>\n )}\n <span data-cimplify-sale-label className={classNames?.label}>\n {badgeText}\n </span>\n {showOriginalPrice && onSale && (\n <span data-cimplify-sale-original-price className={classNames?.originalPrice}>\n <Price amount={getBasePrice(product)} />\n </span>\n )}\n </span>\n );\n}\n' }] } };
|
|
5705
5705
|
var REGISTRY_INDEX = { "components": [{ "name": "price", "title": "Price", "description": "Renders a formatted price in the display currency.", "type": "component", "registryDependencies": [] }, { "name": "quantity-selector", "title": "QuantitySelector", "description": "Controlled increment/decrement quantity input.", "type": "component", "registryDependencies": [] }, { "name": "variant-selector", "title": "VariantSelector", "description": "Select product variants via axis chips or direct list.", "type": "component", "registryDependencies": ["price"] }, { "name": "add-on-selector", "title": "AddOnSelector", "description": "Modifier groups with single-select or multi-select options.", "type": "component", "registryDependencies": ["price"] }, { "name": "bundle-selector", "title": "BundleSelector", "description": "Bundle component picker with variant choices and price summary.", "type": "component", "registryDependencies": ["price"] }, { "name": "composite-selector", "title": "CompositeSelector", "description": "Composite product builder with group constraints and live pricing.", "type": "component", "registryDependencies": ["price"] }, { "name": "product-customizer", "title": "ProductCustomizer", "description": "Full product configuration with variants, add-ons, and add-to-cart.", "type": "component", "registryDependencies": ["price", "quantity-selector", "variant-selector", "add-on-selector", "composite-selector", "bundle-selector"] }, { "name": "product-image-gallery", "title": "ProductImageGallery", "description": "Main image with thumbnail strip for product images.", "type": "component", "registryDependencies": [] }, { "name": "cart-summary", "title": "CartSummary", "description": "Cart line items with quantity controls and totals.", "type": "component", "registryDependencies": ["price", "quantity-selector"] }, { "name": "availability-badge", "title": "AvailabilityBadge", "description": "Displays in-stock / out-of-stock status for tracked products.", "type": "component", "registryDependencies": ["cn"] }, { "name": "sale-badge", "title": "SaleBadge", "description": "Sale/discount indicator with percentage and original price.", "type": "component", "registryDependencies": ["price", "cn"] }, { "name": "product-sheet", "title": "ProductSheet", "description": "Full product detail view with gallery, header, and customizer.", "type": "component", "registryDependencies": ["price", "product-image-gallery", "product-customizer", "cn"] }, { "name": "product-card", "title": "ProductCard", "description": "Product display card with modal or link mode.", "type": "component", "registryDependencies": ["price", "product-sheet", "cn"] }, { "name": "product-grid", "title": "ProductGrid", "description": "Responsive CSS grid that renders ProductCards.", "type": "component", "registryDependencies": ["product-card", "cn"] }, { "name": "cn", "title": "cn", "description": "Utility that merges Tailwind CSS classes with clsx + tailwind-merge.", "type": "utility", "registryDependencies": [] }, { "name": "search-input", "title": "SearchInput", "description": "Search bar with debounced results dropdown.", "type": "component", "registryDependencies": ["cn"] }, { "name": "category-filter", "title": "CategoryFilter", "description": "Selectable category chips for filtering products.", "type": "component", "registryDependencies": ["cn"] }, { "name": "discount-input", "title": "DiscountInput", "description": "Discount code input with inline validation.", "type": "component", "registryDependencies": ["price", "cn"] }, { "name": "category-grid", "title": "CategoryGrid", "description": "Responsive grid of category cards.", "type": "component", "registryDependencies": ["cn"] }, { "name": "deal-banner", "title": "DealBanner", "description": "Displays active deals and promotions.", "type": "component", "registryDependencies": ["price", "cn"] }, { "name": "order-summary", "title": "OrderSummary", "description": "Single order detail view with line items and totals.", "type": "component", "registryDependencies": ["price", "cn"] }, { "name": "order-history", "title": "OrderHistory", "description": "List of past orders with status and totals.", "type": "component", "registryDependencies": ["price", "cn"] }, { "name": "store-nav", "title": "StoreNav", "description": "Top navigation bar with brand, categories, cart badge, and search.", "type": "component", "registryDependencies": ["cn"] }, { "name": "catalogue-page", "title": "CataloguePage", "description": "Browse all products with category filtering and search.", "type": "component", "registryDependencies": ["product-grid", "category-filter", "search-input", "cn"] }, { "name": "product-page", "title": "ProductPage", "description": "Smart product page resolver with per-slug and per-type template routing.", "type": "component", "registryDependencies": ["default-product-layout", "food-product-layout", "wholesale-product-layout", "service-product-layout", "digital-product-layout", "cn"] }, { "name": "cart-page", "title": "CartPage", "description": "Full-page cart with summary, discount input, and checkout.", "type": "component", "registryDependencies": ["cart-summary", "discount-input", "cn"] }, { "name": "checkout-page", "title": "CheckoutPage", "description": "Multi-step checkout with auth, address, and payment.", "type": "component", "registryDependencies": ["cn"] }, { "name": "collection-page", "title": "CollectionPage", "description": "Curated product collection with header and grid.", "type": "component", "registryDependencies": ["product-grid", "cn"] }, { "name": "order-detail-page", "title": "OrderDetailPage", "description": "Single order detail view with live status polling.", "type": "component", "registryDependencies": ["order-summary", "cn"] }, { "name": "order-history-page", "title": "OrderHistoryPage", "description": "Order list with status filtering and inline detail view.", "type": "component", "registryDependencies": ["order-history", "order-summary", "cn"] }, { "name": "search-page", "title": "SearchPage", "description": "Dedicated search page with input and results grid.", "type": "component", "registryDependencies": ["product-grid", "cn"] }, { "name": "deals-page", "title": "DealsPage", "description": "Promotions landing page with deal banners and on-sale products.", "type": "component", "registryDependencies": ["deal-banner", "product-grid", "sale-badge", "product-card", "cn"] }, { "name": "slot-picker", "title": "SlotPicker", "description": "Time slot grid for a single day with morning/afternoon/evening grouping.", "type": "component", "registryDependencies": ["price", "cn"] }, { "name": "date-slot-picker", "title": "DateSlotPicker", "description": "Horizontal date strip with slot picker for service scheduling.", "type": "component", "registryDependencies": ["slot-picker", "cn"] }, { "name": "staff-picker", "title": "StaffPicker", "description": "Staff member selection list with avatar and bio.", "type": "component", "registryDependencies": ["cn"] }, { "name": "booking-card", "title": "BookingCard", "description": "Single booking display with status, time, and action buttons.", "type": "component", "registryDependencies": ["price", "cn"] }, { "name": "booking-list", "title": "BookingList", "description": "List of booking cards with optional self-fetching.", "type": "component", "registryDependencies": ["booking-card", "cn"] }, { "name": "booking-page", "title": "BookingPage", "description": "Multi-step booking flow: service, staff, resource, date/slot, confirmation.", "type": "component", "registryDependencies": ["date-slot-picker", "staff-picker", "resource-picker", "price", "cn"] }, { "name": "bookings-page", "title": "BookingsPage", "description": "Account-area page listing a customer's bookings with filters and detail view.", "type": "component", "registryDependencies": ["booking-list", "booking-card", "cn"] }, { "name": "food-product-card", "title": "FoodProductCard", "description": "Product card for food items with tags, badges, and quick-add.", "type": "component", "registryDependencies": ["price", "cn"] }, { "name": "retail-product-card", "title": "RetailProductCard", "description": "Product card for retail with color swatches, sale badge, wishlist, and sold-out overlay.", "type": "component", "registryDependencies": ["price", "cn"] }, { "name": "wholesale-product-card", "title": "WholesaleProductCard", "description": "B2B product card with price range, MOQ badge, and stock count.", "type": "component", "registryDependencies": ["price", "price-range", "cn"] }, { "name": "digital-product-card", "title": "DigitalProductCard", "description": "Digital product card with type badge, file info, and event details.", "type": "component", "registryDependencies": ["price", "cn"] }, { "name": "standard-service-card", "title": "StandardServiceCard", "description": "Service card with hero image, duration, deposit, and availability.", "type": "component", "registryDependencies": ["price", "cn"] }, { "name": "compact-service-card", "title": "CompactServiceCard", "description": "Horizontal service card with thumbnail for list views.", "type": "component", "registryDependencies": ["price", "cn"] }, { "name": "schedule-service-card", "title": "ScheduleServiceCard", "description": "Service card with next available time slot pills.", "type": "component", "registryDependencies": ["price", "cn"] }, { "name": "rental-service-card", "title": "RentalServiceCard", "description": "Rental card with per-day/hour pricing, deposit, and availability count.", "type": "component", "registryDependencies": ["price", "cn"] }, { "name": "accommodation-card", "title": "AccommodationCard", "description": "Hotel/accommodation card with per-night pricing, amenities, capacity, and cancellation.", "type": "component", "registryDependencies": ["price", "volume-pricing", "cn"] }, { "name": "lease-service-card", "title": "LeaseServiceCard", "description": "Long-term lease card with per-month/year pricing, volume tiers, and billing.", "type": "component", "registryDependencies": ["price", "volume-pricing", "cn"] }, { "name": "subscription-card", "title": "SubscriptionCard", "description": "Subscription card with billing plan options, trial badge, and setup fee.", "type": "component", "registryDependencies": ["price", "cn"] }, { "name": "volume-pricing", "title": "VolumePricing", "description": "Collapsible volume pricing tier table.", "type": "component", "registryDependencies": ["price", "cn"] }, { "name": "price-range", "title": "PriceRange", "description": "Displays min-max price range for products with variants or tiers.", "type": "component", "registryDependencies": [] }, { "name": "default-product-layout", "title": "DefaultProductLayout", "description": "Two-column product layout for retail/physical products with sale badges, specs, and properties.", "type": "component", "registryDependencies": ["product-image-gallery", "product-customizer", "price", "price-range", "availability-badge", "sale-badge", "cn"] }, { "name": "food-product-layout", "title": "FoodProductLayout", "description": "Restaurant product layout with allergens, ingredients, pairings, and dietary tags.", "type": "component", "registryDependencies": ["product-image-gallery", "product-customizer", "price", "cn"] }, { "name": "wholesale-product-layout", "title": "WholesaleProductLayout", "description": "B2B wholesale layout with price range, volume pricing, MOQ, and inventory.", "type": "component", "registryDependencies": ["product-image-gallery", "product-customizer", "price", "price-range", "volume-pricing", "cn"] }, { "name": "service-product-layout", "title": "ServiceProductLayout", "description": "Service booking layout with duration, deposit, staff requirements, and cancellation policy.", "type": "component", "registryDependencies": ["product-image-gallery", "product-customizer", "price", "cn"] }, { "name": "digital-product-layout", "title": "DigitalProductLayout", "description": "Digital product layout with file details, download limits, event info, and access passes.", "type": "component", "registryDependencies": ["product-image-gallery", "product-customizer", "price", "cn"] }, { "name": "chat-widget", "title": "ChatWidget", "description": "Embeddable chat widget with AI shopping assistant powered by the support channel.", "type": "component", "registryDependencies": ["cn"] }, { "name": "cart-drawer", "title": "CartDrawer", "description": "Slide-in side cart drawer with provider context, free-shipping progress, animated subtotal, and empty state. Auto-opens on add-to-cart.", "type": "component", "registryDependencies": ["cart-summary", "price", "cn"] }, { "name": "account", "title": "CimplifyAccount", "description": "Iframe wrapper for the Cimplify account portal \u2014 sign-in, orders, addresses, settings.", "type": "component", "registryDependencies": ["cn"] }, { "name": "billing-plan-selector", "title": "BillingPlanSelector", "description": "Subscription / billing-plan picker \u2014 surfaces eligible plans with pricing and trial periods.", "type": "component", "registryDependencies": ["price", "cn"] }, { "name": "currency-selector", "title": "CurrencySelector", "description": "Multi-currency switcher backed by the FX provider \u2014 locks display currency and quote ID.", "type": "component", "registryDependencies": ["cn"] }, { "name": "customer-input-fields", "title": "CustomerInputFields", "description": "Per-product custom input fields \u2014 text, number, date, time, file upload, image upload, single/multi-select.", "type": "component", "registryDependencies": ["cn"] }, { "name": "delivery-estimate", "title": "DeliveryEstimate", "description": "Delivery fee + ETA preview at the cart/checkout edge, sourced from /delivery/fee with location.", "type": "component", "registryDependencies": ["price", "cn"] }, { "name": "location-picker", "title": "LocationPicker", "description": "Branch / pickup-point selector for businesses with multiple locations.", "type": "component", "registryDependencies": ["cn"] }, { "name": "resource-picker", "title": "ResourcePicker", "description": "Staff / room / resource picker for bookable services \u2014 used by services and restaurant reservation flows.", "type": "component", "registryDependencies": ["cn"] }, { "name": "recently-viewed", "title": "RecentlyViewed", "description": "Horizontally scrollable rail of recently viewed products, hydrated from local activity state.", "type": "component", "registryDependencies": ["product-card", "cn"] }, { "name": "recommendation-carousel", "title": "RecommendationCarousel", "description": "Personalized product carousel powered by /activity/recommendations \u2014 frequently bought, related, trending.", "type": "component", "registryDependencies": ["product-card", "cn"] }, { "name": "session-message-banner", "title": "SessionMessageBanner", "description": "Top-of-page banner for session-scoped messages (promo nudges, abandoned cart prompts, low-stock alerts) with dismiss tracking.", "type": "component", "registryDependencies": ["cn"] }] };
|
|
5706
5706
|
|
|
5707
5707
|
export { REGISTRY, REGISTRY_INDEX, TEMPLATES };
|