@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.
Files changed (71) hide show
  1. package/package.json +4 -2
  2. package/src/_tests/workspaceAliases.ts +1 -0
  3. package/src/contexts/CartContext.tsx +88 -5
  4. package/src/data/queries/useCallBreakouts.ts +112 -0
  5. package/src/data/queries/useCheckouts.ts +15 -2
  6. package/src/data/queries/useEvents.ts +68 -0
  7. package/src/data/queries/useMyBookings.ts +16 -0
  8. package/src/data/queries/useNotifications.ts +1 -1
  9. package/src/i18n/de.json +79 -0
  10. package/src/i18n/en.json +79 -0
  11. package/src/index.ts +1 -0
  12. package/src/server/_tests/buildPwaHead.spec.ts +1 -1
  13. package/src/server/_tests/buildWebManifest.spec.ts +1 -1
  14. package/src/server/_tests/forgeServiceWorkerScript.spec.ts +1 -1
  15. package/src/server/index.ts +1 -1
  16. package/src/server/pwa.ts +1 -1
  17. package/src/types/models.ts +10 -0
  18. package/src/ui/analytics/ForgeAnalytics.tsx +55 -1
  19. package/src/ui/analytics/_tests/ForgeAnalytics.engagement.spec.tsx +78 -0
  20. package/src/ui/headless/checkout/_tests/bundleCoupon.spec.ts +3 -1
  21. package/src/ui/headless/checkout/_tests/cartResume.spec.ts +3 -10
  22. package/src/ui/headless/checkout/_tests/seatConflict.spec.ts +187 -0
  23. package/src/ui/headless/checkout/_tests/useCartResume.spec.tsx +3 -11
  24. package/src/ui/headless/checkout/_tests/useCheckoutSeats.spec.tsx +313 -0
  25. package/src/ui/headless/checkout/seatConflict.ts +117 -0
  26. package/src/ui/headless/checkout/useCheckout.ts +178 -55
  27. package/src/ui/headless/event/_tests/seatingFixture.ts +111 -0
  28. package/src/ui/headless/event/_tests/useEventCheckoutSeats.spec.tsx +443 -0
  29. package/src/ui/headless/event/_tests/useEventSeating.spec.tsx +154 -0
  30. package/src/ui/headless/event/useEventCheckout.ts +362 -22
  31. package/src/ui/headless/event/useEventSeating.ts +233 -0
  32. package/src/ui/headless/forms/_tests/usePhoneInput.spec.ts +187 -0
  33. package/src/ui/headless/forms/useEmailListForm.ts +37 -6
  34. package/src/ui/headless/forms/usePhoneInput.ts +294 -0
  35. package/src/ui/headless/forms/useSectionedForm.ts +2 -2
  36. package/src/ui/headless/index.ts +25 -1
  37. package/src/ui/index.ts +7 -0
  38. package/src/ui/media/CallStage.tsx +107 -10
  39. package/src/ui/media/_tests/CallBreakoutsPanel.spec.tsx +271 -0
  40. package/src/ui/media/_tests/CallStageBreakouts.spec.tsx +212 -0
  41. package/src/ui/media/_tests/callState.spec.ts +103 -1
  42. package/src/ui/media/callBreakouts.ts +57 -0
  43. package/src/ui/media/callState.ts +83 -23
  44. package/src/ui/media/index.ts +11 -0
  45. package/src/ui/media/panels/CallBreakoutsPanel.tsx +395 -0
  46. package/src/ui/media/panels/CallSidePanel.tsx +37 -4
  47. package/src/ui/media/stage/CallCenterNotice.tsx +5 -3
  48. package/src/ui/media/stage/CallControlsBar.tsx +46 -0
  49. package/src/ui/media/stage/CallTopBar.tsx +1 -1
  50. package/src/ui/shell/TribeNestApp.tsx +1 -1
  51. package/src/ui/styled/AccountDashboard.tsx +5 -0
  52. package/src/ui/styled/BundleConfirmation.tsx +47 -2
  53. package/src/ui/styled/Cart.tsx +211 -135
  54. package/src/ui/styled/Checkout.tsx +163 -32
  55. package/src/ui/styled/EmailListForm.tsx +7 -9
  56. package/src/ui/styled/EventTickets.tsx +143 -4
  57. package/src/ui/styled/PhoneInput.tsx +127 -0
  58. package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +36 -0
  59. package/src/ui/styled/_tests/CartSeats.spec.tsx +109 -0
  60. package/src/ui/styled/_tests/CheckoutSeatsConflict.spec.tsx +194 -0
  61. package/src/ui/styled/_tests/EventSeatPicker.spec.tsx +199 -0
  62. package/src/ui/styled/forge-utilities.css +67 -0
  63. package/src/ui/styled/seating/EventSeatPicker.tsx +190 -0
  64. package/src/ui/styled/seating/SeatLegend.tsx +73 -0
  65. package/src/ui/styled/seating/SeatMapCanvas.tsx +647 -0
  66. package/src/ui/styled/seating/paint.ts +79 -0
  67. package/src/ui/styled/seating/useSeatMapViewport.ts +248 -0
  68. package/src/ui/styled/seating/viewport.ts +86 -0
  69. package/src/utils/_tests/engagementTracker.spec.ts +111 -0
  70. package/src/utils/engagementTracker.ts +73 -0
  71. 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.57.0",
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.4.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, { title: string; price: number; pwywAmount?: number }>;
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 hasTicketsFor = useCallback(
343
- (eventId: string) => ticketItems.some((t) => t.eventId === eventId),
344
- [ticketItems],
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
- ? { pwywAmount: t.ticketMeta[ticketId]?.pwywAmount }
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.",