@tribe-nest/forge 3.26.0 → 3.31.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 (129) hide show
  1. package/package.json +6 -3
  2. package/src/_tests/publishedResolvability.spec.ts +184 -0
  3. package/src/_tests/specsRunWorkspaceSource.spec.ts +116 -0
  4. package/src/_tests/workspaceAliases.ts +40 -0
  5. package/src/client/_tests/localeHeader.spec.tsx +98 -0
  6. package/src/client/activeLocale.ts +34 -0
  7. package/src/client/createForgeClient.ts +13 -0
  8. package/src/contexts/CartContext.tsx +76 -0
  9. package/src/contexts/PublicAuthContext.tsx +34 -5
  10. package/src/contexts/_tests/CartContext.spec.tsx +210 -0
  11. package/src/contexts/_tests/PublicAuthRefetch.spec.tsx +147 -0
  12. package/src/data/queries/useBroadcasts.ts +151 -0
  13. package/src/data/queries/useCheckouts.ts +83 -0
  14. package/src/data/queries/useMyBookings.ts +9 -1
  15. package/src/data/queries/useWebsite.ts +7 -0
  16. package/src/i18n/CONVENTION.md +184 -0
  17. package/src/i18n/_tests/translationKeys.spec.ts +153 -0
  18. package/src/i18n/de.json +1019 -0
  19. package/src/i18n/en.json +1019 -0
  20. package/src/i18n/index.ts +125 -0
  21. package/src/index.ts +21 -0
  22. package/src/provider/ForgeAppProvider.tsx +2 -1
  23. package/src/provider/SiteConfigProvider.tsx +11 -0
  24. package/src/ui/format/_tests/membershipPwyw.spec.ts +185 -0
  25. package/src/ui/format/_tests/pwyw.spec.ts +65 -8
  26. package/src/ui/format/membershipPwyw.ts +164 -0
  27. package/src/ui/format/pwyw.ts +37 -0
  28. package/src/ui/headless/broadcast/_tests/broadcastState.spec.ts +235 -0
  29. package/src/ui/headless/broadcast/broadcastState.ts +158 -0
  30. package/src/ui/headless/broadcast/useBroadcastWatch.ts +174 -21
  31. package/src/ui/headless/checkout/_tests/cartResume.spec.ts +291 -0
  32. package/src/ui/headless/checkout/_tests/useCartResume.spec.tsx +294 -0
  33. package/src/ui/headless/checkout/resumedCoupon.ts +61 -0
  34. package/src/ui/headless/checkout/useCartResume.ts +340 -0
  35. package/src/ui/headless/checkout/useCheckout.ts +9 -2
  36. package/src/ui/headless/event/useEventCheckout.ts +8 -13
  37. package/src/ui/headless/index.ts +32 -0
  38. package/src/ui/headless/membership/useMembershipCheckout.ts +160 -32
  39. package/src/ui/index.ts +38 -0
  40. package/src/ui/media/CallHelpHint.tsx +87 -0
  41. package/src/ui/media/CallStage.tsx +542 -0
  42. package/src/ui/media/_tests/CallStage.spec.tsx +685 -0
  43. package/src/ui/media/_tests/bookingSession.spec.tsx +179 -0
  44. package/src/ui/media/_tests/callState.spec.ts +452 -0
  45. package/src/ui/media/_tests/fakeNode.ts +178 -0
  46. package/src/ui/media/bookingSession.tsx +194 -0
  47. package/src/ui/media/callState.ts +341 -0
  48. package/src/ui/media/index.ts +135 -0
  49. package/src/ui/shell/TribeNestApp.tsx +36 -27
  50. package/src/ui/styled/AccountDashboard.tsx +289 -96
  51. package/src/ui/styled/AiAgentWidget.tsx +10 -6
  52. package/src/ui/styled/AudioPlayer.tsx +14 -6
  53. package/src/ui/styled/BlogCategory.tsx +4 -2
  54. package/src/ui/styled/BlogComments.tsx +27 -20
  55. package/src/ui/styled/BlogList.tsx +5 -3
  56. package/src/ui/styled/BlogPost.tsx +14 -11
  57. package/src/ui/styled/BroadcastWatch.tsx +107 -0
  58. package/src/ui/styled/BundleConfirmation.tsx +32 -23
  59. package/src/ui/styled/CancellationTerms.tsx +3 -1
  60. package/src/ui/styled/Cart.tsx +32 -14
  61. package/src/ui/styled/ChatRoom.tsx +65 -20
  62. package/src/ui/styled/Checkout.tsx +151 -89
  63. package/src/ui/styled/CheckoutConfirmation.tsx +66 -35
  64. package/src/ui/styled/CoachingBooking.tsx +71 -44
  65. package/src/ui/styled/CoachingConfirmation.tsx +56 -19
  66. package/src/ui/styled/CoachingDetail.tsx +15 -11
  67. package/src/ui/styled/CohortPage.tsx +10 -8
  68. package/src/ui/styled/ConfirmSubscription.tsx +14 -8
  69. package/src/ui/styled/ContactForm.tsx +31 -12
  70. package/src/ui/styled/CookieConsent.tsx +19 -15
  71. package/src/ui/styled/CourseAccess.tsx +54 -47
  72. package/src/ui/styled/CourseCheckout.tsx +56 -38
  73. package/src/ui/styled/CourseConfirmation.tsx +39 -17
  74. package/src/ui/styled/CourseDetail.tsx +21 -15
  75. package/src/ui/styled/CoursesGrid.tsx +4 -2
  76. package/src/ui/styled/CurrencySwitcher.tsx +4 -1
  77. package/src/ui/styled/DiscountCode.tsx +22 -18
  78. package/src/ui/styled/DonationButton.tsx +52 -17
  79. package/src/ui/styled/DonationPage.tsx +4 -4
  80. package/src/ui/styled/EmailListForm.tsx +22 -9
  81. package/src/ui/styled/EventConfirmation.tsx +37 -14
  82. package/src/ui/styled/EventCountdown.tsx +10 -6
  83. package/src/ui/styled/EventDetail.tsx +8 -6
  84. package/src/ui/styled/EventSeriesDetail.tsx +18 -13
  85. package/src/ui/styled/EventTickets.tsx +232 -108
  86. package/src/ui/styled/EventWaitlist.tsx +42 -33
  87. package/src/ui/styled/EventsList.tsx +15 -13
  88. package/src/ui/styled/ForgotPasswordForm.tsx +17 -12
  89. package/src/ui/styled/FormRenderer.tsx +12 -7
  90. package/src/ui/styled/InstallBanner.tsx +21 -18
  91. package/src/ui/styled/InvoiceConfirmation.tsx +13 -5
  92. package/src/ui/styled/InvoicePayment.tsx +37 -16
  93. package/src/ui/styled/LeadMagnet.tsx +9 -7
  94. package/src/ui/styled/LiveBroadcastList.tsx +171 -0
  95. package/src/ui/styled/Loading.tsx +7 -1
  96. package/src/ui/styled/LoginForm.tsx +32 -14
  97. package/src/ui/styled/MembershipCheckout.tsx +341 -49
  98. package/src/ui/styled/MembershipTiers.tsx +34 -18
  99. package/src/ui/styled/OfferButton.tsx +33 -13
  100. package/src/ui/styled/PaymentLinkConfirmation.tsx +11 -5
  101. package/src/ui/styled/PaymentLinkPayment.tsx +30 -10
  102. package/src/ui/styled/PodcastEpisode.tsx +20 -9
  103. package/src/ui/styled/PodcastList.tsx +9 -3
  104. package/src/ui/styled/PodcastShow.tsx +29 -7
  105. package/src/ui/styled/PostsFeed.tsx +10 -8
  106. package/src/ui/styled/PresaleCode.tsx +8 -11
  107. package/src/ui/styled/ProductBrowseNav.tsx +21 -11
  108. package/src/ui/styled/ProductDetail.tsx +58 -28
  109. package/src/ui/styled/ReactionBar.tsx +4 -2
  110. package/src/ui/styled/ReplayList.tsx +24 -13
  111. package/src/ui/styled/ResetPasswordForm.tsx +32 -15
  112. package/src/ui/styled/ResumeCart.tsx +289 -0
  113. package/src/ui/styled/ReviewForm.tsx +52 -58
  114. package/src/ui/styled/SignupForm.tsx +34 -16
  115. package/src/ui/styled/TicketTransfer.tsx +55 -54
  116. package/src/ui/styled/UserMenu.tsx +16 -14
  117. package/src/ui/styled/_tests/AccountDashboardCommunity.spec.tsx +134 -0
  118. package/src/ui/styled/_tests/BroadcastPassValidation.spec.tsx +125 -0
  119. package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +364 -0
  120. package/src/ui/styled/_tests/ResumeCart.spec.tsx +183 -0
  121. package/src/ui/styled/broadcast/BroadcastPassValidation.tsx +187 -0
  122. package/src/ui/styled/broadcast/BroadcastPlayer.tsx +536 -0
  123. package/src/ui/styled/broadcast/BroadcastTicketPurchase.tsx +74 -0
  124. package/src/ui/styled/broadcast/EndedBroadcast.tsx +103 -0
  125. package/src/ui/styled/community/CommunityComposer.tsx +182 -3
  126. package/src/ui/styled/community/CommunityFeed.tsx +36 -51
  127. package/src/ui/styled/community/CommunityPostDetail.tsx +16 -2
  128. package/src/ui/styled/community/_tests/CommunityComposer.spec.tsx +281 -0
  129. package/src/ui/styled/community/_tests/CommunityPostDetail.spec.tsx +175 -0
