@forgecart/cli 2.202608121449.0 → 2.202608200713.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 (63) 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 +30 -12
  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 +131 -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/track-forward.ts +14 -1
  48. package/templates/storefront/src/lib/uuid.ts +19 -0
  49. package/templates/storefront/src/server/app.module.ts +18 -0
  50. package/templates/storefront/src/server/backend-api.ts +26 -0
  51. package/templates/storefront/src/server/backend-method.decorator.ts +23 -0
  52. package/templates/storefront/src/server/bootstrap.ts +122 -0
  53. package/templates/storefront/src/server/customer-extras/customer-extras.module.ts +13 -0
  54. package/templates/storefront/src/server/customer-extras/service/customer-extras.service.ts +58 -0
  55. package/templates/storefront/src/server/customer-extras/type/customer-extras.types.ts +11 -0
  56. package/templates/storefront/src/server/forgecart/forgecart-client.factory.ts +69 -0
  57. package/templates/storefront/src/server/forgecart/forgecart.module.ts +9 -0
  58. package/templates/storefront/src/server/runner.ts +91 -0
  59. package/templates/storefront/src/server/types.ts +36 -0
  60. package/templates/storefront/tsconfig.json +2 -0
  61. package/templates/storefront/.env.example +0 -12
  62. package/templates/storefront/src/lib/cart-actions.ts +0 -139
  63. package/templates/storefront/tailwind.config.js +0 -8
