@tribe-nest/forge 3.63.0 → 3.65.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 (38) 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/i18n/de.json +46 -0
  5. package/src/i18n/en.json +46 -0
  6. package/src/runtime/pages.ts +2 -0
  7. package/src/seo/_tests/seo.spec.ts +202 -6
  8. package/src/seo/applySeoToHead.ts +7 -1
  9. package/src/seo/index.ts +1 -0
  10. package/src/seo/markdown.ts +76 -0
  11. package/src/seo/resolve.ts +46 -3
  12. package/src/seo/seoHead.ts +6 -0
  13. package/src/seo/siteSeoHead.ts +24 -7
  14. package/src/seo/types.ts +15 -1
  15. package/src/server/_tests/buildPwaHead.spec.ts +18 -1
  16. package/src/server/pwa.ts +16 -1
  17. package/src/types/models.ts +13 -0
  18. package/src/ui/format/_tests/ticketPrice.spec.ts +58 -0
  19. package/src/ui/format/bookingPricing.ts +55 -0
  20. package/src/ui/format/ticketPrice.ts +52 -0
  21. package/src/ui/headless/coaching/useCoachingBooking.ts +139 -13
  22. package/src/ui/index.ts +13 -0
  23. package/src/ui/styled/AccountDashboard.tsx +11 -3
  24. package/src/ui/styled/BookingPriceSummary.tsx +57 -0
  25. package/src/ui/styled/CoachingBooking.tsx +36 -158
  26. package/src/ui/styled/CoachingConfirmation.tsx +13 -2
  27. package/src/ui/styled/CoachingDetail.tsx +8 -2
  28. package/src/ui/styled/CoachingSlotPicker.tsx +874 -0
  29. package/src/ui/styled/EventDetail.tsx +29 -6
  30. package/src/ui/styled/EventTickets.tsx +35 -11
  31. package/src/ui/styled/_tests/CoachingMultiSession.spec.tsx +299 -0
  32. package/src/ui/styled/_tests/FreeTickets.spec.tsx +242 -0
  33. package/src/ui/styled/broadcast/BroadcastTicketPurchase.tsx +18 -4
  34. package/src/ui/styled/forge-utilities.css +0 -5
  35. package/src/ui/styled/seating/EventSeatPicker.tsx +2 -0
  36. package/src/ui/styled/seating/SeatLegend.tsx +3 -1
  37. package/src/utils/_tests/structuredDataSeo.spec.ts +36 -0
  38. package/src/utils/structuredData.ts +28 -1
@@ -12,7 +12,8 @@ import { useCouponField, type CouponQuoteFn } from "../coupon/useCouponField";
12
12
  import { readAttributionRef } from "../../../utils/attribution";
13
13
  import { readLanding } from "../../../utils/landing";
14
14
  import { forgetBookingSecret, rememberBookingSecret } from "../../../utils/bookingSecret";
15
- import type { CancellationTermsView } from "../../../types/models";
15
+ import type { BookingSlot, CancellationTermsView } from "../../../types/models";
16
+ import { bookingGrossAmount, bookingPricingMode, bookingSessionRange } from "../../format/bookingPricing";
16
17
 
17
18
  export type CoachingBookingStep = "slot" | "details" | "payment";
18
19
 
@@ -32,6 +33,13 @@ export interface UseCoachingBookingOptions {
32
33
  */
33
34
  fromDate?: string;
34
35
  toDate?: string;
36
+ /**
37
+ * Browse availability a WEEK at a time (the default, unchanged for existing
38
+ * sites) or a calendar MONTH at a time, the day-first pattern of popular
39
+ * booking platforms (`goPrevMonth` / `goNextMonth`). Forge's own
40
+ * `CoachingBooking` uses `month`.
41
+ */
42
+ view?: "week" | "month";
35
43
  }
36
44
 
37
45
  const errMessage = (e: unknown) =>
@@ -49,6 +57,12 @@ const addDays = (d: Date, n: number) => {
49
57
  return x;
50
58
  };
51
59
  const endOfDay = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate(), 23, 59, 59, 999);
