@tribe-nest/forge 3.21.0 → 3.22.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 (48) hide show
  1. package/package.json +1 -1
  2. package/src/data/queries/_tests/passTransfers.spec.ts +100 -4
  3. package/src/data/queries/useEvents.ts +66 -4
  4. package/src/data/queries/useMembership.ts +8 -2
  5. package/src/data/queries/useMyBookings.ts +12 -0
  6. package/src/data/queries/useMyTickets.ts +112 -0
  7. package/src/data/queries/useOrders.ts +10 -0
  8. package/src/data/queries/usePassTransfers.ts +73 -13
  9. package/src/server/index.ts +52 -0
  10. package/src/types/models.ts +151 -0
  11. package/src/ui/format/_tests/attendees.spec.ts +231 -0
  12. package/src/ui/format/_tests/membershipGate.spec.ts +220 -0
  13. package/src/ui/format/attendees.ts +187 -0
  14. package/src/ui/format/membershipGate.ts +209 -0
  15. package/src/ui/headless/calendar/_tests/useAddToCalendar.spec.ts +83 -0
  16. package/src/ui/headless/calendar/useAddToCalendar.ts +46 -5
  17. package/src/ui/headless/checkout/_tests/inventoryHold.spec.ts +111 -0
  18. package/src/ui/headless/checkout/inventoryHold.ts +83 -0
  19. package/src/ui/headless/checkout/useCheckout.ts +72 -0
  20. package/src/ui/headless/checkout/useInventoryHold.ts +104 -0
  21. package/src/ui/headless/event/useEventCheckout.ts +133 -2
  22. package/src/ui/headless/event/usePresaleCode.ts +181 -0
  23. package/src/ui/headless/index.ts +25 -0
  24. package/src/ui/headless/membership/useMembershipGateNotice.ts +83 -0
  25. package/src/ui/headless/offer/OfferContext.tsx +55 -0
  26. package/src/ui/index.ts +42 -0
  27. package/src/ui/styled/AccountDashboard.tsx +70 -8
  28. package/src/ui/styled/AddToCalendar.tsx +34 -10
  29. package/src/ui/styled/Checkout.tsx +18 -1
  30. package/src/ui/styled/CoachingConfirmation.tsx +4 -0
  31. package/src/ui/styled/CourseDetail.tsx +30 -1
  32. package/src/ui/styled/EventConfirmation.tsx +2 -0
  33. package/src/ui/styled/EventDetail.tsx +53 -22
  34. package/src/ui/styled/EventTickets.tsx +156 -5
  35. package/src/ui/styled/HoldNotice.tsx +192 -0
  36. package/src/ui/styled/MembershipGateNotice.tsx +159 -0
  37. package/src/ui/styled/OfferButton.tsx +23 -0
  38. package/src/ui/styled/PresaleCode.tsx +174 -0
  39. package/src/ui/styled/ProductDetail.tsx +75 -5
  40. package/src/ui/styled/ProductGrid.tsx +26 -0
  41. package/src/ui/styled/TicketTransfer.tsx +69 -40
  42. package/src/ui/styled/_tests/AddToCalendar.spec.tsx +88 -0
  43. package/src/ui/styled/_tests/EventConfirmation.spec.tsx +5 -1
  44. package/src/ui/styled/_tests/PresaleCode.spec.tsx +106 -0
  45. package/src/utils/_tests/presaleCode.spec.ts +168 -0
  46. package/src/utils/_tests/structuredData.spec.ts +275 -0
  47. package/src/utils/presaleCode.ts +96 -0
  48. package/src/utils/structuredData.ts +361 -27
@@ -4,8 +4,10 @@ import {
4
4
  useCancelPassTransfer,
5
5
  useClaimPassTransfer,
6
6
  usePendingPassTransfers,
7
+ mergePendingTransfer,
7
8
  type PassTransfer,
8
9
  } from "../../data/queries/usePassTransfers";
10
+ import type { MyTicketPass, MyTicketPendingTransfer } from "../../data/queries/useMyTickets";
9
11
  import { useThemeTokens, type ResolvedThemeTokens } from "../theme/ForgeThemeProvider";
10
12
 
