@tribe-nest/forge 3.26.0 → 3.29.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 +1 -1
- 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/_tests/CartContext.spec.tsx +210 -0
- package/src/data/queries/useCheckouts.ts +83 -0
- 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 +960 -0
- package/src/i18n/en.json +960 -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/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/index.ts +18 -0
- package/src/ui/index.ts +2 -0
- package/src/ui/shell/TribeNestApp.tsx +36 -27
- package/src/ui/styled/AccountDashboard.tsx +197 -93
- 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/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 +12 -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/Loading.tsx +7 -1
- package/src/ui/styled/LoginForm.tsx +22 -14
- package/src/ui/styled/MembershipCheckout.tsx +32 -13
- package/src/ui/styled/MembershipTiers.tsx +27 -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 +27 -15
- package/src/ui/styled/ResumeCart.tsx +289 -0
- package/src/ui/styled/ReviewForm.tsx +52 -58
- package/src/ui/styled/SignupForm.tsx +29 -16
- package/src/ui/styled/TicketTransfer.tsx +55 -54
- package/src/ui/styled/UserMenu.tsx +16 -14
- package/src/ui/styled/_tests/ResumeCart.spec.tsx +183 -0
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
3
|
+
import { act, renderHook, waitFor } from "@testing-library/react";
|
|
4
|
+
import { useSyncExternalStore } from "react";
|
|
5
|
+
import type { ResumeLine, ResumedCheckout } from "../../../../data/queries/useCheckouts";
|
|
6
|
+
import { ProductDeliveryType } from "../../../../types/models";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* `useCartResume` end to end: what a recovery link actually puts in the basket.
|
|
10
|
+
*
|
|
11
|
+
* The pure translation of a line is asserted in `cartResume.spec.ts`. What is
|
|
12
|
+
* asserted HERE is the part no pure function can show: which lines reach
|
|
13
|
+
* `restoreCart`, and WHEN. Both storefronts render this hook, so a defect here
|
|
14
|
+
* is a defect on every artist's shop at once.
|
|
15
|
+
*
|
|
16
|
+
* Two seams, both mocked at the module boundary so nothing touches the network:
|
|
17
|
+
*
|
|
18
|
+
* - `useResumeCheckout`, whose `mutateAsync` is the exchange.
|
|
19
|
+
* - `useCart`, so the spec can hold `isReady` at `false` and release it. A real
|
|
20
|
+
* `CartProvider` hydrates inside the first commit, which is exactly the case
|
|
21
|
+
* the hook's `isReady` guard is NOT written for.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const cartStub = vi.hoisted(() => {
|
|
25
|
+
let ready = false;
|
|
26
|
+
const listeners = new Set<() => void>();
|
|
27
|
+
return {
|
|
28
|
+
restoreCart: vi.fn(),
|
|
29
|
+
subscribe(listener: () => void) {
|
|
30
|
+
listeners.add(listener);
|
|
31
|
+
return () => void listeners.delete(listener);
|
|
32
|
+
},
|
|
33
|
+
read: () => ready,
|
|
34
|
+
setReady(next: boolean) {
|
|
35
|
+
ready = next;
|
|
36
|
+
listeners.forEach((listener) => listener());
|
|
37
|
+
},
|
|
38
|
+
reset() {
|
|
39
|
+
ready = false;
|
|
40
|
+
listeners.clear();
|
|
41
|
+
cartStub.restoreCart.mockClear();
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const exchange = vi.hoisted(() => ({ mutateAsync: vi.fn() }));
|
|
47
|
+
|
|
48
|
+
vi.mock("../../../../contexts/CartContext", async (importOriginal) => ({
|
|
49
|
+
...(await importOriginal<typeof import("../../../../contexts/CartContext")>()),
|
|
50
|
+
useCart: () => ({
|
|
51
|
+
restoreCart: cartStub.restoreCart,
|
|
52
|
+
isReady: useSyncExternalStore(cartStub.subscribe, cartStub.read, cartStub.read),
|
|
53
|
+
}),
|
|
54
|
+
}));
|
|
55
|
+
|
|
56
|
+
vi.mock("../../../../data/queries/useCheckouts", async (importOriginal) => ({
|
|
57
|
+
...(await importOriginal<typeof import("../../../../data/queries/useCheckouts")>()),
|
|
58
|
+
useResumeCheckout: () => exchange,
|
|
59
|
+
}));
|
|
60
|
+
|
|
61
|
+
import { useCartResume } from "../useCartResume";
|
|
62
|
+
|
|
63
|
+
const productLine = (overrides: Partial<ResumeLine> = {}): ResumeLine => ({
|
|
64
|
+
type: "product",
|
|
65
|
+
quantity: 1,
|
|
66
|
+
title: "Tour Tee",
|
|
67
|
+
coverImage: null,
|
|
68
|
+
price: 25,
|
|
69
|
+
unitPriceCents: 2500,
|
|
70
|
+
quotedUnitPriceCents: 2500,
|
|
71
|
+
priceChanged: false,
|
|
72
|
+
available: true,
|
|
73
|
+
unavailableReason: null,
|
|
74
|
+
productId: "prod-1",
|
|
75
|
+
productVariantId: "var-1",
|
|
76
|
+
deliveryType: ProductDeliveryType.Physical,
|
|
77
|
+
isGift: false,
|
|
78
|
+
...overrides,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const ticketLine = (overrides: Partial<ResumeLine> = {}): ResumeLine => ({
|
|
82
|
+
type: "event_ticket",
|
|
83
|
+
quantity: 2,
|
|
84
|
+
title: "General Admission",
|
|
85
|
+
coverImage: null,
|
|
86
|
+
price: 25,
|
|
87
|
+
unitPriceCents: 2500,
|
|
88
|
+
quotedUnitPriceCents: 2500,
|
|
89
|
+
priceChanged: false,
|
|
90
|
+
available: true,
|
|
91
|
+
unavailableReason: null,
|
|
92
|
+
eventId: "event-1",
|
|
93
|
+
ticketId: "tier-ga",
|
|
94
|
+
eventSlug: "spring-tour",
|
|
95
|
+
eventTitle: "Spring Tour",
|
|
96
|
+
pwywAmount: null,
|
|
97
|
+
bookingFee: null,
|
|
98
|
+
...overrides,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const payload = (lines: ResumeLine[]): ResumedCheckout => ({
|
|
102
|
+
checkoutId: "checkout-1",
|
|
103
|
+
profileId: "profile-1",
|
|
104
|
+
source: "checkout",
|
|
105
|
+
currency: "USD",
|
|
106
|
+
buyer: { firstName: null, lastName: null, email: null },
|
|
107
|
+
couponCode: null,
|
|
108
|
+
lines,
|
|
109
|
+
itemCount: lines.length,
|
|
110
|
+
subtotalCents: 0,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const addon = productLine({
|
|
114
|
+
productId: "prod-addon",
|
|
115
|
+
productVariantId: "var-addon",
|
|
116
|
+
title: "Tour poster",
|
|
117
|
+
attachedTo: { type: "event", entityId: "event-1" },
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
/** Mount the hook, with the exchange already answering `lines`. */
|
|
121
|
+
const resume = async (lines: ResumeLine[], { ready = true } = {}) => {
|
|
122
|
+
exchange.mutateAsync.mockResolvedValue(payload(lines));
|
|
123
|
+
cartStub.setReady(ready);
|
|
124
|
+
const hook = renderHook(() => useCartResume({ token: "tok-1" }));
|
|
125
|
+
await waitFor(() => expect(hook.result.current.status).not.toBe("loading"));
|
|
126
|
+
return hook;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
beforeEach(() => {
|
|
130
|
+
cartStub.reset();
|
|
131
|
+
exchange.mutateAsync.mockReset();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
afterEach(() => {
|
|
135
|
+
vi.clearAllMocks();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe("useCartResume: an add-on never lands without its ticket", () => {
|
|
139
|
+
it("REGRESSION: does not restore an add-on whose event ticket is sold out", async () => {
|
|
140
|
+
// The basket this used to build is one the server refuses outright: an
|
|
141
|
+
// add-on with no base. The buyer reached the payment screen, was turned
|
|
142
|
+
// away, and nothing on the page ever named the poster as the reason.
|
|
143
|
+
const { result } = await resume([
|
|
144
|
+
ticketLine({ available: false, unavailableReason: "sold_out" }),
|
|
145
|
+
addon,
|
|
146
|
+
]);
|
|
147
|
+
|
|
148
|
+
// Nothing survived, so nothing is written to the basket at all. Before the
|
|
149
|
+
// fix this was called with the poster as its only item.
|
|
150
|
+
expect(cartStub.restoreCart).not.toHaveBeenCalled();
|
|
151
|
+
expect(result.current.restoredLines).toEqual([]);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("tells the buyer the add-on was left out rather than dropping it silently", async () => {
|
|
155
|
+
const { result } = await resume([
|
|
156
|
+
ticketLine({ available: false, unavailableReason: "sold_out" }),
|
|
157
|
+
addon,
|
|
158
|
+
]);
|
|
159
|
+
|
|
160
|
+
expect(result.current.droppedLines.map((line) => line.title)).toEqual(["Tour poster"]);
|
|
161
|
+
// Not smuggled into the server's own list: nothing about it is unavailable.
|
|
162
|
+
expect(result.current.unavailableLines.map((line) => line.title)).toEqual([
|
|
163
|
+
"General Admission",
|
|
164
|
+
]);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("leaves an orphaned add-on out of the count and the subtotal", async () => {
|
|
168
|
+
// Otherwise the page promises a basket that is one line and $25 heavier
|
|
169
|
+
// than the one checkout will show.
|
|
170
|
+
const { result } = await resume([
|
|
171
|
+
ticketLine({ available: false, unavailableReason: "sold_out" }),
|
|
172
|
+
addon,
|
|
173
|
+
productLine({ productId: "prod-9", productVariantId: "var-9", quantity: 2 }),
|
|
174
|
+
]);
|
|
175
|
+
|
|
176
|
+
expect(result.current.itemCount).toBe(2);
|
|
177
|
+
expect(result.current.subtotalCents).toBe(5000);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("restores the add-on when its ticket is restored alongside it", async () => {
|
|
181
|
+
const { result } = await resume([ticketLine(), addon]);
|
|
182
|
+
|
|
183
|
+
const [call] = cartStub.restoreCart.mock.calls;
|
|
184
|
+
expect(call[0].items).toHaveLength(1);
|
|
185
|
+
expect(call[0].items[0]).toMatchObject({
|
|
186
|
+
productId: "prod-addon",
|
|
187
|
+
attachedTo: { type: "event", entityId: "event-1" },
|
|
188
|
+
});
|
|
189
|
+
expect(call[0].tickets).toHaveLength(1);
|
|
190
|
+
expect(call[0].tickets[0].tickets).toEqual({ "tier-ga": 2 });
|
|
191
|
+
expect(result.current.droppedLines).toEqual([]);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("drops an add-on attached to an event whose ticket is not in the payload at all", async () => {
|
|
195
|
+
const { result } = await resume([addon]);
|
|
196
|
+
|
|
197
|
+
expect(result.current.droppedLines.map((line) => line.title)).toEqual(["Tour poster"]);
|
|
198
|
+
expect(cartStub.restoreCart).not.toHaveBeenCalled();
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("leaves an ordinary product alone, attached to nothing", async () => {
|
|
202
|
+
// Attachment describes how a line was ADDED, never what the product is. The
|
|
203
|
+
// same poster bought from the shop sells on its own.
|
|
204
|
+
const { result } = await resume([productLine({ title: "Tour poster" })]);
|
|
205
|
+
|
|
206
|
+
expect(result.current.droppedLines).toEqual([]);
|
|
207
|
+
expect(result.current.restoredLines.map((line) => line.title)).toEqual(["Tour poster"]);
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
describe("useCartResume: what goes back, and when", () => {
|
|
212
|
+
it("REGRESSION: restores once the cart finishes hydrating, not never", async () => {
|
|
213
|
+
// The hook waits for `isReady` because `CartProvider` hydrates in a mount
|
|
214
|
+
// effect that REPLACES the whole list. While `isReady` was missing from the
|
|
215
|
+
// callback's dependencies, React handed back the same stale closure for
|
|
216
|
+
// ever and the wait became a permanent refusal: on a slow localStorage read
|
|
217
|
+
// the buyer landed on "your basket is back" with an empty basket.
|
|
218
|
+
const { result } = await resume([productLine()], { ready: false });
|
|
219
|
+
|
|
220
|
+
expect(result.current.status).toBe("restored");
|
|
221
|
+
expect(cartStub.restoreCart).not.toHaveBeenCalled();
|
|
222
|
+
|
|
223
|
+
act(() => cartStub.setReady(true));
|
|
224
|
+
|
|
225
|
+
await waitFor(() => expect(cartStub.restoreCart).toHaveBeenCalledTimes(1));
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("restores once, however many times the page re-renders", async () => {
|
|
229
|
+
// The endpoint stamps the arrival that cart recovery attributes on, and a
|
|
230
|
+
// second restore would re-inflate a quantity the buyer had just lowered.
|
|
231
|
+
const { rerender } = await resume([productLine()]);
|
|
232
|
+
|
|
233
|
+
rerender();
|
|
234
|
+
act(() => cartStub.setReady(true));
|
|
235
|
+
rerender();
|
|
236
|
+
|
|
237
|
+
expect(cartStub.restoreCart).toHaveBeenCalledTimes(1);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("asks the exchange once per token, because the request is also an arrival stamp", async () => {
|
|
241
|
+
const { rerender } = await resume([productLine()]);
|
|
242
|
+
rerender();
|
|
243
|
+
expect(exchange.mutateAsync).toHaveBeenCalledTimes(1);
|
|
244
|
+
expect(exchange.mutateAsync).toHaveBeenCalledWith({ token: "tok-1" });
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("sends nothing and reports the link as broken when the token is blank", async () => {
|
|
248
|
+
cartStub.setReady(true);
|
|
249
|
+
const { result } = renderHook(() => useCartResume({ token: " " }));
|
|
250
|
+
|
|
251
|
+
await waitFor(() => expect(result.current.status).toBe("error"));
|
|
252
|
+
expect(result.current.error).toBe("missing_token");
|
|
253
|
+
expect(exchange.mutateAsync).not.toHaveBeenCalled();
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("surfaces the server's refusal without touching the basket", async () => {
|
|
257
|
+
exchange.mutateAsync.mockRejectedValue({ response: { data: { message: "expired" } } });
|
|
258
|
+
cartStub.setReady(true);
|
|
259
|
+
const { result } = renderHook(() => useCartResume({ token: "tok-1" }));
|
|
260
|
+
|
|
261
|
+
await waitFor(() => expect(result.current.status).toBe("error"));
|
|
262
|
+
expect(result.current.error).toBe("expired");
|
|
263
|
+
expect(cartStub.restoreCart).not.toHaveBeenCalled();
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("holds the basket back when the caller draws its own button", async () => {
|
|
267
|
+
exchange.mutateAsync.mockResolvedValue(payload([productLine()]));
|
|
268
|
+
cartStub.setReady(true);
|
|
269
|
+
const { result } = renderHook(() => useCartResume({ token: "tok-1", autoRestore: false }));
|
|
270
|
+
|
|
271
|
+
await waitFor(() => expect(result.current.status).toBe("restored"));
|
|
272
|
+
expect(cartStub.restoreCart).not.toHaveBeenCalled();
|
|
273
|
+
|
|
274
|
+
act(() => result.current.restore());
|
|
275
|
+
expect(cartStub.restoreCart).toHaveBeenCalledTimes(1);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("keeps a course out of the basket and hands it back as a link", async () => {
|
|
279
|
+
const { result } = await resume([
|
|
280
|
+
{ ...productLine(), type: "course", title: "Songwriting", courseSlug: "songwriting" },
|
|
281
|
+
]);
|
|
282
|
+
|
|
283
|
+
expect(result.current.continueLines.map((line) => line.title)).toEqual(["Songwriting"]);
|
|
284
|
+
expect(cartStub.restoreCart).not.toHaveBeenCalled();
|
|
285
|
+
expect(result.current.status).toBe("restored");
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("reads as empty when nothing at all survived", async () => {
|
|
289
|
+
const { result } = await resume([productLine({ available: false, unavailableReason: "removed" })]);
|
|
290
|
+
|
|
291
|
+
expect(result.current.status).toBe("empty");
|
|
292
|
+
expect(cartStub.restoreCart).not.toHaveBeenCalled();
|
|
293
|
+
});
|
|
294
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The discount code a recovery link carried, handed from the resume page to
|
|
3
|
+
* the checkout page.
|
|
4
|
+
*
|
|
5
|
+
* ## Why a stash and not a prop
|
|
6
|
+
*
|
|
7
|
+
* The two pages are separate route entries on both rendering stacks and the
|
|
8
|
+
* buyer walks between them through the ordinary cart, sometimes via the store
|
|
9
|
+
* in between. Threading the code through would mean a query parameter on every
|
|
10
|
+
* link the resume page can produce, which puts a discount code in browser
|
|
11
|
+
* history and in any `Referer` the storefront leaks. `sessionStorage` is
|
|
12
|
+
* per-tab and dies with the tab.
|
|
13
|
+
*
|
|
14
|
+
* ## What it does NOT do
|
|
15
|
+
*
|
|
16
|
+
* It does not apply anything. The code is only PREFILLED into the discount
|
|
17
|
+
* field, so the buyer still presses Apply and the server re-evaluates every
|
|
18
|
+
* condition (window, caps, membership gate, minimum spend) against today. A
|
|
19
|
+
* code that has since been switched off is already withheld by the resume
|
|
20
|
+
* endpoint, so the common failure never reaches here at all.
|
|
21
|
+
*
|
|
22
|
+
* Consumed on first read, so a reload of the checkout page a week later does
|
|
23
|
+
* not prefill a code the buyer has long forgotten. It is remembered for the
|
|
24
|
+
* rest of the PAGE LOAD in a module variable, and that part is load-bearing
|
|
25
|
+
* rather than an optimisation: `useCheckout` reads it from a lazy `useState`
|
|
26
|
+
* initialiser, which React StrictMode deliberately invokes twice in
|
|
27
|
+
* development. Without the memo the second invocation would find the stash
|
|
28
|
+
* already emptied and the field would come up blank on exactly the builds a
|
|
29
|
+
* developer tests against.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
const KEY = "tribenest-resumed-coupon";
|
|
33
|
+
|
|
34
|
+
/** Undefined until the first read of this page load. */
|
|
35
|
+
let taken: string | undefined;
|
|
36
|
+
|
|
37
|
+
export function rememberResumedCoupon(code: string | null | undefined): void {
|
|
38
|
+
taken = undefined;
|
|
39
|
+
if (typeof window === "undefined") return;
|
|
40
|
+
try {
|
|
41
|
+
if (code && code.trim()) window.sessionStorage.setItem(KEY, code.trim());
|
|
42
|
+
else window.sessionStorage.removeItem(KEY);
|
|
43
|
+
} catch {
|
|
44
|
+
// Private-mode Safari and locked-down embeds throw here. A missing prefill
|
|
45
|
+
// is a smaller problem than a checkout page that fails to mount.
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Read and clear. Returns "" when there is nothing waiting. */
|
|
50
|
+
export function takeResumedCoupon(): string {
|
|
51
|
+
if (taken !== undefined) return taken;
|
|
52
|
+
if (typeof window === "undefined") return "";
|
|
53
|
+
try {
|
|
54
|
+
const value = window.sessionStorage.getItem(KEY) ?? "";
|
|
55
|
+
if (value) window.sessionStorage.removeItem(KEY);
|
|
56
|
+
taken = value;
|
|
57
|
+
return value;
|
|
58
|
+
} catch {
|
|
59
|
+
return "";
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { useCart, type CartItem, type TicketCartItem } from "../../../contexts/CartContext";
|
|
3
|
+
import { useResumeCheckout, type ResumeLine, type ResumedCheckout } from "../../../data/queries/useCheckouts";
|
|
4
|
+
import { rememberResumedCoupon } from "./resumedCoupon";
|
|
5
|
+
import { ProductDeliveryType } from "../../../types/models";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The landing behaviour of a cart-recovery link, shared by both storefronts.
|
|
9
|
+
*
|
|
10
|
+
* `apps/client` and a Forge code website draw this page completely differently
|
|
11
|
+
* (Tailwind against `themeSettings` on one, inline styles against the Forge
|
|
12
|
+
* theme on the other) and they must not decide DIFFERENTLY what a resumed
|
|
13
|
+
* basket does. So the exchange, the restore and the arithmetic live here once
|
|
14
|
+
* and each stack owns only how it looks, exactly as `ticketAvailability` and
|
|
15
|
+
* `useClaimPassTransfer` already do.
|
|
16
|
+
*
|
|
17
|
+
* ## What lands in the basket, and what does not
|
|
18
|
+
*
|
|
19
|
+
* Only the lines the server says can still be bought. Every OTHER line is
|
|
20
|
+
* still returned to the caller in `unavailableLines`, because the two failure
|
|
21
|
+
* modes here are asymmetric and both are bad:
|
|
22
|
+
*
|
|
23
|
+
* - Silently dropping a line is the worst outcome. The buyer came back for the
|
|
24
|
+
* thing they left, the basket is quietly one item lighter, and they find out
|
|
25
|
+
* after paying (or never).
|
|
26
|
+
* - Restoring a line nobody will sell them is the second worst. They reach the
|
|
27
|
+
* payment screen and are refused, which reads as a broken shop.
|
|
28
|
+
*
|
|
29
|
+
* Showing it, greyed out, with the reason next to it is neither.
|
|
30
|
+
*
|
|
31
|
+
* ## An add-on never lands without the ticket it hangs off
|
|
32
|
+
*
|
|
33
|
+
* A product line carrying `attachedTo` was added from an event's add-on block
|
|
34
|
+
* and is only sellable next to that event's own ticket line. If the ticket
|
|
35
|
+
* cannot be restored (sold out, withdrawn) the add-on cannot either: the server
|
|
36
|
+
* refuses a bundle whose add-on has no base, so restoring it alone builds a
|
|
37
|
+
* basket that fails at the payment screen with nothing on the page saying why.
|
|
38
|
+
*
|
|
39
|
+
* The base has to come from THIS payload, not from whatever is in the basket
|
|
40
|
+
* already. The buckets below are derived from the server answer alone, so they
|
|
41
|
+
* cannot change under the page once the restore has run.
|
|
42
|
+
*
|
|
43
|
+
* Those add-ons come back in `droppedLines` rather than disappearing, for the
|
|
44
|
+
* same reason unavailable lines do: a basket that is quietly one item lighter
|
|
45
|
+
* is the worst of the outcomes above.
|
|
46
|
+
*
|
|
47
|
+
* ## Courses and coaching are not basket items
|
|
48
|
+
*
|
|
49
|
+
* The cart holds products and event tickets. A course or a coaching session is
|
|
50
|
+
* bought through its own page, so those lines come back as `continueLines`:
|
|
51
|
+
* nothing is restored and the caller draws a link back to the page where the
|
|
52
|
+
* purchase resumes. Reporting them as "restored" would be a lie, and dropping
|
|
53
|
+
* them would strand the only recovery path those two pillars have.
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
// Re-exported so a caller reaches the whole vocabulary from `forge/ui` without
|
|
57
|
+
// also importing the data layer.
|
|
58
|
+
export type {
|
|
59
|
+
ResumeLine,
|
|
60
|
+
ResumedCheckout,
|
|
61
|
+
ResumeLineOption,
|
|
62
|
+
ResumeLineUnavailableReason,
|
|
63
|
+
} from "../../../data/queries/useCheckouts";
|
|
64
|
+
|
|
65
|
+
export type CartResumeStatus = "loading" | "restored" | "empty" | "error";
|
|
66
|
+
|
|
67
|
+
export type UseCartResumeOptions = {
|
|
68
|
+
/** The `?token=` from the recovery link. Absent or blank means an invalid link. */
|
|
69
|
+
token?: string | null;
|
|
70
|
+
/**
|
|
71
|
+
* Put the basket back as soon as the exchange answers. On by default: the
|
|
72
|
+
* point of the link is that the buyer arrives with a full basket, not with a
|
|
73
|
+
* button that promises one.
|
|
74
|
+
*/
|
|
75
|
+
autoRestore?: boolean;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
export type UseCartResumeResult = {
|
|
79
|
+
status: CartResumeStatus;
|
|
80
|
+
/** The whole server answer, for a caller that wants a field this hook does not surface. */
|
|
81
|
+
resumed: ResumedCheckout | null;
|
|
82
|
+
/** Lines that went back in the basket. */
|
|
83
|
+
restoredLines: ResumeLine[];
|
|
84
|
+
/** Lines that could not, each with its `unavailableReason`. Draw these. */
|
|
85
|
+
unavailableLines: ResumeLine[];
|
|
86
|
+
/**
|
|
87
|
+
* Add-ons the shop would still sell, left out because the event ticket they
|
|
88
|
+
* hang off was not restored. Draw these too: they were in the basket the
|
|
89
|
+
* buyer left, and they are not in the one they got back.
|
|
90
|
+
*/
|
|
91
|
+
droppedLines: ResumeLine[];
|
|
92
|
+
/** Course and coaching lines: not basket items, link the buyer onward. */
|
|
93
|
+
continueLines: ResumeLine[];
|
|
94
|
+
/** Restored lines whose price moved while the buyer was away. */
|
|
95
|
+
repricedLines: ResumeLine[];
|
|
96
|
+
/** Units restored. */
|
|
97
|
+
itemCount: number;
|
|
98
|
+
/** Minor units, restored lines only, at today's prices and before discount. */
|
|
99
|
+
subtotalCents: number;
|
|
100
|
+
currency: string;
|
|
101
|
+
/** A code to re-apply, already stashed for the checkout page. Display it. */
|
|
102
|
+
couponCode: string | null;
|
|
103
|
+
/** Set when the link itself failed (missing, expired, another artist's). */
|
|
104
|
+
error: string | null;
|
|
105
|
+
/** Restore by hand. A no-op once the basket has been restored. */
|
|
106
|
+
restore: () => void;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* A resumed product line as the cart stores it.
|
|
111
|
+
*
|
|
112
|
+
* Exported so the translation can be tested without a DOM: every field here is
|
|
113
|
+
* one the checkout later branches on, and getting `deliveryType` wrong ships an
|
|
114
|
+
* order with nowhere to send it.
|
|
115
|
+
*/
|
|
116
|
+
export function resumeLineToCartItem(line: ResumeLine): CartItem | null {
|
|
117
|
+
if (!line.productId || !line.productVariantId) return null;
|
|
118
|
+
return {
|
|
119
|
+
productId: line.productId,
|
|
120
|
+
productVariantId: line.productVariantId,
|
|
121
|
+
title: line.title,
|
|
122
|
+
price: line.price,
|
|
123
|
+
coverImage: line.coverImage ?? undefined,
|
|
124
|
+
isGift: !!line.isGift,
|
|
125
|
+
recipientName: line.recipientName ?? undefined,
|
|
126
|
+
recipientEmail: line.recipientEmail ?? undefined,
|
|
127
|
+
recipientMessage: line.recipientMessage ?? undefined,
|
|
128
|
+
/**
|
|
129
|
+
* The buyer chose this quantity once; restoring it as 1 would make them
|
|
130
|
+
* re-count. `canIncreaseQuantity` follows the ordinary shop default so the
|
|
131
|
+
* cart's stepper behaves as it does for any other line.
|
|
132
|
+
*/
|
|
133
|
+
quantity: line.quantity,
|
|
134
|
+
canIncreaseQuantity: true,
|
|
135
|
+
payWhatYouWant: !!line.payWhatYouWant,
|
|
136
|
+
options: line.options ?? undefined,
|
|
137
|
+
/**
|
|
138
|
+
* Never guessed. Checkout reads shipping-or-not off this field, and a
|
|
139
|
+
* physical line restored as non-physical means an order arrives with
|
|
140
|
+
* nowhere to send it. The server always sends it for a line it declared
|
|
141
|
+
* available; `Digital` here would be a bug upstream, not a default.
|
|
142
|
+
*/
|
|
143
|
+
deliveryType: line.deliveryType ?? ProductDeliveryType.Digital,
|
|
144
|
+
attachedTo: line.attachedTo ?? undefined,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Ticket lines regrouped into the per-event shape the cart holds. */
|
|
149
|
+
export function resumeLinesToTicketItems(lines: ResumeLine[]): TicketCartItem[] {
|
|
150
|
+
const byEvent = new Map<string, TicketCartItem>();
|
|
151
|
+
for (const line of lines) {
|
|
152
|
+
if (!line.eventId || !line.ticketId) continue;
|
|
153
|
+
const existing = byEvent.get(line.eventId) ?? {
|
|
154
|
+
eventId: line.eventId,
|
|
155
|
+
eventSlug: line.eventSlug ?? "",
|
|
156
|
+
eventTitle: line.eventTitle ?? line.title,
|
|
157
|
+
coverImage: line.coverImage ?? undefined,
|
|
158
|
+
tickets: {},
|
|
159
|
+
ticketMeta: {},
|
|
160
|
+
bookingFee: line.bookingFee ?? null,
|
|
161
|
+
};
|
|
162
|
+
existing.tickets[line.ticketId] = (existing.tickets[line.ticketId] ?? 0) + line.quantity;
|
|
163
|
+
existing.ticketMeta[line.ticketId] = {
|
|
164
|
+
title: line.title,
|
|
165
|
+
// On a pay-what-you-want tier the buyer's own amount is what the cart
|
|
166
|
+
// shows and what the bundle charges, so it wins over the tier's floor.
|
|
167
|
+
price: line.pwywAmount ?? line.price,
|
|
168
|
+
...(line.pwywAmount != null ? { pwywAmount: line.pwywAmount } : {}),
|
|
169
|
+
};
|
|
170
|
+
byEvent.set(line.eventId, existing);
|
|
171
|
+
}
|
|
172
|
+
return [...byEvent.values()];
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const CONTINUE_TYPES = new Set(["course", "coaching"]);
|
|
176
|
+
|
|
177
|
+
/** The four buckets a resumed payload splits into. */
|
|
178
|
+
export type ResumePartition = {
|
|
179
|
+
restoredLines: ResumeLine[];
|
|
180
|
+
unavailableLines: ResumeLine[];
|
|
181
|
+
continueLines: ResumeLine[];
|
|
182
|
+
droppedLines: ResumeLine[];
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Split the server's lines into what goes back in the basket and what only gets
|
|
187
|
+
* drawn. Pure, and exported, because this is the decision the whole page rests
|
|
188
|
+
* on and it must be assertable without a DOM.
|
|
189
|
+
*
|
|
190
|
+
* The subtle bucket is `droppedLines`. An add-on is only sellable next to its
|
|
191
|
+
* base, so a base that is not itself being restored takes the add-on with it.
|
|
192
|
+
* "Being restored" is checked against the fields `resumeLinesToTicketItems`
|
|
193
|
+
* actually needs (`eventId` and `ticketId`), so a ticket line that function
|
|
194
|
+
* would skip does not count as a base either.
|
|
195
|
+
*/
|
|
196
|
+
export function partitionResumeLines(lines: ResumeLine[]): ResumePartition {
|
|
197
|
+
const unavailableLines = lines.filter((line) => !line.available);
|
|
198
|
+
const available = lines.filter((line) => line.available);
|
|
199
|
+
const continueLines = available.filter((line) => CONTINUE_TYPES.has(line.type));
|
|
200
|
+
const restorable = available.filter((line) => !CONTINUE_TYPES.has(line.type));
|
|
201
|
+
|
|
202
|
+
const baseEventIds = new Set(
|
|
203
|
+
restorable
|
|
204
|
+
.filter((line) => line.type === "event_ticket" && line.eventId && line.ticketId)
|
|
205
|
+
.map((line) => line.eventId as string),
|
|
206
|
+
);
|
|
207
|
+
const isOrphanedAddon = (line: ResumeLine) =>
|
|
208
|
+
line.type === "product" &&
|
|
209
|
+
line.attachedTo?.type === "event" &&
|
|
210
|
+
!baseEventIds.has(line.attachedTo.entityId);
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
restoredLines: restorable.filter((line) => !isOrphanedAddon(line)),
|
|
214
|
+
unavailableLines,
|
|
215
|
+
continueLines,
|
|
216
|
+
droppedLines: restorable.filter(isOrphanedAddon),
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** What this line will actually cost per unit, in minor units. */
|
|
221
|
+
export function effectiveUnitCents(line: ResumeLine): number {
|
|
222
|
+
return line.pwywAmount != null ? Math.round(line.pwywAmount * 100) : line.unitPriceCents;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function useCartResume(options: UseCartResumeOptions): UseCartResumeResult {
|
|
226
|
+
const { token, autoRestore = true } = options;
|
|
227
|
+
const { restoreCart, isReady } = useCart();
|
|
228
|
+
const { mutateAsync } = useResumeCheckout();
|
|
229
|
+
|
|
230
|
+
const [resumed, setResumed] = useState<ResumedCheckout | null>(null);
|
|
231
|
+
const [error, setError] = useState<string | null>(null);
|
|
232
|
+
const [restored, setRestored] = useState(false);
|
|
233
|
+
/**
|
|
234
|
+
* One exchange per token. The endpoint stamps the arrival that cart-recovery
|
|
235
|
+
* attributes on, so firing it twice is not merely wasteful, and StrictMode
|
|
236
|
+
* runs every effect twice in development.
|
|
237
|
+
*/
|
|
238
|
+
const requestedFor = useRef<string | null>(null);
|
|
239
|
+
|
|
240
|
+
useEffect(() => {
|
|
241
|
+
const value = (token ?? "").trim();
|
|
242
|
+
if (!value) {
|
|
243
|
+
setError("missing_token");
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
if (requestedFor.current === value) return;
|
|
247
|
+
requestedFor.current = value;
|
|
248
|
+
|
|
249
|
+
let cancelled = false;
|
|
250
|
+
void mutateAsync({ token: value })
|
|
251
|
+
.then((data) => {
|
|
252
|
+
if (cancelled) return;
|
|
253
|
+
setResumed(data);
|
|
254
|
+
setError(null);
|
|
255
|
+
// Stashed even when the caller does not auto-restore, so the code
|
|
256
|
+
// survives the walk from this page to checkout either way.
|
|
257
|
+
rememberResumedCoupon(data.couponCode);
|
|
258
|
+
})
|
|
259
|
+
.catch((err: unknown) => {
|
|
260
|
+
if (cancelled) return;
|
|
261
|
+
setError(
|
|
262
|
+
(err as { response?: { data?: { message?: string } } })?.response?.data?.message ?? "invalid_link",
|
|
263
|
+
);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
return () => {
|
|
267
|
+
cancelled = true;
|
|
268
|
+
};
|
|
269
|
+
}, [token, mutateAsync]);
|
|
270
|
+
|
|
271
|
+
const { restoredLines, unavailableLines, continueLines, droppedLines } = useMemo(
|
|
272
|
+
() => partitionResumeLines(resumed?.lines ?? []),
|
|
273
|
+
[resumed],
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
const repricedLines = useMemo(
|
|
277
|
+
() => restoredLines.filter((line) => line.priceChanged),
|
|
278
|
+
[restoredLines],
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
const restore = useCallback(() => {
|
|
282
|
+
/**
|
|
283
|
+
* `isReady` is not belt and braces. `CartProvider` hydrates from
|
|
284
|
+
* localStorage in a mount effect and that effect REPLACES the whole list,
|
|
285
|
+
* so anything written before it lands is thrown away. The network round
|
|
286
|
+
* trip makes that practically impossible, and "practically impossible"
|
|
287
|
+
* silent data loss is the kind that surfaces once, on somebody's phone, and
|
|
288
|
+
* is never reproduced.
|
|
289
|
+
*/
|
|
290
|
+
if (!isReady || restored || restoredLines.length === 0) return;
|
|
291
|
+
restoreCart({
|
|
292
|
+
items: restoredLines
|
|
293
|
+
.filter((line) => line.type === "product")
|
|
294
|
+
.map(resumeLineToCartItem)
|
|
295
|
+
.filter((item): item is CartItem => item !== null),
|
|
296
|
+
tickets: resumeLinesToTicketItems(restoredLines.filter((line) => line.type === "event_ticket")),
|
|
297
|
+
});
|
|
298
|
+
setRestored(true);
|
|
299
|
+
/**
|
|
300
|
+
* `isReady` belongs here. Without it the callback keeps the `false` it
|
|
301
|
+
* closed over on the first render, React hands the same stale function back
|
|
302
|
+
* on every later one, and the effect below never re-runs once hydration
|
|
303
|
+
* finishes: the guard stops being a wait and becomes a permanent refusal on
|
|
304
|
+
* exactly the slow load it was written for.
|
|
305
|
+
*/
|
|
306
|
+
}, [isReady, restored, restoredLines, restoreCart]);
|
|
307
|
+
|
|
308
|
+
useEffect(() => {
|
|
309
|
+
if (autoRestore) restore();
|
|
310
|
+
}, [autoRestore, restore]);
|
|
311
|
+
|
|
312
|
+
const status: CartResumeStatus = error
|
|
313
|
+
? "error"
|
|
314
|
+
: !resumed
|
|
315
|
+
? "loading"
|
|
316
|
+
: restoredLines.length === 0 && continueLines.length === 0
|
|
317
|
+
? "empty"
|
|
318
|
+
: "restored";
|
|
319
|
+
|
|
320
|
+
return {
|
|
321
|
+
status,
|
|
322
|
+
resumed,
|
|
323
|
+
restoredLines,
|
|
324
|
+
unavailableLines,
|
|
325
|
+
continueLines,
|
|
326
|
+
droppedLines,
|
|
327
|
+
repricedLines,
|
|
328
|
+
itemCount: restoredLines.reduce((sum, line) => sum + line.quantity, 0),
|
|
329
|
+
// The figure the CART will show, so a pay-what-you-want tier counts the
|
|
330
|
+
// buyer's own amount rather than the tier's floor.
|
|
331
|
+
subtotalCents: restoredLines.reduce(
|
|
332
|
+
(sum, line) => sum + effectiveUnitCents(line) * line.quantity,
|
|
333
|
+
0,
|
|
334
|
+
),
|
|
335
|
+
currency: resumed?.currency ?? "USD",
|
|
336
|
+
couponCode: resumed?.couponCode ?? null,
|
|
337
|
+
error,
|
|
338
|
+
restore,
|
|
339
|
+
};
|
|
340
|
+
}
|