@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.
- package/package.json +6 -3
- package/src/_tests/publishedResolvability.spec.ts +184 -0
- package/src/_tests/specsRunWorkspaceSource.spec.ts +116 -0
- package/src/_tests/workspaceAliases.ts +40 -0
- package/src/client/_tests/localeHeader.spec.tsx +98 -0
- package/src/client/activeLocale.ts +34 -0
- package/src/client/createForgeClient.ts +13 -0
- package/src/contexts/CartContext.tsx +76 -0
- package/src/contexts/PublicAuthContext.tsx +34 -5
- package/src/contexts/_tests/CartContext.spec.tsx +210 -0
- package/src/contexts/_tests/PublicAuthRefetch.spec.tsx +147 -0
- package/src/data/queries/useBroadcasts.ts +151 -0
- package/src/data/queries/useCheckouts.ts +83 -0
- package/src/data/queries/useMyBookings.ts +9 -1
- package/src/data/queries/useWebsite.ts +7 -0
- package/src/i18n/CONVENTION.md +184 -0
- package/src/i18n/_tests/translationKeys.spec.ts +153 -0
- package/src/i18n/de.json +1019 -0
- package/src/i18n/en.json +1019 -0
- package/src/i18n/index.ts +125 -0
- package/src/index.ts +21 -0
- package/src/provider/ForgeAppProvider.tsx +2 -1
- package/src/provider/SiteConfigProvider.tsx +11 -0
- package/src/ui/format/_tests/membershipPwyw.spec.ts +185 -0
- package/src/ui/format/_tests/pwyw.spec.ts +65 -8
- package/src/ui/format/membershipPwyw.ts +164 -0
- package/src/ui/format/pwyw.ts +37 -0
- package/src/ui/headless/broadcast/_tests/broadcastState.spec.ts +235 -0
- package/src/ui/headless/broadcast/broadcastState.ts +158 -0
- package/src/ui/headless/broadcast/useBroadcastWatch.ts +174 -21
- package/src/ui/headless/checkout/_tests/cartResume.spec.ts +291 -0
- package/src/ui/headless/checkout/_tests/useCartResume.spec.tsx +294 -0
- package/src/ui/headless/checkout/resumedCoupon.ts +61 -0
- package/src/ui/headless/checkout/useCartResume.ts +340 -0
- package/src/ui/headless/checkout/useCheckout.ts +9 -2
- package/src/ui/headless/event/useEventCheckout.ts +8 -13
- package/src/ui/headless/index.ts +32 -0
- package/src/ui/headless/membership/useMembershipCheckout.ts +160 -32
- package/src/ui/index.ts +38 -0
- package/src/ui/media/CallHelpHint.tsx +87 -0
- package/src/ui/media/CallStage.tsx +542 -0
- package/src/ui/media/_tests/CallStage.spec.tsx +685 -0
- package/src/ui/media/_tests/bookingSession.spec.tsx +179 -0
- package/src/ui/media/_tests/callState.spec.ts +452 -0
- package/src/ui/media/_tests/fakeNode.ts +178 -0
- package/src/ui/media/bookingSession.tsx +194 -0
- package/src/ui/media/callState.ts +341 -0
- package/src/ui/media/index.ts +135 -0
- package/src/ui/shell/TribeNestApp.tsx +36 -27
- package/src/ui/styled/AccountDashboard.tsx +289 -96
- package/src/ui/styled/AiAgentWidget.tsx +10 -6
- package/src/ui/styled/AudioPlayer.tsx +14 -6
- package/src/ui/styled/BlogCategory.tsx +4 -2
- package/src/ui/styled/BlogComments.tsx +27 -20
- package/src/ui/styled/BlogList.tsx +5 -3
- package/src/ui/styled/BlogPost.tsx +14 -11
- package/src/ui/styled/BroadcastWatch.tsx +107 -0
- package/src/ui/styled/BundleConfirmation.tsx +32 -23
- package/src/ui/styled/CancellationTerms.tsx +3 -1
- package/src/ui/styled/Cart.tsx +32 -14
- package/src/ui/styled/ChatRoom.tsx +65 -20
- package/src/ui/styled/Checkout.tsx +151 -89
- package/src/ui/styled/CheckoutConfirmation.tsx +66 -35
- package/src/ui/styled/CoachingBooking.tsx +71 -44
- package/src/ui/styled/CoachingConfirmation.tsx +56 -19
- package/src/ui/styled/CoachingDetail.tsx +15 -11
- package/src/ui/styled/CohortPage.tsx +10 -8
- package/src/ui/styled/ConfirmSubscription.tsx +14 -8
- package/src/ui/styled/ContactForm.tsx +31 -12
- package/src/ui/styled/CookieConsent.tsx +19 -15
- package/src/ui/styled/CourseAccess.tsx +54 -47
- package/src/ui/styled/CourseCheckout.tsx +56 -38
- package/src/ui/styled/CourseConfirmation.tsx +39 -17
- package/src/ui/styled/CourseDetail.tsx +21 -15
- package/src/ui/styled/CoursesGrid.tsx +4 -2
- package/src/ui/styled/CurrencySwitcher.tsx +4 -1
- package/src/ui/styled/DiscountCode.tsx +22 -18
- package/src/ui/styled/DonationButton.tsx +52 -17
- package/src/ui/styled/DonationPage.tsx +4 -4
- package/src/ui/styled/EmailListForm.tsx +22 -9
- package/src/ui/styled/EventConfirmation.tsx +37 -14
- package/src/ui/styled/EventCountdown.tsx +10 -6
- package/src/ui/styled/EventDetail.tsx +8 -6
- package/src/ui/styled/EventSeriesDetail.tsx +18 -13
- package/src/ui/styled/EventTickets.tsx +232 -108
- package/src/ui/styled/EventWaitlist.tsx +42 -33
- package/src/ui/styled/EventsList.tsx +15 -13
- package/src/ui/styled/ForgotPasswordForm.tsx +17 -12
- package/src/ui/styled/FormRenderer.tsx +12 -7
- package/src/ui/styled/InstallBanner.tsx +21 -18
- package/src/ui/styled/InvoiceConfirmation.tsx +13 -5
- package/src/ui/styled/InvoicePayment.tsx +37 -16
- package/src/ui/styled/LeadMagnet.tsx +9 -7
- package/src/ui/styled/LiveBroadcastList.tsx +171 -0
- package/src/ui/styled/Loading.tsx +7 -1
- package/src/ui/styled/LoginForm.tsx +32 -14
- package/src/ui/styled/MembershipCheckout.tsx +341 -49
- package/src/ui/styled/MembershipTiers.tsx +34 -18
- package/src/ui/styled/OfferButton.tsx +33 -13
- package/src/ui/styled/PaymentLinkConfirmation.tsx +11 -5
- package/src/ui/styled/PaymentLinkPayment.tsx +30 -10
- package/src/ui/styled/PodcastEpisode.tsx +20 -9
- package/src/ui/styled/PodcastList.tsx +9 -3
- package/src/ui/styled/PodcastShow.tsx +29 -7
- package/src/ui/styled/PostsFeed.tsx +10 -8
- package/src/ui/styled/PresaleCode.tsx +8 -11
- package/src/ui/styled/ProductBrowseNav.tsx +21 -11
- package/src/ui/styled/ProductDetail.tsx +58 -28
- package/src/ui/styled/ReactionBar.tsx +4 -2
- package/src/ui/styled/ReplayList.tsx +24 -13
- package/src/ui/styled/ResetPasswordForm.tsx +32 -15
- package/src/ui/styled/ResumeCart.tsx +289 -0
- package/src/ui/styled/ReviewForm.tsx +52 -58
- package/src/ui/styled/SignupForm.tsx +34 -16
- package/src/ui/styled/TicketTransfer.tsx +55 -54
- package/src/ui/styled/UserMenu.tsx +16 -14
- package/src/ui/styled/_tests/AccountDashboardCommunity.spec.tsx +134 -0
- package/src/ui/styled/_tests/BroadcastPassValidation.spec.tsx +125 -0
- package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +364 -0
- package/src/ui/styled/_tests/ResumeCart.spec.tsx +183 -0
- package/src/ui/styled/broadcast/BroadcastPassValidation.tsx +187 -0
- package/src/ui/styled/broadcast/BroadcastPlayer.tsx +536 -0
- package/src/ui/styled/broadcast/BroadcastTicketPurchase.tsx +74 -0
- package/src/ui/styled/broadcast/EndedBroadcast.tsx +103 -0
- package/src/ui/styled/community/CommunityComposer.tsx +182 -3
- package/src/ui/styled/community/CommunityFeed.tsx +36 -51
- package/src/ui/styled/community/CommunityPostDetail.tsx +16 -2
- package/src/ui/styled/community/_tests/CommunityComposer.spec.tsx +281 -0
- 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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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;
|