@tribe-nest/forge 3.64.0 → 3.67.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/data/queries/useCoachingAvailability.ts +18 -6
- package/src/data/queries/useMyBookings.ts +7 -0
- package/src/data/queries/useWebsite.ts +42 -11
- package/src/data/queries/useWebsiteAgent.ts +136 -6
- package/src/i18n/de.json +63 -0
- package/src/i18n/en.json +63 -0
- package/src/provider/ForgeProvider.tsx +7 -1
- package/src/types/models.ts +13 -0
- package/src/ui/format/_tests/ticketPrice.spec.ts +58 -0
- package/src/ui/format/bookingPricing.ts +55 -0
- package/src/ui/format/ticketPrice.ts +52 -0
- package/src/ui/headless/agent/_tests/callChallenge.spec.ts +85 -0
- package/src/ui/headless/agent/_tests/useAgentCall.spec.tsx +213 -0
- package/src/ui/headless/agent/callChallenge.ts +72 -0
- package/src/ui/headless/agent/useAgentCall.ts +189 -0
- package/src/ui/headless/agent/useAgentCallRoom.ts +113 -0
- package/src/ui/headless/agent/useAiAgent.ts +8 -20
- package/src/ui/headless/agent/visitorId.ts +20 -0
- package/src/ui/headless/coaching/useCoachingBooking.ts +139 -13
- package/src/ui/index.ts +24 -0
- package/src/ui/styled/AccountDashboard.tsx +11 -3
- package/src/ui/styled/AiAgentCallRoom.tsx +76 -0
- package/src/ui/styled/AiAgentWidget.tsx +276 -21
- package/src/ui/styled/BookingPriceSummary.tsx +57 -0
- package/src/ui/styled/CoachingBooking.tsx +36 -158
- package/src/ui/styled/CoachingConfirmation.tsx +13 -2
- package/src/ui/styled/CoachingDetail.tsx +8 -2
- package/src/ui/styled/CoachingSlotPicker.tsx +874 -0
- package/src/ui/styled/CookieConsent.tsx +13 -9
- package/src/ui/styled/EventDetail.tsx +29 -6
- package/src/ui/styled/EventTickets.tsx +35 -11
- package/src/ui/styled/MusicLinkPage.tsx +6 -3
- package/src/ui/styled/_tests/CoachingMultiSession.spec.tsx +299 -0
- package/src/ui/styled/_tests/FreeTickets.spec.tsx +242 -0
- package/src/ui/styled/broadcast/BroadcastTicketPurchase.tsx +18 -4
- package/src/ui/styled/forge-utilities.css +0 -5
- package/src/ui/styled/seating/EventSeatPicker.tsx +2 -0
- package/src/ui/styled/seating/SeatLegend.tsx +3 -1
- package/src/utils/structuredData.ts +4 -1
|
@@ -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";
|
|
@@ -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.
|
|
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,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
|
+
}
|