60
+ const startOfMonth = (d: Date) => new Date(d.getFullYear(), d.getMonth(), 1);
61
+ const addMonths = (d: Date, n: number) => new Date(d.getFullYear(), d.getMonth() + n, 1);
62
+ const endOfMonth = (d: Date) => new Date(d.getFullYear(), d.getMonth() + 1, 0, 23, 59, 59, 999);
63
+
64
+ /** A slot the buyer picked, kept whole so it survives navigating to another month. */
65
+ export type PickedSlot = Pick<BookingSlot, "id" | "startTime" | "endTime">;
52
66
 
53
67
  /**
54
68
  * Headless coaching booking: slot selection (reserve) → buyer details (update,
@@ -73,8 +87,30 @@ export function useCoachingBooking(slug?: string, opts: UseCoachingBookingOption
73
87
  const goPrevWeek = () => setWeekStart((w) => (w.getTime() > currentWeekStart.getTime() ? addDays(w, -7) : w));
74
88
  const goNextWeek = () => setWeekStart((w) => addDays(w, 7));
75
89
 
76
- const windowFrom = opts.fromDate ?? weekStart.toISOString();
77
- const windowTo = opts.toDate ?? endOfDay(weekEnd).toISOString();
90
+ // Month navigation, bounded by "this month" and the product's booking window
91
+ // (`daysInFuture`): there is nothing to offer past it.
92
+ const isMonthView = opts.view === "month";
93
+ const [currentMonthStart] = useState(() => startOfMonth(new Date()));
94
+ const [monthStart, setMonthStart] = useState<Date>(currentMonthStart);
95
+ const lastBookableMonth = startOfMonth(
96
+ addDays(new Date(), Math.max(0, Number(product?.daysInFuture ?? 60))),
97
+ );
98
+ const canGoPrevMonth = monthStart.getTime() > currentMonthStart.getTime();
99
+ const canGoNextMonth = monthStart.getTime() < lastBookableMonth.getTime();
100
+ const goPrevMonth = () => setMonthStart((m) => (m.getTime() > currentMonthStart.getTime() ? addMonths(m, -1) : m));
101
+ const goNextMonth = () =>
102
+ setMonthStart((m) => (m.getTime() < lastBookableMonth.getTime() ? addMonths(m, 1) : m));
103
+
104
+ // "Now" is read ONCE, at mount. Reading it per render made the window's
105
+ // start (and so the query key) change on every render, refetching the
106
+ // month in a loop and leaving the calendar permanently loading.
107
+ const [openedAt] = useState(() => Date.now());
108
+ const windowFrom =
109
+ opts.fromDate ??
110
+ (isMonthView
111
+ ? new Date(Math.max(monthStart.getTime(), openedAt)).toISOString()
112
+ : weekStart.toISOString());
113
+ const windowTo = opts.toDate ?? (isMonthView ? endOfMonth(monthStart) : endOfDay(weekEnd)).toISOString();
78
114
  const availability = useCoachingAvailability(productId, windowFrom, windowTo);
79
115
  const reserve = useReserveCoachingBooking(productId);
80
116
  const update = useUpdateCoachingBooking(productId);
@@ -90,7 +126,21 @@ export function useCoachingBooking(slug?: string, opts: UseCoachingBookingOption
90
126
  * backend returns it once and cannot reissue it.
91
127
  */
92
128
  const bookingSecretRef = useRef<string | undefined>(undefined);
93
- const [selectedSlotId, setSelectedSlotId] = useState<string | null>(null);
129
+ /**
130
+ * The times picked for this booking, earliest first. One for a single
131
+ * session; up to the product's limit for a package or per-session booking.
132
+ * Kept as whole slots (not ids) so a pick made in October still shows in
133
+ * the basket while the buyer browses November.
134
+ */
135
+ const [selectedSlots, setSelectedSlots] = useState<PickedSlot[]>([]);
136
+ const setSelectedSlotId = (id: string | null) => {
137
+ if (!id) return setSelectedSlots([]);
138
+ const slot = (availability.data ?? []).find((s) => s.id === id);
139
+ setSelectedSlots(slot ? [slot] : [{ id, startTime: "", endTime: "" }]);
140
+ };
141
+ const selectedSlotId = selectedSlots[0]?.id ?? null;
142
+ const sessionRange = bookingSessionRange(product);
143
+ const pricingMode = bookingPricingMode(product);
94
144
  const [firstName, setFirstName] = useState(user?.firstName ?? "");
