@tribe-nest/forge 3.21.0 → 3.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/data/queries/_tests/passTransfers.spec.ts +100 -4
- package/src/data/queries/useEvents.ts +66 -4
- package/src/data/queries/useMembership.ts +8 -2
- package/src/data/queries/useMyBookings.ts +12 -0
- package/src/data/queries/useMyTickets.ts +112 -0
- package/src/data/queries/useOrders.ts +10 -0
- package/src/data/queries/usePassTransfers.ts +73 -13
- package/src/server/index.ts +52 -0
- package/src/types/models.ts +151 -0
- package/src/ui/format/_tests/attendees.spec.ts +231 -0
- package/src/ui/format/_tests/membershipGate.spec.ts +220 -0
- package/src/ui/format/attendees.ts +187 -0
- package/src/ui/format/membershipGate.ts +209 -0
- package/src/ui/headless/calendar/_tests/useAddToCalendar.spec.ts +83 -0
- package/src/ui/headless/calendar/useAddToCalendar.ts +46 -5
- package/src/ui/headless/checkout/_tests/inventoryHold.spec.ts +111 -0
- package/src/ui/headless/checkout/inventoryHold.ts +83 -0
- package/src/ui/headless/checkout/useCheckout.ts +72 -0
- package/src/ui/headless/checkout/useInventoryHold.ts +104 -0
- package/src/ui/headless/event/useEventCheckout.ts +133 -2
- package/src/ui/headless/event/usePresaleCode.ts +181 -0
- package/src/ui/headless/index.ts +25 -0
- package/src/ui/headless/membership/useMembershipGateNotice.ts +83 -0
- package/src/ui/headless/offer/OfferContext.tsx +55 -0
- package/src/ui/index.ts +42 -0
- package/src/ui/styled/AccountDashboard.tsx +70 -8
- package/src/ui/styled/AddToCalendar.tsx +34 -10
- package/src/ui/styled/Checkout.tsx +18 -1
- package/src/ui/styled/CoachingConfirmation.tsx +4 -0
- package/src/ui/styled/CourseDetail.tsx +30 -1
- package/src/ui/styled/EventConfirmation.tsx +2 -0
- package/src/ui/styled/EventDetail.tsx +53 -22
- package/src/ui/styled/EventTickets.tsx +156 -5
- package/src/ui/styled/HoldNotice.tsx +192 -0
- package/src/ui/styled/MembershipGateNotice.tsx +159 -0
- package/src/ui/styled/OfferButton.tsx +23 -0
- package/src/ui/styled/PresaleCode.tsx +174 -0
- package/src/ui/styled/ProductDetail.tsx +75 -5
- package/src/ui/styled/ProductGrid.tsx +26 -0
- package/src/ui/styled/TicketTransfer.tsx +69 -40
- package/src/ui/styled/_tests/AddToCalendar.spec.tsx +88 -0
- package/src/ui/styled/_tests/EventConfirmation.spec.tsx +5 -1
- package/src/ui/styled/_tests/PresaleCode.spec.tsx +106 -0
- package/src/utils/_tests/presaleCode.spec.ts +168 -0
- package/src/utils/_tests/structuredData.spec.ts +275 -0
- package/src/utils/presaleCode.ts +96 -0
- package/src/utils/structuredData.ts +361 -27
|
@@ -45,10 +45,29 @@ export function buildJsonLdScript(...schemas: Record<string, unknown>[]): string
|
|
|
45
45
|
.join("");
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
// ----
|
|
48
|
+
// ---- Sellable entities (Product / Course / Service) ---------------------------
|
|
49
|
+
//
|
|
50
|
+
// ⚠️ THE RULE HERE: reviews ENRICH structured data, they do not GATE it.
|
|
51
|
+
//
|
|
52
|
+
// This file used to return `null` whenever an entity had no review aggregate,
|
|
53
|
+
// so a product nobody had reviewed yet emitted no schema.org/Product at all —
|
|
54
|
+
// no rich result, no free Shopping listing, for exactly the products that need
|
|
55
|
+
// them most. `aggregateRating` is now the only part that waits for reviews
|
|
56
|
+
// (an empty or zero rating is a schema violation Google penalises, so ABSENT is
|
|
57
|
+
// the correct representation of "no reviews yet"); the entity node itself is
|
|
58
|
+
// emitted for every published thing.
|
|
59
|
+
//
|
|
60
|
+
// The other half of the rule: never state a fact the storefront does not have.
|
|
61
|
+
// An `Offer` is emitted only with a real settlement currency, its `availability`
|
|
62
|
+
// mirrors the same `availabilityStatus` the Add-to-cart button reads, and a
|
|
63
|
+
// membership-gated entity gets NO offer at all — the anonymous visitor (and
|
|
64
|
+
// Googlebot, which is anonymous) cannot buy it at that price.
|
|
49
65
|
|
|
50
66
|
export type ReviewSchemaEntityType = "Product" | "Course" | "Service";
|
|
51
67
|
|
|
68
|
+
/** schema.org ItemAvailability values this codebase can honestly assert. */
|
|
69
|
+
export type SchemaAvailability = "InStock" | "OutOfStock";
|
|
70
|
+
|
|
52
71
|
export interface ReviewSchemaAggregate {
|
|
53
72
|
avgRating: number;
|
|
54
73
|
reviewCount: number;
|
|
@@ -62,29 +81,110 @@ export interface ReviewSchemaReview {
|
|
|
62
81
|
publishedAt?: string | null;
|
|
63
82
|
}
|
|
64
83
|
|
|
65
|
-
|
|
66
|
-
|
|
84
|
+
/**
|
|
85
|
+
* An offer, in major units of `currency`.
|
|
86
|
+
*
|
|
87
|
+
* Pass `price` for a single fixed price (→ `Offer`), or `lowPrice`/`highPrice`
|
|
88
|
+
* for a range (→ `AggregateOffer`). `highPrice` is omitted for an open-ended
|
|
89
|
+
* pay-what-you-want floor, which genuinely has no ceiling.
|
|
90
|
+
*/
|
|
91
|
+
export interface EntityOfferInput {
|
|
92
|
+
/** ISO 4217 code. REQUIRED — an Offer without a currency is invalid, so a
|
|
93
|
+
* missing one drops the whole offers node rather than guessing USD. */
|
|
94
|
+
currency: string;
|
|
95
|
+
price?: number;
|
|
96
|
+
lowPrice?: number;
|
|
97
|
+
highPrice?: number;
|
|
98
|
+
offerCount?: number;
|
|
99
|
+
availability?: SchemaAvailability;
|
|
100
|
+
url?: string | null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface EntitySchemaInput {
|
|
104
|
+
/** schema.org type of the thing. */
|
|
67
105
|
type: ReviewSchemaEntityType;
|
|
68
106
|
name: string;
|
|
69
107
|
description?: string | null;
|
|
70
|
-
|
|
108
|
+
/** One URL or several (Google prefers multiple aspect ratios). */
|
|
109
|
+
image?: string | string[] | null;
|
|
71
110
|
url?: string | null;
|
|
111
|
+
/** The creator's name — rendered as `brand` on Product, `provider` otherwise. */
|
|
112
|
+
brand?: string | null;
|
|
72
113
|
aggregate?: ReviewSchemaAggregate | null;
|
|
73
114
|
/** A sample of published reviews (top-N is fine — Google reads a subset). */
|
|
74
115
|
reviews?: ReviewSchemaReview[];
|
|
75
|
-
/**
|
|
76
|
-
offer?:
|
|
116
|
+
/** Offer/AggregateOffer. Omit for anything the visitor cannot actually buy. */
|
|
117
|
+
offer?: EntityOfferInput | null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** @deprecated Renamed to {@link EntitySchemaInput} — reviews are no longer what this is about. */
|
|
121
|
+
export type EntityReviewSchemaInput = EntitySchemaInput;
|
|
122
|
+
|
|
123
|
+
const money = (n: number) => Number(n.toFixed(2));
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* A price, or `undefined` when there isn't one.
|
|
127
|
+
*
|
|
128
|
+
* `Number(null)` and `Number("")` are both `0`, so a naive coercion turns "this
|
|
129
|
+
* response has no price" into "this thing is free" — a claim the storefront
|
|
130
|
+
* never makes. Zero itself IS a real price (a free course), so only
|
|
131
|
+
* null/undefined/blank are rejected.
|
|
132
|
+
*/
|
|
133
|
+
function toPrice(value: unknown): number | undefined {
|
|
134
|
+
if (value === null || value === undefined) return undefined;
|
|
135
|
+
if (typeof value === "string" && value.trim() === "") return undefined;
|
|
136
|
+
const n = Number(value);
|
|
137
|
+
return Number.isFinite(n) ? n : undefined;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function buildOffersNode(offer?: EntityOfferInput | null): Record<string, unknown> | undefined {
|
|
141
|
+
if (!offer || !offer.currency) return undefined;
|
|
142
|
+
const availability = offer.availability ? { availability: `https://schema.org/${offer.availability}` } : {};
|
|
143
|
+
const url = offer.url ? { url: offer.url } : {};
|
|
144
|
+
|
|
145
|
+
const isRange = offer.price === undefined && offer.lowPrice !== undefined;
|
|
146
|
+
if (isRange) {
|
|
147
|
+
if (!Number.isFinite(offer.lowPrice as number)) return undefined;
|
|
148
|
+
return {
|
|
149
|
+
"@type": "AggregateOffer",
|
|
150
|
+
priceCurrency: offer.currency,
|
|
151
|
+
lowPrice: money(offer.lowPrice as number),
|
|
152
|
+
...(offer.highPrice !== undefined && Number.isFinite(offer.highPrice)
|
|
153
|
+
? { highPrice: money(offer.highPrice) }
|
|
154
|
+
: {}),
|
|
155
|
+
...(offer.offerCount !== undefined ? { offerCount: offer.offerCount } : {}),
|
|
156
|
+
...availability,
|
|
157
|
+
...url,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (offer.price === undefined || !Number.isFinite(offer.price)) return undefined;
|
|
162
|
+
return {
|
|
163
|
+
"@type": "Offer",
|
|
164
|
+
priceCurrency: offer.currency,
|
|
165
|
+
price: money(offer.price),
|
|
166
|
+
...availability,
|
|
167
|
+
...url,
|
|
168
|
+
};
|
|
77
169
|
}
|
|
78
170
|
|
|
79
171
|
/**
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
172
|
+
* schema.org JSON-LD for a sellable entity — Product, Course or Service.
|
|
173
|
+
*
|
|
174
|
+
* Returns `null` only when there is no `name`, i.e. nothing truthful to say.
|
|
175
|
+
* A missing review aggregate simply omits `aggregateRating`; it never
|
|
176
|
+
* suppresses the node. See the rule at the top of this section.
|
|
84
177
|
*/
|
|
85
|
-
export function
|
|
178
|
+
export function buildEntitySchema(input: EntitySchemaInput): Record<string, unknown> | null {
|
|
179
|
+
const name = input.name?.trim();
|
|
180
|
+
if (!name) return null;
|
|
181
|
+
|
|
86
182
|
const { aggregate } = input;
|
|
87
|
-
|
|
183
|
+
const hasRatings =
|
|
184
|
+
!!aggregate &&
|
|
185
|
+
aggregate.reviewCount > 0 &&
|
|
186
|
+
Number.isFinite(aggregate.avgRating) &&
|
|
187
|
+
aggregate.avgRating > 0;
|
|
88
188
|
|
|
89
189
|
const reviews = (input.reviews ?? [])
|
|
90
190
|
.filter((r) => r.rating >= 1 && r.rating <= 5)
|
|
@@ -98,29 +198,263 @@ export function buildEntityReviewSchema(input: EntityReviewSchemaInput): Record<
|
|
|
98
198
|
...(r.publishedAt ? { datePublished: r.publishedAt } : {}),
|
|
99
199
|
}));
|
|
100
200
|
|
|
201
|
+
const images = (Array.isArray(input.image) ? input.image : input.image ? [input.image] : []).filter(Boolean);
|
|
202
|
+
const offers = buildOffersNode(input.offer);
|
|
203
|
+
// Product takes `brand`; Course and Service take `provider`. Organization is
|
|
204
|
+
// a valid value for all three, so one node shape serves every type.
|
|
205
|
+
const brandKey = input.type === "Product" ? "brand" : "provider";
|
|
206
|
+
|
|
101
207
|
return {
|
|
102
208
|
"@context": "https://schema.org",
|
|
103
209
|
"@type": input.type,
|
|
104
|
-
name
|
|
210
|
+
name,
|
|
105
211
|
...(input.description ? { description: input.description } : {}),
|
|
106
|
-
...(
|
|
212
|
+
...(images.length > 0 ? { image: images.length === 1 ? images[0] : images } : {}),
|
|
107
213
|
...(input.url ? { url: input.url } : {}),
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
reviewCount: aggregate.reviewCount,
|
|
112
|
-
bestRating: 5,
|
|
113
|
-
worstRating: 1,
|
|
114
|
-
},
|
|
115
|
-
...(reviews.length > 0 ? { review: reviews } : {}),
|
|
116
|
-
...(input.offer
|
|
214
|
+
...(input.brand ? { [brandKey]: { "@type": "Organization", name: input.brand } } : {}),
|
|
215
|
+
...(offers ? { offers } : {}),
|
|
216
|
+
...(hasRatings
|
|
117
217
|
? {
|
|
118
|
-
|
|
119
|
-
"@type": "
|
|
120
|
-
|
|
121
|
-
|
|
218
|
+
aggregateRating: {
|
|
219
|
+
"@type": "AggregateRating",
|
|
220
|
+
ratingValue: Number(aggregate!.avgRating.toFixed(2)),
|
|
221
|
+
reviewCount: aggregate!.reviewCount,
|
|
222
|
+
bestRating: 5,
|
|
223
|
+
worstRating: 1,
|
|
122
224
|
},
|
|
123
225
|
}
|
|
124
226
|
: {}),
|
|
227
|
+
...(reviews.length > 0 ? { review: reviews } : {}),
|
|
125
228
|
};
|
|
126
229
|
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* @deprecated Use {@link buildEntitySchema}. Kept because Forge is a published
|
|
233
|
+
* SDK and a deployed site may import this name. Behaviour is now IDENTICAL —
|
|
234
|
+
* in particular it no longer returns null for an unreviewed entity.
|
|
235
|
+
*/
|
|
236
|
+
export const buildEntityReviewSchema = buildEntitySchema;
|
|
237
|
+
|
|
238
|
+
// ---- Mapping the public API payloads onto the schema --------------------------
|
|
239
|
+
//
|
|
240
|
+
// These live here (not in a route) so the client PWA and a code website derive
|
|
241
|
+
// the same JSON-LD from the same response, and a fix reaches both stacks.
|
|
242
|
+
|
|
243
|
+
/** Strip tags/entities and clamp, for a JSON-LD `description`. */
|
|
244
|
+
export function toSchemaText(html?: string | null, max = 500): string | undefined {
|
|
245
|
+
if (!html) return undefined;
|
|
246
|
+
const text = html
|
|
247
|
+
.replace(/<[^>]*>/g, " ")
|
|
248
|
+
.replace(/&[a-z]+;/gi, " ")
|
|
249
|
+
.replace(/\s+/g, " ")
|
|
250
|
+
.trim();
|
|
251
|
+
if (!text) return undefined;
|
|
252
|
+
return text.length > max ? `${text.slice(0, max - 1).trimEnd()}…` : text;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const imageUrls = (media?: { url: string; type?: string }[] | null) => {
|
|
256
|
+
const all = media ?? [];
|
|
257
|
+
const images = all.filter((m) => (m.type ?? "").startsWith("image")).map((m) => m.url);
|
|
258
|
+
// A product with no typed images still has SOMETHING to show; the storefront
|
|
259
|
+
// falls back to the first medium, so match it rather than emitting nothing.
|
|
260
|
+
return (images.length > 0 ? images : all.slice(0, 1).map((m) => m.url)).filter(Boolean).slice(0, 6);
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
/** Structural view of `GET /public/products/:id` — `IPublicProduct` satisfies it. */
|
|
264
|
+
export interface ProductSchemaSource {
|
|
265
|
+
title: string;
|
|
266
|
+
description?: string | null;
|
|
267
|
+
artist?: string | null;
|
|
268
|
+
media?: { url: string; type?: string }[] | null;
|
|
269
|
+
variants?: {
|
|
270
|
+
price: number | string;
|
|
271
|
+
availabilityStatus?: string;
|
|
272
|
+
payWhatYouWant?: boolean | null;
|
|
273
|
+
payWhatYouWantMaximum?: number | null;
|
|
274
|
+
}[];
|
|
275
|
+
reviewAggregate?: ReviewSchemaAggregate | null;
|
|
276
|
+
/** Present (and `allowed:false`) when the caller may not buy this. */
|
|
277
|
+
membershipGate?: { allowed: boolean } | null;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export interface SellableSchemaOptions {
|
|
281
|
+
/** The tenant's settlement currency. Without it NO offer is emitted. */
|
|
282
|
+
currency?: string | null;
|
|
283
|
+
/** Absolute canonical URL of the page. Omit rather than guess an origin. */
|
|
284
|
+
url?: string | null;
|
|
285
|
+
/** The creator's display name (Product `brand`, Course/Service `provider`). */
|
|
286
|
+
brand?: string | null;
|
|
287
|
+
/** A sample of published reviews, when the caller fetched them. */
|
|
288
|
+
reviews?: ReviewSchemaReview[];
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* The tenant facts every builder here needs, as a route can obtain them.
|
|
293
|
+
*
|
|
294
|
+
* ⚠️ A detail route must fetch this in its OWN loader (`fetchSeoContextServer`
|
|
295
|
+
* from `@tribe-nest/forge/server`) — NOT read it off the root match. During SSR,
|
|
296
|
+
* TanStack hands a child route's `head()` a `matches` array whose parent entries
|
|
297
|
+
* still carry `loaderData: null`, so the root's already-fetched site config is
|
|
298
|
+
* invisible there and every `offers` node silently disappears.
|
|
299
|
+
*/
|
|
300
|
+
export type SiteSeoContext = Pick<SellableSchemaOptions, "currency" | "brand">;
|
|
301
|
+
|
|
302
|
+
/** Narrow a tenant's site config down to the two facts the builders read. */
|
|
303
|
+
export function seoContextFromSiteConfig(
|
|
304
|
+
config?: { currency?: string; siteName?: string | null } | null,
|
|
305
|
+
): SiteSeoContext {
|
|
306
|
+
if (!config) return {};
|
|
307
|
+
return { currency: config.currency || undefined, brand: config.siteName || undefined };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* The offer for a product, derived from its variants.
|
|
312
|
+
*
|
|
313
|
+
* - No currency, no variants, or a membership gate the caller fails → no offer.
|
|
314
|
+
* - One fixed price → `Offer`.
|
|
315
|
+
* - Several prices, or pay-what-you-want (where `price` is the FLOOR) →
|
|
316
|
+
* `AggregateOffer`; an unbounded PWYW variant leaves `highPrice` off because
|
|
317
|
+
* there genuinely is no ceiling.
|
|
318
|
+
* - `availability` mirrors `availabilityStatus`, the exact field the
|
|
319
|
+
* Add-to-cart button reads on both stacks.
|
|
320
|
+
*/
|
|
321
|
+
function deriveProductOffer(product: ProductSchemaSource, currency?: string | null): EntityOfferInput | null {
|
|
322
|
+
if (!currency) return null;
|
|
323
|
+
if (product.membershipGate && product.membershipGate.allowed === false) return null;
|
|
324
|
+
|
|
325
|
+
const variants = (product.variants ?? [])
|
|
326
|
+
.map((v) => ({ ...v, price: toPrice(v.price) }))
|
|
327
|
+
.filter((v): v is typeof v & { price: number } => v.price !== undefined);
|
|
328
|
+
if (variants.length === 0) return null;
|
|
329
|
+
|
|
330
|
+
const availability: SchemaAvailability = variants.some((v) => (v.availabilityStatus ?? "active") === "active")
|
|
331
|
+
? "InStock"
|
|
332
|
+
: "OutOfStock";
|
|
333
|
+
|
|
334
|
+
const floors = variants.map((v) => v.price);
|
|
335
|
+
const lowPrice = Math.min(...floors);
|
|
336
|
+
const anyPayWhatYouWant = variants.some((v) => !!v.payWhatYouWant);
|
|
337
|
+
|
|
338
|
+
if (!anyPayWhatYouWant) {
|
|
339
|
+
const highPrice = Math.max(...floors);
|
|
340
|
+
if (lowPrice === highPrice) return { currency, price: lowPrice, availability };
|
|
341
|
+
return { currency, lowPrice, highPrice, offerCount: variants.length, availability };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const openEnded = variants.some((v) => v.payWhatYouWant && toPrice(v.payWhatYouWantMaximum) === undefined);
|
|
345
|
+
const ceilings = variants.map((v) => (v.payWhatYouWant ? (toPrice(v.payWhatYouWantMaximum) ?? v.price) : v.price));
|
|
346
|
+
return {
|
|
347
|
+
currency,
|
|
348
|
+
lowPrice,
|
|
349
|
+
...(openEnded ? {} : { highPrice: Math.max(...ceilings) }),
|
|
350
|
+
offerCount: variants.length,
|
|
351
|
+
availability,
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** schema.org/Product for a public product — emitted whether or not it has reviews. */
|
|
356
|
+
export function buildProductSchema(
|
|
357
|
+
product: ProductSchemaSource | null | undefined,
|
|
358
|
+
options: SellableSchemaOptions = {},
|
|
359
|
+
): Record<string, unknown> | null {
|
|
360
|
+
if (!product?.title) return null;
|
|
361
|
+
return buildEntitySchema({
|
|
362
|
+
type: "Product",
|
|
363
|
+
name: product.title,
|
|
364
|
+
description: toSchemaText(product.description),
|
|
365
|
+
image: imageUrls(product.media),
|
|
366
|
+
url: options.url ?? undefined,
|
|
367
|
+
// The release artist when there is one, otherwise the creator — both are
|
|
368
|
+
// the name a buyer would call the brand.
|
|
369
|
+
brand: product.artist?.trim() || options.brand || undefined,
|
|
370
|
+
aggregate: product.reviewAggregate ?? null,
|
|
371
|
+
reviews: options.reviews,
|
|
372
|
+
offer: deriveProductOffer(product, options.currency),
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** Structural view of `GET /public/courses/:id` — `PublicCourse` satisfies it. */
|
|
377
|
+
export interface CourseSchemaSource {
|
|
378
|
+
title: string;
|
|
379
|
+
description?: string | null;
|
|
380
|
+
media?: { url: string; type?: string }[] | null;
|
|
381
|
+
price?: number | string | null;
|
|
382
|
+
reviewAggregate?: ReviewSchemaAggregate | null;
|
|
383
|
+
membershipGate?: { allowed: boolean } | null;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* schema.org/Course for a public course.
|
|
388
|
+
*
|
|
389
|
+
* `hasCourseInstance` is deliberately absent: these courses are self-paced with
|
|
390
|
+
* no scheduled instance, and inventing one to chase the Course rich result
|
|
391
|
+
* would be a claim the platform cannot back.
|
|
392
|
+
*/
|
|
393
|
+
export function buildCourseSchema(
|
|
394
|
+
course: CourseSchemaSource | null | undefined,
|
|
395
|
+
options: SellableSchemaOptions = {},
|
|
396
|
+
): Record<string, unknown> | null {
|
|
397
|
+
if (!course?.title) return null;
|
|
398
|
+
const price = toPrice(course.price);
|
|
399
|
+
const gated = !!course.membershipGate && course.membershipGate.allowed === false;
|
|
400
|
+
return buildEntitySchema({
|
|
401
|
+
type: "Course",
|
|
402
|
+
name: course.title,
|
|
403
|
+
description: toSchemaText(course.description),
|
|
404
|
+
image: imageUrls(course.media),
|
|
405
|
+
url: options.url ?? undefined,
|
|
406
|
+
brand: options.brand ?? undefined,
|
|
407
|
+
aggregate: course.reviewAggregate ?? null,
|
|
408
|
+
reviews: options.reviews,
|
|
409
|
+
offer:
|
|
410
|
+
options.currency && !gated && price !== undefined
|
|
411
|
+
? { currency: options.currency, price, availability: "InStock" }
|
|
412
|
+
: null,
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** Structural view of `GET /public/coaching/products/:id` — `CoachingProduct` satisfies it. */
|
|
417
|
+
export interface ServiceSchemaSource {
|
|
418
|
+
title: string;
|
|
419
|
+
description?: string | null;
|
|
420
|
+
media?: { url: string; type?: string }[] | null;
|
|
421
|
+
price?: number | string | null;
|
|
422
|
+
reviewAggregate?: ReviewSchemaAggregate | null;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** schema.org/Service for a coaching product. */
|
|
426
|
+
export function buildServiceSchema(
|
|
427
|
+
service: ServiceSchemaSource | null | undefined,
|
|
428
|
+
options: SellableSchemaOptions = {},
|
|
429
|
+
): Record<string, unknown> | null {
|
|
430
|
+
if (!service?.title) return null;
|
|
431
|
+
const price = toPrice(service.price);
|
|
432
|
+
return buildEntitySchema({
|
|
433
|
+
type: "Service",
|
|
434
|
+
name: service.title,
|
|
435
|
+
description: toSchemaText(service.description),
|
|
436
|
+
image: imageUrls(service.media),
|
|
437
|
+
url: options.url ?? undefined,
|
|
438
|
+
brand: options.brand ?? undefined,
|
|
439
|
+
aggregate: service.reviewAggregate ?? null,
|
|
440
|
+
reviews: options.reviews,
|
|
441
|
+
offer:
|
|
442
|
+
options.currency && price !== undefined
|
|
443
|
+
? { currency: options.currency, price, availability: "InStock" }
|
|
444
|
+
: null,
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* The `scripts` entry a TanStack `head()` needs for a JSON-LD node. Returns an
|
|
450
|
+
* empty array for a null schema, so it can be spread unconditionally:
|
|
451
|
+
*
|
|
452
|
+
* return { ...buildHeadMeta(...), scripts: jsonLdScripts(buildProductSchema(...)) };
|
|
453
|
+
*/
|
|
454
|
+
export function jsonLdScripts(
|
|
455
|
+
...schemas: (Record<string, unknown> | null | undefined)[]
|
|
456
|
+
): { type: string; children: string }[] {
|
|
457
|
+
return schemas
|
|
458
|
+
.filter((s): s is Record<string, unknown> => !!s)
|
|
459
|
+
.map((s) => ({ type: "application/ld+json", children: JSON.stringify(s) }));
|
|
460
|
+
}
|