@tribe-nest/forge 3.60.0 → 3.63.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 (220) hide show
  1. package/package.json +5 -2
  2. package/src/contexts/CartContext.tsx +34 -1
  3. package/src/contexts/PublicAuthContext.tsx +15 -3
  4. package/src/data/queries/_tests/metaSignals.spec.tsx +130 -0
  5. package/src/data/queries/useAccount.ts +33 -2
  6. package/src/data/queries/useAnalytics.ts +6 -6
  7. package/src/data/queries/useCallChat.ts +2 -2
  8. package/src/data/queries/useChat.ts +10 -1
  9. package/src/data/queries/useCheckouts.ts +2 -1
  10. package/src/data/queries/useCoachingAvailability.ts +10 -1
  11. package/src/data/queries/useCommunities.ts +2 -0
  12. package/src/data/queries/useCommunity.ts +362 -18
  13. package/src/data/queries/useCommunityAgent.ts +247 -0
  14. package/src/data/queries/useCommunityCalendar.ts +150 -0
  15. package/src/data/queries/useCommunityClassroom.ts +75 -0
  16. package/src/data/queries/useCommunityMembers.ts +108 -0
  17. package/src/data/queries/useCommunityModeration.ts +327 -0
  18. package/src/data/queries/useCommunityWiki.ts +137 -0
  19. package/src/data/queries/useCourses.ts +2 -0
  20. package/src/data/queries/useEmailSignIn.ts +79 -0
  21. package/src/data/queries/useEvents.ts +6 -1
  22. package/src/data/queries/useForms.ts +5 -1
  23. package/src/data/queries/useLeadMagnet.ts +6 -2
  24. package/src/data/queries/useMyBookings.ts +24 -1
  25. package/src/data/queries/useMyTickets.ts +24 -1
  26. package/src/data/queries/useNotifications.ts +6 -2
  27. package/src/data/queries/useOrders.ts +2 -1
  28. package/src/data/queries/usePWA.ts +111 -35
  29. package/src/data/queries/useSiteApp.ts +130 -0
  30. package/src/data/queries/useSubscriptions.ts +6 -1
  31. package/src/i18n/de.json +746 -1
  32. package/src/i18n/en.json +746 -1
  33. package/src/index.ts +20 -0
  34. package/src/runtime/pages.spec.ts +5 -0
  35. package/src/runtime/pages.ts +32 -6
  36. package/src/seo/_tests/seo.spec.ts +326 -0
  37. package/src/seo/applySeoToHead.ts +98 -0
  38. package/src/seo/index.ts +9 -0
  39. package/src/seo/resolve.ts +211 -0
  40. package/src/seo/seoHead.ts +61 -0
  41. package/src/seo/siteSeoHead.ts +97 -0
  42. package/src/seo/types.ts +99 -0
  43. package/src/server/_tests/siteBootstrap.spec.ts +65 -0
  44. package/src/server/index.ts +33 -4
  45. package/src/types/models.ts +11 -0
  46. package/src/ui/analytics/ForgeAnalytics.tsx +134 -34
  47. package/src/ui/analytics/PageMetaPixel.tsx +23 -8
  48. package/src/ui/analytics/_tests/ForgeAnalytics.meta.spec.tsx +129 -0
  49. package/src/ui/analytics/_tests/ForgeAnalytics.webVitals.spec.tsx +129 -0
  50. package/src/ui/analytics/_tests/metaCommerceEvents.spec.tsx +159 -0
  51. package/src/ui/analytics/useMetaEvents.ts +78 -0
  52. package/src/ui/headless/_tests/communityAgent.spec.tsx +519 -0
  53. package/src/ui/headless/_tests/communityCalendar.spec.tsx +195 -0
  54. package/src/ui/headless/_tests/communityClassroom.spec.tsx +120 -0
  55. package/src/ui/headless/_tests/communityHarness.tsx +10 -3
  56. package/src/ui/headless/_tests/communityMembers.spec.tsx +316 -0
  57. package/src/ui/headless/_tests/communityPoll.spec.tsx +389 -0
  58. package/src/ui/headless/_tests/memberHomeBar.spec.ts +84 -0
  59. package/src/ui/headless/_tests/siteAppBar.spec.ts +83 -0
  60. package/src/ui/headless/_tests/siteAppHarness.ts +19 -0
  61. package/src/ui/headless/_tests/siteAppHome.spec.ts +226 -0
  62. package/src/ui/headless/_tests/siteAppPrompts.spec.ts +185 -0
  63. package/src/ui/headless/_tests/useCommunityJoin.spec.tsx +228 -14
  64. package/src/ui/headless/_tests/useCommunityMembership.spec.tsx +29 -1
  65. package/src/ui/headless/_tests/useCommunityModeration.spec.tsx +420 -0
  66. package/src/ui/headless/_tests/useCommunityReads.spec.tsx +11 -2
  67. package/src/ui/headless/_tests/useCommunityWiki.spec.tsx +224 -0
  68. package/src/ui/headless/_tests/useSiteApp.spec.tsx +52 -0
  69. package/src/ui/headless/_tests/wikiMediaImage.spec.tsx +132 -0
  70. package/src/ui/headless/auth/useEmailCodeSignIn.ts +186 -0
  71. package/src/ui/headless/auth/useSignupForm.ts +20 -1
  72. package/src/ui/headless/chat/useChatAttachmentUpload.ts +9 -6
  73. package/src/ui/headless/chat/useChatRoom.ts +35 -1
  74. package/src/ui/headless/communities/index.ts +41 -0
  75. package/src/ui/headless/communities/useCommunityCalendar.ts +156 -0
  76. package/src/ui/headless/communities/useCommunityClassroom.ts +84 -0
  77. package/src/ui/headless/communities/useCommunityHome.ts +3 -0
  78. package/src/ui/headless/communities/useCommunityJoin.ts +73 -23
  79. package/src/ui/headless/communities/useCommunityMembership.ts +48 -2
  80. package/src/ui/headless/communities/useCommunityWiki.ts +266 -0
  81. package/src/ui/headless/community/communityPoll.ts +297 -0
  82. package/src/ui/headless/community/countries.ts +123 -0
  83. package/src/ui/headless/community/index.ts +81 -1
  84. package/src/ui/headless/community/linkPreview.ts +71 -0
  85. package/src/ui/headless/community/richLexical.ts +27 -2
  86. package/src/ui/headless/community/useCommunityAgent.ts +285 -0
  87. package/src/ui/headless/community/useCommunityMembers.ts +175 -0
  88. package/src/ui/headless/community/useCommunityModeration.ts +300 -0
  89. package/src/ui/headless/community/useCommunitySpaceFeed.ts +22 -4
  90. package/src/ui/headless/community/useCommunityThread.ts +16 -3
  91. package/src/ui/headless/community/useMentionComposer.ts +17 -9
  92. package/src/ui/headless/community/useRichMentionComposer.ts +21 -5
  93. package/src/ui/headless/downloads/index.ts +8 -0
  94. package/src/ui/headless/downloads/useDownloadPage.ts +134 -0
  95. package/src/ui/headless/index.ts +13 -0
  96. package/src/ui/headless/memberHome/index.ts +10 -0
  97. package/src/ui/headless/memberHome/memberHomeBar.ts +91 -0
  98. package/src/ui/headless/memberHome/useMemberHomeNav.ts +56 -18
  99. package/src/ui/headless/portal/index.ts +57 -0
  100. package/src/ui/headless/portal/usePortal.ts +274 -0
  101. package/src/ui/headless/portal/usePortalFiles.ts +68 -0
  102. package/src/ui/headless/portal/usePortalMessages.ts +189 -0
  103. package/src/ui/headless/portal/usePortalPreview.ts +142 -0
  104. package/src/ui/headless/siteApp/appBar.ts +102 -0
  105. package/src/ui/headless/siteApp/appHome.ts +344 -0
  106. package/src/ui/headless/siteApp/appPrompts.ts +229 -0
  107. package/src/ui/headless/siteApp/index.ts +58 -0
  108. package/src/ui/headless/siteApp/useAppHome.ts +210 -0
  109. package/src/ui/headless/siteApp/useAppInstallRequest.ts +16 -0
  110. package/src/ui/headless/work/index.ts +2 -0
  111. package/src/ui/headless/work/useWorkPortal.ts +37 -26
  112. package/src/ui/index.ts +11 -0
  113. package/src/ui/styled/AccountDashboard.tsx +712 -352
  114. package/src/ui/styled/AiAgentWidget.tsx +13 -1
  115. package/src/ui/styled/ChatRoom.tsx +126 -16
  116. package/src/ui/styled/Checkout.tsx +8 -0
  117. package/src/ui/styled/CheckoutConfirmation.tsx +8 -0
  118. package/src/ui/styled/CoachingConfirmation.tsx +8 -0
  119. package/src/ui/styled/CoachingDetail.tsx +2 -0
  120. package/src/ui/styled/CourseConfirmation.tsx +5 -0
  121. package/src/ui/styled/CourseDetail.tsx +2 -0
  122. package/src/ui/styled/DownloadPage.tsx +263 -0
  123. package/src/ui/styled/EventConfirmation.tsx +10 -0
  124. package/src/ui/styled/EventDetail.tsx +10 -0
  125. package/src/ui/styled/FilmLibrary.tsx +22 -9
  126. package/src/ui/styled/MusicLinkPage.tsx +5 -5
  127. package/src/ui/styled/ProductDetail.tsx +12 -0
  128. package/src/ui/styled/PwaRegistration.tsx +5 -1
  129. package/src/ui/styled/SignupForm.tsx +18 -3
  130. package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +5 -5
  131. package/src/ui/styled/_tests/AccountNotifications.spec.tsx +135 -0
  132. package/src/ui/styled/_tests/DownloadPage.spec.tsx +68 -0
  133. package/src/ui/styled/app/AppBar.tsx +298 -0
  134. package/src/ui/styled/app/AppHome.tsx +1139 -0
  135. package/src/ui/styled/app/AppHomeMark.tsx +66 -0
  136. package/src/ui/styled/app/AppPrompts.tsx +464 -0
  137. package/src/ui/styled/app/_tests/AppBar.spec.tsx +96 -0
  138. package/src/ui/styled/app/_tests/AppHome.spec.tsx +209 -0
  139. package/src/ui/styled/app/_tests/AppPrompts.spec.tsx +232 -0
  140. package/src/ui/styled/app/index.ts +7 -0
  141. package/src/ui/styled/chat/ChatThreadPanel.tsx +29 -4
  142. package/src/ui/styled/chat/MessageBubble.tsx +43 -6
  143. package/src/ui/styled/communities/CommunityAbout.tsx +6 -1
  144. package/src/ui/styled/communities/CommunityCalendar.tsx +484 -0
  145. package/src/ui/styled/communities/CommunityClassroom.tsx +302 -0
  146. package/src/ui/styled/communities/CommunityHome.tsx +134 -11
  147. package/src/ui/styled/communities/CommunityJoinSheet.tsx +119 -19
  148. package/src/ui/styled/communities/CommunityMembershipMenu.tsx +168 -52
  149. package/src/ui/styled/communities/CommunityPublicWiki.tsx +75 -0
  150. package/src/ui/styled/communities/CommunityWiki.tsx +289 -0
  151. package/src/ui/styled/communities/MyMembershipsList.tsx +31 -5
  152. package/src/ui/styled/communities/_tests/CommunityAppHome.spec.tsx +92 -0
  153. package/src/ui/styled/communities/_tests/CommunityClassroomCalendar.spec.tsx +453 -0
  154. package/src/ui/styled/communities/_tests/CommunityMobile.spec.tsx +399 -0
  155. package/src/ui/styled/communities/_tests/CommunityWiki.spec.tsx +223 -0
  156. package/src/ui/styled/communities/index.ts +9 -0
  157. package/src/ui/styled/communities/shared.tsx +39 -11
  158. package/src/ui/styled/community/CommunityAgent.tsx +286 -0
  159. package/src/ui/styled/community/CommunityComposer.tsx +266 -15
  160. package/src/ui/styled/community/CommunityFeed.tsx +50 -1
  161. package/src/ui/styled/community/CommunityLinkPreview.tsx +159 -0
  162. package/src/ui/styled/community/CommunityMemberGlobe.tsx +171 -0
  163. package/src/ui/styled/community/CommunityMemberProfile.tsx +137 -4
  164. package/src/ui/styled/community/CommunityMembers.tsx +387 -0
  165. package/src/ui/styled/community/CommunityModeration.tsx +587 -0
  166. package/src/ui/styled/community/CommunityModerationState.tsx +127 -0
  167. package/src/ui/styled/community/CommunityModeratorMenu.tsx +317 -0
  168. package/src/ui/styled/community/CommunityNotifications.tsx +52 -6
  169. package/src/ui/styled/community/CommunityPollCard.tsx +250 -0
  170. package/src/ui/styled/community/CommunityPostCard.tsx +110 -34
  171. package/src/ui/styled/community/CommunityPostDetail.tsx +157 -71
  172. package/src/ui/styled/community/CommunityRichText.tsx +177 -32
  173. package/src/ui/styled/community/CommunitySpaces.tsx +49 -14
  174. package/src/ui/styled/community/_tests/CommunityAgent.spec.tsx +571 -0
  175. package/src/ui/styled/community/_tests/CommunityLinkPreview.spec.tsx +167 -0
  176. package/src/ui/styled/community/_tests/CommunityMembers.spec.tsx +137 -0
  177. package/src/ui/styled/community/_tests/CommunityModeration.spec.tsx +291 -0
  178. package/src/ui/styled/community/_tests/CommunityPoll.spec.tsx +386 -0
  179. package/src/ui/styled/community/index.ts +37 -2
  180. package/src/ui/styled/forge-utilities.css +234 -0
  181. package/src/ui/styled/members/CallRecordingActions.tsx +182 -0
  182. package/src/ui/styled/members/MemberCallsCalendar.tsx +13 -158
  183. package/src/ui/styled/members/MemberHomeLayout.tsx +295 -15
  184. package/src/ui/styled/members/MemberPostsFeed.tsx +3 -2
  185. package/src/ui/styled/members/index.ts +8 -0
  186. package/src/ui/styled/portal/PortalAccountSection.tsx +61 -0
  187. package/src/ui/styled/portal/PortalActivity.tsx +167 -0
  188. package/src/ui/styled/portal/PortalBilling.tsx +197 -0
  189. package/src/ui/styled/portal/PortalFiles.tsx +279 -0
  190. package/src/ui/styled/portal/PortalHome.tsx +401 -0
  191. package/src/ui/styled/portal/PortalMessages.tsx +300 -0
  192. package/src/ui/styled/portal/PortalPreviewBanner.tsx +75 -0
  193. package/src/ui/styled/portal/PortalProjects.tsx +407 -0
  194. package/src/ui/styled/portal/PortalShell.tsx +397 -0
  195. package/src/ui/styled/portal/_tests/PortalAppHome.spec.tsx +90 -0
  196. package/src/ui/styled/portal/_tests/PortalFiles.spec.tsx +159 -0
  197. package/src/ui/styled/portal/_tests/PortalHome.spec.tsx +148 -0
  198. package/src/ui/styled/portal/_tests/PortalMessages.spec.tsx +168 -0
  199. package/src/ui/styled/portal/_tests/PortalPreviewBanner.spec.tsx +132 -0
  200. package/src/ui/styled/portal/_tests/portalNav.spec.ts +123 -0
  201. package/src/ui/styled/portal/index.ts +13 -0
  202. package/src/ui/styled/portal/portalNav.ts +260 -0
  203. package/src/ui/styled/portal/shared.tsx +206 -0
  204. package/src/ui/styled/work/WorkInviteAccept.tsx +228 -168
  205. package/src/ui/styled/work/WorkPortalProjects.tsx +68 -120
  206. package/src/ui/styled/work/WorkProjectDocuments.tsx +99 -128
  207. package/src/ui/styled/work/WorkProjectInvoices.tsx +91 -86
  208. package/src/ui/styled/work/WorkProjectReport.tsx +11 -17
  209. package/src/ui/styled/work/WorkReportBody.tsx +223 -248
  210. package/src/ui/styled/work/WorkTaskAttachments.tsx +23 -29
  211. package/src/ui/styled/work/WorkTaskComments.tsx +75 -102
  212. package/src/ui/styled/work/WorkTaskDetail.tsx +48 -59
  213. package/src/ui/styled/work/WorkTokenReport.tsx +8 -16
  214. package/src/ui/styled/work/index.ts +1 -1
  215. package/src/utils/_tests/landing.spec.ts +39 -0
  216. package/src/utils/_tests/metaEvents.spec.ts +264 -0
  217. package/src/utils/_tests/structuredDataSeo.spec.ts +247 -0
  218. package/src/utils/landing.ts +36 -10
  219. package/src/utils/metaEvents.ts +302 -0
  220. package/src/utils/structuredData.ts +339 -1
