@tribe-nest/forge 3.67.0 → 3.74.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.
@@ -0,0 +1,606 @@
1
+ import { useState, type CSSProperties, type FormEvent } from "react";
2
+ import { useForgeT } from "../../i18n";
3
+ import { useCheckoutLinkPayment } from "../headless/checkoutLink/useCheckoutLinkPayment";
4
+ import { useInventoryHold } from "../headless/checkout/useInventoryHold";
5
+ import { usePaymentRenderer } from "../payment/ForgePaymentProvider";
6
+ import { useThemeTokens } from "../theme/ForgeThemeProvider";
7
+ import { useAmountFormatter } from "../format/useFormatCurrency";
8
+ import type { CheckoutLinkAddress, CheckoutLinkView } from "../../data/queries/useCheckoutLink";
9
+ import { useShippingCountries, useShippingRates, type ShippingGroup, type ShippingRate } from "../../data/queries/useShipping";
10
+ import type { CreateOrderShippingRate } from "../../data/queries/useOrders";
11
+ import { ShippingRateSelector } from "./ShippingRateSelector";
12
+ import { Button } from "./Button";
13
+ import { HoldNotice } from "./HoldNotice";
14
+ import { Loading } from "./Loading";
15
+ import { PaystackPayButton } from "./PaystackPayButton";
16
+
17
+ export interface CheckoutLinkPaymentProps {
18
+ /** The token from the link (`/i/checkout/pay/<token>`). */
19
+ token?: string | null;
20
+ /**
21
+ * True when the payment provider has just sent the buyer back (`?finalise=1`
22
+ * or Stripe's `redirect_status`): the page settles the link instead of
23
+ * offering to pay.
24
+ */
25
+ returning?: boolean;
26
+ /** Where "browse the store" goes when the link can no longer be paid. Default `/i/store`. */
27
+ storePath?: string;
28
+ /** Format a numeric amount for display. */
29
+ formatAmount?: (amount: number, currencyCode?: string) => string;
30
+ className?: string;
31
+ style?: CSSProperties;
32
+ }
33
+
34
+ /**
35
+ * The hosted pay page for a checkout link (`/i/checkout/pay/<token>`).
36
+ *
37
+ * A business (or its agent) held an item for this person and sent them here.
38
+ * The page shows exactly what is held, at what price and until when, and
39
+ * continues to the normal payment step. It does not build a checkout: the
40
+ * checkout already exists on the server, and the `<Checkout>` drop-in always
41
+ * makes a new one, which is why this is its own component.
42
+ *
43
+ * Pressing Pay asks the server first (the link still open, the hold still
44
+ * there or taken again); a refusal is shown in the server's own words, before
45
+ * any card form exists. A paid, expired or cancelled link says which, with a
46
+ * way to the store.
47
+ */
48
+ export function CheckoutLinkPayment({
49
+ token,
50
+ returning = false,
51
+ storePath = "/i/store",
52
+ formatAmount,
53
+ className,
54
+ style,
55
+ }: CheckoutLinkPaymentProps) {
56
+ const tr = useForgeT();
57
+ const t = useThemeTokens();
58
+ const fmt = useAmountFormatter(formatAmount);
59
+ const renderPayment = usePaymentRenderer();
60
+ const page = useCheckoutLinkPayment(token, { returning });
61
+ const hold = useInventoryHold(page.holdExpiresAt);
62
+
63
+ const shell: CSSProperties = {
64
+ maxWidth: 560,
65
+ margin: "0 auto",
66
+ color: t.text,
67
+ fontFamily: t.fontFamily,
68
+ ...style,
69
+ };
70
+ const heading: CSSProperties = { fontSize: 26, fontWeight: 800, fontFamily: t.headingFontFamily, marginBottom: 8 };
71
+ const muted: CSSProperties = { color: t.muted, fontSize: 15, lineHeight: 1.6 };
72
+
73
+ const toStore = (
74
+ <div style={{ marginTop: 24 }}>
75
+ <Button onClick={() => go(storePath)} data-testid="checkout-link-store">
76
+ {tr("forge.checkout_link_payment.browse_store")}
77
+ </Button>
78
+ </div>
79
+ );
80
+ // A booking whose time is gone: the way on is another time of the same service.
81
+ const otherTimes = (path: string | null | undefined) =>
82
+ path ? (
83
+ <div style={{ marginTop: 24 }}>
84
+ <Button onClick={() => go(path)} data-testid="checkout-link-other-times">
85
+ {tr("forge.checkout_link_payment.other_times")}
86
+ </Button>
87
+ </div>
88
+ ) : (
89
+ toStore
90
+ );
91
+
92
+ // ── The return leg: settle, then say what is true ────────────────────────
93
+ if (returning) {
94
+ if (page.finalize.isLoading) return <Loading fullPage />;
95
+ const outcome = page.finalize.data?.outcome;
96
+ if (page.finalize.error || !page.finalize.data) {
97
+ return (
98
+ <div className={className} style={shell} data-testid="checkout-link-page" data-state="not-found">
99
+ <h1 style={heading}>{tr("forge.checkout_link_payment.not_found_title")}</h1>
100
+ <p style={muted}>{tr("forge.checkout_link_payment.not_found_body")}</p>
101
+ {toStore}
102
+ </div>
103
+ );
104
+ }
105
+ if (outcome === "needs_refund" || page.finalize.data.link.needsRefund) {
106
+ return (
107
+ <div className={className} style={shell} data-testid="checkout-link-page" data-state="needs-refund">
108
+ <h1 style={heading}>{tr("forge.checkout_link_payment.too_late_title")}</h1>
109
+ <p style={muted}>{tr("forge.checkout_link_payment.too_late_body")}</p>
110
+ {toStore}
111
+ </div>
112
+ );
113
+ }
114
+ if (outcome === "fulfilled" || outcome === "paid") {
115
+ return (
116
+ <div className={className} style={shell} data-testid="checkout-link-page" data-state="paid">
117
+ <h1 style={heading}>{tr("forge.checkout_link_payment.paid_title")}</h1>
118
+ <Summary view={page.finalize.data.link} fmt={fmt} />
119
+ <p style={{ ...muted, marginTop: 16 }}>{tr("forge.checkout_link_payment.paid_body")}</p>
120
+ </div>
121
+ );
122
+ }
123
+ // Not paid (a declined card, a closed window): the link is still open, so
124
+ // they can try again from the same address.
125
+ return (
126
+ <div className={className} style={shell} data-testid="checkout-link-page" data-state="unpaid">
127
+ <h1 style={heading}>{tr("forge.checkout_link_payment.unpaid_title")}</h1>
128
+ <p style={muted}>{tr("forge.checkout_link_payment.unpaid_body")}</p>
129
+ <div style={{ marginTop: 24 }}>
130
+ <Button onClick={() => go(typeof window !== "undefined" ? window.location.pathname : "")}>
131
+ {tr("forge.checkout_link_payment.try_again")}
132
+ </Button>
133
+ </div>
134
+ </div>
135
+ );
136
+ }
137
+
138
+ // ── The page as the email opens it ───────────────────────────────────────
139
+ if (page.link.isLoading) return <Loading fullPage />;
140
+ const view = page.link.data;
141
+ if (page.link.error || !view) {
142
+ return (
143
+ <div className={className} style={shell} data-testid="checkout-link-page" data-state="not-found">
144
+ <h1 style={heading}>{tr("forge.checkout_link_payment.not_found_title")}</h1>
145
+ <p style={muted}>{tr("forge.checkout_link_payment.not_found_body")}</p>
146
+ {toStore}
147
+ </div>
148
+ );
149
+ }
150
+
151
+ if (view.status !== "open") {
152
+ const closed = view.needsRefund
153
+ ? { title: tr("forge.checkout_link_payment.too_late_title"), body: tr("forge.checkout_link_payment.too_late_body") }
154
+ : view.status === "paid"
155
+ ? { title: tr("forge.checkout_link_payment.paid_title"), body: tr("forge.checkout_link_payment.paid_body") }
156
+ : view.status === "cancelled"
157
+ ? {
158
+ title: tr("forge.checkout_link_payment.cancelled_title"),
159
+ body: tr("forge.checkout_link_payment.cancelled_body"),
160
+ }
161
+ : { title: tr("forge.checkout_link_payment.ended_title"), body: tr("forge.checkout_link_payment.ended_body") };
162
+ return (
163
+ <div className={className} style={shell} data-testid="checkout-link-page" data-state={view.status}>
164
+ <h1 style={heading}>{closed.title}</h1>
165
+ <p style={muted}>{closed.body}</p>
166
+ {view.status !== "paid" && (view.booking ? otherTimes(view.booking.servicePath) : toStore)}
167
+ </div>
168
+ );
169
+ }
170
+
171
+ const shipping = view.shipping ?? null;
172
+ // Something ships and its address is not in yet: Pay waits for it.
173
+ const needsAddress = !!shipping?.required && !shipping.set;
174
+ const totalCents = view.totalCents + (shipping?.set ? (shipping.costCents ?? 0) : 0);
175
+ const total = fmt(totalCents / 100, view.currency);
176
+ const refused = page.refusal;
177
+ const timeTaken = !!refused && refused.code === "INVENTORY_HOLD_EXPIRED" && !!view.booking;
178
+
179
+ return (
180
+ <div className={className} style={shell} data-testid="checkout-link-page" data-state="open">
181
+ <h1 style={heading}>{tr("forge.checkout_link_payment.title")}</h1>
182
+ <Summary view={view} fmt={fmt} />
183
+
184
+ {shipping?.required && (
185
+ <ShippingStep
186
+ view={view}
187
+ fmt={fmt}
188
+ canChange={!page.paymentStarted}
189
+ saving={page.isSavingShipping}
190
+ error={page.shippingError}
191
+ onSave={page.saveShipping}
192
+ />
193
+ )}
194
+
195
+ <div style={{ marginTop: 16 }}>
196
+ <HoldNotice
197
+ hold={hold}
198
+ noun={tr(view.booking ? "forge.checkout_link_payment.hold_noun_booking" : "forge.checkout_link_payment.hold_noun")}
199
+ />
200
+ </div>
201
+
202
+ {refused && (
203
+ <p role="alert" data-testid="checkout-link-refusal" style={{ color: t.secondary, fontSize: 14, marginTop: 8 }}>
204
+ {refused.message || tr("forge.checkout_link_payment.payment_unavailable")}
205
+ </p>
206
+ )}
207
+ {timeTaken && otherTimes(view.booking?.servicePath)}
208
+
209
+ <div style={{ marginTop: 16 }}>
210
+ {page.isPaystack ? (
211
+ page.canOpenPaystack ? (
212
+ <PaystackPayButton
213
+ onPay={page.openPaystackCheckout}
214
+ label={tr("forge.checkout_link_payment.pay_amount", { amount: total })}
215
+ />
216
+ ) : (
217
+ <Loading label={tr("forge.checkout_link_payment.preparing_payment")} size={22} />
218
+ )
219
+ ) : page.clientSecret ? (
220
+ renderPayment?.({
221
+ clientSecret: page.clientSecret,
222
+ returnUrl: page.returnUrl,
223
+ amount: view.totalCents / 100,
224
+ currency: view.currency,
225
+ mode: "redirect",
226
+ })
227
+ ) : timeTaken ? null : (
228
+ <Button
229
+ fullWidth
230
+ loading={page.isStarting}
231
+ disabled={(hold.hasLapsed && !refused) || needsAddress}
232
+ onClick={() => void page.pay()}
233
+ data-testid="checkout-link-pay"
234
+ >
235
+ {tr("forge.checkout_link_payment.pay_amount", { amount: total })}
236
+ </Button>
237
+ )}
238
+ </div>
239
+ </div>
240
+ );
241
+ }
242
+
243
+ function Summary({
244
+ view,
245
+ fmt,
246
+ }: {
247
+ view: CheckoutLinkView;
248
+ fmt: (amount: number, currencyCode?: string) => string;
249
+ }) {
250
+ const tr = useForgeT();
251
+ const t = useThemeTokens();
252
+ const lines = view.items ?? [];
253
+ return (
254
+ <div
255
+ data-testid="checkout-link-summary"
256
+ style={{
257
+ border: `1px solid ${t.border}`,
258
+ borderRadius: t.cornerRadius,
259
+ padding: 20,
260
+ background: t.surface,
261
+ }}
262
+ >
263
+ {lines.length > 1 ? (
264
+ <div data-testid="checkout-link-items">
265
+ {lines.map((line, index) => (
266
+ <div
267
+ key={`${line.title}-${index}`}
268
+ data-testid="checkout-link-line"
269
+ style={{ display: "flex", justifyContent: "space-between", gap: 12, marginTop: index ? 8 : 0 }}
270
+ >
271
+ <span style={{ fontWeight: 600 }}>
272
+ {line.quantity > 1 ? `${line.quantity} x ${line.title}` : line.title}
273
+ </span>
274
+ <span style={{ color: t.muted, whiteSpace: "nowrap" }}>
275
+ {fmt((line.unitPriceCents * line.quantity) / 100, view.currency)}
276
+ </span>
277
+ </div>
278
+ ))}
279
+ </div>
280
+ ) : (
281
+ <div style={{ fontSize: 18, fontWeight: 700 }} data-testid="checkout-link-item">
282
+ {view.item?.title}
283
+ </div>
284
+ )}
285
+ {lines.length > 1 ? null : view.booking ? (
286
+ <div style={{ marginTop: 12, color: t.muted }}>
287
+ {view.booking.sessions.map((session) => (
288
+ // The buyer's own clock: the time is shown in the zone of the
289
+ // device reading it, with the zone named.
290
+ <div key={session.startsAt} data-testid="checkout-link-session" suppressHydrationWarning>
291
+ {formatLocalTime(session.startsAt)}
292
+ </div>
293
+ ))}
294
+ {view.status === "open" && (
295
+ <div style={{ marginTop: 8, fontSize: 14 }} data-testid="checkout-link-held-until" suppressHydrationWarning>
296
+ {tr("forge.checkout_link_payment.held_until", { time: formatLocalTime(view.expiresAt) })}
297
+ </div>
298
+ )}
299
+ </div>
300
+ ) : (
301
+ <div style={{ display: "flex", justifyContent: "space-between", marginTop: 12, color: t.muted }}>
302
+ <span data-testid="checkout-link-quantity">
303
+ {tr("forge.checkout_link_payment.quantity", { quantity: view.quantity })}
304
+ </span>
305
+ {view.unitPriceCents !== null && (
306
+ <span>{tr("forge.checkout_link_payment.each", { price: fmt(view.unitPriceCents / 100, view.currency) })}</span>
307
+ )}
308
+ </div>
309
+ )}
310
+ {view.shipping?.set && view.shipping.costCents !== null && (
311
+ <div
312
+ style={{ display: "flex", justifyContent: "space-between", marginTop: 12, color: t.muted }}
313
+ data-testid="checkout-link-shipping-cost"
314
+ >
315
+ <span>{tr("forge.checkout_link_payment.shipping")}</span>
316
+ <span>{fmt(view.shipping.costCents / 100, view.currency)}</span>
317
+ </div>
318
+ )}
319
+ <div
320
+ style={{ display: "flex", justifyContent: "space-between", marginTop: 12, fontSize: 20, fontWeight: 800 }}
321
+ >
322
+ <span>{tr("forge.checkout_link_payment.total")}</span>
323
+ <span data-testid="checkout-link-total" style={{ color: t.primary }}>
324
+ {fmt(
325
+ (view.totalCents + (view.shipping?.set ? (view.shipping.costCents ?? 0) : 0)) / 100,
326
+ view.currency,
327
+ )}
328
+ </span>
329
+ </div>
330
+ </div>
331
+ );
332
+ }
333
+
334
+ /**
335
+ * Where the link's shipped items go (phase 2): the address, then the store's
336
+ * own shipping rates for it (`/public/orders/shipping-rates`, the same groups
337
+ * and rate picker the store checkout shows), then saved to the link, which
338
+ * prices the shipping again on the server. Once saved it reads as a line with
339
+ * a way to change it, until a payment has been started.
340
+ */
341
+ function ShippingStep({
342
+ view,
343
+ fmt,
344
+ canChange,
345
+ saving,
346
+ error,
347
+ onSave,
348
+ }: {
349
+ view: CheckoutLinkView;
350
+ fmt: (amount: number, currencyCode?: string) => string;
351
+ canChange: boolean;
352
+ saving: boolean;
353
+ error: string | null;
354
+ onSave: (input: {
355
+ shippingAddress: CheckoutLinkAddress & { phone?: string };
356
+ selectedShippingRates: CreateOrderShippingRate[];
357
+ }) => Promise<boolean>;
358
+ }) {
359
+ const tr = useForgeT();
360
+ const t = useThemeTokens();
361
+ const countries = useShippingCountries();
362
+ const rates = useShippingRates();
363
+ const shipping = view.shipping!;
364
+ const [editing, setEditing] = useState(false);
365
+ const [address, setAddress] = useState<CheckoutLinkAddress & { phone: string }>({
366
+ address1: shipping.address?.address1 ?? "",
367
+ city: shipping.address?.city ?? "",
368
+ stateCode: shipping.address?.stateCode ?? "",
369
+ countryCode: shipping.address?.countryCode ?? "",
370
+ zip: shipping.address?.zip ?? "",
371
+ phone: "",
372
+ });
373
+ const [groups, setGroups] = useState<ShippingGroup[] | null>(null);
374
+ const [selections, setSelections] = useState<Record<string, CreateOrderShippingRate>>({});
375
+ const [ratesError, setRatesError] = useState<string | null>(null);
376
+
377
+ const box: CSSProperties = {
378
+ marginTop: 16,
379
+ border: `1px solid ${t.border}`,
380
+ borderRadius: t.cornerRadius,
381
+ padding: 20,
382
+ background: t.surface,
383
+ };
384
+ const input: CSSProperties = {
385
+ width: "100%",
386
+ padding: "10px 12px",
387
+ border: `1px solid ${t.border}`,
388
+ borderRadius: t.cornerRadius,
389
+ background: "transparent",
390
+ color: t.text,
391
+ fontSize: 15,
392
+ };
393
+ const label: CSSProperties = { display: "block", fontSize: 14, fontWeight: 600, margin: "12px 0 6px" };
394
+
395
+ if (shipping.set && !editing) {
396
+ const a = shipping.address;
397
+ return (
398
+ <div style={box} data-testid="checkout-link-shipping" data-state="set">
399
+ <div style={{ display: "flex", justifyContent: "space-between", gap: 12, alignItems: "flex-start" }}>
400
+ <div>
401
+ <div style={{ fontWeight: 700 }}>{tr("forge.checkout_link_payment.ship_to")}</div>
402
+ {a && (
403
+ <div style={{ color: t.muted, marginTop: 4 }} data-testid="checkout-link-ship-to">
404
+ {[a.address1, a.city, a.stateCode, a.zip, a.countryCode].filter(Boolean).join(", ")}
405
+ </div>
406
+ )}
407
+ </div>
408
+ {canChange && (
409
+ <Button variant="secondary" onClick={() => setEditing(true)} data-testid="checkout-link-change-address">
410
+ {tr("forge.checkout_link_payment.change_address")}
411
+ </Button>
412
+ )}
413
+ </div>
414
+ </div>
415
+ );
416
+ }
417
+
418
+ const physical = (view.items ?? []).filter((line) => line.physical && line.productId && line.productVariantId);
419
+ const country = (countries.data ?? []).find((c) => c.code === address.countryCode);
420
+ const set = (key: keyof typeof address) => (event: { target: { value: string } }) =>
421
+ setAddress((prev) => ({ ...prev, [key]: event.target.value }));
422
+
423
+ const toSelected = (group: ShippingGroup, rate: ShippingRate): CreateOrderShippingRate => ({
424
+ groupId: group.id,
425
+ type: group.type,
426
+ productIds: group.productIds,
427
+ rateId: rate.rateId,
428
+ shipmentId: rate.shipmentId,
429
+ amount: rate.amount,
430
+ currency: rate.currency,
431
+ carrier: rate.carrier,
432
+ service: rate.service,
433
+ });
434
+
435
+ const save = async (picked: Record<string, CreateOrderShippingRate>) => {
436
+ const ok = await onSave({
437
+ shippingAddress: { ...address, ...(address.phone ? { phone: address.phone } : {}) },
438
+ selectedShippingRates: Object.values(picked),
439
+ });
440
+ if (ok) {
441
+ setEditing(false);
442
+ setGroups(null);
443
+ }
444
+ };
445
+
446
+ const findRates = async (event: FormEvent) => {
447
+ event.preventDefault();
448
+ setRatesError(null);
449
+ try {
450
+ const found = await rates.mutateAsync({
451
+ shippingAddress: address,
452
+ cartItems: physical.map((line) => ({
453
+ productId: line.productId!,
454
+ productVariantId: line.productVariantId!,
455
+ quantity: line.quantity,
456
+ })),
457
+ });
458
+ const auto: Record<string, CreateOrderShippingRate> = {};
459
+ for (const group of found.groups) {
460
+ if (!group.selectable && group.rates.length > 0) auto[group.id] = toSelected(group, group.rates[0]);
461
+ }
462
+ setSelections(auto);
463
+ // Nothing to choose (flat or external rates only): save straight away.
464
+ if (!found.groups.some((group) => group.selectable)) {
465
+ await save(auto);
466
+ return;
467
+ }
468
+ setGroups(found.groups);
469
+ } catch (err) {
470
+ setRatesError(
471
+ (err as { response?: { data?: { message?: string } } })?.response?.data?.message ??
472
+ tr("forge.checkout_link_payment.shipping_unavailable"),
473
+ );
474
+ }
475
+ };
476
+
477
+ const shownError = ratesError ?? (typeof error === "string" ? error || tr("forge.checkout_link_payment.shipping_unavailable") : null);
478
+
479
+ return (
480
+ <div style={box} data-testid="checkout-link-shipping" data-state={groups ? "rates" : "address"}>
481
+ <div style={{ fontWeight: 700, fontSize: 17 }}>
482
+ {groups ? tr("forge.checkout.shipping_options_title") : tr("forge.checkout_link_payment.shipping_title")}
483
+ </div>
484
+
485
+ {groups ? (
486
+ <div style={{ marginTop: 12 }}>
487
+ <ShippingRateSelector
488
+ groups={groups}
489
+ selections={selections}
490
+ onSelect={(group, rate) => setSelections((prev) => ({ ...prev, [group.id]: toSelected(group, rate) }))}
491
+ formatCurrency={fmt}
492
+ />
493
+ <div style={{ display: "flex", gap: 12, marginTop: 16 }}>
494
+ <Button variant="secondary" onClick={() => setGroups(null)}>
495
+ {tr("forge.checkout.back")}
496
+ </Button>
497
+ <Button
498
+ fullWidth
499
+ loading={saving}
500
+ disabled={groups.some((group) => group.selectable && !selections[group.id])}
501
+ onClick={() => void save(selections)}
502
+ data-testid="checkout-link-use-shipping"
503
+ >
504
+ {tr("forge.checkout_link_payment.use_shipping")}
505
+ </Button>
506
+ </div>
507
+ </div>
508
+ ) : (
509
+ <form onSubmit={(event) => void findRates(event)}>
510
+ <label style={label} htmlFor="checkout-link-address1">
511
+ {tr("forge.checkout.address1_label")}
512
+ </label>
513
+ <input id="checkout-link-address1" required style={input} value={address.address1} onChange={set("address1")} />
514
+ <label style={label} htmlFor="checkout-link-city">
515
+ {tr("forge.checkout.city_label")}
516
+ </label>
517
+ <input id="checkout-link-city" required style={input} value={address.city} onChange={set("city")} />
518
+ <label style={label} htmlFor="checkout-link-zip">
519
+ {tr("forge.checkout.zip_label")}
520
+ </label>
521
+ <input id="checkout-link-zip" required style={input} value={address.zip} onChange={set("zip")} />
522
+ <label style={label} htmlFor="checkout-link-country">
523
+ {tr("forge.checkout.country_label")}
524
+ </label>
525
+ <select
526
+ id="checkout-link-country"
527
+ required
528
+ style={input}
529
+ value={address.countryCode}
530
+ onChange={(event) => setAddress((prev) => ({ ...prev, countryCode: event.target.value, stateCode: "" }))}
531
+ >
532
+ <option value="">{tr("forge.checkout.country_select")}</option>
533
+ {(countries.data ?? []).map((c) => (
534
+ <option key={c.code} value={c.code}>
535
+ {c.name}
536
+ </option>
537
+ ))}
538
+ </select>
539
+ {country?.states && country.states.length > 0 && (
540
+ <>
541
+ <label style={label} htmlFor="checkout-link-state">
542
+ {tr("forge.checkout.state_label")}
543
+ </label>
544
+ <select id="checkout-link-state" required style={input} value={address.stateCode} onChange={set("stateCode")}>
545
+ <option value="">{tr("forge.checkout.state_select")}</option>
546
+ {country.states.map((state) => (
547
+ <option key={state.code} value={state.code}>
548
+ {state.name}
549
+ </option>
550
+ ))}
551
+ </select>
552
+ </>
553
+ )}
554
+ <label style={label} htmlFor="checkout-link-phone">
555
+ {tr("forge.checkout.phone_label")}
556
+ </label>
557
+ <input
558
+ id="checkout-link-phone"
559
+ type="tel"
560
+ style={input}
561
+ value={address.phone}
562
+ placeholder={tr("forge.checkout.phone_placeholder")}
563
+ onChange={set("phone")}
564
+ />
565
+ <div style={{ display: "flex", gap: 12, marginTop: 16 }}>
566
+ {shipping.set && (
567
+ <Button type="button" variant="secondary" onClick={() => setEditing(false)}>
568
+ {tr("forge.checkout.back")}
569
+ </Button>
570
+ )}
571
+ <Button type="submit" fullWidth loading={rates.isPending || saving} data-testid="checkout-link-find-shipping">
572
+ {rates.isPending ? tr("forge.checkout.fetching_rates") : tr("forge.checkout.continue_to_shipping")}
573
+ </Button>
574
+ </div>
575
+ </form>
576
+ )}
577
+
578
+ {shownError && (
579
+ <p role="alert" data-testid="checkout-link-shipping-error" style={{ color: t.secondary, fontSize: 14, marginTop: 8 }}>
580
+ {shownError}
581
+ </p>
582
+ )}
583
+ </div>
584
+ );
585
+ }
586
+
587
+ function go(path: string) {
588
+ if (typeof window !== "undefined") window.location.href = path;
589
+ }
590
+
591
+ /** A moment in the reader's own time zone, with the zone named ("Tuesday 14 October, 10:00 CEST"). */
592
+ function formatLocalTime(iso: string, timeZone?: string): string {
593
+ try {
594
+ return new Intl.DateTimeFormat(undefined, {
595
+ weekday: "long",
596
+ day: "numeric",
597
+ month: "long",
598
+ hour: "2-digit",
599
+ minute: "2-digit",
600
+ timeZoneName: "short",
601
+ ...(timeZone ? { timeZone } : {}),
602
+ }).format(new Date(iso));
603
+ } catch {
604
+ return new Date(iso).toISOString();
605
+ }
606
+ }
@@ -1,4 +1,5 @@
1
1
  import type { CountryCode } from "libphonenumber-js";
