@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
@@ -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";
@@ -197,6 +206,17 @@ export { NewDmDialog, type NewDmDialogProps } from "./styled/chat/NewDmDialog";
197
206
  export { AiAgentWidget, type AiAgentWidgetProps } from "./styled/AiAgentWidget";
198
207
  export { Markdown } from "./styled/Markdown";
199
208
  export { useAiAgent, type AiAgentMessage } from "./headless/agent/useAiAgent";
209
+ // "Call us". The room hook (`useAgentCallRoom`) is deliberately not exported
210
+ // here: it imports the media SDK, and this entry is on every page. A custom
211
+ // call UI imports the media hooks from `@tribe-nest/forge/media` itself.
212
+ export {
213
+ useAgentCall,
214
+ classifyAgentCallError,
215
+ type AgentCallState,
216
+ type AgentCallErrorKind,
217
+ type UseAgentCallOptions,
218
+ } from "./headless/agent/useAgentCall";
219
+ export { solveCallChallenge, leadingZeroBits, type SolveCallChallengeOptions } from "./headless/agent/callChallenge";
200
220
  export { InvoicePayment, type InvoicePaymentProps } from "./styled/InvoicePayment";
201
221
  export { PaymentLinkPayment, type PaymentLinkPaymentProps } from "./styled/PaymentLinkPayment";
202
222
  export { BlogList, type BlogListProps } from "./styled/BlogList";
@@ -268,6 +288,10 @@ export {
268
288
  resolveUnitPrice,
269
289
  ticketSubtotals,
270
290
  } from "./format/pwyw";
291
+ // Free tickets: a zero-priced tier (not PWYW) reads "Free", an event page with
292
+ // free and paid tiers reads "Free to $X", and one free tier is a reservation.
293
+ export { isFreeTicket, summarizeTicketPrices, isSingleFreeTicketEvent } from "./format/ticketPrice";
294
+ export type { TicketPriceSummary } from "./format/ticketPrice";
271
295
  // Membership pricing: which billing cycles a tier sells, a pay-what-you-want
272
296
  // cycle's floor, and the ONE number that may be sent to the subscriptions API.
273
297
  // 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,76 @@
1
+ import { useEffect, type ReactNode } from "react";
2
+ import { useForgeT } from "../../i18n";
3
+ import { MediaRoomProvider } from "../media";
4
+ import { useAgentAudio, useAgentCallRoom, type AgentCallRoomStatus } from "../headless/agent/useAgentCallRoom";
5
+
6
+ export interface AiAgentCallRoomProps {
7
+ getCredentials: () => Promise<{ mediaUrl: string; token: string }>;
8
+ /** This browser's identity in the room, from the start response. */
9
+ selfIdentity: string;
10
+ /** The agent's name, for the status line. */
11
+ agentName: string;
12
+ /** The room says the call is over: the agent left, or the room closed. */
13
+ onEnded: () => void;
14
+ /** Told every status change, so the widget can style around it. */
15
+ onStatus?: (status: AgentCallRoomStatus) => void;
16
+ }
17
+
18
+ /**
19
+ * The live half of "Call us": the media room, the microphone out and the
20
+ * agent's voice in.
21
+ *
22
+ * A DEFAULT export on purpose. `AiAgentWidget` is on every page of every site
23
+ * and reaches this through `React.lazy(() => import(...))`, so the media SDK
24
+ * and mediasoup are fetched only by a visitor who actually presses Call.
25
+ */
26
+ export default function AiAgentCallRoom({ getCredentials, ...rest }: AiAgentCallRoomProps): ReactNode {
27
+ const t = useForgeT();
28
+ return (
29
+ <MediaRoomProvider
30
+ getCredentials={getCredentials}
31
+ fallback={<StatusLine text={t("forge.ai_agent_call_room.connecting")} />}
32
+ >
33
+ <LiveCall {...rest} />
34
+ </MediaRoomProvider>
35
+ );
36
+ }
37
+
38
+ function LiveCall({ selfIdentity, agentName, onEnded, onStatus }: Omit<AiAgentCallRoomProps, "getCredentials">) {
39
+ const t = useForgeT();
40
+ const { status, agentAudioProducerIds, setAgentSpeaking } = useAgentCallRoom({ selfIdentity, onEnded });
41
+
42
+ useEffect(() => {
43
+ onStatus?.(status);
44
+ }, [status, onStatus]);
45
+
46
+ const text =
47
+ status === "no_mic"
48
+ ? t("forge.ai_agent_call_room.no_mic")
49
+ : status === "speaking"
50
+ ? t("forge.ai_agent_call_room.speaking", { name: agentName })
51
+ : status === "listening"
52
+ ? t("forge.ai_agent_call_room.listening", { name: agentName })
53
+ : t("forge.ai_agent_call_room.connecting");
54
+
55
+ return (
56
+ <>
57
+ {agentAudioProducerIds.map((id) => (
58
+ <AgentAudio key={id} producerId={id} onSpeaking={setAgentSpeaking} />
59
+ ))}
60
+ <StatusLine text={text} alert={status === "no_mic"} />
61
+ </>
62
+ );
63
+ }
64
+
65
+ function AgentAudio({ producerId, onSpeaking }: { producerId: string; onSpeaking: (speaking: boolean) => void }) {
66
+ const attach = useAgentAudio(producerId, onSpeaking);
67
+ return <audio ref={attach} autoPlay data-testid="forge-agent-call-audio" />;
68
+ }
69
+
70
+ function StatusLine({ text, alert }: { text: string; alert?: boolean }) {
71
+ return (
72
+ <div role={alert ? "alert" : "status"} aria-live="polite" style={{ fontSize: 14, textAlign: "center" }} data-testid="forge-agent-call-status">
73
+ {text}
74
+ </div>
75
+ );
76
+ }