@@ -0,0 +1,302 @@
1
+ // Meta conversion events from the browser: one call fires the profile pixel
2
+ // AND posts the server (CAPI) copy, both carrying the same event id, so Meta
3
+ // counts the pair once. Nothing here can send one half without the other.
4
+ //
5
+ // The policy (which pixel, whether Meta may run, where the API is) is set by
6
+ // `<ForgeAnalytics/>` and read synchronously, so non-React code (the cart, a
7
+ // mutation) can fire an event without threading props or context through.
8
+ //
9
+ // Consent is read LIVE from the consent store at send time. Without marketing
10
+ // consent on a site that requires it, nothing is fired, posted, built or read:
11
+ // no pixel call, no server copy, no `fbp` / `fbc`.
12
+
13
+ import { newEventId } from "../data/queries/useAnalytics";
14
+ import { getCookieConsent } from "./cookieConsent";
15
+ import { readLandingClickId } from "./landing";
16
+ import { fireMetaEventSingle, initMetaPixelScoped, readMetaCookies } from "./metaPixel";
17
+
18
+ /** The browser signals every visitor-record create request carries (`metaSignals`). */
19
+ export type MetaSignals = {
20
+ fbp?: string;
21
+ fbc?: string;
22
+ pageUrl?: string;
23
+ /** True: allowed (or the site does not require consent). False: declined. Absent: undecided. */
24
+ marketingConsent?: boolean;
25
+ };
26
+
27
+ /** What a create response carries when the server queued a Meta event for that record. */
28
+ export type ServerMetaEvent = { name: string; eventId: string; customData?: Record<string, unknown> };
29
+
30
+ export type MetaPolicy = {
31
+ /** The profile pixel id, if the profile has one. */
32
+ pixelId?: string;
33
+ /** False in the editor / preview: nothing is sent. */
34
+ enabled: boolean;
35
+ /** Whether marketing consent is required before Meta may run. */
36
+ requireConsent: boolean;
37
+ apiUrl?: string;
38
+ subdomain?: string;
39
+ /** The tenant's settlement currency, used when an event has a value but no currency. */
40
+ currency?: string;
41
+ /**
42
+ * False while the pixel id is still loading. Events fired before then wait
43
+ * (the detail page often mounts before the site config resolves).
44
+ */
45
+ resolved: boolean;
46
+ };
47
+
48
+ export type MetaCustomData = {
49
+ value?: number;
50
+ currency?: string;
51
+ content_ids?: string[];
52
+ content_name?: string;
53
+ content_type?: "product" | "product_group";
54
+ content_category?: string;
55
+ num_items?: number;
56
+ search_string?: string;
57
+ [key: string]: unknown;
58
+ };
59
+
60
+ let policy: MetaPolicy | null = null;
61
+ const MAX_PENDING = 25;
62
+ let pending: (() => void)[] = [];
63
+
64
+ function isReady(): boolean {
65
+ return !!policy && (policy.resolved || !policy.enabled);
66
+ }
67
+
68
+ /** Run now if the policy is known, else once it is (bounded queue). */
69
+ function whenReady(fn: () => void): void {
70
+ if (isReady()) {
71
+ fn();
72
+ return;
73
+ }
74
+ if (pending.length < MAX_PENDING) pending.push(fn);
75
+ }
76
+
77
+ /** Set by `<ForgeAnalytics/>`. Flushes events that were waiting for it. */
78
+ export function setMetaPolicy(next: MetaPolicy): void {
79
+ policy = next;
80
+ if (!isReady() || pending.length === 0) return;
81
+ const queued = pending;
82
+ pending = [];
83
+ for (const fn of queued) {
84
+ try {
85
+ fn();
86
+ } catch {
87
+ // A tracking failure never breaks the page.
88
+ }
89
+ }
90
+ }
91
+
92
+ export function getMetaPolicy(): MetaPolicy | null {
93
+ return policy;
94
+ }
95
+
96
+ /** Test helper: forget the policy and anything queued. */
97
+ export function resetMetaPolicy(): void {
98
+ policy = null;
99
+ pending = [];
100
+ }
101
+
102
+ /**
103
+ * The consent half of the Meta gate: true when the site does not require
104
+ * consent or the visitor allowed marketing, false when they declined,
105
+ * undefined while they have not decided.
106
+ */
107
+ export function readMarketingConsent(): boolean | undefined {
108
+ if (policy && !policy.requireConsent) return true;
109
+ const consent = getCookieConsent();
110
+ if (!consent) return undefined;
111
+ return consent.marketing;
112
+ }
113
+
114
+ /** Whether a profile-pixel event may fire right now. */
115
+ function profileAllowed(p: MetaPolicy | null): p is MetaPolicy & { pixelId: string } {
116
+ return !!p && p.enabled && !!p.pixelId && readMarketingConsent() === true;
117
+ }
118
+
119
+ function buildFbc(): string | undefined {
120
+ const landing = readLandingClickId("fbclid");
121
+ if (landing) return `fb.1.${landing.at}.${landing.value}`;
122
+ // The landing snapshot is written after the page's own effects run, so the
123
+ // very first page can still hold the click id only in its URL.
124
+ try {
125
+ const fbclid = new URL(window.location.href).searchParams.get("fbclid");
126
+ if (fbclid && fbclid.length <= 512) return `fb.1.${Date.now()}.${fbclid}`;
127
+ } catch {
128
+ // ignore
129
+ }
130
+ return undefined;
131
+ }
132
+
133
+ /**
134
+ * The `metaSignals` body field for a create request. `fbp` / `fbc` are read
135
+ * (or built) only when marketing is allowed; otherwise they are never touched.
136
+ */
137
+ export function readMetaSignals(): MetaSignals {
138
+ if (typeof window === "undefined") return {};
139
+ const marketingConsent = readMarketingConsent();
140
+ const signals: MetaSignals = { pageUrl: window.location.href };
141
+ if (marketingConsent !== undefined) signals.marketingConsent = marketingConsent;
142
+ if (marketingConsent === true) {
143
+ const { fbp, fbc } = readMetaCookies();
144
+ if (fbp) signals.fbp = fbp;
145
+ const builtFbc = fbc || buildFbc();
146
+ if (builtFbc) signals.fbc = builtFbc;
147
+ }
148
+ return signals;
149
+ }
150
+
151
+ /** Drop empty fields, keep `value` only when it is a finite number, default the currency. */
152
+ function normaliseCustomData(data: MetaCustomData | undefined, fallbackCurrency?: string): MetaCustomData | undefined {
153
+ if (!data) return undefined;
154
+ const out: MetaCustomData = {};
155
+ for (const [key, val] of Object.entries(data)) {
156
+ if (val === undefined || val === null || val === "") continue;
157
+ out[key] = val;
158
+ }
159
+ if ("value" in out) {
160
+ const n = Number(out.value);
161
+ if (Number.isFinite(n)) out.value = n;
162
+ else delete out.value;
163
+ }
164
+ if (typeof out.currency === "string") out.currency = out.currency.toUpperCase();
165
+ else if (out.value !== undefined && fallbackCurrency) out.currency = fallbackCurrency.toUpperCase();
166
+ if (Array.isArray(out.content_ids)) out.content_ids = out.content_ids.map(String).slice(0, 50);
167
+ return Object.keys(out).length > 0 ? out : undefined;
168
+ }
169
+
170
+ type ServerCopy = {
171
+ pixel: "profile" | "page";
172
+ pathname?: string;
173
+ eventName: string;
174
+ eventId: string;
175
+ eventSourceUrl: string;
176
+ customData?: MetaCustomData;
177
+ };
178
+
179
+ /** POST the server copy. keepalive, like `trackBeacon`, so a navigation does not cancel it. */
180
+ function postServerCopy(p: MetaPolicy, copy: ServerCopy): void {
181
+ if (!p.apiUrl || !p.subdomain || typeof fetch === "undefined") return;
182
+ try {
183
+ void fetch(`${p.apiUrl.replace(/\/$/, "")}/public/websites/meta-event`, {
184
+ method: "POST",
185
+ headers: { "Content-Type": "application/json" },
186
+ keepalive: true,
187
+ body: JSON.stringify({ subdomain: p.subdomain, ...copy, metaSignals: readMetaSignals() }),
188
+ }).catch(() => {});
189
+ } catch {
190
+ // Best effort; never disrupt the page.
191
+ }
192
+ }
193
+
194
+ function currentHref(): string {
195
+ return typeof window === "undefined" ? "" : window.location.href;
196
+ }
197
+
198
+ /**
199
+ * Fire a Meta event to the profile pixel AND post its server copy, with one
200
+ * event id. A no-op unless the profile has a pixel, Meta is enabled and
201
+ * marketing is allowed. Returns the event id.
202
+ */
203
+ export function trackMeta(eventName: string, customData?: MetaCustomData, opts?: { eventId?: string }): string {
204
+ const eventId = opts?.eventId ?? newEventId();
205
+ if (typeof window === "undefined") return eventId;
206
+ const eventSourceUrl = currentHref();
207
+ whenReady(() => {
208
+ const p = policy;
209
+ if (!profileAllowed(p)) return;
210
+ const data = normaliseCustomData(customData, p.currency);
211
+ initMetaPixelScoped(p.pixelId);
212
+ fireMetaEventSingle(p.pixelId, eventName, data ?? {}, eventId);
213
+ postServerCopy(p, { pixel: "profile", eventName, eventId, eventSourceUrl, customData: data });
214
+ });
215
+ return eventId;
216
+ }
217
+
218
+ /**
219
+ * The server copy of an event fired to a PAGE (campaign) pixel. The caller
220
+ * fires the pixel itself and decides consent for it (`<PageMetaPixel/>`).
221
+ */
222
+ export function sendPageMetaEvent(input: {
223
+ eventName: string;
224
+ eventId: string;
225
+ pathname: string;
226
+ customData?: MetaCustomData;
227
+ }): void {
228
+ if (typeof window === "undefined") return;
229
+ const eventSourceUrl = currentHref();
230
+ whenReady(() => {
231
+ const p = policy;
232
+ if (!p || !p.enabled) return;
233
+ postServerCopy(p, {
234
+ pixel: "page",
235
+ pathname: input.pathname,
236
+ eventName: input.eventName,
237
+ eventId: input.eventId,
238
+ eventSourceUrl,
239
+ customData: normaliseCustomData(input.customData, p.currency),
240
+ });
241
+ });
242
+ }
243
+
244
+ /**
245
+ * The pixel copy of an event the SERVER already queued (a response's
246
+ * `metaEvent`). No server copy: it exists.
247
+ */
248
+ export function firePixelOnly(eventName: string, eventId: string, customData?: MetaCustomData): void {
249
+ if (typeof window === "undefined" || !eventName || !eventId) return;
250
+ whenReady(() => {
251
+ const p = policy;
252
+ if (!profileAllowed(p)) return;
253
+ initMetaPixelScoped(p.pixelId);
254
+ fireMetaEventSingle(p.pixelId, eventName, normaliseCustomData(customData, p.currency) ?? {}, eventId);
255
+ });
256
+ }
257
+
258
+ /** Fire the pixel copy of a response's `metaEvent`, if it carries one. */
259
+ export function fireServerMetaEvent(response: unknown): void {
260
+ const metaEvent = (response as { metaEvent?: ServerMetaEvent } | null | undefined)?.metaEvent;
261
+ if (!metaEvent || typeof metaEvent.name !== "string" || typeof metaEvent.eventId !== "string") return;
262
+ firePixelOnly(metaEvent.name, metaEvent.eventId, metaEvent.customData as MetaCustomData | undefined);
263
+ }
264
+
265
+ const PURCHASE_KEY_PREFIX = "tn:meta:purchase:";
266
+
267
+ function purchaseAlreadyFired(eventId: string): boolean {
268
+ try {
269
+ return window.sessionStorage.getItem(PURCHASE_KEY_PREFIX + eventId) === "1";
270
+ } catch {
271
+ return false;
272
+ }
273
+ }
274
+
275
+ function markPurchaseFired(eventId: string): void {
276
+ try {
277
+ window.sessionStorage.setItem(PURCHASE_KEY_PREFIX + eventId, "1");
278
+ } catch {
279
+ // Storage disabled: a reload may fire it again, and Meta dedupes on the id.
280
+ }
281
+ }
282
+
283
+ /**
284
+ * The browser copy of a Purchase the server bridge sends on payment. The id is
285
+ * the record id the server used, so the two dedupe. Pixel only (the server
286
+ * copy is the bridge's), at most once per tab per record.
287
+ */
288
+ export function trackPurchase(input: { recordId: string; value: number | string | null | undefined; currency?: string | null }): void {
289
+ if (typeof window === "undefined" || !input.recordId) return;
290
+ const value = Number(input.value);
291
+ if (!Number.isFinite(value) || value <= 0) return;
292
+ const eventId = input.recordId;
293
+ if (purchaseAlreadyFired(eventId)) return;
294
+ whenReady(() => {
295
+ const p = policy;
296
+ if (!profileAllowed(p) || purchaseAlreadyFired(eventId)) return;
297
+ const currency = (input.currency || p.currency || "").toUpperCase() || undefined;
298
+ initMetaPixelScoped(p.pixelId);
299
+ fireMetaEventSingle(p.pixelId, "Purchase", currency ? { value, currency } : { value }, eventId);
300
+ markPurchaseFired(eventId);
301
+ });
302
+ }
@@ -1,4 +1,6 @@
1
- import type { WebPage } from "../types";
1
+ import type { IEvent, ITicket, WebPage } from "../types";
2
+ import type { SeoIdentity } from "../seo/types";
3
+ import { ticketSeatsUnsold } from "../ui/format/ticketAvailability";
2
4
 
