@tribe-nest/forge 3.21.0 → 3.23.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 (97) hide show
  1. package/package.json +2 -1
  2. package/src/data/queries/_tests/eventWaitlist.spec.ts +38 -0
  3. package/src/data/queries/_tests/passTransfers.spec.ts +100 -4
  4. package/src/data/queries/useAuthActions.ts +1 -1
  5. package/src/data/queries/useCheckouts.ts +5 -0
  6. package/src/data/queries/useEventWaitlist.ts +123 -5
  7. package/src/data/queries/useEvents.ts +82 -4
  8. package/src/data/queries/useMembership.ts +8 -2
  9. package/src/data/queries/useMyBookings.ts +37 -0
  10. package/src/data/queries/useMyTickets.ts +112 -0
  11. package/src/data/queries/useOrders.ts +10 -0
  12. package/src/data/queries/usePassTransfers.ts +73 -13
  13. package/src/data/queries/usePaymentFlow.ts +84 -18
  14. package/src/data/queries/useShipping.ts +5 -0
  15. package/src/data/queries/useSubscriptions.ts +50 -4
  16. package/src/index.ts +5 -0
  17. package/src/server/index.ts +52 -0
  18. package/src/server/platform.ts +1 -1
  19. package/src/server/platformEvents.generated.ts +33 -33
  20. package/src/server/pwa.ts +2 -2
  21. package/src/types/models.ts +203 -5
  22. package/src/types/paystack-inline.d.ts +45 -0
  23. package/src/ui/format/_tests/attendees.spec.ts +231 -0
  24. package/src/ui/format/_tests/membershipGate.spec.ts +220 -0
  25. package/src/ui/format/_tests/ticketAvailability.spec.ts +283 -0
  26. package/src/ui/format/attendees.ts +187 -0
  27. package/src/ui/format/membershipGate.ts +209 -0
  28. package/src/ui/format/ticketAvailability.ts +226 -0
  29. package/src/ui/headless/calendar/_tests/useAddToCalendar.spec.ts +83 -0
  30. package/src/ui/headless/calendar/useAddToCalendar.ts +46 -5
  31. package/src/ui/headless/checkout/_tests/inventoryHold.spec.ts +111 -0
  32. package/src/ui/headless/checkout/bundleCoupon.ts +14 -6
  33. package/src/ui/headless/checkout/inventoryHold.ts +83 -0
  34. package/src/ui/headless/checkout/useCheckout.ts +144 -8
  35. package/src/ui/headless/checkout/useInventoryHold.ts +104 -0
  36. package/src/ui/headless/coaching/useCoachingBooking.ts +4 -0
  37. package/src/ui/headless/course/useCourseCheckout.ts +4 -0
  38. package/src/ui/headless/donation/Donation.tsx +10 -0
  39. package/src/ui/headless/donation/DonationContext.tsx +27 -1
  40. package/src/ui/headless/event/useEventCheckout.ts +203 -2
  41. package/src/ui/headless/event/usePresaleCode.ts +181 -0
  42. package/src/ui/headless/index.ts +25 -0
  43. package/src/ui/headless/invoice/useInvoicePayment.ts +7 -2
  44. package/src/ui/headless/membership/useMembershipCheckout.ts +63 -2
  45. package/src/ui/headless/membership/useMembershipGateNotice.ts +83 -0
  46. package/src/ui/headless/offer/Offer.tsx +19 -1
  47. package/src/ui/headless/offer/OfferContext.tsx +69 -1
  48. package/src/ui/headless/paymentLink/usePaymentLinkPayment.ts +7 -1
  49. package/src/ui/headless/work/useWorkPortal.ts +3 -3
  50. package/src/ui/index.ts +64 -0
  51. package/src/ui/shell/diagnosticsGating.ts +3 -3
  52. package/src/ui/styled/AccountDashboard.tsx +143 -12
  53. package/src/ui/styled/AddToCalendar.tsx +34 -10
  54. package/src/ui/styled/AudioPlayer.tsx +1 -1
  55. package/src/ui/styled/BundleConfirmation.tsx +1 -1
  56. package/src/ui/styled/Checkout.tsx +42 -32
  57. package/src/ui/styled/CheckoutConfirmation.tsx +2 -2
  58. package/src/ui/styled/CoachingBooking.tsx +11 -2
  59. package/src/ui/styled/CoachingConfirmation.tsx +7 -3
  60. package/src/ui/styled/CohortPage.tsx +1 -1
  61. package/src/ui/styled/ContactForm.tsx +1 -1
  62. package/src/ui/styled/CourseCheckout.tsx +10 -1
  63. package/src/ui/styled/CourseConfirmation.tsx +2 -2
  64. package/src/ui/styled/CourseDetail.tsx +30 -1
  65. package/src/ui/styled/DiscountCode.tsx +1 -1
  66. package/src/ui/styled/EmailListForm.tsx +1 -1
  67. package/src/ui/styled/EventConfirmation.tsx +3 -1
  68. package/src/ui/styled/EventDetail.tsx +53 -22
  69. package/src/ui/styled/EventSeriesDetail.tsx +1 -1
  70. package/src/ui/styled/EventTickets.tsx +284 -19
  71. package/src/ui/styled/EventWaitlist.tsx +23 -3
  72. package/src/ui/styled/HoldNotice.tsx +192 -0
  73. package/src/ui/styled/InvoicePayment.tsx +14 -5
  74. package/src/ui/styled/MembershipCheckout.tsx +20 -11
  75. package/src/ui/styled/MembershipGateNotice.tsx +159 -0
  76. package/src/ui/styled/OfferButton.tsx +24 -1
  77. package/src/ui/styled/PaymentLinkPayment.tsx +12 -4
  78. package/src/ui/styled/PaystackPayButton.tsx +66 -0
  79. package/src/ui/styled/PresaleCode.tsx +174 -0
  80. package/src/ui/styled/ProductDetail.tsx +75 -5
  81. package/src/ui/styled/ProductGrid.tsx +26 -0
  82. package/src/ui/styled/ReviewForm.tsx +2 -2
  83. package/src/ui/styled/TicketTransfer.tsx +71 -42
  84. package/src/ui/styled/_tests/AddToCalendar.spec.tsx +88 -0
  85. package/src/ui/styled/_tests/EventConfirmation.spec.tsx +5 -1
  86. package/src/ui/styled/_tests/PresaleCode.spec.tsx +106 -0
  87. package/src/ui/styled/community/CommunityFeed.tsx +1 -1
  88. package/src/ui/styled/community/CommunityPostDetail.tsx +1 -1
  89. package/src/utils/_tests/paystackCheckout.spec.ts +266 -0
  90. package/src/utils/_tests/paystackCheckoutBlocked.spec.ts +51 -0
  91. package/src/utils/_tests/presaleCode.spec.ts +168 -0
  92. package/src/utils/_tests/structuredData.spec.ts +275 -0
  93. package/src/utils/membershipAccess.ts +3 -3
  94. package/src/utils/paystackCheckout.ts +277 -0
  95. package/src/utils/presaleCode.ts +96 -0
  96. package/src/utils/structuredData.ts +361 -27
  97. package/src/utils/ticketOrderOutcome.ts +1 -1
@@ -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
+ });
@@ -161,8 +161,8 @@ export function getMembershipStatusMessage(access: MembershipAccessSummary): Mem
161
161
  return {
162
162
  label: "Payment failed",
163
163
  detail: until
164
- ? `We couldn't charge your payment method. You still have full access until ${until} update your card to keep it.`
165
- : "We couldn't charge your payment method. You still have full access update your card to keep it.",
164
+ ? `We couldn't charge your payment method. You still have full access until ${until}. Update your card to keep it.`
165
+ : "We couldn't charge your payment method. You still have full access. Update your card to keep it.",
166
166
  showUpdatePayment: true,
167
167
  tone: "warning",
168
168
  };
@@ -171,7 +171,7 @@ export function getMembershipStatusMessage(access: MembershipAccessSummary): Mem
171
171
  case MEMBERSHIP_BILLING_STATE.pending:
172
172
  return {
173
173
  label: "Pending",
174
- detail: "Your membership isn't active yet finish checkout to unlock it.",
174
+ detail: "Your membership isn't active yet. Finish checkout to unlock it.",
175
175
  showUpdatePayment: false,
176
176
  tone: "neutral",
177
177
  };