@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.
- package/package.json +1 -1
- package/src/data/queries/_tests/formConsent.spec.ts +57 -0
- package/src/data/queries/_tests/websiteAgentChoice.spec.ts +155 -0
- package/src/data/queries/_tests/websiteAgentEmbed.spec.ts +52 -0
- package/src/data/queries/_tests/websiteAgentTranscript.spec.ts +119 -0
- package/src/data/queries/useCheckoutLink.ts +59 -2
- package/src/data/queries/useForms.ts +73 -4
- package/src/data/queries/useLeadMagnet.ts +9 -2
- package/src/data/queries/useWebsiteAgent.ts +205 -10
- package/src/i18n/de.json +10 -1
- package/src/i18n/en.json +10 -1
- package/src/provider/ForgeProvider.tsx +12 -2
- package/src/types/models.ts +24 -0
- package/src/ui/headless/agent/useAiAgent.ts +171 -20
- package/src/ui/headless/agent/visitorId.ts +27 -0
- package/src/ui/headless/checkoutLink/useCheckoutLinkPayment.ts +39 -1
- package/src/ui/headless/forms/useEmailListForm.ts +45 -3
- package/src/ui/headless/forms/useSectionedForm.ts +26 -6
- package/src/ui/index.ts +2 -2
- package/src/ui/styled/AiAgentWidget.tsx +202 -34
- package/src/ui/styled/Checkout.tsx +1 -113
- package/src/ui/styled/CheckoutLinkPayment.tsx +312 -9
- package/src/ui/styled/EmailListForm.tsx +28 -2
- package/src/ui/styled/FormRenderer.tsx +55 -3
- package/src/ui/styled/ShippingRateSelector.tsx +122 -0
- package/src/ui/styled/_tests/CheckoutLinkPayment.spec.tsx +85 -0
|
@@ -1,11 +1,14 @@
|
|
|
1
|
-
import type
|
|
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
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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(
|
|
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({
|
|
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
|
-
{
|
|
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 {
|
|
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
|
+
}
|