@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tribe-nest/forge",
3
- "version": "3.26.0",
3
+ "version": "3.29.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -0,0 +1,98 @@
1
+ import { describe, it, expect, beforeEach } from "vitest";
2
+ import { renderToStaticMarkup } from "react-dom/server";
3
+ import type { AxiosRequestConfig } from "axios";
4
+ import { createForgeClient } from "../createForgeClient";
5
+ import { getSiteLocale, setSiteLocale } from "../activeLocale";
6
+ import { SiteConfigProvider } from "../../provider/SiteConfigProvider";
7
+ import type { SiteConfig } from "../../data/queries/useWebsite";
8
+
9
+ /**
10
+ * The last link in the chain that lets a German site be German all the way down:
11
+ * the tenant's locale rides every API request as `x-locale`, which the backend
12
+ * reads into its request store and answers in.
13
+ *
14
+ * The failure this guards against is quiet. Nothing errors when the header is
15
+ * missing: the page renders German, the API answers English, and the only
16
+ * person who notices is the visitor who gets told "Product not found" on an
17
+ * otherwise German checkout.
18
+ */
19
+
20
+ // Captures the outgoing config without a network round trip. The request
21
+ // interceptor has already run by the time the adapter is called, so whatever
22
+ // lands here is exactly what would have gone over the wire.
23
+ function clientCapturingHeaders() {
24
+ const sent: AxiosRequestConfig[] = [];
25
+ const client = createForgeClient({ baseURL: "https://api.example.test" });
26
+ client.defaults.adapter = async (config) => {
27
+ sent.push(config);
28
+ return { data: {}, status: 200, statusText: "OK", headers: {}, config } as never;
29
+ };
30
+ return { client, sent };
31
+ }
32
+
33
+ describe("x-locale on every Forge request", () => {
34
+ beforeEach(() => {
35
+ setSiteLocale(null);
36
+ });
37
+
38
+ it("sends nothing before any site config has been seen", async () => {
39
+ const { client, sent } = clientCapturingHeaders();
40
+ await client.get("/public/products");
41
+ expect(sent[0].headers?.["x-locale"]).toBeUndefined();
42
+ });
43
+
44
+ it("sends the tenant's locale once it is known", async () => {
45
+ setSiteLocale("de");
46
+ const { client, sent } = clientCapturingHeaders();
47
+ await client.get("/public/products");
48
+ expect(sent[0].headers?.["x-locale"]).toBe("de");
49
+ });
50
+
51
+ // The locale is read per request rather than captured when the client is
52
+ // built: the client is created once, before any site config exists.
53
+ it("picks up a locale set after the client was created", async () => {
54
+ const { client, sent } = clientCapturingHeaders();
55
+ await client.get("/public/products");
56
+ setSiteLocale("de");
57
+ await client.get("/public/products");
58
+ expect(sent[0].headers?.["x-locale"]).toBeUndefined();
59
+ expect(sent[1].headers?.["x-locale"]).toBe("de");
60
+ });
61
+
62
+ it("leaves the other tenant headers alone", async () => {
63
+ setSiteLocale("de");
64
+ const { client, sent } = clientCapturingHeaders();
65
+ await client.get("/public/products");
66
+ expect(sent[0].headers?.["x-timezone-offset"]).toBeDefined();
67
+ });
68
+ });
69
+
70
+ describe("the site config publishes the locale to the client", () => {
71
+ beforeEach(() => {
72
+ setSiteLocale(null);
73
+ });
74
+
75
+ // Mounted by every site through TribeNestApp, which is what makes the header
76
+ // arrive on a Forge upgrade alone, with no edit to a tenant's __root.tsx.
77
+ it("SiteConfigProvider sets it during render, before any child can fetch", () => {
78
+ renderToStaticMarkup(
79
+ <SiteConfigProvider initialConfig={{ locale: "de" } as SiteConfig}>
80
+ <span>{getSiteLocale()}</span>
81
+ </SiteConfigProvider>,
82
+ );
83
+ expect(getSiteLocale()).toBe("de");
84
+ });
85
+
86
+ // An older backend returns a config with no `locale` at all, and a failed SSR
87
+ // bootstrap returns no config. Both mean English, which is what an absent
88
+ // header already gets.
89
+ it("clears the locale when the config carries none", () => {
90
+ setSiteLocale("de");
91
+ renderToStaticMarkup(
92
+ <SiteConfigProvider initialConfig={{} as SiteConfig}>
93
+ <span />
94
+ </SiteConfigProvider>,
95
+ );
96
+ expect(getSiteLocale()).toBeNull();
97
+ });
98
+ });
@@ -0,0 +1,34 @@
1
+ // The language the site is being served in, held outside React so the shared
2
+ // axios client can read it.
3
+ //
4
+ // Why this exists: without it a German site renders German and every error the
5
+ // API returns arrives in English, which is the failure the visitor sees rather
6
+ // than the one the creator configured. The backend already reads an `x-locale`
7
+ // header into its request store (`LOCALE_HEADER_KEY`), so the whole fix is
8
+ // getting the tenant's locale onto every request.
9
+ //
10
+ // Why a module singleton rather than a prop threaded to `createForgeClient`:
11
+ // the client is created once, per `apiUrl`, before any site config is known,
12
+ // and the locale arrives later with the site config. The same reasoning as
13
+ // `activeFunnel` applies too: put it in the transport and no call site can
14
+ // forget it.
15
+ //
16
+ // Scope: one locale at a time. One deploy serves one tenant, so on the server
17
+ // every request writes the same value, and a creator changing the setting is
18
+ // picked up on the next SSR render.
19
+
20
+ let siteLocale: string | null = null;
21
+
22
+ /**
23
+ * Set the language every subsequent API request declares. Called with
24
+ * `siteConfig.locale` where the config enters the tree, so an existing site
25
+ * gains the header from a Forge upgrade with no edit to its `__root.tsx`.
26
+ */
27
+ export function setSiteLocale(locale: string | null | undefined): void {
28
+ siteLocale = locale || null;
29
+ }
30
+
31
+ /** The active site locale, or null before any site config has been seen. */
32
+ export function getSiteLocale(): string | null {
33
+ return siteLocale;
34
+ }
@@ -1,5 +1,6 @@
1
1
  import axios, { type AxiosError, type AxiosInstance } from "axios";