@@ -245,9 +245,35 @@ function PublicAuthProvider({ children }: AuthProviderProps) {
245
245
  await client.post("/public/sessions/resend-code", { email, profileId });
246
246
  };
247
247
 
248
+ /**
249
+ * Re-read the signed-in member from the PUBLIC lane.
250
+ *
251
+ * ## `/public/accounts/me`, never `/accounts/me`
252
+ *
253
+ * This used to call the dashboard endpoint, which signed a fan out every time
254
+ * it ran. A fan session is minted by `mintAssociationSession` and its JWT
255
+ * carries an `associationId`; `requireAuthentication` rejects any token
256
+ * carrying one outright, by design, because honouring it on the admin surface
257
+ * would hand the caller that account's full dashboard permissions. So the
258
+ * call could only ever 401, the catch below read that as "not signed in", and
259
+ * the member was thrown back to the login page while a perfectly good token
260
+ * sat in localStorage. It bit hardest right after joining a membership, where
261
+ * the account page refetches on arrival.
262
+ *
263
+ * The 401 was also charged twice: the axios refresh interceptor treated it as
264
+ * an expired access token, spent a refresh-token rotation on it, and retried
265
+ * into the same 401.
266
+ *
267
+ * ## A failed refetch is not a logout
268
+ *
269
+ * Only an authentication failure clears the session now. A network blip or a
270
+ * 500 leaves the member exactly as they were, because dropping someone's
271
+ * session over a flaky response is a worse answer than showing them slightly
272
+ * stale details.
273
+ */
248
274
  const refetchUser = async () => {
249
275
  try {
250
- const response = await client.get("/accounts/me", {
276
+ const response = await client.get("/public/accounts/me", {
251
277
  params: {
252
278
  profileId: profileId,
253
279
  },
@@ -258,10 +284,13 @@ function PublicAuthProvider({ children }: AuthProviderProps) {
258
284
  });
259
285
  } catch (error: unknown) {
260
286
  console.error(error);
261
- dispatch({
262
- type: Types.Initial,
263
- payload: { isAuthenticated: false, user: null },
264
- });
287
+ const status = (error as { response?: { status?: number } })?.response?.status;
288
+ if (status === 401 || status === 403) {
289
+ dispatch({
290
+ type: Types.Initial,
291
+ payload: { isAuthenticated: false, user: null },
292
+ });
293
+ }
265
294
  }
266
295
  };
267
296
 
@@ -0,0 +1,210 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, it, expect, beforeEach } from "vitest";
3
+ import { act, renderHook } from "@testing-library/react";
4
+ import type { ReactNode } from "react";
5
+ import { CartProvider, useCart, type CartItem, type TicketCartItem } from "../CartContext";
6
+ import { ProductDeliveryType } from "../../types/models";
7
+
8
+ /**
9
+ * `restoreCart`, the one entry point that writes the WHOLE basket at once.
10
+ *
11
+ * It exists for the recovery link, and it is the only cart mutation that runs
12
+ * without the visitor pressing anything: they click a link in an email and the
13
+ * basket changes underneath them. So the thing worth asserting is not that the
14
+ * restored lines arrive, it is that nothing the visitor put in the basket
15
+ * THEMSELVES leaves while that happens. A restore that quietly drops a tier is
16
+ * indistinguishable, to the buyer, from a shop that lost their basket twice.
17
+ *
18
+ * jsdom rather than `renderToStaticMarkup`, because a provider that hydrates in
19
+ * a mount effect and persists in another has no behaviour at all without
20
+ * effects and without `localStorage`.
21
+ */
22
+
23
+ const wrapper = ({ children }: { children: ReactNode }) => <CartProvider>{children}</CartProvider>;
24
+
25
+ const item = (overrides: Partial<CartItem> = {}): CartItem => ({
26
+ productId: "prod-1",
27
+ productVariantId: "var-1",
28
+ title: "Tour Tee",
29
+ price: 25,
30
+ isGift: false,
31
+ canIncreaseQuantity: true,
32
+ quantity: 1,
33
+ payWhatYouWant: false,
34
+ deliveryType: ProductDeliveryType.Physical,
35
+ ...overrides,
36
+ });
37
+
38
+ const selection = (overrides: Partial<TicketCartItem> = {}): TicketCartItem => ({
39
+ eventId: "event-1",
40
+ eventSlug: "spring-tour",
41
+ eventTitle: "Spring Tour",
42
+ tickets: { "tier-ga": 1 },
43
+ ticketMeta: { "tier-ga": { title: "General Admission", price: 25 } },
44
+ bookingFee: null,
45
+ ...overrides,
46
+ });
47
+
48
+ const mount = () => renderHook(() => useCart(), { wrapper });
49
+
50
+ beforeEach(() => {
51
+ localStorage.clear();
52
+ });
53
+
54
+ describe("restoreCart: tickets", () => {
55
+ it("REGRESSION: keeps a tier the visitor picked in this session when the same event is restored", () => {
56
+ // The defect this replaces overwrote the event's whole selection, so a
57
+ // visitor holding two tiers who clicked a recovery email for one of them
58
+ // was left with one. Nothing on the page said so and the total simply
59
+ // shrank.
60
+ const { result } = mount();
61
+
62
+ act(() => {
63
+ result.current.setTickets(
64
+ selection({
65
+ tickets: { "tier-ga": 2, "tier-vip": 1 },
66
+ ticketMeta: {
67
+ "tier-ga": { title: "General Admission", price: 25 },
68
+ "tier-vip": { title: "VIP", price: 80 },
69
+ },
70
+ }),
71
+ );
72
+ });
73
+
74
+ act(() => {
75
+ result.current.restoreCart({
76
+ tickets: [
77
+ selection({
78
+ tickets: { "tier-early": 3 },
79
+ ticketMeta: { "tier-early": { title: "Early bird", price: 15 } },
80
+ }),
81
+ ],
82
+ });
83
+ });
84
+
85
+ expect(result.current.ticketItems).toHaveLength(1);
86
+ expect(result.current.ticketItems[0].tickets).toEqual({
87
+ "tier-ga": 2,
88
+ "tier-vip": 1,
89
+ "tier-early": 3,
90
+ });
91
+ expect(Object.keys(result.current.ticketItems[0].ticketMeta).sort()).toEqual([
92
+ "tier-early",
93
+ "tier-ga",
94
+ "tier-vip",
95
+ ]);
96
+ // The badge counts every unit, so a lost tier is visible there too.
97
+ expect(result.current.itemCount).toBe(6);
98
+ });
99
+
100
+ it("takes the restored quantity on a tier both sides name, rather than adding to it", () => {
101
+ // Both sides describe the same intent. Summing would hand the buyer five
102
+ // tickets they never asked for, and the resumed figure is the one the
103
+ // server has just re-checked, so it wins outright.
104
+ const { result } = mount();
105
+
106
+ act(() => result.current.setTickets(selection({ tickets: { "tier-ga": 3 } })));
107
+ act(() => result.current.restoreCart({ tickets: [selection({ tickets: { "tier-ga": 2 } })] }));
108
+
109
+ expect(result.current.ticketItems[0].tickets).toEqual({ "tier-ga": 2 });
110
+ });
111
+
112
+ it("takes the restored display data for a tier both sides name", () => {
113
+ const { result } = mount();
114
+
115
+ act(() => result.current.setTickets(selection()));
116
+ act(() =>
117
+ result.current.restoreCart({
118
+ tickets: [selection({ ticketMeta: { "tier-ga": { title: "General Admission", price: 30 } } })],
119
+ }),
120
+ );
121
+
122
+ expect(result.current.ticketItems[0].ticketMeta["tier-ga"]).toEqual({
123
+ title: "General Admission",
124
+ price: 30,
125
+ });
126
+ });
127
+
128
+ it("does not erase a captured booking fee when the resumed payload carries none", () => {
129
+ // A payload saved before booking fees existed answers `null`, and the fee
130
+ // the modal captured is what the cart shows the buyer they will pay.
131
+ const fee = { feeAmount: 1.5, feeBps: 250, currency: "USD", minorUnitFactor: 100 };
132
+ const { result } = mount();
133
+
134
+ act(() => result.current.setTickets(selection({ bookingFee: fee })));
135
+ act(() => result.current.restoreCart({ tickets: [selection({ bookingFee: null })] }));
136
+
137
+ expect(result.current.ticketItems[0].bookingFee).toEqual(fee);
138
+ });
139
+
140
+ it("leaves an event the recovery link says nothing about alone, and appends a new one", () => {
141
+ const { result } = mount();
142
+
143
+ act(() => result.current.setTickets(selection({ eventId: "event-9", eventTitle: "Autumn" })));
144
+ act(() => result.current.restoreCart({ tickets: [selection({ eventId: "event-1" })] }));
145
+
146
+ expect(result.current.ticketItems.map((t) => t.eventId)).toEqual(["event-9", "event-1"]);
147
+ });
148
+ });
149
+
150
+ describe("restoreCart: products", () => {
151
+ it("replaces a matching line rather than appending a duplicate of it", () => {
152
+ const { result } = mount();
153
+
154
+ act(() => void result.current.addToCart(item({ quantity: 1, price: 25 })));
155
+ act(() => result.current.restoreCart({ items: [item({ quantity: 3, price: 30 })] }));
156
+
157
+ expect(result.current.cartItems).toHaveLength(1);
158
+ expect(result.current.cartItems[0]).toMatchObject({ quantity: 3, price: 30 });
159
+ });
160
+
161
+ it("keeps a line the recovery link does not name", () => {
162
+ const { result } = mount();
163
+
164
+ act(() => void result.current.addToCart(item({ productId: "prod-9", productVariantId: "var-9" })));
165
+ act(() => result.current.restoreCart({ items: [item()] }));
166
+
167
+ expect(result.current.cartItems.map((i) => i.productId)).toEqual(["prod-9", "prod-1"]);
168
+ });
169
+
170
+ it("treats a gift to a different recipient as its own line", () => {
171
+ // The line identity `addToCart` already uses. Collapsing these would send
172
+ // one of two presents.
173
+ const { result } = mount();
174
+
175
+ act(() => void result.current.addToCart(item({ isGift: true, recipientEmail: "ada@test.com" })));
176
+ act(() =>
177
+ result.current.restoreCart({ items: [item({ isGift: true, recipientEmail: "grace@test.com" })] }),
178
+ );
179
+
180
+ expect(result.current.cartItems).toHaveLength(2);
181
+ });
182
+
183
+ it("does not open the drawer, because the resume page IS the surface", () => {
184
+ const { result } = mount();
185
+ act(() => result.current.restoreCart({ items: [item()], tickets: [selection()] }));
186
+ expect(result.current.isCartOpen).toBe(false);
187
+ });
188
+ });
189
+
190
+ describe("removeTickets", () => {
191
+ it("takes every add-on attached to the event with it", () => {
192
+ // The server refuses a bundle whose add-on has no base, so an orphan left
193
+ // behind here is a checkout that fails with nothing on screen saying why.
194
+ const { result } = mount();
195
+
196
+ act(() => {
197
+ result.current.restoreCart({
198
+ items: [
199
+ item({ attachedTo: { type: "event", entityId: "event-1" } }),
200
+ item({ productId: "prod-2", productVariantId: "var-2" }),
201
+ ],
202
+ tickets: [selection()],
203
+ });
204
+ });
205
+ act(() => result.current.removeTickets("event-1"));
206
+
207
+ expect(result.current.ticketItems).toEqual([]);
208
+ expect(result.current.cartItems.map((i) => i.productId)).toEqual(["prod-2"]);
209
+ });
210
+ });
@@ -0,0 +1,147 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
3
+ import { act, renderHook, waitFor } from "@testing-library/react";
4
+ import axios, { type AxiosAdapter, type InternalAxiosRequestConfig } from "axios";
5
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
6
+ import type { ReactNode } from "react";
7
+ import { ForgeClientProvider } from "../../provider/ForgeProvider";
8
+ import { PublicAuthProvider, usePublicAuth, PUBLIC_ACCESS_TOKEN_KEY } from "../PublicAuthContext";
9
+
10
+ /**
11
+ * `refetchUser`, which used to sign the member out every time it ran.
12
+ *
13
+ * A fan session is minted by the public surface and its JWT carries an
14
+ * `associationId`. `requireAuthentication` refuses any token carrying one, on
15
+ * purpose: honouring it on the admin surface would hand the caller that
16
+ * account's whole dashboard. So `GET /accounts/me` could only ever answer 401
17
+ * for a fan, and `refetchUser`'s catch read that as "not signed in".
18
+ *
19
+ * It bit hardest immediately after joining a membership, because the account
20
+ * page refetches the member on arrival: finish signup, land on the account
21
+ * page, get bounced to login with a perfectly good token still in localStorage.
22
+ *
23
+ * The adapter is swapped rather than the module mocked, so the assertion is on
24
+ * the URL that actually went out.
25
+ */
26
+
27
+ const PROFILE_ID = "profile-1";
28
+
29
+ const member = {
30
+ id: "account-1",
31
+ email: "fan@example.com",
32
+ firstName: "Fan",
33
+ lastName: "Person",
34
+ kind: "fan",
35
+ status: "active",
36
+ createdAt: "2026-01-01T00:00:00.000Z",
37
+ updatedAt: "2026-01-01T00:00:00.000Z",
38
+ membership: { id: "m-1", membershipTierId: "tier-1", status: "active" },
39
+ };
40
+
41
+ let requested: string[] = [];
42
+ let realAdapter: AxiosAdapter | undefined;
43
+
44
+ /**
45
+ * Answers the member on the PUBLIC endpoint and refuses the admin one exactly
46
+ * as the backend does, so a spec cannot pass by calling the wrong one.
47
+ *
48
+ * `refetchStatus` applies from the SECOND `/public/accounts/me` onwards: the
49
+ * first is the provider's own `initialize`, and failing that would sign the
50
+ * member out before `refetchUser` was ever reached, which is a different test.
51
+ */
52
+ function installAdapter(opts: { refetchStatus?: number } = {}) {
53
+ requested = [];
54
+ let meCalls = 0;
55
+ const adapter: AxiosAdapter = async (config: InternalAxiosRequestConfig) => {
56
+ const url = config.url ?? "";
57
+ requested.push(url);
58
+ const ok = (data: unknown) =>
59
+ Promise.resolve({ data, status: 200, statusText: "OK", headers: {}, config } as never);
60
+ const fail = (status: number) =>
61
+ Promise.reject(
62
+ Object.assign(new Error(`Request failed with status code ${status}`), {
63
+ response: { status, data: { message: "nope" }, headers: {}, config },
64
+ config,
65
+ isAxiosError: true,
66
+ }),
67
+ );
68
+
69
+ if (url.includes("/public/accounts/me")) {
70
+ meCalls += 1;
71
+ return opts.refetchStatus && meCalls > 1 ? fail(opts.refetchStatus) : ok(member);
72
+ }
73
+ // The dashboard lane. A fan token is rejected here by design.
74
+ if (url.includes("/accounts/me")) return fail(401);
75
+ if (url.includes("/public/currencies")) {
76
+ return ok({ supportedCurrencies: ["USD"], userCurrency: "USD", exchangeRates: {} });
77
+ }
78
+ return ok({});
79
+ };
80
+ realAdapter = axios.defaults.adapter as AxiosAdapter | undefined;
81
+ axios.defaults.adapter = adapter;
82
+ }
83
+
84
+ const wrapper = ({ children }: { children: ReactNode }) => (
85
+ <QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
86
+ <ForgeClientProvider apiUrl="http://api.test" profileId={PROFILE_ID}>
87
+ <PublicAuthProvider>{children}</PublicAuthProvider>
88
+ </ForgeClientProvider>
89
+ </QueryClientProvider>
90
+ );
91
+
92
+ beforeEach(() => {
93
+ localStorage.clear();
94
+ localStorage.setItem(PUBLIC_ACCESS_TOKEN_KEY, "fan-access-token");
95
+ });
96
+
97
+ afterEach(() => {
98
+ if (realAdapter) axios.defaults.adapter = realAdapter;
99
+ localStorage.clear();
100
+ });
101
+
102
+ describe("refetchUser", () => {
103
+ it("REGRESSION: re-reads the member from the public lane and keeps them signed in", async () => {
104
+ installAdapter();
105
+ const { result } = renderHook(() => usePublicAuth(), { wrapper });
106
+
107
+ await waitFor(() => expect(result.current.isInitialized).toBe(true));
108
+ await act(async () => {
109
+ await result.current.refetchUser();
110
+ });
111
+
112
+ expect(requested.some((url) => url.includes("/public/accounts/me"))).toBe(true);
113
+ // The dashboard endpoint is never touched: it 401s a fan token by design,
114
+ // which is exactly how this became a logout.
115
+ expect(requested.some((url) => url === "/accounts/me")).toBe(false);
116
+ expect(result.current.isAuthenticated).toBe(true);
117
+ expect(result.current.user?.email).toBe("fan@example.com");
118
+ });
119
+
120
+ it("keeps the member signed in when the refetch fails for a reason that is not auth", async () => {
121
+ // A 500 or a dropped connection is not evidence that anyone is signed out.
122
+ // Showing slightly stale details beats throwing someone back to the login
123
+ // page because one request wobbled.
124
+ installAdapter({ refetchStatus: 500 });
125
+ const { result } = renderHook(() => usePublicAuth(), { wrapper });
126
+
127
+ await waitFor(() => expect(result.current.isInitialized).toBe(true));
128
+ await act(async () => {
129
+ await result.current.refetchUser();
130
+ });
131
+
132
+ expect(result.current.isAuthenticated).toBe(true);
133
+ });
134
+
135
+ it("does sign the member out when the server actually rejects the token", async () => {
136
+ installAdapter({ refetchStatus: 401 });
137
+ const { result } = renderHook(() => usePublicAuth(), { wrapper });
138
+
139
+ await waitFor(() => expect(result.current.isInitialized).toBe(true));
140
+ await act(async () => {
141
+ await result.current.refetchUser();
142
+ });
143
+
144
+ expect(result.current.isAuthenticated).toBe(false);
145
+ expect(result.current.user).toBeNull();
146
+ });
147
+ });
@@ -29,3 +29,154 @@ export function useValidateBroadcastPass() {
29
29
  },
30
30
  });
