@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,125 @@
1
+ // Forge i18n. Bundled strings only: the en/de JSON in this folder ships inside
2
+ // the package and is read synchronously, so there is no network call, no
3
+ // loading state and no way for a site to render an untranslated first paint.
4
+ // See docs/proposals/i18n-initiative.md, "Why bundle-only is safe".
5
+ //
6
+ // Key naming is fixed by ./CONVENTION.md. Every key starts with "forge." so a
7
+ // tenant's own copy can never collide with the package's.
8
+
9
+ import { createContext, createElement, useContext, useMemo, type ReactNode } from "react";
10
+ import enMessages from "./en.json";
11
+ import deMessages from "./de.json";
12
+
13
+ /** The locales Forge ships copy for. en + de is the whole proof of concept. */
14
+ export const FORGE_LOCALES = ["en", "de"] as const;
15
+
16
+ export type ForgeLocale = (typeof FORGE_LOCALES)[number];
17
+
18
+ /** The locale every fallback ends at. English is the source language. */
19
+ export const FORGE_DEFAULT_LOCALE: ForgeLocale = "en";
20
+
21
+ /** A flat map of dotted key to copy. Not nested: see ./CONVENTION.md. */
22
+ export type ForgeMessages = Record<string, string>;
23
+
24
+ /** Values substituted into `{placeholders}`. */
25
+ export type ForgeTVars = Record<string, string | number>;
26
+
27
+ /** The translate function `useForgeT()` hands back. */
28
+ export type ForgeT = (key: string, vars?: ForgeTVars) => string;
29
+
30
+ const BUNDLES: Record<ForgeLocale, ForgeMessages> = {
31
+ en: enMessages as ForgeMessages,
32
+ de: deMessages as ForgeMessages,
33
+ };
34
+
35
+ /**
36
+ * Narrow anything a site might pass ("de", "de-DE", "DE", undefined) to a
37
+ * locale Forge actually has copy for. Anything unrecognised becomes English
38
+ * rather than an error, because a wrong language is a defect and a crashed
39
+ * page is an outage.
40
+ */
41
+ export function resolveForgeLocale(locale?: string | null): ForgeLocale {
42
+ if (!locale) return FORGE_DEFAULT_LOCALE;
43
+ const base = locale.toLowerCase().split(/[-_]/)[0];
44
+ return (FORGE_LOCALES as readonly string[]).includes(base) ? (base as ForgeLocale) : FORGE_DEFAULT_LOCALE;
45
+ }
46
+
47
+ /**
48
+ * Substitute `{name}` placeholders. A placeholder with no matching var is left
49
+ * standing rather than blanked, so a missing value shows up as `{name}` in
50
+ * review instead of disappearing silently into a half-finished sentence.
51
+ */
52
+ function interpolate(template: string, vars?: ForgeTVars): string {
53
+ if (!vars) return template;
54
+ return template.replace(/\{(\w+)\}/g, (match, name: string) =>
55
+ Object.prototype.hasOwnProperty.call(vars, name) ? String(vars[name]) : match,
56
+ );
57
+ }
58
+
59
+ /**
60
+ * Look a key up with the full fallback chain: requested locale, then English,
61
+ * then the key itself. The last step is what guarantees a component never
62
+ * renders blank, and it makes an untranslated string visible in the UI rather
63
+ * than invisible.
64
+ */
65
+ export function translateForge(locale: ForgeLocale, key: string, vars?: ForgeTVars): string {
66
+ const template = BUNDLES[locale]?.[key] ?? BUNDLES[FORGE_DEFAULT_LOCALE][key] ?? key;
67
+ return interpolate(template, vars);
68
+ }
69
+
70
+ /** Read-only access to a bundle, for parity specs and tooling. */
71
+ export function getForgeMessages(locale: ForgeLocale): ForgeMessages {
72
+ return BUNDLES[locale] ?? BUNDLES[FORGE_DEFAULT_LOCALE];
73
+ }
74
+
75
+ // `null` means "no provider above me", which is a supported state, not a bug.
76
+ const ForgeI18nContext = createContext<ForgeLocale | null>(null);
77
+
78
+ export interface ForgeI18nProviderProps {
79
+ /**
80
+ * The site's language. Accepts any BCP-47-ish string and narrows it; absent
81
+ * or unsupported means English.
82
+ */
83
+ locale?: string | null;
84
+ children: ReactNode;
85
+ }
86
+
87
+ /**
88
+ * Puts the site's language in context. Mounted by `<TribeNestApp>`, so a site
89
+ * gains i18n from a Forge upgrade without editing its `__root.tsx`. Mount it
90
+ * yourself only if you hand-compose the provider tree instead of using
91
+ * `TribeNestApp`.
92
+ */
93
+ export function ForgeI18nProvider({ locale, children }: ForgeI18nProviderProps) {
94
+ const resolved = resolveForgeLocale(locale);
95
+ return createElement(ForgeI18nContext.Provider, { value: resolved }, children);
96
+ }
97
+
98
+ /**
99
+ * The locale in effect. Returns the default ("en") outside a provider, so this
100
+ * is safe to call anywhere.
101
+ */
102
+ export function useForgeLocale(): ForgeLocale {
103
+ return useContext(ForgeI18nContext) ?? FORGE_DEFAULT_LOCALE;
104
+ }
105
+
106
+ /**
107
+ * The translate function.
108
+ *
109
+ * const t = useForgeT();
110
+ * return <button>{t("forge.cart.checkout")}</button>;
111
+ * return <p>{t("forge.cart.item_count", { count: items.length })}</p>;
112
+ *
113
+ * Fallback chain: the active locale, then bundled English, then the key itself.
114
+ * It never returns an empty string, so a missing key degrades to something a
115
+ * reviewer can see rather than to a blank space in the page.
116
+ *
117
+ * Outside a `<ForgeI18nProvider>` it returns a working English translator
118
+ * rather than throwing, so a component still renders standalone. This mirrors
119
+ * `useFunnelStep`, which returns a working no-op outside a `<Funnel>` for the
120
+ * same reason.
121
+ */
122
+ export function useForgeT(): ForgeT {
123
+ const locale = useForgeLocale();
124
+ return useMemo<ForgeT>(() => (key, vars) => translateForge(locale, key, vars), [locale]);
125
+ }
package/src/index.ts CHANGED
@@ -19,6 +19,27 @@ export {
19
19
  } from "./provider/ForgeProvider";
