@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,12 @@
|
|
|
1
|
-
import { lazy, Suspense, useCallback, useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
|
1
|
+
import { Fragment, lazy, Suspense, useCallback, useEffect, useId, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
|
2
2
|
import { Phone, PhoneOff } from "lucide-react";
|
|
3
3
|
import { useForgeT } from "../../i18n";
|
|
4
4
|
import { useForgeTheme } from "../theme/ForgeThemeProvider";
|
|
5
5
|
import { readableTextOn } from "../theme/contrast";
|
|
6
|
-
import { useAiAgent } from "../headless/agent/useAiAgent";
|
|
6
|
+
import { useAiAgent, type AiAgentEvent } from "../headless/agent/useAiAgent";
|
|
7
7
|
import { useAgentCall, type AgentCallErrorKind } from "../headless/agent/useAgentCall";
|
|
8
8
|
import type { AgentCallRoomStatus } from "../headless/agent/useAgentCallRoom";
|
|
9
|
+
import { textWithoutChoice, type AgentChoiceEvent } from "../../data/queries/useWebsiteAgent";
|
|
9
10
|
import { Markdown } from "./Markdown";
|
|
10
11
|
|
|
11
12
|
// The live call pulls in the media SDK and mediasoup, which no visitor needs
|
|
@@ -33,8 +34,19 @@ export interface AiAgentWidgetProps {
|
|
|
33
34
|
title?: string;
|
|
34
35
|
/** Stacking order for the floating elements. Default 9999. */
|
|
35
36
|
zIndex?: number;
|
|
37
|
+
/** Open the chat panel on mount (the embed loader hands over an open chat). */
|
|
38
|
+
defaultOpen?: boolean;
|
|
39
|
+
/**
|
|
40
|
+
* What happened in the chat, for analytics: opened, closed, started, a
|
|
41
|
+
* message sent, a reply, links in a reply, an error. Carries no text and no
|
|
42
|
+
* personal data. The embed loader turns these into DOM events.
|
|
43
|
+
*/
|
|
44
|
+
onEvent?: (event: AiAgentWidgetEvent) => void;
|
|
36
45
|
}
|
|
37
46
|
|
|
47
|
+
/** `AiAgentEvent`s plus the panel opening and closing. */
|
|
48
|
+
export type AiAgentWidgetEvent = AiAgentEvent | { type: "chat_opened" } | { type: "chat_closed" };
|
|
49
|
+
|
|
38
50
|
/**
|
|
39
51
|
* Drop-in website AI agent. A self-contained floating chat widget: fetches its
|
|
40
52
|
* own config for the site's profile, renders a fixed-position bubble + panel,
|
|
@@ -48,18 +60,44 @@ export interface AiAgentWidgetProps {
|
|
|
48
60
|
* us"): a voice call with the same agent, in the browser. `useAgentCall()` is
|
|
49
61
|
* its headless half.
|
|
50
62
|
*/
|
|
51
|
-
export function AiAgentWidget({
|
|
63
|
+
export function AiAgentWidget({
|
|
64
|
+
position = "bottom-right",
|
|
65
|
+
accentColor,
|
|
66
|
+
title,
|
|
67
|
+
zIndex = 9999,
|
|
68
|
+
defaultOpen = false,
|
|
69
|
+
onEvent,
|
|
70
|
+
}: AiAgentWidgetProps) {
|
|
52
71
|
const t = useForgeT();
|
|
53
72
|
const theme = useForgeTheme();
|
|
54
|
-
const
|
|
73
|
+
const onEventRef = useRef(onEvent);
|
|
74
|
+
onEventRef.current = onEvent;
|
|
75
|
+
const emit = useCallback((event: AiAgentWidgetEvent) => onEventRef.current?.(event), []);
|
|
76
|
+
const { isEnabled, canCall, config, messages, restoring, sending, error, send, choose, visitorId } = useAiAgent({
|
|
77
|
+
onEvent: emit,
|
|
78
|
+
});
|
|
55
79
|
const call = useAgentCall({ visitorId });
|
|
56
|
-
const [view, setView] = useState<"chat" | "call" | null>(null);
|
|
80
|
+
const [view, setView] = useState<"chat" | "call" | null>(defaultOpen ? "chat" : null);
|
|
57
81
|
const open = view === "chat";
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
82
|
+
const panelId = useId();
|
|
83
|
+
const launcherRef = useRef<HTMLButtonElement>(null);
|
|
84
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
85
|
+
const setOpen = (next: boolean | ((o: boolean) => boolean)) => {
|
|
86
|
+
const value = typeof next === "function" ? next(open) : next;
|
|
87
|
+
if (value === open) return;
|
|
88
|
+
setView(value ? "chat" : null);
|
|
89
|
+
emit({ type: value ? "chat_opened" : "chat_closed" });
|
|
90
|
+
// Closing hands focus back to the button that opened it.
|
|
91
|
+
if (!value) launcherRef.current?.focus();
|
|
92
|
+
};
|
|
93
|
+
// An open panel takes the focus, so a keyboard user can type at once.
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
if (open) inputRef.current?.focus();
|
|
96
|
+
}, [open]);
|
|
97
|
+
// A chat handed over open (the embed) counts as opened.
|
|
98
|
+
useEffect(() => {
|
|
99
|
+
if (defaultOpen) emit({ type: "chat_opened" });
|
|
100
|
+
}, [defaultOpen, emit]);
|
|
63
101
|
const [roomStatus, setRoomStatus] = useState<AgentCallRoomStatus>("connecting");
|
|
64
102
|
const [input, setInput] = useState("");
|
|
65
103
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
@@ -95,9 +133,10 @@ export function AiAgentWidget({ position = "bottom-right", accentColor, title, z
|
|
|
95
133
|
if (!isEnabled) return null;
|
|
96
134
|
|
|
97
135
|
const agentName = config?.displayName || heading;
|
|
136
|
+
const lastChoiceIndex = messages.reduce((acc, m, i) => (m.choice ? i : acc), -1);
|
|
98
137
|
|
|
99
138
|
const submit = () => {
|
|
100
|
-
if (!input.trim()) return;
|
|
139
|
+
if (!input.trim() || sending) return;
|
|
101
140
|
send(input);
|
|
102
141
|
setInput("");
|
|
103
142
|
};
|
|
@@ -118,6 +157,15 @@ export function AiAgentWidget({ position = "bottom-right", accentColor, title, z
|
|
|
118
157
|
>
|
|
119
158
|
{open && (
|
|
120
159
|
<div
|
|
160
|
+
id={panelId}
|
|
161
|
+
role="dialog"
|
|
162
|
+
aria-label={heading}
|
|
163
|
+
onKeyDown={(e) => {
|
|
164
|
+
if (e.key === "Escape") {
|
|
165
|
+
e.stopPropagation();
|
|
166
|
+
setOpen(false);
|
|
167
|
+
}
|
|
168
|
+
}}
|
|
121
169
|
style={{
|
|
122
170
|
width: "min(380px, calc(100vw - 40px))",
|
|
123
171
|
height: "min(560px, calc(100vh - 120px))",
|
|
@@ -140,42 +188,67 @@ export function AiAgentWidget({ position = "bottom-right", accentColor, title, z
|
|
|
140
188
|
</div>
|
|
141
189
|
|
|
142
190
|
<div ref={scrollRef} style={{ flex: 1, overflowY: "auto", padding: 16, display: "flex", flexDirection: "column", gap: 10 }}>
|
|
143
|
-
{messages.length === 0 && (
|
|
191
|
+
{messages.length === 0 && !restoring && (
|
|
144
192
|
<div style={{ opacity: 0.7, fontSize: 14 }}>
|
|
145
193
|
{config?.greeting || t("forge.ai_agent_widget.default_greeting")}
|
|
146
194
|
</div>
|
|
147
195
|
)}
|
|
148
|
-
{messages.map((m, i) =>
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
196
|
+
{messages.map((m, i) => {
|
|
197
|
+
// The choice's plain text was streamed into the words too; the
|
|
198
|
+
// buttons show it, so it is not repeated. A choice with no words
|
|
199
|
+
// of its own shows its question.
|
|
200
|
+
const text = textWithoutChoice(m.text, m.choice) || (m.choice ? m.choice.question : "");
|
|
201
|
+
return (
|
|
202
|
+
<Fragment key={i}>
|
|
203
|
+
{(text || !m.choice) && (
|
|
204
|
+
<div
|
|
205
|
+
style={{
|
|
206
|
+
alignSelf: m.role === "user" ? "flex-end" : "flex-start",
|
|
207
|
+
maxWidth: "85%",
|
|
208
|
+
padding: "8px 12px",
|
|
209
|
+
borderRadius: 14,
|
|
210
|
+
fontSize: 14,
|
|
211
|
+
whiteSpace: m.role === "user" ? "pre-wrap" : "normal",
|
|
212
|
+
background: m.role === "user" ? accent : `${theme.colors.text}12`,
|
|
213
|
+
color: m.role === "user" ? onAccent : theme.colors.text,
|
|
214
|
+
}}
|
|
215
|
+
>
|
|
216
|
+
{m.role === "assistant" && text ? (
|
|
217
|
+
<Markdown>{text}</Markdown>
|
|
218
|
+
) : (
|
|
219
|
+
text || (sending && i === messages.length - 1 ? "…" : "")
|
|
220
|
+
)}
|
|
221
|
+
</div>
|
|
222
|
+
)}
|
|
223
|
+
{m.choice && (
|
|
224
|
+
<ChoiceOptions
|
|
225
|
+
choice={m.choice}
|
|
226
|
+
picked={m.picked}
|
|
227
|
+
// Only the newest choice can be answered, and only once; a restored one the chat moved past is closed.
|
|
228
|
+
disabled={sending || m.closed || m.picked !== undefined || i !== lastChoiceIndex}
|
|
229
|
+
accent={accent}
|
|
230
|
+
onAccent={onAccent}
|
|
231
|
+
onPick={(value) => void choose(m.choice!.stepId, value)}
|
|
232
|
+
/>
|
|
233
|
+
)}
|
|
234
|
+
</Fragment>
|
|
235
|
+
);
|
|
236
|
+
})}
|
|
169
237
|
{error && <div style={{ color: "#c0392b", fontSize: 13 }}>{error}</div>}
|
|
170
238
|
</div>
|
|
171
239
|
|
|
172
240
|
<div style={{ display: "flex", gap: 8, padding: 12, borderTop: `1px solid ${theme.colors.text}1a` }}>
|
|
173
241
|
<input
|
|
242
|
+
ref={inputRef}
|
|
243
|
+
aria-label={t("forge.ai_agent_widget.aria_message")}
|
|
174
244
|
value={input}
|
|
175
245
|
onChange={(e) => setInput(e.target.value)}
|
|
176
246
|
onKeyDown={(e) => e.key === "Enter" && submit()}
|
|
177
247
|
placeholder={t("forge.ai_agent_widget.input_placeholder")}
|
|
178
|
-
disabled
|
|
248
|
+
// Not disabled while a reply streams: a disabled input drops the
|
|
249
|
+
// focus to the page, and with it Escape and the next message.
|
|
250
|
+
// `submit` waits for the reply instead.
|
|
251
|
+
aria-busy={sending}
|
|
179
252
|
style={{
|
|
180
253
|
flex: 1,
|
|
181
254
|
padding: "8px 12px",
|
|
@@ -249,8 +322,13 @@ export function AiAgentWidget({ position = "bottom-right", accentColor, title, z
|
|
|
249
322
|
</button>
|
|
250
323
|
)}
|
|
251
324
|
<button
|
|
325
|
+
ref={launcherRef}
|
|
252
326
|
onClick={() => setOpen((o) => !o)}
|
|
253
327
|
aria-label={t("forge.ai_agent_widget.aria_open")}
|
|
328
|
+
aria-expanded={open}
|
|
329
|
+
aria-controls={open ? panelId : undefined}
|
|
330
|
+
aria-haspopup="dialog"
|
|
331
|
+
data-testid="forge-agent-launcher"
|
|
254
332
|
style={{
|
|
255
333
|
width: 56,
|
|
256
334
|
height: 56,
|
|
@@ -422,3 +500,93 @@ function CallStatus({ text, color, alert }: { text: string; color?: string; aler
|
|
|
422
500
|
</div>
|
|
423
501
|
);
|
|
424
502
|
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* A playbook choice as tappable buttons, or as cards when the options carry a
|
|
506
|
+
* length, a price, a picture or a description (live items such as services).
|
|
507
|
+
* A tap answers the choice; the picked option stays marked and the rest dim.
|
|
508
|
+
*/
|
|
509
|
+
function ChoiceOptions({
|
|
510
|
+
choice,
|
|
511
|
+
picked,
|
|
512
|
+
disabled,
|
|
513
|
+
accent,
|
|
514
|
+
onAccent,
|
|
515
|
+
onPick,
|
|
516
|
+
}: {
|
|
517
|
+
choice: AgentChoiceEvent;
|
|
518
|
+
picked?: string;
|
|
519
|
+
disabled: boolean;
|
|
520
|
+
accent: string;
|
|
521
|
+
onAccent: string;
|
|
522
|
+
onPick: (value: string) => void;
|
|
523
|
+
}) {
|
|
524
|
+
const t = useForgeT();
|
|
525
|
+
const theme = useForgeTheme();
|
|
526
|
+
const asCards = choice.options.some((o) => o.priceText || o.durationText || o.imageUrl || o.description);
|
|
527
|
+
|
|
528
|
+
return (
|
|
529
|
+
<div
|
|
530
|
+
role="group"
|
|
531
|
+
aria-label={choice.question || t("forge.ai_agent_widget.choice_label")}
|
|
532
|
+
data-testid="forge-agent-choice"
|
|
533
|
+
style={{
|
|
534
|
+
alignSelf: "flex-start",
|
|
535
|
+
maxWidth: "92%",
|
|
536
|
+
display: "flex",
|
|
537
|
+
flexDirection: asCards ? "column" : "row",
|
|
538
|
+
flexWrap: asCards ? "nowrap" : "wrap",
|
|
539
|
+
gap: 6,
|
|
540
|
+
}}
|
|
541
|
+
>
|
|
542
|
+
{choice.options.map((o) => {
|
|
543
|
+
const isPicked = picked === o.value;
|
|
544
|
+
const base: CSSProperties = {
|
|
545
|
+
textAlign: "left",
|
|
546
|
+
fontSize: 14,
|
|
547
|
+
fontFamily: "inherit",
|
|
548
|
+
cursor: disabled ? "default" : "pointer",
|
|
549
|
+
borderRadius: theme.cornerRadius,
|
|
550
|
+
border: `1px solid ${isPicked ? accent : `${theme.colors.text}33`}`,
|
|
551
|
+
background: isPicked ? accent : theme.colors.background,
|
|
552
|
+
color: isPicked ? onAccent : theme.colors.text,
|
|
553
|
+
opacity: disabled && !isPicked ? 0.55 : 1,
|
|
554
|
+
transition: "border-color 120ms ease, opacity 120ms ease",
|
|
555
|
+
};
|
|
556
|
+
const details = [o.durationText, o.priceText].filter(Boolean).join(" · ");
|
|
557
|
+
return (
|
|
558
|
+
<button
|
|
559
|
+
key={o.value}
|
|
560
|
+
type="button"
|
|
561
|
+
disabled={disabled}
|
|
562
|
+
aria-pressed={isPicked}
|
|
563
|
+
onClick={() => onPick(o.value)}
|
|
564
|
+
data-testid="forge-agent-choice-option"
|
|
565
|
+
style={asCards ? { ...base, display: "flex", gap: 10, alignItems: "center", padding: 8 } : { ...base, padding: "6px 14px" }}
|
|
566
|
+
>
|
|
567
|
+
{asCards && o.imageUrl && (
|
|
568
|
+
<img
|
|
569
|
+
src={o.imageUrl}
|
|
570
|
+
alt=""
|
|
571
|
+
style={{ width: 48, height: 48, objectFit: "cover", borderRadius: Math.min(8, theme.cornerRadius), flexShrink: 0 }}
|
|
572
|
+
/>
|
|
573
|
+
)}
|
|
574
|
+
{asCards ? (
|
|
575
|
+
<span style={{ display: "flex", flexDirection: "column", minWidth: 0 }}>
|
|
576
|
+
<span style={{ fontWeight: 600 }}>{o.label}</span>
|
|
577
|
+
{details && <span style={{ fontSize: 12, opacity: 0.75 }}>{details}</span>}
|
|
578
|
+
{o.description && (
|
|
579
|
+
<span style={{ fontSize: 12, opacity: 0.75, overflow: "hidden", textOverflow: "ellipsis", display: "-webkit-box", WebkitLineClamp: 2, WebkitBoxOrient: "vertical" }}>
|
|
580
|
+
{o.description}
|
|
581
|
+
</span>
|
|
582
|
+
)}
|
|
583
|
+
</span>
|
|
584
|
+
) : (
|
|
585
|
+
o.label
|
|
586
|
+
)}
|
|
587
|
+
</button>
|
|
588
|
+
);
|
|
589
|
+
})}
|
|
590
|
+
</div>
|
|
591
|
+
);
|
|
592
|
+
}
|
|
@@ -2,8 +2,6 @@ import React, { useEffect, useState } from "react";
|
|
|
2
2
|
import { useForm, Controller } from "react-hook-form";
|
|
3
3
|
import { CreditCard, MapPin, Package, User } from "lucide-react";
|
|
4
4
|
import { useCheckout, CHECKOUT_STAGE_ORDER, type CheckoutStage } from "../headless/checkout/useCheckout";
|
|
5
|
-
import type { SelectedShippingRate } from "../headless/checkout/useCheckout";
|
|
6
|
-
import type { ShippingRate, ShippingGroup } from "../../data/queries/useShipping";
|
|
7
5
|
import type { CountryCode } from "libphonenumber-js";
|
|
8
6
|
import { usePhoneInput } from "../headless/forms/usePhoneInput";
|
|
9
7
|
import { PhoneInput } from "./PhoneInput";
|
|
@@ -18,6 +16,7 @@ import { Loading } from "./Loading";
|
|
|
18
16
|
import { CartLineOptions } from "./CartLineOptions";
|
|
19
17
|
import { HoldNotice } from "./HoldNotice";
|
|
20
18
|
import { PaystackPayButton } from "./PaystackPayButton";
|
|
19
|
+
import { ShippingRateSelector } from "./ShippingRateSelector";
|
|
21
20
|
import { useMetaInitiateCheckout } from "../analytics/useMetaEvents";
|
|
22
21
|
|
|
23
22
|
export interface CheckoutProps {
|
|
@@ -1161,114 +1160,3 @@ function PaymentSection({ checkout, onBack }: { checkout: ReturnType<typeof useC
|
|
|
1161
1160
|
</div>
|
|
1162
1161
|
);
|
|
1163
1162
|
}
|
|
1164
|
-
|
|
1165
|
-
// ── Shipping rate selector ──────────────────────────────────────────────────────
|
|
1166
|
-
/**
|
|
1167
|
-
* Renders the shipping groups: fixed lines for auto-selected flat/external
|
|
1168
|
-
* groups, and a radio list for the selectable (dynamic/carrier) group. Selection
|
|
1169
|
-
* state is owned by `useCheckout`; this component just renders + calls `onSelect`.
|
|
1170
|
-
*/
|
|
1171
|
-
function ShippingRateSelector({
|
|
1172
|
-
groups,
|
|
1173
|
-
selections,
|
|
1174
|
-
onSelect,
|
|
1175
|
-
formatCurrency,
|
|
1176
|
-
}: {
|
|
1177
|
-
groups: ShippingGroup[];
|
|
1178
|
-
selections: Record<string, SelectedShippingRate>;
|
|
1179
|
-
onSelect: (group: ShippingGroup, rate: ShippingRate) => void;
|
|
1180
|
-
formatCurrency: (amount: number, currencyCode?: string) => string;
|
|
1181
|
-
}) {
|
|
1182
|
-
const theme = useForgeTheme();
|
|
1183
|
-
const tr = useForgeT();
|
|
1184
|
-
if (groups.length === 0) return null;
|
|
1185
|
-
|
|
1186
|
-
return (
|
|
1187
|
-
<div className="fg:space-y-6">
|
|
1188
|
-
{groups.map((group) => (
|
|
1189
|
-
<div
|
|
1190
|
-
key={group.id}
|
|
1191
|
-
className="fg:p-4 fg:rounded-lg"
|
|
1192
|
-
style={{ border: `1px solid ${theme.colors.primary}${alpha(0.15)}`, borderRadius: `${theme.cornerRadius}px` }}
|
|
1193
|
-
>
|
|
1194
|
-
<p className="fg:font-medium fg:mb-3" style={{ color: theme.colors.text }}>
|
|
1195
|
-
{group.title}
|
|
1196
|
-
</p>
|
|
1197
|
-
|
|
1198
|
-
{!group.selectable && group.rates.length > 0 ? (
|
|
1199
|
-
// Flat / external — a single, auto-selected rate.
|
|
1200
|
-
<div
|
|
1201
|
-
className="fg:flex fg:items-center fg:justify-between fg:p-3 fg:rounded-md"
|
|
1202
|
-
style={{
|
|
1203
|
-
backgroundColor: `${theme.colors.primary}${alpha(0.05)}`,
|
|
1204
|
-
borderRadius: `${theme.cornerRadius}px`,
|
|
1205
|
-
}}
|
|
1206
|
-
>
|
|
1207
|
-
<div>
|
|
1208
|
-
<span className="fg:text-sm fg:font-medium" style={{ color: theme.colors.text }}>
|
|
1209
|
-
{group.rates[0].carrier} · {group.rates[0].service}
|
|
1210
|
-
</span>
|
|
1211
|
-
{group.rates[0].estimatedDays != null && (
|
|
1212
|
-
<span className="fg:text-xs fg:ml-2" style={{ color: `${theme.colors.text}${alpha(0.6)}` }}>
|
|
1213
|
-
{tr("forge.checkout.rate_days", { days: group.rates[0].estimatedDays })}
|
|
1214
|
-
</span>
|
|
1215
|
-
)}
|
|
1216
|
-
</div>
|
|
1217
|
-
<span className="fg:text-sm fg:font-semibold" style={{ color: theme.colors.primary }}>
|
|
1218
|
-
{formatCurrency(group.rates[0].amount, group.rates[0].currency)}
|
|
1219
|
-
</span>
|
|
1220
|
-
</div>
|
|
1221
|
-
) : (
|
|
1222
|
-
// Dynamic — buyer picks a carrier option.
|
|
1223
|
-
<div className="fg:space-y-2">
|
|
1224
|
-
{group.rates.length === 0 && (
|
|
1225
|
-
<p className="fg:text-sm" style={{ color: `${theme.colors.text}${alpha(0.6)}` }}>
|
|
1226
|
-
{tr("forge.checkout.no_shipping_options")}
|
|
1227
|
-
</p>
|
|
1228
|
-
)}
|
|
1229
|
-
{group.rates.map((rate) => {
|
|
1230
|
-
const isSelected = selections[group.id]?.rateId === rate.rateId;
|
|
1231
|
-
return (
|
|
1232
|
-
<label
|
|
1233
|
-
key={rate.rateId}
|
|
1234
|
-
className="fg:flex fg:items-center fg:justify-between fg:p-3 fg:rounded-md fg:cursor-pointer fg:transition-colors"
|
|
1235
|
-
style={{
|
|
1236
|
-
backgroundColor: isSelected ? `${theme.colors.primary}${alpha(0.1)}` : "transparent",
|
|
1237
|
-
border: `1px solid ${isSelected ? theme.colors.primary : `${theme.colors.primary}${alpha(0.2)}`}`,
|
|
1238
|
-
borderRadius: `${theme.cornerRadius}px`,
|
|
1239
|
-
}}
|
|
1240
|
-
>
|
|
1241
|
-
<div className="fg:flex fg:items-center fg:gap-3">
|
|
1242
|
-
<input
|
|
1243
|
-
type="radio"
|
|
1244
|
-
name={`shipping-rate-${group.id}`}
|
|
1245
|
-
checked={isSelected}
|
|
1246
|
-
onChange={() => onSelect(group, rate)}
|
|
1247
|
-
style={{ accentColor: theme.colors.primary }}
|
|
1248
|
-
/>
|
|
1249
|
-
<div>
|
|
1250
|
-
<span className="fg:text-sm fg:font-medium" style={{ color: theme.colors.text }}>
|
|
1251
|
-
{rate.carrier} - {rate.service}
|
|
1252
|
-
</span>
|
|
1253
|
-
{rate.estimatedDays && (
|
|
1254
|
-
<p className="fg:text-xs" style={{ color: `${theme.colors.text}${alpha(0.6)}` }}>
|
|
1255
|
-
{rate.estimatedDays === 1
|
|
1256
|
-
? tr("forge.checkout.rate_estimated_day", { days: rate.estimatedDays })
|
|
1257
|
-
: tr("forge.checkout.rate_estimated_days", { days: rate.estimatedDays })}
|
|
1258
|
-
</p>
|
|
1259
|
-
)}
|
|
1260
|
-
</div>
|
|
1261
|
-
</div>
|
|
1262
|
-
<span className="fg:text-sm fg:font-semibold" style={{ color: theme.colors.primary }}>
|
|
1263
|
-
{formatCurrency(rate.amount, rate.currency)}
|
|
1264
|
-
</span>
|
|
1265
|
-
</label>
|
|
1266
|
-
);
|
|
1267
|
-
})}
|
|
1268
|
-
</div>
|
|
1269
|
-
)}
|
|
1270
|
-
</div>
|
|
1271
|
-
))}
|
|
1272
|
-
</div>
|
|
1273
|
-
);
|
|
1274
|
-
}
|