31
31
  }
32
+
33
+ /**
34
+ * ONE broadcast, by id.
35
+ *
36
+ * Not the same thing as picking the id out of `useLiveBroadcasts()`: the list is
37
+ * what is on now, and a broadcast that has ENDED still has to render its own
38
+ * page (the "broadcast ended, keep this tab open" screen). Reading the detail
39
+ * endpoint is what makes that page survive the end of the stream.
40
+ */
41
+ export function useLiveBroadcast(broadcastId?: string) {
42
+ const { client, profileId } = useForge();
43
+
44
+ return useQuery<ILiveBroadcast>({
45
+ queryKey: ["live-broadcast", profileId, broadcastId],
46
+ queryFn: async () => {
47
+ const res = await client.get(`/public/broadcasts/${broadcastId}`, {
48
+ params: { profileId },
49
+ });
50
+ return res.data;
51
+ },
52
+ enabled: !!profileId && !!client && !!broadcastId,
53
+ });
54
+ }
55
+
56
+ /**
57
+ * The same broadcast, re-read on an interval, purely to notice that it ended.
58
+ *
59
+ * A separate query from `useLiveBroadcast` on purpose. The page keeps rendering
60
+ * the broadcast it opened with, and only the END is allowed to come from the
61
+ * poll, so a mid-stream edit to the title or the thumbnail cannot swap the
62
+ * player out from under someone who is watching.
63
+ */
64
+ export function useLiveBroadcastPoll(broadcastId?: string, intervalMs = 5000) {
65
+ const { client, profileId } = useForge();
66
+
67
+ return useQuery<ILiveBroadcast>({
68
+ queryKey: ["live-broadcast-poll", profileId, broadcastId],
69
+ queryFn: async () => {
70
+ const res = await client.get(`/public/broadcasts/${broadcastId}`, {
71
+ params: { profileId },
72
+ });
73
+ return res.data;
74
+ },
75
+ enabled: !!profileId && !!client && !!broadcastId,
76
+ refetchInterval: intervalMs,
77
+ });
78
+ }
79
+
80
+ /** How many people are watching right now. Polled while the broadcast is on. */
81
+ export function useBroadcastAudience(broadcastId?: string, enabled = true, intervalMs = 3000) {
82
+ const { client, profileId } = useForge();
83
+
84
+ return useQuery<{ count: number }>({
85
+ queryKey: ["broadcast-audience", broadcastId],
86
+ queryFn: async () => {
87
+ const res = await client.get(`/public/broadcasts/${broadcastId}/audience`, {
88
+ params: { profileId },
89
+ });
90
+ return res.data;
91
+ },
92
+ enabled: !!profileId && !!client && !!broadcastId && enabled,
93
+ refetchInterval: intervalMs,
94
+ });
95
+ }
96
+
97
+ /** Which chat surface a broadcast comment arrived from. */
98
+ export const BroadcastChannelProvider = {
99
+ Youtube: "youtube",
100
+ Twitch: "twitch",
101
+ Website: "website",
102
+ CustomRTMP: "custom_rtmp",
103
+ } as const;
104
+
105
+ export type BroadcastChannelProviderValue =
106
+ (typeof BroadcastChannelProvider)[keyof typeof BroadcastChannelProvider];
107
+
108
+ /** One message in a broadcast's live chat. */
109
+ export type BroadcastComment = {
110
+ id: string;
111
+ name: string;
112
+ content: string;
113
+ publishedAt: string;
114
+ channelProvider: BroadcastChannelProviderValue;
115
+ };
116
+
117
+ /** The chat backlog a viewer sees when they join. Realtime arrives over the socket. */
118
+ export function useBroadcastComments(broadcastId?: string) {
119
+ const { client, profileId } = useForge();
120
+
121
+ return useQuery<BroadcastComment[]>({
122
+ queryKey: ["broadcast-comments", profileId, broadcastId],
123
+ queryFn: async () => {
124
+ const res = await client.get(`/public/broadcasts/${broadcastId}/comments`, {
125
+ params: { profileId },
126
+ });
127
+ return res.data;
128
+ },
129
+ enabled: !!profileId && !!client && !!broadcastId,
130
+ });
131
+ }
132
+
133
+ /** The offer/answer exchange that subscribes a viewer to a WebRTC broadcast. */
134
+ export type BroadcastSubscribeAnswer = {
135
+ requiresImmediateRenegotiation: boolean;
136
+ sessionId: string;
137
+ tracks: { trackName: string; mid: string; trackSid: string }[];
138
+ sessionDescription: { sdp: string; type: string };
139
+ };
140
+
141
+ /**
142
+ * The imperative half of watching a broadcast: the calls that are made in
143
+ * response to something happening (a pass validated, a heartbeat, a viewer
144
+ * closing the tab), not on a render.
145
+ *
146
+ * They live here rather than inline in a component so both rendering stacks make
147
+ * the SAME call to the same address. A session that is pinged on one stack and
148
+ * not the other reports a different audience count for the same room.
149
+ */
150
+ export function useBroadcastSessionApi() {
151
+ const { client } = useForge();
152
+
153
+ return {
154
+ /** Re-open a session held in storage from an earlier visit. */
155
+ validateSession: async (broadcastId: string, sessionId: string): Promise<IBroadcastPass> => {
156
+ const res = await client.post(`/public/broadcasts/${broadcastId}/validate-session`, { sessionId });
157
+ return res.data;
158
+ },
159
+ /** Heartbeat. Silence is what marks a viewer as gone. */
160
+ sessionPing: async (broadcastId: string, sessionId: string): Promise<void> => {
161
+ await client.post(`/public/broadcasts/${broadcastId}/session-ping`, { sessionId });
162
+ },
163
+ /** Leave deliberately, so the count drops now rather than on a timeout. */
164
+ leave: async (broadcastId: string, sessionId?: string): Promise<void> => {
165
+ await client.post(`/public/broadcasts/${broadcastId}/leave`, { sessionId });
166
+ },
167
+ /** Subscribe to the WebRTC tracks of a realtime broadcast. */
168
+ subscribe: async (payload: {
169
+ sdp?: string;
170
+ type?: string;
171
+ trackIds?: string[];
172
+ sessionId?: string;
173
+ }): Promise<BroadcastSubscribeAnswer> => {
174
+ const res = await client.post(`/public/broadcasts/subscribe`, payload);
175
+ return res.data;
176
+ },
177
+ /** Pin the viewer to one simulcast layer, or back to auto. */
178
+ switchQuality: async (payload: { track: Record<string, unknown>; sessionId?: string }): Promise<void> => {
179
+ await client.post(`/public/broadcasts/switch-quality`, payload);
180
+ },
181
+ };
182
+ }
@@ -2,6 +2,7 @@ import { useMutation } from "@tanstack/react-query";
2
2
  import { useForge } from "../../provider/ForgeProvider";