20
20
  export { SiteConfigProvider, useInitialSiteConfig } from "./provider/SiteConfigProvider";
21
21
 
22
+ // i18n. Strings are bundled in the package (en + de), never fetched, so there
23
+ // is no loading state and no untranslated first paint. `TribeNestApp` mounts
24
+ // the provider, so a site gets this from a Forge upgrade alone. Outside a
25
+ // provider `useForgeT()` still returns a working English translator.
26
+ // Key naming is fixed by src/i18n/CONVENTION.md.
27
+ export {
28
+ useForgeT,
29
+ useForgeLocale,
30
+ ForgeI18nProvider,
31
+ resolveForgeLocale,
32
+ translateForge,
33
+ getForgeMessages,
34
+ FORGE_LOCALES,
35
+ FORGE_DEFAULT_LOCALE,
36
+ type ForgeT,
37
+ type ForgeTVars,
38
+ type ForgeLocale,
39
+ type ForgeMessages,
40
+ type ForgeI18nProviderProps,
41
+ } from "./i18n";
42
+
22
43
  // All domain types + enums/consts (models) and WebPage.
23
44
  export * from "./types";
24
45
 
@@ -73,7 +73,8 @@ export interface ForgeProviderProps {
73
73
  initialToken?: string | null;
74
74
  /** Editing affordances (the in-place content editor). Off for a live site. */
75
75
  editable?: boolean;
76
- /** Content locale. */
76
+ /** Content locale. Also the language `useForgeT()` renders Forge's own copy
77
+ * in when the tree is wrapped by `TribeNestApp`. Absent means "en". */
77
78
  locale?: string;
78
79
  /** SSR content document (from `fetchContentDocument`). Defaults to empty. */
79
80
  initialContentDoc?: ContentDocument;
@@ -1,5 +1,6 @@
1
1
  import { createContext, useContext, type ReactNode } from "react";
2
2
  import type { SiteConfig } from "../data/queries/useWebsite";
3
+ import { setSiteLocale } from "../client/activeLocale";
3
4
 
4
5
  /**
5
6
  * Holds an SSR-fetched site config so `useSiteConfig()` can seed React Query
@@ -18,6 +19,16 @@ export function SiteConfigProvider({
18
19
  initialConfig?: SiteConfig | null;
19
20
  children: ReactNode;
20
21
  }) {
22
+ // Publish the tenant's language to the axios client, which sends it as
23
+ // `x-locale` on every request so API messages come back in the language the
24
+ // page is written in. Done here rather than in each site's `__root.tsx` so an
25
+ // existing site gets it from a Forge upgrade alone.
26
+ //
27
+ // During render, not in an effect: an effect runs after the first paint, by
28
+ // which time the queries on the first screen have already gone out without the
29
+ // header. The write is an idempotent assignment of a value that is constant for
30
+ // the deploy, so repeating it (StrictMode, every SSR request) changes nothing.
31
+ setSiteLocale(initialConfig?.locale);
21
32
  return <SiteConfigContext.Provider value={initialConfig ?? undefined}>{children}</SiteConfigContext.Provider>;
22
33
  }
23
34
 
@@ -0,0 +1,291 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import {
3
+ partitionResumeLines,
4
+ resumeLineToCartItem,
5
+ resumeLinesToTicketItems,
6
+ effectiveUnitCents,
7
+ type ResumeLine,
8
+ } from "../useCartResume";
9
+ import { rememberResumedCoupon, takeResumedCoupon } from "../resumedCoupon";
10
+ import { ProductDeliveryType } from "../../../../types/models";
11
+
12
+ /**
13
+ * The translation from a resumed line to a cart line, which is where a recovery
14
+ * link either restores the basket the buyer left or a subtly different one.
15
+ *
16
+ * Pure functions, so these run in the package's existing node environment with
17
+ * no jsdom and no network.
18
+ *
19
+ * The hook AROUND them lives in `useCartResume.spec.tsx`, in jsdom, where the
20
+ * exchange and the cart are both stubbed: which lines reach `restoreCart`, and
21
+ * when, is a question effects answer and a pure function cannot. What the two
22
+ * storefronts then DRAW is `ResumeCart.spec.tsx` here and `resume.spec.tsx` in
23
+ * `apps/client`. (This docblock previously claimed the hook was covered "end to
24
+ * end by the backend spec plus the two storefront pages", and none of those
25
+ * three existed.)
26
+ */
27
+
28
+ const productLine = (overrides: Partial<ResumeLine> = {}): ResumeLine => ({
29
+ type: "product",
30
+ quantity: 2,
31
+ title: "Tour Tee",
32
+ coverImage: "https://cdn.test/tee.jpg",
33
+ price: 25,
34
+ unitPriceCents: 2500,
35
+ quotedUnitPriceCents: 2000,
36
+ priceChanged: true,
37
+ available: true,
38
+ unavailableReason: null,
39
+ productId: "prod-1",
40
+ productVariantId: "var-1",
41
+ deliveryType: ProductDeliveryType.Physical,
42
+ payWhatYouWant: false,
43
+ options: [{ axis: "Size", value: "L", swatchHex: null }],
44
+ isGift: false,
45
+ ...overrides,
46
+ });
47
+
48
+ const ticketLine = (overrides: Partial<ResumeLine> = {}): ResumeLine => ({
49
+ type: "event_ticket",
50
+ quantity: 3,
51
+ title: "General Admission",
52
+ coverImage: null,
53
+ price: 25,
54
+ unitPriceCents: 2500,
55
+ quotedUnitPriceCents: 2500,
56
+ priceChanged: false,
57
+ available: true,
58
+ unavailableReason: null,
59
+ eventId: "event-1",
60
+ ticketId: "tier-1",
61
+ eventSlug: "spring-tour",
62
+ eventTitle: "Spring Tour",
63
+ pwywAmount: null,
64
+ bookingFee: null,
65
+ ...overrides,
66
+ });
67
+
68
+ describe("resumeLineToCartItem", () => {
69
+ it("should carry the delivery type through rather than defaulting it", () => {
70
+ // The whole reason the payload has the field: a physical line restored as
71
+ // non-physical means checkout never asks where to send the record, and a
72
+ // missing address is not recoverable after payment.
73
+ const item = resumeLineToCartItem(productLine());
74
+ expect(item?.deliveryType).toBe(ProductDeliveryType.Physical);
75
+ });
76
+
77
+ it("should restore the quantity the buyer chose, not one", () => {
78
+ expect(resumeLineToCartItem(productLine({ quantity: 4 }))?.quantity).toBe(4);
79
+ });
80
+
81
+ it("should quote today's price, never the one the buyer was shown", () => {
82
+ const item = resumeLineToCartItem(productLine({ price: 25, quotedUnitPriceCents: 2000 }));
83
+ expect(item?.price).toBe(25);
84
+ });
85
+
86
+ it("should keep a gift a gift, with its recipient", () => {
87
+ const item = resumeLineToCartItem(
88
+ productLine({
89
+ isGift: true,
90
+ recipientName: "Ada",
91
+ recipientEmail: "ada@test.com",
92
+ recipientMessage: "Happy birthday",
93
+ }),
94
+ );
95
+ expect(item).toMatchObject({
96
+ isGift: true,
97
+ recipientName: "Ada",
98
+ recipientEmail: "ada@test.com",
99
+ recipientMessage: "Happy birthday",
100
+ });
101
+ });
102
+
103
+ it("should keep an add-on bound to the event it hangs off", () => {
104
+ // Without this the rebuilt bundle has an add-on with no base and the server
105
+ // refuses the whole checkout, with nothing on screen explaining why.
106
+ const item = resumeLineToCartItem(
107
+ productLine({ attachedTo: { type: "event", entityId: "event-1" } }),
108
+ );
109
+ expect(item?.attachedTo).toEqual({ type: "event", entityId: "event-1" });
110
+ });
111
+
112
+ it("should carry the chosen variant's options so the cart can name it", () => {
113
+ expect(resumeLineToCartItem(productLine())?.options).toEqual([
114
+ { axis: "Size", value: "L", swatchHex: null },
115
+ ]);
116
+ });
117
+
118
+ it("should refuse a line with no variant rather than invent one", () => {
119
+ expect(resumeLineToCartItem(productLine({ productVariantId: undefined }))).toBeNull();
120
+ expect(resumeLineToCartItem(productLine({ productId: undefined }))).toBeNull();
121
+ });
122
+ });
123
+
124
+ describe("resumeLinesToTicketItems", () => {
125
+ it("should group tiers of one event into a single selection", () => {
126
+ const items = resumeLinesToTicketItems([
127
+ ticketLine({ ticketId: "tier-1", quantity: 2, title: "GA" }),
128
+ ticketLine({ ticketId: "tier-2", quantity: 1, title: "VIP", price: 80, unitPriceCents: 8000 }),
129
+ ]);
130
+
131
+ expect(items).toHaveLength(1);
132
+ expect(items[0]).toMatchObject({
133
+ eventId: "event-1",
134
+ eventSlug: "spring-tour",
135
+ eventTitle: "Spring Tour",
136
+ tickets: { "tier-1": 2, "tier-2": 1 },
137
+ });
138
+ expect(items[0].ticketMeta["tier-2"]).toMatchObject({ title: "VIP", price: 80 });
139
+ });
140
+
141
+ it("should keep two events apart", () => {
142
+ const items = resumeLinesToTicketItems([
143
+ ticketLine({ eventId: "event-1" }),
144
+ ticketLine({ eventId: "event-2", eventSlug: "autumn", eventTitle: "Autumn" }),
145
+ ]);
146
+ expect(items.map((item) => item.eventId)).toEqual(["event-1", "event-2"]);
147
+ });
148
+
149
+ it("should show a pay-what-you-want tier the buyer's own amount, and send it", () => {
150
+ // The bundle endpoint treats `price` as display-only and charges
151
+ // `pwywAmount`. Dropping the second field would silently undercharge to the
152
+ // tier's floor and still answer 200.
153
+ const items = resumeLinesToTicketItems([ticketLine({ price: 10, pwywAmount: 42 })]);
154
+ expect(items[0].ticketMeta["tier-1"]).toMatchObject({ price: 42, pwywAmount: 42 });
155
+ });
156
+
157
+ it("should carry the event's booking fee so the estimate matches the charge", () => {
158
+ const fee = { feeAmount: 1.5, feeBps: 250, currency: "USD", minorUnitFactor: 100 };
159
+ expect(resumeLinesToTicketItems([ticketLine({ bookingFee: fee })])[0].bookingFee).toEqual(fee);
160
+ });
161
+
162
+ it("should skip a line with no tier id", () => {
163
+ expect(resumeLinesToTicketItems([ticketLine({ ticketId: undefined })])).toEqual([]);
164
+ });
165
+ });
166
+
167
+ describe("partitionResumeLines", () => {
168
+ const courseLine = (): ResumeLine => ({
169
+ ...productLine(),
170
+ type: "course",
171
+ title: "Songwriting",
172
+ courseSlug: "songwriting",
173
+ });
174
+
175
+ it("sends a course onward rather than into the basket", () => {
176
+ const parts = partitionResumeLines([productLine(), courseLine()]);
177
+ expect(parts.restoredLines.map((line) => line.title)).toEqual(["Tour Tee"]);
178
+ expect(parts.continueLines.map((line) => line.title)).toEqual(["Songwriting"]);
179
+ });
180
+
181
+ it("keeps every line the server refused, with its reason intact", () => {
182
+ const gone = productLine({ available: false, unavailableReason: "removed", title: "Vinyl" });
183
+ const parts = partitionResumeLines([productLine(), gone]);
184
+ expect(parts.unavailableLines).toEqual([gone]);
185
+ expect(parts.restoredLines.map((line) => line.title)).toEqual(["Tour Tee"]);
186
+ });
187
+
188
+ it("REGRESSION: drops an add-on whose base ticket is not being restored", () => {
189
+ // The server refuses a bundle whose add-on has no base, so restoring it
190
+ // alone builds a basket that can only fail at the payment screen.
191
+ const poster = productLine({
192
+ title: "Tour poster",
193
+ attachedTo: { type: "event", entityId: "event-1" },
194
+ });
195
+ const parts = partitionResumeLines([
196
+ ticketLine({ available: false, unavailableReason: "sold_out" }),
197
+ poster,
198
+ ]);
199
+
200
+ expect(parts.restoredLines).toEqual([]);
201
+ expect(parts.droppedLines).toEqual([poster]);
202
+ });
203
+
204
+ it("keeps the add-on when its base ticket is restored", () => {
205
+ const poster = productLine({ attachedTo: { type: "event", entityId: "event-1" } });
206
+ const parts = partitionResumeLines([ticketLine(), poster]);
207
+ expect(parts.droppedLines).toEqual([]);
208
+ expect(parts.restoredLines).toContain(poster);
209
+ });
210
+
211
+ it("counts only the event the add-on names as its base", () => {
212
+ const poster = productLine({ attachedTo: { type: "event", entityId: "event-2" } });
213
+ const parts = partitionResumeLines([ticketLine({ eventId: "event-1" }), poster]);
214
+ expect(parts.droppedLines).toEqual([poster]);
215
+ });
216
+
217
+ it("does not accept a ticket line the cart would skip as a base", () => {
218
+ // `resumeLinesToTicketItems` needs both ids and silently skips a line
219
+ // missing either, so such a line is not a base that will exist.
220
+ const poster = productLine({ attachedTo: { type: "event", entityId: "event-1" } });
221
+ const parts = partitionResumeLines([ticketLine({ ticketId: undefined }), poster]);
222
+ expect(parts.droppedLines).toEqual([poster]);
223
+ });
224
+
225
+ it("leaves a product attached to nothing alone", () => {
226
+ // Attachment describes how a line was ADDED, not what the product is: the
227
+ // same poster bought from the shop sells on its own as always.
228
+ const parts = partitionResumeLines([productLine({ attachedTo: null })]);
229
+ expect(parts.droppedLines).toEqual([]);
230
+ expect(parts.restoredLines).toHaveLength(1);
231
+ });
232
+ });
233
+
234
+ describe("effectiveUnitCents", () => {
235
+ it("should use the catalogue price when nothing was chosen", () => {
236
+ expect(effectiveUnitCents(ticketLine())).toBe(2500);
237
+ });
238
+
239
+ it("should use the chosen amount on a pay-what-you-want tier", () => {
240
+ expect(effectiveUnitCents(ticketLine({ pwywAmount: 42 }))).toBe(4200);
241
+ });
242
+
243
+ it("should treat a chosen zero as a choice, not as absent", () => {
244
+ expect(effectiveUnitCents(ticketLine({ pwywAmount: 0 }))).toBe(0);
245
+ });
246
+ });
247
+
248
+ describe("the resumed coupon stash", () => {
249
+ const store = new Map<string, string>();
250
+
251
+ beforeEach(() => {
252
+ store.clear();
253
+ (globalThis as { window?: unknown }).window = {
254
+ sessionStorage: {
255
+ getItem: (key: string) => store.get(key) ?? null,
256
+ setItem: (key: string, value: string) => void store.set(key, value),
257
+ removeItem: (key: string) => void store.delete(key),
258
+ },
259
+ };
260
+ rememberResumedCoupon(null);
261
+ });
262
+
263
+ afterEach(() => {
264
+ delete (globalThis as { window?: unknown }).window;
265
+ });
266
+
267
+ it("should hand the code to the checkout page once", () => {
268
+ rememberResumedCoupon("SUMMER10");
269
+ expect(takeResumedCoupon()).toBe("SUMMER10");
270
+ expect(store.has("tribenest-resumed-coupon")).toBe(false);
271
+ });
272
+
273
+ it("should survive a second read in the same page load", () => {
274
+ // React StrictMode invokes a lazy `useState` initialiser twice in
275
+ // development. Without the memo the field would come up blank on exactly
276
+ // the builds a developer tests against.
277
+ rememberResumedCoupon("SUMMER10");
278
+ expect(takeResumedCoupon()).toBe("SUMMER10");
279
+ expect(takeResumedCoupon()).toBe("SUMMER10");
280
+ });
281
+
282
+ it("should answer empty when no link was followed", () => {
283
+ expect(takeResumedCoupon()).toBe("");
284
+ });
285
+
286
+ it("should forget a code when the next resume carries none", () => {
287
+ rememberResumedCoupon("SUMMER10");
288
+ rememberResumedCoupon(null);
289
+ expect(takeResumedCoupon()).toBe("");
290
+ });
291
+ });