@forgecart/cli 2.202608110054.0 → 2.202608160103.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 (62) hide show
  1. package/dist/src/commands/init.d.ts +16 -0
  2. package/dist/src/commands/init.js +18 -7
  3. package/dist/src/commands/init.js.map +1 -1
  4. package/package.json +1 -1
  5. package/templates/storefront/README.md +145 -61
  6. package/templates/storefront/next.config.js +20 -0
  7. package/templates/storefront/package.json +10 -2
  8. package/templates/storefront/postcss.config.js +1 -2
  9. package/templates/storefront/src/app/%5F%5Ffc/track/route.ts +23 -11
  10. package/templates/storefront/src/app/__forge_beacon/route.ts +1 -2
  11. package/templates/storefront/src/app/api/%5F%5Fbackend/methods/route.ts +27 -0
  12. package/templates/storefront/src/app/cart/page.tsx +33 -6
  13. package/templates/storefront/src/app/checkout/page.tsx +40 -0
  14. package/templates/storefront/src/app/error.tsx +21 -0
  15. package/templates/storefront/src/app/global-error.tsx +23 -0
  16. package/templates/storefront/src/app/globals.css +105 -8
  17. package/templates/storefront/src/app/layout.tsx +45 -17
  18. package/templates/storefront/src/app/page.tsx +153 -43
  19. package/templates/storefront/src/app/ping/route.ts +1 -2
  20. package/templates/storefront/src/app/products/[slug]/not-found.tsx +3 -8
  21. package/templates/storefront/src/app/products/[slug]/page.tsx +69 -23
  22. package/templates/storefront/src/app/products/page.tsx +52 -9
  23. package/templates/storefront/src/components/CartView.tsx +244 -117
  24. package/templates/storefront/src/components/ForgeTracker.tsx +40 -26
  25. package/templates/storefront/src/components/Header.tsx +8 -10
  26. package/templates/storefront/src/components/ProductCard.tsx +25 -13
  27. package/templates/storefront/src/components/ProductPurchase.tsx +13 -18
  28. package/templates/storefront/src/components/checkout/AddressStep.tsx +288 -0
  29. package/templates/storefront/src/components/checkout/CheckoutFlow.tsx +543 -0
  30. package/templates/storefront/src/components/checkout/CheckoutGate.tsx +45 -0
  31. package/templates/storefront/src/components/checkout/PaymentElementForm.tsx +138 -0
  32. package/templates/storefront/src/components/checkout/PaymentFormEmbed.tsx +89 -0
  33. package/templates/storefront/src/components/checkout/RatesStep.tsx +113 -0
  34. package/templates/storefront/src/instrumentation.ts +34 -0
  35. package/templates/storefront/src/lib/action-result.ts +30 -0
  36. package/templates/storefront/src/lib/backend-actions.ts +20 -0
  37. package/templates/storefront/src/lib/backend-client.ts +47 -0
  38. package/templates/storefront/src/lib/cart-context.tsx +157 -22
  39. package/templates/storefront/src/lib/checkout-session.ts +185 -0
  40. package/templates/storefront/src/lib/error-messages.ts +24 -0
  41. package/templates/storefront/src/lib/experiments.ts +42 -44
  42. package/templates/storefront/src/lib/forgecart.ts +61 -78
  43. package/templates/storefront/src/lib/format.ts +91 -0
  44. package/templates/storefront/src/lib/session-actions.ts +54 -0
  45. package/templates/storefront/src/lib/shop-config.ts +44 -0
  46. package/templates/storefront/src/lib/shop-session.ts +114 -0
  47. package/templates/storefront/src/lib/uuid.ts +19 -0
  48. package/templates/storefront/src/server/app.module.ts +18 -0
  49. package/templates/storefront/src/server/backend-api.ts +26 -0
  50. package/templates/storefront/src/server/backend-method.decorator.ts +23 -0
  51. package/templates/storefront/src/server/bootstrap.ts +122 -0
  52. package/templates/storefront/src/server/customer-extras/customer-extras.module.ts +13 -0
  53. package/templates/storefront/src/server/customer-extras/service/customer-extras.service.ts +58 -0
  54. package/templates/storefront/src/server/customer-extras/type/customer-extras.types.ts +11 -0
  55. package/templates/storefront/src/server/forgecart/forgecart-client.factory.ts +69 -0
  56. package/templates/storefront/src/server/forgecart/forgecart.module.ts +9 -0
  57. package/templates/storefront/src/server/runner.ts +91 -0
  58. package/templates/storefront/src/server/types.ts +36 -0
  59. package/templates/storefront/tsconfig.json +2 -0
  60. package/templates/storefront/.env.example +0 -12
  61. package/templates/storefront/src/lib/cart-actions.ts +0 -139
  62. package/templates/storefront/tailwind.config.js +0 -8
