@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.
- package/dist/src/commands/init.d.ts +16 -0
- package/dist/src/commands/init.js +18 -7
- package/dist/src/commands/init.js.map +1 -1
- package/package.json +1 -1
- package/templates/storefront/README.md +145 -61
- package/templates/storefront/next.config.js +20 -0
- package/templates/storefront/package.json +10 -2
- package/templates/storefront/postcss.config.js +1 -2
- package/templates/storefront/src/app/%5F%5Ffc/track/route.ts +30 -12
- package/templates/storefront/src/app/__forge_beacon/route.ts +1 -2
- package/templates/storefront/src/app/api/%5F%5Fbackend/methods/route.ts +27 -0
- package/templates/storefront/src/app/cart/page.tsx +33 -6
- package/templates/storefront/src/app/checkout/page.tsx +40 -0
- package/templates/storefront/src/app/error.tsx +21 -0
- package/templates/storefront/src/app/global-error.tsx +23 -0
- package/templates/storefront/src/app/globals.css +105 -8
- package/templates/storefront/src/app/layout.tsx +45 -17
- package/templates/storefront/src/app/page.tsx +153 -43
- package/templates/storefront/src/app/ping/route.ts +1 -2
- package/templates/storefront/src/app/products/[slug]/not-found.tsx +3 -8
- package/templates/storefront/src/app/products/[slug]/page.tsx +69 -23
- package/templates/storefront/src/app/products/page.tsx +52 -9
- package/templates/storefront/src/components/CartView.tsx +244 -117
- package/templates/storefront/src/components/ForgeTracker.tsx +131 -26
- package/templates/storefront/src/components/Header.tsx +8 -10
- package/templates/storefront/src/components/ProductCard.tsx +25 -13
- package/templates/storefront/src/components/ProductPurchase.tsx +13 -18
- package/templates/storefront/src/components/checkout/AddressStep.tsx +288 -0
- package/templates/storefront/src/components/checkout/CheckoutFlow.tsx +543 -0
- package/templates/storefront/src/components/checkout/CheckoutGate.tsx +45 -0
- package/templates/storefront/src/components/checkout/PaymentElementForm.tsx +138 -0
- package/templates/storefront/src/components/checkout/PaymentFormEmbed.tsx +89 -0
- package/templates/storefront/src/components/checkout/RatesStep.tsx +113 -0
- package/templates/storefront/src/instrumentation.ts +34 -0
- package/templates/storefront/src/lib/action-result.ts +30 -0
- package/templates/storefront/src/lib/backend-actions.ts +20 -0
- package/templates/storefront/src/lib/backend-client.ts +47 -0
- package/templates/storefront/src/lib/cart-context.tsx +157 -22
- package/templates/storefront/src/lib/checkout-session.ts +185 -0
- package/templates/storefront/src/lib/error-messages.ts +24 -0
- package/templates/storefront/src/lib/experiments.ts +42 -44
- package/templates/storefront/src/lib/forgecart.ts +61 -78
- package/templates/storefront/src/lib/format.ts +91 -0
- package/templates/storefront/src/lib/session-actions.ts +54 -0
- package/templates/storefront/src/lib/shop-config.ts +44 -0
- package/templates/storefront/src/lib/shop-session.ts +114 -0
- package/templates/storefront/src/lib/track-forward.ts +14 -1
- package/templates/storefront/src/lib/uuid.ts +19 -0
- package/templates/storefront/src/server/app.module.ts +18 -0
- package/templates/storefront/src/server/backend-api.ts +26 -0
- package/templates/storefront/src/server/backend-method.decorator.ts +23 -0
- package/templates/storefront/src/server/bootstrap.ts +122 -0
- package/templates/storefront/src/server/customer-extras/customer-extras.module.ts +13 -0
- package/templates/storefront/src/server/customer-extras/service/customer-extras.service.ts +58 -0
- package/templates/storefront/src/server/customer-extras/type/customer-extras.types.ts +11 -0
- package/templates/storefront/src/server/forgecart/forgecart-client.factory.ts +69 -0
- package/templates/storefront/src/server/forgecart/forgecart.module.ts +9 -0
- package/templates/storefront/src/server/runner.ts +91 -0
- package/templates/storefront/src/server/types.ts +36 -0
- package/templates/storefront/tsconfig.json +2 -0
- package/templates/storefront/.env.example +0 -12
- package/templates/storefront/src/lib/cart-actions.ts +0 -139
- package/templates/storefront/tailwind.config.js +0 -8
|
@@ -0,0 +1,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
|
+
}
|