@tribe-nest/forge 3.64.0 → 3.67.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/useCoachingAvailability.ts +18 -6
- package/src/data/queries/useMyBookings.ts +7 -0
- package/src/data/queries/useWebsite.ts +42 -11
- package/src/data/queries/useWebsiteAgent.ts +136 -6
- package/src/i18n/de.json +63 -0
- package/src/i18n/en.json +63 -0
- package/src/provider/ForgeProvider.tsx +7 -1
- package/src/types/models.ts +13 -0
- package/src/ui/format/_tests/ticketPrice.spec.ts +58 -0
- package/src/ui/format/bookingPricing.ts +55 -0
- package/src/ui/format/ticketPrice.ts +52 -0
- package/src/ui/headless/agent/_tests/callChallenge.spec.ts +85 -0
- package/src/ui/headless/agent/_tests/useAgentCall.spec.tsx +213 -0
- package/src/ui/headless/agent/callChallenge.ts +72 -0
- package/src/ui/headless/agent/useAgentCall.ts +189 -0
- package/src/ui/headless/agent/useAgentCallRoom.ts +113 -0
- package/src/ui/headless/agent/useAiAgent.ts +8 -20
- package/src/ui/headless/agent/visitorId.ts +20 -0
- package/src/ui/headless/coaching/useCoachingBooking.ts +139 -13
- package/src/ui/index.ts +24 -0
- package/src/ui/styled/AccountDashboard.tsx +11 -3
- package/src/ui/styled/AiAgentCallRoom.tsx +76 -0
- package/src/ui/styled/AiAgentWidget.tsx +276 -21
- package/src/ui/styled/BookingPriceSummary.tsx +57 -0
- package/src/ui/styled/CoachingBooking.tsx +36 -158
- package/src/ui/styled/CoachingConfirmation.tsx +13 -2
- package/src/ui/styled/CoachingDetail.tsx +8 -2
- package/src/ui/styled/CoachingSlotPicker.tsx +874 -0
- package/src/ui/styled/CookieConsent.tsx +13 -9
- package/src/ui/styled/EventDetail.tsx +29 -6
- package/src/ui/styled/EventTickets.tsx +35 -11
- package/src/ui/styled/MusicLinkPage.tsx +6 -3
- package/src/ui/styled/_tests/CoachingMultiSession.spec.tsx +299 -0
- package/src/ui/styled/_tests/FreeTickets.spec.tsx +242 -0
- package/src/ui/styled/broadcast/BroadcastTicketPurchase.tsx +18 -4
- package/src/ui/styled/forge-utilities.css +0 -5
- package/src/ui/styled/seating/EventSeatPicker.tsx +2 -0
- package/src/ui/styled/seating/SeatLegend.tsx +3 -1
- package/src/utils/structuredData.ts +4 -1
|
@@ -1,10 +1,29 @@
|
|
|
1
|
-
import { useEffect, useRef, useState } from "react";
|
|
1
|
+
import { lazy, Suspense, useCallback, useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
|
2
|
+
import { Phone, PhoneOff } from "lucide-react";
|
|
2
3
|
import { useForgeT } from "../../i18n";
|
|
3
4
|
import { useForgeTheme } from "../theme/ForgeThemeProvider";
|
|
4
5
|
import { readableTextOn } from "../theme/contrast";
|
|
5
6
|
import { useAiAgent } from "../headless/agent/useAiAgent";
|
|
7
|
+
import { useAgentCall, type AgentCallErrorKind } from "../headless/agent/useAgentCall";
|
|
8
|
+
import type { AgentCallRoomStatus } from "../headless/agent/useAgentCallRoom";
|
|
6
9
|
import { Markdown } from "./Markdown";
|
|
7
10
|
|
|
11
|
+
// The live call pulls in the media SDK and mediasoup, which no visitor needs
|
|
12
|
+
// until they press Call. Split out so it stays out of every page's bundle.
|
|
13
|
+
const loadCallRoom = () => import("./AiAgentCallRoom");
|
|
14
|
+
const AiAgentCallRoom = lazy(loadCallRoom);
|
|
15
|
+
|
|
16
|
+
/** The widget already draws errors in this red; hang up shares it. */
|
|
17
|
+
const DANGER = "#c0392b";
|
|
18
|
+
|
|
19
|
+
const CALL_ERROR_KEYS: Record<AgentCallErrorKind, string> = {
|
|
20
|
+
too_many: "forge.ai_agent_widget.call_error_too_many",
|
|
21
|
+
unavailable: "forge.ai_agent_widget.call_error_unavailable",
|
|
22
|
+
busy: "forge.ai_agent_widget.call_error_busy",
|
|
23
|
+
check_failed: "forge.ai_agent_widget.call_error_check_failed",
|
|
24
|
+
failed: "forge.ai_agent_widget.call_error_failed",
|
|
25
|
+
};
|
|
26
|
+
|
|
8
27
|
export interface AiAgentWidgetProps {
|
|
9
28
|
/** Corner to anchor the floating bubble. Default "bottom-right". */
|
|
10
29
|
position?: "bottom-right" | "bottom-left";
|
|
@@ -24,12 +43,24 @@ export interface AiAgentWidgetProps {
|
|
|
24
43
|
* the profile, or when the visitor is on a page the artist excluded.
|
|
25
44
|
* Appearance-only props let authors restyle it without touching behavior. For
|
|
26
45
|
* custom UI, use the `useAiAgent()` hook instead.
|
|
46
|
+
*
|
|
47
|
+
* When the agent takes calls, a phone button sits beside the bubble ("Call
|
|
48
|
+
* us"): a voice call with the same agent, in the browser. `useAgentCall()` is
|
|
49
|
+
* its headless half.
|
|
27
50
|
*/
|
|
28
51
|
export function AiAgentWidget({ position = "bottom-right", accentColor, title, zIndex = 9999 }: AiAgentWidgetProps) {
|
|
29
52
|
const t = useForgeT();
|
|
30
53
|
const theme = useForgeTheme();
|
|
31
|
-
const { isEnabled, config, messages, sending, error, send } = useAiAgent();
|
|
32
|
-
const
|
|
54
|
+
const { isEnabled, canCall, config, messages, sending, error, send, visitorId } = useAiAgent();
|
|
55
|
+
const call = useAgentCall({ visitorId });
|
|
56
|
+
const [view, setView] = useState<"chat" | "call" | null>(null);
|
|
57
|
+
const open = view === "chat";
|
|
58
|
+
const setOpen = (next: boolean | ((o: boolean) => boolean)) =>
|
|
59
|
+
setView((current) => {
|
|
60
|
+
const value = typeof next === "function" ? next(current === "chat") : next;
|
|
61
|
+
return value ? "chat" : null;
|
|
62
|
+
});
|
|
63
|
+
const [roomStatus, setRoomStatus] = useState<AgentCallRoomStatus>("connecting");
|
|
33
64
|
const [input, setInput] = useState("");
|
|
34
65
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
35
66
|
|
|
@@ -42,8 +73,29 @@ export function AiAgentWidget({ position = "bottom-right", accentColor, title, z
|
|
|
42
73
|
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" });
|
|
43
74
|
}, [messages, sending]);
|
|
44
75
|
|
|
76
|
+
const callPhase = call.state.phase;
|
|
77
|
+
const callActive = callPhase === "connecting" || callPhase === "live";
|
|
78
|
+
|
|
79
|
+
// On a page where the widget hides (an excluded path) the call panel goes
|
|
80
|
+
// with it, and the call would run on with no way to hang up. Hang up instead.
|
|
81
|
+
const { end: endCall } = call;
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
if (!isEnabled || !canCall) endCall();
|
|
84
|
+
}, [isEnabled, canCall, endCall]);
|
|
85
|
+
|
|
86
|
+
const startCall = () => {
|
|
87
|
+
void loadCallRoom();
|
|
88
|
+
setView("call");
|
|
89
|
+
if (callActive) return;
|
|
90
|
+
setRoomStatus("connecting");
|
|
91
|
+
void call.start();
|
|
92
|
+
};
|
|
93
|
+
const onRoomStatus = useCallback((status: AgentCallRoomStatus) => setRoomStatus(status), []);
|
|
94
|
+
|
|
45
95
|
if (!isEnabled) return null;
|
|
46
96
|
|
|
97
|
+
const agentName = config?.displayName || heading;
|
|
98
|
+
|
|
47
99
|
const submit = () => {
|
|
48
100
|
if (!input.trim()) return;
|
|
49
101
|
send(input);
|
|
@@ -146,24 +198,227 @@ export function AiAgentWidget({ position = "bottom-right", accentColor, title, z
|
|
|
146
198
|
</div>
|
|
147
199
|
)}
|
|
148
200
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
201
|
+
{/* Kept mounted, only hidden, while a call is on: unmounting it would
|
|
202
|
+
leave the media room with the call still running. */}
|
|
203
|
+
{(view === "call" || callActive) && (
|
|
204
|
+
<CallPanel
|
|
205
|
+
hidden={view !== "call"}
|
|
206
|
+
heading={heading}
|
|
207
|
+
agentName={agentName}
|
|
208
|
+
accent={accent}
|
|
209
|
+
onAccent={onAccent}
|
|
210
|
+
callState={call.state}
|
|
211
|
+
roomStatus={roomStatus}
|
|
212
|
+
onRoomStatus={onRoomStatus}
|
|
213
|
+
getCredentials={call.getCredentials}
|
|
214
|
+
onHangUp={call.end}
|
|
215
|
+
onRetry={startCall}
|
|
216
|
+
onClose={() => {
|
|
217
|
+
call.reset();
|
|
218
|
+
setView(null);
|
|
219
|
+
}}
|
|
220
|
+
onChat={() => {
|
|
221
|
+
call.reset();
|
|
222
|
+
setView("chat");
|
|
223
|
+
}}
|
|
224
|
+
/>
|
|
225
|
+
)}
|
|
226
|
+
|
|
227
|
+
<div style={{ display: "flex", gap: 12, alignItems: "center", justifyContent: position === "bottom-left" ? "flex-start" : "flex-end" }}>
|
|
228
|
+
{canCall && (
|
|
229
|
+
<button
|
|
230
|
+
onClick={startCall}
|
|
231
|
+
aria-label={callActive ? t("forge.ai_agent_widget.aria_show_call") : t("forge.ai_agent_widget.aria_call")}
|
|
232
|
+
data-testid="forge-agent-call-button"
|
|
233
|
+
style={{
|
|
234
|
+
width: 48,
|
|
235
|
+
height: 48,
|
|
236
|
+
borderRadius: "50%",
|
|
237
|
+
background: callActive ? DANGER : theme.colors.background,
|
|
238
|
+
color: callActive ? readableTextOn(DANGER) : accent,
|
|
239
|
+
border: `2px solid ${callActive ? DANGER : accent}`,
|
|
240
|
+
cursor: "pointer",
|
|
241
|
+
boxShadow: "0 6px 20px rgba(0,0,0,0.18)",
|
|
242
|
+
display: "inline-flex",
|
|
243
|
+
alignItems: "center",
|
|
244
|
+
justifyContent: "center",
|
|
245
|
+
order: position === "bottom-left" ? 2 : 0,
|
|
246
|
+
}}
|
|
247
|
+
>
|
|
248
|
+
<Phone size={20} aria-hidden />
|
|
249
|
+
</button>
|
|
250
|
+
)}
|
|
251
|
+
<button
|
|
252
|
+
onClick={() => setOpen((o) => !o)}
|
|
253
|
+
aria-label={t("forge.ai_agent_widget.aria_open")}
|
|
254
|
+
style={{
|
|
255
|
+
width: 56,
|
|
256
|
+
height: 56,
|
|
257
|
+
borderRadius: "50%",
|
|
258
|
+
background: accent,
|
|
259
|
+
color: onAccent,
|
|
260
|
+
border: "none",
|
|
261
|
+
cursor: "pointer",
|
|
262
|
+
boxShadow: "0 6px 20px rgba(0,0,0,0.25)",
|
|
263
|
+
fontSize: 24,
|
|
264
|
+
order: 1,
|
|
265
|
+
}}
|
|
266
|
+
>
|
|
267
|
+
{open ? "×" : "💬"}
|
|
268
|
+
</button>
|
|
269
|
+
</div>
|
|
270
|
+
</div>
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
interface CallPanelProps {
|
|
275
|
+
hidden: boolean;
|
|
276
|
+
heading: string;
|
|
277
|
+
agentName: string;
|
|
278
|
+
accent: string;
|
|
279
|
+
onAccent: string;
|
|
280
|
+
callState: ReturnType<typeof useAgentCall>["state"];
|
|
281
|
+
roomStatus: AgentCallRoomStatus;
|
|
282
|
+
onRoomStatus: (status: AgentCallRoomStatus) => void;
|
|
283
|
+
getCredentials: () => Promise<{ mediaUrl: string; token: string }>;
|
|
284
|
+
onHangUp: () => void;
|
|
285
|
+
onRetry: () => void;
|
|
286
|
+
onClose: () => void;
|
|
287
|
+
onChat: () => void;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** "Call us": the call's own card, in place of the chat panel. */
|
|
291
|
+
function CallPanel({
|
|
292
|
+
hidden,
|
|
293
|
+
heading,
|
|
294
|
+
agentName,
|
|
295
|
+
accent,
|
|
296
|
+
onAccent,
|
|
297
|
+
callState,
|
|
298
|
+
roomStatus,
|
|
299
|
+
onRoomStatus,
|
|
300
|
+
getCredentials,
|
|
301
|
+
onHangUp,
|
|
302
|
+
onRetry,
|
|
303
|
+
onClose,
|
|
304
|
+
onChat,
|
|
305
|
+
}: CallPanelProps) {
|
|
306
|
+
const t = useForgeT();
|
|
307
|
+
const theme = useForgeTheme();
|
|
308
|
+
const active = callState.phase === "connecting" || callState.phase === "live";
|
|
309
|
+
const speaking = callState.phase === "live" && roomStatus === "speaking";
|
|
310
|
+
|
|
311
|
+
const secondary: CSSProperties = {
|
|
312
|
+
background: "transparent",
|
|
313
|
+
color: theme.colors.text,
|
|
314
|
+
border: `1px solid ${theme.colors.text}33`,
|
|
315
|
+
borderRadius: theme.cornerRadius,
|
|
316
|
+
padding: "8px 14px",
|
|
317
|
+
cursor: "pointer",
|
|
318
|
+
fontSize: 14,
|
|
319
|
+
};
|
|
320
|
+
const primary: CSSProperties = { ...secondary, background: accent, color: onAccent, border: "none", fontWeight: 600 };
|
|
321
|
+
|
|
322
|
+
let body: ReactNode;
|
|
323
|
+
if (callState.phase === "live") {
|
|
324
|
+
body = (
|
|
325
|
+
<Suspense fallback={<CallStatus text={t("forge.ai_agent_widget.call_connecting")} />}>
|
|
326
|
+
<AiAgentCallRoom
|
|
327
|
+
getCredentials={getCredentials}
|
|
328
|
+
selfIdentity={callState.call.identity}
|
|
329
|
+
agentName={agentName}
|
|
330
|
+
onEnded={onHangUp}
|
|
331
|
+
onStatus={onRoomStatus}
|
|
332
|
+
/>
|
|
333
|
+
</Suspense>
|
|
334
|
+
);
|
|
335
|
+
} else if (callState.phase === "ended") {
|
|
336
|
+
body = <CallStatus text={t("forge.ai_agent_widget.call_ended")} />;
|
|
337
|
+
} else if (callState.phase === "error") {
|
|
338
|
+
body = <CallStatus text={t(CALL_ERROR_KEYS[callState.error])} color={DANGER} alert />;
|
|
339
|
+
} else {
|
|
340
|
+
body = <CallStatus text={t("forge.ai_agent_widget.call_connecting")} />;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return (
|
|
344
|
+
<div
|
|
345
|
+
data-testid="forge-agent-call-panel"
|
|
346
|
+
data-phase={callState.phase}
|
|
347
|
+
style={{
|
|
348
|
+
width: "min(380px, calc(100vw - 40px))",
|
|
349
|
+
marginBottom: 12,
|
|
350
|
+
display: hidden ? "none" : "flex",
|
|
351
|
+
flexDirection: "column",
|
|
352
|
+
background: theme.colors.background,
|
|
353
|
+
color: theme.colors.text,
|
|
354
|
+
border: `1px solid ${theme.colors.text}1a`,
|
|
355
|
+
borderRadius: theme.cornerRadius,
|
|
356
|
+
overflow: "hidden",
|
|
357
|
+
boxShadow: "0 12px 40px rgba(0,0,0,0.18)",
|
|
358
|
+
}}
|
|
359
|
+
>
|
|
360
|
+
<div style={{ background: accent, color: onAccent, padding: "12px 16px", fontWeight: 600, display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
|
361
|
+
<span>{heading}</span>
|
|
362
|
+
{!active && (
|
|
363
|
+
<button onClick={onClose} style={{ background: "transparent", border: "none", color: onAccent, cursor: "pointer", fontSize: 20, lineHeight: 1 }} aria-label={t("forge.ai_agent_widget.aria_close")}>
|
|
364
|
+
×
|
|
365
|
+
</button>
|
|
366
|
+
)}
|
|
367
|
+
</div>
|
|
368
|
+
|
|
369
|
+
<div style={{ padding: "24px 16px", display: "flex", flexDirection: "column", alignItems: "center", gap: 12 }}>
|
|
370
|
+
<div
|
|
371
|
+
aria-hidden
|
|
372
|
+
style={{
|
|
373
|
+
width: 64,
|
|
374
|
+
height: 64,
|
|
375
|
+
borderRadius: "50%",
|
|
376
|
+
background: accent,
|
|
377
|
+
color: onAccent,
|
|
378
|
+
display: "flex",
|
|
379
|
+
alignItems: "center",
|
|
380
|
+
justifyContent: "center",
|
|
381
|
+
transform: speaking ? "scale(1.12)" : "scale(1)",
|
|
382
|
+
boxShadow: speaking ? `0 0 0 8px ${accent}33` : "none",
|
|
383
|
+
transition: "transform 150ms ease, box-shadow 150ms ease",
|
|
384
|
+
opacity: active || callState.phase === "ended" ? 1 : 0.6,
|
|
385
|
+
}}
|
|
386
|
+
>
|
|
387
|
+
<Phone size={28} />
|
|
388
|
+
</div>
|
|
389
|
+
<div style={{ fontWeight: 600 }}>{agentName}</div>
|
|
390
|
+
{body}
|
|
391
|
+
</div>
|
|
392
|
+
|
|
393
|
+
<div style={{ display: "flex", gap: 8, padding: 12, justifyContent: "center", borderTop: `1px solid ${theme.colors.text}1a` }}>
|
|
394
|
+
{active ? (
|
|
395
|
+
<button
|
|
396
|
+
onClick={onHangUp}
|
|
397
|
+
data-testid="forge-agent-call-hangup"
|
|
398
|
+
style={{ ...primary, background: DANGER, color: readableTextOn(DANGER), display: "inline-flex", alignItems: "center", gap: 8 }}
|
|
399
|
+
>
|
|
400
|
+
<PhoneOff size={16} aria-hidden />
|
|
401
|
+
{t("forge.ai_agent_widget.call_hang_up")}
|
|
402
|
+
</button>
|
|
403
|
+
) : (
|
|
404
|
+
<>
|
|
405
|
+
<button onClick={onRetry} style={primary} data-testid="forge-agent-call-again">
|
|
406
|
+
{callState.phase === "error" ? t("forge.ai_agent_widget.call_try_again") : t("forge.ai_agent_widget.call_again")}
|
|
407
|
+
</button>
|
|
408
|
+
<button onClick={onChat} style={secondary}>
|
|
409
|
+
{t("forge.ai_agent_widget.call_back_to_chat")}
|
|
410
|
+
</button>
|
|
411
|
+
</>
|
|
412
|
+
)}
|
|
413
|
+
</div>
|
|
414
|
+
</div>
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function CallStatus({ text, color, alert }: { text: string; color?: string; alert?: boolean }) {
|
|
419
|
+
return (
|
|
420
|
+
<div role={alert ? "alert" : "status"} aria-live="polite" style={{ fontSize: 14, textAlign: "center", color }} data-testid="forge-agent-call-status">
|
|
421
|
+
{text}
|
|
167
422
|
</div>
|
|
168
423
|
);
|
|
169
424
|
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { CSSProperties } from "react";
|
|
2
|
+
import type { CoachingProduct } from "../../types/models";
|
|
3
|
+
import { useForgeT } from "../../i18n";
|
|
4
|
+
import { bookingPricingMode, packageSaving } from "../format/bookingPricing";
|
|
5
|
+
import { PriceDisplay } from "../format/PriceDisplay";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A booking product's price as a buyer reads it: "$80", "$350 for 5
|
|
9
|
+
* sessions, save $50", or "$80 per session, book up to 6 at once".
|
|
10
|
+
*
|
|
11
|
+
* One component so the detail page, the listing cards and any custom site say
|
|
12
|
+
* it the same way.
|
|
13
|
+
*/
|
|
14
|
+
export function BookingPriceSummary({
|
|
15
|
+
product,
|
|
16
|
+
fmt,
|
|
17
|
+
pricesIncludeTax,
|
|
18
|
+
mutedColor,
|
|
19
|
+
style,
|
|
20
|
+
}: {
|
|
21
|
+
product: Pick<CoachingProduct, "price" | "pricingMode" | "packageSessionCount" | "packagePrice" | "maxSessionsPerBooking">;
|
|
22
|
+
fmt: (n: number) => string;
|
|
23
|
+
pricesIncludeTax?: boolean;
|
|
24
|
+
mutedColor?: string;
|
|
25
|
+
style?: CSSProperties;
|
|
26
|
+
}) {
|
|
27
|
+
const t = useForgeT();
|
|
28
|
+
const mode = bookingPricingMode(product);
|
|
29
|
+
const muted: CSSProperties = { fontSize: "0.85em", fontWeight: 500, opacity: 0.7 };
|
|
30
|
+
|
|
31
|
+
if (mode === "package") {
|
|
32
|
+
const saving = packageSaving(product);
|
|
33
|
+
return (
|
|
34
|
+
<span data-testid="booking-price-summary" style={{ display: "inline-flex", flexWrap: "wrap", alignItems: "baseline", gap: 6, ...style }}>
|
|
35
|
+
<PriceDisplay amount={Number(product.packagePrice ?? 0)} pricesIncludeTax={pricesIncludeTax} formatAmount={fmt} mutedColor={mutedColor} captionStyle={{ opacity: 0.65 }} />
|
|
36
|
+
<span style={muted}>{t("forge.coaching_detail.price_for_sessions", { count: Number(product.packageSessionCount ?? 0) })}</span>
|
|
37
|
+
{saving > 0 && <span style={{ ...muted, fontWeight: 700, opacity: 1 }}>{t("forge.coaching_detail.price_save", { amount: fmt(saving) })}</span>}
|
|
38
|
+
</span>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (mode === "per_session") {
|
|
43
|
+
return (
|
|
44
|
+
<span data-testid="booking-price-summary" style={{ display: "inline-flex", flexWrap: "wrap", alignItems: "baseline", gap: 6, ...style }}>
|
|
45
|
+
<PriceDisplay amount={Number(product.price ?? 0)} pricesIncludeTax={pricesIncludeTax} formatAmount={fmt} mutedColor={mutedColor} captionStyle={{ opacity: 0.65 }} />
|
|
46
|
+
<span style={muted}>{t("forge.coaching_detail.price_per_session_suffix")}</span>
|
|
47
|
+
<span style={muted}>{t("forge.coaching_detail.price_up_to", { max: Number(product.maxSessionsPerBooking ?? 1) })}</span>
|
|
48
|
+
</span>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<span data-testid="booking-price-summary" style={style}>
|
|
54
|
+
<PriceDisplay amount={Number(product.price ?? 0)} pricesIncludeTax={pricesIncludeTax} formatAmount={fmt} mutedColor={mutedColor} captionStyle={{ opacity: 0.65 }} />
|
|
55
|
+
</span>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
@@ -12,6 +12,7 @@ import { usePaymentRenderer, type PaymentRenderProps } from "../payment/ForgePay
|
|
|
12
12
|
import { buttonStyle } from "./Button";
|
|
13
13
|
import { DiscountCodeField, DiscountSummaryLines } from "./DiscountCode";
|
|
14
14
|
import { CancellationTerms } from "./CancellationTerms";
|
|
15
|
+
import { CoachingSlotPicker, useNarrowViewport } from "./CoachingSlotPicker";
|
|
15
16
|
import {
|
|
16
17
|
DialogRoot,
|
|
17
18
|
DialogTrigger,
|
|
@@ -67,7 +68,10 @@ export function CoachingBooking({
|
|
|
67
68
|
const theme = useForgeTheme();
|
|
68
69
|
const registered = usePaymentRenderer();
|
|
69
70
|
const fmt = useAmountFormatter(formatAmount);
|
|
70
|
-
const c = useCoachingBooking(slug, { finalisePath, onComplete });
|
|
71
|
+
const c = useCoachingBooking(slug, { finalisePath, onComplete, view: "month" });
|
|
72
|
+
// Below 640px the booking takes the whole screen, so the browser bar and the
|
|
73
|
+
// keyboard never cover the button.
|
|
74
|
+
const narrow = useNarrowViewport();
|
|
71
75
|
const renderPay = renderPayment ?? registered ?? undefined;
|
|
72
76
|
|
|
73
77
|
const [open, setOpen] = useState(false);
|
|
@@ -89,18 +93,25 @@ export function CoachingBooking({
|
|
|
89
93
|
<DialogPortal>
|
|
90
94
|
<DialogOverlay style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.5)", zIndex: 50 }} />
|
|
91
95
|
<DialogContent
|
|
96
|
+
data-testid="coaching-booking-dialog"
|
|
92
97
|
style={{
|
|
93
98
|
position: "fixed",
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
+
...(narrow
|
|
100
|
+
? { inset: 0, width: "100%", height: "100dvh", borderRadius: 0, padding: "16px 16px 0" }
|
|
101
|
+
: {
|
|
102
|
+
top: "50%",
|
|
103
|
+
left: "50%",
|
|
104
|
+
transform: "translate(-50%, -50%)",
|
|
105
|
+
width: "min(760px, calc(100vw - 24px))",
|
|
106
|
+
maxHeight: "88vh",
|
|
107
|
+
borderRadius: theme.cornerRadius,
|
|
108
|
+
padding: 24,
|
|
109
|
+
}),
|
|
110
|
+
display: "flex",
|
|
111
|
+
flexDirection: "column",
|
|
99
112
|
overflowY: "auto",
|
|
100
113
|
background: theme.colors.background,
|
|
101
114
|
color: theme.colors.text,
|
|
102
|
-
borderRadius: theme.cornerRadius,
|
|
103
|
-
padding: 24,
|
|
104
115
|
zIndex: 51,
|
|
105
116
|
fontFamily: theme.fontFamily,
|
|
106
117
|
}}
|
|
@@ -119,7 +130,9 @@ export function CoachingBooking({
|
|
|
119
130
|
|
|
120
131
|
{c.isLoading && <Loading />}
|
|
121
132
|
{!c.isLoading && !c.product && <p style={{ marginTop: 8 }}>{t("forge.coaching_booking.not_found")}</p>}
|
|
122
|
-
{c.product &&
|
|
133
|
+
{c.product && (
|
|
134
|
+
<CoachingBookingBody c={c} product={c.product} fmt={fmt} renderPayment={renderPay} narrow={narrow} />
|
|
135
|
+
)}
|
|
123
136
|
</DialogContent>
|
|
124
137
|
</DialogPortal>
|
|
125
138
|
</DialogRoot>
|
|
@@ -133,20 +146,22 @@ function CoachingBookingBody({
|
|
|
133
146
|
product,
|
|
134
147
|
fmt,
|
|
135
148
|
renderPayment,
|
|
149
|
+
narrow,
|
|
136
150
|
}: {
|
|
137
151
|
c: Booking;
|
|
138
152
|
product: CoachingProduct;
|
|
139
153
|
fmt: (n: number) => string;
|
|
140
154
|
renderPayment?: (props: PaymentRenderProps) => ReactNode;
|
|
155
|
+
narrow: boolean;
|
|
141
156
|
}) {
|
|
142
157
|
const stepIndex = c.step === "slot" ? 0 : c.step === "details" ? 1 : 2;
|
|
143
158
|
return (
|
|
144
|
-
<div style={{ width: "100%" }}>
|
|
159
|
+
<div style={{ width: "100%", flex: 1, display: "flex", flexDirection: "column" }}>
|
|
145
160
|
{/* Reservation hold countdown — isolated so its per-second tick re-renders
|
|
146
161
|
only itself, not the modal body (esp. the Stripe payment form). */}
|
|
147
162
|
<ReservationTimer expiresAt={c.reservationExpiresAt} onExpire={c.expireReservation} />
|
|
148
163
|
<Progress current={stepIndex} />
|
|
149
|
-
{c.step === "slot" && <
|
|
164
|
+
{c.step === "slot" && <CoachingSlotPicker c={c} product={product} fmt={fmt} narrow={narrow} />}
|
|
150
165
|
{c.step === "details" && <DetailsStep c={c} product={product} fmt={fmt} />}
|
|
151
166
|
{c.step === "payment" && <PaymentStep c={c} product={product} fmt={fmt} renderPayment={renderPayment} />}
|
|
152
167
|
</div>
|
|
@@ -253,147 +268,7 @@ function Progress({ current }: { current: number }) {
|
|
|
253
268
|
);
|
|
254
269
|
}
|
|
255
270
|
|
|
256
|
-
// ── Step 1:
|
|
257
|
-
function SlotStep({ c, product, fmt }: { c: Booking; product: CoachingProduct; fmt: (n: number) => string }) {
|
|
258
|
-
const t = useForgeT();
|
|
259
|
-
const theme = useForgeTheme();
|
|
260
|
-
const slots: BookingSlot[] = c.availability.data ?? [];
|
|
261
|
-
|
|
262
|
-
const grouped = useMemo(() => {
|
|
263
|
-
const byDay: Record<string, { slotId: string; time: string; ts: number }[]> = {};
|
|
264
|
-
for (const slot of slots) {
|
|
265
|
-
const date = new Date(slot.startTime);
|
|
266
|
-
const dateKey = date.toISOString().split("T")[0];
|
|
267
|
-
if (!dateKey) continue;
|
|
268
|
-
// Shown in the visitor's own timezone (like the client app + the confirmation).
|
|
269
|
-
const time = date.toLocaleTimeString("en-US", {
|
|
270
|
-
hour: "numeric",
|
|
271
|
-
minute: "2-digit",
|
|
272
|
-
hour12: true,
|
|
273
|
-
});
|
|
274
|
-
(byDay[dateKey] ??= []).push({ slotId: slot.id, time, ts: date.getTime() });
|
|
275
|
-
}
|
|
276
|
-
for (const key of Object.keys(byDay)) byDay[key]!.sort((x, y) => x.ts - y.ts);
|
|
277
|
-
return Object.entries(byDay).sort(([x], [y]) => x.localeCompare(y));
|
|
278
|
-
}, [slots]);
|
|
279
|
-
|
|
280
|
-
// The visitor's local timezone abbreviation (e.g. "EDT") — slots are shown in it.
|
|
281
|
-
const tzAbbr = (() => {
|
|
282
|
-
try {
|
|
283
|
-
return (
|
|
284
|
-
new Intl.DateTimeFormat("en-US", { timeZoneName: "short" })
|
|
285
|
-
.formatToParts(new Date())
|
|
286
|
-
.find((p) => p.type === "timeZoneName")?.value ?? ""
|
|
287
|
-
);
|
|
288
|
-
} catch {
|
|
289
|
-
return "";
|
|
290
|
-
}
|
|
291
|
-
})();
|
|
292
|
-
|
|
293
|
-
const weekLabel = t("forge.coaching_booking.week_range", {
|
|
294
|
-
start: c.weekStart.toLocaleDateString("en-US", { month: "short", day: "numeric" }),
|
|
295
|
-
end: c.weekEnd.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }),
|
|
296
|
-
});
|
|
297
|
-
const navButton = (disabled: boolean): CSSProperties => ({
|
|
298
|
-
padding: 8,
|
|
299
|
-
borderRadius: theme.cornerRadius,
|
|
300
|
-
border: `1px solid ${a(theme.colors.primary, 0.25)}`,
|
|
301
|
-
background: "transparent",
|
|
302
|
-
color: disabled ? a(theme.colors.text, 0.3) : theme.colors.text,
|
|
303
|
-
cursor: disabled ? "not-allowed" : "pointer",
|
|
304
|
-
display: "flex",
|
|
305
|
-
alignItems: "center",
|
|
306
|
-
justifyContent: "center",
|
|
307
|
-
});
|
|
308
|
-
|
|
309
|
-
return (
|
|
310
|
-
<div>
|
|
311
|
-
<h2 style={{ fontSize: 22, fontWeight: 700, marginBottom: 6 }}>{t("forge.coaching_booking.slot_title")}</h2>
|
|
312
|
-
<p style={{ display: "flex", alignItems: "center", gap: 6, fontSize: 13, color: a(theme.colors.text, 0.6), margin: "0 0 16px" }}>
|
|
313
|
-
<Clock size={14} />{" "}
|
|
314
|
-
{tzAbbr
|
|
315
|
-
? t("forge.coaching_booking.timezone_note_named", { timezone: tzAbbr })
|
|
316
|
-
: t("forge.coaching_booking.timezone_note")}
|
|
317
|
-
</p>
|
|
318
|
-
|
|
319
|
-
{/* Week navigation */}
|
|
320
|
-
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 16 }}>
|
|
321
|
-
<span style={{ fontSize: 14, color: a(theme.colors.text, 0.6) }}>{weekLabel}</span>
|
|
322
|
-
<div style={{ display: "flex", gap: 8 }}>
|
|
323
|
-
<button aria-label={t("forge.coaching_booking.aria_prev_week")} onClick={c.goPrevWeek} disabled={!c.canGoPrevWeek} style={navButton(!c.canGoPrevWeek)}>
|
|
324
|
-
<ChevronLeft size={16} />
|
|
325
|
-
</button>
|
|
326
|
-
<button aria-label={t("forge.coaching_booking.aria_next_week")} onClick={c.goNextWeek} style={navButton(false)}>
|
|
327
|
-
<ChevronRight size={16} />
|
|
328
|
-
</button>
|
|
329
|
-
</div>
|
|
330
|
-
</div>
|
|
331
|
-
|
|
332
|
-
{c.availability.isLoading ? (
|
|
333
|
-
<Loading label={t("forge.coaching_booking.loading_slots")} />
|
|
334
|
-
) : grouped.length === 0 ? (
|
|
335
|
-
<p style={{ opacity: 0.7 }}>{t("forge.coaching_booking.no_slots")}</p>
|
|
336
|
-
) : (
|
|
337
|
-
<div style={{ display: "flex", flexDirection: "column", gap: 24 }}>
|
|
338
|
-
{grouped.map(([dateKey, daySlots]) => {
|
|
339
|
-
const date = new Date(dateKey);
|
|
340
|
-
const dayName = DAY_NAMES[date.getUTCDay()];
|
|
341
|
-
const dateString = date.toLocaleDateString("en-US", {
|
|
342
|
-
month: "short",
|
|
343
|
-
day: "numeric",
|
|
344
|
-
year: "numeric",
|
|
345
|
-
timeZone: "UTC",
|
|
346
|
-
});
|
|
347
|
-
return (
|
|
348
|
-
<div key={dateKey}>
|
|
349
|
-
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 12 }}>
|
|
350
|
-
<Calendar size={18} style={{ color: theme.colors.primary }} />
|
|
351
|
-
<h3 style={{ fontSize: 17, fontWeight: 600, margin: 0 }}>
|
|
352
|
-
{dayName}, {dateString}
|
|
353
|
-
</h3>
|
|
354
|
-
</div>
|
|
355
|
-
<div className="fg:grid fg:grid-cols-2 fg:md:grid-cols-4" style={{ gap: 10 }}>
|
|
356
|
-
{daySlots.map((slot) => {
|
|
357
|
-
const selected = slot.slotId === c.selectedSlotId;
|
|
358
|
-
return (
|
|
359
|
-
<button
|
|
360
|
-
key={slot.slotId}
|
|
361
|
-
onClick={() => c.selectSlot(slot.slotId)}
|
|
362
|
-
disabled={c.isProcessing}
|
|
363
|
-
style={{
|
|
364
|
-
padding: "12px",
|
|
365
|
-
borderRadius: theme.cornerRadius,
|
|
366
|
-
border: `2px solid ${selected ? theme.colors.primary : a(theme.colors.primary, 0.25)}`,
|
|
367
|
-
background: selected ? a(theme.colors.primary, 0.06) : "transparent",
|
|
368
|
-
color: selected ? theme.colors.primary : theme.colors.text,
|
|
369
|
-
cursor: c.isProcessing ? "not-allowed" : "pointer",
|
|
370
|
-
fontWeight: 600,
|
|
371
|
-
fontFamily: theme.fontFamily,
|
|
372
|
-
}}
|
|
373
|
-
>
|
|
374
|
-
{slot.time}
|
|
375
|
-
</button>
|
|
376
|
-
);
|
|
377
|
-
})}
|
|
378
|
-
</div>
|
|
379
|
-
</div>
|
|
380
|
-
);
|
|
381
|
-
})}
|
|
382
|
-
</div>
|
|
383
|
-
)}
|
|
384
|
-
|
|
385
|
-
{c.error && <p style={{ color: "#ef4444", fontSize: 14, marginTop: 12 }}>{c.error}</p>}
|
|
386
|
-
<ActionBar
|
|
387
|
-
c={c}
|
|
388
|
-
product={product}
|
|
389
|
-
fmt={fmt}
|
|
390
|
-
onNext={c.reserveSelected}
|
|
391
|
-
nextLabel={c.isProcessing ? t("forge.coaching_booking.reserving") : t("forge.coaching_booking.continue")}
|
|
392
|
-
nextDisabled={!c.selectedSlotId || c.isProcessing}
|
|
393
|
-
/>
|
|
394
|
-
</div>
|
|
395
|
-
);
|
|
396
|
-
}
|
|
271
|
+
// ── Step 1: day then time, in `CoachingSlotPicker` ──────────────────────────
|
|
397
272
|
|
|
398
273
|
// ── Step 2: buyer details + questionnaire ─────────────────────────────────────
|
|
399
274
|
function DetailsStep({ c, product, fmt }: { c: Booking; product: CoachingProduct; fmt: (n: number) => string }) {
|
|
@@ -424,7 +299,9 @@ function DetailsStep({ c, product, fmt }: { c: Booking; product: CoachingProduct
|
|
|
424
299
|
c.continueToPayment();
|
|
425
300
|
};
|
|
426
301
|
|
|
427
|
-
|
|
302
|
+
// The basket's price, not the per-session price: a package or several
|
|
303
|
+
// sessions are priced by the hook (and re-priced by the server).
|
|
304
|
+
const isFree = (c.totalAmount ?? c.quotedTotal) <= 0;
|
|
428
305
|
|
|
429
306
|
return (
|
|
430
307
|
<div>
|
|
@@ -499,7 +376,8 @@ function DetailsStep({ c, product, fmt }: { c: Booking; product: CoachingProduct
|
|
|
499
376
|
c={c}
|
|
500
377
|
product={product}
|
|
501
378
|
fmt={fmt}
|
|
502
|
-
|
|
379
|
+
// Back keeps the picked times so the buyer can adjust the basket.
|
|
380
|
+
onBack={() => void c.releaseReservation({ keepSelection: true })}
|
|
503
381
|
onNext={onContinue}
|
|
504
382
|
nextLabel={
|
|
505
383
|
c.isProcessing
|
|
@@ -532,7 +410,7 @@ function PaymentStep({
|
|
|
532
410
|
}) {
|
|
533
411
|
const t = useForgeT();
|
|
534
412
|
const theme = useForgeTheme();
|
|
535
|
-
const total = c.totalAmount ??
|
|
413
|
+
const total = c.totalAmount ?? c.quotedTotal;
|
|
536
414
|
// Authoritative tax quote from start-payment — the charged total already
|
|
537
415
|
// reflects it; we only caption the breakdown (display only).
|
|
538
416
|
const taxSummary = summarizeTaxQuote(c.taxQuote);
|
|
@@ -549,7 +427,7 @@ function PaymentStep({
|
|
|
549
427
|
}}
|
|
550
428
|
>
|
|
551
429
|
<DiscountSummaryLines
|
|
552
|
-
subTotal={c.subTotal ??
|
|
430
|
+
subTotal={c.subTotal ?? c.quotedTotal}
|
|
553
431
|
discountAmount={c.coupon.discountAmount}
|
|
554
432
|
appliedCoupons={c.coupon.appliedCoupons}
|
|
555
433
|
fmt={fmt}
|
|
@@ -614,7 +492,7 @@ function ActionBar({
|
|
|
614
492
|
}) {
|
|
615
493
|
const t = useForgeT();
|
|
616
494
|
const theme = useForgeTheme();
|
|
617
|
-
const total = c.totalAmount ??
|
|
495
|
+
const total = c.totalAmount ?? c.quotedTotal;
|
|
618
496
|
return (
|
|
619
497
|
<div style={{ marginTop: 24 }}>
|
|
620
498
|
<div
|
|
@@ -626,7 +504,7 @@ function ActionBar({
|
|
|
626
504
|
}}
|
|
627
505
|
>
|
|
628
506
|
<DiscountSummaryLines
|
|
629
|
-
subTotal={c.subTotal ??
|
|
507
|
+
subTotal={c.subTotal ?? c.quotedTotal}
|
|
630
508
|
discountAmount={c.coupon.discountAmount}
|
|
631
509
|
appliedCoupons={c.coupon.appliedCoupons}
|
|
632
510
|
fmt={fmt}
|