@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.
- package/package.json +1 -1
- package/src/data/queries/useCoachingAvailability.ts +18 -6
- package/src/data/queries/useMyBookings.ts +7 -0
- package/src/i18n/de.json +46 -0
- package/src/i18n/en.json +46 -0
- package/src/runtime/pages.ts +2 -0
- package/src/seo/_tests/seo.spec.ts +202 -6
- package/src/seo/applySeoToHead.ts +7 -1
- package/src/seo/index.ts +1 -0
- package/src/seo/markdown.ts +76 -0
- package/src/seo/resolve.ts +46 -3
- package/src/seo/seoHead.ts +6 -0
- package/src/seo/siteSeoHead.ts +24 -7
- package/src/seo/types.ts +15 -1
- package/src/server/_tests/buildPwaHead.spec.ts +18 -1
- package/src/server/pwa.ts +16 -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/coaching/useCoachingBooking.ts +139 -13
- package/src/ui/index.ts +13 -0
- package/src/ui/styled/AccountDashboard.tsx +11 -3
- 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/EventDetail.tsx +29 -6
- package/src/ui/styled/EventTickets.tsx +35 -11
- 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/_tests/structuredDataSeo.spec.ts +36 -0
- 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
|
-
|
|
77
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
187
|
-
|
|
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)
|
|
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
|
-
|
|
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.
|
|
1232
|
-
|
|
1233
|
-
|
|
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
|
-
|
|
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}
|
|
@@ -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.
|
|
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")}
|