95
145
  const [lastName, setLastName] = useState(user?.lastName ?? "");
96
146
  const [email, setEmail] = useState(user?.email ?? "");
@@ -148,8 +198,35 @@ export function useCoachingBooking(slug?: string, opts: UseCoachingBookingOption
148
198
  setSelectedSlotId(slotId);
149
199
  };
150
200
 
201
+ /**
202
+ * Add or remove a time from the basket. A single-session product replaces
203
+ * its one pick; a multi-session product adds up to its limit and reports
204
+ * `false` when the basket is already full.
205
+ */
206
+ const toggleSlot = (slot: PickedSlot): boolean => {
207
+ setError(null);
208
+ const already = selectedSlots.some((s) => s.id === slot.id);
209
+ if (sessionRange.max <= 1) {
210
+ setSelectedSlots(already ? [] : [slot]);
211
+ return true;
212
+ }
213
+ if (already) {
214
+ setSelectedSlots(selectedSlots.filter((s) => s.id !== slot.id));
215
+ return true;
216
+ }
217
+ if (selectedSlots.length >= sessionRange.max) return false;
218
+ setSelectedSlots(
219
+ [...selectedSlots, slot].sort((x, y) => new Date(x.startTime).getTime() - new Date(y.startTime).getTime()),
220
+ );
221
+ return true;
222
+ };
223
+ const removeSlot = (slotId: string) => {
224
+ setError(null);
225
+ setSelectedSlots((current) => current.filter((s) => s.id !== slotId));
226
+ };
227
+
151
228
  const changeWeek = (dir: -1 | 1) => {
152
- setSelectedSlotId(null);
229
+ setSelectedSlots([]);
153
230
  if (dir === -1) goPrevWeek();
154
231
  else goNextWeek();
155
232
  };
@@ -165,13 +242,20 @@ export function useCoachingBooking(slug?: string, opts: UseCoachingBookingOption
165
242
  setError("Please select an available time slot.");
166
243
  return;
167
244
  }
245
+ if (selectedSlots.length < sessionRange.min) {
246
+ setError(`Please pick ${sessionRange.min} times.`);
247
+ return;
248
+ }
168
249
  try {
169
250
  // Release any previously-held slot before reserving a new one.
170
251
  if (bookingId) {
171
- await unreserve.mutateAsync({ bookingId }).catch(() => undefined);
252
+ await unreserve.mutateAsync({ bookingId, bookingSecret: bookingSecretRef.current }).catch(() => undefined);
172
253
  forgetBookingSecret(bookingId);
173
254
  }
174
- const data = await reserve.mutateAsync({ slotId: selectedSlotId });
255
+ // Every picked time in ONE call: the server holds all of them or none.
256
+ const data = await reserve.mutateAsync(
257
+ selectedSlots.length > 1 ? { slotIds: selectedSlots.map((s) => s.id) } : { slotId: selectedSlotId },
258
+ );
175
259
  setBookingId(data.bookingId);
176
260
  bookingSecretRef.current = data.bookingSecret;
177
261
  // Written the instant it arrives: this is the only time the server will
@@ -179,12 +263,29 @@ export function useCoachingBooking(slug?: string, opts: UseCoachingBookingOption
179
263
  // way to get it.
180
264
  rememberBookingSecret(data.bookingId, data.bookingSecret);
181
265
  setAgreedCancellation(data.cancellation ?? null);
182
- setReservationExpiresAt(Date.now() + RESERVATION_HOLD_MS);
266
+ // The server's own deadline when it gives one, so the countdown matches.
267
+ const serverDeadline = data.holdExpiresAt ? Date.parse(data.holdExpiresAt) : NaN;
268
+ setReservationExpiresAt(Number.isFinite(serverDeadline) ? serverDeadline : Date.now() + RESERVATION_HOLD_MS);
183
269
  setStep("details");
184
270
  } catch (e) {
185
271
  setError(errMessage(e));
186
- setSelectedSlotId(null);
187
- availability.refetch();
272
+ // One session: start over. Several: keep the basket, drop only the
273
+ // picks this month's fresh grid no longer offers (someone took them).
274
+ if (selectedSlots.length <= 1) {
275
+ setSelectedSlots([]);
276
+ availability.refetch();
277
+ } else {
278
+ const fresh = await availability.refetch().catch(() => null);
279
+ const offered = new Set((fresh?.data ?? []).map((s) => s.id));
280
+ const from = Date.parse(windowFrom);
281
+ const to = Date.parse(windowTo);
282
+ setSelectedSlots((current) =>
283
+ current.filter((s) => {
284
+ const at = Date.parse(s.startTime);
285
+ return !(at >= from && at <= to) || offered.has(s.id);
286
+ }),
287
+ );
288
+ }
188
289
  }
189
290
  };