@@ -0,0 +1,543 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useEffect, useState } from 'react';
4
+
5
+ import type { ExtractedError } from '@forgecart/sdk/shop';
6
+
7
+ import type { ActionResult } from '../../lib/action-result';
8
+ import type {
9
+ CheckoutOrderView,
10
+ Country,
11
+ EligiblePaymentProvider,
12
+ Order,
13
+ PaymentSession,
14
+ PaymentVariables,
15
+ ShippingRateGroup,
16
+ } from '../../lib/forgecart';
17
+ import {
18
+ confirmPaymentSession,
19
+ createPaymentSession,
20
+ getEligiblePaymentProviders,
21
+ getPaymentFormTemplate,
22
+ getPaymentVariables,
23
+ refreshShippingRates,
24
+ selectShippingRate,
25
+ setCustomer,
26
+ setShippingAddress,
27
+ transitionToPayment,
28
+ } from '../../lib/checkout-session';
29
+ import { formatPrice } from '../../lib/format';
30
+ import { AddressStep, EMPTY_ADDRESS, type AddressFormValue } from './AddressStep';
31
+ import { PaymentElementForm } from './PaymentElementForm';
32
+ import { PaymentFormEmbed, type PaymentFormMessage } from './PaymentFormEmbed';
33
+ import { RatesStep } from './RatesStep';
34
+
35
+ type StepId = 'contact' | 'address' | 'rates' | 'payment' | 'confirmation';
36
+
37
+ const STEPS: { id: StepId; label: string }[] = [
38
+ { id: 'contact', label: 'Contact' },
39
+ { id: 'address', label: 'Address' },
40
+ { id: 'rates', label: 'Shipping' },
41
+ { id: 'payment', label: 'Payment' },
42
+ { id: 'confirmation', label: 'Done' },
43
+ ];
44
+
45
+ /**
46
+ * Providers whose payment UI renders DIRECTLY in the page (operator rule:
47
+ * our own payment chrome is never iframed — the srcdoc embed added no
48
+ * security boundary, PCI isolation lives in the provider's own input
49
+ * iframes). ONE explicit lookup, never providerCode string-matching
50
+ * scattered through the flow; providers absent here (redirect models,
51
+ * providers without an adapter) keep the hosted-template embed.
52
+ */
53
+ const IN_PAGE_PAYMENT_PROVIDERS = new Set(['stripe-payment']);
54
+
55
+ /**
56
+ * Session-envelope cache for refresh-resume. A payment session is created
57
+ * ONCE per order and its secret is never re-readable from the API, so a
58
+ * page refresh mid-payment must resume from a client-side copy or the order
59
+ * is stuck (prep mutations throw ORDER_STATE_TRANSITION_ERROR, a second
60
+ * create throws PAYMENT_SESSION_ALREADY_ACTIVE). sessionStorage is
61
+ * tab-scoped and origin-scoped; storage failures (privacy modes) degrade to
62
+ * the honest stuck notice.
63
+ */
64
+ const SESSION_CACHE_PREFIX = 'forgecart-checkout-session:';
65
+
66
+ function readSessionCache(orderId: string): PaymentSession | null {
67
+ try {
68
+ const raw = sessionStorage.getItem(SESSION_CACHE_PREFIX + orderId);
69
+ if (!raw) return null;
70
+ const parsed = JSON.parse(raw) as PaymentSession;
71
+ return parsed.id && parsed.clientSecret ? parsed : null;
72
+ } catch {
73
+ return null;
74
+ }
75
+ }
76
+
77
+ function writeSessionCache(orderId: string, session: PaymentSession): void {
78
+ try {
79
+ sessionStorage.setItem(SESSION_CACHE_PREFIX + orderId, JSON.stringify(session));
80
+ } catch {
81
+ // Storage unavailable — refresh-resume degrades to the stuck notice.
82
+ }
83
+ }
84
+
85
+ function clearSessionCache(orderId: string): void {
86
+ try {
87
+ sessionStorage.removeItem(SESSION_CACHE_PREFIX + orderId);
88
+ } catch {
89
+ // Nothing to clean when storage never worked.
90
+ }
91
+ }
92
+
93
+ /**
94
+ * The reference checkout (Cycle 1): contact → address → rates → payment →
95
+ * confirmation, on the template's design-system vocabulary and the typed
96
+ * `ActionResult` error spine. Step transitions follow the ORDER's state, not
97
+ * local assumptions: prep mutations run while the order is AddingItems;
98
+ * `transitionToPayment` succeeds only once #863's gates (phone-complete
99
+ * address, items, a selected rate per shipment) hold; the payment step's
100
+ * hosted form talks back over the #865 postMessage handshake; coupon edits
101
+ * live in the cart — once a payment session exists the amount is frozen
102
+ * (PAYMENT_SESSION_ALREADY_ACTIVE guards a second session).
103
+ *
104
+ * Every failure renders in place: field-level for the coded field errors,
105
+ * a step-level alert otherwise — never a thrown error, never a blank page.
106
+ */
107
+ export function CheckoutFlow({
108
+ initialOrder,
109
+ countries,
110
+ }: {
111
+ initialOrder: Order;
112
+ countries: Country[];
113
+ }) {
114
+ const [step, setStep] = useState<StepId>('contact');
115
+ const [order, setOrder] = useState<CheckoutOrderView>(initialOrder);
116
+ const [error, setError] = useState<ExtractedError | null>(null);
117
+ const [pending, setPending] = useState(false);
118
+
119
+ const [email, setEmail] = useState('');
120
+ const [firstName, setFirstName] = useState('');
121
+ const [lastName, setLastName] = useState('');
122
+ const [address, setAddress] = useState<AddressFormValue>(EMPTY_ADDRESS);
123
+
124
+ const [rateGroups, setRateGroups] = useState<ShippingRateGroup[]>([]);
125
+ const [ratesRefreshing, setRatesRefreshing] = useState(false);
126
+
127
+ const [providers, setProviders] = useState<EligiblePaymentProvider[]>([]);
128
+ const [providersLoaded, setProvidersLoaded] = useState(false);
129
+ const [session, setSession] = useState<PaymentSession | null>(null);
130
+ const [variables, setVariables] = useState<PaymentVariables | null>(null);
131
+ const [formHtml, setFormHtml] = useState<string | null>(null);
132
+ const [confirmedCode, setConfirmedCode] = useState<string | null>(null);
133
+ const [resumeStuck, setResumeStuck] = useState(false);
134
+
135
+ /** Run one action; on success apply, on failure surface the typed error. */
136
+ const act = useCallback(async function act<T>(
137
+ action: () => Promise<ActionResult<T>>,
138
+ apply: (data: T) => void,
139
+ ): Promise<boolean> {
140
+ setPending(true);
141
+ setError(null);
142
+ const result = await action();
143
+ setPending(false);
144
+ if (!result.ok) {
145
+ setError(result.error);
146
+ return false;
147
+ }
148
+ apply(result.data);
149
+ return true;
150
+ }, []);
151
+
152
+ // ── Step entry effects ────────────────────────────────────────────────────
153
+
154
+ // Refresh-resume: a reload mid-payment arrives with the order already in
155
+ // ArrangingPayment — re-running the prep steps would throw
156
+ // ORDER_STATE_TRANSITION_ERROR and a second createPaymentSession
157
+ // PAYMENT_SESSION_ALREADY_ACTIVE — so the flow re-enters at the payment
158
+ // step and resumes the cached session envelope. No cache (new tab,
159
+ // privacy mode) renders the honest stuck notice instead of a doomed
160
+ // retry surface.
161
+ useEffect(() => {
162
+ if ((initialOrder.state ?? '').toLowerCase() !== 'arrangingpayment') return;
163
+ setStep('payment');
164
+ const cached = readSessionCache(initialOrder.id);
165
+ if (!cached) {
166
+ setResumeStuck(true);
167
+ return;
168
+ }
169
+ setSession(cached);
170
+ void (async () => {
171
+ const varsResult = await getPaymentVariables(initialOrder.id);
172
+ if (varsResult.ok) setVariables(varsResult.data);
173
+ // In-page providers re-mount from the cached secret + variables; only
174
+ // embed-lane providers need the hosted template refetched.
175
+ if (varsResult.ok && IN_PAGE_PAYMENT_PROVIDERS.has(varsResult.data.providerCode)) return;
176
+ await act(
177
+ () => getPaymentFormTemplate(initialOrder.id, window.location.origin),
178
+ (template) => setFormHtml(template.html ?? null),
179
+ );
180
+ })();
181
+ }, [initialOrder.id, initialOrder.state, act]);
182
+
183
+ const refreshRates = useCallback(async () => {
184
+ setRatesRefreshing(true);
185
+ setError(null);
186
+ const result = await refreshShippingRates();
187
+ setRatesRefreshing(false);
188
+ if (!result.ok) {
189
+ setError(result.error);
190
+ return;
191
+ }
192
+ setRateGroups(result.data);
193
+ }, []);
194
+
195
+ useEffect(() => {
196
+ if (step === 'rates') void refreshRates();
197
+ }, [step, refreshRates]);
198
+
199
+ useEffect(() => {
200
+ if (step !== 'payment' || providersLoaded) return;
201
+ void act(
202
+ () => getEligiblePaymentProviders(order.id),
203
+ (eligible) => {
204
+ setProviders(eligible);
205
+ setProvidersLoaded(true);
206
+ },
207
+ );
208
+ }, [step, providersLoaded, order.id, act]);
209
+
210
+ // ── Step handlers ─────────────────────────────────────────────────────────
211
+
212
+ async function submitContact(): Promise<void> {
213
+ const ok = await act(
214
+ () => setCustomer({ emailAddress: email, firstName, lastName }),
215
+ (updated) => updated && setOrder(updated),
216
+ );
217
+ if (ok) setStep('address');
218
+ }
219
+
220
+ async function submitAddress(): Promise<void> {
221
+ const ok = await act(
222
+ () =>
223
+ setShippingAddress({
224
+ streetLine1: address.streetLine1,
225
+ ...(address.streetLine2 ? { streetLine2: address.streetLine2 } : {}),
226
+ city: address.city,
227
+ ...(address.province ? { province: address.province } : {}),
228
+ postalCode: address.postalCode,
229
+ countryId: address.countryId,
230
+ phoneNumber: address.phoneNumber,
231
+ }),
232
+ (updated) => updated && setOrder(updated),
233
+ );
234
+ if (ok) setStep('rates');
235
+ }
236
+
237
+ async function pickRate(rateGroupId: string, rateId: string): Promise<void> {
238
+ await act(
239
+ () => selectShippingRate({ rateGroupId, rateId }),
240
+ (updated) => {
241
+ if (updated) setOrder(updated);
242
+ // Reflect the selection locally — the read-only rates query would
243
+ // also serve it, but the flags are already known.
244
+ setRateGroups((groups) =>
245
+ groups.map((group) =>
246
+ group.id === rateGroupId
247
+ ? {
248
+ ...group,
249
+ rates: group.rates.map((rate) => ({ ...rate, selected: rate.id === rateId })),
250
+ }
251
+ : group,
252
+ ),
253
+ );
254
+ },
255
+ );
256
+ }
257
+
258
+ async function continueToPayment(): Promise<void> {
259
+ const ok = await act(
260
+ () => transitionToPayment(),
261
+ (updated) => updated && setOrder(updated),
262
+ );
263
+ if (ok) setStep('payment');
264
+ }
265
+
266
+ async function pickProvider(providerCode?: string): Promise<void> {
267
+ setPending(true);
268
+ setError(null);
269
+ const varsResult = await getPaymentVariables(order.id);
270
+ setPending(false);
271
+ if (!varsResult.ok) {
272
+ setError(varsResult.error);
273
+ return;
274
+ }
275
+ const fetched = varsResult.data;
276
+ setVariables(fetched);
277
+ const created = await act(
278
+ () => createPaymentSession(order.id, providerCode),
279
+ (paymentSession) => {
280
+ setSession(paymentSession);
281
+ writeSessionCache(order.id, paymentSession);
282
+ },
283
+ );
284
+ if (!created) return;
285
+ // In-page adapter when the RESOLVED provider has one (the variables name
286
+ // the channel's resolved provider); everything else embeds the hosted
287
+ // template. An explicit pick of a different provider falls back too —
288
+ // the variables only describe the default resolution.
289
+ const resolved = providerCode ?? fetched.providerCode;
290
+ if (resolved === fetched.providerCode && IN_PAGE_PAYMENT_PROVIDERS.has(resolved)) return;
291
+ await act(
292
+ () => getPaymentFormTemplate(order.id, window.location.origin),
293
+ (template) => setFormHtml(template.html ?? null),
294
+ );
295
+ }
296
+
297
+ async function onPaymentSuccess(message: PaymentFormMessage): Promise<void> {
298
+ // Client-side confirmation models (Stripe Payment Element) arrive already
299
+ // succeeded — the webhook settles server-side. Token/redirect models post
300
+ // success without a settled intent; those need the explicit confirm.
301
+ if (session && message.status !== 'succeeded') {
302
+ await act(
303
+ () => confirmPaymentSession(session.id),
304
+ () => undefined,
305
+ );
306
+ }
307
+ clearSessionCache(order.id);
308
+ setConfirmedCode(order.code);
309
+ setStep('confirmation');
310
+ }
311
+
312
+ // ── Render ────────────────────────────────────────────────────────────────
313
+
314
+ const stepIndex = STEPS.findIndex((candidate) => candidate.id === step);
315
+ const stepError = step !== 'address' ? error : null;
316
+
317
+ return (
318
+ <div className="space-y-6">
319
+ <ul className="steps w-full">
320
+ {STEPS.map((candidate, index) => (
321
+ <li key={candidate.id} className={`step ${index <= stepIndex ? 'step-primary' : ''}`}>
322
+ {candidate.label}
323
+ </li>
324
+ ))}
325
+ </ul>
326
+
327
+ {step === 'contact' && (
328
+ <form
329
+ className="space-y-4"
330
+ onSubmit={(e) => {
331
+ e.preventDefault();
332
+ void submitContact();
333
+ }}
334
+ >
335
+ <div>
336
+ <label className="mb-1 block text-sm font-medium text-base-content/70" htmlFor="email">
337
+ Email
338
+ </label>
339
+ <input
340
+ id="email"
341
+ type="email"
342
+ required
343
+ autoComplete="email"
344
+ value={email}
345
+ onChange={(e) => setEmail(e.target.value)}
346
+ className="input w-full"
347
+ />
348
+ </div>
349
+ <div className="grid grid-cols-2 gap-4">
350
+ <div>
351
+ <label
352
+ className="mb-1 block text-sm font-medium text-base-content/70"
353
+ htmlFor="first-name"
354
+ >
355
+ First name
356
+ </label>
357
+ <input
358
+ id="first-name"
359
+ type="text"
360
+ required
361
+ autoComplete="given-name"
362
+ value={firstName}
363
+ onChange={(e) => setFirstName(e.target.value)}
364
+ className="input w-full"
365
+ />
366
+ </div>
367
+ <div>
368
+ <label
369
+ className="mb-1 block text-sm font-medium text-base-content/70"
370
+ htmlFor="last-name"
371
+ >
372
+ Last name
373
+ </label>
374
+ <input
375
+ id="last-name"
376
+ type="text"
377
+ required
378
+ autoComplete="family-name"
379
+ value={lastName}
380
+ onChange={(e) => setLastName(e.target.value)}
381
+ className="input w-full"
382
+ />
383
+ </div>
384
+ </div>
385
+ {stepError && (
386
+ <div role="alert" className="alert alert-error text-sm">
387
+ <span>{stepError.message ?? stepError.code}</span>
388
+ </div>
389
+ )}
390
+ <button
391
+ type="submit"
392
+ className="btn btn-primary"
393
+ disabled={pending}
394
+ data-fc-track="checkout-contact"
395
+ >
396
+ {pending ? 'Saving…' : 'Continue to address'}
397
+ </button>
398
+ </form>
399
+ )}
400
+
401
+ {step === 'address' && (
402
+ <AddressStep
403
+ countries={countries}
404
+ value={address}
405
+ onChange={setAddress}
406
+ onSubmit={() => void submitAddress()}
407
+ pending={pending}
408
+ error={error}
409
+ />
410
+ )}
411
+
412
+ {step === 'rates' && (
413
+ <RatesStep
414
+ groups={rateGroups}
415
+ onSelect={(rateGroupId, rateId) => void pickRate(rateGroupId, rateId)}
416
+ onContinue={() => void continueToPayment()}
417
+ pending={pending}
418
+ refreshing={ratesRefreshing}
419
+ error={stepError}
420
+ />
421
+ )}
422
+
423
+ {step === 'payment' && (
424
+ <div className="space-y-4">
425
+ <div className="card flex flex-row items-center justify-between border border-base-300 bg-base-100 p-4">
426
+ <span className="text-sm text-base-content/60">Order total</span>
427
+ <span className="text-lg font-semibold text-base-content">
428
+ {formatPrice(order.totalWithTax, order.currencyCode)}
429
+ </span>
430
+ </div>
431
+
432
+ {resumeStuck && !formHtml && (
433
+ <div role="alert" className="alert alert-warning text-sm">
434
+ <span>
435
+ This order is already awaiting payment, and its payment form can't be recovered in
436
+ this tab. Continue in the tab where you started — or start a new order with a fresh
437
+ cart.
438
+ </span>
439
+ </div>
440
+ )}
441
+
442
+ {!session && !resumeStuck && (
443
+ <div className="space-y-2">
444
+ {!providersLoaded && (
445
+ <p className="text-sm text-base-content/60">Loading payment options…</p>
446
+ )}
447
+ {providersLoaded && providers.length === 0 && (
448
+ <div role="alert" className="alert alert-warning text-sm">
449
+ <span>
450
+ Payment isn't available for this store yet — no payment method is configured.
451
+ Please contact the store.
452
+ </span>
453
+ </div>
454
+ )}
455
+ {providers.map((provider) => (
456
+ <button
457
+ key={provider.providerCode}
458
+ type="button"
459
+ onClick={() => void pickProvider(provider.providerCode)}
460
+ disabled={pending || !provider.isEligible}
461
+ data-fc-track="checkout-pick-provider"
462
+ className="flex w-full items-center justify-between gap-3 rounded-md border border-base-300 px-3 py-2 text-left text-sm transition hover:border-primary disabled:opacity-50"
463
+ >
464
+ <span className="font-medium text-base-content">{provider.providerName}</span>
465
+ {!provider.isEligible && provider.eligibilityMessage && (
466
+ <span className="text-xs text-base-content/60">
467
+ {provider.eligibilityMessage}
468
+ </span>
469
+ )}
470
+ </button>
471
+ ))}
472
+ </div>
473
+ )}
474
+
475
+ {!formHtml &&
476
+ session?.clientSecret &&
477
+ variables?.publishableKey &&
478
+ variables.scriptUrl &&
479
+ IN_PAGE_PAYMENT_PROVIDERS.has(variables.providerCode) && (
480
+ <PaymentElementForm
481
+ publishableKey={variables.publishableKey}
482
+ scriptUrl={variables.scriptUrl}
483
+ clientSecret={session.clientSecret}
484
+ payLabel={`Pay ${formatPrice(
485
+ variables.amount ?? order.totalWithTax,
486
+ variables.currency ?? order.currencyCode,
487
+ )}`}
488
+ onSuccess={(status, paymentIntentId) =>
489
+ void onPaymentSuccess({ type: 'payment-success', status, paymentIntentId })
490
+ }
491
+ onError={(message) =>
492
+ setError({
493
+ code: 'PAYMENT_FAILED',
494
+ variables: {},
495
+ classification: 'BAD_USER_INPUT',
496
+ message,
497
+ })
498
+ }
499
+ />
500
+ )}
501
+
502
+ {formHtml && (
503
+ <PaymentFormEmbed
504
+ html={formHtml}
505
+ clientSecret={session?.clientSecret ?? undefined}
506
+ onSuccess={(message) => void onPaymentSuccess(message)}
507
+ onError={(message) =>
508
+ setError({
509
+ code: 'PAYMENT_FAILED',
510
+ variables: {},
511
+ classification: 'BAD_USER_INPUT',
512
+ message,
513
+ })
514
+ }
515
+ />
516
+ )}
517
+
518
+ {stepError && (
519
+ <div role="alert" className="alert alert-error text-sm">
520
+ <span>{stepError.message ?? stepError.code}</span>
521
+ </div>
522
+ )}
523
+ </div>
524
+ )}
525
+
526
+ {step === 'confirmation' && (
527
+ <div className="card space-y-3 border border-base-300 bg-base-100 p-8 text-center">
528
+ <h2 className="text-2xl font-bold tracking-tight text-base-content">Thank you!</h2>
529
+ <p className="text-base-content/60">Your order is confirmed.</p>
530
+ {confirmedCode && (
531
+ <p className="text-base-content">
532
+ Order code: <span className="font-mono font-semibold">{confirmedCode}</span>
533
+ </p>
534
+ )}
535
+ <p className="text-sm text-base-content/60">
536
+ {order.totalQuantity} item{order.totalQuantity === 1 ? '' : 's'} ·{' '}
537
+ {formatPrice(order.totalWithTax, order.currencyCode)}
538
+ </p>
539
+ </div>
540
+ )}
541
+ </div>
542
+ );
543
+ }
@@ -0,0 +1,45 @@
1
+ 'use client';
2
+
3
+ import Link from 'next/link';
4
+
5
+ import { useCart } from '../../lib/cart-context';
6
+ import type { Country } from '../../lib/forgecart';
7
+ import { CheckoutFlow } from './CheckoutFlow';
8
+
9
+ /**
10
+ * The client gate in front of {@link CheckoutFlow}: the cart lives on the
11
+ * shopper's websocket, so the server can't gate checkout anymore — this
12
+ * component waits for the context hydration, shows the honest empty state
13
+ * when there is nothing to check out, and mounts the flow with the live
14
+ * order once it exists. The flow's own refresh-resume effect (an order
15
+ * already in ArrangingPayment re-enters at the payment step) keeps working
16
+ * because the hydrated active order carries its real state.
17
+ */
18
+ export function CheckoutGate({ countries }: { countries: Country[] }) {
19
+ const { cart, hydrated } = useCart();
20
+
21
+ if (!hydrated) {
22
+ return (
23
+ <div className="space-y-4" aria-busy="true" aria-label="Loading your cart">
24
+ <div className="skeleton h-10 w-full"></div>
25
+ <div className="skeleton h-40 w-full"></div>
26
+ <div className="skeleton h-10 w-40"></div>
27
+ </div>
28
+ );
29
+ }
30
+
31
+ if (!cart || cart.lines.length === 0) {
32
+ return (
33
+ <div className="space-y-4">
34
+ <p className="text-base-content/60">
35
+ Your cart is empty — add something before checking out.
36
+ </p>
37
+ <Link href="/products" className="btn btn-primary">
38
+ Browse products
39
+ </Link>
40
+ </div>
41
+ );
42
+ }
43
+
44
+ return <CheckoutFlow initialOrder={cart} countries={countries} />;
45
+ }