@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.
Files changed (40) hide show
  1. package/package.json +1 -1
  2. package/src/data/queries/useCoachingAvailability.ts +18 -6
  3. package/src/data/queries/useMyBookings.ts +7 -0
  4. package/src/data/queries/useWebsite.ts +42 -11
  5. package/src/data/queries/useWebsiteAgent.ts +136 -6
  6. package/src/i18n/de.json +63 -0
  7. package/src/i18n/en.json +63 -0
  8. package/src/provider/ForgeProvider.tsx +7 -1
  9. package/src/types/models.ts +13 -0
  10. package/src/ui/format/_tests/ticketPrice.spec.ts +58 -0
  11. package/src/ui/format/bookingPricing.ts +55 -0
  12. package/src/ui/format/ticketPrice.ts +52 -0
  13. package/src/ui/headless/agent/_tests/callChallenge.spec.ts +85 -0
  14. package/src/ui/headless/agent/_tests/useAgentCall.spec.tsx +213 -0
  15. package/src/ui/headless/agent/callChallenge.ts +72 -0
  16. package/src/ui/headless/agent/useAgentCall.ts +189 -0
  17. package/src/ui/headless/agent/useAgentCallRoom.ts +113 -0
  18. package/src/ui/headless/agent/useAiAgent.ts +8 -20
  19. package/src/ui/headless/agent/visitorId.ts +20 -0
  20. package/src/ui/headless/coaching/useCoachingBooking.ts +139 -13
  21. package/src/ui/index.ts +24 -0
  22. package/src/ui/styled/AccountDashboard.tsx +11 -3
  23. package/src/ui/styled/AiAgentCallRoom.tsx +76 -0
  24. package/src/ui/styled/AiAgentWidget.tsx +276 -21
  25. package/src/ui/styled/BookingPriceSummary.tsx +57 -0
  26. package/src/ui/styled/CoachingBooking.tsx +36 -158
  27. package/src/ui/styled/CoachingConfirmation.tsx +13 -2
  28. package/src/ui/styled/CoachingDetail.tsx +8 -2
  29. package/src/ui/styled/CoachingSlotPicker.tsx +874 -0
  30. package/src/ui/styled/CookieConsent.tsx +13 -9
  31. package/src/ui/styled/EventDetail.tsx +29 -6
  32. package/src/ui/styled/EventTickets.tsx +35 -11
  33. package/src/ui/styled/MusicLinkPage.tsx +6 -3
  34. package/src/ui/styled/_tests/CoachingMultiSession.spec.tsx +299 -0
  35. package/src/ui/styled/_tests/FreeTickets.spec.tsx +242 -0
  36. package/src/ui/styled/broadcast/BroadcastTicketPurchase.tsx +18 -4
  37. package/src/ui/styled/forge-utilities.css +0 -5
  38. package/src/ui/styled/seating/EventSeatPicker.tsx +2 -0
  39. package/src/ui/styled/seating/SeatLegend.tsx +3 -1
  40. 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 [open, setOpen] = useState(false);
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
- <button
150
- onClick={() => setOpen((o) => !o)}
151
- aria-label={t("forge.ai_agent_widget.aria_open")}
152
- style={{
153
- width: 56,
154
- height: 56,
155
- borderRadius: "50%",
156
- background: accent,
157
- color: onAccent,
158
- border: "none",
159
- cursor: "pointer",
160
- boxShadow: "0 6px 20px rgba(0,0,0,0.25)",
161
- fontSize: 24,
162
- float: position === "bottom-left" ? "left" : "right",
163
- }}
164
- >
165
- {open ? "×" : "💬"}
166
- </button>
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
- top: "50%",
95
- left: "50%",
96
- transform: "translate(-50%, -50%)",
97
- width: "min(640px, calc(100vw - 24px))",
98
- maxHeight: "88vh",
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 && <CoachingBookingBody c={c} product={c.product} fmt={fmt} renderPayment={renderPay} />}
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" && <SlotStep c={c} product={product} fmt={fmt} />}
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: week navigation + slot selection (reserve happens on Continue) ─────
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
- const isFree = Number(product.price) <= 0;
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
- onBack={() => void c.releaseReservation()}
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 ?? Number(product.price);
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 ?? Number(product.price)}
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 ?? Number(product.price);
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 ?? Number(product.price)}
507
+ subTotal={c.subTotal ?? c.quotedTotal}
630
508
  discountAmount={c.coupon.discountAmount}
631
509
  appliedCoupons={c.coupon.appliedCoupons}
632
510
  fmt={fmt}