190
291
 
@@ -192,15 +293,19 @@ export function useCoachingBooking(slug?: string, opts: UseCoachingBookingOption
192
293
  * Release the currently-held slot: unreserve the booking and clear the hold,
193
294
  * returning to slot selection. Call this when the user leaves/closes the flow.
194
295
  */
195
- const releaseReservation = useCallback(async () => {
196
- if (bookingId) await unreserve.mutateAsync({ bookingId }).catch(() => undefined);
296
+ const releaseReservation = useCallback(async (releaseOpts: { keepSelection?: boolean } = {}) => {
297
+ if (bookingId) {
298
+ await unreserve.mutateAsync({ bookingId, bookingSecret: bookingSecretRef.current }).catch(() => undefined);
299
+ }
197
300
  // The credential belonged to a booking that no longer exists — leaving it
198
301
  // in storage would strand a dead secret in the tab for the rest of the
199
302
  // session.
200
303
  forgetBookingSecret(bookingId);
201
304
  bookingSecretRef.current = undefined;
202
305
  setBookingId(null);
203
- setSelectedSlotId(null);
306
+ // "Back" keeps the basket so the buyer can adjust it; closing or an
307
+ // expired hold starts over.
308
+ if (!releaseOpts.keepSelection) setSelectedSlots([]);
204
309
  setReservationExpiresAt(null);
205
310
  // The agreed terms belonged to a booking that no longer exists — the next
206
311
  // reserve snapshots afresh, and may snapshot something different.
@@ -268,6 +373,21 @@ export function useCoachingBooking(slug?: string, opts: UseCoachingBookingOption
268
373
  step,
269
374
  setStep,
270
375
  selectedSlotId,
376
+ /** Every picked time, earliest first. */
377
+ selectedSlots,
378
+ toggleSlot,
379
+ removeSlot,
380
+ /** How many sessions this booking must hold: `{ min, max }` (1/1 for one session). */
381
+ sessionRange,
382
+ pricingMode,
383
+ /** True once the basket holds an allowed number of sessions. */
384
+ canReserve:
385
+ selectedSlots.length >= sessionRange.min && selectedSlots.length <= sessionRange.max && selectedSlots.length > 0,
386
+ /**
387
+ * The price of the current basket before a code or tax, for display only:
388
+ * the package price, or the per-session price times the picks (at least one).
389
+ */
390
+ quotedTotal: bookingGrossAmount(product, Math.max(selectedSlots.length, sessionRange.min)),
271
391
  bookingId,
272
392
  /**
273
393
  * The cancellation terms in force for THIS checkout (S.6): the booking's
@@ -281,6 +401,12 @@ export function useCoachingBooking(slug?: string, opts: UseCoachingBookingOption
281
401
  reservationExpiresAt,
282
402
  expireReservation,
283
403
  releaseReservation,
404
+ // Month navigation (with `view: "month"`)
405
+ monthStart,
406
+ canGoPrevMonth,
407
+ canGoNextMonth,
408
+ goPrevMonth,
409
+ goNextMonth,
284
410
  // Week navigation
285
411
  weekStart,
286
412
  weekEnd,
package/src/ui/index.ts CHANGED
@@ -135,6 +135,15 @@ export { AddToCalendar, type AddToCalendarProps } from "./styled/AddToCalendar";
135
135
  export { WalletPassButtons, type WalletPassButtonsProps } from "./styled/WalletPassButtons";
136
136
  export { CoachingBooking, type CoachingBookingProps } from "./styled/CoachingBooking";
137
137
  export { CoachingDetail, type CoachingDetailProps } from "./styled/CoachingDetail";
138
+ export { BookingPriceSummary } from "./styled/BookingPriceSummary";
139
+ export { CoachingSlotPicker, localDayKey } from "./styled/CoachingSlotPicker";
140
+ export {
141
+ bookingGrossAmount,
142
+ bookingPricingMode,
143
+ bookingSessionRange,
144
+ packageSaving,
145
+ type BookingPricingMode,
146
+ } from "./format/bookingPricing";
138
147
  // S.6: the buyer-facing cancellation policy block. Exported so a code site that
139
148
  // lays out its own booking page still has ONE renderer for these terms.
140
149
  export { CancellationTerms, type CancellationTermsProps } from "./styled/CancellationTerms";
@@ -268,6 +277,10 @@ export {
268
277
  resolveUnitPrice,
269
278
  ticketSubtotals,
270
279
  } from "./format/pwyw";
280
+ // Free tickets: a zero-priced tier (not PWYW) reads "Free", an event page with
281
+ // free and paid tiers reads "Free to $X", and one free tier is a reservation.
282
+ export { isFreeTicket, summarizeTicketPrices, isSingleFreeTicketEvent } from "./format/ticketPrice";
283
+ export type { TicketPriceSummary } from "./format/ticketPrice";
271
284
  // Membership pricing: which billing cycles a tier sells, a pay-what-you-want
272
285
  // cycle's floor, and the ONE number that may be sent to the subscriptions API.
273
286
  // Settlement-currency MAJOR units throughout: convert for the screen, never for
@@ -1228,10 +1228,18 @@ function BookingRow({ booking, ctx }: { booking: MyBooking; ctx: TabContext }) {
1228
1228
  </div>
1229
1229
  <div style={{ textAlign: "right" }}>
1230
1230
  {cancelled && <div style={{ fontSize: 13, color: t.muted }}>{tr("forge.account_dashboard.cancelled")}</div>}
1231
- {booking.totalAmount != null && (
1232
- <div style={{ fontWeight: 600, fontVariantNumeric: "tabular-nums" }}>
1233
- {formatAmount(booking.totalAmount, booking.currency || currency)}
1231
+ {(booking.sessionCount ?? 1) > 1 ? (
1232
+ // One session of several bought together: the payment covers them
1233
+ // all, so no per-row price that would read as this session's own.
1234
+ <div data-testid="booking-package-badge" style={{ fontSize: 13, fontWeight: 600 }}>
1235
+ {tr("forge.account_dashboard.booking_part_of", { count: booking.sessionCount ?? 1 })}
1234
1236
  </div>
1237
+ ) : (
1238
+ booking.totalAmount != null && (
1239
+ <div style={{ fontWeight: 600, fontVariantNumeric: "tabular-nums" }}>
1240
+ {formatAmount(booking.totalAmount, booking.currency || currency)}
1241
+ </div>
1242
+ )
1235
1243
  )}
1236
1244
  </div>
1237
1245
  </div>
@@ -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}
@@ -158,14 +158,25 @@ export function CoachingConfirmation({
158
158
  <div className="cf-rise" style={{ padding: "28px 32px 12px", animationDelay: "0.38s" }}>
159
159
  {data && (
160
160
  <dl style={{ display: "grid", gridTemplateColumns: "1fr auto", rowGap: 14, columnGap: 16, margin: 0 }}>
161
- {data.sessionStartTime && (
161
+ {data.sessions && data.sessions.length > 1 ? (
162
+ // A package or several sessions: every time the buyer committed to.
163
+ data.sessions.map((session, index) => (
164
+ <ConfirmationRow
165
+ key={session.startTime}
166
+ label={t("forge.coaching_confirmation.row_session_n", { index: index + 1, count: data.sessions!.length })}
167
+ value={formatSession(session.startTime)}
168
+ accent
169
+ strong={index === 0}
170
+ />
171
+ ))
172
+ ) : data.sessionStartTime ? (
162
173
  <ConfirmationRow
163
174
  label={t("forge.coaching_confirmation.row_session")}
164
175
  value={formatSession(data.sessionStartTime)}
165
176
  accent
166
177
  strong
167
178
  />
168
- )}
179
+ ) : null}
169
180
  {data.durationMinutes ? (
170
181
  <ConfirmationRow
171
182
  label={t("forge.coaching_confirmation.row_duration")}