@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.
- package/dist/src/commands/init.d.ts +16 -0
- package/dist/src/commands/init.js +18 -7
- package/dist/src/commands/init.js.map +1 -1
- package/package.json +1 -1
- package/templates/storefront/README.md +145 -61
- package/templates/storefront/next.config.js +20 -0
- package/templates/storefront/package.json +10 -2
- package/templates/storefront/postcss.config.js +1 -2
- package/templates/storefront/src/app/%5F%5Ffc/track/route.ts +23 -11
- package/templates/storefront/src/app/__forge_beacon/route.ts +1 -2
- package/templates/storefront/src/app/api/%5F%5Fbackend/methods/route.ts +27 -0
- package/templates/storefront/src/app/cart/page.tsx +33 -6
- package/templates/storefront/src/app/checkout/page.tsx +40 -0
- package/templates/storefront/src/app/error.tsx +21 -0
- package/templates/storefront/src/app/global-error.tsx +23 -0
- package/templates/storefront/src/app/globals.css +105 -8
- package/templates/storefront/src/app/layout.tsx +45 -17
- package/templates/storefront/src/app/page.tsx +153 -43
- package/templates/storefront/src/app/ping/route.ts +1 -2
- package/templates/storefront/src/app/products/[slug]/not-found.tsx +3 -8
- package/templates/storefront/src/app/products/[slug]/page.tsx +69 -23
- package/templates/storefront/src/app/products/page.tsx +52 -9
- package/templates/storefront/src/components/CartView.tsx +244 -117
- package/templates/storefront/src/components/ForgeTracker.tsx +40 -26
- package/templates/storefront/src/components/Header.tsx +8 -10
- package/templates/storefront/src/components/ProductCard.tsx +25 -13
- package/templates/storefront/src/components/ProductPurchase.tsx +13 -18
- package/templates/storefront/src/components/checkout/AddressStep.tsx +288 -0
- package/templates/storefront/src/components/checkout/CheckoutFlow.tsx +543 -0
- package/templates/storefront/src/components/checkout/CheckoutGate.tsx +45 -0
- package/templates/storefront/src/components/checkout/PaymentElementForm.tsx +138 -0
- package/templates/storefront/src/components/checkout/PaymentFormEmbed.tsx +89 -0
- package/templates/storefront/src/components/checkout/RatesStep.tsx +113 -0
- package/templates/storefront/src/instrumentation.ts +34 -0
- package/templates/storefront/src/lib/action-result.ts +30 -0
- package/templates/storefront/src/lib/backend-actions.ts +20 -0
- package/templates/storefront/src/lib/backend-client.ts +47 -0
- package/templates/storefront/src/lib/cart-context.tsx +157 -22
- package/templates/storefront/src/lib/checkout-session.ts +185 -0
- package/templates/storefront/src/lib/error-messages.ts +24 -0
- package/templates/storefront/src/lib/experiments.ts +42 -44
- package/templates/storefront/src/lib/forgecart.ts +61 -78
- package/templates/storefront/src/lib/format.ts +91 -0
- package/templates/storefront/src/lib/session-actions.ts +54 -0
- package/templates/storefront/src/lib/shop-config.ts +44 -0
- package/templates/storefront/src/lib/shop-session.ts +114 -0
- package/templates/storefront/src/lib/uuid.ts +19 -0
- package/templates/storefront/src/server/app.module.ts +18 -0
- package/templates/storefront/src/server/backend-api.ts +26 -0
- package/templates/storefront/src/server/backend-method.decorator.ts +23 -0
- package/templates/storefront/src/server/bootstrap.ts +122 -0
- package/templates/storefront/src/server/customer-extras/customer-extras.module.ts +13 -0
- package/templates/storefront/src/server/customer-extras/service/customer-extras.service.ts +58 -0
- package/templates/storefront/src/server/customer-extras/type/customer-extras.types.ts +11 -0
- package/templates/storefront/src/server/forgecart/forgecart-client.factory.ts +69 -0
- package/templates/storefront/src/server/forgecart/forgecart.module.ts +9 -0
- package/templates/storefront/src/server/runner.ts +91 -0
- package/templates/storefront/src/server/types.ts +36 -0
- package/templates/storefront/tsconfig.json +2 -0
- package/templates/storefront/.env.example +0 -12
- package/templates/storefront/src/lib/cart-actions.ts +0 -139
- 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
|
-
@
|
|
2
|
-
@tailwind components;
|
|
3
|
-
@tailwind utilities;
|
|
1
|
+
@import "tailwindcss";
|
|
4
2
|
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
12
|
-
background: var(--background);
|
|
109
|
+
overscroll-behavior-x: none;
|
|
13
110
|
}
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import type { Metadata } from 'next';
|
|
2
|
-
import
|
|
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
|
-
|
|
19
|
-
|
|
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-
|
|
23
|
-
<CartProvider
|
|
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-
|
|
26
|
-
<footer className="border-t border-
|
|
27
|
-
<
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
//
|
|
8
|
-
|
|
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
|
-
|
|
11
|
-
|
|
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-
|
|
22
|
-
<
|
|
23
|
-
|
|
24
|
-
|
|
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-
|
|
44
|
-
<h2 className="text-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
{
|
|
50
|
-
<
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
*
|
|
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-
|
|
7
|
-
|
|
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'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
|
-
|
|
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
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
43
|
-
<
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
48
|
-
<
|
|
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-
|
|
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
|
-
</
|
|
89
|
+
</figure>
|
|
62
90
|
|
|
63
|
-
<div className="space-y-
|
|
64
|
-
<h1 className="text-
|
|
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
|
-
<
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
75
|
-
|
|
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>
|