@tribe-nest/forge 3.57.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/useEvents.ts +68 -0
- package/src/data/queries/useMyBookings.ts +16 -0
- package/src/data/queries/useNotifications.ts +1 -1
- package/src/i18n/de.json +79 -0
- package/src/i18n/en.json +79 -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 +1 -1
- package/src/server/pwa.ts +1 -1
- package/src/types/models.ts +10 -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 +25 -1
- package/src/ui/index.ts +7 -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/shell/TribeNestApp.tsx +1 -1
- package/src/ui/styled/AccountDashboard.tsx +5 -0
- 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/PhoneInput.tsx +127 -0
- package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +36 -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/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,294 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import type { CountryCode, Examples } from "libphonenumber-js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Phone entry that produces E.164, because the input is the only place the
|
|
6
|
+
* country is knowable.
|
|
7
|
+
*
|
|
8
|
+
* ## Why this exists at all
|
|
9
|
+
*
|
|
10
|
+
* `services/admin/consent/phone.ts` refuses to infer a country code from a bare
|
|
11
|
+
* national number, and it is right to: guessing `+1` for a ten-digit string is
|
|
12
|
+
* wrong for most of the world, and a wrong guess writes a phantom suppression
|
|
13
|
+
* against a number that never opted out. The consequence it accepts is that a
|
|
14
|
+
* bare national number simply never matches an E.164 one.
|
|
15
|
+
*
|
|
16
|
+
* That consequence is a live compliance hole on the way IN. Someone types
|
|
17
|
+
* `07700 900123` into a signup form, it is stored verbatim, they later text
|
|
18
|
+
* STOP from `+447700900123`, and the suppression lookup misses. We keep texting
|
|
19
|
+
* someone who said stop, which is the single most expensive failure in SMS.
|
|
20
|
+
*
|
|
21
|
+
* Nothing downstream can fix that, because by then the country is gone. So the
|
|
22
|
+
* fix belongs here: ask for the country, and emit E.164 from the start.
|
|
23
|
+
*
|
|
24
|
+
* ## Metadata cost, measured
|
|
25
|
+
*
|
|
26
|
+
* `libphonenumber-js/max` is 55.5 KB gzip / 48.1 KB brotli once bundled, and the
|
|
27
|
+
* metadata is effectively the whole of it: a build importing only `AsYouType`
|
|
28
|
+
* and `parsePhoneNumberFromString` measured 35.5 KB gzip against 35.7 KB for the
|
|
29
|
+
* full `min` import, so there is nothing to win by trimming the API surface.
|
|
30
|
+
*
|
|
31
|
+
* `max` rather than the smaller tiers, and the reason is not the line-type
|
|
32
|
+
* warning. `mobile` is a mobile-ONLY dataset: under it a landline is not typed
|
|
33
|
+
* as a landline, it is INVALID. That would refuse a perfectly good courier
|
|
34
|
+
* number at checkout, and would refuse real customers in the many countries
|
|
35
|
+
* whose numbers are typed FIXED_LINE_OR_MOBILE. `min` accepts everything but
|
|
36
|
+
* reports the type inconsistently (a GB landline resolves, a DE one does not),
|
|
37
|
+
* so a warning built on it would appear or vanish by country for no reason the
|
|
38
|
+
* visitor could see. `max` is the only tier where both modes below are correct.
|
|
39
|
+
*
|
|
40
|
+
* It is loaded with a dynamic `import()` so none of it sits in the first paint.
|
|
41
|
+
* A phone field is never the thing a visitor sees first, and until the metadata
|
|
42
|
+
* lands the input still accepts typing: `isReady` is false, validation holds
|
|
43
|
+
* off, and nothing is blocked.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
export type PhoneNumberType = "mobile" | "fixedLine" | "other" | "unknown";
|
|
47
|
+
|
|
48
|
+
export interface UsePhoneInputOptions {
|
|
49
|
+
/**
|
|
50
|
+
* Where to start the country select. Pass the most confident source you have:
|
|
51
|
+
* checkout knows the shipping country, a storefront knows the visitor's geo.
|
|
52
|
+
* Never derive this from the digits someone is typing.
|
|
53
|
+
*/
|
|
54
|
+
defaultCountry?: CountryCode;
|
|
55
|
+
/**
|
|
56
|
+
* `messaging` is for a number we will text: it demands a valid number and
|
|
57
|
+
* warns on a line type that cannot receive SMS. `shipping` is for a number a
|
|
58
|
+
* courier will ring, where a landline is perfectly good and the bar is only
|
|
59
|
+
* that the number is real.
|
|
60
|
+
*/
|
|
61
|
+
mode?: "messaging" | "shipping";
|
|
62
|
+
/** Seed value, E.164 or national. An E.164 seed also sets the country. */
|
|
63
|
+
initialValue?: string;
|
|
64
|
+
/** Called with E.164 (or null while incomplete) on every change. */
|
|
65
|
+
onChange?: (e164: string | null) => void;
|
|
66
|
+
/**
|
|
67
|
+
* Set false when the field is not being shown. Hooks cannot be called
|
|
68
|
+
* conditionally, so a form with an optional phone field has to call this
|
|
69
|
+
* every render; this is what stops it fetching ~48 KB of metadata for a field
|
|
70
|
+
* nobody is going to see.
|
|
71
|
+
*/
|
|
72
|
+
enabled?: boolean;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface Metadata {
|
|
76
|
+
AsYouType: typeof import("libphonenumber-js/max").AsYouType;
|
|
77
|
+
parsePhoneNumberFromString: typeof import("libphonenumber-js/max").parsePhoneNumberFromString;
|
|
78
|
+
getCountries: typeof import("libphonenumber-js/max").getCountries;
|
|
79
|
+
getCountryCallingCode: typeof import("libphonenumber-js/max").getCountryCallingCode;
|
|
80
|
+
getExampleNumber: typeof import("libphonenumber-js/max").getExampleNumber;
|
|
81
|
+
examples: Examples;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
let metadataPromise: Promise<Metadata> | null = null;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* One shared load for the whole page. Two phone fields on one checkout must not
|
|
88
|
+
* fetch the metadata twice, and the promise is cached rather than the result so
|
|
89
|
+
* concurrent callers await the same request.
|
|
90
|
+
*/
|
|
91
|
+
function loadMetadata(): Promise<Metadata> {
|
|
92
|
+
if (!metadataPromise) {
|
|
93
|
+
metadataPromise = Promise.all([
|
|
94
|
+
import("libphonenumber-js/max"),
|
|
95
|
+
import("libphonenumber-js/examples.mobile.json"),
|
|
96
|
+
]).then(([lib, ex]) => ({
|
|
97
|
+
AsYouType: lib.AsYouType,
|
|
98
|
+
parsePhoneNumberFromString: lib.parsePhoneNumberFromString,
|
|
99
|
+
getCountries: lib.getCountries,
|
|
100
|
+
getCountryCallingCode: lib.getCountryCallingCode,
|
|
101
|
+
getExampleNumber: lib.getExampleNumber,
|
|
102
|
+
examples: ((ex as { default?: Examples }).default ?? ex) as Examples,
|
|
103
|
+
}));
|
|
104
|
+
}
|
|
105
|
+
return metadataPromise;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Let a host warm the metadata before the field is reached (on route enter, say). */
|
|
109
|
+
export const preloadPhoneMetadata = loadMetadata;
|
|
110
|
+
|
|
111
|
+
export interface CountryOption {
|
|
112
|
+
country: CountryCode;
|
|
113
|
+
callingCode: string;
|
|
114
|
+
flag: string;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Regional-indicator pair. "GB" becomes the flag, with no icon set to ship. */
|
|
118
|
+
function flagOf(country: string): string {
|
|
119
|
+
if (country.length !== 2) return "";
|
|
120
|
+
return String.fromCodePoint(
|
|
121
|
+
...[...country.toUpperCase()].map((c) => 0x1f1a5 + c.charCodeAt(0)),
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function usePhoneInput(opts: UsePhoneInputOptions = {}) {
|
|
126
|
+
const { defaultCountry, mode = "messaging", initialValue, onChange, enabled = true } = opts;
|
|
127
|
+
|
|
128
|
+
const [meta, setMeta] = useState<Metadata | null>(null);
|
|
129
|
+
const [country, setCountryState] = useState<CountryCode | undefined>(defaultCountry);
|
|
130
|
+
const [nationalValue, setNationalValue] = useState(initialValue ?? "");
|
|
131
|
+
const [touched, setTouched] = useState(false);
|
|
132
|
+
const onChangeRef = useRef(onChange);
|
|
133
|
+
onChangeRef.current = onChange;
|
|
134
|
+
|
|
135
|
+
useEffect(() => {
|
|
136
|
+
if (!enabled) return;
|
|
137
|
+
let alive = true;
|
|
138
|
+
loadMetadata().then((m) => {
|
|
139
|
+
if (alive) setMeta(m);
|
|
140
|
+
});
|
|
141
|
+
return () => {
|
|
142
|
+
alive = false;
|
|
143
|
+
};
|
|
144
|
+
}, [enabled]);
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* A `defaultCountry` that resolves late still has to land. Checkout learns the
|
|
148
|
+
* shipping country after the address is filled, so a prop that arrives second
|
|
149
|
+
* must move the select, but only while the visitor has not chosen for
|
|
150
|
+
* themselves. Overriding a deliberate choice would be worse than being wrong.
|
|
151
|
+
*/
|
|
152
|
+
useEffect(() => {
|
|
153
|
+
if (defaultCountry && !touched && !country) setCountryState(defaultCountry);
|
|
154
|
+
}, [defaultCountry, touched, country]);
|
|
155
|
+
|
|
156
|
+
const parsed = useMemo(() => {
|
|
157
|
+
if (!meta || !nationalValue.trim()) return null;
|
|
158
|
+
return meta.parsePhoneNumberFromString(nationalValue, country);
|
|
159
|
+
}, [meta, nationalValue, country]);
|
|
160
|
+
|
|
161
|
+
const e164 = parsed?.isValid() ? (parsed.number as string) : null;
|
|
162
|
+
|
|
163
|
+
const numberType: PhoneNumberType = useMemo(() => {
|
|
164
|
+
if (!parsed?.isValid()) return "unknown";
|
|
165
|
+
const t = parsed.getType();
|
|
166
|
+
if (t === "MOBILE") return "mobile";
|
|
167
|
+
if (t === "FIXED_LINE") return "fixedLine";
|
|
168
|
+
if (t === "FIXED_LINE_OR_MOBILE" || t === undefined) return "unknown";
|
|
169
|
+
return "other";
|
|
170
|
+
}, [parsed]);
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* A landline is a warning, never an error. The line type is a best guess from
|
|
174
|
+
* a numbering plan that many countries do not partition cleanly, and blocking
|
|
175
|
+
* a real number on a guess is worse than spending one credit finding out.
|
|
176
|
+
*/
|
|
177
|
+
const warning = useMemo(() => {
|
|
178
|
+
if (mode !== "messaging" || !e164) return null;
|
|
179
|
+
return numberType === "fixedLine" ? ("landline" as const) : null;
|
|
180
|
+
}, [mode, e164, numberType]);
|
|
181
|
+
|
|
182
|
+
const error = useMemo(() => {
|
|
183
|
+
if (!touched || !meta) return null;
|
|
184
|
+
const raw = nationalValue.trim();
|
|
185
|
+
if (!raw) return "required" as const;
|
|
186
|
+
if (!country && !raw.startsWith("+")) return "no_country" as const;
|
|
187
|
+
if (!parsed) return "invalid" as const;
|
|
188
|
+
if (!parsed.isValid()) return "invalid" as const;
|
|
189
|
+
return null;
|
|
190
|
+
}, [touched, meta, nationalValue, country, parsed]);
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Format as they type, in the national style of the selected country, so the
|
|
194
|
+
* value on screen matches the shape the placeholder promised.
|
|
195
|
+
*
|
|
196
|
+
* The exception is a pasted international number. The field says "without the
|
|
197
|
+
* country code" and a good share of people will paste `+44…` regardless. Left
|
|
198
|
+
* alone under a `GB` formatter that would read as a national string and come
|
|
199
|
+
* out mangled, so a leading `+` switches to international formatting and
|
|
200
|
+
* re-syncs the flag to whatever was pasted.
|
|
201
|
+
*/
|
|
202
|
+
const setValue = useCallback(
|
|
203
|
+
(raw: string) => {
|
|
204
|
+
if (!meta) {
|
|
205
|
+
setNationalValue(raw);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
const isInternational = raw.trim().startsWith("+");
|
|
209
|
+
if (isInternational) {
|
|
210
|
+
const formatter = new meta.AsYouType();
|
|
211
|
+
const formatted = formatter.input(raw);
|
|
212
|
+
const detected = formatter.getCountry();
|
|
213
|
+
if (detected && detected !== country) {
|
|
214
|
+
setCountryState(detected);
|
|
215
|
+
setTouched(true);
|
|
216
|
+
}
|
|
217
|
+
setNationalValue(formatted);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
const formatter = new meta.AsYouType(country);
|
|
221
|
+
setNationalValue(formatter.input(raw));
|
|
222
|
+
},
|
|
223
|
+
[meta, country],
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
const setCountry = useCallback(
|
|
227
|
+
(next: CountryCode) => {
|
|
228
|
+
setCountryState(next);
|
|
229
|
+
setTouched(true);
|
|
230
|
+
// Re-format what is already typed under the new country rather than
|
|
231
|
+
// clearing it: someone correcting the flag after typing should not lose
|
|
232
|
+
// their number.
|
|
233
|
+
setNationalValue((current) => {
|
|
234
|
+
if (!meta || !current.trim() || current.trim().startsWith("+")) return current;
|
|
235
|
+
const digits = current.replace(/\D/g, "");
|
|
236
|
+
return new meta.AsYouType(next).input(digits);
|
|
237
|
+
});
|
|
238
|
+
},
|
|
239
|
+
[meta],
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
useEffect(() => {
|
|
243
|
+
onChangeRef.current?.(e164);
|
|
244
|
+
}, [e164]);
|
|
245
|
+
|
|
246
|
+
const countries: CountryOption[] = useMemo(() => {
|
|
247
|
+
if (!meta) return [];
|
|
248
|
+
return meta
|
|
249
|
+
.getCountries()
|
|
250
|
+
.map((c) => ({ country: c, callingCode: `+${meta.getCountryCallingCode(c)}`, flag: flagOf(c) }))
|
|
251
|
+
.sort((a, b) => a.country.localeCompare(b.country));
|
|
252
|
+
}, [meta]);
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* The placeholder is the real national format for the chosen country, which
|
|
256
|
+
* teaches the shape better than any sentence under the field: GB shows
|
|
257
|
+
* `07700 900123`, US shows `(201) 555-0123`.
|
|
258
|
+
*/
|
|
259
|
+
const placeholder = useMemo(() => {
|
|
260
|
+
if (!meta || !country) return "";
|
|
261
|
+
const example = meta.getExampleNumber(country, meta.examples);
|
|
262
|
+
return example ? example.formatNational() : "";
|
|
263
|
+
}, [meta, country]);
|
|
264
|
+
|
|
265
|
+
return {
|
|
266
|
+
/** Loaded-metadata flag. Until true, validation holds off and typing still works. */
|
|
267
|
+
isReady: !!meta,
|
|
268
|
+
country,
|
|
269
|
+
setCountry,
|
|
270
|
+
countries,
|
|
271
|
+
callingCode: meta && country ? `+${meta.getCountryCallingCode(country)}` : "",
|
|
272
|
+
/** What the visitor sees: national format, no country code. */
|
|
273
|
+
value: nationalValue,
|
|
274
|
+
setValue,
|
|
275
|
+
placeholder,
|
|
276
|
+
/** What to submit. Null until the number is valid. */
|
|
277
|
+
e164,
|
|
278
|
+
isValid: !!e164,
|
|
279
|
+
numberType,
|
|
280
|
+
/** "landline" when messaging a number that cannot receive SMS. Never blocks. */
|
|
281
|
+
warning,
|
|
282
|
+
/** "required" | "no_country" | "invalid", or null. Only after blur. */
|
|
283
|
+
error,
|
|
284
|
+
touched,
|
|
285
|
+
markTouched: useCallback(() => setTouched(true), []),
|
|
286
|
+
reset: useCallback(() => {
|
|
287
|
+
setNationalValue("");
|
|
288
|
+
setTouched(false);
|
|
289
|
+
setCountryState(defaultCountry);
|
|
290
|
+
}, [defaultCountry]),
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export type UsePhoneInputReturn = ReturnType<typeof usePhoneInput>;
|
|
@@ -111,7 +111,7 @@ function buildModel(form?: FormData) {
|
|
|
111
111
|
|
|
112
112
|
/**
|
|
113
113
|
* Headless multi-section form flow: a paginated wizard over `usePublicForm` that
|
|
114
|
-
* mirrors the server walk (docs/forms-sections.md). Handles current-section
|
|
114
|
+
* mirrors the server walk (docs/archive/shipped/forms-sections.md). Handles current-section
|
|
115
115
|
* state, in-page field visibility (show/hide), per-section required validation,
|
|
116
116
|
* section→section branching (incl. submit), Back navigation, and submit. Bring
|
|
117
117
|
* your own markup (or use the styled `<SectionedFormRenderer>`).
|
|
@@ -135,7 +135,7 @@ export function useSectionedForm(formId?: string) {
|
|
|
135
135
|
const [error, setError] = useState("");
|
|
136
136
|
const [isCompleted, setIsCompleted] = useState(false);
|
|
137
137
|
// Graded quiz result (null until submit + only for quiz-mode forms). Shaped by
|
|
138
|
-
// the server's reveal policy — see docs/quizzes-certification-initiative.md.
|
|
138
|
+
// the server's reveal policy — see docs/archive/shipped/quizzes-certification-initiative.md.
|
|
139
139
|
const [quizResult, setQuizResult] = useState<Record<string, unknown> | null>(null);
|
|
140
140
|
|
|
141
141
|
// Start at the first section once the form loads.
|
package/src/ui/headless/index.ts
CHANGED
|
@@ -7,6 +7,14 @@ export { MembershipGate, type MembershipGateProps, type MembershipGateState } fr
|
|
|
7
7
|
// S.4 members-only gates: the notice a storefront draws BEFORE the buyer pays.
|
|
8
8
|
export { useMembershipGateNotice, type UseMembershipGateNoticeInput } from "./membership/useMembershipGateNotice";
|
|
9
9
|
export { useEmailListForm, type UseEmailListFormOptions, type FormStatus } from "./forms/useEmailListForm";
|
|
10
|
+
export {
|
|
11
|
+
usePhoneInput,
|
|
12
|
+
preloadPhoneMetadata,
|
|
13
|
+
type UsePhoneInputOptions,
|
|
14
|
+
type UsePhoneInputReturn,
|
|
15
|
+
type PhoneNumberType,
|
|
16
|
+
type CountryOption,
|
|
17
|
+
} from "./forms/usePhoneInput";
|
|
10
18
|
export { useContactForm } from "./forms/useContactForm";
|
|
11
19
|
export { useSectionedForm } from "./forms/useSectionedForm";
|
|
12
20
|
export { usePostEngagement, type UsePostEngagementOptions } from "./engagement/usePostEngagement";
|
|
@@ -52,7 +60,23 @@ export { useSignupForm, type UseSignupFormOptions } from "./auth/useSignupForm";
|
|
|
52
60
|
|
|
53
61
|
// Flow primitives (Part A2) — multi-step purchase/booking/subscribe/chat flows,
|
|
54
62
|
// each composed from the data hooks so a creator can rebuild any page.
|
|
55
|
-
export {
|
|
63
|
+
export {
|
|
64
|
+
useEventCheckout,
|
|
65
|
+
type UseEventCheckoutOptions,
|
|
66
|
+
type EventCheckoutStep,
|
|
67
|
+
type ToggleSeatResult,
|
|
68
|
+
} from "./event/useEventCheckout";
|
|
69
|
+
// Reserved seating (wave 2): the joined seating read and the pure pieces a
|
|
70
|
+
// host needs to drive a picker of its own.
|
|
71
|
+
export {
|
|
72
|
+
useEventSeating,
|
|
73
|
+
bestAvailableSeats,
|
|
74
|
+
indexEventSeats,
|
|
75
|
+
seatLabel,
|
|
76
|
+
type EventSeat,
|
|
77
|
+
type EventSeatingState,
|
|
78
|
+
type UseEventSeatingOptions,
|
|
79
|
+
} from "./event/useEventSeating";
|
|
56
80
|
// Ticket requests (curated access): the pure decisions behind the picker and
|
|
57
81
|
// the post-order routing, for a host that drives the hook from its own UI.
|
|
58
82
|
export {
|
package/src/ui/index.ts
CHANGED
|
@@ -101,6 +101,7 @@ export { CartLineOptions, resolveCartLineOptions } from "./styled/CartLineOption
|
|
|
101
101
|
export { CurrencySwitcher, type CurrencySwitcherProps } from "./styled/CurrencySwitcher";
|
|
102
102
|
export { UserMenu, type UserMenuProps } from "./styled/UserMenu";
|
|
103
103
|
export { EmailListForm, type EmailListFormProps } from "./styled/EmailListForm";
|
|
104
|
+
export { PhoneInput, type PhoneInputProps } from "./styled/PhoneInput";
|
|
104
105
|
export { BlogComments, type BlogCommentsProps } from "./styled/BlogComments";
|
|
105
106
|
export { PageActions, type PageActionsProps } from "./styled/PageActions";
|
|
106
107
|
export { MusicLinkPage, type MusicLinkPageProps } from "./styled/MusicLinkPage";
|
|
@@ -118,6 +119,12 @@ export { MembershipTierCallout, type MembershipTierCalloutProps } from "./styled
|
|
|
118
119
|
export { MembershipTiers, type MembershipTiersProps } from "./styled/MembershipTiers";
|
|
119
120
|
export { EventsList, type EventsListProps } from "./styled/EventsList";
|
|
120
121
|
export { EventTickets, type EventTicketsProps } from "./styled/EventTickets";
|
|
122
|
+
// Reserved seating (wave 2): the buyer's seat picker, on its own for a host
|
|
123
|
+
// that composes the ticket flow from `useEventCheckout`.
|
|
124
|
+
export { EventSeatPicker, type EventSeatPickerProps } from "./styled/seating/EventSeatPicker";
|
|
125
|
+
export { SeatMapCanvas, type SeatMapCanvasProps } from "./styled/seating/SeatMapCanvas";
|
|
126
|
+
export { SeatLegend, type SeatLegendProps, type SeatLegendTier } from "./styled/seating/SeatLegend";
|
|
127
|
+
export { tierColorMap, TIER_PALETTE } from "./styled/seating/paint";
|
|
121
128
|
export { EventCountdown, type EventCountdownProps } from "./styled/EventCountdown";
|
|
122
129
|
export { EventWaitlist, type EventWaitlistProps, formatCountdown } from "./styled/EventWaitlist";
|
|
123
130
|
export { EventDetail, type EventDetailProps } from "./styled/EventDetail";
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
|
|
18
18
|
import { useForgeT, type ForgeT } from "../../i18n";
|
|
19
19
|
import { useThemeTokens } from "../theme/ForgeThemeProvider";
|
|
20
|
+
import type { CallStageBreakouts } from "./callBreakouts";
|
|
20
21
|
import type { CallStageChat } from "./callChat";
|
|
21
22
|
import {
|
|
22
23
|
callHeadCount,
|
|
@@ -24,10 +25,11 @@ import {
|
|
|
24
25
|
callTiles,
|
|
25
26
|
remoteAudioProducerIds,
|
|
26
27
|
stageNotice,
|
|
28
|
+
type BreakoutMove,
|
|
27
29
|
type CallTile,
|
|
28
30
|
} from "./callState";
|
|
29
31
|
import { useFlyingReactions, useRaisedHands } from "./hooks/useCallSignals";
|
|
30
|
-
import { CallSidePanel, type SidePanelTab } from "./panels/CallSidePanel";
|
|
32
|
+
import { CallSidePanel, showsRoomsTab, type SidePanelTab } from "./panels/CallSidePanel";
|
|
31
33
|
import { resolveLayout, GALLERY_PAGE_SIZE, pickDominant, type StageLayout, type ViewChoice } from "./stage/callLayout";
|
|
32
34
|
import { diffPeers, unreadCount } from "./stage/callSignals";
|
|
33
35
|
import { createCallSounds } from "./stage/callSounds";
|
|
@@ -96,6 +98,12 @@ export type CallStageProps = {
|
|
|
96
98
|
* how. Absent means no chat tab or button is drawn.
|
|
97
99
|
*/
|
|
98
100
|
chat?: CallStageChat;
|
|
101
|
+
/**
|
|
102
|
+
* The breakout-rooms adapter, injected like `chat`. Absent means no rooms
|
|
103
|
+
* tab, no Rooms button and no Return to main: the stage draws exactly what
|
|
104
|
+
* it drew before rooms existed.
|
|
105
|
+
*/
|
|
106
|
+
breakouts?: CallStageBreakouts;
|
|
99
107
|
/** Rendering hint for host courtesies (lower-hand). The server enforces
|
|
100
108
|
* every real host action itself. */
|
|
101
109
|
isHost?: boolean;
|
|
@@ -161,6 +169,7 @@ export function CallStage({
|
|
|
161
169
|
aspectRatio = 16 / 9,
|
|
162
170
|
recordingControl,
|
|
163
171
|
chat,
|
|
172
|
+
breakouts,
|
|
164
173
|
isHost = false,
|
|
165
174
|
mediaDefaults,
|
|
166
175
|
cameraSimulcast = false,
|
|
@@ -168,7 +177,7 @@ export function CallStage({
|
|
|
168
177
|
const themeTokens = useThemeTokens();
|
|
169
178
|
const tokens = useMemo(() => stageTokens(themeTokens), [themeTokens]);
|
|
170
179
|
const t = useForgeT();
|
|
171
|
-
const { connectionState, error, recovering, retry } = useMediaRoom();
|
|
180
|
+
const { connectionState, error, recovering, retry, room } = useMediaRoom();
|
|
172
181
|
const state = useRoomState();
|
|
173
182
|
const peers = useParticipants();
|
|
174
183
|
const activeSpeakers = useActiveSpeakers();
|
|
@@ -185,9 +194,23 @@ export function CallStage({
|
|
|
185
194
|
|
|
186
195
|
const [ui, dispatch] = useReducer(uiReducer, initialUi);
|
|
187
196
|
|
|
197
|
+
/**
|
|
198
|
+
* A breakout move in flight, from either side.
|
|
199
|
+
*
|
|
200
|
+
* The node's `moveRequested` lands in `state.moving` (the host sent us
|
|
201
|
+
* somewhere); a Join or Return the person pressed themselves goes through
|
|
202
|
+
* `room.moveTo()`, which the SDK reports exactly like a server drain, so the
|
|
203
|
+
* stage remembers the destination itself in `localMoving` until the room is
|
|
204
|
+
* back. Both read the same way everywhere below.
|
|
205
|
+
*/
|
|
206
|
+
const [localMoving, setLocalMoving] = useState<BreakoutMove | null>(null);
|
|
207
|
+
const [movePending, setMovePending] = useState(false);
|
|
208
|
+
const [moveError, setMoveError] = useState<string | null>(null);
|
|
209
|
+
const moving: BreakoutMove | null = state.moving ?? localMoving;
|
|
210
|
+
|
|
188
211
|
const status = useMemo(
|
|
189
|
-
() => callStatus({ connectionState, error, recovering, phase: state.phase, closedReason: state.closedReason }),
|
|
190
|
-
[connectionState, error, recovering, state.phase, state.closedReason],
|
|
212
|
+
() => callStatus({ connectionState, error, recovering, phase: state.phase, closedReason: state.closedReason, moving }),
|
|
213
|
+
[connectionState, error, recovering, state.phase, state.closedReason, moving],
|
|
191
214
|
);
|
|
192
215
|
|
|
193
216
|
// Everyone by name, INCLUDING self as "You" - the peer list is other people
|
|
@@ -211,11 +234,54 @@ export function CallStage({
|
|
|
211
234
|
const remoteCount = useMemo(() => new Set(tiles.map((tile) => tile.identity)).size, [tiles]);
|
|
212
235
|
const everSawPeersRef = useRef(false);
|
|
213
236
|
if (remoteCount > 0) everSawPeersRef.current = true;
|
|
237
|
+
const breakoutsActive = breakouts?.active ?? false;
|
|
214
238
|
const notice = useMemo(
|
|
215
|
-
() => stageNotice({ status, remoteCount, everSawPeers: everSawPeersRef.current }),
|
|
216
|
-
[status, remoteCount],
|
|
239
|
+
() => stageNotice({ status, remoteCount, everSawPeers: everSawPeersRef.current, moving, breakoutsActive }),
|
|
240
|
+
[status, remoteCount, moving, breakoutsActive],
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* The self-move: write the assignment, then leave and reconnect. The backend
|
|
245
|
+
* mints the next ticket for the room the assignment names, so the reconnect
|
|
246
|
+
* IS the move; captures survive it the way they survive a drain.
|
|
247
|
+
*/
|
|
248
|
+
const selfMove = useCallback(
|
|
249
|
+
async (roomPublicId: string | null) => {
|
|
250
|
+
if (!breakouts || !room) return;
|
|
251
|
+
setMoveError(null);
|
|
252
|
+
setMovePending(true);
|
|
253
|
+
try {
|
|
254
|
+
await breakouts.moveSelf(roomPublicId);
|
|
255
|
+
const name = roomPublicId ? (breakouts.rooms.find((r) => r.publicId === roomPublicId)?.name ?? roomPublicId) : null;
|
|
256
|
+
setLocalMoving({ room: roomPublicId ?? "main", name });
|
|
257
|
+
await room.moveTo();
|
|
258
|
+
} catch (cause) {
|
|
259
|
+
setLocalMoving(null);
|
|
260
|
+
setMoveError(cause instanceof Error && cause.message ? cause.message : t("forge.call_stage.breakouts.error"));
|
|
261
|
+
} finally {
|
|
262
|
+
setMovePending(false);
|
|
263
|
+
}
|
|
264
|
+
},
|
|
265
|
+
[breakouts, room, t],
|
|
217
266
|
);
|
|
218
267
|
|
|
268
|
+
// The room is back (or gone): the local destination has been reached or is
|
|
269
|
+
// moot, and the assignment view is re-read so the panel shows where everyone
|
|
270
|
+
// landed. Every RETURN to connected refreshes, not only after our own move:
|
|
271
|
+
// the host may have reassigned us while we were away.
|
|
272
|
+
const previousConnectionRef = useRef(connectionState);
|
|
273
|
+
const refreshRef = useRef(breakouts?.refresh);
|
|
274
|
+
refreshRef.current = breakouts?.refresh;
|
|
275
|
+
useEffect(() => {
|
|
276
|
+
const previous = previousConnectionRef.current;
|
|
277
|
+
previousConnectionRef.current = connectionState;
|
|
278
|
+
if (connectionState === "connected" && previous !== "connected") {
|
|
279
|
+
setLocalMoving(null);
|
|
280
|
+
if (previous !== "idle" && previous !== "connecting") refreshRef.current?.();
|
|
281
|
+
}
|
|
282
|
+
if (connectionState === "closed") setLocalMoving(null);
|
|
283
|
+
}, [connectionState]);
|
|
284
|
+
|
|
219
285
|
// Dominant speaker with a hold, so the rail and speaker view do not flap.
|
|
220
286
|
const [dominant, setDominant] = useState<{ identity: string | null; since: number }>({ identity: null, since: 0 });
|
|
221
287
|
useEffect(() => {
|
|
@@ -308,6 +374,23 @@ export function CallStage({
|
|
|
308
374
|
|
|
309
375
|
const togglePanel = (tab: SidePanelTab) => dispatch({ type: "panel", panel: ui.panel === tab ? null : tab });
|
|
310
376
|
|
|
377
|
+
// A tab whose content went away (chat never injected, rooms closed under an
|
|
378
|
+
// attendee) falls back to People rather than drawing an empty rail.
|
|
379
|
+
const roomsTab = showsRoomsTab(breakouts);
|
|
380
|
+
const panelTab: SidePanelTab | null =
|
|
381
|
+
ui.panel === "chat" && !chat ? "people" : ui.panel === "rooms" && !roomsTab ? "people" : ui.panel;
|
|
382
|
+
|
|
383
|
+
// An attendee's rooms list is read on demand: once when the tab opens and
|
|
384
|
+
// then every few seconds while it stays open. The host's adapter polls on
|
|
385
|
+
// its own, so this only fires for a seat whose adapter does not.
|
|
386
|
+
const attendeeRoomsOpen = panelTab === "rooms" && !!breakouts && !breakouts.canManage;
|
|
387
|
+
useEffect(() => {
|
|
388
|
+
if (!attendeeRoomsOpen) return undefined;
|
|
389
|
+
refreshRef.current?.();
|
|
390
|
+
const timer = setInterval(() => refreshRef.current?.(), 5000);
|
|
391
|
+
return () => clearInterval(timer);
|
|
392
|
+
}, [attendeeRoomsOpen]);
|
|
393
|
+
|
|
311
394
|
const showSelfView =
|
|
312
395
|
!ui.selfHidden && layout.mode !== "duo" && connectionState === "connected" && (!notice || notice.kind === "waiting" || notice.kind === "everyone_left");
|
|
313
396
|
|
|
@@ -410,7 +493,7 @@ export function CallStage({
|
|
|
410
493
|
{ui.panel && narrow && (
|
|
411
494
|
<div data-testid="call-panel-overlay" style={{ position: "absolute", inset: 0, zIndex: 30, display: "flex" }}>
|
|
412
495
|
<CallSidePanel
|
|
413
|
-
tab={
|
|
496
|
+
tab={panelTab ?? "people"}
|
|
414
497
|
onTab={(tab) => dispatch({ type: "panel", panel: tab })}
|
|
415
498
|
onClose={() => dispatch({ type: "panel", panel: null })}
|
|
416
499
|
tokens={tokens}
|
|
@@ -423,6 +506,10 @@ export function CallStage({
|
|
|
423
506
|
onLowerHand={hands.lowerHandOf}
|
|
424
507
|
onLowerAll={hands.lowerAll}
|
|
425
508
|
unread={chatUnread}
|
|
509
|
+
breakouts={breakouts}
|
|
510
|
+
onSelfMove={(id) => void selfMove(id)}
|
|
511
|
+
movePending={movePending}
|
|
512
|
+
moveError={moveError}
|
|
426
513
|
fill
|
|
427
514
|
/>
|
|
428
515
|
</div>
|
|
@@ -431,7 +518,7 @@ export function CallStage({
|
|
|
431
518
|
|
|
432
519
|
{ui.panel && !narrow && (
|
|
433
520
|
<CallSidePanel
|
|
434
|
-
tab={
|
|
521
|
+
tab={panelTab ?? "people"}
|
|
435
522
|
onTab={(tab) => dispatch({ type: "panel", panel: tab })}
|
|
436
523
|
onClose={() => dispatch({ type: "panel", panel: null })}
|
|
437
524
|
tokens={tokens}
|
|
@@ -444,6 +531,10 @@ export function CallStage({
|
|
|
444
531
|
onLowerHand={hands.lowerHandOf}
|
|
445
532
|
onLowerAll={hands.lowerAll}
|
|
446
533
|
unread={chatUnread}
|
|
534
|
+
breakouts={breakouts}
|
|
535
|
+
onSelfMove={(id) => void selfMove(id)}
|
|
536
|
+
movePending={movePending}
|
|
537
|
+
moveError={moveError}
|
|
447
538
|
/>
|
|
448
539
|
)}
|
|
449
540
|
</div>
|
|
@@ -466,8 +557,14 @@ export function CallStage({
|
|
|
466
557
|
chatUnread={chatUnread}
|
|
467
558
|
onToggleChat={() => togglePanel("chat")}
|
|
468
559
|
onTogglePeople={() => togglePanel("people")}
|
|
469
|
-
chatOpen={
|
|
470
|
-
peopleOpen={
|
|
560
|
+
chatOpen={panelTab === "chat"}
|
|
561
|
+
peopleOpen={panelTab === "people"}
|
|
562
|
+
roomsAvailable={roomsTab}
|
|
563
|
+
roomsOpen={panelTab === "rooms"}
|
|
564
|
+
onToggleRooms={() => togglePanel("rooms")}
|
|
565
|
+
inBreakout={!!breakouts?.current}
|
|
566
|
+
onReturnToMain={() => void selfMove(null)}
|
|
567
|
+
movePending={movePending}
|
|
471
568
|
onGesture={unlockSounds}
|
|
472
569
|
/>
|
|
473
570
|
|