2
2
  import { getActiveFunnelId } from "./activeFunnel";
3
+ import { getSiteLocale } from "./activeLocale";
3
4
 
4
5
  /**
5
6
  * Access-token refresh (backend §7 cutover). Access tokens live ~15 minutes;
@@ -69,6 +70,18 @@ export const createForgeClient = ({
69
70
  config.headers["x-funnel-id"] = funnelId;
70
71
  }
71
72
 
73
+ // The tenant's language, so the API answers in the language the page is
74
+ // written in. Without it a German site renders German and every validation
75
+ // message, error and generated string comes back English. The backend reads
76
+ // this header into its request store, so it covers everything a request
77
+ // touches rather than the endpoints someone remembered to translate.
78
+ // Absent until the site config has been seen, and the backend defaults to
79
+ // English on an absent or unrecognised value.
80
+ const locale = getSiteLocale();
81
+ if (locale) {
82
+ config.headers["x-locale"] = locale;
83
+ }
84
+
72
85
  const token = getToken?.();
73
86
  if (token) {
74
87
  config.headers["authorization"] = `Bearer ${token}`;
@@ -106,6 +106,35 @@ interface CartContextType {
106
106
  addToCart: (item: CartItem) => boolean;
107
107
  removeFromCart: (productId: string, isGift: boolean, recipientEmail?: string) => void;
108
108
  clearCart: () => void;
109
+ /**
110
+ * Put a whole basket back at once, MERGING with whatever is already there.
111
+ *
112
+ * Built for the recovery link (`/i/checkout/resume`), where the buyer arrives
113
+ * from an email with lines the server has just re-priced. It exists rather
114
+ * than a loop over `addToCart` for two reasons, both of which produced wrong
115
+ * baskets when tried:
116
+ *
117
+ * - `addToCart` reads `cartItems` from its closure, so a loop decides
118
+ * "already in the cart?" against a snapshot that is one render old. A
119
+ * restored line matching one the visitor already had appended a DUPLICATE
120
+ * instead of replacing it. This merges inside a functional update, so every
121
+ * line is matched against the real current state.
122
+ * - Restoring must not pop the drawer open per line. The resume page IS the
123
+ * surface; a drawer sliding over it is noise.
124
+ *
125
+ * MERGE, not replace: a visitor who put something in their basket between the
126
+ * email and the click keeps it. The restored line wins on a collision,
127
+ * because it is the one that was just re-priced.
128
+ *
129
+ * Tickets merge PER TIER, not per event. An event's selection is a map, and
130
+ * treating it as one value threw away every tier the visitor had picked in
131
+ * this session the moment the resumed basket named the same event. On a tier
132
+ * both sides name, the restored quantity REPLACES the session's rather than
133
+ * adding to it, matching how a colliding product line is replaced whole: both
134
+ * sides describe the same intent at different prices, and the resumed one is
135
+ * the figure the server has just re-checked.
136
+ */
137
+ restoreCart: (input: { items?: CartItem[]; tickets?: TicketCartItem[] }) => void;
109
138
  /** Ticket selections, one entry per event. */
