@forgecart/cli 2.202606151453.0 → 2.202607070306.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 +8 -0
- package/dist/src/commands/init.js +48 -6
- package/dist/src/commands/init.js.map +1 -1
- package/package.json +1 -1
- package/templates/storefront/next.config.js +20 -0
- package/templates/storefront/package.json +1 -1
- package/templates/storefront/src/app/__forge_beacon/route.ts +36 -0
- package/templates/storefront/src/app/cart/page.tsx +9 -84
- package/templates/storefront/src/app/layout.tsx +2 -0
- package/templates/storefront/src/app/ping/route.ts +22 -0
- package/templates/storefront/src/app/products/[slug]/page.tsx +22 -3
- package/templates/storefront/src/components/CartView.tsx +161 -0
- package/templates/storefront/src/components/ForgeErrorBeacon.tsx +71 -0
- package/templates/storefront/src/components/ProductCard.tsx +1 -2
- package/templates/storefront/src/components/ProductPurchase.tsx +153 -8
- package/templates/storefront/src/instrumentation.ts +69 -0
- package/templates/storefront/src/lib/cart-actions.ts +95 -112
- package/templates/storefront/src/lib/cart-context.tsx +18 -5
- package/templates/storefront/src/lib/forgecart.ts +94 -91
|
@@ -26,6 +26,14 @@ export interface InitOptions {
|
|
|
26
26
|
apiUrl: string;
|
|
27
27
|
/** Whether to also scaffold a NestJS application (planned for a later phase). */
|
|
28
28
|
application?: boolean;
|
|
29
|
+
/**
|
|
30
|
+
* Serve-in-place mode: the target directory is ALREADY a scaffolded,
|
|
31
|
+
* dependency- and dev-compile-warmed storefront (materialized into the pod's
|
|
32
|
+
* `/workspace` from the image's pre-warmed tree). Write ONLY the per-channel
|
|
33
|
+
* config + `.env.local` and skip the template copy entirely — copying would
|
|
34
|
+
* clobber the warm `.next` and bust the Turbopack dev cache.
|
|
35
|
+
*/
|
|
36
|
+
configOnly?: boolean;
|
|
29
37
|
}
|
|
30
38
|
/**
|
|
31
39
|
* Run `forgecart init`.
|
|
@@ -42,6 +42,16 @@ async function isDirectory(path) {
|
|
|
42
42
|
return false;
|
|
43
43
|
}
|
|
44
44
|
}
|
|
45
|
+
/** True if the path exists and is a regular file. */
|
|
46
|
+
async function isFile(path) {
|
|
47
|
+
try {
|
|
48
|
+
const info = await stat(path);
|
|
49
|
+
return info.isFile();
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
45
55
|
/**
|
|
46
56
|
* Read a `package.json` and normalize it for content comparison. Returns null
|
|
47
57
|
* when the file is missing or not valid JSON — callers treat that as "manifests
|
|
@@ -121,6 +131,19 @@ function buildEnvLocal(channelToken, apiUrl) {
|
|
|
121
131
|
'',
|
|
122
132
|
].join('\n');
|
|
123
133
|
}
|
|
134
|
+
/**
|
|
135
|
+
* Write the per-channel config the storefront reads at runtime: `.forgecart/
|
|
136
|
+
* config.json` (channel token, api url, app flag) and `.env.local` (the SDK
|
|
137
|
+
* client env). Shared by the full scaffold and the `--config-only` serve-in-place
|
|
138
|
+
* path, so both write byte-identical config — the only difference is whether the
|
|
139
|
+
* template tree was copied first.
|
|
140
|
+
*/
|
|
141
|
+
async function writeChannelConfig(targetDir, channelToken, options) {
|
|
142
|
+
const configDir = join(targetDir, '.forgecart');
|
|
143
|
+
await mkdir(configDir, { recursive: true });
|
|
144
|
+
await writeFile(join(configDir, 'config.json'), `${JSON.stringify(buildConfig(channelToken, options), null, 2)}\n`, 'utf8');
|
|
145
|
+
await writeFile(join(targetDir, '.env.local'), buildEnvLocal(channelToken, options.apiUrl), 'utf8');
|
|
146
|
+
}
|
|
124
147
|
/** Print the post-scaffold next steps. */
|
|
125
148
|
function printNextSteps(targetDir, application, depsInstalled) {
|
|
126
149
|
print('');
|
|
@@ -141,6 +164,16 @@ function printNextSteps(targetDir, application, depsInstalled) {
|
|
|
141
164
|
print('');
|
|
142
165
|
}
|
|
143
166
|
}
|
|
167
|
+
/** Print the post-scaffold summary for the `--config-only` serve-in-place path. */
|
|
168
|
+
function printConfigOnly(targetDir) {
|
|
169
|
+
print('');
|
|
170
|
+
print('Storefront configured (pre-warmed: dependencies and dev cache already in place).');
|
|
171
|
+
print('');
|
|
172
|
+
print('Next steps:');
|
|
173
|
+
print(` cd ${targetDir}`);
|
|
174
|
+
print(' npm run dev');
|
|
175
|
+
print('');
|
|
176
|
+
}
|
|
144
177
|
/**
|
|
145
178
|
* Run `forgecart init`.
|
|
146
179
|
*
|
|
@@ -156,6 +189,18 @@ export async function runInit(name, options) {
|
|
|
156
189
|
throw new Error('A channel token is required. Pass it with --token <token> (or -t <token>).');
|
|
157
190
|
}
|
|
158
191
|
const targetDir = name ? resolve(process.cwd(), name) : process.cwd();
|
|
192
|
+
// Serve-in-place: the target is already a warm storefront materialized from the
|
|
193
|
+
// image's pre-warmed tree (node_modules + a warm `.next`). Write only the
|
|
194
|
+
// per-channel config + env and return — copying the template would clobber the
|
|
195
|
+
// pre-warmed `.next` and bust the Turbopack dev cache the bake exists to provide.
|
|
196
|
+
if (options.configOnly) {
|
|
197
|
+
if (!(await isFile(join(targetDir, 'package.json')))) {
|
|
198
|
+
throw new Error(`--config-only requires an existing storefront at ${targetDir} (no package.json found).`);
|
|
199
|
+
}
|
|
200
|
+
await writeChannelConfig(targetDir, channelToken, options);
|
|
201
|
+
printConfigOnly(targetDir);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
159
204
|
const templateDir = getStorefrontTemplateDir();
|
|
160
205
|
if (!(await isDirectory(templateDir))) {
|
|
161
206
|
throw new Error(`Storefront template not found at ${templateDir}.`);
|
|
@@ -168,13 +213,9 @@ export async function runInit(name, options) {
|
|
|
168
213
|
recursive: true,
|
|
169
214
|
});
|
|
170
215
|
}
|
|
171
|
-
|
|
172
|
-
await mkdir(configDir, { recursive: true });
|
|
173
|
-
const config = buildConfig(channelToken, options);
|
|
174
|
-
await writeFile(join(configDir, 'config.json'), `${JSON.stringify(config, null, 2)}\n`, 'utf8');
|
|
175
|
-
await writeFile(join(targetDir, '.env.local'), buildEnvLocal(channelToken, options.apiUrl), 'utf8');
|
|
216
|
+
await writeChannelConfig(targetDir, channelToken, options);
|
|
176
217
|
await writeDepsReadyMarker(targetDir, plan);
|
|
177
|
-
printNextSteps(name ?? '.',
|
|
218
|
+
printNextSteps(name ?? '.', options.application ?? false, plan.source === 'warmed');
|
|
178
219
|
}
|
|
179
220
|
/** Build the `init [name]` command. */
|
|
180
221
|
export function createInitCommand() {
|
|
@@ -184,6 +225,7 @@ export function createInitCommand() {
|
|
|
184
225
|
.option('-t, --token <token>', 'ForgeCart channel token (required)')
|
|
185
226
|
.option('--api-url <url>', 'Shop API base URL written to the storefront environment', DEFAULT_SHOP_API_URL)
|
|
186
227
|
.option('-a, --application', 'Also scaffold a NestJS application (TODO: next phase)')
|
|
228
|
+
.option('--config-only', 'Write only per-channel config into an existing pre-warmed storefront (skip the template copy)')
|
|
187
229
|
.action(async (name, options) => {
|
|
188
230
|
await runInit(name, options);
|
|
189
231
|
});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"init.js","sourceRoot":"","sources":["../../../src/commands/init.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,EAAE,EAAE,QAAQ,EAAE,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,MAAM,kBAAkB,CAAC;AAC5E,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACnD,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAEzC,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAEpC,OAAO,EAAE,KAAK,EAAE,MAAM,cAAc,CAAC;AAErC,0DAA0D;AAC1D,MAAM,CAAC,MAAM,oBAAoB,GAAG,2BAA2B,CAAC;AAEhE;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,0BAA0B,GAAG,wBAAwB,CAAC;AAEnE;;;;;;;GAOG;AACH,MAAM,CAAC,MAAM,sBAAsB,GAAG,4BAA4B,CAAC;
|
|
1
|
+
{"version":3,"file":"init.js","sourceRoot":"","sources":["../../../src/commands/init.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -4,6 +4,26 @@ const nextConfig = {
|
|
|
4
4
|
// storefront with `next start` without a full node_modules tree.
|
|
5
5
|
output: 'standalone',
|
|
6
6
|
reactStrictMode: true,
|
|
7
|
+
// The visual-editor preview runs THIS dev server inside a workspace pod and is
|
|
8
|
+
// reached cross-origin through the channel's preview ingress host
|
|
9
|
+
// (`<code>.127.0.0.1.nip.io:8081` locally; `<code>.dev|test.forgecart.dev` and
|
|
10
|
+
// `<code>.forgecart.dev` deployed) — never the pod's own `localhost:3000`. Next
|
|
11
|
+
// 16 ENFORCES `allowedDevOrigins`: a dev request whose host is not
|
|
12
|
+
// localhost/127.0.0.1 and not listed here is blocked, which silently kills the
|
|
13
|
+
// `/_next` HMR websocket (and the dashboard iframe's dev channel) with no page
|
|
14
|
+
// error. The `*` wildcard matches across dots, so `*.forgecart.dev` covers every
|
|
15
|
+
// env's preview subdomain. Dev-only — `next start` (deployed storefronts) ignores it.
|
|
16
|
+
allowedDevOrigins: ['*.127.0.0.1.nip.io', '*.dev.forgecart.dev', '*.forgecart.dev'],
|
|
17
|
+
experimental: {
|
|
18
|
+
// Persist Turbopack's dev compile artifacts to `.next` so a pod can serve
|
|
19
|
+
// a PRE-WARMED `.next` baked at build time instead of paying a cold first
|
|
20
|
+
// compile. The cache is path-keyed, so it is only valid when warmed at the
|
|
21
|
+
// SAME absolute path the pod serves from (/workspace) — see the workspace-pod
|
|
22
|
+
// Dockerfile `storefront-prewarm` stage and the STOREFRONT materialization
|
|
23
|
+
// initContainer (serve-in-place; never the /opt→/workspace copy). Default-on
|
|
24
|
+
// in Next 16.1+; set explicitly to document the contract.
|
|
25
|
+
turbopackFileSystemCacheForDev: true,
|
|
26
|
+
},
|
|
7
27
|
// Dev-only `data-fc-source` stamping for the ForgeCart visual editor. The
|
|
8
28
|
// loader marks host JSX elements with their source file:line:col so the
|
|
9
29
|
// dashboard can map a sprayed region back to code. It runs under Turbopack
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DEV-ONLY same-origin error-beacon forwarder.
|
|
3
|
+
*
|
|
4
|
+
* The storefront's client beacon (`ForgeErrorBeacon`) POSTs a browser-surfaced
|
|
5
|
+
* runtime error here — same-origin, so the browser never learns the pod-internal
|
|
6
|
+
* receiver address. This handler forwards the body SERVER-side to the in-pod
|
|
7
|
+
* workspace-manager receiver at `http://127.0.0.1:<FORGE_BEACON_PORT>/__forge_beacon`,
|
|
8
|
+
* which folds it into the supervisor's runtime state so the shop's recovery brain
|
|
9
|
+
* can originate a fix for a crash the dev-server's stderr scan cannot see.
|
|
10
|
+
*
|
|
11
|
+
* Gated entirely on `NODE_ENV === 'development'`: a deployed `next start` storefront
|
|
12
|
+
* answers 404 here and forwards nothing — the whole beacon path ships only in the
|
|
13
|
+
* in-pod dev-server. `force-dynamic` keeps it out of the static prerender so it
|
|
14
|
+
* behaves identically under `next dev` and a dev build.
|
|
15
|
+
*/
|
|
16
|
+
export const dynamic = 'force-dynamic';
|
|
17
|
+
|
|
18
|
+
const BEACON_PORT = process.env.FORGE_BEACON_PORT ?? '3002';
|
|
19
|
+
|
|
20
|
+
export async function POST(request: Request): Promise<Response> {
|
|
21
|
+
if (process.env.NODE_ENV !== 'development') {
|
|
22
|
+
return new Response(null, { status: 404 });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const body = await request.text();
|
|
26
|
+
// Forward server-side to the loopback receiver. A delivery failure is swallowed —
|
|
27
|
+
// the beacon is a best-effort backstop, never a hard dependency of the page — but
|
|
28
|
+
// the client still gets a clean 204 so it never retries against a flapping pod.
|
|
29
|
+
await fetch(`http://127.0.0.1:${BEACON_PORT}/__forge_beacon`, {
|
|
30
|
+
method: 'POST',
|
|
31
|
+
headers: { 'content-type': 'application/json' },
|
|
32
|
+
body,
|
|
33
|
+
}).catch(() => undefined);
|
|
34
|
+
|
|
35
|
+
return new Response(null, { status: 204 });
|
|
36
|
+
}
|
|
@@ -1,88 +1,13 @@
|
|
|
1
|
-
|
|
1
|
+
import { CartView } from '../../components/CartView';
|
|
2
|
+
import { getChannelSellingPlanGroups } from '../../lib/forgecart';
|
|
2
3
|
|
|
3
|
-
|
|
4
|
+
export const dynamic = 'force-dynamic';
|
|
4
5
|
|
|
5
|
-
|
|
6
|
-
|
|
6
|
+
export default async function CartPage() {
|
|
7
|
+
// Channel-wide subscription plans are fetched server-side (the shop client is
|
|
8
|
+
// server-only) and handed to the client cart view, which renders the
|
|
9
|
+
// "Subscribe to your whole order" box only when there are any.
|
|
10
|
+
const channelGroups = await getChannelSellingPlanGroups();
|
|
7
11
|
|
|
8
|
-
|
|
9
|
-
const { cart, itemCount, subtotal, setQuantity, remove, pending } = useCart();
|
|
10
|
-
const lines = cart?.lines ?? [];
|
|
11
|
-
|
|
12
|
-
if (itemCount === 0) {
|
|
13
|
-
return (
|
|
14
|
-
<div className="space-y-4">
|
|
15
|
-
<h1 className="text-2xl font-bold tracking-tight text-gray-900">Your cart</h1>
|
|
16
|
-
<p className="text-gray-500">Your cart is empty.</p>
|
|
17
|
-
<Link
|
|
18
|
-
href="/products"
|
|
19
|
-
className="inline-block rounded-md bg-gray-900 px-5 py-2.5 text-sm font-medium text-white transition hover:bg-gray-700"
|
|
20
|
-
>
|
|
21
|
-
Browse products
|
|
22
|
-
</Link>
|
|
23
|
-
</div>
|
|
24
|
-
);
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
return (
|
|
28
|
-
<div className="space-y-6">
|
|
29
|
-
<h1 className="text-2xl font-bold tracking-tight text-gray-900">Your cart</h1>
|
|
30
|
-
|
|
31
|
-
<ul className="divide-y divide-gray-200 rounded-lg border border-gray-200 bg-white">
|
|
32
|
-
{lines.map((line) => (
|
|
33
|
-
<li key={line.id} className="flex items-center gap-4 p-4">
|
|
34
|
-
<div className="h-16 w-16 shrink-0 overflow-hidden rounded bg-gray-100">
|
|
35
|
-
{line.featuredAsset?.preview ? (
|
|
36
|
-
// eslint-disable-next-line @next/next/no-img-element
|
|
37
|
-
<img
|
|
38
|
-
src={line.featuredAsset.preview}
|
|
39
|
-
alt={line.productVariant.name}
|
|
40
|
-
className="h-full w-full object-cover"
|
|
41
|
-
/>
|
|
42
|
-
) : null}
|
|
43
|
-
</div>
|
|
44
|
-
|
|
45
|
-
<div className="min-w-0 flex-1">
|
|
46
|
-
<p className="truncate font-medium text-gray-900">{line.productVariant.name}</p>
|
|
47
|
-
<p className="text-sm text-gray-500">{formatPrice(line.unitPriceWithTax)} each</p>
|
|
48
|
-
</div>
|
|
49
|
-
|
|
50
|
-
<div className="flex items-center gap-2">
|
|
51
|
-
<label className="sr-only" htmlFor={`qty-${line.id}`}>
|
|
52
|
-
Quantity
|
|
53
|
-
</label>
|
|
54
|
-
<input
|
|
55
|
-
id={`qty-${line.id}`}
|
|
56
|
-
type="number"
|
|
57
|
-
min={1}
|
|
58
|
-
value={line.quantity}
|
|
59
|
-
disabled={pending}
|
|
60
|
-
onChange={(e) => setQuantity(line.id, Number.parseInt(e.target.value, 10) || 1)}
|
|
61
|
-
className="w-16 rounded border border-gray-300 px-2 py-1 text-sm disabled:opacity-50"
|
|
62
|
-
/>
|
|
63
|
-
</div>
|
|
64
|
-
|
|
65
|
-
<div className="w-24 text-right font-medium text-gray-900">
|
|
66
|
-
{formatPrice(line.linePriceWithTax)}
|
|
67
|
-
</div>
|
|
68
|
-
|
|
69
|
-
<button
|
|
70
|
-
type="button"
|
|
71
|
-
onClick={() => remove(line.id)}
|
|
72
|
-
disabled={pending}
|
|
73
|
-
className="text-sm text-gray-400 hover:text-red-600 disabled:opacity-50"
|
|
74
|
-
aria-label={`Remove ${line.productVariant.name}`}
|
|
75
|
-
>
|
|
76
|
-
Remove
|
|
77
|
-
</button>
|
|
78
|
-
</li>
|
|
79
|
-
))}
|
|
80
|
-
</ul>
|
|
81
|
-
|
|
82
|
-
<div className="flex items-center justify-between rounded-lg border border-gray-200 bg-white p-4">
|
|
83
|
-
<span className="text-sm text-gray-600">Subtotal ({itemCount} items)</span>
|
|
84
|
-
<span className="text-lg font-semibold text-gray-900">{formatPrice(subtotal)}</span>
|
|
85
|
-
</div>
|
|
86
|
-
</div>
|
|
87
|
-
);
|
|
12
|
+
return <CartView channelGroups={channelGroups} />;
|
|
88
13
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Metadata } from 'next';
|
|
2
2
|
import type { ReactNode } from 'react';
|
|
3
3
|
|
|
4
|
+
import { ForgeErrorBeacon } from '../components/ForgeErrorBeacon';
|
|
4
5
|
import { ForgecartDesigner } from '../components/ForgecartDesigner';
|
|
5
6
|
import { Header } from '../components/Header';
|
|
6
7
|
import { getCart } from '../lib/cart-actions';
|
|
@@ -28,6 +29,7 @@ export default async function RootLayout({ children }: { children: ReactNode })
|
|
|
28
29
|
</footer>
|
|
29
30
|
</CartProvider>
|
|
30
31
|
<ForgecartDesigner />
|
|
32
|
+
<ForgeErrorBeacon />
|
|
31
33
|
</body>
|
|
32
34
|
</html>
|
|
33
35
|
);
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Liveness ping for the in-pod dev-server readiness probe.
|
|
3
|
+
*
|
|
4
|
+
* Returns a static 200 with no layout, data fetch, or session. The workspace's
|
|
5
|
+
* readiness probe hits THIS route (not `/`) on a fast cadence while the store is
|
|
6
|
+
* STARTING and then a slow liveness cadence forever — and `/` renders the
|
|
7
|
+
* cart-aware layout, whose `activeOrder` call MATERIALISES an active order on every
|
|
8
|
+
* request. Probing `/` therefore minted a throwaway order on every poll; probing a
|
|
9
|
+
* route handler renders nothing and touches no session, so readiness polling stays
|
|
10
|
+
* side-effect-free.
|
|
11
|
+
*
|
|
12
|
+
* `force-dynamic` keeps it out of the static prerender so a production build serves
|
|
13
|
+
* it on demand exactly like `next dev` does — the probe always gets a fresh 200.
|
|
14
|
+
*/
|
|
15
|
+
export const dynamic = 'force-dynamic';
|
|
16
|
+
|
|
17
|
+
export function GET(): Response {
|
|
18
|
+
return new Response('ok', {
|
|
19
|
+
status: 200,
|
|
20
|
+
headers: { 'content-type': 'text/plain' },
|
|
21
|
+
});
|
|
22
|
+
}
|
|
@@ -2,7 +2,11 @@ import Link from 'next/link';
|
|
|
2
2
|
import { notFound } from 'next/navigation';
|
|
3
3
|
|
|
4
4
|
import { ProductPurchase } from '../../../components/ProductPurchase';
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
getProductBySlug,
|
|
7
|
+
getSellingPlanGroupsForVariant,
|
|
8
|
+
type SellingPlanGroup,
|
|
9
|
+
} from '../../../lib/forgecart';
|
|
6
10
|
|
|
7
11
|
export const dynamic = 'force-dynamic';
|
|
8
12
|
|
|
@@ -19,6 +23,21 @@ export default async function ProductDetailPage({
|
|
|
19
23
|
notFound();
|
|
20
24
|
}
|
|
21
25
|
|
|
26
|
+
// Subscription selling plans are eligible per variant, so fetch the groups for
|
|
27
|
+
// each of the product's variants (in parallel) and pass a variantId -> groups
|
|
28
|
+
// map to the client purchase control. Variants with no plans get the plain
|
|
29
|
+
// one-time add-to-cart path.
|
|
30
|
+
const planGroupsByVariant: Record<string, SellingPlanGroup[]> = Object.fromEntries(
|
|
31
|
+
await Promise.all(
|
|
32
|
+
product.variants.map(
|
|
33
|
+
async (variant): Promise<[string, SellingPlanGroup[]]> => [
|
|
34
|
+
variant.id,
|
|
35
|
+
await getSellingPlanGroupsForVariant(variant.id),
|
|
36
|
+
],
|
|
37
|
+
),
|
|
38
|
+
),
|
|
39
|
+
);
|
|
40
|
+
|
|
22
41
|
return (
|
|
23
42
|
<div className="space-y-6">
|
|
24
43
|
<Link href="/products" className="text-sm text-gray-600 hover:text-gray-900">
|
|
@@ -52,8 +71,8 @@ export default async function ProductDetailPage({
|
|
|
52
71
|
/>
|
|
53
72
|
)}
|
|
54
73
|
|
|
55
|
-
{/* Pick a variant (Size/Color) -> price + add-to-cart. */}
|
|
56
|
-
<ProductPurchase product={product} />
|
|
74
|
+
{/* Pick a variant (Size/Color) -> price + purchase options + add-to-cart. */}
|
|
75
|
+
<ProductPurchase product={product} planGroupsByVariant={planGroupsByVariant} />
|
|
57
76
|
</div>
|
|
58
77
|
</div>
|
|
59
78
|
</div>
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import Link from 'next/link';
|
|
4
|
+
|
|
5
|
+
import { useCart } from '../lib/cart-context';
|
|
6
|
+
import {
|
|
7
|
+
formatPrice,
|
|
8
|
+
getPlanCadenceLabel,
|
|
9
|
+
getPlanSavingsLabel,
|
|
10
|
+
type SellingPlan,
|
|
11
|
+
type SellingPlanGroup,
|
|
12
|
+
} from '../lib/forgecart';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The interactive cart, rendered off the live order from `useCart()`.
|
|
16
|
+
*
|
|
17
|
+
* `channelGroups` are the channel-wide subscription selling plans, fetched
|
|
18
|
+
* server-side by the cart route and passed in (the shop client is server-only,
|
|
19
|
+
* so a client component can't fetch them itself). When present, a "Subscribe to
|
|
20
|
+
* your whole order" box lets the customer put the entire order on a recurring
|
|
21
|
+
* plan; the chosen plan is reflected by the order's `sellingPlanId`. Lines that
|
|
22
|
+
* carry their own `sellingPlanId` (per-line subscriptions added from a product
|
|
23
|
+
* page) get a small "Subscription" label.
|
|
24
|
+
*/
|
|
25
|
+
export function CartView({ channelGroups }: { channelGroups: SellingPlanGroup[] }) {
|
|
26
|
+
const { cart, itemCount, subtotal, setQuantity, remove, setSellingPlan, pending } = useCart();
|
|
27
|
+
const lines = cart?.lines ?? [];
|
|
28
|
+
|
|
29
|
+
if (itemCount === 0) {
|
|
30
|
+
return (
|
|
31
|
+
<div className="space-y-4">
|
|
32
|
+
<h1 className="text-2xl font-bold tracking-tight text-gray-900">Your cart</h1>
|
|
33
|
+
<p className="text-gray-500">Your cart is empty.</p>
|
|
34
|
+
<Link
|
|
35
|
+
href="/products"
|
|
36
|
+
className="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
|
+
Browse products
|
|
39
|
+
</Link>
|
|
40
|
+
</div>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Channel-wide plans (enabled only), flattened across the channel groups.
|
|
45
|
+
const channelPlans: SellingPlan[] = channelGroups.flatMap((g) => g.plans).filter((p) => p.enabled);
|
|
46
|
+
const activePlanId = cart?.sellingPlanId ?? null;
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<div className="space-y-6">
|
|
50
|
+
<h1 className="text-2xl font-bold tracking-tight text-gray-900">Your cart</h1>
|
|
51
|
+
|
|
52
|
+
<ul className="divide-y divide-gray-200 rounded-lg border border-gray-200 bg-white">
|
|
53
|
+
{lines.map((line) => (
|
|
54
|
+
<li key={line.id} className="flex items-center gap-4 p-4">
|
|
55
|
+
<div className="h-16 w-16 shrink-0 overflow-hidden rounded bg-gray-100">
|
|
56
|
+
{line.featuredAsset?.preview ? (
|
|
57
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
58
|
+
<img
|
|
59
|
+
src={line.featuredAsset.preview}
|
|
60
|
+
alt={line.productVariant.name}
|
|
61
|
+
className="h-full w-full object-cover"
|
|
62
|
+
/>
|
|
63
|
+
) : null}
|
|
64
|
+
</div>
|
|
65
|
+
|
|
66
|
+
<div className="min-w-0 flex-1">
|
|
67
|
+
<p className="truncate font-medium text-gray-900">{line.productVariant.name}</p>
|
|
68
|
+
<p className="text-sm text-gray-500">{formatPrice(line.unitPriceWithTax)} each</p>
|
|
69
|
+
{line.sellingPlanId && (
|
|
70
|
+
<span className="mt-1 inline-block rounded bg-gray-900 px-1.5 py-0.5 text-xs font-medium text-white">
|
|
71
|
+
Subscription
|
|
72
|
+
</span>
|
|
73
|
+
)}
|
|
74
|
+
</div>
|
|
75
|
+
|
|
76
|
+
<div className="flex items-center gap-2">
|
|
77
|
+
<label className="sr-only" htmlFor={`qty-${line.id}`}>
|
|
78
|
+
Quantity
|
|
79
|
+
</label>
|
|
80
|
+
<input
|
|
81
|
+
id={`qty-${line.id}`}
|
|
82
|
+
type="number"
|
|
83
|
+
min={1}
|
|
84
|
+
value={line.quantity}
|
|
85
|
+
disabled={pending}
|
|
86
|
+
onChange={(e) => setQuantity(line.id, Number.parseInt(e.target.value, 10) || 1)}
|
|
87
|
+
className="w-16 rounded border border-gray-300 px-2 py-1 text-sm disabled:opacity-50"
|
|
88
|
+
/>
|
|
89
|
+
</div>
|
|
90
|
+
|
|
91
|
+
<div className="w-24 text-right font-medium text-gray-900">
|
|
92
|
+
{formatPrice(line.linePriceWithTax)}
|
|
93
|
+
</div>
|
|
94
|
+
|
|
95
|
+
<button
|
|
96
|
+
type="button"
|
|
97
|
+
onClick={() => remove(line.id)}
|
|
98
|
+
disabled={pending}
|
|
99
|
+
className="text-sm text-gray-400 hover:text-red-600 disabled:opacity-50"
|
|
100
|
+
aria-label={`Remove ${line.productVariant.name}`}
|
|
101
|
+
>
|
|
102
|
+
Remove
|
|
103
|
+
</button>
|
|
104
|
+
</li>
|
|
105
|
+
))}
|
|
106
|
+
</ul>
|
|
107
|
+
|
|
108
|
+
{channelPlans.length > 0 && (
|
|
109
|
+
<div className="space-y-2 rounded-lg border border-gray-200 bg-white p-4">
|
|
110
|
+
<h2 className="text-sm font-medium text-gray-700">Subscribe to your whole order</h2>
|
|
111
|
+
<div className="flex flex-col gap-2">
|
|
112
|
+
<button
|
|
113
|
+
type="button"
|
|
114
|
+
onClick={() => setSellingPlan(null)}
|
|
115
|
+
disabled={pending}
|
|
116
|
+
aria-pressed={activePlanId === null}
|
|
117
|
+
className={`rounded-md border px-3 py-2 text-left text-sm transition disabled:opacity-50 ${
|
|
118
|
+
activePlanId === null
|
|
119
|
+
? 'border-gray-900 ring-1 ring-gray-900'
|
|
120
|
+
: 'border-gray-300 hover:border-gray-900'
|
|
121
|
+
}`}
|
|
122
|
+
>
|
|
123
|
+
<span className="font-medium text-gray-900">No, one-time</span>
|
|
124
|
+
</button>
|
|
125
|
+
{channelPlans.map((plan) => {
|
|
126
|
+
const savings = getPlanSavingsLabel(plan);
|
|
127
|
+
const hint = [savings, plan.trialDays > 0 ? `${plan.trialDays}-day free trial` : null]
|
|
128
|
+
.filter(Boolean)
|
|
129
|
+
.join(' · ');
|
|
130
|
+
const active = activePlanId === plan.id;
|
|
131
|
+
return (
|
|
132
|
+
<button
|
|
133
|
+
key={plan.id}
|
|
134
|
+
type="button"
|
|
135
|
+
onClick={() => setSellingPlan(plan.id)}
|
|
136
|
+
disabled={pending}
|
|
137
|
+
aria-pressed={active}
|
|
138
|
+
className={`rounded-md border px-3 py-2 text-left text-sm transition disabled:opacity-50 ${
|
|
139
|
+
active
|
|
140
|
+
? 'border-gray-900 ring-1 ring-gray-900'
|
|
141
|
+
: 'border-gray-300 hover:border-gray-900'
|
|
142
|
+
}`}
|
|
143
|
+
>
|
|
144
|
+
<span className="block font-medium text-gray-900">
|
|
145
|
+
Subscribe — {getPlanCadenceLabel(plan)}
|
|
146
|
+
</span>
|
|
147
|
+
{hint && <span className="block text-xs text-gray-500">{hint}</span>}
|
|
148
|
+
</button>
|
|
149
|
+
);
|
|
150
|
+
})}
|
|
151
|
+
</div>
|
|
152
|
+
</div>
|
|
153
|
+
)}
|
|
154
|
+
|
|
155
|
+
<div className="flex items-center justify-between rounded-lg border border-gray-200 bg-white p-4">
|
|
156
|
+
<span className="text-sm text-gray-600">Subtotal ({itemCount} items)</span>
|
|
157
|
+
<span className="text-lg font-semibold text-gray-900">{formatPrice(subtotal)}</span>
|
|
158
|
+
</div>
|
|
159
|
+
</div>
|
|
160
|
+
);
|
|
161
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect } from 'react';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* DEV-ONLY storefront error beacon. Renders nothing.
|
|
7
|
+
*
|
|
8
|
+
* Registers `window.onerror` + `window.onunhandledrejection` and POSTs the failure
|
|
9
|
+
* detail to the same-origin Next API route `/__forge_beacon`, which forwards it
|
|
10
|
+
* server-side to the in-pod workspace-manager receiver. This surfaces the crash
|
|
11
|
+
* class the pod's dev-server stderr scan CANNOT see: a client-component runtime
|
|
12
|
+
* error or a hydration/SSR-boundary throw that never reaches the dev-server's
|
|
13
|
+
* output. The browser only ever talks SAME-ORIGIN — the API route is the only thing
|
|
14
|
+
* that knows the pod-internal receiver address.
|
|
15
|
+
*
|
|
16
|
+
* Production builds ship zero bytes of this: the literal `NODE_ENV` check is inlined
|
|
17
|
+
* by the bundler, so the effect body is dead-code-eliminated from `next build` /
|
|
18
|
+
* `next start` output — a deployed storefront registers no global error handlers and
|
|
19
|
+
* has no beacon API route reachable in prod (the route itself is dev-gated too).
|
|
20
|
+
*
|
|
21
|
+
* The beacon is best-effort and never blocks the page: the POST is fire-and-forget
|
|
22
|
+
* with `keepalive` so it survives an unload, and a delivery failure is swallowed —
|
|
23
|
+
* the page's own error boundary still renders. The full
|
|
24
|
+
* browser → API route → pod → supervisor-fold path is exercised at the e2e layer,
|
|
25
|
+
* not in this template.
|
|
26
|
+
*/
|
|
27
|
+
export function ForgeErrorBeacon() {
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
if (process.env.NODE_ENV !== 'development') {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const send = (message: string, stack?: string) => {
|
|
34
|
+
const body = JSON.stringify({
|
|
35
|
+
message,
|
|
36
|
+
stack,
|
|
37
|
+
route: window.location.pathname,
|
|
38
|
+
source: 'browser',
|
|
39
|
+
});
|
|
40
|
+
// Same-origin POST; keepalive lets it survive a navigation/unload. A failed
|
|
41
|
+
// delivery is intentionally swallowed — the beacon is a backstop signal, not
|
|
42
|
+
// a hard dependency of the page.
|
|
43
|
+
void fetch('/__forge_beacon', {
|
|
44
|
+
method: 'POST',
|
|
45
|
+
headers: { 'content-type': 'application/json' },
|
|
46
|
+
body,
|
|
47
|
+
keepalive: true,
|
|
48
|
+
}).catch(() => undefined);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const onError = (event: ErrorEvent) => {
|
|
52
|
+
send(event.message || 'window.onerror', event.error?.stack);
|
|
53
|
+
};
|
|
54
|
+
const onRejection = (event: PromiseRejectionEvent) => {
|
|
55
|
+
const reason = event.reason;
|
|
56
|
+
const message =
|
|
57
|
+
reason instanceof Error ? reason.message : String(reason ?? 'unhandledrejection');
|
|
58
|
+
const stack = reason instanceof Error ? reason.stack : undefined;
|
|
59
|
+
send(message, stack);
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
window.addEventListener('error', onError);
|
|
63
|
+
window.addEventListener('unhandledrejection', onRejection);
|
|
64
|
+
return () => {
|
|
65
|
+
window.removeEventListener('error', onError);
|
|
66
|
+
window.removeEventListener('unhandledrejection', onRejection);
|
|
67
|
+
};
|
|
68
|
+
}, []);
|
|
69
|
+
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
import type { Product } from '@forgecart/sdk/shop';
|
|
2
1
|
import Link from 'next/link';
|
|
3
2
|
|
|
4
|
-
import { formatPrice, getStartingPrice } from '../lib/forgecart';
|
|
3
|
+
import { formatPrice, getStartingPrice, type Product } from '../lib/forgecart';
|
|
5
4
|
|
|
6
5
|
/** A product tile linking to its detail page. Used by the home and grid pages. */
|
|
7
6
|
export function ProductCard({ product }: { product: Product }) {
|