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