@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.
- 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 +127 -0
- 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 +35 -1
- package/src/i18n/en.json +35 -1
- package/src/index.ts +1 -0
- package/src/provider/ForgeProvider.tsx +12 -2
- package/src/runtime/pages.ts +1 -0
- 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 +116 -0
- package/src/ui/headless/forms/useEmailListForm.ts +45 -3
- package/src/ui/headless/forms/useSectionedForm.ts +26 -6
- package/src/ui/headless/index.ts +1 -0
- package/src/ui/index.ts +4 -2
- package/src/ui/styled/AiAgentWidget.tsx +202 -34
- package/src/ui/styled/Checkout.tsx +1 -113
- package/src/ui/styled/CheckoutLinkPayment.tsx +606 -0
- 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 +316 -0
|
@@ -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({
|
|
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}
|