110
139
  ticketItems: TicketCartItem[];
111
140
  /** Add or replace the selection for an event (the modal always sends the whole map). */
@@ -232,6 +261,52 @@ export function CartProvider({ children }: { children: ReactNode }) {
232
261
  );
233
262
  };
234
263
 
264
+ /** The identity of a product line, as `addToCart` already defines it. */
265
+ const lineKey = (item: CartItem) =>
266
+ `${item.productId}|${item.productVariantId}|${item.isGift}|${item.recipientEmail ?? ""}`;
267
+
268
+ const restoreCart = useCallback((input: { items?: CartItem[]; tickets?: TicketCartItem[] }) => {
269
+ const items = input.items ?? [];
270
+ const tickets = input.tickets ?? [];
271
+
272
+ if (items.length > 0) {
273
+ setCartItems((prev) => {
274
+ const incoming = new Map(items.map((item) => [lineKey(item), item]));
275
+ const merged = prev.map((item) => incoming.get(lineKey(item)) ?? item);
276
+ const seen = new Set(prev.map(lineKey));
277
+ return [...merged, ...items.filter((item) => !seen.has(lineKey(item)))];
278
+ });
279
+ }
280
+
281
+ if (tickets.length > 0) {
282
+ setTicketItems((prev) => {
283
+ const byEvent = new Map(prev.map((ticket) => [ticket.eventId, ticket]));
284
+ for (const incoming of tickets) {
285
+ const existing = byEvent.get(incoming.eventId);
286
+ byEvent.set(
287
+ incoming.eventId,
288
+ existing
289
+ ? {
290
+ ...existing,
291
+ ...incoming,
292
+ // Tier by tier, so a tier only the session knows about
293
+ // survives and a tier both name takes the resumed quantity.
294
+ tickets: { ...existing.tickets, ...incoming.tickets },
295
+ ticketMeta: { ...existing.ticketMeta, ...incoming.ticketMeta },
296
+ // A resumed payload predating booking fees carries none, and
297
+ // an absent fee must not erase the one the modal captured.
298
+ bookingFee: incoming.bookingFee ?? existing.bookingFee ?? null,
299
+ }
300
+ : incoming,
301
+ );
302
+ }
303
+ // Insertion order: the events already in the basket keep their place,
304
+ // and events only the recovery link knows about go on the end.
305
+ return [...byEvent.values()];
306
+ });
307
+ }
308
+ }, []);
309
+
235
310
  const setTickets = useCallback((item: TicketCartItem) => {
236
311
  setTicketItems((prev) => [...prev.filter((t) => t.eventId !== item.eventId), item]);
237
312
  setCartOpen(true);
@@ -275,6 +350,7 @@ export function CartProvider({ children }: { children: ReactNode }) {
275
350
  addToCart,
276
351
  removeFromCart,
277
352
  clearCart,
353
+ restoreCart,
278
354
  ticketItems,
279
355
  setTickets,
280
356
  removeTickets,
@@ -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
+ });
@@ -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
+ }
@@ -7,6 +7,13 @@ export interface SiteConfig {
7
7
  /** The tenant's settlement currency (e.g. "USD") — the currency API amounts
8
8
  * are in and payments are charged in. */
9
9
  currency: string;
10
+ /** The language the tenant speaks ("en" | "de" today), from
11
+ * `profile_configurations.locale`. Runtime, like `currency`: Forge renders its
12
+ * own copy in it, the site puts it on `<html lang>`, and the axios client sends
13
+ * it as `x-locale` so API messages arrive in the same language. Optional here
14
+ * because a site pinned to an older backend gets no such field, and every
15
+ * reader falls back to English. */
16
+ locale?: string;
10
17
  /** Sales-tax display mode: true = listing prices already contain tax
11
18
  * ("incl. VAT" style, EU/UK), false/absent = tax added at checkout (US).
12
19
  * Display-only — checkout totals come from the authoritative taxQuote. */