@tribe-nest/forge 3.54.0 → 3.58.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -2
- package/src/_tests/workspaceAliases.ts +1 -0
- package/src/contexts/CartContext.tsx +88 -5
- package/src/data/queries/useCallBreakouts.ts +112 -0
- package/src/data/queries/useCheckouts.ts +15 -2
- package/src/data/queries/useCoachingProducts.ts +15 -1
- package/src/data/queries/useCourses.ts +15 -1
- package/src/data/queries/useEvents.ts +80 -1
- package/src/data/queries/useMembership.ts +176 -4
- package/src/data/queries/useMyBookings.ts +16 -0
- package/src/data/queries/useNotifications.ts +1 -1
- package/src/data/queries/useProducts.ts +15 -1
- package/src/data/queries/useSubscriptions.ts +21 -1
- package/src/i18n/de.json +153 -0
- package/src/i18n/en.json +153 -0
- package/src/index.ts +1 -0
- package/src/server/_tests/buildPwaHead.spec.ts +1 -1
- package/src/server/_tests/buildWebManifest.spec.ts +1 -1
- package/src/server/_tests/forgeServiceWorkerScript.spec.ts +1 -1
- package/src/server/index.ts +75 -1
- package/src/server/platformEvents.generated.ts +60 -0
- package/src/server/pwa.ts +1 -1
- package/src/types/models.ts +137 -0
- package/src/ui/analytics/ForgeAnalytics.tsx +55 -1
- package/src/ui/analytics/_tests/ForgeAnalytics.engagement.spec.tsx +78 -0
- package/src/ui/headless/checkout/_tests/bundleCoupon.spec.ts +3 -1
- package/src/ui/headless/checkout/_tests/cartResume.spec.ts +3 -10
- package/src/ui/headless/checkout/_tests/seatConflict.spec.ts +187 -0
- package/src/ui/headless/checkout/_tests/useCartResume.spec.tsx +3 -11
- package/src/ui/headless/checkout/_tests/useCheckoutSeats.spec.tsx +313 -0
- package/src/ui/headless/checkout/seatConflict.ts +117 -0
- package/src/ui/headless/checkout/useCheckout.ts +178 -55
- package/src/ui/headless/event/_tests/seatingFixture.ts +111 -0
- package/src/ui/headless/event/_tests/useEventCheckoutSeats.spec.tsx +443 -0
- package/src/ui/headless/event/_tests/useEventSeating.spec.tsx +154 -0
- package/src/ui/headless/event/useEventCheckout.ts +362 -22
- package/src/ui/headless/event/useEventSeating.ts +233 -0
- package/src/ui/headless/forms/_tests/usePhoneInput.spec.ts +187 -0
- package/src/ui/headless/forms/useEmailListForm.ts +37 -6
- package/src/ui/headless/forms/usePhoneInput.ts +294 -0
- package/src/ui/headless/forms/useSectionedForm.ts +2 -2
- package/src/ui/headless/index.ts +48 -1
- package/src/ui/headless/membership/_tests/membershipApplication.spec.ts +85 -0
- package/src/ui/headless/membership/_tests/membershipCheckoutRefetch.spec.tsx +2 -0
- package/src/ui/headless/membership/_tests/membershipTrial.spec.ts +61 -0
- package/src/ui/headless/membership/_tests/useMembershipCheckoutApplication.spec.tsx +253 -0
- package/src/ui/headless/membership/_tests/useMembershipCheckoutQuestionnaire.spec.tsx +241 -0
- package/src/ui/headless/membership/_tests/useMembershipCheckoutTrial.spec.tsx +165 -0
- package/src/ui/headless/membership/membershipApplication.ts +109 -0
- package/src/ui/headless/membership/membershipQuestionnaire.ts +44 -0
- package/src/ui/headless/membership/membershipTrial.ts +53 -0
- package/src/ui/headless/membership/useMembershipCheckout.ts +227 -8
- package/src/ui/index.ts +16 -0
- package/src/ui/media/CallStage.tsx +107 -10
- package/src/ui/media/_tests/CallBreakoutsPanel.spec.tsx +271 -0
- package/src/ui/media/_tests/CallStageBreakouts.spec.tsx +212 -0
- package/src/ui/media/_tests/callState.spec.ts +103 -1
- package/src/ui/media/callBreakouts.ts +57 -0
- package/src/ui/media/callState.ts +83 -23
- package/src/ui/media/index.ts +11 -0
- package/src/ui/media/panels/CallBreakoutsPanel.tsx +395 -0
- package/src/ui/media/panels/CallSidePanel.tsx +37 -4
- package/src/ui/media/stage/CallCenterNotice.tsx +5 -3
- package/src/ui/media/stage/CallControlsBar.tsx +46 -0
- package/src/ui/media/stage/CallTopBar.tsx +1 -1
- package/src/ui/payment/ForgePaymentProvider.tsx +14 -0
- package/src/ui/payment/ForgeStripePayment.tsx +78 -7
- package/src/ui/payment/_tests/ForgeStripePaymentTrial.spec.tsx +101 -0
- package/src/ui/payment/_tests/stripeIntentKind.spec.ts +43 -0
- package/src/ui/payment/stripeIntentKind.ts +32 -0
- package/src/ui/shell/TribeNestApp.tsx +1 -1
- package/src/ui/styled/AccountDashboard.tsx +165 -2
- package/src/ui/styled/BundleConfirmation.tsx +47 -2
- package/src/ui/styled/Cart.tsx +211 -135
- package/src/ui/styled/Checkout.tsx +163 -32
- package/src/ui/styled/EmailListForm.tsx +7 -9
- package/src/ui/styled/EventTickets.tsx +143 -4
- package/src/ui/styled/LoginForm.tsx +5 -3
- package/src/ui/styled/MembershipCheckout.tsx +596 -256
- package/src/ui/styled/MembershipTierCallout.tsx +17 -3
- package/src/ui/styled/MembershipTiers.tsx +148 -6
- package/src/ui/styled/PhoneInput.tsx +127 -0
- package/src/ui/styled/ProductGrid.tsx +14 -2
- package/src/ui/styled/SignupForm.tsx +4 -2
- package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +36 -0
- package/src/ui/styled/_tests/AccountDashboardTrial.spec.tsx +105 -0
- package/src/ui/styled/_tests/CartSeats.spec.tsx +109 -0
- package/src/ui/styled/_tests/CheckoutSeatsConflict.spec.tsx +194 -0
- package/src/ui/styled/_tests/EventSeatPicker.spec.tsx +199 -0
- package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +106 -0
- package/src/ui/styled/_tests/membershipTiersCuratedAccess.spec.tsx +139 -0
- package/src/ui/styled/_tests/membershipTiersTrial.spec.tsx +97 -0
- package/src/ui/styled/forge-utilities.css +67 -0
- package/src/ui/styled/seating/EventSeatPicker.tsx +190 -0
- package/src/ui/styled/seating/SeatLegend.tsx +73 -0
- package/src/ui/styled/seating/SeatMapCanvas.tsx +647 -0
- package/src/ui/styled/seating/paint.ts +79 -0
- package/src/ui/styled/seating/useSeatMapViewport.ts +248 -0
- package/src/ui/styled/seating/viewport.ts +86 -0
- package/src/utils/_tests/engagementTracker.spec.ts +111 -0
- package/src/utils/engagementTracker.ts +73 -0
- package/src/utils/landing.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tribe-nest/forge",
|
|
3
|
-
"version": "3.
|
|
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.
|
|
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,
|
|
@@ -7,7 +7,20 @@ export interface GetCoachingProductsParams {
|
|
|
7
7
|
query?: string;
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
-
export function useGetCoachingProducts(
|
|
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(
|
|
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 {
|
|
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:
|
|
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
|
-
|
|
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
|
+
}
|