@tribe-nest/forge 3.20.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/useAccountSettings.ts +15 -2
- package/src/data/queries/useAuthActions.ts +51 -7
- 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
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
buildCourseSchema,
|
|
4
|
+
buildEntitySchema,
|
|
5
|
+
buildEntityReviewSchema,
|
|
6
|
+
buildProductSchema,
|
|
7
|
+
buildServiceSchema,
|
|
8
|
+
jsonLdScripts,
|
|
9
|
+
type ProductSchemaSource,
|
|
10
|
+
} from "../structuredData";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The regression this file exists for: `buildEntityReviewSchema` used to return
|
|
14
|
+
* `null` whenever an entity had no review aggregate, so a brand-new product —
|
|
15
|
+
* the one that most needs a rich result and a free Shopping listing — emitted
|
|
16
|
+
* no schema.org/Product at all.
|
|
17
|
+
*
|
|
18
|
+
* Reviews must ENRICH the node, never gate it. The mirror-image rule is that an
|
|
19
|
+
* empty or zero `aggregateRating` is a schema violation Google penalises, so
|
|
20
|
+
* "no reviews yet" must be an ABSENT key, not a zero.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const PRODUCT: ProductSchemaSource = {
|
|
24
|
+
title: "Midnight Pressing (Vinyl)",
|
|
25
|
+
description: "<p>Limited <strong>180g</strong> pressing.</p>",
|
|
26
|
+
artist: "Nova Wren",
|
|
27
|
+
media: [
|
|
28
|
+
{ url: "https://cdn.example/front.jpg", type: "image/jpeg" },
|
|
29
|
+
{ url: "https://cdn.example/back.jpg", type: "image/jpeg" },
|
|
30
|
+
],
|
|
31
|
+
variants: [{ price: 32, availabilityStatus: "active" }],
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
describe("buildProductSchema — reviews enrich, they do not gate", () => {
|
|
35
|
+
it("emits a Product for a product with NO reviews at all", () => {
|
|
36
|
+
const schema = buildProductSchema(PRODUCT, { currency: "USD" });
|
|
37
|
+
expect(schema).not.toBeNull();
|
|
38
|
+
expect(schema).toMatchObject({ "@context": "https://schema.org", "@type": "Product", name: PRODUCT.title });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("omits aggregateRating entirely rather than emitting a zero", () => {
|
|
42
|
+
for (const aggregate of [null, undefined, { avgRating: 0, reviewCount: 0 }]) {
|
|
43
|
+
const schema = buildProductSchema({ ...PRODUCT, reviewAggregate: aggregate }, { currency: "USD" })!;
|
|
44
|
+
expect(schema).not.toHaveProperty("aggregateRating");
|
|
45
|
+
expect(schema).not.toHaveProperty("review");
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("adds aggregateRating once there genuinely are reviews", () => {
|
|
50
|
+
const schema = buildProductSchema(
|
|
51
|
+
{ ...PRODUCT, reviewAggregate: { avgRating: 4.6666, reviewCount: 3 } },
|
|
52
|
+
{
|
|
53
|
+
currency: "USD",
|
|
54
|
+
reviews: [{ rating: 5, title: "Superb", body: "Sounds huge.", reviewerName: "Ada", publishedAt: "2026-01-02" }],
|
|
55
|
+
},
|
|
56
|
+
)!;
|
|
57
|
+
expect(schema.aggregateRating).toEqual({
|
|
58
|
+
"@type": "AggregateRating",
|
|
59
|
+
ratingValue: 4.67,
|
|
60
|
+
reviewCount: 3,
|
|
61
|
+
bestRating: 5,
|
|
62
|
+
worstRating: 1,
|
|
63
|
+
});
|
|
64
|
+
expect(schema.review).toHaveLength(1);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("drops out-of-range review ratings from the sample", () => {
|
|
68
|
+
const schema = buildProductSchema(
|
|
69
|
+
{ ...PRODUCT, reviewAggregate: { avgRating: 5, reviewCount: 2 } },
|
|
70
|
+
{ currency: "USD", reviews: [{ rating: 5 }, { rating: 0 }, { rating: 9 }] },
|
|
71
|
+
)!;
|
|
72
|
+
expect(schema.review).toHaveLength(1);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("returns null only when there is no name — nothing truthful to say", () => {
|
|
76
|
+
expect(buildProductSchema(null, { currency: "USD" })).toBeNull();
|
|
77
|
+
expect(buildProductSchema({ title: " " }, { currency: "USD" })).toBeNull();
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe("buildProductSchema — offers stay honest", () => {
|
|
82
|
+
it("emits a single Offer for one fixed price", () => {
|
|
83
|
+
const schema = buildProductSchema(PRODUCT, { currency: "GBP" })!;
|
|
84
|
+
expect(schema.offers).toEqual({
|
|
85
|
+
"@type": "Offer",
|
|
86
|
+
priceCurrency: "GBP",
|
|
87
|
+
price: 32,
|
|
88
|
+
availability: "https://schema.org/InStock",
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("emits NO offers without a settlement currency — an Offer with no currency is invalid", () => {
|
|
93
|
+
expect(buildProductSchema(PRODUCT, {})!).not.toHaveProperty("offers");
|
|
94
|
+
expect(buildProductSchema(PRODUCT, { currency: null })!).not.toHaveProperty("offers");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("emits NO offers for a product the caller may not buy (membership gate)", () => {
|
|
98
|
+
const gated = buildProductSchema({ ...PRODUCT, membershipGate: { allowed: false } }, { currency: "USD" })!;
|
|
99
|
+
expect(gated).not.toHaveProperty("offers");
|
|
100
|
+
// …but the Product node itself still exists, so the page is still indexable.
|
|
101
|
+
expect(gated["@type"]).toBe("Product");
|
|
102
|
+
|
|
103
|
+
const allowed = buildProductSchema({ ...PRODUCT, membershipGate: { allowed: true } }, { currency: "USD" })!;
|
|
104
|
+
expect(allowed).toHaveProperty("offers");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("mirrors availabilityStatus — the same field the Add-to-cart button reads", () => {
|
|
108
|
+
const soldOut = buildProductSchema(
|
|
109
|
+
{ ...PRODUCT, variants: [{ price: 32, availabilityStatus: "temporarily_out_of_stock" }] },
|
|
110
|
+
{ currency: "USD" },
|
|
111
|
+
)!;
|
|
112
|
+
expect(soldOut.offers).toMatchObject({ availability: "https://schema.org/OutOfStock" });
|
|
113
|
+
|
|
114
|
+
const partial = buildProductSchema(
|
|
115
|
+
{
|
|
116
|
+
...PRODUCT,
|
|
117
|
+
variants: [
|
|
118
|
+
{ price: 32, availabilityStatus: "temporarily_out_of_stock" },
|
|
119
|
+
{ price: 45, availabilityStatus: "active" },
|
|
120
|
+
],
|
|
121
|
+
},
|
|
122
|
+
{ currency: "USD" },
|
|
123
|
+
)!;
|
|
124
|
+
expect(partial.offers).toMatchObject({ availability: "https://schema.org/InStock" });
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("emits an AggregateOffer across several variant prices", () => {
|
|
128
|
+
const schema = buildProductSchema(
|
|
129
|
+
{
|
|
130
|
+
...PRODUCT,
|
|
131
|
+
variants: [
|
|
132
|
+
{ price: 32, availabilityStatus: "active" },
|
|
133
|
+
{ price: "45.5", availabilityStatus: "active" },
|
|
134
|
+
{ price: 40, availabilityStatus: "active" },
|
|
135
|
+
],
|
|
136
|
+
},
|
|
137
|
+
{ currency: "EUR" },
|
|
138
|
+
)!;
|
|
139
|
+
expect(schema.offers).toEqual({
|
|
140
|
+
"@type": "AggregateOffer",
|
|
141
|
+
priceCurrency: "EUR",
|
|
142
|
+
lowPrice: 32,
|
|
143
|
+
highPrice: 45.5,
|
|
144
|
+
offerCount: 3,
|
|
145
|
+
availability: "https://schema.org/InStock",
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("leaves highPrice OFF for an open-ended pay-what-you-want floor", () => {
|
|
150
|
+
// `price` IS the floor for a PWYW variant; with no maximum there is no
|
|
151
|
+
// ceiling, and inventing one would be a claim the storefront cannot back.
|
|
152
|
+
const schema = buildProductSchema(
|
|
153
|
+
{ ...PRODUCT, variants: [{ price: 5, availabilityStatus: "active", payWhatYouWant: true }] },
|
|
154
|
+
{ currency: "USD" },
|
|
155
|
+
)!;
|
|
156
|
+
expect(schema.offers).toEqual({
|
|
157
|
+
"@type": "AggregateOffer",
|
|
158
|
+
priceCurrency: "USD",
|
|
159
|
+
lowPrice: 5,
|
|
160
|
+
offerCount: 1,
|
|
161
|
+
availability: "https://schema.org/InStock",
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("uses the pay-what-you-want maximum as highPrice when one is set", () => {
|
|
166
|
+
const schema = buildProductSchema(
|
|
167
|
+
{
|
|
168
|
+
...PRODUCT,
|
|
169
|
+
variants: [{ price: 5, availabilityStatus: "active", payWhatYouWant: true, payWhatYouWantMaximum: 50 }],
|
|
170
|
+
},
|
|
171
|
+
{ currency: "USD" },
|
|
172
|
+
)!;
|
|
173
|
+
expect(schema.offers).toMatchObject({ "@type": "AggregateOffer", lowPrice: 5, highPrice: 50 });
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("emits no offers for a product with no variants", () => {
|
|
177
|
+
expect(buildProductSchema({ ...PRODUCT, variants: [] }, { currency: "USD" })!).not.toHaveProperty("offers");
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
describe("buildProductSchema — the rest of the node", () => {
|
|
182
|
+
it("strips HTML out of the description", () => {
|
|
183
|
+
expect(buildProductSchema(PRODUCT, {})!.description).toBe("Limited 180g pressing.");
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("emits every image, and a bare string when there is only one", () => {
|
|
187
|
+
expect(buildProductSchema(PRODUCT, {})!.image).toEqual([
|
|
188
|
+
"https://cdn.example/front.jpg",
|
|
189
|
+
"https://cdn.example/back.jpg",
|
|
190
|
+
]);
|
|
191
|
+
const single = buildProductSchema({ ...PRODUCT, media: [PRODUCT.media![0]] }, {})!;
|
|
192
|
+
expect(single.image).toBe("https://cdn.example/front.jpg");
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("prefers the release artist as brand, falling back to the creator", () => {
|
|
196
|
+
expect(buildProductSchema(PRODUCT, { brand: "Wren Records" })!.brand).toEqual({
|
|
197
|
+
"@type": "Organization",
|
|
198
|
+
name: "Nova Wren",
|
|
199
|
+
});
|
|
200
|
+
expect(buildProductSchema({ ...PRODUCT, artist: "" }, { brand: "Wren Records" })!.brand).toEqual({
|
|
201
|
+
"@type": "Organization",
|
|
202
|
+
name: "Wren Records",
|
|
203
|
+
});
|
|
204
|
+
expect(buildProductSchema({ ...PRODUCT, artist: "" }, {})!).not.toHaveProperty("brand");
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("omits url when the caller has no canonical URL to give", () => {
|
|
208
|
+
expect(buildProductSchema(PRODUCT, {})!).not.toHaveProperty("url");
|
|
209
|
+
expect(buildProductSchema(PRODUCT, { url: "https://nova.example/i/store/vinyl" })!.url).toBe(
|
|
210
|
+
"https://nova.example/i/store/vinyl",
|
|
211
|
+
);
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
describe("buildCourseSchema / buildServiceSchema", () => {
|
|
216
|
+
const COURSE = { title: "Mixing in a Bedroom", description: "<p>8 modules.</p>", price: "99", media: [] };
|
|
217
|
+
|
|
218
|
+
it("emits a Course with no reviews, and uses `provider` not `brand`", () => {
|
|
219
|
+
const schema = buildCourseSchema(COURSE, { currency: "USD", brand: "Nova Wren" })!;
|
|
220
|
+
expect(schema["@type"]).toBe("Course");
|
|
221
|
+
expect(schema.provider).toEqual({ "@type": "Organization", name: "Nova Wren" });
|
|
222
|
+
expect(schema).not.toHaveProperty("brand");
|
|
223
|
+
expect(schema).not.toHaveProperty("aggregateRating");
|
|
224
|
+
expect(schema.offers).toEqual({
|
|
225
|
+
"@type": "Offer",
|
|
226
|
+
priceCurrency: "USD",
|
|
227
|
+
price: 99,
|
|
228
|
+
availability: "https://schema.org/InStock",
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("never claims hasCourseInstance — the platform has no scheduled instance to point at", () => {
|
|
233
|
+
expect(buildCourseSchema(COURSE, { currency: "USD" })!).not.toHaveProperty("hasCourseInstance");
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("drops the Course offer for a members-only course", () => {
|
|
237
|
+
const schema = buildCourseSchema({ ...COURSE, membershipGate: { allowed: false } }, { currency: "USD" })!;
|
|
238
|
+
expect(schema["@type"]).toBe("Course");
|
|
239
|
+
expect(schema).not.toHaveProperty("offers");
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("emits a Service for a coaching product with no reviews", () => {
|
|
243
|
+
const schema = buildServiceSchema({ title: "1:1 Mix Review", price: "150" }, { currency: "USD" })!;
|
|
244
|
+
expect(schema["@type"]).toBe("Service");
|
|
245
|
+
expect(schema.offers).toMatchObject({ "@type": "Offer", price: 150 });
|
|
246
|
+
expect(schema).not.toHaveProperty("aggregateRating");
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("emits no offer for a free/unpriced entity's missing price", () => {
|
|
250
|
+
expect(buildServiceSchema({ title: "1:1 Mix Review", price: null }, { currency: "USD" })!).not.toHaveProperty(
|
|
251
|
+
"offers",
|
|
252
|
+
);
|
|
253
|
+
// Zero is a real price (a free course), not a missing one.
|
|
254
|
+
expect(buildCourseSchema({ ...COURSE, price: 0 }, { currency: "USD" })!.offers).toMatchObject({ price: 0 });
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
describe("buildEntitySchema + jsonLdScripts", () => {
|
|
259
|
+
it("keeps buildEntityReviewSchema as an alias so a deployed site's import survives", () => {
|
|
260
|
+
expect(buildEntityReviewSchema).toBe(buildEntitySchema);
|
|
261
|
+
// …and the alias no longer returns null for an unreviewed entity.
|
|
262
|
+
expect(buildEntityReviewSchema({ type: "Product", name: "Tote" })).not.toBeNull();
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("produces one ld+json script per non-null schema, and drops nulls", () => {
|
|
266
|
+
const scripts = jsonLdScripts(buildProductSchema(PRODUCT, { currency: "USD" }), null, undefined);
|
|
267
|
+
expect(scripts).toHaveLength(1);
|
|
268
|
+
expect(scripts[0].type).toBe("application/ld+json");
|
|
269
|
+
expect(JSON.parse(scripts[0].children)["@type"]).toBe("Product");
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("returns an empty array so a head() can spread it unconditionally", () => {
|
|
273
|
+
expect(jsonLdScripts(buildProductSchema(null))).toEqual([]);
|
|
274
|
+
});
|
|
275
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a buyer's presale code lives between typing it and paying with it.
|
|
3
|
+
*
|
|
4
|
+
* ## Why this is a module-level store and not component state
|
|
5
|
+
*
|
|
6
|
+
* The code has to be in three places at once, in components that do not share a
|
|
7
|
+
* parent: the event page's own `useEvent` (to reveal the hidden tiers), the
|
|
8
|
+
* ticket modal's `useEventCheckout` (its own separate `useEvent`), and the
|
|
9
|
+
* order body (the sell guard re-checks the code on every purchase, so a buyer
|
|
10
|
+
* who unlocks a tier and then checks out without the code is refused AFTER
|
|
11
|
+
* entering their card). Threading it through props would work for one host app
|
|
12
|
+
* and break for every code-site that composes the Forge pieces differently.
|
|
13
|
+
*
|
|
14
|
+
* ## Why sessionStorage and not localStorage
|
|
15
|
+
*
|
|
16
|
+
* A presale code is a right to buy now, not a saved preference. Session scope
|
|
17
|
+
* survives the reload, the Stripe redirect and the back button — everything
|
|
18
|
+
* inside one purchase — and does not silently re-apply a spent code to a
|
|
19
|
+
* different show weeks later.
|
|
20
|
+
*
|
|
21
|
+
* Keyed per event, because two presales are two different codes.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const KEY_PREFIX = "tn:presale:";
|
|
25
|
+
|
|
26
|
+
/** eventKey → code, mirroring sessionStorage so reads are synchronous. */
|
|
27
|
+
const memory = new Map<string, string>();
|
|
28
|
+
const listeners = new Set<() => void>();
|
|
29
|
+
|
|
30
|
+
/** Loaded lazily so a server render never touches storage. */
|
|
31
|
+
const hydrated = new Set<string>();
|
|
32
|
+
|
|
33
|
+
const storageKey = (eventKey: string) => `${KEY_PREFIX}${eventKey}`;
|
|
34
|
+
|
|
35
|
+
function hydrate(eventKey: string): void {
|
|
36
|
+
if (hydrated.has(eventKey) || typeof window === "undefined") return;
|
|
37
|
+
hydrated.add(eventKey);
|
|
38
|
+
try {
|
|
39
|
+
const stored = window.sessionStorage.getItem(storageKey(eventKey));
|
|
40
|
+
if (stored) memory.set(eventKey, stored);
|
|
41
|
+
} catch {
|
|
42
|
+
// Private mode / disabled storage. The code still works for this render;
|
|
43
|
+
// it just will not survive a reload.
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function readPresaleCode(eventKey: string): string | null {
|
|
48
|
+
if (!eventKey) return null;
|
|
49
|
+
hydrate(eventKey);
|
|
50
|
+
return memory.get(eventKey) ?? null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function writePresaleCode(eventKey: string, code: string | null): void {
|
|
54
|
+
if (!eventKey) return;
|
|
55
|
+
hydrated.add(eventKey);
|
|
56
|
+
const trimmed = code?.trim();
|
|
57
|
+
if (trimmed) memory.set(eventKey, trimmed);
|
|
58
|
+
else memory.delete(eventKey);
|
|
59
|
+
|
|
60
|
+
if (typeof window !== "undefined") {
|
|
61
|
+
try {
|
|
62
|
+
if (trimmed) window.sessionStorage.setItem(storageKey(eventKey), trimmed);
|
|
63
|
+
else window.sessionStorage.removeItem(storageKey(eventKey));
|
|
64
|
+
} catch {
|
|
65
|
+
// As above — in-memory only.
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
for (const listener of listeners) listener();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function subscribePresaleCode(listener: () => void): () => void {
|
|
73
|
+
listeners.add(listener);
|
|
74
|
+
return () => {
|
|
75
|
+
listeners.delete(listener);
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* `?accessCode=PRESALE24` on the URL, so the link an artist mails to their list
|
|
81
|
+
* unlocks the page on arrival instead of asking the recipient to retype what
|
|
82
|
+
* they just clicked.
|
|
83
|
+
*
|
|
84
|
+
* The parameter is left ON the URL deliberately, unlike the attribution ref: a
|
|
85
|
+
* presale link is meant to be forwardable, and stripping it would break the
|
|
86
|
+
* one thing the recipient is most likely to do with it.
|
|
87
|
+
*/
|
|
88
|
+
export function readPresaleCodeFromUrl(): string | null {
|
|
89
|
+
if (typeof window === "undefined") return null;
|
|
90
|
+
try {
|
|
91
|
+
const value = new URL(window.location.href).searchParams.get("accessCode");
|
|
92
|
+
return value?.trim() || null;
|
|
93
|
+
} catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|