3
3
  import { bundleCouponRequestBody } from "../../ui/headless/checkout/bundleCoupon";
4
4
  import type { CartItem, TicketCartItem } from "../../contexts/CartContext";
5
+ import type { IBookingFee, ProductDeliveryType } from "../../types/models";
5
6
 
6
7
  /** One line of a bundle, in the shape `POST /public/checkouts` expects. */
7
8
  export type CheckoutLineInput =
@@ -201,3 +202,85 @@ export function useFinalizeCheckout() {
201
202
  },
202
203
  });
203
204
  }
205
+
206
+ // ── The recovery link ─────────────────────────────────────────────────────────
207
+
208
+ /** Why a resumed line cannot go back in the basket. `null` when it can. */
209
+ export type ResumeLineUnavailableReason = "removed" | "unavailable" | "sold_out";
210
+
211
+ export type ResumeLineOption = { axis: string; value: string; swatchHex: string | null };
212
+
213
+ /**
214
+ * One line of an abandoned basket, as `POST /public/checkouts/resume` answers.
215
+ *
216
+ * Every line comes back, including the ones that cannot be bought: the server
217
+ * re-reads the catalogue and reports WHY rather than shrinking the basket
218
+ * behind the buyer's back. `price` is today's figure in MAJOR units;
219
+ * `quotedUnitPriceCents` is what they were quoted when they left, in minor
220
+ * units, and is display-only.
221
+ */
222
+ export type ResumeLine = {
223
+ type: "product" | "event_ticket" | "course" | "coaching";
224
+ quantity: number;
225
+ title: string;
226
+ coverImage: string | null;
227
+ price: number;
228
+ unitPriceCents: number;
229
+ quotedUnitPriceCents: number | null;
230
+ priceChanged: boolean;
231
+ available: boolean;
232
+ unavailableReason: ResumeLineUnavailableReason | null;
233
+ productId?: string;
234
+ productVariantId?: string;
235
+ deliveryType?: ProductDeliveryType;
236
+ payWhatYouWant?: boolean;
237
+ options?: ResumeLineOption[];
238
+ isGift?: boolean;
239
+ recipientName?: string | null;
240
+ recipientEmail?: string | null;
241
+ recipientMessage?: string | null;
242
+ attachedTo?: { type: "event"; entityId: string } | null;
243
+ eventId?: string;
244
+ ticketId?: string;
245
+ eventSlug?: string;
246
+ eventTitle?: string;
247
+ pwywAmount?: number | null;
248
+ bookingFee?: IBookingFee | null;
249
+ courseId?: string;
250
+ courseSlug?: string;
251
+ coachingProductId?: string;
252
+ coachingProductSlug?: string;
253
+ };
254
+
255
+ export type ResumedCheckout = {
256
+ checkoutId: string;
257
+ profileId: string;
258
+ source: "product" | "checkout" | "event" | "course" | "coaching" | "membership";
259
+ currency: string;
260
+ buyer: { firstName: string | null; lastName: string | null; email: string | null };
261
+ /** A code to re-apply at checkout, never an amount. */
262
+ couponCode: string | null;
263
+ lines: ResumeLine[];
264
+ /** Units across AVAILABLE lines only. */
265
+ itemCount: number;
266
+ /** Minor units, AVAILABLE lines only, at today's prices and before discount. */
267
+ subtotalCents: number;
268
+ };
269
+
270
+ /**
271
+ * Exchange a recovery-email token for the basket it names.
272
+ *
273
+ * A POST rather than a GET so the token stays out of access logs and `Referer`
274
+ * headers on the API hop. Anonymous by design: the recipient is usually a guest
275
+ * who never had an account, and the token IS the credential.
276
+ */
277
+ export function useResumeCheckout() {
278
+ const { client, profileId } = useForge();
279
+
280
+ return useMutation<ResumedCheckout, unknown, { token: string }>({
281
+ mutationFn: async ({ token }) => {
282
+ const res = await client.post("/public/checkouts/resume", { profileId, token });
283
+ return res.data;
284
+ },
285
+ });
286
+ }
@@ -148,7 +148,15 @@ export type MyBooking = {
148
148
  */
149
149
  export type BookingLocation =
150
150
  | { type: "in_person"; address: string }
151
- | { type: "online"; url: string };
151
+ | { type: "online"; url: string }
152
+ /**
153
+ * A call on the platform's own media network. No payload, and there never
154
+ * will be one: entry is a ticket minted per attempt and valid for minutes, so
155
+ * a URL here would be a forwardable key to somebody else's session. Draw the
156
+ * Join control from `bookingCallWindow` in `@tribe-nest/forge/media` and let
157
+ * `<BookingCallProvider>` fetch the ticket.
158
+ */
159
+ | { type: "video" };
152
160
 
153
161
  export type CancelBookingResult = {
154
162
  bookingId: string;