@tribe-nest/forge 3.69.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.
@@ -1,11 +1,14 @@
1
- import type { CSSProperties } from "react";
1
+ import { useState, type CSSProperties, type FormEvent } from "react";
2
2
  import { useForgeT } from "../../i18n";
3
3
  import { useCheckoutLinkPayment } from "../headless/checkoutLink/useCheckoutLinkPayment";
4
4
  import { useInventoryHold } from "../headless/checkout/useInventoryHold";
5
5
  import { usePaymentRenderer } from "../payment/ForgePaymentProvider";
6
6
  import { useThemeTokens } from "../theme/ForgeThemeProvider";
7
7
  import { useAmountFormatter } from "../format/useFormatCurrency";
8
- import type { CheckoutLinkView } from "../../data/queries/useCheckoutLink";
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";
9
12
  import { Button } from "./Button";
10
13
  import { HoldNotice } from "./HoldNotice";
11
14
  import { Loading } from "./Loading";
@@ -165,7 +168,11 @@ export function CheckoutLinkPayment({
165
168
  );
166
169
  }
167
170
 
168
- const total = fmt(view.totalCents / 100, view.currency);
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);
169
176
  const refused = page.refusal;
170
177
  const timeTaken = !!refused && refused.code === "INVENTORY_HOLD_EXPIRED" && !!view.booking;
171
178
 