11
13
  /**
@@ -89,8 +91,24 @@ const formatWhen = (value: string | null | undefined): string | null => {
89
91
  // ── The holder's side ───────────────────────────────────────────────────────
90
92
 
91
93
  export interface TicketTransferPanelProps {
92
- /** The `TN-…` pass ids on this order — `myTicketPassIds(ticket)`. */
94
+ /**
95
+ * The `TN-…` passes to draw a control for.
96
+ *
97
+ * Pass `myTicketHeldPassIds(ticket)`, not `myTicketPassIds(ticket)`: every
98
+ * pass-scoped endpoint resolves the current HOLDER, so a row drawn on a
99
+ * named guest's seat or on a ticket already claimed by somebody else could
100
+ * only ever answer 404.
101
+ */
93
102
  passIds: string[];
103
+ /**
104
+ * The pass OBJECTS, when the caller has them — `myTicketPasses(ticket)`.
105
+ *
106
+ * Only used to read each pass's `pendingTransfer`, which is how a sender sees
107
+ * and withdraws a transfer they started on ANOTHER DEVICE. Optional so a host
108
+ * on an older API build still gets the panel, falling back to this browser's
109
+ * own local record.
110
+ */
111
+ passes?: MyTicketPass[];
94
112
  /** Extra class(es) on the root element. */
95
113
  className?: string;
96
114
  /** Inline style merged LAST into the root element. */