@@ -0,0 +1,23 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * Root backstop: replaces the whole document when the root layout itself
5
+ * fails to render. Must own its own <html>/<body> — the layout that normally
6
+ * provides them is the thing that crashed. Styled inline (no globals.css —
7
+ * that import lives in the crashed layout).
8
+ */
9
+ export default function GlobalError({ reset }: { error: Error; reset: () => void }) {
10
+ return (
11
+ <html lang="en">
12
+ <body
13
+ style={{ fontFamily: 'system-ui, sans-serif', padding: '4rem 1rem', textAlign: 'center' }}
14
+ >
15
+ <h1>Something went wrong</h1>
16
+ <p>The store is temporarily unavailable. Please try again in a moment.</p>
17
+ <button type="button" onClick={() => reset()} style={{ padding: '0.5rem 1.25rem' }}>
18
+ Try again
19
+ </button>
20
+ </body>
21
+ </html>
22
+ );
23
+ }
@@ -1,13 +1,110 @@
1
- @tailwind base;
2
- @tailwind components;
3
- @tailwind utilities;
1
+ @import "tailwindcss";
4
2
 
5
- :root {
6
- --background: #ffffff;
7
- --foreground: #111827;
3
+ @plugin "daisyui" {
4
+ themes: false;
8
5
  }
9
6
 
7
+ /*
8
+ * ═══════════════════════════════════════════════════════════════════════════
9
+ * STOREFRONT THEME — the single brand mutation point (design-system contract)
10
+ * ═══════════════════════════════════════════════════════════════════════════
11
+ *
12
+ * Every component in this template speaks SEMANTIC daisyUI classes
13
+ * (bg-base-100, btn-primary, badge-secondary, alert, steps, …), so the block
14
+ * below is the ONE place a brand lives. Restyling the storefront = editing
15
+ * these tokens — nothing else. Never scatter raw palette utilities
16
+ * (bg-gray-900, text-white, #hex) through components; if a component needs a
17
+ * color, it needs a TOKEN.
18
+ *
19
+ * Token groups and what they drive:
20
+ * --color-base-* page substrate (200), raised surfaces (100), borders
21
+ * (300) and default text (base-content)
22
+ * --color-primary the brand action color: CTAs (btn-primary), active
23
+ * selections, the cart count badge
24
+ * --color-secondary supporting emphasis (subscription badges, secondary
25
+ * chips)
26
+ * --color-accent reserved highlight — used sparingly by new
27
+ * components; keep it distinct from primary
28
+ * --color-neutral high-contrast neutral fills (rarely needed directly)
29
+ * info/success/warning/ status semantics (alerts, validators, error text) —
30
+ * error conventional hues, usually NOT part of a rebrand
31
+ * --radius-* selector = small controls (checkbox/tabs), field =
32
+ * inputs/buttons, box = cards/modals
33
+ * --size-*, --border control density and border weight
34
+ * --depth, --noise daisyUI effect toggles (flat baseline: 0)
35
+ *
36
+ * The checkout surfaces consume the same set (steps, fieldset/validator
37
+ * inputs, radio-card selection rings, alert) — the block enumerates every
38
+ * variable daisyUI components resolve, so no class ever falls back outside
39
+ * the contract.
40
+ *
41
+ * This baseline is deliberately UNBRANDED (near-black monochrome on soft
42
+ * gray): it is the neutral canvas an agent rebrands per store. Extension
43
+ * points this contract keeps open: additional themes are further
44
+ * `@plugin "daisyui/theme"` blocks (a dark theme adds `prefersdark: true`),
45
+ * and the visual editor writes these same tokens ("theme main colors" =
46
+ * primary / secondary / accent here).
47
+ *
48
+ * A brand font goes in the `@theme` block after the theme: swap the
49
+ * `--font-sans` stack (and add the font's `@font-face`/import above it).
50
+ */
51
+ @plugin "daisyui/theme" {
52
+ name: "storefront";
53
+ default: true;
54
+ color-scheme: light;
55
+
56
+ /* Base — white surfaces over a soft-gray substrate, near-black text. */
57
+ --color-base-100: #ffffff;
58
+ --color-base-200: #f9fafb;
59
+ --color-base-300: #e5e7eb;
60
+ --color-base-content: #111827;
61
+
62
+ /* Actions — unbranded monochrome. */
63
+ --color-primary: #111827;
64
+ --color-primary-content: #ffffff;
65
+ --color-secondary: #374151;
66
+ --color-secondary-content: #ffffff;
67
+ --color-accent: #1f2937;
68
+ --color-accent-content: #ffffff;
69
+ --color-neutral: #111827;
70
+ --color-neutral-content: #f9fafb;
71
+
72
+ /* Status — conventional semantics, not brand. */
73
+ --color-info: #2563eb;
74
+ --color-info-content: #ffffff;
75
+ --color-success: #16a34a;
76
+ --color-success-content: #ffffff;
77
+ --color-warning: #d97706;
78
+ --color-warning-content: #ffffff;
79
+ --color-error: #dc2626;
80
+ --color-error-content: #ffffff;
81
+
82
+ /* Shape — matches the template's previous rounded-md/lg scale. */
83
+ --radius-selector: 0.375rem;
84
+ --radius-field: 0.375rem;
85
+ --radius-box: 0.5rem;
86
+ --size-selector: 0.25rem;
87
+ --size-field: 0.25rem;
88
+ --border: 1px;
89
+ --depth: 0;
90
+ --noise: 0;
91
+ }
92
+
93
+ /* Brand typography mutation point — swap the stack to give the store a font. */
94
+ @theme {
95
+ --font-sans: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
96
+ "Segoe UI Symbol", "Noto Color Emoji";
97
+ }
98
+
99
+ /*
100
+ * Base document behavior (structural — NOT part of the theme contract).
101
+ * A horizontal touchpad glide must never trigger the browser's swipe-back
102
+ * navigation: mid-checkout that discards the shopper's in-progress state
103
+ * (the refresh-resume path recovers the payment step, but preventing the
104
+ * accidental navigation is the real fix). The back button and keyboard
105
+ * navigation are untouched — only the overscroll gesture is consumed.
106
+ */
107
+ html,
10
108
  body {
11
- color: var(--foreground);
12
- background: var(--background);
109
+ overscroll-behavior-x: none;
13
110
  }
@@ -1,12 +1,13 @@
1
1
  import type { Metadata } from 'next';
2
- import type { ReactNode } from 'react';
2
+ import Link from 'next/link';
3
+ import { Suspense, type ReactNode } from 'react';
3
4
 
4
5
  import { ForgeErrorBeacon } from '../components/ForgeErrorBeacon';
5
6
  import { ForgeTracker } from '../components/ForgeTracker';
6
7
  import { ForgecartDesigner } from '../components/ForgecartDesigner';
7
8
  import { Header } from '../components/Header';
8
- import { getCart } from '../lib/cart-actions';
9
9
  import { CartProvider } from '../lib/cart-context';
10
+ import { getBrowserShopConfig } from '../lib/shop-config';
10
11
 
11
12
  import './globals.css';
12
13
 
@@ -15,26 +16,53 @@ export const metadata: Metadata = {
15
16
  description: 'A ForgeCart channel storefront.',
16
17
  };
17
18
 
18
- export default async function RootLayout({ children }: { children: ReactNode }) {
19
- const initialCart = await getCart();
19
+ /**
20
+ * The PPR-static shell: no cookie reads, no session work — the layout renders
21
+ * the same for every shopper. The cart hydrates CLIENT-side over the
22
+ * shopper's own websocket (`CartProvider` + `shop-session.ts`), armed with
23
+ * the channel coordinates serialized here (public storefront token — the
24
+ * admin secret never takes this path).
25
+ */
26
+ export default function RootLayout({ children }: { children: ReactNode }) {
27
+ const shopConfig = getBrowserShopConfig();
20
28
  return (
21
29
  <html lang="en">
22
- <body className="min-h-screen bg-gray-50 text-gray-900 antialiased">
23
- <CartProvider initialCart={initialCart}>
30
+ <body className="min-h-screen bg-base-200 text-base-content antialiased">
31
+ <CartProvider shopConfig={shopConfig}>
24
32
  <Header />
25
- <main className="mx-auto max-w-6xl px-4 py-8">{children}</main>
26
- <footer className="border-t border-gray-200 bg-white">
27
- <div className="mx-auto max-w-6xl px-4 py-6 text-sm text-gray-500">
28
- Powered by ForgeCart
29
- </div>
33
+ <main className="mx-auto max-w-6xl px-4 py-10">{children}</main>
34
+ <footer className="footer footer-horizontal mt-8 items-center border-t border-base-300 bg-base-100 px-6 py-8 text-sm text-base-content/60 sm:px-10">
35
+ <aside>
36
+ <p className="font-medium text-base-content">Storefront</p>
37
+ <p>Powered by ForgeCart</p>
38
+ </aside>
39
+ <nav aria-label="Footer" className="sm:justify-self-end">
40
+ <div className="grid grid-flow-col gap-4">
41
+ <Link href="/" className="link link-hover">
42
+ Home
43
+ </Link>
44
+ <Link href="/products" className="link link-hover">
45
+ Products
46
+ </Link>
47
+ <Link href="/cart" className="link link-hover">
48
+ Cart
49
+ </Link>
50
+ </div>
51
+ </nav>
30
52
  </footer>
31
53
  </CartProvider>
32
- <ForgecartDesigner />
33
- <ForgeErrorBeacon />
34
- <ForgeTracker
35
- shopApiUrl={process.env.FORGECART_SHOP_API_URL ?? ''}
36
- channelToken={process.env.FORGECART_CHANNEL_TOKEN ?? ''}
37
- />
54
+ {/* Invisible client plumbing. The tracker reads usePathname(), which
55
+ cacheComponents only allows under a Suspense boundary — null
56
+ fallback, these render nothing. */}
57
+ <Suspense fallback={null}>
58
+ <ForgecartDesigner />
59
+ <ForgeErrorBeacon />
60
+ <ForgeTracker
61
+ enabled={Boolean(
62
+ process.env.FORGECART_SHOP_API_URL && process.env.FORGECART_CHANNEL_TOKEN,
63
+ )}
64
+ />
65
+ </Suspense>
38
66
  </body>
39
67
  </html>
40
68
  );
@@ -1,61 +1,171 @@
1
1
  import Link from 'next/link';
2
+ import { connection } from 'next/server';
3
+ import { Suspense } from 'react';
4
+ import { cache } from 'react';
2
5
 
3
6
  import { ProductCard } from '../components/ProductCard';
4
7
  import { getVariant } from '../lib/experiments';
5
8
  import { getFeaturedProducts } from '../lib/forgecart';
6
9
 
7
- // Product data changes in the dashboard, so render on each request.
8
- export const dynamic = 'force-dynamic';
10
+ // PROGRESSIVE PRE-RENDERING: static shell; the data sections below are
11
+ // per-request holes streamed inside their Suspense boundaries
12
+ // (cacheComponents — see next.config.js).
9
13
 
10
- export default async function HomePage() {
11
- const products = await getFeaturedProducts(4);
12
- // A/B example (design doc F-B): variants are code branches keyed on
13
- // `variantKey`. `hero-headline` is the template's reference experiment —
14
- // create one with that key (variants `control` + `b`) in the dashboard and
15
- // this hero starts splitting; until then `variantKey` is null and the
16
- // default copy renders. `data-fc-variant` mirrors the assignment for
17
- // debugging/verification; exposure tracking happens server-side.
18
- const hero = await getVariant('hero-headline');
14
+ /** One featured read per request, shared by both holes (React request cache). */
15
+ const featured = cache(async () => getFeaturedProducts(4));
19
16
 
17
+ /**
18
+ * Home — the store's landing surface (Blueprint `pages/product-landing-page`):
19
+ * a product-led hero with ONE dominant conversion action, an editorial
20
+ * featured grid (first product large — asymmetry, not a uniform tile wall),
21
+ * and a closing conversion band. Every color is a semantic token; the brand
22
+ * lives in the globals.css theme block alone.
23
+ */
24
+ export default function HomePage() {
20
25
  return (
21
- <div className="space-y-12">
22
- <section
23
- data-fc-variant={hero.variantKey ?? 'default'}
24
- className="rounded-2xl bg-white p-10 shadow-sm"
25
- >
26
- <h1 className="text-3xl font-bold tracking-tight text-gray-900">
27
- Welcome to the shop
28
- </h1>
29
- <p className="mt-3 max-w-2xl text-gray-600">
30
- {hero.variantKey === 'b'
31
- ? 'Fresh picks, fair prices — see what everyone is adding to their carts.'
32
- : 'This storefront is served by a ForgeCart channel. Browse the catalog and add products to your cart.'}
33
- </p>
34
- <Link
35
- href="/products"
36
- className="mt-6 inline-block rounded-md bg-gray-900 px-5 py-2.5 text-sm font-medium text-white transition hover:bg-gray-700"
37
- >
38
- Shop all products
39
- </Link>
40
- </section>
26
+ <div className="space-y-16">
27
+ <Suspense fallback={<HeroSkeleton />}>
28
+ <Hero />
29
+ </Suspense>
41
30
 
42
- <section>
43
- <div className="mb-4 flex items-center justify-between">
44
- <h2 className="text-xl font-semibold text-gray-900">Featured</h2>
45
- <Link href="/products" className="text-sm text-gray-600 hover:text-gray-900">
46
- View all
31
+ <section aria-labelledby="featured-heading">
32
+ <div className="mb-6 flex items-baseline justify-between gap-4">
33
+ <h2 id="featured-heading" className="text-2xl font-semibold tracking-tight">
34
+ Featured
35
+ </h2>
36
+ <Link href="/products" className="link link-hover text-sm text-base-content/60">
37
+ View all products
47
38
  </Link>
48
39
  </div>
49
- {products.length === 0 ? (
50
- <p className="text-gray-500">No products yet. Add some in your dashboard.</p>
51
- ) : (
52
- <div className="grid grid-cols-2 gap-6 sm:grid-cols-3 lg:grid-cols-4">
53
- {products.map((product) => (
54
- <ProductCard key={product.id} product={product} />
55
- ))}
40
+ <Suspense fallback={<GridSkeleton />}>
41
+ <FeaturedGrid />
42
+ </Suspense>
43
+ </section>
44
+ </div>
45
+ );
46
+ }
47
+
48
+ /**
49
+ * The experiment-split hero (design doc F-B): variants are code branches
50
+ * keyed on `variantKey`. `hero-headline` is the template's reference
51
+ * experiment — create one with that key (variants `control` + `b`) in the
52
+ * dashboard and this hero starts splitting; until then `variantKey` is null
53
+ * and the default copy renders. `data-fc-variant` mirrors the assignment for
54
+ * debugging/verification; exposure tracking happens server-side.
55
+ */
56
+ async function Hero() {
57
+ // Request-time hole (cacheComponents): the build keeps the fallback shell.
58
+ await connection();
59
+ const [products, hero] = await Promise.all([featured(), getVariant('hero-headline')]);
60
+ const heroProduct = products.find((product) => product.featuredAsset) ?? null;
61
+
62
+ return (
63
+ <section
64
+ data-fc-variant={hero.variantKey ?? 'default'}
65
+ className="hero overflow-hidden rounded-box border border-base-300 bg-base-100"
66
+ >
67
+ <div className="hero-content w-full max-w-none flex-col gap-10 p-8 lg:flex-row lg:items-center lg:p-12">
68
+ <div className="max-w-xl lg:flex-1">
69
+ <h1 className="text-4xl font-bold tracking-tight text-balance sm:text-5xl">
70
+ Welcome to the shop
71
+ </h1>
72
+ <p className="mt-4 text-base-content/60 text-pretty">
73
+ {hero.variantKey === 'b'
74
+ ? 'Fresh picks, fair prices — see what everyone is adding to their carts.'
75
+ : 'This storefront is served by a ForgeCart channel. Browse the catalog and add products to your cart.'}
76
+ </p>
77
+ <div className="mt-8">
78
+ <Link href="/products" className="btn btn-primary btn-lg">
79
+ Shop all products
80
+ </Link>
56
81
  </div>
82
+ </div>
83
+ {heroProduct?.featuredAsset && (
84
+ <Link
85
+ href={`/products/${heroProduct.slug}`}
86
+ className="w-full max-w-md lg:flex-1"
87
+ aria-label={`View ${heroProduct.name}`}
88
+ >
89
+ <figure className="aspect-square overflow-hidden rounded-box bg-base-200">
90
+ {/* Plain <img> keeps the template dependency-free of next/image config. */}
91
+ {/* eslint-disable-next-line @next/next/no-img-element */}
92
+ <img
93
+ src={heroProduct.featuredAsset.preview}
94
+ alt={heroProduct.name}
95
+ className="h-full w-full object-cover"
96
+ />
97
+ </figure>
98
+ </Link>
57
99
  )}
100
+ </div>
101
+ </section>
102
+ );
103
+ }
104
+
105
+ async function FeaturedGrid() {
106
+ await connection();
107
+ const products = await featured();
108
+
109
+ if (products.length === 0) {
110
+ return (
111
+ <p className="text-base-content/60">
112
+ No products yet — add your first product in the dashboard and it appears here.
113
+ </p>
114
+ );
115
+ }
116
+
117
+ return (
118
+ <>
119
+ <div className="grid grid-cols-2 gap-4 sm:gap-6 lg:grid-cols-4">
120
+ {products.map((product, index) => (
121
+ <div key={product.id} className={index === 0 ? 'col-span-2 row-span-2' : undefined}>
122
+ <ProductCard product={product} emphasis={index === 0} />
123
+ </div>
124
+ ))}
125
+ </div>
126
+
127
+ <section className="hero mt-16 rounded-box bg-neutral text-neutral-content">
128
+ <div className="hero-content flex-col gap-6 py-14 text-center">
129
+ <h2 className="text-3xl font-bold tracking-tight text-balance">
130
+ Find something you like?
131
+ </h2>
132
+ <p className="max-w-md text-neutral-content/70 text-pretty">
133
+ The whole catalog ships from this store — browse everything in one place.
134
+ </p>
135
+ <Link href="/products" className="btn btn-primary btn-wide">
136
+ Browse the catalog
137
+ </Link>
138
+ </div>
58
139
  </section>
140
+ </>
141
+ );
142
+ }
143
+
144
+ function HeroSkeleton() {
145
+ return (
146
+ <div className="hero rounded-box border border-base-300 bg-base-100" aria-busy="true">
147
+ <div className="hero-content w-full max-w-none flex-col gap-10 p-8 lg:flex-row lg:p-12">
148
+ <div className="w-full max-w-xl space-y-4 lg:flex-1">
149
+ <div className="skeleton h-12 w-3/4"></div>
150
+ <div className="skeleton h-4 w-full"></div>
151
+ <div className="skeleton h-4 w-2/3"></div>
152
+ <div className="skeleton mt-4 h-12 w-48"></div>
153
+ </div>
154
+ <div className="skeleton aspect-square w-full max-w-md lg:flex-1"></div>
155
+ </div>
156
+ </div>
157
+ );
158
+ }
159
+
160
+ function GridSkeleton() {
161
+ return (
162
+ <div className="grid grid-cols-2 gap-4 sm:gap-6 lg:grid-cols-4" aria-busy="true">
163
+ <div className="col-span-2 row-span-2">
164
+ <div className="skeleton h-full min-h-72 w-full"></div>
165
+ </div>
166
+ <div className="skeleton aspect-square w-full"></div>
167
+ <div className="skeleton aspect-square w-full"></div>
168
+ <div className="skeleton aspect-square w-full"></div>
59
169
  </div>
60
170
  );
61
171
  }
@@ -9,10 +9,9 @@
9
9
  * route handler renders nothing and touches no session, so readiness polling stays
10
10
  * side-effect-free.
11
11
  *
12
- * `force-dynamic` keeps it out of the static prerender so a production build serves
12
+ * Request access keeps it out of the static prerender (cacheComponents) so a production build serves
13
13
  * it on demand exactly like `next dev` does — the probe always gets a fresh 200.
14
14
  */
15
- export const dynamic = 'force-dynamic';
16
15
 
17
16
  export function GET(): Response {
18
17
  return new Response('ok', {
@@ -3,16 +3,11 @@ import Link from 'next/link';
3
3
  export default function ProductNotFound() {
4
4
  return (
5
5
  <div className="space-y-4">
6
- <h1 className="text-2xl font-bold tracking-tight text-gray-900">
7
- Product not found
8
- </h1>
9
- <p className="text-gray-500">
6
+ <h1 className="text-2xl font-bold tracking-tight text-base-content">Product not found</h1>
7
+ <p className="text-base-content/60">
10
8
  We couldn&apos;t find that product. It may have been removed.
11
9
  </p>
12
- <Link
13
- href="/products"
14
- className="inline-block rounded-md bg-gray-900 px-5 py-2.5 text-sm font-medium text-white transition hover:bg-gray-700"
15
- >
10
+ <Link href="/products" className="btn btn-primary">
16
11
  Back to products
17
12
  </Link>
18
13
  </div>
@@ -1,5 +1,7 @@
1
1
  import Link from 'next/link';
2
2
  import { notFound } from 'next/navigation';
3
+ import { connection } from 'next/server';
4
+ import { Suspense } from 'react';
3
5
 
4
6
  import { ProductPurchase } from '../../../components/ProductPurchase';
5
7
  import {
@@ -8,14 +10,30 @@ import {
8
10
  type SellingPlanGroup,
9
11
  } from '../../../lib/forgecart';
10
12
 
11
- export const dynamic = 'force-dynamic';
13
+ // PROGRESSIVE PRE-RENDERING: static shell; the data sections below are
14
+ // per-request holes streamed inside their Suspense boundaries
15
+ // (cacheComponents — see next.config.js).
12
16
 
17
+ /**
18
+ * Product detail (Blueprint `pages/product-detail`): identity block ordering —
19
+ * media, then title, then price/variants/CTA (ProductPurchase owns those),
20
+ * then the long-form description. Breadcrumbs replace the bare back-link so
21
+ * shoppers keep their place in the catalog.
22
+ */
13
23
  // Next.js 15: route params are delivered as a Promise.
14
- export default async function ProductDetailPage({
15
- params,
16
- }: {
17
- params: Promise<{ slug: string }>;
18
- }) {
24
+ export default function ProductDetailPage({ params }: { params: Promise<{ slug: string }> }) {
25
+ return (
26
+ <div className="space-y-8">
27
+ <Suspense fallback={<DetailSkeleton />}>
28
+ <ProductDetail params={params} />
29
+ </Suspense>
30
+ </div>
31
+ );
32
+ }
33
+
34
+ async function ProductDetail({ params }: { params: Promise<{ slug: string }> }) {
35
+ // Request-time hole (cacheComponents): the build keeps the fallback shell.
36
+ await connection();
19
37
  const { slug } = await params;
20
38
  const product = await getProductBySlug(slug);
21
39
 
@@ -39,13 +57,23 @@ export default async function ProductDetailPage({
39
57
  );
40
58
 
41
59
  return (
42
- <div className="space-y-6">
43
- <Link href="/products" className="text-sm text-gray-600 hover:text-gray-900">
44
- &larr; Back to products
45
- </Link>
60
+ <>
61
+ <div className="breadcrumbs text-sm text-base-content/60">
62
+ <ul>
63
+ <li>
64
+ <Link href="/">Home</Link>
65
+ </li>
66
+ <li>
67
+ <Link href="/products">Products</Link>
68
+ </li>
69
+ <li>
70
+ <span aria-current="page">{product.name}</span>
71
+ </li>
72
+ </ul>
73
+ </div>
46
74
 
47
- <div className="grid gap-8 md:grid-cols-2">
48
- <div className="aspect-square overflow-hidden rounded-lg border border-gray-200 bg-gray-100">
75
+ <div className="grid gap-8 lg:grid-cols-2 lg:gap-12">
76
+ <figure className="aspect-square overflow-hidden rounded-box border border-base-300 bg-base-200 lg:sticky lg:top-24 lg:self-start">
49
77
  {product.featuredAsset ? (
50
78
  // eslint-disable-next-line @next/next/no-img-element
51
79
  <img
@@ -54,25 +82,43 @@ export default async function ProductDetailPage({
54
82
  className="h-full w-full object-cover"
55
83
  />
56
84
  ) : (
57
- <div className="flex h-full w-full items-center justify-center text-sm text-gray-400">
85
+ <div className="flex h-full w-full items-center justify-center text-sm text-base-content/40">
58
86
  No image
59
87
  </div>
60
88
  )}
61
- </div>
89
+ </figure>
62
90
 
63
- <div className="space-y-4">
64
- <h1 className="text-2xl font-bold tracking-tight text-gray-900">{product.name}</h1>
91
+ <div className="space-y-6">
92
+ <h1 className="text-3xl font-bold tracking-tight text-balance">{product.name}</h1>
93
+
94
+ {/* Pick a variant (Size/Color) -> price + purchase options + add-to-cart. */}
95
+ <ProductPurchase product={product} planGroupsByVariant={planGroupsByVariant} />
65
96
 
66
97
  {product.description && (
67
- <div
68
- className="prose prose-sm max-w-none text-gray-600"
69
- // Product descriptions may contain rich text from the dashboard.
70
- dangerouslySetInnerHTML={{ __html: product.description }}
71
- />
98
+ <section aria-label="Product description" className="border-t border-base-300 pt-6">
99
+ <div
100
+ className="max-w-none text-sm leading-relaxed text-base-content/70"
101
+ // Product descriptions may contain rich text from the dashboard.
102
+ dangerouslySetInnerHTML={{ __html: product.description }}
103
+ />
104
+ </section>
72
105
  )}
106
+ </div>
107
+ </div>
108
+ </>
109
+ );
110
+ }
73
111
 
74
- {/* Pick a variant (Size/Color) -> price + purchase options + add-to-cart. */}
75
- <ProductPurchase product={product} planGroupsByVariant={planGroupsByVariant} />
112
+ function DetailSkeleton() {
113
+ return (
114
+ <div aria-busy="true">
115
+ <div className="skeleton h-4 w-56"></div>
116
+ <div className="mt-8 grid gap-8 lg:grid-cols-2 lg:gap-12">
117
+ <div className="skeleton aspect-square w-full"></div>
118
+ <div className="space-y-4">
119
+ <div className="skeleton h-9 w-3/4"></div>
120
+ <div className="skeleton h-24 w-full"></div>
121
+ <div className="skeleton h-12 w-full"></div>
76
122
  </div>
77
123
  </div>
78
124
  </div>