@@ -174,6 +181,17 @@ export function CheckoutLinkPayment({
174
181
  <h1 style={heading}>{tr("forge.checkout_link_payment.title")}</h1>
175
182
  <Summary view={view} fmt={fmt} />
176
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
+
177
195
  <div style={{ marginTop: 16 }}>
178
196
  <HoldNotice
179
197
  hold={hold}
@@ -210,7 +228,7 @@ export function CheckoutLinkPayment({
210
228
  <Button
211
229
  fullWidth
212
230
  loading={page.isStarting}
213
- disabled={hold.hasLapsed && !refused}
231
+ disabled={(hold.hasLapsed && !refused) || needsAddress}
214
232
  onClick={() => void page.pay()}
215
233
  data-testid="checkout-link-pay"
216
234
  >
@@ -231,6 +249,7 @@ function Summary({
231
249
  }) {
232
250
  const tr = useForgeT();
233
251
  const t = useThemeTokens();
252
+ const lines = view.items ?? [];
234
253
  return (
235
254
  <div
236
255
  data-testid="checkout-link-summary"
@@ -241,10 +260,29 @@ function Summary({
241
260
  background: t.surface,
242
261
  }}
243
262
  >
244
- <div style={{ fontSize: 18, fontWeight: 700 }} data-testid="checkout-link-item">
245
- {view.item?.title}
246
- </div>
247
- {view.booking ? (
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 ? (
248
286
  <div style={{ marginTop: 12, color: t.muted }}>
249
287
  {view.booking.sessions.map((session) => (
250
288
  // The buyer's own clock: the time is shown in the zone of the
@@ -269,18 +307,283 @@ function Summary({
269
307
  )}
270
308
  </div>
271
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
+ )}
272
319
  <div
273
320
  style={{ display: "flex", justifyContent: "space-between", marginTop: 12, fontSize: 20, fontWeight: 800 }}
274
321
  >
275
322
  <span>{tr("forge.checkout_link_payment.total")}</span>
276
323
  <span data-testid="checkout-link-total" style={{ color: t.primary }}>
277
- {fmt(view.totalCents / 100, view.currency)}
324
+ {fmt(
325
+ (view.totalCents + (view.shipping?.set ? (view.shipping.costCents ?? 0) : 0)) / 100,
326
+ view.currency,
327
+ )}
278
328
  </span>
279
329
  </div>
280
330
  </div>
281
331
  );
282
332
  }
283
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
+
284
587
  function go(path: string) {
285
588
  if (typeof window !== "undefined") window.location.href = path;
286
589
  }
@@ -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}
@@ -1,9 +1,16 @@
1
1
  import { useState } from "react";
2
- import { usePublicForm, useSubmitPublicForm } from "../../data/queries/useForms";
2
+ import {
3
+ usePublicForm,
4
+ useSubmitPublicForm,
5
+ formConsentPayload,
6
+ consentWithoutPhone,
7
+ } from "../../data/queries/useForms";
3
8
  import type { FormField } from "../../types/models";
4
9
  import { useForgeTheme } from "../theme/ForgeThemeProvider";
5
- import { useForgeT } from "../../i18n";
10
+ import { useForgeLocale, useForgeT } from "../../i18n";
6
11
  import { Loading } from "./Loading";
12
+ import { PhoneInput } from "./PhoneInput";
13
+
7
14
 
8
15
  export interface FormRendererProps {
9
16
  formId: string;
@@ -17,6 +24,7 @@ export interface FormRendererProps {
17
24
  export function FormRenderer({ formId }: FormRendererProps) {
18
25
  const theme = useForgeTheme();
19
26
  const t = useForgeT();
27
+ const locale = useForgeLocale();
20
28
  const { data: form, isLoading } = usePublicForm(formId);
21
29
  const submitMutation = useSubmitPublicForm(formId);
22
30
 
@@ -45,9 +53,13 @@ export function FormRenderer({ formId }: FormRendererProps) {
45
53
  }
46
54
  }
47
55
  }
56
+ if (consentWithoutPhone(fields, answers)) {
57
+ setError(t("forge.form_renderer.consent_phone_required"));
58
+ return;
59
+ }
48
60
  try {
49
61
  const entries = fields.map((f) => ({ fieldId: f.id, value: answers[f.id] || null }));
50
- await submitMutation.mutateAsync(entries);
62
+ await submitMutation.mutateAsync({ answers: entries, consent: formConsentPayload(fields, locale) });
51
63
  setIsCompleted(true);
52
64
  } catch {
53
65
  setError(t("forge.form_renderer.submit_error"));
@@ -138,8 +150,28 @@ export function FieldInput({
138
150
  </label>
139
151
  );
140
152
 
153
+ // A consent box: unticked by default, never required, and its text is the
154
+ // server's locked wording shown exactly as served (it is the evidence).
155
+ if (field.type === "call_consent") {
156
+ if (!field.consent) return null;
157
+ return (
158
+ <label style={{ display: "flex", alignItems: "flex-start", gap: 10, fontSize: 14, lineHeight: 1.5 }}>
159
+ <input
160
+ type="checkbox"
161
+ checked={value === "true"}
162
+ onChange={(e) => onChange(e.target.checked ? "true" : "")}
163
+ style={{ marginTop: 4, flexShrink: 0 }}
164
+ />
165
+ <span>{field.consent.text}</span>
166
+ </label>
167
+ );
168
+ }
169
+
141
170
  let control: React.ReactNode;
142
171
  switch (field.type) {
172
+ case "phone":
173
+ control = <PhoneFieldControl fieldId={field.id} value={value} onChange={onChange} />;
174
+ break;
143
175
  case "long_text":
144
176
  control = (
145
177
  <textarea
@@ -262,3 +294,23 @@ export function FieldInput({
262
294
  </div>
263
295
  );
264
296
  }
297
+
298
+ /** A form's phone question: country select plus number, submitted as E.164. */
299
+ function PhoneFieldControl({
300
+ fieldId,
301
+ value,
302
+ onChange,
303
+ }: {
304
+ fieldId: string;
305
+ value: string;
306
+ onChange: (v: string) => void;
307
+ }) {
308
+ return (
309
+ <PhoneInput
310
+ id={`forge-form-phone-${fieldId}`}
311
+ mode="shipping"
312
+ initialValue={value || undefined}
313
+ onChange={(e164) => onChange(e164 ?? "")}
314
+ />
315
+ );
316
+ }
@@ -0,0 +1,122 @@
1
+ import type { SelectedShippingRate } from "../headless/checkout/useCheckout";
2
+ import type { ShippingRate, ShippingGroup } from "../../data/queries/useShipping";
3
+ import { useForgeT } from "../../i18n";
4
+ import { useForgeTheme } from "../theme/ForgeThemeProvider";
5
+
6
+ function alpha(value: number) {
7
+ return Math.round(value * 255)
8
+ .toString(16)
9
+ .padStart(2, "0");
10
+ }
11
+
12
+ // Shared by the store checkout and the checkout link pay page (phase 2), so a
13
+ // buyer picks shipping the same way on both.
14
+ /**
15
+ * Renders the shipping groups: fixed lines for auto-selected flat/external
16
+ * groups, and a radio list for the selectable (dynamic/carrier) group. Selection
17
+ * state is owned by `useCheckout`; this component just renders + calls `onSelect`.
18
+ */
19
+ export function ShippingRateSelector({
20
+ groups,
21
+ selections,
22
+ onSelect,
23
+ formatCurrency,
24
+ }: {
25
+ groups: ShippingGroup[];
26
+ selections: Record<string, SelectedShippingRate>;
27
+ onSelect: (group: ShippingGroup, rate: ShippingRate) => void;
28
+ formatCurrency: (amount: number, currencyCode?: string) => string;
29
+ }) {
30
+ const theme = useForgeTheme();
31
+ const tr = useForgeT();
32
+ if (groups.length === 0) return null;
33
+
34
+ return (
35
+ <div className="fg:space-y-6">
36
+ {groups.map((group) => (
37
+ <div
38
+ key={group.id}
39
+ className="fg:p-4 fg:rounded-lg"
40
+ style={{ border: `1px solid ${theme.colors.primary}${alpha(0.15)}`, borderRadius: `${theme.cornerRadius}px` }}
41
+ >
42
+ <p className="fg:font-medium fg:mb-3" style={{ color: theme.colors.text }}>
43
+ {group.title}
44
+ </p>
45
+
46
+ {!group.selectable && group.rates.length > 0 ? (
47
+ // Flat or external: a single, auto-selected rate.
48
+ <div
49
+ className="fg:flex fg:items-center fg:justify-between fg:p-3 fg:rounded-md"
50
+ style={{
51
+ backgroundColor: `${theme.colors.primary}${alpha(0.05)}`,
52
+ borderRadius: `${theme.cornerRadius}px`,
53
+ }}
54
+ >
55
+ <div>
56
+ <span className="fg:text-sm fg:font-medium" style={{ color: theme.colors.text }}>
57
+ {group.rates[0].carrier} · {group.rates[0].service}
58
+ </span>
59
+ {group.rates[0].estimatedDays != null && (
60
+ <span className="fg:text-xs fg:ml-2" style={{ color: `${theme.colors.text}${alpha(0.6)}` }}>
61
+ {tr("forge.checkout.rate_days", { days: group.rates[0].estimatedDays })}
62
+ </span>
63
+ )}
64
+ </div>
65
+ <span className="fg:text-sm fg:font-semibold" style={{ color: theme.colors.primary }}>
66
+ {formatCurrency(group.rates[0].amount, group.rates[0].currency)}
67
+ </span>
68
+ </div>
69
+ ) : (
70
+ // Dynamic: the buyer picks a carrier option.
71
+ <div className="fg:space-y-2">
72
+ {group.rates.length === 0 && (
73
+ <p className="fg:text-sm" style={{ color: `${theme.colors.text}${alpha(0.6)}` }}>
74
+ {tr("forge.checkout.no_shipping_options")}
75
+ </p>
76
+ )}
77
+ {group.rates.map((rate) => {
78
+ const isSelected = selections[group.id]?.rateId === rate.rateId;
79
+ return (
80
+ <label
81
+ key={rate.rateId}
82
+ className="fg:flex fg:items-center fg:justify-between fg:p-3 fg:rounded-md fg:cursor-pointer fg:transition-colors"
83
+ style={{
84
+ backgroundColor: isSelected ? `${theme.colors.primary}${alpha(0.1)}` : "transparent",
85
+ border: `1px solid ${isSelected ? theme.colors.primary : `${theme.colors.primary}${alpha(0.2)}`}`,
86
+ borderRadius: `${theme.cornerRadius}px`,
87
+ }}
88
+ >
89
+ <div className="fg:flex fg:items-center fg:gap-3">
90
+ <input
91
+ type="radio"
92
+ name={`shipping-rate-${group.id}`}
93
+ checked={isSelected}
94
+ onChange={() => onSelect(group, rate)}
95
+ style={{ accentColor: theme.colors.primary }}
96
+ />
97
+ <div>
98
+ <span className="fg:text-sm fg:font-medium" style={{ color: theme.colors.text }}>
99
+ {rate.carrier} - {rate.service}
100
+ </span>
101
+ {rate.estimatedDays && (
102
+ <p className="fg:text-xs" style={{ color: `${theme.colors.text}${alpha(0.6)}` }}>
103
+ {rate.estimatedDays === 1
104
+ ? tr("forge.checkout.rate_estimated_day", { days: rate.estimatedDays })
105
+ : tr("forge.checkout.rate_estimated_days", { days: rate.estimatedDays })}
106
+ </p>
107
+ )}
108
+ </div>
109
+ </div>
110
+ <span className="fg:text-sm fg:font-semibold" style={{ color: theme.colors.primary }}>
111
+ {formatCurrency(rate.amount, rate.currency)}
112
+ </span>
113
+ </label>
114
+ );
115
+ })}
116
+ </div>
117
+ )}
118
+ </div>
119
+ ))}
120
+ </div>
121
+ );
122
+ }