2
+ import type { ConsentChannel } from "../../types/models";
2
3
  import { useEmailListForm } from "../headless/forms/useEmailListForm";
3
4
  import { PhoneInput } from "./PhoneInput";
4
5
  import { useForgeT } from "../../i18n";
@@ -12,6 +13,12 @@ export interface EmailListFormProps {
12
13
  includePhoneNumber?: boolean;
13
14
  /** Seeds the phone country select. Undefined lets the visitor choose. */
14
15
  defaultCountry?: CountryCode;
16
+ /**
17
+ * Consent boxes to offer, one per channel: `ai_call` (calls, including by an
18
+ * AI voice agent), `sms`, `whatsapp`. Each is unticked and optional, worded
19
+ * with the business's name in the site's language. Adds a phone field.
20
+ */
21
+ consentChannels?: ConsentChannel[];
15
22
  title?: string;
16
23
  description?: string;
17
24
  buttonText?: string;
@@ -25,6 +32,7 @@ export function EmailListForm({
25
32
  includeName,
26
33
  includePhoneNumber,
27
34
  defaultCountry,
35
+ consentChannels,
28
36
  title,
29
37
  description,
30
38
  buttonText,
@@ -32,7 +40,14 @@ export function EmailListForm({
32
40
  }: EmailListFormProps) {
33
41
  const t = useForgeT();
34
42
  const theme = useForgeTheme();
35
- const f = useEmailListForm({ emailListId, leadMagnetId, includeName, includePhoneNumber, defaultCountry });
43
+ const f = useEmailListForm({
44
+ emailListId,
45
+ leadMagnetId,
46
+ includeName,
47
+ includePhoneNumber,
48
+ defaultCountry,
49
+ consentChannels,
50
+ });
36
51
 
37
52
  // Defaults resolve here rather than in the signature so they can be
38
53
  // translated. `??` keeps a caller-supplied "" falsy exactly as before.
@@ -76,7 +91,18 @@ export function EmailListForm({
76
91
  onChange={(e) => f.setEmail(e.target.value)}
77
92
  style={inputStyle}
78
93
  />
79
- {includePhoneNumber && <PhoneInput controller={f.phone} />}
94
+ {f.showPhone && <PhoneInput controller={f.phone} />}
95
+ {f.consentBoxes.map((box) => (
96
+ <label key={box.channel} style={{ display: "flex", alignItems: "flex-start", gap: 8, fontSize: 13, lineHeight: 1.5 }}>
97
+ <input
98
+ type="checkbox"
99
+ checked={box.checked}
100
+ onChange={(e) => f.setConsent(box.channel, e.target.checked)}
101
+ style={{ marginTop: 3, flexShrink: 0 }}
102
+ />
103
+ <span>{box.text}</span>
104
+ </label>
105
+ ))}
80
106
  {f.error && <p style={{ color: "#ef4444", fontSize: 14 }}>{f.error}</p>}
81
107
  <button
82
108
  onClick={f.submit}