@tribe-nest/forge 3.54.0 → 3.58.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) 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/useCoachingProducts.ts +15 -1
  7. package/src/data/queries/useCourses.ts +15 -1
  8. package/src/data/queries/useEvents.ts +80 -1
  9. package/src/data/queries/useMembership.ts +176 -4
  10. package/src/data/queries/useMyBookings.ts +16 -0
  11. package/src/data/queries/useNotifications.ts +1 -1
  12. package/src/data/queries/useProducts.ts +15 -1
  13. package/src/data/queries/useSubscriptions.ts +21 -1
  14. package/src/i18n/de.json +153 -0
  15. package/src/i18n/en.json +153 -0
  16. package/src/index.ts +1 -0
  17. package/src/server/_tests/buildPwaHead.spec.ts +1 -1
  18. package/src/server/_tests/buildWebManifest.spec.ts +1 -1
  19. package/src/server/_tests/forgeServiceWorkerScript.spec.ts +1 -1
  20. package/src/server/index.ts +75 -1
  21. package/src/server/platformEvents.generated.ts +60 -0
  22. package/src/server/pwa.ts +1 -1
  23. package/src/types/models.ts +137 -0
  24. package/src/ui/analytics/ForgeAnalytics.tsx +55 -1
  25. package/src/ui/analytics/_tests/ForgeAnalytics.engagement.spec.tsx +78 -0
  26. package/src/ui/headless/checkout/_tests/bundleCoupon.spec.ts +3 -1
  27. package/src/ui/headless/checkout/_tests/cartResume.spec.ts +3 -10
  28. package/src/ui/headless/checkout/_tests/seatConflict.spec.ts +187 -0
  29. package/src/ui/headless/checkout/_tests/useCartResume.spec.tsx +3 -11
  30. package/src/ui/headless/checkout/_tests/useCheckoutSeats.spec.tsx +313 -0
  31. package/src/ui/headless/checkout/seatConflict.ts +117 -0
  32. package/src/ui/headless/checkout/useCheckout.ts +178 -55
  33. package/src/ui/headless/event/_tests/seatingFixture.ts +111 -0
  34. package/src/ui/headless/event/_tests/useEventCheckoutSeats.spec.tsx +443 -0
  35. package/src/ui/headless/event/_tests/useEventSeating.spec.tsx +154 -0
  36. package/src/ui/headless/event/useEventCheckout.ts +362 -22
  37. package/src/ui/headless/event/useEventSeating.ts +233 -0
  38. package/src/ui/headless/forms/_tests/usePhoneInput.spec.ts +187 -0
  39. package/src/ui/headless/forms/useEmailListForm.ts +37 -6
  40. package/src/ui/headless/forms/usePhoneInput.ts +294 -0
  41. package/src/ui/headless/forms/useSectionedForm.ts +2 -2
  42. package/src/ui/headless/index.ts +48 -1
  43. package/src/ui/headless/membership/_tests/membershipApplication.spec.ts +85 -0
  44. package/src/ui/headless/membership/_tests/membershipCheckoutRefetch.spec.tsx +2 -0
  45. package/src/ui/headless/membership/_tests/membershipTrial.spec.ts +61 -0
  46. package/src/ui/headless/membership/_tests/useMembershipCheckoutApplication.spec.tsx +253 -0
  47. package/src/ui/headless/membership/_tests/useMembershipCheckoutQuestionnaire.spec.tsx +241 -0
  48. package/src/ui/headless/membership/_tests/useMembershipCheckoutTrial.spec.tsx +165 -0
  49. package/src/ui/headless/membership/membershipApplication.ts +109 -0
  50. package/src/ui/headless/membership/membershipQuestionnaire.ts +44 -0
  51. package/src/ui/headless/membership/membershipTrial.ts +53 -0
  52. package/src/ui/headless/membership/useMembershipCheckout.ts +227 -8
  53. package/src/ui/index.ts +16 -0
  54. package/src/ui/media/CallStage.tsx +107 -10
  55. package/src/ui/media/_tests/CallBreakoutsPanel.spec.tsx +271 -0
  56. package/src/ui/media/_tests/CallStageBreakouts.spec.tsx +212 -0
  57. package/src/ui/media/_tests/callState.spec.ts +103 -1
  58. package/src/ui/media/callBreakouts.ts +57 -0
  59. package/src/ui/media/callState.ts +83 -23
  60. package/src/ui/media/index.ts +11 -0
  61. package/src/ui/media/panels/CallBreakoutsPanel.tsx +395 -0
  62. package/src/ui/media/panels/CallSidePanel.tsx +37 -4
  63. package/src/ui/media/stage/CallCenterNotice.tsx +5 -3
  64. package/src/ui/media/stage/CallControlsBar.tsx +46 -0
  65. package/src/ui/media/stage/CallTopBar.tsx +1 -1
  66. package/src/ui/payment/ForgePaymentProvider.tsx +14 -0
  67. package/src/ui/payment/ForgeStripePayment.tsx +78 -7
  68. package/src/ui/payment/_tests/ForgeStripePaymentTrial.spec.tsx +101 -0
  69. package/src/ui/payment/_tests/stripeIntentKind.spec.ts +43 -0
  70. package/src/ui/payment/stripeIntentKind.ts +32 -0
  71. package/src/ui/shell/TribeNestApp.tsx +1 -1
  72. package/src/ui/styled/AccountDashboard.tsx +165 -2
  73. package/src/ui/styled/BundleConfirmation.tsx +47 -2
  74. package/src/ui/styled/Cart.tsx +211 -135
  75. package/src/ui/styled/Checkout.tsx +163 -32
  76. package/src/ui/styled/EmailListForm.tsx +7 -9
  77. package/src/ui/styled/EventTickets.tsx +143 -4
  78. package/src/ui/styled/LoginForm.tsx +5 -3
  79. package/src/ui/styled/MembershipCheckout.tsx +596 -256
  80. package/src/ui/styled/MembershipTierCallout.tsx +17 -3
  81. package/src/ui/styled/MembershipTiers.tsx +148 -6
  82. package/src/ui/styled/PhoneInput.tsx +127 -0
  83. package/src/ui/styled/ProductGrid.tsx +14 -2
  84. package/src/ui/styled/SignupForm.tsx +4 -2
  85. package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +36 -0
  86. package/src/ui/styled/_tests/AccountDashboardTrial.spec.tsx +105 -0
  87. package/src/ui/styled/_tests/CartSeats.spec.tsx +109 -0
  88. package/src/ui/styled/_tests/CheckoutSeatsConflict.spec.tsx +194 -0
  89. package/src/ui/styled/_tests/EventSeatPicker.spec.tsx +199 -0
  90. package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +106 -0
  91. package/src/ui/styled/_tests/membershipTiersCuratedAccess.spec.tsx +139 -0
  92. package/src/ui/styled/_tests/membershipTiersTrial.spec.tsx +97 -0
  93. package/src/ui/styled/forge-utilities.css +67 -0
  94. package/src/ui/styled/seating/EventSeatPicker.tsx +190 -0
  95. package/src/ui/styled/seating/SeatLegend.tsx +73 -0
  96. package/src/ui/styled/seating/SeatMapCanvas.tsx +647 -0
  97. package/src/ui/styled/seating/paint.ts +79 -0
  98. package/src/ui/styled/seating/useSeatMapViewport.ts +248 -0
  99. package/src/ui/styled/seating/viewport.ts +86 -0
  100. package/src/utils/_tests/engagementTracker.spec.ts +111 -0
  101. package/src/utils/engagementTracker.ts +73 -0
  102. 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.54.0",
