@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.
@@ -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
- const configDir = join(targetDir, '.forgecart');
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 ?? '.', config.hasApplication, plan.source === 'warmed');
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":"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"}
1
+ {"version":3,"file":"init.js","sourceRoot":"","sources":["../../../src/commands/init.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forgecart/cli",
3
- "version": "2.202606151453.0",
3
+ "version": "2.202607070306.0",
4
4
  "type": "module",
5
5
  "description": "CLI for scaffolding and operating ForgeCart channel storefronts",
6
6
  "bin": {
@@ -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
@@ -10,7 +10,7 @@
10
10
  "dependencies": {
11
11
  "@forgecart/designer-runtime": "^1.0.0",
12
12
  "@forgecart/sdk": "^1.2.6",
13
- "next": "^15.1.0",
13
+ "next": "^16.1.0",
14
14
  "react": "^19.0.0",
15
15
  "react-dom": "^19.0.0"
16
16
  },
@@ -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
- 'use client';
1
+ import { CartView } from '../../components/CartView';
2
+ import { getChannelSellingPlanGroups } from '../../lib/forgecart';
2
3
 
3
- import Link from 'next/link';
4
+ export const dynamic = 'force-dynamic';
4
5
 
5
- import { useCart } from '../../lib/cart-context';
6
- import { formatPrice } from '../../lib/forgecart';
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
- export default function CartPage() {
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 { getProductBySlug } from '../../../lib/forgecart';
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 }) {