@tribe-nest/forge 3.57.0 → 3.59.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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tribe-nest/forge",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.59.0",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
@@ -31,9 +31,11 @@
|
|
|
31
31
|
"@stripe/react-stripe-js": "^3.8.0",
|
|
32
32
|
"@stripe/stripe-js": "^7.6.1",
|
|
33
33
|
"@tanstack/react-query": "^5.62.7",
|
|
34
|
-
"@tribe-nest/media-client": "^0.
|
|
34
|
+
"@tribe-nest/media-client": "^0.5.0",
|
|
35
35
|
"@tribe-nest/media-protocol": "^1.0.0",
|
|
36
|
+
"@tribe-nest/seating-core": "^1.0.0",
|
|
36
37
|
"axios": "^1.7.9",
|
|
38
|
+
"libphonenumber-js": "^1.13.13",
|
|
37
39
|
"lucide-react": "^0.510.0",
|
|
38
40
|
"react-hook-form": "^7.60.0",
|
|
39
41
|
"react-markdown": "^10.1.0",
|
|
@@ -34,6 +34,7 @@ export const WORKSPACE_SOURCE_ALIASES: { find: RegExp; replacement: string }[] =
|
|
|
34
34
|
["@tribe-nest/media-client/protocol", "../../../media-client/src/protocol.ts"],
|
|
35
35
|
["@tribe-nest/media-protocol", "../../../media-protocol/src/index.ts"],
|
|
36
36
|
["@tribe-nest/media-protocol/token", "../../../media-protocol/src/token.ts"],
|
|
37
|
+
["@tribe-nest/seating-core", "../../../seating-core/src/index.ts"],
|
|
37
38
|
].map(([specifier, path]) => ({
|
|
38
39
|
find: new RegExp(`^${specifier!.replace(/[/\\^$*+?.()|[\]{}]/g, "\\$&")}$`),
|
|
39
40
|
replacement: fileURLToPath(new URL(path!, import.meta.url)),
|
|
@@ -75,6 +75,29 @@ export type CartItem = {
|
|
|
75
75
|
* whole existing shop, on both rendering stacks, is unaffected; the two places
|
|
76
76
|
* that genuinely need both (the cart panel and checkout) read both lists.
|
|
77
77
|
*/
|
|
78
|
+
/** One seat a cart line holds, with the label every surface prints. */
|
|
79
|
+
export type CartSeat = { id: string; label: string };
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Display and order data for one tier of a ticket selection.
|
|
83
|
+
*
|
|
84
|
+
* `seatIds` and `seats` are present on a RESERVED-SEATING tier only: the
|
|
85
|
+
* event seat ids the buyer picked on the map (what the bundle checkout sends
|
|
86
|
+
* as the line's `seatIds`, in this order) and the same seats with the labels
|
|
87
|
+
* the cart, the checkout summary and the confirmation print ("Stalls · Row A
|
|
88
|
+
* · Seat 12"). The line's quantity IS `seatIds.length` on such a tier, so the
|
|
89
|
+
* cart never offers a stepper for it: changing seats happens on the event
|
|
90
|
+
* page. Absent on a general admission tier, and on carts saved before seats
|
|
91
|
+
* could ride in them.
|
|
92
|
+
*/
|
|
93
|
+
export type TicketCartLineMeta = {
|
|
94
|
+
title: string;
|
|
95
|
+
price: number;
|
|
96
|
+
pwywAmount?: number;
|
|
97
|
+
seatIds?: string[];
|
|
98
|
+
seats?: CartSeat[];
|
|
99
|
+
};
|
|
100
|
+
|
|
78
101
|
export type TicketCartItem = {
|
|
79
102
|
eventId: string;
|
|
80
103
|
eventSlug: string;
|
|
@@ -92,7 +115,7 @@ export type TicketCartItem = {
|
|
|
92
115
|
* endpoint treats a line's `price` as display-only and never charges it; the
|
|
93
116
|
* chosen amount has to arrive in a field the server actually reads.
|
|
94
117
|
*/
|
|
95
|
-
ticketMeta: Record<string,
|
|
118
|
+
ticketMeta: Record<string, TicketCartLineMeta>;
|
|
96
119
|
/**
|
|
97
120
|
* The event's RESOLVED booking fee, captured when the modal put the tickets
|
|
98
121
|
* here, so the cart checkout can show the fee line the buyer will be charged
|
|
@@ -142,6 +165,17 @@ interface CartContextType {
|
|
|
142
165
|
setTickets: (item: TicketCartItem) => void;
|
|
143
166
|
/** Drops the selection AND every add-on attached to that event. */
|
|
144
167
|
removeTickets: (eventId: string) => void;
|
|
168
|
+
/**
|
|
169
|
+
* Take named seats OUT of an event's selection, without opening the drawer.
|
|
170
|
+
*
|
|
171
|
+
* The bundle checkout calls this when seats in the cart were taken by
|
|
172
|
+
* another buyer: the taken seats leave their tier line (its quantity is its
|
|
173
|
+
* seat count, so it shrinks with them), a tier left with no seat leaves the
|
|
174
|
+
* selection, and an event left with no tier leaves the cart with its add-ons,
|
|
175
|
+
* exactly as `removeTickets` would. Seats are never substituted here: the
|
|
176
|
+
* buyer goes back to the event's picker to choose again.
|
|
177
|
+
*/
|
|
178
|
+
removeTicketSeats: (eventId: string, seatIds: string[]) => void;
|
|
145
179
|
hasTicketsFor: (eventId: string) => boolean;
|
|
146
180
|
/** Products + tickets, for the header badge. */
|
|
147
181
|
itemCount: number;
|
|
@@ -175,6 +209,40 @@ function readAddonContext(): AttachedTo | undefined {
|
|
|
175
209
|
}
|
|
176
210
|
}
|
|
177
211
|
|
|
212
|
+
/**
|
|
213
|
+
* An event's selection with `seatIds` taken out of it, or `null` when nothing
|
|
214
|
+
* is left. Pure, so the checkout's tests can assert it without a provider.
|
|
215
|
+
*
|
|
216
|
+
* A tier's quantity follows its seat count, because on a seated tier the two
|
|
217
|
+
* are one number. A general admission tier (no `seatIds` on its meta) is never
|
|
218
|
+
* touched: the ids belong to a seated tier by construction.
|
|
219
|
+
*/
|
|
220
|
+
export function pruneSeatsFromTicketItem(item: TicketCartItem, seatIds: readonly string[]): TicketCartItem | null {
|
|
221
|
+
const gone = new Set(seatIds);
|
|
222
|
+
const tickets: Record<string, number> = {};
|
|
223
|
+
const ticketMeta: Record<string, TicketCartLineMeta> = {};
|
|
224
|
+
for (const [ticketId, qty] of Object.entries(item.tickets)) {
|
|
225
|
+
const meta = item.ticketMeta[ticketId];
|
|
226
|
+
if (!meta?.seatIds) {
|
|
227
|
+
if (qty > 0) {
|
|
228
|
+
tickets[ticketId] = qty;
|
|
229
|
+
if (meta) ticketMeta[ticketId] = meta;
|
|
230
|
+
}
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
const keptIds = meta.seatIds.filter((id) => !gone.has(id));
|
|
234
|
+
if (keptIds.length === 0) continue;
|
|
235
|
+
tickets[ticketId] = keptIds.length;
|
|
236
|
+
ticketMeta[ticketId] = {
|
|
237
|
+
...meta,
|
|
238
|
+
seatIds: keptIds,
|
|
239
|
+
seats: (meta.seats ?? []).filter((seat) => !gone.has(seat.id)),
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
if (Object.keys(tickets).length === 0) return null;
|
|
243
|
+
return { ...item, tickets, ticketMeta };
|
|
244
|
+
}
|
|
245
|
+
|
|
178
246
|
export function CartProvider({ children }: { children: ReactNode }) {
|
|
179
247
|
const [cartItems, setCartItems] = useState<CartItem[]>([]);
|
|
180
248
|
const [ticketItems, setTicketItems] = useState<TicketCartItem[]>([]);
|
|
@@ -339,10 +407,24 @@ export function CartProvider({ children }: { children: ReactNode }) {
|
|
|
339
407
|
setCartItems((prev) => prev.filter((i) => i.attachedTo?.entityId !== eventId));
|
|
340
408
|
}, []);
|
|
341
409
|
|
|
342
|
-
const
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
410
|
+
const removeTicketSeats = useCallback((eventId: string, seatIds: string[]) => {
|
|
411
|
+
let emptied = false;
|
|
412
|
+
setTicketItems((prev) =>
|
|
413
|
+
prev.flatMap((item) => {
|
|
414
|
+
if (item.eventId !== eventId) return [item];
|
|
415
|
+
const next = pruneSeatsFromTicketItem(item, seatIds);
|
|
416
|
+
if (next) return [next];
|
|
417
|
+
emptied = true;
|
|
418
|
+
return [];
|
|
419
|
+
}),
|
|
420
|
+
);
|
|
421
|
+
// The add-ons go with the base, as in `removeTickets`. Read from the
|
|
422
|
+
// flatMap above rather than from `ticketItems`, so a stale closure cannot
|
|
423
|
+
// keep an orphaned add-on that the server would refuse.
|
|
424
|
+
setCartItems((prev) => (emptied ? prev.filter((i) => i.attachedTo?.entityId !== eventId) : prev));
|
|
425
|
+
}, []);
|
|
426
|
+
|
|
427
|
+
const hasTicketsFor = useCallback((eventId: string) => ticketItems.some((t) => t.eventId === eventId), [ticketItems]);
|
|
346
428
|
|
|
347
429
|
const clearCart = () => {
|
|
348
430
|
setCartItems([]);
|
|
@@ -371,6 +453,7 @@ export function CartProvider({ children }: { children: ReactNode }) {
|
|
|
371
453
|
ticketItems,
|
|
372
454
|
setTickets,
|
|
373
455
|
removeTickets,
|
|
456
|
+
removeTicketSeats,
|
|
374
457
|
hasTicketsFor,
|
|
375
458
|
itemCount,
|
|
376
459
|
isBundle,
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
2
|
+
import { useCallback, useMemo, useState } from "react";
|
|
3
|
+
|
|
4
|
+
import type { BreakoutParticipant, BreakoutRoomSummary, CallStageBreakouts } from "../../ui/media/callBreakouts";
|
|
5
|
+
import { useForge } from "../../provider/ForgeProvider";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The PUBLIC lane's `CallStageBreakouts` adapter: the reference implementation
|
|
9
|
+
* of the shape the stage renders.
|
|
10
|
+
*
|
|
11
|
+
* Everything hangs off the CALL's address (`/video-calls/:id/breakouts`): one
|
|
12
|
+
* GET describes the rooms and this seat's place in them, and every mutation
|
|
13
|
+
* answers with the same describe, so the query is replaced from the response
|
|
14
|
+
* rather than refetched (a refetch as well would race it). Admin composes its
|
|
15
|
+
* own adapter against the dashboard's client; the SHAPE is the seam.
|
|
16
|
+
*
|
|
17
|
+
* ## Who polls
|
|
18
|
+
*
|
|
19
|
+
* The host's view carries live counts that come from the media plane, so the
|
|
20
|
+
* host polls every few seconds. An attendee's list is a handful of names that
|
|
21
|
+
* change only when the host acts, and there can be forty of them: they read
|
|
22
|
+
* once, and the stage calls `refresh()` when the rooms tab is open and after
|
|
23
|
+
* every reconnect (which is what a move is), so they are never stale where it
|
|
24
|
+
* matters and never a request per viewer per few seconds where it does not.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** The server's describe, the wire shape the adapter is built from. */
|
|
28
|
+
export type CallBreakoutsDescribe = {
|
|
29
|
+
canManage: boolean;
|
|
30
|
+
active: boolean;
|
|
31
|
+
current: { publicId: string; name: string } | null;
|
|
32
|
+
main: { participantCount: number; participants: BreakoutParticipant[] };
|
|
33
|
+
rooms: BreakoutRoomSummary[];
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export const CALL_BREAKOUTS_POLL_MS = 5000;
|
|
37
|
+
|
|
38
|
+
/** The server's own sentence when it gave one, else the thrown error's. */
|
|
39
|
+
export const breakoutErrorMessage = (cause: unknown): string => {
|
|
40
|
+
const response = (cause as { response?: { data?: { message?: unknown } } } | undefined)?.response;
|
|
41
|
+
const message = response?.data?.message;
|
|
42
|
+
if (typeof message === "string" && message.trim()) return message;
|
|
43
|
+
return cause instanceof Error && cause.message ? cause.message : "";
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export function useCallBreakouts(callId: string | undefined): CallStageBreakouts | undefined {
|
|
47
|
+
const { client } = useForge();
|
|
48
|
+
const queryClient = useQueryClient();
|
|
49
|
+
const [pending, setPending] = useState(false);
|
|
50
|
+
|
|
51
|
+
const queryKey = useMemo(() => ["call-breakouts", callId] as const, [callId]);
|
|
52
|
+
const base = `/video-calls/${callId}/breakouts`;
|
|
53
|
+
|
|
54
|
+
const query = useQuery<CallBreakoutsDescribe>({
|
|
55
|
+
queryKey,
|
|
56
|
+
queryFn: async () => (await client.get(base)).data,
|
|
57
|
+
enabled: !!callId && !!client,
|
|
58
|
+
// Only the host's view has live numbers in it. See the docblock.
|
|
59
|
+
refetchInterval: (state) => (state.state.data?.canManage ? CALL_BREAKOUTS_POLL_MS : false),
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const act = useCallback(
|
|
63
|
+
async (request: () => Promise<{ data: CallBreakoutsDescribe }>) => {
|
|
64
|
+
setPending(true);
|
|
65
|
+
try {
|
|
66
|
+
const res = await request();
|
|
67
|
+
queryClient.setQueryData(queryKey, res.data);
|
|
68
|
+
} catch (cause) {
|
|
69
|
+
throw new Error(breakoutErrorMessage(cause));
|
|
70
|
+
} finally {
|
|
71
|
+
setPending(false);
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
[queryClient, queryKey],
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
const create = useCallback(
|
|
78
|
+
(input: { count: number; assign: "auto" | "manual" }) => act(() => client.post(base, input)),
|
|
79
|
+
[act, client, base],
|
|
80
|
+
);
|
|
81
|
+
const assign = useCallback(
|
|
82
|
+
(identity: string, roomPublicId: string | null) => act(() => client.put(`${base}/assignment`, { identity, roomPublicId })),
|
|
83
|
+
[act, client, base],
|
|
84
|
+
);
|
|
85
|
+
const moveSelf = useCallback(
|
|
86
|
+
(roomPublicId: string | null) => act(() => client.put(`${base}/me`, { roomPublicId })),
|
|
87
|
+
[act, client, base],
|
|
88
|
+
);
|
|
89
|
+
const closeAll = useCallback(() => act(() => client.delete(base)), [act, client, base]);
|
|
90
|
+
const refresh = useCallback(() => {
|
|
91
|
+
void queryClient.invalidateQueries({ queryKey });
|
|
92
|
+
}, [queryClient, queryKey]);
|
|
93
|
+
|
|
94
|
+
return useMemo<CallStageBreakouts | undefined>(() => {
|
|
95
|
+
if (!callId) return undefined;
|
|
96
|
+
const data = query.data;
|
|
97
|
+
return {
|
|
98
|
+
ready: !!data,
|
|
99
|
+
canManage: data?.canManage ?? false,
|
|
100
|
+
active: data?.active ?? false,
|
|
101
|
+
current: data?.current ?? null,
|
|
102
|
+
main: data?.main ?? { participantCount: 0, participants: [] },
|
|
103
|
+
rooms: data?.rooms ?? [],
|
|
104
|
+
pending,
|
|
105
|
+
create,
|
|
106
|
+
assign,
|
|
107
|
+
moveSelf,
|
|
108
|
+
closeAll,
|
|
109
|
+
refresh,
|
|
110
|
+
};
|
|
111
|
+
}, [callId, query.data, pending, create, assign, moveSelf, closeAll, refresh]);
|
|
112
|
+
}
|
|
@@ -34,6 +34,14 @@ export type CheckoutLineInput =
|
|
|
34
34
|
* server clamps it against the tier's floor).
|
|
35
35
|
*/
|
|
36
36
|
pwywAmount?: number;
|
|
37
|
+
/**
|
|
38
|
+
* The EVENT seat ids the buyer picked, on a reserved-seating tier only:
|
|
39
|
+
* exactly `quantity` of them, in the order chosen. The server refuses a
|
|
40
|
+
* seated tier's line without them (400 `seats_required`) and answers 409
|
|
41
|
+
* `SEATS_TAKEN` with `details.eventSeatIds` when any has gone to another
|
|
42
|
+
* buyer since. Absent on a general admission tier.
|
|
43
|
+
*/
|
|
44
|
+
seatIds?: string[];
|
|
37
45
|
title: string;
|
|
38
46
|
coverImage?: string;
|
|
39
47
|
};
|
|
@@ -57,8 +65,13 @@ export function cartToCheckoutLines(cartItems: CartItem[], ticketItems: TicketCa
|
|
|
57
65
|
// display-only and never charges. This is the field the server forwards
|
|
58
66
|
// into the ticket order — dropping it would charge the tier's floor and
|
|
59
67
|
// still return 200, so the buyer would be quietly undercharged.
|
|
60
|
-
...(t.ticketMeta[ticketId]?.pwywAmount != null
|
|
61
|
-
|
|
68
|
+
...(t.ticketMeta[ticketId]?.pwywAmount != null ? { pwywAmount: t.ticketMeta[ticketId]?.pwywAmount } : {}),
|
|
69
|
+
// The seats ride on the line: a seated tier's order is created FROM
|
|
70
|
+
// this, and without them the server would either refuse the line or
|
|
71
|
+
// (on an older build) seat the buyer anywhere, which is the one thing
|
|
72
|
+
// a buyer who chose on a map must never get silently.
|
|
73
|
+
...((t.ticketMeta[ticketId]?.seatIds?.length ?? 0) > 0
|
|
74
|
+
? { seatIds: [...t.ticketMeta[ticketId]!.seatIds!] }
|
|
62
75
|
: {}),
|
|
63
76
|
title: t.ticketMeta[ticketId]?.title ?? t.eventTitle,
|
|
64
77
|
coverImage: t.coverImage,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { AppliedDiscountCoupon, IEvent } from "../../types/models";
|
|
2
|
+
import type { SeatAttributes, SeatMapDocument } from "@tribe-nest/seating-core";
|
|
2
3
|
import { useForge } from "../../provider/ForgeProvider";
|
|
3
4
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
|
4
5
|
|
|
@@ -81,8 +82,75 @@ export function useEvent(
|
|
|
81
82
|
});
|
|
82
83
|
}
|
|
83
84
|
|
|
85
|
+
/**
|
|
86
|
+
* One sellable seat of an event, as the public seating read publishes it.
|
|
87
|
+
*
|
|
88
|
+
* `id` is the EVENT seat (what an order names); `sourceSeatId` is the seat's id
|
|
89
|
+
* inside the frozen layout, which is how a renderer joins it to a `<circle>`.
|
|
90
|
+
* Held, sold and blocked all read `unavailable`: a buyer must not be able to
|
|
91
|
+
* tell a house seat from a sold one.
|
|
92
|
+
*/
|
|
93
|
+
export type PublicEventSeat = {
|
|
94
|
+
id: string;
|
|
95
|
+
sourceSeatId: string;
|
|
96
|
+
eventTicketId: string;
|
|
97
|
+
status: "available" | "unavailable";
|
|
98
|
+
attributes: SeatAttributes;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/** `GET /public/events/:id/seating`. `null` when the event has no seat map. */
|
|
102
|
+
export type PublicEventSeating = {
|
|
103
|
+
/** The layout frozen at bind time. Coordinates are absolute and baked. */
|
|
104
|
+
layout: SeatMapDocument;
|
|
105
|
+
/** Layout category key -> the ticket tier that prices it for this event. */
|
|
106
|
+
tierMapping: Record<string, string>;
|
|
107
|
+
seats: PublicEventSeat[];
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The seat map and live availability for a reserved-seating event.
|
|
112
|
+
*
|
|
113
|
+
* `Cache-Control: no-store` on the server, and `pollMs` here, because the
|
|
114
|
+
* whole value of the read is that it is CURRENT: a seat somebody else took ten
|
|
115
|
+
* seconds ago must not still look free. Disabled (no request at all) unless
|
|
116
|
+
* `enabled`, so an event page that never opens the picker never polls.
|
|
117
|
+
*/
|
|
118
|
+
export function useEventSeatingQuery(
|
|
119
|
+
eventId?: string,
|
|
120
|
+
options: {
|
|
121
|
+
enabled?: boolean;
|
|
122
|
+
/** Refetch interval while enabled. `false` (or 0) polls nothing. */
|
|
123
|
+
pollMs?: number | false;
|
|
124
|
+
} = {},
|
|
125
|
+
) {
|
|
126
|
+
const { client, profileId } = useForge();
|
|
127
|
+
const enabled = (options.enabled ?? true) && !!eventId && !!profileId && !!client;
|
|
128
|
+
return useQuery<PublicEventSeating | null>({
|
|
129
|
+
queryKey: ["event-seating", eventId, profileId],
|
|
130
|
+
queryFn: async () => {
|
|
131
|
+
const res = await client.get(`/public/events/${eventId}/seating`, { params: { profileId } });
|
|
132
|
+
// An empty body (204, or an older API with no route) reads as "no map".
|
|
133
|
+
return (res.data ?? null) as PublicEventSeating | null;
|
|
134
|
+
},
|
|
135
|
+
enabled,
|
|
136
|
+
staleTime: 0,
|
|
137
|
+
refetchInterval: enabled && options.pollMs ? options.pollMs : false,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
84
141
|
export type CreateEventOrderInput = {
|
|
85
142
|
items: Record<string, number>;
|
|
143
|
+
/**
|
|
144
|
+
* ticketId -> the EVENT seat ids (`PublicEventSeat.id`) the buyer picked, for
|
|
145
|
+
* every tier that sells reserved seats (seating wave 2).
|
|
146
|
+
*
|
|
147
|
+
* A seated tier requires exactly `items[ticketId]` seats; a general
|
|
148
|
+
* admission tier must not carry any. Omit the key entirely on an event with
|
|
149
|
+
* no seat map, so an ordinary purchase posts the body it always did. A 409
|
|
150
|
+
* `errors.event.seats_taken` with `details.eventSeatIds` means those seats
|
|
151
|
+
* went to somebody else between the read and the order.
|
|
152
|
+
*/
|
|
153
|
+
seats?: Record<string, string[]>;
|
|
86
154
|
/**
|
|
87
155
|
* ticketId → the buyer's chosen amount, PER UNIT, on a pay-what-you-want
|
|
88
156
|
* tier. Ignored server-side for any tier that is not PWYW, and clamped up to
|
|
@@ -122,6 +122,22 @@ export type MyBooking = {
|
|
|
122
122
|
/** The server's answer. Never recomputed here. */
|
|
123
123
|
canReschedule: boolean;
|
|
124
124
|
rescheduleReason: RescheduleRefusal | null;
|
|
125
|
+
/**
|
|
126
|
+
* The last instant a self-service move is allowed (ISO), or null when it is
|
|
127
|
+
* not allowed at all. Optional: an older API build does not emit it.
|
|
128
|
+
*/
|
|
129
|
+
rescheduleDeadline?: string | null;
|
|
130
|
+
/**
|
|
131
|
+
* The reschedule rule this booking was sold under. `followsCancellation` is
|
|
132
|
+
* true when the coach set no reschedule policy, so the cancellation window
|
|
133
|
+
* governs moves and its sentence already covers it. Optional, as above.
|
|
134
|
+
*/
|
|
135
|
+
reschedulePolicy?: {
|
|
136
|
+
policy: "none" | "until" | "anytime";
|
|
137
|
+
cutoffHours: number | null;
|
|
138
|
+
followsCancellation: boolean;
|
|
139
|
+
description: string | null;
|
|
140
|
+
};
|
|
125
141
|
/**
|
|
126
142
|
* The session's `.ics`, both forms — what lets the portal offer Apple
|
|
127
143
|
* Calendar rather than only Google/Outlook/Microsoft 365. `null` unless the
|
|
@@ -5,7 +5,7 @@ import { useCallback, useEffect, useState } from "react";
|
|
|
5
5
|
/**
|
|
6
6
|
* Member notification headless hooks — the FROZEN public contract that the
|
|
7
7
|
* apps/client PWA and every generated custom-code site consume. Speaks ONLY the
|
|
8
|
-
* `/public/notifications/*` API (see docs/membership-notifications-initiative.md
|
|
8
|
+
* `/public/notifications/*` API (see docs/archive/shipped/membership-notifications-initiative.md
|
|
9
9
|
* "API surface"). Moved here from apps/client so custom-code sites get push
|
|
10
10
|
* subscription registration, inbox, and a settings matrix with no apps/client
|
|
11
11
|
* code.
|
package/src/i18n/de.json
CHANGED
|
@@ -229,6 +229,7 @@
|
|
|
229
229
|
"forge.bundle_confirmation.partial_body": "Deine Zahlung ist angekommen und ein Teil deiner Bestellung ist bestätigt. Den Rest schließen wir gerade ab. Er kommt gleich per E-Mail, ohne weitere Kosten für dich.",
|
|
230
230
|
"forge.bundle_confirmation.partial_title": "Zahlung erhalten",
|
|
231
231
|
"forge.bundle_confirmation.return_to_checkout": "Zurück zur Kasse",
|
|
232
|
+
"forge.bundle_confirmation.seats_title": "Deine Plätze",
|
|
232
233
|
"forge.bundle_confirmation.summary_both": "In einem Schritt bezahlt. Deine Tickets kommen per E-Mail und deine Bestellung ist bestätigt.",
|
|
233
234
|
"forge.bundle_confirmation.summary_order": "In einem Schritt bezahlt. Deine Bestellung ist bestätigt.",
|
|
234
235
|
"forge.bundle_confirmation.summary_paid": "In einem Schritt bezahlt.",
|
|
@@ -236,6 +237,40 @@
|
|
|
236
237
|
"forge.bundle_confirmation.tickets_emailed": "Tickets per E-Mail an dich unterwegs",
|
|
237
238
|
"forge.bundle_confirmation.title": "Alles erledigt",
|
|
238
239
|
"forge.bundle_confirmation.view_orders": "Bestellungen ansehen",
|
|
240
|
+
"forge.call_stage.breakouts.assign_auto": "Automatisch",
|
|
241
|
+
"forge.call_stage.breakouts.assign_label": "Personen zuweisen",
|
|
242
|
+
"forge.call_stage.breakouts.assign_manual": "Ich weise sie selbst zu",
|
|
243
|
+
"forge.call_stage.breakouts.assigned_not_arrived": "(unterwegs)",
|
|
244
|
+
"forge.call_stage.breakouts.button": "Räume",
|
|
245
|
+
"forge.call_stage.breakouts.close_all": "Alle Räume schließen",
|
|
246
|
+
"forge.call_stage.breakouts.close_cancel": "Offen lassen",
|
|
247
|
+
"forge.call_stage.breakouts.close_confirm": "Räume schließen",
|
|
248
|
+
"forge.call_stage.breakouts.close_confirm_body": "Alle kommen zurück in den Hauptraum, und die Räume werden geschlossen.",
|
|
249
|
+
"forge.call_stage.breakouts.count_label": "Anzahl der Räume",
|
|
250
|
+
"forge.call_stage.breakouts.create": "Räume erstellen",
|
|
251
|
+
"forge.call_stage.breakouts.create_title": "Gruppenräume",
|
|
252
|
+
"forge.call_stage.breakouts.current_marker": "Du bist hier",
|
|
253
|
+
"forge.call_stage.breakouts.empty_room": "Noch niemand hier",
|
|
254
|
+
"forge.call_stage.breakouts.error": "Das hat nicht geklappt. Versuche es noch einmal.",
|
|
255
|
+
"forge.call_stage.breakouts.help_attendee": "Die Gastgeberin oder der Gastgeber hat diesen Anruf in kleinere Räume aufgeteilt. Du wirst automatisch verschoben, Kamera und Mikrofon bleiben wie sie sind, und du kommst zurück in den Hauptraum, wenn die Räume geschlossen werden oder du auf Zurück in den Hauptraum drückst.",
|
|
256
|
+
"forge.call_stage.breakouts.help_attendee_label": "Was sind Gruppenräume?",
|
|
257
|
+
"forge.call_stage.breakouts.help_create": "Jeder Raum ist ein eigener Anruf unter diesem. Bei automatischer Zuweisung werden alle Anwesenden gleichmäßig auf die Räume verteilt und sofort verschoben; bei manueller Zuweisung bewegt sich niemand, bis du einen Raum für die Person wählst. Du kannst selbst jeden Raum betreten, und alle kommen zurück, wenn du die Räume schließt.",
|
|
258
|
+
"forge.call_stage.breakouts.help_create_label": "Was passiert, wenn ich Räume erstelle?",
|
|
259
|
+
"forge.call_stage.breakouts.help_join": "Beitreten bringt dich in diesen Raum; die anderen Räume laufen ohne dich weiter. Ein Raum neben einem Namen verschiebt diese Person, und eine Aufzeichnung, die du startest, folgt dem Raum, in dem du bist.",
|
|
260
|
+
"forge.call_stage.breakouts.help_join_label": "Wie wechsle ich zwischen Räumen?",
|
|
261
|
+
"forge.call_stage.breakouts.join": "Beitreten",
|
|
262
|
+
"forge.call_stage.breakouts.loading": "Räume werden geladen…",
|
|
263
|
+
"forge.call_stage.breakouts.main_room": "Hauptraum",
|
|
264
|
+
"forge.call_stage.breakouts.move_to_label": "{name} in einen Raum verschieben",
|
|
265
|
+
"forge.call_stage.breakouts.participant_count": "{count} in diesem Raum",
|
|
266
|
+
"forge.call_stage.breakouts.return_to_main": "Zurück zum Hauptraum",
|
|
267
|
+
"forge.call_stage.breakouts.return_to_main_room": "Zurück in den Hauptraum",
|
|
268
|
+
"forge.call_stage.breakouts.status_moving": "Du wirst in {room} verschoben",
|
|
269
|
+
"forge.call_stage.breakouts.status_moving_detail": "Kamera und Mikrofon bleiben wie sie sind.",
|
|
270
|
+
"forge.call_stage.breakouts.status_returning": "Du kommst zurück in den Hauptraum",
|
|
271
|
+
"forge.call_stage.breakouts.tab": "Räume",
|
|
272
|
+
"forge.call_stage.breakouts.you_are_in": "Du bist in {room}",
|
|
273
|
+
"forge.call_stage.breakouts.you_are_in_main": "Du bist im Hauptraum",
|
|
239
274
|
"forge.call_stage.camera_start": "Kamera starten",
|
|
240
275
|
"forge.call_stage.camera_stop": "Kamera stoppen",
|
|
241
276
|
"forge.call_stage.chat": "Chat",
|
|
@@ -353,6 +388,7 @@
|
|
|
353
388
|
"forge.cart.aria_remove_item": "Artikel entfernen",
|
|
354
389
|
"forge.cart.aria_remove_tickets": "Tickets entfernen",
|
|
355
390
|
"forge.cart.attached_to_tickets": "Mit deinen Tickets hinzugefügt",
|
|
391
|
+
"forge.cart.change_seats": "Plätze ändern",
|
|
356
392
|
"forge.cart.checkout": "Zur Kasse",
|
|
357
393
|
"forge.cart.empty_title": "Dein Warenkorb ist leer.",
|
|
358
394
|
"forge.cart.gift_for": "Geschenk für {recipient_name} ({recipient_email})",
|
|
@@ -436,6 +472,10 @@
|
|
|
436
472
|
"forge.checkout.rate_days": "({days} Tage)",
|
|
437
473
|
"forge.checkout.rate_estimated_day": "Voraussichtlich {days} Tag",
|
|
438
474
|
"forge.checkout.rate_estimated_days": "Voraussichtlich {days} Tage",
|
|
475
|
+
"forge.checkout.seats_conflict_action": "Andere Plätze wählen",
|
|
476
|
+
"forge.checkout.seats_conflict_body": "{seats} für {event_title} sind an jemand anderen gegangen. Wähle andere Plätze, um fortzufahren; der Rest deines Warenkorbs bleibt unverändert.",
|
|
477
|
+
"forge.checkout.seats_conflict_title": "Einige deiner Plätze wurden gerade vergeben",
|
|
478
|
+
"forge.checkout.seats_label": "Plätze: {seats}",
|
|
439
479
|
"forge.checkout.shipping": "Versand",
|
|
440
480
|
"forge.checkout.shipping_address_title": "Lieferadresse",
|
|
441
481
|
"forge.checkout.shipping_options_title": "Versandoptionen",
|
|
@@ -809,6 +849,17 @@
|
|
|
809
849
|
"forge.event_detail.not_found": "Event nicht gefunden.",
|
|
810
850
|
"forge.event_detail.presale_only": "Tickets für dieses Event gibt es nur im Vorverkauf.",
|
|
811
851
|
"forge.event_detail.price_from_label": "Ab",
|
|
852
|
+
"forge.event_seat_picker.dismiss": "Schließen",
|
|
853
|
+
"forge.event_seat_picker.empty": "Es gibt noch keine Plätze zur Auswahl.",
|
|
854
|
+
"forge.event_seat_picker.help_body": "Tippe auf einen Platz, um ihn auszuwählen, und noch einmal, um ihn freizugeben. Zoome mit zwei Fingern oder dem Mausrad und ziehe, um den Plan zu verschieben. Graue Plätze sind nicht verfügbar. Beste verfügbare wählt die niedrigsten zusammenhängenden Plätze für dich.",
|
|
855
|
+
"forge.event_seat_picker.help_label": "Wie wähle ich Plätze aus?",
|
|
856
|
+
"forge.event_seat_picker.loading": "Saalplan wird geladen",
|
|
857
|
+
"forge.event_seat_picker.max_reached": "Du hast die maximale Anzahl an Plätzen für {tier} erreicht.",
|
|
858
|
+
"forge.event_seat_picker.mix_blocked": "Dieser Platz kann deiner aktuellen Auswahl nicht hinzugefügt werden.",
|
|
859
|
+
"forge.event_seat_picker.seat_unavailable": "Dieser Platz ist nicht verfügbar.",
|
|
860
|
+
"forge.event_seat_picker.seats_taken_body": "{seats} sind an jemand anderen gegangen. Wähle erneut aus den noch verfügbaren Plätzen.",
|
|
861
|
+
"forge.event_seat_picker.seats_taken_title": "Einige deiner Plätze wurden gerade vergeben",
|
|
862
|
+
"forge.event_seat_picker.title": "Plätze auswählen",
|
|
812
863
|
"forge.event_series_detail.date_count": "{count} Termine",
|
|
813
864
|
"forge.event_series_detail.date_count_range": "{count} Termine · {first_date} bis {last_date}",
|
|
814
865
|
"forge.event_series_detail.location_online": "Online",
|
|
@@ -883,6 +934,11 @@
|
|
|
883
934
|
"forge.event_tickets.pwyw_range_min_max": "(mindestens {min}, bis {max})",
|
|
884
935
|
"forge.event_tickets.remove_discount": "Rabatt entfernen",
|
|
885
936
|
"forge.event_tickets.request_tickets": "Tickets anfragen",
|
|
937
|
+
"forge.event_tickets.seat_best_available": "Beste verfügbare",
|
|
938
|
+
"forge.event_tickets.seat_remove_aria": "{seat} entfernen",
|
|
939
|
+
"forge.event_tickets.seats_left_one": "{count} Platz frei",
|
|
940
|
+
"forge.event_tickets.seats_left_other": "{count} Plätze frei",
|
|
941
|
+
"forge.event_tickets.seats_title": "Deine Plätze",
|
|
886
942
|
"forge.event_tickets.select_option": "Auswählen…",
|
|
887
943
|
"forge.event_tickets.select_title": "Tickets wählen",
|
|
888
944
|
"forge.event_tickets.subtotal": "Zwischensumme",
|
|
@@ -1228,6 +1284,14 @@
|
|
|
1228
1284
|
"forge.payment_link_payment.preparing_payment": "Zahlung wird vorbereitet…",
|
|
1229
1285
|
"forge.payment_link_payment.tax_added": "+ {tax_amount} MwSt.",
|
|
1230
1286
|
"forge.payment_link_payment.title": "Zahlungsanfrage",
|
|
1287
|
+
"forge.phone_input.country_label": "Land",
|
|
1288
|
+
"forge.phone_input.country_placeholder": "Land auswaehlen",
|
|
1289
|
+
"forge.phone_input.error_invalid": "Das sieht nicht nach einer gueltigen Nummer fuer das gewaehlte Land aus",
|
|
1290
|
+
"forge.phone_input.error_no_country": "Bitte waehlen Sie zuerst Ihr Land aus",
|
|
1291
|
+
"forge.phone_input.error_required": "Bitte geben Sie eine Telefonnummer ein",
|
|
1292
|
+
"forge.phone_input.hint_no_country_code": "Die Landesvorwahl koennen Sie weglassen. Die Flagge setzt sie fuer Sie.",
|
|
1293
|
+
"forge.phone_input.placeholder_fallback": "Telefonnummer",
|
|
1294
|
+
"forge.phone_input.warning_landline": "Das sieht nach einem Festnetzanschluss aus und kann moeglicherweise keine SMS empfangen.",
|
|
1231
1295
|
"forge.podcast_episode.next": "Weiter →",
|
|
1232
1296
|
"forge.podcast_episode.not_found": "Folge nicht gefunden.",
|
|
1233
1297
|
"forge.podcast_episode.pause": "Pause",
|
|
@@ -1358,6 +1422,21 @@
|
|
|
1358
1422
|
"forge.review_form.title_label": "Titel",
|
|
1359
1423
|
"forge.review_form.title_placeholder": "Fass es in ein paar Worten zusammen",
|
|
1360
1424
|
"forge.review_form.update_submit": "Bewertung aktualisieren",
|
|
1425
|
+
"forge.seat_legend.aria_label": "Legende",
|
|
1426
|
+
"forge.seat_legend.left_one": "{count} frei",
|
|
1427
|
+
"forge.seat_legend.left_other": "{count} frei",
|
|
1428
|
+
"forge.seat_legend.selected": "Ausgewählt",
|
|
1429
|
+
"forge.seat_legend.sold_out": "Ausverkauft",
|
|
1430
|
+
"forge.seat_legend.unavailable": "Nicht verfügbar",
|
|
1431
|
+
"forge.seat_map_canvas.accessible": "barrierefreier Platz",
|
|
1432
|
+
"forge.seat_map_canvas.aria_label": "Saalplan",
|
|
1433
|
+
"forge.seat_map_canvas.fit": "Ganzen Plan anzeigen",
|
|
1434
|
+
"forge.seat_map_canvas.general_admission": "Freie Platzwahl",
|
|
1435
|
+
"forge.seat_map_canvas.status_available": "verfügbar",
|
|
1436
|
+
"forge.seat_map_canvas.status_selected": "ausgewählt",
|
|
1437
|
+
"forge.seat_map_canvas.status_unavailable": "nicht verfügbar",
|
|
1438
|
+
"forge.seat_map_canvas.zoom_in": "Vergrößern",
|
|
1439
|
+
"forge.seat_map_canvas.zoom_out": "Verkleinern",
|
|
1361
1440
|
"forge.signup_form.accept_terms": "Ich akzeptiere die AGB und die Datenschutzerklärung",
|
|
1362
1441
|
"forge.signup_form.code_label": "Bestätigungscode",
|
|
1363
1442
|
"forge.signup_form.code_sent": "Wir haben einen 6-stelligen Code an {email} geschickt. Gib ihn ein, um dein Konto fertig einzurichten.",
|