@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.
- package/package.json +1 -1
- package/src/data/queries/_tests/passTransfers.spec.ts +100 -4
- package/src/data/queries/useEvents.ts +66 -4
- package/src/data/queries/useMembership.ts +8 -2
- package/src/data/queries/useMyBookings.ts +12 -0
- package/src/data/queries/useMyTickets.ts +112 -0
- package/src/data/queries/useOrders.ts +10 -0
- package/src/data/queries/usePassTransfers.ts +73 -13
- package/src/server/index.ts +52 -0
- package/src/types/models.ts +151 -0
- package/src/ui/format/_tests/attendees.spec.ts +231 -0
- package/src/ui/format/_tests/membershipGate.spec.ts +220 -0
- package/src/ui/format/attendees.ts +187 -0
- package/src/ui/format/membershipGate.ts +209 -0
- package/src/ui/headless/calendar/_tests/useAddToCalendar.spec.ts +83 -0
- package/src/ui/headless/calendar/useAddToCalendar.ts +46 -5
- package/src/ui/headless/checkout/_tests/inventoryHold.spec.ts +111 -0
- package/src/ui/headless/checkout/inventoryHold.ts +83 -0
- package/src/ui/headless/checkout/useCheckout.ts +72 -0
- package/src/ui/headless/checkout/useInventoryHold.ts +104 -0
- package/src/ui/headless/event/useEventCheckout.ts +133 -2
- package/src/ui/headless/event/usePresaleCode.ts +181 -0
- package/src/ui/headless/index.ts +25 -0
- package/src/ui/headless/membership/useMembershipGateNotice.ts +83 -0
- package/src/ui/headless/offer/OfferContext.tsx +55 -0
- package/src/ui/index.ts +42 -0
- package/src/ui/styled/AccountDashboard.tsx +70 -8
- package/src/ui/styled/AddToCalendar.tsx +34 -10
- package/src/ui/styled/Checkout.tsx +18 -1
- package/src/ui/styled/CoachingConfirmation.tsx +4 -0
- package/src/ui/styled/CourseDetail.tsx +30 -1
- package/src/ui/styled/EventConfirmation.tsx +2 -0
- package/src/ui/styled/EventDetail.tsx +53 -22
- package/src/ui/styled/EventTickets.tsx +156 -5
- package/src/ui/styled/HoldNotice.tsx +192 -0
- package/src/ui/styled/MembershipGateNotice.tsx +159 -0
- package/src/ui/styled/OfferButton.tsx +23 -0
- package/src/ui/styled/PresaleCode.tsx +174 -0
- package/src/ui/styled/ProductDetail.tsx +75 -5
- package/src/ui/styled/ProductGrid.tsx +26 -0
- package/src/ui/styled/TicketTransfer.tsx +69 -40
- package/src/ui/styled/_tests/AddToCalendar.spec.tsx +88 -0
- package/src/ui/styled/_tests/EventConfirmation.spec.tsx +5 -1
- package/src/ui/styled/_tests/PresaleCode.spec.tsx +106 -0
- package/src/utils/_tests/presaleCode.spec.ts +168 -0
- package/src/utils/_tests/structuredData.spec.ts +275 -0
- package/src/utils/presaleCode.ts +96 -0
- 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
|
-
/**
|
|
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
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
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
|
|
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
|
-
{/*
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
Your QR code keeps working until they claim it.
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
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'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
|
+
});
|