@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.
Files changed (90) hide show
  1. package/package.json +1 -1
  2. package/src/client/_tests/localeHeader.spec.tsx +98 -0
  3. package/src/client/activeLocale.ts +34 -0
  4. package/src/client/createForgeClient.ts +13 -0
  5. package/src/contexts/CartContext.tsx +76 -0
  6. package/src/contexts/_tests/CartContext.spec.tsx +210 -0
  7. package/src/data/queries/useCheckouts.ts +83 -0
  8. package/src/data/queries/useWebsite.ts +7 -0
  9. package/src/i18n/CONVENTION.md +184 -0
  10. package/src/i18n/_tests/translationKeys.spec.ts +153 -0
  11. package/src/i18n/de.json +960 -0
  12. package/src/i18n/en.json +960 -0
  13. package/src/i18n/index.ts +125 -0
  14. package/src/index.ts +21 -0
  15. package/src/provider/ForgeAppProvider.tsx +2 -1
  16. package/src/provider/SiteConfigProvider.tsx +11 -0
  17. package/src/ui/headless/checkout/_tests/cartResume.spec.ts +291 -0
  18. package/src/ui/headless/checkout/_tests/useCartResume.spec.tsx +294 -0
  19. package/src/ui/headless/checkout/resumedCoupon.ts +61 -0
  20. package/src/ui/headless/checkout/useCartResume.ts +340 -0
  21. package/src/ui/headless/checkout/useCheckout.ts +9 -2
  22. package/src/ui/headless/index.ts +18 -0
  23. package/src/ui/index.ts +2 -0
  24. package/src/ui/shell/TribeNestApp.tsx +36 -27
  25. package/src/ui/styled/AccountDashboard.tsx +197 -93
  26. package/src/ui/styled/AiAgentWidget.tsx +10 -6
  27. package/src/ui/styled/AudioPlayer.tsx +14 -6
  28. package/src/ui/styled/BlogCategory.tsx +4 -2
  29. package/src/ui/styled/BlogComments.tsx +27 -20
  30. package/src/ui/styled/BlogList.tsx +5 -3
  31. package/src/ui/styled/BlogPost.tsx +14 -11
  32. package/src/ui/styled/BundleConfirmation.tsx +32 -23
  33. package/src/ui/styled/CancellationTerms.tsx +3 -1
  34. package/src/ui/styled/Cart.tsx +32 -14
  35. package/src/ui/styled/ChatRoom.tsx +65 -20
  36. package/src/ui/styled/Checkout.tsx +151 -89
  37. package/src/ui/styled/CheckoutConfirmation.tsx +66 -35
  38. package/src/ui/styled/CoachingBooking.tsx +71 -44
  39. package/src/ui/styled/CoachingConfirmation.tsx +56 -19
  40. package/src/ui/styled/CoachingDetail.tsx +15 -11
  41. package/src/ui/styled/CohortPage.tsx +10 -8
  42. package/src/ui/styled/ConfirmSubscription.tsx +14 -8
  43. package/src/ui/styled/ContactForm.tsx +31 -12
  44. package/src/ui/styled/CookieConsent.tsx +19 -15
  45. package/src/ui/styled/CourseAccess.tsx +54 -47
  46. package/src/ui/styled/CourseCheckout.tsx +56 -38
  47. package/src/ui/styled/CourseConfirmation.tsx +39 -17
  48. package/src/ui/styled/CourseDetail.tsx +21 -15
  49. package/src/ui/styled/CoursesGrid.tsx +4 -2
  50. package/src/ui/styled/CurrencySwitcher.tsx +4 -1
  51. package/src/ui/styled/DiscountCode.tsx +22 -18
  52. package/src/ui/styled/DonationButton.tsx +52 -17
  53. package/src/ui/styled/DonationPage.tsx +4 -4
  54. package/src/ui/styled/EmailListForm.tsx +22 -9
  55. package/src/ui/styled/EventConfirmation.tsx +37 -14
  56. package/src/ui/styled/EventCountdown.tsx +10 -6
  57. package/src/ui/styled/EventDetail.tsx +8 -6
  58. package/src/ui/styled/EventSeriesDetail.tsx +18 -13
  59. package/src/ui/styled/EventTickets.tsx +232 -108
  60. package/src/ui/styled/EventWaitlist.tsx +42 -33
  61. package/src/ui/styled/EventsList.tsx +15 -13
  62. package/src/ui/styled/ForgotPasswordForm.tsx +12 -12
  63. package/src/ui/styled/FormRenderer.tsx +12 -7
  64. package/src/ui/styled/InstallBanner.tsx +21 -18
  65. package/src/ui/styled/InvoiceConfirmation.tsx +13 -5
  66. package/src/ui/styled/InvoicePayment.tsx +37 -16
  67. package/src/ui/styled/LeadMagnet.tsx +9 -7
  68. package/src/ui/styled/Loading.tsx +7 -1
  69. package/src/ui/styled/LoginForm.tsx +22 -14
  70. package/src/ui/styled/MembershipCheckout.tsx +32 -13
  71. package/src/ui/styled/MembershipTiers.tsx +27 -18
  72. package/src/ui/styled/OfferButton.tsx +33 -13
  73. package/src/ui/styled/PaymentLinkConfirmation.tsx +11 -5
  74. package/src/ui/styled/PaymentLinkPayment.tsx +30 -10
  75. package/src/ui/styled/PodcastEpisode.tsx +20 -9
  76. package/src/ui/styled/PodcastList.tsx +9 -3
  77. package/src/ui/styled/PodcastShow.tsx +29 -7
  78. package/src/ui/styled/PostsFeed.tsx +10 -8
  79. package/src/ui/styled/PresaleCode.tsx +8 -11
  80. package/src/ui/styled/ProductBrowseNav.tsx +21 -11
  81. package/src/ui/styled/ProductDetail.tsx +58 -28
  82. package/src/ui/styled/ReactionBar.tsx +4 -2
  83. package/src/ui/styled/ReplayList.tsx +24 -13
  84. package/src/ui/styled/ResetPasswordForm.tsx +27 -15
  85. package/src/ui/styled/ResumeCart.tsx +289 -0
  86. package/src/ui/styled/ReviewForm.tsx +52 -58
  87. package/src/ui/styled/SignupForm.tsx +29 -16
  88. package/src/ui/styled/TicketTransfer.tsx +55 -54
  89. package/src/ui/styled/UserMenu.tsx +16 -14
  90. 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
+ }