@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.
- package/package.json +2 -1
- package/src/data/queries/_tests/eventWaitlist.spec.ts +38 -0
- package/src/data/queries/_tests/passTransfers.spec.ts +100 -4
- package/src/data/queries/useAuthActions.ts +1 -1
- package/src/data/queries/useCheckouts.ts +5 -0
- package/src/data/queries/useEventWaitlist.ts +123 -5
- package/src/data/queries/useEvents.ts +82 -4
- package/src/data/queries/useMembership.ts +8 -2
- package/src/data/queries/useMyBookings.ts +37 -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/data/queries/usePaymentFlow.ts +84 -18
- package/src/data/queries/useShipping.ts +5 -0
- package/src/data/queries/useSubscriptions.ts +50 -4
- package/src/index.ts +5 -0
- package/src/server/index.ts +52 -0
- package/src/server/platform.ts +1 -1
- package/src/server/platformEvents.generated.ts +33 -33
- package/src/server/pwa.ts +2 -2
- package/src/types/models.ts +203 -5
- package/src/types/paystack-inline.d.ts +45 -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/_tests/ticketAvailability.spec.ts +283 -0
- package/src/ui/format/attendees.ts +187 -0
- package/src/ui/format/membershipGate.ts +209 -0
- package/src/ui/format/ticketAvailability.ts +226 -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/bundleCoupon.ts +14 -6
- package/src/ui/headless/checkout/inventoryHold.ts +83 -0
- package/src/ui/headless/checkout/useCheckout.ts +144 -8
- package/src/ui/headless/checkout/useInventoryHold.ts +104 -0
- package/src/ui/headless/coaching/useCoachingBooking.ts +4 -0
- package/src/ui/headless/course/useCourseCheckout.ts +4 -0
- package/src/ui/headless/donation/Donation.tsx +10 -0
- package/src/ui/headless/donation/DonationContext.tsx +27 -1
- package/src/ui/headless/event/useEventCheckout.ts +203 -2
- package/src/ui/headless/event/usePresaleCode.ts +181 -0
- package/src/ui/headless/index.ts +25 -0
- package/src/ui/headless/invoice/useInvoicePayment.ts +7 -2
- package/src/ui/headless/membership/useMembershipCheckout.ts +63 -2
- package/src/ui/headless/membership/useMembershipGateNotice.ts +83 -0
- package/src/ui/headless/offer/Offer.tsx +19 -1
- package/src/ui/headless/offer/OfferContext.tsx +69 -1
- package/src/ui/headless/paymentLink/usePaymentLinkPayment.ts +7 -1
- package/src/ui/headless/work/useWorkPortal.ts +3 -3
- package/src/ui/index.ts +64 -0
- package/src/ui/shell/diagnosticsGating.ts +3 -3
- package/src/ui/styled/AccountDashboard.tsx +143 -12
- package/src/ui/styled/AddToCalendar.tsx +34 -10
- package/src/ui/styled/AudioPlayer.tsx +1 -1
- package/src/ui/styled/BundleConfirmation.tsx +1 -1
- package/src/ui/styled/Checkout.tsx +42 -32
- package/src/ui/styled/CheckoutConfirmation.tsx +2 -2
- package/src/ui/styled/CoachingBooking.tsx +11 -2
- package/src/ui/styled/CoachingConfirmation.tsx +7 -3
- package/src/ui/styled/CohortPage.tsx +1 -1
- package/src/ui/styled/ContactForm.tsx +1 -1
- package/src/ui/styled/CourseCheckout.tsx +10 -1
- package/src/ui/styled/CourseConfirmation.tsx +2 -2
- package/src/ui/styled/CourseDetail.tsx +30 -1
- package/src/ui/styled/DiscountCode.tsx +1 -1
- package/src/ui/styled/EmailListForm.tsx +1 -1
- package/src/ui/styled/EventConfirmation.tsx +3 -1
- package/src/ui/styled/EventDetail.tsx +53 -22
- package/src/ui/styled/EventSeriesDetail.tsx +1 -1
- package/src/ui/styled/EventTickets.tsx +284 -19
- package/src/ui/styled/EventWaitlist.tsx +23 -3
- package/src/ui/styled/HoldNotice.tsx +192 -0
- package/src/ui/styled/InvoicePayment.tsx +14 -5
- package/src/ui/styled/MembershipCheckout.tsx +20 -11
- package/src/ui/styled/MembershipGateNotice.tsx +159 -0
- package/src/ui/styled/OfferButton.tsx +24 -1
- package/src/ui/styled/PaymentLinkPayment.tsx +12 -4
- package/src/ui/styled/PaystackPayButton.tsx +66 -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/ReviewForm.tsx +2 -2
- package/src/ui/styled/TicketTransfer.tsx +71 -42
- 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/ui/styled/community/CommunityFeed.tsx +1 -1
- package/src/ui/styled/community/CommunityPostDetail.tsx +1 -1
- package/src/utils/_tests/paystackCheckout.spec.ts +266 -0
- package/src/utils/_tests/paystackCheckoutBlocked.spec.ts +51 -0
- package/src/utils/_tests/presaleCode.spec.ts +168 -0
- package/src/utils/_tests/structuredData.spec.ts +275 -0
- package/src/utils/membershipAccess.ts +3 -3
- package/src/utils/paystackCheckout.ts +277 -0
- package/src/utils/presaleCode.ts +96 -0
- package/src/utils/structuredData.ts +361 -27
- 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}
|
|
165
|
-
: "We couldn't charge your payment method. You still have full access
|
|
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
|
|
174
|
+
detail: "Your membership isn't active yet. Finish checkout to unlock it.",
|
|
175
175
|
showUpdatePayment: false,
|
|
176
176
|
tone: "neutral",
|
|
177
177
|
};
|