@tribe-nest/forge 3.20.0 → 3.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/package.json +1 -1
  2. package/src/data/queries/_tests/passTransfers.spec.ts +100 -4
  3. package/src/data/queries/useAccountSettings.ts +15 -2
  4. package/src/data/queries/useAuthActions.ts +51 -7
  5. package/src/data/queries/useEvents.ts +66 -4
  6. package/src/data/queries/useMembership.ts +8 -2
  7. package/src/data/queries/useMyBookings.ts +12 -0
  8. package/src/data/queries/useMyTickets.ts +112 -0
  9. package/src/data/queries/useOrders.ts +10 -0
  10. package/src/data/queries/usePassTransfers.ts +73 -13
  11. package/src/server/index.ts +52 -0
  12. package/src/types/models.ts +151 -0
  13. package/src/ui/format/_tests/attendees.spec.ts +231 -0
  14. package/src/ui/format/_tests/membershipGate.spec.ts +220 -0
  15. package/src/ui/format/attendees.ts +187 -0
  16. package/src/ui/format/membershipGate.ts +209 -0
  17. package/src/ui/headless/calendar/_tests/useAddToCalendar.spec.ts +83 -0
  18. package/src/ui/headless/calendar/useAddToCalendar.ts +46 -5
  19. package/src/ui/headless/checkout/_tests/inventoryHold.spec.ts +111 -0
  20. package/src/ui/headless/checkout/inventoryHold.ts +83 -0
  21. package/src/ui/headless/checkout/useCheckout.ts +72 -0
  22. package/src/ui/headless/checkout/useInventoryHold.ts +104 -0
  23. package/src/ui/headless/event/useEventCheckout.ts +133 -2
  24. package/src/ui/headless/event/usePresaleCode.ts +181 -0
  25. package/src/ui/headless/index.ts +25 -0
  26. package/src/ui/headless/membership/useMembershipGateNotice.ts +83 -0
  27. package/src/ui/headless/offer/OfferContext.tsx +55 -0
  28. package/src/ui/index.ts +42 -0
  29. package/src/ui/styled/AccountDashboard.tsx +70 -8
  30. package/src/ui/styled/AddToCalendar.tsx +34 -10
  31. package/src/ui/styled/Checkout.tsx +18 -1
  32. package/src/ui/styled/CoachingConfirmation.tsx +4 -0
  33. package/src/ui/styled/CourseDetail.tsx +30 -1
  34. package/src/ui/styled/EventConfirmation.tsx +2 -0
  35. package/src/ui/styled/EventDetail.tsx +53 -22
  36. package/src/ui/styled/EventTickets.tsx +156 -5
  37. package/src/ui/styled/HoldNotice.tsx +192 -0
  38. package/src/ui/styled/MembershipGateNotice.tsx +159 -0
  39. package/src/ui/styled/OfferButton.tsx +23 -0
  40. package/src/ui/styled/PresaleCode.tsx +174 -0
  41. package/src/ui/styled/ProductDetail.tsx +75 -5
  42. package/src/ui/styled/ProductGrid.tsx +26 -0
  43. package/src/ui/styled/TicketTransfer.tsx +69 -40
  44. package/src/ui/styled/_tests/AddToCalendar.spec.tsx +88 -0
  45. package/src/ui/styled/_tests/EventConfirmation.spec.tsx +5 -1
  46. package/src/ui/styled/_tests/PresaleCode.spec.tsx +106 -0
  47. package/src/utils/_tests/presaleCode.spec.ts +168 -0
  48. package/src/utils/_tests/structuredData.spec.ts +275 -0
  49. package/src/utils/presaleCode.ts +96 -0
  50. 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
- // ---- Reviews / AggregateRating ------------------------------------------------
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
- export interface EntityReviewSchemaInput {
66
- /** schema.org type of the reviewed thing. */
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
- image?: string | null;
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
- /** Optional offer (price in major units + ISO currency) for Product rich results. */
76
- offer?: { price: number; currency: string } | null;
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
- * Generates schema.org JSON-LD for a reviewed entity: the entity node with an
81
- * `AggregateRating` and a sample of `Review` children — the shape Google reads
82
- * for star rich snippets. Returns null when there is no aggregate (no
83
- * published reviews), so callers can skip the script tag entirely.
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 buildEntityReviewSchema(input: EntityReviewSchemaInput): Record<string, unknown> | null {
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
- if (!aggregate || aggregate.reviewCount <= 0) return null;
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: input.name,
210
+ name,
105
211
  ...(input.description ? { description: input.description } : {}),
106
- ...(input.image ? { image: input.image } : {}),
212
+ ...(images.length > 0 ? { image: images.length === 1 ? images[0] : images } : {}),
107
213
  ...(input.url ? { url: input.url } : {}),
108
- aggregateRating: {
109
- "@type": "AggregateRating",
110
- ratingValue: Number(aggregate.avgRating.toFixed(2)),
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
- offers: {
119
- "@type": "Offer",
120
- price: input.offer.price,
121
- priceCurrency: input.offer.currency,
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
+ }