@tribe-nest/forge 3.54.0 → 3.58.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 +4 -2
- package/src/_tests/workspaceAliases.ts +1 -0
- package/src/contexts/CartContext.tsx +88 -5
- package/src/data/queries/useCallBreakouts.ts +112 -0
- package/src/data/queries/useCheckouts.ts +15 -2
- package/src/data/queries/useCoachingProducts.ts +15 -1
- package/src/data/queries/useCourses.ts +15 -1
- package/src/data/queries/useEvents.ts +80 -1
- package/src/data/queries/useMembership.ts +176 -4
- package/src/data/queries/useMyBookings.ts +16 -0
- package/src/data/queries/useNotifications.ts +1 -1
- package/src/data/queries/useProducts.ts +15 -1
- package/src/data/queries/useSubscriptions.ts +21 -1
- package/src/i18n/de.json +153 -0
- package/src/i18n/en.json +153 -0
- package/src/index.ts +1 -0
- package/src/server/_tests/buildPwaHead.spec.ts +1 -1
- package/src/server/_tests/buildWebManifest.spec.ts +1 -1
- package/src/server/_tests/forgeServiceWorkerScript.spec.ts +1 -1
- package/src/server/index.ts +75 -1
- package/src/server/platformEvents.generated.ts +60 -0
- package/src/server/pwa.ts +1 -1
- package/src/types/models.ts +137 -0
- package/src/ui/analytics/ForgeAnalytics.tsx +55 -1
- package/src/ui/analytics/_tests/ForgeAnalytics.engagement.spec.tsx +78 -0
- package/src/ui/headless/checkout/_tests/bundleCoupon.spec.ts +3 -1
- package/src/ui/headless/checkout/_tests/cartResume.spec.ts +3 -10
- package/src/ui/headless/checkout/_tests/seatConflict.spec.ts +187 -0
- package/src/ui/headless/checkout/_tests/useCartResume.spec.tsx +3 -11
- package/src/ui/headless/checkout/_tests/useCheckoutSeats.spec.tsx +313 -0
- package/src/ui/headless/checkout/seatConflict.ts +117 -0
- package/src/ui/headless/checkout/useCheckout.ts +178 -55
- package/src/ui/headless/event/_tests/seatingFixture.ts +111 -0
- package/src/ui/headless/event/_tests/useEventCheckoutSeats.spec.tsx +443 -0
- package/src/ui/headless/event/_tests/useEventSeating.spec.tsx +154 -0
- package/src/ui/headless/event/useEventCheckout.ts +362 -22
- package/src/ui/headless/event/useEventSeating.ts +233 -0
- package/src/ui/headless/forms/_tests/usePhoneInput.spec.ts +187 -0
- package/src/ui/headless/forms/useEmailListForm.ts +37 -6
- package/src/ui/headless/forms/usePhoneInput.ts +294 -0
- package/src/ui/headless/forms/useSectionedForm.ts +2 -2
- package/src/ui/headless/index.ts +48 -1
- package/src/ui/headless/membership/_tests/membershipApplication.spec.ts +85 -0
- package/src/ui/headless/membership/_tests/membershipCheckoutRefetch.spec.tsx +2 -0
- package/src/ui/headless/membership/_tests/membershipTrial.spec.ts +61 -0
- package/src/ui/headless/membership/_tests/useMembershipCheckoutApplication.spec.tsx +253 -0
- package/src/ui/headless/membership/_tests/useMembershipCheckoutQuestionnaire.spec.tsx +241 -0
- package/src/ui/headless/membership/_tests/useMembershipCheckoutTrial.spec.tsx +165 -0
- package/src/ui/headless/membership/membershipApplication.ts +109 -0
- package/src/ui/headless/membership/membershipQuestionnaire.ts +44 -0
- package/src/ui/headless/membership/membershipTrial.ts +53 -0
- package/src/ui/headless/membership/useMembershipCheckout.ts +227 -8
- package/src/ui/index.ts +16 -0
- package/src/ui/media/CallStage.tsx +107 -10
- package/src/ui/media/_tests/CallBreakoutsPanel.spec.tsx +271 -0
- package/src/ui/media/_tests/CallStageBreakouts.spec.tsx +212 -0
- package/src/ui/media/_tests/callState.spec.ts +103 -1
- package/src/ui/media/callBreakouts.ts +57 -0
- package/src/ui/media/callState.ts +83 -23
- package/src/ui/media/index.ts +11 -0
- package/src/ui/media/panels/CallBreakoutsPanel.tsx +395 -0
- package/src/ui/media/panels/CallSidePanel.tsx +37 -4
- package/src/ui/media/stage/CallCenterNotice.tsx +5 -3
- package/src/ui/media/stage/CallControlsBar.tsx +46 -0
- package/src/ui/media/stage/CallTopBar.tsx +1 -1
- package/src/ui/payment/ForgePaymentProvider.tsx +14 -0
- package/src/ui/payment/ForgeStripePayment.tsx +78 -7
- package/src/ui/payment/_tests/ForgeStripePaymentTrial.spec.tsx +101 -0
- package/src/ui/payment/_tests/stripeIntentKind.spec.ts +43 -0
- package/src/ui/payment/stripeIntentKind.ts +32 -0
- package/src/ui/shell/TribeNestApp.tsx +1 -1
- package/src/ui/styled/AccountDashboard.tsx +165 -2
- package/src/ui/styled/BundleConfirmation.tsx +47 -2
- package/src/ui/styled/Cart.tsx +211 -135
- package/src/ui/styled/Checkout.tsx +163 -32
- package/src/ui/styled/EmailListForm.tsx +7 -9
- package/src/ui/styled/EventTickets.tsx +143 -4
- package/src/ui/styled/LoginForm.tsx +5 -3
- package/src/ui/styled/MembershipCheckout.tsx +596 -256
- package/src/ui/styled/MembershipTierCallout.tsx +17 -3
- package/src/ui/styled/MembershipTiers.tsx +148 -6
- package/src/ui/styled/PhoneInput.tsx +127 -0
- package/src/ui/styled/ProductGrid.tsx +14 -2
- package/src/ui/styled/SignupForm.tsx +4 -2
- package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +36 -0
- package/src/ui/styled/_tests/AccountDashboardTrial.spec.tsx +105 -0
- package/src/ui/styled/_tests/CartSeats.spec.tsx +109 -0
- package/src/ui/styled/_tests/CheckoutSeatsConflict.spec.tsx +194 -0
- package/src/ui/styled/_tests/EventSeatPicker.spec.tsx +199 -0
- package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +106 -0
- package/src/ui/styled/_tests/membershipTiersCuratedAccess.spec.tsx +139 -0
- package/src/ui/styled/_tests/membershipTiersTrial.spec.tsx +97 -0
- package/src/ui/styled/forge-utilities.css +67 -0
- package/src/ui/styled/seating/EventSeatPicker.tsx +190 -0
- package/src/ui/styled/seating/SeatLegend.tsx +73 -0
- package/src/ui/styled/seating/SeatMapCanvas.tsx +647 -0
- package/src/ui/styled/seating/paint.ts +79 -0
- package/src/ui/styled/seating/useSeatMapViewport.ts +248 -0
- package/src/ui/styled/seating/viewport.ts +86 -0
- package/src/utils/_tests/engagementTracker.spec.ts +111 -0
- package/src/utils/engagementTracker.ts +73 -0
- package/src/utils/landing.ts +1 -1
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
import { useState, type CSSProperties } from "react";
|
|
2
|
+
import { DoorOpen, Undo2 } from "lucide-react";
|
|
3
|
+
|
|
4
|
+
import type { ForgeT } from "../../../i18n";
|
|
5
|
+
import { CallHelpHint } from "../CallHelpHint";
|
|
6
|
+
import type { BreakoutParticipant, BreakoutRoomSummary, CallStageBreakouts } from "../callBreakouts";
|
|
7
|
+
import type { StageTokens } from "../stage/stageTheme";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The rooms rail: breakout rooms, as the host runs them and as everyone else
|
|
11
|
+
* sees them.
|
|
12
|
+
*
|
|
13
|
+
* Everything this panel touches arrives through the `CallStageBreakouts`
|
|
14
|
+
* adapter, never a client: the stage serves three differently-authenticated
|
|
15
|
+
* surfaces and the admin dashboard mounts no `ForgeProvider`. The one thing
|
|
16
|
+
* the panel does NOT do itself is move the viewer: a self-move is the stage's
|
|
17
|
+
* flow (write the assignment, then `room.moveTo()`), so the Join and Return
|
|
18
|
+
* buttons call `onSelfMove` and the stage owns the reconnect.
|
|
19
|
+
*
|
|
20
|
+
* Copy: explanations live behind a `?`; what stays visible is state (where you
|
|
21
|
+
* are, who is where), errors, and the one warning a host has to read before
|
|
22
|
+
* closing every room.
|
|
23
|
+
*/
|
|
24
|
+
export function CallBreakoutsPanel({
|
|
25
|
+
breakouts,
|
|
26
|
+
myIdentity,
|
|
27
|
+
onSelfMove,
|
|
28
|
+
movePending,
|
|
29
|
+
moveError,
|
|
30
|
+
tokens,
|
|
31
|
+
t,
|
|
32
|
+
}: {
|
|
33
|
+
breakouts: CallStageBreakouts;
|
|
34
|
+
myIdentity: string | null;
|
|
35
|
+
onSelfMove: (roomPublicId: string | null) => void;
|
|
36
|
+
/** The stage's own self-move is in flight. */
|
|
37
|
+
movePending: boolean;
|
|
38
|
+
/** The stage's own self-move failed, verbatim from the server when it said why. */
|
|
39
|
+
moveError: string | null;
|
|
40
|
+
tokens: StageTokens;
|
|
41
|
+
t: ForgeT;
|
|
42
|
+
}) {
|
|
43
|
+
const [error, setError] = useState<string | null>(null);
|
|
44
|
+
const busy = breakouts.pending || movePending;
|
|
45
|
+
|
|
46
|
+
// Every adapter call reports the same way: the server's sentence when it
|
|
47
|
+
// gave one, else our own. Visible, because an error is never help.
|
|
48
|
+
const run = (action: () => Promise<void>) => {
|
|
49
|
+
setError(null);
|
|
50
|
+
void action().catch((cause: unknown) => {
|
|
51
|
+
const message = cause instanceof Error && cause.message ? cause.message : t("forge.call_stage.breakouts.error");
|
|
52
|
+
setError(message);
|
|
53
|
+
});
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<div
|
|
58
|
+
data-testid="call-breakouts-panel"
|
|
59
|
+
style={{
|
|
60
|
+
flex: 1,
|
|
61
|
+
minHeight: 0,
|
|
62
|
+
overflowY: "auto",
|
|
63
|
+
display: "flex",
|
|
64
|
+
flexDirection: "column",
|
|
65
|
+
gap: "0.75rem",
|
|
66
|
+
padding: "0.75rem",
|
|
67
|
+
fontSize: "0.8125rem",
|
|
68
|
+
}}
|
|
69
|
+
>
|
|
70
|
+
{!breakouts.ready && <span style={{ color: tokens.stageMuted }}>{t("forge.call_stage.breakouts.loading")}</span>}
|
|
71
|
+
|
|
72
|
+
{breakouts.ready && breakouts.canManage && (
|
|
73
|
+
<HostView breakouts={breakouts} myIdentity={myIdentity} onSelfMove={onSelfMove} busy={busy} run={run} tokens={tokens} t={t} />
|
|
74
|
+
)}
|
|
75
|
+
{breakouts.ready && !breakouts.canManage && (
|
|
76
|
+
<AttendeeView breakouts={breakouts} onSelfMove={onSelfMove} busy={busy} tokens={tokens} t={t} />
|
|
77
|
+
)}
|
|
78
|
+
|
|
79
|
+
{(error || moveError) && (
|
|
80
|
+
<span role="alert" data-testid="breakouts-error" style={{ color: tokens.stageText }}>
|
|
81
|
+
{error ?? moveError}
|
|
82
|
+
</span>
|
|
83
|
+
)}
|
|
84
|
+
</div>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const panelButton = (tokens: StageTokens, primary: boolean, busy: boolean, danger = false): CSSProperties => ({
|
|
89
|
+
display: "inline-flex",
|
|
90
|
+
alignItems: "center",
|
|
91
|
+
gap: "0.3rem",
|
|
92
|
+
padding: "0.3rem 0.65rem",
|
|
93
|
+
borderRadius: tokens.cornerRadius,
|
|
94
|
+
border: `1px solid ${danger ? tokens.danger : primary ? tokens.accent : tokens.stageBorder}`,
|
|
95
|
+
backgroundColor: primary ? tokens.accent : "transparent",
|
|
96
|
+
color: primary ? tokens.accentText : tokens.stageText,
|
|
97
|
+
cursor: busy ? "progress" : "pointer",
|
|
98
|
+
opacity: busy ? 0.6 : 1,
|
|
99
|
+
font: "inherit",
|
|
100
|
+
fontSize: "0.75rem",
|
|
101
|
+
whiteSpace: "nowrap",
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const fieldStyle = (tokens: StageTokens): CSSProperties => ({
|
|
105
|
+
padding: "0.3rem 0.45rem",
|
|
106
|
+
borderRadius: tokens.cornerRadius,
|
|
107
|
+
border: `1px solid ${tokens.stageBorder}`,
|
|
108
|
+
backgroundColor: tokens.stageBg,
|
|
109
|
+
color: tokens.stageText,
|
|
110
|
+
font: "inherit",
|
|
111
|
+
fontSize: "0.8125rem",
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const labelStyle: CSSProperties = { display: "flex", alignItems: "center", gap: "0.35rem", color: "inherit" };
|
|
115
|
+
|
|
116
|
+
const roomHeading = (tokens: StageTokens): CSSProperties => ({
|
|
117
|
+
display: "flex",
|
|
118
|
+
alignItems: "center",
|
|
119
|
+
gap: "0.5rem",
|
|
120
|
+
fontWeight: 600,
|
|
121
|
+
color: tokens.stageText,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const nameOf = (person: BreakoutParticipant): string => person.name?.trim() || person.identity;
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Everyone who belongs to a room, arrived or not. Live presence first (the
|
|
128
|
+
* plane's own account), then anybody assigned who has not landed yet, so a
|
|
129
|
+
* host sees the person they just sent to Room 2 listed there before the
|
|
130
|
+
* reconnect completes rather than vanishing for a second.
|
|
131
|
+
*/
|
|
132
|
+
function roster(room: Pick<BreakoutRoomSummary, "participants" | "assigned">): (BreakoutParticipant & { arrived: boolean })[] {
|
|
133
|
+
const rows = room.participants.map((person) => ({ ...person, arrived: true }));
|
|
134
|
+
const seen = new Set(rows.map((person) => person.identity));
|
|
135
|
+
for (const person of room.assigned) {
|
|
136
|
+
if (seen.has(person.identity)) continue;
|
|
137
|
+
seen.add(person.identity);
|
|
138
|
+
rows.push({ ...person, arrived: false });
|
|
139
|
+
}
|
|
140
|
+
return rows;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function HostView({
|
|
144
|
+
breakouts,
|
|
145
|
+
myIdentity,
|
|
146
|
+
onSelfMove,
|
|
147
|
+
busy,
|
|
148
|
+
run,
|
|
149
|
+
tokens,
|
|
150
|
+
t,
|
|
151
|
+
}: {
|
|
152
|
+
breakouts: CallStageBreakouts;
|
|
153
|
+
myIdentity: string | null;
|
|
154
|
+
onSelfMove: (roomPublicId: string | null) => void;
|
|
155
|
+
busy: boolean;
|
|
156
|
+
run: (action: () => Promise<void>) => void;
|
|
157
|
+
tokens: StageTokens;
|
|
158
|
+
t: ForgeT;
|
|
159
|
+
}) {
|
|
160
|
+
const [count, setCount] = useState(2);
|
|
161
|
+
const [assign, setAssign] = useState<"auto" | "manual">("auto");
|
|
162
|
+
const [confirmingClose, setConfirmingClose] = useState(false);
|
|
163
|
+
|
|
164
|
+
if (!breakouts.active) {
|
|
165
|
+
const validCount = Number.isInteger(count) && count >= 1 && count <= 20;
|
|
166
|
+
return (
|
|
167
|
+
<form
|
|
168
|
+
data-testid="breakouts-create"
|
|
169
|
+
onSubmit={(event) => {
|
|
170
|
+
event.preventDefault();
|
|
171
|
+
if (!validCount) return;
|
|
172
|
+
run(() => breakouts.create({ count, assign }));
|
|
173
|
+
}}
|
|
174
|
+
style={{ display: "flex", flexDirection: "column", gap: "0.625rem" }}
|
|
175
|
+
>
|
|
176
|
+
<div style={roomHeading(tokens)}>
|
|
177
|
+
{t("forge.call_stage.breakouts.create_title")}
|
|
178
|
+
<CallHelpHint label={t("forge.call_stage.breakouts.help_create_label")}>{t("forge.call_stage.breakouts.help_create")}</CallHelpHint>
|
|
179
|
+
</div>
|
|
180
|
+
<label style={labelStyle}>
|
|
181
|
+
<span style={{ flex: 1 }}>{t("forge.call_stage.breakouts.count_label")}</span>
|
|
182
|
+
<input
|
|
183
|
+
type="number"
|
|
184
|
+
min={1}
|
|
185
|
+
max={20}
|
|
186
|
+
step={1}
|
|
187
|
+
value={count}
|
|
188
|
+
onChange={(event) => setCount(Number(event.target.value))}
|
|
189
|
+
style={{ ...fieldStyle(tokens), width: "4.5rem" }}
|
|
190
|
+
/>
|
|
191
|
+
</label>
|
|
192
|
+
<label style={labelStyle}>
|
|
193
|
+
<span style={{ flex: 1 }}>{t("forge.call_stage.breakouts.assign_label")}</span>
|
|
194
|
+
<select value={assign} onChange={(event) => setAssign(event.target.value as "auto" | "manual")} style={fieldStyle(tokens)}>
|
|
195
|
+
<option value="auto">{t("forge.call_stage.breakouts.assign_auto")}</option>
|
|
196
|
+
<option value="manual">{t("forge.call_stage.breakouts.assign_manual")}</option>
|
|
197
|
+
</select>
|
|
198
|
+
</label>
|
|
199
|
+
<button type="submit" disabled={busy || !validCount} aria-busy={busy} style={{ ...panelButton(tokens, true, busy), alignSelf: "flex-start" }}>
|
|
200
|
+
<DoorOpen width={14} height={14} aria-hidden="true" />
|
|
201
|
+
{t("forge.call_stage.breakouts.create")}
|
|
202
|
+
</button>
|
|
203
|
+
</form>
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const roomOptions = [{ value: "", label: t("forge.call_stage.breakouts.main_room") }, ...breakouts.rooms.map((room) => ({ value: room.publicId, label: room.name }))];
|
|
208
|
+
|
|
209
|
+
const rows = (room: Pick<BreakoutRoomSummary, "participants" | "assigned">, here: string | null) =>
|
|
210
|
+
roster(room).map((person) => {
|
|
211
|
+
const mine = person.identity === myIdentity;
|
|
212
|
+
return (
|
|
213
|
+
<div key={person.identity} style={{ display: "flex", alignItems: "center", gap: "0.5rem", padding: "0.2rem 0" }}>
|
|
214
|
+
<span style={{ flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
|
215
|
+
{nameOf(person)}
|
|
216
|
+
{mine && <span style={{ color: tokens.stageMuted }}> {t("forge.call_stage.you_suffix")}</span>}
|
|
217
|
+
{!person.arrived && <span style={{ color: tokens.stageMuted }}> {t("forge.call_stage.breakouts.assigned_not_arrived")}</span>}
|
|
218
|
+
</span>
|
|
219
|
+
{!mine && (
|
|
220
|
+
<select
|
|
221
|
+
aria-label={t("forge.call_stage.breakouts.move_to_label", { name: nameOf(person) })}
|
|
222
|
+
value={here ?? ""}
|
|
223
|
+
disabled={busy}
|
|
224
|
+
onChange={(event) => run(() => breakouts.assign(person.identity, event.target.value || null))}
|
|
225
|
+
style={{ ...fieldStyle(tokens), fontSize: "0.75rem", padding: "0.15rem 0.3rem" }}
|
|
226
|
+
>
|
|
227
|
+
{roomOptions.map((option) => (
|
|
228
|
+
<option key={option.value} value={option.value}>
|
|
229
|
+
{option.label}
|
|
230
|
+
</option>
|
|
231
|
+
))}
|
|
232
|
+
</select>
|
|
233
|
+
)}
|
|
234
|
+
</div>
|
|
235
|
+
);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
const joinButton = (roomPublicId: string | null) => {
|
|
239
|
+
const here = (breakouts.current?.publicId ?? null) === roomPublicId;
|
|
240
|
+
return here ? (
|
|
241
|
+
<span style={{ fontSize: "0.6875rem", color: tokens.stageMuted }}>{t("forge.call_stage.breakouts.current_marker")}</span>
|
|
242
|
+
) : (
|
|
243
|
+
<button
|
|
244
|
+
type="button"
|
|
245
|
+
data-testid={roomPublicId ? `breakouts-join-${roomPublicId}` : "breakouts-join-main"}
|
|
246
|
+
onClick={() => onSelfMove(roomPublicId)}
|
|
247
|
+
disabled={busy}
|
|
248
|
+
aria-busy={busy}
|
|
249
|
+
style={{ ...panelButton(tokens, false, busy), marginLeft: "auto" }}
|
|
250
|
+
>
|
|
251
|
+
{roomPublicId ? t("forge.call_stage.breakouts.join") : t("forge.call_stage.breakouts.return_to_main")}
|
|
252
|
+
</button>
|
|
253
|
+
);
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
return (
|
|
257
|
+
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
|
|
258
|
+
<div style={roomHeading(tokens)}>
|
|
259
|
+
{t("forge.call_stage.breakouts.create_title")}
|
|
260
|
+
<CallHelpHint label={t("forge.call_stage.breakouts.help_join_label")}>{t("forge.call_stage.breakouts.help_join")}</CallHelpHint>
|
|
261
|
+
</div>
|
|
262
|
+
|
|
263
|
+
<section data-testid="breakouts-room-main" style={{ display: "flex", flexDirection: "column", gap: "0.25rem" }}>
|
|
264
|
+
<div style={roomHeading(tokens)}>
|
|
265
|
+
<span>{t("forge.call_stage.breakouts.main_room")}</span>
|
|
266
|
+
<span style={{ fontWeight: 400, color: tokens.stageMuted }}>
|
|
267
|
+
{t("forge.call_stage.breakouts.participant_count", { count: breakouts.main.participantCount })}
|
|
268
|
+
</span>
|
|
269
|
+
{joinButton(null)}
|
|
270
|
+
</div>
|
|
271
|
+
{breakouts.main.participants.length === 0 && <span style={{ color: tokens.stageMuted }}>{t("forge.call_stage.breakouts.empty_room")}</span>}
|
|
272
|
+
{rows({ participants: breakouts.main.participants, assigned: [] }, null)}
|
|
273
|
+
</section>
|
|
274
|
+
|
|
275
|
+
{breakouts.rooms.map((room) => (
|
|
276
|
+
<section key={room.publicId} data-testid={`breakouts-room-${room.publicId}`} style={{ display: "flex", flexDirection: "column", gap: "0.25rem" }}>
|
|
277
|
+
<div style={roomHeading(tokens)}>
|
|
278
|
+
<span>{room.name}</span>
|
|
279
|
+
<span style={{ fontWeight: 400, color: tokens.stageMuted }}>
|
|
280
|
+
{t("forge.call_stage.breakouts.participant_count", { count: room.participantCount })}
|
|
281
|
+
</span>
|
|
282
|
+
{joinButton(room.publicId)}
|
|
283
|
+
</div>
|
|
284
|
+
{room.participants.length === 0 && room.assigned.length === 0 && (
|
|
285
|
+
<span style={{ color: tokens.stageMuted }}>{t("forge.call_stage.breakouts.empty_room")}</span>
|
|
286
|
+
)}
|
|
287
|
+
{rows(room, room.publicId)}
|
|
288
|
+
</section>
|
|
289
|
+
))}
|
|
290
|
+
|
|
291
|
+
{confirmingClose ? (
|
|
292
|
+
<div
|
|
293
|
+
role="group"
|
|
294
|
+
aria-label={t("forge.call_stage.breakouts.close_all")}
|
|
295
|
+
style={{
|
|
296
|
+
display: "flex",
|
|
297
|
+
flexDirection: "column",
|
|
298
|
+
gap: "0.5rem",
|
|
299
|
+
padding: "0.625rem",
|
|
300
|
+
borderRadius: tokens.cornerRadius,
|
|
301
|
+
border: `1px solid ${tokens.danger}`,
|
|
302
|
+
}}
|
|
303
|
+
>
|
|
304
|
+
{/* A warning about what a control is about to do must be read, so it
|
|
305
|
+
is content here, never a `?` hint. */}
|
|
306
|
+
<span>{t("forge.call_stage.breakouts.close_confirm_body")}</span>
|
|
307
|
+
<div style={{ display: "flex", gap: "0.5rem" }}>
|
|
308
|
+
<button
|
|
309
|
+
type="button"
|
|
310
|
+
data-testid="breakouts-close-confirm"
|
|
311
|
+
disabled={busy}
|
|
312
|
+
onClick={() => {
|
|
313
|
+
setConfirmingClose(false);
|
|
314
|
+
run(() => breakouts.closeAll());
|
|
315
|
+
}}
|
|
316
|
+
style={panelButton(tokens, false, busy, true)}
|
|
317
|
+
>
|
|
318
|
+
{t("forge.call_stage.breakouts.close_confirm")}
|
|
319
|
+
</button>
|
|
320
|
+
<button type="button" onClick={() => setConfirmingClose(false)} style={panelButton(tokens, false, false)}>
|
|
321
|
+
{t("forge.call_stage.breakouts.close_cancel")}
|
|
322
|
+
</button>
|
|
323
|
+
</div>
|
|
324
|
+
</div>
|
|
325
|
+
) : (
|
|
326
|
+
<button
|
|
327
|
+
type="button"
|
|
328
|
+
data-testid="breakouts-close-all"
|
|
329
|
+
onClick={() => setConfirmingClose(true)}
|
|
330
|
+
disabled={busy}
|
|
331
|
+
style={{ ...panelButton(tokens, false, busy, true), alignSelf: "flex-start" }}
|
|
332
|
+
>
|
|
333
|
+
{t("forge.call_stage.breakouts.close_all")}
|
|
334
|
+
</button>
|
|
335
|
+
)}
|
|
336
|
+
</div>
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function AttendeeView({
|
|
341
|
+
breakouts,
|
|
342
|
+
onSelfMove,
|
|
343
|
+
busy,
|
|
344
|
+
tokens,
|
|
345
|
+
t,
|
|
346
|
+
}: {
|
|
347
|
+
breakouts: CallStageBreakouts;
|
|
348
|
+
onSelfMove: (roomPublicId: string | null) => void;
|
|
349
|
+
busy: boolean;
|
|
350
|
+
tokens: StageTokens;
|
|
351
|
+
t: ForgeT;
|
|
352
|
+
}) {
|
|
353
|
+
return (
|
|
354
|
+
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
|
|
355
|
+
<div style={roomHeading(tokens)}>
|
|
356
|
+
{/* Where you are is STATE, and stays visible. */}
|
|
357
|
+
<span data-testid="breakouts-where">
|
|
358
|
+
{breakouts.current
|
|
359
|
+
? t("forge.call_stage.breakouts.you_are_in", { room: breakouts.current.name })
|
|
360
|
+
: t("forge.call_stage.breakouts.you_are_in_main")}
|
|
361
|
+
</span>
|
|
362
|
+
<CallHelpHint label={t("forge.call_stage.breakouts.help_attendee_label")}>{t("forge.call_stage.breakouts.help_attendee")}</CallHelpHint>
|
|
363
|
+
</div>
|
|
364
|
+
|
|
365
|
+
<ul style={{ listStyle: "none", margin: 0, padding: 0, display: "flex", flexDirection: "column", gap: "0.25rem" }}>
|
|
366
|
+
<li style={{ display: "flex", gap: "0.5rem" }}>
|
|
367
|
+
<span>{t("forge.call_stage.breakouts.main_room")}</span>
|
|
368
|
+
{!breakouts.current && <span style={{ color: tokens.stageMuted }}>{t("forge.call_stage.breakouts.current_marker")}</span>}
|
|
369
|
+
</li>
|
|
370
|
+
{breakouts.rooms.map((room) => (
|
|
371
|
+
<li key={room.publicId} style={{ display: "flex", gap: "0.5rem" }}>
|
|
372
|
+
<span>{room.name}</span>
|
|
373
|
+
{breakouts.current?.publicId === room.publicId && (
|
|
374
|
+
<span style={{ color: tokens.stageMuted }}>{t("forge.call_stage.breakouts.current_marker")}</span>
|
|
375
|
+
)}
|
|
376
|
+
</li>
|
|
377
|
+
))}
|
|
378
|
+
</ul>
|
|
379
|
+
|
|
380
|
+
{breakouts.current && (
|
|
381
|
+
<button
|
|
382
|
+
type="button"
|
|
383
|
+
data-testid="breakouts-return-main"
|
|
384
|
+
onClick={() => onSelfMove(null)}
|
|
385
|
+
disabled={busy}
|
|
386
|
+
aria-busy={busy}
|
|
387
|
+
style={{ ...panelButton(tokens, true, busy), alignSelf: "flex-start" }}
|
|
388
|
+
>
|
|
389
|
+
<Undo2 width={14} height={14} aria-hidden="true" />
|
|
390
|
+
{t("forge.call_stage.breakouts.return_to_main_room")}
|
|
391
|
+
</button>
|
|
392
|
+
)}
|
|
393
|
+
</div>
|
|
394
|
+
);
|
|
395
|
+
}
|
|
@@ -2,18 +2,30 @@ import { X } from "lucide-react";
|
|
|
2
2
|
import type { RaisedHand } from "@tribe-nest/media-client";
|
|
3
3
|
|
|
4
4
|
import type { ForgeT } from "../../../i18n";
|
|
5
|
+
import type { CallStageBreakouts } from "../callBreakouts";
|
|
5
6
|
import type { CallStageChat } from "../callChat";
|
|
6
7
|
import type { CallTile } from "../callState";
|
|
7
8
|
import type { StageTokens } from "../stage/stageTheme";
|
|
9
|
+
import { CallBreakoutsPanel } from "./CallBreakoutsPanel";
|
|
8
10
|
import { CallChatPanel } from "./CallChatPanel";
|
|
9
11
|
import { CallParticipantsPanel } from "./CallParticipantsPanel";
|
|
10
12
|
|
|
11
|
-
export type SidePanelTab = "chat" | "people";
|
|
13
|
+
export type SidePanelTab = "chat" | "people" | "rooms";
|
|
12
14
|
|
|
13
15
|
/**
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
16
|
+
* Is the rooms tab drawn at all? Only when the page injected an adapter AND
|
|
17
|
+
* there is something to show: a host can always open it (to create rooms), and
|
|
18
|
+
* everyone else sees it only while rooms are open. A call where nobody creates
|
|
19
|
+
* breakouts draws the two tabs it always drew.
|
|
20
|
+
*/
|
|
21
|
+
export const showsRoomsTab = (breakouts: CallStageBreakouts | undefined): breakouts is CallStageBreakouts =>
|
|
22
|
+
!!breakouts && (breakouts.canManage || breakouts.active);
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The right rail: one width, two tabs (three with breakout rooms), the
|
|
26
|
+
* Zoom-familiar shape. Tabs rather than stacked panels because the rail's
|
|
27
|
+
* width is the stage's loss, and two rails would take a third of a laptop
|
|
28
|
+
* screen.
|
|
17
29
|
*/
|
|
18
30
|
export function CallSidePanel({
|
|
19
31
|
tab,
|
|
@@ -29,6 +41,10 @@ export function CallSidePanel({
|
|
|
29
41
|
onLowerHand,
|
|
30
42
|
onLowerAll,
|
|
31
43
|
unread,
|
|
44
|
+
breakouts,
|
|
45
|
+
onSelfMove,
|
|
46
|
+
movePending = false,
|
|
47
|
+
moveError = null,
|
|
32
48
|
fill = false,
|
|
33
49
|
}: {
|
|
34
50
|
tab: SidePanelTab;
|
|
@@ -44,6 +60,12 @@ export function CallSidePanel({
|
|
|
44
60
|
onLowerHand: (identity: string) => void;
|
|
45
61
|
onLowerAll: () => void;
|
|
46
62
|
unread: number;
|
|
63
|
+
/** The rooms adapter. Absent, or inactive for a non-host, draws no tab. */
|
|
64
|
+
breakouts?: CallStageBreakouts | undefined;
|
|
65
|
+
/** The stage's self-move flow (assignment, then `room.moveTo()`). */
|
|
66
|
+
onSelfMove?: ((roomPublicId: string | null) => void) | undefined;
|
|
67
|
+
movePending?: boolean | undefined;
|
|
68
|
+
moveError?: string | null | undefined;
|
|
47
69
|
/** Fill the parent (a full-screen overlay on a phone) instead of the fixed
|
|
48
70
|
* docked width. */
|
|
49
71
|
fill?: boolean;
|
|
@@ -111,6 +133,7 @@ export function CallSidePanel({
|
|
|
111
133
|
<div style={{ display: "flex", alignItems: "center", borderBottom: `1px solid ${tokens.stageBorder}` }}>
|
|
112
134
|
{chat && tabButton("chat", t("forge.call_stage.tab_chat"), tab === "chat" ? 0 : unread)}
|
|
113
135
|
{tabButton("people", t("forge.call_stage.tab_people"), hands.length)}
|
|
136
|
+
{showsRoomsTab(breakouts) && tabButton("rooms", t("forge.call_stage.breakouts.tab"))}
|
|
114
137
|
<button
|
|
115
138
|
type="button"
|
|
116
139
|
onClick={onClose}
|
|
@@ -135,6 +158,16 @@ export function CallSidePanel({
|
|
|
135
158
|
|
|
136
159
|
{tab === "chat" && chat ? (
|
|
137
160
|
<CallChatPanel chat={chat} tokens={tokens} t={t} />
|
|
161
|
+
) : tab === "rooms" && showsRoomsTab(breakouts) ? (
|
|
162
|
+
<CallBreakoutsPanel
|
|
163
|
+
breakouts={breakouts}
|
|
164
|
+
myIdentity={myIdentity}
|
|
165
|
+
onSelfMove={onSelfMove ?? (() => undefined)}
|
|
166
|
+
movePending={movePending}
|
|
167
|
+
moveError={moveError}
|
|
168
|
+
tokens={tokens}
|
|
169
|
+
t={t}
|
|
170
|
+
/>
|
|
138
171
|
) : (
|
|
139
172
|
<CallParticipantsPanel
|
|
140
173
|
tiles={tiles}
|
|
@@ -25,8 +25,10 @@ export function CallCenterNotice({
|
|
|
25
25
|
onRetry: () => void;
|
|
26
26
|
}) {
|
|
27
27
|
const detail = notice.detailKey ? t(notice.detailKey) : notice.detailText;
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
// A breakout move is a reconnect the person asked for (or was sent on), so
|
|
29
|
+
// it spins like joining does: something is happening, nothing is wrong.
|
|
30
|
+
const busy = notice.kind === "joining" || notice.kind === "moving";
|
|
31
|
+
const Icon = busy ? Loader2 : notice.kind === "waiting" || notice.kind === "everyone_left" ? Users : PhoneOff;
|
|
30
32
|
|
|
31
33
|
return (
|
|
32
34
|
<div
|
|
@@ -59,7 +61,7 @@ export function CallCenterNotice({
|
|
|
59
61
|
color={notice.kind === "failed" ? tokens.danger : tokens.stageMuted}
|
|
60
62
|
style={busy ? { animation: "forge-call-spin 1.2s linear infinite" } : undefined}
|
|
61
63
|
/>
|
|
62
|
-
<strong style={{ fontSize: "1.05rem" }}>{t(notice.headlineKey)}</strong>
|
|
64
|
+
<strong style={{ fontSize: "1.05rem" }}>{t(notice.headlineKey, notice.headlineVars)}</strong>
|
|
63
65
|
{detail && <span style={{ fontSize: "0.875rem", color: tokens.stageMuted, maxWidth: "28rem" }}>{detail}</span>}
|
|
64
66
|
{notice.canRetry && (
|
|
65
67
|
<button
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef, useState, type CSSProperties } from "react";
|
|
2
2
|
import {
|
|
3
3
|
Circle,
|
|
4
|
+
DoorOpen,
|
|
4
5
|
Hand,
|
|
5
6
|
MessageSquare,
|
|
6
7
|
Mic,
|
|
@@ -10,6 +11,7 @@ import {
|
|
|
10
11
|
PhoneOff,
|
|
11
12
|
SmilePlus,
|
|
12
13
|
Square,
|
|
14
|
+
Undo2,
|
|
13
15
|
Users,
|
|
14
16
|
Video,
|
|
15
17
|
VideoOff,
|
|
@@ -73,6 +75,18 @@ export type CallControlsBarProps = {
|
|
|
73
75
|
onTogglePeople: () => void;
|
|
74
76
|
peopleOpen: boolean;
|
|
75
77
|
chatOpen: boolean;
|
|
78
|
+
/**
|
|
79
|
+
* Breakout rooms. All absent by default, and then nothing here is drawn:
|
|
80
|
+
* the bar renders pixel for pixel as it did before rooms existed.
|
|
81
|
+
*/
|
|
82
|
+
roomsAvailable?: boolean | undefined;
|
|
83
|
+
roomsOpen?: boolean | undefined;
|
|
84
|
+
onToggleRooms?: (() => void) | undefined;
|
|
85
|
+
/** This seat is in a breakout room, so Return to main is offered. */
|
|
86
|
+
inBreakout?: boolean | undefined;
|
|
87
|
+
onReturnToMain?: (() => void) | undefined;
|
|
88
|
+
/** The self-move is in flight. Holds the button rather than letting it be pressed twice. */
|
|
89
|
+
movePending?: boolean | undefined;
|
|
76
90
|
/** Presses of any control unlock the chime AudioContext. */
|
|
77
91
|
onGesture?: (() => void) | undefined;
|
|
78
92
|
};
|
|
@@ -372,7 +386,39 @@ export function CallControlsBar(props: CallControlsBarProps) {
|
|
|
372
386
|
)}
|
|
373
387
|
</div>
|
|
374
388
|
|
|
389
|
+
{props.inBreakout && props.onReturnToMain && (
|
|
390
|
+
<button
|
|
391
|
+
type="button"
|
|
392
|
+
data-testid="return-to-main"
|
|
393
|
+
onClick={() => {
|
|
394
|
+
gesture();
|
|
395
|
+
props.onReturnToMain?.();
|
|
396
|
+
}}
|
|
397
|
+
disabled={props.movePending}
|
|
398
|
+
aria-busy={props.movePending}
|
|
399
|
+
style={barButton(tokens, false, props.movePending)}
|
|
400
|
+
>
|
|
401
|
+
<Undo2 width={16} height={16} aria-hidden="true" />
|
|
402
|
+
{t("forge.call_stage.breakouts.return_to_main")}
|
|
403
|
+
</button>
|
|
404
|
+
)}
|
|
405
|
+
|
|
375
406
|
<span style={{ marginLeft: "auto", display: "inline-flex", gap: "0.5rem" }}>
|
|
407
|
+
{props.roomsAvailable && props.onToggleRooms && (
|
|
408
|
+
<button
|
|
409
|
+
type="button"
|
|
410
|
+
data-testid="toggle-rooms"
|
|
411
|
+
onClick={() => {
|
|
412
|
+
gesture();
|
|
413
|
+
props.onToggleRooms?.();
|
|
414
|
+
}}
|
|
415
|
+
aria-pressed={!!props.roomsOpen}
|
|
416
|
+
style={barButton(tokens, !!props.roomsOpen)}
|
|
417
|
+
>
|
|
418
|
+
<DoorOpen width={16} height={16} aria-hidden="true" />
|
|
419
|
+
{t("forge.call_stage.breakouts.button")}
|
|
420
|
+
</button>
|
|
421
|
+
)}
|
|
376
422
|
{props.chatAvailable && (
|
|
377
423
|
<button
|
|
378
424
|
type="button"
|
|
@@ -250,7 +250,7 @@ function ConnectionPill({ status, tokens, t }: { status: CallStatus; tokens: Sta
|
|
|
250
250
|
{status.tone === "live" && (
|
|
251
251
|
<span aria-hidden="true" style={{ width: 7, height: 7, borderRadius: 9999, backgroundColor: tokens.accent, display: "inline-block" }} />
|
|
252
252
|
)}
|
|
253
|
-
<span style={{ fontWeight: status.tone === "failed" ? 600 : 400 }}>{t(status.headlineKey)}</span>
|
|
253
|
+
<span style={{ fontWeight: status.tone === "failed" ? 600 : 400 }}>{t(status.headlineKey, status.headlineVars)}</span>
|
|
254
254
|
{detail && status.tone !== "live" && <span>{detail}</span>}
|
|
255
255
|
</span>
|
|
256
256
|
);
|
|
@@ -31,6 +31,20 @@ export interface PaymentRenderProps {
|
|
|
31
31
|
* nothing until approval. Only sent with `settlement: "deferred"`.
|
|
32
32
|
*/
|
|
33
33
|
deferredStrategy?: DeferredChargeStrategy | null;
|
|
34
|
+
/**
|
|
35
|
+
* Set when a SetupIntent secret starts a FREE TRIAL on a paid membership
|
|
36
|
+
* rather than an application. The built-in renderer then reads "Start free
|
|
37
|
+
* trial" and says under the button that nothing is charged today, what is
|
|
38
|
+
* charged per cycle, and from which date. Ignored on a payment secret.
|
|
39
|
+
*/
|
|
40
|
+
trial?: PaymentTrial | null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The free trial a card save starts: when the first charge falls, and its cycle. */
|
|
44
|
+
export interface PaymentTrial {
|
|
45
|
+
/** ISO date of the first charge. */
|
|
46
|
+
endsAt: string;
|
|
47
|
+
billingCycle: "month" | "year";
|
|
34
48
|
}
|
|
35
49
|
|
|
36
50
|
export type PaymentRenderer = (props: PaymentRenderProps) => ReactNode;
|