3
+ "version": "3.58.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,
@@ -7,7 +7,20 @@ export interface GetCoachingProductsParams {
7
7
  query?: string;
8
8
  }
9
9
 
10
- export function useGetCoachingProducts(params?: GetCoachingProductsParams, enabled = true) {
10
+ export function useGetCoachingProducts(
11
+ params?: GetCoachingProductsParams,
12
+ enabled = true,
13
+ options?: {
14
+ /**
15
+ * The page a server render already fetched.
16
+ *
17
+ * Seeds the cache so the first client render matches the markup and nothing
18
+ * is refetched. Must be built with the SAME params as the key below, or the
19
+ * seed belongs to a different query and is ignored.
20
+ */
21
+ initialData?: PaginatedData<CoachingProduct>;
22
+ },
23
+ ) {
11
24
  const { client, profileId } = useForge();
12
25
 
13
26
  return useQuery<PaginatedData<CoachingProduct>>({
@@ -23,6 +36,7 @@ export function useGetCoachingProducts(params?: GetCoachingProductsParams, enabl
23
36
  return res.data;
24
37
  },
25
38
  enabled: !!profileId && !!client && enabled,
39
+ initialData: options?.initialData,
26
40
  });
27
41
  }
28
42
 
@@ -90,7 +90,20 @@ export interface GetCoursesParams {
90
90
  query?: string;
91
91
  }
92
92
 
93
- export function useGetCourses(params?: GetCoursesParams, enabled = true) {
93
+ export function useGetCourses(
94
+ params?: GetCoursesParams,
95
+ enabled = true,
96
+ options?: {
97
+ /**
98
+ * The page a server render already fetched.
99
+ *
100
+ * Seeds the cache so the first client render matches the markup and nothing
101
+ * is refetched. Must be built with the SAME params as the key below, or the
102
+ * seed belongs to a different query and is ignored.
103
+ */
104
+ initialData?: PaginatedData<PublicCourse>;
105
+ },
106
+ ) {
94
107
  const { client, profileId } = useForge();
95
108
 
96
109
  return useQuery<PaginatedData<PublicCourse>>({
@@ -106,6 +119,7 @@ export function useGetCourses(params?: GetCoursesParams, enabled = true) {
106
119
  return res.data;
107
120
  },
108
121
  enabled: !!profileId && !!client && enabled,
122
+ initialData: options?.initialData,
109
123
  });
110
124
  }
111
125
 
@@ -1,9 +1,20 @@
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
 
5
6
  /** All published events for the profile. */
6
- export function useEvents() {
7
+ export function useEvents(options?: {
8
+ /**
9
+ * The list a server render already fetched (`fetchEventsServer`).
10
+ *
11
+ * Seeds the cache so the first client render shows the same events as the
12
+ * markup and nothing is refetched. Without it the list page renders a
13
+ * spinner on the server, which is what a crawler sees and what a fan sees
14
+ * for as long as the request takes.
15
+ */
16
+ initialData?: IEvent[];
17
+ }) {
7
18
  const { client, profileId } = useForge();
8
19
 
9
20
  return useQuery<IEvent[]>({
@@ -15,6 +26,7 @@ export function useEvents() {
15
26
  return res.data;
16
27
  },
17
28
  enabled: !!profileId && !!client,
29
+ initialData: options?.initialData,
18
30
  });
19
31
  }
20
32
 
@@ -70,8 +82,75 @@ export function useEvent(
70
82
  });
71
83
  }
72
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
+
73
141
  export type CreateEventOrderInput = {
74
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[]>;
75
154
  /**
76
155
  * ticketId → the buyer's chosen amount, PER UNIT, on a pay-what-you-want
77
156
  * tier. Ignored server-side for any tier that is not PWYW, and clamped up to
@@ -1,22 +1,60 @@
1
- import type { MembershipTier } from "../../types/models";
1
+ import type {
2
+ ApplyForMembershipResult,
3
+ MembershipApplication,
4
+ MembershipInviteInfo,
5
+ MembershipTier,
6
+ QuestionnaireAnswer,
7
+ } from "../../types/models";
2
8
  import { useForge } from "../../provider/ForgeProvider";
3
- import { useQuery } from "@tanstack/react-query";
9
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
10
+
11
+ /**
12
+ * The membership invite token this visitor arrived holding, read from the
13
+ * `invite` search param of the CURRENT url (the invite email links to
14
+ * `/i/membership/checkout?membershipTierId=X&invite=TOKEN`).
15
+ *
16
+ * Deliberately not stored anywhere: it is a bearer credential, and the durable
17
+ * copy is the email. Returns undefined on the server and when absent.
18
+ */
19
+ export const readMembershipInviteFromUrl = (search?: string): string | undefined => {
20
+ const raw = search ?? (typeof window === "undefined" ? "" : window.location.search);
21
+ if (!raw) return undefined;
22
+ const token = new URLSearchParams(raw).get("invite")?.trim();
23
+ return token ? token : undefined;
24
+ };
25
+
26
+ export interface UseGetMembershipTiersOptions {
27
+ enabled?: boolean;
28
+ /**
29
+ * An invite token to forward, so an invite-only tier it unlocks is listed.
30
+ * Omitted (undefined), the `invite` search param of the current url is used;
31
+ * `null` forwards nothing even when the url carries one.
32
+ */
33
+ inviteToken?: string | null;
34
+ }
4
35
 
5
36
  /**
6
37
  * `opts.enabled` gates the request on top of the usual profile/client checks —
7
38
  * for a caller that only needs tier NAMES when something is actually gated, and
8
39
  * must not add a request to every ungated storefront. Defaults to on, so every
9
40
  * existing caller is unchanged.
41
+ *
42
+ * Invite-only tiers are omitted by the server unless the request carries a
43
+ * live invite token for them, so the token is part of the query key.
10
44
  */
11
- export function useGetMembershipTiers(opts: { enabled?: boolean } = {}) {
45
+ export function useGetMembershipTiers(opts: UseGetMembershipTiersOptions = {}) {
12
46
  const { client, profileId } = useForge();
47
+ const inviteToken = opts.inviteToken === undefined ? readMembershipInviteFromUrl() : (opts.inviteToken ?? undefined);
13
48
 
14
49
  return useQuery<MembershipTier[]>({
15
- queryKey: ["membership-tiers", profileId],
50
+ // The token is appended only when present, so the no-invite key stays the
51
+ // one every existing cache seed (SSR, specs) already writes.
52
+ queryKey: inviteToken ? ["membership-tiers", profileId, inviteToken] : ["membership-tiers", profileId],
16
53
  queryFn: async () => {
17
54
  const res = await client.get("/public/membership-tiers", {
18
55
  params: {
19
56
  profileId: profileId,
57
+ ...(inviteToken ? { inviteToken } : {}),
20
58
  },
21
59
  });
22
60
  return res.data;
@@ -24,3 +62,137 @@ export function useGetMembershipTiers(opts: { enabled?: boolean } = {}) {
24
62
  enabled: !!profileId && !!client && opts.enabled !== false,
25
63
  });
26
64
  }
65
+
66
+ // ---- Invites -------------------------------------------------------------------
67
+
68
+ /**
69
+ * What a membership invite link is for: the tier and the invited email, so the
70
+ * landing page can name both before asking for anything.
71
+ *
72
+ * A dead token (unknown, expired, revoked or already accepted) is a 404, which
73
+ * is an ANSWER rather than a transient failure, so it is not retried. Tell the
74
+ * two apart with `isMembershipInviteDead(error)`.
75
+ */
76
+ export function useMembershipInviteInfo(token?: string | null) {
77
+ const { client } = useForge();
78
+
79
+ return useQuery<MembershipInviteInfo>({
80
+ queryKey: ["membership-invite-info", token ?? null],
81
+ queryFn: async () => {
82
+ const res = await client.get("/public/membership-invites/info", { params: { token } });
83
+ return res.data;
84
+ },
85
+ enabled: !!token && !!client,
86
+ retry: (count, error) => !isMembershipInviteDead(error) && count < 2,
87
+ });
88
+ }
89
+
90
+ /** True when an invite-info error means the invite is gone (404), not that the request failed. */
91
+ export function isMembershipInviteDead(error: unknown): boolean {
92
+ return (error as { response?: { status?: number } } | null)?.response?.status === 404;
93
+ }
94
+
95
+ // ---- Applications ----------------------------------------------------------------
96
+
97
+ export type ApplyForMembershipInput = {
98
+ membershipTierId: string;
99
+ /** Required on a paid tier. */
100
+ billingCycle?: "month" | "year";
101
+ /** Required on a paid tier; same floor/ceiling rules as a subscription. */
102
+ amount?: number;
103
+ /**
104
+ * A free-text note, up to 1000 characters. Still accepted by the server, but
105
+ * no current checkout sends it: the tier's questions replaced it.
106
+ */
107
+ message?: string;
108
+ /** Answers to the tier's questions. Required by the server when the tier has any. */
109
+ questionnaire?: QuestionnaireAnswer[];
110
+ /** Where Stripe returns after a redirect-based card confirmation. */
111
+ returnUrl?: string;
112
+ };
113
+
114
+ /** Invalidate everything an application changes: the tier cards and the fan's list. */
115
+ function useInvalidateApplications() {
116
+ const queryClient = useQueryClient();
117
+ return () =>
118
+ Promise.all([
119
+ queryClient.invalidateQueries({ queryKey: ["membership-tiers"] }),
120
+ queryClient.invalidateQueries({ queryKey: ["my-membership-applications"] }),
121
+ ]);
122
+ }
123
+
124
+ /**
125
+ * Apply to join an `application` tier.
126
+ *
127
+ * A free tier comes back `pending` with nothing more to do. A paid tier comes
128
+ * back `card_pending` with `requiresCard` and a SetupIntent `clientSecret`: the
129
+ * card is saved, not charged, and after the renderer confirms it the caller
130
+ * calls `useConfirmMembershipApplication`. Applying again while one is open
131
+ * returns that one.
132
+ */
133
+ export function useApplyForMembership() {
134
+ const { client, profileId } = useForge();
135
+ const invalidate = useInvalidateApplications();
136
+
137
+ return useMutation<ApplyForMembershipResult, unknown, ApplyForMembershipInput>({
138
+ mutationFn: async (body) => {
139
+ const res = await client.post("/public/membership-applications", { ...body, profileId });
140
+ return res.data;
141
+ },
142
+ onSuccess: () => void invalidate(),
143
+ });
144
+ }
145
+
146
+ /**
147
+ * Tell the server the card step finished. Idempotent. The application comes
148
+ * back `pending` once the provider shows the card saved, still `card_pending`
149
+ * if it does not yet, or `withdrawn` with `closedReason: "tier_full"` when the
150
+ * last seat went in the meantime.
151
+ */
152
+ export function useConfirmMembershipApplication() {
153
+ const { client, profileId } = useForge();
154
+ const invalidate = useInvalidateApplications();
155
+
156
+ return useMutation<{ application: MembershipApplication }, unknown, { applicationId: string }>({
157
+ mutationFn: async ({ applicationId }) => {
158
+ const res = await client.post(`/public/membership-applications/${encodeURIComponent(applicationId)}/confirm`, {
159
+ profileId,
160
+ });
161
+ return res.data;
162
+ },
163
+ onSuccess: () => void invalidate(),
164
+ });
165
+ }
166
+
167
+ /** Withdraw an application that is still `card_pending` or `pending`. */
168
+ export function useWithdrawMembershipApplication() {
169
+ const { client, profileId } = useForge();
170
+ const invalidate = useInvalidateApplications();
171
+
172
+ return useMutation<{ application: MembershipApplication }, unknown, { applicationId: string }>({
173
+ mutationFn: async ({ applicationId }) => {
174
+ const res = await client.post(`/public/membership-applications/${encodeURIComponent(applicationId)}/withdraw`, {
175
+ profileId,
176
+ });
177
+ return res.data;
178
+ },
179
+ onSuccess: () => void invalidate(),
180
+ });
181
+ }
182
+
183
+ /**
184
+ * The signed-in fan's applications on this profile, newest first. Pass the
185
+ * account id so the query waits for sign-in and is keyed per account.
186
+ */
187
+ export function useMyMembershipApplications(accountId?: string) {
188
+ const { client, profileId } = useForge();
189
+
190
+ return useQuery<MembershipApplication[]>({
191
+ queryKey: ["my-membership-applications", accountId, profileId],
192
+ queryFn: async () => {
193
+ const res = await client.get("/public/membership-applications/mine", { params: { profileId } });
194
+ return res.data;
195
+ },
196
+ enabled: !!accountId && !!profileId && !!client,
197
+ });
198
+ }