@cimplify/cli 0.7.21 → 0.7.23

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.
@@ -720,7 +720,7 @@ function Field({
720
720
  </div>
721
721
  );
722
722
  }
723
- ` }, { "path": "app/track-order/track-order-form.tsx", "kind": "text", "content": '"use client";\n\nimport { useState } from "react";\nimport { useRouter } from "next/navigation";\n\n/**\n * Guest order tracker. Routes /orders/<id> with the entered id; the\n * post-checkout confirmation page already lives at /orders/[id] so this\n * just sends the visitor there. Replace the redirect with a real lookup\n * (e.g. Cimplify orders API + email-match guard) for production.\n */\nexport function TrackOrderForm() {\n const router = useRouter();\n const [orderId, setOrderId] = useState("");\n const [email, setEmail] = useState("");\n\n return (\n <form\n onSubmit={(e) => {\n e.preventDefault();\n if (!orderId.trim()) return;\n router.push(`/orders/${encodeURIComponent(orderId.trim())}`);\n }}\n className="space-y-4 rounded-2xl border border-border bg-card p-6"\n >\n <div>\n <label\n htmlFor="orderId"\n className="text-xs font-semibold uppercase tracking-wider text-muted-foreground block mb-1.5"\n >\n Order number\n </label>\n <input\n id="orderId"\n name="orderId"\n required\n value={orderId}\n onChange={(e) => setOrderId(e.target.value)}\n placeholder="ord_abc123\u2026"\n className="w-full px-4 py-3 rounded-md bg-background border border-border focus:border-primary focus:ring-2 focus:ring-primary/20 outline-none text-sm"\n />\n </div>\n <div>\n <label\n htmlFor="email"\n className="text-xs font-semibold uppercase tracking-wider text-muted-foreground block mb-1.5"\n >\n Order email\n </label>\n <input\n id="email"\n name="email"\n type="email"\n required\n value={email}\n onChange={(e) => setEmail(e.target.value)}\n placeholder="you@email.com"\n className="w-full px-4 py-3 rounded-md bg-background border border-border focus:border-primary focus:ring-2 focus:ring-primary/20 outline-none text-sm"\n />\n </div>\n <button\n type="submit"\n className="w-full inline-flex items-center justify-center px-5 py-3 rounded-md bg-primary text-primary-foreground font-semibold text-sm hover:bg-primary/90 transition-colors"\n >\n Track order\n </button>\n </form>\n );\n}\n' }, { "path": "app/track-order/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { TrackOrderForm } from "./track-order-form";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Track an order \u2014 ${brand.name}`,\n description: brand.trackOrder.body,\n};\n\nexport default function TrackOrderPage() {\n const t = brand.trackOrder;\n return (\n <article className="max-w-2xl 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 {t.eyebrow}\n </p>\n <h1 className="font-serif text-[clamp(2rem,5vw,3rem)] font-semibold mb-4 -tracking-[0.02em]">\n {t.title}\n </h1>\n <p className="text-muted-foreground leading-relaxed mb-8">{t.body}</p>\n <TrackOrderForm />\n </article>\n );\n}\n' }, { "path": "app/robots.ts", "kind": "text", "content": 'import type { MetadataRoute } from "next";\nimport { getSiteUrl } from "@/lib/site-url";\n\nexport default async function robots(): Promise<MetadataRoute.Robots> {\n const siteUrl = await getSiteUrl();\n return {\n rules: [\n {\n userAgent: "*",\n allow: "/",\n disallow: ["/cart", "/checkout", "/orders/", "/api/"],\n },\n ],\n sitemap: `${siteUrl}/sitemap.xml`,\n host: siteUrl,\n };\n}\n' }, { "path": ".gitignore", "kind": "text", "content": "node_modules\n.next\n.open-next\nout\n.env\n.env.local\n.env*.local\n.DS_Store\n*.tsbuildinfo\nnext-env.d.ts\n\n# Playwright runtime artifacts (commit the snapshots in e2e/__snapshots__/)\ntest-results/\nplaywright-report/\nblob-report/\n.playwright/\n" }, { "path": "__tests__/brand.test.ts", "kind": "text", "content": 'import { createBrandSuite } from "@cimplify/sdk/testing/suite";\nimport { brand } from "../lib/brand";\n\ncreateBrandSuite({ brand });\n' }, { "path": "__tests__/contract.test.ts", "kind": "text", "content": 'import { createContractSuite } from "@cimplify/sdk/testing/suite";\nimport { brand } from "../lib/brand";\n\ncreateContractSuite({ seed: brand.mock.seed });\n' }, { "path": "__tests__/cart-flow.test.ts", "kind": "text", "content": 'import { createCartFlowSuite } from "@cimplify/sdk/testing/suite";\nimport { expect } from "vitest";\nimport { brand } from "../lib/brand";\n\n/**\n * Cart flow suite \u2014 base + per-merchant assertions.\n *\n * The base suite (from `@cimplify/sdk/testing/suite`) exercises the\n * universal contract: empty cart, add, dedupe, remove, businessId\n * round-trip. The `extend` hook below adds Studio FRX-specific\n * invariants \u2014 copy this pattern when a merchant has unique business\n * rules that need their own coverage.\n *\n * \u2022 `getHandle()` returns the suite\'s current `TestClientHandle`\n * (per `it`) so the seed and session are already wired.\n * \u2022 `it` is the vitest API; cases land inside the suite\'s `describe`\n * so its `beforeEach`/`afterEach` hooks apply automatically.\n */\ncreateCartFlowSuite({\n seed: brand.mock.seed,\n businessId: brand.mock.businessId,\n extend: ({ getHandle, it }) => {\n it("Studio FRX prices everything in GHS", async () => {\n // Currency is brand-level (not per-product) \u2014 Product carries the price\n // string only. Catalog completeness here is just a sanity check.\n expect(brand.currency).toBe("GHS");\n const h = getHandle();\n const list = await h.client.catalogue.getProducts();\n if (!list.ok) throw list.error;\n const items = (list.value as unknown as { items?: unknown[] }).items ?? [];\n expect(items.length).toBeGreaterThan(0);\n });\n\n it("every apparel product covers the studio\'s core size run (S/M/L)", async () => {\n const h = getHandle();\n const list = await h.client.catalogue.getProducts();\n if (!list.ok) throw list.error;\n const items = (list.value as unknown as { items?: { id: string; name: string }[] }).items ?? [];\n\n // Apparel only \u2014 caps/socks/totes intentionally lack size variants.\n const apparel = items.filter((p) => /tee|hoodie|jacket|trouser|pant/i.test(p.name));\n expect(apparel.length).toBeGreaterThan(0);\n\n const required = new Set(["S", "M", "L"]);\n for (const product of apparel) {\n const variants = await h.client.catalogue.getVariants(product.id);\n if (!variants.ok) throw variants.error;\n const sizes = new Set(variants.value.map((v) => v.name).filter(Boolean));\n for (const size of required) {\n expect(sizes, `${product.name} missing size ${size}`).toContain(size);\n }\n }\n });\n },\n});\n' }, { "path": "playwright.config.ts", "kind": "text", "content": 'import { defineConfig, devices } from "@playwright/test";\n\n/**\n * Visual regression config \u2014 boots `bun dev` (mock + next), runs against\n * a stable Chromium, snapshots key pages.\n *\n * Run:\n * bun run check:visual # compare against baseline\n * bun run check:visual --update-snapshots # accept new baseline\n *\n * Baselines live in `e2e/__snapshots__/`. Commit them.\n */\nexport default defineConfig({\n testDir: "./e2e",\n fullyParallel: false,\n forbidOnly: Boolean(process.env.CI),\n retries: process.env.CI ? 1 : 0,\n workers: 1,\n reporter: process.env.CI ? "github" : "list",\n\n use: {\n baseURL: "http://localhost:3000",\n trace: "retain-on-failure",\n // Viewport snapshots are noisy across OS / browser builds. Lock here.\n viewport: { width: 1280, height: 800 },\n },\n\n projects: [\n {\n name: "chromium",\n use: { ...devices["Desktop Chrome"], viewport: { width: 1280, height: 800 } },\n },\n ],\n\n expect: {\n // Anti-flake: allow tiny rendering differences (font hinting across runners).\n toHaveScreenshot: { maxDiffPixelRatio: 0.01 },\n },\n\n webServer: {\n command: "bun dev",\n url: "http://localhost:3000",\n reuseExistingServer: !process.env.CI,\n timeout: 120_000,\n stdout: "ignore",\n stderr: "pipe",\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.80.0",\n "next": "^16.2.6",\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": "middleware.ts", "kind": "text", "content": `import { NextResponse, type NextRequest } from "next/server";
723
+ ` }, { "path": "app/track-order/track-order-form.tsx", "kind": "text", "content": '"use client";\n\nimport { useState } from "react";\nimport { useRouter } from "next/navigation";\n\n/**\n * Guest order tracker. Routes /orders/<id> with the entered id; the\n * post-checkout confirmation page already lives at /orders/[id] so this\n * just sends the visitor there. Replace the redirect with a real lookup\n * (e.g. Cimplify orders API + email-match guard) for production.\n */\nexport function TrackOrderForm() {\n const router = useRouter();\n const [orderId, setOrderId] = useState("");\n const [email, setEmail] = useState("");\n\n return (\n <form\n onSubmit={(e) => {\n e.preventDefault();\n if (!orderId.trim()) return;\n router.push(`/orders/${encodeURIComponent(orderId.trim())}`);\n }}\n className="space-y-4 rounded-2xl border border-border bg-card p-6"\n >\n <div>\n <label\n htmlFor="orderId"\n className="text-xs font-semibold uppercase tracking-wider text-muted-foreground block mb-1.5"\n >\n Order number\n </label>\n <input\n id="orderId"\n name="orderId"\n required\n value={orderId}\n onChange={(e) => setOrderId(e.target.value)}\n placeholder="ord_abc123\u2026"\n className="w-full px-4 py-3 rounded-md bg-background border border-border focus:border-primary focus:ring-2 focus:ring-primary/20 outline-none text-sm"\n />\n </div>\n <div>\n <label\n htmlFor="email"\n className="text-xs font-semibold uppercase tracking-wider text-muted-foreground block mb-1.5"\n >\n Order email\n </label>\n <input\n id="email"\n name="email"\n type="email"\n required\n value={email}\n onChange={(e) => setEmail(e.target.value)}\n placeholder="you@email.com"\n className="w-full px-4 py-3 rounded-md bg-background border border-border focus:border-primary focus:ring-2 focus:ring-primary/20 outline-none text-sm"\n />\n </div>\n <button\n type="submit"\n className="w-full inline-flex items-center justify-center px-5 py-3 rounded-md bg-primary text-primary-foreground font-semibold text-sm hover:bg-primary/90 transition-colors"\n >\n Track order\n </button>\n </form>\n );\n}\n' }, { "path": "app/track-order/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { TrackOrderForm } from "./track-order-form";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Track an order \u2014 ${brand.name}`,\n description: brand.trackOrder.body,\n};\n\nexport default function TrackOrderPage() {\n const t = brand.trackOrder;\n return (\n <article className="max-w-2xl 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 {t.eyebrow}\n </p>\n <h1 className="font-serif text-[clamp(2rem,5vw,3rem)] font-semibold mb-4 -tracking-[0.02em]">\n {t.title}\n </h1>\n <p className="text-muted-foreground leading-relaxed mb-8">{t.body}</p>\n <TrackOrderForm />\n </article>\n );\n}\n' }, { "path": "app/robots.ts", "kind": "text", "content": 'import type { MetadataRoute } from "next";\nimport { getSiteUrl } from "@/lib/site-url";\n\nexport default async function robots(): Promise<MetadataRoute.Robots> {\n const siteUrl = await getSiteUrl();\n return {\n rules: [\n {\n userAgent: "*",\n allow: "/",\n disallow: ["/cart", "/checkout", "/orders/", "/api/"],\n },\n ],\n sitemap: `${siteUrl}/sitemap.xml`,\n host: siteUrl,\n };\n}\n' }, { "path": ".gitignore", "kind": "text", "content": "node_modules\n.next\n.open-next\nout\n.env\n.env.local\n.env*.local\n.DS_Store\n*.tsbuildinfo\nnext-env.d.ts\n\n# Playwright runtime artifacts (commit the snapshots in e2e/__snapshots__/)\ntest-results/\nplaywright-report/\nblob-report/\n.playwright/\n" }, { "path": "__tests__/brand.test.ts", "kind": "text", "content": 'import { createBrandSuite } from "@cimplify/sdk/testing/suite";\nimport { brand } from "../lib/brand";\n\ncreateBrandSuite({ brand });\n' }, { "path": "__tests__/contract.test.ts", "kind": "text", "content": 'import { createContractSuite } from "@cimplify/sdk/testing/suite";\nimport { brand } from "../lib/brand";\n\ncreateContractSuite({ seed: brand.mock.seed });\n' }, { "path": "__tests__/cart-flow.test.ts", "kind": "text", "content": 'import { createCartFlowSuite } from "@cimplify/sdk/testing/suite";\nimport { expect } from "vitest";\nimport { brand } from "../lib/brand";\n\n/**\n * Cart flow suite \u2014 base + per-merchant assertions.\n *\n * The base suite (from `@cimplify/sdk/testing/suite`) exercises the\n * universal contract: empty cart, add, dedupe, remove, businessId\n * round-trip. The `extend` hook below adds Studio FRX-specific\n * invariants \u2014 copy this pattern when a merchant has unique business\n * rules that need their own coverage.\n *\n * \u2022 `getHandle()` returns the suite\'s current `TestClientHandle`\n * (per `it`) so the seed and session are already wired.\n * \u2022 `it` is the vitest API; cases land inside the suite\'s `describe`\n * so its `beforeEach`/`afterEach` hooks apply automatically.\n */\ncreateCartFlowSuite({\n seed: brand.mock.seed,\n businessId: brand.mock.businessId,\n extend: ({ getHandle, it }) => {\n it("Studio FRX prices everything in GHS", async () => {\n // Currency is brand-level (not per-product) \u2014 Product carries the price\n // string only. Catalog completeness here is just a sanity check.\n expect(brand.currency).toBe("GHS");\n const h = getHandle();\n const list = await h.client.catalogue.getProducts();\n if (!list.ok) throw list.error;\n const items = (list.value as unknown as { items?: unknown[] }).items ?? [];\n expect(items.length).toBeGreaterThan(0);\n });\n\n it("every apparel product covers the studio\'s core size run (S/M/L)", async () => {\n const h = getHandle();\n const list = await h.client.catalogue.getProducts();\n if (!list.ok) throw list.error;\n const items = (list.value as unknown as { items?: { id: string; name: string }[] }).items ?? [];\n\n // Apparel only \u2014 caps/socks/totes intentionally lack size variants.\n const apparel = items.filter((p) => /tee|hoodie|jacket|trouser|pant/i.test(p.name));\n expect(apparel.length).toBeGreaterThan(0);\n\n const required = new Set(["S", "M", "L"]);\n for (const product of apparel) {\n const variants = await h.client.catalogue.getVariants(product.id);\n if (!variants.ok) throw variants.error;\n const sizes = new Set(variants.value.map((v) => v.name).filter(Boolean));\n for (const size of required) {\n expect(sizes, `${product.name} missing size ${size}`).toContain(size);\n }\n }\n });\n },\n});\n' }, { "path": "playwright.config.ts", "kind": "text", "content": 'import { defineConfig, devices } from "@playwright/test";\n\n/**\n * Visual regression config \u2014 boots `bun dev` (mock + next), runs against\n * a stable Chromium, snapshots key pages.\n *\n * Run:\n * bun run check:visual # compare against baseline\n * bun run check:visual --update-snapshots # accept new baseline\n *\n * Baselines live in `e2e/__snapshots__/`. Commit them.\n */\nexport default defineConfig({\n testDir: "./e2e",\n fullyParallel: false,\n forbidOnly: Boolean(process.env.CI),\n retries: process.env.CI ? 1 : 0,\n workers: 1,\n reporter: process.env.CI ? "github" : "list",\n\n use: {\n baseURL: "http://localhost:3000",\n trace: "retain-on-failure",\n // Viewport snapshots are noisy across OS / browser builds. Lock here.\n viewport: { width: 1280, height: 800 },\n },\n\n projects: [\n {\n name: "chromium",\n use: { ...devices["Desktop Chrome"], viewport: { width: 1280, height: 800 } },\n },\n ],\n\n expect: {\n // Anti-flake: allow tiny rendering differences (font hinting across runners).\n toHaveScreenshot: { maxDiffPixelRatio: 0.01 },\n },\n\n webServer: {\n command: "bun dev",\n url: "http://localhost:3000",\n reuseExistingServer: !process.env.CI,\n timeout: 120_000,\n stdout: "ignore",\n stderr: "pipe",\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.84.0",\n "next": "^16.2.6",\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": "middleware.ts", "kind": "text", "content": `import { NextResponse, type NextRequest } from "next/server";
724
724
  import { handleTokenRefresh, type OidcConfig } from "@cimplify/sdk/server";
725
725
 
726
726
  function oidcConfig(): OidcConfig | null {
@@ -1490,7 +1490,7 @@ function Field({
1490
1490
  </div>
1491
1491
  );
1492
1492
  }
1493
- ` }, { "path": "app/track-order/track-order-form.tsx", "kind": "text", "content": '"use client";\n\nimport { useState } from "react";\nimport { useRouter } from "next/navigation";\n\n/**\n * Guest order tracker. Routes /orders/<id> with the entered id; the\n * post-checkout confirmation page already lives at /orders/[id] so this\n * just sends the visitor there. Replace the redirect with a real lookup\n * (e.g. Cimplify orders API + email-match guard) for production.\n */\nexport function TrackOrderForm() {\n const router = useRouter();\n const [orderId, setOrderId] = useState("");\n const [email, setEmail] = useState("");\n\n return (\n <form\n onSubmit={(e) => {\n e.preventDefault();\n if (!orderId.trim()) return;\n router.push(`/orders/${encodeURIComponent(orderId.trim())}`);\n }}\n className="space-y-4 rounded-2xl border border-border bg-card p-6"\n >\n <div>\n <label\n htmlFor="orderId"\n className="text-xs font-semibold uppercase tracking-wider text-muted-foreground block mb-1.5"\n >\n Order number\n </label>\n <input\n id="orderId"\n name="orderId"\n required\n value={orderId}\n onChange={(e) => setOrderId(e.target.value)}\n placeholder="ord_abc123\u2026"\n className="w-full px-4 py-3 rounded-md bg-background border border-border focus:border-primary focus:ring-2 focus:ring-primary/20 outline-none text-sm"\n />\n </div>\n <div>\n <label\n htmlFor="email"\n className="text-xs font-semibold uppercase tracking-wider text-muted-foreground block mb-1.5"\n >\n Order email\n </label>\n <input\n id="email"\n name="email"\n type="email"\n required\n value={email}\n onChange={(e) => setEmail(e.target.value)}\n placeholder="you@email.com"\n className="w-full px-4 py-3 rounded-md bg-background border border-border focus:border-primary focus:ring-2 focus:ring-primary/20 outline-none text-sm"\n />\n </div>\n <button\n type="submit"\n className="w-full inline-flex items-center justify-center px-5 py-3 rounded-md bg-primary text-primary-foreground font-semibold text-sm hover:bg-primary/90 transition-colors"\n >\n Track order\n </button>\n </form>\n );\n}\n' }, { "path": "app/track-order/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { TrackOrderForm } from "./track-order-form";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Track an order \u2014 ${brand.name}`,\n description: brand.trackOrder.body,\n};\n\nexport default function TrackOrderPage() {\n const t = brand.trackOrder;\n return (\n <article className="max-w-2xl 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 {t.eyebrow}\n </p>\n <h1 className="font-serif text-[clamp(2rem,5vw,3rem)] font-semibold mb-4 -tracking-[0.02em]">\n {t.title}\n </h1>\n <p className="text-muted-foreground leading-relaxed mb-8">{t.body}</p>\n <TrackOrderForm />\n </article>\n );\n}\n' }, { "path": "app/robots.ts", "kind": "text", "content": 'import type { MetadataRoute } from "next";\nimport { getSiteUrl } from "@/lib/site-url";\n\nexport default async function robots(): Promise<MetadataRoute.Robots> {\n const siteUrl = await getSiteUrl();\n return {\n rules: [\n {\n userAgent: "*",\n allow: "/",\n disallow: ["/cart", "/checkout", "/orders/", "/api/"],\n },\n ],\n sitemap: `${siteUrl}/sitemap.xml`,\n host: siteUrl,\n };\n}\n' }, { "path": ".gitignore", "kind": "text", "content": "node_modules\n.next\n.open-next\nout\n.env\n.env.local\n.env*.local\n.DS_Store\n*.tsbuildinfo\nnext-env.d.ts\n" }, { "path": "__tests__/brand.test.ts", "kind": "text", "content": 'import { createBrandSuite } from "@cimplify/sdk/testing/suite";\nimport { brand } from "../lib/brand";\n\ncreateBrandSuite({ brand });\n' }, { "path": "__tests__/contract.test.ts", "kind": "text", "content": 'import { createContractSuite } from "@cimplify/sdk/testing/suite";\nimport { brand } from "../lib/brand";\n\ncreateContractSuite({ seed: brand.mock.seed });\n' }, { "path": "__tests__/cart-flow.test.ts", "kind": "text", "content": 'import { createCartFlowSuite } from "@cimplify/sdk/testing/suite";\nimport { brand } from "../lib/brand";\n\ncreateCartFlowSuite({ seed: brand.mock.seed, businessId: brand.mock.businessId });\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.80.0",\n "next": "^16.2.6",\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": "middleware.ts", "kind": "text", "content": `import { NextResponse, type NextRequest } from "next/server";
1493
+ ` }, { "path": "app/track-order/track-order-form.tsx", "kind": "text", "content": '"use client";\n\nimport { useState } from "react";\nimport { useRouter } from "next/navigation";\n\n/**\n * Guest order tracker. Routes /orders/<id> with the entered id; the\n * post-checkout confirmation page already lives at /orders/[id] so this\n * just sends the visitor there. Replace the redirect with a real lookup\n * (e.g. Cimplify orders API + email-match guard) for production.\n */\nexport function TrackOrderForm() {\n const router = useRouter();\n const [orderId, setOrderId] = useState("");\n const [email, setEmail] = useState("");\n\n return (\n <form\n onSubmit={(e) => {\n e.preventDefault();\n if (!orderId.trim()) return;\n router.push(`/orders/${encodeURIComponent(orderId.trim())}`);\n }}\n className="space-y-4 rounded-2xl border border-border bg-card p-6"\n >\n <div>\n <label\n htmlFor="orderId"\n className="text-xs font-semibold uppercase tracking-wider text-muted-foreground block mb-1.5"\n >\n Order number\n </label>\n <input\n id="orderId"\n name="orderId"\n required\n value={orderId}\n onChange={(e) => setOrderId(e.target.value)}\n placeholder="ord_abc123\u2026"\n className="w-full px-4 py-3 rounded-md bg-background border border-border focus:border-primary focus:ring-2 focus:ring-primary/20 outline-none text-sm"\n />\n </div>\n <div>\n <label\n htmlFor="email"\n className="text-xs font-semibold uppercase tracking-wider text-muted-foreground block mb-1.5"\n >\n Order email\n </label>\n <input\n id="email"\n name="email"\n type="email"\n required\n value={email}\n onChange={(e) => setEmail(e.target.value)}\n placeholder="you@email.com"\n className="w-full px-4 py-3 rounded-md bg-background border border-border focus:border-primary focus:ring-2 focus:ring-primary/20 outline-none text-sm"\n />\n </div>\n <button\n type="submit"\n className="w-full inline-flex items-center justify-center px-5 py-3 rounded-md bg-primary text-primary-foreground font-semibold text-sm hover:bg-primary/90 transition-colors"\n >\n Track order\n </button>\n </form>\n );\n}\n' }, { "path": "app/track-order/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { TrackOrderForm } from "./track-order-form";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Track an order \u2014 ${brand.name}`,\n description: brand.trackOrder.body,\n};\n\nexport default function TrackOrderPage() {\n const t = brand.trackOrder;\n return (\n <article className="max-w-2xl 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 {t.eyebrow}\n </p>\n <h1 className="font-serif text-[clamp(2rem,5vw,3rem)] font-semibold mb-4 -tracking-[0.02em]">\n {t.title}\n </h1>\n <p className="text-muted-foreground leading-relaxed mb-8">{t.body}</p>\n <TrackOrderForm />\n </article>\n );\n}\n' }, { "path": "app/robots.ts", "kind": "text", "content": 'import type { MetadataRoute } from "next";\nimport { getSiteUrl } from "@/lib/site-url";\n\nexport default async function robots(): Promise<MetadataRoute.Robots> {\n const siteUrl = await getSiteUrl();\n return {\n rules: [\n {\n userAgent: "*",\n allow: "/",\n disallow: ["/cart", "/checkout", "/orders/", "/api/"],\n },\n ],\n sitemap: `${siteUrl}/sitemap.xml`,\n host: siteUrl,\n };\n}\n' }, { "path": ".gitignore", "kind": "text", "content": "node_modules\n.next\n.open-next\nout\n.env\n.env.local\n.env*.local\n.DS_Store\n*.tsbuildinfo\nnext-env.d.ts\n" }, { "path": "__tests__/brand.test.ts", "kind": "text", "content": 'import { createBrandSuite } from "@cimplify/sdk/testing/suite";\nimport { brand } from "../lib/brand";\n\ncreateBrandSuite({ brand });\n' }, { "path": "__tests__/contract.test.ts", "kind": "text", "content": 'import { createContractSuite } from "@cimplify/sdk/testing/suite";\nimport { brand } from "../lib/brand";\n\ncreateContractSuite({ seed: brand.mock.seed });\n' }, { "path": "__tests__/cart-flow.test.ts", "kind": "text", "content": 'import { createCartFlowSuite } from "@cimplify/sdk/testing/suite";\nimport { brand } from "../lib/brand";\n\ncreateCartFlowSuite({ seed: brand.mock.seed, businessId: brand.mock.businessId });\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.84.0",\n "next": "^16.2.6",\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": "middleware.ts", "kind": "text", "content": `import { NextResponse, type NextRequest } from "next/server";
1494
1494
  import { handleTokenRefresh, type OidcConfig } from "@cimplify/sdk/server";
1495
1495
 
1496
1496
  function oidcConfig(): OidcConfig | null {
@@ -2086,7 +2086,7 @@ function Field({
2086
2086
  </div>
2087
2087
  );
2088
2088
  }
2089
- ` }, { "path": "app/track-order/track-order-form.tsx", "kind": "text", "content": '"use client";\n\nimport { useState } from "react";\nimport { useRouter } from "next/navigation";\n\n/**\n * Guest order tracker. Routes /orders/<id> with the entered id; the\n * post-checkout confirmation page already lives at /orders/[id] so this\n * just sends the visitor there. Replace the redirect with a real lookup\n * (e.g. Cimplify orders API + email-match guard) for production.\n */\nexport function TrackOrderForm() {\n const router = useRouter();\n const [orderId, setOrderId] = useState("");\n const [email, setEmail] = useState("");\n\n return (\n <form\n onSubmit={(e) => {\n e.preventDefault();\n if (!orderId.trim()) return;\n router.push(`/orders/${encodeURIComponent(orderId.trim())}`);\n }}\n className="space-y-4 rounded-2xl border border-border bg-card p-6"\n >\n <div>\n <label\n htmlFor="orderId"\n className="text-xs font-semibold uppercase tracking-wider text-muted-foreground block mb-1.5"\n >\n Order number\n </label>\n <input\n id="orderId"\n name="orderId"\n required\n value={orderId}\n onChange={(e) => setOrderId(e.target.value)}\n placeholder="ord_abc123\u2026"\n className="w-full px-4 py-3 rounded-md bg-background border border-border focus:border-primary focus:ring-2 focus:ring-primary/20 outline-none text-sm"\n />\n </div>\n <div>\n <label\n htmlFor="email"\n className="text-xs font-semibold uppercase tracking-wider text-muted-foreground block mb-1.5"\n >\n Order email\n </label>\n <input\n id="email"\n name="email"\n type="email"\n required\n value={email}\n onChange={(e) => setEmail(e.target.value)}\n placeholder="you@email.com"\n className="w-full px-4 py-3 rounded-md bg-background border border-border focus:border-primary focus:ring-2 focus:ring-primary/20 outline-none text-sm"\n />\n </div>\n <button\n type="submit"\n className="w-full inline-flex items-center justify-center px-5 py-3 rounded-md bg-primary text-primary-foreground font-semibold text-sm hover:bg-primary/90 transition-colors"\n >\n Track order\n </button>\n </form>\n );\n}\n' }, { "path": "app/track-order/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { TrackOrderForm } from "./track-order-form";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Track an order \u2014 ${brand.name}`,\n description: brand.trackOrder.body,\n};\n\nexport default function TrackOrderPage() {\n const t = brand.trackOrder;\n return (\n <article className="max-w-2xl 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 {t.eyebrow}\n </p>\n <h1 className="font-serif text-[clamp(2rem,5vw,3rem)] font-semibold mb-4 -tracking-[0.02em]">\n {t.title}\n </h1>\n <p className="text-muted-foreground leading-relaxed mb-8">{t.body}</p>\n <TrackOrderForm />\n </article>\n );\n}\n' }, { "path": "app/robots.ts", "kind": "text", "content": 'import type { MetadataRoute } from "next";\nimport { getSiteUrl } from "@/lib/site-url";\n\nexport default async function robots(): Promise<MetadataRoute.Robots> {\n const siteUrl = await getSiteUrl();\n return {\n rules: [\n {\n userAgent: "*",\n allow: "/",\n disallow: ["/cart", "/checkout", "/orders/", "/api/"],\n },\n ],\n sitemap: `${siteUrl}/sitemap.xml`,\n host: siteUrl,\n };\n}\n' }, { "path": ".gitignore", "kind": "text", "content": "node_modules\n.next\n.open-next\nout\n.env\n.env.local\n.env*.local\n.DS_Store\n*.tsbuildinfo\nnext-env.d.ts\n" }, { "path": "__tests__/brand.test.ts", "kind": "text", "content": 'import { createBrandSuite } from "@cimplify/sdk/testing/suite";\nimport { brand } from "../lib/brand";\n\ncreateBrandSuite({ brand });\n' }, { "path": "__tests__/contract.test.ts", "kind": "text", "content": 'import { createContractSuite } from "@cimplify/sdk/testing/suite";\nimport { brand } from "../lib/brand";\n\ncreateContractSuite({ seed: brand.mock.seed });\n' }, { "path": "__tests__/cart-flow.test.ts", "kind": "text", "content": 'import { createCartFlowSuite } from "@cimplify/sdk/testing/suite";\nimport { brand } from "../lib/brand";\n\ncreateCartFlowSuite({ seed: brand.mock.seed, businessId: brand.mock.businessId });\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.80.0",\n "next": "^16.2.6",\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": "middleware.ts", "kind": "text", "content": `import { NextResponse, type NextRequest } from "next/server";
2089
+ ` }, { "path": "app/track-order/track-order-form.tsx", "kind": "text", "content": '"use client";\n\nimport { useState } from "react";\nimport { useRouter } from "next/navigation";\n\n/**\n * Guest order tracker. Routes /orders/<id> with the entered id; the\n * post-checkout confirmation page already lives at /orders/[id] so this\n * just sends the visitor there. Replace the redirect with a real lookup\n * (e.g. Cimplify orders API + email-match guard) for production.\n */\nexport function TrackOrderForm() {\n const router = useRouter();\n const [orderId, setOrderId] = useState("");\n const [email, setEmail] = useState("");\n\n return (\n <form\n onSubmit={(e) => {\n e.preventDefault();\n if (!orderId.trim()) return;\n router.push(`/orders/${encodeURIComponent(orderId.trim())}`);\n }}\n className="space-y-4 rounded-2xl border border-border bg-card p-6"\n >\n <div>\n <label\n htmlFor="orderId"\n className="text-xs font-semibold uppercase tracking-wider text-muted-foreground block mb-1.5"\n >\n Order number\n </label>\n <input\n id="orderId"\n name="orderId"\n required\n value={orderId}\n onChange={(e) => setOrderId(e.target.value)}\n placeholder="ord_abc123\u2026"\n className="w-full px-4 py-3 rounded-md bg-background border border-border focus:border-primary focus:ring-2 focus:ring-primary/20 outline-none text-sm"\n />\n </div>\n <div>\n <label\n htmlFor="email"\n className="text-xs font-semibold uppercase tracking-wider text-muted-foreground block mb-1.5"\n >\n Order email\n </label>\n <input\n id="email"\n name="email"\n type="email"\n required\n value={email}\n onChange={(e) => setEmail(e.target.value)}\n placeholder="you@email.com"\n className="w-full px-4 py-3 rounded-md bg-background border border-border focus:border-primary focus:ring-2 focus:ring-primary/20 outline-none text-sm"\n />\n </div>\n <button\n type="submit"\n className="w-full inline-flex items-center justify-center px-5 py-3 rounded-md bg-primary text-primary-foreground font-semibold text-sm hover:bg-primary/90 transition-colors"\n >\n Track order\n </button>\n </form>\n );\n}\n' }, { "path": "app/track-order/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { TrackOrderForm } from "./track-order-form";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Track an order \u2014 ${brand.name}`,\n description: brand.trackOrder.body,\n};\n\nexport default function TrackOrderPage() {\n const t = brand.trackOrder;\n return (\n <article className="max-w-2xl 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 {t.eyebrow}\n </p>\n <h1 className="font-serif text-[clamp(2rem,5vw,3rem)] font-semibold mb-4 -tracking-[0.02em]">\n {t.title}\n </h1>\n <p className="text-muted-foreground leading-relaxed mb-8">{t.body}</p>\n <TrackOrderForm />\n </article>\n );\n}\n' }, { "path": "app/robots.ts", "kind": "text", "content": 'import type { MetadataRoute } from "next";\nimport { getSiteUrl } from "@/lib/site-url";\n\nexport default async function robots(): Promise<MetadataRoute.Robots> {\n const siteUrl = await getSiteUrl();\n return {\n rules: [\n {\n userAgent: "*",\n allow: "/",\n disallow: ["/cart", "/checkout", "/orders/", "/api/"],\n },\n ],\n sitemap: `${siteUrl}/sitemap.xml`,\n host: siteUrl,\n };\n}\n' }, { "path": ".gitignore", "kind": "text", "content": "node_modules\n.next\n.open-next\nout\n.env\n.env.local\n.env*.local\n.DS_Store\n*.tsbuildinfo\nnext-env.d.ts\n" }, { "path": "__tests__/brand.test.ts", "kind": "text", "content": 'import { createBrandSuite } from "@cimplify/sdk/testing/suite";\nimport { brand } from "../lib/brand";\n\ncreateBrandSuite({ brand });\n' }, { "path": "__tests__/contract.test.ts", "kind": "text", "content": 'import { createContractSuite } from "@cimplify/sdk/testing/suite";\nimport { brand } from "../lib/brand";\n\ncreateContractSuite({ seed: brand.mock.seed });\n' }, { "path": "__tests__/cart-flow.test.ts", "kind": "text", "content": 'import { createCartFlowSuite } from "@cimplify/sdk/testing/suite";\nimport { brand } from "../lib/brand";\n\ncreateCartFlowSuite({ seed: brand.mock.seed, businessId: brand.mock.businessId });\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.84.0",\n "next": "^16.2.6",\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": "middleware.ts", "kind": "text", "content": `import { NextResponse, type NextRequest } from "next/server";
2090
2090
  import { handleTokenRefresh, type OidcConfig } from "@cimplify/sdk/server";
2091
2091
 
2092
2092
  function oidcConfig(): OidcConfig | null {
@@ -3164,7 +3164,7 @@ function Field({
3164
3164
  </div>
3165
3165
  );
3166
3166
  }
3167
- ` }, { "path": "app/track-order/track-order-form.tsx", "kind": "text", "content": '"use client";\n\nimport { useState } from "react";\nimport { useRouter } from "next/navigation";\n\n/**\n * Guest order tracker. Routes /orders/<id> with the entered id; the\n * post-checkout confirmation page already lives at /orders/[id] so this\n * just sends the visitor there. Replace the redirect with a real lookup\n * (e.g. Cimplify orders API + email-match guard) for production.\n */\nexport function TrackOrderForm() {\n const router = useRouter();\n const [orderId, setOrderId] = useState("");\n const [email, setEmail] = useState("");\n\n return (\n <form\n onSubmit={(e) => {\n e.preventDefault();\n if (!orderId.trim()) return;\n router.push(`/orders/${encodeURIComponent(orderId.trim())}`);\n }}\n className="space-y-4 rounded-2xl border border-border bg-card p-6"\n >\n <div>\n <label\n htmlFor="orderId"\n className="text-xs font-semibold uppercase tracking-wider text-muted-foreground block mb-1.5"\n >\n Order number\n </label>\n <input\n id="orderId"\n name="orderId"\n required\n value={orderId}\n onChange={(e) => setOrderId(e.target.value)}\n placeholder="ord_abc123\u2026"\n className="w-full px-4 py-3 rounded-md bg-background border border-border focus:border-primary focus:ring-2 focus:ring-primary/20 outline-none text-sm"\n />\n </div>\n <div>\n <label\n htmlFor="email"\n className="text-xs font-semibold uppercase tracking-wider text-muted-foreground block mb-1.5"\n >\n Order email\n </label>\n <input\n id="email"\n name="email"\n type="email"\n required\n value={email}\n onChange={(e) => setEmail(e.target.value)}\n placeholder="you@email.com"\n className="w-full px-4 py-3 rounded-md bg-background border border-border focus:border-primary focus:ring-2 focus:ring-primary/20 outline-none text-sm"\n />\n </div>\n <button\n type="submit"\n className="w-full inline-flex items-center justify-center px-5 py-3 rounded-md bg-primary text-primary-foreground font-semibold text-sm hover:bg-primary/90 transition-colors"\n >\n Track order\n </button>\n </form>\n );\n}\n' }, { "path": "app/track-order/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { TrackOrderForm } from "./track-order-form";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Track an order \u2014 ${brand.name}`,\n description: brand.trackOrder.body,\n};\n\nexport default function TrackOrderPage() {\n const t = brand.trackOrder;\n return (\n <article className="max-w-2xl 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 {t.eyebrow}\n </p>\n <h1 className="font-serif text-[clamp(2rem,5vw,3rem)] font-semibold mb-4 -tracking-[0.02em]">\n {t.title}\n </h1>\n <p className="text-muted-foreground leading-relaxed mb-8">{t.body}</p>\n <TrackOrderForm />\n </article>\n );\n}\n' }, { "path": "app/robots.ts", "kind": "text", "content": 'import type { MetadataRoute } from "next";\nimport { getSiteUrl } from "@/lib/site-url";\n\nexport default async function robots(): Promise<MetadataRoute.Robots> {\n const siteUrl = await getSiteUrl();\n return {\n rules: [\n {\n userAgent: "*",\n allow: "/",\n disallow: ["/cart", "/checkout", "/orders/", "/api/"],\n },\n ],\n sitemap: `${siteUrl}/sitemap.xml`,\n host: siteUrl,\n };\n}\n' }, { "path": ".gitignore", "kind": "text", "content": "node_modules\n.next\n.open-next\nout\n.env\n.env.local\n.env*.local\n.DS_Store\n*.tsbuildinfo\nnext-env.d.ts\n" }, { "path": "__tests__/brand.test.ts", "kind": "text", "content": 'import { createBrandSuite } from "@cimplify/sdk/testing/suite";\nimport { brand } from "../lib/brand";\n\ncreateBrandSuite({ brand });\n' }, { "path": "__tests__/contract.test.ts", "kind": "text", "content": 'import { createContractSuite } from "@cimplify/sdk/testing/suite";\nimport { brand } from "../lib/brand";\n\ncreateContractSuite({ seed: brand.mock.seed });\n' }, { "path": "__tests__/cart-flow.test.ts", "kind": "text", "content": 'import { createCartFlowSuite } from "@cimplify/sdk/testing/suite";\nimport { brand } from "../lib/brand";\n\ncreateCartFlowSuite({ seed: brand.mock.seed, businessId: brand.mock.businessId });\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.80.0",\n "next": "^16.2.6",\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": "middleware.ts", "kind": "text", "content": `import { NextResponse, type NextRequest } from "next/server";
3167
+ ` }, { "path": "app/track-order/track-order-form.tsx", "kind": "text", "content": '"use client";\n\nimport { useState } from "react";\nimport { useRouter } from "next/navigation";\n\n/**\n * Guest order tracker. Routes /orders/<id> with the entered id; the\n * post-checkout confirmation page already lives at /orders/[id] so this\n * just sends the visitor there. Replace the redirect with a real lookup\n * (e.g. Cimplify orders API + email-match guard) for production.\n */\nexport function TrackOrderForm() {\n const router = useRouter();\n const [orderId, setOrderId] = useState("");\n const [email, setEmail] = useState("");\n\n return (\n <form\n onSubmit={(e) => {\n e.preventDefault();\n if (!orderId.trim()) return;\n router.push(`/orders/${encodeURIComponent(orderId.trim())}`);\n }}\n className="space-y-4 rounded-2xl border border-border bg-card p-6"\n >\n <div>\n <label\n htmlFor="orderId"\n className="text-xs font-semibold uppercase tracking-wider text-muted-foreground block mb-1.5"\n >\n Order number\n </label>\n <input\n id="orderId"\n name="orderId"\n required\n value={orderId}\n onChange={(e) => setOrderId(e.target.value)}\n placeholder="ord_abc123\u2026"\n className="w-full px-4 py-3 rounded-md bg-background border border-border focus:border-primary focus:ring-2 focus:ring-primary/20 outline-none text-sm"\n />\n </div>\n <div>\n <label\n htmlFor="email"\n className="text-xs font-semibold uppercase tracking-wider text-muted-foreground block mb-1.5"\n >\n Order email\n </label>\n <input\n id="email"\n name="email"\n type="email"\n required\n value={email}\n onChange={(e) => setEmail(e.target.value)}\n placeholder="you@email.com"\n className="w-full px-4 py-3 rounded-md bg-background border border-border focus:border-primary focus:ring-2 focus:ring-primary/20 outline-none text-sm"\n />\n </div>\n <button\n type="submit"\n className="w-full inline-flex items-center justify-center px-5 py-3 rounded-md bg-primary text-primary-foreground font-semibold text-sm hover:bg-primary/90 transition-colors"\n >\n Track order\n </button>\n </form>\n );\n}\n' }, { "path": "app/track-order/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { TrackOrderForm } from "./track-order-form";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Track an order \u2014 ${brand.name}`,\n description: brand.trackOrder.body,\n};\n\nexport default function TrackOrderPage() {\n const t = brand.trackOrder;\n return (\n <article className="max-w-2xl 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 {t.eyebrow}\n </p>\n <h1 className="font-serif text-[clamp(2rem,5vw,3rem)] font-semibold mb-4 -tracking-[0.02em]">\n {t.title}\n </h1>\n <p className="text-muted-foreground leading-relaxed mb-8">{t.body}</p>\n <TrackOrderForm />\n </article>\n );\n}\n' }, { "path": "app/robots.ts", "kind": "text", "content": 'import type { MetadataRoute } from "next";\nimport { getSiteUrl } from "@/lib/site-url";\n\nexport default async function robots(): Promise<MetadataRoute.Robots> {\n const siteUrl = await getSiteUrl();\n return {\n rules: [\n {\n userAgent: "*",\n allow: "/",\n disallow: ["/cart", "/checkout", "/orders/", "/api/"],\n },\n ],\n sitemap: `${siteUrl}/sitemap.xml`,\n host: siteUrl,\n };\n}\n' }, { "path": ".gitignore", "kind": "text", "content": "node_modules\n.next\n.open-next\nout\n.env\n.env.local\n.env*.local\n.DS_Store\n*.tsbuildinfo\nnext-env.d.ts\n" }, { "path": "__tests__/brand.test.ts", "kind": "text", "content": 'import { createBrandSuite } from "@cimplify/sdk/testing/suite";\nimport { brand } from "../lib/brand";\n\ncreateBrandSuite({ brand });\n' }, { "path": "__tests__/contract.test.ts", "kind": "text", "content": 'import { createContractSuite } from "@cimplify/sdk/testing/suite";\nimport { brand } from "../lib/brand";\n\ncreateContractSuite({ seed: brand.mock.seed });\n' }, { "path": "__tests__/cart-flow.test.ts", "kind": "text", "content": 'import { createCartFlowSuite } from "@cimplify/sdk/testing/suite";\nimport { brand } from "../lib/brand";\n\ncreateCartFlowSuite({ seed: brand.mock.seed, businessId: brand.mock.businessId });\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.84.0",\n "next": "^16.2.6",\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": "middleware.ts", "kind": "text", "content": `import { NextResponse, type NextRequest } from "next/server";
3168
3168
  import { handleTokenRefresh, type OidcConfig } from "@cimplify/sdk/server";
3169
3169
 
3170
3170
  function oidcConfig(): OidcConfig | null {
@@ -3784,7 +3784,7 @@ function Field({
3784
3784
  </div>
3785
3785
  );
3786
3786
  }
3787
- ` }, { "path": "app/track-order/track-order-form.tsx", "kind": "text", "content": '"use client";\n\nimport { useState } from "react";\nimport { useRouter } from "next/navigation";\n\n/**\n * Guest order tracker. Routes /orders/<id> with the entered id; the\n * post-checkout confirmation page already lives at /orders/[id] so this\n * just sends the visitor there. Replace the redirect with a real lookup\n * (e.g. Cimplify orders API + email-match guard) for production.\n */\nexport function TrackOrderForm() {\n const router = useRouter();\n const [orderId, setOrderId] = useState("");\n const [email, setEmail] = useState("");\n\n return (\n <form\n onSubmit={(e) => {\n e.preventDefault();\n if (!orderId.trim()) return;\n router.push(`/orders/${encodeURIComponent(orderId.trim())}`);\n }}\n className="space-y-4 rounded-2xl border border-border bg-card p-6"\n >\n <div>\n <label\n htmlFor="orderId"\n className="text-xs font-semibold uppercase tracking-wider text-muted-foreground block mb-1.5"\n >\n Order number\n </label>\n <input\n id="orderId"\n name="orderId"\n required\n value={orderId}\n onChange={(e) => setOrderId(e.target.value)}\n placeholder="ord_abc123\u2026"\n className="w-full px-4 py-3 rounded-md bg-background border border-border focus:border-primary focus:ring-2 focus:ring-primary/20 outline-none text-sm"\n />\n </div>\n <div>\n <label\n htmlFor="email"\n className="text-xs font-semibold uppercase tracking-wider text-muted-foreground block mb-1.5"\n >\n Order email\n </label>\n <input\n id="email"\n name="email"\n type="email"\n required\n value={email}\n onChange={(e) => setEmail(e.target.value)}\n placeholder="you@email.com"\n className="w-full px-4 py-3 rounded-md bg-background border border-border focus:border-primary focus:ring-2 focus:ring-primary/20 outline-none text-sm"\n />\n </div>\n <button\n type="submit"\n className="w-full inline-flex items-center justify-center px-5 py-3 rounded-md bg-primary text-primary-foreground font-semibold text-sm hover:bg-primary/90 transition-colors"\n >\n Track order\n </button>\n </form>\n );\n}\n' }, { "path": "app/track-order/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { TrackOrderForm } from "./track-order-form";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Track an order \u2014 ${brand.name}`,\n description: brand.trackOrder.body,\n};\n\nexport default function TrackOrderPage() {\n const t = brand.trackOrder;\n return (\n <article className="max-w-2xl 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 {t.eyebrow}\n </p>\n <h1 className="font-serif text-[clamp(2rem,5vw,3rem)] font-semibold mb-4 -tracking-[0.02em]">\n {t.title}\n </h1>\n <p className="text-muted-foreground leading-relaxed mb-8">{t.body}</p>\n <TrackOrderForm />\n </article>\n );\n}\n' }, { "path": "app/robots.ts", "kind": "text", "content": 'import type { MetadataRoute } from "next";\nimport { getSiteUrl } from "@/lib/site-url";\n\nexport default async function robots(): Promise<MetadataRoute.Robots> {\n const siteUrl = await getSiteUrl();\n return {\n rules: [\n {\n userAgent: "*",\n allow: "/",\n disallow: ["/cart", "/checkout", "/orders/", "/api/"],\n },\n ],\n sitemap: `${siteUrl}/sitemap.xml`,\n host: siteUrl,\n };\n}\n' }, { "path": ".gitignore", "kind": "text", "content": "node_modules\n.next\n.open-next\nout\n.env\n.env.local\n.env*.local\n.DS_Store\n*.tsbuildinfo\nnext-env.d.ts\n" }, { "path": "__tests__/brand.test.ts", "kind": "text", "content": 'import { createBrandSuite } from "@cimplify/sdk/testing/suite";\nimport { brand } from "../lib/brand";\n\ncreateBrandSuite({ brand });\n' }, { "path": "__tests__/contract.test.ts", "kind": "text", "content": 'import { createContractSuite } from "@cimplify/sdk/testing/suite";\nimport { brand } from "../lib/brand";\n\ncreateContractSuite({ seed: brand.mock.seed });\n' }, { "path": "__tests__/cart-flow.test.ts", "kind": "text", "content": 'import { createCartFlowSuite } from "@cimplify/sdk/testing/suite";\nimport { brand } from "../lib/brand";\n\ncreateCartFlowSuite({ seed: brand.mock.seed, businessId: brand.mock.businessId });\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.80.0",\n "next": "^16.2.6",\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": "middleware.ts", "kind": "text", "content": `import { NextResponse, type NextRequest } from "next/server";
3787
+ ` }, { "path": "app/track-order/track-order-form.tsx", "kind": "text", "content": '"use client";\n\nimport { useState } from "react";\nimport { useRouter } from "next/navigation";\n\n/**\n * Guest order tracker. Routes /orders/<id> with the entered id; the\n * post-checkout confirmation page already lives at /orders/[id] so this\n * just sends the visitor there. Replace the redirect with a real lookup\n * (e.g. Cimplify orders API + email-match guard) for production.\n */\nexport function TrackOrderForm() {\n const router = useRouter();\n const [orderId, setOrderId] = useState("");\n const [email, setEmail] = useState("");\n\n return (\n <form\n onSubmit={(e) => {\n e.preventDefault();\n if (!orderId.trim()) return;\n router.push(`/orders/${encodeURIComponent(orderId.trim())}`);\n }}\n className="space-y-4 rounded-2xl border border-border bg-card p-6"\n >\n <div>\n <label\n htmlFor="orderId"\n className="text-xs font-semibold uppercase tracking-wider text-muted-foreground block mb-1.5"\n >\n Order number\n </label>\n <input\n id="orderId"\n name="orderId"\n required\n value={orderId}\n onChange={(e) => setOrderId(e.target.value)}\n placeholder="ord_abc123\u2026"\n className="w-full px-4 py-3 rounded-md bg-background border border-border focus:border-primary focus:ring-2 focus:ring-primary/20 outline-none text-sm"\n />\n </div>\n <div>\n <label\n htmlFor="email"\n className="text-xs font-semibold uppercase tracking-wider text-muted-foreground block mb-1.5"\n >\n Order email\n </label>\n <input\n id="email"\n name="email"\n type="email"\n required\n value={email}\n onChange={(e) => setEmail(e.target.value)}\n placeholder="you@email.com"\n className="w-full px-4 py-3 rounded-md bg-background border border-border focus:border-primary focus:ring-2 focus:ring-primary/20 outline-none text-sm"\n />\n </div>\n <button\n type="submit"\n className="w-full inline-flex items-center justify-center px-5 py-3 rounded-md bg-primary text-primary-foreground font-semibold text-sm hover:bg-primary/90 transition-colors"\n >\n Track order\n </button>\n </form>\n );\n}\n' }, { "path": "app/track-order/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { TrackOrderForm } from "./track-order-form";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Track an order \u2014 ${brand.name}`,\n description: brand.trackOrder.body,\n};\n\nexport default function TrackOrderPage() {\n const t = brand.trackOrder;\n return (\n <article className="max-w-2xl 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 {t.eyebrow}\n </p>\n <h1 className="font-serif text-[clamp(2rem,5vw,3rem)] font-semibold mb-4 -tracking-[0.02em]">\n {t.title}\n </h1>\n <p className="text-muted-foreground leading-relaxed mb-8">{t.body}</p>\n <TrackOrderForm />\n </article>\n );\n}\n' }, { "path": "app/robots.ts", "kind": "text", "content": 'import type { MetadataRoute } from "next";\nimport { getSiteUrl } from "@/lib/site-url";\n\nexport default async function robots(): Promise<MetadataRoute.Robots> {\n const siteUrl = await getSiteUrl();\n return {\n rules: [\n {\n userAgent: "*",\n allow: "/",\n disallow: ["/cart", "/checkout", "/orders/", "/api/"],\n },\n ],\n sitemap: `${siteUrl}/sitemap.xml`,\n host: siteUrl,\n };\n}\n' }, { "path": ".gitignore", "kind": "text", "content": "node_modules\n.next\n.open-next\nout\n.env\n.env.local\n.env*.local\n.DS_Store\n*.tsbuildinfo\nnext-env.d.ts\n" }, { "path": "__tests__/brand.test.ts", "kind": "text", "content": 'import { createBrandSuite } from "@cimplify/sdk/testing/suite";\nimport { brand } from "../lib/brand";\n\ncreateBrandSuite({ brand });\n' }, { "path": "__tests__/contract.test.ts", "kind": "text", "content": 'import { createContractSuite } from "@cimplify/sdk/testing/suite";\nimport { brand } from "../lib/brand";\n\ncreateContractSuite({ seed: brand.mock.seed });\n' }, { "path": "__tests__/cart-flow.test.ts", "kind": "text", "content": 'import { createCartFlowSuite } from "@cimplify/sdk/testing/suite";\nimport { brand } from "../lib/brand";\n\ncreateCartFlowSuite({ seed: brand.mock.seed, businessId: brand.mock.businessId });\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.84.0",\n "next": "^16.2.6",\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": "middleware.ts", "kind": "text", "content": `import { NextResponse, type NextRequest } from "next/server";
3788
3788
  import { handleTokenRefresh, type OidcConfig } from "@cimplify/sdk/server";
3789
3789
 
3790
3790
  function oidcConfig(): OidcConfig | null {
@@ -4736,7 +4736,7 @@ function Field({
4736
4736
  </div>
4737
4737
  );
4738
4738
  }
4739
- ` }, { "path": "app/track-order/track-order-form.tsx", "kind": "text", "content": '"use client";\n\nimport { useState } from "react";\nimport { useRouter } from "next/navigation";\n\n/**\n * Guest order tracker. Routes /orders/<id> with the entered id; the\n * post-checkout confirmation page already lives at /orders/[id] so this\n * just sends the visitor there. Replace the redirect with a real lookup\n * (e.g. Cimplify orders API + email-match guard) for production.\n */\nexport function TrackOrderForm() {\n const router = useRouter();\n const [orderId, setOrderId] = useState("");\n const [email, setEmail] = useState("");\n\n return (\n <form\n onSubmit={(e) => {\n e.preventDefault();\n if (!orderId.trim()) return;\n router.push(`/orders/${encodeURIComponent(orderId.trim())}`);\n }}\n className="space-y-4 rounded-2xl border border-border bg-card p-6"\n >\n <div>\n <label\n htmlFor="orderId"\n className="text-xs font-semibold uppercase tracking-wider text-muted-foreground block mb-1.5"\n >\n Order number\n </label>\n <input\n id="orderId"\n name="orderId"\n required\n value={orderId}\n onChange={(e) => setOrderId(e.target.value)}\n placeholder="ord_abc123\u2026"\n className="w-full px-4 py-3 rounded-md bg-background border border-border focus:border-primary focus:ring-2 focus:ring-primary/20 outline-none text-sm"\n />\n </div>\n <div>\n <label\n htmlFor="email"\n className="text-xs font-semibold uppercase tracking-wider text-muted-foreground block mb-1.5"\n >\n Order email\n </label>\n <input\n id="email"\n name="email"\n type="email"\n required\n value={email}\n onChange={(e) => setEmail(e.target.value)}\n placeholder="you@email.com"\n className="w-full px-4 py-3 rounded-md bg-background border border-border focus:border-primary focus:ring-2 focus:ring-primary/20 outline-none text-sm"\n />\n </div>\n <button\n type="submit"\n className="w-full inline-flex items-center justify-center px-5 py-3 rounded-md bg-primary text-primary-foreground font-semibold text-sm hover:bg-primary/90 transition-colors"\n >\n Track order\n </button>\n </form>\n );\n}\n' }, { "path": "app/track-order/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { TrackOrderForm } from "./track-order-form";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Track an order \u2014 ${brand.name}`,\n description: brand.trackOrder.body,\n};\n\nexport default function TrackOrderPage() {\n const t = brand.trackOrder;\n return (\n <article className="max-w-2xl 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 {t.eyebrow}\n </p>\n <h1 className="font-serif text-[clamp(2rem,5vw,3rem)] font-semibold mb-4 -tracking-[0.02em]">\n {t.title}\n </h1>\n <p className="text-muted-foreground leading-relaxed mb-8">{t.body}</p>\n <TrackOrderForm />\n </article>\n );\n}\n' }, { "path": "app/robots.ts", "kind": "text", "content": 'import type { MetadataRoute } from "next";\nimport { getSiteUrl } from "@/lib/site-url";\n\nexport default async function robots(): Promise<MetadataRoute.Robots> {\n const siteUrl = await getSiteUrl();\n return {\n rules: [\n {\n userAgent: "*",\n allow: "/",\n disallow: ["/cart", "/checkout", "/orders/", "/api/"],\n },\n ],\n sitemap: `${siteUrl}/sitemap.xml`,\n host: siteUrl,\n };\n}\n' }, { "path": ".gitignore", "kind": "text", "content": "node_modules\n.next\n.open-next\nout\n.env\n.env.local\n.env*.local\n.DS_Store\n*.tsbuildinfo\nnext-env.d.ts\n" }, { "path": "__tests__/brand.test.ts", "kind": "text", "content": 'import { createBrandSuite } from "@cimplify/sdk/testing/suite";\nimport { brand } from "../lib/brand";\n\ncreateBrandSuite({ brand });\n' }, { "path": "__tests__/contract.test.ts", "kind": "text", "content": 'import { createContractSuite } from "@cimplify/sdk/testing/suite";\nimport { brand } from "../lib/brand";\n\ncreateContractSuite({ seed: brand.mock.seed });\n' }, { "path": "__tests__/cart-flow.test.ts", "kind": "text", "content": 'import { createCartFlowSuite } from "@cimplify/sdk/testing/suite";\nimport { brand } from "../lib/brand";\n\ncreateCartFlowSuite({ seed: brand.mock.seed, businessId: brand.mock.businessId });\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.80.0",\n "next": "^16.2.6",\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": "middleware.ts", "kind": "text", "content": `import { NextResponse, type NextRequest } from "next/server";
4739
+ ` }, { "path": "app/track-order/track-order-form.tsx", "kind": "text", "content": '"use client";\n\nimport { useState } from "react";\nimport { useRouter } from "next/navigation";\n\n/**\n * Guest order tracker. Routes /orders/<id> with the entered id; the\n * post-checkout confirmation page already lives at /orders/[id] so this\n * just sends the visitor there. Replace the redirect with a real lookup\n * (e.g. Cimplify orders API + email-match guard) for production.\n */\nexport function TrackOrderForm() {\n const router = useRouter();\n const [orderId, setOrderId] = useState("");\n const [email, setEmail] = useState("");\n\n return (\n <form\n onSubmit={(e) => {\n e.preventDefault();\n if (!orderId.trim()) return;\n router.push(`/orders/${encodeURIComponent(orderId.trim())}`);\n }}\n className="space-y-4 rounded-2xl border border-border bg-card p-6"\n >\n <div>\n <label\n htmlFor="orderId"\n className="text-xs font-semibold uppercase tracking-wider text-muted-foreground block mb-1.5"\n >\n Order number\n </label>\n <input\n id="orderId"\n name="orderId"\n required\n value={orderId}\n onChange={(e) => setOrderId(e.target.value)}\n placeholder="ord_abc123\u2026"\n className="w-full px-4 py-3 rounded-md bg-background border border-border focus:border-primary focus:ring-2 focus:ring-primary/20 outline-none text-sm"\n />\n </div>\n <div>\n <label\n htmlFor="email"\n className="text-xs font-semibold uppercase tracking-wider text-muted-foreground block mb-1.5"\n >\n Order email\n </label>\n <input\n id="email"\n name="email"\n type="email"\n required\n value={email}\n onChange={(e) => setEmail(e.target.value)}\n placeholder="you@email.com"\n className="w-full px-4 py-3 rounded-md bg-background border border-border focus:border-primary focus:ring-2 focus:ring-primary/20 outline-none text-sm"\n />\n </div>\n <button\n type="submit"\n className="w-full inline-flex items-center justify-center px-5 py-3 rounded-md bg-primary text-primary-foreground font-semibold text-sm hover:bg-primary/90 transition-colors"\n >\n Track order\n </button>\n </form>\n );\n}\n' }, { "path": "app/track-order/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { TrackOrderForm } from "./track-order-form";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Track an order \u2014 ${brand.name}`,\n description: brand.trackOrder.body,\n};\n\nexport default function TrackOrderPage() {\n const t = brand.trackOrder;\n return (\n <article className="max-w-2xl 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 {t.eyebrow}\n </p>\n <h1 className="font-serif text-[clamp(2rem,5vw,3rem)] font-semibold mb-4 -tracking-[0.02em]">\n {t.title}\n </h1>\n <p className="text-muted-foreground leading-relaxed mb-8">{t.body}</p>\n <TrackOrderForm />\n </article>\n );\n}\n' }, { "path": "app/robots.ts", "kind": "text", "content": 'import type { MetadataRoute } from "next";\nimport { getSiteUrl } from "@/lib/site-url";\n\nexport default async function robots(): Promise<MetadataRoute.Robots> {\n const siteUrl = await getSiteUrl();\n return {\n rules: [\n {\n userAgent: "*",\n allow: "/",\n disallow: ["/cart", "/checkout", "/orders/", "/api/"],\n },\n ],\n sitemap: `${siteUrl}/sitemap.xml`,\n host: siteUrl,\n };\n}\n' }, { "path": ".gitignore", "kind": "text", "content": "node_modules\n.next\n.open-next\nout\n.env\n.env.local\n.env*.local\n.DS_Store\n*.tsbuildinfo\nnext-env.d.ts\n" }, { "path": "__tests__/brand.test.ts", "kind": "text", "content": 'import { createBrandSuite } from "@cimplify/sdk/testing/suite";\nimport { brand } from "../lib/brand";\n\ncreateBrandSuite({ brand });\n' }, { "path": "__tests__/contract.test.ts", "kind": "text", "content": 'import { createContractSuite } from "@cimplify/sdk/testing/suite";\nimport { brand } from "../lib/brand";\n\ncreateContractSuite({ seed: brand.mock.seed });\n' }, { "path": "__tests__/cart-flow.test.ts", "kind": "text", "content": 'import { createCartFlowSuite } from "@cimplify/sdk/testing/suite";\nimport { brand } from "../lib/brand";\n\ncreateCartFlowSuite({ seed: brand.mock.seed, businessId: brand.mock.businessId });\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.84.0",\n "next": "^16.2.6",\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": "middleware.ts", "kind": "text", "content": `import { NextResponse, type NextRequest } from "next/server";
4740
4740
  import { handleTokenRefresh, type OidcConfig } from "@cimplify/sdk/server";
4741
4741
 
4742
4742
  function oidcConfig(): OidcConfig | null {
@@ -5357,7 +5357,7 @@ function Field({
5357
5357
  </div>
5358
5358
  );
5359
5359
  }
5360
- ` }, { "path": "app/track-order/track-order-form.tsx", "kind": "text", "content": '"use client";\n\nimport { useState } from "react";\nimport { useRouter } from "next/navigation";\n\n/**\n * Guest order tracker. Routes /orders/<id> with the entered id; the\n * post-checkout confirmation page already lives at /orders/[id] so this\n * just sends the visitor there. Replace the redirect with a real lookup\n * (e.g. Cimplify orders API + email-match guard) for production.\n */\nexport function TrackOrderForm() {\n const router = useRouter();\n const [orderId, setOrderId] = useState("");\n const [email, setEmail] = useState("");\n\n return (\n <form\n onSubmit={(e) => {\n e.preventDefault();\n if (!orderId.trim()) return;\n router.push(`/orders/${encodeURIComponent(orderId.trim())}`);\n }}\n className="space-y-4 rounded-2xl border border-border bg-card p-6"\n >\n <div>\n <label\n htmlFor="orderId"\n className="text-xs font-semibold uppercase tracking-wider text-muted-foreground block mb-1.5"\n >\n Order number\n </label>\n <input\n id="orderId"\n name="orderId"\n required\n value={orderId}\n onChange={(e) => setOrderId(e.target.value)}\n placeholder="ord_abc123\u2026"\n className="w-full px-4 py-3 rounded-md bg-background border border-border focus:border-primary focus:ring-2 focus:ring-primary/20 outline-none text-sm"\n />\n </div>\n <div>\n <label\n htmlFor="email"\n className="text-xs font-semibold uppercase tracking-wider text-muted-foreground block mb-1.5"\n >\n Order email\n </label>\n <input\n id="email"\n name="email"\n type="email"\n required\n value={email}\n onChange={(e) => setEmail(e.target.value)}\n placeholder="you@email.com"\n className="w-full px-4 py-3 rounded-md bg-background border border-border focus:border-primary focus:ring-2 focus:ring-primary/20 outline-none text-sm"\n />\n </div>\n <button\n type="submit"\n className="w-full inline-flex items-center justify-center px-5 py-3 rounded-md bg-primary text-primary-foreground font-semibold text-sm hover:bg-primary/90 transition-colors"\n >\n Track order\n </button>\n </form>\n );\n}\n' }, { "path": "app/track-order/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { TrackOrderForm } from "./track-order-form";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Track an order \u2014 ${brand.name}`,\n description: brand.trackOrder.body,\n};\n\nexport default function TrackOrderPage() {\n const t = brand.trackOrder;\n return (\n <article className="max-w-2xl 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 {t.eyebrow}\n </p>\n <h1 className="font-serif text-[clamp(2rem,5vw,3rem)] font-semibold mb-4 -tracking-[0.02em]">\n {t.title}\n </h1>\n <p className="text-muted-foreground leading-relaxed mb-8">{t.body}</p>\n <TrackOrderForm />\n </article>\n );\n}\n' }, { "path": "app/robots.ts", "kind": "text", "content": 'import type { MetadataRoute } from "next";\nimport { getSiteUrl } from "@/lib/site-url";\n\nexport default async function robots(): Promise<MetadataRoute.Robots> {\n const siteUrl = await getSiteUrl();\n return {\n rules: [\n {\n userAgent: "*",\n allow: "/",\n disallow: ["/cart", "/checkout", "/orders/", "/api/"],\n },\n ],\n sitemap: `${siteUrl}/sitemap.xml`,\n host: siteUrl,\n };\n}\n' }, { "path": ".gitignore", "kind": "text", "content": "node_modules\n.next\n.open-next\nout\n.env\n.env.local\n.env*.local\n.DS_Store\n*.tsbuildinfo\nnext-env.d.ts\n" }, { "path": "__tests__/brand.test.ts", "kind": "text", "content": 'import { createBrandSuite } from "@cimplify/sdk/testing/suite";\nimport { brand } from "../lib/brand";\n\ncreateBrandSuite({ brand });\n' }, { "path": "__tests__/contract.test.ts", "kind": "text", "content": 'import { createContractSuite } from "@cimplify/sdk/testing/suite";\nimport { brand } from "../lib/brand";\n\ncreateContractSuite({ seed: brand.mock.seed });\n' }, { "path": "__tests__/cart-flow.test.ts", "kind": "text", "content": 'import { createCartFlowSuite } from "@cimplify/sdk/testing/suite";\nimport { brand } from "../lib/brand";\n\ncreateCartFlowSuite({ seed: brand.mock.seed, businessId: brand.mock.businessId });\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.80.0",\n "next": "^16.2.6",\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": "middleware.ts", "kind": "text", "content": `import { NextResponse, type NextRequest } from "next/server";
5360
+ ` }, { "path": "app/track-order/track-order-form.tsx", "kind": "text", "content": '"use client";\n\nimport { useState } from "react";\nimport { useRouter } from "next/navigation";\n\n/**\n * Guest order tracker. Routes /orders/<id> with the entered id; the\n * post-checkout confirmation page already lives at /orders/[id] so this\n * just sends the visitor there. Replace the redirect with a real lookup\n * (e.g. Cimplify orders API + email-match guard) for production.\n */\nexport function TrackOrderForm() {\n const router = useRouter();\n const [orderId, setOrderId] = useState("");\n const [email, setEmail] = useState("");\n\n return (\n <form\n onSubmit={(e) => {\n e.preventDefault();\n if (!orderId.trim()) return;\n router.push(`/orders/${encodeURIComponent(orderId.trim())}`);\n }}\n className="space-y-4 rounded-2xl border border-border bg-card p-6"\n >\n <div>\n <label\n htmlFor="orderId"\n className="text-xs font-semibold uppercase tracking-wider text-muted-foreground block mb-1.5"\n >\n Order number\n </label>\n <input\n id="orderId"\n name="orderId"\n required\n value={orderId}\n onChange={(e) => setOrderId(e.target.value)}\n placeholder="ord_abc123\u2026"\n className="w-full px-4 py-3 rounded-md bg-background border border-border focus:border-primary focus:ring-2 focus:ring-primary/20 outline-none text-sm"\n />\n </div>\n <div>\n <label\n htmlFor="email"\n className="text-xs font-semibold uppercase tracking-wider text-muted-foreground block mb-1.5"\n >\n Order email\n </label>\n <input\n id="email"\n name="email"\n type="email"\n required\n value={email}\n onChange={(e) => setEmail(e.target.value)}\n placeholder="you@email.com"\n className="w-full px-4 py-3 rounded-md bg-background border border-border focus:border-primary focus:ring-2 focus:ring-primary/20 outline-none text-sm"\n />\n </div>\n <button\n type="submit"\n className="w-full inline-flex items-center justify-center px-5 py-3 rounded-md bg-primary text-primary-foreground font-semibold text-sm hover:bg-primary/90 transition-colors"\n >\n Track order\n </button>\n </form>\n );\n}\n' }, { "path": "app/track-order/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { TrackOrderForm } from "./track-order-form";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Track an order \u2014 ${brand.name}`,\n description: brand.trackOrder.body,\n};\n\nexport default function TrackOrderPage() {\n const t = brand.trackOrder;\n return (\n <article className="max-w-2xl 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 {t.eyebrow}\n </p>\n <h1 className="font-serif text-[clamp(2rem,5vw,3rem)] font-semibold mb-4 -tracking-[0.02em]">\n {t.title}\n </h1>\n <p className="text-muted-foreground leading-relaxed mb-8">{t.body}</p>\n <TrackOrderForm />\n </article>\n );\n}\n' }, { "path": "app/robots.ts", "kind": "text", "content": 'import type { MetadataRoute } from "next";\nimport { getSiteUrl } from "@/lib/site-url";\n\nexport default async function robots(): Promise<MetadataRoute.Robots> {\n const siteUrl = await getSiteUrl();\n return {\n rules: [\n {\n userAgent: "*",\n allow: "/",\n disallow: ["/cart", "/checkout", "/orders/", "/api/"],\n },\n ],\n sitemap: `${siteUrl}/sitemap.xml`,\n host: siteUrl,\n };\n}\n' }, { "path": ".gitignore", "kind": "text", "content": "node_modules\n.next\n.open-next\nout\n.env\n.env.local\n.env*.local\n.DS_Store\n*.tsbuildinfo\nnext-env.d.ts\n" }, { "path": "__tests__/brand.test.ts", "kind": "text", "content": 'import { createBrandSuite } from "@cimplify/sdk/testing/suite";\nimport { brand } from "../lib/brand";\n\ncreateBrandSuite({ brand });\n' }, { "path": "__tests__/contract.test.ts", "kind": "text", "content": 'import { createContractSuite } from "@cimplify/sdk/testing/suite";\nimport { brand } from "../lib/brand";\n\ncreateContractSuite({ seed: brand.mock.seed });\n' }, { "path": "__tests__/cart-flow.test.ts", "kind": "text", "content": 'import { createCartFlowSuite } from "@cimplify/sdk/testing/suite";\nimport { brand } from "../lib/brand";\n\ncreateCartFlowSuite({ seed: brand.mock.seed, businessId: brand.mock.businessId });\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.84.0",\n "next": "^16.2.6",\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": "middleware.ts", "kind": "text", "content": `import { NextResponse, type NextRequest } from "next/server";
5361
5361
  import { handleTokenRefresh, type OidcConfig } from "@cimplify/sdk/server";
5362
5362
 
5363
5363
  function oidcConfig(): OidcConfig | null {
@@ -6389,7 +6389,7 @@ function Field({
6389
6389
  </div>
6390
6390
  );
6391
6391
  }
6392
- ` }, { "path": "app/track-order/track-order-form.tsx", "kind": "text", "content": '"use client";\n\nimport { useState } from "react";\nimport { useRouter } from "next/navigation";\n\n/**\n * Guest order tracker. Routes /orders/<id> with the entered id; the\n * post-checkout confirmation page already lives at /orders/[id] so this\n * just sends the visitor there. Replace the redirect with a real lookup\n * (e.g. Cimplify orders API + email-match guard) for production.\n */\nexport function TrackOrderForm() {\n const router = useRouter();\n const [orderId, setOrderId] = useState("");\n const [email, setEmail] = useState("");\n\n return (\n <form\n onSubmit={(e) => {\n e.preventDefault();\n if (!orderId.trim()) return;\n router.push(`/orders/${encodeURIComponent(orderId.trim())}`);\n }}\n className="space-y-4 rounded-2xl border border-border bg-card p-6"\n >\n <div>\n <label\n htmlFor="orderId"\n className="text-xs font-semibold uppercase tracking-wider text-muted-foreground block mb-1.5"\n >\n Order number\n </label>\n <input\n id="orderId"\n name="orderId"\n required\n value={orderId}\n onChange={(e) => setOrderId(e.target.value)}\n placeholder="ord_abc123\u2026"\n className="w-full px-4 py-3 rounded-md bg-background border border-border focus:border-primary focus:ring-2 focus:ring-primary/20 outline-none text-sm"\n />\n </div>\n <div>\n <label\n htmlFor="email"\n className="text-xs font-semibold uppercase tracking-wider text-muted-foreground block mb-1.5"\n >\n Order email\n </label>\n <input\n id="email"\n name="email"\n type="email"\n required\n value={email}\n onChange={(e) => setEmail(e.target.value)}\n placeholder="you@email.com"\n className="w-full px-4 py-3 rounded-md bg-background border border-border focus:border-primary focus:ring-2 focus:ring-primary/20 outline-none text-sm"\n />\n </div>\n <button\n type="submit"\n className="w-full inline-flex items-center justify-center px-5 py-3 rounded-md bg-primary text-primary-foreground font-semibold text-sm hover:bg-primary/90 transition-colors"\n >\n Track order\n </button>\n </form>\n );\n}\n' }, { "path": "app/track-order/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { TrackOrderForm } from "./track-order-form";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Track an order \u2014 ${brand.name}`,\n description: brand.trackOrder.body,\n};\n\nexport default function TrackOrderPage() {\n const t = brand.trackOrder;\n return (\n <article className="max-w-2xl 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 {t.eyebrow}\n </p>\n <h1 className="font-serif text-[clamp(2rem,5vw,3rem)] font-semibold mb-4 -tracking-[0.02em]">\n {t.title}\n </h1>\n <p className="text-muted-foreground leading-relaxed mb-8">{t.body}</p>\n <TrackOrderForm />\n </article>\n );\n}\n' }, { "path": "app/robots.ts", "kind": "text", "content": 'import type { MetadataRoute } from "next";\nimport { getSiteUrl } from "@/lib/site-url";\n\nexport default async function robots(): Promise<MetadataRoute.Robots> {\n const siteUrl = await getSiteUrl();\n return {\n rules: [\n {\n userAgent: "*",\n allow: "/",\n disallow: ["/cart", "/checkout", "/orders/", "/api/"],\n },\n ],\n sitemap: `${siteUrl}/sitemap.xml`,\n host: siteUrl,\n };\n}\n' }, { "path": ".gitignore", "kind": "text", "content": "node_modules\n.next\n.open-next\nout\n.env\n.env.local\n.env*.local\n.DS_Store\n*.tsbuildinfo\nnext-env.d.ts\n" }, { "path": "__tests__/brand.test.ts", "kind": "text", "content": 'import { createBrandSuite } from "@cimplify/sdk/testing/suite";\nimport { brand } from "../lib/brand";\n\ncreateBrandSuite({ brand });\n' }, { "path": "__tests__/contract.test.ts", "kind": "text", "content": 'import { createContractSuite } from "@cimplify/sdk/testing/suite";\nimport { brand } from "../lib/brand";\n\ncreateContractSuite({ seed: brand.mock.seed });\n' }, { "path": "__tests__/cart-flow.test.ts", "kind": "text", "content": 'import { createCartFlowSuite } from "@cimplify/sdk/testing/suite";\nimport { brand } from "../lib/brand";\n\ncreateCartFlowSuite({ seed: brand.mock.seed, businessId: brand.mock.businessId });\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.80.0",\n "next": "^16.2.6",\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": "middleware.ts", "kind": "text", "content": `import { NextResponse, type NextRequest } from "next/server";
6392
+ ` }, { "path": "app/track-order/track-order-form.tsx", "kind": "text", "content": '"use client";\n\nimport { useState } from "react";\nimport { useRouter } from "next/navigation";\n\n/**\n * Guest order tracker. Routes /orders/<id> with the entered id; the\n * post-checkout confirmation page already lives at /orders/[id] so this\n * just sends the visitor there. Replace the redirect with a real lookup\n * (e.g. Cimplify orders API + email-match guard) for production.\n */\nexport function TrackOrderForm() {\n const router = useRouter();\n const [orderId, setOrderId] = useState("");\n const [email, setEmail] = useState("");\n\n return (\n <form\n onSubmit={(e) => {\n e.preventDefault();\n if (!orderId.trim()) return;\n router.push(`/orders/${encodeURIComponent(orderId.trim())}`);\n }}\n className="space-y-4 rounded-2xl border border-border bg-card p-6"\n >\n <div>\n <label\n htmlFor="orderId"\n className="text-xs font-semibold uppercase tracking-wider text-muted-foreground block mb-1.5"\n >\n Order number\n </label>\n <input\n id="orderId"\n name="orderId"\n required\n value={orderId}\n onChange={(e) => setOrderId(e.target.value)}\n placeholder="ord_abc123\u2026"\n className="w-full px-4 py-3 rounded-md bg-background border border-border focus:border-primary focus:ring-2 focus:ring-primary/20 outline-none text-sm"\n />\n </div>\n <div>\n <label\n htmlFor="email"\n className="text-xs font-semibold uppercase tracking-wider text-muted-foreground block mb-1.5"\n >\n Order email\n </label>\n <input\n id="email"\n name="email"\n type="email"\n required\n value={email}\n onChange={(e) => setEmail(e.target.value)}\n placeholder="you@email.com"\n className="w-full px-4 py-3 rounded-md bg-background border border-border focus:border-primary focus:ring-2 focus:ring-primary/20 outline-none text-sm"\n />\n </div>\n <button\n type="submit"\n className="w-full inline-flex items-center justify-center px-5 py-3 rounded-md bg-primary text-primary-foreground font-semibold text-sm hover:bg-primary/90 transition-colors"\n >\n Track order\n </button>\n </form>\n );\n}\n' }, { "path": "app/track-order/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { TrackOrderForm } from "./track-order-form";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Track an order \u2014 ${brand.name}`,\n description: brand.trackOrder.body,\n};\n\nexport default function TrackOrderPage() {\n const t = brand.trackOrder;\n return (\n <article className="max-w-2xl 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 {t.eyebrow}\n </p>\n <h1 className="font-serif text-[clamp(2rem,5vw,3rem)] font-semibold mb-4 -tracking-[0.02em]">\n {t.title}\n </h1>\n <p className="text-muted-foreground leading-relaxed mb-8">{t.body}</p>\n <TrackOrderForm />\n </article>\n );\n}\n' }, { "path": "app/robots.ts", "kind": "text", "content": 'import type { MetadataRoute } from "next";\nimport { getSiteUrl } from "@/lib/site-url";\n\nexport default async function robots(): Promise<MetadataRoute.Robots> {\n const siteUrl = await getSiteUrl();\n return {\n rules: [\n {\n userAgent: "*",\n allow: "/",\n disallow: ["/cart", "/checkout", "/orders/", "/api/"],\n },\n ],\n sitemap: `${siteUrl}/sitemap.xml`,\n host: siteUrl,\n };\n}\n' }, { "path": ".gitignore", "kind": "text", "content": "node_modules\n.next\n.open-next\nout\n.env\n.env.local\n.env*.local\n.DS_Store\n*.tsbuildinfo\nnext-env.d.ts\n" }, { "path": "__tests__/brand.test.ts", "kind": "text", "content": 'import { createBrandSuite } from "@cimplify/sdk/testing/suite";\nimport { brand } from "../lib/brand";\n\ncreateBrandSuite({ brand });\n' }, { "path": "__tests__/contract.test.ts", "kind": "text", "content": 'import { createContractSuite } from "@cimplify/sdk/testing/suite";\nimport { brand } from "../lib/brand";\n\ncreateContractSuite({ seed: brand.mock.seed });\n' }, { "path": "__tests__/cart-flow.test.ts", "kind": "text", "content": 'import { createCartFlowSuite } from "@cimplify/sdk/testing/suite";\nimport { brand } from "../lib/brand";\n\ncreateCartFlowSuite({ seed: brand.mock.seed, businessId: brand.mock.businessId });\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.84.0",\n "next": "^16.2.6",\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": "middleware.ts", "kind": "text", "content": `import { NextResponse, type NextRequest } from "next/server";
6393
6393
  import { handleTokenRefresh, type OidcConfig } from "@cimplify/sdk/server";
6394
6394
 
6395
6395
  function oidcConfig(): OidcConfig | null {
@@ -6429,7 +6429,7 @@ export const config = {
6429
6429
  matcher: ["/account/:path*"],
6430
6430
  };
6431
6431
  ` }] };
6432
- var REGISTRY = { "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' }] }, "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' }] }, "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, getProductCurrency } 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 currency = getProductCurrency(product);\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} currency={currency} className="text-sm font-bold" />\n {onSale && (\n <Price amount={getBasePrice(product)} currency={currency} 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' }] }, "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, useMemo } from "react";\nimport type { Product, Category, PropertyFacet } from "@cimplify/sdk";\nimport { useProducts, useCategories, type UseProductsOptions } from "@cimplify/sdk/react";\nimport { prefetchProducts } from "./hooks/use-products";\nimport { speculate } from "./utils/prefetch-policy";\nimport { useOptionalCimplifyClient } 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 filterOptions = useMemo<UseProductsOptions>(\n () => ({\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 limit: pageSize,\n }),\n [selectedCategory, debouncedSearch, sortBy, sortOrder, inStockOnly, selectedTags, minPrice, maxPrice, pageSize],\n );\n\n const { products: fetchedProducts, isLoading, pagination } = useProducts({\n ...filterOptions,\n enabled: productsProp === undefined,\n page,\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 context = useOptionalCimplifyClient();\n if (\n context &&\n context.prefetch.mode !== "off" &&\n productsProp === undefined &&\n !isLoading &&\n page < totalPages\n ) {\n speculate(() => prefetchProducts(context.client, { ...filterOptions, page: page + 1 }));\n }\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' }] }, "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' }] }, "compact-service-card": { "name": "compact-service-card", "title": "CompactServiceCard", "description": "Horizontal service card with thumbnail for list views.", "type": "component", "registryDependencies": ["price", "cn", "strip-html"], "files": [{ "path": "cards/compact-service-card.tsx", "content": '"use client";\n\nimport React from "react";\nimport { stripHtmlToText } from "@cimplify/sdk/react";\nimport type { ServiceCardLayoutProps } from "./standard-service-card";\nimport { Price } from "@cimplify/sdk/react";\nimport { parsePrice, getProductCurrency } 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 currency = getProductCurrency(product);\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 {stripHtmlToText(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!} currency={currency} /> 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} currency={currency} 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' }] }, "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 type { Product } from "@cimplify/sdk";\nimport { useSearch } from "@cimplify/sdk/react";\nimport type { UseSearchOptions } from "@cimplify/sdk/react";\nimport { prefetchProduct } from "./hooks/use-product";\nimport { speculate } from "./utils/prefetch-policy";\nimport { useOptionalCimplifyClient } 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: Product) => void;\n /** Custom result item renderer. */\n renderResult?: (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 context = useOptionalCimplifyClient();\n const warmResult = useCallback(\n (product: Product) => {\n const client = mergedOptions.client ?? context?.client;\n if (!client || context?.prefetch.mode === "off") return;\n speculate(() => prefetchProduct(client, product.slug ?? product.id));\n },\n [mergedOptions.client, context],\n );\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 &times;\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 onPointerEnter={() => warmResult(product)}\n onFocus={() => warmResult(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' }] }, "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' }] }, "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, getProductCurrency } from "@cimplify/sdk";\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 resolvedCurrency = currency ?? getProductCurrency(product);\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' }] }, "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 &times;\n </button>\n )}\n </>\n )}\n </div>\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", "rich-text", "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 { resolveGallery } from "../../gallery";\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, getProductCurrency } from "@cimplify/sdk";\nimport { RichText } from "@cimplify/sdk/react";\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 [selectedVariantId, setSelectedVariantId] = React.useState<string | undefined>();\n const images = resolveGallery(product, selectedVariantId);\n const currency = getProductCurrency(product);\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} currency={currency} className="text-2xl font-extrabold" />\n ) : onSale ? (\n <>\n <Price amount={product.default_price} currency={currency} className="text-2xl font-extrabold" />\n <Price amount={getBasePrice(product)} currency={currency} 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} currency={currency} className="text-2xl font-semibold" />\n )}\n </div>\n\n {/* Inventory */}\n <InventoryBadge product={product} />\n\n {/* Description */}\n <RichText html={product.description} data-cimplify-product-layout-description />\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 onVariantChange={(variantId) => setSelectedVariantId(variantId)}\n />\n </div>\n }\n />\n\n {showRelated && relatedProducts && relatedProducts.length > 0 && (\n <RelatedProductsSection\n products={relatedProducts}\n />\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", "strip-html"], "files": [{ "path": "cards/lease-service-card.tsx", "content": '"use client";\n\nimport React from "react";\nimport { stripHtmlToText } from "@cimplify/sdk/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 { getProductCurrency } from "@cimplify/sdk";\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 currency = getProductCurrency(product);\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} currency={currency} />/{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 {stripHtmlToText(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!} currency={currency} /> 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 &rarr;\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, CurrencyCode } 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 swatch?: string;\n swatchSelected?: 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 currency?: CurrencyCode;\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 currency,\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\n // Color/image axis values render as swatches; selection is a ring\n // so the swatch keeps its own fill or photo.\n if (value.image_url || value.color_hex) {\n const isImage = Boolean(value.image_url);\n return (\n <Radio.Root\n key={value.id}\n value={value.id}\n data-cimplify-variant-option\n data-cimplify-variant-swatch\n data-selected={isSelected || undefined}\n aria-label={value.name}\n title={value.name}\n style={isImage ? undefined : { backgroundColor: value.color_hex }}\n className={cn(\n "relative overflow-hidden border border-border transition-shadow",\n isImage ? "h-12 w-12 rounded-md" : "h-9 w-9 rounded-full",\n isSelected && "ring-2 ring-foreground ring-offset-2 ring-offset-background border-transparent",\n isSelected ? classNames?.swatchSelected : classNames?.swatch,\n )}\n >\n {isImage && (\n <img\n src={value.image_url}\n alt=""\n className="h-full w-full object-cover"\n />\n )}\n </Radio.Root>\n );\n }\n\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} currency={currency} />\n </span>\n </Radio.Root>\n );\n })}\n </RadioGroup>\n </div>\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' }] }, "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' }] }, "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' }] }, "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' }] }, "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", "strip-html"], "files": [{ "path": "cards/food-product-card.tsx", "content": '"use client";\n\nimport React from "react";\nimport { stripHtmlToText } from "@cimplify/sdk/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 { getProductCurrency } from "@cimplify/sdk";\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 currency = getProductCurrency(product);\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 {stripHtmlToText(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} currency={currency} /></span>\n ) : (\n <Price amount={product.default_price} currency={currency} 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' }] }, "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", "rich-text", "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 { getProductCurrency } from "@cimplify/sdk";\nimport { RichText } 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 currency = getProductCurrency(product);\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} currency={currency} className="text-2xl font-extrabold" />\n ) : (\n <Price amount={product.default_price} currency={currency} 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 <RichText html={product.description} data-cimplify-product-layout-description />\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' }] }, "schedule-service-card": { "name": "schedule-service-card", "title": "ScheduleServiceCard", "description": "Service card with next available time slot pills.", "type": "component", "registryDependencies": ["price", "cn", "strip-html"], "files": [{ "path": "cards/schedule-service-card.tsx", "content": '"use client";\n\nimport React from "react";\nimport { stripHtmlToText } from "@cimplify/sdk/react";\nimport type { ServiceCardLayoutProps } from "./standard-service-card";\nimport { Price } from "@cimplify/sdk/react";\nimport { getProductCurrency } from "@cimplify/sdk";\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 currency = getProductCurrency(product);\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} currency={currency} />\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 {stripHtmlToText(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' }] }, "media-gallery": { "name": "media-gallery", "title": "MediaGallery", "description": "Like ProductImageGallery but accepts mixed image/video/3D items in one carousel. Thumbnails show a poster + small overlay icon for video/3D.", "type": "component", "registryDependencies": ["store-video", "product-model-3d"], "files": [{ "path": "media-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";\nimport { StoreVideo } from "@cimplify/sdk/react";\nimport { ProductModel3D } from "@cimplify/sdk/react";\n\nexport type MediaItem =\n | { type: "image"; src: string; alt?: string }\n | { type: "video"; src: string; poster?: string; alt?: string }\n | { type: "model"; src: string; iosSrc?: string; poster?: string; alt?: string };\n\nexport interface MediaGalleryProps {\n items: MediaItem[];\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\nfunction thumbnailFor(item: MediaItem): string | null {\n if (item.type === "image") return item.src;\n return item.poster ?? null;\n}\n\nfunction thumbIcon(item: MediaItem): string | null {\n if (item.type === "video") return "\u25B6";\n if (item.type === "model") return "\u25C6";\n return null;\n}\n\n/**\n * MediaGallery \u2014 like ProductImageGallery, but accepts mixed image/video/3D\n * items in one carousel. The active item renders via the matching SDK\n * component (img / StoreVideo / ProductModel3D); thumbnails show a poster\n * (or the image itself) with a small overlay icon for video/3D.\n */\nexport function MediaGallery({\n items,\n productName,\n aspectRatio = "4/3",\n className,\n}: MediaGalleryProps): React.ReactElement | null {\n const validItems = useMemo(\n () => items.filter((i) => typeof i.src === "string" && i.src.trim().length > 0),\n [items],\n );\n\n const [selected, setSelected] = useState(0);\n\n useEffect(() => {\n setSelected(0);\n }, [validItems.length, productName]);\n\n if (validItems.length === 0) return null;\n\n const active = validItems[selected] ?? validItems[0];\n\n return (\n <div data-cimplify-media-gallery className={className}>\n <div\n data-cimplify-media-gallery-main\n style={{ position: "relative", overflow: "hidden", ...ASPECT_STYLES[aspectRatio] }}\n >\n {active.type === "image" ? (\n <img\n src={active.src}\n alt={active.alt ?? productName}\n style={{ width: "100%", height: "100%", objectFit: "cover" }}\n data-cimplify-media-gallery-active\n />\n ) : active.type === "video" ? (\n <StoreVideo\n src={active.src}\n poster={active.poster}\n alt={active.alt ?? productName}\n aspectRatio="square"\n lazy={false}\n />\n ) : (\n <ProductModel3D\n src={active.src}\n iosSrc={active.iosSrc}\n poster={active.poster}\n alt={active.alt ?? productName}\n aspectRatio="square"\n />\n )}\n </div>\n\n {validItems.length > 1 && (\n <RadioGroup\n aria-label={`${productName} media thumbnails`}\n value={String(selected)}\n onValueChange={(v) => setSelected(Number(v))}\n data-cimplify-media-gallery-thumbnails\n style={{ display: "flex", gap: "0.5rem", marginTop: "0.75rem" }}\n >\n {validItems.map((item, index) => {\n const thumb = thumbnailFor(item);\n const icon = thumbIcon(item);\n const isSelected = selected === index;\n return (\n <Radio.Root\n key={`${item.src}-${index}`}\n value={String(index)}\n data-cimplify-media-gallery-thumb\n data-selected={isSelected || undefined}\n data-type={item.type}\n style={{\n position: "relative",\n width: "4rem",\n height: "4rem",\n overflow: "hidden",\n padding: 0,\n border: "none",\n cursor: "pointer",\n backgroundColor: "var(--muted, #f3f4f6)",\n }}\n >\n {thumb ? (\n <img\n src={thumb}\n alt=""\n style={{ width: "100%", height: "100%", objectFit: "cover" }}\n />\n ) : null}\n {icon ? (\n <span\n aria-hidden\n style={{\n position: "absolute",\n inset: 0,\n display: "flex",\n alignItems: "center",\n justifyContent: "center",\n color: "white",\n fontSize: "0.875rem",\n textShadow: "0 1px 2px rgba(0,0,0,0.6)",\n pointerEvents: "none",\n }}\n >\n {icon}\n </span>\n ) : null}\n </Radio.Root>\n );\n })}\n </RadioGroup>\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, { useCallback } from "react";\nimport type { ActivityRecommendation } from "../activity";\nimport { useRecommendations } from "./hooks/use-recommendations";\nimport { prefetchProduct } from "./hooks/use-product";\nimport { speculate } from "./utils/prefetch-policy";\nimport { useOptionalCimplifyClient } from "@cimplify/sdk/react";\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 const context = useOptionalCimplifyClient();\n const warmRecommendation = useCallback(\n (rec: ActivityRecommendation) => {\n const productId = rec.product?.id;\n if (!productId || !context || context.prefetch.mode === "off") return;\n speculate(() => prefetchProduct(context.client, productId));\n },\n [context],\n );\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 onPointerEnter={() => warmRecommendation(rec)}\n onFocus={() => warmRecommendation(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' }] }, "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 type { CurrencyCode } 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 /** Currency the slot prices are denominated in. */\n currency?: CurrencyCode;\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 /** Forwarded to `<SlotPicker>` \u2014 hide slots already in the past. Default: true. */\n hideElapsedSlots?: boolean;\n /** Forwarded to `<SlotPicker>` \u2014 minimum lead time in minutes. Default: 0. */\n minLeadMinutes?: 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 currency,\n schedulingMode,\n durationUnit,\n durationValue,\n hideElapsedSlots = true,\n minLeadMinutes = 0,\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 &larr;\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 &rarr;\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 currency={currency}\n schedulingMode={schedulingMode}\n durationUnit={durationUnit}\n durationValue={durationValue}\n hideElapsedSlots={hideElapsedSlots}\n minLeadMinutes={minLeadMinutes}\n />\n </div>\n </Tabs.Root>\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' }] }, "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 { ImagePriorityContext } 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, index) => {\n const card = renderCard ? (\n renderCard(product)\n ) : (\n <ProductCard product={product} renderImage={renderImage} />\n );\n return (\n <div\n key={product.id}\n data-cimplify-product-grid-item\n className={classNames?.item}\n >\n {index < xl ? (\n <ImagePriorityContext.Provider value={true}>{card}</ImagePriorityContext.Provider>\n ) : (\n card\n )}\n </div>\n );\n })}\n </div>\n </>\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 partially_paid: "Partially 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}&times;</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}&times; {sel.product_name}</span>\n {sel.product_type === "service" && sel.scheduling?.scheduled_start && (\n <span> &mdash; {new Date(sel.scheduling.scheduled_start).toLocaleDateString()}</span>\n )}\n {sel.product_type === "digital" && <span> &mdash; 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}&times; {sel.component_name}</span>\n {sel.product_type === "service" && sel.scheduling?.scheduled_start && (\n <span> &mdash; {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' }] }, "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' }] }, "strip-html": { "name": "strip-html", "title": "stripHtmlToText", "description": "Projects HTML to a single-line plain-text preview for cards and meta descriptions. Not a security boundary \u2014 output is rendered as text only.", "type": "utility", "registryDependencies": [], "files": [{ "path": "utils/strip-html.ts", "content": `/**
6432
+ var REGISTRY = { "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' }] }, "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' }] }, "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, getProductCurrency } 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 currency = getProductCurrency(product);\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 {product.sale ? (\n <>\n <Price amount={product.sale.price} currency={currency} className="text-sm font-bold" />\n <Price amount={product.sale.original} currency={currency} className="text-xs text-muted-foreground line-through" />\n </>\n ) : (\n <>\n <Price amount={product.default_price} currency={currency} className="text-sm font-bold" />\n {onSale && (\n <Price amount={getBasePrice(product)} currency={currency} className="text-xs text-muted-foreground line-through" />\n )}\n </>\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' }] }, "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, useMemo } from "react";\nimport type { Product, Category, PropertyFacet } from "@cimplify/sdk";\nimport { useProducts, useCategories, type UseProductsOptions } from "@cimplify/sdk/react";\nimport { prefetchProducts } from "./hooks/use-products";\nimport { speculate } from "./utils/prefetch-policy";\nimport { useOptionalCimplifyClient } 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 filterOptions = useMemo<UseProductsOptions>(\n () => ({\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 limit: pageSize,\n }),\n [selectedCategory, debouncedSearch, sortBy, sortOrder, inStockOnly, selectedTags, minPrice, maxPrice, pageSize],\n );\n\n const { products: fetchedProducts, isLoading, pagination } = useProducts({\n ...filterOptions,\n enabled: productsProp === undefined,\n page,\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 context = useOptionalCimplifyClient();\n if (\n context &&\n context.prefetch.mode !== "off" &&\n productsProp === undefined &&\n !isLoading &&\n page < totalPages\n ) {\n speculate(() => prefetchProducts(context.client, { ...filterOptions, page: page + 1 }));\n }\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' }] }, "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' }] }, "compact-service-card": { "name": "compact-service-card", "title": "CompactServiceCard", "description": "Horizontal service card with thumbnail for list views.", "type": "component", "registryDependencies": ["price", "cn", "strip-html"], "files": [{ "path": "cards/compact-service-card.tsx", "content": '"use client";\n\nimport React from "react";\nimport { stripHtmlToText } from "@cimplify/sdk/react";\nimport type { ServiceCardLayoutProps } from "./standard-service-card";\nimport { Price } from "@cimplify/sdk/react";\nimport { parsePrice, getProductCurrency } 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 currency = getProductCurrency(product);\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 {stripHtmlToText(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!} currency={currency} /> 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} currency={currency} 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' }] }, "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 type { Product } from "@cimplify/sdk";\nimport { useSearch } from "@cimplify/sdk/react";\nimport type { UseSearchOptions } from "@cimplify/sdk/react";\nimport { prefetchProduct } from "./hooks/use-product";\nimport { speculate } from "./utils/prefetch-policy";\nimport { useOptionalCimplifyClient } 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: Product) => void;\n /** Custom result item renderer. */\n renderResult?: (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 context = useOptionalCimplifyClient();\n const warmResult = useCallback(\n (product: Product) => {\n const client = mergedOptions.client ?? context?.client;\n if (!client || context?.prefetch.mode === "off") return;\n speculate(() => prefetchProduct(client, product.slug ?? product.id));\n },\n [mergedOptions.client, context],\n );\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 &times;\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 onPointerEnter={() => warmResult(product)}\n onFocus={() => warmResult(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' }] }, "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' }] }, "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, getProductCurrency } from "@cimplify/sdk";\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 resolvedCurrency = currency ?? getProductCurrency(product);\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' }] }, "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 &times;\n </button>\n )}\n </>\n )}\n </div>\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", "rich-text", "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 { resolveGallery } from "../../gallery";\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, getProductCurrency } from "@cimplify/sdk";\nimport { RichText } from "@cimplify/sdk/react";\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 [selectedVariantId, setSelectedVariantId] = React.useState<string | undefined>();\n const images = resolveGallery(product, selectedVariantId);\n const currency = getProductCurrency(product);\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} currency={currency} className="text-2xl font-extrabold" />\n ) : product.sale ? (\n <>\n <Price amount={product.sale.price} currency={currency} className="text-2xl font-extrabold" />\n <Price amount={product.sale.original} currency={currency} 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 {product.sale.badge_text ?? `${Math.round(parsePrice(product.sale.percent_off))}% OFF`}\n </span>\n </>\n ) : onSale ? (\n <>\n <Price amount={product.default_price} currency={currency} className="text-2xl font-extrabold" />\n <Price amount={getBasePrice(product)} currency={currency} 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} currency={currency} className="text-2xl font-semibold" />\n )}\n </div>\n\n {/* Inventory */}\n <InventoryBadge product={product} />\n\n {/* Description */}\n <RichText html={product.description} data-cimplify-product-layout-description />\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 onVariantChange={(variantId) => setSelectedVariantId(variantId)}\n />\n </div>\n }\n />\n\n {showRelated && relatedProducts && relatedProducts.length > 0 && (\n <RelatedProductsSection\n products={relatedProducts}\n />\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", "strip-html"], "files": [{ "path": "cards/lease-service-card.tsx", "content": '"use client";\n\nimport React from "react";\nimport { stripHtmlToText } from "@cimplify/sdk/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 { getProductCurrency } from "@cimplify/sdk";\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 currency = getProductCurrency(product);\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} currency={currency} />/{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 {stripHtmlToText(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!} currency={currency} /> 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 &rarr;\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, CurrencyCode } 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 swatch?: string;\n swatchSelected?: 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 currency?: CurrencyCode;\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 currency,\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\n // Color/image axis values render as swatches; selection is a ring\n // so the swatch keeps its own fill or photo.\n if (value.image_url || value.color_hex) {\n const isImage = Boolean(value.image_url);\n return (\n <Radio.Root\n key={value.id}\n value={value.id}\n data-cimplify-variant-option\n data-cimplify-variant-swatch\n data-selected={isSelected || undefined}\n aria-label={value.name}\n title={value.name}\n style={isImage ? undefined : { backgroundColor: value.color_hex }}\n className={cn(\n "relative overflow-hidden border border-border transition-shadow",\n isImage ? "h-12 w-12 rounded-md" : "h-9 w-9 rounded-full",\n isSelected && "ring-2 ring-foreground ring-offset-2 ring-offset-background border-transparent",\n isSelected ? classNames?.swatchSelected : classNames?.swatch,\n )}\n >\n {isImage && (\n <img\n src={value.image_url}\n alt=""\n className="h-full w-full object-cover"\n />\n )}\n </Radio.Root>\n );\n }\n\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} currency={currency} />\n </span>\n </Radio.Root>\n );\n })}\n </RadioGroup>\n </div>\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' }] }, "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' }] }, "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' }] }, "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' }] }, "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", "strip-html"], "files": [{ "path": "cards/food-product-card.tsx", "content": '"use client";\n\nimport React from "react";\nimport { stripHtmlToText } from "@cimplify/sdk/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 { getProductCurrency } from "@cimplify/sdk";\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 currency = getProductCurrency(product);\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 {stripHtmlToText(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} currency={currency} /></span>\n ) : (\n <Price amount={product.default_price} currency={currency} 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' }] }, "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", "rich-text", "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 { getProductCurrency } from "@cimplify/sdk";\nimport { RichText } 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 currency = getProductCurrency(product);\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} currency={currency} className="text-2xl font-extrabold" />\n ) : (\n <Price amount={product.default_price} currency={currency} 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 <RichText html={product.description} data-cimplify-product-layout-description />\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' }] }, "schedule-service-card": { "name": "schedule-service-card", "title": "ScheduleServiceCard", "description": "Service card with next available time slot pills.", "type": "component", "registryDependencies": ["price", "cn", "strip-html"], "files": [{ "path": "cards/schedule-service-card.tsx", "content": '"use client";\n\nimport React from "react";\nimport { stripHtmlToText } from "@cimplify/sdk/react";\nimport type { ServiceCardLayoutProps } from "./standard-service-card";\nimport { Price } from "@cimplify/sdk/react";\nimport { getProductCurrency } from "@cimplify/sdk";\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 currency = getProductCurrency(product);\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} currency={currency} />\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 {stripHtmlToText(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' }] }, "media-gallery": { "name": "media-gallery", "title": "MediaGallery", "description": "Like ProductImageGallery but accepts mixed image/video/3D items in one carousel. Thumbnails show a poster + small overlay icon for video/3D.", "type": "component", "registryDependencies": ["store-video", "product-model-3d"], "files": [{ "path": "media-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";\nimport { StoreVideo } from "@cimplify/sdk/react";\nimport { ProductModel3D } from "@cimplify/sdk/react";\n\nexport type MediaItem =\n | { type: "image"; src: string; alt?: string }\n | { type: "video"; src: string; poster?: string; alt?: string }\n | { type: "model"; src: string; iosSrc?: string; poster?: string; alt?: string };\n\nexport interface MediaGalleryProps {\n items: MediaItem[];\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\nfunction thumbnailFor(item: MediaItem): string | null {\n if (item.type === "image") return item.src;\n return item.poster ?? null;\n}\n\nfunction thumbIcon(item: MediaItem): string | null {\n if (item.type === "video") return "\u25B6";\n if (item.type === "model") return "\u25C6";\n return null;\n}\n\n/**\n * MediaGallery \u2014 like ProductImageGallery, but accepts mixed image/video/3D\n * items in one carousel. The active item renders via the matching SDK\n * component (img / StoreVideo / ProductModel3D); thumbnails show a poster\n * (or the image itself) with a small overlay icon for video/3D.\n */\nexport function MediaGallery({\n items,\n productName,\n aspectRatio = "4/3",\n className,\n}: MediaGalleryProps): React.ReactElement | null {\n const validItems = useMemo(\n () => items.filter((i) => typeof i.src === "string" && i.src.trim().length > 0),\n [items],\n );\n\n const [selected, setSelected] = useState(0);\n\n useEffect(() => {\n setSelected(0);\n }, [validItems.length, productName]);\n\n if (validItems.length === 0) return null;\n\n const active = validItems[selected] ?? validItems[0];\n\n return (\n <div data-cimplify-media-gallery className={className}>\n <div\n data-cimplify-media-gallery-main\n style={{ position: "relative", overflow: "hidden", ...ASPECT_STYLES[aspectRatio] }}\n >\n {active.type === "image" ? (\n <img\n src={active.src}\n alt={active.alt ?? productName}\n style={{ width: "100%", height: "100%", objectFit: "cover" }}\n data-cimplify-media-gallery-active\n />\n ) : active.type === "video" ? (\n <StoreVideo\n src={active.src}\n poster={active.poster}\n alt={active.alt ?? productName}\n aspectRatio="square"\n lazy={false}\n />\n ) : (\n <ProductModel3D\n src={active.src}\n iosSrc={active.iosSrc}\n poster={active.poster}\n alt={active.alt ?? productName}\n aspectRatio="square"\n />\n )}\n </div>\n\n {validItems.length > 1 && (\n <RadioGroup\n aria-label={`${productName} media thumbnails`}\n value={String(selected)}\n onValueChange={(v) => setSelected(Number(v))}\n data-cimplify-media-gallery-thumbnails\n style={{ display: "flex", gap: "0.5rem", marginTop: "0.75rem" }}\n >\n {validItems.map((item, index) => {\n const thumb = thumbnailFor(item);\n const icon = thumbIcon(item);\n const isSelected = selected === index;\n return (\n <Radio.Root\n key={`${item.src}-${index}`}\n value={String(index)}\n data-cimplify-media-gallery-thumb\n data-selected={isSelected || undefined}\n data-type={item.type}\n style={{\n position: "relative",\n width: "4rem",\n height: "4rem",\n overflow: "hidden",\n padding: 0,\n border: "none",\n cursor: "pointer",\n backgroundColor: "var(--muted, #f3f4f6)",\n }}\n >\n {thumb ? (\n <img\n src={thumb}\n alt=""\n style={{ width: "100%", height: "100%", objectFit: "cover" }}\n />\n ) : null}\n {icon ? (\n <span\n aria-hidden\n style={{\n position: "absolute",\n inset: 0,\n display: "flex",\n alignItems: "center",\n justifyContent: "center",\n color: "white",\n fontSize: "0.875rem",\n textShadow: "0 1px 2px rgba(0,0,0,0.6)",\n pointerEvents: "none",\n }}\n >\n {icon}\n </span>\n ) : null}\n </Radio.Root>\n );\n })}\n </RadioGroup>\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, { useCallback } from "react";\nimport type { ActivityRecommendation } from "../activity";\nimport { useRecommendations } from "./hooks/use-recommendations";\nimport { prefetchProduct } from "./hooks/use-product";\nimport { speculate } from "./utils/prefetch-policy";\nimport { useOptionalCimplifyClient } from "@cimplify/sdk/react";\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 const context = useOptionalCimplifyClient();\n const warmRecommendation = useCallback(\n (rec: ActivityRecommendation) => {\n const productId = rec.product?.id;\n if (!productId || !context || context.prefetch.mode === "off") return;\n speculate(() => prefetchProduct(context.client, productId));\n },\n [context],\n );\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 onPointerEnter={() => warmRecommendation(rec)}\n onFocus={() => warmRecommendation(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' }] }, "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 type { CurrencyCode } 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 /** Currency the slot prices are denominated in. */\n currency?: CurrencyCode;\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 /** Forwarded to `<SlotPicker>` \u2014 hide slots already in the past. Default: true. */\n hideElapsedSlots?: boolean;\n /** Forwarded to `<SlotPicker>` \u2014 minimum lead time in minutes. Default: 0. */\n minLeadMinutes?: 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 currency,\n schedulingMode,\n durationUnit,\n durationValue,\n hideElapsedSlots = true,\n minLeadMinutes = 0,\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 &larr;\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 &rarr;\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 currency={currency}\n schedulingMode={schedulingMode}\n durationUnit={durationUnit}\n durationValue={durationValue}\n hideElapsedSlots={hideElapsedSlots}\n minLeadMinutes={minLeadMinutes}\n />\n </div>\n </Tabs.Root>\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' }] }, "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 { ImagePriorityContext } 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, index) => {\n const card = renderCard ? (\n renderCard(product)\n ) : (\n <ProductCard product={product} renderImage={renderImage} />\n );\n return (\n <div\n key={product.id}\n data-cimplify-product-grid-item\n className={classNames?.item}\n >\n {index < xl ? (\n <ImagePriorityContext.Provider value={true}>{card}</ImagePriorityContext.Provider>\n ) : (\n card\n )}\n </div>\n );\n })}\n </div>\n </>\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 partially_paid: "Partially 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}&times;</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}&times; {sel.product_name}</span>\n {sel.product_type === "service" && sel.scheduling?.scheduled_start && (\n <span> &mdash; {new Date(sel.scheduling.scheduled_start).toLocaleDateString()}</span>\n )}\n {sel.product_type === "digital" && <span> &mdash; 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}&times; {sel.component_name}</span>\n {sel.product_type === "service" && sel.scheduling?.scheduled_start && (\n <span> &mdash; {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' }] }, "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' }] }, "strip-html": { "name": "strip-html", "title": "stripHtmlToText", "description": "Projects HTML to a single-line plain-text preview for cards and meta descriptions. Not a security boundary \u2014 output is rendered as text only.", "type": "utility", "registryDependencies": [], "files": [{ "path": "utils/strip-html.ts", "content": `/**
6433
6433
  * Project HTML to a single-line plain-text preview. NOT a security boundary \u2014
6434
6434
  * output is only ever rendered as text (never as HTML). For card/meta previews
6435
6435
  * where tags must not appear.