@@ -105,19 +123,21 @@ export interface TicketTransferPanelProps {
105
123
  * does not emit them cannot address any pass-scoped endpoint, so an affordance
106
124
  * would only ever 404.
107
125
  *
108
- * The pending list comes from this browser's local record of its own sends,
109
- * because the transfer HISTORY endpoint is operator-only by design (it names
110
- * every previous holder) and nothing on `my-tickets` carries transfer state.
111
- * That limit is stated to the buyer rather than hidden: the recipient's email is
112
- * the durable copy.
126
+ * The pending transfer comes from the SERVER `pendingTransfer` on each pass,
127
+ * narrowed by the API to a row this reader sent and still `pending`. That is
128
+ * what makes "withdraw" work for a sender who has since signed in somewhere
129
+ * else; the transfer HISTORY endpoint stays operator-only by design, because it
130
+ * names every previous holder. This browser's local record is kept only as a
131
+ * bridge across the refetch that follows a send.
113
132
  */
114
- export function TicketTransferPanel({ passIds, className, style }: TicketTransferPanelProps) {
133
+ export function TicketTransferPanel({ passIds, passes, className, style }: TicketTransferPanelProps) {
115
134
  const t = useThemeTokens();
116
135
  const [openFor, setOpenFor] = useState<string | null>(null);
117
136
 
118
137
  if (passIds.length === 0) return null;
119
138
 
120
139
  const single = passIds.length === 1;
140
+ const byId = new Map((passes ?? []).map((pass) => [pass.id, pass]));
121
141
 
122
142
  return (
123
143
  <div
@@ -129,6 +149,7 @@ export function TicketTransferPanel({ passIds, className, style }: TicketTransfe
129
149
  <TicketTransferRow
130
150
  key={passId}
131
151
  passId={passId}
152
+ serverTransfer={byId.get(passId)?.pendingTransfer ?? null}
132
153
  t={t}
133
154
  showPassId={!single}
134
155
  isOpen={openFor === passId}
@@ -141,12 +162,14 @@ export function TicketTransferPanel({ passIds, className, style }: TicketTransfe
141
162
 
142
163
  function TicketTransferRow({
143
164
  passId,
165
+ serverTransfer,
144
166
  t,
145
167
  showPassId,
146
168
  isOpen,
147
169
  onToggle,
148
170
  }: {
149
171
  passId: string;
172
+ serverTransfer: MyTicketPendingTransfer | null;
150
173
  t: ResolvedThemeTokens;
151
174
  showPassId: boolean;
152
175
  isOpen: boolean;
@@ -154,7 +177,10 @@ function TicketTransferRow({
154
177
  }) {
155
178
  const send = useSendPassTransfer();
156
179
  const cancel = useCancelPassTransfer();
157
- const pending = usePendingPassTransfers(passId);
180
+ const remembered = usePendingPassTransfers(passId);
181
+ // One live transfer per pass is a DATABASE guarantee, so exactly one row may
182
+ // ever be drawn. The server's copy wins whenever it has one.
183
+ const pending = mergePendingTransfer(serverTransfer, remembered);
158
184
 
159
185
  const [toEmail, setToEmail] = useState("");
160
186
  const [toName, setToName] = useState("");
@@ -207,39 +233,42 @@ function TicketTransferRow({
207
233
  </button>
208
234
  </div>
209
235
 
210
- {/* A live transfer this browser sent. One per pass is a database
211
- guarantee, so this is a list only because the store is one. */}
212
- {pending.map((item) => {
213
- const expires = formatWhen(item.expiresAt);
214
- return (
215
- <div
216
- key={item.transferId}
217
- style={{
218
- marginTop: 12,
219
- padding: 12,
220
- borderRadius: t.cornerRadius,
221
- border: `1px solid ${t.primary}4d`,
222
- fontSize: 14,
223
- }}
224
- >
225
- <div>
226
- Sent to <strong>{item.toEmail}</strong>
227
- {expires ? ` — the link works until ${expires}.` : "."}
228
- </div>
229
- <div style={{ fontSize: 13, color: t.muted, marginTop: 4 }}>
230
- Your QR code keeps working until they claim it.
231
- </div>
232
- <button
233
- type="button"
234
- onClick={() => void onCancel(item.transferId)}
235
- disabled={cancel.isPending}
236
- style={{ ...ghostButton(t, cancel.isPending), marginTop: 10 }}
237
- >
238
- {cancel.isPending ? "Withdrawing…" : "Withdraw transfer"}
239
- </button>
236
+ {/* The live transfer out on this pass, from whichever device sent it. */}
237
+ {pending && (
238
+ <div
239
+ data-testid="pending-pass-transfer"
240
+ style={{
241
+ marginTop: 12,
242
+ padding: 12,
243
+ borderRadius: t.cornerRadius,
244
+ border: `1px solid ${t.primary}4d`,
245
+ fontSize: 14,
246
+ }}
247
+ >
248
+ <div>
249
+ Sent to <strong>{pending.toEmail}</strong>
250
+ {formatWhen(pending.expiresAt) && pending.open
251
+ ? ` — the link works until ${formatWhen(pending.expiresAt)}.`
252
+ : "."}
253
+ </div>
254
+ <div style={{ fontSize: 13, color: t.muted, marginTop: 4 }}>
255
+ {pending.open
256
+ ? "Your QR code keeps working until they claim it."
257
+ : /* Lapsed but not yet swept. Still `pending`, still withdrawable —
258
+ hiding it would leave the sender with a live row and no way to
259
+ address it. */
260
+ "That link has expired. Withdraw it to send this ticket to someone else."}
240
261
  </div>
241
- );
242
- })}
262
+ <button
263
+ type="button"
264
+ onClick={() => void onCancel(pending.transferId)}
265
+ disabled={cancel.isPending}
266
+ style={{ ...ghostButton(t, cancel.isPending), marginTop: 10 }}
267
+ >
268
+ {cancel.isPending ? "Withdrawing…" : "Withdraw transfer"}
269
+ </button>
270
+ </div>
271
+ )}
243
272
 
244
273
  {sent && (
245
274
  <p style={{ fontSize: 13, marginTop: 10 }}>
@@ -0,0 +1,88 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { renderToStaticMarkup } from "react-dom/server";
3
+ import { ForgeThemeProvider } from "../../theme/ForgeThemeProvider";
4
+ import { AddToCalendar } from "../AddToCalendar";
5
+
6
+ /**
7
+ * Rendered through `react-dom/server`, which needs no DOM — same approach as
8
+ * `EventConfirmation.spec.tsx`.
9
+ *
10
+ * What this guards is the anchor ATTRIBUTES, because they are what actually
11
+ * decides whether an iPhone opens Calendar. A `webcal:` link with
12
+ * `target="_blank"` leaves a blank tab behind and, on several browsers, never
13
+ * reaches the OS handler at all; one with `download` asks to save a file from a
14
+ * scheme that has no file. Both are easy to add back by reflex, and neither
15
+ * failure is visible from a desktop.
16
+ */
17
+
18
+ const THEME = {
19
+ colors: { text: "#111111", background: "#ffffff", primary: "#6d28d9" },
20
+ cornerRadius: 8,
21
+ };
22
+
23
+ const render = (node: React.ReactNode) =>
24
+ renderToStaticMarkup(<ForgeThemeProvider theme={THEME}>{node}</ForgeThemeProvider>);
25
+
26
+ const ICS = "https://api.tribenest.co/public/calendar/events/abc/tok.ics";
27
+ const WEBCAL = "webcal://api.tribenest.co/public/calendar/events/abc/tok.ics";
28
+
29
+ const base = { title: "Night Show", start: "2026-03-15T20:00:00.000Z" };
30
+
31
+ describe("<AddToCalendar>", () => {
32
+ it("draws only the three template providers when no .ics is supplied", () => {
33
+ const html = render(<AddToCalendar {...base} />);
34
+
35
+ expect(html).toContain("add-to-calendar-google");
36
+ expect(html).toContain("add-to-calendar-outlook");
37
+ expect(html).toContain("add-to-calendar-office365");
38
+ // The pre-existing behaviour, preserved for a site on an older API build:
39
+ // no Apple chip rather than a dead one.
40
+ expect(html).not.toContain("add-to-calendar-apple");
41
+ expect(html).not.toContain("add-to-calendar-ics");
42
+ });
43
+
44
+ it("adds Apple Calendar and a download once the host has the address", () => {
45
+ const html = render(<AddToCalendar {...base} icsUrl={ICS} webcalUrl={WEBCAL} />);
46
+
47
+ expect(html).toContain("add-to-calendar-apple");
48
+ expect(html).toContain(`href="${WEBCAL}"`);
49
+ expect(html).toContain("Apple Calendar");
50
+
51
+ expect(html).toContain("add-to-calendar-ics");
52
+ expect(html).toContain("Download .ics");
53
+ });
54
+
55
+ it("the Apple anchor is a plain same-window navigation", () => {
56
+ const html = render(<AddToCalendar {...base} icsUrl={ICS} webcalUrl={WEBCAL} />);
57
+
58
+ // Isolate the Apple anchor so the assertions cannot be satisfied by the
59
+ // Google one sitting next to it.
60
+ const apple = html.slice(html.indexOf('data-testid="add-to-calendar-apple"'));
61
+ const anchor = apple.slice(0, apple.indexOf(">"));
62
+
63
+ expect(anchor).not.toContain('target="_blank"');
64
+ expect(anchor).not.toContain("download");
65
+ });
66
+
67
+ it("the .ics anchor DOES ask to download", () => {
68
+ const html = render(<AddToCalendar {...base} icsUrl={ICS} webcalUrl={WEBCAL} />);
69
+ const ics = html.slice(html.indexOf('data-testid="add-to-calendar-ics"'));
70
+ expect(ics.slice(0, ics.indexOf(">"))).toContain("download");
71
+ });
72
+
73
+ it("still offers Apple when only the https form is passed", () => {
74
+ // The webcal address is derived; a host that only forwards `calendarUrl`
75
+ // must not lose the one option iOS can use.
76
+ const html = render(<AddToCalendar {...base} icsUrl={ICS} />);
77
+ expect(html).toContain(`href="${WEBCAL}"`);
78
+ });
79
+
80
+ it("renders nothing at all for an unusable start time", () => {
81
+ // The theme provider still emits its `:root` variables, so the assertion is
82
+ // that no ANCHOR was drawn — a chip pointing at a garbage prefill is worse
83
+ // than no chip.
84
+ const html = render(<AddToCalendar title="X" start="not-a-date" icsUrl={ICS} webcalUrl={WEBCAL} />);
85
+ expect(html).not.toContain("<a ");
86
+ expect(html).not.toContain("add-to-calendar");
87
+ });
88
+ });
@@ -38,7 +38,11 @@ const baseOrder: ITicketOrder = {
38
38
  function render(order: ITicketOrder) {
39
39
  const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
40
40
  queryClient.setQueryData(["eventTicketOrderStatus", PROFILE_ID, ORDER_ID], order);
41
- queryClient.setQueryData(["event", EVENT_ID, PROFILE_ID], {
41
+ // Four-element key: `useEvent` gained a presale `accessCode` segment, which
42
+ // is `null` for an ordinary read. Seeding the old three-element key left
43
+ // `event` undefined, which silently dropped the add-to-calendar block — the
44
+ // assertion below is what caught it.
45
+ queryClient.setQueryData(["event", EVENT_ID, PROFILE_ID, null], {
42
46
  id: EVENT_ID,
43
47
  title: "Midnight Set",
44
48
  description: "A set",
@@ -0,0 +1,106 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { renderToStaticMarkup } from "react-dom/server";
3
+ import { ForgeThemeProvider } from "../../theme/ForgeThemeProvider";
4
+ import { PresaleCodeField } from "../PresaleCode";
5
+ import type { PresaleCodeField as PresaleCodeState } from "../../headless/event/usePresaleCode";
6
+
7
+ /**
8
+ * Events 1.2 — the box a buyer types a presale code into.
9
+ *
10
+ * Rendered through `react-dom/server`, which needs no DOM, so these run in the
11
+ * package's existing node vitest environment. Effects never fire under
12
+ * `renderToStaticMarkup`, so the state fixture is the only data source and
13
+ * nothing reaches the network.
14
+ *
15
+ * This is the REAL component the Forge storefront renders, and the client app's
16
+ * own copy is a presentational twin driven by the same `usePresaleCode` state —
17
+ * so what is asserted here about WHAT IS SAID holds on both rendering stacks.
18
+ */
19
+
20
+ function state(overrides: Partial<PresaleCodeState> = {}): PresaleCodeState {
21
+ return {
22
+ event: undefined,
23
+ isLoading: false,
24
+ visible: true,
25
+ input: "",
26
+ setInput: () => {},
27
+ code: null,
28
+ status: "idle",
29
+ message: null,
30
+ submit: () => {},
31
+ clear: () => {},
32
+ isUnlocked: false,
33
+ ...overrides,
34
+ };
35
+ }
36
+
37
+ const theme = { colors: { text: "#111111", background: "#ffffff", primary: "#6d28d9" }, cornerRadius: 8 };
38
+
39
+ const render = (presale: PresaleCodeState) =>
40
+ renderToStaticMarkup(
41
+ <ForgeThemeProvider theme={theme}>
42
+ <PresaleCodeField presale={presale} />
43
+ </ForgeThemeProvider>,
44
+ );
45
+
46
+ describe("PresaleCodeField", () => {
47
+ it("REGRESSION: renders NOTHING on an event with no coded tier", () => {
48
+ // The whole reason `hasCodedTiers` is a server fact. A code box on an
49
+ // ordinary event tells a buyer there is a secret they are missing and gives
50
+ // them no way to discover there isn't one.
51
+ const html = render(state({ visible: false }));
52
+ // The provider still emits its :root variables; nothing of the FIELD is there.
53
+ expect(html).not.toContain("presale-code");
54
+ expect(html).not.toContain("presale code");
55
+ });
56
+
57
+ it("offers the prompt and an input when a presale exists", () => {
58
+ const html = render(state());
59
+ expect(html).toContain("Have a presale code?");
60
+ expect(html).toContain('data-testid="presale-code-input"');
61
+ expect(html).toContain("Unlock");
62
+ });
63
+
64
+ it("disables Unlock until something is typed", () => {
65
+ expect(render(state())).toContain("disabled");
66
+ expect(render(state({ input: "PRESALE24" }))).not.toContain("disabled");
67
+ });
68
+
69
+ it("says the code is not valid for the EVENT, naming no tier", () => {
70
+ const html = render(
71
+ state({ code: "GUESS", status: "rejected", message: "That code isn't valid for this event." }),
72
+ );
73
+ expect(html).toContain("That code isn&#x27;t valid for this event.");
74
+ // A refusal that named a tier — "that isn't the VIP code" — would turn the
75
+ // box into a way to enumerate them.
76
+ expect(html.toLowerCase()).not.toContain("tier");
77
+ expect(html).toContain('role="alert"');
78
+ expect(html).toContain('aria-invalid="true"');
79
+ });
80
+
81
+ it("confirms an accepted code, with a way to remove it", () => {
82
+ // Without this, a code for a tier that was already VISIBLE (code-gated but
83
+ // listed) appears to do nothing at all: the tier list is unchanged.
84
+ const html = render(state({ code: "PRESALE24", status: "accepted", isUnlocked: true, message: "Code applied." }));
85
+ expect(html).toContain("Presale unlocked — PRESALE24");
86
+ expect(html).toContain('data-testid="presale-code-remove"');
87
+ // The input is gone — there is nothing left to type.
88
+ expect(html).not.toContain('data-testid="presale-code-input"');
89
+ });
90
+
91
+ it("shows a pending state while the server decides", () => {
92
+ const html = render(state({ code: "PRESALE24", input: "PRESALE24", status: "checking" }));
93
+ expect(html).toContain("Checking…");
94
+ // No verdict yet, so no message either way.
95
+ expect(html).not.toContain('data-testid="presale-code-message"');
96
+ });
97
+
98
+ it("takes its colours from the theme, not a hardcoded palette", () => {
99
+ const html = renderToStaticMarkup(
100
+ <ForgeThemeProvider theme={{ ...theme, colors: { ...theme.colors, primary: "#0f766e" } }}>
101
+ <PresaleCodeField presale={state({ code: "X", status: "accepted", isUnlocked: true })} />
102
+ </ForgeThemeProvider>,
103
+ );
104
+ expect(html).toContain("#0f766e");
105
+ });
106
+ });
@@ -0,0 +1,168 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2
+ import {
3
+ readPresaleCode,
4
+ readPresaleCodeFromUrl,
5
+ subscribePresaleCode,
6
+ writePresaleCode,
7
+ } from "../presaleCode";
8
+
9
+ /**
10
+ * Events 1.2 — where a buyer's presale code lives between typing it and paying
11
+ * with it.
12
+ *
13
+ * This is the part that stops the feature being a toy. The sell guard
14
+ * re-evaluates the code on the ORDER, not just on the read that revealed the
15
+ * tier, so a code that does not survive from the event page to the payment step
16
+ * produces the worst possible outcome: the buyer unlocks a presale, fills in
17
+ * their details, and is refused at the card.
18
+ *
19
+ * The package's vitest environment is `node`, so `window` is stubbed here
20
+ * rather than assumed. That is also the point of the SSR case below — every
21
+ * function has to be callable with no `window` at all, because Forge renders on
22
+ * a Cloudflare Worker first.
23
+ */
24
+
25
+ type Store = Record<string, string>;
26
+
27
+ function stubWindow(initial: Store = {}) {
28
+ const store: Store = { ...initial };
29
+ vi.stubGlobal("window", {
30
+ sessionStorage: {
31
+ getItem: (k: string) => store[k] ?? null,
32
+ setItem: (k: string, v: string) => {
33
+ store[k] = v;
34
+ },
35
+ removeItem: (k: string) => {
36
+ delete store[k];
37
+ },
38
+ },
39
+ location: { href: "https://artist.test/events/the-show" },
40
+ });
41
+ return store;
42
+ }
43
+
44
+ // The module memoises per key, so every test uses a fresh event id.
45
+ let n = 0;
46
+ const key = () => `event-${++n}`;
47
+
48
+ beforeEach(() => stubWindow());
49
+ afterEach(() => vi.unstubAllGlobals());
50
+
51
+ describe("presale code storage", () => {
52
+ it("round-trips a code for one event", () => {
53
+ const k = key();
54
+ writePresaleCode(k, "PRESALE24");
55
+ expect(readPresaleCode(k)).toBe("PRESALE24");
56
+ });
57
+
58
+ it("keeps two events' codes apart", () => {
59
+ // Two presales are two different codes; a shared slot would silently apply
60
+ // one show's code to another.
61
+ const a = key();
62
+ const b = key();
63
+ writePresaleCode(a, "AAA");
64
+ writePresaleCode(b, "BBB");
65
+ expect(readPresaleCode(a)).toBe("AAA");
66
+ expect(readPresaleCode(b)).toBe("BBB");
67
+ });
68
+
69
+ it("survives a reload — the whole reason it is not component state", () => {
70
+ const k = key();
71
+ const store = stubWindow();
72
+ writePresaleCode(k, "PRESALE24");
73
+ expect(store[`tn:presale:${k}`]).toBe("PRESALE24");
74
+
75
+ // A fresh page: same sessionStorage, module memory not yet primed for a key
76
+ // it has never seen.
77
+ const fresh = key();
78
+ stubWindow({ [`tn:presale:${fresh}`]: "FROMSTORAGE" });
79
+ expect(readPresaleCode(fresh)).toBe("FROMSTORAGE");
80
+ });
81
+
82
+ it("trims what the buyer typed", () => {
83
+ const k = key();
84
+ writePresaleCode(k, " presale24 ");
85
+ expect(readPresaleCode(k)).toBe("presale24");
86
+ });
87
+
88
+ it("clearing removes it from storage too, not just from memory", () => {
89
+ const k = key();
90
+ const store = stubWindow();
91
+ writePresaleCode(k, "PRESALE24");
92
+ writePresaleCode(k, null);
93
+ expect(readPresaleCode(k)).toBeNull();
94
+ expect(store[`tn:presale:${k}`]).toBeUndefined();
95
+ });
96
+
97
+ it("an empty or whitespace code clears rather than storing blank", () => {
98
+ const k = key();
99
+ writePresaleCode(k, "PRESALE24");
100
+ writePresaleCode(k, " ");
101
+ expect(readPresaleCode(k)).toBeNull();
102
+ });
103
+
104
+ it("notifies every subscriber — the page and the ticket modal are separate components", () => {
105
+ const k = key();
106
+ const seen: (string | null)[] = [];
107
+ const unsubscribe = subscribePresaleCode(() => seen.push(readPresaleCode(k)));
108
+
109
+ writePresaleCode(k, "PRESALE24");
110
+ writePresaleCode(k, null);
111
+ unsubscribe();
112
+ writePresaleCode(k, "IGNORED");
113
+
114
+ expect(seen).toEqual(["PRESALE24", null]);
115
+ });
116
+
117
+ it("survives storage being unavailable (private mode) without throwing", () => {
118
+ const k = key();
119
+ vi.stubGlobal("window", {
120
+ sessionStorage: {
121
+ getItem: () => {
122
+ throw new Error("denied");
123
+ },
124
+ setItem: () => {
125
+ throw new Error("denied");
126
+ },
127
+ removeItem: () => {
128
+ throw new Error("denied");
129
+ },
130
+ },
131
+ location: { href: "https://artist.test/" },
132
+ });
133
+
134
+ expect(() => writePresaleCode(k, "PRESALE24")).not.toThrow();
135
+ // Still applied for this page view — it just will not survive a reload.
136
+ expect(readPresaleCode(k)).toBe("PRESALE24");
137
+ });
138
+
139
+ it("is inert on the server, where Forge renders first", () => {
140
+ vi.stubGlobal("window", undefined);
141
+ const k = key();
142
+ expect(readPresaleCode(k)).toBeNull();
143
+ expect(readPresaleCodeFromUrl()).toBeNull();
144
+ expect(() => writePresaleCode(k, "PRESALE24")).not.toThrow();
145
+ });
146
+ });
147
+
148
+ describe("presale code on the URL", () => {
149
+ it("reads ?accessCode — what makes a mailed presale link work on arrival", () => {
150
+ vi.stubGlobal("window", {
151
+ sessionStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
152
+ location: { href: "https://artist.test/events/the-show?accessCode=PRESALE24" },
153
+ });
154
+ expect(readPresaleCodeFromUrl()).toBe("PRESALE24");
155
+ });
156
+
157
+ it("is null when the link carries no code", () => {
158
+ expect(readPresaleCodeFromUrl()).toBeNull();
159
+ });
160
+
161
+ it("ignores a blank parameter rather than applying an empty code", () => {
162
+ vi.stubGlobal("window", {
163
+ sessionStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
164
+ location: { href: "https://artist.test/events/the-show?accessCode=%20%20" },
165
+ });
166
+ expect(readPresaleCodeFromUrl()).toBeNull();
167
+ });
168
+ });