3
5
  /**
4
6
  * Generates Organization/Person JSON-LD for every page.
@@ -529,3 +531,339 @@ export function jsonLdScripts(
529
531
  .filter((s): s is Record<string, unknown> => !!s)
530
532
  .map((s) => ({ type: "application/ld+json", children: JSON.stringify(s) }));
531
533
  }
534
+
535
+ // ---- Events, posts, podcasts, the site and who runs it --------------------------
536
+ //
537
+ // Same rule as the sellables above: say only what the page itself says. A
538
+ // cancelled event is still described (so a search result can show it as
539
+ // cancelled) but sells nothing; a tier nobody may buy anonymously gets no
540
+ // offer; a missing currency drops the offers rather than guessing one.
541
+
542
+ /** A post's or episode's author, and the site's publisher, as the SEO record names them. */
543
+ export type SchemaIdentityInput = Pick<SeoIdentity, "kind" | "name"> & Partial<Omit<SeoIdentity, "kind" | "name">>;
544
+
545
+ const schemaTypeForIdentity = (kind: SeoIdentity["kind"]) =>
546
+ kind === "person" ? "Person" : kind === "local_business" ? "LocalBusiness" : "Organization";
547
+
548
+ const cleanUrls = (urls?: (string | null | undefined)[] | null) =>
549
+ (urls ?? []).filter((u): u is string => typeof u === "string" && /^https?:\/\//i.test(u.trim())).map((u) => u.trim());
550
+
551
+ function postalAddress(address?: SeoIdentity["address"]): Record<string, unknown> | undefined {
552
+ if (!address) return undefined;
553
+ const node = {
554
+ ...(address.street ? { streetAddress: address.street } : {}),
555
+ ...(address.locality ? { addressLocality: address.locality } : {}),
556
+ ...(address.region ? { addressRegion: address.region } : {}),
557
+ ...(address.postalCode ? { postalCode: address.postalCode } : {}),
558
+ ...(address.country ? { addressCountry: address.country } : {}),
559
+ };
560
+ return Object.keys(node).length ? { "@type": "PostalAddress", ...node } : undefined;
561
+ }
562
+
563
+ /** A short reference to the identity, for `author` / `publisher` inside another node. */
564
+ function identityRef(identity?: SchemaIdentityInput | null, origin?: string | null): Record<string, unknown> | undefined {
565
+ const name = identity?.name?.trim();
566
+ if (!identity || !name) return undefined;
567
+ return {
568
+ "@type": schemaTypeForIdentity(identity.kind),
569
+ name,
570
+ ...(origin ? { url: origin } : {}),
571
+ ...(identity.logo && identity.kind !== "person" ? { logo: identity.logo } : {}),
572
+ };
573
+ }
574
+
575
+ /** The `@id` the WebSite node's `publisher` points at, so the two link up. */
576
+ export const identitySchemaId = (origin: string) => `${origin.replace(/\/+$/, "")}/#identity`;
577
+
578
+ /**
579
+ * schema.org Person, Organization or LocalBusiness for whoever runs the site.
580
+ *
581
+ * `logo` is an Organization property; a Person carries the same picture as
582
+ * `image`. `address` and `telephone` are emitted for any kind that has them,
583
+ * because a coach working from a studio is as findable by address as a shop.
584
+ */
585
+ export function buildIdentitySchema(
586
+ identity: SchemaIdentityInput | null | undefined,
587
+ options: { origin?: string | null } = {},
588
+ ): Record<string, unknown> | null {
589
+ const name = identity?.name?.trim();
590
+ if (!identity || !name) return null;
591
+ const origin = options.origin?.replace(/\/+$/, "");
592
+ const sameAs = cleanUrls(identity.sameAs);
593
+ const address = postalAddress(identity.address);
594
+ return {
595
+ "@context": "https://schema.org",
596
+ "@type": schemaTypeForIdentity(identity.kind),
597
+ ...(origin ? { "@id": identitySchemaId(origin), url: origin } : {}),
598
+ name,
599
+ ...(identity.logo ? (identity.kind === "person" ? { image: identity.logo } : { logo: identity.logo }) : {}),
600
+ ...(sameAs.length ? { sameAs } : {}),
601
+ ...(address ? { address } : {}),
602
+ ...(identity.phone ? { telephone: identity.phone } : {}),
603
+ };
604
+ }
605
+
606
+ /** schema.org/WebSite for the home page. */
607
+ export function buildWebSiteSchema(input: {
608
+ name: string;
609
+ url: string;
610
+ description?: string | null;
611
+ /** Links the site to its identity node by `@id`. */
612
+ publisherId?: string | null;
613
+ }): Record<string, unknown> | null {
614
+ const name = input.name?.trim();
615
+ if (!name || !input.url) return null;
616
+ return {
617
+ "@context": "https://schema.org",
618
+ "@type": "WebSite",
619
+ name,
620
+ url: input.url,
621
+ ...(input.description ? { description: input.description } : {}),
622
+ ...(input.publisherId ? { publisher: { "@id": input.publisherId } } : {}),
623
+ };
624
+ }
625
+
626
+ /** schema.org/BreadcrumbList. Each item needs an absolute URL, so pass full ones. */
627
+ export function buildBreadcrumbSchema(items: { name: string; url: string }[]): Record<string, unknown> | null {
628
+ const list = items.filter((i) => i.name?.trim() && i.url);
629
+ if (list.length < 2) return null;
630
+ return {
631
+ "@context": "https://schema.org",
632
+ "@type": "BreadcrumbList",
633
+ itemListElement: list.map((item, index) => ({
634
+ "@type": "ListItem",
635
+ position: index + 1,
636
+ name: item.name.trim(),
637
+ item: item.url,
638
+ })),
639
+ };
640
+ }
641
+
642
+ /** Structural view of `GET /public/events/:id`. `IEvent` satisfies it. */
643
+ export interface EventSchemaSource {
644
+ title: string;
645
+ description?: string | null;
646
+ dateTime: string;
647
+ endDateTime?: string | null;
648
+ doorsOpenAt?: string | null;
649
+ status?: IEvent["status"];
650
+ cancelledAt?: string | null;
651
+ type: IEvent["type"];
652
+ address?: IEvent["address"] | null;
653
+ media?: { url: string; type?: string }[] | null;
654
+ tickets?: Pick<
655
+ ITicket,
656
+ "title" | "price" | "quantity" | "sold" | "availableFrom" | "archivedAt" | "isHidden" | "membershipGate"
657
+ >[];
658
+ membershipGate?: { allowed: boolean } | null;
659
+ bookingFee?: { currency: string } | null;
660
+ }
661
+
662
+ const ATTENDANCE_MODE: Record<IEvent["type"], string> = {
663
+ physical: "https://schema.org/OfflineEventAttendanceMode",
664
+ virtual: "https://schema.org/OnlineEventAttendanceMode",
665
+ hybrid: "https://schema.org/MixedEventAttendanceMode",
666
+ };
667
+
668
+ /**
669
+ * schema.org/Event.
670
+ *
671
+ * `offers` is one Offer per public ticket tier, priced in the event's own
672
+ * settlement currency. A tier is left out when it is hidden, archived or
673
+ * members-only; a members-only EVENT sells nothing to the anonymous visitor
674
+ * Googlebot is, so it has no offers at all. Neither has a cancelled or finished
675
+ * event: there is nothing left to buy.
676
+ *
677
+ * A virtual event's location is a VirtualLocation at the event page itself,
678
+ * which is where a ticket-holder is let in. A hybrid event carries both.
679
+ */
680
+ export function buildEventSchema(
681
+ event: EventSchemaSource | null | undefined,
682
+ options: SellableSchemaOptions & { organizer?: SchemaIdentityInput | null; origin?: string | null } = {},
683
+ ): Record<string, unknown> | null {
684
+ const name = event?.title?.trim();
685
+ if (!event || !name || !event.dateTime) return null;
686
+
687
+ const cancelled = event.status === "cancelled" || !!event.cancelledAt;
688
+ const description = toSchemaText(event.description);
689
+ const images = imageUrls(event.media);
690
+
691
+ const place = (() => {
692
+ if (event.type === "virtual" || !event.address) return undefined;
693
+ const a = event.address;
694
+ const address = postalAddress({
695
+ street: a.street || undefined,
696
+ locality: a.city || undefined,
697
+ region: a.state || undefined,
698
+ postalCode: a.zipCode || undefined,
699
+ country: a.country || undefined,
700
+ });
701
+ return { "@type": "Place", ...(a.name ? { name: a.name } : {}), ...(address ? { address } : {}) };
702
+ })();
703
+ const virtual =
704
+ event.type !== "physical" && options.url ? { "@type": "VirtualLocation", url: options.url } : undefined;
705
+ const locations = [place, virtual].filter(Boolean);
706
+
707
+ const currency = event.bookingFee?.currency || options.currency || undefined;
708
+ const sellable =
709
+ !!currency &&
710
+ !cancelled &&
711
+ event.status !== "completed" &&
712
+ !(event.membershipGate && event.membershipGate.allowed === false);
713
+ const now = Date.now();
714
+ const offers = sellable
715
+ ? (event.tickets ?? [])
716
+ .filter((t) => !t.archivedAt && !t.isHidden && !(t.membershipGate && t.membershipGate.allowed === false))
717
+ .map((t) => {
718
+ const price = toPrice(t.price);
719
+ if (price === undefined) return null;
720
+ const notYet = !!t.availableFrom && Date.parse(t.availableFrom) > now;
721
+ const soldOut = event.status === "sold_out" || ticketSeatsUnsold(t) <= 0;
722
+ return {
723
+ "@type": "Offer",
724
+ ...(t.title ? { name: t.title } : {}),
725
+ price: money(price),
726
+ priceCurrency: currency,
727
+ availability: `https://schema.org/${soldOut ? "SoldOut" : notYet ? "PreOrder" : "InStock"}`,
728
+ ...(t.availableFrom ? { validFrom: t.availableFrom } : {}),
729
+ ...(options.url ? { url: options.url } : {}),
730
+ };
731
+ })
732
+ .filter((o): o is NonNullable<typeof o> => !!o)
733
+ : [];
734
+
735
+ const organizer =
736
+ identityRef(options.organizer, options.origin) ??
737
+ (options.brand ? { "@type": "Organization", name: options.brand } : undefined);
738
+
739
+ return {
740
+ "@context": "https://schema.org",
741
+ "@type": "Event",
742
+ name,
743
+ startDate: event.dateTime,
744
+ ...(event.endDateTime ? { endDate: event.endDateTime } : {}),
745
+ ...(event.doorsOpenAt ? { doorTime: event.doorsOpenAt } : {}),
746
+ eventStatus: `https://schema.org/${cancelled ? "EventCancelled" : "EventScheduled"}`,
747
+ eventAttendanceMode: ATTENDANCE_MODE[event.type] ?? ATTENDANCE_MODE.physical,
748
+ ...(locations.length ? { location: locations.length === 1 ? locations[0] : locations } : {}),
749
+ ...(description ? { description } : {}),
750
+ ...(images.length ? { image: images.length === 1 ? images[0] : images } : {}),
751
+ ...(options.url ? { url: options.url } : {}),
752
+ ...(organizer ? { organizer } : {}),
753
+ ...(offers.length ? { offers: offers.length === 1 ? offers[0] : offers } : {}),
754
+ };
755
+ }
756
+
757
+ /** Structural view of `GET /public/blog/posts/:id`. `BlogPost` satisfies it. */
758
+ export interface BlogPostingSchemaSource {
759
+ title: string;
760
+ content?: string | null;
761
+ publishedAt?: string | null;
762
+ updatedAt?: string | null;
763
+ media?: { url: string; type?: string }[] | null;
764
+ }
765
+
766
+ /** schema.org/BlogPosting. `author` and `publisher` both come from the site's identity. */
767
+ export function buildBlogPostingSchema(
768
+ post: BlogPostingSchemaSource | null | undefined,
769
+ options: { url?: string | null; identity?: SchemaIdentityInput | null; origin?: string | null } = {},
770
+ ): Record<string, unknown> | null {
771
+ const headline = post?.title?.trim();
772
+ if (!post || !headline) return null;
773
+ const description = toSchemaText(post.content, 300);
774
+ const images = imageUrls((post.media ?? []).filter((m) => (m.type ?? "").startsWith("image")));
775
+ const author = identityRef(options.identity, options.origin);
776
+ return {
777
+ "@context": "https://schema.org",
778
+ "@type": "BlogPosting",
779
+ // Google truncates a headline past 110 characters; clamping keeps it valid.
780
+ headline: headline.length > 110 ? `${headline.slice(0, 109).trimEnd()}…` : headline,
781
+ ...(description ? { description } : {}),
782
+ ...(post.publishedAt ? { datePublished: post.publishedAt } : {}),
783
+ ...(post.updatedAt || post.publishedAt ? { dateModified: post.updatedAt || post.publishedAt } : {}),
784
+ ...(images.length ? { image: images.length === 1 ? images[0] : images } : {}),
785
+ ...(options.url ? { url: options.url, mainEntityOfPage: { "@type": "WebPage", "@id": options.url } } : {}),
786
+ ...(author ? { author } : {}),
787
+ ...(author && options.identity?.kind !== "person" ? { publisher: author } : {}),
788
+ };
789
+ }
790
+
791
+ /** Structural view of a podcast show. `PodcastShow` and `BlogCategory` both satisfy it. */
792
+ export interface PodcastSeriesSchemaSource {
793
+ title: string;
794
+ description?: string | null;
795
+ coverImage?: { url: string } | null;
796
+ author?: string | null;
797
+ }
798
+
799
+ /** schema.org/PodcastSeries. */
800
+ export function buildPodcastSeriesSchema(
801
+ show: PodcastSeriesSchemaSource | null | undefined,
802
+ options: { url?: string | null; identity?: SchemaIdentityInput | null; origin?: string | null } = {},
803
+ ): Record<string, unknown> | null {
804
+ const name = show?.title?.trim();
805
+ if (!show || !name) return null;
806
+ const description = toSchemaText(show.description);
807
+ const author = show.author?.trim()
808
+ ? { "@type": "Person", name: show.author.trim() }
809
+ : identityRef(options.identity, options.origin);
810
+ return {
811
+ "@context": "https://schema.org",
812
+ "@type": "PodcastSeries",
813
+ name,
814
+ ...(description ? { description } : {}),
815
+ ...(show.coverImage?.url ? { image: show.coverImage.url } : {}),
816
+ ...(options.url ? { url: options.url } : {}),
817
+ ...(author ? { author } : {}),
818
+ };
819
+ }
820
+
821
+ /** Structural view of a podcast episode (a blog post with an audio medium). */
822
+ export interface PodcastEpisodeSchemaSource {
823
+ title: string;
824
+ content?: string | null;
825
+ publishedAt?: string | null;
826
+ media?: { url: string; type?: string; durationSec?: number | null }[] | null;
827
+ }
828
+
829
+ /** schema.org/PodcastEpisode, linked to its series. */
830
+ export function buildPodcastEpisodeSchema(
831
+ episode: PodcastEpisodeSchemaSource | null | undefined,
832
+ options: { url?: string | null; series?: { name: string; url?: string | null } | null } = {},
833
+ ): Record<string, unknown> | null {
834
+ const name = episode?.title?.trim();
835
+ if (!episode || !name) return null;
836
+ const description = toSchemaText(episode.content);
837
+ const audio = (episode.media ?? []).find((m) => (m.type ?? "").startsWith("audio"));
838
+ const image = (episode.media ?? []).find((m) => (m.type ?? "").startsWith("image"))?.url;
839
+ const duration = isoDuration(audio?.durationSec);
840
+ return {
841
+ "@context": "https://schema.org",
842
+ "@type": "PodcastEpisode",
843
+ name,
844
+ ...(description ? { description } : {}),
845
+ ...(episode.publishedAt ? { datePublished: episode.publishedAt } : {}),
846
+ ...(options.url ? { url: options.url } : {}),
847
+ ...(image ? { image } : {}),
848
+ ...(duration ? { timeRequired: duration } : {}),
849
+ ...(audio?.url
850
+ ? {
851
+ associatedMedia: {
852
+ "@type": "MediaObject",
853
+ contentUrl: audio.url,
854
+ ...(audio.type ? { encodingFormat: audio.type } : {}),
855
+ ...(duration ? { duration } : {}),
856
+ },
857
+ }
858
+ : {}),
859
+ ...(options.series?.name
860
+ ? {
861
+ partOfSeries: {
862
+ "@type": "PodcastSeries",
863
+ name: options.series.name,
864
+ ...(options.series.url ? { url: options.series.url } : {}),
865
+ },
866
+ }
867
+ : {}),
868
+ };
869
+ }