@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
@@ -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
+ }