@tribe-nest/forge 3.63.0 → 3.65.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/useCoachingAvailability.ts +18 -6
- package/src/data/queries/useMyBookings.ts +7 -0
- package/src/i18n/de.json +46 -0
- package/src/i18n/en.json +46 -0
- package/src/runtime/pages.ts +2 -0
- package/src/seo/_tests/seo.spec.ts +202 -6
- package/src/seo/applySeoToHead.ts +7 -1
- package/src/seo/index.ts +1 -0
- package/src/seo/markdown.ts +76 -0
- package/src/seo/resolve.ts +46 -3
- package/src/seo/seoHead.ts +6 -0
- package/src/seo/siteSeoHead.ts +24 -7
- package/src/seo/types.ts +15 -1
- package/src/server/_tests/buildPwaHead.spec.ts +18 -1
- package/src/server/pwa.ts +16 -1
- package/src/types/models.ts +13 -0
- package/src/ui/format/_tests/ticketPrice.spec.ts +58 -0
- package/src/ui/format/bookingPricing.ts +55 -0
- package/src/ui/format/ticketPrice.ts +52 -0
- package/src/ui/headless/coaching/useCoachingBooking.ts +139 -13
- package/src/ui/index.ts +13 -0
- package/src/ui/styled/AccountDashboard.tsx +11 -3
- package/src/ui/styled/BookingPriceSummary.tsx +57 -0
- package/src/ui/styled/CoachingBooking.tsx +36 -158
- package/src/ui/styled/CoachingConfirmation.tsx +13 -2
- package/src/ui/styled/CoachingDetail.tsx +8 -2
- package/src/ui/styled/CoachingSlotPicker.tsx +874 -0
- package/src/ui/styled/EventDetail.tsx +29 -6
- package/src/ui/styled/EventTickets.tsx +35 -11
- package/src/ui/styled/_tests/CoachingMultiSession.spec.tsx +299 -0
- package/src/ui/styled/_tests/FreeTickets.spec.tsx +242 -0
- package/src/ui/styled/broadcast/BroadcastTicketPurchase.tsx +18 -4
- package/src/ui/styled/forge-utilities.css +0 -5
- package/src/ui/styled/seating/EventSeatPicker.tsx +2 -0
- package/src/ui/styled/seating/SeatLegend.tsx +3 -1
- package/src/utils/_tests/structuredDataSeo.spec.ts +36 -0
- package/src/utils/structuredData.ts +28 -1
package/src/seo/index.ts
CHANGED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { normalizePathname } from "./resolve";
|
|
2
|
+
import type { SeoLinkEntry, SeoRecord } from "./types";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Readable copies for agents (docs/proposals/seo-initiative.md, section 5.4).
|
|
6
|
+
*
|
|
7
|
+
* The edge answers `Accept: text/markdown` or `?md` on a page with Markdown.
|
|
8
|
+
* These helpers decide which pages have such a copy, so a page only ever
|
|
9
|
+
* advertises one the edge will actually serve.
|
|
10
|
+
*
|
|
11
|
+
* KEEP IN STEP with `isMarkdownPath` in apps/dispatch-worker/src/markdown.ts.
|
|
12
|
+
* The dispatch Worker does not depend on Forge, so the rule is written twice;
|
|
13
|
+
* both specs run the same table of paths.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** Hosted families with a public page per item. Everything else under /i/ is private or transactional. */
|
|
17
|
+
export const MARKDOWN_HOSTED_FAMILIES = [
|
|
18
|
+
"store",
|
|
19
|
+
"events",
|
|
20
|
+
"courses",
|
|
21
|
+
"coaching",
|
|
22
|
+
"blog",
|
|
23
|
+
"blog-category",
|
|
24
|
+
"podcasts",
|
|
25
|
+
"films",
|
|
26
|
+
"series",
|
|
27
|
+
"communities",
|
|
28
|
+
] as const;
|
|
29
|
+
|
|
30
|
+
/** Segments that mark a step in a flow, not a page about something. */
|
|
31
|
+
const FLOW_SEGMENTS = new Set(["finalise", "library", "claim", "watch"]);
|
|
32
|
+
|
|
33
|
+
/** Whether the edge serves a Markdown copy of this path. */
|
|
34
|
+
export function isMarkdownPath(pathname: string): boolean {
|
|
35
|
+
const path = normalizePathname(pathname);
|
|
36
|
+
if (path.startsWith("/api/") || path.startsWith("/__tn/") || path.startsWith("/.well-known/")) return false;
|
|
37
|
+
const segments = path.split("/").filter(Boolean);
|
|
38
|
+
// A file, not a page: `/logo.png`, `/sw.js`, `/robots.txt`.
|
|
39
|
+
const last = segments[segments.length - 1] ?? "";
|
|
40
|
+
if (last.includes(".")) return false;
|
|
41
|
+
if (segments[0] !== "i") return true;
|
|
42
|
+
|
|
43
|
+
const [, family, ...rest] = segments;
|
|
44
|
+
if (!family || !(MARKDOWN_HOSTED_FAMILIES as readonly string[]).includes(family)) return false;
|
|
45
|
+
if (rest.some((s) => FLOW_SEGMENTS.has(s))) return false;
|
|
46
|
+
// The index and one item. A podcast episode is the one page a level deeper.
|
|
47
|
+
return rest.length <= (family === "podcasts" ? 2 : 1);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Where the Markdown copy of a page lives: the canonical with `md` added, when
|
|
52
|
+
* the canonical is one of this profile's websites (they all serve Markdown).
|
|
53
|
+
* A canonical pointing anywhere else may not, so the page's own URL is used.
|
|
54
|
+
*/
|
|
55
|
+
export function markdownAlternateHref(seo: SeoRecord, pathname: string, canonical?: string | null): string {
|
|
56
|
+
const own = [seo.origin, seo.defaultWebsiteOrigin].filter(Boolean).map((o) => o.replace(/\/+$/, ""));
|
|
57
|
+
const onOurs = !!canonical && own.some((o) => canonical === o || canonical.startsWith(`${o}/`));
|
|
58
|
+
const path = normalizePathname(pathname);
|
|
59
|
+
const base = onOurs ? canonical! : `${seo.origin.replace(/\/+$/, "")}${path === "/" ? "/" : path}`;
|
|
60
|
+
return `${base}${base.includes("?") ? "&" : "?"}md`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The `<link rel="alternate" type="text/markdown">` for an indexable page, or
|
|
65
|
+
* null when there is no copy to point at (readable copies off, a noindex page,
|
|
66
|
+
* a path the edge does not convert).
|
|
67
|
+
*/
|
|
68
|
+
export function markdownAlternateLink(
|
|
69
|
+
seo: SeoRecord,
|
|
70
|
+
pathname: string,
|
|
71
|
+
options: { canonical?: string | null; noindex?: boolean },
|
|
72
|
+
): SeoLinkEntry | null {
|
|
73
|
+
if (seo.markdownEnabled === false || options.noindex || seo.indexing === false) return null;
|
|
74
|
+
if (!isMarkdownPath(pathname)) return null;
|
|
75
|
+
return { rel: "alternate", type: "text/markdown", href: markdownAlternateHref(seo, pathname, options.canonical) };
|
|
76
|
+
}
|
package/src/seo/resolve.ts
CHANGED
|
@@ -40,8 +40,36 @@ export function seoRecordFromContext(ctx: SeoHeadContext | undefined | null): Se
|
|
|
40
40
|
return seo && typeof seo === "object" && typeof seo.siteName === "string" ? seo : null;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
/**
|
|
43
|
+
/**
|
|
44
|
+
* Whether this render is a not-found page: a URL no route matches, or a route
|
|
45
|
+
* whose loader threw `notFound()`. TanStack marks the boundary match with
|
|
46
|
+
* `_notFound` and the throwing match with `status: "notFound"`.
|
|
47
|
+
*/
|
|
48
|
+
export function isNotFoundContext(ctx: SeoHeadContext | undefined | null): boolean {
|
|
49
|
+
const all = [...(ctx?.matches ?? []), ctx?.match];
|
|
50
|
+
return all.some((m) => !!m && (m._notFound === true || m.status === "notFound"));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The browser's own pathname, when there is a browser. Undefined on the server. */
|
|
54
|
+
function locationPathname(): string | undefined {
|
|
55
|
+
const loc = (globalThis as { location?: { pathname?: unknown } }).location;
|
|
56
|
+
return typeof loc?.pathname === "string" && loc.pathname ? loc.pathname : undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The pathname of the page being rendered: the leaf match's.
|
|
61
|
+
*
|
|
62
|
+
* A not-found render is the exception. A URL no route matches has only the
|
|
63
|
+
* root match left, whose pathname is "/", so the leaf would claim to be the
|
|
64
|
+
* home page. There the location is used when there is one (in the browser),
|
|
65
|
+
* and the leaf only when nothing better is known. Callers must not state a
|
|
66
|
+
* canonical for a not-found render whatever this returns (see siteSeoHead).
|
|
67
|
+
*/
|
|
44
68
|
export function pathnameFromContext(ctx: SeoHeadContext | undefined | null): string {
|
|
69
|
+
if (isNotFoundContext(ctx)) {
|
|
70
|
+
const fromLocation = locationPathname();
|
|
71
|
+
if (fromLocation) return normalizePathname(fromLocation);
|
|
72
|
+
}
|
|
45
73
|
const matches = ctx?.matches ?? [];
|
|
46
74
|
for (let i = matches.length - 1; i >= 0; i--) {
|
|
47
75
|
const pathname = matches[i]?.pathname;
|
|
@@ -97,9 +125,20 @@ export function fillTemplate(template: string, values: { title: string; site: st
|
|
|
97
125
|
.trim();
|
|
98
126
|
}
|
|
99
127
|
|
|
128
|
+
/**
|
|
129
|
+
* Whether a stored title template can be used: it must say where the page's
|
|
130
|
+
* title goes. The API refuses one without `{title}`, but a record saved before
|
|
131
|
+
* that check (or by an older API) may still carry "%s | Clay Studio", which
|
|
132
|
+
* would otherwise become every page's literal title.
|
|
133
|
+
*/
|
|
134
|
+
export function isUsableTitleTemplate(template: string | null | undefined): template is string {
|
|
135
|
+
return typeof template === "string" && template.includes("{title}");
|
|
136
|
+
}
|
|
137
|
+
|
|
100
138
|
/**
|
|
101
139
|
* The document title for a code title.
|
|
102
140
|
*
|
|
141
|
+
* A template without `{title}` is ignored in favour of the default.
|
|
103
142
|
* The template is skipped when the title already names the site, so a code
|
|
104
143
|
* title of "Clay Studio | Pottery in Hackney" does not become
|
|
105
144
|
* "Clay Studio | Pottery in Hackney | Clay Studio".
|
|
@@ -110,7 +149,8 @@ export function applyTitleTemplate(title: string, siteName: string, template?: s
|
|
|
110
149
|
if (!site) return base;
|
|
111
150
|
if (!base) return site;
|
|
112
151
|
if (base.toLowerCase().includes(site.toLowerCase())) return base;
|
|
113
|
-
const
|
|
152
|
+
const usable = isUsableTitleTemplate(template?.trim()) ? template!.trim() : DEFAULT_TITLE_TEMPLATE;
|
|
153
|
+
const filled = fillTemplate(usable, { title: base, site });
|
|
114
154
|
return filled || base;
|
|
115
155
|
}
|
|
116
156
|
|
|
@@ -183,7 +223,10 @@ export function resolvePageSeo(seo: SeoRecord | null | undefined, input: Resolve
|
|
|
183
223
|
shareTitle = title;
|
|
184
224
|
} else {
|
|
185
225
|
shareTitle = codeTitle || site;
|
|
186
|
-
|
|
226
|
+
// The page type's template, else the site's: whichever first says where the
|
|
227
|
+
// title goes. Neither usable means the default.
|
|
228
|
+
const template = [typeTemplate?.title, seo.titleTemplate].find(isUsableTitleTemplate);
|
|
229
|
+
title = applyTitleTemplate(shareTitle, site, template);
|
|
187
230
|
}
|
|
188
231
|
|
|
189
232
|
const description =
|
package/src/seo/seoHead.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
isNotFoundContext,
|
|
2
3
|
pathnameFromContext,
|
|
3
4
|
resolvePageSeo,
|
|
4
5
|
routePatternFromContext,
|
|
@@ -57,5 +58,10 @@ export function seoHead(ctx: SeoHeadContext | undefined | null, input: SeoHeadIn
|
|
|
57
58
|
image: input.image,
|
|
58
59
|
noindex: input.noindex,
|
|
59
60
|
});
|
|
61
|
+
// A page whose loader threw notFound() renders the not-found page under its
|
|
62
|
+
// own URL. That is not a page to index or to share as itself.
|
|
63
|
+
if (isNotFoundContext(ctx)) {
|
|
64
|
+
return { meta: pageMeta({ ...resolved, noindex: true, canonical: null }) };
|
|
65
|
+
}
|
|
60
66
|
return { meta: [...pageMeta(resolved), ...jsonLdMeta(input.jsonLd)] };
|
|
61
67
|
}
|
package/src/seo/siteSeoHead.ts
CHANGED
|
@@ -3,10 +3,12 @@ import {
|
|
|
3
3
|
canonicalFor,
|
|
4
4
|
findOverride,
|
|
5
5
|
isHostedPath,
|
|
6
|
+
isNotFoundContext,
|
|
6
7
|
leafRoutePatternFromContext,
|
|
7
8
|
pathnameFromContext,
|
|
8
9
|
seoRecordFromContext,
|
|
9
10
|
} from "./resolve";
|
|
11
|
+
import { markdownAlternateLink } from "./markdown";
|
|
10
12
|
import { jsonLdMeta } from "./seoHead";
|
|
11
13
|
import type { SeoHeadContext, SeoLinkEntry, SeoMetaEntry } from "./types";
|
|
12
14
|
|
|
@@ -30,10 +32,16 @@ export type SiteSeoFallback = {
|
|
|
30
32
|
* than replaced. It is skipped under `/i/`, where the hosted page emits its
|
|
31
33
|
* own (an entity's canonical is on the default website, which only that page
|
|
32
34
|
* knows to say);
|
|
33
|
-
* - the WebSite and identity JSON-LD, on "/" only
|
|
35
|
+
* - the WebSite and identity JSON-LD, on "/" only;
|
|
36
|
+
* - the `alternate` link to the page's Markdown copy, beside the canonical.
|
|
34
37
|
*
|
|
35
38
|
* `robots` is set here when the whole site is noindex or this path's override
|
|
36
39
|
* says so, so it holds even on a page whose code never calls `seoHead`.
|
|
40
|
+
*
|
|
41
|
+
* A not-found render states none of the three: no canonical, no Markdown copy
|
|
42
|
+
* and no site JSON-LD, and it is `noindex`. On a URL no route matches, the only
|
|
43
|
+
* match left is the root's, at "/", so without this every 404 would claim to
|
|
44
|
+
* be the home page.
|
|
37
45
|
*/
|
|
38
46
|
export function siteSeoHead(
|
|
39
47
|
ctx: SeoHeadContext | undefined | null,
|
|
@@ -55,10 +63,13 @@ export function siteSeoHead(
|
|
|
55
63
|
};
|
|
56
64
|
}
|
|
57
65
|
|
|
58
|
-
const
|
|
66
|
+
const notFound = isNotFoundContext(ctx);
|
|
67
|
+
// A missing page has no override: one keyed to "/" or to the route pattern
|
|
68
|
+
// belongs to a page that exists.
|
|
69
|
+
const override = notFound ? null : findOverride(seo, pathname, leafRoutePatternFromContext(ctx));
|
|
59
70
|
const description = seo.defaultDescription || fallback.description || undefined;
|
|
60
71
|
const image = seo.defaultImage || fallback.image || undefined;
|
|
61
|
-
const noindex = seo.indexing === false || override?.noindex === true;
|
|
72
|
+
const noindex = notFound || seo.indexing === false || override?.noindex === true;
|
|
62
73
|
|
|
63
74
|
const meta: SeoMetaEntry[] = [
|
|
64
75
|
{ title: seo.siteName },
|
|
@@ -71,7 +82,7 @@ export function siteSeoHead(
|
|
|
71
82
|
...(noindex ? [{ name: "robots", content: "noindex, nofollow" }] : []),
|
|
72
83
|
];
|
|
73
84
|
|
|
74
|
-
if (pathname === "/") {
|
|
85
|
+
if (pathname === "/" && !notFound) {
|
|
75
86
|
const origin = seo.origin.replace(/\/+$/, "");
|
|
76
87
|
const identity = buildIdentitySchema(seo.identity, { origin });
|
|
77
88
|
meta.push(
|
|
@@ -89,9 +100,15 @@ export function siteSeoHead(
|
|
|
89
100
|
);
|
|
90
101
|
}
|
|
91
102
|
|
|
92
|
-
const links: SeoLinkEntry[] =
|
|
93
|
-
|
|
94
|
-
|
|
103
|
+
const links: SeoLinkEntry[] = [];
|
|
104
|
+
if (!notFound && !isHostedPath(pathname)) {
|
|
105
|
+
const canonical = canonicalFor(seo, pathname, override);
|
|
106
|
+
links.push({ rel: "canonical", href: canonical });
|
|
107
|
+
// The page's readable copy for agents (section 5.4). A hosted page states
|
|
108
|
+
// its own, beside its own canonical (see applySeoToHead).
|
|
109
|
+
const markdown = markdownAlternateLink(seo, pathname, { canonical, noindex });
|
|
110
|
+
if (markdown) links.push(markdown);
|
|
111
|
+
}
|
|
95
112
|
|
|
96
113
|
return { meta, links };
|
|
97
114
|
}
|
package/src/seo/types.ts
CHANGED
|
@@ -53,6 +53,12 @@ export type SeoRecord = {
|
|
|
53
53
|
verification: { google?: string | null; bing?: string | null };
|
|
54
54
|
/** false = the whole site is noindex. */
|
|
55
55
|
indexing: boolean;
|
|
56
|
+
/**
|
|
57
|
+
* false = readable copies are off: the edge serves no Markdown and pages do
|
|
58
|
+
* not advertise one. Optional because an API older than Forge 3.64 omits it,
|
|
59
|
+
* which means on.
|
|
60
|
+
*/
|
|
61
|
+
markdownEnabled?: boolean;
|
|
56
62
|
/**
|
|
57
63
|
* Key = exact pathname ("/about", "/i/store/mug") OR route pattern
|
|
58
64
|
* ("/team/$slug"). Entity overrides arrive already expanded to their /i/
|
|
@@ -84,6 +90,14 @@ export type SeoMatchLike = {
|
|
|
84
90
|
routeId?: string;
|
|
85
91
|
fullPath?: string;
|
|
86
92
|
loaderData?: unknown;
|
|
93
|
+
/** "notFound" on the match whose loader threw `notFound()`. */
|
|
94
|
+
status?: string;
|
|
95
|
+
/**
|
|
96
|
+
* TanStack sets this on the match that renders the not-found boundary. On a
|
|
97
|
+
* URL no route matches, that is the ROOT match, whose pathname is "/": read
|
|
98
|
+
* without this flag, every unknown URL looks like the home page.
|
|
99
|
+
*/
|
|
100
|
+
_notFound?: boolean;
|
|
87
101
|
};
|
|
88
102
|
|
|
89
103
|
export type SeoHeadContext = {
|
|
@@ -96,4 +110,4 @@ export type SeoHeadContext = {
|
|
|
96
110
|
/** One `head()` meta entry. A JSON-LD node rides as `{ "script:ld+json": obj }`. */
|
|
97
111
|
export type SeoMetaEntry = Record<string, unknown>;
|
|
98
112
|
|
|
99
|
-
export type SeoLinkEntry = { rel: string; href: string };
|
|
113
|
+
export type SeoLinkEntry = { rel: string; href: string; type?: string };
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// - apple-touch-icon derive (icon192 preferred, icon512 fallback, absent → omit)
|
|
8
8
|
// - theme-color derivation + default fallback (bare + localized + no doc)
|
|
9
9
|
// - manifestHref override / default
|
|
10
|
-
// - apple-mobile-web-app-title fallback chain (shortName → name → siteName → "App")
|
|
10
|
+
// - apple-mobile-web-app-title fallback chain (shortName → name → SEO siteName → siteName → "App")
|
|
11
11
|
// - null/undefined siteConfig degradation (never throws)
|
|
12
12
|
// - the known client bug this fixes: apple-touch-icon is always emitted when an icon exists
|
|
13
13
|
//
|
|
@@ -165,6 +165,23 @@ describe("buildPwaHead — apple-mobile-web-app-title fallback chain", () => {
|
|
|
165
165
|
const head = buildPwaHead({ siteConfig: siteConfig({ pwa: null, siteName: null }) });
|
|
166
166
|
expect(metaFor(head.metas, "apple-mobile-web-app-title")?.content).toBe("App");
|
|
167
167
|
});
|
|
168
|
+
|
|
169
|
+
it("prefers the SEO record's site name over the site config's (the profile's)", () => {
|
|
170
|
+
const head = buildPwaHead({ siteConfig: siteConfig({ pwa: null, siteName: "Ada Potter" }), seo: { siteName: "Clay Studio" } });
|
|
171
|
+
expect(metaFor(head.metas, "apple-mobile-web-app-title")?.content).toBe("Clay Studio");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("keeps an explicit PWA name ahead of the SEO site name", () => {
|
|
175
|
+
const head = buildPwaHead({ siteConfig: siteConfig({ pwa: fullPwa({ shortName: "Nv" }) }), seo: { siteName: "Clay Studio" } });
|
|
176
|
+
expect(metaFor(head.metas, "apple-mobile-web-app-title")?.content).toBe("Nv");
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("ignores a blank SEO site name, and no record at all", () => {
|
|
180
|
+
const blank = buildPwaHead({ siteConfig: siteConfig({ pwa: null, siteName: "Ada Potter" }), seo: { siteName: " " } });
|
|
181
|
+
expect(metaFor(blank.metas, "apple-mobile-web-app-title")?.content).toBe("Ada Potter");
|
|
182
|
+
const none = buildPwaHead({ siteConfig: siteConfig({ pwa: null, siteName: "Ada Potter" }), seo: null });
|
|
183
|
+
expect(metaFor(none.metas, "apple-mobile-web-app-title")?.content).toBe("Ada Potter");
|
|
184
|
+
});
|
|
168
185
|
});
|
|
169
186
|
|
|
170
187
|
describe("buildPwaHead — degradation (never throws)", () => {
|
package/src/server/pwa.ts
CHANGED
|
@@ -178,6 +178,13 @@ export interface BuildPwaHeadOptions {
|
|
|
178
178
|
contentDoc?: ContentDocument | null;
|
|
179
179
|
/** Where the manifest route lives (default `/manifest.webmanifest`). */
|
|
180
180
|
manifestHref?: string;
|
|
181
|
+
/**
|
|
182
|
+
* The website's SEO record, as the root loader returned it. Its `siteName` is
|
|
183
|
+
* what the creator calls the site in search, so the Home Screen label uses it
|
|
184
|
+
* ahead of the site config's name (the profile's). A PWA name the creator set
|
|
185
|
+
* explicitly still wins: that is the app's own label.
|
|
186
|
+
*/
|
|
187
|
+
seo?: { siteName?: string | null } | null;
|
|
181
188
|
}
|
|
182
189
|
|
|
183
190
|
/** A TanStack `head()`-shaped link entry. */
|
|
@@ -219,7 +226,15 @@ export function buildPwaHead(opts: BuildPwaHeadOptions): PwaHead {
|
|
|
219
226
|
{ name: "mobile-web-app-capable", content: "yes" },
|
|
220
227
|
{ name: "apple-mobile-web-app-capable", content: "yes" },
|
|
221
228
|
{ name: "apple-mobile-web-app-status-bar-style", content: "default" },
|
|
222
|
-
{
|
|
229
|
+
{
|
|
230
|
+
name: "apple-mobile-web-app-title",
|
|
231
|
+
content:
|
|
232
|
+
siteConfig?.pwa?.shortName ||
|
|
233
|
+
siteConfig?.pwa?.name ||
|
|
234
|
+
opts.seo?.siteName?.trim() ||
|
|
235
|
+
siteConfig?.siteName ||
|
|
236
|
+
"App",
|
|
237
|
+
},
|
|
223
238
|
];
|
|
224
239
|
return { links, metas };
|
|
225
240
|
}
|
package/src/types/models.ts
CHANGED
|
@@ -1097,6 +1097,17 @@ export type CoachingProduct = {
|
|
|
1097
1097
|
reviewAggregate?: IReviewAggregate | null;
|
|
1098
1098
|
/** The seller's CURRENT cancellation terms — present on the detail endpoint only. */
|
|
1099
1099
|
cancellation?: CancellationTermsView | null;
|
|
1100
|
+
/**
|
|
1101
|
+
* How the product is sold. Absent on older backends, which means one session.
|
|
1102
|
+
* `package`: exactly `packageSessionCount` sessions for `packagePrice`, and
|
|
1103
|
+
* `price` is the single-session price shown as the saving (0 = none).
|
|
1104
|
+
* `per_session`: 1 to `maxSessionsPerBooking` sessions at `price` each.
|
|
1105
|
+
* The buyer picks every session in one booking.
|
|
1106
|
+
*/
|
|
1107
|
+
pricingMode?: "single" | "package" | "per_session";
|
|
1108
|
+
packageSessionCount?: number | null;
|
|
1109
|
+
packagePrice?: string | number | null;
|
|
1110
|
+
maxSessionsPerBooking?: number | null;
|
|
1100
1111
|
};
|
|
1101
1112
|
|
|
1102
1113
|
// ---- Membership gates (S.4) --------------------------------------------------
|
|
@@ -1862,6 +1873,8 @@ export type CoachingBooking = {
|
|
|
1862
1873
|
productTitle?: string | null;
|
|
1863
1874
|
durationMinutes?: number | null;
|
|
1864
1875
|
timezone?: string | null;
|
|
1876
|
+
/** Every session of a multi-session booking, earliest first. Absent for one session. */
|
|
1877
|
+
sessions?: { startTime: string; endTime: string }[];
|
|
1865
1878
|
/**
|
|
1866
1879
|
* The session's `.ics`, both forms — see {@link CalendarFeedLinks}. Null
|
|
1867
1880
|
* unless the booking is CONFIRMED: a held slot is not a session, and offering
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { isFreeTicket, isSingleFreeTicketEvent, summarizeTicketPrices } from "../ticketPrice";
|
|
3
|
+
|
|
4
|
+
const tier = (price: number, payWhatYouWant = false) => ({ price, payWhatYouWant });
|
|
5
|
+
|
|
6
|
+
describe("isFreeTicket", () => {
|
|
7
|
+
it("is true for a zero price", () => {
|
|
8
|
+
expect(isFreeTicket(tier(0))).toBe(true);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("is false for a paid tier", () => {
|
|
12
|
+
expect(isFreeTicket(tier(12))).toBe(false);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("is false for a PWYW tier with a zero floor: the floor is not a gift", () => {
|
|
16
|
+
expect(isFreeTicket(tier(0, true))).toBe(false);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("reads a decimal string price from the wire", () => {
|
|
20
|
+
expect(isFreeTicket({ price: "0.00" as unknown as number })).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe("summarizeTicketPrices", () => {
|
|
25
|
+
it("all free reads free", () => {
|
|
26
|
+
expect(summarizeTicketPrices([tier(0), tier(0)])).toEqual({ kind: "free" });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("free and paid reads free to the dearest tier", () => {
|
|
30
|
+
expect(summarizeTicketPrices([tier(0), tier(25), tier(40)])).toEqual({ kind: "free_to", max: 40 });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("all paid keeps the cheapest tier", () => {
|
|
34
|
+
expect(summarizeTicketPrices([tier(40), tier(25)])).toEqual({ kind: "from", min: 25 });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("a zero-floor PWYW tier keeps the From wording", () => {
|
|
38
|
+
expect(summarizeTicketPrices([tier(0, true), tier(20)])).toEqual({ kind: "from", min: 0 });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("no tiers summarise to nothing", () => {
|
|
42
|
+
expect(summarizeTicketPrices([])).toBeNull();
|
|
43
|
+
expect(summarizeTicketPrices(undefined)).toBeNull();
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("isSingleFreeTicketEvent", () => {
|
|
48
|
+
it("one free tier is a reservation", () => {
|
|
49
|
+
expect(isSingleFreeTicketEvent([tier(0)])).toBe(true);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("two free tiers, one paid tier, or one PWYW tier are not", () => {
|
|
53
|
+
expect(isSingleFreeTicketEvent([tier(0), tier(0)])).toBe(false);
|
|
54
|
+
expect(isSingleFreeTicketEvent([tier(10)])).toBe(false);
|
|
55
|
+
expect(isSingleFreeTicketEvent([tier(0, true)])).toBe(false);
|
|
56
|
+
expect(isSingleFreeTicketEvent([])).toBe(false);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { CoachingProduct } from "../../types/models";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* How a booking product is priced when one booking can hold several sessions.
|
|
5
|
+
*
|
|
6
|
+
* Mirrors the backend's `db/types/bookingPricing.ts`, which is the authority:
|
|
7
|
+
* the server re-prices at reserve and at the details step, so these numbers
|
|
8
|
+
* are only ever what the buyer is SHOWN before that. Kept in Forge rather than
|
|
9
|
+
* imported because the storefront ships without the backend.
|
|
10
|
+
*/
|
|
11
|
+
export type BookingPricingMode = "single" | "package" | "per_session";
|
|
12
|
+
|
|
13
|
+
type PricingFields = Pick<CoachingProduct, "price"> &
|
|
14
|
+
Partial<Pick<CoachingProduct, "pricingMode" | "packageSessionCount" | "packagePrice" | "maxSessionsPerBooking">>;
|
|
15
|
+
|
|
16
|
+
export function bookingPricingMode(product: PricingFields | null | undefined): BookingPricingMode {
|
|
17
|
+
const mode = product?.pricingMode;
|
|
18
|
+
return mode === "package" || mode === "per_session" ? mode : "single";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The number of sessions one booking must hold, inclusive. */
|
|
22
|
+
export function bookingSessionRange(product: PricingFields | null | undefined): { min: number; max: number } {
|
|
23
|
+
const mode = bookingPricingMode(product);
|
|
24
|
+
if (mode === "package") {
|
|
25
|
+
const n = Math.max(1, Number(product?.packageSessionCount ?? 1));
|
|
26
|
+
return { min: n, max: n };
|
|
27
|
+
}
|
|
28
|
+
if (mode === "per_session") return { min: 1, max: Math.max(1, Number(product?.maxSessionsPerBooking ?? 1)) };
|
|
29
|
+
return { min: 1, max: 1 };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const money = (value: unknown) => {
|
|
33
|
+
const n = Number(value ?? 0);
|
|
34
|
+
return Number.isFinite(n) && n > 0 ? Math.round(n * 100) / 100 : 0;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** The gross price of `sessionCount` sessions, before any discount or tax. */
|
|
38
|
+
export function bookingGrossAmount(product: PricingFields | null | undefined, sessionCount: number): number {
|
|
39
|
+
const mode = bookingPricingMode(product);
|
|
40
|
+
if (mode === "package") return money(product?.packagePrice);
|
|
41
|
+
if (mode === "per_session") return Math.round(money(product?.price) * 100 * sessionCount) / 100;
|
|
42
|
+
return money(product?.price);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* What a package saves against buying its sessions one at a time. 0 when the
|
|
47
|
+
* creator gave no single-session price, or the package is not cheaper.
|
|
48
|
+
*/
|
|
49
|
+
export function packageSaving(product: PricingFields | null | undefined): number {
|
|
50
|
+
if (bookingPricingMode(product) !== "package") return 0;
|
|
51
|
+
const single = money(product?.price);
|
|
52
|
+
const count = Number(product?.packageSessionCount ?? 0);
|
|
53
|
+
if (!single || !count) return 0;
|
|
54
|
+
return Math.max(0, Math.round((single * count - money(product?.packagePrice)) * 100) / 100);
|
|
55
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { ITicket } from "../../types/models";
|
|
2
|
+
import { isPayWhatYouWant } from "./pwyw";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Free event tickets, as a buyer-facing surface has to word them.
|
|
6
|
+
*
|
|
7
|
+
* A tier priced at zero is FREE, and "$0.00" reads as a price, not as a gift.
|
|
8
|
+
* Every surface that quotes a ticket price runs it through these helpers so a
|
|
9
|
+
* free tier says "Free" everywhere, and a checkout with nothing to pay says
|
|
10
|
+
* "Reserve" rather than "Pay $0.00".
|
|
11
|
+
*
|
|
12
|
+
* Pay-what-you-want is the exception that matters: on a PWYW tier `price` is
|
|
13
|
+
* the FLOOR, so a zero floor means "pay what you like, from nothing", not
|
|
14
|
+
* "free". Those tiers keep their existing "From" wording.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
type PricedTicket = Pick<ITicket, "price"> & Partial<Pick<ITicket, "payWhatYouWant">>;
|
|
18
|
+
|
|
19
|
+
/** A tier with nothing to pay: priced at zero and not pay-what-you-want. */
|
|
20
|
+
export const isFreeTicket = (ticket: PricedTicket): boolean =>
|
|
21
|
+
!isPayWhatYouWant(ticket as ITicket) && Number(ticket.price) === 0;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The one-line price summary an event page shows beside its buy button.
|
|
25
|
+
*
|
|
26
|
+
* - every tier free: `free`
|
|
27
|
+
* - some free, some paid: `free_to` with the dearest tier, so the page reads
|
|
28
|
+
* "Free to $40" instead of "From $0.00"
|
|
29
|
+
* - none free: `from` with the cheapest tier, which is the existing wording
|
|
30
|
+
*
|
|
31
|
+
* `null` for no tiers at all, so the caller renders nothing.
|
|
32
|
+
*/
|
|
33
|
+
export type TicketPriceSummary =
|
|
34
|
+
| { kind: "free" }
|
|
35
|
+
| { kind: "free_to"; max: number }
|
|
36
|
+
| { kind: "from"; min: number };
|
|
37
|
+
|
|
38
|
+
export function summarizeTicketPrices(tickets: readonly PricedTicket[] | null | undefined): TicketPriceSummary | null {
|
|
39
|
+
if (!tickets || tickets.length === 0) return null;
|
|
40
|
+
const free = tickets.filter(isFreeTicket);
|
|
41
|
+
if (free.length === tickets.length) return { kind: "free" };
|
|
42
|
+
const prices = tickets.map((ticket) => Number(ticket.price)).filter((price) => Number.isFinite(price));
|
|
43
|
+
if (free.length > 0) return { kind: "free_to", max: Math.max(...prices) };
|
|
44
|
+
return { kind: "from", min: prices.length ? Math.min(...prices) : 0 };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The event sells exactly one tier and it is free, so the call to action is a
|
|
49
|
+
* reservation, not a purchase ("Reserve" rather than "Buy tickets").
|
|
50
|
+
*/
|
|
51
|
+
export const isSingleFreeTicketEvent = (tickets: readonly PricedTicket[] | null | undefined): boolean =>
|
|
52
|
+
!!tickets && tickets.length === 1 && isFreeTicket(tickets[0]);
|