@cimplify/cli 0.13.4 → 0.14.0

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.
Files changed (26) hide show
  1. package/dist/{add-542NLKYG.mjs → add-PPUGTTDA.mjs} +1 -1
  2. package/dist/{chunk-OE7YRDLH.mjs → chunk-7CI5RWNE.mjs} +22 -4
  3. package/dist/{chunk-VCDGYXFU.mjs → chunk-JOF7ESSA.mjs} +9 -9
  4. package/dist/{chunk-7NUSJVN3.mjs → chunk-KAZMZD4X.mjs} +2 -2
  5. package/dist/{chunk-HJQ6PF5N.mjs → chunk-SOYZZZVT.mjs} +2 -2
  6. package/dist/{chunk-MLVBE5V2.mjs → chunk-YIBOOKQQ.mjs} +15 -1
  7. package/dist/{deploy-SLRFPGTH.mjs → deploy-64ZVAQ3R.mjs} +36 -3
  8. package/dist/dispatcher.mjs +14 -14
  9. package/dist/{doctor-E6JH4VSN.mjs → doctor-6ZIFM5J5.mjs} +3 -3
  10. package/dist/{explain-SZPLL5MP.mjs → explain-REIDDRKC.mjs} +2 -2
  11. package/dist/{introspect-SDHD3PVX.mjs → introspect-APRI7R7K.mjs} +3 -3
  12. package/dist/{link-KGNQWCSZ.mjs → link-AWC7VJNN.mjs} +18 -1
  13. package/dist/{lint-NM3APP7L.mjs → lint-EYKIQ6RO.mjs} +9 -5
  14. package/dist/{list-W5JABY7B.mjs → list-7ZL6FRKT.mjs} +1 -1
  15. package/dist/{projects-U75DOOR7.mjs → projects-P65SUETQ.mjs} +2 -2
  16. package/dist/{repo-IE5IOLR5.mjs → repo-ZPYZK3KW.mjs} +2 -1
  17. package/dist/{update-447FR363.mjs → update-MBEPZANS.mjs} +1 -1
  18. package/package.json +2 -2
  19. package/templates/storefront-auto/package.json +1 -1
  20. package/templates/storefront-bakery/package.json +1 -1
  21. package/templates/storefront-fashion/package.json +1 -1
  22. package/templates/storefront-grocery/package.json +1 -1
  23. package/templates/storefront-pharmacy/package.json +1 -1
  24. package/templates/storefront-restaurant/package.json +1 -1
  25. package/templates/storefront-retail/package.json +1 -1
  26. package/templates/storefront-services/package.json +1 -1
@@ -576,7 +576,7 @@ export const brand: Brand = {
576
576
  businessId: "bus_default_akua_bakery",
577
577
  },
578
578
  };
579
- ` }, { "path": "lib/cimplify-server.ts", "kind": "text", "content": 'import { cookies } from "next/headers";\nimport {\n getAccessTokenFromCookieHeader,\n getSessionFromCookieHeader,\n} from "@cimplify/sdk/server";\nimport type { NavItem } from "@cimplify/sdk/react";\n\nexport const ACCOUNT_NAV: NavItem[] = [\n { label: "Dashboard", href: "/account" },\n { label: "Orders", href: "/account/orders" },\n { label: "Cimplify identity", href: "https://link.cimplify.io", external: true },\n];\n\nconst CLIENT_ID = process.env.CIMPLIFY_CLIENT_ID ?? "";\nconst ISSUER = (process.env.CIMPLIFY_ISSUER ?? "https://api.cimplify.io").replace(/\\/$/, "");\n\nconst OIDC_CONFIG = { clientId: CLIENT_ID, issuer: ISSUER };\n\nasync function cookieHeader(): Promise<string> {\n const store = await cookies();\n return store.getAll().map((c) => `${c.name}=${c.value}`).join("; ");\n}\n\nexport async function serverSession() {\n return getSessionFromCookieHeader(OIDC_CONFIG, await cookieHeader());\n}\n\nexport async function serverAccessToken(): Promise<string | null> {\n return getAccessTokenFromCookieHeader(OIDC_CONFIG, await cookieHeader());\n}\n\ninterface Envelope<T> {\n data: T;\n}\n\nexport async function apiFetch<T>(\n path: string,\n init: RequestInit & { bearer: string | null },\n): Promise<T | null> {\n if (!init.bearer) return null;\n const { bearer, ...rest } = init;\n const headers = new Headers(rest.headers);\n headers.set("Authorization", `Bearer ${bearer}`);\n headers.set("Accept", "application/json");\n if (rest.body && !headers.has("Content-Type")) {\n headers.set("Content-Type", "application/json");\n }\n const res = await fetch(`${ISSUER}${path}`, { ...rest, headers, cache: "no-store" });\n if (!res.ok) return null;\n const envelope = (await res.json()) as Envelope<T>;\n return envelope.data;\n}\n' }, { "path": "lib/cimplify-loader.ts", "kind": "text", "content": 'import type { ImageLoader } from "next/image";\n\nimport { assetUrl, isCimplifyAsset } from "@cimplify/sdk";\n\nconst cdnBase = process.env.NEXT_PUBLIC_CIMPLIFY_CDN_URL?.trim() || undefined;\n\nconst cimplifyImageLoader: ImageLoader = ({ src, width, quality }) => {\n if (isCimplifyAsset(src, cdnBase)) {\n return assetUrl(src, {\n base: cdnBase,\n w: width,\n quality,\n format: "auto",\n });\n }\n return src;\n};\n\nexport default cimplifyImageLoader;\n' }, { "path": "lib/site-url.ts", "kind": "text", "content": 'import { SITE_URL } from "./site.config";\n\n/** Canonical absolute URL for this storefront. */\nexport async function getSiteUrl(): Promise<string> {\n return SITE_URL;\n}\n' }, { "path": ".env.example", "kind": "text", "content": "# Your tenant public key. Get it from the desk's Developers tab.\n# `cpk_live_*` / `cpk_test_*` keys auto-route same-origin requests\n# to hosted Cimplify in both dev and prod. Anything else (`mock-dev`,\n# empty) falls back to the local `cimplify dev` mock.\nNEXT_PUBLIC_CIMPLIFY_PUBLIC_KEY=mock-dev\n\n# Optional. Forces a fixed canonical URL for sitemap.xml, robots.txt,\n# OpenGraph, and llms.txt. Leave unset and the storefront derives the\n# canonical from the request `Host` header automatically.\n# NEXT_PUBLIC_SITE_URL=\n" }, { "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__/brand.test.ts", "kind": "text", "content": 'import { createBrandSuite } from "@cimplify/sdk/testing/suite";\nimport { brand } from "../lib/brand";\n\ncreateBrandSuite({ brand });\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.100.4",\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": "metadata.json", "kind": "text", "content": '{\n "id": "bakery",\n "name": "Bakery",\n "tagline": "Warm, food-forward storefront with custom-order forms and same-day delivery checkout.",\n "industry": "food",\n "tags": ["food", "bakery", "pastry"],\n "stability": "stable",\n "schemaType": "Bakery",\n "mock": {\n "seedName": "default",\n "seedBusinessId": "bus_default_akua_bakery"\n }\n}\n' }, { "path": "app/search/search-client.tsx", "kind": "text", "content": '"use client";\n\nimport { SearchPage } from "@cimplify/sdk/react";\n\nexport function SearchClient() {\n return <SearchPage />;\n}\n' }, { "path": "app/search/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Suspense } from "react";\nimport { SearchClient } from "./search-client";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Search \u2014 ${brand.name}`,\n description: `Search ${brand.name} \u2014 products, collections, categories.`,\n};\n\nexport default function SearchPage() {\n return (\n <article className="max-w-7xl mx-auto px-6 sm:px-8 py-10">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-2">\n Search\n </p>\n <h1 className="font-serif text-[clamp(2rem,4vw,2.75rem)] font-semibold mb-8 -tracking-[0.02em]">\n Find anything.\n </h1>\n <Suspense fallback={<SearchSkeleton />}>\n <SearchClient />\n </Suspense>\n </article>\n );\n}\n\nfunction SearchSkeleton() {\n return (\n <div>\n <div className="h-12 w-full max-w-xl bg-muted rounded animate-pulse mb-8" />\n <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3 sm:gap-4">\n {Array.from({ length: 8 }).map((_, i) => (\n <div key={i} className="aspect-[4/3] bg-muted rounded-2xl animate-pulse" />\n ))}\n </div>\n </div>\n );\n}\n' }, { "path": "app/categories/[slug]/listing-client.tsx", "kind": "text", "content": '"use client";\n\nimport { ProductGrid } from "@cimplify/sdk/react";\nimport type { Product } from "@cimplify/sdk";\nimport { StoreProductCard } from "@/components/store-product-card";\n\n/**\n * Client island for the category listing. Receives server-fetched products\n * as props (serializable) and owns the `renderCard` function.\n */\nexport function ListingClient({ products }: { products: Product[] }) {\n return (\n <ProductGrid\n products={products}\n emptyMessage="No products in this category yet."\n renderCard={(p) => <StoreProductCard product={p} />}\n />\n );\n}\n' }, { "path": "app/categories/[slug]/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Suspense } from "react";\nimport Link from "next/link";\nimport { notFound } from "next/navigation";\nimport {\n getServerClient,\n tags,\n type Category,\n type Product,\n} from "@cimplify/sdk/server";\nimport { ListingClient } from "./listing-client";\nimport { brand } from "@/lib/brand";\n\n// See app/products/[slug]/page.tsx for the rationale on generateStaticParams.\nexport async function generateStaticParams() {\n const r = await getServerClient().catalogue.getCategories();\n if (!r.ok || r.value.length === 0) {\n return [{ slug: "__placeholder__" }];\n }\n return r.value.map((c) => ({ slug: c.slug ?? c.id }));\n}\n\nexport const revalidate = 3600;\n\ninterface CategoryData {\n category: Category;\n products: Product[];\n}\n\ntype CategoryResult =\n | { ok: true; data: CategoryData }\n | { ok: false; code: string };\n\nasync function getCategory(slug: string): Promise<CategoryResult> {\n const client = getServerClient();\n const catRes = await client.catalogue.getCategoryBySlug(slug, {\n cacheOptions: { revalidate: 3600, tags: [tags.categories()] },\n });\n if (!catRes.ok) return { ok: false, code: catRes.error.code };\n\n const r = await client.catalogue.getCategoryProducts(catRes.value.id, undefined, {\n cacheOptions: {\n revalidate: 3600,\n tags: [\n tags.products(),\n tags.category(catRes.value.id),\n tags.categoryProducts(catRes.value.id),\n ],\n },\n });\n const products = r.ok\n ? ((r.value as { items?: Product[] }).items ?? (r.value as Product[]))\n : [];\n return { ok: true, data: { category: catRes.value, products } };\n}\n\nexport async function generateMetadata({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}): Promise<Metadata> {\n const { slug } = await params;\n const result = await getCategory(slug);\n if (!result.ok) return {};\n const data = result.data;\n return {\n title: `${data.category.name} \u2014 ${brand.name}`,\n description: data.category.description ?? undefined,\n };\n}\n\nexport default async function CategoryPage({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}) {\n return (\n <Suspense fallback={<CategorySkeleton />}>\n <CategoryContent params={params} />\n </Suspense>\n );\n}\n\nasync function CategoryContent({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}) {\n const { slug } = await params;\n const result = await getCategory(slug);\n if (!result.ok) {\n if (result.code === "NOT_FOUND") notFound();\n return <CategorySkeleton />;\n }\n const { category, products } = result.data;\n return (\n <section className="max-w-7xl mx-auto px-6 sm:px-8 pt-12">\n <header className="mb-8 text-center">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-2">\n Category\n </p>\n <h1 className="font-serif text-[clamp(2rem,4vw,2.75rem)] font-semibold mb-2">\n {category.name}\n </h1>\n {category.description && (\n <p className="mx-auto mb-2 max-w-xl text-muted-foreground">\n {category.description}\n </p>\n )}\n <p className="text-sm text-muted-foreground">\n {products.length} item{products.length === 1 ? "" : "s"}\n </p>\n </header>\n <ListingClient products={products} />\n {products.length === 0 && (\n <p className="text-center mt-8">\n <Link href="/" className="text-primary font-semibold">\n \u2190 Back home\n </Link>\n </p>\n )}\n </section>\n );\n}\n\nfunction CategorySkeleton() {\n return (\n <section className="max-w-7xl mx-auto px-6 sm:px-8 pt-12">\n <header className="mb-8 text-center">\n <div className="mx-auto h-3 w-20 bg-muted rounded mb-2 animate-pulse" />\n <div className="mx-auto h-10 w-64 bg-muted rounded mb-2 animate-pulse" />\n <div className="mx-auto h-4 w-80 bg-muted rounded animate-pulse" />\n </header>\n <div className="grid grid-cols-2 md:grid-cols-3 gap-4">\n {Array.from({ length: 6 }).map((_, i) => (\n <div key={i} className="aspect-square bg-muted rounded-2xl animate-pulse" />\n ))}\n </div>\n </section>\n );\n}\n' }, { "path": "app/terms/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Terms of Service \u2014 ${brand.name}`,\n description: `The rules of the road for ordering from ${brand.name}.`,\n};\n\nexport default function TermsPage() {\n const t = brand.terms;\n return (\n <article className="max-w-3xl mx-auto px-6 sm:px-8 py-16 prose prose-lg max-w-none">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-2 not-prose">\n {t.eyebrow}\n </p>\n <h1 className="font-serif text-[clamp(2.25rem,5vw,3.5rem)] font-semibold mb-2 -tracking-[0.02em]">\n {t.title}\n </h1>\n <p className="text-sm text-muted-foreground not-prose mb-10">\n Last updated: {t.lastUpdated}\n </p>\n\n <section className="space-y-5 leading-relaxed text-foreground/90">\n {t.sections.map((s) => (\n <div key={s.heading}>\n <h2 className="font-serif text-2xl font-semibold mt-0">{s.heading}</h2>\n {typeof s.body === "string" ? (\n <p>{s.body}</p>\n ) : (\n <>\n <p>{s.body.intro}</p>\n <ul className="list-disc pl-6 space-y-2">\n {s.body.bullets.map((b) => (\n <li key={b}>{b}</li>\n ))}\n </ul>\n </>\n )}\n </div>\n ))}\n </section>\n </article>\n );\n}\n' }, { "path": "app/not-found.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport Link from "next/link";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Page not found \u2014 ${brand.name}`,\n description: "We couldn\'t find that page.",\n};\n\nexport default function NotFound() {\n return (\n <section className="max-w-2xl mx-auto px-6 sm:px-8 py-20 text-center">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-3">\n 404\n </p>\n <h1 className="font-serif text-[clamp(2.5rem,5vw,3.5rem)] font-semibold mb-4">\n Page not found.\n </h1>\n <p className="text-muted-foreground leading-relaxed mb-8">\n The URL you followed might be old, a typo, or pointing at a product\n that&apos;s sold out for the day. Try the menu or head back home.\n </p>\n <div className="flex flex-wrap items-center justify-center gap-3">\n <Link\n href="/shop"\n className="inline-block px-6 py-3 rounded-full bg-primary text-primary-foreground font-semibold text-sm transition-colors hover:bg-primary/90"\n >\n Browse the menu\n </Link>\n <Link\n href="/"\n className="inline-block px-6 py-3 rounded-full border border-border hover:bg-muted transition-colors text-sm font-medium"\n >\n Back home\n </Link>\n </div>\n </section>\n );\n}\n' }, { "path": "app/cart/page.tsx", "kind": "text", "content": '"use client";\n\nimport { useRouter } from "next/navigation";\nimport { CartPage as SdkCartPage } from "@cimplify/sdk/react";\n\nexport default function CartPage() {\n const router = useRouter();\n return <SdkCartPage onCheckout={() => router.push("/checkout")} />;\n}\n' }, { "path": "app/accessibility/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\nimport { PolicyPage } from "@/components/policy-page";\n\nexport const metadata: Metadata = {\n title: `${brand.accessibility.title} \u2014 ${brand.name}`,\n};\n\nexport default function AccessibilityPage() {\n return <PolicyPage policy={brand.accessibility} />;\n}\n' }, { "path": "app/account/orders/[id]/page.tsx", "kind": "text", "content": 'import { notFound } from "next/navigation";\nimport { AccountSignedOutPrompt, toAccountOrderView } from "@cimplify/sdk/react";\nimport type { EnrichedOrder } from "@cimplify/sdk";\nimport { brand } from "@/lib/brand";\nimport { apiFetch, serverAccessToken, serverSession } from "@/lib/cimplify-server";\nimport { OrderDetail } from "@/components/order-detail";\n\nconst CLIENT_ID = process.env.NEXT_PUBLIC_CIMPLIFY_CLIENT_ID ?? "";\nconst REDIRECT_URI = process.env.CIMPLIFY_REDIRECT_URI ?? "";\n\nexport default async function OrderDetailPage({\n params,\n}: {\n params: Promise<{ id: string }>;\n}) {\n const { id } = await params;\n\n const session = await serverSession();\n if (!session) {\n return (\n <AccountSignedOutPrompt\n clientId={CLIENT_ID}\n redirectUri={REDIRECT_URI}\n eyebrow={brand.account.loginEyebrow}\n title={brand.account.loginTitle}\n description={brand.account.loginSubtitle}\n />\n );\n }\n\n const bearer = await serverAccessToken();\n const order = await apiFetch<EnrichedOrder>(`/api/v1/orders/${encodeURIComponent(id)}`, {\n bearer,\n });\n if (!order) notFound();\n\n return (\n <div className="mx-auto w-full max-w-5xl px-4 py-8 sm:px-6">\n <OrderDetail {...toAccountOrderView(order)} />\n </div>\n );\n}\n' }, { "path": "app/account/orders/page.tsx", "kind": "text", "content": 'import {\n AccountOrdersPage,\n AccountSignedOutPrompt,\n} from "@cimplify/sdk/react";\nimport type { OrderRowData, OrderRowStatus } from "@cimplify/sdk/react";\nimport type { Order, OrderStatus } from "@cimplify/sdk";\nimport { formatMoney } from "@cimplify/sdk";\nimport { brand } from "@/lib/brand";\nimport { ACCOUNT_NAV, apiFetch, serverAccessToken, serverSession } from "@/lib/cimplify-server";\n\nconst CLIENT_ID = process.env.NEXT_PUBLIC_CIMPLIFY_CLIENT_ID ?? "";\nconst REDIRECT_URI = process.env.CIMPLIFY_REDIRECT_URI ?? "";\n\nfunction mapStatus(status: OrderStatus): OrderRowStatus {\n switch (status) {\n case "in_preparation":\n case "ready_to_serve":\n case "partially_served":\n return { kind: "preparing", label: "Preparing" };\n case "delivered":\n return { kind: "delivered" };\n case "picked_up":\n return { kind: "delivered", label: "Picked up" };\n case "served":\n case "completed":\n return { kind: "completed" };\n case "cancelled":\n case "refunded":\n return { kind: "cancelled", label: status === "refunded" ? "Refunded" : undefined };\n case "pending":\n case "created":\n case "confirmed":\n default:\n return { kind: "active", label: "Confirmed" };\n }\n}\n\nfunction relativeMeta(iso: string, orderNumber: string): string {\n const when = new Date(iso);\n const formatted = when.toLocaleDateString(undefined, {\n month: "short",\n day: "numeric",\n year: when.getFullYear() === new Date().getFullYear() ? undefined : "numeric",\n });\n return `Order ${orderNumber} \xB7 ${formatted}`;\n}\n\nfunction toRowData(order: Order): OrderRowData {\n const count = order.items?.length ?? 0;\n return {\n id: order.id,\n href: `/account/orders/${order.id}`,\n summary: count > 0 ? `${count} ${count === 1 ? "item" : "items"}` : "Order",\n meta: relativeMeta(order.created_at, order.user_friendly_id ?? order.id),\n status: mapStatus(order.status),\n total: formatMoney(order.total_price, order.currency),\n };\n}\n\nexport default async function OrdersPage() {\n const session = await serverSession();\n if (!session) {\n return (\n <AccountSignedOutPrompt\n clientId={CLIENT_ID}\n redirectUri={REDIRECT_URI}\n eyebrow={brand.account.loginEyebrow}\n title={brand.account.loginTitle}\n description={brand.account.loginSubtitle}\n />\n );\n }\n\n const bearer = await serverAccessToken();\n const orders = await apiFetch<Order[]>(\n `/v1/link/orders?business_id=${encodeURIComponent(brand.mock.businessId)}`,\n { bearer },\n );\n\n return (\n <AccountOrdersPage\n customerName={session.name ?? ""}\n customerEmail={session.email}\n customerId={session.sub}\n merchantName={brand.name}\n nav={ACCOUNT_NAV}\n orders={(orders ?? []).map(toRowData)}\n />\n );\n}\n' }, { "path": "app/account/page.tsx", "kind": "text", "content": 'import {\n AccountDashboardPage,\n AccountSignedOutPrompt,\n} from "@cimplify/sdk/react";\nimport type { CustomerAddress, CustomerMobileMoney } from "@cimplify/sdk";\nimport { brand } from "@/lib/brand";\nimport { ACCOUNT_NAV, apiFetch, serverAccessToken, serverSession } from "@/lib/cimplify-server";\n\nconst CLIENT_ID = process.env.NEXT_PUBLIC_CIMPLIFY_CLIENT_ID ?? "";\nconst REDIRECT_URI = process.env.CIMPLIFY_REDIRECT_URI ?? "";\n\nexport default async function AccountPage() {\n const session = await serverSession();\n if (!session) {\n return (\n <AccountSignedOutPrompt\n clientId={CLIENT_ID}\n redirectUri={REDIRECT_URI}\n eyebrow={brand.account.loginEyebrow}\n title={brand.account.loginTitle}\n description={brand.account.loginSubtitle}\n />\n );\n }\n\n const bearer = await serverAccessToken();\n const [addresses, wallets] = await Promise.all([\n apiFetch<CustomerAddress[]>("/v1/link/addresses", { bearer }),\n apiFetch<CustomerMobileMoney[]>("/v1/link/mobile-money", { bearer }),\n ]);\n\n return (\n <AccountDashboardPage\n customerName={session.name ?? ""}\n customerEmail={session.email}\n customerId={session.sub}\n merchantName={brand.name}\n nav={ACCOUNT_NAV}\n identitySummary={{\n addresses: addresses?.length ?? 0,\n paymentMethods: wallets?.length ?? 0,\n }}\n />\n );\n}\n' }, { "path": "app/.well-known/ucp/route.ts", "kind": "text", "content": 'import { NextResponse } from "next/server";\n\n/**\n * UCP (Universal Commerce Protocol) manifest discovery endpoint.\n *\n * Agents \u2014 Claude, ChatGPT, Gemini, MCP clients \u2014 probe\n * `https://<your-domain>/.well-known/ucp` to learn what commerce\n * capabilities your storefront supports. We resolve the business\'s\n * UCP handle from the public key (via the storefront API), then forward\n * to Cimplify for the canonical manifest. Edge-cached for an hour\n * because capabilities change rarely.\n */\nconst UCP_API_BASE = "https://api.cimplify.io";\nconst STOREFRONT_API_BASE =\n process.env.NODE_ENV === "production"\n ? "https://storefronts.cimplify.io"\n : "http://127.0.0.1:8787";\n\nexport async function GET() {\n const publicKey = process.env.NEXT_PUBLIC_CIMPLIFY_PUBLIC_KEY;\n if (!publicKey) {\n return NextResponse.json(\n {\n error: "NEXT_PUBLIC_CIMPLIFY_PUBLIC_KEY not set",\n remediation:\n "Set NEXT_PUBLIC_CIMPLIFY_PUBLIC_KEY in .env.local (and your deployment env).",\n },\n { status: 500 },\n );\n }\n\n try {\n // Step 1 \u2014 resolve the business handle from the public key.\n const businessResp = await fetch(`${STOREFRONT_API_BASE}/api/v1/business`, {\n headers: { "X-API-Key": publicKey, "Content-Type": "application/json" },\n next: { revalidate: 3600 },\n });\n\n if (!businessResp.ok) {\n return NextResponse.json(\n { error: `Failed to resolve business: ${businessResp.status}` },\n { status: businessResp.status },\n );\n }\n\n const businessJson = await businessResp.json();\n const handle: string | undefined = businessJson?.data?.handle;\n if (!handle) {\n return NextResponse.json(\n { error: "Business has no handle configured" },\n { status: 500 },\n );\n }\n\n // Step 2 \u2014 fetch the canonical UCP manifest.\n const manifestResp = await fetch(\n `${UCP_API_BASE}/ucp/v1/${handle}/manifest`,\n {\n headers: { "Content-Type": "application/json" },\n next: { revalidate: 3600 },\n },\n );\n\n if (!manifestResp.ok) {\n return NextResponse.json(\n { error: `Upstream UCP manifest fetch failed: ${manifestResp.status}` },\n { status: manifestResp.status },\n );\n }\n\n const manifest = await manifestResp.json();\n return NextResponse.json(manifest, {\n headers: {\n "Content-Type": "application/json",\n "Cache-Control": "public, max-age=3600, s-maxage=3600",\n },\n });\n } catch (error) {\n return NextResponse.json(\n {\n error: "Failed to fetch UCP manifest",\n detail: error instanceof Error ? error.message : String(error),\n },\n { status: 500 },\n );\n }\n}\n' }, { "path": "app/returns/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\nimport { PolicyPage } from "@/components/policy-page";\n\nexport const metadata: Metadata = {\n title: `${brand.returns.title} \u2014 ${brand.name}`,\n};\n\nexport default function ReturnsPage() {\n return <PolicyPage policy={brand.returns} />;\n}\n' }, { "path": "app/orders/[id]/page.tsx", "kind": "text", "content": 'import Link from "next/link";\n\nexport default async function OrderPage({ params }: { params: Promise<{ id: string }> }) {\n const { id } = await params;\n return (\n <section className="max-w-2xl mx-auto px-6 sm:px-8 py-20 text-center">\n <h1 className="font-serif text-3xl mt-0 mb-3">Thanks \u2014 your order is confirmed</h1>\n <p className="text-muted-foreground">\n Order <code className="font-mono text-foreground">{id}</code>\n </p>\n <p className="mt-6">\n <Link\n href="/"\n className="inline-block px-6 py-3 rounded-full bg-primary text-primary-foreground font-semibold text-sm transition-colors hover:bg-primary/90"\n >\n Continue shopping\n </Link>\n </p>\n </section>\n );\n}\n' }, { "path": "app/globals.css", "kind": "text", "content": '@import "tailwindcss";\n\n/* Scan the SDK\'s bundled JS so utility classes used inside its components\n land in the compiled stylesheet alongside our own. */\n@source "../node_modules/@cimplify/sdk/dist";\n\n/* The SDK declares its design tokens with `@theme reference`, meaning the\n consumer must supply the actual values. Warm bakery palette below \u2014 swap\n these to retheme the entire SDK + storefront in one place. */\n@theme {\n --color-background: oklch(0.985 0.007 80);\n --color-foreground: oklch(0.18 0.01 60);\n --color-card: oklch(1 0 0);\n --color-card-foreground: oklch(0.18 0.01 60);\n --color-popover: oklch(1 0 0);\n --color-popover-foreground: oklch(0.18 0.01 60);\n --color-primary: oklch(0.45 0.16 40);\n --color-primary-foreground: oklch(0.99 0 0);\n --color-secondary: oklch(0.95 0.02 80);\n --color-secondary-foreground: oklch(0.25 0.04 50);\n --color-muted: oklch(0.96 0.01 80);\n --color-muted-foreground: oklch(0.5 0.02 60);\n --color-accent: oklch(0.92 0.05 70);\n --color-accent-foreground: oklch(0.3 0.06 40);\n --color-destructive: oklch(0.58 0.24 27);\n --color-destructive-foreground: oklch(0.99 0 0);\n --color-border: oklch(0.9 0.02 80);\n --color-input: oklch(0.92 0.02 80);\n --color-ring: oklch(0.6 0.1 40);\n --radius: 0.75rem;\n}\n\n@layer base {\n html, body {\n margin: 0;\n padding: 0;\n background: var(--color-background);\n color: var(--color-foreground);\n -webkit-font-smoothing: antialiased;\n -moz-osx-font-smoothing: grayscale;\n }\n a { color: inherit; text-decoration: none; }\n h1, h2, h3, h4 {\n font-family: var(--font-serif);\n letter-spacing: -0.01em;\n }\n}\n' }, { "path": "app/about/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `About \u2014 ${brand.name}`,\n description: brand.description,\n};\n\nexport default function AboutPage() {\n const a = brand.about;\n const titleParts = a.title.split("\\n");\n return (\n <article className="max-w-3xl 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 {a.eyebrow}\n </p>\n <h1 className="font-serif text-[clamp(2.25rem,5vw,3.5rem)] font-semibold mb-6 -tracking-[0.02em]">\n {titleParts.map((line, i) => (\n <span key={i}>\n {line}\n {i < titleParts.length - 1 && <br />}\n </span>\n ))}\n </h1>\n <div className="prose prose-lg max-w-none space-y-5 text-foreground/90 leading-relaxed">\n {a.paragraphs.map((p, i) => (\n <p key={i}>{p}</p>\n ))}\n {a.sections.map((s) => (\n <div key={s.heading}>\n <h2 className="font-serif text-3xl font-semibold mt-10 mb-3">{s.heading}</h2>\n <p>{s.body}</p>\n </div>\n ))}\n </div>\n </article>\n );\n}\n' }, { "path": "app/auth/signout/route.ts", "kind": "text", "content": 'import { buildSignoutCookies } from "@cimplify/sdk/server";\n\nconst CLIENT_ID = process.env.CIMPLIFY_CLIENT_ID ?? "";\n\nexport async function POST(): Promise<Response> {\n const headers = new Headers({ "Content-Type": "application/json" });\n for (const cookie of buildSignoutCookies({ clientId: CLIENT_ID })) {\n headers.append("Set-Cookie", cookie);\n }\n return new Response(JSON.stringify({ ok: true }), { status: 200, headers });\n}\n' }, { "path": "app/auth/session/route.ts", "kind": "text", "content": 'import { handleSessionRequest } from "@cimplify/sdk/server";\n\nconst CLIENT_ID = process.env.CIMPLIFY_CLIENT_ID ?? "";\nconst ISSUER = process.env.CIMPLIFY_ISSUER;\n\nexport async function GET(req: Request): Promise<Response> {\n if (!CLIENT_ID) {\n return Response.json({ sub: null }, { status: 200 });\n }\n return handleSessionRequest(req, { clientId: CLIENT_ID, issuer: ISSUER });\n}\n' }, { "path": "app/auth/callback/route.ts", "kind": "text", "content": 'import { handleOidcCallback, handleRedirectCallback } from "@cimplify/sdk/server";\n\nconst CLIENT_ID = process.env.CIMPLIFY_CLIENT_ID ?? "";\nconst ISSUER = process.env.CIMPLIFY_ISSUER;\nconst REDIRECT_URI = process.env.CIMPLIFY_REDIRECT_URI ?? "";\n\n// Redirect sign-in: the browser lands here via a top-level GET carrying ?code.\nexport async function GET(req: Request): Promise<Response> {\n if (!CLIENT_ID || !REDIRECT_URI) {\n return Response.json({ error: "oidc_not_configured" }, { status: 500 });\n }\n return handleRedirectCallback(req, {\n clientId: CLIENT_ID,\n issuer: ISSUER,\n redirectUri: REDIRECT_URI,\n defaultReturnTo: "/account",\n });\n}\n\n// Modal/silent sign-in: posts {code, codeVerifier, state} here for exchange.\nexport async function POST(req: Request): Promise<Response> {\n if (!CLIENT_ID || !REDIRECT_URI) {\n return Response.json({ error: "oidc_not_configured" }, { status: 500 });\n }\n return handleOidcCallback(req, {\n clientId: CLIENT_ID,\n issuer: ISSUER,\n redirectUri: REDIRECT_URI,\n });\n}\n' }, { "path": "app/privacy/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Privacy Policy \u2014 ${brand.name}`,\n description: `How ${brand.name} collects, uses, and protects your personal data.`,\n};\n\nexport default function PrivacyPage() {\n const p = brand.privacy;\n return (\n <article className="max-w-3xl mx-auto px-6 sm:px-8 py-16 prose prose-lg max-w-none">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-2 not-prose">\n {p.eyebrow}\n </p>\n <h1 className="font-serif text-[clamp(2.25rem,5vw,3.5rem)] font-semibold mb-2 -tracking-[0.02em]">\n {p.title}\n </h1>\n <p className="text-sm text-muted-foreground not-prose mb-10">\n Last updated: {p.lastUpdated}\n </p>\n\n <section className="space-y-5 leading-relaxed text-foreground/90">\n {p.sections.map((s) => (\n <div key={s.heading}>\n <h2 className="font-serif text-2xl font-semibold mt-0">{s.heading}</h2>\n {typeof s.body === "string" ? (\n <p>{s.body}</p>\n ) : (\n <>\n <p>{s.body.intro}</p>\n <ul className="list-disc pl-6 space-y-2">\n {s.body.bullets.map((b) => (\n <li key={b}>{b}</li>\n ))}\n </ul>\n </>\n )}\n </div>\n ))}\n </section>\n </article>\n );\n}\n' }, { "path": "app/sitemap-page/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport Link from "next/link";\nimport { getServerClient, tags, type Product } from "@cimplify/sdk/server";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Sitemap \u2014 ${brand.name}`,\n description: "A human-readable index of every page on this site.",\n};\n\nexport const revalidate = 3600;\n\ninterface SitemapData {\n products: { slug: string; name: string }[];\n categories: { slug: string; name: string }[];\n collections: { slug: string; name: string }[];\n}\n\nasync function getSitemap(): Promise<SitemapData> {\n const client = getServerClient();\n const [pRes, cRes, colRes] = await Promise.all([\n client.catalogue.getProducts(\n { limit: 500 },\n { cacheOptions: { revalidate: 3600, tags: [tags.products()] } },\n ),\n client.catalogue.getCategories({\n cacheOptions: { revalidate: 3600, tags: [tags.categories()] },\n }),\n client.catalogue.getCollections({\n cacheOptions: { revalidate: 3600, tags: [tags.collections()] },\n }),\n ]);\n return {\n products: (pRes.ok ? pRes.value.items : []).map((p: Product) => ({\n slug: p.slug ?? p.id,\n name: p.name,\n })),\n categories: (cRes.ok ? cRes.value : []).map((c) => ({ slug: c.slug, name: c.name })),\n collections: (colRes.ok ? colRes.value : []).map((c) => ({ slug: c.slug, name: c.name })),\n };\n}\n\nconst STATIC_LINKS: { title: string; links: { href: string; label: string }[] }[] = [\n {\n title: "Browse",\n links: [\n { href: "/", label: "Home" },\n { href: "/shop", label: "Shop" },\n { href: "/search", label: "Search" },\n ],\n },\n {\n title: "Account",\n links: [\n { href: "/account", label: "Account" },\n { href: "/account/orders", label: "Orders" },\n { href: "/login", label: "Sign in" },\n { href: "/signup", label: "Create account" },\n { href: "/track-order", label: "Track an order" },\n { href: "/cart", label: "Cart" },\n { href: "/checkout", label: "Checkout" },\n ],\n },\n {\n title: "About",\n links: [\n { href: "/about", label: "About" },\n { href: "/faq", label: "FAQ" },\n { href: "/contact", label: "Contact" },\n ],\n },\n {\n title: "Policies",\n links: [\n { href: "/shipping", label: "Shipping" },\n { href: "/returns", label: "Returns" },\n { href: "/accessibility", label: "Accessibility" },\n { href: "/terms", label: "Terms of Service" },\n { href: "/privacy", label: "Privacy Policy" },\n ],\n },\n {\n title: "Machine-readable",\n links: [\n { href: "/sitemap.xml", label: "sitemap.xml (search engines)" },\n { href: "/llms.txt", label: "llms.txt (LLM agents)" },\n { href: "/robots.txt", label: "robots.txt" },\n { href: "/opensearch.xml", label: "opensearch.xml (browser search)" },\n ],\n },\n];\n\nexport default async function SitemapHtmlPage() {\n const { products, categories, collections } = await getSitemap();\n\n return (\n <article className="max-w-5xl mx-auto px-6 sm:px-8 py-16">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-2">\n Sitemap\n </p>\n <h1 className="text-[clamp(2rem,4vw,2.75rem)] font-semibold mb-2 -tracking-[0.02em]">\n Every page, in one place.\n </h1>\n <p className="text-muted-foreground mb-12">\n For search engines, see <Link href="/sitemap.xml" className="text-primary hover:underline">/sitemap.xml</Link>.\n For LLM agents, see <Link href="/llms.txt" className="text-primary hover:underline">/llms.txt</Link>.\n </p>\n <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-10">\n {STATIC_LINKS.map((s) => (\n <Section key={s.title} title={s.title}>\n {s.links.map((l) => (\n <li key={l.href}>\n <Link href={l.href} className="hover:text-primary transition-colors">\n {l.label}\n </Link>\n </li>\n ))}\n </Section>\n ))}\n {categories.length > 0 && (\n <Section title="Categories">\n {categories.map((c) => (\n <li key={c.slug}>\n <Link href={`/categories/${c.slug}`} className="hover:text-primary transition-colors">\n {c.name}\n </Link>\n </li>\n ))}\n </Section>\n )}\n {collections.length > 0 && (\n <Section title="Collections">\n {collections.map((c) => (\n <li key={c.slug}>\n <Link href={`/collections/${c.slug}`} className="hover:text-primary transition-colors">\n {c.name}\n </Link>\n </li>\n ))}\n </Section>\n )}\n {products.length > 0 && (\n <Section title={`Products (${products.length})`}>\n {products.map((p) => (\n <li key={p.slug}>\n <Link href={`/shop?product=${encodeURIComponent(p.slug)}`} className="hover:text-primary transition-colors">\n {p.name}\n </Link>\n </li>\n ))}\n </Section>\n )}\n </div>\n </article>\n );\n}\n\nfunction Section({ title, children }: { title: string; children: React.ReactNode }) {\n return (\n <div>\n <p className="font-semibold text-[12px] uppercase tracking-[0.12em] text-foreground mb-3">\n {title}\n </p>\n <ul className="space-y-2 m-0 p-0 list-none text-sm text-muted-foreground">\n {children}\n </ul>\n </div>\n );\n}\n' }, { "path": "app/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": "app/collections/[slug]/listing-client.tsx", "kind": "text", "content": '"use client";\n\nimport { ProductGrid } from "@cimplify/sdk/react";\nimport type { Product } from "@cimplify/sdk";\nimport { StoreProductCard } from "@/components/store-product-card";\n\n/**\n * Client island for the collection listing. Receives server-fetched\n * products as props (serializable) and owns the `renderCard` function\n * (which can\'t cross the server/client boundary).\n */\nexport function ListingClient({ products }: { products: Product[] }) {\n return (\n <ProductGrid\n products={products}\n emptyMessage="No products in this collection yet."\n renderCard={(p) => <StoreProductCard product={p} />}\n />\n );\n}\n' }, { "path": "app/collections/[slug]/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Suspense } from "react";\nimport Link from "next/link";\nimport { notFound } from "next/navigation";\nimport {\n getServerClient,\n tags,\n type Collection,\n type Product,\n} from "@cimplify/sdk/server";\nimport { ListingClient } from "./listing-client";\nimport { brand } from "@/lib/brand";\n\n// See app/products/[slug]/page.tsx for the rationale on generateStaticParams.\nexport async function generateStaticParams() {\n const r = await getServerClient().catalogue.getCollections();\n if (!r.ok || r.value.length === 0) {\n return [{ slug: "__placeholder__" }];\n }\n return r.value.map((c) => ({ slug: c.slug ?? c.id }));\n}\n\nexport const revalidate = 3600;\n\ninterface CollectionData {\n collection: Collection;\n products: Product[];\n}\n\ntype CollectionResult =\n | { ok: true; data: CollectionData }\n | { ok: false; code: string };\n\nasync function getCollection(slug: string): Promise<CollectionResult> {\n const client = getServerClient();\n const colRes = await client.catalogue.getCollectionBySlug(slug, {\n cacheOptions: { revalidate: 3600, tags: [tags.collections()] },\n });\n if (!colRes.ok) return { ok: false, code: colRes.error.code };\n\n const r = await client.catalogue.getCollectionProducts(colRes.value.id, undefined, {\n cacheOptions: {\n revalidate: 3600,\n tags: [\n tags.products(),\n tags.collection(colRes.value.id),\n tags.collectionProducts(colRes.value.id),\n ],\n },\n });\n const products = r.ok\n ? ((r.value as { items?: Product[] }).items ?? (r.value as Product[]))\n : [];\n return { ok: true, data: { collection: colRes.value, products } };\n}\n\nexport async function generateMetadata({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}): Promise<Metadata> {\n const { slug } = await params;\n const result = await getCollection(slug);\n if (!result.ok) return {};\n const data = result.data;\n return {\n title: `${data.collection.name} \u2014 ${brand.name}`,\n description: data.collection.description ?? undefined,\n };\n}\n\nexport default async function CollectionPage({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}) {\n return (\n <Suspense fallback={<CollectionSkeleton />}>\n <CollectionContent params={params} />\n </Suspense>\n );\n}\n\nasync function CollectionContent({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}) {\n const { slug } = await params;\n const result = await getCollection(slug);\n if (!result.ok) {\n if (result.code === "NOT_FOUND") notFound();\n return <CollectionSkeleton />;\n }\n const { collection, products } = result.data;\n return (\n <section className="max-w-7xl mx-auto px-6 sm:px-8 pt-12">\n <header className="mb-8 text-center">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-2">\n Collection\n </p>\n <h1 className="font-serif text-[clamp(2rem,4vw,2.75rem)] font-semibold mb-2">\n {collection.name}\n </h1>\n {collection.description && (\n <p className="mx-auto mb-2 max-w-xl text-muted-foreground">\n {collection.description}\n </p>\n )}\n <p className="text-sm text-muted-foreground">\n {products.length} item{products.length === 1 ? "" : "s"}\n </p>\n </header>\n <ListingClient products={products} />\n {products.length === 0 && (\n <p className="text-center mt-8">\n <Link href="/" className="text-primary font-semibold">\n \u2190 Back home\n </Link>\n </p>\n )}\n </section>\n );\n}\n\nfunction CollectionSkeleton() {\n return (\n <section className="max-w-7xl mx-auto px-6 sm:px-8 pt-12">\n <header className="mb-8 text-center">\n <div className="mx-auto h-3 w-20 bg-muted rounded mb-2 animate-pulse" />\n <div className="mx-auto h-10 w-64 bg-muted rounded mb-2 animate-pulse" />\n <div className="mx-auto h-4 w-80 bg-muted rounded animate-pulse" />\n </header>\n <div className="grid grid-cols-2 md:grid-cols-3 gap-4">\n {Array.from({ length: 6 }).map((_, i) => (\n <div key={i} className="aspect-square bg-muted rounded-2xl animate-pulse" />\n ))}\n </div>\n </section>\n );\n}\n' }, { "path": "app/layout.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Inter, Playfair_Display } from "next/font/google";\nimport "./globals.css";\nimport { Providers } from "@/components/providers";\nimport { getServerClient, tags } from "@cimplify/sdk/server";\nimport { Header } from "@/components/header";\nimport { Footer } from "@/components/footer";\nimport { ProductModal } from "@/components/product-modal";\nimport { CartDrawer } from "@/components/cart-drawer";\nimport { SupportChat } from "@/components/support-chat";\nimport { OrganizationJsonLd } from "@/components/json-ld";\nimport { Suspense } from "react";\nimport { brand } from "@/lib/brand";\nimport { getSiteUrl } from "@/lib/site-url";\n\nconst inter = Inter({\n subsets: ["latin"],\n variable: "--font-sans",\n display: "swap",\n});\n\nconst playfair = Playfair_Display({\n subsets: ["latin"],\n variable: "--font-serif",\n display: "swap",\n});\n\nexport async function generateMetadata(): Promise<Metadata> {\n const siteUrl = await getSiteUrl();\n return {\n metadataBase: new URL(siteUrl),\n title: {\n default: brand.name,\n template: `%s \u2014 ${brand.name}`,\n },\n description: brand.description,\n openGraph: {\n type: "website",\n siteName: brand.name,\n locale: brand.locale,\n },\n twitter: { card: "summary_large_image" },\n };\n}\n\nexport default async function RootLayout({ children }: { children: React.ReactNode }) {\n const server = getServerClient();\n const [bizResult, locResult] = await Promise.all([\n server.business.getInfo({ cacheOptions: { revalidate: 3600, tags: [tags.business()] } }),\n server.business.getLocations({ cacheOptions: { revalidate: 3600, tags: [tags.locations()] } }),\n ]);\n const initialBusiness = bizResult.ok ? bizResult.value : null;\n const initialLocations = locResult.ok ? locResult.value : [];\n\n return (\n <html lang="en" suppressHydrationWarning className={`${inter.variable} ${playfair.variable}`}>\n <body\n suppressHydrationWarning\n className="min-h-screen flex flex-col bg-background text-foreground font-sans"\n >\n <Suspense fallback={null}>\n <OrganizationJsonLd />\n </Suspense>\n <Providers initialBusiness={initialBusiness} initialLocations={initialLocations}>\n <Header />\n <main className="flex-1 pb-12 w-full">\n <Suspense fallback={null}>{children}</Suspense>\n </main>\n <Footer />\n <Suspense fallback={null}>\n <ProductModal />\n </Suspense>\n <CartDrawer />\n <SupportChat />\n </Providers>\n </body>\n </html>\n );\n}\n' }, { "path": "app/error.tsx", "kind": "text", "content": '"use client";\n\nimport { useEffect } from "react";\nimport Link from "next/link";\n\n/**\n * Root error boundary. Next.js calls this whenever a thrown error escapes\n * a Server Component without being caught by a closer `error.tsx`.\n *\n * Wire `reportError` (Sentry, Datadog, etc.) here so production failures\n * surface \u2014 silently recovering hides real bugs.\n */\nexport default function GlobalError({\n error,\n reset,\n}: {\n error: Error & { digest?: string };\n reset: () => void;\n}) {\n useEffect(() => {\n // TODO: replace with your error reporter\n // eslint-disable-next-line no-console\n console.error("Storefront error:", error);\n }, [error]);\n\n return (\n <section className="max-w-2xl mx-auto px-6 sm:px-8 py-20 text-center">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-3">\n Something broke\n </p>\n <h1 className="font-serif text-[clamp(2rem,4vw,2.75rem)] font-semibold mb-4">\n That wasn&apos;t supposed to happen.\n </h1>\n <p className="text-muted-foreground leading-relaxed mb-8">\n We&apos;ve been notified. Refresh the page, or head back home \u2014\n most of the time the second try just works.\n </p>\n {error.digest && (\n <p className="text-xs font-mono text-muted-foreground mb-6">\n Reference: <code className="text-foreground">{error.digest}</code>\n </p>\n )}\n <div className="flex flex-wrap items-center justify-center gap-3">\n <button\n type="button"\n onClick={reset}\n className="inline-block px-6 py-3 rounded-full bg-primary text-primary-foreground font-semibold text-sm transition-colors hover:bg-primary/90"\n >\n Try again\n </button>\n <Link\n href="/"\n className="inline-block px-6 py-3 rounded-full border border-border hover:bg-muted transition-colors text-sm font-medium"\n >\n Back home\n </Link>\n </div>\n </section>\n );\n}\n' }, { "path": "app/faq/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `FAQ \u2014 ${brand.name}`,\n description: "Delivery, pickup, custom cakes, allergens, payment \u2014 answers to the questions we hear most often.",\n};\n\nexport default function FaqPage() {\n const f = brand.faq;\n return (\n <article className="max-w-3xl 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 {f.eyebrow}\n </p>\n <h1 className="font-serif text-[clamp(2.25rem,5vw,3.5rem)] font-semibold mb-10 -tracking-[0.02em]">\n {f.title}\n </h1>\n <div className="space-y-12">\n {f.sections.map((section) => (\n <section key={section.title}>\n <h2 className="font-serif text-2xl font-semibold mb-5">{section.title}</h2>\n <dl className="space-y-6">\n {section.items.map((item) => (\n <div key={item.q}>\n <dt className="font-medium text-foreground mb-1.5">{item.q}</dt>\n <dd className="text-muted-foreground leading-relaxed">{item.a}</dd>\n </div>\n ))}\n </dl>\n </section>\n ))}\n </div>\n <p className="mt-12 pt-8 border-t border-border text-sm text-muted-foreground">\n {f.contactPrompt}{" "}\n <a\n href={`mailto:${f.contactEmail}`}\n className="text-primary font-semibold hover:underline"\n >\n {f.contactEmail}\n </a>{" "}\n and a real human will reply within 24 hours.\n </p>\n </article>\n );\n}\n' }, { "path": "app/llms.txt/route.ts", "kind": "text", "content": 'import { getServerClient, tags, type Product } from "@cimplify/sdk/server";\nimport { brand } from "@/lib/brand";\nimport { getSiteUrl } from "@/lib/site-url";\n\nexport const revalidate = 3600;\n\nasync function buildLlmsTxt(SITE_URL: string): Promise<string> {\n const client = getServerClient();\n const [productsRes, categoriesRes, collectionsRes] = await Promise.all([\n client.catalogue.getProducts(\n { limit: 500 },\n { cacheOptions: { revalidate: 3600, tags: [tags.products()] } },\n ),\n client.catalogue.getCategories({\n cacheOptions: { revalidate: 3600, tags: [tags.categories()] },\n }),\n client.catalogue.getCollections({\n cacheOptions: { revalidate: 3600, tags: [tags.collections()] },\n }),\n ]);\n\n const products: Product[] = productsRes.ok ? productsRes.value.items : [];\n const categories = categoriesRes.ok ? categoriesRes.value : [];\n const collections = collectionsRes.ok ? collectionsRes.value : [];\n\n const lines: string[] = [];\n lines.push(`# ${brand.name}`);\n lines.push("");\n lines.push(`> ${brand.llms.summary}`);\n lines.push("");\n lines.push("## Browse");\n lines.push(`- [Home](${SITE_URL}/)`);\n lines.push(`- [Shop](${SITE_URL}/shop): Full menu with filter and sort.`);\n\n if (categories.length > 0) {\n lines.push("");\n lines.push("## Categories");\n for (const c of categories) {\n lines.push(\n `- [${c.name}](${SITE_URL}/categories/${c.slug})${c.description ? `: ${c.description}` : ""}`,\n );\n }\n }\n\n if (collections.length > 0) {\n lines.push("");\n lines.push("## Collections");\n for (const c of collections) {\n lines.push(\n `- [${c.name}](${SITE_URL}/collections/${c.slug})${c.description ? `: ${c.description}` : ""}`,\n );\n }\n }\n\n if (products.length > 0) {\n lines.push("");\n lines.push("## Products");\n for (const p of products) {\n const slug = p.slug ?? p.id;\n const price = `${brand.currency} ${p.default_price}`;\n const desc = p.description ? ` \u2014 ${p.description.replace(/\\s+/g, " ").slice(0, 200)}` : "";\n lines.push(`- [${p.name}](${SITE_URL}/shop?product=${slug}) (${price})${desc}`);\n }\n }\n\n lines.push("");\n lines.push("## Information");\n lines.push(`- [About](${SITE_URL}/about)`);\n lines.push(`- [FAQ](${SITE_URL}/faq)`);\n lines.push(`- [Terms of Service](${SITE_URL}/terms)`);\n lines.push(`- [Privacy Policy](${SITE_URL}/privacy)`);\n lines.push(`- [Sitemap (XML)](${SITE_URL}/sitemap.xml)`);\n lines.push("");\n lines.push("## Contact");\n lines.push(`- Email: ${brand.contact.email}`);\n lines.push(`- Phone: ${brand.contact.phone}`);\n lines.push(`- Address: ${brand.contact.address}`);\n\n return lines.join("\\n") + "\\n";\n}\n\n/**\n * `/llms.txt` \u2014 machine-readable site index for LLMs (per llmstxt.org).\n * Lets coding agents and chat assistants find products, categories, and\n * support pages without scraping HTML. Plain Markdown so it streams cheaply\n * into context windows.\n */\nexport async function GET(): Promise<Response> {\n const body = await buildLlmsTxt(await getSiteUrl());\n return new Response(body, {\n headers: {\n "Content-Type": "text/plain; charset=utf-8",\n "Cache-Control": "public, max-age=0, s-maxage=3600, stale-while-revalidate=86400",\n },\n });\n}\n' }, { "path": "app/contact/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { ContactForm } from "./contact-form";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Contact \u2014 ${brand.name}`,\n description: brand.contactPage.body,\n};\n\nexport default function ContactPage() {\n const c = brand.contactPage;\n return (\n <article className="max-w-5xl mx-auto px-6 sm:px-8 py-16">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-2">\n {c.eyebrow}\n </p>\n <h1 className="font-serif text-[clamp(2.25rem,5vw,3.5rem)] font-semibold mb-4 -tracking-[0.02em]">\n {c.title}\n </h1>\n <p className="text-lg text-muted-foreground max-w-2xl mb-12 leading-relaxed">{c.body}</p>\n\n <div className="grid grid-cols-1 lg:grid-cols-[1.5fr_1fr] gap-10 items-start">\n <ContactForm reasons={c.reasons} />\n <aside className="space-y-5 lg:pl-10 lg:border-l border-border">\n <div>\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground mb-3">\n Direct lines\n </p>\n <ul className="space-y-3 list-none p-0 m-0">\n {c.directLines.map((line) => (\n <li key={line.label}>\n <p className="text-xs text-muted-foreground m-0">{line.label}</p>\n <a\n href={line.href}\n className="text-foreground hover:text-primary transition-colors"\n >\n {line.value}\n </a>\n </li>\n ))}\n </ul>\n </div>\n <div>\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground mb-3">\n Visit\n </p>\n <p className="text-foreground m-0">{brand.contact.address}</p>\n <p className="text-xs text-muted-foreground mt-1">{brand.contact.hours}</p>\n </div>\n </aside>\n </div>\n </article>\n );\n}\n' }, { "path": "app/contact/contact-form.tsx", "kind": "text", "content": `"use client";
579
+ ` }, { "path": "lib/cimplify-server.ts", "kind": "text", "content": 'import { cookies } from "next/headers";\nimport {\n getAccessTokenFromCookieHeader,\n getSessionFromCookieHeader,\n} from "@cimplify/sdk/server";\nimport type { NavItem } from "@cimplify/sdk/react";\n\nexport const ACCOUNT_NAV: NavItem[] = [\n { label: "Dashboard", href: "/account" },\n { label: "Orders", href: "/account/orders" },\n { label: "Cimplify identity", href: "https://link.cimplify.io", external: true },\n];\n\nconst CLIENT_ID = process.env.CIMPLIFY_CLIENT_ID ?? "";\nconst ISSUER = (process.env.CIMPLIFY_ISSUER ?? "https://api.cimplify.io").replace(/\\/$/, "");\n\nconst OIDC_CONFIG = { clientId: CLIENT_ID, issuer: ISSUER };\n\nasync function cookieHeader(): Promise<string> {\n const store = await cookies();\n return store.getAll().map((c) => `${c.name}=${c.value}`).join("; ");\n}\n\nexport async function serverSession() {\n return getSessionFromCookieHeader(OIDC_CONFIG, await cookieHeader());\n}\n\nexport async function serverAccessToken(): Promise<string | null> {\n return getAccessTokenFromCookieHeader(OIDC_CONFIG, await cookieHeader());\n}\n\ninterface Envelope<T> {\n data: T;\n}\n\nexport async function apiFetch<T>(\n path: string,\n init: RequestInit & { bearer: string | null },\n): Promise<T | null> {\n if (!init.bearer) return null;\n const { bearer, ...rest } = init;\n const headers = new Headers(rest.headers);\n headers.set("Authorization", `Bearer ${bearer}`);\n headers.set("Accept", "application/json");\n if (rest.body && !headers.has("Content-Type")) {\n headers.set("Content-Type", "application/json");\n }\n const res = await fetch(`${ISSUER}${path}`, { ...rest, headers, cache: "no-store" });\n if (!res.ok) return null;\n const envelope = (await res.json()) as Envelope<T>;\n return envelope.data;\n}\n' }, { "path": "lib/cimplify-loader.ts", "kind": "text", "content": 'import type { ImageLoader } from "next/image";\n\nimport { assetUrl, isCimplifyAsset } from "@cimplify/sdk";\n\nconst cdnBase = process.env.NEXT_PUBLIC_CIMPLIFY_CDN_URL?.trim() || undefined;\n\nconst cimplifyImageLoader: ImageLoader = ({ src, width, quality }) => {\n if (isCimplifyAsset(src, cdnBase)) {\n return assetUrl(src, {\n base: cdnBase,\n w: width,\n quality,\n format: "auto",\n });\n }\n return src;\n};\n\nexport default cimplifyImageLoader;\n' }, { "path": "lib/site-url.ts", "kind": "text", "content": 'import { SITE_URL } from "./site.config";\n\n/** Canonical absolute URL for this storefront. */\nexport async function getSiteUrl(): Promise<string> {\n return SITE_URL;\n}\n' }, { "path": ".env.example", "kind": "text", "content": "# Your tenant public key. Get it from the desk's Developers tab.\n# `cpk_live_*` / `cpk_test_*` keys auto-route same-origin requests\n# to hosted Cimplify in both dev and prod. Anything else (`mock-dev`,\n# empty) falls back to the local `cimplify dev` mock.\nNEXT_PUBLIC_CIMPLIFY_PUBLIC_KEY=mock-dev\n\n# Optional. Forces a fixed canonical URL for sitemap.xml, robots.txt,\n# OpenGraph, and llms.txt. Leave unset and the storefront derives the\n# canonical from the request `Host` header automatically.\n# NEXT_PUBLIC_SITE_URL=\n" }, { "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__/brand.test.ts", "kind": "text", "content": 'import { createBrandSuite } from "@cimplify/sdk/testing/suite";\nimport { brand } from "../lib/brand";\n\ncreateBrandSuite({ brand });\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.101.1",\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": "metadata.json", "kind": "text", "content": '{\n "id": "bakery",\n "name": "Bakery",\n "tagline": "Warm, food-forward storefront with custom-order forms and same-day delivery checkout.",\n "industry": "food",\n "tags": ["food", "bakery", "pastry"],\n "stability": "stable",\n "schemaType": "Bakery",\n "mock": {\n "seedName": "default",\n "seedBusinessId": "bus_default_akua_bakery"\n }\n}\n' }, { "path": "app/search/search-client.tsx", "kind": "text", "content": '"use client";\n\nimport { SearchPage } from "@cimplify/sdk/react";\n\nexport function SearchClient() {\n return <SearchPage />;\n}\n' }, { "path": "app/search/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Suspense } from "react";\nimport { SearchClient } from "./search-client";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Search \u2014 ${brand.name}`,\n description: `Search ${brand.name} \u2014 products, collections, categories.`,\n};\n\nexport default function SearchPage() {\n return (\n <article className="max-w-7xl mx-auto px-6 sm:px-8 py-10">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-2">\n Search\n </p>\n <h1 className="font-serif text-[clamp(2rem,4vw,2.75rem)] font-semibold mb-8 -tracking-[0.02em]">\n Find anything.\n </h1>\n <Suspense fallback={<SearchSkeleton />}>\n <SearchClient />\n </Suspense>\n </article>\n );\n}\n\nfunction SearchSkeleton() {\n return (\n <div>\n <div className="h-12 w-full max-w-xl bg-muted rounded animate-pulse mb-8" />\n <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3 sm:gap-4">\n {Array.from({ length: 8 }).map((_, i) => (\n <div key={i} className="aspect-[4/3] bg-muted rounded-2xl animate-pulse" />\n ))}\n </div>\n </div>\n );\n}\n' }, { "path": "app/categories/[slug]/listing-client.tsx", "kind": "text", "content": '"use client";\n\nimport { ProductGrid } from "@cimplify/sdk/react";\nimport type { Product } from "@cimplify/sdk";\nimport { StoreProductCard } from "@/components/store-product-card";\n\n/**\n * Client island for the category listing. Receives server-fetched products\n * as props (serializable) and owns the `renderCard` function.\n */\nexport function ListingClient({ products }: { products: Product[] }) {\n return (\n <ProductGrid\n products={products}\n emptyMessage="No products in this category yet."\n renderCard={(p) => <StoreProductCard product={p} />}\n />\n );\n}\n' }, { "path": "app/categories/[slug]/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Suspense } from "react";\nimport Link from "next/link";\nimport { notFound } from "next/navigation";\nimport {\n getServerClient,\n tags,\n type Category,\n type Product,\n} from "@cimplify/sdk/server";\nimport { ListingClient } from "./listing-client";\nimport { brand } from "@/lib/brand";\n\n// See app/products/[slug]/page.tsx for the rationale on generateStaticParams.\nexport async function generateStaticParams() {\n const r = await getServerClient().catalogue.getCategories();\n if (!r.ok || r.value.length === 0) {\n return [{ slug: "__placeholder__" }];\n }\n return r.value.map((c) => ({ slug: c.slug ?? c.id }));\n}\n\nexport const revalidate = 3600;\n\ninterface CategoryData {\n category: Category;\n products: Product[];\n}\n\ntype CategoryResult =\n | { ok: true; data: CategoryData }\n | { ok: false; code: string };\n\nasync function getCategory(slug: string): Promise<CategoryResult> {\n const client = getServerClient();\n const catRes = await client.catalogue.getCategoryBySlug(slug, {\n cacheOptions: { revalidate: 3600, tags: [tags.categories()] },\n });\n if (!catRes.ok) return { ok: false, code: catRes.error.code };\n\n const r = await client.catalogue.getCategoryProducts(catRes.value.id, undefined, {\n cacheOptions: {\n revalidate: 3600,\n tags: [\n tags.products(),\n tags.category(catRes.value.id),\n tags.categoryProducts(catRes.value.id),\n ],\n },\n });\n const products = r.ok\n ? ((r.value as { items?: Product[] }).items ?? (r.value as Product[]))\n : [];\n return { ok: true, data: { category: catRes.value, products } };\n}\n\nexport async function generateMetadata({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}): Promise<Metadata> {\n const { slug } = await params;\n const result = await getCategory(slug);\n if (!result.ok) return {};\n const data = result.data;\n return {\n title: `${data.category.name} \u2014 ${brand.name}`,\n description: data.category.description ?? undefined,\n };\n}\n\nexport default async function CategoryPage({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}) {\n return (\n <Suspense fallback={<CategorySkeleton />}>\n <CategoryContent params={params} />\n </Suspense>\n );\n}\n\nasync function CategoryContent({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}) {\n const { slug } = await params;\n const result = await getCategory(slug);\n if (!result.ok) {\n if (result.code === "NOT_FOUND") notFound();\n return <CategorySkeleton />;\n }\n const { category, products } = result.data;\n return (\n <section className="max-w-7xl mx-auto px-6 sm:px-8 pt-12">\n <header className="mb-8 text-center">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-2">\n Category\n </p>\n <h1 className="font-serif text-[clamp(2rem,4vw,2.75rem)] font-semibold mb-2">\n {category.name}\n </h1>\n {category.description && (\n <p className="mx-auto mb-2 max-w-xl text-muted-foreground">\n {category.description}\n </p>\n )}\n <p className="text-sm text-muted-foreground">\n {products.length} item{products.length === 1 ? "" : "s"}\n </p>\n </header>\n <ListingClient products={products} />\n {products.length === 0 && (\n <p className="text-center mt-8">\n <Link href="/" className="text-primary font-semibold">\n \u2190 Back home\n </Link>\n </p>\n )}\n </section>\n );\n}\n\nfunction CategorySkeleton() {\n return (\n <section className="max-w-7xl mx-auto px-6 sm:px-8 pt-12">\n <header className="mb-8 text-center">\n <div className="mx-auto h-3 w-20 bg-muted rounded mb-2 animate-pulse" />\n <div className="mx-auto h-10 w-64 bg-muted rounded mb-2 animate-pulse" />\n <div className="mx-auto h-4 w-80 bg-muted rounded animate-pulse" />\n </header>\n <div className="grid grid-cols-2 md:grid-cols-3 gap-4">\n {Array.from({ length: 6 }).map((_, i) => (\n <div key={i} className="aspect-square bg-muted rounded-2xl animate-pulse" />\n ))}\n </div>\n </section>\n );\n}\n' }, { "path": "app/terms/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Terms of Service \u2014 ${brand.name}`,\n description: `The rules of the road for ordering from ${brand.name}.`,\n};\n\nexport default function TermsPage() {\n const t = brand.terms;\n return (\n <article className="max-w-3xl mx-auto px-6 sm:px-8 py-16 prose prose-lg max-w-none">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-2 not-prose">\n {t.eyebrow}\n </p>\n <h1 className="font-serif text-[clamp(2.25rem,5vw,3.5rem)] font-semibold mb-2 -tracking-[0.02em]">\n {t.title}\n </h1>\n <p className="text-sm text-muted-foreground not-prose mb-10">\n Last updated: {t.lastUpdated}\n </p>\n\n <section className="space-y-5 leading-relaxed text-foreground/90">\n {t.sections.map((s) => (\n <div key={s.heading}>\n <h2 className="font-serif text-2xl font-semibold mt-0">{s.heading}</h2>\n {typeof s.body === "string" ? (\n <p>{s.body}</p>\n ) : (\n <>\n <p>{s.body.intro}</p>\n <ul className="list-disc pl-6 space-y-2">\n {s.body.bullets.map((b) => (\n <li key={b}>{b}</li>\n ))}\n </ul>\n </>\n )}\n </div>\n ))}\n </section>\n </article>\n );\n}\n' }, { "path": "app/not-found.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport Link from "next/link";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Page not found \u2014 ${brand.name}`,\n description: "We couldn\'t find that page.",\n};\n\nexport default function NotFound() {\n return (\n <section className="max-w-2xl mx-auto px-6 sm:px-8 py-20 text-center">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-3">\n 404\n </p>\n <h1 className="font-serif text-[clamp(2.5rem,5vw,3.5rem)] font-semibold mb-4">\n Page not found.\n </h1>\n <p className="text-muted-foreground leading-relaxed mb-8">\n The URL you followed might be old, a typo, or pointing at a product\n that&apos;s sold out for the day. Try the menu or head back home.\n </p>\n <div className="flex flex-wrap items-center justify-center gap-3">\n <Link\n href="/shop"\n className="inline-block px-6 py-3 rounded-full bg-primary text-primary-foreground font-semibold text-sm transition-colors hover:bg-primary/90"\n >\n Browse the menu\n </Link>\n <Link\n href="/"\n className="inline-block px-6 py-3 rounded-full border border-border hover:bg-muted transition-colors text-sm font-medium"\n >\n Back home\n </Link>\n </div>\n </section>\n );\n}\n' }, { "path": "app/cart/page.tsx", "kind": "text", "content": '"use client";\n\nimport { useRouter } from "next/navigation";\nimport { CartPage as SdkCartPage } from "@cimplify/sdk/react";\n\nexport default function CartPage() {\n const router = useRouter();\n return <SdkCartPage onCheckout={() => router.push("/checkout")} />;\n}\n' }, { "path": "app/accessibility/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\nimport { PolicyPage } from "@/components/policy-page";\n\nexport const metadata: Metadata = {\n title: `${brand.accessibility.title} \u2014 ${brand.name}`,\n};\n\nexport default function AccessibilityPage() {\n return <PolicyPage policy={brand.accessibility} />;\n}\n' }, { "path": "app/account/orders/[id]/page.tsx", "kind": "text", "content": 'import { notFound } from "next/navigation";\nimport { AccountSignedOutPrompt, toAccountOrderView } from "@cimplify/sdk/react";\nimport type { EnrichedOrder } from "@cimplify/sdk";\nimport { brand } from "@/lib/brand";\nimport { apiFetch, serverAccessToken, serverSession } from "@/lib/cimplify-server";\nimport { OrderDetail } from "@/components/order-detail";\n\nconst CLIENT_ID = process.env.NEXT_PUBLIC_CIMPLIFY_CLIENT_ID ?? "";\nconst REDIRECT_URI = process.env.CIMPLIFY_REDIRECT_URI ?? "";\n\nexport default async function OrderDetailPage({\n params,\n}: {\n params: Promise<{ id: string }>;\n}) {\n const { id } = await params;\n\n const session = await serverSession();\n if (!session) {\n return (\n <AccountSignedOutPrompt\n clientId={CLIENT_ID}\n redirectUri={REDIRECT_URI}\n eyebrow={brand.account.loginEyebrow}\n title={brand.account.loginTitle}\n description={brand.account.loginSubtitle}\n />\n );\n }\n\n const bearer = await serverAccessToken();\n const order = await apiFetch<EnrichedOrder>(`/api/v1/orders/${encodeURIComponent(id)}`, {\n bearer,\n });\n if (!order) notFound();\n\n return (\n <div className="mx-auto w-full max-w-5xl px-4 py-8 sm:px-6">\n <OrderDetail {...toAccountOrderView(order)} />\n </div>\n );\n}\n' }, { "path": "app/account/orders/page.tsx", "kind": "text", "content": 'import {\n AccountOrdersPage,\n AccountSignedOutPrompt,\n} from "@cimplify/sdk/react";\nimport type { OrderRowData, OrderRowStatus } from "@cimplify/sdk/react";\nimport type { Order, OrderStatus } from "@cimplify/sdk";\nimport { formatMoney } from "@cimplify/sdk";\nimport { brand } from "@/lib/brand";\nimport { ACCOUNT_NAV, apiFetch, serverAccessToken, serverSession } from "@/lib/cimplify-server";\n\nconst CLIENT_ID = process.env.NEXT_PUBLIC_CIMPLIFY_CLIENT_ID ?? "";\nconst REDIRECT_URI = process.env.CIMPLIFY_REDIRECT_URI ?? "";\n\nfunction mapStatus(status: OrderStatus): OrderRowStatus {\n switch (status) {\n case "in_preparation":\n case "ready_to_serve":\n case "partially_served":\n return { kind: "preparing", label: "Preparing" };\n case "delivered":\n return { kind: "delivered" };\n case "picked_up":\n return { kind: "delivered", label: "Picked up" };\n case "served":\n case "completed":\n return { kind: "completed" };\n case "cancelled":\n case "refunded":\n return { kind: "cancelled", label: status === "refunded" ? "Refunded" : undefined };\n case "pending":\n case "created":\n case "confirmed":\n default:\n return { kind: "active", label: "Confirmed" };\n }\n}\n\nfunction relativeMeta(iso: string, orderNumber: string): string {\n const when = new Date(iso);\n const formatted = when.toLocaleDateString(undefined, {\n month: "short",\n day: "numeric",\n year: when.getFullYear() === new Date().getFullYear() ? undefined : "numeric",\n });\n return `Order ${orderNumber} \xB7 ${formatted}`;\n}\n\nfunction toRowData(order: Order): OrderRowData {\n const count = order.items?.length ?? 0;\n return {\n id: order.id,\n href: `/account/orders/${order.id}`,\n summary: count > 0 ? `${count} ${count === 1 ? "item" : "items"}` : "Order",\n meta: relativeMeta(order.created_at, order.user_friendly_id ?? order.id),\n status: mapStatus(order.status),\n total: formatMoney(order.total_price, order.currency),\n };\n}\n\nexport default async function OrdersPage() {\n const session = await serverSession();\n if (!session) {\n return (\n <AccountSignedOutPrompt\n clientId={CLIENT_ID}\n redirectUri={REDIRECT_URI}\n eyebrow={brand.account.loginEyebrow}\n title={brand.account.loginTitle}\n description={brand.account.loginSubtitle}\n />\n );\n }\n\n const bearer = await serverAccessToken();\n const orders = await apiFetch<Order[]>(\n `/v1/link/orders?business_id=${encodeURIComponent(brand.mock.businessId)}`,\n { bearer },\n );\n\n return (\n <AccountOrdersPage\n customerName={session.name ?? ""}\n customerEmail={session.email}\n customerId={session.sub}\n merchantName={brand.name}\n nav={ACCOUNT_NAV}\n orders={(orders ?? []).map(toRowData)}\n />\n );\n}\n' }, { "path": "app/account/page.tsx", "kind": "text", "content": 'import {\n AccountDashboardPage,\n AccountSignedOutPrompt,\n} from "@cimplify/sdk/react";\nimport type { CustomerAddress, CustomerMobileMoney } from "@cimplify/sdk";\nimport { brand } from "@/lib/brand";\nimport { ACCOUNT_NAV, apiFetch, serverAccessToken, serverSession } from "@/lib/cimplify-server";\n\nconst CLIENT_ID = process.env.NEXT_PUBLIC_CIMPLIFY_CLIENT_ID ?? "";\nconst REDIRECT_URI = process.env.CIMPLIFY_REDIRECT_URI ?? "";\n\nexport default async function AccountPage() {\n const session = await serverSession();\n if (!session) {\n return (\n <AccountSignedOutPrompt\n clientId={CLIENT_ID}\n redirectUri={REDIRECT_URI}\n eyebrow={brand.account.loginEyebrow}\n title={brand.account.loginTitle}\n description={brand.account.loginSubtitle}\n />\n );\n }\n\n const bearer = await serverAccessToken();\n const [addresses, wallets] = await Promise.all([\n apiFetch<CustomerAddress[]>("/v1/link/addresses", { bearer }),\n apiFetch<CustomerMobileMoney[]>("/v1/link/mobile-money", { bearer }),\n ]);\n\n return (\n <AccountDashboardPage\n customerName={session.name ?? ""}\n customerEmail={session.email}\n customerId={session.sub}\n merchantName={brand.name}\n nav={ACCOUNT_NAV}\n identitySummary={{\n addresses: addresses?.length ?? 0,\n paymentMethods: wallets?.length ?? 0,\n }}\n />\n );\n}\n' }, { "path": "app/.well-known/ucp/route.ts", "kind": "text", "content": 'import { NextResponse } from "next/server";\n\n/**\n * UCP (Universal Commerce Protocol) manifest discovery endpoint.\n *\n * Agents \u2014 Claude, ChatGPT, Gemini, MCP clients \u2014 probe\n * `https://<your-domain>/.well-known/ucp` to learn what commerce\n * capabilities your storefront supports. We resolve the business\'s\n * UCP handle from the public key (via the storefront API), then forward\n * to Cimplify for the canonical manifest. Edge-cached for an hour\n * because capabilities change rarely.\n */\nconst UCP_API_BASE = "https://api.cimplify.io";\nconst STOREFRONT_API_BASE =\n process.env.NODE_ENV === "production"\n ? "https://storefronts.cimplify.io"\n : "http://127.0.0.1:8787";\n\nexport async function GET() {\n const publicKey = process.env.NEXT_PUBLIC_CIMPLIFY_PUBLIC_KEY;\n if (!publicKey) {\n return NextResponse.json(\n {\n error: "NEXT_PUBLIC_CIMPLIFY_PUBLIC_KEY not set",\n remediation:\n "Set NEXT_PUBLIC_CIMPLIFY_PUBLIC_KEY in .env.local (and your deployment env).",\n },\n { status: 500 },\n );\n }\n\n try {\n // Step 1 \u2014 resolve the business handle from the public key.\n const businessResp = await fetch(`${STOREFRONT_API_BASE}/api/v1/business`, {\n headers: { "X-API-Key": publicKey, "Content-Type": "application/json" },\n next: { revalidate: 3600 },\n });\n\n if (!businessResp.ok) {\n return NextResponse.json(\n { error: `Failed to resolve business: ${businessResp.status}` },\n { status: businessResp.status },\n );\n }\n\n const businessJson = await businessResp.json();\n const handle: string | undefined = businessJson?.data?.handle;\n if (!handle) {\n return NextResponse.json(\n { error: "Business has no handle configured" },\n { status: 500 },\n );\n }\n\n // Step 2 \u2014 fetch the canonical UCP manifest.\n const manifestResp = await fetch(\n `${UCP_API_BASE}/ucp/v1/${handle}/manifest`,\n {\n headers: { "Content-Type": "application/json" },\n next: { revalidate: 3600 },\n },\n );\n\n if (!manifestResp.ok) {\n return NextResponse.json(\n { error: `Upstream UCP manifest fetch failed: ${manifestResp.status}` },\n { status: manifestResp.status },\n );\n }\n\n const manifest = await manifestResp.json();\n return NextResponse.json(manifest, {\n headers: {\n "Content-Type": "application/json",\n "Cache-Control": "public, max-age=3600, s-maxage=3600",\n },\n });\n } catch (error) {\n return NextResponse.json(\n {\n error: "Failed to fetch UCP manifest",\n detail: error instanceof Error ? error.message : String(error),\n },\n { status: 500 },\n );\n }\n}\n' }, { "path": "app/returns/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\nimport { PolicyPage } from "@/components/policy-page";\n\nexport const metadata: Metadata = {\n title: `${brand.returns.title} \u2014 ${brand.name}`,\n};\n\nexport default function ReturnsPage() {\n return <PolicyPage policy={brand.returns} />;\n}\n' }, { "path": "app/orders/[id]/page.tsx", "kind": "text", "content": 'import Link from "next/link";\n\nexport default async function OrderPage({ params }: { params: Promise<{ id: string }> }) {\n const { id } = await params;\n return (\n <section className="max-w-2xl mx-auto px-6 sm:px-8 py-20 text-center">\n <h1 className="font-serif text-3xl mt-0 mb-3">Thanks \u2014 your order is confirmed</h1>\n <p className="text-muted-foreground">\n Order <code className="font-mono text-foreground">{id}</code>\n </p>\n <p className="mt-6">\n <Link\n href="/"\n className="inline-block px-6 py-3 rounded-full bg-primary text-primary-foreground font-semibold text-sm transition-colors hover:bg-primary/90"\n >\n Continue shopping\n </Link>\n </p>\n </section>\n );\n}\n' }, { "path": "app/globals.css", "kind": "text", "content": '@import "tailwindcss";\n\n/* Scan the SDK\'s bundled JS so utility classes used inside its components\n land in the compiled stylesheet alongside our own. */\n@source "../node_modules/@cimplify/sdk/dist";\n\n/* The SDK declares its design tokens with `@theme reference`, meaning the\n consumer must supply the actual values. Warm bakery palette below \u2014 swap\n these to retheme the entire SDK + storefront in one place. */\n@theme {\n --color-background: oklch(0.985 0.007 80);\n --color-foreground: oklch(0.18 0.01 60);\n --color-card: oklch(1 0 0);\n --color-card-foreground: oklch(0.18 0.01 60);\n --color-popover: oklch(1 0 0);\n --color-popover-foreground: oklch(0.18 0.01 60);\n --color-primary: oklch(0.45 0.16 40);\n --color-primary-foreground: oklch(0.99 0 0);\n --color-secondary: oklch(0.95 0.02 80);\n --color-secondary-foreground: oklch(0.25 0.04 50);\n --color-muted: oklch(0.96 0.01 80);\n --color-muted-foreground: oklch(0.5 0.02 60);\n --color-accent: oklch(0.92 0.05 70);\n --color-accent-foreground: oklch(0.3 0.06 40);\n --color-destructive: oklch(0.58 0.24 27);\n --color-destructive-foreground: oklch(0.99 0 0);\n --color-border: oklch(0.9 0.02 80);\n --color-input: oklch(0.92 0.02 80);\n --color-ring: oklch(0.6 0.1 40);\n --radius: 0.75rem;\n}\n\n@layer base {\n html, body {\n margin: 0;\n padding: 0;\n background: var(--color-background);\n color: var(--color-foreground);\n -webkit-font-smoothing: antialiased;\n -moz-osx-font-smoothing: grayscale;\n }\n a { color: inherit; text-decoration: none; }\n h1, h2, h3, h4 {\n font-family: var(--font-serif);\n letter-spacing: -0.01em;\n }\n}\n' }, { "path": "app/about/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `About \u2014 ${brand.name}`,\n description: brand.description,\n};\n\nexport default function AboutPage() {\n const a = brand.about;\n const titleParts = a.title.split("\\n");\n return (\n <article className="max-w-3xl 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 {a.eyebrow}\n </p>\n <h1 className="font-serif text-[clamp(2.25rem,5vw,3.5rem)] font-semibold mb-6 -tracking-[0.02em]">\n {titleParts.map((line, i) => (\n <span key={i}>\n {line}\n {i < titleParts.length - 1 && <br />}\n </span>\n ))}\n </h1>\n <div className="prose prose-lg max-w-none space-y-5 text-foreground/90 leading-relaxed">\n {a.paragraphs.map((p, i) => (\n <p key={i}>{p}</p>\n ))}\n {a.sections.map((s) => (\n <div key={s.heading}>\n <h2 className="font-serif text-3xl font-semibold mt-10 mb-3">{s.heading}</h2>\n <p>{s.body}</p>\n </div>\n ))}\n </div>\n </article>\n );\n}\n' }, { "path": "app/auth/signout/route.ts", "kind": "text", "content": 'import { buildSignoutCookies } from "@cimplify/sdk/server";\n\nconst CLIENT_ID = process.env.CIMPLIFY_CLIENT_ID ?? "";\n\nexport async function POST(): Promise<Response> {\n const headers = new Headers({ "Content-Type": "application/json" });\n for (const cookie of buildSignoutCookies({ clientId: CLIENT_ID })) {\n headers.append("Set-Cookie", cookie);\n }\n return new Response(JSON.stringify({ ok: true }), { status: 200, headers });\n}\n' }, { "path": "app/auth/session/route.ts", "kind": "text", "content": 'import { handleSessionRequest } from "@cimplify/sdk/server";\n\nconst CLIENT_ID = process.env.CIMPLIFY_CLIENT_ID ?? "";\nconst ISSUER = process.env.CIMPLIFY_ISSUER;\n\nexport async function GET(req: Request): Promise<Response> {\n if (!CLIENT_ID) {\n return Response.json({ sub: null }, { status: 200 });\n }\n return handleSessionRequest(req, { clientId: CLIENT_ID, issuer: ISSUER });\n}\n' }, { "path": "app/auth/callback/route.ts", "kind": "text", "content": 'import { handleOidcCallback, handleRedirectCallback } from "@cimplify/sdk/server";\n\nconst CLIENT_ID = process.env.CIMPLIFY_CLIENT_ID ?? "";\nconst ISSUER = process.env.CIMPLIFY_ISSUER;\nconst REDIRECT_URI = process.env.CIMPLIFY_REDIRECT_URI ?? "";\n\n// Redirect sign-in: the browser lands here via a top-level GET carrying ?code.\nexport async function GET(req: Request): Promise<Response> {\n if (!CLIENT_ID || !REDIRECT_URI) {\n return Response.json({ error: "oidc_not_configured" }, { status: 500 });\n }\n return handleRedirectCallback(req, {\n clientId: CLIENT_ID,\n issuer: ISSUER,\n redirectUri: REDIRECT_URI,\n defaultReturnTo: "/account",\n });\n}\n\n// Modal/silent sign-in: posts {code, codeVerifier, state} here for exchange.\nexport async function POST(req: Request): Promise<Response> {\n if (!CLIENT_ID || !REDIRECT_URI) {\n return Response.json({ error: "oidc_not_configured" }, { status: 500 });\n }\n return handleOidcCallback(req, {\n clientId: CLIENT_ID,\n issuer: ISSUER,\n redirectUri: REDIRECT_URI,\n });\n}\n' }, { "path": "app/privacy/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Privacy Policy \u2014 ${brand.name}`,\n description: `How ${brand.name} collects, uses, and protects your personal data.`,\n};\n\nexport default function PrivacyPage() {\n const p = brand.privacy;\n return (\n <article className="max-w-3xl mx-auto px-6 sm:px-8 py-16 prose prose-lg max-w-none">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-2 not-prose">\n {p.eyebrow}\n </p>\n <h1 className="font-serif text-[clamp(2.25rem,5vw,3.5rem)] font-semibold mb-2 -tracking-[0.02em]">\n {p.title}\n </h1>\n <p className="text-sm text-muted-foreground not-prose mb-10">\n Last updated: {p.lastUpdated}\n </p>\n\n <section className="space-y-5 leading-relaxed text-foreground/90">\n {p.sections.map((s) => (\n <div key={s.heading}>\n <h2 className="font-serif text-2xl font-semibold mt-0">{s.heading}</h2>\n {typeof s.body === "string" ? (\n <p>{s.body}</p>\n ) : (\n <>\n <p>{s.body.intro}</p>\n <ul className="list-disc pl-6 space-y-2">\n {s.body.bullets.map((b) => (\n <li key={b}>{b}</li>\n ))}\n </ul>\n </>\n )}\n </div>\n ))}\n </section>\n </article>\n );\n}\n' }, { "path": "app/sitemap-page/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport Link from "next/link";\nimport { getServerClient, tags, type Product } from "@cimplify/sdk/server";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Sitemap \u2014 ${brand.name}`,\n description: "A human-readable index of every page on this site.",\n};\n\nexport const revalidate = 3600;\n\ninterface SitemapData {\n products: { slug: string; name: string }[];\n categories: { slug: string; name: string }[];\n collections: { slug: string; name: string }[];\n}\n\nasync function getSitemap(): Promise<SitemapData> {\n const client = getServerClient();\n const [pRes, cRes, colRes] = await Promise.all([\n client.catalogue.getProducts(\n { limit: 500 },\n { cacheOptions: { revalidate: 3600, tags: [tags.products()] } },\n ),\n client.catalogue.getCategories({\n cacheOptions: { revalidate: 3600, tags: [tags.categories()] },\n }),\n client.catalogue.getCollections({\n cacheOptions: { revalidate: 3600, tags: [tags.collections()] },\n }),\n ]);\n return {\n products: (pRes.ok ? pRes.value.items : []).map((p: Product) => ({\n slug: p.slug ?? p.id,\n name: p.name,\n })),\n categories: (cRes.ok ? cRes.value : []).map((c) => ({ slug: c.slug, name: c.name })),\n collections: (colRes.ok ? colRes.value : []).map((c) => ({ slug: c.slug, name: c.name })),\n };\n}\n\nconst STATIC_LINKS: { title: string; links: { href: string; label: string }[] }[] = [\n {\n title: "Browse",\n links: [\n { href: "/", label: "Home" },\n { href: "/shop", label: "Shop" },\n { href: "/search", label: "Search" },\n ],\n },\n {\n title: "Account",\n links: [\n { href: "/account", label: "Account" },\n { href: "/account/orders", label: "Orders" },\n { href: "/login", label: "Sign in" },\n { href: "/signup", label: "Create account" },\n { href: "/track-order", label: "Track an order" },\n { href: "/cart", label: "Cart" },\n { href: "/checkout", label: "Checkout" },\n ],\n },\n {\n title: "About",\n links: [\n { href: "/about", label: "About" },\n { href: "/faq", label: "FAQ" },\n { href: "/contact", label: "Contact" },\n ],\n },\n {\n title: "Policies",\n links: [\n { href: "/shipping", label: "Shipping" },\n { href: "/returns", label: "Returns" },\n { href: "/accessibility", label: "Accessibility" },\n { href: "/terms", label: "Terms of Service" },\n { href: "/privacy", label: "Privacy Policy" },\n ],\n },\n {\n title: "Machine-readable",\n links: [\n { href: "/sitemap.xml", label: "sitemap.xml (search engines)" },\n { href: "/llms.txt", label: "llms.txt (LLM agents)" },\n { href: "/robots.txt", label: "robots.txt" },\n { href: "/opensearch.xml", label: "opensearch.xml (browser search)" },\n ],\n },\n];\n\nexport default async function SitemapHtmlPage() {\n const { products, categories, collections } = await getSitemap();\n\n return (\n <article className="max-w-5xl mx-auto px-6 sm:px-8 py-16">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-2">\n Sitemap\n </p>\n <h1 className="text-[clamp(2rem,4vw,2.75rem)] font-semibold mb-2 -tracking-[0.02em]">\n Every page, in one place.\n </h1>\n <p className="text-muted-foreground mb-12">\n For search engines, see <Link href="/sitemap.xml" className="text-primary hover:underline">/sitemap.xml</Link>.\n For LLM agents, see <Link href="/llms.txt" className="text-primary hover:underline">/llms.txt</Link>.\n </p>\n <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-10">\n {STATIC_LINKS.map((s) => (\n <Section key={s.title} title={s.title}>\n {s.links.map((l) => (\n <li key={l.href}>\n <Link href={l.href} className="hover:text-primary transition-colors">\n {l.label}\n </Link>\n </li>\n ))}\n </Section>\n ))}\n {categories.length > 0 && (\n <Section title="Categories">\n {categories.map((c) => (\n <li key={c.slug}>\n <Link href={`/categories/${c.slug}`} className="hover:text-primary transition-colors">\n {c.name}\n </Link>\n </li>\n ))}\n </Section>\n )}\n {collections.length > 0 && (\n <Section title="Collections">\n {collections.map((c) => (\n <li key={c.slug}>\n <Link href={`/collections/${c.slug}`} className="hover:text-primary transition-colors">\n {c.name}\n </Link>\n </li>\n ))}\n </Section>\n )}\n {products.length > 0 && (\n <Section title={`Products (${products.length})`}>\n {products.map((p) => (\n <li key={p.slug}>\n <Link href={`/shop?product=${encodeURIComponent(p.slug)}`} className="hover:text-primary transition-colors">\n {p.name}\n </Link>\n </li>\n ))}\n </Section>\n )}\n </div>\n </article>\n );\n}\n\nfunction Section({ title, children }: { title: string; children: React.ReactNode }) {\n return (\n <div>\n <p className="font-semibold text-[12px] uppercase tracking-[0.12em] text-foreground mb-3">\n {title}\n </p>\n <ul className="space-y-2 m-0 p-0 list-none text-sm text-muted-foreground">\n {children}\n </ul>\n </div>\n );\n}\n' }, { "path": "app/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": "app/collections/[slug]/listing-client.tsx", "kind": "text", "content": '"use client";\n\nimport { ProductGrid } from "@cimplify/sdk/react";\nimport type { Product } from "@cimplify/sdk";\nimport { StoreProductCard } from "@/components/store-product-card";\n\n/**\n * Client island for the collection listing. Receives server-fetched\n * products as props (serializable) and owns the `renderCard` function\n * (which can\'t cross the server/client boundary).\n */\nexport function ListingClient({ products }: { products: Product[] }) {\n return (\n <ProductGrid\n products={products}\n emptyMessage="No products in this collection yet."\n renderCard={(p) => <StoreProductCard product={p} />}\n />\n );\n}\n' }, { "path": "app/collections/[slug]/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Suspense } from "react";\nimport Link from "next/link";\nimport { notFound } from "next/navigation";\nimport {\n getServerClient,\n tags,\n type Collection,\n type Product,\n} from "@cimplify/sdk/server";\nimport { ListingClient } from "./listing-client";\nimport { brand } from "@/lib/brand";\n\n// See app/products/[slug]/page.tsx for the rationale on generateStaticParams.\nexport async function generateStaticParams() {\n const r = await getServerClient().catalogue.getCollections();\n if (!r.ok || r.value.length === 0) {\n return [{ slug: "__placeholder__" }];\n }\n return r.value.map((c) => ({ slug: c.slug ?? c.id }));\n}\n\nexport const revalidate = 3600;\n\ninterface CollectionData {\n collection: Collection;\n products: Product[];\n}\n\ntype CollectionResult =\n | { ok: true; data: CollectionData }\n | { ok: false; code: string };\n\nasync function getCollection(slug: string): Promise<CollectionResult> {\n const client = getServerClient();\n const colRes = await client.catalogue.getCollectionBySlug(slug, {\n cacheOptions: { revalidate: 3600, tags: [tags.collections()] },\n });\n if (!colRes.ok) return { ok: false, code: colRes.error.code };\n\n const r = await client.catalogue.getCollectionProducts(colRes.value.id, undefined, {\n cacheOptions: {\n revalidate: 3600,\n tags: [\n tags.products(),\n tags.collection(colRes.value.id),\n tags.collectionProducts(colRes.value.id),\n ],\n },\n });\n const products = r.ok\n ? ((r.value as { items?: Product[] }).items ?? (r.value as Product[]))\n : [];\n return { ok: true, data: { collection: colRes.value, products } };\n}\n\nexport async function generateMetadata({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}): Promise<Metadata> {\n const { slug } = await params;\n const result = await getCollection(slug);\n if (!result.ok) return {};\n const data = result.data;\n return {\n title: `${data.collection.name} \u2014 ${brand.name}`,\n description: data.collection.description ?? undefined,\n };\n}\n\nexport default async function CollectionPage({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}) {\n return (\n <Suspense fallback={<CollectionSkeleton />}>\n <CollectionContent params={params} />\n </Suspense>\n );\n}\n\nasync function CollectionContent({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}) {\n const { slug } = await params;\n const result = await getCollection(slug);\n if (!result.ok) {\n if (result.code === "NOT_FOUND") notFound();\n return <CollectionSkeleton />;\n }\n const { collection, products } = result.data;\n return (\n <section className="max-w-7xl mx-auto px-6 sm:px-8 pt-12">\n <header className="mb-8 text-center">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-2">\n Collection\n </p>\n <h1 className="font-serif text-[clamp(2rem,4vw,2.75rem)] font-semibold mb-2">\n {collection.name}\n </h1>\n {collection.description && (\n <p className="mx-auto mb-2 max-w-xl text-muted-foreground">\n {collection.description}\n </p>\n )}\n <p className="text-sm text-muted-foreground">\n {products.length} item{products.length === 1 ? "" : "s"}\n </p>\n </header>\n <ListingClient products={products} />\n {products.length === 0 && (\n <p className="text-center mt-8">\n <Link href="/" className="text-primary font-semibold">\n \u2190 Back home\n </Link>\n </p>\n )}\n </section>\n );\n}\n\nfunction CollectionSkeleton() {\n return (\n <section className="max-w-7xl mx-auto px-6 sm:px-8 pt-12">\n <header className="mb-8 text-center">\n <div className="mx-auto h-3 w-20 bg-muted rounded mb-2 animate-pulse" />\n <div className="mx-auto h-10 w-64 bg-muted rounded mb-2 animate-pulse" />\n <div className="mx-auto h-4 w-80 bg-muted rounded animate-pulse" />\n </header>\n <div className="grid grid-cols-2 md:grid-cols-3 gap-4">\n {Array.from({ length: 6 }).map((_, i) => (\n <div key={i} className="aspect-square bg-muted rounded-2xl animate-pulse" />\n ))}\n </div>\n </section>\n );\n}\n' }, { "path": "app/layout.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Inter, Playfair_Display } from "next/font/google";\nimport "./globals.css";\nimport { Providers } from "@/components/providers";\nimport { getServerClient, tags } from "@cimplify/sdk/server";\nimport { Header } from "@/components/header";\nimport { Footer } from "@/components/footer";\nimport { ProductModal } from "@/components/product-modal";\nimport { CartDrawer } from "@/components/cart-drawer";\nimport { SupportChat } from "@/components/support-chat";\nimport { OrganizationJsonLd } from "@/components/json-ld";\nimport { Suspense } from "react";\nimport { brand } from "@/lib/brand";\nimport { getSiteUrl } from "@/lib/site-url";\n\nconst inter = Inter({\n subsets: ["latin"],\n variable: "--font-sans",\n display: "swap",\n});\n\nconst playfair = Playfair_Display({\n subsets: ["latin"],\n variable: "--font-serif",\n display: "swap",\n});\n\nexport async function generateMetadata(): Promise<Metadata> {\n const siteUrl = await getSiteUrl();\n return {\n metadataBase: new URL(siteUrl),\n title: {\n default: brand.name,\n template: `%s \u2014 ${brand.name}`,\n },\n description: brand.description,\n openGraph: {\n type: "website",\n siteName: brand.name,\n locale: brand.locale,\n },\n twitter: { card: "summary_large_image" },\n };\n}\n\nexport default async function RootLayout({ children }: { children: React.ReactNode }) {\n const server = getServerClient();\n const [bizResult, locResult] = await Promise.all([\n server.business.getInfo({ cacheOptions: { revalidate: 3600, tags: [tags.business()] } }),\n server.business.getLocations({ cacheOptions: { revalidate: 3600, tags: [tags.locations()] } }),\n ]);\n const initialBusiness = bizResult.ok ? bizResult.value : null;\n const initialLocations = locResult.ok ? locResult.value : [];\n\n return (\n <html lang="en" suppressHydrationWarning className={`${inter.variable} ${playfair.variable}`}>\n <body\n suppressHydrationWarning\n className="min-h-screen flex flex-col bg-background text-foreground font-sans"\n >\n <Suspense fallback={null}>\n <OrganizationJsonLd />\n </Suspense>\n <Providers initialBusiness={initialBusiness} initialLocations={initialLocations}>\n <Header />\n <main className="flex-1 pb-12 w-full">\n <Suspense fallback={null}>{children}</Suspense>\n </main>\n <Footer />\n <Suspense fallback={null}>\n <ProductModal />\n </Suspense>\n <CartDrawer />\n <SupportChat />\n </Providers>\n </body>\n </html>\n );\n}\n' }, { "path": "app/error.tsx", "kind": "text", "content": '"use client";\n\nimport { useEffect } from "react";\nimport Link from "next/link";\n\n/**\n * Root error boundary. Next.js calls this whenever a thrown error escapes\n * a Server Component without being caught by a closer `error.tsx`.\n *\n * Wire `reportError` (Sentry, Datadog, etc.) here so production failures\n * surface \u2014 silently recovering hides real bugs.\n */\nexport default function GlobalError({\n error,\n reset,\n}: {\n error: Error & { digest?: string };\n reset: () => void;\n}) {\n useEffect(() => {\n // TODO: replace with your error reporter\n // eslint-disable-next-line no-console\n console.error("Storefront error:", error);\n }, [error]);\n\n return (\n <section className="max-w-2xl mx-auto px-6 sm:px-8 py-20 text-center">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-3">\n Something broke\n </p>\n <h1 className="font-serif text-[clamp(2rem,4vw,2.75rem)] font-semibold mb-4">\n That wasn&apos;t supposed to happen.\n </h1>\n <p className="text-muted-foreground leading-relaxed mb-8">\n We&apos;ve been notified. Refresh the page, or head back home \u2014\n most of the time the second try just works.\n </p>\n {error.digest && (\n <p className="text-xs font-mono text-muted-foreground mb-6">\n Reference: <code className="text-foreground">{error.digest}</code>\n </p>\n )}\n <div className="flex flex-wrap items-center justify-center gap-3">\n <button\n type="button"\n onClick={reset}\n className="inline-block px-6 py-3 rounded-full bg-primary text-primary-foreground font-semibold text-sm transition-colors hover:bg-primary/90"\n >\n Try again\n </button>\n <Link\n href="/"\n className="inline-block px-6 py-3 rounded-full border border-border hover:bg-muted transition-colors text-sm font-medium"\n >\n Back home\n </Link>\n </div>\n </section>\n );\n}\n' }, { "path": "app/faq/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `FAQ \u2014 ${brand.name}`,\n description: "Delivery, pickup, custom cakes, allergens, payment \u2014 answers to the questions we hear most often.",\n};\n\nexport default function FaqPage() {\n const f = brand.faq;\n return (\n <article className="max-w-3xl 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 {f.eyebrow}\n </p>\n <h1 className="font-serif text-[clamp(2.25rem,5vw,3.5rem)] font-semibold mb-10 -tracking-[0.02em]">\n {f.title}\n </h1>\n <div className="space-y-12">\n {f.sections.map((section) => (\n <section key={section.title}>\n <h2 className="font-serif text-2xl font-semibold mb-5">{section.title}</h2>\n <dl className="space-y-6">\n {section.items.map((item) => (\n <div key={item.q}>\n <dt className="font-medium text-foreground mb-1.5">{item.q}</dt>\n <dd className="text-muted-foreground leading-relaxed">{item.a}</dd>\n </div>\n ))}\n </dl>\n </section>\n ))}\n </div>\n <p className="mt-12 pt-8 border-t border-border text-sm text-muted-foreground">\n {f.contactPrompt}{" "}\n <a\n href={`mailto:${f.contactEmail}`}\n className="text-primary font-semibold hover:underline"\n >\n {f.contactEmail}\n </a>{" "}\n and a real human will reply within 24 hours.\n </p>\n </article>\n );\n}\n' }, { "path": "app/llms.txt/route.ts", "kind": "text", "content": 'import { getServerClient, tags, type Product } from "@cimplify/sdk/server";\nimport { brand } from "@/lib/brand";\nimport { getSiteUrl } from "@/lib/site-url";\n\nexport const revalidate = 3600;\n\nasync function buildLlmsTxt(SITE_URL: string): Promise<string> {\n const client = getServerClient();\n const [productsRes, categoriesRes, collectionsRes] = await Promise.all([\n client.catalogue.getProducts(\n { limit: 500 },\n { cacheOptions: { revalidate: 3600, tags: [tags.products()] } },\n ),\n client.catalogue.getCategories({\n cacheOptions: { revalidate: 3600, tags: [tags.categories()] },\n }),\n client.catalogue.getCollections({\n cacheOptions: { revalidate: 3600, tags: [tags.collections()] },\n }),\n ]);\n\n const products: Product[] = productsRes.ok ? productsRes.value.items : [];\n const categories = categoriesRes.ok ? categoriesRes.value : [];\n const collections = collectionsRes.ok ? collectionsRes.value : [];\n\n const lines: string[] = [];\n lines.push(`# ${brand.name}`);\n lines.push("");\n lines.push(`> ${brand.llms.summary}`);\n lines.push("");\n lines.push("## Browse");\n lines.push(`- [Home](${SITE_URL}/)`);\n lines.push(`- [Shop](${SITE_URL}/shop): Full menu with filter and sort.`);\n\n if (categories.length > 0) {\n lines.push("");\n lines.push("## Categories");\n for (const c of categories) {\n lines.push(\n `- [${c.name}](${SITE_URL}/categories/${c.slug})${c.description ? `: ${c.description}` : ""}`,\n );\n }\n }\n\n if (collections.length > 0) {\n lines.push("");\n lines.push("## Collections");\n for (const c of collections) {\n lines.push(\n `- [${c.name}](${SITE_URL}/collections/${c.slug})${c.description ? `: ${c.description}` : ""}`,\n );\n }\n }\n\n if (products.length > 0) {\n lines.push("");\n lines.push("## Products");\n for (const p of products) {\n const slug = p.slug ?? p.id;\n const price = `${brand.currency} ${p.default_price}`;\n const desc = p.description ? ` \u2014 ${p.description.replace(/\\s+/g, " ").slice(0, 200)}` : "";\n lines.push(`- [${p.name}](${SITE_URL}/shop?product=${slug}) (${price})${desc}`);\n }\n }\n\n lines.push("");\n lines.push("## Information");\n lines.push(`- [About](${SITE_URL}/about)`);\n lines.push(`- [FAQ](${SITE_URL}/faq)`);\n lines.push(`- [Terms of Service](${SITE_URL}/terms)`);\n lines.push(`- [Privacy Policy](${SITE_URL}/privacy)`);\n lines.push(`- [Sitemap (XML)](${SITE_URL}/sitemap.xml)`);\n lines.push("");\n lines.push("## Contact");\n lines.push(`- Email: ${brand.contact.email}`);\n lines.push(`- Phone: ${brand.contact.phone}`);\n lines.push(`- Address: ${brand.contact.address}`);\n\n return lines.join("\\n") + "\\n";\n}\n\n/**\n * `/llms.txt` \u2014 machine-readable site index for LLMs (per llmstxt.org).\n * Lets coding agents and chat assistants find products, categories, and\n * support pages without scraping HTML. Plain Markdown so it streams cheaply\n * into context windows.\n */\nexport async function GET(): Promise<Response> {\n const body = await buildLlmsTxt(await getSiteUrl());\n return new Response(body, {\n headers: {\n "Content-Type": "text/plain; charset=utf-8",\n "Cache-Control": "public, max-age=0, s-maxage=3600, stale-while-revalidate=86400",\n },\n });\n}\n' }, { "path": "app/contact/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { ContactForm } from "./contact-form";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Contact \u2014 ${brand.name}`,\n description: brand.contactPage.body,\n};\n\nexport default function ContactPage() {\n const c = brand.contactPage;\n return (\n <article className="max-w-5xl mx-auto px-6 sm:px-8 py-16">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-2">\n {c.eyebrow}\n </p>\n <h1 className="font-serif text-[clamp(2.25rem,5vw,3.5rem)] font-semibold mb-4 -tracking-[0.02em]">\n {c.title}\n </h1>\n <p className="text-lg text-muted-foreground max-w-2xl mb-12 leading-relaxed">{c.body}</p>\n\n <div className="grid grid-cols-1 lg:grid-cols-[1.5fr_1fr] gap-10 items-start">\n <ContactForm reasons={c.reasons} />\n <aside className="space-y-5 lg:pl-10 lg:border-l border-border">\n <div>\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground mb-3">\n Direct lines\n </p>\n <ul className="space-y-3 list-none p-0 m-0">\n {c.directLines.map((line) => (\n <li key={line.label}>\n <p className="text-xs text-muted-foreground m-0">{line.label}</p>\n <a\n href={line.href}\n className="text-foreground hover:text-primary transition-colors"\n >\n {line.value}\n </a>\n </li>\n ))}\n </ul>\n </div>\n <div>\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground mb-3">\n Visit\n </p>\n <p className="text-foreground m-0">{brand.contact.address}</p>\n <p className="text-xs text-muted-foreground mt-1">{brand.contact.hours}</p>\n </div>\n </aside>\n </div>\n </article>\n );\n}\n' }, { "path": "app/contact/contact-form.tsx", "kind": "text", "content": `"use client";
580
580
 
581
581
  import { useState } from "react";
582
582
 
@@ -1440,7 +1440,7 @@ export const brand: Brand = {
1440
1440
  businessId: "bus_currents_electronics",
1441
1441
  },
1442
1442
  };
1443
- ` }, { "path": "lib/cimplify-server.ts", "kind": "text", "content": 'import { cookies } from "next/headers";\nimport {\n getAccessTokenFromCookieHeader,\n getSessionFromCookieHeader,\n} from "@cimplify/sdk/server";\nimport type { NavItem } from "@cimplify/sdk/react";\n\nexport const ACCOUNT_NAV: NavItem[] = [\n { label: "Dashboard", href: "/account" },\n { label: "Orders", href: "/account/orders" },\n { label: "Cimplify identity", href: "https://link.cimplify.io", external: true },\n];\n\nconst CLIENT_ID = process.env.CIMPLIFY_CLIENT_ID ?? "";\nconst ISSUER = (process.env.CIMPLIFY_ISSUER ?? "https://api.cimplify.io").replace(/\\/$/, "");\n\nconst OIDC_CONFIG = { clientId: CLIENT_ID, issuer: ISSUER };\n\nasync function cookieHeader(): Promise<string> {\n const store = await cookies();\n return store.getAll().map((c) => `${c.name}=${c.value}`).join("; ");\n}\n\nexport async function serverSession() {\n return getSessionFromCookieHeader(OIDC_CONFIG, await cookieHeader());\n}\n\nexport async function serverAccessToken(): Promise<string | null> {\n return getAccessTokenFromCookieHeader(OIDC_CONFIG, await cookieHeader());\n}\n\ninterface Envelope<T> {\n data: T;\n}\n\nexport async function apiFetch<T>(\n path: string,\n init: RequestInit & { bearer: string | null },\n): Promise<T | null> {\n if (!init.bearer) return null;\n const { bearer, ...rest } = init;\n const headers = new Headers(rest.headers);\n headers.set("Authorization", `Bearer ${bearer}`);\n headers.set("Accept", "application/json");\n if (rest.body && !headers.has("Content-Type")) {\n headers.set("Content-Type", "application/json");\n }\n const res = await fetch(`${ISSUER}${path}`, { ...rest, headers, cache: "no-store" });\n if (!res.ok) return null;\n const envelope = (await res.json()) as Envelope<T>;\n return envelope.data;\n}\n' }, { "path": "lib/cimplify-loader.ts", "kind": "text", "content": 'import type { ImageLoader } from "next/image";\n\nimport { assetUrl, isCimplifyAsset } from "@cimplify/sdk";\n\nconst cdnBase = process.env.NEXT_PUBLIC_CIMPLIFY_CDN_URL?.trim() || undefined;\n\nconst cimplifyImageLoader: ImageLoader = ({ src, width, quality }) => {\n if (isCimplifyAsset(src, cdnBase)) {\n return assetUrl(src, {\n base: cdnBase,\n w: width,\n quality,\n format: "auto",\n });\n }\n return src;\n};\n\nexport default cimplifyImageLoader;\n' }, { "path": "lib/site-url.ts", "kind": "text", "content": 'import { SITE_URL } from "./site.config";\n\n/** Canonical absolute URL for this storefront. */\nexport async function getSiteUrl(): Promise<string> {\n return SITE_URL;\n}\n' }, { "path": ".env.example", "kind": "text", "content": "# Your tenant public key. Get it from the desk's Developers tab.\n# `cpk_live_*` / `cpk_test_*` keys auto-route same-origin requests\n# to hosted Cimplify in both dev and prod. Anything else (`mock-dev`,\n# empty) falls back to the local `cimplify dev` mock.\nNEXT_PUBLIC_CIMPLIFY_PUBLIC_KEY=mock-dev\n\n# Optional. Forces a fixed canonical URL for sitemap.xml, robots.txt,\n# OpenGraph, and llms.txt. Leave unset and the storefront derives the\n# canonical from the request `Host` header automatically.\n# NEXT_PUBLIC_SITE_URL=\n" }, { "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__/brand.test.ts", "kind": "text", "content": 'import { createBrandSuite } from "@cimplify/sdk/testing/suite";\nimport { brand } from "../lib/brand";\n\ncreateBrandSuite({ brand });\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.100.4",\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": "metadata.json", "kind": "text", "content": '{\n "id": "retail",\n "name": "Retail",\n "tagline": "Variant-aware retail storefront with full product detail pages and instalment support.",\n "industry": "retail",\n "tags": ["retail", "electronics", "variants"],\n "stability": "stable",\n "schemaType": "Store",\n "mock": {\n "seedName": "retail",\n "seedBusinessId": "bus_currents_electronics"\n }\n}\n' }, { "path": "app/search/search-client.tsx", "kind": "text", "content": '"use client";\n\nimport { SearchPage } from "@cimplify/sdk/react";\n\nexport function SearchClient() {\n return <SearchPage />;\n}\n' }, { "path": "app/search/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Suspense } from "react";\nimport { SearchClient } from "./search-client";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Search \u2014 ${brand.name}`,\n description: `Search ${brand.name} \u2014 products, collections, categories.`,\n};\n\nexport default function SearchPage() {\n return (\n <article className="max-w-7xl mx-auto px-6 sm:px-8 py-10">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-2">\n Search\n </p>\n <h1 className="font-serif text-[clamp(2rem,4vw,2.75rem)] font-semibold mb-8 -tracking-[0.02em]">\n Find anything.\n </h1>\n <Suspense fallback={<SearchSkeleton />}>\n <SearchClient />\n </Suspense>\n </article>\n );\n}\n\nfunction SearchSkeleton() {\n return (\n <div>\n <div className="h-12 w-full max-w-xl bg-muted rounded animate-pulse mb-8" />\n <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3 sm:gap-4">\n {Array.from({ length: 8 }).map((_, i) => (\n <div key={i} className="aspect-[4/3] bg-muted rounded-2xl animate-pulse" />\n ))}\n </div>\n </div>\n );\n}\n' }, { "path": "app/categories/[slug]/listing-client.tsx", "kind": "text", "content": '"use client";\n\nimport { ProductGrid } from "@cimplify/sdk/react";\nimport type { Product } from "@cimplify/sdk";\nimport { StoreProductCard } from "@/components/store-product-card";\n\n/**\n * Client island for the category listing. Receives server-fetched products\n * as props (serializable) and owns the `renderCard` function.\n */\nexport function ListingClient({ products }: { products: Product[] }) {\n return (\n <ProductGrid\n products={products}\n emptyMessage="No products in this category yet."\n renderCard={(p) => <StoreProductCard product={p} />}\n />\n );\n}\n' }, { "path": "app/categories/[slug]/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Suspense } from "react";\nimport Link from "next/link";\nimport { notFound } from "next/navigation";\nimport {\n getServerClient,\n tags,\n type Category,\n type Product,\n} from "@cimplify/sdk/server";\nimport { ListingClient } from "./listing-client";\nimport { brand } from "@/lib/brand";\n\n// See app/products/[slug]/page.tsx for the rationale on generateStaticParams.\nexport async function generateStaticParams() {\n const r = await getServerClient().catalogue.getCategories();\n if (!r.ok || r.value.length === 0) {\n return [{ slug: "__placeholder__" }];\n }\n return r.value.map((c) => ({ slug: c.slug ?? c.id }));\n}\n\nexport const revalidate = 3600;\n\ninterface CategoryData {\n category: Category;\n products: Product[];\n}\n\ntype CategoryResult =\n | { ok: true; data: CategoryData }\n | { ok: false; code: string };\n\nasync function getCategory(slug: string): Promise<CategoryResult> {\n const client = getServerClient();\n const catRes = await client.catalogue.getCategoryBySlug(slug, {\n cacheOptions: { revalidate: 3600, tags: [tags.categories()] },\n });\n if (!catRes.ok) return { ok: false, code: catRes.error.code };\n\n const r = await client.catalogue.getCategoryProducts(catRes.value.id, undefined, {\n cacheOptions: {\n revalidate: 3600,\n tags: [\n tags.products(),\n tags.category(catRes.value.id),\n tags.categoryProducts(catRes.value.id),\n ],\n },\n });\n const products = r.ok\n ? ((r.value as { items?: Product[] }).items ?? (r.value as Product[]))\n : [];\n return { ok: true, data: { category: catRes.value, products } };\n}\n\nexport async function generateMetadata({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}): Promise<Metadata> {\n const { slug } = await params;\n const result = await getCategory(slug);\n if (!result.ok) return {};\n const data = result.data;\n return {\n title: `${data.category.name} \u2014 ${brand.name}`,\n description: data.category.description ?? undefined,\n };\n}\n\nexport default async function CategoryPage({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}) {\n return (\n <Suspense fallback={<CategorySkeleton />}>\n <CategoryContent params={params} />\n </Suspense>\n );\n}\n\nasync function CategoryContent({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}) {\n const { slug } = await params;\n const result = await getCategory(slug);\n if (!result.ok) {\n if (result.code === "NOT_FOUND") notFound();\n return <CategorySkeleton />;\n }\n const { category, products } = result.data;\n return (\n <>\n <section className="bg-foreground text-background relative overflow-hidden">\n <div className="absolute inset-0 opacity-[0.04] pointer-events-none [background-image:radial-gradient(circle_at_2px_2px,white_1px,transparent_0)] [background-size:32px_32px]" />\n <div className="relative max-w-7xl mx-auto px-6 sm:px-8 py-12 sm:py-14">\n <nav className="text-[12px] font-mono text-background/60 mb-3 flex items-center gap-2">\n <Link href="/" className="hover:text-background transition-colors">Home</Link>\n <span>/</span>\n <Link href="/shop" className="hover:text-background transition-colors">Shop</Link>\n <span>/</span>\n <span className="text-background/90">{category.name}</span>\n </nav>\n <h1 className="text-[clamp(2rem,4vw,3rem)] font-bold m-0 -tracking-[0.025em]">\n {category.name}\n </h1>\n {category.description && (\n <p className="mt-3 max-w-2xl text-base text-background/75">\n {category.description}\n </p>\n )}\n <p className="mt-4 text-[11px] font-mono uppercase tracking-[0.16em] text-background/60 tabular-nums">\n {products.length} {products.length === 1 ? "product" : "products"}\n </p>\n </div>\n </section>\n <section className="max-w-7xl mx-auto px-6 sm:px-8 py-10 sm:py-12">\n <ListingClient products={products} />\n {products.length === 0 && (\n <p className="text-center mt-8">\n <Link href="/shop" className="text-primary font-semibold hover:underline">\n \u2190 Browse all products\n </Link>\n </p>\n )}\n </section>\n </>\n );\n}\n\nfunction CategorySkeleton() {\n return (\n <>\n <section className="bg-foreground py-12 sm:py-14">\n <div className="max-w-7xl mx-auto px-6 sm:px-8">\n <div className="h-3 w-32 bg-background/20 rounded mb-3 animate-pulse" />\n <div className="h-12 w-72 bg-background/20 rounded animate-pulse" />\n </div>\n </section>\n <section className="max-w-7xl mx-auto px-6 sm:px-8 py-10 sm:py-12">\n <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3 sm:gap-4">\n {Array.from({ length: 8 }).map((_, i) => (\n <div key={i} className="aspect-[4/3] bg-muted rounded-2xl animate-pulse" />\n ))}\n </div>\n </section>\n </>\n );\n}\n' }, { "path": "app/terms/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Terms of Service \u2014 ${brand.name}`,\n description: `The rules of the road for ordering from ${brand.name}.`,\n};\n\nexport default function TermsPage() {\n const t = brand.terms;\n return (\n <article className="max-w-3xl mx-auto px-6 sm:px-8 py-16 prose prose-lg max-w-none">\n <p className="text-[11px] font-mono uppercase tracking-[0.16em] text-primary mb-2 not-prose">\n {t.eyebrow}\n </p>\n <h1 className="text-[clamp(2.25rem,5vw,3.5rem)] font-bold mb-2 -tracking-[0.025em]">\n {t.title}\n </h1>\n <p className="text-sm text-muted-foreground not-prose mb-10">\n Last updated: {t.lastUpdated}\n </p>\n\n <section className="space-y-5 leading-relaxed text-foreground/90">\n {t.sections.map((s) => (\n <div key={s.heading}>\n <h2 className="text-2xl font-semibold mt-0 -tracking-[0.02em]">{s.heading}</h2>\n {typeof s.body === "string" ? (\n <p>{s.body}</p>\n ) : (\n <>\n <p>{s.body.intro}</p>\n <ul className="list-disc pl-6 space-y-2">\n {s.body.bullets.map((b) => (\n <li key={b}>{b}</li>\n ))}\n </ul>\n </>\n )}\n </div>\n ))}\n </section>\n </article>\n );\n}\n' }, { "path": "app/not-found.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport Link from "next/link";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Page not found \u2014 ${brand.name}`,\n description: "We couldn\'t find that page.",\n};\n\nexport default function NotFound() {\n return (\n <section className="max-w-2xl mx-auto px-6 sm:px-8 py-20 text-center">\n <p className="text-[11px] font-mono uppercase tracking-[0.16em] text-primary mb-3">\n 404\n </p>\n <h1 className="text-[clamp(2.5rem,5vw,4rem)] font-bold mb-4 -tracking-[0.03em]">\n Page not found.\n </h1>\n <p className="text-muted-foreground leading-relaxed mb-8">\n The URL you followed might be old, a typo, or pointing at a product\n that&apos;s no longer in stock. Try the menu or head back home.\n </p>\n <div className="flex flex-wrap items-center justify-center gap-3">\n <Link\n href="/shop"\n className="inline-flex items-center gap-2 px-5 py-2.5 rounded-md bg-primary text-primary-foreground font-semibold text-sm hover:bg-primary/90 transition-colors"\n >\n Browse the shop\n </Link>\n <Link\n href="/"\n className="inline-flex items-center gap-2 px-5 py-2.5 rounded-md border border-border hover:bg-muted transition-colors text-sm font-medium"\n >\n Back home\n </Link>\n </div>\n </section>\n );\n}\n' }, { "path": "app/cart/page.tsx", "kind": "text", "content": '"use client";\n\nimport { useRouter } from "next/navigation";\nimport { CartPage as SdkCartPage } from "@cimplify/sdk/react";\n\nexport default function CartPage() {\n const router = useRouter();\n return <SdkCartPage onCheckout={() => router.push("/checkout")} />;\n}\n' }, { "path": "app/accessibility/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\nimport { PolicyPage } from "@/components/policy-page";\n\nexport const metadata: Metadata = {\n title: `${brand.accessibility.title} \u2014 ${brand.name}`,\n};\n\nexport default function AccessibilityPage() {\n return <PolicyPage policy={brand.accessibility} />;\n}\n' }, { "path": "app/account/orders/[id]/page.tsx", "kind": "text", "content": 'import { notFound } from "next/navigation";\nimport { AccountSignedOutPrompt, toAccountOrderView } from "@cimplify/sdk/react";\nimport type { EnrichedOrder } from "@cimplify/sdk";\nimport { brand } from "@/lib/brand";\nimport { apiFetch, serverAccessToken, serverSession } from "@/lib/cimplify-server";\nimport { OrderDetail } from "@/components/order-detail";\n\nconst CLIENT_ID = process.env.NEXT_PUBLIC_CIMPLIFY_CLIENT_ID ?? "";\nconst REDIRECT_URI = process.env.CIMPLIFY_REDIRECT_URI ?? "";\n\nexport default async function OrderDetailPage({\n params,\n}: {\n params: Promise<{ id: string }>;\n}) {\n const { id } = await params;\n\n const session = await serverSession();\n if (!session) {\n return (\n <AccountSignedOutPrompt\n clientId={CLIENT_ID}\n redirectUri={REDIRECT_URI}\n eyebrow={brand.account.loginEyebrow}\n title={brand.account.loginTitle}\n description={brand.account.loginSubtitle}\n />\n );\n }\n\n const bearer = await serverAccessToken();\n const order = await apiFetch<EnrichedOrder>(`/api/v1/orders/${encodeURIComponent(id)}`, {\n bearer,\n });\n if (!order) notFound();\n\n return (\n <div className="mx-auto w-full max-w-5xl px-4 py-8 sm:px-6">\n <OrderDetail {...toAccountOrderView(order)} />\n </div>\n );\n}\n' }, { "path": "app/account/orders/page.tsx", "kind": "text", "content": 'import {\n AccountOrdersPage,\n AccountSignedOutPrompt,\n} from "@cimplify/sdk/react";\nimport type { OrderRowData, OrderRowStatus } from "@cimplify/sdk/react";\nimport type { Order, OrderStatus } from "@cimplify/sdk";\nimport { formatMoney } from "@cimplify/sdk";\nimport { brand } from "@/lib/brand";\nimport { ACCOUNT_NAV, apiFetch, serverAccessToken, serverSession } from "@/lib/cimplify-server";\n\nconst CLIENT_ID = process.env.NEXT_PUBLIC_CIMPLIFY_CLIENT_ID ?? "";\nconst REDIRECT_URI = process.env.CIMPLIFY_REDIRECT_URI ?? "";\n\nfunction mapStatus(status: OrderStatus): OrderRowStatus {\n switch (status) {\n case "in_preparation":\n case "ready_to_serve":\n case "partially_served":\n return { kind: "preparing", label: "Preparing" };\n case "delivered":\n return { kind: "delivered" };\n case "picked_up":\n return { kind: "delivered", label: "Picked up" };\n case "served":\n case "completed":\n return { kind: "completed" };\n case "cancelled":\n case "refunded":\n return { kind: "cancelled", label: status === "refunded" ? "Refunded" : undefined };\n case "pending":\n case "created":\n case "confirmed":\n default:\n return { kind: "active", label: "Confirmed" };\n }\n}\n\nfunction relativeMeta(iso: string, orderNumber: string): string {\n const when = new Date(iso);\n const formatted = when.toLocaleDateString(undefined, {\n month: "short",\n day: "numeric",\n year: when.getFullYear() === new Date().getFullYear() ? undefined : "numeric",\n });\n return `Order ${orderNumber} \xB7 ${formatted}`;\n}\n\nfunction toRowData(order: Order): OrderRowData {\n const count = order.items?.length ?? 0;\n return {\n id: order.id,\n href: `/account/orders/${order.id}`,\n summary: count > 0 ? `${count} ${count === 1 ? "item" : "items"}` : "Order",\n meta: relativeMeta(order.created_at, order.user_friendly_id ?? order.id),\n status: mapStatus(order.status),\n total: formatMoney(order.total_price, order.currency),\n };\n}\n\nexport default async function OrdersPage() {\n const session = await serverSession();\n if (!session) {\n return (\n <AccountSignedOutPrompt\n clientId={CLIENT_ID}\n redirectUri={REDIRECT_URI}\n eyebrow={brand.account.loginEyebrow}\n title={brand.account.loginTitle}\n description={brand.account.loginSubtitle}\n />\n );\n }\n\n const bearer = await serverAccessToken();\n const orders = await apiFetch<Order[]>(\n `/v1/link/orders?business_id=${encodeURIComponent(brand.mock.businessId)}`,\n { bearer },\n );\n\n return (\n <AccountOrdersPage\n customerName={session.name ?? ""}\n customerEmail={session.email}\n customerId={session.sub}\n merchantName={brand.name}\n nav={ACCOUNT_NAV}\n orders={(orders ?? []).map(toRowData)}\n />\n );\n}\n' }, { "path": "app/account/page.tsx", "kind": "text", "content": 'import {\n AccountDashboardPage,\n AccountSignedOutPrompt,\n} from "@cimplify/sdk/react";\nimport type { CustomerAddress, CustomerMobileMoney } from "@cimplify/sdk";\nimport { brand } from "@/lib/brand";\nimport { ACCOUNT_NAV, apiFetch, serverAccessToken, serverSession } from "@/lib/cimplify-server";\n\nconst CLIENT_ID = process.env.NEXT_PUBLIC_CIMPLIFY_CLIENT_ID ?? "";\nconst REDIRECT_URI = process.env.CIMPLIFY_REDIRECT_URI ?? "";\n\nexport default async function AccountPage() {\n const session = await serverSession();\n if (!session) {\n return (\n <AccountSignedOutPrompt\n clientId={CLIENT_ID}\n redirectUri={REDIRECT_URI}\n eyebrow={brand.account.loginEyebrow}\n title={brand.account.loginTitle}\n description={brand.account.loginSubtitle}\n />\n );\n }\n\n const bearer = await serverAccessToken();\n const [addresses, wallets] = await Promise.all([\n apiFetch<CustomerAddress[]>("/v1/link/addresses", { bearer }),\n apiFetch<CustomerMobileMoney[]>("/v1/link/mobile-money", { bearer }),\n ]);\n\n return (\n <AccountDashboardPage\n customerName={session.name ?? ""}\n customerEmail={session.email}\n customerId={session.sub}\n merchantName={brand.name}\n nav={ACCOUNT_NAV}\n identitySummary={{\n addresses: addresses?.length ?? 0,\n paymentMethods: wallets?.length ?? 0,\n }}\n />\n );\n}\n' }, { "path": "app/.well-known/ucp/route.ts", "kind": "text", "content": 'import { NextResponse } from "next/server";\n\n/**\n * UCP (Universal Commerce Protocol) manifest discovery endpoint.\n *\n * Agents \u2014 Claude, ChatGPT, Gemini, MCP clients \u2014 probe\n * `https://<your-domain>/.well-known/ucp` to learn what commerce\n * capabilities your storefront supports. We resolve the business\'s\n * UCP handle from the public key (via the storefront API), then forward\n * to Cimplify for the canonical manifest. Edge-cached for an hour\n * because capabilities change rarely.\n */\nconst UCP_API_BASE = "https://api.cimplify.io";\nconst STOREFRONT_API_BASE =\n process.env.NODE_ENV === "production"\n ? "https://storefronts.cimplify.io"\n : "http://127.0.0.1:8787";\n\nexport async function GET() {\n const publicKey = process.env.NEXT_PUBLIC_CIMPLIFY_PUBLIC_KEY;\n if (!publicKey) {\n return NextResponse.json(\n {\n error: "NEXT_PUBLIC_CIMPLIFY_PUBLIC_KEY not set",\n remediation:\n "Set NEXT_PUBLIC_CIMPLIFY_PUBLIC_KEY in .env.local (and your deployment env).",\n },\n { status: 500 },\n );\n }\n\n try {\n // Step 1 \u2014 resolve the business handle from the public key.\n const businessResp = await fetch(`${STOREFRONT_API_BASE}/api/v1/business`, {\n headers: { "X-API-Key": publicKey, "Content-Type": "application/json" },\n next: { revalidate: 3600 },\n });\n\n if (!businessResp.ok) {\n return NextResponse.json(\n { error: `Failed to resolve business: ${businessResp.status}` },\n { status: businessResp.status },\n );\n }\n\n const businessJson = await businessResp.json();\n const handle: string | undefined = businessJson?.data?.handle;\n if (!handle) {\n return NextResponse.json(\n { error: "Business has no handle configured" },\n { status: 500 },\n );\n }\n\n // Step 2 \u2014 fetch the canonical UCP manifest.\n const manifestResp = await fetch(\n `${UCP_API_BASE}/ucp/v1/${handle}/manifest`,\n {\n headers: { "Content-Type": "application/json" },\n next: { revalidate: 3600 },\n },\n );\n\n if (!manifestResp.ok) {\n return NextResponse.json(\n { error: `Upstream UCP manifest fetch failed: ${manifestResp.status}` },\n { status: manifestResp.status },\n );\n }\n\n const manifest = await manifestResp.json();\n return NextResponse.json(manifest, {\n headers: {\n "Content-Type": "application/json",\n "Cache-Control": "public, max-age=3600, s-maxage=3600",\n },\n });\n } catch (error) {\n return NextResponse.json(\n {\n error: "Failed to fetch UCP manifest",\n detail: error instanceof Error ? error.message : String(error),\n },\n { status: 500 },\n );\n }\n}\n' }, { "path": "app/returns/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\nimport { PolicyPage } from "@/components/policy-page";\n\nexport const metadata: Metadata = {\n title: `${brand.returns.title} \u2014 ${brand.name}`,\n};\n\nexport default function ReturnsPage() {\n return <PolicyPage policy={brand.returns} />;\n}\n' }, { "path": "app/orders/[id]/page.tsx", "kind": "text", "content": 'import Link from "next/link";\n\nexport default async function OrderPage({ params }: { params: Promise<{ id: string }> }) {\n const { id } = await params;\n return (\n <section className="max-w-2xl mx-auto px-6 sm:px-8 py-20 text-center">\n <h1 className="text-3xl mt-0 mb-3 font-bold -tracking-[0.025em]">\n Order confirmed.\n </h1>\n <p className="text-muted-foreground">\n Order <code className="font-mono text-foreground">{id}</code> \u2014 you&apos;ll\n get an SMS with tracking within 30 minutes.\n </p>\n <p className="mt-6">\n <Link\n href="/"\n className="inline-block px-6 py-3 rounded-md bg-primary text-primary-foreground font-semibold text-sm transition-colors hover:bg-primary/90"\n >\n Continue shopping\n </Link>\n </p>\n </section>\n );\n}\n' }, { "path": "app/globals.css", "kind": "text", "content": `@import "tailwindcss";
1443
+ ` }, { "path": "lib/cimplify-server.ts", "kind": "text", "content": 'import { cookies } from "next/headers";\nimport {\n getAccessTokenFromCookieHeader,\n getSessionFromCookieHeader,\n} from "@cimplify/sdk/server";\nimport type { NavItem } from "@cimplify/sdk/react";\n\nexport const ACCOUNT_NAV: NavItem[] = [\n { label: "Dashboard", href: "/account" },\n { label: "Orders", href: "/account/orders" },\n { label: "Cimplify identity", href: "https://link.cimplify.io", external: true },\n];\n\nconst CLIENT_ID = process.env.CIMPLIFY_CLIENT_ID ?? "";\nconst ISSUER = (process.env.CIMPLIFY_ISSUER ?? "https://api.cimplify.io").replace(/\\/$/, "");\n\nconst OIDC_CONFIG = { clientId: CLIENT_ID, issuer: ISSUER };\n\nasync function cookieHeader(): Promise<string> {\n const store = await cookies();\n return store.getAll().map((c) => `${c.name}=${c.value}`).join("; ");\n}\n\nexport async function serverSession() {\n return getSessionFromCookieHeader(OIDC_CONFIG, await cookieHeader());\n}\n\nexport async function serverAccessToken(): Promise<string | null> {\n return getAccessTokenFromCookieHeader(OIDC_CONFIG, await cookieHeader());\n}\n\ninterface Envelope<T> {\n data: T;\n}\n\nexport async function apiFetch<T>(\n path: string,\n init: RequestInit & { bearer: string | null },\n): Promise<T | null> {\n if (!init.bearer) return null;\n const { bearer, ...rest } = init;\n const headers = new Headers(rest.headers);\n headers.set("Authorization", `Bearer ${bearer}`);\n headers.set("Accept", "application/json");\n if (rest.body && !headers.has("Content-Type")) {\n headers.set("Content-Type", "application/json");\n }\n const res = await fetch(`${ISSUER}${path}`, { ...rest, headers, cache: "no-store" });\n if (!res.ok) return null;\n const envelope = (await res.json()) as Envelope<T>;\n return envelope.data;\n}\n' }, { "path": "lib/cimplify-loader.ts", "kind": "text", "content": 'import type { ImageLoader } from "next/image";\n\nimport { assetUrl, isCimplifyAsset } from "@cimplify/sdk";\n\nconst cdnBase = process.env.NEXT_PUBLIC_CIMPLIFY_CDN_URL?.trim() || undefined;\n\nconst cimplifyImageLoader: ImageLoader = ({ src, width, quality }) => {\n if (isCimplifyAsset(src, cdnBase)) {\n return assetUrl(src, {\n base: cdnBase,\n w: width,\n quality,\n format: "auto",\n });\n }\n return src;\n};\n\nexport default cimplifyImageLoader;\n' }, { "path": "lib/site-url.ts", "kind": "text", "content": 'import { SITE_URL } from "./site.config";\n\n/** Canonical absolute URL for this storefront. */\nexport async function getSiteUrl(): Promise<string> {\n return SITE_URL;\n}\n' }, { "path": ".env.example", "kind": "text", "content": "# Your tenant public key. Get it from the desk's Developers tab.\n# `cpk_live_*` / `cpk_test_*` keys auto-route same-origin requests\n# to hosted Cimplify in both dev and prod. Anything else (`mock-dev`,\n# empty) falls back to the local `cimplify dev` mock.\nNEXT_PUBLIC_CIMPLIFY_PUBLIC_KEY=mock-dev\n\n# Optional. Forces a fixed canonical URL for sitemap.xml, robots.txt,\n# OpenGraph, and llms.txt. Leave unset and the storefront derives the\n# canonical from the request `Host` header automatically.\n# NEXT_PUBLIC_SITE_URL=\n" }, { "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__/brand.test.ts", "kind": "text", "content": 'import { createBrandSuite } from "@cimplify/sdk/testing/suite";\nimport { brand } from "../lib/brand";\n\ncreateBrandSuite({ brand });\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.101.1",\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": "metadata.json", "kind": "text", "content": '{\n "id": "retail",\n "name": "Retail",\n "tagline": "Variant-aware retail storefront with full product detail pages and instalment support.",\n "industry": "retail",\n "tags": ["retail", "electronics", "variants"],\n "stability": "stable",\n "schemaType": "Store",\n "mock": {\n "seedName": "retail",\n "seedBusinessId": "bus_currents_electronics"\n }\n}\n' }, { "path": "app/search/search-client.tsx", "kind": "text", "content": '"use client";\n\nimport { SearchPage } from "@cimplify/sdk/react";\n\nexport function SearchClient() {\n return <SearchPage />;\n}\n' }, { "path": "app/search/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Suspense } from "react";\nimport { SearchClient } from "./search-client";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Search \u2014 ${brand.name}`,\n description: `Search ${brand.name} \u2014 products, collections, categories.`,\n};\n\nexport default function SearchPage() {\n return (\n <article className="max-w-7xl mx-auto px-6 sm:px-8 py-10">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-2">\n Search\n </p>\n <h1 className="font-serif text-[clamp(2rem,4vw,2.75rem)] font-semibold mb-8 -tracking-[0.02em]">\n Find anything.\n </h1>\n <Suspense fallback={<SearchSkeleton />}>\n <SearchClient />\n </Suspense>\n </article>\n );\n}\n\nfunction SearchSkeleton() {\n return (\n <div>\n <div className="h-12 w-full max-w-xl bg-muted rounded animate-pulse mb-8" />\n <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3 sm:gap-4">\n {Array.from({ length: 8 }).map((_, i) => (\n <div key={i} className="aspect-[4/3] bg-muted rounded-2xl animate-pulse" />\n ))}\n </div>\n </div>\n );\n}\n' }, { "path": "app/categories/[slug]/listing-client.tsx", "kind": "text", "content": '"use client";\n\nimport { ProductGrid } from "@cimplify/sdk/react";\nimport type { Product } from "@cimplify/sdk";\nimport { StoreProductCard } from "@/components/store-product-card";\n\n/**\n * Client island for the category listing. Receives server-fetched products\n * as props (serializable) and owns the `renderCard` function.\n */\nexport function ListingClient({ products }: { products: Product[] }) {\n return (\n <ProductGrid\n products={products}\n emptyMessage="No products in this category yet."\n renderCard={(p) => <StoreProductCard product={p} />}\n />\n );\n}\n' }, { "path": "app/categories/[slug]/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Suspense } from "react";\nimport Link from "next/link";\nimport { notFound } from "next/navigation";\nimport {\n getServerClient,\n tags,\n type Category,\n type Product,\n} from "@cimplify/sdk/server";\nimport { ListingClient } from "./listing-client";\nimport { brand } from "@/lib/brand";\n\n// See app/products/[slug]/page.tsx for the rationale on generateStaticParams.\nexport async function generateStaticParams() {\n const r = await getServerClient().catalogue.getCategories();\n if (!r.ok || r.value.length === 0) {\n return [{ slug: "__placeholder__" }];\n }\n return r.value.map((c) => ({ slug: c.slug ?? c.id }));\n}\n\nexport const revalidate = 3600;\n\ninterface CategoryData {\n category: Category;\n products: Product[];\n}\n\ntype CategoryResult =\n | { ok: true; data: CategoryData }\n | { ok: false; code: string };\n\nasync function getCategory(slug: string): Promise<CategoryResult> {\n const client = getServerClient();\n const catRes = await client.catalogue.getCategoryBySlug(slug, {\n cacheOptions: { revalidate: 3600, tags: [tags.categories()] },\n });\n if (!catRes.ok) return { ok: false, code: catRes.error.code };\n\n const r = await client.catalogue.getCategoryProducts(catRes.value.id, undefined, {\n cacheOptions: {\n revalidate: 3600,\n tags: [\n tags.products(),\n tags.category(catRes.value.id),\n tags.categoryProducts(catRes.value.id),\n ],\n },\n });\n const products = r.ok\n ? ((r.value as { items?: Product[] }).items ?? (r.value as Product[]))\n : [];\n return { ok: true, data: { category: catRes.value, products } };\n}\n\nexport async function generateMetadata({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}): Promise<Metadata> {\n const { slug } = await params;\n const result = await getCategory(slug);\n if (!result.ok) return {};\n const data = result.data;\n return {\n title: `${data.category.name} \u2014 ${brand.name}`,\n description: data.category.description ?? undefined,\n };\n}\n\nexport default async function CategoryPage({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}) {\n return (\n <Suspense fallback={<CategorySkeleton />}>\n <CategoryContent params={params} />\n </Suspense>\n );\n}\n\nasync function CategoryContent({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}) {\n const { slug } = await params;\n const result = await getCategory(slug);\n if (!result.ok) {\n if (result.code === "NOT_FOUND") notFound();\n return <CategorySkeleton />;\n }\n const { category, products } = result.data;\n return (\n <>\n <section className="bg-foreground text-background relative overflow-hidden">\n <div className="absolute inset-0 opacity-[0.04] pointer-events-none [background-image:radial-gradient(circle_at_2px_2px,white_1px,transparent_0)] [background-size:32px_32px]" />\n <div className="relative max-w-7xl mx-auto px-6 sm:px-8 py-12 sm:py-14">\n <nav className="text-[12px] font-mono text-background/60 mb-3 flex items-center gap-2">\n <Link href="/" className="hover:text-background transition-colors">Home</Link>\n <span>/</span>\n <Link href="/shop" className="hover:text-background transition-colors">Shop</Link>\n <span>/</span>\n <span className="text-background/90">{category.name}</span>\n </nav>\n <h1 className="text-[clamp(2rem,4vw,3rem)] font-bold m-0 -tracking-[0.025em]">\n {category.name}\n </h1>\n {category.description && (\n <p className="mt-3 max-w-2xl text-base text-background/75">\n {category.description}\n </p>\n )}\n <p className="mt-4 text-[11px] font-mono uppercase tracking-[0.16em] text-background/60 tabular-nums">\n {products.length} {products.length === 1 ? "product" : "products"}\n </p>\n </div>\n </section>\n <section className="max-w-7xl mx-auto px-6 sm:px-8 py-10 sm:py-12">\n <ListingClient products={products} />\n {products.length === 0 && (\n <p className="text-center mt-8">\n <Link href="/shop" className="text-primary font-semibold hover:underline">\n \u2190 Browse all products\n </Link>\n </p>\n )}\n </section>\n </>\n );\n}\n\nfunction CategorySkeleton() {\n return (\n <>\n <section className="bg-foreground py-12 sm:py-14">\n <div className="max-w-7xl mx-auto px-6 sm:px-8">\n <div className="h-3 w-32 bg-background/20 rounded mb-3 animate-pulse" />\n <div className="h-12 w-72 bg-background/20 rounded animate-pulse" />\n </div>\n </section>\n <section className="max-w-7xl mx-auto px-6 sm:px-8 py-10 sm:py-12">\n <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3 sm:gap-4">\n {Array.from({ length: 8 }).map((_, i) => (\n <div key={i} className="aspect-[4/3] bg-muted rounded-2xl animate-pulse" />\n ))}\n </div>\n </section>\n </>\n );\n}\n' }, { "path": "app/terms/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Terms of Service \u2014 ${brand.name}`,\n description: `The rules of the road for ordering from ${brand.name}.`,\n};\n\nexport default function TermsPage() {\n const t = brand.terms;\n return (\n <article className="max-w-3xl mx-auto px-6 sm:px-8 py-16 prose prose-lg max-w-none">\n <p className="text-[11px] font-mono uppercase tracking-[0.16em] text-primary mb-2 not-prose">\n {t.eyebrow}\n </p>\n <h1 className="text-[clamp(2.25rem,5vw,3.5rem)] font-bold mb-2 -tracking-[0.025em]">\n {t.title}\n </h1>\n <p className="text-sm text-muted-foreground not-prose mb-10">\n Last updated: {t.lastUpdated}\n </p>\n\n <section className="space-y-5 leading-relaxed text-foreground/90">\n {t.sections.map((s) => (\n <div key={s.heading}>\n <h2 className="text-2xl font-semibold mt-0 -tracking-[0.02em]">{s.heading}</h2>\n {typeof s.body === "string" ? (\n <p>{s.body}</p>\n ) : (\n <>\n <p>{s.body.intro}</p>\n <ul className="list-disc pl-6 space-y-2">\n {s.body.bullets.map((b) => (\n <li key={b}>{b}</li>\n ))}\n </ul>\n </>\n )}\n </div>\n ))}\n </section>\n </article>\n );\n}\n' }, { "path": "app/not-found.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport Link from "next/link";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Page not found \u2014 ${brand.name}`,\n description: "We couldn\'t find that page.",\n};\n\nexport default function NotFound() {\n return (\n <section className="max-w-2xl mx-auto px-6 sm:px-8 py-20 text-center">\n <p className="text-[11px] font-mono uppercase tracking-[0.16em] text-primary mb-3">\n 404\n </p>\n <h1 className="text-[clamp(2.5rem,5vw,4rem)] font-bold mb-4 -tracking-[0.03em]">\n Page not found.\n </h1>\n <p className="text-muted-foreground leading-relaxed mb-8">\n The URL you followed might be old, a typo, or pointing at a product\n that&apos;s no longer in stock. Try the menu or head back home.\n </p>\n <div className="flex flex-wrap items-center justify-center gap-3">\n <Link\n href="/shop"\n className="inline-flex items-center gap-2 px-5 py-2.5 rounded-md bg-primary text-primary-foreground font-semibold text-sm hover:bg-primary/90 transition-colors"\n >\n Browse the shop\n </Link>\n <Link\n href="/"\n className="inline-flex items-center gap-2 px-5 py-2.5 rounded-md border border-border hover:bg-muted transition-colors text-sm font-medium"\n >\n Back home\n </Link>\n </div>\n </section>\n );\n}\n' }, { "path": "app/cart/page.tsx", "kind": "text", "content": '"use client";\n\nimport { useRouter } from "next/navigation";\nimport { CartPage as SdkCartPage } from "@cimplify/sdk/react";\n\nexport default function CartPage() {\n const router = useRouter();\n return <SdkCartPage onCheckout={() => router.push("/checkout")} />;\n}\n' }, { "path": "app/accessibility/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\nimport { PolicyPage } from "@/components/policy-page";\n\nexport const metadata: Metadata = {\n title: `${brand.accessibility.title} \u2014 ${brand.name}`,\n};\n\nexport default function AccessibilityPage() {\n return <PolicyPage policy={brand.accessibility} />;\n}\n' }, { "path": "app/account/orders/[id]/page.tsx", "kind": "text", "content": 'import { notFound } from "next/navigation";\nimport { AccountSignedOutPrompt, toAccountOrderView } from "@cimplify/sdk/react";\nimport type { EnrichedOrder } from "@cimplify/sdk";\nimport { brand } from "@/lib/brand";\nimport { apiFetch, serverAccessToken, serverSession } from "@/lib/cimplify-server";\nimport { OrderDetail } from "@/components/order-detail";\n\nconst CLIENT_ID = process.env.NEXT_PUBLIC_CIMPLIFY_CLIENT_ID ?? "";\nconst REDIRECT_URI = process.env.CIMPLIFY_REDIRECT_URI ?? "";\n\nexport default async function OrderDetailPage({\n params,\n}: {\n params: Promise<{ id: string }>;\n}) {\n const { id } = await params;\n\n const session = await serverSession();\n if (!session) {\n return (\n <AccountSignedOutPrompt\n clientId={CLIENT_ID}\n redirectUri={REDIRECT_URI}\n eyebrow={brand.account.loginEyebrow}\n title={brand.account.loginTitle}\n description={brand.account.loginSubtitle}\n />\n );\n }\n\n const bearer = await serverAccessToken();\n const order = await apiFetch<EnrichedOrder>(`/api/v1/orders/${encodeURIComponent(id)}`, {\n bearer,\n });\n if (!order) notFound();\n\n return (\n <div className="mx-auto w-full max-w-5xl px-4 py-8 sm:px-6">\n <OrderDetail {...toAccountOrderView(order)} />\n </div>\n );\n}\n' }, { "path": "app/account/orders/page.tsx", "kind": "text", "content": 'import {\n AccountOrdersPage,\n AccountSignedOutPrompt,\n} from "@cimplify/sdk/react";\nimport type { OrderRowData, OrderRowStatus } from "@cimplify/sdk/react";\nimport type { Order, OrderStatus } from "@cimplify/sdk";\nimport { formatMoney } from "@cimplify/sdk";\nimport { brand } from "@/lib/brand";\nimport { ACCOUNT_NAV, apiFetch, serverAccessToken, serverSession } from "@/lib/cimplify-server";\n\nconst CLIENT_ID = process.env.NEXT_PUBLIC_CIMPLIFY_CLIENT_ID ?? "";\nconst REDIRECT_URI = process.env.CIMPLIFY_REDIRECT_URI ?? "";\n\nfunction mapStatus(status: OrderStatus): OrderRowStatus {\n switch (status) {\n case "in_preparation":\n case "ready_to_serve":\n case "partially_served":\n return { kind: "preparing", label: "Preparing" };\n case "delivered":\n return { kind: "delivered" };\n case "picked_up":\n return { kind: "delivered", label: "Picked up" };\n case "served":\n case "completed":\n return { kind: "completed" };\n case "cancelled":\n case "refunded":\n return { kind: "cancelled", label: status === "refunded" ? "Refunded" : undefined };\n case "pending":\n case "created":\n case "confirmed":\n default:\n return { kind: "active", label: "Confirmed" };\n }\n}\n\nfunction relativeMeta(iso: string, orderNumber: string): string {\n const when = new Date(iso);\n const formatted = when.toLocaleDateString(undefined, {\n month: "short",\n day: "numeric",\n year: when.getFullYear() === new Date().getFullYear() ? undefined : "numeric",\n });\n return `Order ${orderNumber} \xB7 ${formatted}`;\n}\n\nfunction toRowData(order: Order): OrderRowData {\n const count = order.items?.length ?? 0;\n return {\n id: order.id,\n href: `/account/orders/${order.id}`,\n summary: count > 0 ? `${count} ${count === 1 ? "item" : "items"}` : "Order",\n meta: relativeMeta(order.created_at, order.user_friendly_id ?? order.id),\n status: mapStatus(order.status),\n total: formatMoney(order.total_price, order.currency),\n };\n}\n\nexport default async function OrdersPage() {\n const session = await serverSession();\n if (!session) {\n return (\n <AccountSignedOutPrompt\n clientId={CLIENT_ID}\n redirectUri={REDIRECT_URI}\n eyebrow={brand.account.loginEyebrow}\n title={brand.account.loginTitle}\n description={brand.account.loginSubtitle}\n />\n );\n }\n\n const bearer = await serverAccessToken();\n const orders = await apiFetch<Order[]>(\n `/v1/link/orders?business_id=${encodeURIComponent(brand.mock.businessId)}`,\n { bearer },\n );\n\n return (\n <AccountOrdersPage\n customerName={session.name ?? ""}\n customerEmail={session.email}\n customerId={session.sub}\n merchantName={brand.name}\n nav={ACCOUNT_NAV}\n orders={(orders ?? []).map(toRowData)}\n />\n );\n}\n' }, { "path": "app/account/page.tsx", "kind": "text", "content": 'import {\n AccountDashboardPage,\n AccountSignedOutPrompt,\n} from "@cimplify/sdk/react";\nimport type { CustomerAddress, CustomerMobileMoney } from "@cimplify/sdk";\nimport { brand } from "@/lib/brand";\nimport { ACCOUNT_NAV, apiFetch, serverAccessToken, serverSession } from "@/lib/cimplify-server";\n\nconst CLIENT_ID = process.env.NEXT_PUBLIC_CIMPLIFY_CLIENT_ID ?? "";\nconst REDIRECT_URI = process.env.CIMPLIFY_REDIRECT_URI ?? "";\n\nexport default async function AccountPage() {\n const session = await serverSession();\n if (!session) {\n return (\n <AccountSignedOutPrompt\n clientId={CLIENT_ID}\n redirectUri={REDIRECT_URI}\n eyebrow={brand.account.loginEyebrow}\n title={brand.account.loginTitle}\n description={brand.account.loginSubtitle}\n />\n );\n }\n\n const bearer = await serverAccessToken();\n const [addresses, wallets] = await Promise.all([\n apiFetch<CustomerAddress[]>("/v1/link/addresses", { bearer }),\n apiFetch<CustomerMobileMoney[]>("/v1/link/mobile-money", { bearer }),\n ]);\n\n return (\n <AccountDashboardPage\n customerName={session.name ?? ""}\n customerEmail={session.email}\n customerId={session.sub}\n merchantName={brand.name}\n nav={ACCOUNT_NAV}\n identitySummary={{\n addresses: addresses?.length ?? 0,\n paymentMethods: wallets?.length ?? 0,\n }}\n />\n );\n}\n' }, { "path": "app/.well-known/ucp/route.ts", "kind": "text", "content": 'import { NextResponse } from "next/server";\n\n/**\n * UCP (Universal Commerce Protocol) manifest discovery endpoint.\n *\n * Agents \u2014 Claude, ChatGPT, Gemini, MCP clients \u2014 probe\n * `https://<your-domain>/.well-known/ucp` to learn what commerce\n * capabilities your storefront supports. We resolve the business\'s\n * UCP handle from the public key (via the storefront API), then forward\n * to Cimplify for the canonical manifest. Edge-cached for an hour\n * because capabilities change rarely.\n */\nconst UCP_API_BASE = "https://api.cimplify.io";\nconst STOREFRONT_API_BASE =\n process.env.NODE_ENV === "production"\n ? "https://storefronts.cimplify.io"\n : "http://127.0.0.1:8787";\n\nexport async function GET() {\n const publicKey = process.env.NEXT_PUBLIC_CIMPLIFY_PUBLIC_KEY;\n if (!publicKey) {\n return NextResponse.json(\n {\n error: "NEXT_PUBLIC_CIMPLIFY_PUBLIC_KEY not set",\n remediation:\n "Set NEXT_PUBLIC_CIMPLIFY_PUBLIC_KEY in .env.local (and your deployment env).",\n },\n { status: 500 },\n );\n }\n\n try {\n // Step 1 \u2014 resolve the business handle from the public key.\n const businessResp = await fetch(`${STOREFRONT_API_BASE}/api/v1/business`, {\n headers: { "X-API-Key": publicKey, "Content-Type": "application/json" },\n next: { revalidate: 3600 },\n });\n\n if (!businessResp.ok) {\n return NextResponse.json(\n { error: `Failed to resolve business: ${businessResp.status}` },\n { status: businessResp.status },\n );\n }\n\n const businessJson = await businessResp.json();\n const handle: string | undefined = businessJson?.data?.handle;\n if (!handle) {\n return NextResponse.json(\n { error: "Business has no handle configured" },\n { status: 500 },\n );\n }\n\n // Step 2 \u2014 fetch the canonical UCP manifest.\n const manifestResp = await fetch(\n `${UCP_API_BASE}/ucp/v1/${handle}/manifest`,\n {\n headers: { "Content-Type": "application/json" },\n next: { revalidate: 3600 },\n },\n );\n\n if (!manifestResp.ok) {\n return NextResponse.json(\n { error: `Upstream UCP manifest fetch failed: ${manifestResp.status}` },\n { status: manifestResp.status },\n );\n }\n\n const manifest = await manifestResp.json();\n return NextResponse.json(manifest, {\n headers: {\n "Content-Type": "application/json",\n "Cache-Control": "public, max-age=3600, s-maxage=3600",\n },\n });\n } catch (error) {\n return NextResponse.json(\n {\n error: "Failed to fetch UCP manifest",\n detail: error instanceof Error ? error.message : String(error),\n },\n { status: 500 },\n );\n }\n}\n' }, { "path": "app/returns/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\nimport { PolicyPage } from "@/components/policy-page";\n\nexport const metadata: Metadata = {\n title: `${brand.returns.title} \u2014 ${brand.name}`,\n};\n\nexport default function ReturnsPage() {\n return <PolicyPage policy={brand.returns} />;\n}\n' }, { "path": "app/orders/[id]/page.tsx", "kind": "text", "content": 'import Link from "next/link";\n\nexport default async function OrderPage({ params }: { params: Promise<{ id: string }> }) {\n const { id } = await params;\n return (\n <section className="max-w-2xl mx-auto px-6 sm:px-8 py-20 text-center">\n <h1 className="text-3xl mt-0 mb-3 font-bold -tracking-[0.025em]">\n Order confirmed.\n </h1>\n <p className="text-muted-foreground">\n Order <code className="font-mono text-foreground">{id}</code> \u2014 you&apos;ll\n get an SMS with tracking within 30 minutes.\n </p>\n <p className="mt-6">\n <Link\n href="/"\n className="inline-block px-6 py-3 rounded-md bg-primary text-primary-foreground font-semibold text-sm transition-colors hover:bg-primary/90"\n >\n Continue shopping\n </Link>\n </p>\n </section>\n );\n}\n' }, { "path": "app/globals.css", "kind": "text", "content": `@import "tailwindcss";
1444
1444
 
1445
1445
  /* Scan the SDK's bundled JS so utility classes used inside its components
1446
1446
  land in the compiled stylesheet alongside our own. */
@@ -2518,7 +2518,7 @@ export const brand: Brand = {
2518
2518
  businessId: "bus_wellspring_pharmacy",
2519
2519
  },
2520
2520
  };
2521
- ` }, { "path": "lib/cimplify-server.ts", "kind": "text", "content": 'import { cookies } from "next/headers";\nimport {\n getAccessTokenFromCookieHeader,\n getSessionFromCookieHeader,\n} from "@cimplify/sdk/server";\nimport type { NavItem } from "@cimplify/sdk/react";\n\nexport const ACCOUNT_NAV: NavItem[] = [\n { label: "Dashboard", href: "/account" },\n { label: "Orders", href: "/account/orders" },\n { label: "Cimplify identity", href: "https://link.cimplify.io", external: true },\n];\n\nconst CLIENT_ID = process.env.CIMPLIFY_CLIENT_ID ?? "";\nconst ISSUER = (process.env.CIMPLIFY_ISSUER ?? "https://api.cimplify.io").replace(/\\/$/, "");\n\nconst OIDC_CONFIG = { clientId: CLIENT_ID, issuer: ISSUER };\n\nasync function cookieHeader(): Promise<string> {\n const store = await cookies();\n return store.getAll().map((c) => `${c.name}=${c.value}`).join("; ");\n}\n\nexport async function serverSession() {\n return getSessionFromCookieHeader(OIDC_CONFIG, await cookieHeader());\n}\n\nexport async function serverAccessToken(): Promise<string | null> {\n return getAccessTokenFromCookieHeader(OIDC_CONFIG, await cookieHeader());\n}\n\ninterface Envelope<T> {\n data: T;\n}\n\nexport async function apiFetch<T>(\n path: string,\n init: RequestInit & { bearer: string | null },\n): Promise<T | null> {\n if (!init.bearer) return null;\n const { bearer, ...rest } = init;\n const headers = new Headers(rest.headers);\n headers.set("Authorization", `Bearer ${bearer}`);\n headers.set("Accept", "application/json");\n if (rest.body && !headers.has("Content-Type")) {\n headers.set("Content-Type", "application/json");\n }\n const res = await fetch(`${ISSUER}${path}`, { ...rest, headers, cache: "no-store" });\n if (!res.ok) return null;\n const envelope = (await res.json()) as Envelope<T>;\n return envelope.data;\n}\n' }, { "path": "lib/cimplify-loader.ts", "kind": "text", "content": 'import type { ImageLoader } from "next/image";\n\nimport { assetUrl, isCimplifyAsset } from "@cimplify/sdk";\n\nconst cdnBase = process.env.NEXT_PUBLIC_CIMPLIFY_CDN_URL?.trim() || undefined;\n\nconst cimplifyImageLoader: ImageLoader = ({ src, width, quality }) => {\n if (isCimplifyAsset(src, cdnBase)) {\n return assetUrl(src, {\n base: cdnBase,\n w: width,\n quality,\n format: "auto",\n });\n }\n return src;\n};\n\nexport default cimplifyImageLoader;\n' }, { "path": "lib/site-url.ts", "kind": "text", "content": 'import { SITE_URL } from "./site.config";\n\n/** Canonical absolute URL for this storefront. */\nexport async function getSiteUrl(): Promise<string> {\n return SITE_URL;\n}\n' }, { "path": ".env.example", "kind": "text", "content": "# Your tenant public key. Get it from the desk's Developers tab.\n# `cpk_live_*` / `cpk_test_*` keys auto-route same-origin requests\n# to hosted Cimplify in both dev and prod. Anything else (`mock-dev`,\n# empty) falls back to the local `cimplify dev` mock.\nNEXT_PUBLIC_CIMPLIFY_PUBLIC_KEY=mock-dev\n\n# Optional. Forces a fixed canonical URL for sitemap.xml, robots.txt,\n# OpenGraph, and llms.txt. Leave unset and the storefront derives the\n# canonical from the request `Host` header automatically.\n# NEXT_PUBLIC_SITE_URL=\n" }, { "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__/brand.test.ts", "kind": "text", "content": 'import { createBrandSuite } from "@cimplify/sdk/testing/suite";\nimport { brand } from "../lib/brand";\n\ncreateBrandSuite({ brand });\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.100.4",\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": "metadata.json", "kind": "text", "content": '{\n "id": "pharmacy",\n "name": "Pharmacy",\n "tagline": "Licensed community pharmacy with prescription uploads, pharmacist consults, and OTC catalog.",\n "industry": "healthcare",\n "tags": ["healthcare", "regulated", "prescription"],\n "stability": "stable",\n "schemaType": "Pharmacy",\n "mock": {\n "seedName": "pharmacy",\n "seedBusinessId": "bus_wellspring_pharmacy"\n }\n}\n' }, { "path": "app/search/search-client.tsx", "kind": "text", "content": '"use client";\n\nimport { SearchPage } from "@cimplify/sdk/react";\n\nexport function SearchClient() {\n return <SearchPage />;\n}\n' }, { "path": "app/search/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Suspense } from "react";\nimport { SearchClient } from "./search-client";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Search \u2014 ${brand.name}`,\n description: `Search ${brand.name} \u2014 products, collections, categories.`,\n};\n\nexport default function SearchPage() {\n return (\n <article className="max-w-7xl mx-auto px-6 sm:px-8 py-10">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-2">\n Search\n </p>\n <h1 className="font-serif text-[clamp(2rem,4vw,2.75rem)] font-semibold mb-8 -tracking-[0.02em]">\n Find anything.\n </h1>\n <Suspense fallback={<SearchSkeleton />}>\n <SearchClient />\n </Suspense>\n </article>\n );\n}\n\nfunction SearchSkeleton() {\n return (\n <div>\n <div className="h-12 w-full max-w-xl bg-muted rounded animate-pulse mb-8" />\n <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3 sm:gap-4">\n {Array.from({ length: 8 }).map((_, i) => (\n <div key={i} className="aspect-[4/3] bg-muted rounded-2xl animate-pulse" />\n ))}\n </div>\n </div>\n );\n}\n' }, { "path": "app/categories/[slug]/listing-client.tsx", "kind": "text", "content": '"use client";\n\nimport { ProductGrid } from "@cimplify/sdk/react";\nimport type { Product } from "@cimplify/sdk";\nimport { StoreProductCard } from "@/components/store-product-card";\n\n/**\n * Client island for the category listing. Receives server-fetched products\n * as props (serializable) and owns the `renderCard` function.\n */\nexport function ListingClient({ products }: { products: Product[] }) {\n return (\n <ProductGrid\n products={products}\n emptyMessage="No products in this category yet."\n renderCard={(p) => <StoreProductCard product={p} />}\n />\n );\n}\n' }, { "path": "app/categories/[slug]/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Suspense } from "react";\nimport Link from "next/link";\nimport { notFound } from "next/navigation";\nimport {\n getServerClient,\n tags,\n type Category,\n type Product,\n} from "@cimplify/sdk/server";\nimport { ListingClient } from "./listing-client";\nimport { brand } from "@/lib/brand";\n\n// See app/products/[slug]/page.tsx for the rationale on generateStaticParams.\nexport async function generateStaticParams() {\n const r = await getServerClient().catalogue.getCategories();\n if (!r.ok || r.value.length === 0) {\n return [{ slug: "__placeholder__" }];\n }\n return r.value.map((c) => ({ slug: c.slug ?? c.id }));\n}\n\nexport const revalidate = 3600;\n\ninterface CategoryData {\n category: Category;\n products: Product[];\n}\n\ntype CategoryResult =\n | { ok: true; data: CategoryData }\n | { ok: false; code: string };\n\nasync function getCategory(slug: string): Promise<CategoryResult> {\n const client = getServerClient();\n const catRes = await client.catalogue.getCategoryBySlug(slug, {\n cacheOptions: { revalidate: 3600, tags: [tags.categories()] },\n });\n if (!catRes.ok) return { ok: false, code: catRes.error.code };\n\n const r = await client.catalogue.getCategoryProducts(catRes.value.id, undefined, {\n cacheOptions: {\n revalidate: 3600,\n tags: [\n tags.products(),\n tags.category(catRes.value.id),\n tags.categoryProducts(catRes.value.id),\n ],\n },\n });\n const products = r.ok\n ? ((r.value as { items?: Product[] }).items ?? (r.value as Product[]))\n : [];\n return { ok: true, data: { category: catRes.value, products } };\n}\n\nexport async function generateMetadata({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}): Promise<Metadata> {\n const { slug } = await params;\n const result = await getCategory(slug);\n if (!result.ok) return {};\n const data = result.data;\n return {\n title: `${data.category.name} \u2014 ${brand.name}`,\n description: data.category.description ?? undefined,\n };\n}\n\nexport default async function CategoryPage({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}) {\n return (\n <Suspense fallback={<CategorySkeleton />}>\n <CategoryContent params={params} />\n </Suspense>\n );\n}\n\nasync function CategoryContent({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}) {\n const { slug } = await params;\n const result = await getCategory(slug);\n if (!result.ok) {\n if (result.code === "NOT_FOUND") notFound();\n return <CategorySkeleton />;\n }\n const { category, products } = result.data;\n return (\n <>\n <section className="bg-foreground text-background relative overflow-hidden">\n <div className="absolute inset-0 opacity-[0.04] pointer-events-none [background-image:radial-gradient(circle_at_2px_2px,white_1px,transparent_0)] [background-size:32px_32px]" />\n <div className="relative max-w-7xl mx-auto px-6 sm:px-8 py-12 sm:py-14">\n <nav className="text-[12px] font-mono text-background/60 mb-3 flex items-center gap-2">\n <Link href="/" className="hover:text-background transition-colors">Home</Link>\n <span>/</span>\n <Link href="/shop" className="hover:text-background transition-colors">Shop</Link>\n <span>/</span>\n <span className="text-background/90">{category.name}</span>\n </nav>\n <h1 className="text-[clamp(2rem,4vw,3rem)] font-bold m-0 -tracking-[0.025em]">\n {category.name}\n </h1>\n {category.description && (\n <p className="mt-3 max-w-2xl text-base text-background/75">\n {category.description}\n </p>\n )}\n <p className="mt-4 text-[11px] font-mono uppercase tracking-[0.16em] text-background/60 tabular-nums">\n {products.length} {products.length === 1 ? "product" : "products"}\n </p>\n </div>\n </section>\n <section className="max-w-7xl mx-auto px-6 sm:px-8 py-10 sm:py-12">\n <ListingClient products={products} />\n {products.length === 0 && (\n <p className="text-center mt-8">\n <Link href="/shop" className="text-primary font-semibold hover:underline">\n \u2190 Browse all products\n </Link>\n </p>\n )}\n </section>\n </>\n );\n}\n\nfunction CategorySkeleton() {\n return (\n <>\n <section className="bg-foreground py-12 sm:py-14">\n <div className="max-w-7xl mx-auto px-6 sm:px-8">\n <div className="h-3 w-32 bg-background/20 rounded mb-3 animate-pulse" />\n <div className="h-12 w-72 bg-background/20 rounded animate-pulse" />\n </div>\n </section>\n <section className="max-w-7xl mx-auto px-6 sm:px-8 py-10 sm:py-12">\n <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3 sm:gap-4">\n {Array.from({ length: 8 }).map((_, i) => (\n <div key={i} className="aspect-[4/3] bg-muted rounded-2xl animate-pulse" />\n ))}\n </div>\n </section>\n </>\n );\n}\n' }, { "path": "app/terms/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Terms of Service \u2014 ${brand.name}`,\n description: `The rules of the road for ordering from ${brand.name}.`,\n};\n\nexport default function TermsPage() {\n const t = brand.terms;\n return (\n <article className="max-w-3xl mx-auto px-6 sm:px-8 py-16 prose prose-lg max-w-none">\n <p className="text-[11px] font-mono uppercase tracking-[0.16em] text-primary mb-2 not-prose">\n {t.eyebrow}\n </p>\n <h1 className="text-[clamp(2.25rem,5vw,3.5rem)] font-bold mb-2 -tracking-[0.025em]">\n {t.title}\n </h1>\n <p className="text-sm text-muted-foreground not-prose mb-10">\n Last updated: {t.lastUpdated}\n </p>\n\n <section className="space-y-5 leading-relaxed text-foreground/90">\n {t.sections.map((s) => (\n <div key={s.heading}>\n <h2 className="text-2xl font-semibold mt-0 -tracking-[0.02em]">{s.heading}</h2>\n {typeof s.body === "string" ? (\n <p>{s.body}</p>\n ) : (\n <>\n <p>{s.body.intro}</p>\n <ul className="list-disc pl-6 space-y-2">\n {s.body.bullets.map((b) => (\n <li key={b}>{b}</li>\n ))}\n </ul>\n </>\n )}\n </div>\n ))}\n </section>\n </article>\n );\n}\n' }, { "path": "app/not-found.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport Link from "next/link";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Page not found \u2014 ${brand.name}`,\n description: "We couldn\'t find that page.",\n};\n\nexport default function NotFound() {\n return (\n <section className="max-w-2xl mx-auto px-6 sm:px-8 py-20 text-center">\n <p className="text-[11px] font-mono uppercase tracking-[0.16em] text-primary mb-3">\n 404\n </p>\n <h1 className="text-[clamp(2.5rem,5vw,4rem)] font-bold mb-4 -tracking-[0.03em]">\n Page not found.\n </h1>\n <p className="text-muted-foreground leading-relaxed mb-8">\n The URL you followed might be old, a typo, or pointing at a product\n that&apos;s no longer in stock. Try the menu or head back home.\n </p>\n <div className="flex flex-wrap items-center justify-center gap-3">\n <Link\n href="/shop"\n className="inline-flex items-center gap-2 px-5 py-2.5 rounded-md bg-primary text-primary-foreground font-semibold text-sm hover:bg-primary/90 transition-colors"\n >\n Browse the shop\n </Link>\n <Link\n href="/"\n className="inline-flex items-center gap-2 px-5 py-2.5 rounded-md border border-border hover:bg-muted transition-colors text-sm font-medium"\n >\n Back home\n </Link>\n </div>\n </section>\n );\n}\n' }, { "path": "app/cart/page.tsx", "kind": "text", "content": '"use client";\n\nimport { useRouter } from "next/navigation";\nimport { CartPage as SdkCartPage } from "@cimplify/sdk/react";\n\nexport default function CartPage() {\n const router = useRouter();\n return <SdkCartPage onCheckout={() => router.push("/checkout")} />;\n}\n' }, { "path": "app/accessibility/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\nimport { PolicyPage } from "@/components/policy-page";\n\nexport const metadata: Metadata = {\n title: `${brand.accessibility.title} \u2014 ${brand.name}`,\n};\n\nexport default function AccessibilityPage() {\n return <PolicyPage policy={brand.accessibility} />;\n}\n' }, { "path": "app/account/orders/[id]/page.tsx", "kind": "text", "content": 'import { notFound } from "next/navigation";\nimport { AccountSignedOutPrompt, toAccountOrderView } from "@cimplify/sdk/react";\nimport type { EnrichedOrder } from "@cimplify/sdk";\nimport { brand } from "@/lib/brand";\nimport { apiFetch, serverAccessToken, serverSession } from "@/lib/cimplify-server";\nimport { OrderDetail } from "@/components/order-detail";\n\nconst CLIENT_ID = process.env.NEXT_PUBLIC_CIMPLIFY_CLIENT_ID ?? "";\nconst REDIRECT_URI = process.env.CIMPLIFY_REDIRECT_URI ?? "";\n\nexport default async function OrderDetailPage({\n params,\n}: {\n params: Promise<{ id: string }>;\n}) {\n const { id } = await params;\n\n const session = await serverSession();\n if (!session) {\n return (\n <AccountSignedOutPrompt\n clientId={CLIENT_ID}\n redirectUri={REDIRECT_URI}\n eyebrow={brand.account.loginEyebrow}\n title={brand.account.loginTitle}\n description={brand.account.loginSubtitle}\n />\n );\n }\n\n const bearer = await serverAccessToken();\n const order = await apiFetch<EnrichedOrder>(`/api/v1/orders/${encodeURIComponent(id)}`, {\n bearer,\n });\n if (!order) notFound();\n\n return (\n <div className="mx-auto w-full max-w-5xl px-4 py-8 sm:px-6">\n <OrderDetail {...toAccountOrderView(order)} />\n </div>\n );\n}\n' }, { "path": "app/account/orders/page.tsx", "kind": "text", "content": 'import {\n AccountOrdersPage,\n AccountSignedOutPrompt,\n} from "@cimplify/sdk/react";\nimport type { OrderRowData, OrderRowStatus } from "@cimplify/sdk/react";\nimport type { Order, OrderStatus } from "@cimplify/sdk";\nimport { formatMoney } from "@cimplify/sdk";\nimport { brand } from "@/lib/brand";\nimport { ACCOUNT_NAV, apiFetch, serverAccessToken, serverSession } from "@/lib/cimplify-server";\n\nconst CLIENT_ID = process.env.NEXT_PUBLIC_CIMPLIFY_CLIENT_ID ?? "";\nconst REDIRECT_URI = process.env.CIMPLIFY_REDIRECT_URI ?? "";\n\nfunction mapStatus(status: OrderStatus): OrderRowStatus {\n switch (status) {\n case "in_preparation":\n case "ready_to_serve":\n case "partially_served":\n return { kind: "preparing", label: "Preparing" };\n case "delivered":\n return { kind: "delivered" };\n case "picked_up":\n return { kind: "delivered", label: "Picked up" };\n case "served":\n case "completed":\n return { kind: "completed" };\n case "cancelled":\n case "refunded":\n return { kind: "cancelled", label: status === "refunded" ? "Refunded" : undefined };\n case "pending":\n case "created":\n case "confirmed":\n default:\n return { kind: "active", label: "Confirmed" };\n }\n}\n\nfunction relativeMeta(iso: string, orderNumber: string): string {\n const when = new Date(iso);\n const formatted = when.toLocaleDateString(undefined, {\n month: "short",\n day: "numeric",\n year: when.getFullYear() === new Date().getFullYear() ? undefined : "numeric",\n });\n return `Order ${orderNumber} \xB7 ${formatted}`;\n}\n\nfunction toRowData(order: Order): OrderRowData {\n const count = order.items?.length ?? 0;\n return {\n id: order.id,\n href: `/account/orders/${order.id}`,\n summary: count > 0 ? `${count} ${count === 1 ? "item" : "items"}` : "Order",\n meta: relativeMeta(order.created_at, order.user_friendly_id ?? order.id),\n status: mapStatus(order.status),\n total: formatMoney(order.total_price, order.currency),\n };\n}\n\nexport default async function OrdersPage() {\n const session = await serverSession();\n if (!session) {\n return (\n <AccountSignedOutPrompt\n clientId={CLIENT_ID}\n redirectUri={REDIRECT_URI}\n eyebrow={brand.account.loginEyebrow}\n title={brand.account.loginTitle}\n description={brand.account.loginSubtitle}\n />\n );\n }\n\n const bearer = await serverAccessToken();\n const orders = await apiFetch<Order[]>(\n `/v1/link/orders?business_id=${encodeURIComponent(brand.mock.businessId)}`,\n { bearer },\n );\n\n return (\n <AccountOrdersPage\n customerName={session.name ?? ""}\n customerEmail={session.email}\n customerId={session.sub}\n merchantName={brand.name}\n nav={ACCOUNT_NAV}\n orders={(orders ?? []).map(toRowData)}\n />\n );\n}\n' }, { "path": "app/account/page.tsx", "kind": "text", "content": 'import {\n AccountDashboardPage,\n AccountSignedOutPrompt,\n} from "@cimplify/sdk/react";\nimport type { CustomerAddress, CustomerMobileMoney } from "@cimplify/sdk";\nimport { brand } from "@/lib/brand";\nimport { ACCOUNT_NAV, apiFetch, serverAccessToken, serverSession } from "@/lib/cimplify-server";\n\nconst CLIENT_ID = process.env.NEXT_PUBLIC_CIMPLIFY_CLIENT_ID ?? "";\nconst REDIRECT_URI = process.env.CIMPLIFY_REDIRECT_URI ?? "";\n\nexport default async function AccountPage() {\n const session = await serverSession();\n if (!session) {\n return (\n <AccountSignedOutPrompt\n clientId={CLIENT_ID}\n redirectUri={REDIRECT_URI}\n eyebrow={brand.account.loginEyebrow}\n title={brand.account.loginTitle}\n description={brand.account.loginSubtitle}\n />\n );\n }\n\n const bearer = await serverAccessToken();\n const [addresses, wallets] = await Promise.all([\n apiFetch<CustomerAddress[]>("/v1/link/addresses", { bearer }),\n apiFetch<CustomerMobileMoney[]>("/v1/link/mobile-money", { bearer }),\n ]);\n\n return (\n <AccountDashboardPage\n customerName={session.name ?? ""}\n customerEmail={session.email}\n customerId={session.sub}\n merchantName={brand.name}\n nav={ACCOUNT_NAV}\n identitySummary={{\n addresses: addresses?.length ?? 0,\n paymentMethods: wallets?.length ?? 0,\n }}\n />\n );\n}\n' }, { "path": "app/.well-known/ucp/route.ts", "kind": "text", "content": 'import { NextResponse } from "next/server";\n\n/**\n * UCP (Universal Commerce Protocol) manifest discovery endpoint.\n *\n * Agents \u2014 Claude, ChatGPT, Gemini, MCP clients \u2014 probe\n * `https://<your-domain>/.well-known/ucp` to learn what commerce\n * capabilities your storefront supports. We resolve the business\'s\n * UCP handle from the public key (via the storefront API), then forward\n * to Cimplify for the canonical manifest. Edge-cached for an hour\n * because capabilities change rarely.\n */\nconst UCP_API_BASE = "https://api.cimplify.io";\nconst STOREFRONT_API_BASE =\n process.env.NODE_ENV === "production"\n ? "https://storefronts.cimplify.io"\n : "http://127.0.0.1:8787";\n\nexport async function GET() {\n const publicKey = process.env.NEXT_PUBLIC_CIMPLIFY_PUBLIC_KEY;\n if (!publicKey) {\n return NextResponse.json(\n {\n error: "NEXT_PUBLIC_CIMPLIFY_PUBLIC_KEY not set",\n remediation:\n "Set NEXT_PUBLIC_CIMPLIFY_PUBLIC_KEY in .env.local (and your deployment env).",\n },\n { status: 500 },\n );\n }\n\n try {\n // Step 1 \u2014 resolve the business handle from the public key.\n const businessResp = await fetch(`${STOREFRONT_API_BASE}/api/v1/business`, {\n headers: { "X-API-Key": publicKey, "Content-Type": "application/json" },\n next: { revalidate: 3600 },\n });\n\n if (!businessResp.ok) {\n return NextResponse.json(\n { error: `Failed to resolve business: ${businessResp.status}` },\n { status: businessResp.status },\n );\n }\n\n const businessJson = await businessResp.json();\n const handle: string | undefined = businessJson?.data?.handle;\n if (!handle) {\n return NextResponse.json(\n { error: "Business has no handle configured" },\n { status: 500 },\n );\n }\n\n // Step 2 \u2014 fetch the canonical UCP manifest.\n const manifestResp = await fetch(\n `${UCP_API_BASE}/ucp/v1/${handle}/manifest`,\n {\n headers: { "Content-Type": "application/json" },\n next: { revalidate: 3600 },\n },\n );\n\n if (!manifestResp.ok) {\n return NextResponse.json(\n { error: `Upstream UCP manifest fetch failed: ${manifestResp.status}` },\n { status: manifestResp.status },\n );\n }\n\n const manifest = await manifestResp.json();\n return NextResponse.json(manifest, {\n headers: {\n "Content-Type": "application/json",\n "Cache-Control": "public, max-age=3600, s-maxage=3600",\n },\n });\n } catch (error) {\n return NextResponse.json(\n {\n error: "Failed to fetch UCP manifest",\n detail: error instanceof Error ? error.message : String(error),\n },\n { status: 500 },\n );\n }\n}\n' }, { "path": "app/returns/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\nimport { PolicyPage } from "@/components/policy-page";\n\nexport const metadata: Metadata = {\n title: `${brand.returns.title} \u2014 ${brand.name}`,\n};\n\nexport default function ReturnsPage() {\n return <PolicyPage policy={brand.returns} />;\n}\n' }, { "path": "app/orders/[id]/page.tsx", "kind": "text", "content": 'import Link from "next/link";\n\nexport default async function OrderPage({ params }: { params: Promise<{ id: string }> }) {\n const { id } = await params;\n return (\n <section className="max-w-2xl mx-auto px-6 sm:px-8 py-20 text-center">\n <h1 className="text-3xl mt-0 mb-3 font-bold -tracking-[0.025em]">\n Order confirmed.\n </h1>\n <p className="text-muted-foreground">\n Order <code className="font-mono text-foreground">{id}</code> \u2014 you&apos;ll\n get an SMS with tracking within 30 minutes.\n </p>\n <p className="mt-6">\n <Link\n href="/"\n className="inline-block px-6 py-3 rounded-md bg-primary text-primary-foreground font-semibold text-sm transition-colors hover:bg-primary/90"\n >\n Continue shopping\n </Link>\n </p>\n </section>\n );\n}\n' }, { "path": "app/globals.css", "kind": "text", "content": `@import "tailwindcss";
2521
+ ` }, { "path": "lib/cimplify-server.ts", "kind": "text", "content": 'import { cookies } from "next/headers";\nimport {\n getAccessTokenFromCookieHeader,\n getSessionFromCookieHeader,\n} from "@cimplify/sdk/server";\nimport type { NavItem } from "@cimplify/sdk/react";\n\nexport const ACCOUNT_NAV: NavItem[] = [\n { label: "Dashboard", href: "/account" },\n { label: "Orders", href: "/account/orders" },\n { label: "Cimplify identity", href: "https://link.cimplify.io", external: true },\n];\n\nconst CLIENT_ID = process.env.CIMPLIFY_CLIENT_ID ?? "";\nconst ISSUER = (process.env.CIMPLIFY_ISSUER ?? "https://api.cimplify.io").replace(/\\/$/, "");\n\nconst OIDC_CONFIG = { clientId: CLIENT_ID, issuer: ISSUER };\n\nasync function cookieHeader(): Promise<string> {\n const store = await cookies();\n return store.getAll().map((c) => `${c.name}=${c.value}`).join("; ");\n}\n\nexport async function serverSession() {\n return getSessionFromCookieHeader(OIDC_CONFIG, await cookieHeader());\n}\n\nexport async function serverAccessToken(): Promise<string | null> {\n return getAccessTokenFromCookieHeader(OIDC_CONFIG, await cookieHeader());\n}\n\ninterface Envelope<T> {\n data: T;\n}\n\nexport async function apiFetch<T>(\n path: string,\n init: RequestInit & { bearer: string | null },\n): Promise<T | null> {\n if (!init.bearer) return null;\n const { bearer, ...rest } = init;\n const headers = new Headers(rest.headers);\n headers.set("Authorization", `Bearer ${bearer}`);\n headers.set("Accept", "application/json");\n if (rest.body && !headers.has("Content-Type")) {\n headers.set("Content-Type", "application/json");\n }\n const res = await fetch(`${ISSUER}${path}`, { ...rest, headers, cache: "no-store" });\n if (!res.ok) return null;\n const envelope = (await res.json()) as Envelope<T>;\n return envelope.data;\n}\n' }, { "path": "lib/cimplify-loader.ts", "kind": "text", "content": 'import type { ImageLoader } from "next/image";\n\nimport { assetUrl, isCimplifyAsset } from "@cimplify/sdk";\n\nconst cdnBase = process.env.NEXT_PUBLIC_CIMPLIFY_CDN_URL?.trim() || undefined;\n\nconst cimplifyImageLoader: ImageLoader = ({ src, width, quality }) => {\n if (isCimplifyAsset(src, cdnBase)) {\n return assetUrl(src, {\n base: cdnBase,\n w: width,\n quality,\n format: "auto",\n });\n }\n return src;\n};\n\nexport default cimplifyImageLoader;\n' }, { "path": "lib/site-url.ts", "kind": "text", "content": 'import { SITE_URL } from "./site.config";\n\n/** Canonical absolute URL for this storefront. */\nexport async function getSiteUrl(): Promise<string> {\n return SITE_URL;\n}\n' }, { "path": ".env.example", "kind": "text", "content": "# Your tenant public key. Get it from the desk's Developers tab.\n# `cpk_live_*` / `cpk_test_*` keys auto-route same-origin requests\n# to hosted Cimplify in both dev and prod. Anything else (`mock-dev`,\n# empty) falls back to the local `cimplify dev` mock.\nNEXT_PUBLIC_CIMPLIFY_PUBLIC_KEY=mock-dev\n\n# Optional. Forces a fixed canonical URL for sitemap.xml, robots.txt,\n# OpenGraph, and llms.txt. Leave unset and the storefront derives the\n# canonical from the request `Host` header automatically.\n# NEXT_PUBLIC_SITE_URL=\n" }, { "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__/brand.test.ts", "kind": "text", "content": 'import { createBrandSuite } from "@cimplify/sdk/testing/suite";\nimport { brand } from "../lib/brand";\n\ncreateBrandSuite({ brand });\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.101.1",\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": "metadata.json", "kind": "text", "content": '{\n "id": "pharmacy",\n "name": "Pharmacy",\n "tagline": "Licensed community pharmacy with prescription uploads, pharmacist consults, and OTC catalog.",\n "industry": "healthcare",\n "tags": ["healthcare", "regulated", "prescription"],\n "stability": "stable",\n "schemaType": "Pharmacy",\n "mock": {\n "seedName": "pharmacy",\n "seedBusinessId": "bus_wellspring_pharmacy"\n }\n}\n' }, { "path": "app/search/search-client.tsx", "kind": "text", "content": '"use client";\n\nimport { SearchPage } from "@cimplify/sdk/react";\n\nexport function SearchClient() {\n return <SearchPage />;\n}\n' }, { "path": "app/search/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Suspense } from "react";\nimport { SearchClient } from "./search-client";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Search \u2014 ${brand.name}`,\n description: `Search ${brand.name} \u2014 products, collections, categories.`,\n};\n\nexport default function SearchPage() {\n return (\n <article className="max-w-7xl mx-auto px-6 sm:px-8 py-10">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-2">\n Search\n </p>\n <h1 className="font-serif text-[clamp(2rem,4vw,2.75rem)] font-semibold mb-8 -tracking-[0.02em]">\n Find anything.\n </h1>\n <Suspense fallback={<SearchSkeleton />}>\n <SearchClient />\n </Suspense>\n </article>\n );\n}\n\nfunction SearchSkeleton() {\n return (\n <div>\n <div className="h-12 w-full max-w-xl bg-muted rounded animate-pulse mb-8" />\n <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3 sm:gap-4">\n {Array.from({ length: 8 }).map((_, i) => (\n <div key={i} className="aspect-[4/3] bg-muted rounded-2xl animate-pulse" />\n ))}\n </div>\n </div>\n );\n}\n' }, { "path": "app/categories/[slug]/listing-client.tsx", "kind": "text", "content": '"use client";\n\nimport { ProductGrid } from "@cimplify/sdk/react";\nimport type { Product } from "@cimplify/sdk";\nimport { StoreProductCard } from "@/components/store-product-card";\n\n/**\n * Client island for the category listing. Receives server-fetched products\n * as props (serializable) and owns the `renderCard` function.\n */\nexport function ListingClient({ products }: { products: Product[] }) {\n return (\n <ProductGrid\n products={products}\n emptyMessage="No products in this category yet."\n renderCard={(p) => <StoreProductCard product={p} />}\n />\n );\n}\n' }, { "path": "app/categories/[slug]/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Suspense } from "react";\nimport Link from "next/link";\nimport { notFound } from "next/navigation";\nimport {\n getServerClient,\n tags,\n type Category,\n type Product,\n} from "@cimplify/sdk/server";\nimport { ListingClient } from "./listing-client";\nimport { brand } from "@/lib/brand";\n\n// See app/products/[slug]/page.tsx for the rationale on generateStaticParams.\nexport async function generateStaticParams() {\n const r = await getServerClient().catalogue.getCategories();\n if (!r.ok || r.value.length === 0) {\n return [{ slug: "__placeholder__" }];\n }\n return r.value.map((c) => ({ slug: c.slug ?? c.id }));\n}\n\nexport const revalidate = 3600;\n\ninterface CategoryData {\n category: Category;\n products: Product[];\n}\n\ntype CategoryResult =\n | { ok: true; data: CategoryData }\n | { ok: false; code: string };\n\nasync function getCategory(slug: string): Promise<CategoryResult> {\n const client = getServerClient();\n const catRes = await client.catalogue.getCategoryBySlug(slug, {\n cacheOptions: { revalidate: 3600, tags: [tags.categories()] },\n });\n if (!catRes.ok) return { ok: false, code: catRes.error.code };\n\n const r = await client.catalogue.getCategoryProducts(catRes.value.id, undefined, {\n cacheOptions: {\n revalidate: 3600,\n tags: [\n tags.products(),\n tags.category(catRes.value.id),\n tags.categoryProducts(catRes.value.id),\n ],\n },\n });\n const products = r.ok\n ? ((r.value as { items?: Product[] }).items ?? (r.value as Product[]))\n : [];\n return { ok: true, data: { category: catRes.value, products } };\n}\n\nexport async function generateMetadata({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}): Promise<Metadata> {\n const { slug } = await params;\n const result = await getCategory(slug);\n if (!result.ok) return {};\n const data = result.data;\n return {\n title: `${data.category.name} \u2014 ${brand.name}`,\n description: data.category.description ?? undefined,\n };\n}\n\nexport default async function CategoryPage({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}) {\n return (\n <Suspense fallback={<CategorySkeleton />}>\n <CategoryContent params={params} />\n </Suspense>\n );\n}\n\nasync function CategoryContent({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}) {\n const { slug } = await params;\n const result = await getCategory(slug);\n if (!result.ok) {\n if (result.code === "NOT_FOUND") notFound();\n return <CategorySkeleton />;\n }\n const { category, products } = result.data;\n return (\n <>\n <section className="bg-foreground text-background relative overflow-hidden">\n <div className="absolute inset-0 opacity-[0.04] pointer-events-none [background-image:radial-gradient(circle_at_2px_2px,white_1px,transparent_0)] [background-size:32px_32px]" />\n <div className="relative max-w-7xl mx-auto px-6 sm:px-8 py-12 sm:py-14">\n <nav className="text-[12px] font-mono text-background/60 mb-3 flex items-center gap-2">\n <Link href="/" className="hover:text-background transition-colors">Home</Link>\n <span>/</span>\n <Link href="/shop" className="hover:text-background transition-colors">Shop</Link>\n <span>/</span>\n <span className="text-background/90">{category.name}</span>\n </nav>\n <h1 className="text-[clamp(2rem,4vw,3rem)] font-bold m-0 -tracking-[0.025em]">\n {category.name}\n </h1>\n {category.description && (\n <p className="mt-3 max-w-2xl text-base text-background/75">\n {category.description}\n </p>\n )}\n <p className="mt-4 text-[11px] font-mono uppercase tracking-[0.16em] text-background/60 tabular-nums">\n {products.length} {products.length === 1 ? "product" : "products"}\n </p>\n </div>\n </section>\n <section className="max-w-7xl mx-auto px-6 sm:px-8 py-10 sm:py-12">\n <ListingClient products={products} />\n {products.length === 0 && (\n <p className="text-center mt-8">\n <Link href="/shop" className="text-primary font-semibold hover:underline">\n \u2190 Browse all products\n </Link>\n </p>\n )}\n </section>\n </>\n );\n}\n\nfunction CategorySkeleton() {\n return (\n <>\n <section className="bg-foreground py-12 sm:py-14">\n <div className="max-w-7xl mx-auto px-6 sm:px-8">\n <div className="h-3 w-32 bg-background/20 rounded mb-3 animate-pulse" />\n <div className="h-12 w-72 bg-background/20 rounded animate-pulse" />\n </div>\n </section>\n <section className="max-w-7xl mx-auto px-6 sm:px-8 py-10 sm:py-12">\n <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3 sm:gap-4">\n {Array.from({ length: 8 }).map((_, i) => (\n <div key={i} className="aspect-[4/3] bg-muted rounded-2xl animate-pulse" />\n ))}\n </div>\n </section>\n </>\n );\n}\n' }, { "path": "app/terms/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Terms of Service \u2014 ${brand.name}`,\n description: `The rules of the road for ordering from ${brand.name}.`,\n};\n\nexport default function TermsPage() {\n const t = brand.terms;\n return (\n <article className="max-w-3xl mx-auto px-6 sm:px-8 py-16 prose prose-lg max-w-none">\n <p className="text-[11px] font-mono uppercase tracking-[0.16em] text-primary mb-2 not-prose">\n {t.eyebrow}\n </p>\n <h1 className="text-[clamp(2.25rem,5vw,3.5rem)] font-bold mb-2 -tracking-[0.025em]">\n {t.title}\n </h1>\n <p className="text-sm text-muted-foreground not-prose mb-10">\n Last updated: {t.lastUpdated}\n </p>\n\n <section className="space-y-5 leading-relaxed text-foreground/90">\n {t.sections.map((s) => (\n <div key={s.heading}>\n <h2 className="text-2xl font-semibold mt-0 -tracking-[0.02em]">{s.heading}</h2>\n {typeof s.body === "string" ? (\n <p>{s.body}</p>\n ) : (\n <>\n <p>{s.body.intro}</p>\n <ul className="list-disc pl-6 space-y-2">\n {s.body.bullets.map((b) => (\n <li key={b}>{b}</li>\n ))}\n </ul>\n </>\n )}\n </div>\n ))}\n </section>\n </article>\n );\n}\n' }, { "path": "app/not-found.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport Link from "next/link";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Page not found \u2014 ${brand.name}`,\n description: "We couldn\'t find that page.",\n};\n\nexport default function NotFound() {\n return (\n <section className="max-w-2xl mx-auto px-6 sm:px-8 py-20 text-center">\n <p className="text-[11px] font-mono uppercase tracking-[0.16em] text-primary mb-3">\n 404\n </p>\n <h1 className="text-[clamp(2.5rem,5vw,4rem)] font-bold mb-4 -tracking-[0.03em]">\n Page not found.\n </h1>\n <p className="text-muted-foreground leading-relaxed mb-8">\n The URL you followed might be old, a typo, or pointing at a product\n that&apos;s no longer in stock. Try the menu or head back home.\n </p>\n <div className="flex flex-wrap items-center justify-center gap-3">\n <Link\n href="/shop"\n className="inline-flex items-center gap-2 px-5 py-2.5 rounded-md bg-primary text-primary-foreground font-semibold text-sm hover:bg-primary/90 transition-colors"\n >\n Browse the shop\n </Link>\n <Link\n href="/"\n className="inline-flex items-center gap-2 px-5 py-2.5 rounded-md border border-border hover:bg-muted transition-colors text-sm font-medium"\n >\n Back home\n </Link>\n </div>\n </section>\n );\n}\n' }, { "path": "app/cart/page.tsx", "kind": "text", "content": '"use client";\n\nimport { useRouter } from "next/navigation";\nimport { CartPage as SdkCartPage } from "@cimplify/sdk/react";\n\nexport default function CartPage() {\n const router = useRouter();\n return <SdkCartPage onCheckout={() => router.push("/checkout")} />;\n}\n' }, { "path": "app/accessibility/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\nimport { PolicyPage } from "@/components/policy-page";\n\nexport const metadata: Metadata = {\n title: `${brand.accessibility.title} \u2014 ${brand.name}`,\n};\n\nexport default function AccessibilityPage() {\n return <PolicyPage policy={brand.accessibility} />;\n}\n' }, { "path": "app/account/orders/[id]/page.tsx", "kind": "text", "content": 'import { notFound } from "next/navigation";\nimport { AccountSignedOutPrompt, toAccountOrderView } from "@cimplify/sdk/react";\nimport type { EnrichedOrder } from "@cimplify/sdk";\nimport { brand } from "@/lib/brand";\nimport { apiFetch, serverAccessToken, serverSession } from "@/lib/cimplify-server";\nimport { OrderDetail } from "@/components/order-detail";\n\nconst CLIENT_ID = process.env.NEXT_PUBLIC_CIMPLIFY_CLIENT_ID ?? "";\nconst REDIRECT_URI = process.env.CIMPLIFY_REDIRECT_URI ?? "";\n\nexport default async function OrderDetailPage({\n params,\n}: {\n params: Promise<{ id: string }>;\n}) {\n const { id } = await params;\n\n const session = await serverSession();\n if (!session) {\n return (\n <AccountSignedOutPrompt\n clientId={CLIENT_ID}\n redirectUri={REDIRECT_URI}\n eyebrow={brand.account.loginEyebrow}\n title={brand.account.loginTitle}\n description={brand.account.loginSubtitle}\n />\n );\n }\n\n const bearer = await serverAccessToken();\n const order = await apiFetch<EnrichedOrder>(`/api/v1/orders/${encodeURIComponent(id)}`, {\n bearer,\n });\n if (!order) notFound();\n\n return (\n <div className="mx-auto w-full max-w-5xl px-4 py-8 sm:px-6">\n <OrderDetail {...toAccountOrderView(order)} />\n </div>\n );\n}\n' }, { "path": "app/account/orders/page.tsx", "kind": "text", "content": 'import {\n AccountOrdersPage,\n AccountSignedOutPrompt,\n} from "@cimplify/sdk/react";\nimport type { OrderRowData, OrderRowStatus } from "@cimplify/sdk/react";\nimport type { Order, OrderStatus } from "@cimplify/sdk";\nimport { formatMoney } from "@cimplify/sdk";\nimport { brand } from "@/lib/brand";\nimport { ACCOUNT_NAV, apiFetch, serverAccessToken, serverSession } from "@/lib/cimplify-server";\n\nconst CLIENT_ID = process.env.NEXT_PUBLIC_CIMPLIFY_CLIENT_ID ?? "";\nconst REDIRECT_URI = process.env.CIMPLIFY_REDIRECT_URI ?? "";\n\nfunction mapStatus(status: OrderStatus): OrderRowStatus {\n switch (status) {\n case "in_preparation":\n case "ready_to_serve":\n case "partially_served":\n return { kind: "preparing", label: "Preparing" };\n case "delivered":\n return { kind: "delivered" };\n case "picked_up":\n return { kind: "delivered", label: "Picked up" };\n case "served":\n case "completed":\n return { kind: "completed" };\n case "cancelled":\n case "refunded":\n return { kind: "cancelled", label: status === "refunded" ? "Refunded" : undefined };\n case "pending":\n case "created":\n case "confirmed":\n default:\n return { kind: "active", label: "Confirmed" };\n }\n}\n\nfunction relativeMeta(iso: string, orderNumber: string): string {\n const when = new Date(iso);\n const formatted = when.toLocaleDateString(undefined, {\n month: "short",\n day: "numeric",\n year: when.getFullYear() === new Date().getFullYear() ? undefined : "numeric",\n });\n return `Order ${orderNumber} \xB7 ${formatted}`;\n}\n\nfunction toRowData(order: Order): OrderRowData {\n const count = order.items?.length ?? 0;\n return {\n id: order.id,\n href: `/account/orders/${order.id}`,\n summary: count > 0 ? `${count} ${count === 1 ? "item" : "items"}` : "Order",\n meta: relativeMeta(order.created_at, order.user_friendly_id ?? order.id),\n status: mapStatus(order.status),\n total: formatMoney(order.total_price, order.currency),\n };\n}\n\nexport default async function OrdersPage() {\n const session = await serverSession();\n if (!session) {\n return (\n <AccountSignedOutPrompt\n clientId={CLIENT_ID}\n redirectUri={REDIRECT_URI}\n eyebrow={brand.account.loginEyebrow}\n title={brand.account.loginTitle}\n description={brand.account.loginSubtitle}\n />\n );\n }\n\n const bearer = await serverAccessToken();\n const orders = await apiFetch<Order[]>(\n `/v1/link/orders?business_id=${encodeURIComponent(brand.mock.businessId)}`,\n { bearer },\n );\n\n return (\n <AccountOrdersPage\n customerName={session.name ?? ""}\n customerEmail={session.email}\n customerId={session.sub}\n merchantName={brand.name}\n nav={ACCOUNT_NAV}\n orders={(orders ?? []).map(toRowData)}\n />\n );\n}\n' }, { "path": "app/account/page.tsx", "kind": "text", "content": 'import {\n AccountDashboardPage,\n AccountSignedOutPrompt,\n} from "@cimplify/sdk/react";\nimport type { CustomerAddress, CustomerMobileMoney } from "@cimplify/sdk";\nimport { brand } from "@/lib/brand";\nimport { ACCOUNT_NAV, apiFetch, serverAccessToken, serverSession } from "@/lib/cimplify-server";\n\nconst CLIENT_ID = process.env.NEXT_PUBLIC_CIMPLIFY_CLIENT_ID ?? "";\nconst REDIRECT_URI = process.env.CIMPLIFY_REDIRECT_URI ?? "";\n\nexport default async function AccountPage() {\n const session = await serverSession();\n if (!session) {\n return (\n <AccountSignedOutPrompt\n clientId={CLIENT_ID}\n redirectUri={REDIRECT_URI}\n eyebrow={brand.account.loginEyebrow}\n title={brand.account.loginTitle}\n description={brand.account.loginSubtitle}\n />\n );\n }\n\n const bearer = await serverAccessToken();\n const [addresses, wallets] = await Promise.all([\n apiFetch<CustomerAddress[]>("/v1/link/addresses", { bearer }),\n apiFetch<CustomerMobileMoney[]>("/v1/link/mobile-money", { bearer }),\n ]);\n\n return (\n <AccountDashboardPage\n customerName={session.name ?? ""}\n customerEmail={session.email}\n customerId={session.sub}\n merchantName={brand.name}\n nav={ACCOUNT_NAV}\n identitySummary={{\n addresses: addresses?.length ?? 0,\n paymentMethods: wallets?.length ?? 0,\n }}\n />\n );\n}\n' }, { "path": "app/.well-known/ucp/route.ts", "kind": "text", "content": 'import { NextResponse } from "next/server";\n\n/**\n * UCP (Universal Commerce Protocol) manifest discovery endpoint.\n *\n * Agents \u2014 Claude, ChatGPT, Gemini, MCP clients \u2014 probe\n * `https://<your-domain>/.well-known/ucp` to learn what commerce\n * capabilities your storefront supports. We resolve the business\'s\n * UCP handle from the public key (via the storefront API), then forward\n * to Cimplify for the canonical manifest. Edge-cached for an hour\n * because capabilities change rarely.\n */\nconst UCP_API_BASE = "https://api.cimplify.io";\nconst STOREFRONT_API_BASE =\n process.env.NODE_ENV === "production"\n ? "https://storefronts.cimplify.io"\n : "http://127.0.0.1:8787";\n\nexport async function GET() {\n const publicKey = process.env.NEXT_PUBLIC_CIMPLIFY_PUBLIC_KEY;\n if (!publicKey) {\n return NextResponse.json(\n {\n error: "NEXT_PUBLIC_CIMPLIFY_PUBLIC_KEY not set",\n remediation:\n "Set NEXT_PUBLIC_CIMPLIFY_PUBLIC_KEY in .env.local (and your deployment env).",\n },\n { status: 500 },\n );\n }\n\n try {\n // Step 1 \u2014 resolve the business handle from the public key.\n const businessResp = await fetch(`${STOREFRONT_API_BASE}/api/v1/business`, {\n headers: { "X-API-Key": publicKey, "Content-Type": "application/json" },\n next: { revalidate: 3600 },\n });\n\n if (!businessResp.ok) {\n return NextResponse.json(\n { error: `Failed to resolve business: ${businessResp.status}` },\n { status: businessResp.status },\n );\n }\n\n const businessJson = await businessResp.json();\n const handle: string | undefined = businessJson?.data?.handle;\n if (!handle) {\n return NextResponse.json(\n { error: "Business has no handle configured" },\n { status: 500 },\n );\n }\n\n // Step 2 \u2014 fetch the canonical UCP manifest.\n const manifestResp = await fetch(\n `${UCP_API_BASE}/ucp/v1/${handle}/manifest`,\n {\n headers: { "Content-Type": "application/json" },\n next: { revalidate: 3600 },\n },\n );\n\n if (!manifestResp.ok) {\n return NextResponse.json(\n { error: `Upstream UCP manifest fetch failed: ${manifestResp.status}` },\n { status: manifestResp.status },\n );\n }\n\n const manifest = await manifestResp.json();\n return NextResponse.json(manifest, {\n headers: {\n "Content-Type": "application/json",\n "Cache-Control": "public, max-age=3600, s-maxage=3600",\n },\n });\n } catch (error) {\n return NextResponse.json(\n {\n error: "Failed to fetch UCP manifest",\n detail: error instanceof Error ? error.message : String(error),\n },\n { status: 500 },\n );\n }\n}\n' }, { "path": "app/returns/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\nimport { PolicyPage } from "@/components/policy-page";\n\nexport const metadata: Metadata = {\n title: `${brand.returns.title} \u2014 ${brand.name}`,\n};\n\nexport default function ReturnsPage() {\n return <PolicyPage policy={brand.returns} />;\n}\n' }, { "path": "app/orders/[id]/page.tsx", "kind": "text", "content": 'import Link from "next/link";\n\nexport default async function OrderPage({ params }: { params: Promise<{ id: string }> }) {\n const { id } = await params;\n return (\n <section className="max-w-2xl mx-auto px-6 sm:px-8 py-20 text-center">\n <h1 className="text-3xl mt-0 mb-3 font-bold -tracking-[0.025em]">\n Order confirmed.\n </h1>\n <p className="text-muted-foreground">\n Order <code className="font-mono text-foreground">{id}</code> \u2014 you&apos;ll\n get an SMS with tracking within 30 minutes.\n </p>\n <p className="mt-6">\n <Link\n href="/"\n className="inline-block px-6 py-3 rounded-md bg-primary text-primary-foreground font-semibold text-sm transition-colors hover:bg-primary/90"\n >\n Continue shopping\n </Link>\n </p>\n </section>\n );\n}\n' }, { "path": "app/globals.css", "kind": "text", "content": `@import "tailwindcss";
2522
2522
 
2523
2523
  /* Scan the SDK's bundled JS so utility classes used inside its components
2524
2524
  land in the compiled stylesheet alongside our own. */
@@ -3332,7 +3332,7 @@ export const brand: Brand = {
3332
3332
  businessId: "bus_studio_frx",
3333
3333
  },
3334
3334
  };
3335
- ` }, { "path": "lib/cimplify-server.ts", "kind": "text", "content": 'import { cookies } from "next/headers";\nimport {\n getAccessTokenFromCookieHeader,\n getSessionFromCookieHeader,\n} from "@cimplify/sdk/server";\nimport type { NavItem } from "@cimplify/sdk/react";\n\nexport const ACCOUNT_NAV: NavItem[] = [\n { label: "Dashboard", href: "/account" },\n { label: "Orders", href: "/account/orders" },\n { label: "Cimplify identity", href: "https://link.cimplify.io", external: true },\n];\n\nconst CLIENT_ID = process.env.CIMPLIFY_CLIENT_ID ?? "";\nconst ISSUER = (process.env.CIMPLIFY_ISSUER ?? "https://api.cimplify.io").replace(/\\/$/, "");\n\nconst OIDC_CONFIG = { clientId: CLIENT_ID, issuer: ISSUER };\n\nasync function cookieHeader(): Promise<string> {\n const store = await cookies();\n return store.getAll().map((c) => `${c.name}=${c.value}`).join("; ");\n}\n\nexport async function serverSession() {\n return getSessionFromCookieHeader(OIDC_CONFIG, await cookieHeader());\n}\n\nexport async function serverAccessToken(): Promise<string | null> {\n return getAccessTokenFromCookieHeader(OIDC_CONFIG, await cookieHeader());\n}\n\ninterface Envelope<T> {\n data: T;\n}\n\nexport async function apiFetch<T>(\n path: string,\n init: RequestInit & { bearer: string | null },\n): Promise<T | null> {\n if (!init.bearer) return null;\n const { bearer, ...rest } = init;\n const headers = new Headers(rest.headers);\n headers.set("Authorization", `Bearer ${bearer}`);\n headers.set("Accept", "application/json");\n if (rest.body && !headers.has("Content-Type")) {\n headers.set("Content-Type", "application/json");\n }\n const res = await fetch(`${ISSUER}${path}`, { ...rest, headers, cache: "no-store" });\n if (!res.ok) return null;\n const envelope = (await res.json()) as Envelope<T>;\n return envelope.data;\n}\n' }, { "path": "lib/cimplify-loader.ts", "kind": "text", "content": 'import type { ImageLoader } from "next/image";\n\nimport { assetUrl, isCimplifyAsset } from "@cimplify/sdk";\n\nconst cdnBase = process.env.NEXT_PUBLIC_CIMPLIFY_CDN_URL?.trim() || undefined;\n\nconst cimplifyImageLoader: ImageLoader = ({ src, width, quality }) => {\n if (isCimplifyAsset(src, cdnBase)) {\n return assetUrl(src, {\n base: cdnBase,\n w: width,\n quality,\n format: "auto",\n });\n }\n return src;\n};\n\nexport default cimplifyImageLoader;\n' }, { "path": "lib/site-url.ts", "kind": "text", "content": 'import { SITE_URL } from "./site.config";\n\n/** Canonical absolute URL for this storefront. */\nexport async function getSiteUrl(): Promise<string> {\n return SITE_URL;\n}\n' }, { "path": ".env.example", "kind": "text", "content": "# Your tenant public key. Get it from the desk's Developers tab.\n# `cpk_live_*` / `cpk_test_*` keys auto-route same-origin requests\n# to hosted Cimplify in both dev and prod. Anything else (`mock-dev`,\n# empty) falls back to the local `cimplify dev` mock.\nNEXT_PUBLIC_CIMPLIFY_PUBLIC_KEY=mock-dev\n\n# Optional. Forces a fixed canonical URL for sitemap.xml, robots.txt,\n# OpenGraph, and llms.txt. Leave unset and the storefront derives the\n# canonical from the request `Host` header automatically.\n# NEXT_PUBLIC_SITE_URL=\n" }, { "path": "e2e/visual.spec.ts", "kind": "text", "content": 'import { test, expect } from "@playwright/test";\n\n/**\n * Visual regression on the storefront\'s key surfaces. Catches CSS / layout\n * drift that the contract harness can\'t see.\n *\n * Anti-flake notes:\n * - We disable animations (CSS transitions can leave half-frame snapshots).\n * - We mask images (Unsplash / CDN URLs occasionally render slight pixel\n * differences). The container layout is what we\'re regressing on, not\n * image bytes.\n * - Fonts are served by `next/font` and locked at build time, but if you\n * see flake on a fresh CI runner, `await page.evaluate(() => document.fonts.ready)`\n * before the snapshot.\n */\ntest.describe("visual regression", () => {\n test.beforeEach(async ({ page }) => {\n await page.addStyleTag({\n content: `*, *::before, *::after { animation: none !important; transition: none !important; }`,\n });\n });\n\n test("homepage", async ({ page }) => {\n await page.goto("/");\n await page.evaluate(() => document.fonts.ready);\n await expect(page).toHaveScreenshot("homepage.png", {\n fullPage: true,\n mask: [page.locator("img")],\n });\n });\n\n test("shop / catalogue", async ({ page }) => {\n await page.goto("/shop");\n await page.evaluate(() => document.fonts.ready);\n await expect(page).toHaveScreenshot("shop.png", {\n fullPage: true,\n mask: [page.locator("img")],\n });\n });\n\n test("cart drawer after adding a product", async ({ page }) => {\n await page.goto("/products/studio-tee-natural");\n await page.evaluate(() => document.fonts.ready);\n // Click whatever the storefront\'s primary Add-to-Cart button is.\n await page.getByRole("button", { name: /add to cart/i }).first().click();\n // Drawer auto-opens via CartDrawerProvider\'s openOnAdd watcher.\n await expect(page.getByRole("dialog", { name: /cart/i })).toBeVisible();\n await expect(page).toHaveScreenshot("cart-drawer.png", {\n mask: [page.locator("img")],\n });\n });\n});\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__/brand.test.ts", "kind": "text", "content": 'import { createBrandSuite } from "@cimplify/sdk/testing/suite";\nimport { brand } from "../lib/brand";\n\ncreateBrandSuite({ brand });\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": "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.100.4",\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": "metadata.json", "kind": "text", "content": '{\n "id": "fashion",\n "name": "Fashion",\n "tagline": "Editorial multi-drop fashion storefront with lookbooks and size guides.",\n "industry": "fashion",\n "tags": ["fashion", "apparel", "lookbook"],\n "stability": "stable",\n "schemaType": "Store",\n "mock": {\n "seedName": "fashion",\n "seedBusinessId": "bus_studio_frx"\n }\n}\n' }, { "path": "app/size-guide/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Size Guide \u2014 ${brand.name}`,\n description: "Chest, length, and shoulder measurements for every Studio FRX piece.",\n};\n\ninterface SizeRow {\n size: string;\n chest: string;\n length: string;\n shoulder: string;\n}\n\nconst TOPS_CHART: SizeRow[] = [\n { size: "XS", chest: "104 cm / 41 in", length: "68 cm / 26.8 in", shoulder: "50 cm / 19.7 in" },\n { size: "S", chest: "110 cm / 43.3 in", length: "70 cm / 27.5 in", shoulder: "52 cm / 20.5 in" },\n { size: "M", chest: "116 cm / 45.7 in", length: "72 cm / 28.3 in", shoulder: "54 cm / 21.3 in" },\n { size: "L", chest: "122 cm / 48 in", length: "74 cm / 29.1 in", shoulder: "56 cm / 22 in" },\n { size: "XL", chest: "128 cm / 50.4 in", length: "76 cm / 29.9 in", shoulder: "58 cm / 22.8 in" },\n { size: "2XL", chest: "134 cm / 52.7 in", length: "78 cm / 30.7 in", shoulder: "60 cm / 23.6 in" },\n { size: "3XL", chest: "140 cm / 55.1 in", length: "80 cm / 31.5 in", shoulder: "62 cm / 24.4 in" },\n];\n\nconst BOTTOMS_CHART: { size: string; waist: string; hip: string; inseam: string }[] = [\n { size: "XS", waist: "72 cm / 28 in", hip: "92 cm / 36 in", inseam: "78 cm / 30.7 in" },\n { size: "S", waist: "76 cm / 30 in", hip: "96 cm / 38 in", inseam: "78 cm / 30.7 in" },\n { size: "M", waist: "82 cm / 32 in", hip: "102 cm / 40 in", inseam: "80 cm / 31.5 in" },\n { size: "L", waist: "88 cm / 35 in", hip: "108 cm / 42.5 in", inseam: "80 cm / 31.5 in" },\n { size: "XL", waist: "94 cm / 37 in", hip: "114 cm / 45 in", inseam: "82 cm / 32.3 in" },\n { size: "2XL", waist: "102 cm / 40 in", hip: "122 cm / 48 in", inseam: "82 cm / 32.3 in" },\n];\n\nexport default function SizeGuidePage() {\n return (\n <article className="max-w-4xl mx-auto px-6 sm:px-8 py-16">\n <p className="text-[11px] font-mono uppercase tracking-[0.2em] text-primary mb-2">\n Size guide\n </p>\n <h1 className="font-display text-[clamp(2.5rem,7vw,5rem)] uppercase mb-4 -tracking-[0.04em] leading-[0.95]">\n Find your fit.\n </h1>\n <p className="text-base text-muted-foreground leading-relaxed max-w-2xl mb-12">\n Our pieces are cut for an oversized, dropped-shoulder fit. Order true to size for\n the intended fit; size down for a regular cut. All measurements are flat-laid\n garment measurements.\n </p>\n\n <section className="mb-14">\n <h2 className="text-2xl font-bold mb-2 -tracking-[0.025em]">Tops & outerwear</h2>\n <p className="text-sm text-muted-foreground mb-4">\n Hoodies, tees, sweats, jackets. Chest measured pit-to-pit, doubled. Length\n measured from highest shoulder seam to hem.\n </p>\n <SizeTable\n headings={["Size", "Chest", "Length", "Shoulder"]}\n rows={TOPS_CHART.map((r) => [r.size, r.chest, r.length, r.shoulder])}\n />\n </section>\n\n <section className="mb-14">\n <h2 className="text-2xl font-bold mb-2 -tracking-[0.025em]">Bottoms</h2>\n <p className="text-sm text-muted-foreground mb-4">\n Trousers, shorts, sweats. Waist measured flat across the top of the\n waistband, doubled.\n </p>\n <SizeTable\n headings={["Size", "Waist", "Hip", "Inseam"]}\n rows={BOTTOMS_CHART.map((r) => [r.size, r.waist, r.hip, r.inseam])}\n />\n </section>\n\n <section className="rounded-2xl bg-foreground text-background p-8">\n <h2 className="text-xl font-bold mb-3 -tracking-[0.025em]">How to measure yourself</h2>\n <ul className="space-y-3 text-background/85 leading-relaxed">\n <li>\n <strong className="text-background">Chest</strong> \u2014 Wrap a soft tape around\n the fullest part of your chest, under the arms, keeping the tape level.\n </li>\n <li>\n <strong className="text-background">Waist</strong> \u2014 Around the narrowest part\n of your waist, just above the navel.\n </li>\n <li>\n <strong className="text-background">Hip</strong> \u2014 Around the fullest part of\n your hips, feet together.\n </li>\n <li>\n <strong className="text-background">Inseam</strong> \u2014 From the very top of the\n inside of your leg down to the floor (no shoes).\n </li>\n </ul>\n </section>\n\n <p className="mt-12 text-sm text-muted-foreground">\n Still unsure?{" "}\n <a\n href={`mailto:${brand.faq.contactEmail}`}\n className="text-primary font-semibold hover:underline"\n >\n Email us your usual size in another brand\n </a>{" "}\n and we\'ll recommend a fit.\n </p>\n </article>\n );\n}\n\nfunction SizeTable({\n headings,\n rows,\n}: {\n headings: string[];\n rows: string[][];\n}) {\n return (\n <div className="overflow-x-auto rounded-2xl border border-border">\n <table className="w-full text-sm">\n <thead className="bg-muted">\n <tr>\n {headings.map((h) => (\n <th\n key={h}\n className="text-left font-semibold uppercase tracking-wider text-[11px] text-muted-foreground px-4 py-3"\n >\n {h}\n </th>\n ))}\n </tr>\n </thead>\n <tbody>\n {rows.map((r, i) => (\n <tr\n key={r[0]}\n className={i % 2 === 0 ? "bg-background" : "bg-muted/30"}\n >\n {r.map((cell, j) => (\n <td\n key={j}\n className={[\n "px-4 py-3 tabular-nums",\n j === 0 ? "font-semibold text-foreground" : "text-foreground/80",\n ].join(" ")}\n >\n {cell}\n </td>\n ))}\n </tr>\n ))}\n </tbody>\n </table>\n </div>\n );\n}\n' }, { "path": "app/search/search-client.tsx", "kind": "text", "content": '"use client";\n\nimport { SearchPage } from "@cimplify/sdk/react";\n\nexport function SearchClient() {\n return <SearchPage />;\n}\n' }, { "path": "app/search/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Suspense } from "react";\nimport { SearchClient } from "./search-client";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Search \u2014 ${brand.name}`,\n description: `Search ${brand.name} \u2014 products, collections, categories.`,\n};\n\nexport default function SearchPage() {\n return (\n <article className="max-w-7xl mx-auto px-6 sm:px-8 py-10">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-2">\n Search\n </p>\n <h1 className="font-serif text-[clamp(2rem,4vw,2.75rem)] font-semibold mb-8 -tracking-[0.02em]">\n Find anything.\n </h1>\n <Suspense fallback={<SearchSkeleton />}>\n <SearchClient />\n </Suspense>\n </article>\n );\n}\n\nfunction SearchSkeleton() {\n return (\n <div>\n <div className="h-12 w-full max-w-xl bg-muted rounded animate-pulse mb-8" />\n <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3 sm:gap-4">\n {Array.from({ length: 8 }).map((_, i) => (\n <div key={i} className="aspect-[4/3] bg-muted rounded-2xl animate-pulse" />\n ))}\n </div>\n </div>\n );\n}\n' }, { "path": "app/categories/[slug]/listing-client.tsx", "kind": "text", "content": '"use client";\n\nimport { ProductGrid } from "@cimplify/sdk/react";\nimport type { Product } from "@cimplify/sdk";\nimport { StoreProductCard } from "@/components/store-product-card";\n\n/**\n * Client island for the category listing. Receives server-fetched products\n * as props (serializable) and owns the `renderCard` function.\n */\nexport function ListingClient({ products }: { products: Product[] }) {\n return (\n <ProductGrid\n products={products}\n emptyMessage="No products in this category yet."\n renderCard={(p) => <StoreProductCard product={p} />}\n />\n );\n}\n' }, { "path": "app/categories/[slug]/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Suspense } from "react";\nimport Link from "next/link";\nimport { notFound } from "next/navigation";\nimport {\n getServerClient,\n tags,\n type Category,\n type Product,\n} from "@cimplify/sdk/server";\nimport { ListingClient } from "./listing-client";\nimport { brand } from "@/lib/brand";\n\n// See app/products/[slug]/page.tsx for the rationale on generateStaticParams.\nexport async function generateStaticParams() {\n const r = await getServerClient().catalogue.getCategories();\n if (!r.ok || r.value.length === 0) {\n return [{ slug: "__placeholder__" }];\n }\n return r.value.map((c) => ({ slug: c.slug ?? c.id }));\n}\n\nexport const revalidate = 3600;\n\ninterface CategoryData {\n category: Category;\n products: Product[];\n}\n\ntype CategoryResult =\n | { ok: true; data: CategoryData }\n | { ok: false; code: string };\n\nasync function getCategory(slug: string): Promise<CategoryResult> {\n const client = getServerClient();\n const catRes = await client.catalogue.getCategoryBySlug(slug, {\n cacheOptions: { revalidate: 3600, tags: [tags.categories()] },\n });\n if (!catRes.ok) return { ok: false, code: catRes.error.code };\n\n const r = await client.catalogue.getCategoryProducts(catRes.value.id, undefined, {\n cacheOptions: {\n revalidate: 3600,\n tags: [\n tags.products(),\n tags.category(catRes.value.id),\n tags.categoryProducts(catRes.value.id),\n ],\n },\n });\n const products = r.ok\n ? ((r.value as { items?: Product[] }).items ?? (r.value as Product[]))\n : [];\n return { ok: true, data: { category: catRes.value, products } };\n}\n\nexport async function generateMetadata({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}): Promise<Metadata> {\n const { slug } = await params;\n const result = await getCategory(slug);\n if (!result.ok) return {};\n const data = result.data;\n return {\n title: `${data.category.name} \u2014 ${brand.name}`,\n description: data.category.description ?? undefined,\n };\n}\n\nexport default async function CategoryPage({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}) {\n return (\n <Suspense fallback={<CategorySkeleton />}>\n <CategoryContent params={params} />\n </Suspense>\n );\n}\n\nasync function CategoryContent({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}) {\n const { slug } = await params;\n const result = await getCategory(slug);\n if (!result.ok) {\n if (result.code === "NOT_FOUND") notFound();\n return <CategorySkeleton />;\n }\n const { category, products } = result.data;\n return (\n <>\n <section className="bg-foreground text-background relative overflow-hidden">\n <div className="absolute inset-0 opacity-[0.04] pointer-events-none [background-image:radial-gradient(circle_at_2px_2px,white_1px,transparent_0)] [background-size:32px_32px]" />\n <div className="relative max-w-7xl mx-auto px-6 sm:px-8 py-12 sm:py-14">\n <nav className="text-[12px] font-mono text-background/60 mb-3 flex items-center gap-2">\n <Link href="/" className="hover:text-background transition-colors">Home</Link>\n <span>/</span>\n <Link href="/shop" className="hover:text-background transition-colors">Shop</Link>\n <span>/</span>\n <span className="text-background/90">{category.name}</span>\n </nav>\n <h1 className="text-[clamp(2rem,4vw,3rem)] font-bold m-0 -tracking-[0.025em]">\n {category.name}\n </h1>\n {category.description && (\n <p className="mt-3 max-w-2xl text-base text-background/75">\n {category.description}\n </p>\n )}\n <p className="mt-4 text-[11px] font-mono uppercase tracking-[0.16em] text-background/60 tabular-nums">\n {products.length} {products.length === 1 ? "product" : "products"}\n </p>\n </div>\n </section>\n <section className="max-w-7xl mx-auto px-6 sm:px-8 py-10 sm:py-12">\n <ListingClient products={products} />\n {products.length === 0 && (\n <p className="text-center mt-8">\n <Link href="/shop" className="text-primary font-semibold hover:underline">\n \u2190 Browse all products\n </Link>\n </p>\n )}\n </section>\n </>\n );\n}\n\nfunction CategorySkeleton() {\n return (\n <>\n <section className="bg-foreground py-12 sm:py-14">\n <div className="max-w-7xl mx-auto px-6 sm:px-8">\n <div className="h-3 w-32 bg-background/20 rounded mb-3 animate-pulse" />\n <div className="h-12 w-72 bg-background/20 rounded animate-pulse" />\n </div>\n </section>\n <section className="max-w-7xl mx-auto px-6 sm:px-8 py-10 sm:py-12">\n <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3 sm:gap-4">\n {Array.from({ length: 8 }).map((_, i) => (\n <div key={i} className="aspect-[4/3] bg-muted rounded-2xl animate-pulse" />\n ))}\n </div>\n </section>\n </>\n );\n}\n' }, { "path": "app/terms/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Terms of Service \u2014 ${brand.name}`,\n description: `The rules of the road for ordering from ${brand.name}.`,\n};\n\nexport default function TermsPage() {\n const t = brand.terms;\n return (\n <article className="max-w-3xl mx-auto px-6 sm:px-8 py-16 prose prose-lg max-w-none">\n <p className="text-[11px] font-mono uppercase tracking-[0.16em] text-primary mb-2 not-prose">\n {t.eyebrow}\n </p>\n <h1 className="text-[clamp(2.25rem,5vw,3.5rem)] font-bold mb-2 -tracking-[0.025em]">\n {t.title}\n </h1>\n <p className="text-sm text-muted-foreground not-prose mb-10">\n Last updated: {t.lastUpdated}\n </p>\n\n <section className="space-y-5 leading-relaxed text-foreground/90">\n {t.sections.map((s) => (\n <div key={s.heading}>\n <h2 className="text-2xl font-semibold mt-0 -tracking-[0.02em]">{s.heading}</h2>\n {typeof s.body === "string" ? (\n <p>{s.body}</p>\n ) : (\n <>\n <p>{s.body.intro}</p>\n <ul className="list-disc pl-6 space-y-2">\n {s.body.bullets.map((b) => (\n <li key={b}>{b}</li>\n ))}\n </ul>\n </>\n )}\n </div>\n ))}\n </section>\n </article>\n );\n}\n' }, { "path": "app/not-found.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport Link from "next/link";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Page not found \u2014 ${brand.name}`,\n description: "We couldn\'t find that page.",\n};\n\nexport default function NotFound() {\n return (\n <section className="max-w-2xl mx-auto px-6 sm:px-8 py-20 text-center">\n <p className="text-[11px] font-mono uppercase tracking-[0.16em] text-primary mb-3">\n 404\n </p>\n <h1 className="text-[clamp(2.5rem,5vw,4rem)] font-bold mb-4 -tracking-[0.03em]">\n Page not found.\n </h1>\n <p className="text-muted-foreground leading-relaxed mb-8">\n The URL you followed might be old, a typo, or pointing at a product\n that&apos;s no longer in stock. Try the menu or head back home.\n </p>\n <div className="flex flex-wrap items-center justify-center gap-3">\n <Link\n href="/shop"\n className="inline-flex items-center gap-2 px-5 py-2.5 rounded-md bg-primary text-primary-foreground font-semibold text-sm hover:bg-primary/90 transition-colors"\n >\n Browse the shop\n </Link>\n <Link\n href="/"\n className="inline-flex items-center gap-2 px-5 py-2.5 rounded-md border border-border hover:bg-muted transition-colors text-sm font-medium"\n >\n Back home\n </Link>\n </div>\n </section>\n );\n}\n' }, { "path": "app/cart/page.tsx", "kind": "text", "content": '"use client";\n\nimport { useRouter } from "next/navigation";\nimport { CartPage as SdkCartPage } from "@cimplify/sdk/react";\n\nexport default function CartPage() {\n const router = useRouter();\n return <SdkCartPage onCheckout={() => router.push("/checkout")} />;\n}\n' }, { "path": "app/accessibility/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\nimport { PolicyPage } from "@/components/policy-page";\n\nexport const metadata: Metadata = {\n title: `${brand.accessibility.title} \u2014 ${brand.name}`,\n};\n\nexport default function AccessibilityPage() {\n return <PolicyPage policy={brand.accessibility} />;\n}\n' }, { "path": "app/account/orders/[id]/page.tsx", "kind": "text", "content": 'import { notFound } from "next/navigation";\nimport { AccountSignedOutPrompt, toAccountOrderView } from "@cimplify/sdk/react";\nimport type { EnrichedOrder } from "@cimplify/sdk";\nimport { brand } from "@/lib/brand";\nimport { apiFetch, serverAccessToken, serverSession } from "@/lib/cimplify-server";\nimport { OrderDetail } from "@/components/order-detail";\n\nconst CLIENT_ID = process.env.NEXT_PUBLIC_CIMPLIFY_CLIENT_ID ?? "";\nconst REDIRECT_URI = process.env.CIMPLIFY_REDIRECT_URI ?? "";\n\nexport default async function OrderDetailPage({\n params,\n}: {\n params: Promise<{ id: string }>;\n}) {\n const { id } = await params;\n\n const session = await serverSession();\n if (!session) {\n return (\n <AccountSignedOutPrompt\n clientId={CLIENT_ID}\n redirectUri={REDIRECT_URI}\n eyebrow={brand.account.loginEyebrow}\n title={brand.account.loginTitle}\n description={brand.account.loginSubtitle}\n />\n );\n }\n\n const bearer = await serverAccessToken();\n const order = await apiFetch<EnrichedOrder>(`/api/v1/orders/${encodeURIComponent(id)}`, {\n bearer,\n });\n if (!order) notFound();\n\n return (\n <div className="mx-auto w-full max-w-5xl px-4 py-8 sm:px-6">\n <OrderDetail {...toAccountOrderView(order)} />\n </div>\n );\n}\n' }, { "path": "app/account/orders/page.tsx", "kind": "text", "content": 'import {\n AccountOrdersPage,\n AccountSignedOutPrompt,\n} from "@cimplify/sdk/react";\nimport type { OrderRowData, OrderRowStatus } from "@cimplify/sdk/react";\nimport type { Order, OrderStatus } from "@cimplify/sdk";\nimport { formatMoney } from "@cimplify/sdk";\nimport { brand } from "@/lib/brand";\nimport { ACCOUNT_NAV, apiFetch, serverAccessToken, serverSession } from "@/lib/cimplify-server";\n\nconst CLIENT_ID = process.env.NEXT_PUBLIC_CIMPLIFY_CLIENT_ID ?? "";\nconst REDIRECT_URI = process.env.CIMPLIFY_REDIRECT_URI ?? "";\n\nfunction mapStatus(status: OrderStatus): OrderRowStatus {\n switch (status) {\n case "in_preparation":\n case "ready_to_serve":\n case "partially_served":\n return { kind: "preparing", label: "Preparing" };\n case "delivered":\n return { kind: "delivered" };\n case "picked_up":\n return { kind: "delivered", label: "Picked up" };\n case "served":\n case "completed":\n return { kind: "completed" };\n case "cancelled":\n case "refunded":\n return { kind: "cancelled", label: status === "refunded" ? "Refunded" : undefined };\n case "pending":\n case "created":\n case "confirmed":\n default:\n return { kind: "active", label: "Confirmed" };\n }\n}\n\nfunction relativeMeta(iso: string, orderNumber: string): string {\n const when = new Date(iso);\n const formatted = when.toLocaleDateString(undefined, {\n month: "short",\n day: "numeric",\n year: when.getFullYear() === new Date().getFullYear() ? undefined : "numeric",\n });\n return `Order ${orderNumber} \xB7 ${formatted}`;\n}\n\nfunction toRowData(order: Order): OrderRowData {\n const count = order.items?.length ?? 0;\n return {\n id: order.id,\n href: `/account/orders/${order.id}`,\n summary: count > 0 ? `${count} ${count === 1 ? "item" : "items"}` : "Order",\n meta: relativeMeta(order.created_at, order.user_friendly_id ?? order.id),\n status: mapStatus(order.status),\n total: formatMoney(order.total_price, order.currency),\n };\n}\n\nexport default async function OrdersPage() {\n const session = await serverSession();\n if (!session) {\n return (\n <AccountSignedOutPrompt\n clientId={CLIENT_ID}\n redirectUri={REDIRECT_URI}\n eyebrow={brand.account.loginEyebrow}\n title={brand.account.loginTitle}\n description={brand.account.loginSubtitle}\n />\n );\n }\n\n const bearer = await serverAccessToken();\n const orders = await apiFetch<Order[]>(\n `/v1/link/orders?business_id=${encodeURIComponent(brand.mock.businessId)}`,\n { bearer },\n );\n\n return (\n <AccountOrdersPage\n customerName={session.name ?? ""}\n customerEmail={session.email}\n customerId={session.sub}\n merchantName={brand.name}\n nav={ACCOUNT_NAV}\n orders={(orders ?? []).map(toRowData)}\n />\n );\n}\n' }, { "path": "app/account/page.tsx", "kind": "text", "content": 'import {\n AccountDashboardPage,\n AccountSignedOutPrompt,\n} from "@cimplify/sdk/react";\nimport type { CustomerAddress, CustomerMobileMoney } from "@cimplify/sdk";\nimport { brand } from "@/lib/brand";\nimport { ACCOUNT_NAV, apiFetch, serverAccessToken, serverSession } from "@/lib/cimplify-server";\n\nconst CLIENT_ID = process.env.NEXT_PUBLIC_CIMPLIFY_CLIENT_ID ?? "";\nconst REDIRECT_URI = process.env.CIMPLIFY_REDIRECT_URI ?? "";\n\nexport default async function AccountPage() {\n const session = await serverSession();\n if (!session) {\n return (\n <AccountSignedOutPrompt\n clientId={CLIENT_ID}\n redirectUri={REDIRECT_URI}\n eyebrow={brand.account.loginEyebrow}\n title={brand.account.loginTitle}\n description={brand.account.loginSubtitle}\n />\n );\n }\n\n const bearer = await serverAccessToken();\n const [addresses, wallets] = await Promise.all([\n apiFetch<CustomerAddress[]>("/v1/link/addresses", { bearer }),\n apiFetch<CustomerMobileMoney[]>("/v1/link/mobile-money", { bearer }),\n ]);\n\n return (\n <AccountDashboardPage\n customerName={session.name ?? ""}\n customerEmail={session.email}\n customerId={session.sub}\n merchantName={brand.name}\n nav={ACCOUNT_NAV}\n identitySummary={{\n addresses: addresses?.length ?? 0,\n paymentMethods: wallets?.length ?? 0,\n }}\n />\n );\n}\n' }, { "path": "app/.well-known/ucp/route.ts", "kind": "text", "content": 'import { NextResponse } from "next/server";\n\n/**\n * UCP (Universal Commerce Protocol) manifest discovery endpoint.\n *\n * Agents \u2014 Claude, ChatGPT, Gemini, MCP clients \u2014 probe\n * `https://<your-domain>/.well-known/ucp` to learn what commerce\n * capabilities your storefront supports. We resolve the business\'s\n * UCP handle from the public key (via the storefront API), then forward\n * to Cimplify for the canonical manifest. Edge-cached for an hour\n * because capabilities change rarely.\n */\nconst UCP_API_BASE = "https://api.cimplify.io";\nconst STOREFRONT_API_BASE =\n process.env.NODE_ENV === "production"\n ? "https://storefronts.cimplify.io"\n : "http://127.0.0.1:8787";\n\nexport async function GET() {\n const publicKey = process.env.NEXT_PUBLIC_CIMPLIFY_PUBLIC_KEY;\n if (!publicKey) {\n return NextResponse.json(\n {\n error: "NEXT_PUBLIC_CIMPLIFY_PUBLIC_KEY not set",\n remediation:\n "Set NEXT_PUBLIC_CIMPLIFY_PUBLIC_KEY in .env.local (and your deployment env).",\n },\n { status: 500 },\n );\n }\n\n try {\n // Step 1 \u2014 resolve the business handle from the public key.\n const businessResp = await fetch(`${STOREFRONT_API_BASE}/api/v1/business`, {\n headers: { "X-API-Key": publicKey, "Content-Type": "application/json" },\n next: { revalidate: 3600 },\n });\n\n if (!businessResp.ok) {\n return NextResponse.json(\n { error: `Failed to resolve business: ${businessResp.status}` },\n { status: businessResp.status },\n );\n }\n\n const businessJson = await businessResp.json();\n const handle: string | undefined = businessJson?.data?.handle;\n if (!handle) {\n return NextResponse.json(\n { error: "Business has no handle configured" },\n { status: 500 },\n );\n }\n\n // Step 2 \u2014 fetch the canonical UCP manifest.\n const manifestResp = await fetch(\n `${UCP_API_BASE}/ucp/v1/${handle}/manifest`,\n {\n headers: { "Content-Type": "application/json" },\n next: { revalidate: 3600 },\n },\n );\n\n if (!manifestResp.ok) {\n return NextResponse.json(\n { error: `Upstream UCP manifest fetch failed: ${manifestResp.status}` },\n { status: manifestResp.status },\n );\n }\n\n const manifest = await manifestResp.json();\n return NextResponse.json(manifest, {\n headers: {\n "Content-Type": "application/json",\n "Cache-Control": "public, max-age=3600, s-maxage=3600",\n },\n });\n } catch (error) {\n return NextResponse.json(\n {\n error: "Failed to fetch UCP manifest",\n detail: error instanceof Error ? error.message : String(error),\n },\n { status: 500 },\n );\n }\n}\n' }, { "path": "app/returns/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\nimport { PolicyPage } from "@/components/policy-page";\n\nexport const metadata: Metadata = {\n title: `${brand.returns.title} \u2014 ${brand.name}`,\n};\n\nexport default function ReturnsPage() {\n return <PolicyPage policy={brand.returns} />;\n}\n' }, { "path": "app/orders/[id]/page.tsx", "kind": "text", "content": 'import Link from "next/link";\n\nexport default async function OrderPage({ params }: { params: Promise<{ id: string }> }) {\n const { id } = await params;\n return (\n <section className="max-w-2xl mx-auto px-6 sm:px-8 py-20 text-center">\n <h1 className="text-3xl mt-0 mb-3 font-bold -tracking-[0.025em]">\n Order confirmed.\n </h1>\n <p className="text-muted-foreground">\n Order <code className="font-mono text-foreground">{id}</code> \u2014 you&apos;ll\n get an SMS with tracking within 30 minutes.\n </p>\n <p className="mt-6">\n <Link\n href="/"\n className="inline-block px-6 py-3 rounded-md bg-primary text-primary-foreground font-semibold text-sm transition-colors hover:bg-primary/90"\n >\n Continue shopping\n </Link>\n </p>\n </section>\n );\n}\n' }, { "path": "app/globals.css", "kind": "text", "content": '@import "tailwindcss";\n\n@source "../node_modules/@cimplify/sdk/dist";\n\n/* Studio FRX \u2014 Nike/Aritzia-style streetwear palette. High-contrast\n neutrals with one electric accent. Tweak primary to retheme everything. */\n@theme {\n --color-background: oklch(0.99 0 0);\n --color-foreground: oklch(0.12 0 0);\n --color-card: oklch(1 0 0);\n --color-card-foreground: oklch(0.12 0 0);\n --color-popover: oklch(1 0 0);\n --color-popover-foreground: oklch(0.12 0 0);\n --color-primary: oklch(0.7 0.24 30);\n --color-primary-foreground: oklch(0.99 0 0);\n --color-secondary: oklch(0.96 0 0);\n --color-secondary-foreground: oklch(0.18 0 0);\n --color-muted: oklch(0.96 0 0);\n --color-muted-foreground: oklch(0.45 0 0);\n --color-accent: oklch(0.92 0 0);\n --color-accent-foreground: oklch(0.18 0 0);\n --color-destructive: oklch(0.58 0.24 27);\n --color-destructive-foreground: oklch(0.99 0 0);\n --color-border: oklch(0.9 0 0);\n --color-input: oklch(0.93 0 0);\n --color-ring: oklch(0.7 0.24 30);\n --radius: 0.125rem;\n}\n\n@layer base {\n html, body {\n margin: 0;\n padding: 0;\n background: var(--color-background);\n color: var(--color-foreground);\n -webkit-font-smoothing: antialiased;\n -moz-osx-font-smoothing: grayscale;\n }\n a { color: inherit; text-decoration: none; }\n h1, h2, h3, h4 {\n font-family: var(--font-display);\n letter-spacing: -0.04em;\n font-weight: 800;\n line-height: 0.95;\n }\n}\n' }, { "path": "app/about/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `About \u2014 ${brand.name}`,\n description: brand.description,\n};\n\nexport default function AboutPage() {\n const a = brand.about;\n // Title supports a single \\n for a hard line break.\n const titleParts = a.title.split("\\n");\n return (\n <article className="max-w-3xl mx-auto px-6 sm:px-8 py-16">\n <p className="text-[11px] font-mono uppercase tracking-[0.16em] text-primary mb-2">\n {a.eyebrow}\n </p>\n <h1 className="text-[clamp(2.25rem,5vw,3.5rem)] font-bold mb-6 -tracking-[0.025em] leading-tight">\n {titleParts.map((line, i) => (\n <span key={i}>\n {line}\n {i < titleParts.length - 1 && <br />}\n </span>\n ))}\n </h1>\n <div className="prose prose-lg max-w-none space-y-5 text-foreground/90 leading-relaxed">\n {a.paragraphs.map((p, i) => (\n <p key={i}>{p}</p>\n ))}\n {a.sections.map((s) => (\n <div key={s.heading}>\n <h2 className="text-2xl font-semibold mt-10 mb-3 -tracking-[0.02em]">\n {s.heading}\n </h2>\n <p>{s.body}</p>\n </div>\n ))}\n </div>\n </article>\n );\n}\n' }, { "path": "app/auth/signout/route.ts", "kind": "text", "content": 'import { buildSignoutCookies } from "@cimplify/sdk/server";\n\nconst CLIENT_ID = process.env.CIMPLIFY_CLIENT_ID ?? "";\n\nexport async function POST(): Promise<Response> {\n const headers = new Headers({ "Content-Type": "application/json" });\n for (const cookie of buildSignoutCookies({ clientId: CLIENT_ID })) {\n headers.append("Set-Cookie", cookie);\n }\n return new Response(JSON.stringify({ ok: true }), { status: 200, headers });\n}\n' }, { "path": "app/auth/session/route.ts", "kind": "text", "content": 'import { handleSessionRequest } from "@cimplify/sdk/server";\n\nconst CLIENT_ID = process.env.CIMPLIFY_CLIENT_ID ?? "";\nconst ISSUER = process.env.CIMPLIFY_ISSUER;\n\nexport async function GET(req: Request): Promise<Response> {\n if (!CLIENT_ID) {\n return Response.json({ sub: null }, { status: 200 });\n }\n return handleSessionRequest(req, { clientId: CLIENT_ID, issuer: ISSUER });\n}\n' }, { "path": "app/auth/callback/route.ts", "kind": "text", "content": 'import { handleOidcCallback, handleRedirectCallback } from "@cimplify/sdk/server";\n\nconst CLIENT_ID = process.env.CIMPLIFY_CLIENT_ID ?? "";\nconst ISSUER = process.env.CIMPLIFY_ISSUER;\nconst REDIRECT_URI = process.env.CIMPLIFY_REDIRECT_URI ?? "";\n\n// Redirect sign-in: the browser lands here via a top-level GET carrying ?code.\nexport async function GET(req: Request): Promise<Response> {\n if (!CLIENT_ID || !REDIRECT_URI) {\n return Response.json({ error: "oidc_not_configured" }, { status: 500 });\n }\n return handleRedirectCallback(req, {\n clientId: CLIENT_ID,\n issuer: ISSUER,\n redirectUri: REDIRECT_URI,\n defaultReturnTo: "/account",\n });\n}\n\n// Modal/silent sign-in: posts {code, codeVerifier, state} here for exchange.\nexport async function POST(req: Request): Promise<Response> {\n if (!CLIENT_ID || !REDIRECT_URI) {\n return Response.json({ error: "oidc_not_configured" }, { status: 500 });\n }\n return handleOidcCallback(req, {\n clientId: CLIENT_ID,\n issuer: ISSUER,\n redirectUri: REDIRECT_URI,\n });\n}\n' }, { "path": "app/privacy/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Privacy Policy \u2014 ${brand.name}`,\n description: `How ${brand.name} collects, uses, and protects your personal data.`,\n};\n\nexport default function PrivacyPage() {\n const p = brand.privacy;\n return (\n <article className="max-w-3xl mx-auto px-6 sm:px-8 py-16 prose prose-lg max-w-none">\n <p className="text-[11px] font-mono uppercase tracking-[0.16em] text-primary mb-2 not-prose">\n {p.eyebrow}\n </p>\n <h1 className="text-[clamp(2.25rem,5vw,3.5rem)] font-bold mb-2 -tracking-[0.025em]">\n {p.title}\n </h1>\n <p className="text-sm text-muted-foreground not-prose mb-10">\n Last updated: {p.lastUpdated}\n </p>\n\n <section className="space-y-5 leading-relaxed text-foreground/90">\n {p.sections.map((s) => (\n <div key={s.heading}>\n <h2 className="text-2xl font-semibold mt-0 -tracking-[0.02em]">{s.heading}</h2>\n {typeof s.body === "string" ? (\n <p>{s.body}</p>\n ) : (\n <>\n <p>{s.body.intro}</p>\n <ul className="list-disc pl-6 space-y-2">\n {s.body.bullets.map((b) => (\n <li key={b}>{b}</li>\n ))}\n </ul>\n </>\n )}\n </div>\n ))}\n </section>\n </article>\n );\n}\n' }, { "path": "app/sitemap-page/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport Link from "next/link";\nimport { getServerClient, tags, type Product } from "@cimplify/sdk/server";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Sitemap \u2014 ${brand.name}`,\n description: "A human-readable index of every page on this site.",\n};\n\nexport const revalidate = 3600;\n\ninterface SitemapData {\n products: { slug: string; name: string }[];\n categories: { slug: string; name: string }[];\n collections: { slug: string; name: string }[];\n}\n\nasync function getSitemap(): Promise<SitemapData> {\n const client = getServerClient();\n const [pRes, cRes, colRes] = await Promise.all([\n client.catalogue.getProducts(\n { limit: 500 },\n { cacheOptions: { revalidate: 3600, tags: [tags.products()] } },\n ),\n client.catalogue.getCategories({\n cacheOptions: { revalidate: 3600, tags: [tags.categories()] },\n }),\n client.catalogue.getCollections({\n cacheOptions: { revalidate: 3600, tags: [tags.collections()] },\n }),\n ]);\n return {\n products: (pRes.ok ? pRes.value.items : []).map((p: Product) => ({\n slug: p.slug ?? p.id,\n name: p.name,\n })),\n categories: (cRes.ok ? cRes.value : []).map((c) => ({ slug: c.slug, name: c.name })),\n collections: (colRes.ok ? colRes.value : []).map((c) => ({ slug: c.slug, name: c.name })),\n };\n}\n\nconst STATIC_LINKS: { title: string; links: { href: string; label: string }[] }[] = [\n {\n title: "Browse",\n links: [\n { href: "/", label: "Home" },\n { href: "/shop", label: "Shop" },\n { href: "/search", label: "Search" },\n ],\n },\n {\n title: "Account",\n links: [\n { href: "/account", label: "Account" },\n { href: "/account/orders", label: "Orders" },\n { href: "/login", label: "Sign in" },\n { href: "/signup", label: "Create account" },\n { href: "/track-order", label: "Track an order" },\n { href: "/cart", label: "Cart" },\n { href: "/checkout", label: "Checkout" },\n ],\n },\n {\n title: "About",\n links: [\n { href: "/about", label: "About" },\n { href: "/faq", label: "FAQ" },\n { href: "/contact", label: "Contact" },\n ],\n },\n {\n title: "Policies",\n links: [\n { href: "/shipping", label: "Shipping" },\n { href: "/returns", label: "Returns" },\n { href: "/accessibility", label: "Accessibility" },\n { href: "/terms", label: "Terms of Service" },\n { href: "/privacy", label: "Privacy Policy" },\n ],\n },\n {\n title: "Machine-readable",\n links: [\n { href: "/sitemap.xml", label: "sitemap.xml (search engines)" },\n { href: "/llms.txt", label: "llms.txt (LLM agents)" },\n { href: "/robots.txt", label: "robots.txt" },\n { href: "/opensearch.xml", label: "opensearch.xml (browser search)" },\n ],\n },\n];\n\nexport default async function SitemapHtmlPage() {\n const { products, categories, collections } = await getSitemap();\n\n return (\n <article className="max-w-5xl mx-auto px-6 sm:px-8 py-16">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-2">\n Sitemap\n </p>\n <h1 className="text-[clamp(2rem,4vw,2.75rem)] font-semibold mb-2 -tracking-[0.02em]">\n Every page, in one place.\n </h1>\n <p className="text-muted-foreground mb-12">\n For search engines, see <Link href="/sitemap.xml" className="text-primary hover:underline">/sitemap.xml</Link>.\n For LLM agents, see <Link href="/llms.txt" className="text-primary hover:underline">/llms.txt</Link>.\n </p>\n <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-10">\n {STATIC_LINKS.map((s) => (\n <Section key={s.title} title={s.title}>\n {s.links.map((l) => (\n <li key={l.href}>\n <Link href={l.href} className="hover:text-primary transition-colors">\n {l.label}\n </Link>\n </li>\n ))}\n </Section>\n ))}\n {categories.length > 0 && (\n <Section title="Categories">\n {categories.map((c) => (\n <li key={c.slug}>\n <Link href={`/categories/${c.slug}`} className="hover:text-primary transition-colors">\n {c.name}\n </Link>\n </li>\n ))}\n </Section>\n )}\n {collections.length > 0 && (\n <Section title="Collections">\n {collections.map((c) => (\n <li key={c.slug}>\n <Link href={`/collections/${c.slug}`} className="hover:text-primary transition-colors">\n {c.name}\n </Link>\n </li>\n ))}\n </Section>\n )}\n {products.length > 0 && (\n <Section title={`Products (${products.length})`}>\n {products.map((p) => (\n <li key={p.slug}>\n <Link href={`/shop?product=${encodeURIComponent(p.slug)}`} className="hover:text-primary transition-colors">\n {p.name}\n </Link>\n </li>\n ))}\n </Section>\n )}\n </div>\n </article>\n );\n}\n\nfunction Section({ title, children }: { title: string; children: React.ReactNode }) {\n return (\n <div>\n <p className="font-semibold text-[12px] uppercase tracking-[0.12em] text-foreground mb-3">\n {title}\n </p>\n <ul className="space-y-2 m-0 p-0 list-none text-sm text-muted-foreground">\n {children}\n </ul>\n </div>\n );\n}\n' }, { "path": "app/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": "app/collections/[slug]/listing-client.tsx", "kind": "text", "content": '"use client";\n\nimport { ProductGrid } from "@cimplify/sdk/react";\nimport type { Product } from "@cimplify/sdk";\nimport { StoreProductCard } from "@/components/store-product-card";\n\n/**\n * Client island for the collection listing. Receives server-fetched\n * products as props (serializable) and owns the `renderCard` function\n * (which can\'t cross the server/client boundary).\n */\nexport function ListingClient({ products }: { products: Product[] }) {\n return (\n <ProductGrid\n products={products}\n emptyMessage="No products in this collection yet."\n renderCard={(p) => <StoreProductCard product={p} />}\n />\n );\n}\n' }, { "path": "app/collections/[slug]/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Suspense } from "react";\nimport Link from "next/link";\nimport { notFound } from "next/navigation";\nimport {\n getServerClient,\n tags,\n type Collection,\n type Product,\n} from "@cimplify/sdk/server";\nimport { ListingClient } from "./listing-client";\nimport { brand } from "@/lib/brand";\n\n// See app/products/[slug]/page.tsx for the rationale on generateStaticParams.\nexport async function generateStaticParams() {\n const r = await getServerClient().catalogue.getCollections();\n if (!r.ok || r.value.length === 0) {\n return [{ slug: "__placeholder__" }];\n }\n return r.value.map((c) => ({ slug: c.slug ?? c.id }));\n}\n\nexport const revalidate = 3600;\n\ninterface CollectionData {\n collection: Collection;\n products: Product[];\n}\n\ntype CollectionResult =\n | { ok: true; data: CollectionData }\n | { ok: false; code: string };\n\nasync function getCollection(slug: string): Promise<CollectionResult> {\n const client = getServerClient();\n const colRes = await client.catalogue.getCollectionBySlug(slug, {\n cacheOptions: { revalidate: 3600, tags: [tags.collections()] },\n });\n if (!colRes.ok) return { ok: false, code: colRes.error.code };\n\n const r = await client.catalogue.getCollectionProducts(colRes.value.id, undefined, {\n cacheOptions: {\n revalidate: 3600,\n tags: [\n tags.products(),\n tags.collection(colRes.value.id),\n tags.collectionProducts(colRes.value.id),\n ],\n },\n });\n const products = r.ok\n ? ((r.value as { items?: Product[] }).items ?? (r.value as Product[]))\n : [];\n return { ok: true, data: { collection: colRes.value, products } };\n}\n\nexport async function generateMetadata({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}): Promise<Metadata> {\n const { slug } = await params;\n const result = await getCollection(slug);\n if (!result.ok) return {};\n const data = result.data;\n return {\n title: `${data.collection.name} \u2014 ${brand.name}`,\n description: data.collection.description ?? undefined,\n };\n}\n\nexport default async function CollectionPage({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}) {\n return (\n <Suspense fallback={<CollectionSkeleton />}>\n <CollectionContent params={params} />\n </Suspense>\n );\n}\n\nasync function CollectionContent({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}) {\n const { slug } = await params;\n const result = await getCollection(slug);\n if (!result.ok) {\n if (result.code === "NOT_FOUND") notFound();\n return <CollectionSkeleton />;\n }\n const { collection, products } = result.data;\n return (\n <>\n <section className="bg-foreground text-background relative overflow-hidden">\n <div className="absolute inset-0 opacity-[0.04] pointer-events-none [background-image:radial-gradient(circle_at_2px_2px,white_1px,transparent_0)] [background-size:32px_32px]" />\n <div className="relative max-w-7xl mx-auto px-6 sm:px-8 py-12 sm:py-14">\n <nav className="text-[12px] font-mono text-background/60 mb-3 flex items-center gap-2">\n <Link href="/" className="hover:text-background transition-colors">Home</Link>\n <span>/</span>\n <span className="text-background/90">Collections</span>\n <span>/</span>\n <span className="text-background/90">{collection.name}</span>\n </nav>\n <h1 className="text-[clamp(2rem,4vw,3rem)] font-bold m-0 -tracking-[0.025em]">\n {collection.name}\n </h1>\n {collection.description && (\n <p className="mt-3 max-w-2xl text-base text-background/75">\n {collection.description}\n </p>\n )}\n <p className="mt-4 text-[11px] font-mono uppercase tracking-[0.16em] text-background/60 tabular-nums">\n {products.length} {products.length === 1 ? "product" : "products"}\n </p>\n </div>\n </section>\n <section className="max-w-7xl mx-auto px-6 sm:px-8 py-10 sm:py-12">\n <ListingClient products={products} />\n {products.length === 0 && (\n <p className="text-center mt-8">\n <Link href="/shop" className="text-primary font-semibold hover:underline">\n \u2190 Browse all products\n </Link>\n </p>\n )}\n </section>\n </>\n );\n}\n\nfunction CollectionSkeleton() {\n return (\n <>\n <section className="bg-foreground py-12 sm:py-14">\n <div className="max-w-7xl mx-auto px-6 sm:px-8">\n <div className="h-3 w-32 bg-background/20 rounded mb-3 animate-pulse" />\n <div className="h-12 w-72 bg-background/20 rounded animate-pulse" />\n </div>\n </section>\n <section className="max-w-7xl mx-auto px-6 sm:px-8 py-10 sm:py-12">\n <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3 sm:gap-4">\n {Array.from({ length: 8 }).map((_, i) => (\n <div key={i} className="aspect-[4/3] bg-muted rounded-2xl animate-pulse" />\n ))}\n </div>\n </section>\n </>\n );\n}\n' }, { "path": "app/layout.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Inter, Anton } from "next/font/google";\nimport "./globals.css";\nimport { Providers } from "@/components/providers";\nimport { getServerClient, tags } from "@cimplify/sdk/server";\nimport { Header } from "@/components/header";\nimport { Footer } from "@/components/footer";\nimport { ProductModal } from "@/components/product-modal";\nimport { CartDrawer } from "@/components/cart-drawer";\nimport { SupportChat } from "@/components/support-chat";\nimport { OrganizationJsonLd } from "@/components/json-ld";\nimport { Suspense } from "react";\nimport { brand } from "@/lib/brand";\nimport { getSiteUrl } from "@/lib/site-url";\n\nconst inter = Inter({\n subsets: ["latin"],\n variable: "--font-sans",\n display: "swap",\n});\n\nconst anton = Anton({\n subsets: ["latin"],\n variable: "--font-display",\n weight: "400",\n display: "swap",\n});\n\nexport async function generateMetadata(): Promise<Metadata> {\n const siteUrl = await getSiteUrl();\n return {\n metadataBase: new URL(siteUrl),\n title: {\n default: brand.name,\n template: `%s \u2014 ${brand.name}`,\n },\n description: brand.description,\n openGraph: {\n type: "website",\n siteName: brand.name,\n locale: brand.locale,\n },\n twitter: { card: "summary_large_image" },\n };\n}\n\nexport default async function RootLayout({ children }: { children: React.ReactNode }) {\n const server = getServerClient();\n const [bizResult, locResult] = await Promise.all([\n server.business.getInfo({ cacheOptions: { revalidate: 3600, tags: [tags.business()] } }),\n server.business.getLocations({ cacheOptions: { revalidate: 3600, tags: [tags.locations()] } }),\n ]);\n const initialBusiness = bizResult.ok ? bizResult.value : null;\n const initialLocations = locResult.ok ? locResult.value : [];\n\n return (\n <html lang="en" suppressHydrationWarning className={`${inter.variable} ${anton.variable}`}>\n <body\n suppressHydrationWarning\n className="min-h-screen flex flex-col bg-background text-foreground font-sans"\n >\n <Suspense fallback={null}>\n <OrganizationJsonLd />\n </Suspense>\n <Providers initialBusiness={initialBusiness} initialLocations={initialLocations}>\n <Header />\n <main className="flex-1 pb-12 w-full">\n <Suspense fallback={null}>{children}</Suspense>\n </main>\n <Footer />\n <Suspense fallback={null}>\n <ProductModal />\n </Suspense>\n <CartDrawer />\n <SupportChat />\n </Providers>\n </body>\n </html>\n );\n}\n' }, { "path": "app/lookbook/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport Image from "next/image";\nimport Link from "next/link";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Lookbook \u2014 ${brand.name}`,\n description: "Editorial photography from each Studio FRX drop.",\n};\n\ninterface LookbookEntry {\n drop: string;\n title: string;\n hero: string;\n tiles: string[];\n byline: string;\n date: string;\n}\n\n// Each entry is a drop; tiles are the editorial shots. Replace the URLs\n// with your own asset bucket / CDN before going live.\nconst ENTRIES: LookbookEntry[] = [\n {\n drop: "Drop 04",\n title: "Built for now.",\n date: "Spring 2026",\n byline: "Photographed by Selasi Adjei in Jamestown, Accra.",\n hero: "https://images.unsplash.com/photo-1490481651871-ab68de25d43d?w=1800&h=1100&fit=crop&auto=format&q=85",\n tiles: [\n "https://images.unsplash.com/photo-1483985988355-763728e1935b?w=900&h=1200&fit=crop&auto=format&q=85",\n "https://images.unsplash.com/photo-1521572163474-6864f9cf17ab?w=900&h=1200&fit=crop&auto=format&q=85",\n "https://images.unsplash.com/photo-1485518882345-15568b007407?w=900&h=1200&fit=crop&auto=format&q=85",\n ],\n },\n {\n drop: "Drop 03",\n title: "Heavyweight, hand-screened.",\n date: "Winter 2025",\n byline: "Shot at the Tema dye works.",\n hero: "https://images.unsplash.com/photo-1467043153537-a4fba2cd39ef?w=1800&h=1100&fit=crop&auto=format&q=85",\n tiles: [\n "https://images.unsplash.com/photo-1576566588028-4147f3842f27?w=900&h=1200&fit=crop&auto=format&q=85",\n "https://images.unsplash.com/photo-1542838686-37da4a9fd1b3?w=900&h=1200&fit=crop&auto=format&q=85",\n "https://images.unsplash.com/photo-1496747611176-843222e1e57c?w=900&h=1200&fit=crop&auto=format&q=85",\n ],\n },\n {\n drop: "Drop 02",\n title: "From the studio floor.",\n date: "Autumn 2025",\n byline: "Studio days, Osu.",\n hero: "https://images.unsplash.com/photo-1525507119028-ed4c629a60a3?w=1800&h=1100&fit=crop&auto=format&q=85",\n tiles: [\n "https://images.unsplash.com/photo-1512436991641-6745cdb1723f?w=900&h=1200&fit=crop&auto=format&q=85",\n "https://images.unsplash.com/photo-1539109136881-3be0616acf4b?w=900&h=1200&fit=crop&auto=format&q=85",\n "https://images.unsplash.com/photo-1490114538077-0a7f8cb49891?w=900&h=1200&fit=crop&auto=format&q=85",\n ],\n },\n];\n\nexport default function LookbookPage() {\n return (\n <article>\n <section className="border-b border-border">\n <div className="max-w-7xl mx-auto px-6 sm:px-10 py-14">\n <p className="text-[11px] font-mono uppercase tracking-[0.2em] text-primary mb-3">\n Lookbook\n </p>\n <h1 className="font-display text-[clamp(3rem,9vw,7rem)] uppercase mb-4 -tracking-[0.04em] leading-[0.92]">\n The drops, in full.\n </h1>\n <p className="text-base text-muted-foreground max-w-2xl leading-relaxed">\n Each drop is photographed in the room it\'s built in. Editorials below;\n full collection in the shop.\n </p>\n </div>\n </section>\n\n {ENTRIES.map((entry, i) => (\n <section key={entry.drop} className="border-b border-border">\n <div className="max-w-7xl mx-auto px-6 sm:px-10 py-16">\n <div className="flex items-end justify-between gap-6 mb-8">\n <div>\n <p className="text-[11px] font-mono uppercase tracking-[0.2em] text-muted-foreground mb-2">\n {entry.drop} \xB7 {entry.date}\n </p>\n <h2 className="font-display text-[clamp(2rem,5vw,3.5rem)] uppercase m-0 -tracking-[0.04em] leading-[0.95]">\n {entry.title}\n </h2>\n <p className="text-sm text-muted-foreground mt-2">{entry.byline}</p>\n </div>\n <Link\n href="/shop"\n className="hidden sm:inline-flex items-center gap-2 text-sm font-semibold uppercase tracking-wider text-foreground hover:text-primary transition-colors whitespace-nowrap"\n >\n Shop {entry.drop} \u2192\n </Link>\n </div>\n\n <div className="relative w-full aspect-[16/9] rounded-2xl overflow-hidden bg-muted mb-3">\n <Image\n src={entry.hero}\n alt={`${entry.drop} hero \u2014 ${entry.title}`}\n fill\n sizes="(min-width: 1280px) 1280px, 100vw"\n className="object-cover"\n priority={i === 0}\n />\n </div>\n\n <div className="grid grid-cols-1 sm:grid-cols-3 gap-3">\n {entry.tiles.map((src, ti) => (\n <div\n key={src}\n className="relative aspect-[3/4] rounded-2xl overflow-hidden bg-muted"\n >\n <Image\n src={src}\n alt={`${entry.drop} editorial ${ti + 1}`}\n fill\n sizes="(min-width: 768px) 33vw, 100vw"\n className="object-cover"\n />\n </div>\n ))}\n </div>\n </div>\n </section>\n ))}\n </article>\n );\n}\n' }, { "path": "app/error.tsx", "kind": "text", "content": '"use client";\n\nimport { useEffect } from "react";\nimport Link from "next/link";\n\n/**\n * Root error boundary. Next.js calls this whenever a thrown error escapes\n * a Server Component without being caught by a closer `error.tsx`.\n *\n * Wire `reportError` (Sentry, Datadog, etc.) here so production failures\n * surface \u2014 silently recovering hides real bugs.\n */\nexport default function GlobalError({\n error,\n reset,\n}: {\n error: Error & { digest?: string };\n reset: () => void;\n}) {\n useEffect(() => {\n // TODO: replace with your error reporter\n // eslint-disable-next-line no-console\n console.error("Storefront error:", error);\n }, [error]);\n\n return (\n <section className="max-w-2xl mx-auto px-6 sm:px-8 py-20 text-center">\n <p className="text-[11px] font-mono uppercase tracking-[0.16em] text-primary mb-3">\n Something broke\n </p>\n <h1 className="text-[clamp(2rem,4vw,3rem)] font-bold mb-4 -tracking-[0.025em]">\n That wasn&apos;t supposed to happen.\n </h1>\n <p className="text-muted-foreground leading-relaxed mb-8">\n We&apos;ve been notified. Refresh the page, or head back home \u2014 most\n of the time the second try just works.\n </p>\n {error.digest && (\n <p className="text-xs font-mono text-muted-foreground mb-6">\n Reference:{" "}\n <code className="text-foreground">{error.digest}</code>\n </p>\n )}\n <div className="flex flex-wrap items-center justify-center gap-3">\n <button\n type="button"\n onClick={reset}\n className="inline-flex items-center gap-2 px-5 py-2.5 rounded-md bg-primary text-primary-foreground font-semibold text-sm hover:bg-primary/90 transition-colors"\n >\n Try again\n </button>\n <Link\n href="/"\n className="inline-flex items-center gap-2 px-5 py-2.5 rounded-md border border-border hover:bg-muted transition-colors text-sm font-medium"\n >\n Back home\n </Link>\n </div>\n </section>\n );\n}\n' }, { "path": "app/faq/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Support \u2014 ${brand.name}`,\n description: "Shipping, warranty, repairs, returns, payment \u2014 answers to the questions we hear most often.",\n};\n\nexport default function FaqPage() {\n const f = brand.faq;\n return (\n <article className="max-w-3xl mx-auto px-6 sm:px-8 py-16">\n <p className="text-[11px] font-mono uppercase tracking-[0.16em] text-primary mb-2">\n {f.eyebrow}\n </p>\n <h1 className="text-[clamp(2.25rem,5vw,3.5rem)] font-bold mb-10 -tracking-[0.025em]">\n {f.title}\n </h1>\n <div className="space-y-12">\n {f.sections.map((section) => (\n <section key={section.title}>\n <h2 className="text-2xl font-semibold mb-5 -tracking-[0.02em]">{section.title}</h2>\n <dl className="space-y-6">\n {section.items.map((item) => (\n <div key={item.q}>\n <dt className="font-semibold text-foreground mb-1.5">{item.q}</dt>\n <dd className="text-muted-foreground leading-relaxed">{item.a}</dd>\n </div>\n ))}\n </dl>\n </section>\n ))}\n </div>\n <p className="mt-12 pt-8 border-t border-border text-sm text-muted-foreground">\n {f.contactPrompt}{" "}\n <a\n href={`mailto:${f.contactEmail}`}\n className="text-primary font-semibold hover:underline"\n >\n {f.contactEmail}\n </a>{" "}\n and a real human will reply within 24 hours.\n </p>\n </article>\n );\n}\n' }, { "path": "app/llms.txt/route.ts", "kind": "text", "content": 'import { getServerClient, tags, type Product } from "@cimplify/sdk/server";\nimport { brand } from "@/lib/brand";\nimport { getSiteUrl } from "@/lib/site-url";\n\nexport const revalidate = 3600;\n\nasync function buildLlmsTxt(SITE_URL: string): Promise<string> {\n const client = getServerClient();\n const [productsRes, categoriesRes, collectionsRes] = await Promise.all([\n client.catalogue.getProducts(\n { limit: 500 },\n { cacheOptions: { revalidate: 3600, tags: [tags.products()] } },\n ),\n client.catalogue.getCategories({\n cacheOptions: { revalidate: 3600, tags: [tags.categories()] },\n }),\n client.catalogue.getCollections({\n cacheOptions: { revalidate: 3600, tags: [tags.collections()] },\n }),\n ]);\n\n const products: Product[] = productsRes.ok ? productsRes.value.items : [];\n const categories = categoriesRes.ok ? categoriesRes.value : [];\n const collections = collectionsRes.ok ? collectionsRes.value : [];\n\n const lines: string[] = [];\n lines.push(`# ${brand.name}`);\n lines.push("");\n lines.push(`> ${brand.llms.summary}`);\n lines.push("");\n lines.push("## Browse");\n lines.push(`- [Home](${SITE_URL}/)`);\n lines.push(`- [Shop](${SITE_URL}/shop): Full catalogue with filter and sort.`);\n\n if (categories.length > 0) {\n lines.push("");\n lines.push("## Categories");\n for (const c of categories) {\n lines.push(\n `- [${c.name}](${SITE_URL}/categories/${c.slug})${c.description ? `: ${c.description}` : ""}`,\n );\n }\n }\n\n if (collections.length > 0) {\n lines.push("");\n lines.push("## Collections");\n for (const c of collections) {\n lines.push(\n `- [${c.name}](${SITE_URL}/collections/${c.slug})${c.description ? `: ${c.description}` : ""}`,\n );\n }\n }\n\n if (products.length > 0) {\n lines.push("");\n lines.push("## Products");\n for (const p of products) {\n const slug = p.slug ?? p.id;\n const price = `${brand.currency} ${p.default_price}`;\n const desc = p.description ? ` \u2014 ${p.description.replace(/\\s+/g, " ").slice(0, 200)}` : "";\n lines.push(`- [${p.name}](${SITE_URL}/products/${slug}) (${price})${desc}`);\n }\n }\n\n lines.push("");\n lines.push("## Information");\n lines.push(`- [About](${SITE_URL}/about)`);\n lines.push(`- [Support / FAQ](${SITE_URL}/faq)`);\n lines.push(`- [Terms of Service](${SITE_URL}/terms)`);\n lines.push(`- [Privacy Policy](${SITE_URL}/privacy)`);\n lines.push(`- [Sitemap (XML)](${SITE_URL}/sitemap.xml)`);\n lines.push("");\n lines.push("## Contact");\n lines.push(`- Email: ${brand.contact.email}`);\n lines.push(`- Phone: ${brand.contact.phone}`);\n lines.push(`- Address: ${brand.contact.address}`);\n\n return lines.join("\\n") + "\\n";\n}\n\n/**\n * `/llms.txt` \u2014 machine-readable site index for LLMs (per llmstxt.org).\n * Lets coding agents and chat assistants find products, categories, and\n * support pages without scraping HTML. Plain Markdown so it streams cheaply\n * into context windows.\n */\nexport async function GET(): Promise<Response> {\n const body = await buildLlmsTxt(await getSiteUrl());\n return new Response(body, {\n headers: {\n "Content-Type": "text/plain; charset=utf-8",\n "Cache-Control": "public, max-age=0, s-maxage=3600, stale-while-revalidate=86400",\n },\n });\n}\n' }, { "path": "app/contact/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { ContactForm } from "./contact-form";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Contact \u2014 ${brand.name}`,\n description: brand.contactPage.body,\n};\n\nexport default function ContactPage() {\n const c = brand.contactPage;\n return (\n <article className="max-w-5xl mx-auto px-6 sm:px-8 py-16">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-2">\n {c.eyebrow}\n </p>\n <h1 className="font-serif text-[clamp(2.25rem,5vw,3.5rem)] font-semibold mb-4 -tracking-[0.02em]">\n {c.title}\n </h1>\n <p className="text-lg text-muted-foreground max-w-2xl mb-12 leading-relaxed">{c.body}</p>\n\n <div className="grid grid-cols-1 lg:grid-cols-[1.5fr_1fr] gap-10 items-start">\n <ContactForm reasons={c.reasons} />\n <aside className="space-y-5 lg:pl-10 lg:border-l border-border">\n <div>\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground mb-3">\n Direct lines\n </p>\n <ul className="space-y-3 list-none p-0 m-0">\n {c.directLines.map((line) => (\n <li key={line.label}>\n <p className="text-xs text-muted-foreground m-0">{line.label}</p>\n <a\n href={line.href}\n className="text-foreground hover:text-primary transition-colors"\n >\n {line.value}\n </a>\n </li>\n ))}\n </ul>\n </div>\n <div>\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground mb-3">\n Visit\n </p>\n <p className="text-foreground m-0">{brand.contact.address}</p>\n <p className="text-xs text-muted-foreground mt-1">{brand.contact.hours}</p>\n </div>\n </aside>\n </div>\n </article>\n );\n}\n' }, { "path": "app/contact/contact-form.tsx", "kind": "text", "content": `"use client";
3335
+ ` }, { "path": "lib/cimplify-server.ts", "kind": "text", "content": 'import { cookies } from "next/headers";\nimport {\n getAccessTokenFromCookieHeader,\n getSessionFromCookieHeader,\n} from "@cimplify/sdk/server";\nimport type { NavItem } from "@cimplify/sdk/react";\n\nexport const ACCOUNT_NAV: NavItem[] = [\n { label: "Dashboard", href: "/account" },\n { label: "Orders", href: "/account/orders" },\n { label: "Cimplify identity", href: "https://link.cimplify.io", external: true },\n];\n\nconst CLIENT_ID = process.env.CIMPLIFY_CLIENT_ID ?? "";\nconst ISSUER = (process.env.CIMPLIFY_ISSUER ?? "https://api.cimplify.io").replace(/\\/$/, "");\n\nconst OIDC_CONFIG = { clientId: CLIENT_ID, issuer: ISSUER };\n\nasync function cookieHeader(): Promise<string> {\n const store = await cookies();\n return store.getAll().map((c) => `${c.name}=${c.value}`).join("; ");\n}\n\nexport async function serverSession() {\n return getSessionFromCookieHeader(OIDC_CONFIG, await cookieHeader());\n}\n\nexport async function serverAccessToken(): Promise<string | null> {\n return getAccessTokenFromCookieHeader(OIDC_CONFIG, await cookieHeader());\n}\n\ninterface Envelope<T> {\n data: T;\n}\n\nexport async function apiFetch<T>(\n path: string,\n init: RequestInit & { bearer: string | null },\n): Promise<T | null> {\n if (!init.bearer) return null;\n const { bearer, ...rest } = init;\n const headers = new Headers(rest.headers);\n headers.set("Authorization", `Bearer ${bearer}`);\n headers.set("Accept", "application/json");\n if (rest.body && !headers.has("Content-Type")) {\n headers.set("Content-Type", "application/json");\n }\n const res = await fetch(`${ISSUER}${path}`, { ...rest, headers, cache: "no-store" });\n if (!res.ok) return null;\n const envelope = (await res.json()) as Envelope<T>;\n return envelope.data;\n}\n' }, { "path": "lib/cimplify-loader.ts", "kind": "text", "content": 'import type { ImageLoader } from "next/image";\n\nimport { assetUrl, isCimplifyAsset } from "@cimplify/sdk";\n\nconst cdnBase = process.env.NEXT_PUBLIC_CIMPLIFY_CDN_URL?.trim() || undefined;\n\nconst cimplifyImageLoader: ImageLoader = ({ src, width, quality }) => {\n if (isCimplifyAsset(src, cdnBase)) {\n return assetUrl(src, {\n base: cdnBase,\n w: width,\n quality,\n format: "auto",\n });\n }\n return src;\n};\n\nexport default cimplifyImageLoader;\n' }, { "path": "lib/site-url.ts", "kind": "text", "content": 'import { SITE_URL } from "./site.config";\n\n/** Canonical absolute URL for this storefront. */\nexport async function getSiteUrl(): Promise<string> {\n return SITE_URL;\n}\n' }, { "path": ".env.example", "kind": "text", "content": "# Your tenant public key. Get it from the desk's Developers tab.\n# `cpk_live_*` / `cpk_test_*` keys auto-route same-origin requests\n# to hosted Cimplify in both dev and prod. Anything else (`mock-dev`,\n# empty) falls back to the local `cimplify dev` mock.\nNEXT_PUBLIC_CIMPLIFY_PUBLIC_KEY=mock-dev\n\n# Optional. Forces a fixed canonical URL for sitemap.xml, robots.txt,\n# OpenGraph, and llms.txt. Leave unset and the storefront derives the\n# canonical from the request `Host` header automatically.\n# NEXT_PUBLIC_SITE_URL=\n" }, { "path": "e2e/visual.spec.ts", "kind": "text", "content": 'import { test, expect } from "@playwright/test";\n\n/**\n * Visual regression on the storefront\'s key surfaces. Catches CSS / layout\n * drift that the contract harness can\'t see.\n *\n * Anti-flake notes:\n * - We disable animations (CSS transitions can leave half-frame snapshots).\n * - We mask images (Unsplash / CDN URLs occasionally render slight pixel\n * differences). The container layout is what we\'re regressing on, not\n * image bytes.\n * - Fonts are served by `next/font` and locked at build time, but if you\n * see flake on a fresh CI runner, `await page.evaluate(() => document.fonts.ready)`\n * before the snapshot.\n */\ntest.describe("visual regression", () => {\n test.beforeEach(async ({ page }) => {\n await page.addStyleTag({\n content: `*, *::before, *::after { animation: none !important; transition: none !important; }`,\n });\n });\n\n test("homepage", async ({ page }) => {\n await page.goto("/");\n await page.evaluate(() => document.fonts.ready);\n await expect(page).toHaveScreenshot("homepage.png", {\n fullPage: true,\n mask: [page.locator("img")],\n });\n });\n\n test("shop / catalogue", async ({ page }) => {\n await page.goto("/shop");\n await page.evaluate(() => document.fonts.ready);\n await expect(page).toHaveScreenshot("shop.png", {\n fullPage: true,\n mask: [page.locator("img")],\n });\n });\n\n test("cart drawer after adding a product", async ({ page }) => {\n await page.goto("/products/studio-tee-natural");\n await page.evaluate(() => document.fonts.ready);\n // Click whatever the storefront\'s primary Add-to-Cart button is.\n await page.getByRole("button", { name: /add to cart/i }).first().click();\n // Drawer auto-opens via CartDrawerProvider\'s openOnAdd watcher.\n await expect(page.getByRole("dialog", { name: /cart/i })).toBeVisible();\n await expect(page).toHaveScreenshot("cart-drawer.png", {\n mask: [page.locator("img")],\n });\n });\n});\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__/brand.test.ts", "kind": "text", "content": 'import { createBrandSuite } from "@cimplify/sdk/testing/suite";\nimport { brand } from "../lib/brand";\n\ncreateBrandSuite({ brand });\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": "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.101.1",\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": "metadata.json", "kind": "text", "content": '{\n "id": "fashion",\n "name": "Fashion",\n "tagline": "Editorial multi-drop fashion storefront with lookbooks and size guides.",\n "industry": "fashion",\n "tags": ["fashion", "apparel", "lookbook"],\n "stability": "stable",\n "schemaType": "Store",\n "mock": {\n "seedName": "fashion",\n "seedBusinessId": "bus_studio_frx"\n }\n}\n' }, { "path": "app/size-guide/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Size Guide \u2014 ${brand.name}`,\n description: "Chest, length, and shoulder measurements for every Studio FRX piece.",\n};\n\ninterface SizeRow {\n size: string;\n chest: string;\n length: string;\n shoulder: string;\n}\n\nconst TOPS_CHART: SizeRow[] = [\n { size: "XS", chest: "104 cm / 41 in", length: "68 cm / 26.8 in", shoulder: "50 cm / 19.7 in" },\n { size: "S", chest: "110 cm / 43.3 in", length: "70 cm / 27.5 in", shoulder: "52 cm / 20.5 in" },\n { size: "M", chest: "116 cm / 45.7 in", length: "72 cm / 28.3 in", shoulder: "54 cm / 21.3 in" },\n { size: "L", chest: "122 cm / 48 in", length: "74 cm / 29.1 in", shoulder: "56 cm / 22 in" },\n { size: "XL", chest: "128 cm / 50.4 in", length: "76 cm / 29.9 in", shoulder: "58 cm / 22.8 in" },\n { size: "2XL", chest: "134 cm / 52.7 in", length: "78 cm / 30.7 in", shoulder: "60 cm / 23.6 in" },\n { size: "3XL", chest: "140 cm / 55.1 in", length: "80 cm / 31.5 in", shoulder: "62 cm / 24.4 in" },\n];\n\nconst BOTTOMS_CHART: { size: string; waist: string; hip: string; inseam: string }[] = [\n { size: "XS", waist: "72 cm / 28 in", hip: "92 cm / 36 in", inseam: "78 cm / 30.7 in" },\n { size: "S", waist: "76 cm / 30 in", hip: "96 cm / 38 in", inseam: "78 cm / 30.7 in" },\n { size: "M", waist: "82 cm / 32 in", hip: "102 cm / 40 in", inseam: "80 cm / 31.5 in" },\n { size: "L", waist: "88 cm / 35 in", hip: "108 cm / 42.5 in", inseam: "80 cm / 31.5 in" },\n { size: "XL", waist: "94 cm / 37 in", hip: "114 cm / 45 in", inseam: "82 cm / 32.3 in" },\n { size: "2XL", waist: "102 cm / 40 in", hip: "122 cm / 48 in", inseam: "82 cm / 32.3 in" },\n];\n\nexport default function SizeGuidePage() {\n return (\n <article className="max-w-4xl mx-auto px-6 sm:px-8 py-16">\n <p className="text-[11px] font-mono uppercase tracking-[0.2em] text-primary mb-2">\n Size guide\n </p>\n <h1 className="font-display text-[clamp(2.5rem,7vw,5rem)] uppercase mb-4 -tracking-[0.04em] leading-[0.95]">\n Find your fit.\n </h1>\n <p className="text-base text-muted-foreground leading-relaxed max-w-2xl mb-12">\n Our pieces are cut for an oversized, dropped-shoulder fit. Order true to size for\n the intended fit; size down for a regular cut. All measurements are flat-laid\n garment measurements.\n </p>\n\n <section className="mb-14">\n <h2 className="text-2xl font-bold mb-2 -tracking-[0.025em]">Tops & outerwear</h2>\n <p className="text-sm text-muted-foreground mb-4">\n Hoodies, tees, sweats, jackets. Chest measured pit-to-pit, doubled. Length\n measured from highest shoulder seam to hem.\n </p>\n <SizeTable\n headings={["Size", "Chest", "Length", "Shoulder"]}\n rows={TOPS_CHART.map((r) => [r.size, r.chest, r.length, r.shoulder])}\n />\n </section>\n\n <section className="mb-14">\n <h2 className="text-2xl font-bold mb-2 -tracking-[0.025em]">Bottoms</h2>\n <p className="text-sm text-muted-foreground mb-4">\n Trousers, shorts, sweats. Waist measured flat across the top of the\n waistband, doubled.\n </p>\n <SizeTable\n headings={["Size", "Waist", "Hip", "Inseam"]}\n rows={BOTTOMS_CHART.map((r) => [r.size, r.waist, r.hip, r.inseam])}\n />\n </section>\n\n <section className="rounded-2xl bg-foreground text-background p-8">\n <h2 className="text-xl font-bold mb-3 -tracking-[0.025em]">How to measure yourself</h2>\n <ul className="space-y-3 text-background/85 leading-relaxed">\n <li>\n <strong className="text-background">Chest</strong> \u2014 Wrap a soft tape around\n the fullest part of your chest, under the arms, keeping the tape level.\n </li>\n <li>\n <strong className="text-background">Waist</strong> \u2014 Around the narrowest part\n of your waist, just above the navel.\n </li>\n <li>\n <strong className="text-background">Hip</strong> \u2014 Around the fullest part of\n your hips, feet together.\n </li>\n <li>\n <strong className="text-background">Inseam</strong> \u2014 From the very top of the\n inside of your leg down to the floor (no shoes).\n </li>\n </ul>\n </section>\n\n <p className="mt-12 text-sm text-muted-foreground">\n Still unsure?{" "}\n <a\n href={`mailto:${brand.faq.contactEmail}`}\n className="text-primary font-semibold hover:underline"\n >\n Email us your usual size in another brand\n </a>{" "}\n and we\'ll recommend a fit.\n </p>\n </article>\n );\n}\n\nfunction SizeTable({\n headings,\n rows,\n}: {\n headings: string[];\n rows: string[][];\n}) {\n return (\n <div className="overflow-x-auto rounded-2xl border border-border">\n <table className="w-full text-sm">\n <thead className="bg-muted">\n <tr>\n {headings.map((h) => (\n <th\n key={h}\n className="text-left font-semibold uppercase tracking-wider text-[11px] text-muted-foreground px-4 py-3"\n >\n {h}\n </th>\n ))}\n </tr>\n </thead>\n <tbody>\n {rows.map((r, i) => (\n <tr\n key={r[0]}\n className={i % 2 === 0 ? "bg-background" : "bg-muted/30"}\n >\n {r.map((cell, j) => (\n <td\n key={j}\n className={[\n "px-4 py-3 tabular-nums",\n j === 0 ? "font-semibold text-foreground" : "text-foreground/80",\n ].join(" ")}\n >\n {cell}\n </td>\n ))}\n </tr>\n ))}\n </tbody>\n </table>\n </div>\n );\n}\n' }, { "path": "app/search/search-client.tsx", "kind": "text", "content": '"use client";\n\nimport { SearchPage } from "@cimplify/sdk/react";\n\nexport function SearchClient() {\n return <SearchPage />;\n}\n' }, { "path": "app/search/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Suspense } from "react";\nimport { SearchClient } from "./search-client";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Search \u2014 ${brand.name}`,\n description: `Search ${brand.name} \u2014 products, collections, categories.`,\n};\n\nexport default function SearchPage() {\n return (\n <article className="max-w-7xl mx-auto px-6 sm:px-8 py-10">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-2">\n Search\n </p>\n <h1 className="font-serif text-[clamp(2rem,4vw,2.75rem)] font-semibold mb-8 -tracking-[0.02em]">\n Find anything.\n </h1>\n <Suspense fallback={<SearchSkeleton />}>\n <SearchClient />\n </Suspense>\n </article>\n );\n}\n\nfunction SearchSkeleton() {\n return (\n <div>\n <div className="h-12 w-full max-w-xl bg-muted rounded animate-pulse mb-8" />\n <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3 sm:gap-4">\n {Array.from({ length: 8 }).map((_, i) => (\n <div key={i} className="aspect-[4/3] bg-muted rounded-2xl animate-pulse" />\n ))}\n </div>\n </div>\n );\n}\n' }, { "path": "app/categories/[slug]/listing-client.tsx", "kind": "text", "content": '"use client";\n\nimport { ProductGrid } from "@cimplify/sdk/react";\nimport type { Product } from "@cimplify/sdk";\nimport { StoreProductCard } from "@/components/store-product-card";\n\n/**\n * Client island for the category listing. Receives server-fetched products\n * as props (serializable) and owns the `renderCard` function.\n */\nexport function ListingClient({ products }: { products: Product[] }) {\n return (\n <ProductGrid\n products={products}\n emptyMessage="No products in this category yet."\n renderCard={(p) => <StoreProductCard product={p} />}\n />\n );\n}\n' }, { "path": "app/categories/[slug]/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Suspense } from "react";\nimport Link from "next/link";\nimport { notFound } from "next/navigation";\nimport {\n getServerClient,\n tags,\n type Category,\n type Product,\n} from "@cimplify/sdk/server";\nimport { ListingClient } from "./listing-client";\nimport { brand } from "@/lib/brand";\n\n// See app/products/[slug]/page.tsx for the rationale on generateStaticParams.\nexport async function generateStaticParams() {\n const r = await getServerClient().catalogue.getCategories();\n if (!r.ok || r.value.length === 0) {\n return [{ slug: "__placeholder__" }];\n }\n return r.value.map((c) => ({ slug: c.slug ?? c.id }));\n}\n\nexport const revalidate = 3600;\n\ninterface CategoryData {\n category: Category;\n products: Product[];\n}\n\ntype CategoryResult =\n | { ok: true; data: CategoryData }\n | { ok: false; code: string };\n\nasync function getCategory(slug: string): Promise<CategoryResult> {\n const client = getServerClient();\n const catRes = await client.catalogue.getCategoryBySlug(slug, {\n cacheOptions: { revalidate: 3600, tags: [tags.categories()] },\n });\n if (!catRes.ok) return { ok: false, code: catRes.error.code };\n\n const r = await client.catalogue.getCategoryProducts(catRes.value.id, undefined, {\n cacheOptions: {\n revalidate: 3600,\n tags: [\n tags.products(),\n tags.category(catRes.value.id),\n tags.categoryProducts(catRes.value.id),\n ],\n },\n });\n const products = r.ok\n ? ((r.value as { items?: Product[] }).items ?? (r.value as Product[]))\n : [];\n return { ok: true, data: { category: catRes.value, products } };\n}\n\nexport async function generateMetadata({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}): Promise<Metadata> {\n const { slug } = await params;\n const result = await getCategory(slug);\n if (!result.ok) return {};\n const data = result.data;\n return {\n title: `${data.category.name} \u2014 ${brand.name}`,\n description: data.category.description ?? undefined,\n };\n}\n\nexport default async function CategoryPage({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}) {\n return (\n <Suspense fallback={<CategorySkeleton />}>\n <CategoryContent params={params} />\n </Suspense>\n );\n}\n\nasync function CategoryContent({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}) {\n const { slug } = await params;\n const result = await getCategory(slug);\n if (!result.ok) {\n if (result.code === "NOT_FOUND") notFound();\n return <CategorySkeleton />;\n }\n const { category, products } = result.data;\n return (\n <>\n <section className="bg-foreground text-background relative overflow-hidden">\n <div className="absolute inset-0 opacity-[0.04] pointer-events-none [background-image:radial-gradient(circle_at_2px_2px,white_1px,transparent_0)] [background-size:32px_32px]" />\n <div className="relative max-w-7xl mx-auto px-6 sm:px-8 py-12 sm:py-14">\n <nav className="text-[12px] font-mono text-background/60 mb-3 flex items-center gap-2">\n <Link href="/" className="hover:text-background transition-colors">Home</Link>\n <span>/</span>\n <Link href="/shop" className="hover:text-background transition-colors">Shop</Link>\n <span>/</span>\n <span className="text-background/90">{category.name}</span>\n </nav>\n <h1 className="text-[clamp(2rem,4vw,3rem)] font-bold m-0 -tracking-[0.025em]">\n {category.name}\n </h1>\n {category.description && (\n <p className="mt-3 max-w-2xl text-base text-background/75">\n {category.description}\n </p>\n )}\n <p className="mt-4 text-[11px] font-mono uppercase tracking-[0.16em] text-background/60 tabular-nums">\n {products.length} {products.length === 1 ? "product" : "products"}\n </p>\n </div>\n </section>\n <section className="max-w-7xl mx-auto px-6 sm:px-8 py-10 sm:py-12">\n <ListingClient products={products} />\n {products.length === 0 && (\n <p className="text-center mt-8">\n <Link href="/shop" className="text-primary font-semibold hover:underline">\n \u2190 Browse all products\n </Link>\n </p>\n )}\n </section>\n </>\n );\n}\n\nfunction CategorySkeleton() {\n return (\n <>\n <section className="bg-foreground py-12 sm:py-14">\n <div className="max-w-7xl mx-auto px-6 sm:px-8">\n <div className="h-3 w-32 bg-background/20 rounded mb-3 animate-pulse" />\n <div className="h-12 w-72 bg-background/20 rounded animate-pulse" />\n </div>\n </section>\n <section className="max-w-7xl mx-auto px-6 sm:px-8 py-10 sm:py-12">\n <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3 sm:gap-4">\n {Array.from({ length: 8 }).map((_, i) => (\n <div key={i} className="aspect-[4/3] bg-muted rounded-2xl animate-pulse" />\n ))}\n </div>\n </section>\n </>\n );\n}\n' }, { "path": "app/terms/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Terms of Service \u2014 ${brand.name}`,\n description: `The rules of the road for ordering from ${brand.name}.`,\n};\n\nexport default function TermsPage() {\n const t = brand.terms;\n return (\n <article className="max-w-3xl mx-auto px-6 sm:px-8 py-16 prose prose-lg max-w-none">\n <p className="text-[11px] font-mono uppercase tracking-[0.16em] text-primary mb-2 not-prose">\n {t.eyebrow}\n </p>\n <h1 className="text-[clamp(2.25rem,5vw,3.5rem)] font-bold mb-2 -tracking-[0.025em]">\n {t.title}\n </h1>\n <p className="text-sm text-muted-foreground not-prose mb-10">\n Last updated: {t.lastUpdated}\n </p>\n\n <section className="space-y-5 leading-relaxed text-foreground/90">\n {t.sections.map((s) => (\n <div key={s.heading}>\n <h2 className="text-2xl font-semibold mt-0 -tracking-[0.02em]">{s.heading}</h2>\n {typeof s.body === "string" ? (\n <p>{s.body}</p>\n ) : (\n <>\n <p>{s.body.intro}</p>\n <ul className="list-disc pl-6 space-y-2">\n {s.body.bullets.map((b) => (\n <li key={b}>{b}</li>\n ))}\n </ul>\n </>\n )}\n </div>\n ))}\n </section>\n </article>\n );\n}\n' }, { "path": "app/not-found.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport Link from "next/link";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Page not found \u2014 ${brand.name}`,\n description: "We couldn\'t find that page.",\n};\n\nexport default function NotFound() {\n return (\n <section className="max-w-2xl mx-auto px-6 sm:px-8 py-20 text-center">\n <p className="text-[11px] font-mono uppercase tracking-[0.16em] text-primary mb-3">\n 404\n </p>\n <h1 className="text-[clamp(2.5rem,5vw,4rem)] font-bold mb-4 -tracking-[0.03em]">\n Page not found.\n </h1>\n <p className="text-muted-foreground leading-relaxed mb-8">\n The URL you followed might be old, a typo, or pointing at a product\n that&apos;s no longer in stock. Try the menu or head back home.\n </p>\n <div className="flex flex-wrap items-center justify-center gap-3">\n <Link\n href="/shop"\n className="inline-flex items-center gap-2 px-5 py-2.5 rounded-md bg-primary text-primary-foreground font-semibold text-sm hover:bg-primary/90 transition-colors"\n >\n Browse the shop\n </Link>\n <Link\n href="/"\n className="inline-flex items-center gap-2 px-5 py-2.5 rounded-md border border-border hover:bg-muted transition-colors text-sm font-medium"\n >\n Back home\n </Link>\n </div>\n </section>\n );\n}\n' }, { "path": "app/cart/page.tsx", "kind": "text", "content": '"use client";\n\nimport { useRouter } from "next/navigation";\nimport { CartPage as SdkCartPage } from "@cimplify/sdk/react";\n\nexport default function CartPage() {\n const router = useRouter();\n return <SdkCartPage onCheckout={() => router.push("/checkout")} />;\n}\n' }, { "path": "app/accessibility/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\nimport { PolicyPage } from "@/components/policy-page";\n\nexport const metadata: Metadata = {\n title: `${brand.accessibility.title} \u2014 ${brand.name}`,\n};\n\nexport default function AccessibilityPage() {\n return <PolicyPage policy={brand.accessibility} />;\n}\n' }, { "path": "app/account/orders/[id]/page.tsx", "kind": "text", "content": 'import { notFound } from "next/navigation";\nimport { AccountSignedOutPrompt, toAccountOrderView } from "@cimplify/sdk/react";\nimport type { EnrichedOrder } from "@cimplify/sdk";\nimport { brand } from "@/lib/brand";\nimport { apiFetch, serverAccessToken, serverSession } from "@/lib/cimplify-server";\nimport { OrderDetail } from "@/components/order-detail";\n\nconst CLIENT_ID = process.env.NEXT_PUBLIC_CIMPLIFY_CLIENT_ID ?? "";\nconst REDIRECT_URI = process.env.CIMPLIFY_REDIRECT_URI ?? "";\n\nexport default async function OrderDetailPage({\n params,\n}: {\n params: Promise<{ id: string }>;\n}) {\n const { id } = await params;\n\n const session = await serverSession();\n if (!session) {\n return (\n <AccountSignedOutPrompt\n clientId={CLIENT_ID}\n redirectUri={REDIRECT_URI}\n eyebrow={brand.account.loginEyebrow}\n title={brand.account.loginTitle}\n description={brand.account.loginSubtitle}\n />\n );\n }\n\n const bearer = await serverAccessToken();\n const order = await apiFetch<EnrichedOrder>(`/api/v1/orders/${encodeURIComponent(id)}`, {\n bearer,\n });\n if (!order) notFound();\n\n return (\n <div className="mx-auto w-full max-w-5xl px-4 py-8 sm:px-6">\n <OrderDetail {...toAccountOrderView(order)} />\n </div>\n );\n}\n' }, { "path": "app/account/orders/page.tsx", "kind": "text", "content": 'import {\n AccountOrdersPage,\n AccountSignedOutPrompt,\n} from "@cimplify/sdk/react";\nimport type { OrderRowData, OrderRowStatus } from "@cimplify/sdk/react";\nimport type { Order, OrderStatus } from "@cimplify/sdk";\nimport { formatMoney } from "@cimplify/sdk";\nimport { brand } from "@/lib/brand";\nimport { ACCOUNT_NAV, apiFetch, serverAccessToken, serverSession } from "@/lib/cimplify-server";\n\nconst CLIENT_ID = process.env.NEXT_PUBLIC_CIMPLIFY_CLIENT_ID ?? "";\nconst REDIRECT_URI = process.env.CIMPLIFY_REDIRECT_URI ?? "";\n\nfunction mapStatus(status: OrderStatus): OrderRowStatus {\n switch (status) {\n case "in_preparation":\n case "ready_to_serve":\n case "partially_served":\n return { kind: "preparing", label: "Preparing" };\n case "delivered":\n return { kind: "delivered" };\n case "picked_up":\n return { kind: "delivered", label: "Picked up" };\n case "served":\n case "completed":\n return { kind: "completed" };\n case "cancelled":\n case "refunded":\n return { kind: "cancelled", label: status === "refunded" ? "Refunded" : undefined };\n case "pending":\n case "created":\n case "confirmed":\n default:\n return { kind: "active", label: "Confirmed" };\n }\n}\n\nfunction relativeMeta(iso: string, orderNumber: string): string {\n const when = new Date(iso);\n const formatted = when.toLocaleDateString(undefined, {\n month: "short",\n day: "numeric",\n year: when.getFullYear() === new Date().getFullYear() ? undefined : "numeric",\n });\n return `Order ${orderNumber} \xB7 ${formatted}`;\n}\n\nfunction toRowData(order: Order): OrderRowData {\n const count = order.items?.length ?? 0;\n return {\n id: order.id,\n href: `/account/orders/${order.id}`,\n summary: count > 0 ? `${count} ${count === 1 ? "item" : "items"}` : "Order",\n meta: relativeMeta(order.created_at, order.user_friendly_id ?? order.id),\n status: mapStatus(order.status),\n total: formatMoney(order.total_price, order.currency),\n };\n}\n\nexport default async function OrdersPage() {\n const session = await serverSession();\n if (!session) {\n return (\n <AccountSignedOutPrompt\n clientId={CLIENT_ID}\n redirectUri={REDIRECT_URI}\n eyebrow={brand.account.loginEyebrow}\n title={brand.account.loginTitle}\n description={brand.account.loginSubtitle}\n />\n );\n }\n\n const bearer = await serverAccessToken();\n const orders = await apiFetch<Order[]>(\n `/v1/link/orders?business_id=${encodeURIComponent(brand.mock.businessId)}`,\n { bearer },\n );\n\n return (\n <AccountOrdersPage\n customerName={session.name ?? ""}\n customerEmail={session.email}\n customerId={session.sub}\n merchantName={brand.name}\n nav={ACCOUNT_NAV}\n orders={(orders ?? []).map(toRowData)}\n />\n );\n}\n' }, { "path": "app/account/page.tsx", "kind": "text", "content": 'import {\n AccountDashboardPage,\n AccountSignedOutPrompt,\n} from "@cimplify/sdk/react";\nimport type { CustomerAddress, CustomerMobileMoney } from "@cimplify/sdk";\nimport { brand } from "@/lib/brand";\nimport { ACCOUNT_NAV, apiFetch, serverAccessToken, serverSession } from "@/lib/cimplify-server";\n\nconst CLIENT_ID = process.env.NEXT_PUBLIC_CIMPLIFY_CLIENT_ID ?? "";\nconst REDIRECT_URI = process.env.CIMPLIFY_REDIRECT_URI ?? "";\n\nexport default async function AccountPage() {\n const session = await serverSession();\n if (!session) {\n return (\n <AccountSignedOutPrompt\n clientId={CLIENT_ID}\n redirectUri={REDIRECT_URI}\n eyebrow={brand.account.loginEyebrow}\n title={brand.account.loginTitle}\n description={brand.account.loginSubtitle}\n />\n );\n }\n\n const bearer = await serverAccessToken();\n const [addresses, wallets] = await Promise.all([\n apiFetch<CustomerAddress[]>("/v1/link/addresses", { bearer }),\n apiFetch<CustomerMobileMoney[]>("/v1/link/mobile-money", { bearer }),\n ]);\n\n return (\n <AccountDashboardPage\n customerName={session.name ?? ""}\n customerEmail={session.email}\n customerId={session.sub}\n merchantName={brand.name}\n nav={ACCOUNT_NAV}\n identitySummary={{\n addresses: addresses?.length ?? 0,\n paymentMethods: wallets?.length ?? 0,\n }}\n />\n );\n}\n' }, { "path": "app/.well-known/ucp/route.ts", "kind": "text", "content": 'import { NextResponse } from "next/server";\n\n/**\n * UCP (Universal Commerce Protocol) manifest discovery endpoint.\n *\n * Agents \u2014 Claude, ChatGPT, Gemini, MCP clients \u2014 probe\n * `https://<your-domain>/.well-known/ucp` to learn what commerce\n * capabilities your storefront supports. We resolve the business\'s\n * UCP handle from the public key (via the storefront API), then forward\n * to Cimplify for the canonical manifest. Edge-cached for an hour\n * because capabilities change rarely.\n */\nconst UCP_API_BASE = "https://api.cimplify.io";\nconst STOREFRONT_API_BASE =\n process.env.NODE_ENV === "production"\n ? "https://storefronts.cimplify.io"\n : "http://127.0.0.1:8787";\n\nexport async function GET() {\n const publicKey = process.env.NEXT_PUBLIC_CIMPLIFY_PUBLIC_KEY;\n if (!publicKey) {\n return NextResponse.json(\n {\n error: "NEXT_PUBLIC_CIMPLIFY_PUBLIC_KEY not set",\n remediation:\n "Set NEXT_PUBLIC_CIMPLIFY_PUBLIC_KEY in .env.local (and your deployment env).",\n },\n { status: 500 },\n );\n }\n\n try {\n // Step 1 \u2014 resolve the business handle from the public key.\n const businessResp = await fetch(`${STOREFRONT_API_BASE}/api/v1/business`, {\n headers: { "X-API-Key": publicKey, "Content-Type": "application/json" },\n next: { revalidate: 3600 },\n });\n\n if (!businessResp.ok) {\n return NextResponse.json(\n { error: `Failed to resolve business: ${businessResp.status}` },\n { status: businessResp.status },\n );\n }\n\n const businessJson = await businessResp.json();\n const handle: string | undefined = businessJson?.data?.handle;\n if (!handle) {\n return NextResponse.json(\n { error: "Business has no handle configured" },\n { status: 500 },\n );\n }\n\n // Step 2 \u2014 fetch the canonical UCP manifest.\n const manifestResp = await fetch(\n `${UCP_API_BASE}/ucp/v1/${handle}/manifest`,\n {\n headers: { "Content-Type": "application/json" },\n next: { revalidate: 3600 },\n },\n );\n\n if (!manifestResp.ok) {\n return NextResponse.json(\n { error: `Upstream UCP manifest fetch failed: ${manifestResp.status}` },\n { status: manifestResp.status },\n );\n }\n\n const manifest = await manifestResp.json();\n return NextResponse.json(manifest, {\n headers: {\n "Content-Type": "application/json",\n "Cache-Control": "public, max-age=3600, s-maxage=3600",\n },\n });\n } catch (error) {\n return NextResponse.json(\n {\n error: "Failed to fetch UCP manifest",\n detail: error instanceof Error ? error.message : String(error),\n },\n { status: 500 },\n );\n }\n}\n' }, { "path": "app/returns/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\nimport { PolicyPage } from "@/components/policy-page";\n\nexport const metadata: Metadata = {\n title: `${brand.returns.title} \u2014 ${brand.name}`,\n};\n\nexport default function ReturnsPage() {\n return <PolicyPage policy={brand.returns} />;\n}\n' }, { "path": "app/orders/[id]/page.tsx", "kind": "text", "content": 'import Link from "next/link";\n\nexport default async function OrderPage({ params }: { params: Promise<{ id: string }> }) {\n const { id } = await params;\n return (\n <section className="max-w-2xl mx-auto px-6 sm:px-8 py-20 text-center">\n <h1 className="text-3xl mt-0 mb-3 font-bold -tracking-[0.025em]">\n Order confirmed.\n </h1>\n <p className="text-muted-foreground">\n Order <code className="font-mono text-foreground">{id}</code> \u2014 you&apos;ll\n get an SMS with tracking within 30 minutes.\n </p>\n <p className="mt-6">\n <Link\n href="/"\n className="inline-block px-6 py-3 rounded-md bg-primary text-primary-foreground font-semibold text-sm transition-colors hover:bg-primary/90"\n >\n Continue shopping\n </Link>\n </p>\n </section>\n );\n}\n' }, { "path": "app/globals.css", "kind": "text", "content": '@import "tailwindcss";\n\n@source "../node_modules/@cimplify/sdk/dist";\n\n/* Studio FRX \u2014 Nike/Aritzia-style streetwear palette. High-contrast\n neutrals with one electric accent. Tweak primary to retheme everything. */\n@theme {\n --color-background: oklch(0.99 0 0);\n --color-foreground: oklch(0.12 0 0);\n --color-card: oklch(1 0 0);\n --color-card-foreground: oklch(0.12 0 0);\n --color-popover: oklch(1 0 0);\n --color-popover-foreground: oklch(0.12 0 0);\n --color-primary: oklch(0.7 0.24 30);\n --color-primary-foreground: oklch(0.99 0 0);\n --color-secondary: oklch(0.96 0 0);\n --color-secondary-foreground: oklch(0.18 0 0);\n --color-muted: oklch(0.96 0 0);\n --color-muted-foreground: oklch(0.45 0 0);\n --color-accent: oklch(0.92 0 0);\n --color-accent-foreground: oklch(0.18 0 0);\n --color-destructive: oklch(0.58 0.24 27);\n --color-destructive-foreground: oklch(0.99 0 0);\n --color-border: oklch(0.9 0 0);\n --color-input: oklch(0.93 0 0);\n --color-ring: oklch(0.7 0.24 30);\n --radius: 0.125rem;\n}\n\n@layer base {\n html, body {\n margin: 0;\n padding: 0;\n background: var(--color-background);\n color: var(--color-foreground);\n -webkit-font-smoothing: antialiased;\n -moz-osx-font-smoothing: grayscale;\n }\n a { color: inherit; text-decoration: none; }\n h1, h2, h3, h4 {\n font-family: var(--font-display);\n letter-spacing: -0.04em;\n font-weight: 800;\n line-height: 0.95;\n }\n}\n' }, { "path": "app/about/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `About \u2014 ${brand.name}`,\n description: brand.description,\n};\n\nexport default function AboutPage() {\n const a = brand.about;\n // Title supports a single \\n for a hard line break.\n const titleParts = a.title.split("\\n");\n return (\n <article className="max-w-3xl mx-auto px-6 sm:px-8 py-16">\n <p className="text-[11px] font-mono uppercase tracking-[0.16em] text-primary mb-2">\n {a.eyebrow}\n </p>\n <h1 className="text-[clamp(2.25rem,5vw,3.5rem)] font-bold mb-6 -tracking-[0.025em] leading-tight">\n {titleParts.map((line, i) => (\n <span key={i}>\n {line}\n {i < titleParts.length - 1 && <br />}\n </span>\n ))}\n </h1>\n <div className="prose prose-lg max-w-none space-y-5 text-foreground/90 leading-relaxed">\n {a.paragraphs.map((p, i) => (\n <p key={i}>{p}</p>\n ))}\n {a.sections.map((s) => (\n <div key={s.heading}>\n <h2 className="text-2xl font-semibold mt-10 mb-3 -tracking-[0.02em]">\n {s.heading}\n </h2>\n <p>{s.body}</p>\n </div>\n ))}\n </div>\n </article>\n );\n}\n' }, { "path": "app/auth/signout/route.ts", "kind": "text", "content": 'import { buildSignoutCookies } from "@cimplify/sdk/server";\n\nconst CLIENT_ID = process.env.CIMPLIFY_CLIENT_ID ?? "";\n\nexport async function POST(): Promise<Response> {\n const headers = new Headers({ "Content-Type": "application/json" });\n for (const cookie of buildSignoutCookies({ clientId: CLIENT_ID })) {\n headers.append("Set-Cookie", cookie);\n }\n return new Response(JSON.stringify({ ok: true }), { status: 200, headers });\n}\n' }, { "path": "app/auth/session/route.ts", "kind": "text", "content": 'import { handleSessionRequest } from "@cimplify/sdk/server";\n\nconst CLIENT_ID = process.env.CIMPLIFY_CLIENT_ID ?? "";\nconst ISSUER = process.env.CIMPLIFY_ISSUER;\n\nexport async function GET(req: Request): Promise<Response> {\n if (!CLIENT_ID) {\n return Response.json({ sub: null }, { status: 200 });\n }\n return handleSessionRequest(req, { clientId: CLIENT_ID, issuer: ISSUER });\n}\n' }, { "path": "app/auth/callback/route.ts", "kind": "text", "content": 'import { handleOidcCallback, handleRedirectCallback } from "@cimplify/sdk/server";\n\nconst CLIENT_ID = process.env.CIMPLIFY_CLIENT_ID ?? "";\nconst ISSUER = process.env.CIMPLIFY_ISSUER;\nconst REDIRECT_URI = process.env.CIMPLIFY_REDIRECT_URI ?? "";\n\n// Redirect sign-in: the browser lands here via a top-level GET carrying ?code.\nexport async function GET(req: Request): Promise<Response> {\n if (!CLIENT_ID || !REDIRECT_URI) {\n return Response.json({ error: "oidc_not_configured" }, { status: 500 });\n }\n return handleRedirectCallback(req, {\n clientId: CLIENT_ID,\n issuer: ISSUER,\n redirectUri: REDIRECT_URI,\n defaultReturnTo: "/account",\n });\n}\n\n// Modal/silent sign-in: posts {code, codeVerifier, state} here for exchange.\nexport async function POST(req: Request): Promise<Response> {\n if (!CLIENT_ID || !REDIRECT_URI) {\n return Response.json({ error: "oidc_not_configured" }, { status: 500 });\n }\n return handleOidcCallback(req, {\n clientId: CLIENT_ID,\n issuer: ISSUER,\n redirectUri: REDIRECT_URI,\n });\n}\n' }, { "path": "app/privacy/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Privacy Policy \u2014 ${brand.name}`,\n description: `How ${brand.name} collects, uses, and protects your personal data.`,\n};\n\nexport default function PrivacyPage() {\n const p = brand.privacy;\n return (\n <article className="max-w-3xl mx-auto px-6 sm:px-8 py-16 prose prose-lg max-w-none">\n <p className="text-[11px] font-mono uppercase tracking-[0.16em] text-primary mb-2 not-prose">\n {p.eyebrow}\n </p>\n <h1 className="text-[clamp(2.25rem,5vw,3.5rem)] font-bold mb-2 -tracking-[0.025em]">\n {p.title}\n </h1>\n <p className="text-sm text-muted-foreground not-prose mb-10">\n Last updated: {p.lastUpdated}\n </p>\n\n <section className="space-y-5 leading-relaxed text-foreground/90">\n {p.sections.map((s) => (\n <div key={s.heading}>\n <h2 className="text-2xl font-semibold mt-0 -tracking-[0.02em]">{s.heading}</h2>\n {typeof s.body === "string" ? (\n <p>{s.body}</p>\n ) : (\n <>\n <p>{s.body.intro}</p>\n <ul className="list-disc pl-6 space-y-2">\n {s.body.bullets.map((b) => (\n <li key={b}>{b}</li>\n ))}\n </ul>\n </>\n )}\n </div>\n ))}\n </section>\n </article>\n );\n}\n' }, { "path": "app/sitemap-page/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport Link from "next/link";\nimport { getServerClient, tags, type Product } from "@cimplify/sdk/server";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Sitemap \u2014 ${brand.name}`,\n description: "A human-readable index of every page on this site.",\n};\n\nexport const revalidate = 3600;\n\ninterface SitemapData {\n products: { slug: string; name: string }[];\n categories: { slug: string; name: string }[];\n collections: { slug: string; name: string }[];\n}\n\nasync function getSitemap(): Promise<SitemapData> {\n const client = getServerClient();\n const [pRes, cRes, colRes] = await Promise.all([\n client.catalogue.getProducts(\n { limit: 500 },\n { cacheOptions: { revalidate: 3600, tags: [tags.products()] } },\n ),\n client.catalogue.getCategories({\n cacheOptions: { revalidate: 3600, tags: [tags.categories()] },\n }),\n client.catalogue.getCollections({\n cacheOptions: { revalidate: 3600, tags: [tags.collections()] },\n }),\n ]);\n return {\n products: (pRes.ok ? pRes.value.items : []).map((p: Product) => ({\n slug: p.slug ?? p.id,\n name: p.name,\n })),\n categories: (cRes.ok ? cRes.value : []).map((c) => ({ slug: c.slug, name: c.name })),\n collections: (colRes.ok ? colRes.value : []).map((c) => ({ slug: c.slug, name: c.name })),\n };\n}\n\nconst STATIC_LINKS: { title: string; links: { href: string; label: string }[] }[] = [\n {\n title: "Browse",\n links: [\n { href: "/", label: "Home" },\n { href: "/shop", label: "Shop" },\n { href: "/search", label: "Search" },\n ],\n },\n {\n title: "Account",\n links: [\n { href: "/account", label: "Account" },\n { href: "/account/orders", label: "Orders" },\n { href: "/login", label: "Sign in" },\n { href: "/signup", label: "Create account" },\n { href: "/track-order", label: "Track an order" },\n { href: "/cart", label: "Cart" },\n { href: "/checkout", label: "Checkout" },\n ],\n },\n {\n title: "About",\n links: [\n { href: "/about", label: "About" },\n { href: "/faq", label: "FAQ" },\n { href: "/contact", label: "Contact" },\n ],\n },\n {\n title: "Policies",\n links: [\n { href: "/shipping", label: "Shipping" },\n { href: "/returns", label: "Returns" },\n { href: "/accessibility", label: "Accessibility" },\n { href: "/terms", label: "Terms of Service" },\n { href: "/privacy", label: "Privacy Policy" },\n ],\n },\n {\n title: "Machine-readable",\n links: [\n { href: "/sitemap.xml", label: "sitemap.xml (search engines)" },\n { href: "/llms.txt", label: "llms.txt (LLM agents)" },\n { href: "/robots.txt", label: "robots.txt" },\n { href: "/opensearch.xml", label: "opensearch.xml (browser search)" },\n ],\n },\n];\n\nexport default async function SitemapHtmlPage() {\n const { products, categories, collections } = await getSitemap();\n\n return (\n <article className="max-w-5xl mx-auto px-6 sm:px-8 py-16">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-2">\n Sitemap\n </p>\n <h1 className="text-[clamp(2rem,4vw,2.75rem)] font-semibold mb-2 -tracking-[0.02em]">\n Every page, in one place.\n </h1>\n <p className="text-muted-foreground mb-12">\n For search engines, see <Link href="/sitemap.xml" className="text-primary hover:underline">/sitemap.xml</Link>.\n For LLM agents, see <Link href="/llms.txt" className="text-primary hover:underline">/llms.txt</Link>.\n </p>\n <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-10">\n {STATIC_LINKS.map((s) => (\n <Section key={s.title} title={s.title}>\n {s.links.map((l) => (\n <li key={l.href}>\n <Link href={l.href} className="hover:text-primary transition-colors">\n {l.label}\n </Link>\n </li>\n ))}\n </Section>\n ))}\n {categories.length > 0 && (\n <Section title="Categories">\n {categories.map((c) => (\n <li key={c.slug}>\n <Link href={`/categories/${c.slug}`} className="hover:text-primary transition-colors">\n {c.name}\n </Link>\n </li>\n ))}\n </Section>\n )}\n {collections.length > 0 && (\n <Section title="Collections">\n {collections.map((c) => (\n <li key={c.slug}>\n <Link href={`/collections/${c.slug}`} className="hover:text-primary transition-colors">\n {c.name}\n </Link>\n </li>\n ))}\n </Section>\n )}\n {products.length > 0 && (\n <Section title={`Products (${products.length})`}>\n {products.map((p) => (\n <li key={p.slug}>\n <Link href={`/shop?product=${encodeURIComponent(p.slug)}`} className="hover:text-primary transition-colors">\n {p.name}\n </Link>\n </li>\n ))}\n </Section>\n )}\n </div>\n </article>\n );\n}\n\nfunction Section({ title, children }: { title: string; children: React.ReactNode }) {\n return (\n <div>\n <p className="font-semibold text-[12px] uppercase tracking-[0.12em] text-foreground mb-3">\n {title}\n </p>\n <ul className="space-y-2 m-0 p-0 list-none text-sm text-muted-foreground">\n {children}\n </ul>\n </div>\n );\n}\n' }, { "path": "app/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": "app/collections/[slug]/listing-client.tsx", "kind": "text", "content": '"use client";\n\nimport { ProductGrid } from "@cimplify/sdk/react";\nimport type { Product } from "@cimplify/sdk";\nimport { StoreProductCard } from "@/components/store-product-card";\n\n/**\n * Client island for the collection listing. Receives server-fetched\n * products as props (serializable) and owns the `renderCard` function\n * (which can\'t cross the server/client boundary).\n */\nexport function ListingClient({ products }: { products: Product[] }) {\n return (\n <ProductGrid\n products={products}\n emptyMessage="No products in this collection yet."\n renderCard={(p) => <StoreProductCard product={p} />}\n />\n );\n}\n' }, { "path": "app/collections/[slug]/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Suspense } from "react";\nimport Link from "next/link";\nimport { notFound } from "next/navigation";\nimport {\n getServerClient,\n tags,\n type Collection,\n type Product,\n} from "@cimplify/sdk/server";\nimport { ListingClient } from "./listing-client";\nimport { brand } from "@/lib/brand";\n\n// See app/products/[slug]/page.tsx for the rationale on generateStaticParams.\nexport async function generateStaticParams() {\n const r = await getServerClient().catalogue.getCollections();\n if (!r.ok || r.value.length === 0) {\n return [{ slug: "__placeholder__" }];\n }\n return r.value.map((c) => ({ slug: c.slug ?? c.id }));\n}\n\nexport const revalidate = 3600;\n\ninterface CollectionData {\n collection: Collection;\n products: Product[];\n}\n\ntype CollectionResult =\n | { ok: true; data: CollectionData }\n | { ok: false; code: string };\n\nasync function getCollection(slug: string): Promise<CollectionResult> {\n const client = getServerClient();\n const colRes = await client.catalogue.getCollectionBySlug(slug, {\n cacheOptions: { revalidate: 3600, tags: [tags.collections()] },\n });\n if (!colRes.ok) return { ok: false, code: colRes.error.code };\n\n const r = await client.catalogue.getCollectionProducts(colRes.value.id, undefined, {\n cacheOptions: {\n revalidate: 3600,\n tags: [\n tags.products(),\n tags.collection(colRes.value.id),\n tags.collectionProducts(colRes.value.id),\n ],\n },\n });\n const products = r.ok\n ? ((r.value as { items?: Product[] }).items ?? (r.value as Product[]))\n : [];\n return { ok: true, data: { collection: colRes.value, products } };\n}\n\nexport async function generateMetadata({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}): Promise<Metadata> {\n const { slug } = await params;\n const result = await getCollection(slug);\n if (!result.ok) return {};\n const data = result.data;\n return {\n title: `${data.collection.name} \u2014 ${brand.name}`,\n description: data.collection.description ?? undefined,\n };\n}\n\nexport default async function CollectionPage({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}) {\n return (\n <Suspense fallback={<CollectionSkeleton />}>\n <CollectionContent params={params} />\n </Suspense>\n );\n}\n\nasync function CollectionContent({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}) {\n const { slug } = await params;\n const result = await getCollection(slug);\n if (!result.ok) {\n if (result.code === "NOT_FOUND") notFound();\n return <CollectionSkeleton />;\n }\n const { collection, products } = result.data;\n return (\n <>\n <section className="bg-foreground text-background relative overflow-hidden">\n <div className="absolute inset-0 opacity-[0.04] pointer-events-none [background-image:radial-gradient(circle_at_2px_2px,white_1px,transparent_0)] [background-size:32px_32px]" />\n <div className="relative max-w-7xl mx-auto px-6 sm:px-8 py-12 sm:py-14">\n <nav className="text-[12px] font-mono text-background/60 mb-3 flex items-center gap-2">\n <Link href="/" className="hover:text-background transition-colors">Home</Link>\n <span>/</span>\n <span className="text-background/90">Collections</span>\n <span>/</span>\n <span className="text-background/90">{collection.name}</span>\n </nav>\n <h1 className="text-[clamp(2rem,4vw,3rem)] font-bold m-0 -tracking-[0.025em]">\n {collection.name}\n </h1>\n {collection.description && (\n <p className="mt-3 max-w-2xl text-base text-background/75">\n {collection.description}\n </p>\n )}\n <p className="mt-4 text-[11px] font-mono uppercase tracking-[0.16em] text-background/60 tabular-nums">\n {products.length} {products.length === 1 ? "product" : "products"}\n </p>\n </div>\n </section>\n <section className="max-w-7xl mx-auto px-6 sm:px-8 py-10 sm:py-12">\n <ListingClient products={products} />\n {products.length === 0 && (\n <p className="text-center mt-8">\n <Link href="/shop" className="text-primary font-semibold hover:underline">\n \u2190 Browse all products\n </Link>\n </p>\n )}\n </section>\n </>\n );\n}\n\nfunction CollectionSkeleton() {\n return (\n <>\n <section className="bg-foreground py-12 sm:py-14">\n <div className="max-w-7xl mx-auto px-6 sm:px-8">\n <div className="h-3 w-32 bg-background/20 rounded mb-3 animate-pulse" />\n <div className="h-12 w-72 bg-background/20 rounded animate-pulse" />\n </div>\n </section>\n <section className="max-w-7xl mx-auto px-6 sm:px-8 py-10 sm:py-12">\n <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3 sm:gap-4">\n {Array.from({ length: 8 }).map((_, i) => (\n <div key={i} className="aspect-[4/3] bg-muted rounded-2xl animate-pulse" />\n ))}\n </div>\n </section>\n </>\n );\n}\n' }, { "path": "app/layout.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Inter, Anton } from "next/font/google";\nimport "./globals.css";\nimport { Providers } from "@/components/providers";\nimport { getServerClient, tags } from "@cimplify/sdk/server";\nimport { Header } from "@/components/header";\nimport { Footer } from "@/components/footer";\nimport { ProductModal } from "@/components/product-modal";\nimport { CartDrawer } from "@/components/cart-drawer";\nimport { SupportChat } from "@/components/support-chat";\nimport { OrganizationJsonLd } from "@/components/json-ld";\nimport { Suspense } from "react";\nimport { brand } from "@/lib/brand";\nimport { getSiteUrl } from "@/lib/site-url";\n\nconst inter = Inter({\n subsets: ["latin"],\n variable: "--font-sans",\n display: "swap",\n});\n\nconst anton = Anton({\n subsets: ["latin"],\n variable: "--font-display",\n weight: "400",\n display: "swap",\n});\n\nexport async function generateMetadata(): Promise<Metadata> {\n const siteUrl = await getSiteUrl();\n return {\n metadataBase: new URL(siteUrl),\n title: {\n default: brand.name,\n template: `%s \u2014 ${brand.name}`,\n },\n description: brand.description,\n openGraph: {\n type: "website",\n siteName: brand.name,\n locale: brand.locale,\n },\n twitter: { card: "summary_large_image" },\n };\n}\n\nexport default async function RootLayout({ children }: { children: React.ReactNode }) {\n const server = getServerClient();\n const [bizResult, locResult] = await Promise.all([\n server.business.getInfo({ cacheOptions: { revalidate: 3600, tags: [tags.business()] } }),\n server.business.getLocations({ cacheOptions: { revalidate: 3600, tags: [tags.locations()] } }),\n ]);\n const initialBusiness = bizResult.ok ? bizResult.value : null;\n const initialLocations = locResult.ok ? locResult.value : [];\n\n return (\n <html lang="en" suppressHydrationWarning className={`${inter.variable} ${anton.variable}`}>\n <body\n suppressHydrationWarning\n className="min-h-screen flex flex-col bg-background text-foreground font-sans"\n >\n <Suspense fallback={null}>\n <OrganizationJsonLd />\n </Suspense>\n <Providers initialBusiness={initialBusiness} initialLocations={initialLocations}>\n <Header />\n <main className="flex-1 pb-12 w-full">\n <Suspense fallback={null}>{children}</Suspense>\n </main>\n <Footer />\n <Suspense fallback={null}>\n <ProductModal />\n </Suspense>\n <CartDrawer />\n <SupportChat />\n </Providers>\n </body>\n </html>\n );\n}\n' }, { "path": "app/lookbook/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport Image from "next/image";\nimport Link from "next/link";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Lookbook \u2014 ${brand.name}`,\n description: "Editorial photography from each Studio FRX drop.",\n};\n\ninterface LookbookEntry {\n drop: string;\n title: string;\n hero: string;\n tiles: string[];\n byline: string;\n date: string;\n}\n\n// Each entry is a drop; tiles are the editorial shots. Replace the URLs\n// with your own asset bucket / CDN before going live.\nconst ENTRIES: LookbookEntry[] = [\n {\n drop: "Drop 04",\n title: "Built for now.",\n date: "Spring 2026",\n byline: "Photographed by Selasi Adjei in Jamestown, Accra.",\n hero: "https://images.unsplash.com/photo-1490481651871-ab68de25d43d?w=1800&h=1100&fit=crop&auto=format&q=85",\n tiles: [\n "https://images.unsplash.com/photo-1483985988355-763728e1935b?w=900&h=1200&fit=crop&auto=format&q=85",\n "https://images.unsplash.com/photo-1521572163474-6864f9cf17ab?w=900&h=1200&fit=crop&auto=format&q=85",\n "https://images.unsplash.com/photo-1485518882345-15568b007407?w=900&h=1200&fit=crop&auto=format&q=85",\n ],\n },\n {\n drop: "Drop 03",\n title: "Heavyweight, hand-screened.",\n date: "Winter 2025",\n byline: "Shot at the Tema dye works.",\n hero: "https://images.unsplash.com/photo-1467043153537-a4fba2cd39ef?w=1800&h=1100&fit=crop&auto=format&q=85",\n tiles: [\n "https://images.unsplash.com/photo-1576566588028-4147f3842f27?w=900&h=1200&fit=crop&auto=format&q=85",\n "https://images.unsplash.com/photo-1542838686-37da4a9fd1b3?w=900&h=1200&fit=crop&auto=format&q=85",\n "https://images.unsplash.com/photo-1496747611176-843222e1e57c?w=900&h=1200&fit=crop&auto=format&q=85",\n ],\n },\n {\n drop: "Drop 02",\n title: "From the studio floor.",\n date: "Autumn 2025",\n byline: "Studio days, Osu.",\n hero: "https://images.unsplash.com/photo-1525507119028-ed4c629a60a3?w=1800&h=1100&fit=crop&auto=format&q=85",\n tiles: [\n "https://images.unsplash.com/photo-1512436991641-6745cdb1723f?w=900&h=1200&fit=crop&auto=format&q=85",\n "https://images.unsplash.com/photo-1539109136881-3be0616acf4b?w=900&h=1200&fit=crop&auto=format&q=85",\n "https://images.unsplash.com/photo-1490114538077-0a7f8cb49891?w=900&h=1200&fit=crop&auto=format&q=85",\n ],\n },\n];\n\nexport default function LookbookPage() {\n return (\n <article>\n <section className="border-b border-border">\n <div className="max-w-7xl mx-auto px-6 sm:px-10 py-14">\n <p className="text-[11px] font-mono uppercase tracking-[0.2em] text-primary mb-3">\n Lookbook\n </p>\n <h1 className="font-display text-[clamp(3rem,9vw,7rem)] uppercase mb-4 -tracking-[0.04em] leading-[0.92]">\n The drops, in full.\n </h1>\n <p className="text-base text-muted-foreground max-w-2xl leading-relaxed">\n Each drop is photographed in the room it\'s built in. Editorials below;\n full collection in the shop.\n </p>\n </div>\n </section>\n\n {ENTRIES.map((entry, i) => (\n <section key={entry.drop} className="border-b border-border">\n <div className="max-w-7xl mx-auto px-6 sm:px-10 py-16">\n <div className="flex items-end justify-between gap-6 mb-8">\n <div>\n <p className="text-[11px] font-mono uppercase tracking-[0.2em] text-muted-foreground mb-2">\n {entry.drop} \xB7 {entry.date}\n </p>\n <h2 className="font-display text-[clamp(2rem,5vw,3.5rem)] uppercase m-0 -tracking-[0.04em] leading-[0.95]">\n {entry.title}\n </h2>\n <p className="text-sm text-muted-foreground mt-2">{entry.byline}</p>\n </div>\n <Link\n href="/shop"\n className="hidden sm:inline-flex items-center gap-2 text-sm font-semibold uppercase tracking-wider text-foreground hover:text-primary transition-colors whitespace-nowrap"\n >\n Shop {entry.drop} \u2192\n </Link>\n </div>\n\n <div className="relative w-full aspect-[16/9] rounded-2xl overflow-hidden bg-muted mb-3">\n <Image\n src={entry.hero}\n alt={`${entry.drop} hero \u2014 ${entry.title}`}\n fill\n sizes="(min-width: 1280px) 1280px, 100vw"\n className="object-cover"\n priority={i === 0}\n />\n </div>\n\n <div className="grid grid-cols-1 sm:grid-cols-3 gap-3">\n {entry.tiles.map((src, ti) => (\n <div\n key={src}\n className="relative aspect-[3/4] rounded-2xl overflow-hidden bg-muted"\n >\n <Image\n src={src}\n alt={`${entry.drop} editorial ${ti + 1}`}\n fill\n sizes="(min-width: 768px) 33vw, 100vw"\n className="object-cover"\n />\n </div>\n ))}\n </div>\n </div>\n </section>\n ))}\n </article>\n );\n}\n' }, { "path": "app/error.tsx", "kind": "text", "content": '"use client";\n\nimport { useEffect } from "react";\nimport Link from "next/link";\n\n/**\n * Root error boundary. Next.js calls this whenever a thrown error escapes\n * a Server Component without being caught by a closer `error.tsx`.\n *\n * Wire `reportError` (Sentry, Datadog, etc.) here so production failures\n * surface \u2014 silently recovering hides real bugs.\n */\nexport default function GlobalError({\n error,\n reset,\n}: {\n error: Error & { digest?: string };\n reset: () => void;\n}) {\n useEffect(() => {\n // TODO: replace with your error reporter\n // eslint-disable-next-line no-console\n console.error("Storefront error:", error);\n }, [error]);\n\n return (\n <section className="max-w-2xl mx-auto px-6 sm:px-8 py-20 text-center">\n <p className="text-[11px] font-mono uppercase tracking-[0.16em] text-primary mb-3">\n Something broke\n </p>\n <h1 className="text-[clamp(2rem,4vw,3rem)] font-bold mb-4 -tracking-[0.025em]">\n That wasn&apos;t supposed to happen.\n </h1>\n <p className="text-muted-foreground leading-relaxed mb-8">\n We&apos;ve been notified. Refresh the page, or head back home \u2014 most\n of the time the second try just works.\n </p>\n {error.digest && (\n <p className="text-xs font-mono text-muted-foreground mb-6">\n Reference:{" "}\n <code className="text-foreground">{error.digest}</code>\n </p>\n )}\n <div className="flex flex-wrap items-center justify-center gap-3">\n <button\n type="button"\n onClick={reset}\n className="inline-flex items-center gap-2 px-5 py-2.5 rounded-md bg-primary text-primary-foreground font-semibold text-sm hover:bg-primary/90 transition-colors"\n >\n Try again\n </button>\n <Link\n href="/"\n className="inline-flex items-center gap-2 px-5 py-2.5 rounded-md border border-border hover:bg-muted transition-colors text-sm font-medium"\n >\n Back home\n </Link>\n </div>\n </section>\n );\n}\n' }, { "path": "app/faq/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Support \u2014 ${brand.name}`,\n description: "Shipping, warranty, repairs, returns, payment \u2014 answers to the questions we hear most often.",\n};\n\nexport default function FaqPage() {\n const f = brand.faq;\n return (\n <article className="max-w-3xl mx-auto px-6 sm:px-8 py-16">\n <p className="text-[11px] font-mono uppercase tracking-[0.16em] text-primary mb-2">\n {f.eyebrow}\n </p>\n <h1 className="text-[clamp(2.25rem,5vw,3.5rem)] font-bold mb-10 -tracking-[0.025em]">\n {f.title}\n </h1>\n <div className="space-y-12">\n {f.sections.map((section) => (\n <section key={section.title}>\n <h2 className="text-2xl font-semibold mb-5 -tracking-[0.02em]">{section.title}</h2>\n <dl className="space-y-6">\n {section.items.map((item) => (\n <div key={item.q}>\n <dt className="font-semibold text-foreground mb-1.5">{item.q}</dt>\n <dd className="text-muted-foreground leading-relaxed">{item.a}</dd>\n </div>\n ))}\n </dl>\n </section>\n ))}\n </div>\n <p className="mt-12 pt-8 border-t border-border text-sm text-muted-foreground">\n {f.contactPrompt}{" "}\n <a\n href={`mailto:${f.contactEmail}`}\n className="text-primary font-semibold hover:underline"\n >\n {f.contactEmail}\n </a>{" "}\n and a real human will reply within 24 hours.\n </p>\n </article>\n );\n}\n' }, { "path": "app/llms.txt/route.ts", "kind": "text", "content": 'import { getServerClient, tags, type Product } from "@cimplify/sdk/server";\nimport { brand } from "@/lib/brand";\nimport { getSiteUrl } from "@/lib/site-url";\n\nexport const revalidate = 3600;\n\nasync function buildLlmsTxt(SITE_URL: string): Promise<string> {\n const client = getServerClient();\n const [productsRes, categoriesRes, collectionsRes] = await Promise.all([\n client.catalogue.getProducts(\n { limit: 500 },\n { cacheOptions: { revalidate: 3600, tags: [tags.products()] } },\n ),\n client.catalogue.getCategories({\n cacheOptions: { revalidate: 3600, tags: [tags.categories()] },\n }),\n client.catalogue.getCollections({\n cacheOptions: { revalidate: 3600, tags: [tags.collections()] },\n }),\n ]);\n\n const products: Product[] = productsRes.ok ? productsRes.value.items : [];\n const categories = categoriesRes.ok ? categoriesRes.value : [];\n const collections = collectionsRes.ok ? collectionsRes.value : [];\n\n const lines: string[] = [];\n lines.push(`# ${brand.name}`);\n lines.push("");\n lines.push(`> ${brand.llms.summary}`);\n lines.push("");\n lines.push("## Browse");\n lines.push(`- [Home](${SITE_URL}/)`);\n lines.push(`- [Shop](${SITE_URL}/shop): Full catalogue with filter and sort.`);\n\n if (categories.length > 0) {\n lines.push("");\n lines.push("## Categories");\n for (const c of categories) {\n lines.push(\n `- [${c.name}](${SITE_URL}/categories/${c.slug})${c.description ? `: ${c.description}` : ""}`,\n );\n }\n }\n\n if (collections.length > 0) {\n lines.push("");\n lines.push("## Collections");\n for (const c of collections) {\n lines.push(\n `- [${c.name}](${SITE_URL}/collections/${c.slug})${c.description ? `: ${c.description}` : ""}`,\n );\n }\n }\n\n if (products.length > 0) {\n lines.push("");\n lines.push("## Products");\n for (const p of products) {\n const slug = p.slug ?? p.id;\n const price = `${brand.currency} ${p.default_price}`;\n const desc = p.description ? ` \u2014 ${p.description.replace(/\\s+/g, " ").slice(0, 200)}` : "";\n lines.push(`- [${p.name}](${SITE_URL}/products/${slug}) (${price})${desc}`);\n }\n }\n\n lines.push("");\n lines.push("## Information");\n lines.push(`- [About](${SITE_URL}/about)`);\n lines.push(`- [Support / FAQ](${SITE_URL}/faq)`);\n lines.push(`- [Terms of Service](${SITE_URL}/terms)`);\n lines.push(`- [Privacy Policy](${SITE_URL}/privacy)`);\n lines.push(`- [Sitemap (XML)](${SITE_URL}/sitemap.xml)`);\n lines.push("");\n lines.push("## Contact");\n lines.push(`- Email: ${brand.contact.email}`);\n lines.push(`- Phone: ${brand.contact.phone}`);\n lines.push(`- Address: ${brand.contact.address}`);\n\n return lines.join("\\n") + "\\n";\n}\n\n/**\n * `/llms.txt` \u2014 machine-readable site index for LLMs (per llmstxt.org).\n * Lets coding agents and chat assistants find products, categories, and\n * support pages without scraping HTML. Plain Markdown so it streams cheaply\n * into context windows.\n */\nexport async function GET(): Promise<Response> {\n const body = await buildLlmsTxt(await getSiteUrl());\n return new Response(body, {\n headers: {\n "Content-Type": "text/plain; charset=utf-8",\n "Cache-Control": "public, max-age=0, s-maxage=3600, stale-while-revalidate=86400",\n },\n });\n}\n' }, { "path": "app/contact/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { ContactForm } from "./contact-form";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Contact \u2014 ${brand.name}`,\n description: brand.contactPage.body,\n};\n\nexport default function ContactPage() {\n const c = brand.contactPage;\n return (\n <article className="max-w-5xl mx-auto px-6 sm:px-8 py-16">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-2">\n {c.eyebrow}\n </p>\n <h1 className="font-serif text-[clamp(2.25rem,5vw,3.5rem)] font-semibold mb-4 -tracking-[0.02em]">\n {c.title}\n </h1>\n <p className="text-lg text-muted-foreground max-w-2xl mb-12 leading-relaxed">{c.body}</p>\n\n <div className="grid grid-cols-1 lg:grid-cols-[1.5fr_1fr] gap-10 items-start">\n <ContactForm reasons={c.reasons} />\n <aside className="space-y-5 lg:pl-10 lg:border-l border-border">\n <div>\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground mb-3">\n Direct lines\n </p>\n <ul className="space-y-3 list-none p-0 m-0">\n {c.directLines.map((line) => (\n <li key={line.label}>\n <p className="text-xs text-muted-foreground m-0">{line.label}</p>\n <a\n href={line.href}\n className="text-foreground hover:text-primary transition-colors"\n >\n {line.value}\n </a>\n </li>\n ))}\n </ul>\n </div>\n <div>\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground mb-3">\n Visit\n </p>\n <p className="text-foreground m-0">{brand.contact.address}</p>\n <p className="text-xs text-muted-foreground mt-1">{brand.contact.hours}</p>\n </div>\n </aside>\n </div>\n </article>\n );\n}\n' }, { "path": "app/contact/contact-form.tsx", "kind": "text", "content": `"use client";
3336
3336
 
3337
3337
  import { useState } from "react";
3338
3338
 
@@ -4317,7 +4317,7 @@ export const brand: Brand = {
4317
4317
  businessId: "bus_driveline_auto",
4318
4318
  },
4319
4319
  };
4320
- ` }, { "path": "lib/cimplify-server.ts", "kind": "text", "content": 'import { cookies } from "next/headers";\nimport {\n getAccessTokenFromCookieHeader,\n getSessionFromCookieHeader,\n} from "@cimplify/sdk/server";\nimport type { NavItem } from "@cimplify/sdk/react";\n\nexport const ACCOUNT_NAV: NavItem[] = [\n { label: "Dashboard", href: "/account" },\n { label: "Orders", href: "/account/orders" },\n { label: "Cimplify identity", href: "https://link.cimplify.io", external: true },\n];\n\nconst CLIENT_ID = process.env.CIMPLIFY_CLIENT_ID ?? "";\nconst ISSUER = (process.env.CIMPLIFY_ISSUER ?? "https://api.cimplify.io").replace(/\\/$/, "");\n\nconst OIDC_CONFIG = { clientId: CLIENT_ID, issuer: ISSUER };\n\nasync function cookieHeader(): Promise<string> {\n const store = await cookies();\n return store.getAll().map((c) => `${c.name}=${c.value}`).join("; ");\n}\n\nexport async function serverSession() {\n return getSessionFromCookieHeader(OIDC_CONFIG, await cookieHeader());\n}\n\nexport async function serverAccessToken(): Promise<string | null> {\n return getAccessTokenFromCookieHeader(OIDC_CONFIG, await cookieHeader());\n}\n\ninterface Envelope<T> {\n data: T;\n}\n\nexport async function apiFetch<T>(\n path: string,\n init: RequestInit & { bearer: string | null },\n): Promise<T | null> {\n if (!init.bearer) return null;\n const { bearer, ...rest } = init;\n const headers = new Headers(rest.headers);\n headers.set("Authorization", `Bearer ${bearer}`);\n headers.set("Accept", "application/json");\n if (rest.body && !headers.has("Content-Type")) {\n headers.set("Content-Type", "application/json");\n }\n const res = await fetch(`${ISSUER}${path}`, { ...rest, headers, cache: "no-store" });\n if (!res.ok) return null;\n const envelope = (await res.json()) as Envelope<T>;\n return envelope.data;\n}\n' }, { "path": "lib/cimplify-loader.ts", "kind": "text", "content": 'import type { ImageLoader } from "next/image";\n\nimport { assetUrl, isCimplifyAsset } from "@cimplify/sdk";\n\nconst cdnBase = process.env.NEXT_PUBLIC_CIMPLIFY_CDN_URL?.trim() || undefined;\n\nconst cimplifyImageLoader: ImageLoader = ({ src, width, quality }) => {\n if (isCimplifyAsset(src, cdnBase)) {\n return assetUrl(src, {\n base: cdnBase,\n w: width,\n quality,\n format: "auto",\n });\n }\n return src;\n};\n\nexport default cimplifyImageLoader;\n' }, { "path": "lib/site-url.ts", "kind": "text", "content": 'import { SITE_URL } from "./site.config";\n\n/** Canonical absolute URL for this storefront. */\nexport async function getSiteUrl(): Promise<string> {\n return SITE_URL;\n}\n' }, { "path": ".env.example", "kind": "text", "content": "# Your tenant public key. Get it from the desk's Developers tab.\n# `cpk_live_*` / `cpk_test_*` keys auto-route same-origin requests\n# to hosted Cimplify in both dev and prod. Anything else (`mock-dev`,\n# empty) falls back to the local `cimplify dev` mock.\nNEXT_PUBLIC_CIMPLIFY_PUBLIC_KEY=mock-dev\n\n# Optional. Forces a fixed canonical URL for sitemap.xml, robots.txt,\n# OpenGraph, and llms.txt. Leave unset and the storefront derives the\n# canonical from the request `Host` header automatically.\n# NEXT_PUBLIC_SITE_URL=\n" }, { "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__/brand.test.ts", "kind": "text", "content": 'import { createBrandSuite } from "@cimplify/sdk/testing/suite";\nimport { brand } from "../lib/brand";\n\ncreateBrandSuite({ brand });\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.100.4",\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": "metadata.json", "kind": "text", "content": '{\n "id": "auto",\n "name": "Auto Parts",\n "tagline": "Auto-parts storefront with vehicle fitment finder and installation booking.",\n "industry": "automotive",\n "tags": ["automotive", "parts", "fitment"],\n "stability": "stable",\n "schemaType": "AutoPartsStore",\n "mock": {\n "seedName": "auto",\n "seedBusinessId": "bus_driveline_auto"\n }\n}\n' }, { "path": "app/search/search-client.tsx", "kind": "text", "content": '"use client";\n\nimport { SearchPage } from "@cimplify/sdk/react";\n\nexport function SearchClient() {\n return <SearchPage />;\n}\n' }, { "path": "app/search/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Suspense } from "react";\nimport { SearchClient } from "./search-client";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Search \u2014 ${brand.name}`,\n description: `Search ${brand.name} \u2014 products, collections, categories.`,\n};\n\nexport default function SearchPage() {\n return (\n <article className="max-w-7xl mx-auto px-6 sm:px-8 py-10">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-2">\n Search\n </p>\n <h1 className="font-serif text-[clamp(2rem,4vw,2.75rem)] font-semibold mb-8 -tracking-[0.02em]">\n Find anything.\n </h1>\n <Suspense fallback={<SearchSkeleton />}>\n <SearchClient />\n </Suspense>\n </article>\n );\n}\n\nfunction SearchSkeleton() {\n return (\n <div>\n <div className="h-12 w-full max-w-xl bg-muted rounded animate-pulse mb-8" />\n <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3 sm:gap-4">\n {Array.from({ length: 8 }).map((_, i) => (\n <div key={i} className="aspect-[4/3] bg-muted rounded-2xl animate-pulse" />\n ))}\n </div>\n </div>\n );\n}\n' }, { "path": "app/categories/[slug]/listing-client.tsx", "kind": "text", "content": '"use client";\n\nimport { ProductGrid } from "@cimplify/sdk/react";\nimport type { Product } from "@cimplify/sdk";\nimport { StoreProductCard } from "@/components/store-product-card";\n\n/**\n * Client island for the category listing. Receives server-fetched products\n * as props (serializable) and owns the `renderCard` function.\n */\nexport function ListingClient({ products }: { products: Product[] }) {\n return (\n <ProductGrid\n products={products}\n emptyMessage="No products in this category yet."\n renderCard={(p) => <StoreProductCard product={p} />}\n />\n );\n}\n' }, { "path": "app/categories/[slug]/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Suspense } from "react";\nimport Link from "next/link";\nimport { notFound } from "next/navigation";\nimport {\n getServerClient,\n tags,\n type Category,\n type Product,\n} from "@cimplify/sdk/server";\nimport { ListingClient } from "./listing-client";\nimport { brand } from "@/lib/brand";\n\n// See app/products/[slug]/page.tsx for the rationale on generateStaticParams.\nexport async function generateStaticParams() {\n const r = await getServerClient().catalogue.getCategories();\n if (!r.ok || r.value.length === 0) {\n return [{ slug: "__placeholder__" }];\n }\n return r.value.map((c) => ({ slug: c.slug ?? c.id }));\n}\n\nexport const revalidate = 3600;\n\ninterface CategoryData {\n category: Category;\n products: Product[];\n}\n\ntype CategoryResult =\n | { ok: true; data: CategoryData }\n | { ok: false; code: string };\n\nasync function getCategory(slug: string): Promise<CategoryResult> {\n const client = getServerClient();\n const catRes = await client.catalogue.getCategoryBySlug(slug, {\n cacheOptions: { revalidate: 3600, tags: [tags.categories()] },\n });\n if (!catRes.ok) return { ok: false, code: catRes.error.code };\n\n const r = await client.catalogue.getCategoryProducts(catRes.value.id, undefined, {\n cacheOptions: {\n revalidate: 3600,\n tags: [\n tags.products(),\n tags.category(catRes.value.id),\n tags.categoryProducts(catRes.value.id),\n ],\n },\n });\n const products = r.ok\n ? ((r.value as { items?: Product[] }).items ?? (r.value as Product[]))\n : [];\n return { ok: true, data: { category: catRes.value, products } };\n}\n\nexport async function generateMetadata({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}): Promise<Metadata> {\n const { slug } = await params;\n const result = await getCategory(slug);\n if (!result.ok) return {};\n const data = result.data;\n return {\n title: `${data.category.name} \u2014 ${brand.name}`,\n description: data.category.description ?? undefined,\n };\n}\n\nexport default async function CategoryPage({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}) {\n return (\n <Suspense fallback={<CategorySkeleton />}>\n <CategoryContent params={params} />\n </Suspense>\n );\n}\n\nasync function CategoryContent({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}) {\n const { slug } = await params;\n const result = await getCategory(slug);\n if (!result.ok) {\n if (result.code === "NOT_FOUND") notFound();\n return <CategorySkeleton />;\n }\n const { category, products } = result.data;\n return (\n <>\n <section className="bg-foreground text-background relative overflow-hidden">\n <div className="absolute inset-0 opacity-[0.04] pointer-events-none [background-image:radial-gradient(circle_at_2px_2px,white_1px,transparent_0)] [background-size:32px_32px]" />\n <div className="relative max-w-7xl mx-auto px-6 sm:px-8 py-12 sm:py-14">\n <nav className="text-[12px] font-mono text-background/60 mb-3 flex items-center gap-2">\n <Link href="/" className="hover:text-background transition-colors">Home</Link>\n <span>/</span>\n <Link href="/shop" className="hover:text-background transition-colors">Shop</Link>\n <span>/</span>\n <span className="text-background/90">{category.name}</span>\n </nav>\n <h1 className="text-[clamp(2rem,4vw,3rem)] font-bold m-0 -tracking-[0.025em]">\n {category.name}\n </h1>\n {category.description && (\n <p className="mt-3 max-w-2xl text-base text-background/75">\n {category.description}\n </p>\n )}\n <p className="mt-4 text-[11px] font-mono uppercase tracking-[0.16em] text-background/60 tabular-nums">\n {products.length} {products.length === 1 ? "product" : "products"}\n </p>\n </div>\n </section>\n <section className="max-w-7xl mx-auto px-6 sm:px-8 py-10 sm:py-12">\n <ListingClient products={products} />\n {products.length === 0 && (\n <p className="text-center mt-8">\n <Link href="/shop" className="text-primary font-semibold hover:underline">\n \u2190 Browse all products\n </Link>\n </p>\n )}\n </section>\n </>\n );\n}\n\nfunction CategorySkeleton() {\n return (\n <>\n <section className="bg-foreground py-12 sm:py-14">\n <div className="max-w-7xl mx-auto px-6 sm:px-8">\n <div className="h-3 w-32 bg-background/20 rounded mb-3 animate-pulse" />\n <div className="h-12 w-72 bg-background/20 rounded animate-pulse" />\n </div>\n </section>\n <section className="max-w-7xl mx-auto px-6 sm:px-8 py-10 sm:py-12">\n <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3 sm:gap-4">\n {Array.from({ length: 8 }).map((_, i) => (\n <div key={i} className="aspect-[4/3] bg-muted rounded-2xl animate-pulse" />\n ))}\n </div>\n </section>\n </>\n );\n}\n' }, { "path": "app/terms/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Terms of Service \u2014 ${brand.name}`,\n description: `The rules of the road for ordering from ${brand.name}.`,\n};\n\nexport default function TermsPage() {\n const t = brand.terms;\n return (\n <article className="max-w-3xl mx-auto px-6 sm:px-8 py-16 prose prose-lg max-w-none">\n <p className="text-[11px] font-mono uppercase tracking-[0.16em] text-primary mb-2 not-prose">\n {t.eyebrow}\n </p>\n <h1 className="text-[clamp(2.25rem,5vw,3.5rem)] font-bold mb-2 -tracking-[0.025em]">\n {t.title}\n </h1>\n <p className="text-sm text-muted-foreground not-prose mb-10">\n Last updated: {t.lastUpdated}\n </p>\n\n <section className="space-y-5 leading-relaxed text-foreground/90">\n {t.sections.map((s) => (\n <div key={s.heading}>\n <h2 className="text-2xl font-semibold mt-0 -tracking-[0.02em]">{s.heading}</h2>\n {typeof s.body === "string" ? (\n <p>{s.body}</p>\n ) : (\n <>\n <p>{s.body.intro}</p>\n <ul className="list-disc pl-6 space-y-2">\n {s.body.bullets.map((b) => (\n <li key={b}>{b}</li>\n ))}\n </ul>\n </>\n )}\n </div>\n ))}\n </section>\n </article>\n );\n}\n' }, { "path": "app/not-found.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport Link from "next/link";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Page not found \u2014 ${brand.name}`,\n description: "We couldn\'t find that page.",\n};\n\nexport default function NotFound() {\n return (\n <section className="max-w-2xl mx-auto px-6 sm:px-8 py-20 text-center">\n <p className="text-[11px] font-mono uppercase tracking-[0.16em] text-primary mb-3">\n 404\n </p>\n <h1 className="text-[clamp(2.5rem,5vw,4rem)] font-bold mb-4 -tracking-[0.03em]">\n Page not found.\n </h1>\n <p className="text-muted-foreground leading-relaxed mb-8">\n The URL you followed might be old, a typo, or pointing at a product\n that&apos;s no longer in stock. Try the menu or head back home.\n </p>\n <div className="flex flex-wrap items-center justify-center gap-3">\n <Link\n href="/shop"\n className="inline-flex items-center gap-2 px-5 py-2.5 rounded-md bg-primary text-primary-foreground font-semibold text-sm hover:bg-primary/90 transition-colors"\n >\n Browse the shop\n </Link>\n <Link\n href="/"\n className="inline-flex items-center gap-2 px-5 py-2.5 rounded-md border border-border hover:bg-muted transition-colors text-sm font-medium"\n >\n Back home\n </Link>\n </div>\n </section>\n );\n}\n' }, { "path": "app/cart/page.tsx", "kind": "text", "content": '"use client";\n\nimport { useRouter } from "next/navigation";\nimport { CartPage as SdkCartPage } from "@cimplify/sdk/react";\n\nexport default function CartPage() {\n const router = useRouter();\n return <SdkCartPage onCheckout={() => router.push("/checkout")} />;\n}\n' }, { "path": "app/accessibility/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\nimport { PolicyPage } from "@/components/policy-page";\n\nexport const metadata: Metadata = {\n title: `${brand.accessibility.title} \u2014 ${brand.name}`,\n};\n\nexport default function AccessibilityPage() {\n return <PolicyPage policy={brand.accessibility} />;\n}\n' }, { "path": "app/account/orders/[id]/page.tsx", "kind": "text", "content": 'import { notFound } from "next/navigation";\nimport { AccountSignedOutPrompt, toAccountOrderView } from "@cimplify/sdk/react";\nimport type { EnrichedOrder } from "@cimplify/sdk";\nimport { brand } from "@/lib/brand";\nimport { apiFetch, serverAccessToken, serverSession } from "@/lib/cimplify-server";\nimport { OrderDetail } from "@/components/order-detail";\n\nconst CLIENT_ID = process.env.NEXT_PUBLIC_CIMPLIFY_CLIENT_ID ?? "";\nconst REDIRECT_URI = process.env.CIMPLIFY_REDIRECT_URI ?? "";\n\nexport default async function OrderDetailPage({\n params,\n}: {\n params: Promise<{ id: string }>;\n}) {\n const { id } = await params;\n\n const session = await serverSession();\n if (!session) {\n return (\n <AccountSignedOutPrompt\n clientId={CLIENT_ID}\n redirectUri={REDIRECT_URI}\n eyebrow={brand.account.loginEyebrow}\n title={brand.account.loginTitle}\n description={brand.account.loginSubtitle}\n />\n );\n }\n\n const bearer = await serverAccessToken();\n const order = await apiFetch<EnrichedOrder>(`/api/v1/orders/${encodeURIComponent(id)}`, {\n bearer,\n });\n if (!order) notFound();\n\n return (\n <div className="mx-auto w-full max-w-5xl px-4 py-8 sm:px-6">\n <OrderDetail {...toAccountOrderView(order)} />\n </div>\n );\n}\n' }, { "path": "app/account/orders/page.tsx", "kind": "text", "content": 'import {\n AccountOrdersPage,\n AccountSignedOutPrompt,\n} from "@cimplify/sdk/react";\nimport type { OrderRowData, OrderRowStatus } from "@cimplify/sdk/react";\nimport type { Order, OrderStatus } from "@cimplify/sdk";\nimport { formatMoney } from "@cimplify/sdk";\nimport { brand } from "@/lib/brand";\nimport { ACCOUNT_NAV, apiFetch, serverAccessToken, serverSession } from "@/lib/cimplify-server";\n\nconst CLIENT_ID = process.env.NEXT_PUBLIC_CIMPLIFY_CLIENT_ID ?? "";\nconst REDIRECT_URI = process.env.CIMPLIFY_REDIRECT_URI ?? "";\n\nfunction mapStatus(status: OrderStatus): OrderRowStatus {\n switch (status) {\n case "in_preparation":\n case "ready_to_serve":\n case "partially_served":\n return { kind: "preparing", label: "Preparing" };\n case "delivered":\n return { kind: "delivered" };\n case "picked_up":\n return { kind: "delivered", label: "Picked up" };\n case "served":\n case "completed":\n return { kind: "completed" };\n case "cancelled":\n case "refunded":\n return { kind: "cancelled", label: status === "refunded" ? "Refunded" : undefined };\n case "pending":\n case "created":\n case "confirmed":\n default:\n return { kind: "active", label: "Confirmed" };\n }\n}\n\nfunction relativeMeta(iso: string, orderNumber: string): string {\n const when = new Date(iso);\n const formatted = when.toLocaleDateString(undefined, {\n month: "short",\n day: "numeric",\n year: when.getFullYear() === new Date().getFullYear() ? undefined : "numeric",\n });\n return `Order ${orderNumber} \xB7 ${formatted}`;\n}\n\nfunction toRowData(order: Order): OrderRowData {\n const count = order.items?.length ?? 0;\n return {\n id: order.id,\n href: `/account/orders/${order.id}`,\n summary: count > 0 ? `${count} ${count === 1 ? "item" : "items"}` : "Order",\n meta: relativeMeta(order.created_at, order.user_friendly_id ?? order.id),\n status: mapStatus(order.status),\n total: formatMoney(order.total_price, order.currency),\n };\n}\n\nexport default async function OrdersPage() {\n const session = await serverSession();\n if (!session) {\n return (\n <AccountSignedOutPrompt\n clientId={CLIENT_ID}\n redirectUri={REDIRECT_URI}\n eyebrow={brand.account.loginEyebrow}\n title={brand.account.loginTitle}\n description={brand.account.loginSubtitle}\n />\n );\n }\n\n const bearer = await serverAccessToken();\n const orders = await apiFetch<Order[]>(\n `/v1/link/orders?business_id=${encodeURIComponent(brand.mock.businessId)}`,\n { bearer },\n );\n\n return (\n <AccountOrdersPage\n customerName={session.name ?? ""}\n customerEmail={session.email}\n customerId={session.sub}\n merchantName={brand.name}\n nav={ACCOUNT_NAV}\n orders={(orders ?? []).map(toRowData)}\n />\n );\n}\n' }, { "path": "app/account/page.tsx", "kind": "text", "content": 'import {\n AccountDashboardPage,\n AccountSignedOutPrompt,\n} from "@cimplify/sdk/react";\nimport type { CustomerAddress, CustomerMobileMoney } from "@cimplify/sdk";\nimport { brand } from "@/lib/brand";\nimport { ACCOUNT_NAV, apiFetch, serverAccessToken, serverSession } from "@/lib/cimplify-server";\n\nconst CLIENT_ID = process.env.NEXT_PUBLIC_CIMPLIFY_CLIENT_ID ?? "";\nconst REDIRECT_URI = process.env.CIMPLIFY_REDIRECT_URI ?? "";\n\nexport default async function AccountPage() {\n const session = await serverSession();\n if (!session) {\n return (\n <AccountSignedOutPrompt\n clientId={CLIENT_ID}\n redirectUri={REDIRECT_URI}\n eyebrow={brand.account.loginEyebrow}\n title={brand.account.loginTitle}\n description={brand.account.loginSubtitle}\n />\n );\n }\n\n const bearer = await serverAccessToken();\n const [addresses, wallets] = await Promise.all([\n apiFetch<CustomerAddress[]>("/v1/link/addresses", { bearer }),\n apiFetch<CustomerMobileMoney[]>("/v1/link/mobile-money", { bearer }),\n ]);\n\n return (\n <AccountDashboardPage\n customerName={session.name ?? ""}\n customerEmail={session.email}\n customerId={session.sub}\n merchantName={brand.name}\n nav={ACCOUNT_NAV}\n identitySummary={{\n addresses: addresses?.length ?? 0,\n paymentMethods: wallets?.length ?? 0,\n }}\n />\n );\n}\n' }, { "path": "app/.well-known/ucp/route.ts", "kind": "text", "content": 'import { NextResponse } from "next/server";\n\n/**\n * UCP (Universal Commerce Protocol) manifest discovery endpoint.\n *\n * Agents \u2014 Claude, ChatGPT, Gemini, MCP clients \u2014 probe\n * `https://<your-domain>/.well-known/ucp` to learn what commerce\n * capabilities your storefront supports. We resolve the business\'s\n * UCP handle from the public key (via the storefront API), then forward\n * to Cimplify for the canonical manifest. Edge-cached for an hour\n * because capabilities change rarely.\n */\nconst UCP_API_BASE = "https://api.cimplify.io";\nconst STOREFRONT_API_BASE =\n process.env.NODE_ENV === "production"\n ? "https://storefronts.cimplify.io"\n : "http://127.0.0.1:8787";\n\nexport async function GET() {\n const publicKey = process.env.NEXT_PUBLIC_CIMPLIFY_PUBLIC_KEY;\n if (!publicKey) {\n return NextResponse.json(\n {\n error: "NEXT_PUBLIC_CIMPLIFY_PUBLIC_KEY not set",\n remediation:\n "Set NEXT_PUBLIC_CIMPLIFY_PUBLIC_KEY in .env.local (and your deployment env).",\n },\n { status: 500 },\n );\n }\n\n try {\n // Step 1 \u2014 resolve the business handle from the public key.\n const businessResp = await fetch(`${STOREFRONT_API_BASE}/api/v1/business`, {\n headers: { "X-API-Key": publicKey, "Content-Type": "application/json" },\n next: { revalidate: 3600 },\n });\n\n if (!businessResp.ok) {\n return NextResponse.json(\n { error: `Failed to resolve business: ${businessResp.status}` },\n { status: businessResp.status },\n );\n }\n\n const businessJson = await businessResp.json();\n const handle: string | undefined = businessJson?.data?.handle;\n if (!handle) {\n return NextResponse.json(\n { error: "Business has no handle configured" },\n { status: 500 },\n );\n }\n\n // Step 2 \u2014 fetch the canonical UCP manifest.\n const manifestResp = await fetch(\n `${UCP_API_BASE}/ucp/v1/${handle}/manifest`,\n {\n headers: { "Content-Type": "application/json" },\n next: { revalidate: 3600 },\n },\n );\n\n if (!manifestResp.ok) {\n return NextResponse.json(\n { error: `Upstream UCP manifest fetch failed: ${manifestResp.status}` },\n { status: manifestResp.status },\n );\n }\n\n const manifest = await manifestResp.json();\n return NextResponse.json(manifest, {\n headers: {\n "Content-Type": "application/json",\n "Cache-Control": "public, max-age=3600, s-maxage=3600",\n },\n });\n } catch (error) {\n return NextResponse.json(\n {\n error: "Failed to fetch UCP manifest",\n detail: error instanceof Error ? error.message : String(error),\n },\n { status: 500 },\n );\n }\n}\n' }, { "path": "app/returns/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\nimport { PolicyPage } from "@/components/policy-page";\n\nexport const metadata: Metadata = {\n title: `${brand.returns.title} \u2014 ${brand.name}`,\n};\n\nexport default function ReturnsPage() {\n return <PolicyPage policy={brand.returns} />;\n}\n' }, { "path": "app/orders/[id]/page.tsx", "kind": "text", "content": 'import Link from "next/link";\n\nexport default async function OrderPage({ params }: { params: Promise<{ id: string }> }) {\n const { id } = await params;\n return (\n <section className="max-w-2xl mx-auto px-6 sm:px-8 py-20 text-center">\n <h1 className="text-3xl mt-0 mb-3 font-bold -tracking-[0.025em]">\n Order confirmed.\n </h1>\n <p className="text-muted-foreground">\n Order <code className="font-mono text-foreground">{id}</code> \u2014 you&apos;ll\n get an SMS with tracking within 30 minutes.\n </p>\n <p className="mt-6">\n <Link\n href="/"\n className="inline-block px-6 py-3 rounded-md bg-primary text-primary-foreground font-semibold text-sm transition-colors hover:bg-primary/90"\n >\n Continue shopping\n </Link>\n </p>\n </section>\n );\n}\n' }, { "path": "app/globals.css", "kind": "text", "content": `@import "tailwindcss";
4320
+ ` }, { "path": "lib/cimplify-server.ts", "kind": "text", "content": 'import { cookies } from "next/headers";\nimport {\n getAccessTokenFromCookieHeader,\n getSessionFromCookieHeader,\n} from "@cimplify/sdk/server";\nimport type { NavItem } from "@cimplify/sdk/react";\n\nexport const ACCOUNT_NAV: NavItem[] = [\n { label: "Dashboard", href: "/account" },\n { label: "Orders", href: "/account/orders" },\n { label: "Cimplify identity", href: "https://link.cimplify.io", external: true },\n];\n\nconst CLIENT_ID = process.env.CIMPLIFY_CLIENT_ID ?? "";\nconst ISSUER = (process.env.CIMPLIFY_ISSUER ?? "https://api.cimplify.io").replace(/\\/$/, "");\n\nconst OIDC_CONFIG = { clientId: CLIENT_ID, issuer: ISSUER };\n\nasync function cookieHeader(): Promise<string> {\n const store = await cookies();\n return store.getAll().map((c) => `${c.name}=${c.value}`).join("; ");\n}\n\nexport async function serverSession() {\n return getSessionFromCookieHeader(OIDC_CONFIG, await cookieHeader());\n}\n\nexport async function serverAccessToken(): Promise<string | null> {\n return getAccessTokenFromCookieHeader(OIDC_CONFIG, await cookieHeader());\n}\n\ninterface Envelope<T> {\n data: T;\n}\n\nexport async function apiFetch<T>(\n path: string,\n init: RequestInit & { bearer: string | null },\n): Promise<T | null> {\n if (!init.bearer) return null;\n const { bearer, ...rest } = init;\n const headers = new Headers(rest.headers);\n headers.set("Authorization", `Bearer ${bearer}`);\n headers.set("Accept", "application/json");\n if (rest.body && !headers.has("Content-Type")) {\n headers.set("Content-Type", "application/json");\n }\n const res = await fetch(`${ISSUER}${path}`, { ...rest, headers, cache: "no-store" });\n if (!res.ok) return null;\n const envelope = (await res.json()) as Envelope<T>;\n return envelope.data;\n}\n' }, { "path": "lib/cimplify-loader.ts", "kind": "text", "content": 'import type { ImageLoader } from "next/image";\n\nimport { assetUrl, isCimplifyAsset } from "@cimplify/sdk";\n\nconst cdnBase = process.env.NEXT_PUBLIC_CIMPLIFY_CDN_URL?.trim() || undefined;\n\nconst cimplifyImageLoader: ImageLoader = ({ src, width, quality }) => {\n if (isCimplifyAsset(src, cdnBase)) {\n return assetUrl(src, {\n base: cdnBase,\n w: width,\n quality,\n format: "auto",\n });\n }\n return src;\n};\n\nexport default cimplifyImageLoader;\n' }, { "path": "lib/site-url.ts", "kind": "text", "content": 'import { SITE_URL } from "./site.config";\n\n/** Canonical absolute URL for this storefront. */\nexport async function getSiteUrl(): Promise<string> {\n return SITE_URL;\n}\n' }, { "path": ".env.example", "kind": "text", "content": "# Your tenant public key. Get it from the desk's Developers tab.\n# `cpk_live_*` / `cpk_test_*` keys auto-route same-origin requests\n# to hosted Cimplify in both dev and prod. Anything else (`mock-dev`,\n# empty) falls back to the local `cimplify dev` mock.\nNEXT_PUBLIC_CIMPLIFY_PUBLIC_KEY=mock-dev\n\n# Optional. Forces a fixed canonical URL for sitemap.xml, robots.txt,\n# OpenGraph, and llms.txt. Leave unset and the storefront derives the\n# canonical from the request `Host` header automatically.\n# NEXT_PUBLIC_SITE_URL=\n" }, { "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__/brand.test.ts", "kind": "text", "content": 'import { createBrandSuite } from "@cimplify/sdk/testing/suite";\nimport { brand } from "../lib/brand";\n\ncreateBrandSuite({ brand });\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.101.1",\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": "metadata.json", "kind": "text", "content": '{\n "id": "auto",\n "name": "Auto Parts",\n "tagline": "Auto-parts storefront with vehicle fitment finder and installation booking.",\n "industry": "automotive",\n "tags": ["automotive", "parts", "fitment"],\n "stability": "stable",\n "schemaType": "AutoPartsStore",\n "mock": {\n "seedName": "auto",\n "seedBusinessId": "bus_driveline_auto"\n }\n}\n' }, { "path": "app/search/search-client.tsx", "kind": "text", "content": '"use client";\n\nimport { SearchPage } from "@cimplify/sdk/react";\n\nexport function SearchClient() {\n return <SearchPage />;\n}\n' }, { "path": "app/search/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Suspense } from "react";\nimport { SearchClient } from "./search-client";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Search \u2014 ${brand.name}`,\n description: `Search ${brand.name} \u2014 products, collections, categories.`,\n};\n\nexport default function SearchPage() {\n return (\n <article className="max-w-7xl mx-auto px-6 sm:px-8 py-10">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-2">\n Search\n </p>\n <h1 className="font-serif text-[clamp(2rem,4vw,2.75rem)] font-semibold mb-8 -tracking-[0.02em]">\n Find anything.\n </h1>\n <Suspense fallback={<SearchSkeleton />}>\n <SearchClient />\n </Suspense>\n </article>\n );\n}\n\nfunction SearchSkeleton() {\n return (\n <div>\n <div className="h-12 w-full max-w-xl bg-muted rounded animate-pulse mb-8" />\n <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3 sm:gap-4">\n {Array.from({ length: 8 }).map((_, i) => (\n <div key={i} className="aspect-[4/3] bg-muted rounded-2xl animate-pulse" />\n ))}\n </div>\n </div>\n );\n}\n' }, { "path": "app/categories/[slug]/listing-client.tsx", "kind": "text", "content": '"use client";\n\nimport { ProductGrid } from "@cimplify/sdk/react";\nimport type { Product } from "@cimplify/sdk";\nimport { StoreProductCard } from "@/components/store-product-card";\n\n/**\n * Client island for the category listing. Receives server-fetched products\n * as props (serializable) and owns the `renderCard` function.\n */\nexport function ListingClient({ products }: { products: Product[] }) {\n return (\n <ProductGrid\n products={products}\n emptyMessage="No products in this category yet."\n renderCard={(p) => <StoreProductCard product={p} />}\n />\n );\n}\n' }, { "path": "app/categories/[slug]/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Suspense } from "react";\nimport Link from "next/link";\nimport { notFound } from "next/navigation";\nimport {\n getServerClient,\n tags,\n type Category,\n type Product,\n} from "@cimplify/sdk/server";\nimport { ListingClient } from "./listing-client";\nimport { brand } from "@/lib/brand";\n\n// See app/products/[slug]/page.tsx for the rationale on generateStaticParams.\nexport async function generateStaticParams() {\n const r = await getServerClient().catalogue.getCategories();\n if (!r.ok || r.value.length === 0) {\n return [{ slug: "__placeholder__" }];\n }\n return r.value.map((c) => ({ slug: c.slug ?? c.id }));\n}\n\nexport const revalidate = 3600;\n\ninterface CategoryData {\n category: Category;\n products: Product[];\n}\n\ntype CategoryResult =\n | { ok: true; data: CategoryData }\n | { ok: false; code: string };\n\nasync function getCategory(slug: string): Promise<CategoryResult> {\n const client = getServerClient();\n const catRes = await client.catalogue.getCategoryBySlug(slug, {\n cacheOptions: { revalidate: 3600, tags: [tags.categories()] },\n });\n if (!catRes.ok) return { ok: false, code: catRes.error.code };\n\n const r = await client.catalogue.getCategoryProducts(catRes.value.id, undefined, {\n cacheOptions: {\n revalidate: 3600,\n tags: [\n tags.products(),\n tags.category(catRes.value.id),\n tags.categoryProducts(catRes.value.id),\n ],\n },\n });\n const products = r.ok\n ? ((r.value as { items?: Product[] }).items ?? (r.value as Product[]))\n : [];\n return { ok: true, data: { category: catRes.value, products } };\n}\n\nexport async function generateMetadata({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}): Promise<Metadata> {\n const { slug } = await params;\n const result = await getCategory(slug);\n if (!result.ok) return {};\n const data = result.data;\n return {\n title: `${data.category.name} \u2014 ${brand.name}`,\n description: data.category.description ?? undefined,\n };\n}\n\nexport default async function CategoryPage({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}) {\n return (\n <Suspense fallback={<CategorySkeleton />}>\n <CategoryContent params={params} />\n </Suspense>\n );\n}\n\nasync function CategoryContent({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}) {\n const { slug } = await params;\n const result = await getCategory(slug);\n if (!result.ok) {\n if (result.code === "NOT_FOUND") notFound();\n return <CategorySkeleton />;\n }\n const { category, products } = result.data;\n return (\n <>\n <section className="bg-foreground text-background relative overflow-hidden">\n <div className="absolute inset-0 opacity-[0.04] pointer-events-none [background-image:radial-gradient(circle_at_2px_2px,white_1px,transparent_0)] [background-size:32px_32px]" />\n <div className="relative max-w-7xl mx-auto px-6 sm:px-8 py-12 sm:py-14">\n <nav className="text-[12px] font-mono text-background/60 mb-3 flex items-center gap-2">\n <Link href="/" className="hover:text-background transition-colors">Home</Link>\n <span>/</span>\n <Link href="/shop" className="hover:text-background transition-colors">Shop</Link>\n <span>/</span>\n <span className="text-background/90">{category.name}</span>\n </nav>\n <h1 className="text-[clamp(2rem,4vw,3rem)] font-bold m-0 -tracking-[0.025em]">\n {category.name}\n </h1>\n {category.description && (\n <p className="mt-3 max-w-2xl text-base text-background/75">\n {category.description}\n </p>\n )}\n <p className="mt-4 text-[11px] font-mono uppercase tracking-[0.16em] text-background/60 tabular-nums">\n {products.length} {products.length === 1 ? "product" : "products"}\n </p>\n </div>\n </section>\n <section className="max-w-7xl mx-auto px-6 sm:px-8 py-10 sm:py-12">\n <ListingClient products={products} />\n {products.length === 0 && (\n <p className="text-center mt-8">\n <Link href="/shop" className="text-primary font-semibold hover:underline">\n \u2190 Browse all products\n </Link>\n </p>\n )}\n </section>\n </>\n );\n}\n\nfunction CategorySkeleton() {\n return (\n <>\n <section className="bg-foreground py-12 sm:py-14">\n <div className="max-w-7xl mx-auto px-6 sm:px-8">\n <div className="h-3 w-32 bg-background/20 rounded mb-3 animate-pulse" />\n <div className="h-12 w-72 bg-background/20 rounded animate-pulse" />\n </div>\n </section>\n <section className="max-w-7xl mx-auto px-6 sm:px-8 py-10 sm:py-12">\n <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3 sm:gap-4">\n {Array.from({ length: 8 }).map((_, i) => (\n <div key={i} className="aspect-[4/3] bg-muted rounded-2xl animate-pulse" />\n ))}\n </div>\n </section>\n </>\n );\n}\n' }, { "path": "app/terms/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Terms of Service \u2014 ${brand.name}`,\n description: `The rules of the road for ordering from ${brand.name}.`,\n};\n\nexport default function TermsPage() {\n const t = brand.terms;\n return (\n <article className="max-w-3xl mx-auto px-6 sm:px-8 py-16 prose prose-lg max-w-none">\n <p className="text-[11px] font-mono uppercase tracking-[0.16em] text-primary mb-2 not-prose">\n {t.eyebrow}\n </p>\n <h1 className="text-[clamp(2.25rem,5vw,3.5rem)] font-bold mb-2 -tracking-[0.025em]">\n {t.title}\n </h1>\n <p className="text-sm text-muted-foreground not-prose mb-10">\n Last updated: {t.lastUpdated}\n </p>\n\n <section className="space-y-5 leading-relaxed text-foreground/90">\n {t.sections.map((s) => (\n <div key={s.heading}>\n <h2 className="text-2xl font-semibold mt-0 -tracking-[0.02em]">{s.heading}</h2>\n {typeof s.body === "string" ? (\n <p>{s.body}</p>\n ) : (\n <>\n <p>{s.body.intro}</p>\n <ul className="list-disc pl-6 space-y-2">\n {s.body.bullets.map((b) => (\n <li key={b}>{b}</li>\n ))}\n </ul>\n </>\n )}\n </div>\n ))}\n </section>\n </article>\n );\n}\n' }, { "path": "app/not-found.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport Link from "next/link";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Page not found \u2014 ${brand.name}`,\n description: "We couldn\'t find that page.",\n};\n\nexport default function NotFound() {\n return (\n <section className="max-w-2xl mx-auto px-6 sm:px-8 py-20 text-center">\n <p className="text-[11px] font-mono uppercase tracking-[0.16em] text-primary mb-3">\n 404\n </p>\n <h1 className="text-[clamp(2.5rem,5vw,4rem)] font-bold mb-4 -tracking-[0.03em]">\n Page not found.\n </h1>\n <p className="text-muted-foreground leading-relaxed mb-8">\n The URL you followed might be old, a typo, or pointing at a product\n that&apos;s no longer in stock. Try the menu or head back home.\n </p>\n <div className="flex flex-wrap items-center justify-center gap-3">\n <Link\n href="/shop"\n className="inline-flex items-center gap-2 px-5 py-2.5 rounded-md bg-primary text-primary-foreground font-semibold text-sm hover:bg-primary/90 transition-colors"\n >\n Browse the shop\n </Link>\n <Link\n href="/"\n className="inline-flex items-center gap-2 px-5 py-2.5 rounded-md border border-border hover:bg-muted transition-colors text-sm font-medium"\n >\n Back home\n </Link>\n </div>\n </section>\n );\n}\n' }, { "path": "app/cart/page.tsx", "kind": "text", "content": '"use client";\n\nimport { useRouter } from "next/navigation";\nimport { CartPage as SdkCartPage } from "@cimplify/sdk/react";\n\nexport default function CartPage() {\n const router = useRouter();\n return <SdkCartPage onCheckout={() => router.push("/checkout")} />;\n}\n' }, { "path": "app/accessibility/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\nimport { PolicyPage } from "@/components/policy-page";\n\nexport const metadata: Metadata = {\n title: `${brand.accessibility.title} \u2014 ${brand.name}`,\n};\n\nexport default function AccessibilityPage() {\n return <PolicyPage policy={brand.accessibility} />;\n}\n' }, { "path": "app/account/orders/[id]/page.tsx", "kind": "text", "content": 'import { notFound } from "next/navigation";\nimport { AccountSignedOutPrompt, toAccountOrderView } from "@cimplify/sdk/react";\nimport type { EnrichedOrder } from "@cimplify/sdk";\nimport { brand } from "@/lib/brand";\nimport { apiFetch, serverAccessToken, serverSession } from "@/lib/cimplify-server";\nimport { OrderDetail } from "@/components/order-detail";\n\nconst CLIENT_ID = process.env.NEXT_PUBLIC_CIMPLIFY_CLIENT_ID ?? "";\nconst REDIRECT_URI = process.env.CIMPLIFY_REDIRECT_URI ?? "";\n\nexport default async function OrderDetailPage({\n params,\n}: {\n params: Promise<{ id: string }>;\n}) {\n const { id } = await params;\n\n const session = await serverSession();\n if (!session) {\n return (\n <AccountSignedOutPrompt\n clientId={CLIENT_ID}\n redirectUri={REDIRECT_URI}\n eyebrow={brand.account.loginEyebrow}\n title={brand.account.loginTitle}\n description={brand.account.loginSubtitle}\n />\n );\n }\n\n const bearer = await serverAccessToken();\n const order = await apiFetch<EnrichedOrder>(`/api/v1/orders/${encodeURIComponent(id)}`, {\n bearer,\n });\n if (!order) notFound();\n\n return (\n <div className="mx-auto w-full max-w-5xl px-4 py-8 sm:px-6">\n <OrderDetail {...toAccountOrderView(order)} />\n </div>\n );\n}\n' }, { "path": "app/account/orders/page.tsx", "kind": "text", "content": 'import {\n AccountOrdersPage,\n AccountSignedOutPrompt,\n} from "@cimplify/sdk/react";\nimport type { OrderRowData, OrderRowStatus } from "@cimplify/sdk/react";\nimport type { Order, OrderStatus } from "@cimplify/sdk";\nimport { formatMoney } from "@cimplify/sdk";\nimport { brand } from "@/lib/brand";\nimport { ACCOUNT_NAV, apiFetch, serverAccessToken, serverSession } from "@/lib/cimplify-server";\n\nconst CLIENT_ID = process.env.NEXT_PUBLIC_CIMPLIFY_CLIENT_ID ?? "";\nconst REDIRECT_URI = process.env.CIMPLIFY_REDIRECT_URI ?? "";\n\nfunction mapStatus(status: OrderStatus): OrderRowStatus {\n switch (status) {\n case "in_preparation":\n case "ready_to_serve":\n case "partially_served":\n return { kind: "preparing", label: "Preparing" };\n case "delivered":\n return { kind: "delivered" };\n case "picked_up":\n return { kind: "delivered", label: "Picked up" };\n case "served":\n case "completed":\n return { kind: "completed" };\n case "cancelled":\n case "refunded":\n return { kind: "cancelled", label: status === "refunded" ? "Refunded" : undefined };\n case "pending":\n case "created":\n case "confirmed":\n default:\n return { kind: "active", label: "Confirmed" };\n }\n}\n\nfunction relativeMeta(iso: string, orderNumber: string): string {\n const when = new Date(iso);\n const formatted = when.toLocaleDateString(undefined, {\n month: "short",\n day: "numeric",\n year: when.getFullYear() === new Date().getFullYear() ? undefined : "numeric",\n });\n return `Order ${orderNumber} \xB7 ${formatted}`;\n}\n\nfunction toRowData(order: Order): OrderRowData {\n const count = order.items?.length ?? 0;\n return {\n id: order.id,\n href: `/account/orders/${order.id}`,\n summary: count > 0 ? `${count} ${count === 1 ? "item" : "items"}` : "Order",\n meta: relativeMeta(order.created_at, order.user_friendly_id ?? order.id),\n status: mapStatus(order.status),\n total: formatMoney(order.total_price, order.currency),\n };\n}\n\nexport default async function OrdersPage() {\n const session = await serverSession();\n if (!session) {\n return (\n <AccountSignedOutPrompt\n clientId={CLIENT_ID}\n redirectUri={REDIRECT_URI}\n eyebrow={brand.account.loginEyebrow}\n title={brand.account.loginTitle}\n description={brand.account.loginSubtitle}\n />\n );\n }\n\n const bearer = await serverAccessToken();\n const orders = await apiFetch<Order[]>(\n `/v1/link/orders?business_id=${encodeURIComponent(brand.mock.businessId)}`,\n { bearer },\n );\n\n return (\n <AccountOrdersPage\n customerName={session.name ?? ""}\n customerEmail={session.email}\n customerId={session.sub}\n merchantName={brand.name}\n nav={ACCOUNT_NAV}\n orders={(orders ?? []).map(toRowData)}\n />\n );\n}\n' }, { "path": "app/account/page.tsx", "kind": "text", "content": 'import {\n AccountDashboardPage,\n AccountSignedOutPrompt,\n} from "@cimplify/sdk/react";\nimport type { CustomerAddress, CustomerMobileMoney } from "@cimplify/sdk";\nimport { brand } from "@/lib/brand";\nimport { ACCOUNT_NAV, apiFetch, serverAccessToken, serverSession } from "@/lib/cimplify-server";\n\nconst CLIENT_ID = process.env.NEXT_PUBLIC_CIMPLIFY_CLIENT_ID ?? "";\nconst REDIRECT_URI = process.env.CIMPLIFY_REDIRECT_URI ?? "";\n\nexport default async function AccountPage() {\n const session = await serverSession();\n if (!session) {\n return (\n <AccountSignedOutPrompt\n clientId={CLIENT_ID}\n redirectUri={REDIRECT_URI}\n eyebrow={brand.account.loginEyebrow}\n title={brand.account.loginTitle}\n description={brand.account.loginSubtitle}\n />\n );\n }\n\n const bearer = await serverAccessToken();\n const [addresses, wallets] = await Promise.all([\n apiFetch<CustomerAddress[]>("/v1/link/addresses", { bearer }),\n apiFetch<CustomerMobileMoney[]>("/v1/link/mobile-money", { bearer }),\n ]);\n\n return (\n <AccountDashboardPage\n customerName={session.name ?? ""}\n customerEmail={session.email}\n customerId={session.sub}\n merchantName={brand.name}\n nav={ACCOUNT_NAV}\n identitySummary={{\n addresses: addresses?.length ?? 0,\n paymentMethods: wallets?.length ?? 0,\n }}\n />\n );\n}\n' }, { "path": "app/.well-known/ucp/route.ts", "kind": "text", "content": 'import { NextResponse } from "next/server";\n\n/**\n * UCP (Universal Commerce Protocol) manifest discovery endpoint.\n *\n * Agents \u2014 Claude, ChatGPT, Gemini, MCP clients \u2014 probe\n * `https://<your-domain>/.well-known/ucp` to learn what commerce\n * capabilities your storefront supports. We resolve the business\'s\n * UCP handle from the public key (via the storefront API), then forward\n * to Cimplify for the canonical manifest. Edge-cached for an hour\n * because capabilities change rarely.\n */\nconst UCP_API_BASE = "https://api.cimplify.io";\nconst STOREFRONT_API_BASE =\n process.env.NODE_ENV === "production"\n ? "https://storefronts.cimplify.io"\n : "http://127.0.0.1:8787";\n\nexport async function GET() {\n const publicKey = process.env.NEXT_PUBLIC_CIMPLIFY_PUBLIC_KEY;\n if (!publicKey) {\n return NextResponse.json(\n {\n error: "NEXT_PUBLIC_CIMPLIFY_PUBLIC_KEY not set",\n remediation:\n "Set NEXT_PUBLIC_CIMPLIFY_PUBLIC_KEY in .env.local (and your deployment env).",\n },\n { status: 500 },\n );\n }\n\n try {\n // Step 1 \u2014 resolve the business handle from the public key.\n const businessResp = await fetch(`${STOREFRONT_API_BASE}/api/v1/business`, {\n headers: { "X-API-Key": publicKey, "Content-Type": "application/json" },\n next: { revalidate: 3600 },\n });\n\n if (!businessResp.ok) {\n return NextResponse.json(\n { error: `Failed to resolve business: ${businessResp.status}` },\n { status: businessResp.status },\n );\n }\n\n const businessJson = await businessResp.json();\n const handle: string | undefined = businessJson?.data?.handle;\n if (!handle) {\n return NextResponse.json(\n { error: "Business has no handle configured" },\n { status: 500 },\n );\n }\n\n // Step 2 \u2014 fetch the canonical UCP manifest.\n const manifestResp = await fetch(\n `${UCP_API_BASE}/ucp/v1/${handle}/manifest`,\n {\n headers: { "Content-Type": "application/json" },\n next: { revalidate: 3600 },\n },\n );\n\n if (!manifestResp.ok) {\n return NextResponse.json(\n { error: `Upstream UCP manifest fetch failed: ${manifestResp.status}` },\n { status: manifestResp.status },\n );\n }\n\n const manifest = await manifestResp.json();\n return NextResponse.json(manifest, {\n headers: {\n "Content-Type": "application/json",\n "Cache-Control": "public, max-age=3600, s-maxage=3600",\n },\n });\n } catch (error) {\n return NextResponse.json(\n {\n error: "Failed to fetch UCP manifest",\n detail: error instanceof Error ? error.message : String(error),\n },\n { status: 500 },\n );\n }\n}\n' }, { "path": "app/returns/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\nimport { PolicyPage } from "@/components/policy-page";\n\nexport const metadata: Metadata = {\n title: `${brand.returns.title} \u2014 ${brand.name}`,\n};\n\nexport default function ReturnsPage() {\n return <PolicyPage policy={brand.returns} />;\n}\n' }, { "path": "app/orders/[id]/page.tsx", "kind": "text", "content": 'import Link from "next/link";\n\nexport default async function OrderPage({ params }: { params: Promise<{ id: string }> }) {\n const { id } = await params;\n return (\n <section className="max-w-2xl mx-auto px-6 sm:px-8 py-20 text-center">\n <h1 className="text-3xl mt-0 mb-3 font-bold -tracking-[0.025em]">\n Order confirmed.\n </h1>\n <p className="text-muted-foreground">\n Order <code className="font-mono text-foreground">{id}</code> \u2014 you&apos;ll\n get an SMS with tracking within 30 minutes.\n </p>\n <p className="mt-6">\n <Link\n href="/"\n className="inline-block px-6 py-3 rounded-md bg-primary text-primary-foreground font-semibold text-sm transition-colors hover:bg-primary/90"\n >\n Continue shopping\n </Link>\n </p>\n </section>\n );\n}\n' }, { "path": "app/globals.css", "kind": "text", "content": `@import "tailwindcss";
4321
4321
 
4322
4322
  /* Scan the SDK's bundled JS so utility classes used inside its components
4323
4323
  land in the compiled stylesheet alongside our own. */
@@ -4982,7 +4982,7 @@ export const brand: Brand = {
4982
4982
  businessId: "bus_mamas_kitchen",
4983
4983
  },
4984
4984
  };
4985
- ` }, { "path": "lib/cimplify-server.ts", "kind": "text", "content": 'import { cookies } from "next/headers";\nimport {\n getAccessTokenFromCookieHeader,\n getSessionFromCookieHeader,\n} from "@cimplify/sdk/server";\nimport type { NavItem } from "@cimplify/sdk/react";\n\nexport const ACCOUNT_NAV: NavItem[] = [\n { label: "Dashboard", href: "/account" },\n { label: "Orders", href: "/account/orders" },\n { label: "Cimplify identity", href: "https://link.cimplify.io", external: true },\n];\n\nconst CLIENT_ID = process.env.CIMPLIFY_CLIENT_ID ?? "";\nconst ISSUER = (process.env.CIMPLIFY_ISSUER ?? "https://api.cimplify.io").replace(/\\/$/, "");\n\nconst OIDC_CONFIG = { clientId: CLIENT_ID, issuer: ISSUER };\n\nasync function cookieHeader(): Promise<string> {\n const store = await cookies();\n return store.getAll().map((c) => `${c.name}=${c.value}`).join("; ");\n}\n\nexport async function serverSession() {\n return getSessionFromCookieHeader(OIDC_CONFIG, await cookieHeader());\n}\n\nexport async function serverAccessToken(): Promise<string | null> {\n return getAccessTokenFromCookieHeader(OIDC_CONFIG, await cookieHeader());\n}\n\ninterface Envelope<T> {\n data: T;\n}\n\nexport async function apiFetch<T>(\n path: string,\n init: RequestInit & { bearer: string | null },\n): Promise<T | null> {\n if (!init.bearer) return null;\n const { bearer, ...rest } = init;\n const headers = new Headers(rest.headers);\n headers.set("Authorization", `Bearer ${bearer}`);\n headers.set("Accept", "application/json");\n if (rest.body && !headers.has("Content-Type")) {\n headers.set("Content-Type", "application/json");\n }\n const res = await fetch(`${ISSUER}${path}`, { ...rest, headers, cache: "no-store" });\n if (!res.ok) return null;\n const envelope = (await res.json()) as Envelope<T>;\n return envelope.data;\n}\n' }, { "path": "lib/cimplify-loader.ts", "kind": "text", "content": 'import type { ImageLoader } from "next/image";\n\nimport { assetUrl, isCimplifyAsset } from "@cimplify/sdk";\n\nconst cdnBase = process.env.NEXT_PUBLIC_CIMPLIFY_CDN_URL?.trim() || undefined;\n\nconst cimplifyImageLoader: ImageLoader = ({ src, width, quality }) => {\n if (isCimplifyAsset(src, cdnBase)) {\n return assetUrl(src, {\n base: cdnBase,\n w: width,\n quality,\n format: "auto",\n });\n }\n return src;\n};\n\nexport default cimplifyImageLoader;\n' }, { "path": "lib/site-url.ts", "kind": "text", "content": 'import { SITE_URL } from "./site.config";\n\n/** Canonical absolute URL for this storefront. */\nexport async function getSiteUrl(): Promise<string> {\n return SITE_URL;\n}\n' }, { "path": ".env.example", "kind": "text", "content": "# Your tenant public key. Get it from the desk's Developers tab.\n# `cpk_live_*` / `cpk_test_*` keys auto-route same-origin requests\n# to hosted Cimplify in both dev and prod. Anything else (`mock-dev`,\n# empty) falls back to the local `cimplify dev` mock.\nNEXT_PUBLIC_CIMPLIFY_PUBLIC_KEY=mock-dev\n\n# Optional. Forces a fixed canonical URL for sitemap.xml, robots.txt,\n# OpenGraph, and llms.txt. Leave unset and the storefront derives the\n# canonical from the request `Host` header automatically.\n# NEXT_PUBLIC_SITE_URL=\n" }, { "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__/brand.test.ts", "kind": "text", "content": 'import { createBrandSuite } from "@cimplify/sdk/testing/suite";\nimport { brand } from "../lib/brand";\n\ncreateBrandSuite({ brand });\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.100.4",\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": "metadata.json", "kind": "text", "content": '{\n "id": "restaurant",\n "name": "Restaurant",\n "tagline": "Menu-driven storefront with reservations and table-service support.",\n "industry": "food",\n "tags": ["food", "restaurant", "reservations"],\n "stability": "stable",\n "schemaType": "Restaurant",\n "mock": {\n "seedName": "restaurant",\n "seedBusinessId": "bus_mamas_kitchen"\n }\n}\n' }, { "path": "app/search/search-client.tsx", "kind": "text", "content": '"use client";\n\nimport { SearchPage } from "@cimplify/sdk/react";\n\nexport function SearchClient() {\n return <SearchPage />;\n}\n' }, { "path": "app/search/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Suspense } from "react";\nimport { SearchClient } from "./search-client";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Search \u2014 ${brand.name}`,\n description: `Search ${brand.name} \u2014 products, collections, categories.`,\n};\n\nexport default function SearchPage() {\n return (\n <article className="max-w-7xl mx-auto px-6 sm:px-8 py-10">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-2">\n Search\n </p>\n <h1 className="font-serif text-[clamp(2rem,4vw,2.75rem)] font-semibold mb-8 -tracking-[0.02em]">\n Find anything.\n </h1>\n <Suspense fallback={<SearchSkeleton />}>\n <SearchClient />\n </Suspense>\n </article>\n );\n}\n\nfunction SearchSkeleton() {\n return (\n <div>\n <div className="h-12 w-full max-w-xl bg-muted rounded animate-pulse mb-8" />\n <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3 sm:gap-4">\n {Array.from({ length: 8 }).map((_, i) => (\n <div key={i} className="aspect-[4/3] bg-muted rounded-2xl animate-pulse" />\n ))}\n </div>\n </div>\n );\n}\n' }, { "path": "app/categories/[slug]/listing-client.tsx", "kind": "text", "content": '"use client";\n\nimport { ProductGrid } from "@cimplify/sdk/react";\nimport type { Product } from "@cimplify/sdk";\nimport { StoreProductCard } from "@/components/store-product-card";\n\n/**\n * Client island for the category listing. Receives server-fetched products\n * as props (serializable) and owns the `renderCard` function.\n */\nexport function ListingClient({ products }: { products: Product[] }) {\n return (\n <ProductGrid\n products={products}\n emptyMessage="No products in this category yet."\n renderCard={(p) => <StoreProductCard product={p} />}\n />\n );\n}\n' }, { "path": "app/categories/[slug]/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Suspense } from "react";\nimport Link from "next/link";\nimport { notFound } from "next/navigation";\nimport {\n getServerClient,\n tags,\n type Category,\n type Product,\n} from "@cimplify/sdk/server";\nimport { ListingClient } from "./listing-client";\nimport { brand } from "@/lib/brand";\n\n// See app/products/[slug]/page.tsx for the rationale on generateStaticParams.\nexport async function generateStaticParams() {\n const r = await getServerClient().catalogue.getCategories();\n if (!r.ok || r.value.length === 0) {\n return [{ slug: "__placeholder__" }];\n }\n return r.value.map((c) => ({ slug: c.slug ?? c.id }));\n}\n\nexport const revalidate = 3600;\n\ninterface CategoryData {\n category: Category;\n products: Product[];\n}\n\ntype CategoryResult =\n | { ok: true; data: CategoryData }\n | { ok: false; code: string };\n\nasync function getCategory(slug: string): Promise<CategoryResult> {\n const client = getServerClient();\n const catRes = await client.catalogue.getCategoryBySlug(slug, {\n cacheOptions: { revalidate: 3600, tags: [tags.categories()] },\n });\n if (!catRes.ok) return { ok: false, code: catRes.error.code };\n\n const r = await client.catalogue.getCategoryProducts(catRes.value.id, undefined, {\n cacheOptions: {\n revalidate: 3600,\n tags: [\n tags.products(),\n tags.category(catRes.value.id),\n tags.categoryProducts(catRes.value.id),\n ],\n },\n });\n const products = r.ok\n ? ((r.value as { items?: Product[] }).items ?? (r.value as Product[]))\n : [];\n return { ok: true, data: { category: catRes.value, products } };\n}\n\nexport async function generateMetadata({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}): Promise<Metadata> {\n const { slug } = await params;\n const result = await getCategory(slug);\n if (!result.ok) return {};\n const data = result.data;\n return {\n title: `${data.category.name} \u2014 ${brand.name}`,\n description: data.category.description ?? undefined,\n };\n}\n\nexport default async function CategoryPage({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}) {\n return (\n <Suspense fallback={<CategorySkeleton />}>\n <CategoryContent params={params} />\n </Suspense>\n );\n}\n\nasync function CategoryContent({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}) {\n const { slug } = await params;\n const result = await getCategory(slug);\n if (!result.ok) {\n if (result.code === "NOT_FOUND") notFound();\n return <CategorySkeleton />;\n }\n const { category, products } = result.data;\n return (\n <section className="max-w-7xl mx-auto px-6 sm:px-8 pt-12">\n <header className="mb-8 text-center">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-2">\n Category\n </p>\n <h1 className="font-serif text-[clamp(2rem,4vw,2.75rem)] font-semibold mb-2">\n {category.name}\n </h1>\n {category.description && (\n <p className="mx-auto mb-2 max-w-xl text-muted-foreground">\n {category.description}\n </p>\n )}\n <p className="text-sm text-muted-foreground">\n {products.length} item{products.length === 1 ? "" : "s"}\n </p>\n </header>\n <ListingClient products={products} />\n {products.length === 0 && (\n <p className="text-center mt-8">\n <Link href="/" className="text-primary font-semibold">\n \u2190 Back home\n </Link>\n </p>\n )}\n </section>\n );\n}\n\nfunction CategorySkeleton() {\n return (\n <section className="max-w-7xl mx-auto px-6 sm:px-8 pt-12">\n <header className="mb-8 text-center">\n <div className="mx-auto h-3 w-20 bg-muted rounded mb-2 animate-pulse" />\n <div className="mx-auto h-10 w-64 bg-muted rounded mb-2 animate-pulse" />\n <div className="mx-auto h-4 w-80 bg-muted rounded animate-pulse" />\n </header>\n <div className="grid grid-cols-2 md:grid-cols-3 gap-4">\n {Array.from({ length: 6 }).map((_, i) => (\n <div key={i} className="aspect-square bg-muted rounded-2xl animate-pulse" />\n ))}\n </div>\n </section>\n );\n}\n' }, { "path": "app/terms/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Terms of Service \u2014 ${brand.name}`,\n description: `The rules of the road for ordering from ${brand.name}.`,\n};\n\nexport default function TermsPage() {\n const t = brand.terms;\n return (\n <article className="max-w-3xl mx-auto px-6 sm:px-8 py-16 prose prose-lg max-w-none">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-2 not-prose">\n {t.eyebrow}\n </p>\n <h1 className="font-serif text-[clamp(2.25rem,5vw,3.5rem)] font-semibold mb-2 -tracking-[0.02em]">\n {t.title}\n </h1>\n <p className="text-sm text-muted-foreground not-prose mb-10">\n Last updated: {t.lastUpdated}\n </p>\n\n <section className="space-y-5 leading-relaxed text-foreground/90">\n {t.sections.map((s) => (\n <div key={s.heading}>\n <h2 className="font-serif text-2xl font-semibold mt-0">{s.heading}</h2>\n {typeof s.body === "string" ? (\n <p>{s.body}</p>\n ) : (\n <>\n <p>{s.body.intro}</p>\n <ul className="list-disc pl-6 space-y-2">\n {s.body.bullets.map((b) => (\n <li key={b}>{b}</li>\n ))}\n </ul>\n </>\n )}\n </div>\n ))}\n </section>\n </article>\n );\n}\n' }, { "path": "app/not-found.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport Link from "next/link";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Page not found \u2014 ${brand.name}`,\n description: "We couldn\'t find that page.",\n};\n\nexport default function NotFound() {\n return (\n <section className="max-w-2xl mx-auto px-6 sm:px-8 py-20 text-center">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-3">\n 404\n </p>\n <h1 className="font-serif text-[clamp(2.5rem,5vw,3.5rem)] font-semibold mb-4">\n Page not found.\n </h1>\n <p className="text-muted-foreground leading-relaxed mb-8">\n The URL you followed might be old, a typo, or pointing at a product\n that&apos;s sold out for the day. Try the menu or head back home.\n </p>\n <div className="flex flex-wrap items-center justify-center gap-3">\n <Link\n href="/shop"\n className="inline-block px-6 py-3 rounded-full bg-primary text-primary-foreground font-semibold text-sm transition-colors hover:bg-primary/90"\n >\n Browse the menu\n </Link>\n <Link\n href="/"\n className="inline-block px-6 py-3 rounded-full border border-border hover:bg-muted transition-colors text-sm font-medium"\n >\n Back home\n </Link>\n </div>\n </section>\n );\n}\n' }, { "path": "app/cart/page.tsx", "kind": "text", "content": '"use client";\n\nimport { useRouter } from "next/navigation";\nimport { CartPage as SdkCartPage } from "@cimplify/sdk/react";\n\nexport default function CartPage() {\n const router = useRouter();\n return <SdkCartPage onCheckout={() => router.push("/checkout")} />;\n}\n' }, { "path": "app/accessibility/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\nimport { PolicyPage } from "@/components/policy-page";\n\nexport const metadata: Metadata = {\n title: `${brand.accessibility.title} \u2014 ${brand.name}`,\n};\n\nexport default function AccessibilityPage() {\n return <PolicyPage policy={brand.accessibility} />;\n}\n' }, { "path": "app/account/orders/[id]/page.tsx", "kind": "text", "content": 'import { notFound } from "next/navigation";\nimport { AccountSignedOutPrompt, toAccountOrderView } from "@cimplify/sdk/react";\nimport type { EnrichedOrder } from "@cimplify/sdk";\nimport { brand } from "@/lib/brand";\nimport { apiFetch, serverAccessToken, serverSession } from "@/lib/cimplify-server";\nimport { OrderDetail } from "@/components/order-detail";\n\nconst CLIENT_ID = process.env.NEXT_PUBLIC_CIMPLIFY_CLIENT_ID ?? "";\nconst REDIRECT_URI = process.env.CIMPLIFY_REDIRECT_URI ?? "";\n\nexport default async function OrderDetailPage({\n params,\n}: {\n params: Promise<{ id: string }>;\n}) {\n const { id } = await params;\n\n const session = await serverSession();\n if (!session) {\n return (\n <AccountSignedOutPrompt\n clientId={CLIENT_ID}\n redirectUri={REDIRECT_URI}\n eyebrow={brand.account.loginEyebrow}\n title={brand.account.loginTitle}\n description={brand.account.loginSubtitle}\n />\n );\n }\n\n const bearer = await serverAccessToken();\n const order = await apiFetch<EnrichedOrder>(`/api/v1/orders/${encodeURIComponent(id)}`, {\n bearer,\n });\n if (!order) notFound();\n\n return (\n <div className="mx-auto w-full max-w-5xl px-4 py-8 sm:px-6">\n <OrderDetail {...toAccountOrderView(order)} />\n </div>\n );\n}\n' }, { "path": "app/account/orders/page.tsx", "kind": "text", "content": 'import {\n AccountOrdersPage,\n AccountSignedOutPrompt,\n} from "@cimplify/sdk/react";\nimport type { OrderRowData, OrderRowStatus } from "@cimplify/sdk/react";\nimport type { Order, OrderStatus } from "@cimplify/sdk";\nimport { formatMoney } from "@cimplify/sdk";\nimport { brand } from "@/lib/brand";\nimport { ACCOUNT_NAV, apiFetch, serverAccessToken, serverSession } from "@/lib/cimplify-server";\n\nconst CLIENT_ID = process.env.NEXT_PUBLIC_CIMPLIFY_CLIENT_ID ?? "";\nconst REDIRECT_URI = process.env.CIMPLIFY_REDIRECT_URI ?? "";\n\nfunction mapStatus(status: OrderStatus): OrderRowStatus {\n switch (status) {\n case "in_preparation":\n case "ready_to_serve":\n case "partially_served":\n return { kind: "preparing", label: "Preparing" };\n case "delivered":\n return { kind: "delivered" };\n case "picked_up":\n return { kind: "delivered", label: "Picked up" };\n case "served":\n case "completed":\n return { kind: "completed" };\n case "cancelled":\n case "refunded":\n return { kind: "cancelled", label: status === "refunded" ? "Refunded" : undefined };\n case "pending":\n case "created":\n case "confirmed":\n default:\n return { kind: "active", label: "Confirmed" };\n }\n}\n\nfunction relativeMeta(iso: string, orderNumber: string): string {\n const when = new Date(iso);\n const formatted = when.toLocaleDateString(undefined, {\n month: "short",\n day: "numeric",\n year: when.getFullYear() === new Date().getFullYear() ? undefined : "numeric",\n });\n return `Order ${orderNumber} \xB7 ${formatted}`;\n}\n\nfunction toRowData(order: Order): OrderRowData {\n const count = order.items?.length ?? 0;\n return {\n id: order.id,\n href: `/account/orders/${order.id}`,\n summary: count > 0 ? `${count} ${count === 1 ? "item" : "items"}` : "Order",\n meta: relativeMeta(order.created_at, order.user_friendly_id ?? order.id),\n status: mapStatus(order.status),\n total: formatMoney(order.total_price, order.currency),\n };\n}\n\nexport default async function OrdersPage() {\n const session = await serverSession();\n if (!session) {\n return (\n <AccountSignedOutPrompt\n clientId={CLIENT_ID}\n redirectUri={REDIRECT_URI}\n eyebrow={brand.account.loginEyebrow}\n title={brand.account.loginTitle}\n description={brand.account.loginSubtitle}\n />\n );\n }\n\n const bearer = await serverAccessToken();\n const orders = await apiFetch<Order[]>(\n `/v1/link/orders?business_id=${encodeURIComponent(brand.mock.businessId)}`,\n { bearer },\n );\n\n return (\n <AccountOrdersPage\n customerName={session.name ?? ""}\n customerEmail={session.email}\n customerId={session.sub}\n merchantName={brand.name}\n nav={ACCOUNT_NAV}\n orders={(orders ?? []).map(toRowData)}\n />\n );\n}\n' }, { "path": "app/account/page.tsx", "kind": "text", "content": 'import {\n AccountDashboardPage,\n AccountSignedOutPrompt,\n} from "@cimplify/sdk/react";\nimport type { CustomerAddress, CustomerMobileMoney } from "@cimplify/sdk";\nimport { brand } from "@/lib/brand";\nimport { ACCOUNT_NAV, apiFetch, serverAccessToken, serverSession } from "@/lib/cimplify-server";\n\nconst CLIENT_ID = process.env.NEXT_PUBLIC_CIMPLIFY_CLIENT_ID ?? "";\nconst REDIRECT_URI = process.env.CIMPLIFY_REDIRECT_URI ?? "";\n\nexport default async function AccountPage() {\n const session = await serverSession();\n if (!session) {\n return (\n <AccountSignedOutPrompt\n clientId={CLIENT_ID}\n redirectUri={REDIRECT_URI}\n eyebrow={brand.account.loginEyebrow}\n title={brand.account.loginTitle}\n description={brand.account.loginSubtitle}\n />\n );\n }\n\n const bearer = await serverAccessToken();\n const [addresses, wallets] = await Promise.all([\n apiFetch<CustomerAddress[]>("/v1/link/addresses", { bearer }),\n apiFetch<CustomerMobileMoney[]>("/v1/link/mobile-money", { bearer }),\n ]);\n\n return (\n <AccountDashboardPage\n customerName={session.name ?? ""}\n customerEmail={session.email}\n customerId={session.sub}\n merchantName={brand.name}\n nav={ACCOUNT_NAV}\n identitySummary={{\n addresses: addresses?.length ?? 0,\n paymentMethods: wallets?.length ?? 0,\n }}\n />\n );\n}\n' }, { "path": "app/.well-known/ucp/route.ts", "kind": "text", "content": 'import { NextResponse } from "next/server";\n\n/**\n * UCP (Universal Commerce Protocol) manifest discovery endpoint.\n *\n * Agents \u2014 Claude, ChatGPT, Gemini, MCP clients \u2014 probe\n * `https://<your-domain>/.well-known/ucp` to learn what commerce\n * capabilities your storefront supports. We resolve the business\'s\n * UCP handle from the public key (via the storefront API), then forward\n * to Cimplify for the canonical manifest. Edge-cached for an hour\n * because capabilities change rarely.\n */\nconst UCP_API_BASE = "https://api.cimplify.io";\nconst STOREFRONT_API_BASE =\n process.env.NODE_ENV === "production"\n ? "https://storefronts.cimplify.io"\n : "http://127.0.0.1:8787";\n\nexport async function GET() {\n const publicKey = process.env.NEXT_PUBLIC_CIMPLIFY_PUBLIC_KEY;\n if (!publicKey) {\n return NextResponse.json(\n {\n error: "NEXT_PUBLIC_CIMPLIFY_PUBLIC_KEY not set",\n remediation:\n "Set NEXT_PUBLIC_CIMPLIFY_PUBLIC_KEY in .env.local (and your deployment env).",\n },\n { status: 500 },\n );\n }\n\n try {\n // Step 1 \u2014 resolve the business handle from the public key.\n const businessResp = await fetch(`${STOREFRONT_API_BASE}/api/v1/business`, {\n headers: { "X-API-Key": publicKey, "Content-Type": "application/json" },\n next: { revalidate: 3600 },\n });\n\n if (!businessResp.ok) {\n return NextResponse.json(\n { error: `Failed to resolve business: ${businessResp.status}` },\n { status: businessResp.status },\n );\n }\n\n const businessJson = await businessResp.json();\n const handle: string | undefined = businessJson?.data?.handle;\n if (!handle) {\n return NextResponse.json(\n { error: "Business has no handle configured" },\n { status: 500 },\n );\n }\n\n // Step 2 \u2014 fetch the canonical UCP manifest.\n const manifestResp = await fetch(\n `${UCP_API_BASE}/ucp/v1/${handle}/manifest`,\n {\n headers: { "Content-Type": "application/json" },\n next: { revalidate: 3600 },\n },\n );\n\n if (!manifestResp.ok) {\n return NextResponse.json(\n { error: `Upstream UCP manifest fetch failed: ${manifestResp.status}` },\n { status: manifestResp.status },\n );\n }\n\n const manifest = await manifestResp.json();\n return NextResponse.json(manifest, {\n headers: {\n "Content-Type": "application/json",\n "Cache-Control": "public, max-age=3600, s-maxage=3600",\n },\n });\n } catch (error) {\n return NextResponse.json(\n {\n error: "Failed to fetch UCP manifest",\n detail: error instanceof Error ? error.message : String(error),\n },\n { status: 500 },\n );\n }\n}\n' }, { "path": "app/returns/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\nimport { PolicyPage } from "@/components/policy-page";\n\nexport const metadata: Metadata = {\n title: `${brand.returns.title} \u2014 ${brand.name}`,\n};\n\nexport default function ReturnsPage() {\n return <PolicyPage policy={brand.returns} />;\n}\n' }, { "path": "app/orders/[id]/page.tsx", "kind": "text", "content": 'import Link from "next/link";\n\nexport default async function OrderPage({ params }: { params: Promise<{ id: string }> }) {\n const { id } = await params;\n return (\n <section className="max-w-2xl mx-auto px-6 sm:px-8 py-20 text-center">\n <h1 className="font-serif text-3xl mt-0 mb-3">Thanks \u2014 your order is confirmed</h1>\n <p className="text-muted-foreground">\n Order <code className="font-mono text-foreground">{id}</code>\n </p>\n <p className="mt-6">\n <Link\n href="/"\n className="inline-block px-6 py-3 rounded-full bg-primary text-primary-foreground font-semibold text-sm transition-colors hover:bg-primary/90"\n >\n Continue shopping\n </Link>\n </p>\n </section>\n );\n}\n' }, { "path": "app/globals.css", "kind": "text", "content": `@import "tailwindcss";
4985
+ ` }, { "path": "lib/cimplify-server.ts", "kind": "text", "content": 'import { cookies } from "next/headers";\nimport {\n getAccessTokenFromCookieHeader,\n getSessionFromCookieHeader,\n} from "@cimplify/sdk/server";\nimport type { NavItem } from "@cimplify/sdk/react";\n\nexport const ACCOUNT_NAV: NavItem[] = [\n { label: "Dashboard", href: "/account" },\n { label: "Orders", href: "/account/orders" },\n { label: "Cimplify identity", href: "https://link.cimplify.io", external: true },\n];\n\nconst CLIENT_ID = process.env.CIMPLIFY_CLIENT_ID ?? "";\nconst ISSUER = (process.env.CIMPLIFY_ISSUER ?? "https://api.cimplify.io").replace(/\\/$/, "");\n\nconst OIDC_CONFIG = { clientId: CLIENT_ID, issuer: ISSUER };\n\nasync function cookieHeader(): Promise<string> {\n const store = await cookies();\n return store.getAll().map((c) => `${c.name}=${c.value}`).join("; ");\n}\n\nexport async function serverSession() {\n return getSessionFromCookieHeader(OIDC_CONFIG, await cookieHeader());\n}\n\nexport async function serverAccessToken(): Promise<string | null> {\n return getAccessTokenFromCookieHeader(OIDC_CONFIG, await cookieHeader());\n}\n\ninterface Envelope<T> {\n data: T;\n}\n\nexport async function apiFetch<T>(\n path: string,\n init: RequestInit & { bearer: string | null },\n): Promise<T | null> {\n if (!init.bearer) return null;\n const { bearer, ...rest } = init;\n const headers = new Headers(rest.headers);\n headers.set("Authorization", `Bearer ${bearer}`);\n headers.set("Accept", "application/json");\n if (rest.body && !headers.has("Content-Type")) {\n headers.set("Content-Type", "application/json");\n }\n const res = await fetch(`${ISSUER}${path}`, { ...rest, headers, cache: "no-store" });\n if (!res.ok) return null;\n const envelope = (await res.json()) as Envelope<T>;\n return envelope.data;\n}\n' }, { "path": "lib/cimplify-loader.ts", "kind": "text", "content": 'import type { ImageLoader } from "next/image";\n\nimport { assetUrl, isCimplifyAsset } from "@cimplify/sdk";\n\nconst cdnBase = process.env.NEXT_PUBLIC_CIMPLIFY_CDN_URL?.trim() || undefined;\n\nconst cimplifyImageLoader: ImageLoader = ({ src, width, quality }) => {\n if (isCimplifyAsset(src, cdnBase)) {\n return assetUrl(src, {\n base: cdnBase,\n w: width,\n quality,\n format: "auto",\n });\n }\n return src;\n};\n\nexport default cimplifyImageLoader;\n' }, { "path": "lib/site-url.ts", "kind": "text", "content": 'import { SITE_URL } from "./site.config";\n\n/** Canonical absolute URL for this storefront. */\nexport async function getSiteUrl(): Promise<string> {\n return SITE_URL;\n}\n' }, { "path": ".env.example", "kind": "text", "content": "# Your tenant public key. Get it from the desk's Developers tab.\n# `cpk_live_*` / `cpk_test_*` keys auto-route same-origin requests\n# to hosted Cimplify in both dev and prod. Anything else (`mock-dev`,\n# empty) falls back to the local `cimplify dev` mock.\nNEXT_PUBLIC_CIMPLIFY_PUBLIC_KEY=mock-dev\n\n# Optional. Forces a fixed canonical URL for sitemap.xml, robots.txt,\n# OpenGraph, and llms.txt. Leave unset and the storefront derives the\n# canonical from the request `Host` header automatically.\n# NEXT_PUBLIC_SITE_URL=\n" }, { "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__/brand.test.ts", "kind": "text", "content": 'import { createBrandSuite } from "@cimplify/sdk/testing/suite";\nimport { brand } from "../lib/brand";\n\ncreateBrandSuite({ brand });\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.101.1",\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": "metadata.json", "kind": "text", "content": '{\n "id": "restaurant",\n "name": "Restaurant",\n "tagline": "Menu-driven storefront with reservations and table-service support.",\n "industry": "food",\n "tags": ["food", "restaurant", "reservations"],\n "stability": "stable",\n "schemaType": "Restaurant",\n "mock": {\n "seedName": "restaurant",\n "seedBusinessId": "bus_mamas_kitchen"\n }\n}\n' }, { "path": "app/search/search-client.tsx", "kind": "text", "content": '"use client";\n\nimport { SearchPage } from "@cimplify/sdk/react";\n\nexport function SearchClient() {\n return <SearchPage />;\n}\n' }, { "path": "app/search/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Suspense } from "react";\nimport { SearchClient } from "./search-client";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Search \u2014 ${brand.name}`,\n description: `Search ${brand.name} \u2014 products, collections, categories.`,\n};\n\nexport default function SearchPage() {\n return (\n <article className="max-w-7xl mx-auto px-6 sm:px-8 py-10">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-2">\n Search\n </p>\n <h1 className="font-serif text-[clamp(2rem,4vw,2.75rem)] font-semibold mb-8 -tracking-[0.02em]">\n Find anything.\n </h1>\n <Suspense fallback={<SearchSkeleton />}>\n <SearchClient />\n </Suspense>\n </article>\n );\n}\n\nfunction SearchSkeleton() {\n return (\n <div>\n <div className="h-12 w-full max-w-xl bg-muted rounded animate-pulse mb-8" />\n <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3 sm:gap-4">\n {Array.from({ length: 8 }).map((_, i) => (\n <div key={i} className="aspect-[4/3] bg-muted rounded-2xl animate-pulse" />\n ))}\n </div>\n </div>\n );\n}\n' }, { "path": "app/categories/[slug]/listing-client.tsx", "kind": "text", "content": '"use client";\n\nimport { ProductGrid } from "@cimplify/sdk/react";\nimport type { Product } from "@cimplify/sdk";\nimport { StoreProductCard } from "@/components/store-product-card";\n\n/**\n * Client island for the category listing. Receives server-fetched products\n * as props (serializable) and owns the `renderCard` function.\n */\nexport function ListingClient({ products }: { products: Product[] }) {\n return (\n <ProductGrid\n products={products}\n emptyMessage="No products in this category yet."\n renderCard={(p) => <StoreProductCard product={p} />}\n />\n );\n}\n' }, { "path": "app/categories/[slug]/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Suspense } from "react";\nimport Link from "next/link";\nimport { notFound } from "next/navigation";\nimport {\n getServerClient,\n tags,\n type Category,\n type Product,\n} from "@cimplify/sdk/server";\nimport { ListingClient } from "./listing-client";\nimport { brand } from "@/lib/brand";\n\n// See app/products/[slug]/page.tsx for the rationale on generateStaticParams.\nexport async function generateStaticParams() {\n const r = await getServerClient().catalogue.getCategories();\n if (!r.ok || r.value.length === 0) {\n return [{ slug: "__placeholder__" }];\n }\n return r.value.map((c) => ({ slug: c.slug ?? c.id }));\n}\n\nexport const revalidate = 3600;\n\ninterface CategoryData {\n category: Category;\n products: Product[];\n}\n\ntype CategoryResult =\n | { ok: true; data: CategoryData }\n | { ok: false; code: string };\n\nasync function getCategory(slug: string): Promise<CategoryResult> {\n const client = getServerClient();\n const catRes = await client.catalogue.getCategoryBySlug(slug, {\n cacheOptions: { revalidate: 3600, tags: [tags.categories()] },\n });\n if (!catRes.ok) return { ok: false, code: catRes.error.code };\n\n const r = await client.catalogue.getCategoryProducts(catRes.value.id, undefined, {\n cacheOptions: {\n revalidate: 3600,\n tags: [\n tags.products(),\n tags.category(catRes.value.id),\n tags.categoryProducts(catRes.value.id),\n ],\n },\n });\n const products = r.ok\n ? ((r.value as { items?: Product[] }).items ?? (r.value as Product[]))\n : [];\n return { ok: true, data: { category: catRes.value, products } };\n}\n\nexport async function generateMetadata({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}): Promise<Metadata> {\n const { slug } = await params;\n const result = await getCategory(slug);\n if (!result.ok) return {};\n const data = result.data;\n return {\n title: `${data.category.name} \u2014 ${brand.name}`,\n description: data.category.description ?? undefined,\n };\n}\n\nexport default async function CategoryPage({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}) {\n return (\n <Suspense fallback={<CategorySkeleton />}>\n <CategoryContent params={params} />\n </Suspense>\n );\n}\n\nasync function CategoryContent({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}) {\n const { slug } = await params;\n const result = await getCategory(slug);\n if (!result.ok) {\n if (result.code === "NOT_FOUND") notFound();\n return <CategorySkeleton />;\n }\n const { category, products } = result.data;\n return (\n <section className="max-w-7xl mx-auto px-6 sm:px-8 pt-12">\n <header className="mb-8 text-center">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-2">\n Category\n </p>\n <h1 className="font-serif text-[clamp(2rem,4vw,2.75rem)] font-semibold mb-2">\n {category.name}\n </h1>\n {category.description && (\n <p className="mx-auto mb-2 max-w-xl text-muted-foreground">\n {category.description}\n </p>\n )}\n <p className="text-sm text-muted-foreground">\n {products.length} item{products.length === 1 ? "" : "s"}\n </p>\n </header>\n <ListingClient products={products} />\n {products.length === 0 && (\n <p className="text-center mt-8">\n <Link href="/" className="text-primary font-semibold">\n \u2190 Back home\n </Link>\n </p>\n )}\n </section>\n );\n}\n\nfunction CategorySkeleton() {\n return (\n <section className="max-w-7xl mx-auto px-6 sm:px-8 pt-12">\n <header className="mb-8 text-center">\n <div className="mx-auto h-3 w-20 bg-muted rounded mb-2 animate-pulse" />\n <div className="mx-auto h-10 w-64 bg-muted rounded mb-2 animate-pulse" />\n <div className="mx-auto h-4 w-80 bg-muted rounded animate-pulse" />\n </header>\n <div className="grid grid-cols-2 md:grid-cols-3 gap-4">\n {Array.from({ length: 6 }).map((_, i) => (\n <div key={i} className="aspect-square bg-muted rounded-2xl animate-pulse" />\n ))}\n </div>\n </section>\n );\n}\n' }, { "path": "app/terms/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Terms of Service \u2014 ${brand.name}`,\n description: `The rules of the road for ordering from ${brand.name}.`,\n};\n\nexport default function TermsPage() {\n const t = brand.terms;\n return (\n <article className="max-w-3xl mx-auto px-6 sm:px-8 py-16 prose prose-lg max-w-none">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-2 not-prose">\n {t.eyebrow}\n </p>\n <h1 className="font-serif text-[clamp(2.25rem,5vw,3.5rem)] font-semibold mb-2 -tracking-[0.02em]">\n {t.title}\n </h1>\n <p className="text-sm text-muted-foreground not-prose mb-10">\n Last updated: {t.lastUpdated}\n </p>\n\n <section className="space-y-5 leading-relaxed text-foreground/90">\n {t.sections.map((s) => (\n <div key={s.heading}>\n <h2 className="font-serif text-2xl font-semibold mt-0">{s.heading}</h2>\n {typeof s.body === "string" ? (\n <p>{s.body}</p>\n ) : (\n <>\n <p>{s.body.intro}</p>\n <ul className="list-disc pl-6 space-y-2">\n {s.body.bullets.map((b) => (\n <li key={b}>{b}</li>\n ))}\n </ul>\n </>\n )}\n </div>\n ))}\n </section>\n </article>\n );\n}\n' }, { "path": "app/not-found.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport Link from "next/link";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Page not found \u2014 ${brand.name}`,\n description: "We couldn\'t find that page.",\n};\n\nexport default function NotFound() {\n return (\n <section className="max-w-2xl mx-auto px-6 sm:px-8 py-20 text-center">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-3">\n 404\n </p>\n <h1 className="font-serif text-[clamp(2.5rem,5vw,3.5rem)] font-semibold mb-4">\n Page not found.\n </h1>\n <p className="text-muted-foreground leading-relaxed mb-8">\n The URL you followed might be old, a typo, or pointing at a product\n that&apos;s sold out for the day. Try the menu or head back home.\n </p>\n <div className="flex flex-wrap items-center justify-center gap-3">\n <Link\n href="/shop"\n className="inline-block px-6 py-3 rounded-full bg-primary text-primary-foreground font-semibold text-sm transition-colors hover:bg-primary/90"\n >\n Browse the menu\n </Link>\n <Link\n href="/"\n className="inline-block px-6 py-3 rounded-full border border-border hover:bg-muted transition-colors text-sm font-medium"\n >\n Back home\n </Link>\n </div>\n </section>\n );\n}\n' }, { "path": "app/cart/page.tsx", "kind": "text", "content": '"use client";\n\nimport { useRouter } from "next/navigation";\nimport { CartPage as SdkCartPage } from "@cimplify/sdk/react";\n\nexport default function CartPage() {\n const router = useRouter();\n return <SdkCartPage onCheckout={() => router.push("/checkout")} />;\n}\n' }, { "path": "app/accessibility/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\nimport { PolicyPage } from "@/components/policy-page";\n\nexport const metadata: Metadata = {\n title: `${brand.accessibility.title} \u2014 ${brand.name}`,\n};\n\nexport default function AccessibilityPage() {\n return <PolicyPage policy={brand.accessibility} />;\n}\n' }, { "path": "app/account/orders/[id]/page.tsx", "kind": "text", "content": 'import { notFound } from "next/navigation";\nimport { AccountSignedOutPrompt, toAccountOrderView } from "@cimplify/sdk/react";\nimport type { EnrichedOrder } from "@cimplify/sdk";\nimport { brand } from "@/lib/brand";\nimport { apiFetch, serverAccessToken, serverSession } from "@/lib/cimplify-server";\nimport { OrderDetail } from "@/components/order-detail";\n\nconst CLIENT_ID = process.env.NEXT_PUBLIC_CIMPLIFY_CLIENT_ID ?? "";\nconst REDIRECT_URI = process.env.CIMPLIFY_REDIRECT_URI ?? "";\n\nexport default async function OrderDetailPage({\n params,\n}: {\n params: Promise<{ id: string }>;\n}) {\n const { id } = await params;\n\n const session = await serverSession();\n if (!session) {\n return (\n <AccountSignedOutPrompt\n clientId={CLIENT_ID}\n redirectUri={REDIRECT_URI}\n eyebrow={brand.account.loginEyebrow}\n title={brand.account.loginTitle}\n description={brand.account.loginSubtitle}\n />\n );\n }\n\n const bearer = await serverAccessToken();\n const order = await apiFetch<EnrichedOrder>(`/api/v1/orders/${encodeURIComponent(id)}`, {\n bearer,\n });\n if (!order) notFound();\n\n return (\n <div className="mx-auto w-full max-w-5xl px-4 py-8 sm:px-6">\n <OrderDetail {...toAccountOrderView(order)} />\n </div>\n );\n}\n' }, { "path": "app/account/orders/page.tsx", "kind": "text", "content": 'import {\n AccountOrdersPage,\n AccountSignedOutPrompt,\n} from "@cimplify/sdk/react";\nimport type { OrderRowData, OrderRowStatus } from "@cimplify/sdk/react";\nimport type { Order, OrderStatus } from "@cimplify/sdk";\nimport { formatMoney } from "@cimplify/sdk";\nimport { brand } from "@/lib/brand";\nimport { ACCOUNT_NAV, apiFetch, serverAccessToken, serverSession } from "@/lib/cimplify-server";\n\nconst CLIENT_ID = process.env.NEXT_PUBLIC_CIMPLIFY_CLIENT_ID ?? "";\nconst REDIRECT_URI = process.env.CIMPLIFY_REDIRECT_URI ?? "";\n\nfunction mapStatus(status: OrderStatus): OrderRowStatus {\n switch (status) {\n case "in_preparation":\n case "ready_to_serve":\n case "partially_served":\n return { kind: "preparing", label: "Preparing" };\n case "delivered":\n return { kind: "delivered" };\n case "picked_up":\n return { kind: "delivered", label: "Picked up" };\n case "served":\n case "completed":\n return { kind: "completed" };\n case "cancelled":\n case "refunded":\n return { kind: "cancelled", label: status === "refunded" ? "Refunded" : undefined };\n case "pending":\n case "created":\n case "confirmed":\n default:\n return { kind: "active", label: "Confirmed" };\n }\n}\n\nfunction relativeMeta(iso: string, orderNumber: string): string {\n const when = new Date(iso);\n const formatted = when.toLocaleDateString(undefined, {\n month: "short",\n day: "numeric",\n year: when.getFullYear() === new Date().getFullYear() ? undefined : "numeric",\n });\n return `Order ${orderNumber} \xB7 ${formatted}`;\n}\n\nfunction toRowData(order: Order): OrderRowData {\n const count = order.items?.length ?? 0;\n return {\n id: order.id,\n href: `/account/orders/${order.id}`,\n summary: count > 0 ? `${count} ${count === 1 ? "item" : "items"}` : "Order",\n meta: relativeMeta(order.created_at, order.user_friendly_id ?? order.id),\n status: mapStatus(order.status),\n total: formatMoney(order.total_price, order.currency),\n };\n}\n\nexport default async function OrdersPage() {\n const session = await serverSession();\n if (!session) {\n return (\n <AccountSignedOutPrompt\n clientId={CLIENT_ID}\n redirectUri={REDIRECT_URI}\n eyebrow={brand.account.loginEyebrow}\n title={brand.account.loginTitle}\n description={brand.account.loginSubtitle}\n />\n );\n }\n\n const bearer = await serverAccessToken();\n const orders = await apiFetch<Order[]>(\n `/v1/link/orders?business_id=${encodeURIComponent(brand.mock.businessId)}`,\n { bearer },\n );\n\n return (\n <AccountOrdersPage\n customerName={session.name ?? ""}\n customerEmail={session.email}\n customerId={session.sub}\n merchantName={brand.name}\n nav={ACCOUNT_NAV}\n orders={(orders ?? []).map(toRowData)}\n />\n );\n}\n' }, { "path": "app/account/page.tsx", "kind": "text", "content": 'import {\n AccountDashboardPage,\n AccountSignedOutPrompt,\n} from "@cimplify/sdk/react";\nimport type { CustomerAddress, CustomerMobileMoney } from "@cimplify/sdk";\nimport { brand } from "@/lib/brand";\nimport { ACCOUNT_NAV, apiFetch, serverAccessToken, serverSession } from "@/lib/cimplify-server";\n\nconst CLIENT_ID = process.env.NEXT_PUBLIC_CIMPLIFY_CLIENT_ID ?? "";\nconst REDIRECT_URI = process.env.CIMPLIFY_REDIRECT_URI ?? "";\n\nexport default async function AccountPage() {\n const session = await serverSession();\n if (!session) {\n return (\n <AccountSignedOutPrompt\n clientId={CLIENT_ID}\n redirectUri={REDIRECT_URI}\n eyebrow={brand.account.loginEyebrow}\n title={brand.account.loginTitle}\n description={brand.account.loginSubtitle}\n />\n );\n }\n\n const bearer = await serverAccessToken();\n const [addresses, wallets] = await Promise.all([\n apiFetch<CustomerAddress[]>("/v1/link/addresses", { bearer }),\n apiFetch<CustomerMobileMoney[]>("/v1/link/mobile-money", { bearer }),\n ]);\n\n return (\n <AccountDashboardPage\n customerName={session.name ?? ""}\n customerEmail={session.email}\n customerId={session.sub}\n merchantName={brand.name}\n nav={ACCOUNT_NAV}\n identitySummary={{\n addresses: addresses?.length ?? 0,\n paymentMethods: wallets?.length ?? 0,\n }}\n />\n );\n}\n' }, { "path": "app/.well-known/ucp/route.ts", "kind": "text", "content": 'import { NextResponse } from "next/server";\n\n/**\n * UCP (Universal Commerce Protocol) manifest discovery endpoint.\n *\n * Agents \u2014 Claude, ChatGPT, Gemini, MCP clients \u2014 probe\n * `https://<your-domain>/.well-known/ucp` to learn what commerce\n * capabilities your storefront supports. We resolve the business\'s\n * UCP handle from the public key (via the storefront API), then forward\n * to Cimplify for the canonical manifest. Edge-cached for an hour\n * because capabilities change rarely.\n */\nconst UCP_API_BASE = "https://api.cimplify.io";\nconst STOREFRONT_API_BASE =\n process.env.NODE_ENV === "production"\n ? "https://storefronts.cimplify.io"\n : "http://127.0.0.1:8787";\n\nexport async function GET() {\n const publicKey = process.env.NEXT_PUBLIC_CIMPLIFY_PUBLIC_KEY;\n if (!publicKey) {\n return NextResponse.json(\n {\n error: "NEXT_PUBLIC_CIMPLIFY_PUBLIC_KEY not set",\n remediation:\n "Set NEXT_PUBLIC_CIMPLIFY_PUBLIC_KEY in .env.local (and your deployment env).",\n },\n { status: 500 },\n );\n }\n\n try {\n // Step 1 \u2014 resolve the business handle from the public key.\n const businessResp = await fetch(`${STOREFRONT_API_BASE}/api/v1/business`, {\n headers: { "X-API-Key": publicKey, "Content-Type": "application/json" },\n next: { revalidate: 3600 },\n });\n\n if (!businessResp.ok) {\n return NextResponse.json(\n { error: `Failed to resolve business: ${businessResp.status}` },\n { status: businessResp.status },\n );\n }\n\n const businessJson = await businessResp.json();\n const handle: string | undefined = businessJson?.data?.handle;\n if (!handle) {\n return NextResponse.json(\n { error: "Business has no handle configured" },\n { status: 500 },\n );\n }\n\n // Step 2 \u2014 fetch the canonical UCP manifest.\n const manifestResp = await fetch(\n `${UCP_API_BASE}/ucp/v1/${handle}/manifest`,\n {\n headers: { "Content-Type": "application/json" },\n next: { revalidate: 3600 },\n },\n );\n\n if (!manifestResp.ok) {\n return NextResponse.json(\n { error: `Upstream UCP manifest fetch failed: ${manifestResp.status}` },\n { status: manifestResp.status },\n );\n }\n\n const manifest = await manifestResp.json();\n return NextResponse.json(manifest, {\n headers: {\n "Content-Type": "application/json",\n "Cache-Control": "public, max-age=3600, s-maxage=3600",\n },\n });\n } catch (error) {\n return NextResponse.json(\n {\n error: "Failed to fetch UCP manifest",\n detail: error instanceof Error ? error.message : String(error),\n },\n { status: 500 },\n );\n }\n}\n' }, { "path": "app/returns/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\nimport { PolicyPage } from "@/components/policy-page";\n\nexport const metadata: Metadata = {\n title: `${brand.returns.title} \u2014 ${brand.name}`,\n};\n\nexport default function ReturnsPage() {\n return <PolicyPage policy={brand.returns} />;\n}\n' }, { "path": "app/orders/[id]/page.tsx", "kind": "text", "content": 'import Link from "next/link";\n\nexport default async function OrderPage({ params }: { params: Promise<{ id: string }> }) {\n const { id } = await params;\n return (\n <section className="max-w-2xl mx-auto px-6 sm:px-8 py-20 text-center">\n <h1 className="font-serif text-3xl mt-0 mb-3">Thanks \u2014 your order is confirmed</h1>\n <p className="text-muted-foreground">\n Order <code className="font-mono text-foreground">{id}</code>\n </p>\n <p className="mt-6">\n <Link\n href="/"\n className="inline-block px-6 py-3 rounded-full bg-primary text-primary-foreground font-semibold text-sm transition-colors hover:bg-primary/90"\n >\n Continue shopping\n </Link>\n </p>\n </section>\n );\n}\n' }, { "path": "app/globals.css", "kind": "text", "content": `@import "tailwindcss";
4986
4986
 
4987
4987
  @source "../node_modules/@cimplify/sdk/dist";
4988
4988
 
@@ -5601,7 +5601,7 @@ export const brand: Brand = {
5601
5601
  businessId: "bus_freshmart",
5602
5602
  },
5603
5603
  };
5604
- ` }, { "path": "lib/cimplify-server.ts", "kind": "text", "content": 'import { cookies } from "next/headers";\nimport {\n getAccessTokenFromCookieHeader,\n getSessionFromCookieHeader,\n} from "@cimplify/sdk/server";\nimport type { NavItem } from "@cimplify/sdk/react";\n\nexport const ACCOUNT_NAV: NavItem[] = [\n { label: "Dashboard", href: "/account" },\n { label: "Orders", href: "/account/orders" },\n { label: "Cimplify identity", href: "https://link.cimplify.io", external: true },\n];\n\nconst CLIENT_ID = process.env.CIMPLIFY_CLIENT_ID ?? "";\nconst ISSUER = (process.env.CIMPLIFY_ISSUER ?? "https://api.cimplify.io").replace(/\\/$/, "");\n\nconst OIDC_CONFIG = { clientId: CLIENT_ID, issuer: ISSUER };\n\nasync function cookieHeader(): Promise<string> {\n const store = await cookies();\n return store.getAll().map((c) => `${c.name}=${c.value}`).join("; ");\n}\n\nexport async function serverSession() {\n return getSessionFromCookieHeader(OIDC_CONFIG, await cookieHeader());\n}\n\nexport async function serverAccessToken(): Promise<string | null> {\n return getAccessTokenFromCookieHeader(OIDC_CONFIG, await cookieHeader());\n}\n\ninterface Envelope<T> {\n data: T;\n}\n\nexport async function apiFetch<T>(\n path: string,\n init: RequestInit & { bearer: string | null },\n): Promise<T | null> {\n if (!init.bearer) return null;\n const { bearer, ...rest } = init;\n const headers = new Headers(rest.headers);\n headers.set("Authorization", `Bearer ${bearer}`);\n headers.set("Accept", "application/json");\n if (rest.body && !headers.has("Content-Type")) {\n headers.set("Content-Type", "application/json");\n }\n const res = await fetch(`${ISSUER}${path}`, { ...rest, headers, cache: "no-store" });\n if (!res.ok) return null;\n const envelope = (await res.json()) as Envelope<T>;\n return envelope.data;\n}\n' }, { "path": "lib/cimplify-loader.ts", "kind": "text", "content": 'import type { ImageLoader } from "next/image";\n\nimport { assetUrl, isCimplifyAsset } from "@cimplify/sdk";\n\nconst cdnBase = process.env.NEXT_PUBLIC_CIMPLIFY_CDN_URL?.trim() || undefined;\n\nconst cimplifyImageLoader: ImageLoader = ({ src, width, quality }) => {\n if (isCimplifyAsset(src, cdnBase)) {\n return assetUrl(src, {\n base: cdnBase,\n w: width,\n quality,\n format: "auto",\n });\n }\n return src;\n};\n\nexport default cimplifyImageLoader;\n' }, { "path": "lib/site-url.ts", "kind": "text", "content": 'import { SITE_URL } from "./site.config";\n\n/** Canonical absolute URL for this storefront. */\nexport async function getSiteUrl(): Promise<string> {\n return SITE_URL;\n}\n' }, { "path": ".env.example", "kind": "text", "content": "# Your tenant public key. Get it from the desk's Developers tab.\n# `cpk_live_*` / `cpk_test_*` keys auto-route same-origin requests\n# to hosted Cimplify in both dev and prod. Anything else (`mock-dev`,\n# empty) falls back to the local `cimplify dev` mock.\nNEXT_PUBLIC_CIMPLIFY_PUBLIC_KEY=mock-dev\n\n# Optional. Forces a fixed canonical URL for sitemap.xml, robots.txt,\n# OpenGraph, and llms.txt. Leave unset and the storefront derives the\n# canonical from the request `Host` header automatically.\n# NEXT_PUBLIC_SITE_URL=\n" }, { "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__/brand.test.ts", "kind": "text", "content": 'import { createBrandSuite } from "@cimplify/sdk/testing/suite";\nimport { brand } from "../lib/brand";\n\ncreateBrandSuite({ brand });\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.100.4",\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": "metadata.json", "kind": "text", "content": '{\n "id": "grocery",\n "name": "Grocery",\n "tagline": "High-SKU grocery storefront with quick-add cart UX and delivery windows.",\n "industry": "retail",\n "tags": ["food", "grocery", "delivery"],\n "stability": "stable",\n "schemaType": "GroceryStore",\n "mock": {\n "seedName": "grocery",\n "seedBusinessId": "bus_freshmart"\n }\n}\n' }, { "path": "app/search/search-client.tsx", "kind": "text", "content": '"use client";\n\nimport { SearchPage } from "@cimplify/sdk/react";\n\nexport function SearchClient() {\n return <SearchPage />;\n}\n' }, { "path": "app/search/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Suspense } from "react";\nimport { SearchClient } from "./search-client";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Search \u2014 ${brand.name}`,\n description: `Search ${brand.name} \u2014 products, collections, categories.`,\n};\n\nexport default function SearchPage() {\n return (\n <article className="max-w-7xl mx-auto px-6 sm:px-8 py-10">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-2">\n Search\n </p>\n <h1 className="font-serif text-[clamp(2rem,4vw,2.75rem)] font-semibold mb-8 -tracking-[0.02em]">\n Find anything.\n </h1>\n <Suspense fallback={<SearchSkeleton />}>\n <SearchClient />\n </Suspense>\n </article>\n );\n}\n\nfunction SearchSkeleton() {\n return (\n <div>\n <div className="h-12 w-full max-w-xl bg-muted rounded animate-pulse mb-8" />\n <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3 sm:gap-4">\n {Array.from({ length: 8 }).map((_, i) => (\n <div key={i} className="aspect-[4/3] bg-muted rounded-2xl animate-pulse" />\n ))}\n </div>\n </div>\n );\n}\n' }, { "path": "app/categories/[slug]/listing-client.tsx", "kind": "text", "content": '"use client";\n\nimport { ProductGrid } from "@cimplify/sdk/react";\nimport type { Product } from "@cimplify/sdk";\nimport { StoreProductCard } from "@/components/store-product-card";\n\n/**\n * Client island for the category listing. Receives server-fetched products\n * as props (serializable) and owns the `renderCard` function.\n */\nexport function ListingClient({ products }: { products: Product[] }) {\n return (\n <ProductGrid\n products={products}\n emptyMessage="No products in this category yet."\n renderCard={(p) => <StoreProductCard product={p} />}\n />\n );\n}\n' }, { "path": "app/categories/[slug]/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Suspense } from "react";\nimport Link from "next/link";\nimport { notFound } from "next/navigation";\nimport {\n getServerClient,\n tags,\n type Category,\n type Product,\n} from "@cimplify/sdk/server";\nimport { ListingClient } from "./listing-client";\nimport { brand } from "@/lib/brand";\n\n// See app/products/[slug]/page.tsx for the rationale on generateStaticParams.\nexport async function generateStaticParams() {\n const r = await getServerClient().catalogue.getCategories();\n if (!r.ok || r.value.length === 0) {\n return [{ slug: "__placeholder__" }];\n }\n return r.value.map((c) => ({ slug: c.slug ?? c.id }));\n}\n\nexport const revalidate = 3600;\n\ninterface CategoryData {\n category: Category;\n products: Product[];\n}\n\ntype CategoryResult =\n | { ok: true; data: CategoryData }\n | { ok: false; code: string };\n\nasync function getCategory(slug: string): Promise<CategoryResult> {\n const client = getServerClient();\n const catRes = await client.catalogue.getCategoryBySlug(slug, {\n cacheOptions: { revalidate: 3600, tags: [tags.categories()] },\n });\n if (!catRes.ok) return { ok: false, code: catRes.error.code };\n\n const r = await client.catalogue.getCategoryProducts(catRes.value.id, undefined, {\n cacheOptions: {\n revalidate: 3600,\n tags: [\n tags.products(),\n tags.category(catRes.value.id),\n tags.categoryProducts(catRes.value.id),\n ],\n },\n });\n const products = r.ok\n ? ((r.value as { items?: Product[] }).items ?? (r.value as Product[]))\n : [];\n return { ok: true, data: { category: catRes.value, products } };\n}\n\nexport async function generateMetadata({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}): Promise<Metadata> {\n const { slug } = await params;\n const result = await getCategory(slug);\n if (!result.ok) return {};\n const data = result.data;\n return {\n title: `${data.category.name} \u2014 ${brand.name}`,\n description: data.category.description ?? undefined,\n };\n}\n\nexport default async function CategoryPage({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}) {\n return (\n <Suspense fallback={<CategorySkeleton />}>\n <CategoryContent params={params} />\n </Suspense>\n );\n}\n\nasync function CategoryContent({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}) {\n const { slug } = await params;\n const result = await getCategory(slug);\n if (!result.ok) {\n if (result.code === "NOT_FOUND") notFound();\n return <CategorySkeleton />;\n }\n const { category, products } = result.data;\n return (\n <section className="max-w-7xl mx-auto px-6 sm:px-8 pt-12">\n <header className="mb-8 text-center">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-2">\n Category\n </p>\n <h1 className="font-serif text-[clamp(2rem,4vw,2.75rem)] font-semibold mb-2">\n {category.name}\n </h1>\n {category.description && (\n <p className="mx-auto mb-2 max-w-xl text-muted-foreground">\n {category.description}\n </p>\n )}\n <p className="text-sm text-muted-foreground">\n {products.length} item{products.length === 1 ? "" : "s"}\n </p>\n </header>\n <ListingClient products={products} />\n {products.length === 0 && (\n <p className="text-center mt-8">\n <Link href="/" className="text-primary font-semibold">\n \u2190 Back home\n </Link>\n </p>\n )}\n </section>\n );\n}\n\nfunction CategorySkeleton() {\n return (\n <section className="max-w-7xl mx-auto px-6 sm:px-8 pt-12">\n <header className="mb-8 text-center">\n <div className="mx-auto h-3 w-20 bg-muted rounded mb-2 animate-pulse" />\n <div className="mx-auto h-10 w-64 bg-muted rounded mb-2 animate-pulse" />\n <div className="mx-auto h-4 w-80 bg-muted rounded animate-pulse" />\n </header>\n <div className="grid grid-cols-2 md:grid-cols-3 gap-4">\n {Array.from({ length: 6 }).map((_, i) => (\n <div key={i} className="aspect-square bg-muted rounded-2xl animate-pulse" />\n ))}\n </div>\n </section>\n );\n}\n' }, { "path": "app/terms/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Terms of Service \u2014 ${brand.name}`,\n description: `The rules of the road for ordering from ${brand.name}.`,\n};\n\nexport default function TermsPage() {\n const t = brand.terms;\n return (\n <article className="max-w-3xl mx-auto px-6 sm:px-8 py-16 prose prose-lg max-w-none">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-2 not-prose">\n {t.eyebrow}\n </p>\n <h1 className="font-serif text-[clamp(2.25rem,5vw,3.5rem)] font-semibold mb-2 -tracking-[0.02em]">\n {t.title}\n </h1>\n <p className="text-sm text-muted-foreground not-prose mb-10">\n Last updated: {t.lastUpdated}\n </p>\n\n <section className="space-y-5 leading-relaxed text-foreground/90">\n {t.sections.map((s) => (\n <div key={s.heading}>\n <h2 className="font-serif text-2xl font-semibold mt-0">{s.heading}</h2>\n {typeof s.body === "string" ? (\n <p>{s.body}</p>\n ) : (\n <>\n <p>{s.body.intro}</p>\n <ul className="list-disc pl-6 space-y-2">\n {s.body.bullets.map((b) => (\n <li key={b}>{b}</li>\n ))}\n </ul>\n </>\n )}\n </div>\n ))}\n </section>\n </article>\n );\n}\n' }, { "path": "app/not-found.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport Link from "next/link";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Page not found \u2014 ${brand.name}`,\n description: "We couldn\'t find that page.",\n};\n\nexport default function NotFound() {\n return (\n <section className="max-w-2xl mx-auto px-6 sm:px-8 py-20 text-center">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-3">\n 404\n </p>\n <h1 className="font-serif text-[clamp(2.5rem,5vw,3.5rem)] font-semibold mb-4">\n Page not found.\n </h1>\n <p className="text-muted-foreground leading-relaxed mb-8">\n The URL you followed might be old, a typo, or pointing at a product\n that&apos;s sold out for the day. Try the menu or head back home.\n </p>\n <div className="flex flex-wrap items-center justify-center gap-3">\n <Link\n href="/shop"\n className="inline-block px-6 py-3 rounded-full bg-primary text-primary-foreground font-semibold text-sm transition-colors hover:bg-primary/90"\n >\n Browse the menu\n </Link>\n <Link\n href="/"\n className="inline-block px-6 py-3 rounded-full border border-border hover:bg-muted transition-colors text-sm font-medium"\n >\n Back home\n </Link>\n </div>\n </section>\n );\n}\n' }, { "path": "app/cart/page.tsx", "kind": "text", "content": '"use client";\n\nimport { useRouter } from "next/navigation";\nimport { CartPage as SdkCartPage } from "@cimplify/sdk/react";\n\nexport default function CartPage() {\n const router = useRouter();\n return <SdkCartPage onCheckout={() => router.push("/checkout")} />;\n}\n' }, { "path": "app/accessibility/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\nimport { PolicyPage } from "@/components/policy-page";\n\nexport const metadata: Metadata = {\n title: `${brand.accessibility.title} \u2014 ${brand.name}`,\n};\n\nexport default function AccessibilityPage() {\n return <PolicyPage policy={brand.accessibility} />;\n}\n' }, { "path": "app/account/orders/[id]/page.tsx", "kind": "text", "content": 'import { notFound } from "next/navigation";\nimport { AccountSignedOutPrompt, toAccountOrderView } from "@cimplify/sdk/react";\nimport type { EnrichedOrder } from "@cimplify/sdk";\nimport { brand } from "@/lib/brand";\nimport { apiFetch, serverAccessToken, serverSession } from "@/lib/cimplify-server";\nimport { OrderDetail } from "@/components/order-detail";\n\nconst CLIENT_ID = process.env.NEXT_PUBLIC_CIMPLIFY_CLIENT_ID ?? "";\nconst REDIRECT_URI = process.env.CIMPLIFY_REDIRECT_URI ?? "";\n\nexport default async function OrderDetailPage({\n params,\n}: {\n params: Promise<{ id: string }>;\n}) {\n const { id } = await params;\n\n const session = await serverSession();\n if (!session) {\n return (\n <AccountSignedOutPrompt\n clientId={CLIENT_ID}\n redirectUri={REDIRECT_URI}\n eyebrow={brand.account.loginEyebrow}\n title={brand.account.loginTitle}\n description={brand.account.loginSubtitle}\n />\n );\n }\n\n const bearer = await serverAccessToken();\n const order = await apiFetch<EnrichedOrder>(`/api/v1/orders/${encodeURIComponent(id)}`, {\n bearer,\n });\n if (!order) notFound();\n\n return (\n <div className="mx-auto w-full max-w-5xl px-4 py-8 sm:px-6">\n <OrderDetail {...toAccountOrderView(order)} />\n </div>\n );\n}\n' }, { "path": "app/account/orders/page.tsx", "kind": "text", "content": 'import {\n AccountOrdersPage,\n AccountSignedOutPrompt,\n} from "@cimplify/sdk/react";\nimport type { OrderRowData, OrderRowStatus } from "@cimplify/sdk/react";\nimport type { Order, OrderStatus } from "@cimplify/sdk";\nimport { formatMoney } from "@cimplify/sdk";\nimport { brand } from "@/lib/brand";\nimport { ACCOUNT_NAV, apiFetch, serverAccessToken, serverSession } from "@/lib/cimplify-server";\n\nconst CLIENT_ID = process.env.NEXT_PUBLIC_CIMPLIFY_CLIENT_ID ?? "";\nconst REDIRECT_URI = process.env.CIMPLIFY_REDIRECT_URI ?? "";\n\nfunction mapStatus(status: OrderStatus): OrderRowStatus {\n switch (status) {\n case "in_preparation":\n case "ready_to_serve":\n case "partially_served":\n return { kind: "preparing", label: "Preparing" };\n case "delivered":\n return { kind: "delivered" };\n case "picked_up":\n return { kind: "delivered", label: "Picked up" };\n case "served":\n case "completed":\n return { kind: "completed" };\n case "cancelled":\n case "refunded":\n return { kind: "cancelled", label: status === "refunded" ? "Refunded" : undefined };\n case "pending":\n case "created":\n case "confirmed":\n default:\n return { kind: "active", label: "Confirmed" };\n }\n}\n\nfunction relativeMeta(iso: string, orderNumber: string): string {\n const when = new Date(iso);\n const formatted = when.toLocaleDateString(undefined, {\n month: "short",\n day: "numeric",\n year: when.getFullYear() === new Date().getFullYear() ? undefined : "numeric",\n });\n return `Order ${orderNumber} \xB7 ${formatted}`;\n}\n\nfunction toRowData(order: Order): OrderRowData {\n const count = order.items?.length ?? 0;\n return {\n id: order.id,\n href: `/account/orders/${order.id}`,\n summary: count > 0 ? `${count} ${count === 1 ? "item" : "items"}` : "Order",\n meta: relativeMeta(order.created_at, order.user_friendly_id ?? order.id),\n status: mapStatus(order.status),\n total: formatMoney(order.total_price, order.currency),\n };\n}\n\nexport default async function OrdersPage() {\n const session = await serverSession();\n if (!session) {\n return (\n <AccountSignedOutPrompt\n clientId={CLIENT_ID}\n redirectUri={REDIRECT_URI}\n eyebrow={brand.account.loginEyebrow}\n title={brand.account.loginTitle}\n description={brand.account.loginSubtitle}\n />\n );\n }\n\n const bearer = await serverAccessToken();\n const orders = await apiFetch<Order[]>(\n `/v1/link/orders?business_id=${encodeURIComponent(brand.mock.businessId)}`,\n { bearer },\n );\n\n return (\n <AccountOrdersPage\n customerName={session.name ?? ""}\n customerEmail={session.email}\n customerId={session.sub}\n merchantName={brand.name}\n nav={ACCOUNT_NAV}\n orders={(orders ?? []).map(toRowData)}\n />\n );\n}\n' }, { "path": "app/account/page.tsx", "kind": "text", "content": 'import {\n AccountDashboardPage,\n AccountSignedOutPrompt,\n} from "@cimplify/sdk/react";\nimport type { CustomerAddress, CustomerMobileMoney } from "@cimplify/sdk";\nimport { brand } from "@/lib/brand";\nimport { ACCOUNT_NAV, apiFetch, serverAccessToken, serverSession } from "@/lib/cimplify-server";\n\nconst CLIENT_ID = process.env.NEXT_PUBLIC_CIMPLIFY_CLIENT_ID ?? "";\nconst REDIRECT_URI = process.env.CIMPLIFY_REDIRECT_URI ?? "";\n\nexport default async function AccountPage() {\n const session = await serverSession();\n if (!session) {\n return (\n <AccountSignedOutPrompt\n clientId={CLIENT_ID}\n redirectUri={REDIRECT_URI}\n eyebrow={brand.account.loginEyebrow}\n title={brand.account.loginTitle}\n description={brand.account.loginSubtitle}\n />\n );\n }\n\n const bearer = await serverAccessToken();\n const [addresses, wallets] = await Promise.all([\n apiFetch<CustomerAddress[]>("/v1/link/addresses", { bearer }),\n apiFetch<CustomerMobileMoney[]>("/v1/link/mobile-money", { bearer }),\n ]);\n\n return (\n <AccountDashboardPage\n customerName={session.name ?? ""}\n customerEmail={session.email}\n customerId={session.sub}\n merchantName={brand.name}\n nav={ACCOUNT_NAV}\n identitySummary={{\n addresses: addresses?.length ?? 0,\n paymentMethods: wallets?.length ?? 0,\n }}\n />\n );\n}\n' }, { "path": "app/.well-known/ucp/route.ts", "kind": "text", "content": 'import { NextResponse } from "next/server";\n\n/**\n * UCP (Universal Commerce Protocol) manifest discovery endpoint.\n *\n * Agents \u2014 Claude, ChatGPT, Gemini, MCP clients \u2014 probe\n * `https://<your-domain>/.well-known/ucp` to learn what commerce\n * capabilities your storefront supports. We resolve the business\'s\n * UCP handle from the public key (via the storefront API), then forward\n * to Cimplify for the canonical manifest. Edge-cached for an hour\n * because capabilities change rarely.\n */\nconst UCP_API_BASE = "https://api.cimplify.io";\nconst STOREFRONT_API_BASE =\n process.env.NODE_ENV === "production"\n ? "https://storefronts.cimplify.io"\n : "http://127.0.0.1:8787";\n\nexport async function GET() {\n const publicKey = process.env.NEXT_PUBLIC_CIMPLIFY_PUBLIC_KEY;\n if (!publicKey) {\n return NextResponse.json(\n {\n error: "NEXT_PUBLIC_CIMPLIFY_PUBLIC_KEY not set",\n remediation:\n "Set NEXT_PUBLIC_CIMPLIFY_PUBLIC_KEY in .env.local (and your deployment env).",\n },\n { status: 500 },\n );\n }\n\n try {\n // Step 1 \u2014 resolve the business handle from the public key.\n const businessResp = await fetch(`${STOREFRONT_API_BASE}/api/v1/business`, {\n headers: { "X-API-Key": publicKey, "Content-Type": "application/json" },\n next: { revalidate: 3600 },\n });\n\n if (!businessResp.ok) {\n return NextResponse.json(\n { error: `Failed to resolve business: ${businessResp.status}` },\n { status: businessResp.status },\n );\n }\n\n const businessJson = await businessResp.json();\n const handle: string | undefined = businessJson?.data?.handle;\n if (!handle) {\n return NextResponse.json(\n { error: "Business has no handle configured" },\n { status: 500 },\n );\n }\n\n // Step 2 \u2014 fetch the canonical UCP manifest.\n const manifestResp = await fetch(\n `${UCP_API_BASE}/ucp/v1/${handle}/manifest`,\n {\n headers: { "Content-Type": "application/json" },\n next: { revalidate: 3600 },\n },\n );\n\n if (!manifestResp.ok) {\n return NextResponse.json(\n { error: `Upstream UCP manifest fetch failed: ${manifestResp.status}` },\n { status: manifestResp.status },\n );\n }\n\n const manifest = await manifestResp.json();\n return NextResponse.json(manifest, {\n headers: {\n "Content-Type": "application/json",\n "Cache-Control": "public, max-age=3600, s-maxage=3600",\n },\n });\n } catch (error) {\n return NextResponse.json(\n {\n error: "Failed to fetch UCP manifest",\n detail: error instanceof Error ? error.message : String(error),\n },\n { status: 500 },\n );\n }\n}\n' }, { "path": "app/returns/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\nimport { PolicyPage } from "@/components/policy-page";\n\nexport const metadata: Metadata = {\n title: `${brand.returns.title} \u2014 ${brand.name}`,\n};\n\nexport default function ReturnsPage() {\n return <PolicyPage policy={brand.returns} />;\n}\n' }, { "path": "app/orders/[id]/page.tsx", "kind": "text", "content": 'import Link from "next/link";\n\nexport default async function OrderPage({ params }: { params: Promise<{ id: string }> }) {\n const { id } = await params;\n return (\n <section className="max-w-2xl mx-auto px-6 sm:px-8 py-20 text-center">\n <h1 className="font-serif text-3xl mt-0 mb-3">Thanks \u2014 your order is confirmed</h1>\n <p className="text-muted-foreground">\n Order <code className="font-mono text-foreground">{id}</code>\n </p>\n <p className="mt-6">\n <Link\n href="/"\n className="inline-block px-6 py-3 rounded-full bg-primary text-primary-foreground font-semibold text-sm transition-colors hover:bg-primary/90"\n >\n Continue shopping\n </Link>\n </p>\n </section>\n );\n}\n' }, { "path": "app/globals.css", "kind": "text", "content": `@import "tailwindcss";
5604
+ ` }, { "path": "lib/cimplify-server.ts", "kind": "text", "content": 'import { cookies } from "next/headers";\nimport {\n getAccessTokenFromCookieHeader,\n getSessionFromCookieHeader,\n} from "@cimplify/sdk/server";\nimport type { NavItem } from "@cimplify/sdk/react";\n\nexport const ACCOUNT_NAV: NavItem[] = [\n { label: "Dashboard", href: "/account" },\n { label: "Orders", href: "/account/orders" },\n { label: "Cimplify identity", href: "https://link.cimplify.io", external: true },\n];\n\nconst CLIENT_ID = process.env.CIMPLIFY_CLIENT_ID ?? "";\nconst ISSUER = (process.env.CIMPLIFY_ISSUER ?? "https://api.cimplify.io").replace(/\\/$/, "");\n\nconst OIDC_CONFIG = { clientId: CLIENT_ID, issuer: ISSUER };\n\nasync function cookieHeader(): Promise<string> {\n const store = await cookies();\n return store.getAll().map((c) => `${c.name}=${c.value}`).join("; ");\n}\n\nexport async function serverSession() {\n return getSessionFromCookieHeader(OIDC_CONFIG, await cookieHeader());\n}\n\nexport async function serverAccessToken(): Promise<string | null> {\n return getAccessTokenFromCookieHeader(OIDC_CONFIG, await cookieHeader());\n}\n\ninterface Envelope<T> {\n data: T;\n}\n\nexport async function apiFetch<T>(\n path: string,\n init: RequestInit & { bearer: string | null },\n): Promise<T | null> {\n if (!init.bearer) return null;\n const { bearer, ...rest } = init;\n const headers = new Headers(rest.headers);\n headers.set("Authorization", `Bearer ${bearer}`);\n headers.set("Accept", "application/json");\n if (rest.body && !headers.has("Content-Type")) {\n headers.set("Content-Type", "application/json");\n }\n const res = await fetch(`${ISSUER}${path}`, { ...rest, headers, cache: "no-store" });\n if (!res.ok) return null;\n const envelope = (await res.json()) as Envelope<T>;\n return envelope.data;\n}\n' }, { "path": "lib/cimplify-loader.ts", "kind": "text", "content": 'import type { ImageLoader } from "next/image";\n\nimport { assetUrl, isCimplifyAsset } from "@cimplify/sdk";\n\nconst cdnBase = process.env.NEXT_PUBLIC_CIMPLIFY_CDN_URL?.trim() || undefined;\n\nconst cimplifyImageLoader: ImageLoader = ({ src, width, quality }) => {\n if (isCimplifyAsset(src, cdnBase)) {\n return assetUrl(src, {\n base: cdnBase,\n w: width,\n quality,\n format: "auto",\n });\n }\n return src;\n};\n\nexport default cimplifyImageLoader;\n' }, { "path": "lib/site-url.ts", "kind": "text", "content": 'import { SITE_URL } from "./site.config";\n\n/** Canonical absolute URL for this storefront. */\nexport async function getSiteUrl(): Promise<string> {\n return SITE_URL;\n}\n' }, { "path": ".env.example", "kind": "text", "content": "# Your tenant public key. Get it from the desk's Developers tab.\n# `cpk_live_*` / `cpk_test_*` keys auto-route same-origin requests\n# to hosted Cimplify in both dev and prod. Anything else (`mock-dev`,\n# empty) falls back to the local `cimplify dev` mock.\nNEXT_PUBLIC_CIMPLIFY_PUBLIC_KEY=mock-dev\n\n# Optional. Forces a fixed canonical URL for sitemap.xml, robots.txt,\n# OpenGraph, and llms.txt. Leave unset and the storefront derives the\n# canonical from the request `Host` header automatically.\n# NEXT_PUBLIC_SITE_URL=\n" }, { "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__/brand.test.ts", "kind": "text", "content": 'import { createBrandSuite } from "@cimplify/sdk/testing/suite";\nimport { brand } from "../lib/brand";\n\ncreateBrandSuite({ brand });\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.101.1",\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": "metadata.json", "kind": "text", "content": '{\n "id": "grocery",\n "name": "Grocery",\n "tagline": "High-SKU grocery storefront with quick-add cart UX and delivery windows.",\n "industry": "retail",\n "tags": ["food", "grocery", "delivery"],\n "stability": "stable",\n "schemaType": "GroceryStore",\n "mock": {\n "seedName": "grocery",\n "seedBusinessId": "bus_freshmart"\n }\n}\n' }, { "path": "app/search/search-client.tsx", "kind": "text", "content": '"use client";\n\nimport { SearchPage } from "@cimplify/sdk/react";\n\nexport function SearchClient() {\n return <SearchPage />;\n}\n' }, { "path": "app/search/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Suspense } from "react";\nimport { SearchClient } from "./search-client";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Search \u2014 ${brand.name}`,\n description: `Search ${brand.name} \u2014 products, collections, categories.`,\n};\n\nexport default function SearchPage() {\n return (\n <article className="max-w-7xl mx-auto px-6 sm:px-8 py-10">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-2">\n Search\n </p>\n <h1 className="font-serif text-[clamp(2rem,4vw,2.75rem)] font-semibold mb-8 -tracking-[0.02em]">\n Find anything.\n </h1>\n <Suspense fallback={<SearchSkeleton />}>\n <SearchClient />\n </Suspense>\n </article>\n );\n}\n\nfunction SearchSkeleton() {\n return (\n <div>\n <div className="h-12 w-full max-w-xl bg-muted rounded animate-pulse mb-8" />\n <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3 sm:gap-4">\n {Array.from({ length: 8 }).map((_, i) => (\n <div key={i} className="aspect-[4/3] bg-muted rounded-2xl animate-pulse" />\n ))}\n </div>\n </div>\n );\n}\n' }, { "path": "app/categories/[slug]/listing-client.tsx", "kind": "text", "content": '"use client";\n\nimport { ProductGrid } from "@cimplify/sdk/react";\nimport type { Product } from "@cimplify/sdk";\nimport { StoreProductCard } from "@/components/store-product-card";\n\n/**\n * Client island for the category listing. Receives server-fetched products\n * as props (serializable) and owns the `renderCard` function.\n */\nexport function ListingClient({ products }: { products: Product[] }) {\n return (\n <ProductGrid\n products={products}\n emptyMessage="No products in this category yet."\n renderCard={(p) => <StoreProductCard product={p} />}\n />\n );\n}\n' }, { "path": "app/categories/[slug]/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Suspense } from "react";\nimport Link from "next/link";\nimport { notFound } from "next/navigation";\nimport {\n getServerClient,\n tags,\n type Category,\n type Product,\n} from "@cimplify/sdk/server";\nimport { ListingClient } from "./listing-client";\nimport { brand } from "@/lib/brand";\n\n// See app/products/[slug]/page.tsx for the rationale on generateStaticParams.\nexport async function generateStaticParams() {\n const r = await getServerClient().catalogue.getCategories();\n if (!r.ok || r.value.length === 0) {\n return [{ slug: "__placeholder__" }];\n }\n return r.value.map((c) => ({ slug: c.slug ?? c.id }));\n}\n\nexport const revalidate = 3600;\n\ninterface CategoryData {\n category: Category;\n products: Product[];\n}\n\ntype CategoryResult =\n | { ok: true; data: CategoryData }\n | { ok: false; code: string };\n\nasync function getCategory(slug: string): Promise<CategoryResult> {\n const client = getServerClient();\n const catRes = await client.catalogue.getCategoryBySlug(slug, {\n cacheOptions: { revalidate: 3600, tags: [tags.categories()] },\n });\n if (!catRes.ok) return { ok: false, code: catRes.error.code };\n\n const r = await client.catalogue.getCategoryProducts(catRes.value.id, undefined, {\n cacheOptions: {\n revalidate: 3600,\n tags: [\n tags.products(),\n tags.category(catRes.value.id),\n tags.categoryProducts(catRes.value.id),\n ],\n },\n });\n const products = r.ok\n ? ((r.value as { items?: Product[] }).items ?? (r.value as Product[]))\n : [];\n return { ok: true, data: { category: catRes.value, products } };\n}\n\nexport async function generateMetadata({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}): Promise<Metadata> {\n const { slug } = await params;\n const result = await getCategory(slug);\n if (!result.ok) return {};\n const data = result.data;\n return {\n title: `${data.category.name} \u2014 ${brand.name}`,\n description: data.category.description ?? undefined,\n };\n}\n\nexport default async function CategoryPage({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}) {\n return (\n <Suspense fallback={<CategorySkeleton />}>\n <CategoryContent params={params} />\n </Suspense>\n );\n}\n\nasync function CategoryContent({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}) {\n const { slug } = await params;\n const result = await getCategory(slug);\n if (!result.ok) {\n if (result.code === "NOT_FOUND") notFound();\n return <CategorySkeleton />;\n }\n const { category, products } = result.data;\n return (\n <section className="max-w-7xl mx-auto px-6 sm:px-8 pt-12">\n <header className="mb-8 text-center">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-2">\n Category\n </p>\n <h1 className="font-serif text-[clamp(2rem,4vw,2.75rem)] font-semibold mb-2">\n {category.name}\n </h1>\n {category.description && (\n <p className="mx-auto mb-2 max-w-xl text-muted-foreground">\n {category.description}\n </p>\n )}\n <p className="text-sm text-muted-foreground">\n {products.length} item{products.length === 1 ? "" : "s"}\n </p>\n </header>\n <ListingClient products={products} />\n {products.length === 0 && (\n <p className="text-center mt-8">\n <Link href="/" className="text-primary font-semibold">\n \u2190 Back home\n </Link>\n </p>\n )}\n </section>\n );\n}\n\nfunction CategorySkeleton() {\n return (\n <section className="max-w-7xl mx-auto px-6 sm:px-8 pt-12">\n <header className="mb-8 text-center">\n <div className="mx-auto h-3 w-20 bg-muted rounded mb-2 animate-pulse" />\n <div className="mx-auto h-10 w-64 bg-muted rounded mb-2 animate-pulse" />\n <div className="mx-auto h-4 w-80 bg-muted rounded animate-pulse" />\n </header>\n <div className="grid grid-cols-2 md:grid-cols-3 gap-4">\n {Array.from({ length: 6 }).map((_, i) => (\n <div key={i} className="aspect-square bg-muted rounded-2xl animate-pulse" />\n ))}\n </div>\n </section>\n );\n}\n' }, { "path": "app/terms/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Terms of Service \u2014 ${brand.name}`,\n description: `The rules of the road for ordering from ${brand.name}.`,\n};\n\nexport default function TermsPage() {\n const t = brand.terms;\n return (\n <article className="max-w-3xl mx-auto px-6 sm:px-8 py-16 prose prose-lg max-w-none">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-2 not-prose">\n {t.eyebrow}\n </p>\n <h1 className="font-serif text-[clamp(2.25rem,5vw,3.5rem)] font-semibold mb-2 -tracking-[0.02em]">\n {t.title}\n </h1>\n <p className="text-sm text-muted-foreground not-prose mb-10">\n Last updated: {t.lastUpdated}\n </p>\n\n <section className="space-y-5 leading-relaxed text-foreground/90">\n {t.sections.map((s) => (\n <div key={s.heading}>\n <h2 className="font-serif text-2xl font-semibold mt-0">{s.heading}</h2>\n {typeof s.body === "string" ? (\n <p>{s.body}</p>\n ) : (\n <>\n <p>{s.body.intro}</p>\n <ul className="list-disc pl-6 space-y-2">\n {s.body.bullets.map((b) => (\n <li key={b}>{b}</li>\n ))}\n </ul>\n </>\n )}\n </div>\n ))}\n </section>\n </article>\n );\n}\n' }, { "path": "app/not-found.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport Link from "next/link";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Page not found \u2014 ${brand.name}`,\n description: "We couldn\'t find that page.",\n};\n\nexport default function NotFound() {\n return (\n <section className="max-w-2xl mx-auto px-6 sm:px-8 py-20 text-center">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-3">\n 404\n </p>\n <h1 className="font-serif text-[clamp(2.5rem,5vw,3.5rem)] font-semibold mb-4">\n Page not found.\n </h1>\n <p className="text-muted-foreground leading-relaxed mb-8">\n The URL you followed might be old, a typo, or pointing at a product\n that&apos;s sold out for the day. Try the menu or head back home.\n </p>\n <div className="flex flex-wrap items-center justify-center gap-3">\n <Link\n href="/shop"\n className="inline-block px-6 py-3 rounded-full bg-primary text-primary-foreground font-semibold text-sm transition-colors hover:bg-primary/90"\n >\n Browse the menu\n </Link>\n <Link\n href="/"\n className="inline-block px-6 py-3 rounded-full border border-border hover:bg-muted transition-colors text-sm font-medium"\n >\n Back home\n </Link>\n </div>\n </section>\n );\n}\n' }, { "path": "app/cart/page.tsx", "kind": "text", "content": '"use client";\n\nimport { useRouter } from "next/navigation";\nimport { CartPage as SdkCartPage } from "@cimplify/sdk/react";\n\nexport default function CartPage() {\n const router = useRouter();\n return <SdkCartPage onCheckout={() => router.push("/checkout")} />;\n}\n' }, { "path": "app/accessibility/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\nimport { PolicyPage } from "@/components/policy-page";\n\nexport const metadata: Metadata = {\n title: `${brand.accessibility.title} \u2014 ${brand.name}`,\n};\n\nexport default function AccessibilityPage() {\n return <PolicyPage policy={brand.accessibility} />;\n}\n' }, { "path": "app/account/orders/[id]/page.tsx", "kind": "text", "content": 'import { notFound } from "next/navigation";\nimport { AccountSignedOutPrompt, toAccountOrderView } from "@cimplify/sdk/react";\nimport type { EnrichedOrder } from "@cimplify/sdk";\nimport { brand } from "@/lib/brand";\nimport { apiFetch, serverAccessToken, serverSession } from "@/lib/cimplify-server";\nimport { OrderDetail } from "@/components/order-detail";\n\nconst CLIENT_ID = process.env.NEXT_PUBLIC_CIMPLIFY_CLIENT_ID ?? "";\nconst REDIRECT_URI = process.env.CIMPLIFY_REDIRECT_URI ?? "";\n\nexport default async function OrderDetailPage({\n params,\n}: {\n params: Promise<{ id: string }>;\n}) {\n const { id } = await params;\n\n const session = await serverSession();\n if (!session) {\n return (\n <AccountSignedOutPrompt\n clientId={CLIENT_ID}\n redirectUri={REDIRECT_URI}\n eyebrow={brand.account.loginEyebrow}\n title={brand.account.loginTitle}\n description={brand.account.loginSubtitle}\n />\n );\n }\n\n const bearer = await serverAccessToken();\n const order = await apiFetch<EnrichedOrder>(`/api/v1/orders/${encodeURIComponent(id)}`, {\n bearer,\n });\n if (!order) notFound();\n\n return (\n <div className="mx-auto w-full max-w-5xl px-4 py-8 sm:px-6">\n <OrderDetail {...toAccountOrderView(order)} />\n </div>\n );\n}\n' }, { "path": "app/account/orders/page.tsx", "kind": "text", "content": 'import {\n AccountOrdersPage,\n AccountSignedOutPrompt,\n} from "@cimplify/sdk/react";\nimport type { OrderRowData, OrderRowStatus } from "@cimplify/sdk/react";\nimport type { Order, OrderStatus } from "@cimplify/sdk";\nimport { formatMoney } from "@cimplify/sdk";\nimport { brand } from "@/lib/brand";\nimport { ACCOUNT_NAV, apiFetch, serverAccessToken, serverSession } from "@/lib/cimplify-server";\n\nconst CLIENT_ID = process.env.NEXT_PUBLIC_CIMPLIFY_CLIENT_ID ?? "";\nconst REDIRECT_URI = process.env.CIMPLIFY_REDIRECT_URI ?? "";\n\nfunction mapStatus(status: OrderStatus): OrderRowStatus {\n switch (status) {\n case "in_preparation":\n case "ready_to_serve":\n case "partially_served":\n return { kind: "preparing", label: "Preparing" };\n case "delivered":\n return { kind: "delivered" };\n case "picked_up":\n return { kind: "delivered", label: "Picked up" };\n case "served":\n case "completed":\n return { kind: "completed" };\n case "cancelled":\n case "refunded":\n return { kind: "cancelled", label: status === "refunded" ? "Refunded" : undefined };\n case "pending":\n case "created":\n case "confirmed":\n default:\n return { kind: "active", label: "Confirmed" };\n }\n}\n\nfunction relativeMeta(iso: string, orderNumber: string): string {\n const when = new Date(iso);\n const formatted = when.toLocaleDateString(undefined, {\n month: "short",\n day: "numeric",\n year: when.getFullYear() === new Date().getFullYear() ? undefined : "numeric",\n });\n return `Order ${orderNumber} \xB7 ${formatted}`;\n}\n\nfunction toRowData(order: Order): OrderRowData {\n const count = order.items?.length ?? 0;\n return {\n id: order.id,\n href: `/account/orders/${order.id}`,\n summary: count > 0 ? `${count} ${count === 1 ? "item" : "items"}` : "Order",\n meta: relativeMeta(order.created_at, order.user_friendly_id ?? order.id),\n status: mapStatus(order.status),\n total: formatMoney(order.total_price, order.currency),\n };\n}\n\nexport default async function OrdersPage() {\n const session = await serverSession();\n if (!session) {\n return (\n <AccountSignedOutPrompt\n clientId={CLIENT_ID}\n redirectUri={REDIRECT_URI}\n eyebrow={brand.account.loginEyebrow}\n title={brand.account.loginTitle}\n description={brand.account.loginSubtitle}\n />\n );\n }\n\n const bearer = await serverAccessToken();\n const orders = await apiFetch<Order[]>(\n `/v1/link/orders?business_id=${encodeURIComponent(brand.mock.businessId)}`,\n { bearer },\n );\n\n return (\n <AccountOrdersPage\n customerName={session.name ?? ""}\n customerEmail={session.email}\n customerId={session.sub}\n merchantName={brand.name}\n nav={ACCOUNT_NAV}\n orders={(orders ?? []).map(toRowData)}\n />\n );\n}\n' }, { "path": "app/account/page.tsx", "kind": "text", "content": 'import {\n AccountDashboardPage,\n AccountSignedOutPrompt,\n} from "@cimplify/sdk/react";\nimport type { CustomerAddress, CustomerMobileMoney } from "@cimplify/sdk";\nimport { brand } from "@/lib/brand";\nimport { ACCOUNT_NAV, apiFetch, serverAccessToken, serverSession } from "@/lib/cimplify-server";\n\nconst CLIENT_ID = process.env.NEXT_PUBLIC_CIMPLIFY_CLIENT_ID ?? "";\nconst REDIRECT_URI = process.env.CIMPLIFY_REDIRECT_URI ?? "";\n\nexport default async function AccountPage() {\n const session = await serverSession();\n if (!session) {\n return (\n <AccountSignedOutPrompt\n clientId={CLIENT_ID}\n redirectUri={REDIRECT_URI}\n eyebrow={brand.account.loginEyebrow}\n title={brand.account.loginTitle}\n description={brand.account.loginSubtitle}\n />\n );\n }\n\n const bearer = await serverAccessToken();\n const [addresses, wallets] = await Promise.all([\n apiFetch<CustomerAddress[]>("/v1/link/addresses", { bearer }),\n apiFetch<CustomerMobileMoney[]>("/v1/link/mobile-money", { bearer }),\n ]);\n\n return (\n <AccountDashboardPage\n customerName={session.name ?? ""}\n customerEmail={session.email}\n customerId={session.sub}\n merchantName={brand.name}\n nav={ACCOUNT_NAV}\n identitySummary={{\n addresses: addresses?.length ?? 0,\n paymentMethods: wallets?.length ?? 0,\n }}\n />\n );\n}\n' }, { "path": "app/.well-known/ucp/route.ts", "kind": "text", "content": 'import { NextResponse } from "next/server";\n\n/**\n * UCP (Universal Commerce Protocol) manifest discovery endpoint.\n *\n * Agents \u2014 Claude, ChatGPT, Gemini, MCP clients \u2014 probe\n * `https://<your-domain>/.well-known/ucp` to learn what commerce\n * capabilities your storefront supports. We resolve the business\'s\n * UCP handle from the public key (via the storefront API), then forward\n * to Cimplify for the canonical manifest. Edge-cached for an hour\n * because capabilities change rarely.\n */\nconst UCP_API_BASE = "https://api.cimplify.io";\nconst STOREFRONT_API_BASE =\n process.env.NODE_ENV === "production"\n ? "https://storefronts.cimplify.io"\n : "http://127.0.0.1:8787";\n\nexport async function GET() {\n const publicKey = process.env.NEXT_PUBLIC_CIMPLIFY_PUBLIC_KEY;\n if (!publicKey) {\n return NextResponse.json(\n {\n error: "NEXT_PUBLIC_CIMPLIFY_PUBLIC_KEY not set",\n remediation:\n "Set NEXT_PUBLIC_CIMPLIFY_PUBLIC_KEY in .env.local (and your deployment env).",\n },\n { status: 500 },\n );\n }\n\n try {\n // Step 1 \u2014 resolve the business handle from the public key.\n const businessResp = await fetch(`${STOREFRONT_API_BASE}/api/v1/business`, {\n headers: { "X-API-Key": publicKey, "Content-Type": "application/json" },\n next: { revalidate: 3600 },\n });\n\n if (!businessResp.ok) {\n return NextResponse.json(\n { error: `Failed to resolve business: ${businessResp.status}` },\n { status: businessResp.status },\n );\n }\n\n const businessJson = await businessResp.json();\n const handle: string | undefined = businessJson?.data?.handle;\n if (!handle) {\n return NextResponse.json(\n { error: "Business has no handle configured" },\n { status: 500 },\n );\n }\n\n // Step 2 \u2014 fetch the canonical UCP manifest.\n const manifestResp = await fetch(\n `${UCP_API_BASE}/ucp/v1/${handle}/manifest`,\n {\n headers: { "Content-Type": "application/json" },\n next: { revalidate: 3600 },\n },\n );\n\n if (!manifestResp.ok) {\n return NextResponse.json(\n { error: `Upstream UCP manifest fetch failed: ${manifestResp.status}` },\n { status: manifestResp.status },\n );\n }\n\n const manifest = await manifestResp.json();\n return NextResponse.json(manifest, {\n headers: {\n "Content-Type": "application/json",\n "Cache-Control": "public, max-age=3600, s-maxage=3600",\n },\n });\n } catch (error) {\n return NextResponse.json(\n {\n error: "Failed to fetch UCP manifest",\n detail: error instanceof Error ? error.message : String(error),\n },\n { status: 500 },\n );\n }\n}\n' }, { "path": "app/returns/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\nimport { PolicyPage } from "@/components/policy-page";\n\nexport const metadata: Metadata = {\n title: `${brand.returns.title} \u2014 ${brand.name}`,\n};\n\nexport default function ReturnsPage() {\n return <PolicyPage policy={brand.returns} />;\n}\n' }, { "path": "app/orders/[id]/page.tsx", "kind": "text", "content": 'import Link from "next/link";\n\nexport default async function OrderPage({ params }: { params: Promise<{ id: string }> }) {\n const { id } = await params;\n return (\n <section className="max-w-2xl mx-auto px-6 sm:px-8 py-20 text-center">\n <h1 className="font-serif text-3xl mt-0 mb-3">Thanks \u2014 your order is confirmed</h1>\n <p className="text-muted-foreground">\n Order <code className="font-mono text-foreground">{id}</code>\n </p>\n <p className="mt-6">\n <Link\n href="/"\n className="inline-block px-6 py-3 rounded-full bg-primary text-primary-foreground font-semibold text-sm transition-colors hover:bg-primary/90"\n >\n Continue shopping\n </Link>\n </p>\n </section>\n );\n}\n' }, { "path": "app/globals.css", "kind": "text", "content": `@import "tailwindcss";
5605
5605
 
5606
5606
  @source "../node_modules/@cimplify/sdk/dist";
5607
5607
 
@@ -6242,7 +6242,7 @@ export const brand: Brand = {
6242
6242
  businessId: "bus_serene_spa",
6243
6243
  },
6244
6244
  };
6245
- ` }, { "path": "lib/cimplify-server.ts", "kind": "text", "content": 'import { cookies } from "next/headers";\nimport {\n getAccessTokenFromCookieHeader,\n getSessionFromCookieHeader,\n} from "@cimplify/sdk/server";\nimport type { NavItem } from "@cimplify/sdk/react";\n\nexport const ACCOUNT_NAV: NavItem[] = [\n { label: "Dashboard", href: "/account" },\n { label: "Orders", href: "/account/orders" },\n { label: "Cimplify identity", href: "https://link.cimplify.io", external: true },\n];\n\nconst CLIENT_ID = process.env.CIMPLIFY_CLIENT_ID ?? "";\nconst ISSUER = (process.env.CIMPLIFY_ISSUER ?? "https://api.cimplify.io").replace(/\\/$/, "");\n\nconst OIDC_CONFIG = { clientId: CLIENT_ID, issuer: ISSUER };\n\nasync function cookieHeader(): Promise<string> {\n const store = await cookies();\n return store.getAll().map((c) => `${c.name}=${c.value}`).join("; ");\n}\n\nexport async function serverSession() {\n return getSessionFromCookieHeader(OIDC_CONFIG, await cookieHeader());\n}\n\nexport async function serverAccessToken(): Promise<string | null> {\n return getAccessTokenFromCookieHeader(OIDC_CONFIG, await cookieHeader());\n}\n\ninterface Envelope<T> {\n data: T;\n}\n\nexport async function apiFetch<T>(\n path: string,\n init: RequestInit & { bearer: string | null },\n): Promise<T | null> {\n if (!init.bearer) return null;\n const { bearer, ...rest } = init;\n const headers = new Headers(rest.headers);\n headers.set("Authorization", `Bearer ${bearer}`);\n headers.set("Accept", "application/json");\n if (rest.body && !headers.has("Content-Type")) {\n headers.set("Content-Type", "application/json");\n }\n const res = await fetch(`${ISSUER}${path}`, { ...rest, headers, cache: "no-store" });\n if (!res.ok) return null;\n const envelope = (await res.json()) as Envelope<T>;\n return envelope.data;\n}\n' }, { "path": "lib/cimplify-loader.ts", "kind": "text", "content": 'import type { ImageLoader } from "next/image";\n\nimport { assetUrl, isCimplifyAsset } from "@cimplify/sdk";\n\nconst cdnBase = process.env.NEXT_PUBLIC_CIMPLIFY_CDN_URL?.trim() || undefined;\n\nconst cimplifyImageLoader: ImageLoader = ({ src, width, quality }) => {\n if (isCimplifyAsset(src, cdnBase)) {\n return assetUrl(src, {\n base: cdnBase,\n w: width,\n quality,\n format: "auto",\n });\n }\n return src;\n};\n\nexport default cimplifyImageLoader;\n' }, { "path": "lib/site-url.ts", "kind": "text", "content": 'import { SITE_URL } from "./site.config";\n\n/** Canonical absolute URL for this storefront. */\nexport async function getSiteUrl(): Promise<string> {\n return SITE_URL;\n}\n' }, { "path": ".env.example", "kind": "text", "content": "# Your tenant public key. Get it from the desk's Developers tab.\n# `cpk_live_*` / `cpk_test_*` keys auto-route same-origin requests\n# to hosted Cimplify in both dev and prod. Anything else (`mock-dev`,\n# empty) falls back to the local `cimplify dev` mock.\nNEXT_PUBLIC_CIMPLIFY_PUBLIC_KEY=mock-dev\n\n# Optional. Forces a fixed canonical URL for sitemap.xml, robots.txt,\n# OpenGraph, and llms.txt. Leave unset and the storefront derives the\n# canonical from the request `Host` header automatically.\n# NEXT_PUBLIC_SITE_URL=\n" }, { "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__/brand.test.ts", "kind": "text", "content": 'import { createBrandSuite } from "@cimplify/sdk/testing/suite";\nimport { brand } from "../lib/brand";\n\ncreateBrandSuite({ brand });\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.100.4",\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": "metadata.json", "kind": "text", "content": '{\n "id": "services",\n "name": "Services",\n "tagline": "Bookable-services storefront with calendar slots and staff profiles.",\n "industry": "services",\n "tags": ["services", "bookings", "appointments"],\n "stability": "stable",\n "schemaType": "BeautySalon",\n "mock": {\n "seedName": "services",\n "seedBusinessId": "bus_serene_spa"\n }\n}\n' }, { "path": "app/search/search-client.tsx", "kind": "text", "content": '"use client";\n\nimport { SearchPage } from "@cimplify/sdk/react";\n\nexport function SearchClient() {\n return <SearchPage />;\n}\n' }, { "path": "app/search/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Suspense } from "react";\nimport { SearchClient } from "./search-client";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Search \u2014 ${brand.name}`,\n description: `Search ${brand.name} \u2014 products, collections, categories.`,\n};\n\nexport default function SearchPage() {\n return (\n <article className="max-w-7xl mx-auto px-6 sm:px-8 py-10">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-2">\n Search\n </p>\n <h1 className="font-serif text-[clamp(2rem,4vw,2.75rem)] font-semibold mb-8 -tracking-[0.02em]">\n Find anything.\n </h1>\n <Suspense fallback={<SearchSkeleton />}>\n <SearchClient />\n </Suspense>\n </article>\n );\n}\n\nfunction SearchSkeleton() {\n return (\n <div>\n <div className="h-12 w-full max-w-xl bg-muted rounded animate-pulse mb-8" />\n <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3 sm:gap-4">\n {Array.from({ length: 8 }).map((_, i) => (\n <div key={i} className="aspect-[4/3] bg-muted rounded-2xl animate-pulse" />\n ))}\n </div>\n </div>\n );\n}\n' }, { "path": "app/categories/[slug]/listing-client.tsx", "kind": "text", "content": '"use client";\n\nimport { ProductGrid } from "@cimplify/sdk/react";\nimport type { Product } from "@cimplify/sdk";\nimport { StoreProductCard } from "@/components/store-product-card";\n\n/**\n * Client island for the category listing. Receives server-fetched products\n * as props (serializable) and owns the `renderCard` function.\n */\nexport function ListingClient({ products }: { products: Product[] }) {\n return (\n <ProductGrid\n products={products}\n emptyMessage="No products in this category yet."\n renderCard={(p) => <StoreProductCard product={p} />}\n />\n );\n}\n' }, { "path": "app/categories/[slug]/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Suspense } from "react";\nimport Link from "next/link";\nimport { notFound } from "next/navigation";\nimport {\n getServerClient,\n tags,\n type Category,\n type Product,\n} from "@cimplify/sdk/server";\nimport { ListingClient } from "./listing-client";\nimport { brand } from "@/lib/brand";\n\n// See app/products/[slug]/page.tsx for the rationale on generateStaticParams.\nexport async function generateStaticParams() {\n const r = await getServerClient().catalogue.getCategories();\n if (!r.ok || r.value.length === 0) {\n return [{ slug: "__placeholder__" }];\n }\n return r.value.map((c) => ({ slug: c.slug ?? c.id }));\n}\n\nexport const revalidate = 3600;\n\ninterface CategoryData {\n category: Category;\n products: Product[];\n}\n\ntype CategoryResult =\n | { ok: true; data: CategoryData }\n | { ok: false; code: string };\n\nasync function getCategory(slug: string): Promise<CategoryResult> {\n const client = getServerClient();\n const catRes = await client.catalogue.getCategoryBySlug(slug, {\n cacheOptions: { revalidate: 3600, tags: [tags.categories()] },\n });\n if (!catRes.ok) return { ok: false, code: catRes.error.code };\n\n const r = await client.catalogue.getCategoryProducts(catRes.value.id, undefined, {\n cacheOptions: {\n revalidate: 3600,\n tags: [\n tags.products(),\n tags.category(catRes.value.id),\n tags.categoryProducts(catRes.value.id),\n ],\n },\n });\n const products = r.ok\n ? ((r.value as { items?: Product[] }).items ?? (r.value as Product[]))\n : [];\n return { ok: true, data: { category: catRes.value, products } };\n}\n\nexport async function generateMetadata({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}): Promise<Metadata> {\n const { slug } = await params;\n const result = await getCategory(slug);\n if (!result.ok) return {};\n const data = result.data;\n return {\n title: `${data.category.name} \u2014 ${brand.name}`,\n description: data.category.description ?? undefined,\n };\n}\n\nexport default async function CategoryPage({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}) {\n return (\n <Suspense fallback={<CategorySkeleton />}>\n <CategoryContent params={params} />\n </Suspense>\n );\n}\n\nasync function CategoryContent({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}) {\n const { slug } = await params;\n const result = await getCategory(slug);\n if (!result.ok) {\n if (result.code === "NOT_FOUND") notFound();\n return <CategorySkeleton />;\n }\n const { category, products } = result.data;\n return (\n <section className="max-w-7xl mx-auto px-6 sm:px-8 pt-12">\n <header className="mb-8 text-center">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-2">\n Category\n </p>\n <h1 className="font-serif text-[clamp(2rem,4vw,2.75rem)] font-semibold mb-2">\n {category.name}\n </h1>\n {category.description && (\n <p className="mx-auto mb-2 max-w-xl text-muted-foreground">\n {category.description}\n </p>\n )}\n <p className="text-sm text-muted-foreground">\n {products.length} item{products.length === 1 ? "" : "s"}\n </p>\n </header>\n <ListingClient products={products} />\n {products.length === 0 && (\n <p className="text-center mt-8">\n <Link href="/" className="text-primary font-semibold">\n \u2190 Back home\n </Link>\n </p>\n )}\n </section>\n );\n}\n\nfunction CategorySkeleton() {\n return (\n <section className="max-w-7xl mx-auto px-6 sm:px-8 pt-12">\n <header className="mb-8 text-center">\n <div className="mx-auto h-3 w-20 bg-muted rounded mb-2 animate-pulse" />\n <div className="mx-auto h-10 w-64 bg-muted rounded mb-2 animate-pulse" />\n <div className="mx-auto h-4 w-80 bg-muted rounded animate-pulse" />\n </header>\n <div className="grid grid-cols-2 md:grid-cols-3 gap-4">\n {Array.from({ length: 6 }).map((_, i) => (\n <div key={i} className="aspect-square bg-muted rounded-2xl animate-pulse" />\n ))}\n </div>\n </section>\n );\n}\n' }, { "path": "app/terms/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Terms of Service \u2014 ${brand.name}`,\n description: `The rules of the road for ordering from ${brand.name}.`,\n};\n\nexport default function TermsPage() {\n const t = brand.terms;\n return (\n <article className="max-w-3xl mx-auto px-6 sm:px-8 py-16 prose prose-lg max-w-none">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-2 not-prose">\n {t.eyebrow}\n </p>\n <h1 className="font-serif text-[clamp(2.25rem,5vw,3.5rem)] font-semibold mb-2 -tracking-[0.02em]">\n {t.title}\n </h1>\n <p className="text-sm text-muted-foreground not-prose mb-10">\n Last updated: {t.lastUpdated}\n </p>\n\n <section className="space-y-5 leading-relaxed text-foreground/90">\n {t.sections.map((s) => (\n <div key={s.heading}>\n <h2 className="font-serif text-2xl font-semibold mt-0">{s.heading}</h2>\n {typeof s.body === "string" ? (\n <p>{s.body}</p>\n ) : (\n <>\n <p>{s.body.intro}</p>\n <ul className="list-disc pl-6 space-y-2">\n {s.body.bullets.map((b) => (\n <li key={b}>{b}</li>\n ))}\n </ul>\n </>\n )}\n </div>\n ))}\n </section>\n </article>\n );\n}\n' }, { "path": "app/not-found.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport Link from "next/link";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Page not found \u2014 ${brand.name}`,\n description: "We couldn\'t find that page.",\n};\n\nexport default function NotFound() {\n return (\n <section className="max-w-2xl mx-auto px-6 sm:px-8 py-20 text-center">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-3">\n 404\n </p>\n <h1 className="font-serif text-[clamp(2.5rem,5vw,3.5rem)] font-semibold mb-4">\n Page not found.\n </h1>\n <p className="text-muted-foreground leading-relaxed mb-8">\n The URL you followed might be old, a typo, or pointing at a product\n that&apos;s sold out for the day. Try the menu or head back home.\n </p>\n <div className="flex flex-wrap items-center justify-center gap-3">\n <Link\n href="/shop"\n className="inline-block px-6 py-3 rounded-full bg-primary text-primary-foreground font-semibold text-sm transition-colors hover:bg-primary/90"\n >\n Browse the menu\n </Link>\n <Link\n href="/"\n className="inline-block px-6 py-3 rounded-full border border-border hover:bg-muted transition-colors text-sm font-medium"\n >\n Back home\n </Link>\n </div>\n </section>\n );\n}\n' }, { "path": "app/cart/page.tsx", "kind": "text", "content": '"use client";\n\nimport { useRouter } from "next/navigation";\nimport { CartPage as SdkCartPage } from "@cimplify/sdk/react";\n\nexport default function CartPage() {\n const router = useRouter();\n return <SdkCartPage onCheckout={() => router.push("/checkout")} />;\n}\n' }, { "path": "app/accessibility/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\nimport { PolicyPage } from "@/components/policy-page";\n\nexport const metadata: Metadata = {\n title: `${brand.accessibility.title} \u2014 ${brand.name}`,\n};\n\nexport default function AccessibilityPage() {\n return <PolicyPage policy={brand.accessibility} />;\n}\n' }, { "path": "app/account/orders/[id]/page.tsx", "kind": "text", "content": 'import { notFound } from "next/navigation";\nimport { AccountSignedOutPrompt, toAccountOrderView } from "@cimplify/sdk/react";\nimport type { EnrichedOrder } from "@cimplify/sdk";\nimport { brand } from "@/lib/brand";\nimport { apiFetch, serverAccessToken, serverSession } from "@/lib/cimplify-server";\nimport { OrderDetail } from "@/components/order-detail";\n\nconst CLIENT_ID = process.env.NEXT_PUBLIC_CIMPLIFY_CLIENT_ID ?? "";\nconst REDIRECT_URI = process.env.CIMPLIFY_REDIRECT_URI ?? "";\n\nexport default async function OrderDetailPage({\n params,\n}: {\n params: Promise<{ id: string }>;\n}) {\n const { id } = await params;\n\n const session = await serverSession();\n if (!session) {\n return (\n <AccountSignedOutPrompt\n clientId={CLIENT_ID}\n redirectUri={REDIRECT_URI}\n eyebrow={brand.account.loginEyebrow}\n title={brand.account.loginTitle}\n description={brand.account.loginSubtitle}\n />\n );\n }\n\n const bearer = await serverAccessToken();\n const order = await apiFetch<EnrichedOrder>(`/api/v1/orders/${encodeURIComponent(id)}`, {\n bearer,\n });\n if (!order) notFound();\n\n return (\n <div className="mx-auto w-full max-w-5xl px-4 py-8 sm:px-6">\n <OrderDetail {...toAccountOrderView(order)} />\n </div>\n );\n}\n' }, { "path": "app/account/orders/page.tsx", "kind": "text", "content": 'import {\n AccountOrdersPage,\n AccountSignedOutPrompt,\n} from "@cimplify/sdk/react";\nimport type { OrderRowData, OrderRowStatus } from "@cimplify/sdk/react";\nimport type { Order, OrderStatus } from "@cimplify/sdk";\nimport { formatMoney } from "@cimplify/sdk";\nimport { brand } from "@/lib/brand";\nimport { ACCOUNT_NAV, apiFetch, serverAccessToken, serverSession } from "@/lib/cimplify-server";\n\nconst CLIENT_ID = process.env.NEXT_PUBLIC_CIMPLIFY_CLIENT_ID ?? "";\nconst REDIRECT_URI = process.env.CIMPLIFY_REDIRECT_URI ?? "";\n\nfunction mapStatus(status: OrderStatus): OrderRowStatus {\n switch (status) {\n case "in_preparation":\n case "ready_to_serve":\n case "partially_served":\n return { kind: "preparing", label: "Preparing" };\n case "delivered":\n return { kind: "delivered" };\n case "picked_up":\n return { kind: "delivered", label: "Picked up" };\n case "served":\n case "completed":\n return { kind: "completed" };\n case "cancelled":\n case "refunded":\n return { kind: "cancelled", label: status === "refunded" ? "Refunded" : undefined };\n case "pending":\n case "created":\n case "confirmed":\n default:\n return { kind: "active", label: "Confirmed" };\n }\n}\n\nfunction relativeMeta(iso: string, orderNumber: string): string {\n const when = new Date(iso);\n const formatted = when.toLocaleDateString(undefined, {\n month: "short",\n day: "numeric",\n year: when.getFullYear() === new Date().getFullYear() ? undefined : "numeric",\n });\n return `Order ${orderNumber} \xB7 ${formatted}`;\n}\n\nfunction toRowData(order: Order): OrderRowData {\n const count = order.items?.length ?? 0;\n return {\n id: order.id,\n href: `/account/orders/${order.id}`,\n summary: count > 0 ? `${count} ${count === 1 ? "item" : "items"}` : "Order",\n meta: relativeMeta(order.created_at, order.user_friendly_id ?? order.id),\n status: mapStatus(order.status),\n total: formatMoney(order.total_price, order.currency),\n };\n}\n\nexport default async function OrdersPage() {\n const session = await serverSession();\n if (!session) {\n return (\n <AccountSignedOutPrompt\n clientId={CLIENT_ID}\n redirectUri={REDIRECT_URI}\n eyebrow={brand.account.loginEyebrow}\n title={brand.account.loginTitle}\n description={brand.account.loginSubtitle}\n />\n );\n }\n\n const bearer = await serverAccessToken();\n const orders = await apiFetch<Order[]>(\n `/v1/link/orders?business_id=${encodeURIComponent(brand.mock.businessId)}`,\n { bearer },\n );\n\n return (\n <AccountOrdersPage\n customerName={session.name ?? ""}\n customerEmail={session.email}\n customerId={session.sub}\n merchantName={brand.name}\n nav={ACCOUNT_NAV}\n orders={(orders ?? []).map(toRowData)}\n />\n );\n}\n' }, { "path": "app/account/page.tsx", "kind": "text", "content": 'import {\n AccountDashboardPage,\n AccountSignedOutPrompt,\n} from "@cimplify/sdk/react";\nimport type { CustomerAddress, CustomerMobileMoney } from "@cimplify/sdk";\nimport { brand } from "@/lib/brand";\nimport { ACCOUNT_NAV, apiFetch, serverAccessToken, serverSession } from "@/lib/cimplify-server";\n\nconst CLIENT_ID = process.env.NEXT_PUBLIC_CIMPLIFY_CLIENT_ID ?? "";\nconst REDIRECT_URI = process.env.CIMPLIFY_REDIRECT_URI ?? "";\n\nexport default async function AccountPage() {\n const session = await serverSession();\n if (!session) {\n return (\n <AccountSignedOutPrompt\n clientId={CLIENT_ID}\n redirectUri={REDIRECT_URI}\n eyebrow={brand.account.loginEyebrow}\n title={brand.account.loginTitle}\n description={brand.account.loginSubtitle}\n />\n );\n }\n\n const bearer = await serverAccessToken();\n const [addresses, wallets] = await Promise.all([\n apiFetch<CustomerAddress[]>("/v1/link/addresses", { bearer }),\n apiFetch<CustomerMobileMoney[]>("/v1/link/mobile-money", { bearer }),\n ]);\n\n return (\n <AccountDashboardPage\n customerName={session.name ?? ""}\n customerEmail={session.email}\n customerId={session.sub}\n merchantName={brand.name}\n nav={ACCOUNT_NAV}\n identitySummary={{\n addresses: addresses?.length ?? 0,\n paymentMethods: wallets?.length ?? 0,\n }}\n />\n );\n}\n' }, { "path": "app/.well-known/ucp/route.ts", "kind": "text", "content": 'import { NextResponse } from "next/server";\n\n/**\n * UCP (Universal Commerce Protocol) manifest discovery endpoint.\n *\n * Agents \u2014 Claude, ChatGPT, Gemini, MCP clients \u2014 probe\n * `https://<your-domain>/.well-known/ucp` to learn what commerce\n * capabilities your storefront supports. We resolve the business\'s\n * UCP handle from the public key (via the storefront API), then forward\n * to Cimplify for the canonical manifest. Edge-cached for an hour\n * because capabilities change rarely.\n */\nconst UCP_API_BASE = "https://api.cimplify.io";\nconst STOREFRONT_API_BASE =\n process.env.NODE_ENV === "production"\n ? "https://storefronts.cimplify.io"\n : "http://127.0.0.1:8787";\n\nexport async function GET() {\n const publicKey = process.env.NEXT_PUBLIC_CIMPLIFY_PUBLIC_KEY;\n if (!publicKey) {\n return NextResponse.json(\n {\n error: "NEXT_PUBLIC_CIMPLIFY_PUBLIC_KEY not set",\n remediation:\n "Set NEXT_PUBLIC_CIMPLIFY_PUBLIC_KEY in .env.local (and your deployment env).",\n },\n { status: 500 },\n );\n }\n\n try {\n // Step 1 \u2014 resolve the business handle from the public key.\n const businessResp = await fetch(`${STOREFRONT_API_BASE}/api/v1/business`, {\n headers: { "X-API-Key": publicKey, "Content-Type": "application/json" },\n next: { revalidate: 3600 },\n });\n\n if (!businessResp.ok) {\n return NextResponse.json(\n { error: `Failed to resolve business: ${businessResp.status}` },\n { status: businessResp.status },\n );\n }\n\n const businessJson = await businessResp.json();\n const handle: string | undefined = businessJson?.data?.handle;\n if (!handle) {\n return NextResponse.json(\n { error: "Business has no handle configured" },\n { status: 500 },\n );\n }\n\n // Step 2 \u2014 fetch the canonical UCP manifest.\n const manifestResp = await fetch(\n `${UCP_API_BASE}/ucp/v1/${handle}/manifest`,\n {\n headers: { "Content-Type": "application/json" },\n next: { revalidate: 3600 },\n },\n );\n\n if (!manifestResp.ok) {\n return NextResponse.json(\n { error: `Upstream UCP manifest fetch failed: ${manifestResp.status}` },\n { status: manifestResp.status },\n );\n }\n\n const manifest = await manifestResp.json();\n return NextResponse.json(manifest, {\n headers: {\n "Content-Type": "application/json",\n "Cache-Control": "public, max-age=3600, s-maxage=3600",\n },\n });\n } catch (error) {\n return NextResponse.json(\n {\n error: "Failed to fetch UCP manifest",\n detail: error instanceof Error ? error.message : String(error),\n },\n { status: 500 },\n );\n }\n}\n' }, { "path": "app/returns/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\nimport { PolicyPage } from "@/components/policy-page";\n\nexport const metadata: Metadata = {\n title: `${brand.returns.title} \u2014 ${brand.name}`,\n};\n\nexport default function ReturnsPage() {\n return <PolicyPage policy={brand.returns} />;\n}\n' }, { "path": "app/orders/[id]/page.tsx", "kind": "text", "content": 'import Link from "next/link";\n\nexport default async function OrderPage({ params }: { params: Promise<{ id: string }> }) {\n const { id } = await params;\n return (\n <section className="max-w-2xl mx-auto px-6 sm:px-8 py-20 text-center">\n <h1 className="font-serif text-3xl mt-0 mb-3">Thanks \u2014 your order is confirmed</h1>\n <p className="text-muted-foreground">\n Order <code className="font-mono text-foreground">{id}</code>\n </p>\n <p className="mt-6">\n <Link\n href="/"\n className="inline-block px-6 py-3 rounded-full bg-primary text-primary-foreground font-semibold text-sm transition-colors hover:bg-primary/90"\n >\n Continue shopping\n </Link>\n </p>\n </section>\n );\n}\n' }, { "path": "app/globals.css", "kind": "text", "content": '@import "tailwindcss";\n\n@source "../node_modules/@cimplify/sdk/dist";\n\n/* Serene Spa \u2014 calm sand + sage palette, soft contrast.\n Tweak primary to retheme everything. */\n@theme {\n --color-background: oklch(0.98 0.012 90);\n --color-foreground: oklch(0.22 0.02 140);\n --color-card: oklch(1 0 0);\n --color-card-foreground: oklch(0.22 0.02 140);\n --color-popover: oklch(1 0 0);\n --color-popover-foreground: oklch(0.22 0.02 140);\n --color-primary: oklch(0.46 0.06 150);\n --color-primary-foreground: oklch(0.99 0.01 90);\n --color-secondary: oklch(0.93 0.025 90);\n --color-secondary-foreground: oklch(0.3 0.04 140);\n --color-muted: oklch(0.95 0.018 90);\n --color-muted-foreground: oklch(0.5 0.02 140);\n --color-accent: oklch(0.9 0.035 90);\n --color-accent-foreground: oklch(0.3 0.04 140);\n --color-destructive: oklch(0.58 0.24 27);\n --color-destructive-foreground: oklch(0.99 0 0);\n --color-border: oklch(0.91 0.018 90);\n --color-input: oklch(0.93 0.018 90);\n --color-ring: oklch(0.46 0.06 150);\n --radius: 1rem;\n}\n\n@layer base {\n html, body {\n margin: 0;\n padding: 0;\n background: var(--color-background);\n color: var(--color-foreground);\n -webkit-font-smoothing: antialiased;\n -moz-osx-font-smoothing: grayscale;\n }\n a { color: inherit; text-decoration: none; }\n h1, h2, h3, h4 {\n font-family: var(--font-serif);\n letter-spacing: -0.015em;\n font-weight: 500;\n }\n}\n' }, { "path": "app/about/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `About \u2014 ${brand.name}`,\n description: brand.description,\n};\n\nexport default function AboutPage() {\n const a = brand.about;\n const titleParts = a.title.split("\\n");\n return (\n <article className="max-w-3xl 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 {a.eyebrow}\n </p>\n <h1 className="font-serif text-[clamp(2.25rem,5vw,3.5rem)] font-semibold mb-6 -tracking-[0.02em]">\n {titleParts.map((line, i) => (\n <span key={i}>\n {line}\n {i < titleParts.length - 1 && <br />}\n </span>\n ))}\n </h1>\n <div className="prose prose-lg max-w-none space-y-5 text-foreground/90 leading-relaxed">\n {a.paragraphs.map((p, i) => (\n <p key={i}>{p}</p>\n ))}\n {a.sections.map((s) => (\n <div key={s.heading}>\n <h2 className="font-serif text-3xl font-semibold mt-10 mb-3">{s.heading}</h2>\n <p>{s.body}</p>\n </div>\n ))}\n </div>\n </article>\n );\n}\n' }, { "path": "app/auth/signout/route.ts", "kind": "text", "content": 'import { buildSignoutCookies } from "@cimplify/sdk/server";\n\nconst CLIENT_ID = process.env.CIMPLIFY_CLIENT_ID ?? "";\n\nexport async function POST(): Promise<Response> {\n const headers = new Headers({ "Content-Type": "application/json" });\n for (const cookie of buildSignoutCookies({ clientId: CLIENT_ID })) {\n headers.append("Set-Cookie", cookie);\n }\n return new Response(JSON.stringify({ ok: true }), { status: 200, headers });\n}\n' }, { "path": "app/auth/session/route.ts", "kind": "text", "content": 'import { handleSessionRequest } from "@cimplify/sdk/server";\n\nconst CLIENT_ID = process.env.CIMPLIFY_CLIENT_ID ?? "";\nconst ISSUER = process.env.CIMPLIFY_ISSUER;\n\nexport async function GET(req: Request): Promise<Response> {\n if (!CLIENT_ID) {\n return Response.json({ sub: null }, { status: 200 });\n }\n return handleSessionRequest(req, { clientId: CLIENT_ID, issuer: ISSUER });\n}\n' }, { "path": "app/auth/callback/route.ts", "kind": "text", "content": 'import { handleOidcCallback, handleRedirectCallback } from "@cimplify/sdk/server";\n\nconst CLIENT_ID = process.env.CIMPLIFY_CLIENT_ID ?? "";\nconst ISSUER = process.env.CIMPLIFY_ISSUER;\nconst REDIRECT_URI = process.env.CIMPLIFY_REDIRECT_URI ?? "";\n\n// Redirect sign-in: the browser lands here via a top-level GET carrying ?code.\nexport async function GET(req: Request): Promise<Response> {\n if (!CLIENT_ID || !REDIRECT_URI) {\n return Response.json({ error: "oidc_not_configured" }, { status: 500 });\n }\n return handleRedirectCallback(req, {\n clientId: CLIENT_ID,\n issuer: ISSUER,\n redirectUri: REDIRECT_URI,\n defaultReturnTo: "/account",\n });\n}\n\n// Modal/silent sign-in: posts {code, codeVerifier, state} here for exchange.\nexport async function POST(req: Request): Promise<Response> {\n if (!CLIENT_ID || !REDIRECT_URI) {\n return Response.json({ error: "oidc_not_configured" }, { status: 500 });\n }\n return handleOidcCallback(req, {\n clientId: CLIENT_ID,\n issuer: ISSUER,\n redirectUri: REDIRECT_URI,\n });\n}\n' }, { "path": "app/privacy/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Privacy Policy \u2014 ${brand.name}`,\n description: `How ${brand.name} collects, uses, and protects your personal data.`,\n};\n\nexport default function PrivacyPage() {\n const p = brand.privacy;\n return (\n <article className="max-w-3xl mx-auto px-6 sm:px-8 py-16 prose prose-lg max-w-none">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-2 not-prose">\n {p.eyebrow}\n </p>\n <h1 className="font-serif text-[clamp(2.25rem,5vw,3.5rem)] font-semibold mb-2 -tracking-[0.02em]">\n {p.title}\n </h1>\n <p className="text-sm text-muted-foreground not-prose mb-10">\n Last updated: {p.lastUpdated}\n </p>\n\n <section className="space-y-5 leading-relaxed text-foreground/90">\n {p.sections.map((s) => (\n <div key={s.heading}>\n <h2 className="font-serif text-2xl font-semibold mt-0">{s.heading}</h2>\n {typeof s.body === "string" ? (\n <p>{s.body}</p>\n ) : (\n <>\n <p>{s.body.intro}</p>\n <ul className="list-disc pl-6 space-y-2">\n {s.body.bullets.map((b) => (\n <li key={b}>{b}</li>\n ))}\n </ul>\n </>\n )}\n </div>\n ))}\n </section>\n </article>\n );\n}\n' }, { "path": "app/sitemap-page/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport Link from "next/link";\nimport { getServerClient, tags, type Product } from "@cimplify/sdk/server";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Sitemap \u2014 ${brand.name}`,\n description: "A human-readable index of every page on this site.",\n};\n\nexport const revalidate = 3600;\n\ninterface SitemapData {\n products: { slug: string; name: string }[];\n categories: { slug: string; name: string }[];\n collections: { slug: string; name: string }[];\n}\n\nasync function getSitemap(): Promise<SitemapData> {\n const client = getServerClient();\n const [pRes, cRes, colRes] = await Promise.all([\n client.catalogue.getProducts(\n { limit: 500 },\n { cacheOptions: { revalidate: 3600, tags: [tags.products()] } },\n ),\n client.catalogue.getCategories({\n cacheOptions: { revalidate: 3600, tags: [tags.categories()] },\n }),\n client.catalogue.getCollections({\n cacheOptions: { revalidate: 3600, tags: [tags.collections()] },\n }),\n ]);\n return {\n products: (pRes.ok ? pRes.value.items : []).map((p: Product) => ({\n slug: p.slug ?? p.id,\n name: p.name,\n })),\n categories: (cRes.ok ? cRes.value : []).map((c) => ({ slug: c.slug, name: c.name })),\n collections: (colRes.ok ? colRes.value : []).map((c) => ({ slug: c.slug, name: c.name })),\n };\n}\n\nconst STATIC_LINKS: { title: string; links: { href: string; label: string }[] }[] = [\n {\n title: "Browse",\n links: [\n { href: "/", label: "Home" },\n { href: "/shop", label: "Shop" },\n { href: "/search", label: "Search" },\n ],\n },\n {\n title: "Account",\n links: [\n { href: "/account", label: "Account" },\n { href: "/account/orders", label: "Orders" },\n { href: "/login", label: "Sign in" },\n { href: "/signup", label: "Create account" },\n { href: "/track-order", label: "Track an order" },\n { href: "/cart", label: "Cart" },\n { href: "/checkout", label: "Checkout" },\n ],\n },\n {\n title: "About",\n links: [\n { href: "/about", label: "About" },\n { href: "/faq", label: "FAQ" },\n { href: "/contact", label: "Contact" },\n ],\n },\n {\n title: "Policies",\n links: [\n { href: "/shipping", label: "Shipping" },\n { href: "/returns", label: "Returns" },\n { href: "/accessibility", label: "Accessibility" },\n { href: "/terms", label: "Terms of Service" },\n { href: "/privacy", label: "Privacy Policy" },\n ],\n },\n {\n title: "Machine-readable",\n links: [\n { href: "/sitemap.xml", label: "sitemap.xml (search engines)" },\n { href: "/llms.txt", label: "llms.txt (LLM agents)" },\n { href: "/robots.txt", label: "robots.txt" },\n { href: "/opensearch.xml", label: "opensearch.xml (browser search)" },\n ],\n },\n];\n\nexport default async function SitemapHtmlPage() {\n const { products, categories, collections } = await getSitemap();\n\n return (\n <article className="max-w-5xl mx-auto px-6 sm:px-8 py-16">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-2">\n Sitemap\n </p>\n <h1 className="text-[clamp(2rem,4vw,2.75rem)] font-semibold mb-2 -tracking-[0.02em]">\n Every page, in one place.\n </h1>\n <p className="text-muted-foreground mb-12">\n For search engines, see <Link href="/sitemap.xml" className="text-primary hover:underline">/sitemap.xml</Link>.\n For LLM agents, see <Link href="/llms.txt" className="text-primary hover:underline">/llms.txt</Link>.\n </p>\n <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-10">\n {STATIC_LINKS.map((s) => (\n <Section key={s.title} title={s.title}>\n {s.links.map((l) => (\n <li key={l.href}>\n <Link href={l.href} className="hover:text-primary transition-colors">\n {l.label}\n </Link>\n </li>\n ))}\n </Section>\n ))}\n {categories.length > 0 && (\n <Section title="Categories">\n {categories.map((c) => (\n <li key={c.slug}>\n <Link href={`/categories/${c.slug}`} className="hover:text-primary transition-colors">\n {c.name}\n </Link>\n </li>\n ))}\n </Section>\n )}\n {collections.length > 0 && (\n <Section title="Collections">\n {collections.map((c) => (\n <li key={c.slug}>\n <Link href={`/collections/${c.slug}`} className="hover:text-primary transition-colors">\n {c.name}\n </Link>\n </li>\n ))}\n </Section>\n )}\n {products.length > 0 && (\n <Section title={`Products (${products.length})`}>\n {products.map((p) => (\n <li key={p.slug}>\n <Link href={`/shop?product=${encodeURIComponent(p.slug)}`} className="hover:text-primary transition-colors">\n {p.name}\n </Link>\n </li>\n ))}\n </Section>\n )}\n </div>\n </article>\n );\n}\n\nfunction Section({ title, children }: { title: string; children: React.ReactNode }) {\n return (\n <div>\n <p className="font-semibold text-[12px] uppercase tracking-[0.12em] text-foreground mb-3">\n {title}\n </p>\n <ul className="space-y-2 m-0 p-0 list-none text-sm text-muted-foreground">\n {children}\n </ul>\n </div>\n );\n}\n' }, { "path": "app/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": "app/collections/[slug]/listing-client.tsx", "kind": "text", "content": '"use client";\n\nimport { ProductGrid } from "@cimplify/sdk/react";\nimport type { Product } from "@cimplify/sdk";\nimport { StoreProductCard } from "@/components/store-product-card";\n\n/**\n * Client island for the collection listing. Receives server-fetched\n * products as props (serializable) and owns the `renderCard` function\n * (which can\'t cross the server/client boundary).\n */\nexport function ListingClient({ products }: { products: Product[] }) {\n return (\n <ProductGrid\n products={products}\n emptyMessage="No products in this collection yet."\n renderCard={(p) => <StoreProductCard product={p} />}\n />\n );\n}\n' }, { "path": "app/collections/[slug]/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Suspense } from "react";\nimport Link from "next/link";\nimport { notFound } from "next/navigation";\nimport {\n getServerClient,\n tags,\n type Collection,\n type Product,\n} from "@cimplify/sdk/server";\nimport { ListingClient } from "./listing-client";\nimport { brand } from "@/lib/brand";\n\n// See app/products/[slug]/page.tsx for the rationale on generateStaticParams.\nexport async function generateStaticParams() {\n const r = await getServerClient().catalogue.getCollections();\n if (!r.ok || r.value.length === 0) {\n return [{ slug: "__placeholder__" }];\n }\n return r.value.map((c) => ({ slug: c.slug ?? c.id }));\n}\n\nexport const revalidate = 3600;\n\ninterface CollectionData {\n collection: Collection;\n products: Product[];\n}\n\ntype CollectionResult =\n | { ok: true; data: CollectionData }\n | { ok: false; code: string };\n\nasync function getCollection(slug: string): Promise<CollectionResult> {\n const client = getServerClient();\n const colRes = await client.catalogue.getCollectionBySlug(slug, {\n cacheOptions: { revalidate: 3600, tags: [tags.collections()] },\n });\n if (!colRes.ok) return { ok: false, code: colRes.error.code };\n\n const r = await client.catalogue.getCollectionProducts(colRes.value.id, undefined, {\n cacheOptions: {\n revalidate: 3600,\n tags: [\n tags.products(),\n tags.collection(colRes.value.id),\n tags.collectionProducts(colRes.value.id),\n ],\n },\n });\n const products = r.ok\n ? ((r.value as { items?: Product[] }).items ?? (r.value as Product[]))\n : [];\n return { ok: true, data: { collection: colRes.value, products } };\n}\n\nexport async function generateMetadata({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}): Promise<Metadata> {\n const { slug } = await params;\n const result = await getCollection(slug);\n if (!result.ok) return {};\n const data = result.data;\n return {\n title: `${data.collection.name} \u2014 ${brand.name}`,\n description: data.collection.description ?? undefined,\n };\n}\n\nexport default async function CollectionPage({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}) {\n return (\n <Suspense fallback={<CollectionSkeleton />}>\n <CollectionContent params={params} />\n </Suspense>\n );\n}\n\nasync function CollectionContent({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}) {\n const { slug } = await params;\n const result = await getCollection(slug);\n if (!result.ok) {\n if (result.code === "NOT_FOUND") notFound();\n return <CollectionSkeleton />;\n }\n const { collection, products } = result.data;\n return (\n <section className="max-w-7xl mx-auto px-6 sm:px-8 pt-12">\n <header className="mb-8 text-center">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-2">\n Collection\n </p>\n <h1 className="font-serif text-[clamp(2rem,4vw,2.75rem)] font-semibold mb-2">\n {collection.name}\n </h1>\n {collection.description && (\n <p className="mx-auto mb-2 max-w-xl text-muted-foreground">\n {collection.description}\n </p>\n )}\n <p className="text-sm text-muted-foreground">\n {products.length} item{products.length === 1 ? "" : "s"}\n </p>\n </header>\n <ListingClient products={products} />\n {products.length === 0 && (\n <p className="text-center mt-8">\n <Link href="/" className="text-primary font-semibold">\n \u2190 Back home\n </Link>\n </p>\n )}\n </section>\n );\n}\n\nfunction CollectionSkeleton() {\n return (\n <section className="max-w-7xl mx-auto px-6 sm:px-8 pt-12">\n <header className="mb-8 text-center">\n <div className="mx-auto h-3 w-20 bg-muted rounded mb-2 animate-pulse" />\n <div className="mx-auto h-10 w-64 bg-muted rounded mb-2 animate-pulse" />\n <div className="mx-auto h-4 w-80 bg-muted rounded animate-pulse" />\n </header>\n <div className="grid grid-cols-2 md:grid-cols-3 gap-4">\n {Array.from({ length: 6 }).map((_, i) => (\n <div key={i} className="aspect-square bg-muted rounded-2xl animate-pulse" />\n ))}\n </div>\n </section>\n );\n}\n' }, { "path": "app/layout.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Inter, Fraunces } from "next/font/google";\nimport "./globals.css";\nimport { Providers } from "@/components/providers";\nimport { getServerClient, tags } from "@cimplify/sdk/server";\nimport { Header } from "@/components/header";\nimport { Footer } from "@/components/footer";\nimport { ProductModal } from "@/components/product-modal";\nimport { CartDrawer } from "@/components/cart-drawer";\nimport { SupportChat } from "@/components/support-chat";\nimport { OrganizationJsonLd } from "@/components/json-ld";\nimport { Suspense } from "react";\nimport { brand } from "@/lib/brand";\nimport { getSiteUrl } from "@/lib/site-url";\n\nconst inter = Inter({\n subsets: ["latin"],\n variable: "--font-sans",\n display: "swap",\n});\n\nconst fraunces = Fraunces({\n subsets: ["latin"],\n variable: "--font-serif",\n display: "swap",\n});\n\nexport async function generateMetadata(): Promise<Metadata> {\n const siteUrl = await getSiteUrl();\n return {\n metadataBase: new URL(siteUrl),\n title: {\n default: brand.name,\n template: `%s \u2014 ${brand.name}`,\n },\n description: brand.description,\n openGraph: {\n type: "website",\n siteName: brand.name,\n locale: brand.locale,\n },\n twitter: { card: "summary_large_image" },\n };\n}\n\nexport default async function RootLayout({ children }: { children: React.ReactNode }) {\n const server = getServerClient();\n const [bizResult, locResult] = await Promise.all([\n server.business.getInfo({ cacheOptions: { revalidate: 3600, tags: [tags.business()] } }),\n server.business.getLocations({ cacheOptions: { revalidate: 3600, tags: [tags.locations()] } }),\n ]);\n const initialBusiness = bizResult.ok ? bizResult.value : null;\n const initialLocations = locResult.ok ? locResult.value : [];\n\n return (\n <html lang="en" suppressHydrationWarning className={`${inter.variable} ${fraunces.variable}`}>\n <body\n suppressHydrationWarning\n className="min-h-screen flex flex-col bg-background text-foreground font-sans"\n >\n <Suspense fallback={null}>\n <OrganizationJsonLd />\n </Suspense>\n <Providers initialBusiness={initialBusiness} initialLocations={initialLocations}>\n <Header />\n <main className="flex-1 pb-12 w-full">\n <Suspense fallback={null}>{children}</Suspense>\n </main>\n <Footer />\n <Suspense fallback={null}>\n <ProductModal />\n </Suspense>\n <CartDrawer />\n <SupportChat />\n </Providers>\n </body>\n </html>\n );\n}\n' }, { "path": "app/error.tsx", "kind": "text", "content": '"use client";\n\nimport { useEffect } from "react";\nimport Link from "next/link";\n\n/**\n * Root error boundary. Next.js calls this whenever a thrown error escapes\n * a Server Component without being caught by a closer `error.tsx`.\n *\n * Wire `reportError` (Sentry, Datadog, etc.) here so production failures\n * surface \u2014 silently recovering hides real bugs.\n */\nexport default function GlobalError({\n error,\n reset,\n}: {\n error: Error & { digest?: string };\n reset: () => void;\n}) {\n useEffect(() => {\n // TODO: replace with your error reporter\n // eslint-disable-next-line no-console\n console.error("Storefront error:", error);\n }, [error]);\n\n return (\n <section className="max-w-2xl mx-auto px-6 sm:px-8 py-20 text-center">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-3">\n Something broke\n </p>\n <h1 className="font-serif text-[clamp(2rem,4vw,2.75rem)] font-semibold mb-4">\n That wasn&apos;t supposed to happen.\n </h1>\n <p className="text-muted-foreground leading-relaxed mb-8">\n We&apos;ve been notified. Refresh the page, or head back home \u2014\n most of the time the second try just works.\n </p>\n {error.digest && (\n <p className="text-xs font-mono text-muted-foreground mb-6">\n Reference: <code className="text-foreground">{error.digest}</code>\n </p>\n )}\n <div className="flex flex-wrap items-center justify-center gap-3">\n <button\n type="button"\n onClick={reset}\n className="inline-block px-6 py-3 rounded-full bg-primary text-primary-foreground font-semibold text-sm transition-colors hover:bg-primary/90"\n >\n Try again\n </button>\n <Link\n href="/"\n className="inline-block px-6 py-3 rounded-full border border-border hover:bg-muted transition-colors text-sm font-medium"\n >\n Back home\n </Link>\n </div>\n </section>\n );\n}\n' }, { "path": "app/faq/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `FAQ \u2014 ${brand.name}`,\n description: "Delivery, pickup, custom cakes, allergens, payment \u2014 answers to the questions we hear most often.",\n};\n\nexport default function FaqPage() {\n const f = brand.faq;\n return (\n <article className="max-w-3xl 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 {f.eyebrow}\n </p>\n <h1 className="font-serif text-[clamp(2.25rem,5vw,3.5rem)] font-semibold mb-10 -tracking-[0.02em]">\n {f.title}\n </h1>\n <div className="space-y-12">\n {f.sections.map((section) => (\n <section key={section.title}>\n <h2 className="font-serif text-2xl font-semibold mb-5">{section.title}</h2>\n <dl className="space-y-6">\n {section.items.map((item) => (\n <div key={item.q}>\n <dt className="font-medium text-foreground mb-1.5">{item.q}</dt>\n <dd className="text-muted-foreground leading-relaxed">{item.a}</dd>\n </div>\n ))}\n </dl>\n </section>\n ))}\n </div>\n <p className="mt-12 pt-8 border-t border-border text-sm text-muted-foreground">\n {f.contactPrompt}{" "}\n <a\n href={`mailto:${f.contactEmail}`}\n className="text-primary font-semibold hover:underline"\n >\n {f.contactEmail}\n </a>{" "}\n and a real human will reply within 24 hours.\n </p>\n </article>\n );\n}\n' }, { "path": "app/llms.txt/route.ts", "kind": "text", "content": 'import { getServerClient, tags, type Product } from "@cimplify/sdk/server";\nimport { brand } from "@/lib/brand";\nimport { getSiteUrl } from "@/lib/site-url";\n\nexport const revalidate = 3600;\n\nasync function buildLlmsTxt(SITE_URL: string): Promise<string> {\n const client = getServerClient();\n const [productsRes, categoriesRes, collectionsRes] = await Promise.all([\n client.catalogue.getProducts(\n { limit: 500 },\n { cacheOptions: { revalidate: 3600, tags: [tags.products()] } },\n ),\n client.catalogue.getCategories({\n cacheOptions: { revalidate: 3600, tags: [tags.categories()] },\n }),\n client.catalogue.getCollections({\n cacheOptions: { revalidate: 3600, tags: [tags.collections()] },\n }),\n ]);\n\n const products: Product[] = productsRes.ok ? productsRes.value.items : [];\n const categories = categoriesRes.ok ? categoriesRes.value : [];\n const collections = collectionsRes.ok ? collectionsRes.value : [];\n\n const lines: string[] = [];\n lines.push(`# ${brand.name}`);\n lines.push("");\n lines.push(`> ${brand.llms.summary}`);\n lines.push("");\n lines.push("## Browse");\n lines.push(`- [Home](${SITE_URL}/)`);\n lines.push(`- [Shop](${SITE_URL}/shop): Full menu with filter and sort.`);\n\n if (categories.length > 0) {\n lines.push("");\n lines.push("## Categories");\n for (const c of categories) {\n lines.push(\n `- [${c.name}](${SITE_URL}/categories/${c.slug})${c.description ? `: ${c.description}` : ""}`,\n );\n }\n }\n\n if (collections.length > 0) {\n lines.push("");\n lines.push("## Collections");\n for (const c of collections) {\n lines.push(\n `- [${c.name}](${SITE_URL}/collections/${c.slug})${c.description ? `: ${c.description}` : ""}`,\n );\n }\n }\n\n if (products.length > 0) {\n lines.push("");\n lines.push("## Products");\n for (const p of products) {\n const slug = p.slug ?? p.id;\n const price = `${brand.currency} ${p.default_price}`;\n const desc = p.description ? ` \u2014 ${p.description.replace(/\\s+/g, " ").slice(0, 200)}` : "";\n lines.push(`- [${p.name}](${SITE_URL}/shop?product=${slug}) (${price})${desc}`);\n }\n }\n\n lines.push("");\n lines.push("## Information");\n lines.push(`- [About](${SITE_URL}/about)`);\n lines.push(`- [FAQ](${SITE_URL}/faq)`);\n lines.push(`- [Terms of Service](${SITE_URL}/terms)`);\n lines.push(`- [Privacy Policy](${SITE_URL}/privacy)`);\n lines.push(`- [Sitemap (XML)](${SITE_URL}/sitemap.xml)`);\n lines.push("");\n lines.push("## Contact");\n lines.push(`- Email: ${brand.contact.email}`);\n lines.push(`- Phone: ${brand.contact.phone}`);\n lines.push(`- Address: ${brand.contact.address}`);\n\n return lines.join("\\n") + "\\n";\n}\n\n/**\n * `/llms.txt` \u2014 machine-readable site index for LLMs (per llmstxt.org).\n * Lets coding agents and chat assistants find products, categories, and\n * support pages without scraping HTML. Plain Markdown so it streams cheaply\n * into context windows.\n */\nexport async function GET(): Promise<Response> {\n const body = await buildLlmsTxt(await getSiteUrl());\n return new Response(body, {\n headers: {\n "Content-Type": "text/plain; charset=utf-8",\n "Cache-Control": "public, max-age=0, s-maxage=3600, stale-while-revalidate=86400",\n },\n });\n}\n' }, { "path": "app/contact/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { ContactForm } from "./contact-form";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Contact \u2014 ${brand.name}`,\n description: brand.contactPage.body,\n};\n\nexport default function ContactPage() {\n const c = brand.contactPage;\n return (\n <article className="max-w-5xl mx-auto px-6 sm:px-8 py-16">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-2">\n {c.eyebrow}\n </p>\n <h1 className="font-serif text-[clamp(2.25rem,5vw,3.5rem)] font-semibold mb-4 -tracking-[0.02em]">\n {c.title}\n </h1>\n <p className="text-lg text-muted-foreground max-w-2xl mb-12 leading-relaxed">{c.body}</p>\n\n <div className="grid grid-cols-1 lg:grid-cols-[1.5fr_1fr] gap-10 items-start">\n <ContactForm reasons={c.reasons} />\n <aside className="space-y-5 lg:pl-10 lg:border-l border-border">\n <div>\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground mb-3">\n Direct lines\n </p>\n <ul className="space-y-3 list-none p-0 m-0">\n {c.directLines.map((line) => (\n <li key={line.label}>\n <p className="text-xs text-muted-foreground m-0">{line.label}</p>\n <a\n href={line.href}\n className="text-foreground hover:text-primary transition-colors"\n >\n {line.value}\n </a>\n </li>\n ))}\n </ul>\n </div>\n <div>\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground mb-3">\n Visit\n </p>\n <p className="text-foreground m-0">{brand.contact.address}</p>\n <p className="text-xs text-muted-foreground mt-1">{brand.contact.hours}</p>\n </div>\n </aside>\n </div>\n </article>\n );\n}\n' }, { "path": "app/contact/contact-form.tsx", "kind": "text", "content": `"use client";
6245
+ ` }, { "path": "lib/cimplify-server.ts", "kind": "text", "content": 'import { cookies } from "next/headers";\nimport {\n getAccessTokenFromCookieHeader,\n getSessionFromCookieHeader,\n} from "@cimplify/sdk/server";\nimport type { NavItem } from "@cimplify/sdk/react";\n\nexport const ACCOUNT_NAV: NavItem[] = [\n { label: "Dashboard", href: "/account" },\n { label: "Orders", href: "/account/orders" },\n { label: "Cimplify identity", href: "https://link.cimplify.io", external: true },\n];\n\nconst CLIENT_ID = process.env.CIMPLIFY_CLIENT_ID ?? "";\nconst ISSUER = (process.env.CIMPLIFY_ISSUER ?? "https://api.cimplify.io").replace(/\\/$/, "");\n\nconst OIDC_CONFIG = { clientId: CLIENT_ID, issuer: ISSUER };\n\nasync function cookieHeader(): Promise<string> {\n const store = await cookies();\n return store.getAll().map((c) => `${c.name}=${c.value}`).join("; ");\n}\n\nexport async function serverSession() {\n return getSessionFromCookieHeader(OIDC_CONFIG, await cookieHeader());\n}\n\nexport async function serverAccessToken(): Promise<string | null> {\n return getAccessTokenFromCookieHeader(OIDC_CONFIG, await cookieHeader());\n}\n\ninterface Envelope<T> {\n data: T;\n}\n\nexport async function apiFetch<T>(\n path: string,\n init: RequestInit & { bearer: string | null },\n): Promise<T | null> {\n if (!init.bearer) return null;\n const { bearer, ...rest } = init;\n const headers = new Headers(rest.headers);\n headers.set("Authorization", `Bearer ${bearer}`);\n headers.set("Accept", "application/json");\n if (rest.body && !headers.has("Content-Type")) {\n headers.set("Content-Type", "application/json");\n }\n const res = await fetch(`${ISSUER}${path}`, { ...rest, headers, cache: "no-store" });\n if (!res.ok) return null;\n const envelope = (await res.json()) as Envelope<T>;\n return envelope.data;\n}\n' }, { "path": "lib/cimplify-loader.ts", "kind": "text", "content": 'import type { ImageLoader } from "next/image";\n\nimport { assetUrl, isCimplifyAsset } from "@cimplify/sdk";\n\nconst cdnBase = process.env.NEXT_PUBLIC_CIMPLIFY_CDN_URL?.trim() || undefined;\n\nconst cimplifyImageLoader: ImageLoader = ({ src, width, quality }) => {\n if (isCimplifyAsset(src, cdnBase)) {\n return assetUrl(src, {\n base: cdnBase,\n w: width,\n quality,\n format: "auto",\n });\n }\n return src;\n};\n\nexport default cimplifyImageLoader;\n' }, { "path": "lib/site-url.ts", "kind": "text", "content": 'import { SITE_URL } from "./site.config";\n\n/** Canonical absolute URL for this storefront. */\nexport async function getSiteUrl(): Promise<string> {\n return SITE_URL;\n}\n' }, { "path": ".env.example", "kind": "text", "content": "# Your tenant public key. Get it from the desk's Developers tab.\n# `cpk_live_*` / `cpk_test_*` keys auto-route same-origin requests\n# to hosted Cimplify in both dev and prod. Anything else (`mock-dev`,\n# empty) falls back to the local `cimplify dev` mock.\nNEXT_PUBLIC_CIMPLIFY_PUBLIC_KEY=mock-dev\n\n# Optional. Forces a fixed canonical URL for sitemap.xml, robots.txt,\n# OpenGraph, and llms.txt. Leave unset and the storefront derives the\n# canonical from the request `Host` header automatically.\n# NEXT_PUBLIC_SITE_URL=\n" }, { "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__/brand.test.ts", "kind": "text", "content": 'import { createBrandSuite } from "@cimplify/sdk/testing/suite";\nimport { brand } from "../lib/brand";\n\ncreateBrandSuite({ brand });\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.101.1",\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": "metadata.json", "kind": "text", "content": '{\n "id": "services",\n "name": "Services",\n "tagline": "Bookable-services storefront with calendar slots and staff profiles.",\n "industry": "services",\n "tags": ["services", "bookings", "appointments"],\n "stability": "stable",\n "schemaType": "BeautySalon",\n "mock": {\n "seedName": "services",\n "seedBusinessId": "bus_serene_spa"\n }\n}\n' }, { "path": "app/search/search-client.tsx", "kind": "text", "content": '"use client";\n\nimport { SearchPage } from "@cimplify/sdk/react";\n\nexport function SearchClient() {\n return <SearchPage />;\n}\n' }, { "path": "app/search/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Suspense } from "react";\nimport { SearchClient } from "./search-client";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Search \u2014 ${brand.name}`,\n description: `Search ${brand.name} \u2014 products, collections, categories.`,\n};\n\nexport default function SearchPage() {\n return (\n <article className="max-w-7xl mx-auto px-6 sm:px-8 py-10">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-2">\n Search\n </p>\n <h1 className="font-serif text-[clamp(2rem,4vw,2.75rem)] font-semibold mb-8 -tracking-[0.02em]">\n Find anything.\n </h1>\n <Suspense fallback={<SearchSkeleton />}>\n <SearchClient />\n </Suspense>\n </article>\n );\n}\n\nfunction SearchSkeleton() {\n return (\n <div>\n <div className="h-12 w-full max-w-xl bg-muted rounded animate-pulse mb-8" />\n <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3 sm:gap-4">\n {Array.from({ length: 8 }).map((_, i) => (\n <div key={i} className="aspect-[4/3] bg-muted rounded-2xl animate-pulse" />\n ))}\n </div>\n </div>\n );\n}\n' }, { "path": "app/categories/[slug]/listing-client.tsx", "kind": "text", "content": '"use client";\n\nimport { ProductGrid } from "@cimplify/sdk/react";\nimport type { Product } from "@cimplify/sdk";\nimport { StoreProductCard } from "@/components/store-product-card";\n\n/**\n * Client island for the category listing. Receives server-fetched products\n * as props (serializable) and owns the `renderCard` function.\n */\nexport function ListingClient({ products }: { products: Product[] }) {\n return (\n <ProductGrid\n products={products}\n emptyMessage="No products in this category yet."\n renderCard={(p) => <StoreProductCard product={p} />}\n />\n );\n}\n' }, { "path": "app/categories/[slug]/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Suspense } from "react";\nimport Link from "next/link";\nimport { notFound } from "next/navigation";\nimport {\n getServerClient,\n tags,\n type Category,\n type Product,\n} from "@cimplify/sdk/server";\nimport { ListingClient } from "./listing-client";\nimport { brand } from "@/lib/brand";\n\n// See app/products/[slug]/page.tsx for the rationale on generateStaticParams.\nexport async function generateStaticParams() {\n const r = await getServerClient().catalogue.getCategories();\n if (!r.ok || r.value.length === 0) {\n return [{ slug: "__placeholder__" }];\n }\n return r.value.map((c) => ({ slug: c.slug ?? c.id }));\n}\n\nexport const revalidate = 3600;\n\ninterface CategoryData {\n category: Category;\n products: Product[];\n}\n\ntype CategoryResult =\n | { ok: true; data: CategoryData }\n | { ok: false; code: string };\n\nasync function getCategory(slug: string): Promise<CategoryResult> {\n const client = getServerClient();\n const catRes = await client.catalogue.getCategoryBySlug(slug, {\n cacheOptions: { revalidate: 3600, tags: [tags.categories()] },\n });\n if (!catRes.ok) return { ok: false, code: catRes.error.code };\n\n const r = await client.catalogue.getCategoryProducts(catRes.value.id, undefined, {\n cacheOptions: {\n revalidate: 3600,\n tags: [\n tags.products(),\n tags.category(catRes.value.id),\n tags.categoryProducts(catRes.value.id),\n ],\n },\n });\n const products = r.ok\n ? ((r.value as { items?: Product[] }).items ?? (r.value as Product[]))\n : [];\n return { ok: true, data: { category: catRes.value, products } };\n}\n\nexport async function generateMetadata({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}): Promise<Metadata> {\n const { slug } = await params;\n const result = await getCategory(slug);\n if (!result.ok) return {};\n const data = result.data;\n return {\n title: `${data.category.name} \u2014 ${brand.name}`,\n description: data.category.description ?? undefined,\n };\n}\n\nexport default async function CategoryPage({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}) {\n return (\n <Suspense fallback={<CategorySkeleton />}>\n <CategoryContent params={params} />\n </Suspense>\n );\n}\n\nasync function CategoryContent({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}) {\n const { slug } = await params;\n const result = await getCategory(slug);\n if (!result.ok) {\n if (result.code === "NOT_FOUND") notFound();\n return <CategorySkeleton />;\n }\n const { category, products } = result.data;\n return (\n <section className="max-w-7xl mx-auto px-6 sm:px-8 pt-12">\n <header className="mb-8 text-center">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-2">\n Category\n </p>\n <h1 className="font-serif text-[clamp(2rem,4vw,2.75rem)] font-semibold mb-2">\n {category.name}\n </h1>\n {category.description && (\n <p className="mx-auto mb-2 max-w-xl text-muted-foreground">\n {category.description}\n </p>\n )}\n <p className="text-sm text-muted-foreground">\n {products.length} item{products.length === 1 ? "" : "s"}\n </p>\n </header>\n <ListingClient products={products} />\n {products.length === 0 && (\n <p className="text-center mt-8">\n <Link href="/" className="text-primary font-semibold">\n \u2190 Back home\n </Link>\n </p>\n )}\n </section>\n );\n}\n\nfunction CategorySkeleton() {\n return (\n <section className="max-w-7xl mx-auto px-6 sm:px-8 pt-12">\n <header className="mb-8 text-center">\n <div className="mx-auto h-3 w-20 bg-muted rounded mb-2 animate-pulse" />\n <div className="mx-auto h-10 w-64 bg-muted rounded mb-2 animate-pulse" />\n <div className="mx-auto h-4 w-80 bg-muted rounded animate-pulse" />\n </header>\n <div className="grid grid-cols-2 md:grid-cols-3 gap-4">\n {Array.from({ length: 6 }).map((_, i) => (\n <div key={i} className="aspect-square bg-muted rounded-2xl animate-pulse" />\n ))}\n </div>\n </section>\n );\n}\n' }, { "path": "app/terms/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Terms of Service \u2014 ${brand.name}`,\n description: `The rules of the road for ordering from ${brand.name}.`,\n};\n\nexport default function TermsPage() {\n const t = brand.terms;\n return (\n <article className="max-w-3xl mx-auto px-6 sm:px-8 py-16 prose prose-lg max-w-none">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-2 not-prose">\n {t.eyebrow}\n </p>\n <h1 className="font-serif text-[clamp(2.25rem,5vw,3.5rem)] font-semibold mb-2 -tracking-[0.02em]">\n {t.title}\n </h1>\n <p className="text-sm text-muted-foreground not-prose mb-10">\n Last updated: {t.lastUpdated}\n </p>\n\n <section className="space-y-5 leading-relaxed text-foreground/90">\n {t.sections.map((s) => (\n <div key={s.heading}>\n <h2 className="font-serif text-2xl font-semibold mt-0">{s.heading}</h2>\n {typeof s.body === "string" ? (\n <p>{s.body}</p>\n ) : (\n <>\n <p>{s.body.intro}</p>\n <ul className="list-disc pl-6 space-y-2">\n {s.body.bullets.map((b) => (\n <li key={b}>{b}</li>\n ))}\n </ul>\n </>\n )}\n </div>\n ))}\n </section>\n </article>\n );\n}\n' }, { "path": "app/not-found.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport Link from "next/link";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Page not found \u2014 ${brand.name}`,\n description: "We couldn\'t find that page.",\n};\n\nexport default function NotFound() {\n return (\n <section className="max-w-2xl mx-auto px-6 sm:px-8 py-20 text-center">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-3">\n 404\n </p>\n <h1 className="font-serif text-[clamp(2.5rem,5vw,3.5rem)] font-semibold mb-4">\n Page not found.\n </h1>\n <p className="text-muted-foreground leading-relaxed mb-8">\n The URL you followed might be old, a typo, or pointing at a product\n that&apos;s sold out for the day. Try the menu or head back home.\n </p>\n <div className="flex flex-wrap items-center justify-center gap-3">\n <Link\n href="/shop"\n className="inline-block px-6 py-3 rounded-full bg-primary text-primary-foreground font-semibold text-sm transition-colors hover:bg-primary/90"\n >\n Browse the menu\n </Link>\n <Link\n href="/"\n className="inline-block px-6 py-3 rounded-full border border-border hover:bg-muted transition-colors text-sm font-medium"\n >\n Back home\n </Link>\n </div>\n </section>\n );\n}\n' }, { "path": "app/cart/page.tsx", "kind": "text", "content": '"use client";\n\nimport { useRouter } from "next/navigation";\nimport { CartPage as SdkCartPage } from "@cimplify/sdk/react";\n\nexport default function CartPage() {\n const router = useRouter();\n return <SdkCartPage onCheckout={() => router.push("/checkout")} />;\n}\n' }, { "path": "app/accessibility/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\nimport { PolicyPage } from "@/components/policy-page";\n\nexport const metadata: Metadata = {\n title: `${brand.accessibility.title} \u2014 ${brand.name}`,\n};\n\nexport default function AccessibilityPage() {\n return <PolicyPage policy={brand.accessibility} />;\n}\n' }, { "path": "app/account/orders/[id]/page.tsx", "kind": "text", "content": 'import { notFound } from "next/navigation";\nimport { AccountSignedOutPrompt, toAccountOrderView } from "@cimplify/sdk/react";\nimport type { EnrichedOrder } from "@cimplify/sdk";\nimport { brand } from "@/lib/brand";\nimport { apiFetch, serverAccessToken, serverSession } from "@/lib/cimplify-server";\nimport { OrderDetail } from "@/components/order-detail";\n\nconst CLIENT_ID = process.env.NEXT_PUBLIC_CIMPLIFY_CLIENT_ID ?? "";\nconst REDIRECT_URI = process.env.CIMPLIFY_REDIRECT_URI ?? "";\n\nexport default async function OrderDetailPage({\n params,\n}: {\n params: Promise<{ id: string }>;\n}) {\n const { id } = await params;\n\n const session = await serverSession();\n if (!session) {\n return (\n <AccountSignedOutPrompt\n clientId={CLIENT_ID}\n redirectUri={REDIRECT_URI}\n eyebrow={brand.account.loginEyebrow}\n title={brand.account.loginTitle}\n description={brand.account.loginSubtitle}\n />\n );\n }\n\n const bearer = await serverAccessToken();\n const order = await apiFetch<EnrichedOrder>(`/api/v1/orders/${encodeURIComponent(id)}`, {\n bearer,\n });\n if (!order) notFound();\n\n return (\n <div className="mx-auto w-full max-w-5xl px-4 py-8 sm:px-6">\n <OrderDetail {...toAccountOrderView(order)} />\n </div>\n );\n}\n' }, { "path": "app/account/orders/page.tsx", "kind": "text", "content": 'import {\n AccountOrdersPage,\n AccountSignedOutPrompt,\n} from "@cimplify/sdk/react";\nimport type { OrderRowData, OrderRowStatus } from "@cimplify/sdk/react";\nimport type { Order, OrderStatus } from "@cimplify/sdk";\nimport { formatMoney } from "@cimplify/sdk";\nimport { brand } from "@/lib/brand";\nimport { ACCOUNT_NAV, apiFetch, serverAccessToken, serverSession } from "@/lib/cimplify-server";\n\nconst CLIENT_ID = process.env.NEXT_PUBLIC_CIMPLIFY_CLIENT_ID ?? "";\nconst REDIRECT_URI = process.env.CIMPLIFY_REDIRECT_URI ?? "";\n\nfunction mapStatus(status: OrderStatus): OrderRowStatus {\n switch (status) {\n case "in_preparation":\n case "ready_to_serve":\n case "partially_served":\n return { kind: "preparing", label: "Preparing" };\n case "delivered":\n return { kind: "delivered" };\n case "picked_up":\n return { kind: "delivered", label: "Picked up" };\n case "served":\n case "completed":\n return { kind: "completed" };\n case "cancelled":\n case "refunded":\n return { kind: "cancelled", label: status === "refunded" ? "Refunded" : undefined };\n case "pending":\n case "created":\n case "confirmed":\n default:\n return { kind: "active", label: "Confirmed" };\n }\n}\n\nfunction relativeMeta(iso: string, orderNumber: string): string {\n const when = new Date(iso);\n const formatted = when.toLocaleDateString(undefined, {\n month: "short",\n day: "numeric",\n year: when.getFullYear() === new Date().getFullYear() ? undefined : "numeric",\n });\n return `Order ${orderNumber} \xB7 ${formatted}`;\n}\n\nfunction toRowData(order: Order): OrderRowData {\n const count = order.items?.length ?? 0;\n return {\n id: order.id,\n href: `/account/orders/${order.id}`,\n summary: count > 0 ? `${count} ${count === 1 ? "item" : "items"}` : "Order",\n meta: relativeMeta(order.created_at, order.user_friendly_id ?? order.id),\n status: mapStatus(order.status),\n total: formatMoney(order.total_price, order.currency),\n };\n}\n\nexport default async function OrdersPage() {\n const session = await serverSession();\n if (!session) {\n return (\n <AccountSignedOutPrompt\n clientId={CLIENT_ID}\n redirectUri={REDIRECT_URI}\n eyebrow={brand.account.loginEyebrow}\n title={brand.account.loginTitle}\n description={brand.account.loginSubtitle}\n />\n );\n }\n\n const bearer = await serverAccessToken();\n const orders = await apiFetch<Order[]>(\n `/v1/link/orders?business_id=${encodeURIComponent(brand.mock.businessId)}`,\n { bearer },\n );\n\n return (\n <AccountOrdersPage\n customerName={session.name ?? ""}\n customerEmail={session.email}\n customerId={session.sub}\n merchantName={brand.name}\n nav={ACCOUNT_NAV}\n orders={(orders ?? []).map(toRowData)}\n />\n );\n}\n' }, { "path": "app/account/page.tsx", "kind": "text", "content": 'import {\n AccountDashboardPage,\n AccountSignedOutPrompt,\n} from "@cimplify/sdk/react";\nimport type { CustomerAddress, CustomerMobileMoney } from "@cimplify/sdk";\nimport { brand } from "@/lib/brand";\nimport { ACCOUNT_NAV, apiFetch, serverAccessToken, serverSession } from "@/lib/cimplify-server";\n\nconst CLIENT_ID = process.env.NEXT_PUBLIC_CIMPLIFY_CLIENT_ID ?? "";\nconst REDIRECT_URI = process.env.CIMPLIFY_REDIRECT_URI ?? "";\n\nexport default async function AccountPage() {\n const session = await serverSession();\n if (!session) {\n return (\n <AccountSignedOutPrompt\n clientId={CLIENT_ID}\n redirectUri={REDIRECT_URI}\n eyebrow={brand.account.loginEyebrow}\n title={brand.account.loginTitle}\n description={brand.account.loginSubtitle}\n />\n );\n }\n\n const bearer = await serverAccessToken();\n const [addresses, wallets] = await Promise.all([\n apiFetch<CustomerAddress[]>("/v1/link/addresses", { bearer }),\n apiFetch<CustomerMobileMoney[]>("/v1/link/mobile-money", { bearer }),\n ]);\n\n return (\n <AccountDashboardPage\n customerName={session.name ?? ""}\n customerEmail={session.email}\n customerId={session.sub}\n merchantName={brand.name}\n nav={ACCOUNT_NAV}\n identitySummary={{\n addresses: addresses?.length ?? 0,\n paymentMethods: wallets?.length ?? 0,\n }}\n />\n );\n}\n' }, { "path": "app/.well-known/ucp/route.ts", "kind": "text", "content": 'import { NextResponse } from "next/server";\n\n/**\n * UCP (Universal Commerce Protocol) manifest discovery endpoint.\n *\n * Agents \u2014 Claude, ChatGPT, Gemini, MCP clients \u2014 probe\n * `https://<your-domain>/.well-known/ucp` to learn what commerce\n * capabilities your storefront supports. We resolve the business\'s\n * UCP handle from the public key (via the storefront API), then forward\n * to Cimplify for the canonical manifest. Edge-cached for an hour\n * because capabilities change rarely.\n */\nconst UCP_API_BASE = "https://api.cimplify.io";\nconst STOREFRONT_API_BASE =\n process.env.NODE_ENV === "production"\n ? "https://storefronts.cimplify.io"\n : "http://127.0.0.1:8787";\n\nexport async function GET() {\n const publicKey = process.env.NEXT_PUBLIC_CIMPLIFY_PUBLIC_KEY;\n if (!publicKey) {\n return NextResponse.json(\n {\n error: "NEXT_PUBLIC_CIMPLIFY_PUBLIC_KEY not set",\n remediation:\n "Set NEXT_PUBLIC_CIMPLIFY_PUBLIC_KEY in .env.local (and your deployment env).",\n },\n { status: 500 },\n );\n }\n\n try {\n // Step 1 \u2014 resolve the business handle from the public key.\n const businessResp = await fetch(`${STOREFRONT_API_BASE}/api/v1/business`, {\n headers: { "X-API-Key": publicKey, "Content-Type": "application/json" },\n next: { revalidate: 3600 },\n });\n\n if (!businessResp.ok) {\n return NextResponse.json(\n { error: `Failed to resolve business: ${businessResp.status}` },\n { status: businessResp.status },\n );\n }\n\n const businessJson = await businessResp.json();\n const handle: string | undefined = businessJson?.data?.handle;\n if (!handle) {\n return NextResponse.json(\n { error: "Business has no handle configured" },\n { status: 500 },\n );\n }\n\n // Step 2 \u2014 fetch the canonical UCP manifest.\n const manifestResp = await fetch(\n `${UCP_API_BASE}/ucp/v1/${handle}/manifest`,\n {\n headers: { "Content-Type": "application/json" },\n next: { revalidate: 3600 },\n },\n );\n\n if (!manifestResp.ok) {\n return NextResponse.json(\n { error: `Upstream UCP manifest fetch failed: ${manifestResp.status}` },\n { status: manifestResp.status },\n );\n }\n\n const manifest = await manifestResp.json();\n return NextResponse.json(manifest, {\n headers: {\n "Content-Type": "application/json",\n "Cache-Control": "public, max-age=3600, s-maxage=3600",\n },\n });\n } catch (error) {\n return NextResponse.json(\n {\n error: "Failed to fetch UCP manifest",\n detail: error instanceof Error ? error.message : String(error),\n },\n { status: 500 },\n );\n }\n}\n' }, { "path": "app/returns/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\nimport { PolicyPage } from "@/components/policy-page";\n\nexport const metadata: Metadata = {\n title: `${brand.returns.title} \u2014 ${brand.name}`,\n};\n\nexport default function ReturnsPage() {\n return <PolicyPage policy={brand.returns} />;\n}\n' }, { "path": "app/orders/[id]/page.tsx", "kind": "text", "content": 'import Link from "next/link";\n\nexport default async function OrderPage({ params }: { params: Promise<{ id: string }> }) {\n const { id } = await params;\n return (\n <section className="max-w-2xl mx-auto px-6 sm:px-8 py-20 text-center">\n <h1 className="font-serif text-3xl mt-0 mb-3">Thanks \u2014 your order is confirmed</h1>\n <p className="text-muted-foreground">\n Order <code className="font-mono text-foreground">{id}</code>\n </p>\n <p className="mt-6">\n <Link\n href="/"\n className="inline-block px-6 py-3 rounded-full bg-primary text-primary-foreground font-semibold text-sm transition-colors hover:bg-primary/90"\n >\n Continue shopping\n </Link>\n </p>\n </section>\n );\n}\n' }, { "path": "app/globals.css", "kind": "text", "content": '@import "tailwindcss";\n\n@source "../node_modules/@cimplify/sdk/dist";\n\n/* Serene Spa \u2014 calm sand + sage palette, soft contrast.\n Tweak primary to retheme everything. */\n@theme {\n --color-background: oklch(0.98 0.012 90);\n --color-foreground: oklch(0.22 0.02 140);\n --color-card: oklch(1 0 0);\n --color-card-foreground: oklch(0.22 0.02 140);\n --color-popover: oklch(1 0 0);\n --color-popover-foreground: oklch(0.22 0.02 140);\n --color-primary: oklch(0.46 0.06 150);\n --color-primary-foreground: oklch(0.99 0.01 90);\n --color-secondary: oklch(0.93 0.025 90);\n --color-secondary-foreground: oklch(0.3 0.04 140);\n --color-muted: oklch(0.95 0.018 90);\n --color-muted-foreground: oklch(0.5 0.02 140);\n --color-accent: oklch(0.9 0.035 90);\n --color-accent-foreground: oklch(0.3 0.04 140);\n --color-destructive: oklch(0.58 0.24 27);\n --color-destructive-foreground: oklch(0.99 0 0);\n --color-border: oklch(0.91 0.018 90);\n --color-input: oklch(0.93 0.018 90);\n --color-ring: oklch(0.46 0.06 150);\n --radius: 1rem;\n}\n\n@layer base {\n html, body {\n margin: 0;\n padding: 0;\n background: var(--color-background);\n color: var(--color-foreground);\n -webkit-font-smoothing: antialiased;\n -moz-osx-font-smoothing: grayscale;\n }\n a { color: inherit; text-decoration: none; }\n h1, h2, h3, h4 {\n font-family: var(--font-serif);\n letter-spacing: -0.015em;\n font-weight: 500;\n }\n}\n' }, { "path": "app/about/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `About \u2014 ${brand.name}`,\n description: brand.description,\n};\n\nexport default function AboutPage() {\n const a = brand.about;\n const titleParts = a.title.split("\\n");\n return (\n <article className="max-w-3xl 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 {a.eyebrow}\n </p>\n <h1 className="font-serif text-[clamp(2.25rem,5vw,3.5rem)] font-semibold mb-6 -tracking-[0.02em]">\n {titleParts.map((line, i) => (\n <span key={i}>\n {line}\n {i < titleParts.length - 1 && <br />}\n </span>\n ))}\n </h1>\n <div className="prose prose-lg max-w-none space-y-5 text-foreground/90 leading-relaxed">\n {a.paragraphs.map((p, i) => (\n <p key={i}>{p}</p>\n ))}\n {a.sections.map((s) => (\n <div key={s.heading}>\n <h2 className="font-serif text-3xl font-semibold mt-10 mb-3">{s.heading}</h2>\n <p>{s.body}</p>\n </div>\n ))}\n </div>\n </article>\n );\n}\n' }, { "path": "app/auth/signout/route.ts", "kind": "text", "content": 'import { buildSignoutCookies } from "@cimplify/sdk/server";\n\nconst CLIENT_ID = process.env.CIMPLIFY_CLIENT_ID ?? "";\n\nexport async function POST(): Promise<Response> {\n const headers = new Headers({ "Content-Type": "application/json" });\n for (const cookie of buildSignoutCookies({ clientId: CLIENT_ID })) {\n headers.append("Set-Cookie", cookie);\n }\n return new Response(JSON.stringify({ ok: true }), { status: 200, headers });\n}\n' }, { "path": "app/auth/session/route.ts", "kind": "text", "content": 'import { handleSessionRequest } from "@cimplify/sdk/server";\n\nconst CLIENT_ID = process.env.CIMPLIFY_CLIENT_ID ?? "";\nconst ISSUER = process.env.CIMPLIFY_ISSUER;\n\nexport async function GET(req: Request): Promise<Response> {\n if (!CLIENT_ID) {\n return Response.json({ sub: null }, { status: 200 });\n }\n return handleSessionRequest(req, { clientId: CLIENT_ID, issuer: ISSUER });\n}\n' }, { "path": "app/auth/callback/route.ts", "kind": "text", "content": 'import { handleOidcCallback, handleRedirectCallback } from "@cimplify/sdk/server";\n\nconst CLIENT_ID = process.env.CIMPLIFY_CLIENT_ID ?? "";\nconst ISSUER = process.env.CIMPLIFY_ISSUER;\nconst REDIRECT_URI = process.env.CIMPLIFY_REDIRECT_URI ?? "";\n\n// Redirect sign-in: the browser lands here via a top-level GET carrying ?code.\nexport async function GET(req: Request): Promise<Response> {\n if (!CLIENT_ID || !REDIRECT_URI) {\n return Response.json({ error: "oidc_not_configured" }, { status: 500 });\n }\n return handleRedirectCallback(req, {\n clientId: CLIENT_ID,\n issuer: ISSUER,\n redirectUri: REDIRECT_URI,\n defaultReturnTo: "/account",\n });\n}\n\n// Modal/silent sign-in: posts {code, codeVerifier, state} here for exchange.\nexport async function POST(req: Request): Promise<Response> {\n if (!CLIENT_ID || !REDIRECT_URI) {\n return Response.json({ error: "oidc_not_configured" }, { status: 500 });\n }\n return handleOidcCallback(req, {\n clientId: CLIENT_ID,\n issuer: ISSUER,\n redirectUri: REDIRECT_URI,\n });\n}\n' }, { "path": "app/privacy/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Privacy Policy \u2014 ${brand.name}`,\n description: `How ${brand.name} collects, uses, and protects your personal data.`,\n};\n\nexport default function PrivacyPage() {\n const p = brand.privacy;\n return (\n <article className="max-w-3xl mx-auto px-6 sm:px-8 py-16 prose prose-lg max-w-none">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-2 not-prose">\n {p.eyebrow}\n </p>\n <h1 className="font-serif text-[clamp(2.25rem,5vw,3.5rem)] font-semibold mb-2 -tracking-[0.02em]">\n {p.title}\n </h1>\n <p className="text-sm text-muted-foreground not-prose mb-10">\n Last updated: {p.lastUpdated}\n </p>\n\n <section className="space-y-5 leading-relaxed text-foreground/90">\n {p.sections.map((s) => (\n <div key={s.heading}>\n <h2 className="font-serif text-2xl font-semibold mt-0">{s.heading}</h2>\n {typeof s.body === "string" ? (\n <p>{s.body}</p>\n ) : (\n <>\n <p>{s.body.intro}</p>\n <ul className="list-disc pl-6 space-y-2">\n {s.body.bullets.map((b) => (\n <li key={b}>{b}</li>\n ))}\n </ul>\n </>\n )}\n </div>\n ))}\n </section>\n </article>\n );\n}\n' }, { "path": "app/sitemap-page/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport Link from "next/link";\nimport { getServerClient, tags, type Product } from "@cimplify/sdk/server";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Sitemap \u2014 ${brand.name}`,\n description: "A human-readable index of every page on this site.",\n};\n\nexport const revalidate = 3600;\n\ninterface SitemapData {\n products: { slug: string; name: string }[];\n categories: { slug: string; name: string }[];\n collections: { slug: string; name: string }[];\n}\n\nasync function getSitemap(): Promise<SitemapData> {\n const client = getServerClient();\n const [pRes, cRes, colRes] = await Promise.all([\n client.catalogue.getProducts(\n { limit: 500 },\n { cacheOptions: { revalidate: 3600, tags: [tags.products()] } },\n ),\n client.catalogue.getCategories({\n cacheOptions: { revalidate: 3600, tags: [tags.categories()] },\n }),\n client.catalogue.getCollections({\n cacheOptions: { revalidate: 3600, tags: [tags.collections()] },\n }),\n ]);\n return {\n products: (pRes.ok ? pRes.value.items : []).map((p: Product) => ({\n slug: p.slug ?? p.id,\n name: p.name,\n })),\n categories: (cRes.ok ? cRes.value : []).map((c) => ({ slug: c.slug, name: c.name })),\n collections: (colRes.ok ? colRes.value : []).map((c) => ({ slug: c.slug, name: c.name })),\n };\n}\n\nconst STATIC_LINKS: { title: string; links: { href: string; label: string }[] }[] = [\n {\n title: "Browse",\n links: [\n { href: "/", label: "Home" },\n { href: "/shop", label: "Shop" },\n { href: "/search", label: "Search" },\n ],\n },\n {\n title: "Account",\n links: [\n { href: "/account", label: "Account" },\n { href: "/account/orders", label: "Orders" },\n { href: "/login", label: "Sign in" },\n { href: "/signup", label: "Create account" },\n { href: "/track-order", label: "Track an order" },\n { href: "/cart", label: "Cart" },\n { href: "/checkout", label: "Checkout" },\n ],\n },\n {\n title: "About",\n links: [\n { href: "/about", label: "About" },\n { href: "/faq", label: "FAQ" },\n { href: "/contact", label: "Contact" },\n ],\n },\n {\n title: "Policies",\n links: [\n { href: "/shipping", label: "Shipping" },\n { href: "/returns", label: "Returns" },\n { href: "/accessibility", label: "Accessibility" },\n { href: "/terms", label: "Terms of Service" },\n { href: "/privacy", label: "Privacy Policy" },\n ],\n },\n {\n title: "Machine-readable",\n links: [\n { href: "/sitemap.xml", label: "sitemap.xml (search engines)" },\n { href: "/llms.txt", label: "llms.txt (LLM agents)" },\n { href: "/robots.txt", label: "robots.txt" },\n { href: "/opensearch.xml", label: "opensearch.xml (browser search)" },\n ],\n },\n];\n\nexport default async function SitemapHtmlPage() {\n const { products, categories, collections } = await getSitemap();\n\n return (\n <article className="max-w-5xl mx-auto px-6 sm:px-8 py-16">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-2">\n Sitemap\n </p>\n <h1 className="text-[clamp(2rem,4vw,2.75rem)] font-semibold mb-2 -tracking-[0.02em]">\n Every page, in one place.\n </h1>\n <p className="text-muted-foreground mb-12">\n For search engines, see <Link href="/sitemap.xml" className="text-primary hover:underline">/sitemap.xml</Link>.\n For LLM agents, see <Link href="/llms.txt" className="text-primary hover:underline">/llms.txt</Link>.\n </p>\n <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-10">\n {STATIC_LINKS.map((s) => (\n <Section key={s.title} title={s.title}>\n {s.links.map((l) => (\n <li key={l.href}>\n <Link href={l.href} className="hover:text-primary transition-colors">\n {l.label}\n </Link>\n </li>\n ))}\n </Section>\n ))}\n {categories.length > 0 && (\n <Section title="Categories">\n {categories.map((c) => (\n <li key={c.slug}>\n <Link href={`/categories/${c.slug}`} className="hover:text-primary transition-colors">\n {c.name}\n </Link>\n </li>\n ))}\n </Section>\n )}\n {collections.length > 0 && (\n <Section title="Collections">\n {collections.map((c) => (\n <li key={c.slug}>\n <Link href={`/collections/${c.slug}`} className="hover:text-primary transition-colors">\n {c.name}\n </Link>\n </li>\n ))}\n </Section>\n )}\n {products.length > 0 && (\n <Section title={`Products (${products.length})`}>\n {products.map((p) => (\n <li key={p.slug}>\n <Link href={`/shop?product=${encodeURIComponent(p.slug)}`} className="hover:text-primary transition-colors">\n {p.name}\n </Link>\n </li>\n ))}\n </Section>\n )}\n </div>\n </article>\n );\n}\n\nfunction Section({ title, children }: { title: string; children: React.ReactNode }) {\n return (\n <div>\n <p className="font-semibold text-[12px] uppercase tracking-[0.12em] text-foreground mb-3">\n {title}\n </p>\n <ul className="space-y-2 m-0 p-0 list-none text-sm text-muted-foreground">\n {children}\n </ul>\n </div>\n );\n}\n' }, { "path": "app/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": "app/collections/[slug]/listing-client.tsx", "kind": "text", "content": '"use client";\n\nimport { ProductGrid } from "@cimplify/sdk/react";\nimport type { Product } from "@cimplify/sdk";\nimport { StoreProductCard } from "@/components/store-product-card";\n\n/**\n * Client island for the collection listing. Receives server-fetched\n * products as props (serializable) and owns the `renderCard` function\n * (which can\'t cross the server/client boundary).\n */\nexport function ListingClient({ products }: { products: Product[] }) {\n return (\n <ProductGrid\n products={products}\n emptyMessage="No products in this collection yet."\n renderCard={(p) => <StoreProductCard product={p} />}\n />\n );\n}\n' }, { "path": "app/collections/[slug]/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Suspense } from "react";\nimport Link from "next/link";\nimport { notFound } from "next/navigation";\nimport {\n getServerClient,\n tags,\n type Collection,\n type Product,\n} from "@cimplify/sdk/server";\nimport { ListingClient } from "./listing-client";\nimport { brand } from "@/lib/brand";\n\n// See app/products/[slug]/page.tsx for the rationale on generateStaticParams.\nexport async function generateStaticParams() {\n const r = await getServerClient().catalogue.getCollections();\n if (!r.ok || r.value.length === 0) {\n return [{ slug: "__placeholder__" }];\n }\n return r.value.map((c) => ({ slug: c.slug ?? c.id }));\n}\n\nexport const revalidate = 3600;\n\ninterface CollectionData {\n collection: Collection;\n products: Product[];\n}\n\ntype CollectionResult =\n | { ok: true; data: CollectionData }\n | { ok: false; code: string };\n\nasync function getCollection(slug: string): Promise<CollectionResult> {\n const client = getServerClient();\n const colRes = await client.catalogue.getCollectionBySlug(slug, {\n cacheOptions: { revalidate: 3600, tags: [tags.collections()] },\n });\n if (!colRes.ok) return { ok: false, code: colRes.error.code };\n\n const r = await client.catalogue.getCollectionProducts(colRes.value.id, undefined, {\n cacheOptions: {\n revalidate: 3600,\n tags: [\n tags.products(),\n tags.collection(colRes.value.id),\n tags.collectionProducts(colRes.value.id),\n ],\n },\n });\n const products = r.ok\n ? ((r.value as { items?: Product[] }).items ?? (r.value as Product[]))\n : [];\n return { ok: true, data: { collection: colRes.value, products } };\n}\n\nexport async function generateMetadata({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}): Promise<Metadata> {\n const { slug } = await params;\n const result = await getCollection(slug);\n if (!result.ok) return {};\n const data = result.data;\n return {\n title: `${data.collection.name} \u2014 ${brand.name}`,\n description: data.collection.description ?? undefined,\n };\n}\n\nexport default async function CollectionPage({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}) {\n return (\n <Suspense fallback={<CollectionSkeleton />}>\n <CollectionContent params={params} />\n </Suspense>\n );\n}\n\nasync function CollectionContent({\n params,\n}: {\n params: Promise<{ slug: string }>;\n}) {\n const { slug } = await params;\n const result = await getCollection(slug);\n if (!result.ok) {\n if (result.code === "NOT_FOUND") notFound();\n return <CollectionSkeleton />;\n }\n const { collection, products } = result.data;\n return (\n <section className="max-w-7xl mx-auto px-6 sm:px-8 pt-12">\n <header className="mb-8 text-center">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-2">\n Collection\n </p>\n <h1 className="font-serif text-[clamp(2rem,4vw,2.75rem)] font-semibold mb-2">\n {collection.name}\n </h1>\n {collection.description && (\n <p className="mx-auto mb-2 max-w-xl text-muted-foreground">\n {collection.description}\n </p>\n )}\n <p className="text-sm text-muted-foreground">\n {products.length} item{products.length === 1 ? "" : "s"}\n </p>\n </header>\n <ListingClient products={products} />\n {products.length === 0 && (\n <p className="text-center mt-8">\n <Link href="/" className="text-primary font-semibold">\n \u2190 Back home\n </Link>\n </p>\n )}\n </section>\n );\n}\n\nfunction CollectionSkeleton() {\n return (\n <section className="max-w-7xl mx-auto px-6 sm:px-8 pt-12">\n <header className="mb-8 text-center">\n <div className="mx-auto h-3 w-20 bg-muted rounded mb-2 animate-pulse" />\n <div className="mx-auto h-10 w-64 bg-muted rounded mb-2 animate-pulse" />\n <div className="mx-auto h-4 w-80 bg-muted rounded animate-pulse" />\n </header>\n <div className="grid grid-cols-2 md:grid-cols-3 gap-4">\n {Array.from({ length: 6 }).map((_, i) => (\n <div key={i} className="aspect-square bg-muted rounded-2xl animate-pulse" />\n ))}\n </div>\n </section>\n );\n}\n' }, { "path": "app/layout.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { Inter, Fraunces } from "next/font/google";\nimport "./globals.css";\nimport { Providers } from "@/components/providers";\nimport { getServerClient, tags } from "@cimplify/sdk/server";\nimport { Header } from "@/components/header";\nimport { Footer } from "@/components/footer";\nimport { ProductModal } from "@/components/product-modal";\nimport { CartDrawer } from "@/components/cart-drawer";\nimport { SupportChat } from "@/components/support-chat";\nimport { OrganizationJsonLd } from "@/components/json-ld";\nimport { Suspense } from "react";\nimport { brand } from "@/lib/brand";\nimport { getSiteUrl } from "@/lib/site-url";\n\nconst inter = Inter({\n subsets: ["latin"],\n variable: "--font-sans",\n display: "swap",\n});\n\nconst fraunces = Fraunces({\n subsets: ["latin"],\n variable: "--font-serif",\n display: "swap",\n});\n\nexport async function generateMetadata(): Promise<Metadata> {\n const siteUrl = await getSiteUrl();\n return {\n metadataBase: new URL(siteUrl),\n title: {\n default: brand.name,\n template: `%s \u2014 ${brand.name}`,\n },\n description: brand.description,\n openGraph: {\n type: "website",\n siteName: brand.name,\n locale: brand.locale,\n },\n twitter: { card: "summary_large_image" },\n };\n}\n\nexport default async function RootLayout({ children }: { children: React.ReactNode }) {\n const server = getServerClient();\n const [bizResult, locResult] = await Promise.all([\n server.business.getInfo({ cacheOptions: { revalidate: 3600, tags: [tags.business()] } }),\n server.business.getLocations({ cacheOptions: { revalidate: 3600, tags: [tags.locations()] } }),\n ]);\n const initialBusiness = bizResult.ok ? bizResult.value : null;\n const initialLocations = locResult.ok ? locResult.value : [];\n\n return (\n <html lang="en" suppressHydrationWarning className={`${inter.variable} ${fraunces.variable}`}>\n <body\n suppressHydrationWarning\n className="min-h-screen flex flex-col bg-background text-foreground font-sans"\n >\n <Suspense fallback={null}>\n <OrganizationJsonLd />\n </Suspense>\n <Providers initialBusiness={initialBusiness} initialLocations={initialLocations}>\n <Header />\n <main className="flex-1 pb-12 w-full">\n <Suspense fallback={null}>{children}</Suspense>\n </main>\n <Footer />\n <Suspense fallback={null}>\n <ProductModal />\n </Suspense>\n <CartDrawer />\n <SupportChat />\n </Providers>\n </body>\n </html>\n );\n}\n' }, { "path": "app/error.tsx", "kind": "text", "content": '"use client";\n\nimport { useEffect } from "react";\nimport Link from "next/link";\n\n/**\n * Root error boundary. Next.js calls this whenever a thrown error escapes\n * a Server Component without being caught by a closer `error.tsx`.\n *\n * Wire `reportError` (Sentry, Datadog, etc.) here so production failures\n * surface \u2014 silently recovering hides real bugs.\n */\nexport default function GlobalError({\n error,\n reset,\n}: {\n error: Error & { digest?: string };\n reset: () => void;\n}) {\n useEffect(() => {\n // TODO: replace with your error reporter\n // eslint-disable-next-line no-console\n console.error("Storefront error:", error);\n }, [error]);\n\n return (\n <section className="max-w-2xl mx-auto px-6 sm:px-8 py-20 text-center">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-3">\n Something broke\n </p>\n <h1 className="font-serif text-[clamp(2rem,4vw,2.75rem)] font-semibold mb-4">\n That wasn&apos;t supposed to happen.\n </h1>\n <p className="text-muted-foreground leading-relaxed mb-8">\n We&apos;ve been notified. Refresh the page, or head back home \u2014\n most of the time the second try just works.\n </p>\n {error.digest && (\n <p className="text-xs font-mono text-muted-foreground mb-6">\n Reference: <code className="text-foreground">{error.digest}</code>\n </p>\n )}\n <div className="flex flex-wrap items-center justify-center gap-3">\n <button\n type="button"\n onClick={reset}\n className="inline-block px-6 py-3 rounded-full bg-primary text-primary-foreground font-semibold text-sm transition-colors hover:bg-primary/90"\n >\n Try again\n </button>\n <Link\n href="/"\n className="inline-block px-6 py-3 rounded-full border border-border hover:bg-muted transition-colors text-sm font-medium"\n >\n Back home\n </Link>\n </div>\n </section>\n );\n}\n' }, { "path": "app/faq/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `FAQ \u2014 ${brand.name}`,\n description: "Delivery, pickup, custom cakes, allergens, payment \u2014 answers to the questions we hear most often.",\n};\n\nexport default function FaqPage() {\n const f = brand.faq;\n return (\n <article className="max-w-3xl 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 {f.eyebrow}\n </p>\n <h1 className="font-serif text-[clamp(2.25rem,5vw,3.5rem)] font-semibold mb-10 -tracking-[0.02em]">\n {f.title}\n </h1>\n <div className="space-y-12">\n {f.sections.map((section) => (\n <section key={section.title}>\n <h2 className="font-serif text-2xl font-semibold mb-5">{section.title}</h2>\n <dl className="space-y-6">\n {section.items.map((item) => (\n <div key={item.q}>\n <dt className="font-medium text-foreground mb-1.5">{item.q}</dt>\n <dd className="text-muted-foreground leading-relaxed">{item.a}</dd>\n </div>\n ))}\n </dl>\n </section>\n ))}\n </div>\n <p className="mt-12 pt-8 border-t border-border text-sm text-muted-foreground">\n {f.contactPrompt}{" "}\n <a\n href={`mailto:${f.contactEmail}`}\n className="text-primary font-semibold hover:underline"\n >\n {f.contactEmail}\n </a>{" "}\n and a real human will reply within 24 hours.\n </p>\n </article>\n );\n}\n' }, { "path": "app/llms.txt/route.ts", "kind": "text", "content": 'import { getServerClient, tags, type Product } from "@cimplify/sdk/server";\nimport { brand } from "@/lib/brand";\nimport { getSiteUrl } from "@/lib/site-url";\n\nexport const revalidate = 3600;\n\nasync function buildLlmsTxt(SITE_URL: string): Promise<string> {\n const client = getServerClient();\n const [productsRes, categoriesRes, collectionsRes] = await Promise.all([\n client.catalogue.getProducts(\n { limit: 500 },\n { cacheOptions: { revalidate: 3600, tags: [tags.products()] } },\n ),\n client.catalogue.getCategories({\n cacheOptions: { revalidate: 3600, tags: [tags.categories()] },\n }),\n client.catalogue.getCollections({\n cacheOptions: { revalidate: 3600, tags: [tags.collections()] },\n }),\n ]);\n\n const products: Product[] = productsRes.ok ? productsRes.value.items : [];\n const categories = categoriesRes.ok ? categoriesRes.value : [];\n const collections = collectionsRes.ok ? collectionsRes.value : [];\n\n const lines: string[] = [];\n lines.push(`# ${brand.name}`);\n lines.push("");\n lines.push(`> ${brand.llms.summary}`);\n lines.push("");\n lines.push("## Browse");\n lines.push(`- [Home](${SITE_URL}/)`);\n lines.push(`- [Shop](${SITE_URL}/shop): Full menu with filter and sort.`);\n\n if (categories.length > 0) {\n lines.push("");\n lines.push("## Categories");\n for (const c of categories) {\n lines.push(\n `- [${c.name}](${SITE_URL}/categories/${c.slug})${c.description ? `: ${c.description}` : ""}`,\n );\n }\n }\n\n if (collections.length > 0) {\n lines.push("");\n lines.push("## Collections");\n for (const c of collections) {\n lines.push(\n `- [${c.name}](${SITE_URL}/collections/${c.slug})${c.description ? `: ${c.description}` : ""}`,\n );\n }\n }\n\n if (products.length > 0) {\n lines.push("");\n lines.push("## Products");\n for (const p of products) {\n const slug = p.slug ?? p.id;\n const price = `${brand.currency} ${p.default_price}`;\n const desc = p.description ? ` \u2014 ${p.description.replace(/\\s+/g, " ").slice(0, 200)}` : "";\n lines.push(`- [${p.name}](${SITE_URL}/shop?product=${slug}) (${price})${desc}`);\n }\n }\n\n lines.push("");\n lines.push("## Information");\n lines.push(`- [About](${SITE_URL}/about)`);\n lines.push(`- [FAQ](${SITE_URL}/faq)`);\n lines.push(`- [Terms of Service](${SITE_URL}/terms)`);\n lines.push(`- [Privacy Policy](${SITE_URL}/privacy)`);\n lines.push(`- [Sitemap (XML)](${SITE_URL}/sitemap.xml)`);\n lines.push("");\n lines.push("## Contact");\n lines.push(`- Email: ${brand.contact.email}`);\n lines.push(`- Phone: ${brand.contact.phone}`);\n lines.push(`- Address: ${brand.contact.address}`);\n\n return lines.join("\\n") + "\\n";\n}\n\n/**\n * `/llms.txt` \u2014 machine-readable site index for LLMs (per llmstxt.org).\n * Lets coding agents and chat assistants find products, categories, and\n * support pages without scraping HTML. Plain Markdown so it streams cheaply\n * into context windows.\n */\nexport async function GET(): Promise<Response> {\n const body = await buildLlmsTxt(await getSiteUrl());\n return new Response(body, {\n headers: {\n "Content-Type": "text/plain; charset=utf-8",\n "Cache-Control": "public, max-age=0, s-maxage=3600, stale-while-revalidate=86400",\n },\n });\n}\n' }, { "path": "app/contact/page.tsx", "kind": "text", "content": 'import type { Metadata } from "next";\nimport { ContactForm } from "./contact-form";\nimport { brand } from "@/lib/brand";\n\nexport const metadata: Metadata = {\n title: `Contact \u2014 ${brand.name}`,\n description: brand.contactPage.body,\n};\n\nexport default function ContactPage() {\n const c = brand.contactPage;\n return (\n <article className="max-w-5xl mx-auto px-6 sm:px-8 py-16">\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-primary mb-2">\n {c.eyebrow}\n </p>\n <h1 className="font-serif text-[clamp(2.25rem,5vw,3.5rem)] font-semibold mb-4 -tracking-[0.02em]">\n {c.title}\n </h1>\n <p className="text-lg text-muted-foreground max-w-2xl mb-12 leading-relaxed">{c.body}</p>\n\n <div className="grid grid-cols-1 lg:grid-cols-[1.5fr_1fr] gap-10 items-start">\n <ContactForm reasons={c.reasons} />\n <aside className="space-y-5 lg:pl-10 lg:border-l border-border">\n <div>\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground mb-3">\n Direct lines\n </p>\n <ul className="space-y-3 list-none p-0 m-0">\n {c.directLines.map((line) => (\n <li key={line.label}>\n <p className="text-xs text-muted-foreground m-0">{line.label}</p>\n <a\n href={line.href}\n className="text-foreground hover:text-primary transition-colors"\n >\n {line.value}\n </a>\n </li>\n ))}\n </ul>\n </div>\n <div>\n <p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground mb-3">\n Visit\n </p>\n <p className="text-foreground m-0">{brand.contact.address}</p>\n <p className="text-xs text-muted-foreground mt-1">{brand.contact.hours}</p>\n </div>\n </aside>\n </div>\n </article>\n );\n}\n' }, { "path": "app/contact/contact-form.tsx", "kind": "text", "content": `"use client";
6246
6246
 
6247
6247
  import { useState } from "react";
6248
6248
 
@@ -6437,7 +6437,7 @@ export const config = {
6437
6437
  matcher: ["/account/:path*"],
6438
6438
  };
6439
6439
  ` }] };
6440
- var REGISTRY = { "quantity-selector": { "name": "quantity-selector", "title": "QuantitySelector", "description": "Controlled increment/decrement quantity input.", "type": "component", "registryDependencies": [], "files": [{ "path": "quantity-selector.tsx", "content": '"use client";\n\nimport React from "react";\nimport { NumberField } from "@base-ui/react/number-field";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface QuantitySelectorClassNames {\n root?: string;\n button?: string;\n value?: string;\n}\n\nexport interface QuantitySelectorProps {\n value: number;\n onChange: (value: number) => void;\n min?: number;\n max?: number;\n className?: string;\n classNames?: QuantitySelectorClassNames;\n}\n\nexport function QuantitySelector({\n value,\n onChange,\n min = 1,\n max,\n className,\n classNames,\n}: QuantitySelectorProps): React.ReactElement {\n return (\n <NumberField.Root\n value={value}\n onValueChange={(val) => {\n if (val != null) {\n onChange(val);\n }\n }}\n min={min}\n max={max}\n step={1}\n >\n <NumberField.Group\n data-cimplify-quantity\n className={cn("inline-flex items-center gap-3 border border-border px-2", className, classNames?.root)}\n >\n <NumberField.Decrement\n aria-label="Decrease quantity"\n data-cimplify-quantity-decrement\n className={cn(\n "w-10 h-10 flex items-center justify-center hover:text-primary transition-colors disabled:opacity-30",\n classNames?.button,\n )}\n >\n &#x2212;\n </NumberField.Decrement>\n <NumberField.Input\n data-cimplify-quantity-value\n aria-live="polite"\n readOnly\n className={cn("w-8 text-center font-medium bg-transparent border-none outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none", classNames?.value)}\n />\n <NumberField.Increment\n aria-label="Increase quantity"\n data-cimplify-quantity-increment\n className={cn(\n "w-10 h-10 flex items-center justify-center hover:text-primary transition-colors disabled:opacity-30",\n classNames?.button,\n )}\n >\n +\n </NumberField.Increment>\n </NumberField.Group>\n </NumberField.Root>\n );\n}\n' }] }, "availability-badge": { "name": "availability-badge", "title": "AvailabilityBadge", "description": "Displays in-stock / out-of-stock status for tracked products.", "type": "component", "registryDependencies": ["cn"], "files": [{ "path": "availability-badge.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { Product } from "@cimplify/sdk";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface AvailabilityBadgeClassNames {\n root?: string;\n dot?: string;\n label?: string;\n lowStock?: string;\n stockCount?: string;\n}\n\nexport interface AvailabilityBadgeProps {\n /** The product to check availability for. */\n product: Product;\n /** Override availability (e.g. from location_availability). */\n isAvailable?: boolean;\n /** Override stock status (e.g. from location_availability). */\n isInStock?: boolean;\n /** Current stock quantity. When provided, enables "Only X left" display. */\n stockQuantity?: number;\n /** Threshold at which stock is considered low. Default: 5. */\n lowStockThreshold?: number;\n /** Location context for availability checks. Passed through as a data attribute. */\n locationId?: string;\n className?: string;\n classNames?: AvailabilityBadgeClassNames;\n}\n\n/**\n * AvailabilityBadge \u2014 displays in-stock / out-of-stock status for tracked products.\n *\n * Returns `null` for products that don\'t have inventory tracking enabled,\n * since there\'s no meaningful stock state to show.\n */\nexport function AvailabilityBadge({\n product,\n isAvailable,\n isInStock,\n stockQuantity,\n lowStockThreshold = 5,\n locationId,\n className,\n classNames,\n}: AvailabilityBadgeProps): React.ReactElement | null {\n if (product.is_tracked !== true) {\n return null;\n }\n\n const outOfStock =\n isInStock === false ||\n isAvailable === false ||\n (stockQuantity !== undefined && stockQuantity <= 0);\n const isLowStock =\n !outOfStock &&\n stockQuantity !== undefined &&\n stockQuantity > 0 &&\n stockQuantity <= lowStockThreshold;\n\n const stockState = outOfStock\n ? "out_of_stock"\n : isLowStock\n ? "low_stock"\n : "in_stock";\n const label = outOfStock ? "Out of Stock" : "In Stock";\n\n return (\n <span\n data-cimplify-availability-badge\n data-stock-state={stockState}\n {...(locationId ? { "data-location-id": locationId } : undefined)}\n className={cn(className, classNames?.root, isLowStock && classNames?.lowStock)}\n style={{ display: "inline-flex", alignItems: "center", gap: "0.375rem" }}\n >\n <span\n data-cimplify-availability-dot\n className={classNames?.dot}\n style={{\n display: "inline-block",\n width: "0.5rem",\n height: "0.5rem",\n borderRadius: "9999px",\n }}\n />\n <span data-cimplify-availability-label className={classNames?.label}>\n {label}\n </span>\n {stockQuantity !== undefined && isLowStock && (\n <span data-cimplify-availability-stock-count className={classNames?.stockCount}>\n Only {stockQuantity} left\n </span>\n )}\n </span>\n );\n}\n' }] }, "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' }] }, "rich-text": { "name": "rich-text", "title": "RichText", "description": "Renders sanitized merchant-authored HTML (descriptions) with theme-aware prose styling. Edge-safe XSS sanitization via the pure-JS xss library.", "type": "component", "registryDependencies": ["cn"], "files": [{ "path": "rich-text.tsx", "content": '"use client";\n\nimport React, { useMemo } from "react";\nimport { FilterXSS, safeAttrValue as baseSafeAttrValue } from "xss";\nimport { cn } from "@cimplify/sdk/react";\n\n// Hosts allowed for <iframe> embeds. Everything else has its src blanked.\nconst ALLOWED_EMBED_HOSTS = new Set<string>([\n "www.youtube.com",\n "youtube.com",\n "www.youtube-nocookie.com",\n "youtube-nocookie.com",\n "player.vimeo.com",\n]);\n\n// Fixed permission policy for embeds. Merchant-supplied `allow` is discarded\n// and replaced with this \u2014 a description must never request camera/mic/etc.\nconst IFRAME_ALLOW =\n "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen";\n\n// `class` is an unrestricted CSS channel in a Tailwind storefront \u2014 a merchant\n// class like `fixed inset-0 z-50` re-enables exactly what the inline-CSS filter\n// below forbids. So `class` values are filtered to this namespace: only the\n// SDK\'s own `cimplify-*` layout primitives survive; everything else is dropped.\nconst SAFE_CLASS_PATTERN = /^cimplify-[a-z0-9-]+$/i;\n\n// Inline CSS properties we permit (via the xss css filter). Anything else \u2014\n// position, z-index, behaviours, url() exfil vectors \u2014 is dropped.\nconst ALLOWED_CSS_PROPERTIES: Record<string, boolean> = {\n "text-align": true,\n color: true,\n "background-color": true,\n "font-weight": true,\n "font-style": true,\n "text-decoration": true,\n "vertical-align": true,\n width: true,\n height: true,\n};\n\nconst STYLEABLE = ["style", "class"];\n\n/**\n * Allowlist covering rich product copy: rich text, spec and\n * comparison tables, figures, definition lists, collapsible sections, and\n * host-locked video embeds. Tags outside this set are dropped; `javascript:`\n * URLs, event handlers, and unlisted CSS properties are stripped.\n */\nconst RICH_TEXT_WHITELIST: Record<string, string[]> = {\n p: STYLEABLE,\n br: [],\n div: STYLEABLE,\n section: ["class"],\n span: STYLEABLE,\n strong: [],\n b: [],\n em: [],\n i: [],\n u: [],\n s: [],\n strike: [],\n del: [],\n ins: [],\n mark: [],\n small: [],\n sub: [],\n sup: [],\n h1: STYLEABLE,\n h2: STYLEABLE,\n h3: STYLEABLE,\n h4: STYLEABLE,\n h5: STYLEABLE,\n h6: STYLEABLE,\n ul: ["class"],\n ol: ["start", "type", "class"],\n li: ["class"],\n dl: ["class"],\n dt: ["class"],\n dd: ["class"],\n blockquote: ["class"],\n code: [],\n pre: ["class"],\n hr: [],\n a: ["href", "title", "target", "rel"],\n img: ["src", "alt", "title", "width", "height", "loading", "class", "style"],\n figure: ["class"],\n figcaption: ["class"],\n details: [],\n summary: [],\n table: ["class", "style"],\n thead: [],\n tbody: [],\n tfoot: [],\n caption: [],\n colgroup: [],\n col: ["span", "style"],\n tr: ["class"],\n th: ["colspan", "rowspan", "scope", "align", "class", "style"],\n td: ["colspan", "rowspan", "align", "class", "style"],\n iframe: ["src", "width", "height", "allow", "allowfullscreen", "frameborder", "title", "loading"],\n};\n\nfunction safeAttrValue(\n tag: string,\n name: string,\n value: string,\n cssFilter: Parameters<typeof baseSafeAttrValue>[3],\n): string {\n if (tag === "iframe" && name === "src") {\n try {\n const url = new URL(value);\n if (url.protocol === "https:" && ALLOWED_EMBED_HOSTS.has(url.hostname)) {\n return value;\n }\n } catch {\n // malformed URL \u2014 fall through to blank\n }\n return "";\n }\n if (tag === "iframe" && name === "allow") {\n return IFRAME_ALLOW;\n }\n if (name === "class") {\n return value\n .split(/\\s+/)\n .filter((token) => SAFE_CLASS_PATTERN.test(token))\n .join(" ");\n }\n return baseSafeAttrValue(tag, name, value, cssFilter);\n}\n\n// Reverse-tabnabbing guard: force `rel="noopener noreferrer"` on any\n// `target="_blank"` anchor. Runs after FILTER.process, whose output has already\n// escaped attribute values \u2014 so no raw `>` can appear inside an attribute and a\n// tag-level regex is safe. Deterministic (identical SSR + client).\nfunction withLinkSafety(html: string): string {\n return html.replace(/<a\\b[^>]*>/gi, (tag) => {\n if (!/\\btarget\\s*=\\s*["\']_blank["\']/i.test(tag)) return tag;\n return /\\brel\\s*=\\s*["\'][^"\']*["\']/i.test(tag)\n ? tag.replace(/\\brel\\s*=\\s*["\'][^"\']*["\']/i, \'rel="noopener noreferrer"\')\n : tag.replace(/>$/, \' rel="noopener noreferrer">\');\n });\n}\n\n// A single configured filter instance, reused across every call.\nconst FILTER = new FilterXSS({\n whiteList: RICH_TEXT_WHITELIST,\n css: { whiteList: ALLOWED_CSS_PROPERTIES },\n safeAttrValue,\n stripIgnoreTag: true,\n stripIgnoreTagBody: ["script", "style"],\n allowCommentTag: false,\n});\n\n/**\n * Sanitize merchant-authored HTML for safe rendering. Runs identically on the\n * Cloudflare Workers edge (SSR) and in the browser \u2014 no DOM dependency.\n */\nexport function sanitizeRichTextHtml(html: string): string {\n return withLinkSafety(FILTER.process(html));\n}\n\n// Theme-aware prose styling. Wired to the consumer\'s own design tokens\n// (`foreground`, `muted-foreground`, `primary`, `border`, `muted`) so rich\n// text inherits each storefront\'s type scale and colours.\nconst RICH_TEXT_PROSE = cn(\n "text-muted-foreground leading-relaxed",\n "[&>*:first-child]:mt-0 [&>*:last-child]:mb-0",\n "[&_p]:my-3",\n "[&_h1]:mt-6 [&_h1]:mb-2 [&_h1]:text-xl [&_h1]:font-semibold [&_h1]:leading-snug [&_h1]:text-foreground",\n "[&_h2]:mt-5 [&_h2]:mb-2 [&_h2]:text-lg [&_h2]:font-semibold [&_h2]:leading-snug [&_h2]:text-foreground",\n "[&_h3]:mt-4 [&_h3]:mb-1.5 [&_h3]:text-base [&_h3]:font-semibold [&_h3]:text-foreground",\n "[&_h4]:mt-4 [&_h4]:mb-1.5 [&_h4]:text-sm [&_h4]:font-semibold [&_h4]:text-foreground",\n "[&_h5]:mt-3 [&_h5]:mb-1 [&_h5]:font-semibold [&_h5]:text-foreground",\n "[&_h6]:mt-3 [&_h6]:mb-1 [&_h6]:font-semibold [&_h6]:text-foreground",\n "[&_strong]:font-semibold [&_strong]:text-foreground [&_b]:font-semibold [&_b]:text-foreground",\n "[&_em]:italic [&_i]:italic",\n "[&_u]:underline [&_ins]:underline [&_s]:line-through [&_del]:line-through",\n "[&_mark]:rounded [&_mark]:px-0.5 [&_sub]:align-sub [&_sub]:text-[0.75em] [&_sup]:align-super [&_sup]:text-[0.75em]",\n "[&_a]:font-medium [&_a]:text-primary [&_a]:underline [&_a]:underline-offset-2",\n "[&_ul]:my-3 [&_ul]:list-disc [&_ul]:pl-5 [&_ul]:space-y-1",\n "[&_ol]:my-3 [&_ol]:list-decimal [&_ol]:pl-5 [&_ol]:space-y-1",\n "[&_li]:pl-1 [&_li>p]:my-0 [&_ul_ul]:my-1 [&_ol_ol]:my-1 [&_ul_ul]:list-[circle]",\n "[&_dl]:my-3 [&_dt]:font-semibold [&_dt]:text-foreground [&_dd]:mb-2 [&_dd]:pl-4",\n "[&_blockquote]:my-3 [&_blockquote]:border-l-2 [&_blockquote]:border-border [&_blockquote]:pl-4 [&_blockquote]:italic",\n "[&_code]:rounded [&_code]:bg-muted [&_code]:px-1 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-[0.85em]",\n "[&_pre]:my-3 [&_pre]:overflow-x-auto [&_pre]:rounded-md [&_pre]:bg-muted [&_pre]:p-3 [&_pre]:text-[0.85em]",\n "[&_pre>code]:bg-transparent [&_pre>code]:p-0",\n "[&_hr]:my-4 [&_hr]:border-border",\n "[&_figure]:my-4 [&_figcaption]:mt-2 [&_figcaption]:text-center [&_figcaption]:text-xs [&_figcaption]:text-muted-foreground",\n "[&_img]:my-3 [&_img]:h-auto [&_img]:max-w-full [&_img]:rounded-md",\n "[&_details]:my-3 [&_details]:rounded-md [&_details]:border [&_details]:border-border [&_details]:p-3",\n "[&_summary]:cursor-pointer [&_summary]:font-medium [&_summary]:text-foreground",\n "[&_table]:my-4 [&_table]:w-full [&_table]:border-collapse [&_table]:text-sm",\n "[&_caption]:caption-bottom [&_caption]:mt-2 [&_caption]:text-xs [&_caption]:text-muted-foreground",\n "[&_th]:border [&_th]:border-border [&_th]:bg-muted [&_th]:px-3 [&_th]:py-2 [&_th]:text-left [&_th]:font-semibold [&_th]:text-foreground",\n "[&_td]:border [&_td]:border-border [&_td]:px-3 [&_td]:py-2 [&_td]:align-top",\n "[&_iframe]:my-4 [&_iframe]:aspect-video [&_iframe]:w-full [&_iframe]:rounded-md",\n);\n\nexport interface RichTextProps\n extends Omit<React.HTMLAttributes<HTMLDivElement>, "dangerouslySetInnerHTML" | "children"> {\n /** Merchant-authored HTML (e.g. a product description). Sanitized before render. */\n html?: string | null;\n}\n\n/**\n * RichText \u2014 renders sanitized merchant-authored HTML with theme-aware prose\n * styling. Headings, lists, tables, figures, quotes, code, collapsible\n * sections and host-locked embeds all display correctly; scripts, event\n * handlers and unlisted CSS are stripped.\n *\n * Returns `null` when there\'s no renderable content.\n */\nexport function RichText({ html, className, ...rest }: RichTextProps): React.ReactElement | null {\n const clean = useMemo(() => (html ? sanitizeRichTextHtml(html) : ""), [html]);\n\n if (!clean) {\n return null;\n }\n\n return (\n <div\n data-cimplify-rich-text\n className={cn(RICH_TEXT_PROSE, className)}\n dangerouslySetInnerHTML={{ __html: clean }}\n {...rest}\n />\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' }] }, "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' }] }, "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' }] }, "bookings-page": { "name": "bookings-page", "title": "BookingsPage", "description": "Account-area page listing a customer's bookings with filters and detail view.", "type": "component", "registryDependencies": ["booking-list", "booking-card", "cn"], "files": [{ "path": "bookings-page.tsx", "content": '"use client";\n\nimport React, { useState, useCallback } from "react";\nimport { Tabs } from "@base-ui/react/tabs";\nimport type { CustomerBooking } from "@cimplify/sdk";\nimport { BookingList } from "@cimplify/sdk/react";\nimport { BookingCard } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface BookingsPageClassNames {\n root?: string;\n header?: string;\n title?: string;\n filters?: string;\n filterButton?: string;\n list?: string;\n detail?: string;\n backButton?: string;\n}\n\nexport interface BookingsPageProps {\n /** Page title. */\n title?: string;\n /** Pre-fetched bookings for SSR. */\n bookings?: CustomerBooking[];\n /** Called when navigating to a booking detail (e.g. for routing). */\n onBookingNavigate?: (booking: CustomerBooking) => void;\n /** Called when cancel is clicked. */\n onCancel?: (booking: CustomerBooking) => void;\n /** Called when reschedule is clicked. */\n onReschedule?: (booking: CustomerBooking) => void;\n /** Show filter tabs. Default: true. */\n showFilters?: boolean;\n /** Custom booking renderer. */\n renderBooking?: (booking: CustomerBooking) => React.ReactNode;\n className?: string;\n classNames?: BookingsPageClassNames;\n}\n\nconst BOOKING_FILTERS: { label: string; value: "all" | "upcoming" | "past" }[] = [\n { label: "All", value: "all" },\n { label: "Upcoming", value: "upcoming" },\n { label: "Past", value: "past" },\n];\n\nexport function BookingsPage({\n title = "My Bookings",\n bookings: bookingsProp,\n onBookingNavigate,\n onCancel,\n onReschedule,\n showFilters = true,\n renderBooking,\n className,\n classNames,\n}: BookingsPageProps): React.ReactElement {\n const [filter, setFilter] = useState<"all" | "upcoming" | "past">("all");\n const [selectedBooking, setSelectedBooking] = useState<CustomerBooking | null>(null);\n\n const handleBookingClick = useCallback(\n (booking: CustomerBooking) => {\n if (onBookingNavigate) {\n onBookingNavigate(booking);\n } else {\n setSelectedBooking(booking);\n }\n },\n [onBookingNavigate],\n );\n\n const handleBack = useCallback(() => {\n setSelectedBooking(null);\n }, []);\n\n if (selectedBooking && !onBookingNavigate) {\n return (\n <div data-cimplify-bookings-page className={cn(className, classNames?.root)}>\n <div data-cimplify-bookings-detail className={classNames?.detail}>\n <button\n type="button"\n onClick={handleBack}\n data-cimplify-bookings-back\n className={classNames?.backButton}\n >\n Back to bookings\n </button>\n <BookingCard\n booking={selectedBooking}\n onCancel={onCancel}\n onReschedule={onReschedule}\n />\n </div>\n </div>\n );\n }\n\n return (\n <div data-cimplify-bookings-page className={cn(className, classNames?.root)}>\n <div data-cimplify-bookings-header className={classNames?.header}>\n <h1 data-cimplify-bookings-title className={classNames?.title}>\n {title}\n </h1>\n </div>\n\n {showFilters && (\n <Tabs.Root\n value={filter}\n onValueChange={(value) => setFilter(value as "all" | "upcoming" | "past")}\n >\n <Tabs.List data-cimplify-bookings-filters className={classNames?.filters}>\n {BOOKING_FILTERS.map((f) => (\n <Tabs.Tab\n key={f.value}\n value={f.value}\n data-cimplify-booking-filter\n data-selected={filter === f.value || undefined}\n className={classNames?.filterButton}\n >\n {f.label}\n </Tabs.Tab>\n ))}\n </Tabs.List>\n </Tabs.Root>\n )}\n\n <div data-cimplify-bookings-list className={classNames?.list}>\n <BookingList\n bookings={bookingsProp}\n filter={filter}\n onCancel={onCancel}\n onReschedule={onReschedule}\n onBookingClick={handleBookingClick}\n renderBooking={renderBooking}\n />\n </div>\n </div>\n );\n}\n' }] }, "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' }] }, "product-page": { "name": "product-page", "title": "ProductPage", "description": "Smart product page resolver with per-slug and per-type template routing.", "type": "component", "registryDependencies": ["default-product-layout", "food-product-layout", "wholesale-product-layout", "service-product-layout", "digital-product-layout", "cn"], "files": [{ "path": "product-page.tsx", "content": '"use client";\n\nimport React, { useEffect } from "react";\nimport type { Product, ProductWithDetails } from "@cimplify/sdk";\nimport { PRODUCT_TYPE, RENDER_HINT } from "@cimplify/sdk";\nimport { trackProductView } from "./hooks/use-product";\nimport { useOptionalCimplifyClient } from "@cimplify/sdk/react";\nimport type { ProductLayoutProps } from "@cimplify/sdk/react";\nimport { DefaultProductLayout } from "@cimplify/sdk/react";\nimport { FoodProductLayout } from "@cimplify/sdk/react";\nimport { WholesaleProductLayout } from "@cimplify/sdk/react";\nimport { ServiceProductLayout } from "@cimplify/sdk/react";\nimport { DigitalProductLayout } from "@cimplify/sdk/react";\nimport { BundleProductLayout } from "./layouts/bundle-product-layout";\nimport { CompositeProductLayout } from "./layouts/composite-product-layout";\nimport type { AddToCartOptions } from "@cimplify/sdk/react";\nimport { useProduct } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport type { ProductLayoutProps };\n\nexport enum ProductTemplate {\n Default = "default",\n Food = "food",\n Wholesale = "wholesale",\n Service = "service",\n Digital = "digital",\n Bundle = "bundle",\n Composite = "composite",\n Physical = "physical",\n}\n\nexport interface ProductPageClassNames {\n root?: string;\n loading?: string;\n}\n\nexport interface ProductPageProps {\n /** Product slug or ID \u2014 used for client-side fetch when `product` is not provided. */\n productId?: string;\n /** Pre-fetched product for SSR. Skips client-side fetch when provided. */\n product?: ProductWithDetails;\n /** Per-slug page map. Highest priority \u2014 maps a product slug to a custom component. */\n pages?: Record<string, React.ComponentType<ProductLayoutProps>>;\n /** Per-type template map. Overrides built-in layouts for specific template keys. */\n templates?: Partial<Record<ProductTemplate | string, React.ComponentType<ProductLayoutProps>>>;\n /** Override add-to-cart behavior. */\n onAddToCart?: (\n product: ProductWithDetails,\n quantity: number,\n options: AddToCartOptions,\n ) => void | Promise<void>;\n /** Custom image renderer (e.g. Next.js Image). */\n renderImage?: (props: { src: string; alt: string; className?: string }) => React.ReactNode;\n /** Breadcrumb renderer. Receives the product for context. */\n renderBreadcrumb?: (product: ProductWithDetails) => React.ReactNode;\n /** Pre-fetched related products. */\n relatedProducts?: Product[];\n /** Show related products section. Default: true. */\n showRelated?: boolean;\n className?: string;\n classNames?: ProductPageClassNames;\n}\n\nconst BUILT_IN_LAYOUTS: Record<string, React.ComponentType<ProductLayoutProps>> = {\n [ProductTemplate.Food]: FoodProductLayout,\n [ProductTemplate.Wholesale]: WholesaleProductLayout,\n [ProductTemplate.Service]: ServiceProductLayout,\n [ProductTemplate.Digital]: DigitalProductLayout,\n [ProductTemplate.Bundle]: BundleProductLayout,\n [ProductTemplate.Composite]: CompositeProductLayout,\n [ProductTemplate.Default]: DefaultProductLayout,\n};\n\nfunction resolveTemplateKey(product: ProductWithDetails): ProductTemplate | string {\n const metaTemplate = product.metadata?.page_template;\n if (typeof metaTemplate === "string" && metaTemplate.trim()) {\n return metaTemplate.trim();\n }\n\n if (product.type === PRODUCT_TYPE.Bundle) {\n return ProductTemplate.Bundle;\n }\n\n if (product.type === PRODUCT_TYPE.Composite) {\n return ProductTemplate.Composite;\n }\n\n if (product.quantity_pricing && product.quantity_pricing.length > 1) {\n return ProductTemplate.Wholesale;\n }\n\n if (product.type === PRODUCT_TYPE.Service) {\n return ProductTemplate.Service;\n }\n\n if (product.type === PRODUCT_TYPE.Digital) {\n return ProductTemplate.Digital;\n }\n\n if (product.render_hint === RENDER_HINT.Food) {\n return ProductTemplate.Food;\n }\n\n if (product.render_hint === RENDER_HINT.Physical) {\n return ProductTemplate.Physical;\n }\n\n return ProductTemplate.Default;\n}\n\nfunction resolveLayout(\n product: ProductWithDetails,\n pages?: Record<string, React.ComponentType<ProductLayoutProps>>,\n templates?: Partial<Record<string, React.ComponentType<ProductLayoutProps>>>,\n): React.ComponentType<ProductLayoutProps> {\n // 1. Per-slug page (AI-generated, highest priority)\n if (pages?.[product.slug]) {\n return pages[product.slug];\n }\n\n const key = resolveTemplateKey(product);\n\n // 2. Consumer-provided template override\n if (templates?.[key]) {\n return templates[key];\n }\n\n // 3. Built-in layout\n if (BUILT_IN_LAYOUTS[key]) {\n return BUILT_IN_LAYOUTS[key];\n }\n\n // 4. Fallback\n return DefaultProductLayout;\n}\n\nexport function ProductPage({\n productId,\n product: productProp,\n pages,\n templates,\n onAddToCart,\n renderImage,\n renderBreadcrumb,\n relatedProducts,\n showRelated = true,\n className,\n classNames,\n}: ProductPageProps): React.ReactElement {\n const resolvedId = productId || productProp?.slug || productProp?.id || "";\n const { product: fetched, isLoading } = useProduct(resolvedId, {\n enabled: !productProp && resolvedId.length > 0,\n });\n const product = productProp ?? fetched;\n\n const context = useOptionalCimplifyClient();\n useEffect(() => {\n if (product && context) {\n trackProductView(context.client, product);\n }\n }, [context, product]);\n\n if (isLoading && !product) {\n return (\n <div\n data-cimplify-product-page\n aria-busy="true"\n className={cn(className, classNames?.root, classNames?.loading)}\n >\n <div className="grid lg:grid-cols-2 gap-8 lg:gap-12 animate-pulse">\n <div className="aspect-square bg-muted" />\n <div className="space-y-4 py-4">\n <div className="h-8 w-3/5 bg-muted" />\n <div className="h-6 w-2/5 bg-muted" />\n <div className="h-4 w-4/5 bg-muted" />\n <div className="h-4 w-3/5 bg-muted" />\n <div className="h-14 w-full bg-muted mt-8" />\n </div>\n </div>\n </div>\n );\n }\n\n if (!product) {\n return (\n <div data-cimplify-product-page className={cn(className, classNames?.root)}>\n <p className="text-muted-foreground">Product not found.</p>\n </div>\n );\n }\n\n const Layout = resolveLayout(product, pages, templates);\n\n return (\n <div data-cimplify-product-page className={cn(className, classNames?.root)}>\n <Layout\n product={product}\n onAddToCart={onAddToCart}\n renderImage={renderImage}\n renderBreadcrumb={renderBreadcrumb}\n relatedProducts={relatedProducts}\n showRelated={showRelated}\n />\n </div>\n );\n}\n' }] }, "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' }] }, "staff-picker": { "name": "staff-picker", "title": "StaffPicker", "description": "Staff member selection list with avatar and bio.", "type": "component", "registryDependencies": ["cn"], "files": [{ "path": "staff-picker.tsx", "content": '"use client";\n\nimport { Radio } from "@base-ui/react/radio";\nimport { RadioGroup } from "@base-ui/react/radio-group";\nimport React from "react";\nimport type { Staff } from "@cimplify/sdk";\nimport { cn } from "@cimplify/sdk/react";\n\nconst ANY_VALUE = "__any__";\n\nexport interface StaffPickerClassNames {\n root?: string;\n option?: string;\n avatar?: string;\n name?: string;\n bio?: string;\n}\n\nexport interface StaffPickerProps {\n /** List of available staff members. */\n staff: Staff[];\n /** Currently selected staff ID, or null for "Any available". */\n selectedStaffId?: string | null;\n /** Called when a staff member is selected. Passes null for "Any available". */\n onStaffSelect?: (staffId: string | null) => void;\n /** Show "Any available" option. Default: true. */\n showAnyOption?: boolean;\n /** Label for the "Any available" option. */\n anyLabel?: string;\n className?: string;\n classNames?: StaffPickerClassNames;\n}\n\nexport function StaffPicker({\n staff,\n selectedStaffId,\n onStaffSelect,\n showAnyOption = true,\n anyLabel = "Any available",\n className,\n classNames,\n}: StaffPickerProps): React.ReactElement {\n const groupValue =\n selectedStaffId === null ? ANY_VALUE : (selectedStaffId ?? "");\n\n return (\n <RadioGroup\n data-cimplify-staff-picker\n className={cn(className, classNames?.root)}\n value={groupValue}\n onValueChange={(value) => {\n onStaffSelect?.(value === ANY_VALUE ? null : value);\n }}\n >\n {showAnyOption && (\n <Radio.Root\n value={ANY_VALUE}\n data-cimplify-staff-option\n data-selected={selectedStaffId === null || undefined}\n data-any\n className={classNames?.option}\n >\n <span data-cimplify-staff-name className={classNames?.name}>\n {anyLabel}\n </span>\n </Radio.Root>\n )}\n {staff.map((member) => (\n <Radio.Root\n key={member.id}\n value={member.id}\n data-cimplify-staff-option\n data-selected={selectedStaffId === member.id || undefined}\n className={classNames?.option}\n >\n {member.avatar_url && (\n <img\n src={member.avatar_url}\n alt={member.name}\n data-cimplify-staff-avatar\n className={classNames?.avatar}\n />\n )}\n <span data-cimplify-staff-name className={classNames?.name}>\n {member.name}\n </span>\n {member.bio && (\n <span data-cimplify-staff-bio className={classNames?.bio}>\n {member.bio}\n </span>\n )}\n </Radio.Root>\n ))}\n </RadioGroup>\n );\n}\n' }] }, "chat-widget": { "name": "chat-widget", "title": "ChatWidget", "description": "Embeddable chat widget with AI shopping assistant powered by the support channel.", "type": "component", "registryDependencies": ["cn"], "files": [{ "path": "chat-widget.tsx", "content": '"use client";\n\nimport React, {\n useState,\n useRef,\n useCallback,\n type KeyboardEvent,\n} from "react";\nimport type { CimplifyClient } from "../client";\nimport { CimplifyError } from "@cimplify/sdk";\nimport type { ChatMessage, ChatUploadAttachment, ChatWidgetStarter } from "../types/support";\nimport { isOptimistic } from "./chat-model";\nimport {\n ChatKitProgram,\n ChatReplyCards,\n MessageBubble,\n Seam,\n chatTimeline,\n dayLabel,\n deliveryState,\n groupPosition,\n proseOf,\n sameDay,\n type GenUIActionEvent,\n} from "./chat-kit";\nimport { useChat } from "./hooks/use-chat";\nimport { useOptionalCimplifyClient } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nconst MAX_ATTACHMENT_BYTES = 5 * 1024 * 1024;\nconst KEEP_DISMISSED_KEY = "cimplify_keep_chat_dismissed";\n/** How close to the bottom still counts as reading the live edge. */\nconst AT_BOTTOM_SLACK_PX = 48;\n\n/* \u2500\u2500\u2500 Public API \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\nexport interface ChatWidgetClassNames {\n root?: string;\n bubble?: string;\n panel?: string;\n header?: string;\n messages?: string;\n input?: string;\n welcome?: string;\n}\n\nexport interface ChatWidgetProps {\n client?: CimplifyClient;\n /** Business display name shown in the header */\n businessName?: string;\n /** Greeting text on the welcome screen */\n greeting?: string;\n /** Subtitle on the welcome screen */\n subtitle?: string;\n /** Conversation starters */\n starters?: ChatWidgetStarter[];\n /** Input placeholder */\n placeholder?: string;\n /** Position of the bubble */\n position?: "bottom-right" | "bottom-left";\n /** Start with the panel open */\n defaultOpen?: boolean;\n /** Poll interval in ms (default: 3000) */\n pollInterval?: number;\n /** Currency for structured product cards (e.g. "GHS"). */\n currency?: string;\n /**\n * Receives taps on GenUI buttons and structured cards\n * (`chat:view_product:{id}`, `chat:view_order:{id}`). Without it, cards\n * render read-only and program buttons no-op.\n */\n onGenUIAction?: (event: GenUIActionEvent) => void;\n className?: string;\n classNames?: ChatWidgetClassNames;\n}\n\nexport function ChatWidget({\n client: clientProp,\n businessName: businessNameProp,\n greeting = "Hi there!",\n subtitle = "Ask us anything \u2014 we typically reply in a few seconds.",\n starters,\n placeholder = "Type a message\\u2026",\n position = "bottom-right",\n defaultOpen = false,\n pollInterval,\n currency: currencyProp,\n onGenUIAction,\n className,\n classNames,\n}: ChatWidgetProps) {\n const context = useOptionalCimplifyClient();\n const client = clientProp ?? context?.client;\n // Provider-wrapped hosts get the real identity with zero config.\n const businessName = businessNameProp ?? context?.business?.name ?? "Support";\n const currency = currencyProp ?? context?.baseCurrency;\n\n const [open, setOpen] = useState(defaultOpen);\n const [readAt, setReadAt] = useState<string | null>(null);\n const [attachError, setAttachError] = useState<CimplifyError | null>(null);\n const [replyTo, setReplyTo] = useState<ChatMessage | null>(null);\n\n const {\n messages,\n isLoading,\n isSending,\n awaitingReply,\n seenAt,\n error,\n isActive,\n humanRequested,\n claimedAs,\n signOut,\n send,\n react,\n requestHuman,\n startConversation,\n loadOlder,\n mayHaveOlder,\n openIfExisting,\n } = useChat({ client, starters, pollInterval });\n const showTyping = isSending || awaitingReply;\n\n const attachPhoto = useCallback(\n async (file: File) => {\n if (!client) return;\n setAttachError(null);\n if (!file.type.startsWith("image/")) {\n setAttachError(new CimplifyError("invalid_attachment", "Only photos can be attached."));\n return;\n }\n if (file.size > MAX_ATTACHMENT_BYTES) {\n setAttachError(new CimplifyError("attachment_too_large", "Photos must be under 5MB."));\n return;\n }\n const uploaded = await client.uploads.upload(file);\n if (!uploaded.ok) {\n setAttachError(uploaded.error);\n return;\n }\n const attachment: ChatUploadAttachment = {\n upload_id: uploaded.value.id,\n url: uploaded.value.url,\n mime_type: uploaded.value.content_type,\n filename: uploaded.value.filename,\n size: uploaded.value.size_bytes,\n };\n await send("", { attachments: [attachment] });\n },\n [client, send],\n );\n\n const handleSend = useCallback(\n (content: string) => {\n setAttachError(null);\n const target = replyTo;\n setReplyTo(null);\n return send(content, target ? { replyTo: target } : undefined);\n },\n [send, replyTo],\n );\n\n // Unread badge: incoming messages newer than the watermark snapshotted\n // when the panel last closed.\n const unread = open\n ? 0\n : messages.filter(\n (m) => m.sender_type !== "customer" && (!readAt || m.created_at > readAt),\n ).length;\n\n useState(() => {\n if (defaultOpen) void openIfExisting();\n });\n\n const handleOpen = useCallback(() => {\n setOpen(true);\n void openIfExisting();\n }, [openIfExisting]);\n\n const handleClose = useCallback(() => {\n const newest = messages[messages.length - 1];\n setReadAt(newest?.created_at ?? null);\n setOpen(false);\n }, [messages]);\n\n const isLeft = position === "bottom-left";\n\n return (\n <div\n className={cn(\n "fixed bottom-6 z-[9999]",\n isLeft ? "left-6" : "right-6",\n className,\n classNames?.root,\n )}\n >\n {/* Bubble */}\n {!open && (\n <Bubble\n unread={unread}\n onClick={handleOpen}\n className={classNames?.bubble}\n />\n )}\n\n {/* Panel */}\n {open && (\n <Panel\n client={client}\n businessName={businessName}\n greeting={greeting}\n subtitle={subtitle}\n starters={starters}\n placeholder={placeholder}\n messages={messages}\n isLoading={isLoading}\n isSending={isSending}\n isTyping={showTyping}\n seenAt={seenAt}\n readAt={readAt}\n error={error ?? attachError}\n isActive={isActive}\n isLeft={isLeft}\n humanRequested={humanRequested}\n mayHaveOlder={mayHaveOlder}\n replyTo={replyTo}\n claimedAs={claimedAs}\n onSignOut={() => void signOut()}\n onClose={handleClose}\n onSend={handleSend}\n onReact={react}\n onReply={setReplyTo}\n onCancelReply={() => setReplyTo(null)}\n onRequestHuman={() => void requestHuman()}\n onLoadOlder={() => void loadOlder()}\n onAttach={attachPhoto}\n onStartConversation={startConversation}\n currency={currency}\n onGenUIAction={onGenUIAction}\n classNames={classNames}\n />\n )}\n </div>\n );\n}\n\n/* \u2500\u2500\u2500 Bubble \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\nfunction Bubble({\n unread,\n onClick,\n className,\n}: {\n unread: number;\n onClick: () => void;\n className?: string;\n}) {\n return (\n <button\n type="button"\n onClick={onClick}\n aria-label="Open chat"\n className={cn(\n "relative flex h-14 w-14 items-center justify-center rounded-full",\n "bg-foreground text-background shadow-lg",\n "transition-transform duration-300 ease-out",\n "hover:scale-[1.08] active:scale-95",\n className,\n )}\n >\n <ChatIcon className="h-6 w-6" />\n {unread > 0 && (\n <span\n className={cn(\n "absolute -right-0.5 -top-0.5 flex h-5 w-5 items-center justify-center",\n "rounded-full bg-destructive text-[10px] font-bold text-white",\n "ring-2 ring-background",\n )}\n >\n {unread > 9 ? "9+" : unread}\n </span>\n )}\n </button>\n );\n}\n\n/* \u2500\u2500\u2500 Panel \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\nfunction Panel({\n client,\n businessName,\n greeting,\n subtitle,\n starters,\n placeholder,\n messages,\n isLoading,\n isSending,\n isTyping,\n seenAt,\n readAt,\n error,\n isActive,\n isLeft,\n humanRequested,\n mayHaveOlder,\n replyTo,\n claimedAs,\n onSignOut,\n onClose,\n onSend,\n onReact,\n onReply,\n onCancelReply,\n onRequestHuman,\n onLoadOlder,\n onAttach,\n onStartConversation,\n currency,\n onGenUIAction,\n classNames,\n}: {\n client: CimplifyClient | undefined;\n businessName: string;\n greeting: string;\n subtitle: string;\n starters?: ChatWidgetStarter[];\n placeholder: string;\n messages: ChatMessage[];\n isLoading: boolean;\n isSending: boolean;\n isTyping: boolean;\n seenAt: string | null;\n readAt: string | null;\n error: CimplifyError | null;\n isActive: boolean;\n isLeft: boolean;\n humanRequested: boolean;\n mayHaveOlder: boolean;\n replyTo: ChatMessage | null;\n claimedAs: string | null;\n onSignOut: () => void;\n onClose: () => void;\n onSend: (content: string) => Promise<boolean>;\n onReact: (message: ChatMessage, emoji: string) => Promise<boolean>;\n onReply: (message: ChatMessage) => void;\n onCancelReply: () => void;\n onRequestHuman: () => void;\n onLoadOlder: () => void;\n onAttach: (file: File) => Promise<void>;\n onStartConversation: (text?: string) => Promise<void>;\n currency?: string;\n onGenUIAction?: (event: GenUIActionEvent) => void;\n classNames?: ChatWidgetClassNames;\n}) {\n return (\n <div\n className={cn(\n "flex flex-col overflow-hidden rounded-2xl bg-background shadow-2xl",\n "w-[400px] max-w-[calc(100vw-3rem)]",\n "h-[min(600px,calc(100vh-6rem))]",\n "animate-in fade-in slide-in-from-bottom-4 duration-300",\n isLeft ? "origin-bottom-left" : "origin-bottom-right",\n classNames?.panel,\n )}\n >\n {/* Header */}\n <Header\n businessName={businessName}\n claimedAs={claimedAs}\n onSignOut={onSignOut}\n onClose={onClose}\n className={classNames?.header}\n />\n\n {/* Body */}\n {!isActive && !isLoading ? (\n <Welcome\n greeting={greeting}\n subtitle={subtitle}\n starters={starters}\n onStarter={onStartConversation}\n className={classNames?.welcome}\n />\n ) : (\n <MessageList\n messages={messages}\n businessName={businessName}\n isLoading={isLoading}\n isSending={isTyping}\n seenAt={seenAt}\n readAt={readAt}\n humanRequested={humanRequested}\n mayHaveOlder={mayHaveOlder}\n onReact={onReact}\n onReply={onReply}\n onRequestHuman={onRequestHuman}\n onLoadOlder={onLoadOlder}\n currency={currency}\n onGenUIAction={onGenUIAction}\n className={classNames?.messages}\n />\n )}\n\n {isActive && !claimedAs && client && <KeepConversation client={client} />}\n\n {humanRequested && isActive && (\n <div className="flex items-center gap-2 border-t border-border bg-muted/50 px-4 py-2 text-[11px] text-muted-foreground">\n <span className="h-1.5 w-1.5 flex-shrink-0 animate-pulse rounded-full bg-amber-500" />\n Waiting for {businessName} \u2014 usually replies in minutes\n </div>\n )}\n\n {/* Error */}\n {error != null && (\n <div className="border-t border-destructive/20 bg-destructive/10 px-4 py-2 text-xs text-destructive">\n {error.userMessage || "Something went wrong. Please try again."}\n </div>\n )}\n\n {replyTo && (\n <div className="flex items-start gap-2 border-t border-border bg-muted/50 px-4 py-2">\n <span className="w-[3px] shrink-0 self-stretch rounded-full bg-foreground/40" />\n <div className="min-w-0 flex-1">\n <div className="text-[11px] font-semibold text-foreground">\n Replying to {replyTo.sender_type === "customer" ? "yourself" : businessName}\n </div>\n <div className="truncate text-[11px] text-muted-foreground">\n {proseOf(replyTo.content) || "Photo"}\n </div>\n </div>\n <button\n type="button"\n aria-label="Cancel reply"\n onClick={onCancelReply}\n className="flex-shrink-0 p-0.5 text-muted-foreground hover:text-foreground"\n >\n <CloseIcon className="h-3.5 w-3.5" />\n </button>\n </div>\n )}\n\n {/* Always present: gating on an active conversation left a visitor who\n wants to type their own question with only the starter buttons. */}\n <ChatInput\n placeholder={placeholder}\n isSending={isSending}\n onSend={isActive ? onSend : async (text) => { await onStartConversation(text); return true; }}\n onAttach={onAttach}\n className={classNames?.input}\n />\n\n\n {/* Footer */}\n <div className="flex-shrink-0 border-t border-border bg-background px-4 py-1.5 text-center text-[10px] text-muted-foreground">\n Powered by{" "}\n <span className="font-semibold text-foreground">Cimplify</span>\n </div>\n </div>\n );\n}\n\n/* \u2500\u2500\u2500 Header \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\nfunction Header({\n businessName,\n claimedAs,\n onSignOut,\n onClose,\n className,\n}: {\n businessName: string;\n claimedAs: string | null;\n onSignOut: () => void;\n onClose: () => void;\n className?: string;\n}) {\n return (\n <div\n className={cn(\n "flex flex-shrink-0 items-center gap-3 bg-foreground px-5 py-4 text-background",\n className,\n )}\n >\n <div className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-[10px] bg-background/15">\n <ChatIcon className="h-5 w-5" />\n </div>\n <div className="min-w-0 flex-1">\n <div className="truncate text-[15px] font-semibold leading-tight">\n {businessName}\n </div>\n {claimedAs ? (\n <div className="flex items-center gap-1 truncate text-xs text-background/60">\n <span className="truncate">Chatting as {claimedAs}</span>\n <span aria-hidden="true">\xB7</span>\n <button\n type="button"\n onClick={onSignOut}\n className="flex-shrink-0 underline underline-offset-2 hover:text-background"\n >\n Not you?\n </button>\n </div>\n ) : (\n <div className="flex items-center gap-1.5 text-xs text-background/60">\n <span className="h-[7px] w-[7px] flex-shrink-0 rounded-full bg-emerald-400" />\n Online\n </div>\n )}\n </div>\n <button\n type="button"\n onClick={onClose}\n aria-label="Close chat"\n className={cn(\n "flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg",\n "bg-background/10 text-background/70 transition-colors hover:bg-background/20 hover:text-background",\n )}\n >\n <CloseIcon className="h-[18px] w-[18px]" />\n </button>\n </div>\n );\n}\n\n/* \u2500\u2500\u2500 Welcome \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\nfunction Welcome({\n greeting,\n subtitle,\n starters,\n onStarter,\n className,\n}: {\n greeting: string;\n subtitle: string;\n starters?: ChatWidgetStarter[];\n onStarter: (text?: string) => Promise<void>;\n className?: string;\n}) {\n const defaultStarters: ChatWidgetStarter[] = [\n { icon: "\\ud83d\\udd0d", text: "Help me find a product" },\n { icon: "\\ud83d\\uded2", text: "Check my cart" },\n { icon: "\\ud83d\\udce6", text: "Track my order" },\n ];\n\n const items = starters ?? defaultStarters;\n\n return (\n <div\n className={cn(\n "flex flex-1 flex-col items-center justify-center overflow-y-auto bg-muted/50 px-8 py-10 text-center",\n className,\n )}\n >\n <div className="mb-5 flex h-16 w-16 items-center justify-center rounded-2xl bg-muted">\n <ChatIcon className="h-7 w-7 text-muted-foreground" />\n </div>\n <h3 className="mb-2 text-xl font-bold text-foreground">{greeting}</h3>\n <p className="mb-6 max-w-[280px] text-sm leading-relaxed text-muted-foreground">\n {subtitle}\n </p>\n <div className="flex w-full max-w-[300px] flex-col gap-2">\n {items.map((s) => (\n <button\n key={s.text}\n type="button"\n onClick={() => onStarter(s.text)}\n className={cn(\n "flex items-center gap-3 rounded-xl border border-border bg-background px-4 py-3",\n "text-left text-[13px] font-medium text-foreground",\n "transition-all hover:-translate-y-px hover:border-foreground hover:shadow-sm",\n )}\n >\n {s.icon && (\n <span className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-muted text-[15px]">\n {s.icon}\n </span>\n )}\n <span>{s.text}</span>\n </button>\n ))}\n </div>\n </div>\n );\n}\n\n/* \u2500\u2500\u2500 Messages \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\nconst QUICK_REACTIONS = ["\u{1F44D}", "\u2764\uFE0F"] as const;\n\nfunction LetterAvatar({ name }: { name: string }) {\n return (\n <span className="flex size-6 items-center justify-center rounded-full bg-foreground text-[10px] font-bold text-background">\n {name.charAt(0).toUpperCase()}\n </span>\n );\n}\n\nfunction MessageList({\n messages,\n businessName,\n isLoading,\n isSending,\n seenAt,\n readAt,\n humanRequested,\n mayHaveOlder,\n onReact,\n onReply,\n onRequestHuman,\n onLoadOlder,\n currency,\n onGenUIAction,\n className,\n}: {\n messages: ChatMessage[];\n businessName: string;\n isLoading: boolean;\n isSending: boolean;\n seenAt: string | null;\n readAt: string | null;\n humanRequested: boolean;\n mayHaveOlder: boolean;\n onReact: (message: ChatMessage, emoji: string) => Promise<boolean>;\n onReply: (message: ChatMessage) => void;\n onRequestHuman: () => void;\n onLoadOlder: () => void;\n currency?: string;\n onGenUIAction?: (event: GenUIActionEvent) => void;\n className?: string;\n}) {\n const scrolledUpRef = useRef(false);\n const timeline = chatTimeline(messages, { readAt, rendersPrograms: true });\n const { bubbles, reactionsByTarget, firstUnreadId, unreadOnOpen, firstAgentMessageId } =\n timeline;\n const lastBubble = bubbles[bubbles.length - 1];\n const offerHuman = !humanRequested && lastBubble?.sender_type === "bot" && !isSending;\n\n const handleScroll = (event: React.UIEvent<HTMLDivElement>) => {\n const node = event.currentTarget;\n scrolledUpRef.current =\n node.scrollHeight - node.scrollTop - node.clientHeight >= AT_BOTTOM_SLACK_PX;\n };\n\n return (\n <div\n onScroll={handleScroll}\n style={{ background: "var(--color-chat-canvas, var(--color-muted, #f3f5f3))" }}\n className={cn("flex flex-1 flex-col gap-0.5 overflow-y-auto px-3.5 py-3.5", className)}\n >\n {isLoading && messages.length === 0 && (\n <div className="flex flex-1 items-center justify-center">\n <TypingDots />\n </div>\n )}\n\n {mayHaveOlder && (\n <div className="flex justify-center pb-1">\n <button\n type="button"\n onClick={onLoadOlder}\n className="rounded-full border border-foreground/[0.06] bg-background px-3.5 py-[7px] text-xs font-semibold text-muted-foreground shadow-[0_1px_2px_rgba(15,23,42,0.04)]"\n >\n Show earlier messages\n </button>\n </div>\n )}\n\n {bubbles.map((msg, i) => {\n const prev = bubbles[i - 1];\n const newDay = !prev || !sameDay(prev.created_at, msg.created_at);\n const group = groupPosition(timeline, i);\n const startsRun = group === "single" || group === "first";\n const delivery = deliveryState(msg, timeline, seenAt, isOptimistic);\n\n return (\n <div\n key={msg.id}\n className={cn("flex flex-col gap-1.5", startsRun && i > 0 && "mt-2.5")}\n >\n {newDay && <Seam variant="chip" label={dayLabel(msg.created_at)} />}\n {msg.id === firstUnreadId && (\n <Seam\n variant="unread"\n label={`${unreadOnOpen} new message${unreadOnOpen === 1 ? "" : "s"}`}\n />\n )}\n {msg.id === firstAgentMessageId && <Seam label="A person joined" />}\n {msg.sender_type === "system" ? (\n <Seam label="Passing you to the team" />\n ) : (\n <div className="group relative">\n {msg.sender_type !== "customer" && !isOptimistic(msg) && (\n <HoverRail message={msg} onReact={onReact} onReply={onReply} />\n )}\n <MessageBubble\n message={msg}\n reactions={reactionsByTarget.get(msg.id)}\n businessName={businessName}\n group={group}\n delivery={delivery}\n onReply={onReply}\n avatar={<LetterAvatar name={businessName} />}\n renderProgram={(source) => (\n <ChatKitProgram source={source} onAction={onGenUIAction} />\n )}\n renderCards={(target) => (\n <ChatReplyCards message={target} currency={currency} onAction={onGenUIAction} />\n )}\n onQuoteMiss={async () => {\n onLoadOlder();\n return true;\n }}\n />\n </div>\n )}\n </div>\n );\n })}\n\n {isSending && (\n <div className="mr-auto mt-1">\n <TypingDots />\n </div>\n )}\n\n {offerHuman && (\n <div className="mt-2">\n <button\n type="button"\n onClick={onRequestHuman}\n className="rounded-full border border-border bg-background px-3 py-1.5 text-xs font-semibold text-foreground hover:border-foreground"\n >\n Talk to a person\n </button>\n </div>\n )}\n\n <div\n key={`${messages.length}:${isSending}`}\n ref={(node) => {\n // Keyed so the callback re-fires per new message/typing change \u2014\n // follows the conversation, but never yanks a reader out of history.\n if (node && !scrolledUpRef.current) node.scrollIntoView({ behavior: "smooth" });\n }}\n />\n </div>\n );\n}\n\nfunction HoverRail({\n message,\n onReact,\n onReply,\n}: {\n message: ChatMessage;\n onReact: (message: ChatMessage, emoji: string) => Promise<boolean>;\n onReply: (message: ChatMessage) => void;\n}) {\n return (\n <div className="absolute -top-3 right-0 z-10 hidden gap-0.5 rounded-full border border-border bg-background px-1 py-0.5 shadow-sm group-hover:flex">\n {QUICK_REACTIONS.map((emoji) => (\n <button\n key={emoji}\n type="button"\n aria-label={`React ${emoji}`}\n onClick={() => void onReact(message, emoji)}\n className="flex h-6 w-6 items-center justify-center rounded-full text-[13px] hover:bg-muted"\n >\n {emoji}\n </button>\n ))}\n <button\n type="button"\n aria-label="Reply"\n onClick={() => onReply(message)}\n className="flex h-6 w-6 items-center justify-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground"\n >\n <ReplyIcon className="h-3.5 w-3.5" />\n </button>\n </div>\n );\n}\n\n/* \u2500\u2500\u2500 Keep this conversation \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\nfunction readKeepDismissed(): boolean {\n try {\n return typeof window !== "undefined" && window.localStorage.getItem(KEEP_DISMISSED_KEY) === "1";\n } catch {\n return false;\n }\n}\n\nfunction writeKeepDismissed(): void {\n try {\n window.localStorage.setItem(KEEP_DISMISSED_KEY, "1");\n } catch {}\n}\n\nfunction KeepConversation({ client }: { client: CimplifyClient }) {\n const [step, setStep] = useState<"row" | "contact" | "code">("row");\n const [dismissed, setDismissed] = useState(readKeepDismissed);\n const [contact, setContact] = useState("");\n const [code, setCode] = useState("");\n const [busy, setBusy] = useState(false);\n const [verifyError, setVerifyError] = useState<string | null>(null);\n\n if (dismissed) return null;\n\n const contactType = contact.includes("@") ? "email" : "phone";\n\n const requestCode = async () => {\n const trimmed = contact.trim();\n if (!trimmed || busy) return;\n setBusy(true);\n setVerifyError(null);\n const result = await client.link.requestOtp({ contact: trimmed, contact_type: contactType });\n setBusy(false);\n if (result.ok) {\n setCode("");\n setStep("code");\n } else {\n setVerifyError(result.error.userMessage || "Couldn\'t send a code \u2014 try again.");\n }\n };\n\n const verify = async (value: string) => {\n if (value.length !== 6 || busy) return;\n setBusy(true);\n setVerifyError(null);\n const result = await client.link.verifyOtp({\n contact: contact.trim(),\n contact_type: contactType,\n otp_code: value,\n });\n setBusy(false);\n if (!result.ok) {\n setCode("");\n setVerifyError(result.error.userMessage || "Invalid or expired code.");\n }\n };\n\n return (\n <div className="flex-shrink-0 border-t border-border bg-muted/50 px-4 py-2.5">\n {step === "row" && (\n <div className="flex items-center gap-2.5">\n <button\n type="button"\n onClick={() => setStep("contact")}\n className="flex min-w-0 flex-1 flex-col text-left"\n >\n <span className="text-xs font-semibold text-foreground">Keep this conversation</span>\n <span className="truncate text-[11px] text-muted-foreground">\n Verify your number and it follows you \u2014 any device\n </span>\n </button>\n <button\n type="button"\n aria-label="Dismiss"\n onClick={() => {\n writeKeepDismissed();\n setDismissed(true);\n }}\n className="flex-shrink-0 p-1 text-muted-foreground hover:text-foreground"\n >\n <CloseIcon className="h-3.5 w-3.5" />\n </button>\n </div>\n )}\n\n {step === "contact" && (\n <div className="flex items-center gap-2">\n <input\n type="text"\n inputMode="email"\n value={contact}\n onChange={(e) => setContact(e.target.value)}\n onKeyDown={(e) => e.key === "Enter" && void requestCode()}\n placeholder="Phone or email"\n autoFocus\n className="h-9 min-w-0 flex-1 rounded-lg border border-border bg-background px-3 text-xs text-foreground outline-none placeholder:text-muted-foreground focus:border-foreground"\n />\n <button\n type="button"\n onClick={() => void requestCode()}\n disabled={!contact.trim() || busy}\n className="h-9 flex-shrink-0 rounded-lg bg-foreground px-3 text-xs font-semibold text-background disabled:opacity-50"\n >\n {busy ? "Sending\u2026" : "Send code"}\n </button>\n </div>\n )}\n\n {step === "code" && (\n <div className="flex items-center gap-2">\n <input\n type="text"\n inputMode="numeric"\n autoComplete="one-time-code"\n maxLength={6}\n value={code}\n onChange={(e) => {\n const digits = e.target.value.replace(/\\D/g, "").slice(0, 6);\n setCode(digits);\n if (digits.length === 6) void verify(digits);\n }}\n placeholder="6-digit code"\n autoFocus\n className="h-9 min-w-0 flex-1 rounded-lg border border-border bg-background px-3 text-xs tracking-[0.2em] text-foreground outline-none placeholder:tracking-normal placeholder:text-muted-foreground focus:border-foreground"\n />\n <button\n type="button"\n onClick={() => void requestCode()}\n disabled={busy}\n className="h-9 flex-shrink-0 rounded-lg px-2 text-[11px] font-medium text-muted-foreground hover:text-foreground disabled:opacity-50"\n >\n Resend\n </button>\n </div>\n )}\n\n {verifyError && <p className="mt-1.5 text-[11px] text-destructive">{verifyError}</p>}\n </div>\n );\n}\n\n/* \u2500\u2500\u2500 Input \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\nfunction ChatInput({\n placeholder,\n isSending,\n onSend,\n onAttach,\n className,\n}: {\n placeholder: string;\n isSending: boolean;\n onSend: (content: string) => Promise<boolean>;\n onAttach: (file: File) => Promise<void>;\n className?: string;\n}) {\n const [value, setValue] = useState("");\n const [uploading, setUploading] = useState(false);\n const textareaRef = useRef<HTMLTextAreaElement>(null);\n const fileRef = useRef<HTMLInputElement>(null);\n\n const handleAttach = useCallback(\n async (file: File) => {\n setUploading(true);\n try {\n await onAttach(file);\n } finally {\n setUploading(false);\n }\n },\n [onAttach],\n );\n\n const handleSend = useCallback(async () => {\n const trimmed = value.trim();\n if (!trimmed || isSending) return;\n setValue("");\n if (textareaRef.current) {\n textareaRef.current.style.height = "auto";\n }\n const sent = await onSend(trimmed);\n // A failed send must not eat the message \u2014 put it back to retry.\n if (!sent) setValue((current) => current || trimmed);\n }, [value, isSending, onSend]);\n\n const handleKeyDown = useCallback(\n (e: KeyboardEvent<HTMLTextAreaElement>) => {\n if (e.key === "Enter" && !e.shiftKey) {\n e.preventDefault();\n handleSend();\n }\n },\n [handleSend],\n );\n\n const handleInput = useCallback(() => {\n const el = textareaRef.current;\n if (!el) return;\n el.style.height = "auto";\n el.style.height = `${Math.min(el.scrollHeight, 100)}px`;\n }, []);\n\n const hasContent = value.trim().length > 0;\n\n return (\n <div\n className={cn(\n "flex flex-shrink-0 items-end gap-2 border-t border-border bg-background px-4 py-3",\n className,\n )}\n >\n <input\n ref={fileRef}\n type="file"\n accept="image/*"\n className="hidden"\n aria-hidden="true"\n tabIndex={-1}\n onChange={(e) => {\n const file = e.target.files?.[0];\n e.target.value = "";\n if (file) void handleAttach(file);\n }}\n />\n <button\n type="button"\n aria-label="Attach a photo"\n disabled={uploading}\n onClick={() => fileRef.current?.click()}\n className={cn(\n "flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-[10px]",\n "bg-muted text-muted-foreground transition-colors hover:text-foreground",\n uploading && "animate-pulse",\n )}\n >\n <ImageIcon className="h-[18px] w-[18px]" />\n </button>\n <div\n className={cn(\n "flex flex-1 items-end rounded-xl border-[1.5px] border-transparent bg-muted px-1",\n "transition-colors focus-within:border-foreground focus-within:bg-background",\n )}\n >\n <textarea\n ref={textareaRef}\n value={value}\n onChange={(e) => setValue(e.target.value)}\n onKeyDown={handleKeyDown}\n onInput={handleInput}\n placeholder={placeholder}\n rows={1}\n className={cn(\n "max-h-[100px] min-h-[20px] flex-1 resize-none bg-transparent px-2.5 py-2",\n "text-sm text-foreground outline-none placeholder:text-muted-foreground",\n )}\n />\n </div>\n\n <button\n type="button"\n onClick={handleSend}\n disabled={!hasContent || isSending}\n aria-label="Send message"\n className={cn(\n "flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-[10px]",\n "transition-all duration-200",\n hasContent && !isSending\n ? "bg-foreground text-background hover:scale-105 active:scale-95"\n : "bg-muted text-muted-foreground",\n )}\n >\n <SendIcon className="h-[18px] w-[18px]" />\n </button>\n </div>\n );\n}\n\n/* \u2500\u2500\u2500 Typing dots \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\nfunction TypingDots() {\n return (\n <div className="flex items-center gap-1 px-4 py-3">\n {[0, 1, 2].map((i) => (\n <span\n key={i}\n className="h-[7px] w-[7px] animate-bounce rounded-full bg-muted-foreground/40"\n style={{ animationDelay: `${i * 160}ms` }}\n />\n ))}\n </div>\n );\n}\n\n/* \u2500\u2500\u2500 Icons (inline SVG \u2014 no icon library) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\nfunction ChatIcon({ className }: { className?: string }) {\n return (\n <svg\n className={className}\n viewBox="0 0 24 24"\n fill="none"\n stroke="currentColor"\n strokeWidth={2}\n strokeLinecap="round"\n strokeLinejoin="round"\n >\n <path d="M21 11.5a8.38 8.38 0 01-.9 3.8 8.5 8.5 0 01-7.6 4.7 8.38 8.38 0 01-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 01-.9-3.8 8.5 8.5 0 014.7-7.6 8.38 8.38 0 013.8-.9h.5a8.48 8.48 0 018 8v.5z" />\n </svg>\n );\n}\n\nfunction CloseIcon({ className }: { className?: string }) {\n return (\n <svg\n className={className}\n viewBox="0 0 24 24"\n fill="none"\n stroke="currentColor"\n strokeWidth={2}\n strokeLinecap="round"\n strokeLinejoin="round"\n >\n <path d="M18 6L6 18M6 6l12 12" />\n </svg>\n );\n}\n\nfunction SendIcon({ className }: { className?: string }) {\n return (\n <svg\n className={className}\n viewBox="0 0 24 24"\n fill="none"\n stroke="currentColor"\n strokeWidth={2}\n strokeLinecap="round"\n strokeLinejoin="round"\n >\n <line x1="22" y1="2" x2="11" y2="13" />\n <polygon points="22 2 15 22 11 13 2 9 22 2" />\n </svg>\n );\n}\n\nfunction ImageIcon({ className }: { className?: string }) {\n return (\n <svg\n className={className}\n viewBox="0 0 24 24"\n fill="none"\n stroke="currentColor"\n strokeWidth={2}\n strokeLinecap="round"\n strokeLinejoin="round"\n >\n <rect x="3" y="3" width="18" height="18" rx="2" ry="2" />\n <circle cx="8.5" cy="8.5" r="1.5" />\n <polyline points="21 15 16 10 5 21" />\n </svg>\n );\n}\n\nfunction ReplyIcon({ className }: { className?: string }) {\n return (\n <svg\n className={className}\n viewBox="0 0 24 24"\n fill="none"\n stroke="currentColor"\n strokeWidth={2}\n strokeLinecap="round"\n strokeLinejoin="round"\n >\n <polyline points="9 17 4 12 9 7" />\n <path d="M20 18v-2a4 4 0 0 0-4-4H4" />\n </svg>\n );\n}\n\n' }] }, "service-product-layout": { "name": "service-product-layout", "title": "ServiceProductLayout", "description": "Service booking layout with duration, deposit, staff requirements, and cancellation policy.", "type": "component", "registryDependencies": ["product-image-gallery", "product-customizer", "price", "rich-text", "cn"], "files": [{ "path": "layouts/service-product-layout.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { ProductLayoutProps } from "@cimplify/sdk/react";\nimport {\n TwoColumnGrid,\n RelatedProductsSection,\n CustomAttributesTable,\n TagPills,\n} from "@cimplify/sdk/react";\nimport { ProductImageGallery } from "@cimplify/sdk/react";\nimport { ProductCustomizer } from "@cimplify/sdk/react";\nimport { Price } from "@cimplify/sdk/react";\nimport { parsePrice, getProductCurrency } from "@cimplify/sdk";\nimport { RichText } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\nimport { formatDuration } from "../utils/format-duration";\n\nexport function ServiceProductLayout({\n product,\n onAddToCart,\n renderBreadcrumb,\n relatedProducts,\n showRelated = true,\n className,\n}: ProductLayoutProps): React.ReactElement {\n const images = product.images || (product.image_url ? [product.image_url] : []);\n const currency = getProductCurrency(product);\n const hasDeposit = product.deposit_type && product.deposit_type !== "none" && product.deposit_amount;\n const depositAmount = hasDeposit ? parsePrice(product.deposit_amount!) : 0;\n const cancellationHours = product.cancellation_window_minutes\n ? Math.floor(product.cancellation_window_minutes / 60)\n : null;\n\n return (\n <div data-cimplify-product-layout="service" className={cn("space-y-8", className)}>\n {renderBreadcrumb?.(product)}\n\n <TwoColumnGrid\n left={\n <ProductImageGallery\n images={images}\n productName={product.name}\n />\n }\n right={\n <div className="space-y-6">\n {/* Tags */}\n <TagPills tags={product.tags || []} />\n\n {/* Name + Price */}\n <div>\n <h1 data-cimplify-product-layout-name className="text-3xl lg:text-4xl font-extrabold tracking-tight">\n {product.name}\n </h1>\n <Price amount={product.default_price} currency={currency} className="text-2xl font-bold text-primary mt-2 block" />\n </div>\n\n {/* Service info pills */}\n <div data-cimplify-product-layout-service-info className="flex flex-wrap gap-2">\n {product.duration_minutes != null && (\n <span className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-muted text-sm text-muted-foreground rounded-full">\n {formatDuration(product.duration_minutes, product.duration_unit)}\n </span>\n )}\n {hasDeposit && (\n <span className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-muted text-sm text-muted-foreground rounded-full">\n <Price amount={depositAmount} currency={currency} /> deposit\n </span>\n )}\n {product.general_service_capacity != null && product.general_service_capacity > 1 && (\n <span className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-muted text-sm text-muted-foreground rounded-full">\n Up to {product.general_service_capacity} people\n </span>\n )}\n {product.buffer_before_minutes != null && product.buffer_before_minutes > 0 && (\n <span className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-muted text-sm text-muted-foreground rounded-full">\n {product.buffer_before_minutes} min buffer\n </span>\n )}\n </div>\n\n {/* Description */}\n <RichText html={product.description} data-cimplify-product-layout-description />\n\n {/* Staff & Resource requirements */}\n {(product.requires_specific_staff || product.requires_specific_resource) && (\n <div data-cimplify-product-layout-requirements className="text-sm text-muted-foreground space-y-1">\n {product.requires_specific_staff && (\n <p>Staff selection required</p>\n )}\n {product.requires_specific_resource && (\n <p>Resource selection required</p>\n )}\n </div>\n )}\n\n {/* Custom Attributes */}\n <CustomAttributesTable attributes={product.custom_attributes || []} />\n\n {/* Customizer (variants, scheduling, add to cart) */}\n <ProductCustomizer\n product={product}\n onAddToCart={onAddToCart}\n showSpecialInstructions={false}\n />\n\n {/* Cancellation policy */}\n {(cancellationHours != null || product.no_show_fee) && (\n <div data-cimplify-product-layout-cancellation className="border border-border p-4 space-y-2 text-sm">\n <h3 className="font-semibold">Cancellation Policy</h3>\n {cancellationHours != null && (\n <p className="text-muted-foreground">\n Free cancellation up to <strong className="text-foreground">{cancellationHours} hours</strong> before the booking\n </p>\n )}\n {product.no_show_fee && (\n <p className="text-muted-foreground">\n No-show fee: <Price amount={product.no_show_fee} currency={currency} className="font-medium text-foreground" />\n </p>\n )}\n {hasDeposit && (\n <p className="text-muted-foreground">\n {product.deposit_type === "fixed"\n ? <><Price amount={depositAmount} currency={currency} className="font-medium text-foreground" /> deposit charged at booking</>\n : <>{parsePrice(product.deposit_amount!)}% deposit charged at booking</>\n }\n </p>\n )}\n </div>\n )}\n </div>\n }\n />\n\n {showRelated && relatedProducts && relatedProducts.length > 0 && (\n <RelatedProductsSection\n products={relatedProducts}\n title="Other services"\n />\n )}\n </div>\n );\n}\n' }] }, "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' }] }, "deal-banner": { "name": "deal-banner", "title": "DealBanner", "description": "Displays active deals and promotions.", "type": "component", "registryDependencies": ["price", "cn"], "files": [{ "path": "deal-banner.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { Deal } from "@cimplify/sdk";\nimport { useDeals } from "@cimplify/sdk/react";\nimport { Price } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface DealBannerClassNames {\n root?: string;\n item?: string;\n description?: string;\n value?: string;\n badge?: string;\n empty?: string;\n}\n\nexport interface DealBannerProps {\n /** Override deals (skips useDeals fetch). For SSR, pass pre-fetched deals. */\n deals?: Deal[];\n /** Location ID for location-specific deals. */\n locationId?: string;\n /** Called when a deal is clicked. */\n onDealClick?: (deal: Deal) => void;\n /** Custom deal card renderer. */\n renderDeal?: (deal: Deal) => React.ReactNode;\n /** Maximum deals to show. Default: all. */\n limit?: number;\n className?: string;\n classNames?: DealBannerClassNames;\n}\n\nfunction formatBenefitLabel(deal: Deal): string {\n switch (deal.benefit_type) {\n case "percentage":\n return `${deal.value}% off`;\n case "fixed":\n return `Save`;\n case "free_item":\n return "Free item";\n case "buy_x_get_y_free":\n return `Buy ${deal.buy_quantity ?? ""} get ${deal.get_quantity ?? 1} free`;\n case "points":\n return `Earn ${deal.value} points`;\n default:\n return "Special offer";\n }\n}\n\n/**\n * DealBanner \u2014 displays active deals/promotions.\n *\n * Renders as a horizontal scrollable strip or grid of deal cards.\n */\nexport function DealBanner({\n deals: dealsProp,\n locationId,\n onDealClick,\n renderDeal,\n limit,\n className,\n classNames,\n}: DealBannerProps): React.ReactElement | null {\n const { deals: fetched, isLoading } = useDeals({\n locationId,\n enabled: dealsProp === undefined,\n });\n\n const allDeals = dealsProp ?? fetched;\n const deals = limit ? allDeals.slice(0, limit) : allDeals;\n\n if (isLoading && deals.length === 0) {\n return (\n <div\n data-cimplify-deal-banner\n aria-busy="true"\n className={cn(className, classNames?.root)}\n />\n );\n }\n\n if (deals.length === 0) {\n return null;\n }\n\n return (\n <div data-cimplify-deal-banner className={cn(className, classNames?.root)}>\n {deals.map((deal) => (\n <button\n key={deal.id}\n type="button"\n onClick={() => onDealClick?.(deal)}\n data-cimplify-deal-item\n data-benefit-type={deal.benefit_type}\n className={classNames?.item}\n >\n {renderDeal ? (\n renderDeal(deal)\n ) : (\n <>\n <span data-cimplify-deal-badge className={classNames?.badge}>\n {formatBenefitLabel(deal)}\n </span>\n <span data-cimplify-deal-description className={classNames?.description}>\n {deal.description}\n </span>\n {deal.benefit_type === "fixed" && (\n <span data-cimplify-deal-value className={classNames?.value}>\n <Price amount={deal.value} />\n </span>\n )}\n {deal.min_order_value && (\n <span data-cimplify-deal-minimum>\n Min. order <Price amount={deal.min_order_value} />\n </span>\n )}\n </>\n )}\n </button>\n ))}\n </div>\n );\n}\n' }] }, "order-history-page": { "name": "order-history-page", "title": "OrderHistoryPage", "description": "Order list with status filtering and inline detail view.", "type": "component", "registryDependencies": ["order-history", "order-summary", "cn"], "files": [{ "path": "order-history-page.tsx", "content": '"use client";\n\nimport React, { useState, useCallback } from "react";\nimport { Tabs } from "@base-ui/react/tabs";\nimport type { Order, OrderStatus } from "@cimplify/sdk";\nimport { OrderHistory } from "@cimplify/sdk/react";\nimport { OrderSummary } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface OrderHistoryPageClassNames {\n root?: string;\n header?: string;\n title?: string;\n filters?: string;\n filterButton?: string;\n list?: string;\n detail?: string;\n backButton?: string;\n}\n\nexport interface OrderHistoryPageProps {\n /** Page title. */\n title?: string;\n /** Pre-fetched orders for SSR. */\n orders?: Order[];\n /** Max orders to show per page. */\n limit?: number;\n /** Called when navigating to an order detail (e.g. for routing). */\n onOrderNavigate?: (order: Order) => void;\n /** Called when the reorder button is clicked. */\n onReorder?: (order: Order) => void;\n /** Show status filter tabs. Default: true. */\n showFilters?: boolean;\n /** Custom order row renderer. */\n renderOrder?: (order: Order) => React.ReactNode;\n className?: string;\n classNames?: OrderHistoryPageClassNames;\n}\n\nconst STATUS_FILTERS: { label: string; value: OrderStatus | undefined; tabValue: string }[] = [\n { label: "All", value: undefined, tabValue: "all" },\n { label: "Active", value: "confirmed", tabValue: "confirmed" },\n { label: "Completed", value: "completed", tabValue: "completed" },\n { label: "Cancelled", value: "cancelled", tabValue: "cancelled" },\n];\n\n/**\n * OrderHistoryPage \u2014 order list with inline detail view and status filtering.\n *\n * SSR-friendly: pass `orders` prop for server rendering.\n * Supports both inline detail view and external navigation via `onOrderNavigate`.\n */\nexport function OrderHistoryPage({\n title = "Order History",\n orders: ordersProp,\n limit = 20,\n onOrderNavigate,\n onReorder,\n showFilters = true,\n renderOrder,\n className,\n classNames,\n}: OrderHistoryPageProps): React.ReactElement {\n const [statusFilter, setStatusFilter] = useState<OrderStatus | undefined>(undefined);\n const [selectedOrder, setSelectedOrder] = useState<Order | null>(null);\n\n const handleTabChange = useCallback((value: string | number | null) => {\n const filter = STATUS_FILTERS.find((f) => f.tabValue === value);\n setStatusFilter(filter?.value);\n }, []);\n\n const handleOrderClick = useCallback(\n (order: Order) => {\n if (onOrderNavigate) {\n onOrderNavigate(order);\n } else {\n setSelectedOrder(order);\n }\n },\n [onOrderNavigate],\n );\n\n const handleBack = useCallback(() => {\n setSelectedOrder(null);\n }, []);\n\n const activeTabValue = STATUS_FILTERS.find((f) => f.value === statusFilter)?.tabValue ?? "all";\n\n // Inline detail view\n if (selectedOrder && !onOrderNavigate) {\n return (\n <div data-cimplify-order-history-page className={cn(className, classNames?.root)}>\n <div data-cimplify-order-history-detail className={classNames?.detail}>\n <button\n type="button"\n onClick={handleBack}\n data-cimplify-order-history-back\n className={classNames?.backButton}\n >\n Back to orders\n </button>\n <OrderSummary order={selectedOrder} onReorder={onReorder} />\n </div>\n </div>\n );\n }\n\n return (\n <div data-cimplify-order-history-page className={cn(className, classNames?.root)}>\n {/* Header */}\n <div data-cimplify-order-history-header className={classNames?.header}>\n <h1 data-cimplify-order-history-title className={classNames?.title}>\n {title}\n </h1>\n </div>\n\n {/* Status filter */}\n {showFilters && (\n <Tabs.Root value={activeTabValue} onValueChange={handleTabChange}>\n <Tabs.List data-cimplify-order-history-filters className={classNames?.filters}>\n {STATUS_FILTERS.map((filter) => (\n <Tabs.Tab\n key={filter.tabValue}\n value={filter.tabValue}\n data-cimplify-order-filter\n data-selected={statusFilter === filter.value || undefined}\n className={classNames?.filterButton}\n >\n {filter.label}\n </Tabs.Tab>\n ))}\n </Tabs.List>\n </Tabs.Root>\n )}\n\n {/* Order list */}\n <div data-cimplify-order-history-list className={classNames?.list}>\n <OrderHistory\n orders={ordersProp}\n status={statusFilter}\n limit={limit}\n onOrderClick={handleOrderClick}\n renderOrder={renderOrder}\n onReorder={onReorder}\n />\n </div>\n </div>\n );\n}\n' }] }, "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' }] }, "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' }] }, "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 { currencyCode, type CurrencyCode } 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 type { InternalUseCartItem } from "./hooks/use-cart-types";\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 currency,\n classNames,\n}: {\n item: UseCartItem;\n onRemove: (itemId: string) => void;\n onQuantityChange: (itemId: string, qty: number) => void;\n currency: CurrencyCode;\n classNames?: CartSummaryClassNames;\n}): React.ReactElement {\n const { unitPrice: locallyResolvedUnitPrice } = useProductPrice({\n product: item.product,\n variant: item.variant,\n addOnOptions: item.addOnOptions,\n });\n const pinnedUnitPrice = (item as InternalUseCartItem)._quotedUnitPrice;\n const unitPrice =\n pinnedUnitPrice === undefined ? locallyResolvedUnitPrice : parsePrice(pinnedUnitPrice);\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 src={imageUrl} alt="" className="w-full h-full object-cover" loading="lazy" />\n ) : (\n <div className="w-full h-full grid place-items-center text-muted-foreground/40">\n <svg\n className="w-6 h-6"\n fill="none"\n stroke="currentColor"\n strokeWidth="1.5"\n viewBox="0 0 24 24"\n aria-hidden\n >\n <path\n strokeLinecap="round"\n strokeLinejoin="round"\n 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 />\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\n className="w-3.5 h-3.5"\n fill="none"\n stroke="currentColor"\n strokeWidth="2"\n viewBox="0 0 24 24"\n aria-hidden\n >\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 ||\n (item.addOnOptions && item.addOnOptions.length > 0) ||\n hasComposite ||\n 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\n data-cimplify-cart-customer-inputs\n className={cn("mt-1.5 text-xs space-y-0.5", classNames?.customerInputs)}\n >\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">\n {input.field_name}:\n </dt>\n <dd data-cimplify-cart-input-value className="text-foreground m-0 truncate">\n {input.field_type === INPUT_FIELD_TYPE.File ||\n 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") &&\n item.scheduledStart && (\n <div\n data-cimplify-cart-service-info\n className={cn(\n "mt-1.5 flex items-center gap-1.5 text-xs text-muted-foreground",\n classNames?.serviceInfo,\n )}\n >\n <svg\n className="w-3.5 h-3.5 shrink-0"\n fill="none"\n stroke="currentColor"\n strokeWidth="2"\n viewBox="0 0 24 24"\n aria-hidden\n >\n <path\n strokeLinecap="round"\n strokeLinejoin="round"\n 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 />\n </svg>\n <span>\n {new Date(item.scheduledStart).toLocaleDateString(undefined, {\n month: "short",\n day: "numeric",\n })}{" "}\n at{" "}\n {new Date(item.scheduledStart).toLocaleTimeString([], {\n hour: "2-digit",\n minute: "2-digit",\n })}\n </span>\n {item.serviceStatus && (\n <span\n data-status={item.serviceStatus}\n className="ml-auto px-1.5 py-0.5 rounded-full bg-muted text-[10px] uppercase tracking-wider"\n >\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\n data-cimplify-cart-digital-info\n className={cn(\n "mt-1.5 flex items-center gap-1 text-xs text-muted-foreground",\n classNames?.digitalInfo,\n )}\n >\n <svg\n className="w-3.5 h-3.5 shrink-0"\n fill="none"\n stroke="currentColor"\n strokeWidth="2"\n viewBox="0 0 24 24"\n aria-hidden\n >\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(\n "mt-1.5 pl-3 border-l border-border space-y-0.5 text-xs text-muted-foreground",\n item.compositeResolved ? classNames?.compositeBreakdown : classNames?.bundleBreakdown,\n )}\n >\n {(\n (item.bundleResolved?.selections ??\n item.compositeResolved?.selections ??\n []) as Array<{\n component_id?: string;\n product_name?: string;\n component_name?: string;\n quantity: number;\n product_type?: string;\n }>\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\n key={compId}\n data-product-type={sel.product_type}\n className="flex items-baseline gap-1.5 m-0"\n >\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 currency={currency}\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 currency,\n removeItem,\n updateQuantity,\n } = useCart();\n const displayCurrency = serverCart?.currency ?? currencyCode(currency);\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 currency={displayCurrency}\n classNames={classNames}\n />\n ))}\n </div>\n\n {/* Totals */}\n {showTotals && (\n <div\n data-cimplify-cart-totals\n className={cn(\n "mt-6 pt-4 border-t border-border space-y-2 text-sm",\n classNames?.totals,\n )}\n >\n <div\n data-cimplify-cart-subtotal\n className={cn("flex items-baseline justify-between", classNames?.subtotal)}\n >\n <span className="text-muted-foreground">\n Subtotal ({itemCount} {itemCount === 1 ? "item" : "items"})\n </span>\n <Price amount={subtotal} currency={displayCurrency} className="tabular-nums" />\n </div>\n\n {totalDiscounts != null && parsePrice(totalDiscounts) > 0 && (\n <div\n data-cimplify-cart-discount\n className={cn("flex items-baseline justify-between", classNames?.discount)}\n >\n <span className="text-muted-foreground">Discount</span>\n <Price\n amount={totalDiscounts}\n currency={displayCurrency}\n prefix="-"\n className="tabular-nums text-primary"\n />\n </div>\n )}\n\n {deliveryFee != null && (\n <div\n data-cimplify-cart-delivery-fee\n className={cn("flex items-baseline justify-between", classNames?.deliveryFee)}\n >\n <span className="text-muted-foreground">Delivery</span>\n {parsePrice(deliveryFee) > 0 ? (\n <Price\n amount={deliveryFee}\n currency={displayCurrency}\n className="tabular-nums"\n />\n ) : (\n <span className="font-medium text-primary">Free</span>\n )}\n </div>\n )}\n\n {serviceCharge != null && parsePrice(serviceCharge) > 0 && (\n <div\n data-cimplify-cart-service-charge\n className={cn("flex items-baseline justify-between", classNames?.serviceCharge)}\n >\n <span className="text-muted-foreground">Service charge</span>\n <Price\n amount={serviceCharge}\n currency={displayCurrency}\n className="tabular-nums"\n />\n </div>\n )}\n\n <div\n data-cimplify-cart-tax\n className={cn("flex items-baseline justify-between", classNames?.tax)}\n >\n <span className="text-muted-foreground">Tax</span>\n <Price amount={tax} currency={displayCurrency} className="tabular-nums" />\n </div>\n\n <div\n data-cimplify-cart-total\n className={cn(\n "flex items-baseline justify-between pt-3 mt-1 border-t border-border",\n classNames?.total,\n )}\n >\n <span className="font-semibold">Total</span>\n <Price\n amount={total}\n currency={displayCurrency}\n className="text-base font-bold tabular-nums"\n />\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' }] }, "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} = ${Number(rate.rate).toFixed(4)} ${currency}`\n : null}\n </span>\n )}\n </>\n )}\n </button>\n );\n })}\n </div>\n );\n}\n' }] }, "product-model-3d": { "name": "product-model-3d", "title": "ProductModel3D", "description": "Interactive 3D product viewer wrapping Google's <model-viewer>. Loads the web component on demand; AR mode lights up on iOS (Quick Look) and Android (Scene Viewer) automatically.", "type": "component", "registryDependencies": [], "files": [{ "path": "product-model-3d.tsx", "content": '"use client";\n\nimport React, { useEffect, useState } from "react";\n\nexport interface ProductModel3DProps {\n /** glTF or GLB URL. */\n src: string;\n /** Optional USDZ URL for iOS AR Quick Look (Safari/WebKit). */\n iosSrc?: string;\n /** Poster image shown while the model loads (and as a fallback). */\n poster?: string;\n alt?: string;\n aspectRatio?: "square" | "4/3" | "16/9" | "3/4";\n /** Enable AR mode. Lights up on iOS (Quick Look) + Android (Scene Viewer). Default `true`. */\n ar?: boolean;\n autoRotate?: boolean;\n cameraControls?: boolean;\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/9": { aspectRatio: "16/9" },\n "3/4": { aspectRatio: "3/4" },\n};\n\nconst MODEL_VIEWER_CDN =\n "https://unpkg.com/@google/model-viewer@4.0.0/dist/model-viewer.min.js";\n\n// Module-level cache so multiple <ProductModel3D> instances share one script load.\nlet modelViewerLoadPromise: Promise<void> | null = null;\nfunction ensureModelViewer(): Promise<void> {\n if (typeof window === "undefined") return Promise.resolve();\n if (modelViewerLoadPromise) return modelViewerLoadPromise;\n if (window.customElements?.get("model-viewer")) {\n modelViewerLoadPromise = Promise.resolve();\n return modelViewerLoadPromise;\n }\n modelViewerLoadPromise = new Promise<void>((resolve, reject) => {\n const script = document.createElement("script");\n script.type = "module";\n script.src = MODEL_VIEWER_CDN;\n script.onload = () => resolve();\n script.onerror = () => reject(new Error("Failed to load model-viewer"));\n document.head.appendChild(script);\n });\n return modelViewerLoadPromise;\n}\n\n/**\n * ProductModel3D \u2014 interactive 3D product viewer wrapping Google\'s\n * `<model-viewer>` web component. Script loads on demand (one tag per page,\n * cached). AR mode auto-resolves the right OS surface: USDZ Quick Look on iOS,\n * Scene Viewer on Android, WebXR where available. Poster image shows during\n * load and as a fallback if the script can\'t load.\n */\nexport function ProductModel3D({\n src,\n iosSrc,\n poster,\n alt,\n aspectRatio = "square",\n ar = true,\n autoRotate = true,\n cameraControls = true,\n className,\n}: ProductModel3DProps): React.ReactElement {\n const [ready, setReady] = useState(false);\n\n useEffect(() => {\n let cancelled = false;\n ensureModelViewer().then(\n () => {\n if (!cancelled) setReady(true);\n },\n () => {\n // Stay on the poster fallback if the CDN script fails.\n },\n );\n return () => {\n cancelled = true;\n };\n }, []);\n\n return (\n <div\n data-cimplify-product-model-3d\n className={className}\n style={{ position: "relative", overflow: "hidden", ...ASPECT_STYLES[aspectRatio] }}\n >\n {ready\n ? React.createElement("model-viewer", {\n src,\n "ios-src": iosSrc,\n alt,\n poster,\n ar: ar || undefined,\n "ar-modes": ar ? "webxr scene-viewer quick-look" : undefined,\n "camera-controls": cameraControls || undefined,\n "auto-rotate": autoRotate || undefined,\n "shadow-intensity": "1",\n style: { width: "100%", height: "100%", backgroundColor: "transparent" },\n })\n : poster\n ? React.createElement("img", {\n src: poster,\n alt: alt ?? "",\n "data-cimplify-product-model-3d-loading": true,\n style: { width: "100%", height: "100%", objectFit: "cover" },\n })\n : React.createElement("div", {\n "data-cimplify-product-model-3d-loading": true,\n style: { width: "100%", height: "100%", backgroundColor: "var(--muted, #f3f4f6)" },\n })}\n </div>\n );\n}\n' }] }, "digital-product-card": { "name": "digital-product-card", "title": "DigitalProductCard", "description": "Digital product card with type badge, file info, and event details.", "type": "component", "registryDependencies": ["price", "cn", "strip-html"], "files": [{ "path": "cards/digital-product-card.tsx", "content": '"use client";\n\nimport React from "react";\nimport { stripHtmlToText } from "@cimplify/sdk/react";\nimport type { CardLayoutProps } from "@cimplify/sdk/react";\nimport { CardShell, CardImage } from "@cimplify/sdk/react";\nimport { Price } from "@cimplify/sdk/react";\nimport { getProductCurrency } from "@cimplify/sdk";\n\nconst TYPE_BADGES: Record<string, { label: string; color: string }> = {\n download: { label: "Download", color: "bg-sky-50 text-sky-700 border-sky-200/60" },\n license_key: { label: "License", color: "bg-blue-50 text-blue-700 border-blue-200/60" },\n ticket: { label: "Event", color: "bg-violet-50 text-violet-700 border-violet-200/60" },\n access_grant: { label: "Access", color: "bg-emerald-50 text-emerald-700 border-emerald-200/60" },\n redemption_code: { label: "Code", color: "bg-amber-50 text-amber-700 border-amber-200/60" },\n};\n\nexport function DigitalProductCard({\n product,\n renderImage,\n renderLink,\n className,\n}: CardLayoutProps): React.ReactElement {\n const image = product.image_url || product.images?.[0] || "";\n const currency = getProductCurrency(product);\n const typeBadge = product.digital_type ? TYPE_BADGES[product.digital_type] : null;\n const isTicket = product.digital_type === "ticket";\n\n return (\n <CardShell product={product} renderLink={renderLink} className={className}>\n <CardImage src={image} alt={product.name} aspectRatio="4/3" renderImage={renderImage}>\n {typeBadge && (\n <span className={`absolute top-3 left-3 text-[11px] font-semibold tracking-wide border px-2 py-0.5 rounded-md ${typeBadge.color}`}>\n {typeBadge.label}\n </span>\n )}\n </CardImage>\n\n <div className="p-3.5">\n <h3 className="text-[14.5px] font-semibold text-foreground leading-snug tracking-tight truncate">\n {product.name}\n </h3>\n\n {/* Event: date + venue */}\n {isTicket && (product.event_date || product.venue) && (\n <p className="text-[12px] text-muted-foreground mt-1 truncate">\n {product.event_date && new Date(product.event_date).toLocaleDateString(undefined, { month: "short", day: "numeric" })}\n {product.event_date && product.venue && " \xB7 "}\n {product.venue}\n </p>\n )}\n\n {/* Non-event: description */}\n {!isTicket && product.description && (\n <p className="text-[12px] text-muted-foreground mt-1 line-clamp-1">\n {stripHtmlToText(product.description)}\n </p>\n )}\n\n <div className="flex items-center justify-between mt-2.5">\n <Price amount={product.default_price} currency={currency} className="text-sm font-bold" />\n\n {/* File info pill */}\n {product.file_type && (\n <span className="text-[10px] text-muted-foreground bg-muted px-1.5 py-0.5 rounded">\n {product.file_type.toUpperCase()}\n {product.file_size_mb != null && ` \xB7 ${product.file_size_mb >= 1024 ? `${(product.file_size_mb / 1024).toFixed(1)}GB` : `${product.file_size_mb}MB`}`}\n </span>\n )}\n </div>\n </div>\n </CardShell>\n );\n}\n' }] }, "deals-page": { "name": "deals-page", "title": "DealsPage", "description": "Promotions landing page with deal banners and on-sale products.", "type": "component", "registryDependencies": ["deal-banner", "product-grid", "sale-badge", "product-card", "cn"], "files": [{ "path": "deals-page.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { Product, Deal, ProductDealInfo } from "@cimplify/sdk";\nimport { useDeals, useProductsOnSale } from "@cimplify/sdk/react";\nimport { DealBanner } from "@cimplify/sdk/react";\nimport { ProductGrid } from "./product-grid";\nimport { SaleBadge } from "./sale-badge";\nimport { ProductCard } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface DealsPageClassNames {\n root?: string;\n header?: string;\n title?: string;\n bannerSection?: string;\n productsSection?: string;\n productsTitle?: string;\n empty?: string;\n loading?: string;\n}\n\nexport interface DealsPageProps {\n /** Page title. */\n title?: string;\n /** Pre-fetched deals for SSR. */\n deals?: Deal[];\n /** Pre-fetched products on sale for SSR. */\n productsOnSale?: ProductDealInfo[];\n /** Pre-fetched product objects for rendering. */\n products?: Product[];\n /** Location ID for location-specific deals. */\n locationId?: string;\n /** Custom image renderer. */\n renderImage?: (props: { src: string; alt: string; className?: string }) => React.ReactNode;\n /** Grid column config. */\n columns?: { sm?: number; md?: number; lg?: number; xl?: number };\n className?: string;\n classNames?: DealsPageClassNames;\n}\n\n/**\n * DealsPage \u2014 promotions landing page with deal banners and on-sale products.\n *\n * SSR-friendly: pass `deals` and `productsOnSale` props for server rendering.\n */\nexport function DealsPage({\n title = "Deals & Offers",\n deals: dealsProp,\n productsOnSale: productsOnSaleProp,\n products: productsProp,\n locationId,\n renderImage,\n columns,\n className,\n classNames,\n}: DealsPageProps): React.ReactElement {\n const { deals: fetchedDeals, isLoading: dealsLoading } = useDeals({\n locationId,\n enabled: dealsProp === undefined,\n });\n const { products: fetchedOnSale, isLoading: productsLoading } = useProductsOnSale({\n enabled: productsOnSaleProp === undefined,\n });\n\n const deals = dealsProp ?? fetchedDeals;\n const onSale = productsOnSaleProp ?? fetchedOnSale;\n const isLoading = dealsLoading || productsLoading;\n\n const isEmpty = deals.length === 0 && onSale.length === 0;\n\n if (isLoading && isEmpty) {\n return (\n <div\n data-cimplify-deals-page\n aria-busy="true"\n className={cn(className, classNames?.root, classNames?.loading)}\n />\n );\n }\n\n return (\n <div data-cimplify-deals-page className={cn(className, classNames?.root)}>\n {/* Header */}\n <div data-cimplify-deals-header className={classNames?.header}>\n <h1 data-cimplify-deals-title className={classNames?.title}>\n {title}\n </h1>\n </div>\n\n {/* Deal banners */}\n {deals.length > 0 && (\n <div data-cimplify-deals-banner-section className={classNames?.bannerSection}>\n <DealBanner deals={deals} />\n </div>\n )}\n\n {/* Products on sale */}\n {(onSale.length > 0 || (productsProp && productsProp.length > 0)) && (\n <div data-cimplify-deals-products className={classNames?.productsSection}>\n <h2 data-cimplify-deals-products-title className={classNames?.productsTitle}>\n On Sale Now\n </h2>\n {productsProp ? (\n <ProductGrid\n products={productsProp}\n renderImage={renderImage}\n columns={columns}\n />\n ) : (\n <div data-cimplify-deals-sale-list>\n {onSale.map((dealInfo: ProductDealInfo) => (\n <div key={`${dealInfo.product_id}-${dealInfo.deal_id}`} data-cimplify-deals-sale-item>\n <SaleBadge\n product={{ id: dealInfo.product_id, name: dealInfo.deal_name } as Product}\n dealInfo={dealInfo}\n showOriginalPrice\n />\n </div>\n ))}\n </div>\n )}\n </div>\n )}\n\n {/* Empty state */}\n {isEmpty && (\n <div data-cimplify-deals-empty className={classNames?.empty}>\n <p>No deals available right now. Check back soon!</p>\n </div>\n )}\n </div>\n );\n}\n' }] }, "checkout-page": { "name": "checkout-page", "title": "CheckoutPage", "description": "Multi-step checkout with auth, address, and payment.", "type": "component", "registryDependencies": ["cn"], "files": [{ "path": "checkout-page.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { ProcessCheckoutResult } from "@cimplify/sdk";\nimport { useCimplifyClient } from "@cimplify/sdk/react";\nimport { CimplifyCheckout } from "@cimplify/sdk/react";\nimport type { CimplifyCheckoutProps } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface CheckoutPageClassNames {\n root?: string;\n header?: string;\n title?: string;\n checkout?: string;\n}\n\nexport interface CheckoutPageProps {\n /** Page title. */\n title?: string;\n /** Called after successful checkout. */\n onComplete: (result: ProcessCheckoutResult) => void;\n /** Called on checkout failure. */\n onError?: (error: { code: string; message: string }) => void;\n /** Props forwarded to CimplifyCheckout. */\n checkoutProps?: Partial<\n Omit<CimplifyCheckoutProps, "client" | "onComplete" | "onError">\n >;\n className?: string;\n classNames?: CheckoutPageClassNames;\n}\n\n/**\n * CheckoutPage \u2014 thin page shell around CimplifyCheckout.\n *\n * Reads the CimplifyClient from CimplifyProvider context,\n * renders a page header, and delegates all checkout logic\n * to CimplifyCheckout.\n */\nexport function CheckoutPage({\n title = "Checkout",\n onComplete,\n onError,\n checkoutProps,\n className,\n classNames,\n}: CheckoutPageProps): React.ReactElement {\n const { client } = useCimplifyClient();\n\n return (\n <div data-cimplify-checkout-page className={cn(className, classNames?.root)}>\n <div data-cimplify-checkout-header className={classNames?.header}>\n <h1 data-cimplify-checkout-title className={classNames?.title}>\n {title}\n </h1>\n </div>\n\n <CimplifyCheckout\n client={client}\n onComplete={onComplete}\n onError={onError}\n className={classNames?.checkout}\n {...checkoutProps}\n />\n </div>\n );\n}\n' }] }, "food-product-layout": { "name": "food-product-layout", "title": "FoodProductLayout", "description": "Restaurant product layout with allergens, ingredients, pairings, and dietary tags.", "type": "component", "registryDependencies": ["product-image-gallery", "product-customizer", "price", "rich-text", "cn"], "files": [{ "path": "layouts/food-product-layout.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { ProductLayoutProps } from "@cimplify/sdk/react";\nimport {\n TwoColumnGrid,\n RelatedProductsSection,\n MetadataStringList,\n CustomAttributesTable,\n TagPills,\n getMetadataStringList,\n} from "@cimplify/sdk/react";\nimport { ProductImageGallery } from "@cimplify/sdk/react";\nimport { ProductCustomizer } from "@cimplify/sdk/react";\nimport { Price } from "@cimplify/sdk/react";\nimport { parsePrice, getProductCurrency } from "@cimplify/sdk";\nimport { RichText } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\n\nexport function FoodProductLayout({\n product,\n onAddToCart,\n renderBreadcrumb,\n relatedProducts,\n showRelated = true,\n className,\n}: ProductLayoutProps): React.ReactElement {\n const images = product.images || (product.image_url ? [product.image_url] : []);\n const currency = getProductCurrency(product);\n const tags = product.tags || [];\n const ingredients = getMetadataStringList(product.metadata, "ingredients");\n const pairings = getMetadataStringList(product.metadata, "pairings");\n\n return (\n <div data-cimplify-product-layout="food" className={cn("space-y-8", className)}>\n {renderBreadcrumb?.(product)}\n\n <TwoColumnGrid\n left={\n <ProductImageGallery\n images={images}\n productName={product.name}\n />\n }\n right={\n <div className="space-y-6">\n {/* Tags + Allergens */}\n <div className="flex flex-wrap gap-1.5">\n {tags.map((tag) => (\n <span key={tag} className="inline-flex items-center gap-1 px-2.5 py-1 bg-green-100 text-green-800 text-xs font-medium rounded-full">\n {tag}\n </span>\n ))}\n {product.allergies && product.allergies.map((allergy) => (\n <span key={allergy} className="inline-flex items-center gap-1 px-2.5 py-1 bg-amber-100 text-amber-800 text-xs font-medium rounded-full">\n Contains {allergy}\n </span>\n ))}\n </div>\n\n {/* Name + Price */}\n <div>\n <h1 data-cimplify-product-layout-name className="text-3xl lg:text-4xl font-bold tracking-tight">\n {product.name}\n </h1>\n <Price amount={product.default_price} currency={currency} className="text-2xl font-semibold mt-2 block" />\n </div>\n\n {/* Quick stats */}\n {(product.calories != null || product.preparation_time_minutes != null) && (\n <div data-cimplify-product-layout-stats className="flex flex-wrap gap-4 py-3 border-y border-border">\n {product.preparation_time_minutes != null && (\n <span className="flex items-center gap-1.5 text-sm text-muted-foreground">\n <strong className="text-foreground">{product.preparation_time_minutes} min</strong> prep time\n </span>\n )}\n {product.calories != null && (\n <span className="flex items-center gap-1.5 text-sm text-muted-foreground">\n <strong className="text-foreground">{product.calories}</strong> calories\n </span>\n )}\n </div>\n )}\n\n {/* Description */}\n <RichText html={product.description} data-cimplify-product-layout-description />\n\n {/* Ingredients + Pairings */}\n {(ingredients.length > 0 || pairings.length > 0) && (\n <div className="grid sm:grid-cols-2 gap-4">\n <MetadataStringList items={ingredients} label="Key Ingredients" />\n <MetadataStringList items={pairings} label="Pairings" />\n </div>\n )}\n\n {/* Custom Attributes */}\n <CustomAttributesTable attributes={product.custom_attributes || []} />\n\n {/* Customizer (variants, add-ons, composites, bundles, billing, volume) */}\n <ProductCustomizer\n product={product}\n onAddToCart={onAddToCart}\n />\n </div>\n }\n />\n\n {showRelated && relatedProducts && relatedProducts.length > 0 && (\n <RelatedProductsSection\n products={relatedProducts}\n title="You may also enjoy"\n />\n )}\n </div>\n );\n}\n' }] }, "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": `/**
6440
+ var REGISTRY = { "quantity-selector": { "name": "quantity-selector", "title": "QuantitySelector", "description": "Controlled increment/decrement quantity input.", "type": "component", "registryDependencies": [], "files": [{ "path": "quantity-selector.tsx", "content": '"use client";\n\nimport React from "react";\nimport { NumberField } from "@base-ui/react/number-field";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface QuantitySelectorClassNames {\n root?: string;\n button?: string;\n value?: string;\n}\n\nexport interface QuantitySelectorProps {\n value: number;\n onChange: (value: number) => void;\n min?: number;\n max?: number;\n className?: string;\n classNames?: QuantitySelectorClassNames;\n}\n\nexport function QuantitySelector({\n value,\n onChange,\n min = 1,\n max,\n className,\n classNames,\n}: QuantitySelectorProps): React.ReactElement {\n return (\n <NumberField.Root\n value={value}\n onValueChange={(val) => {\n if (val != null) {\n onChange(val);\n }\n }}\n min={min}\n max={max}\n step={1}\n >\n <NumberField.Group\n data-cimplify-quantity\n className={cn("inline-flex items-center gap-3 border border-border px-2", className, classNames?.root)}\n >\n <NumberField.Decrement\n aria-label="Decrease quantity"\n data-cimplify-quantity-decrement\n className={cn(\n "w-10 h-10 flex items-center justify-center hover:text-primary transition-colors disabled:opacity-30",\n classNames?.button,\n )}\n >\n &#x2212;\n </NumberField.Decrement>\n <NumberField.Input\n data-cimplify-quantity-value\n aria-live="polite"\n readOnly\n className={cn("w-8 text-center font-medium bg-transparent border-none outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none", classNames?.value)}\n />\n <NumberField.Increment\n aria-label="Increase quantity"\n data-cimplify-quantity-increment\n className={cn(\n "w-10 h-10 flex items-center justify-center hover:text-primary transition-colors disabled:opacity-30",\n classNames?.button,\n )}\n >\n +\n </NumberField.Increment>\n </NumberField.Group>\n </NumberField.Root>\n );\n}\n' }] }, "availability-badge": { "name": "availability-badge", "title": "AvailabilityBadge", "description": "Displays in-stock / out-of-stock status for tracked products.", "type": "component", "registryDependencies": ["cn"], "files": [{ "path": "availability-badge.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { Product } from "@cimplify/sdk";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface AvailabilityBadgeClassNames {\n root?: string;\n dot?: string;\n label?: string;\n lowStock?: string;\n stockCount?: string;\n}\n\nexport interface AvailabilityBadgeProps {\n /** The product to check availability for. */\n product: Product;\n /** Override availability (e.g. from location_availability). */\n isAvailable?: boolean;\n /** Override stock status (e.g. from location_availability). */\n isInStock?: boolean;\n /** Current stock quantity. When provided, enables "Only X left" display. */\n stockQuantity?: number;\n /** Threshold at which stock is considered low. Default: 5. */\n lowStockThreshold?: number;\n /** Location context for availability checks. Passed through as a data attribute. */\n locationId?: string;\n className?: string;\n classNames?: AvailabilityBadgeClassNames;\n}\n\n/**\n * AvailabilityBadge \u2014 displays in-stock / out-of-stock status for tracked products.\n *\n * Returns `null` for products that don\'t have inventory tracking enabled,\n * since there\'s no meaningful stock state to show.\n */\nexport function AvailabilityBadge({\n product,\n isAvailable,\n isInStock,\n stockQuantity,\n lowStockThreshold = 5,\n locationId,\n className,\n classNames,\n}: AvailabilityBadgeProps): React.ReactElement | null {\n if (product.is_tracked !== true) {\n return null;\n }\n\n const outOfStock =\n isInStock === false ||\n isAvailable === false ||\n (stockQuantity !== undefined && stockQuantity <= 0);\n const isLowStock =\n !outOfStock &&\n stockQuantity !== undefined &&\n stockQuantity > 0 &&\n stockQuantity <= lowStockThreshold;\n\n const stockState = outOfStock\n ? "out_of_stock"\n : isLowStock\n ? "low_stock"\n : "in_stock";\n const label = outOfStock ? "Out of Stock" : "In Stock";\n\n return (\n <span\n data-cimplify-availability-badge\n data-stock-state={stockState}\n {...(locationId ? { "data-location-id": locationId } : undefined)}\n className={cn(className, classNames?.root, isLowStock && classNames?.lowStock)}\n style={{ display: "inline-flex", alignItems: "center", gap: "0.375rem" }}\n >\n <span\n data-cimplify-availability-dot\n className={classNames?.dot}\n style={{\n display: "inline-block",\n width: "0.5rem",\n height: "0.5rem",\n borderRadius: "9999px",\n }}\n />\n <span data-cimplify-availability-label className={classNames?.label}>\n {label}\n </span>\n {stockQuantity !== undefined && isLowStock && (\n <span data-cimplify-availability-stock-count className={classNames?.stockCount}>\n Only {stockQuantity} left\n </span>\n )}\n </span>\n );\n}\n' }] }, "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' }] }, "rich-text": { "name": "rich-text", "title": "RichText", "description": "Renders sanitized merchant-authored HTML (descriptions) with theme-aware prose styling. Edge-safe XSS sanitization via the pure-JS xss library.", "type": "component", "registryDependencies": ["cn"], "files": [{ "path": "rich-text.tsx", "content": '"use client";\n\nimport React, { useMemo } from "react";\nimport { FilterXSS, safeAttrValue as baseSafeAttrValue } from "xss";\nimport { cn } from "@cimplify/sdk/react";\n\n// Hosts allowed for <iframe> embeds. Everything else has its src blanked.\nconst ALLOWED_EMBED_HOSTS = new Set<string>([\n "www.youtube.com",\n "youtube.com",\n "www.youtube-nocookie.com",\n "youtube-nocookie.com",\n "player.vimeo.com",\n]);\n\n// Fixed permission policy for embeds. Merchant-supplied `allow` is discarded\n// and replaced with this \u2014 a description must never request camera/mic/etc.\nconst IFRAME_ALLOW =\n "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen";\n\n// `class` is an unrestricted CSS channel in a Tailwind storefront \u2014 a merchant\n// class like `fixed inset-0 z-50` re-enables exactly what the inline-CSS filter\n// below forbids. So `class` values are filtered to this namespace: only the\n// SDK\'s own `cimplify-*` layout primitives survive; everything else is dropped.\nconst SAFE_CLASS_PATTERN = /^cimplify-[a-z0-9-]+$/i;\n\n// Inline CSS properties we permit (via the xss css filter). Anything else \u2014\n// position, z-index, behaviours, url() exfil vectors \u2014 is dropped.\nconst ALLOWED_CSS_PROPERTIES: Record<string, boolean> = {\n "text-align": true,\n color: true,\n "background-color": true,\n "font-weight": true,\n "font-style": true,\n "text-decoration": true,\n "vertical-align": true,\n width: true,\n height: true,\n};\n\nconst STYLEABLE = ["style", "class"];\n\n/**\n * Allowlist covering rich product copy: rich text, spec and\n * comparison tables, figures, definition lists, collapsible sections, and\n * host-locked video embeds. Tags outside this set are dropped; `javascript:`\n * URLs, event handlers, and unlisted CSS properties are stripped.\n */\nconst RICH_TEXT_WHITELIST: Record<string, string[]> = {\n p: STYLEABLE,\n br: [],\n div: STYLEABLE,\n section: ["class"],\n span: STYLEABLE,\n strong: [],\n b: [],\n em: [],\n i: [],\n u: [],\n s: [],\n strike: [],\n del: [],\n ins: [],\n mark: [],\n small: [],\n sub: [],\n sup: [],\n h1: STYLEABLE,\n h2: STYLEABLE,\n h3: STYLEABLE,\n h4: STYLEABLE,\n h5: STYLEABLE,\n h6: STYLEABLE,\n ul: ["class"],\n ol: ["start", "type", "class"],\n li: ["class"],\n dl: ["class"],\n dt: ["class"],\n dd: ["class"],\n blockquote: ["class"],\n code: [],\n pre: ["class"],\n hr: [],\n a: ["href", "title", "target", "rel"],\n img: ["src", "alt", "title", "width", "height", "loading", "class", "style"],\n figure: ["class"],\n figcaption: ["class"],\n details: [],\n summary: [],\n table: ["class", "style"],\n thead: [],\n tbody: [],\n tfoot: [],\n caption: [],\n colgroup: [],\n col: ["span", "style"],\n tr: ["class"],\n th: ["colspan", "rowspan", "scope", "align", "class", "style"],\n td: ["colspan", "rowspan", "align", "class", "style"],\n iframe: ["src", "width", "height", "allow", "allowfullscreen", "frameborder", "title", "loading"],\n};\n\nfunction safeAttrValue(\n tag: string,\n name: string,\n value: string,\n cssFilter: Parameters<typeof baseSafeAttrValue>[3],\n): string {\n if (tag === "iframe" && name === "src") {\n try {\n const url = new URL(value);\n if (url.protocol === "https:" && ALLOWED_EMBED_HOSTS.has(url.hostname)) {\n return value;\n }\n } catch {\n // malformed URL \u2014 fall through to blank\n }\n return "";\n }\n if (tag === "iframe" && name === "allow") {\n return IFRAME_ALLOW;\n }\n if (name === "class") {\n return value\n .split(/\\s+/)\n .filter((token) => SAFE_CLASS_PATTERN.test(token))\n .join(" ");\n }\n return baseSafeAttrValue(tag, name, value, cssFilter);\n}\n\n// Reverse-tabnabbing guard: force `rel="noopener noreferrer"` on any\n// `target="_blank"` anchor. Runs after FILTER.process, whose output has already\n// escaped attribute values \u2014 so no raw `>` can appear inside an attribute and a\n// tag-level regex is safe. Deterministic (identical SSR + client).\nfunction withLinkSafety(html: string): string {\n return html.replace(/<a\\b[^>]*>/gi, (tag) => {\n if (!/\\btarget\\s*=\\s*["\']_blank["\']/i.test(tag)) return tag;\n return /\\brel\\s*=\\s*["\'][^"\']*["\']/i.test(tag)\n ? tag.replace(/\\brel\\s*=\\s*["\'][^"\']*["\']/i, \'rel="noopener noreferrer"\')\n : tag.replace(/>$/, \' rel="noopener noreferrer">\');\n });\n}\n\n// A single configured filter instance, reused across every call.\nconst FILTER = new FilterXSS({\n whiteList: RICH_TEXT_WHITELIST,\n css: { whiteList: ALLOWED_CSS_PROPERTIES },\n safeAttrValue,\n stripIgnoreTag: true,\n stripIgnoreTagBody: ["script", "style"],\n allowCommentTag: false,\n});\n\n/**\n * Sanitize merchant-authored HTML for safe rendering. Runs identically on the\n * Cloudflare Workers edge (SSR) and in the browser \u2014 no DOM dependency.\n */\nexport function sanitizeRichTextHtml(html: string): string {\n return withLinkSafety(FILTER.process(html));\n}\n\n// Theme-aware prose styling. Wired to the consumer\'s own design tokens\n// (`foreground`, `muted-foreground`, `primary`, `border`, `muted`) so rich\n// text inherits each storefront\'s type scale and colours.\nconst RICH_TEXT_PROSE = cn(\n "text-muted-foreground leading-relaxed",\n "[&>*:first-child]:mt-0 [&>*:last-child]:mb-0",\n "[&_p]:my-3",\n "[&_h1]:mt-6 [&_h1]:mb-2 [&_h1]:text-xl [&_h1]:font-semibold [&_h1]:leading-snug [&_h1]:text-foreground",\n "[&_h2]:mt-5 [&_h2]:mb-2 [&_h2]:text-lg [&_h2]:font-semibold [&_h2]:leading-snug [&_h2]:text-foreground",\n "[&_h3]:mt-4 [&_h3]:mb-1.5 [&_h3]:text-base [&_h3]:font-semibold [&_h3]:text-foreground",\n "[&_h4]:mt-4 [&_h4]:mb-1.5 [&_h4]:text-sm [&_h4]:font-semibold [&_h4]:text-foreground",\n "[&_h5]:mt-3 [&_h5]:mb-1 [&_h5]:font-semibold [&_h5]:text-foreground",\n "[&_h6]:mt-3 [&_h6]:mb-1 [&_h6]:font-semibold [&_h6]:text-foreground",\n "[&_strong]:font-semibold [&_strong]:text-foreground [&_b]:font-semibold [&_b]:text-foreground",\n "[&_em]:italic [&_i]:italic",\n "[&_u]:underline [&_ins]:underline [&_s]:line-through [&_del]:line-through",\n "[&_mark]:rounded [&_mark]:px-0.5 [&_sub]:align-sub [&_sub]:text-[0.75em] [&_sup]:align-super [&_sup]:text-[0.75em]",\n "[&_a]:font-medium [&_a]:text-primary [&_a]:underline [&_a]:underline-offset-2",\n "[&_ul]:my-3 [&_ul]:list-disc [&_ul]:pl-5 [&_ul]:space-y-1",\n "[&_ol]:my-3 [&_ol]:list-decimal [&_ol]:pl-5 [&_ol]:space-y-1",\n "[&_li]:pl-1 [&_li>p]:my-0 [&_ul_ul]:my-1 [&_ol_ol]:my-1 [&_ul_ul]:list-[circle]",\n "[&_dl]:my-3 [&_dt]:font-semibold [&_dt]:text-foreground [&_dd]:mb-2 [&_dd]:pl-4",\n "[&_blockquote]:my-3 [&_blockquote]:border-l-2 [&_blockquote]:border-border [&_blockquote]:pl-4 [&_blockquote]:italic",\n "[&_code]:rounded [&_code]:bg-muted [&_code]:px-1 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-[0.85em]",\n "[&_pre]:my-3 [&_pre]:overflow-x-auto [&_pre]:rounded-md [&_pre]:bg-muted [&_pre]:p-3 [&_pre]:text-[0.85em]",\n "[&_pre>code]:bg-transparent [&_pre>code]:p-0",\n "[&_hr]:my-4 [&_hr]:border-border",\n "[&_figure]:my-4 [&_figcaption]:mt-2 [&_figcaption]:text-center [&_figcaption]:text-xs [&_figcaption]:text-muted-foreground",\n "[&_img]:my-3 [&_img]:h-auto [&_img]:max-w-full [&_img]:rounded-md",\n "[&_details]:my-3 [&_details]:rounded-md [&_details]:border [&_details]:border-border [&_details]:p-3",\n "[&_summary]:cursor-pointer [&_summary]:font-medium [&_summary]:text-foreground",\n "[&_table]:my-4 [&_table]:w-full [&_table]:border-collapse [&_table]:text-sm",\n "[&_caption]:caption-bottom [&_caption]:mt-2 [&_caption]:text-xs [&_caption]:text-muted-foreground",\n "[&_th]:border [&_th]:border-border [&_th]:bg-muted [&_th]:px-3 [&_th]:py-2 [&_th]:text-left [&_th]:font-semibold [&_th]:text-foreground",\n "[&_td]:border [&_td]:border-border [&_td]:px-3 [&_td]:py-2 [&_td]:align-top",\n "[&_iframe]:my-4 [&_iframe]:aspect-video [&_iframe]:w-full [&_iframe]:rounded-md",\n);\n\nexport interface RichTextProps\n extends Omit<React.HTMLAttributes<HTMLDivElement>, "dangerouslySetInnerHTML" | "children"> {\n /** Merchant-authored HTML (e.g. a product description). Sanitized before render. */\n html?: string | null;\n}\n\n/**\n * RichText \u2014 renders sanitized merchant-authored HTML with theme-aware prose\n * styling. Headings, lists, tables, figures, quotes, code, collapsible\n * sections and host-locked embeds all display correctly; scripts, event\n * handlers and unlisted CSS are stripped.\n *\n * Returns `null` when there\'s no renderable content.\n */\nexport function RichText({ html, className, ...rest }: RichTextProps): React.ReactElement | null {\n const clean = useMemo(() => (html ? sanitizeRichTextHtml(html) : ""), [html]);\n\n if (!clean) {\n return null;\n }\n\n return (\n <div\n data-cimplify-rich-text\n className={cn(RICH_TEXT_PROSE, className)}\n dangerouslySetInnerHTML={{ __html: clean }}\n {...rest}\n />\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' }] }, "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' }] }, "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' }] }, "bookings-page": { "name": "bookings-page", "title": "BookingsPage", "description": "Account-area page listing a customer's bookings with filters and detail view.", "type": "component", "registryDependencies": ["booking-list", "booking-card", "cn"], "files": [{ "path": "bookings-page.tsx", "content": '"use client";\n\nimport React, { useState, useCallback } from "react";\nimport { Tabs } from "@base-ui/react/tabs";\nimport type { CustomerBooking } from "@cimplify/sdk";\nimport { BookingList } from "@cimplify/sdk/react";\nimport { BookingCard } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface BookingsPageClassNames {\n root?: string;\n header?: string;\n title?: string;\n filters?: string;\n filterButton?: string;\n list?: string;\n detail?: string;\n backButton?: string;\n}\n\nexport interface BookingsPageProps {\n /** Page title. */\n title?: string;\n /** Pre-fetched bookings for SSR. */\n bookings?: CustomerBooking[];\n /** Called when navigating to a booking detail (e.g. for routing). */\n onBookingNavigate?: (booking: CustomerBooking) => void;\n /** Called when cancel is clicked. */\n onCancel?: (booking: CustomerBooking) => void;\n /** Called when reschedule is clicked. */\n onReschedule?: (booking: CustomerBooking) => void;\n /** Show filter tabs. Default: true. */\n showFilters?: boolean;\n /** Custom booking renderer. */\n renderBooking?: (booking: CustomerBooking) => React.ReactNode;\n className?: string;\n classNames?: BookingsPageClassNames;\n}\n\nconst BOOKING_FILTERS: { label: string; value: "all" | "upcoming" | "past" }[] = [\n { label: "All", value: "all" },\n { label: "Upcoming", value: "upcoming" },\n { label: "Past", value: "past" },\n];\n\nexport function BookingsPage({\n title = "My Bookings",\n bookings: bookingsProp,\n onBookingNavigate,\n onCancel,\n onReschedule,\n showFilters = true,\n renderBooking,\n className,\n classNames,\n}: BookingsPageProps): React.ReactElement {\n const [filter, setFilter] = useState<"all" | "upcoming" | "past">("all");\n const [selectedBooking, setSelectedBooking] = useState<CustomerBooking | null>(null);\n\n const handleBookingClick = useCallback(\n (booking: CustomerBooking) => {\n if (onBookingNavigate) {\n onBookingNavigate(booking);\n } else {\n setSelectedBooking(booking);\n }\n },\n [onBookingNavigate],\n );\n\n const handleBack = useCallback(() => {\n setSelectedBooking(null);\n }, []);\n\n if (selectedBooking && !onBookingNavigate) {\n return (\n <div data-cimplify-bookings-page className={cn(className, classNames?.root)}>\n <div data-cimplify-bookings-detail className={classNames?.detail}>\n <button\n type="button"\n onClick={handleBack}\n data-cimplify-bookings-back\n className={classNames?.backButton}\n >\n Back to bookings\n </button>\n <BookingCard\n booking={selectedBooking}\n onCancel={onCancel}\n onReschedule={onReschedule}\n />\n </div>\n </div>\n );\n }\n\n return (\n <div data-cimplify-bookings-page className={cn(className, classNames?.root)}>\n <div data-cimplify-bookings-header className={classNames?.header}>\n <h1 data-cimplify-bookings-title className={classNames?.title}>\n {title}\n </h1>\n </div>\n\n {showFilters && (\n <Tabs.Root\n value={filter}\n onValueChange={(value) => setFilter(value as "all" | "upcoming" | "past")}\n >\n <Tabs.List data-cimplify-bookings-filters className={classNames?.filters}>\n {BOOKING_FILTERS.map((f) => (\n <Tabs.Tab\n key={f.value}\n value={f.value}\n data-cimplify-booking-filter\n data-selected={filter === f.value || undefined}\n className={classNames?.filterButton}\n >\n {f.label}\n </Tabs.Tab>\n ))}\n </Tabs.List>\n </Tabs.Root>\n )}\n\n <div data-cimplify-bookings-list className={classNames?.list}>\n <BookingList\n bookings={bookingsProp}\n filter={filter}\n onCancel={onCancel}\n onReschedule={onReschedule}\n onBookingClick={handleBookingClick}\n renderBooking={renderBooking}\n />\n </div>\n </div>\n );\n}\n' }] }, "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' }] }, "product-page": { "name": "product-page", "title": "ProductPage", "description": "Smart product page resolver with per-slug and per-type template routing.", "type": "component", "registryDependencies": ["default-product-layout", "food-product-layout", "wholesale-product-layout", "service-product-layout", "digital-product-layout", "cn"], "files": [{ "path": "product-page.tsx", "content": '"use client";\n\nimport React, { useEffect } from "react";\nimport type { Product, ProductWithDetails } from "@cimplify/sdk";\nimport { PRODUCT_TYPE, RENDER_HINT } from "@cimplify/sdk";\nimport { trackProductView } from "./hooks/use-product";\nimport { useOptionalCimplifyClient } from "@cimplify/sdk/react";\nimport type { ProductLayoutProps } from "@cimplify/sdk/react";\nimport { DefaultProductLayout } from "@cimplify/sdk/react";\nimport { FoodProductLayout } from "@cimplify/sdk/react";\nimport { WholesaleProductLayout } from "@cimplify/sdk/react";\nimport { ServiceProductLayout } from "@cimplify/sdk/react";\nimport { DigitalProductLayout } from "@cimplify/sdk/react";\nimport { BundleProductLayout } from "./layouts/bundle-product-layout";\nimport { CompositeProductLayout } from "./layouts/composite-product-layout";\nimport type { AddToCartOptions } from "@cimplify/sdk/react";\nimport { useProduct } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport type { ProductLayoutProps };\n\nexport enum ProductTemplate {\n Default = "default",\n Food = "food",\n Wholesale = "wholesale",\n Service = "service",\n Digital = "digital",\n Bundle = "bundle",\n Composite = "composite",\n Physical = "physical",\n}\n\nexport interface ProductPageClassNames {\n root?: string;\n loading?: string;\n}\n\nexport interface ProductPageProps {\n /** Product slug or ID \u2014 used for client-side fetch when `product` is not provided. */\n productId?: string;\n /** Pre-fetched product for SSR. Skips client-side fetch when provided. */\n product?: ProductWithDetails;\n /** Per-slug page map. Highest priority \u2014 maps a product slug to a custom component. */\n pages?: Record<string, React.ComponentType<ProductLayoutProps>>;\n /** Per-type template map. Overrides built-in layouts for specific template keys. */\n templates?: Partial<Record<ProductTemplate | string, React.ComponentType<ProductLayoutProps>>>;\n /** Override add-to-cart behavior. */\n onAddToCart?: (\n product: ProductWithDetails,\n quantity: number,\n options: AddToCartOptions,\n ) => void | Promise<void>;\n /** Custom image renderer (e.g. Next.js Image). */\n renderImage?: (props: { src: string; alt: string; className?: string }) => React.ReactNode;\n /** Breadcrumb renderer. Receives the product for context. */\n renderBreadcrumb?: (product: ProductWithDetails) => React.ReactNode;\n /** Pre-fetched related products. */\n relatedProducts?: Product[];\n /** Show related products section. Default: true. */\n showRelated?: boolean;\n className?: string;\n classNames?: ProductPageClassNames;\n}\n\nconst BUILT_IN_LAYOUTS: Record<string, React.ComponentType<ProductLayoutProps>> = {\n [ProductTemplate.Food]: FoodProductLayout,\n [ProductTemplate.Wholesale]: WholesaleProductLayout,\n [ProductTemplate.Service]: ServiceProductLayout,\n [ProductTemplate.Digital]: DigitalProductLayout,\n [ProductTemplate.Bundle]: BundleProductLayout,\n [ProductTemplate.Composite]: CompositeProductLayout,\n [ProductTemplate.Default]: DefaultProductLayout,\n};\n\nfunction resolveTemplateKey(product: ProductWithDetails): ProductTemplate | string {\n const metaTemplate = product.metadata?.page_template;\n if (typeof metaTemplate === "string" && metaTemplate.trim()) {\n return metaTemplate.trim();\n }\n\n if (product.type === PRODUCT_TYPE.Bundle) {\n return ProductTemplate.Bundle;\n }\n\n if (product.type === PRODUCT_TYPE.Composite) {\n return ProductTemplate.Composite;\n }\n\n if (product.quantity_pricing && product.quantity_pricing.length > 1) {\n return ProductTemplate.Wholesale;\n }\n\n if (product.type === PRODUCT_TYPE.Service) {\n return ProductTemplate.Service;\n }\n\n if (product.type === PRODUCT_TYPE.Digital) {\n return ProductTemplate.Digital;\n }\n\n if (product.render_hint === RENDER_HINT.Food) {\n return ProductTemplate.Food;\n }\n\n if (product.render_hint === RENDER_HINT.Physical) {\n return ProductTemplate.Physical;\n }\n\n return ProductTemplate.Default;\n}\n\nfunction resolveLayout(\n product: ProductWithDetails,\n pages?: Record<string, React.ComponentType<ProductLayoutProps>>,\n templates?: Partial<Record<string, React.ComponentType<ProductLayoutProps>>>,\n): React.ComponentType<ProductLayoutProps> {\n // 1. Per-slug page (AI-generated, highest priority)\n if (pages?.[product.slug]) {\n return pages[product.slug];\n }\n\n const key = resolveTemplateKey(product);\n\n // 2. Consumer-provided template override\n if (templates?.[key]) {\n return templates[key];\n }\n\n // 3. Built-in layout\n if (BUILT_IN_LAYOUTS[key]) {\n return BUILT_IN_LAYOUTS[key];\n }\n\n // 4. Fallback\n return DefaultProductLayout;\n}\n\nexport function ProductPage({\n productId,\n product: productProp,\n pages,\n templates,\n onAddToCart,\n renderImage,\n renderBreadcrumb,\n relatedProducts,\n showRelated = true,\n className,\n classNames,\n}: ProductPageProps): React.ReactElement {\n const resolvedId = productId || productProp?.slug || productProp?.id || "";\n const { product: fetched, isLoading } = useProduct(resolvedId, {\n enabled: !productProp && resolvedId.length > 0,\n });\n const product = productProp ?? fetched;\n\n const context = useOptionalCimplifyClient();\n useEffect(() => {\n if (product && context) {\n trackProductView(context.client, product);\n }\n }, [context, product]);\n\n if (isLoading && !product) {\n return (\n <div\n data-cimplify-product-page\n aria-busy="true"\n className={cn(className, classNames?.root, classNames?.loading)}\n >\n <div className="grid lg:grid-cols-2 gap-8 lg:gap-12 animate-pulse">\n <div className="aspect-square bg-muted" />\n <div className="space-y-4 py-4">\n <div className="h-8 w-3/5 bg-muted" />\n <div className="h-6 w-2/5 bg-muted" />\n <div className="h-4 w-4/5 bg-muted" />\n <div className="h-4 w-3/5 bg-muted" />\n <div className="h-14 w-full bg-muted mt-8" />\n </div>\n </div>\n </div>\n );\n }\n\n if (!product) {\n return (\n <div data-cimplify-product-page className={cn(className, classNames?.root)}>\n <p className="text-muted-foreground">Product not found.</p>\n </div>\n );\n }\n\n const Layout = resolveLayout(product, pages, templates);\n\n return (\n <div data-cimplify-product-page className={cn(className, classNames?.root)}>\n <Layout\n product={product}\n onAddToCart={onAddToCart}\n renderImage={renderImage}\n renderBreadcrumb={renderBreadcrumb}\n relatedProducts={relatedProducts}\n showRelated={showRelated}\n />\n </div>\n );\n}\n' }] }, "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' }] }, "staff-picker": { "name": "staff-picker", "title": "StaffPicker", "description": "Staff member selection list with avatar and bio.", "type": "component", "registryDependencies": ["cn"], "files": [{ "path": "staff-picker.tsx", "content": '"use client";\n\nimport { Radio } from "@base-ui/react/radio";\nimport { RadioGroup } from "@base-ui/react/radio-group";\nimport React from "react";\nimport type { Staff } from "@cimplify/sdk";\nimport { cn } from "@cimplify/sdk/react";\n\nconst ANY_VALUE = "__any__";\n\nexport interface StaffPickerClassNames {\n root?: string;\n option?: string;\n avatar?: string;\n name?: string;\n bio?: string;\n}\n\nexport interface StaffPickerProps {\n /** List of available staff members. */\n staff: Staff[];\n /** Currently selected staff ID, or null for "Any available". */\n selectedStaffId?: string | null;\n /** Called when a staff member is selected. Passes null for "Any available". */\n onStaffSelect?: (staffId: string | null) => void;\n /** Show "Any available" option. Default: true. */\n showAnyOption?: boolean;\n /** Label for the "Any available" option. */\n anyLabel?: string;\n className?: string;\n classNames?: StaffPickerClassNames;\n}\n\nexport function StaffPicker({\n staff,\n selectedStaffId,\n onStaffSelect,\n showAnyOption = true,\n anyLabel = "Any available",\n className,\n classNames,\n}: StaffPickerProps): React.ReactElement {\n const groupValue =\n selectedStaffId === null ? ANY_VALUE : (selectedStaffId ?? "");\n\n return (\n <RadioGroup\n data-cimplify-staff-picker\n className={cn(className, classNames?.root)}\n value={groupValue}\n onValueChange={(value) => {\n onStaffSelect?.(value === ANY_VALUE ? null : value);\n }}\n >\n {showAnyOption && (\n <Radio.Root\n value={ANY_VALUE}\n data-cimplify-staff-option\n data-selected={selectedStaffId === null || undefined}\n data-any\n className={classNames?.option}\n >\n <span data-cimplify-staff-name className={classNames?.name}>\n {anyLabel}\n </span>\n </Radio.Root>\n )}\n {staff.map((member) => (\n <Radio.Root\n key={member.id}\n value={member.id}\n data-cimplify-staff-option\n data-selected={selectedStaffId === member.id || undefined}\n className={classNames?.option}\n >\n {member.avatar_url && (\n <img\n src={member.avatar_url}\n alt={member.name}\n data-cimplify-staff-avatar\n className={classNames?.avatar}\n />\n )}\n <span data-cimplify-staff-name className={classNames?.name}>\n {member.name}\n </span>\n {member.bio && (\n <span data-cimplify-staff-bio className={classNames?.bio}>\n {member.bio}\n </span>\n )}\n </Radio.Root>\n ))}\n </RadioGroup>\n );\n}\n' }] }, "chat-widget": { "name": "chat-widget", "title": "ChatWidget", "description": "Embeddable chat widget with AI shopping assistant powered by the support channel.", "type": "component", "registryDependencies": ["cn"], "files": [{ "path": "chat-widget.tsx", "content": '"use client";\n\nimport React, {\n useState,\n useRef,\n useCallback,\n type KeyboardEvent,\n} from "react";\nimport type { CimplifyClient } from "../client";\nimport { CimplifyError } from "@cimplify/sdk";\nimport type { ChatMessage, ChatUploadAttachment, ChatWidgetStarter } from "../types/support";\nimport { isOptimistic } from "./chat-model";\nimport {\n ChatKitProgram,\n ChatReplyCards,\n MessageBubble,\n Seam,\n chatTimeline,\n dayLabel,\n deliveryState,\n groupPosition,\n proseOf,\n sameDay,\n type GenUIActionEvent,\n} from "./chat-kit";\nimport { useChat } from "./hooks/use-chat";\nimport { useOptionalCimplifyClient } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nconst MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024;\n\n/** Mirrors the server allowlist: images plus inert document types. */\nconst DOCUMENT_MIME_TYPES = new Set([\n "application/pdf",\n "text/plain",\n "text/csv",\n "application/msword",\n "application/vnd.openxmlformats-officedocument.wordprocessingml.document",\n "application/vnd.ms-excel",\n "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",\n]);\n\nconst ATTACHMENT_ACCEPT = ["image/*", ...DOCUMENT_MIME_TYPES].join(",");\n\nfunction isAllowedAttachment(file: File): boolean {\n return file.type.startsWith("image/") || DOCUMENT_MIME_TYPES.has(file.type);\n}\nconst KEEP_DISMISSED_KEY = "cimplify_keep_chat_dismissed";\n/** How close to the bottom still counts as reading the live edge. */\nconst AT_BOTTOM_SLACK_PX = 48;\n\n/* \u2500\u2500\u2500 Public API \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\nexport interface ChatWidgetClassNames {\n root?: string;\n bubble?: string;\n panel?: string;\n header?: string;\n messages?: string;\n input?: string;\n welcome?: string;\n}\n\nexport interface ChatWidgetProps {\n client?: CimplifyClient;\n /** Business display name shown in the header */\n businessName?: string;\n /** Greeting text on the welcome screen */\n greeting?: string;\n /** Subtitle on the welcome screen */\n subtitle?: string;\n /** Conversation starters */\n starters?: ChatWidgetStarter[];\n /** Input placeholder */\n placeholder?: string;\n /** Position of the bubble */\n position?: "bottom-right" | "bottom-left";\n /** Start with the panel open */\n defaultOpen?: boolean;\n /** Poll interval in ms (default: 3000) */\n pollInterval?: number;\n /** Currency for structured product cards (e.g. "GHS"). */\n currency?: string;\n /**\n * Receives taps on GenUI buttons and structured cards\n * (`chat:view_product:{id}`, `chat:view_order:{id}`). Without it, cards\n * render read-only and program buttons no-op.\n */\n onGenUIAction?: (event: GenUIActionEvent) => void;\n className?: string;\n classNames?: ChatWidgetClassNames;\n}\n\nexport function ChatWidget({\n client: clientProp,\n businessName: businessNameProp,\n greeting = "Hi there!",\n subtitle = "Ask us anything \u2014 we typically reply in a few seconds.",\n starters,\n placeholder = "Type a message\\u2026",\n position = "bottom-right",\n defaultOpen = false,\n pollInterval,\n currency: currencyProp,\n onGenUIAction,\n className,\n classNames,\n}: ChatWidgetProps) {\n const context = useOptionalCimplifyClient();\n const client = clientProp ?? context?.client;\n // Provider-wrapped hosts get the real identity with zero config.\n const businessName = businessNameProp ?? context?.business?.name ?? "Support";\n const currency = currencyProp ?? context?.baseCurrency;\n\n const [open, setOpen] = useState(defaultOpen);\n const [readAt, setReadAt] = useState<string | null>(null);\n const [attachError, setAttachError] = useState<CimplifyError | null>(null);\n const [replyTo, setReplyTo] = useState<ChatMessage | null>(null);\n\n const {\n messages,\n isLoading,\n isSending,\n awaitingReply,\n seenAt,\n error,\n isActive,\n humanRequested,\n claimedAs,\n signOut,\n send,\n react,\n requestHuman,\n startConversation,\n loadOlder,\n mayHaveOlder,\n openIfExisting,\n } = useChat({ client, starters, pollInterval });\n const showTyping = isSending || awaitingReply;\n\n const attachFile = useCallback(\n async (file: File) => {\n if (!client) return;\n setAttachError(null);\n if (!isAllowedAttachment(file)) {\n setAttachError(\n new CimplifyError("invalid_attachment", "Only photos and documents can be attached."),\n );\n return;\n }\n if (file.size > MAX_ATTACHMENT_BYTES) {\n setAttachError(new CimplifyError("attachment_too_large", "Files must be under 25MB."));\n return;\n }\n const uploaded = await client.uploads.upload(file);\n if (!uploaded.ok) {\n setAttachError(uploaded.error);\n return;\n }\n const attachment: ChatUploadAttachment = {\n upload_id: uploaded.value.id,\n url: uploaded.value.url,\n mime_type: uploaded.value.content_type,\n filename: uploaded.value.filename,\n size: uploaded.value.size_bytes,\n };\n await send("", { attachments: [attachment] });\n },\n [client, send],\n );\n\n const handleSend = useCallback(\n (content: string) => {\n setAttachError(null);\n const target = replyTo;\n setReplyTo(null);\n return send(content, target ? { replyTo: target } : undefined);\n },\n [send, replyTo],\n );\n\n // Unread badge: incoming messages newer than the watermark snapshotted\n // when the panel last closed.\n const unread = open\n ? 0\n : messages.filter(\n (m) => m.sender_type !== "customer" && (!readAt || m.created_at > readAt),\n ).length;\n\n useState(() => {\n if (defaultOpen) void openIfExisting();\n });\n\n const handleOpen = useCallback(() => {\n setOpen(true);\n void openIfExisting();\n }, [openIfExisting]);\n\n const handleClose = useCallback(() => {\n const newest = messages[messages.length - 1];\n setReadAt(newest?.created_at ?? null);\n setOpen(false);\n }, [messages]);\n\n const isLeft = position === "bottom-left";\n\n return (\n <div\n className={cn(\n "fixed bottom-6 z-[9999]",\n isLeft ? "left-6" : "right-6",\n className,\n classNames?.root,\n )}\n >\n {/* Bubble */}\n {!open && (\n <Bubble\n unread={unread}\n onClick={handleOpen}\n className={classNames?.bubble}\n />\n )}\n\n {/* Panel */}\n {open && (\n <Panel\n client={client}\n businessName={businessName}\n greeting={greeting}\n subtitle={subtitle}\n starters={starters}\n placeholder={placeholder}\n messages={messages}\n isLoading={isLoading}\n isSending={isSending}\n isTyping={showTyping}\n seenAt={seenAt}\n readAt={readAt}\n error={error ?? attachError}\n isActive={isActive}\n isLeft={isLeft}\n humanRequested={humanRequested}\n mayHaveOlder={mayHaveOlder}\n replyTo={replyTo}\n claimedAs={claimedAs}\n onSignOut={() => void signOut()}\n onClose={handleClose}\n onSend={handleSend}\n onReact={react}\n onReply={setReplyTo}\n onCancelReply={() => setReplyTo(null)}\n onRequestHuman={() => void requestHuman()}\n onLoadOlder={() => void loadOlder()}\n onAttach={attachFile}\n onStartConversation={startConversation}\n currency={currency}\n onGenUIAction={onGenUIAction}\n classNames={classNames}\n />\n )}\n </div>\n );\n}\n\n/* \u2500\u2500\u2500 Bubble \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\nfunction Bubble({\n unread,\n onClick,\n className,\n}: {\n unread: number;\n onClick: () => void;\n className?: string;\n}) {\n return (\n <button\n type="button"\n onClick={onClick}\n aria-label="Open chat"\n className={cn(\n "relative flex h-14 w-14 items-center justify-center rounded-full",\n "bg-foreground text-background shadow-lg",\n "transition-transform duration-300 ease-out",\n "hover:scale-[1.08] active:scale-95",\n className,\n )}\n >\n <ChatIcon className="h-6 w-6" />\n {unread > 0 && (\n <span\n className={cn(\n "absolute -right-0.5 -top-0.5 flex h-5 w-5 items-center justify-center",\n "rounded-full bg-destructive text-[10px] font-bold text-white",\n "ring-2 ring-background",\n )}\n >\n {unread > 9 ? "9+" : unread}\n </span>\n )}\n </button>\n );\n}\n\n/* \u2500\u2500\u2500 Panel \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\nfunction Panel({\n client,\n businessName,\n greeting,\n subtitle,\n starters,\n placeholder,\n messages,\n isLoading,\n isSending,\n isTyping,\n seenAt,\n readAt,\n error,\n isActive,\n isLeft,\n humanRequested,\n mayHaveOlder,\n replyTo,\n claimedAs,\n onSignOut,\n onClose,\n onSend,\n onReact,\n onReply,\n onCancelReply,\n onRequestHuman,\n onLoadOlder,\n onAttach,\n onStartConversation,\n currency,\n onGenUIAction,\n classNames,\n}: {\n client: CimplifyClient | undefined;\n businessName: string;\n greeting: string;\n subtitle: string;\n starters?: ChatWidgetStarter[];\n placeholder: string;\n messages: ChatMessage[];\n isLoading: boolean;\n isSending: boolean;\n isTyping: boolean;\n seenAt: string | null;\n readAt: string | null;\n error: CimplifyError | null;\n isActive: boolean;\n isLeft: boolean;\n humanRequested: boolean;\n mayHaveOlder: boolean;\n replyTo: ChatMessage | null;\n claimedAs: string | null;\n onSignOut: () => void;\n onClose: () => void;\n onSend: (content: string) => Promise<boolean>;\n onReact: (message: ChatMessage, emoji: string) => Promise<boolean>;\n onReply: (message: ChatMessage) => void;\n onCancelReply: () => void;\n onRequestHuman: () => void;\n onLoadOlder: () => void;\n onAttach: (file: File) => Promise<void>;\n onStartConversation: (text?: string) => Promise<void>;\n currency?: string;\n onGenUIAction?: (event: GenUIActionEvent) => void;\n classNames?: ChatWidgetClassNames;\n}) {\n return (\n <div\n className={cn(\n "flex flex-col overflow-hidden rounded-2xl bg-background shadow-2xl",\n "w-[400px] max-w-[calc(100vw-3rem)]",\n "h-[min(600px,calc(100vh-6rem))]",\n "animate-in fade-in slide-in-from-bottom-4 duration-300",\n isLeft ? "origin-bottom-left" : "origin-bottom-right",\n classNames?.panel,\n )}\n >\n {/* Header */}\n <Header\n businessName={businessName}\n claimedAs={claimedAs}\n onSignOut={onSignOut}\n onClose={onClose}\n className={classNames?.header}\n />\n\n {/* Body */}\n {!isActive && !isLoading ? (\n <Welcome\n greeting={greeting}\n subtitle={subtitle}\n starters={starters}\n onStarter={onStartConversation}\n className={classNames?.welcome}\n />\n ) : (\n <MessageList\n messages={messages}\n businessName={businessName}\n isLoading={isLoading}\n isSending={isTyping}\n seenAt={seenAt}\n readAt={readAt}\n humanRequested={humanRequested}\n mayHaveOlder={mayHaveOlder}\n onReact={onReact}\n onReply={onReply}\n onRequestHuman={onRequestHuman}\n onLoadOlder={onLoadOlder}\n currency={currency}\n onGenUIAction={onGenUIAction}\n className={classNames?.messages}\n />\n )}\n\n {isActive && !claimedAs && client && <KeepConversation client={client} />}\n\n {humanRequested && isActive && (\n <div className="flex items-center gap-2 border-t border-border bg-muted/50 px-4 py-2 text-[11px] text-muted-foreground">\n <span className="h-1.5 w-1.5 flex-shrink-0 animate-pulse rounded-full bg-amber-500" />\n Waiting for {businessName} \u2014 usually replies in minutes\n </div>\n )}\n\n {/* Error */}\n {error != null && (\n <div className="border-t border-destructive/20 bg-destructive/10 px-4 py-2 text-xs text-destructive">\n {error.userMessage || "Something went wrong. Please try again."}\n </div>\n )}\n\n {replyTo && (\n <div className="flex items-start gap-2 border-t border-border bg-muted/50 px-4 py-2">\n <span className="w-[3px] shrink-0 self-stretch rounded-full bg-foreground/40" />\n <div className="min-w-0 flex-1">\n <div className="text-[11px] font-semibold text-foreground">\n Replying to {replyTo.sender_type === "customer" ? "yourself" : businessName}\n </div>\n <div className="truncate text-[11px] text-muted-foreground">\n {proseOf(replyTo.content) || "Photo"}\n </div>\n </div>\n <button\n type="button"\n aria-label="Cancel reply"\n onClick={onCancelReply}\n className="flex-shrink-0 p-0.5 text-muted-foreground hover:text-foreground"\n >\n <CloseIcon className="h-3.5 w-3.5" />\n </button>\n </div>\n )}\n\n {/* Always present: gating on an active conversation left a visitor who\n wants to type their own question with only the starter buttons. */}\n <ChatInput\n placeholder={placeholder}\n isSending={isSending}\n onSend={isActive ? onSend : async (text) => { await onStartConversation(text); return true; }}\n onAttach={onAttach}\n className={classNames?.input}\n />\n\n\n {/* Footer */}\n <div className="flex-shrink-0 border-t border-border bg-background px-4 py-1.5 text-center text-[10px] text-muted-foreground">\n Powered by{" "}\n <span className="font-semibold text-foreground">Cimplify</span>\n </div>\n </div>\n );\n}\n\n/* \u2500\u2500\u2500 Header \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\nfunction Header({\n businessName,\n claimedAs,\n onSignOut,\n onClose,\n className,\n}: {\n businessName: string;\n claimedAs: string | null;\n onSignOut: () => void;\n onClose: () => void;\n className?: string;\n}) {\n return (\n <div\n className={cn(\n "flex flex-shrink-0 items-center gap-3 bg-foreground px-5 py-4 text-background",\n className,\n )}\n >\n <div className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-[10px] bg-background/15">\n <ChatIcon className="h-5 w-5" />\n </div>\n <div className="min-w-0 flex-1">\n <div className="truncate text-[15px] font-semibold leading-tight">\n {businessName}\n </div>\n {claimedAs ? (\n <div className="flex items-center gap-1 truncate text-xs text-background/60">\n <span className="truncate">Chatting as {claimedAs}</span>\n <span aria-hidden="true">\xB7</span>\n <button\n type="button"\n onClick={onSignOut}\n className="flex-shrink-0 underline underline-offset-2 hover:text-background"\n >\n Not you?\n </button>\n </div>\n ) : (\n <div className="flex items-center gap-1.5 text-xs text-background/60">\n <span className="h-[7px] w-[7px] flex-shrink-0 rounded-full bg-emerald-400" />\n Online\n </div>\n )}\n </div>\n <button\n type="button"\n onClick={onClose}\n aria-label="Close chat"\n className={cn(\n "flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg",\n "bg-background/10 text-background/70 transition-colors hover:bg-background/20 hover:text-background",\n )}\n >\n <CloseIcon className="h-[18px] w-[18px]" />\n </button>\n </div>\n );\n}\n\n/* \u2500\u2500\u2500 Welcome \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\nfunction Welcome({\n greeting,\n subtitle,\n starters,\n onStarter,\n className,\n}: {\n greeting: string;\n subtitle: string;\n starters?: ChatWidgetStarter[];\n onStarter: (text?: string) => Promise<void>;\n className?: string;\n}) {\n const defaultStarters: ChatWidgetStarter[] = [\n { icon: "\\ud83d\\udd0d", text: "Help me find a product" },\n { icon: "\\ud83d\\uded2", text: "Check my cart" },\n { icon: "\\ud83d\\udce6", text: "Track my order" },\n ];\n\n const items = starters ?? defaultStarters;\n\n return (\n <div\n className={cn(\n "flex flex-1 flex-col items-center justify-center overflow-y-auto bg-muted/50 px-8 py-10 text-center",\n className,\n )}\n >\n <div className="mb-5 flex h-16 w-16 items-center justify-center rounded-2xl bg-muted">\n <ChatIcon className="h-7 w-7 text-muted-foreground" />\n </div>\n <h3 className="mb-2 text-xl font-bold text-foreground">{greeting}</h3>\n <p className="mb-6 max-w-[280px] text-sm leading-relaxed text-muted-foreground">\n {subtitle}\n </p>\n <div className="flex w-full max-w-[300px] flex-col gap-2">\n {items.map((s) => (\n <button\n key={s.text}\n type="button"\n onClick={() => onStarter(s.text)}\n className={cn(\n "flex items-center gap-3 rounded-xl border border-border bg-background px-4 py-3",\n "text-left text-[13px] font-medium text-foreground",\n "transition-all hover:-translate-y-px hover:border-foreground hover:shadow-sm",\n )}\n >\n {s.icon && (\n <span className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-muted text-[15px]">\n {s.icon}\n </span>\n )}\n <span>{s.text}</span>\n </button>\n ))}\n </div>\n </div>\n );\n}\n\n/* \u2500\u2500\u2500 Messages \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\nconst QUICK_REACTIONS = ["\u{1F44D}", "\u2764\uFE0F"] as const;\n\nfunction LetterAvatar({ name }: { name: string }) {\n return (\n <span className="flex size-6 items-center justify-center rounded-full bg-foreground text-[10px] font-bold text-background">\n {name.charAt(0).toUpperCase()}\n </span>\n );\n}\n\nfunction MessageList({\n messages,\n businessName,\n isLoading,\n isSending,\n seenAt,\n readAt,\n humanRequested,\n mayHaveOlder,\n onReact,\n onReply,\n onRequestHuman,\n onLoadOlder,\n currency,\n onGenUIAction,\n className,\n}: {\n messages: ChatMessage[];\n businessName: string;\n isLoading: boolean;\n isSending: boolean;\n seenAt: string | null;\n readAt: string | null;\n humanRequested: boolean;\n mayHaveOlder: boolean;\n onReact: (message: ChatMessage, emoji: string) => Promise<boolean>;\n onReply: (message: ChatMessage) => void;\n onRequestHuman: () => void;\n onLoadOlder: () => void;\n currency?: string;\n onGenUIAction?: (event: GenUIActionEvent) => void;\n className?: string;\n}) {\n const scrolledUpRef = useRef(false);\n const timeline = chatTimeline(messages, { readAt, rendersPrograms: true });\n const { bubbles, reactionsByTarget, firstUnreadId, unreadOnOpen, firstAgentMessageId } =\n timeline;\n const lastBubble = bubbles[bubbles.length - 1];\n const offerHuman = !humanRequested && lastBubble?.sender_type === "bot" && !isSending;\n\n const handleScroll = (event: React.UIEvent<HTMLDivElement>) => {\n const node = event.currentTarget;\n scrolledUpRef.current =\n node.scrollHeight - node.scrollTop - node.clientHeight >= AT_BOTTOM_SLACK_PX;\n };\n\n return (\n <div\n onScroll={handleScroll}\n style={{ background: "var(--color-chat-canvas, var(--color-muted, #f3f5f3))" }}\n className={cn("flex flex-1 flex-col gap-0.5 overflow-y-auto px-3.5 py-3.5", className)}\n >\n {isLoading && messages.length === 0 && (\n <div className="flex flex-1 items-center justify-center">\n <TypingDots />\n </div>\n )}\n\n {mayHaveOlder && (\n <div className="flex justify-center pb-1">\n <button\n type="button"\n onClick={onLoadOlder}\n className="rounded-full border border-foreground/[0.06] bg-background px-3.5 py-[7px] text-xs font-semibold text-muted-foreground shadow-[0_1px_2px_rgba(15,23,42,0.04)]"\n >\n Show earlier messages\n </button>\n </div>\n )}\n\n {bubbles.map((msg, i) => {\n const prev = bubbles[i - 1];\n const newDay = !prev || !sameDay(prev.created_at, msg.created_at);\n const group = groupPosition(timeline, i);\n const startsRun = group === "single" || group === "first";\n const delivery = deliveryState(msg, timeline, seenAt, isOptimistic);\n\n return (\n <div\n key={msg.id}\n className={cn("flex flex-col gap-1.5", startsRun && i > 0 && "mt-2.5")}\n >\n {newDay && <Seam variant="chip" label={dayLabel(msg.created_at)} />}\n {msg.id === firstUnreadId && (\n <Seam\n variant="unread"\n label={`${unreadOnOpen} new message${unreadOnOpen === 1 ? "" : "s"}`}\n />\n )}\n {msg.id === firstAgentMessageId && <Seam label="A person joined" />}\n {msg.sender_type === "system" ? (\n <Seam label="Passing you to the team" />\n ) : (\n <div className="group relative">\n {msg.sender_type !== "customer" && !isOptimistic(msg) && (\n <HoverRail message={msg} onReact={onReact} onReply={onReply} />\n )}\n <MessageBubble\n message={msg}\n reactions={reactionsByTarget.get(msg.id)}\n businessName={businessName}\n group={group}\n delivery={delivery}\n onReply={onReply}\n avatar={<LetterAvatar name={businessName} />}\n renderProgram={(source) => (\n <ChatKitProgram source={source} onAction={onGenUIAction} />\n )}\n renderCards={(target) => (\n <ChatReplyCards message={target} currency={currency} onAction={onGenUIAction} />\n )}\n onQuoteMiss={async () => {\n onLoadOlder();\n return true;\n }}\n />\n </div>\n )}\n </div>\n );\n })}\n\n {isSending && (\n <div className="mr-auto mt-1">\n <TypingDots />\n </div>\n )}\n\n {offerHuman && (\n <div className="mt-2">\n <button\n type="button"\n onClick={onRequestHuman}\n className="rounded-full border border-border bg-background px-3 py-1.5 text-xs font-semibold text-foreground hover:border-foreground"\n >\n Talk to a person\n </button>\n </div>\n )}\n\n <div\n key={`${messages.length}:${isSending}`}\n ref={(node) => {\n // Keyed so the callback re-fires per new message/typing change \u2014\n // follows the conversation, but never yanks a reader out of history.\n if (node && !scrolledUpRef.current) node.scrollIntoView({ behavior: "smooth" });\n }}\n />\n </div>\n );\n}\n\nfunction HoverRail({\n message,\n onReact,\n onReply,\n}: {\n message: ChatMessage;\n onReact: (message: ChatMessage, emoji: string) => Promise<boolean>;\n onReply: (message: ChatMessage) => void;\n}) {\n return (\n <div className="absolute -top-3 right-0 z-10 hidden gap-0.5 rounded-full border border-border bg-background px-1 py-0.5 shadow-sm group-hover:flex">\n {QUICK_REACTIONS.map((emoji) => (\n <button\n key={emoji}\n type="button"\n aria-label={`React ${emoji}`}\n onClick={() => void onReact(message, emoji)}\n className="flex h-6 w-6 items-center justify-center rounded-full text-[13px] hover:bg-muted"\n >\n {emoji}\n </button>\n ))}\n <button\n type="button"\n aria-label="Reply"\n onClick={() => onReply(message)}\n className="flex h-6 w-6 items-center justify-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground"\n >\n <ReplyIcon className="h-3.5 w-3.5" />\n </button>\n </div>\n );\n}\n\n/* \u2500\u2500\u2500 Keep this conversation \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\nfunction readKeepDismissed(): boolean {\n try {\n return typeof window !== "undefined" && window.localStorage.getItem(KEEP_DISMISSED_KEY) === "1";\n } catch {\n return false;\n }\n}\n\nfunction writeKeepDismissed(): void {\n try {\n window.localStorage.setItem(KEEP_DISMISSED_KEY, "1");\n } catch {}\n}\n\nfunction KeepConversation({ client }: { client: CimplifyClient }) {\n const [step, setStep] = useState<"row" | "contact" | "code">("row");\n const [dismissed, setDismissed] = useState(readKeepDismissed);\n const [contact, setContact] = useState("");\n const [code, setCode] = useState("");\n const [busy, setBusy] = useState(false);\n const [verifyError, setVerifyError] = useState<string | null>(null);\n\n if (dismissed) return null;\n\n const contactType = contact.includes("@") ? "email" : "phone";\n\n const requestCode = async () => {\n const trimmed = contact.trim();\n if (!trimmed || busy) return;\n setBusy(true);\n setVerifyError(null);\n const result = await client.link.requestOtp({ contact: trimmed, contact_type: contactType });\n setBusy(false);\n if (result.ok) {\n setCode("");\n setStep("code");\n } else {\n setVerifyError(result.error.userMessage || "Couldn\'t send a code \u2014 try again.");\n }\n };\n\n const verify = async (value: string) => {\n if (value.length !== 6 || busy) return;\n setBusy(true);\n setVerifyError(null);\n const result = await client.link.verifyOtp({\n contact: contact.trim(),\n contact_type: contactType,\n otp_code: value,\n });\n setBusy(false);\n if (!result.ok) {\n setCode("");\n setVerifyError(result.error.userMessage || "Invalid or expired code.");\n }\n };\n\n return (\n <div className="flex-shrink-0 border-t border-border bg-muted/50 px-4 py-2.5">\n {step === "row" && (\n <div className="flex items-center gap-2.5">\n <button\n type="button"\n onClick={() => setStep("contact")}\n className="flex min-w-0 flex-1 flex-col text-left"\n >\n <span className="text-xs font-semibold text-foreground">Keep this conversation</span>\n <span className="truncate text-[11px] text-muted-foreground">\n Verify your number and it follows you \u2014 any device\n </span>\n </button>\n <button\n type="button"\n aria-label="Dismiss"\n onClick={() => {\n writeKeepDismissed();\n setDismissed(true);\n }}\n className="flex-shrink-0 p-1 text-muted-foreground hover:text-foreground"\n >\n <CloseIcon className="h-3.5 w-3.5" />\n </button>\n </div>\n )}\n\n {step === "contact" && (\n <div className="flex items-center gap-2">\n <input\n type="text"\n inputMode="email"\n value={contact}\n onChange={(e) => setContact(e.target.value)}\n onKeyDown={(e) => e.key === "Enter" && void requestCode()}\n placeholder="Phone or email"\n autoFocus\n className="h-9 min-w-0 flex-1 rounded-lg border border-border bg-background px-3 text-xs text-foreground outline-none placeholder:text-muted-foreground focus:border-foreground"\n />\n <button\n type="button"\n onClick={() => void requestCode()}\n disabled={!contact.trim() || busy}\n className="h-9 flex-shrink-0 rounded-lg bg-foreground px-3 text-xs font-semibold text-background disabled:opacity-50"\n >\n {busy ? "Sending\u2026" : "Send code"}\n </button>\n </div>\n )}\n\n {step === "code" && (\n <div className="flex items-center gap-2">\n <input\n type="text"\n inputMode="numeric"\n autoComplete="one-time-code"\n maxLength={6}\n value={code}\n onChange={(e) => {\n const digits = e.target.value.replace(/\\D/g, "").slice(0, 6);\n setCode(digits);\n if (digits.length === 6) void verify(digits);\n }}\n placeholder="6-digit code"\n autoFocus\n className="h-9 min-w-0 flex-1 rounded-lg border border-border bg-background px-3 text-xs tracking-[0.2em] text-foreground outline-none placeholder:tracking-normal placeholder:text-muted-foreground focus:border-foreground"\n />\n <button\n type="button"\n onClick={() => void requestCode()}\n disabled={busy}\n className="h-9 flex-shrink-0 rounded-lg px-2 text-[11px] font-medium text-muted-foreground hover:text-foreground disabled:opacity-50"\n >\n Resend\n </button>\n </div>\n )}\n\n {verifyError && <p className="mt-1.5 text-[11px] text-destructive">{verifyError}</p>}\n </div>\n );\n}\n\n/* \u2500\u2500\u2500 Input \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\nfunction ChatInput({\n placeholder,\n isSending,\n onSend,\n onAttach,\n className,\n}: {\n placeholder: string;\n isSending: boolean;\n onSend: (content: string) => Promise<boolean>;\n onAttach: (file: File) => Promise<void>;\n className?: string;\n}) {\n const [value, setValue] = useState("");\n const [uploading, setUploading] = useState(false);\n const textareaRef = useRef<HTMLTextAreaElement>(null);\n const fileRef = useRef<HTMLInputElement>(null);\n\n const handleAttach = useCallback(\n async (file: File) => {\n setUploading(true);\n try {\n await onAttach(file);\n } finally {\n setUploading(false);\n }\n },\n [onAttach],\n );\n\n const handleSend = useCallback(async () => {\n const trimmed = value.trim();\n if (!trimmed || isSending) return;\n setValue("");\n if (textareaRef.current) {\n textareaRef.current.style.height = "auto";\n }\n const sent = await onSend(trimmed);\n // A failed send must not eat the message \u2014 put it back to retry.\n if (!sent) setValue((current) => current || trimmed);\n }, [value, isSending, onSend]);\n\n const handleKeyDown = useCallback(\n (e: KeyboardEvent<HTMLTextAreaElement>) => {\n if (e.key === "Enter" && !e.shiftKey) {\n e.preventDefault();\n handleSend();\n }\n },\n [handleSend],\n );\n\n const handleInput = useCallback(() => {\n const el = textareaRef.current;\n if (!el) return;\n el.style.height = "auto";\n el.style.height = `${Math.min(el.scrollHeight, 100)}px`;\n }, []);\n\n const hasContent = value.trim().length > 0;\n\n return (\n <div\n className={cn(\n "flex flex-shrink-0 items-end gap-2 border-t border-border bg-background px-4 py-3",\n className,\n )}\n >\n <input\n ref={fileRef}\n type="file"\n accept={ATTACHMENT_ACCEPT}\n className="hidden"\n aria-hidden="true"\n tabIndex={-1}\n onChange={(e) => {\n const file = e.target.files?.[0];\n e.target.value = "";\n if (file) void handleAttach(file);\n }}\n />\n <button\n type="button"\n aria-label="Attach a photo"\n disabled={uploading}\n onClick={() => fileRef.current?.click()}\n className={cn(\n "flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-[10px]",\n "bg-muted text-muted-foreground transition-colors hover:text-foreground",\n uploading && "animate-pulse",\n )}\n >\n <ImageIcon className="h-[18px] w-[18px]" />\n </button>\n <div\n className={cn(\n "flex flex-1 items-end rounded-xl border-[1.5px] border-transparent bg-muted px-1",\n "transition-colors focus-within:border-foreground focus-within:bg-background",\n )}\n >\n <textarea\n ref={textareaRef}\n value={value}\n onChange={(e) => setValue(e.target.value)}\n onKeyDown={handleKeyDown}\n onInput={handleInput}\n placeholder={placeholder}\n rows={1}\n className={cn(\n "max-h-[100px] min-h-[20px] flex-1 resize-none bg-transparent px-2.5 py-2",\n "text-sm text-foreground outline-none placeholder:text-muted-foreground",\n )}\n />\n </div>\n\n <button\n type="button"\n onClick={handleSend}\n disabled={!hasContent || isSending}\n aria-label="Send message"\n className={cn(\n "flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-[10px]",\n "transition-all duration-200",\n hasContent && !isSending\n ? "bg-foreground text-background hover:scale-105 active:scale-95"\n : "bg-muted text-muted-foreground",\n )}\n >\n <SendIcon className="h-[18px] w-[18px]" />\n </button>\n </div>\n );\n}\n\n/* \u2500\u2500\u2500 Typing dots \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\nfunction TypingDots() {\n return (\n <div className="flex items-center gap-1 px-4 py-3">\n {[0, 1, 2].map((i) => (\n <span\n key={i}\n className="h-[7px] w-[7px] animate-bounce rounded-full bg-muted-foreground/40"\n style={{ animationDelay: `${i * 160}ms` }}\n />\n ))}\n </div>\n );\n}\n\n/* \u2500\u2500\u2500 Icons (inline SVG \u2014 no icon library) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */\n\nfunction ChatIcon({ className }: { className?: string }) {\n return (\n <svg\n className={className}\n viewBox="0 0 24 24"\n fill="none"\n stroke="currentColor"\n strokeWidth={2}\n strokeLinecap="round"\n strokeLinejoin="round"\n >\n <path d="M21 11.5a8.38 8.38 0 01-.9 3.8 8.5 8.5 0 01-7.6 4.7 8.38 8.38 0 01-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 01-.9-3.8 8.5 8.5 0 014.7-7.6 8.38 8.38 0 013.8-.9h.5a8.48 8.48 0 018 8v.5z" />\n </svg>\n );\n}\n\nfunction CloseIcon({ className }: { className?: string }) {\n return (\n <svg\n className={className}\n viewBox="0 0 24 24"\n fill="none"\n stroke="currentColor"\n strokeWidth={2}\n strokeLinecap="round"\n strokeLinejoin="round"\n >\n <path d="M18 6L6 18M6 6l12 12" />\n </svg>\n );\n}\n\nfunction SendIcon({ className }: { className?: string }) {\n return (\n <svg\n className={className}\n viewBox="0 0 24 24"\n fill="none"\n stroke="currentColor"\n strokeWidth={2}\n strokeLinecap="round"\n strokeLinejoin="round"\n >\n <line x1="22" y1="2" x2="11" y2="13" />\n <polygon points="22 2 15 22 11 13 2 9 22 2" />\n </svg>\n );\n}\n\nfunction ImageIcon({ className }: { className?: string }) {\n return (\n <svg\n className={className}\n viewBox="0 0 24 24"\n fill="none"\n stroke="currentColor"\n strokeWidth={2}\n strokeLinecap="round"\n strokeLinejoin="round"\n >\n <rect x="3" y="3" width="18" height="18" rx="2" ry="2" />\n <circle cx="8.5" cy="8.5" r="1.5" />\n <polyline points="21 15 16 10 5 21" />\n </svg>\n );\n}\n\nfunction ReplyIcon({ className }: { className?: string }) {\n return (\n <svg\n className={className}\n viewBox="0 0 24 24"\n fill="none"\n stroke="currentColor"\n strokeWidth={2}\n strokeLinecap="round"\n strokeLinejoin="round"\n >\n <polyline points="9 17 4 12 9 7" />\n <path d="M20 18v-2a4 4 0 0 0-4-4H4" />\n </svg>\n );\n}\n\n' }] }, "service-product-layout": { "name": "service-product-layout", "title": "ServiceProductLayout", "description": "Service booking layout with duration, deposit, staff requirements, and cancellation policy.", "type": "component", "registryDependencies": ["product-image-gallery", "product-customizer", "price", "rich-text", "cn"], "files": [{ "path": "layouts/service-product-layout.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { ProductLayoutProps } from "@cimplify/sdk/react";\nimport {\n TwoColumnGrid,\n RelatedProductsSection,\n CustomAttributesTable,\n TagPills,\n} from "@cimplify/sdk/react";\nimport { ProductImageGallery } from "@cimplify/sdk/react";\nimport { ProductCustomizer } from "@cimplify/sdk/react";\nimport { Price } from "@cimplify/sdk/react";\nimport { parsePrice, getProductCurrency } from "@cimplify/sdk";\nimport { RichText } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\nimport { formatDuration } from "../utils/format-duration";\n\nexport function ServiceProductLayout({\n product,\n onAddToCart,\n renderBreadcrumb,\n relatedProducts,\n showRelated = true,\n className,\n}: ProductLayoutProps): React.ReactElement {\n const images = product.images || (product.image_url ? [product.image_url] : []);\n const currency = getProductCurrency(product);\n const hasDeposit = product.deposit_type && product.deposit_type !== "none" && product.deposit_amount;\n const depositAmount = hasDeposit ? parsePrice(product.deposit_amount!) : 0;\n const cancellationHours = product.cancellation_window_minutes\n ? Math.floor(product.cancellation_window_minutes / 60)\n : null;\n\n return (\n <div data-cimplify-product-layout="service" className={cn("space-y-8", className)}>\n {renderBreadcrumb?.(product)}\n\n <TwoColumnGrid\n left={\n <ProductImageGallery\n images={images}\n productName={product.name}\n />\n }\n right={\n <div className="space-y-6">\n {/* Tags */}\n <TagPills tags={product.tags || []} />\n\n {/* Name + Price */}\n <div>\n <h1 data-cimplify-product-layout-name className="text-3xl lg:text-4xl font-extrabold tracking-tight">\n {product.name}\n </h1>\n <Price amount={product.default_price} currency={currency} className="text-2xl font-bold text-primary mt-2 block" />\n </div>\n\n {/* Service info pills */}\n <div data-cimplify-product-layout-service-info className="flex flex-wrap gap-2">\n {product.duration_minutes != null && (\n <span className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-muted text-sm text-muted-foreground rounded-full">\n {formatDuration(product.duration_minutes, product.duration_unit)}\n </span>\n )}\n {hasDeposit && (\n <span className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-muted text-sm text-muted-foreground rounded-full">\n <Price amount={depositAmount} currency={currency} /> deposit\n </span>\n )}\n {product.general_service_capacity != null && product.general_service_capacity > 1 && (\n <span className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-muted text-sm text-muted-foreground rounded-full">\n Up to {product.general_service_capacity} people\n </span>\n )}\n {product.buffer_before_minutes != null && product.buffer_before_minutes > 0 && (\n <span className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-muted text-sm text-muted-foreground rounded-full">\n {product.buffer_before_minutes} min buffer\n </span>\n )}\n </div>\n\n {/* Description */}\n <RichText html={product.description} data-cimplify-product-layout-description />\n\n {/* Staff & Resource requirements */}\n {(product.requires_specific_staff || product.requires_specific_resource) && (\n <div data-cimplify-product-layout-requirements className="text-sm text-muted-foreground space-y-1">\n {product.requires_specific_staff && (\n <p>Staff selection required</p>\n )}\n {product.requires_specific_resource && (\n <p>Resource selection required</p>\n )}\n </div>\n )}\n\n {/* Custom Attributes */}\n <CustomAttributesTable attributes={product.custom_attributes || []} />\n\n {/* Customizer (variants, scheduling, add to cart) */}\n <ProductCustomizer\n product={product}\n onAddToCart={onAddToCart}\n showSpecialInstructions={false}\n />\n\n {/* Cancellation policy */}\n {(cancellationHours != null || product.no_show_fee) && (\n <div data-cimplify-product-layout-cancellation className="border border-border p-4 space-y-2 text-sm">\n <h3 className="font-semibold">Cancellation Policy</h3>\n {cancellationHours != null && (\n <p className="text-muted-foreground">\n Free cancellation up to <strong className="text-foreground">{cancellationHours} hours</strong> before the booking\n </p>\n )}\n {product.no_show_fee && (\n <p className="text-muted-foreground">\n No-show fee: <Price amount={product.no_show_fee} currency={currency} className="font-medium text-foreground" />\n </p>\n )}\n {hasDeposit && (\n <p className="text-muted-foreground">\n {product.deposit_type === "fixed"\n ? <><Price amount={depositAmount} currency={currency} className="font-medium text-foreground" /> deposit charged at booking</>\n : <>{parsePrice(product.deposit_amount!)}% deposit charged at booking</>\n }\n </p>\n )}\n </div>\n )}\n </div>\n }\n />\n\n {showRelated && relatedProducts && relatedProducts.length > 0 && (\n <RelatedProductsSection\n products={relatedProducts}\n title="Other services"\n />\n )}\n </div>\n );\n}\n' }] }, "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' }] }, "deal-banner": { "name": "deal-banner", "title": "DealBanner", "description": "Displays active deals and promotions.", "type": "component", "registryDependencies": ["price", "cn"], "files": [{ "path": "deal-banner.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { Deal } from "@cimplify/sdk";\nimport { useDeals } from "@cimplify/sdk/react";\nimport { Price } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface DealBannerClassNames {\n root?: string;\n item?: string;\n description?: string;\n value?: string;\n badge?: string;\n empty?: string;\n}\n\nexport interface DealBannerProps {\n /** Override deals (skips useDeals fetch). For SSR, pass pre-fetched deals. */\n deals?: Deal[];\n /** Location ID for location-specific deals. */\n locationId?: string;\n /** Called when a deal is clicked. */\n onDealClick?: (deal: Deal) => void;\n /** Custom deal card renderer. */\n renderDeal?: (deal: Deal) => React.ReactNode;\n /** Maximum deals to show. Default: all. */\n limit?: number;\n className?: string;\n classNames?: DealBannerClassNames;\n}\n\nfunction formatBenefitLabel(deal: Deal): string {\n switch (deal.benefit_type) {\n case "percentage":\n return `${deal.value}% off`;\n case "fixed":\n return `Save`;\n case "free_item":\n return "Free item";\n case "buy_x_get_y_free":\n return `Buy ${deal.buy_quantity ?? ""} get ${deal.get_quantity ?? 1} free`;\n case "points":\n return `Earn ${deal.value} points`;\n default:\n return "Special offer";\n }\n}\n\n/**\n * DealBanner \u2014 displays active deals/promotions.\n *\n * Renders as a horizontal scrollable strip or grid of deal cards.\n */\nexport function DealBanner({\n deals: dealsProp,\n locationId,\n onDealClick,\n renderDeal,\n limit,\n className,\n classNames,\n}: DealBannerProps): React.ReactElement | null {\n const { deals: fetched, isLoading } = useDeals({\n locationId,\n enabled: dealsProp === undefined,\n });\n\n const allDeals = dealsProp ?? fetched;\n const deals = limit ? allDeals.slice(0, limit) : allDeals;\n\n if (isLoading && deals.length === 0) {\n return (\n <div\n data-cimplify-deal-banner\n aria-busy="true"\n className={cn(className, classNames?.root)}\n />\n );\n }\n\n if (deals.length === 0) {\n return null;\n }\n\n return (\n <div data-cimplify-deal-banner className={cn(className, classNames?.root)}>\n {deals.map((deal) => (\n <button\n key={deal.id}\n type="button"\n onClick={() => onDealClick?.(deal)}\n data-cimplify-deal-item\n data-benefit-type={deal.benefit_type}\n className={classNames?.item}\n >\n {renderDeal ? (\n renderDeal(deal)\n ) : (\n <>\n <span data-cimplify-deal-badge className={classNames?.badge}>\n {formatBenefitLabel(deal)}\n </span>\n <span data-cimplify-deal-description className={classNames?.description}>\n {deal.description}\n </span>\n {deal.benefit_type === "fixed" && (\n <span data-cimplify-deal-value className={classNames?.value}>\n <Price amount={deal.value} />\n </span>\n )}\n {deal.min_order_value && (\n <span data-cimplify-deal-minimum>\n Min. order <Price amount={deal.min_order_value} />\n </span>\n )}\n </>\n )}\n </button>\n ))}\n </div>\n );\n}\n' }] }, "order-history-page": { "name": "order-history-page", "title": "OrderHistoryPage", "description": "Order list with status filtering and inline detail view.", "type": "component", "registryDependencies": ["order-history", "order-summary", "cn"], "files": [{ "path": "order-history-page.tsx", "content": '"use client";\n\nimport React, { useState, useCallback } from "react";\nimport { Tabs } from "@base-ui/react/tabs";\nimport type { Order, OrderStatus } from "@cimplify/sdk";\nimport { OrderHistory } from "@cimplify/sdk/react";\nimport { OrderSummary } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface OrderHistoryPageClassNames {\n root?: string;\n header?: string;\n title?: string;\n filters?: string;\n filterButton?: string;\n list?: string;\n detail?: string;\n backButton?: string;\n}\n\nexport interface OrderHistoryPageProps {\n /** Page title. */\n title?: string;\n /** Pre-fetched orders for SSR. */\n orders?: Order[];\n /** Max orders to show per page. */\n limit?: number;\n /** Called when navigating to an order detail (e.g. for routing). */\n onOrderNavigate?: (order: Order) => void;\n /** Called when the reorder button is clicked. */\n onReorder?: (order: Order) => void;\n /** Show status filter tabs. Default: true. */\n showFilters?: boolean;\n /** Custom order row renderer. */\n renderOrder?: (order: Order) => React.ReactNode;\n className?: string;\n classNames?: OrderHistoryPageClassNames;\n}\n\nconst STATUS_FILTERS: { label: string; value: OrderStatus | undefined; tabValue: string }[] = [\n { label: "All", value: undefined, tabValue: "all" },\n { label: "Active", value: "confirmed", tabValue: "confirmed" },\n { label: "Completed", value: "completed", tabValue: "completed" },\n { label: "Cancelled", value: "cancelled", tabValue: "cancelled" },\n];\n\n/**\n * OrderHistoryPage \u2014 order list with inline detail view and status filtering.\n *\n * SSR-friendly: pass `orders` prop for server rendering.\n * Supports both inline detail view and external navigation via `onOrderNavigate`.\n */\nexport function OrderHistoryPage({\n title = "Order History",\n orders: ordersProp,\n limit = 20,\n onOrderNavigate,\n onReorder,\n showFilters = true,\n renderOrder,\n className,\n classNames,\n}: OrderHistoryPageProps): React.ReactElement {\n const [statusFilter, setStatusFilter] = useState<OrderStatus | undefined>(undefined);\n const [selectedOrder, setSelectedOrder] = useState<Order | null>(null);\n\n const handleTabChange = useCallback((value: string | number | null) => {\n const filter = STATUS_FILTERS.find((f) => f.tabValue === value);\n setStatusFilter(filter?.value);\n }, []);\n\n const handleOrderClick = useCallback(\n (order: Order) => {\n if (onOrderNavigate) {\n onOrderNavigate(order);\n } else {\n setSelectedOrder(order);\n }\n },\n [onOrderNavigate],\n );\n\n const handleBack = useCallback(() => {\n setSelectedOrder(null);\n }, []);\n\n const activeTabValue = STATUS_FILTERS.find((f) => f.value === statusFilter)?.tabValue ?? "all";\n\n // Inline detail view\n if (selectedOrder && !onOrderNavigate) {\n return (\n <div data-cimplify-order-history-page className={cn(className, classNames?.root)}>\n <div data-cimplify-order-history-detail className={classNames?.detail}>\n <button\n type="button"\n onClick={handleBack}\n data-cimplify-order-history-back\n className={classNames?.backButton}\n >\n Back to orders\n </button>\n <OrderSummary order={selectedOrder} onReorder={onReorder} />\n </div>\n </div>\n );\n }\n\n return (\n <div data-cimplify-order-history-page className={cn(className, classNames?.root)}>\n {/* Header */}\n <div data-cimplify-order-history-header className={classNames?.header}>\n <h1 data-cimplify-order-history-title className={classNames?.title}>\n {title}\n </h1>\n </div>\n\n {/* Status filter */}\n {showFilters && (\n <Tabs.Root value={activeTabValue} onValueChange={handleTabChange}>\n <Tabs.List data-cimplify-order-history-filters className={classNames?.filters}>\n {STATUS_FILTERS.map((filter) => (\n <Tabs.Tab\n key={filter.tabValue}\n value={filter.tabValue}\n data-cimplify-order-filter\n data-selected={statusFilter === filter.value || undefined}\n className={classNames?.filterButton}\n >\n {filter.label}\n </Tabs.Tab>\n ))}\n </Tabs.List>\n </Tabs.Root>\n )}\n\n {/* Order list */}\n <div data-cimplify-order-history-list className={classNames?.list}>\n <OrderHistory\n orders={ordersProp}\n status={statusFilter}\n limit={limit}\n onOrderClick={handleOrderClick}\n renderOrder={renderOrder}\n onReorder={onReorder}\n />\n </div>\n </div>\n );\n}\n' }] }, "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' }] }, "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' }] }, "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 { currencyCode, type CurrencyCode } 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 type { InternalUseCartItem } from "./hooks/use-cart-types";\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 currency,\n classNames,\n}: {\n item: UseCartItem;\n onRemove: (itemId: string) => void;\n onQuantityChange: (itemId: string, qty: number) => void;\n currency: CurrencyCode;\n classNames?: CartSummaryClassNames;\n}): React.ReactElement {\n const { unitPrice: locallyResolvedUnitPrice } = useProductPrice({\n product: item.product,\n variant: item.variant,\n addOnOptions: item.addOnOptions,\n });\n const pinnedUnitPrice = (item as InternalUseCartItem)._quotedUnitPrice;\n const unitPrice =\n pinnedUnitPrice === undefined ? locallyResolvedUnitPrice : parsePrice(pinnedUnitPrice);\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 src={imageUrl} alt="" className="w-full h-full object-cover" loading="lazy" />\n ) : (\n <div className="w-full h-full grid place-items-center text-muted-foreground/40">\n <svg\n className="w-6 h-6"\n fill="none"\n stroke="currentColor"\n strokeWidth="1.5"\n viewBox="0 0 24 24"\n aria-hidden\n >\n <path\n strokeLinecap="round"\n strokeLinejoin="round"\n 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 />\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\n className="w-3.5 h-3.5"\n fill="none"\n stroke="currentColor"\n strokeWidth="2"\n viewBox="0 0 24 24"\n aria-hidden\n >\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 ||\n (item.addOnOptions && item.addOnOptions.length > 0) ||\n hasComposite ||\n 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\n data-cimplify-cart-customer-inputs\n className={cn("mt-1.5 text-xs space-y-0.5", classNames?.customerInputs)}\n >\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">\n {input.field_name}:\n </dt>\n <dd data-cimplify-cart-input-value className="text-foreground m-0 truncate">\n {input.field_type === INPUT_FIELD_TYPE.File ||\n 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") &&\n item.scheduledStart && (\n <div\n data-cimplify-cart-service-info\n className={cn(\n "mt-1.5 flex items-center gap-1.5 text-xs text-muted-foreground",\n classNames?.serviceInfo,\n )}\n >\n <svg\n className="w-3.5 h-3.5 shrink-0"\n fill="none"\n stroke="currentColor"\n strokeWidth="2"\n viewBox="0 0 24 24"\n aria-hidden\n >\n <path\n strokeLinecap="round"\n strokeLinejoin="round"\n 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 />\n </svg>\n <span>\n {new Date(item.scheduledStart).toLocaleDateString(undefined, {\n month: "short",\n day: "numeric",\n })}{" "}\n at{" "}\n {new Date(item.scheduledStart).toLocaleTimeString([], {\n hour: "2-digit",\n minute: "2-digit",\n })}\n </span>\n {item.serviceStatus && (\n <span\n data-status={item.serviceStatus}\n className="ml-auto px-1.5 py-0.5 rounded-full bg-muted text-[10px] uppercase tracking-wider"\n >\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\n data-cimplify-cart-digital-info\n className={cn(\n "mt-1.5 flex items-center gap-1 text-xs text-muted-foreground",\n classNames?.digitalInfo,\n )}\n >\n <svg\n className="w-3.5 h-3.5 shrink-0"\n fill="none"\n stroke="currentColor"\n strokeWidth="2"\n viewBox="0 0 24 24"\n aria-hidden\n >\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(\n "mt-1.5 pl-3 border-l border-border space-y-0.5 text-xs text-muted-foreground",\n item.compositeResolved ? classNames?.compositeBreakdown : classNames?.bundleBreakdown,\n )}\n >\n {(\n (item.bundleResolved?.selections ??\n item.compositeResolved?.selections ??\n []) as Array<{\n component_id?: string;\n product_name?: string;\n component_name?: string;\n quantity: number;\n product_type?: string;\n }>\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\n key={compId}\n data-product-type={sel.product_type}\n className="flex items-baseline gap-1.5 m-0"\n >\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 currency={currency}\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 taxRate,\n taxInclusive,\n taxJurisdictionLabel,\n total,\n isEmpty,\n isOptimistic,\n pendingOpCount,\n currency,\n removeItem,\n updateQuantity,\n } = useCart();\n const displayCurrency = serverCart?.currency ?? currencyCode(currency);\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 currency={displayCurrency}\n classNames={classNames}\n />\n ))}\n </div>\n\n {/* Totals */}\n {showTotals && (\n <div\n data-cimplify-cart-totals\n className={cn(\n "mt-6 pt-4 border-t border-border space-y-2 text-sm",\n classNames?.totals,\n )}\n >\n <div\n data-cimplify-cart-subtotal\n className={cn("flex items-baseline justify-between", classNames?.subtotal)}\n >\n <span className="text-muted-foreground">\n Subtotal ({itemCount} {itemCount === 1 ? "item" : "items"})\n </span>\n <Price amount={subtotal} currency={displayCurrency} className="tabular-nums" />\n </div>\n\n {totalDiscounts != null && parsePrice(totalDiscounts) > 0 && (\n <div\n data-cimplify-cart-discount\n className={cn("flex items-baseline justify-between", classNames?.discount)}\n >\n <span className="text-muted-foreground">Discount</span>\n <Price\n amount={totalDiscounts}\n currency={displayCurrency}\n prefix="-"\n className="tabular-nums text-primary"\n />\n </div>\n )}\n\n {deliveryFee != null && (\n <div\n data-cimplify-cart-delivery-fee\n className={cn("flex items-baseline justify-between", classNames?.deliveryFee)}\n >\n <span className="text-muted-foreground">Delivery</span>\n {parsePrice(deliveryFee) > 0 ? (\n <Price\n amount={deliveryFee}\n currency={displayCurrency}\n className="tabular-nums"\n />\n ) : (\n <span className="font-medium text-primary">Free</span>\n )}\n </div>\n )}\n\n {serviceCharge != null && parsePrice(serviceCharge) > 0 && (\n <div\n data-cimplify-cart-service-charge\n className={cn("flex items-baseline justify-between", classNames?.serviceCharge)}\n >\n <span className="text-muted-foreground">Service charge</span>\n <Price\n amount={serviceCharge}\n currency={displayCurrency}\n className="tabular-nums"\n />\n </div>\n )}\n\n <div\n data-cimplify-cart-tax\n className={cn("flex items-baseline justify-between", classNames?.tax)}\n >\n <span className="flex items-baseline gap-1.5 text-muted-foreground">\n {taxInclusive\n ? "Tax, included in prices"\n : taxJurisdictionLabel\n ? `Sales tax, ${taxJurisdictionLabel}`\n : "Sales tax"}\n {taxRate != null && taxRate > 0 && (\n <span className="rounded-full bg-muted px-1.5 py-0.5 text-[11px] font-medium">\n {taxRate}%\n </span>\n )}\n </span>\n <Price amount={tax} currency={displayCurrency} className="tabular-nums" />\n </div>\n\n <div\n data-cimplify-cart-total\n className={cn(\n "flex items-baseline justify-between pt-3 mt-1 border-t border-border",\n classNames?.total,\n )}\n >\n <span className="font-semibold">Total</span>\n <Price\n amount={total}\n currency={displayCurrency}\n className="text-base font-bold tabular-nums"\n />\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' }] }, "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} = ${Number(rate.rate).toFixed(4)} ${currency}`\n : null}\n </span>\n )}\n </>\n )}\n </button>\n );\n })}\n </div>\n );\n}\n' }] }, "product-model-3d": { "name": "product-model-3d", "title": "ProductModel3D", "description": "Interactive 3D product viewer wrapping Google's <model-viewer>. Loads the web component on demand; AR mode lights up on iOS (Quick Look) and Android (Scene Viewer) automatically.", "type": "component", "registryDependencies": [], "files": [{ "path": "product-model-3d.tsx", "content": '"use client";\n\nimport React, { useEffect, useState } from "react";\n\nexport interface ProductModel3DProps {\n /** glTF or GLB URL. */\n src: string;\n /** Optional USDZ URL for iOS AR Quick Look (Safari/WebKit). */\n iosSrc?: string;\n /** Poster image shown while the model loads (and as a fallback). */\n poster?: string;\n alt?: string;\n aspectRatio?: "square" | "4/3" | "16/9" | "3/4";\n /** Enable AR mode. Lights up on iOS (Quick Look) + Android (Scene Viewer). Default `true`. */\n ar?: boolean;\n autoRotate?: boolean;\n cameraControls?: boolean;\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/9": { aspectRatio: "16/9" },\n "3/4": { aspectRatio: "3/4" },\n};\n\nconst MODEL_VIEWER_CDN =\n "https://unpkg.com/@google/model-viewer@4.0.0/dist/model-viewer.min.js";\n\n// Module-level cache so multiple <ProductModel3D> instances share one script load.\nlet modelViewerLoadPromise: Promise<void> | null = null;\nfunction ensureModelViewer(): Promise<void> {\n if (typeof window === "undefined") return Promise.resolve();\n if (modelViewerLoadPromise) return modelViewerLoadPromise;\n if (window.customElements?.get("model-viewer")) {\n modelViewerLoadPromise = Promise.resolve();\n return modelViewerLoadPromise;\n }\n modelViewerLoadPromise = new Promise<void>((resolve, reject) => {\n const script = document.createElement("script");\n script.type = "module";\n script.src = MODEL_VIEWER_CDN;\n script.onload = () => resolve();\n script.onerror = () => reject(new Error("Failed to load model-viewer"));\n document.head.appendChild(script);\n });\n return modelViewerLoadPromise;\n}\n\n/**\n * ProductModel3D \u2014 interactive 3D product viewer wrapping Google\'s\n * `<model-viewer>` web component. Script loads on demand (one tag per page,\n * cached). AR mode auto-resolves the right OS surface: USDZ Quick Look on iOS,\n * Scene Viewer on Android, WebXR where available. Poster image shows during\n * load and as a fallback if the script can\'t load.\n */\nexport function ProductModel3D({\n src,\n iosSrc,\n poster,\n alt,\n aspectRatio = "square",\n ar = true,\n autoRotate = true,\n cameraControls = true,\n className,\n}: ProductModel3DProps): React.ReactElement {\n const [ready, setReady] = useState(false);\n\n useEffect(() => {\n let cancelled = false;\n ensureModelViewer().then(\n () => {\n if (!cancelled) setReady(true);\n },\n () => {\n // Stay on the poster fallback if the CDN script fails.\n },\n );\n return () => {\n cancelled = true;\n };\n }, []);\n\n return (\n <div\n data-cimplify-product-model-3d\n className={className}\n style={{ position: "relative", overflow: "hidden", ...ASPECT_STYLES[aspectRatio] }}\n >\n {ready\n ? React.createElement("model-viewer", {\n src,\n "ios-src": iosSrc,\n alt,\n poster,\n ar: ar || undefined,\n "ar-modes": ar ? "webxr scene-viewer quick-look" : undefined,\n "camera-controls": cameraControls || undefined,\n "auto-rotate": autoRotate || undefined,\n "shadow-intensity": "1",\n style: { width: "100%", height: "100%", backgroundColor: "transparent" },\n })\n : poster\n ? React.createElement("img", {\n src: poster,\n alt: alt ?? "",\n "data-cimplify-product-model-3d-loading": true,\n style: { width: "100%", height: "100%", objectFit: "cover" },\n })\n : React.createElement("div", {\n "data-cimplify-product-model-3d-loading": true,\n style: { width: "100%", height: "100%", backgroundColor: "var(--muted, #f3f4f6)" },\n })}\n </div>\n );\n}\n' }] }, "digital-product-card": { "name": "digital-product-card", "title": "DigitalProductCard", "description": "Digital product card with type badge, file info, and event details.", "type": "component", "registryDependencies": ["price", "cn", "strip-html"], "files": [{ "path": "cards/digital-product-card.tsx", "content": '"use client";\n\nimport React from "react";\nimport { stripHtmlToText } from "@cimplify/sdk/react";\nimport type { CardLayoutProps } from "@cimplify/sdk/react";\nimport { CardShell, CardImage } from "@cimplify/sdk/react";\nimport { Price } from "@cimplify/sdk/react";\nimport { getProductCurrency } from "@cimplify/sdk";\n\nconst TYPE_BADGES: Record<string, { label: string; color: string }> = {\n download: { label: "Download", color: "bg-sky-50 text-sky-700 border-sky-200/60" },\n license_key: { label: "License", color: "bg-blue-50 text-blue-700 border-blue-200/60" },\n ticket: { label: "Event", color: "bg-violet-50 text-violet-700 border-violet-200/60" },\n access_grant: { label: "Access", color: "bg-emerald-50 text-emerald-700 border-emerald-200/60" },\n redemption_code: { label: "Code", color: "bg-amber-50 text-amber-700 border-amber-200/60" },\n};\n\nexport function DigitalProductCard({\n product,\n renderImage,\n renderLink,\n className,\n}: CardLayoutProps): React.ReactElement {\n const image = product.image_url || product.images?.[0] || "";\n const currency = getProductCurrency(product);\n const typeBadge = product.digital_type ? TYPE_BADGES[product.digital_type] : null;\n const isTicket = product.digital_type === "ticket";\n\n return (\n <CardShell product={product} renderLink={renderLink} className={className}>\n <CardImage src={image} alt={product.name} aspectRatio="4/3" renderImage={renderImage}>\n {typeBadge && (\n <span className={`absolute top-3 left-3 text-[11px] font-semibold tracking-wide border px-2 py-0.5 rounded-md ${typeBadge.color}`}>\n {typeBadge.label}\n </span>\n )}\n </CardImage>\n\n <div className="p-3.5">\n <h3 className="text-[14.5px] font-semibold text-foreground leading-snug tracking-tight truncate">\n {product.name}\n </h3>\n\n {/* Event: date + venue */}\n {isTicket && (product.event_date || product.venue) && (\n <p className="text-[12px] text-muted-foreground mt-1 truncate">\n {product.event_date && new Date(product.event_date).toLocaleDateString(undefined, { month: "short", day: "numeric" })}\n {product.event_date && product.venue && " \xB7 "}\n {product.venue}\n </p>\n )}\n\n {/* Non-event: description */}\n {!isTicket && product.description && (\n <p className="text-[12px] text-muted-foreground mt-1 line-clamp-1">\n {stripHtmlToText(product.description)}\n </p>\n )}\n\n <div className="flex items-center justify-between mt-2.5">\n <Price amount={product.default_price} currency={currency} className="text-sm font-bold" />\n\n {/* File info pill */}\n {product.file_type && (\n <span className="text-[10px] text-muted-foreground bg-muted px-1.5 py-0.5 rounded">\n {product.file_type.toUpperCase()}\n {product.file_size_mb != null && ` \xB7 ${product.file_size_mb >= 1024 ? `${(product.file_size_mb / 1024).toFixed(1)}GB` : `${product.file_size_mb}MB`}`}\n </span>\n )}\n </div>\n </div>\n </CardShell>\n );\n}\n' }] }, "deals-page": { "name": "deals-page", "title": "DealsPage", "description": "Promotions landing page with deal banners and on-sale products.", "type": "component", "registryDependencies": ["deal-banner", "product-grid", "sale-badge", "product-card", "cn"], "files": [{ "path": "deals-page.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { Product, Deal, ProductDealInfo } from "@cimplify/sdk";\nimport { useDeals, useProductsOnSale } from "@cimplify/sdk/react";\nimport { DealBanner } from "@cimplify/sdk/react";\nimport { ProductGrid } from "./product-grid";\nimport { SaleBadge } from "./sale-badge";\nimport { ProductCard } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface DealsPageClassNames {\n root?: string;\n header?: string;\n title?: string;\n bannerSection?: string;\n productsSection?: string;\n productsTitle?: string;\n empty?: string;\n loading?: string;\n}\n\nexport interface DealsPageProps {\n /** Page title. */\n title?: string;\n /** Pre-fetched deals for SSR. */\n deals?: Deal[];\n /** Pre-fetched products on sale for SSR. */\n productsOnSale?: ProductDealInfo[];\n /** Pre-fetched product objects for rendering. */\n products?: Product[];\n /** Location ID for location-specific deals. */\n locationId?: string;\n /** Custom image renderer. */\n renderImage?: (props: { src: string; alt: string; className?: string }) => React.ReactNode;\n /** Grid column config. */\n columns?: { sm?: number; md?: number; lg?: number; xl?: number };\n className?: string;\n classNames?: DealsPageClassNames;\n}\n\n/**\n * DealsPage \u2014 promotions landing page with deal banners and on-sale products.\n *\n * SSR-friendly: pass `deals` and `productsOnSale` props for server rendering.\n */\nexport function DealsPage({\n title = "Deals & Offers",\n deals: dealsProp,\n productsOnSale: productsOnSaleProp,\n products: productsProp,\n locationId,\n renderImage,\n columns,\n className,\n classNames,\n}: DealsPageProps): React.ReactElement {\n const { deals: fetchedDeals, isLoading: dealsLoading } = useDeals({\n locationId,\n enabled: dealsProp === undefined,\n });\n const { products: fetchedOnSale, isLoading: productsLoading } = useProductsOnSale({\n enabled: productsOnSaleProp === undefined,\n });\n\n const deals = dealsProp ?? fetchedDeals;\n const onSale = productsOnSaleProp ?? fetchedOnSale;\n const isLoading = dealsLoading || productsLoading;\n\n const isEmpty = deals.length === 0 && onSale.length === 0;\n\n if (isLoading && isEmpty) {\n return (\n <div\n data-cimplify-deals-page\n aria-busy="true"\n className={cn(className, classNames?.root, classNames?.loading)}\n />\n );\n }\n\n return (\n <div data-cimplify-deals-page className={cn(className, classNames?.root)}>\n {/* Header */}\n <div data-cimplify-deals-header className={classNames?.header}>\n <h1 data-cimplify-deals-title className={classNames?.title}>\n {title}\n </h1>\n </div>\n\n {/* Deal banners */}\n {deals.length > 0 && (\n <div data-cimplify-deals-banner-section className={classNames?.bannerSection}>\n <DealBanner deals={deals} />\n </div>\n )}\n\n {/* Products on sale */}\n {(onSale.length > 0 || (productsProp && productsProp.length > 0)) && (\n <div data-cimplify-deals-products className={classNames?.productsSection}>\n <h2 data-cimplify-deals-products-title className={classNames?.productsTitle}>\n On Sale Now\n </h2>\n {productsProp ? (\n <ProductGrid\n products={productsProp}\n renderImage={renderImage}\n columns={columns}\n />\n ) : (\n <div data-cimplify-deals-sale-list>\n {onSale.map((dealInfo: ProductDealInfo) => (\n <div key={`${dealInfo.product_id}-${dealInfo.deal_id}`} data-cimplify-deals-sale-item>\n <SaleBadge\n product={{ id: dealInfo.product_id, name: dealInfo.deal_name } as Product}\n dealInfo={dealInfo}\n showOriginalPrice\n />\n </div>\n ))}\n </div>\n )}\n </div>\n )}\n\n {/* Empty state */}\n {isEmpty && (\n <div data-cimplify-deals-empty className={classNames?.empty}>\n <p>No deals available right now. Check back soon!</p>\n </div>\n )}\n </div>\n );\n}\n' }] }, "checkout-page": { "name": "checkout-page", "title": "CheckoutPage", "description": "Multi-step checkout with auth, address, and payment.", "type": "component", "registryDependencies": ["cn"], "files": [{ "path": "checkout-page.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { ProcessCheckoutResult } from "@cimplify/sdk";\nimport { useCimplifyClient } from "@cimplify/sdk/react";\nimport { CimplifyCheckout } from "@cimplify/sdk/react";\nimport type { CimplifyCheckoutProps } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\nexport interface CheckoutPageClassNames {\n root?: string;\n header?: string;\n title?: string;\n checkout?: string;\n}\n\nexport interface CheckoutPageProps {\n /** Page title. */\n title?: string;\n /** Called after successful checkout. */\n onComplete: (result: ProcessCheckoutResult) => void;\n /** Called on checkout failure. */\n onError?: (error: { code: string; message: string }) => void;\n /** Props forwarded to CimplifyCheckout. */\n checkoutProps?: Partial<\n Omit<CimplifyCheckoutProps, "client" | "onComplete" | "onError">\n >;\n className?: string;\n classNames?: CheckoutPageClassNames;\n}\n\n/**\n * CheckoutPage \u2014 thin page shell around CimplifyCheckout.\n *\n * Reads the CimplifyClient from CimplifyProvider context,\n * renders a page header, and delegates all checkout logic\n * to CimplifyCheckout.\n */\nexport function CheckoutPage({\n title = "Checkout",\n onComplete,\n onError,\n checkoutProps,\n className,\n classNames,\n}: CheckoutPageProps): React.ReactElement {\n const { client } = useCimplifyClient();\n\n return (\n <div data-cimplify-checkout-page className={cn(className, classNames?.root)}>\n <div data-cimplify-checkout-header className={classNames?.header}>\n <h1 data-cimplify-checkout-title className={classNames?.title}>\n {title}\n </h1>\n </div>\n\n <CimplifyCheckout\n client={client}\n onComplete={onComplete}\n onError={onError}\n className={classNames?.checkout}\n {...checkoutProps}\n />\n </div>\n );\n}\n' }] }, "food-product-layout": { "name": "food-product-layout", "title": "FoodProductLayout", "description": "Restaurant product layout with allergens, ingredients, pairings, and dietary tags.", "type": "component", "registryDependencies": ["product-image-gallery", "product-customizer", "price", "rich-text", "cn"], "files": [{ "path": "layouts/food-product-layout.tsx", "content": '"use client";\n\nimport React from "react";\nimport type { ProductLayoutProps } from "@cimplify/sdk/react";\nimport {\n TwoColumnGrid,\n RelatedProductsSection,\n MetadataStringList,\n CustomAttributesTable,\n TagPills,\n getMetadataStringList,\n} from "@cimplify/sdk/react";\nimport { ProductImageGallery } from "@cimplify/sdk/react";\nimport { ProductCustomizer } from "@cimplify/sdk/react";\nimport { Price } from "@cimplify/sdk/react";\nimport { parsePrice, getProductCurrency } from "@cimplify/sdk";\nimport { RichText } from "@cimplify/sdk/react";\nimport { cn } from "@cimplify/sdk/react";\n\n\nexport function FoodProductLayout({\n product,\n onAddToCart,\n renderBreadcrumb,\n relatedProducts,\n showRelated = true,\n className,\n}: ProductLayoutProps): React.ReactElement {\n const images = product.images || (product.image_url ? [product.image_url] : []);\n const currency = getProductCurrency(product);\n const tags = product.tags || [];\n const ingredients = getMetadataStringList(product.metadata, "ingredients");\n const pairings = getMetadataStringList(product.metadata, "pairings");\n\n return (\n <div data-cimplify-product-layout="food" className={cn("space-y-8", className)}>\n {renderBreadcrumb?.(product)}\n\n <TwoColumnGrid\n left={\n <ProductImageGallery\n images={images}\n productName={product.name}\n />\n }\n right={\n <div className="space-y-6">\n {/* Tags + Allergens */}\n <div className="flex flex-wrap gap-1.5">\n {tags.map((tag) => (\n <span key={tag} className="inline-flex items-center gap-1 px-2.5 py-1 bg-green-100 text-green-800 text-xs font-medium rounded-full">\n {tag}\n </span>\n ))}\n {product.allergies && product.allergies.map((allergy) => (\n <span key={allergy} className="inline-flex items-center gap-1 px-2.5 py-1 bg-amber-100 text-amber-800 text-xs font-medium rounded-full">\n Contains {allergy}\n </span>\n ))}\n </div>\n\n {/* Name + Price */}\n <div>\n <h1 data-cimplify-product-layout-name className="text-3xl lg:text-4xl font-bold tracking-tight">\n {product.name}\n </h1>\n <Price amount={product.default_price} currency={currency} className="text-2xl font-semibold mt-2 block" />\n </div>\n\n {/* Quick stats */}\n {(product.calories != null || product.preparation_time_minutes != null) && (\n <div data-cimplify-product-layout-stats className="flex flex-wrap gap-4 py-3 border-y border-border">\n {product.preparation_time_minutes != null && (\n <span className="flex items-center gap-1.5 text-sm text-muted-foreground">\n <strong className="text-foreground">{product.preparation_time_minutes} min</strong> prep time\n </span>\n )}\n {product.calories != null && (\n <span className="flex items-center gap-1.5 text-sm text-muted-foreground">\n <strong className="text-foreground">{product.calories}</strong> calories\n </span>\n )}\n </div>\n )}\n\n {/* Description */}\n <RichText html={product.description} data-cimplify-product-layout-description />\n\n {/* Ingredients + Pairings */}\n {(ingredients.length > 0 || pairings.length > 0) && (\n <div className="grid sm:grid-cols-2 gap-4">\n <MetadataStringList items={ingredients} label="Key Ingredients" />\n <MetadataStringList items={pairings} label="Pairings" />\n </div>\n )}\n\n {/* Custom Attributes */}\n <CustomAttributesTable attributes={product.custom_attributes || []} />\n\n {/* Customizer (variants, add-ons, composites, bundles, billing, volume) */}\n <ProductCustomizer\n product={product}\n onAddToCart={onAddToCart}\n />\n </div>\n }\n />\n\n {showRelated && relatedProducts && relatedProducts.length > 0 && (\n <RelatedProductsSection\n products={relatedProducts}\n title="You may also enjoy"\n />\n )}\n </div>\n );\n}\n' }] }, "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": `/**
6441
6441
  * Project HTML to a single-line plain-text preview. NOT a security boundary \u2014
6442
6442
  * output is only ever rendered as text (never as HTML). For card/meta previews
6443
6443
  * where tags must not appear.