@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,211 @@
1
+ import type { SeoHeadContext, SeoPageOverride, SeoPageType, SeoRecord } from "./types";
2
+
3
+ // Pure resolution: one place decides what a page's SEO is, so the server render,
4
+ // the hydrated page and an in-site navigation all compute the same tags from
5
+ // the same serialized record.
6
+ //
7
+ // The order, field by field:
8
+ // 1. the creator's approved override for this exact pathname,
9
+ // 2. then the override for the route pattern ("/team/$slug"),
10
+ // 3. then what the code passed in,
11
+ // 4. then the page type's template,
12
+ // 5. then the site defaults.
13
+
14
+ export const DEFAULT_TITLE_TEMPLATE = "{title} | {site}";
15
+
16
+ /** "/about/" and "/about" are one page. The root stays "/". */
17
+ export function normalizePathname(pathname: string | undefined | null): string {
18
+ if (!pathname) return "/";
19
+ const bare = pathname.split(/[?#]/)[0] || "/";
20
+ const withSlash = bare.startsWith("/") ? bare : `/${bare}`;
21
+ return withSlash.length > 1 ? withSlash.replace(/\/+$/, "") || "/" : withSlash;
22
+ }
23
+
24
+ /** Hosted pages render centrally and emit their own canonical. */
25
+ export function isHostedPath(pathname: string): boolean {
26
+ const p = normalizePathname(pathname);
27
+ return p === "/i" || p.startsWith("/i/");
28
+ }
29
+
30
+ /**
31
+ * The record a site's root loader returned, read from the root match.
32
+ *
33
+ * `undefined` and `null` both mean "no record": an old backend, a failed fetch,
34
+ * or a site whose root loader predates the field. Every caller degrades to the
35
+ * code's own values in that case.
36
+ */
37
+ export function seoRecordFromContext(ctx: SeoHeadContext | undefined | null): SeoRecord | null {
38
+ const root = ctx?.matches?.[0]?.loaderData as { seo?: SeoRecord | null } | null | undefined;
39
+ const seo = root?.seo;
40
+ return seo && typeof seo === "object" && typeof seo.siteName === "string" ? seo : null;
41
+ }
42
+
43
+ /** The leaf pathname: the last match is the page being rendered. */
44
+ export function pathnameFromContext(ctx: SeoHeadContext | undefined | null): string {
45
+ const matches = ctx?.matches ?? [];
46
+ for (let i = matches.length - 1; i >= 0; i--) {
47
+ const pathname = matches[i]?.pathname;
48
+ if (pathname) return normalizePathname(pathname);
49
+ }
50
+ return normalizePathname(ctx?.match?.pathname);
51
+ }
52
+
53
+ /** The route pattern of the match whose head() is running ("/team/$slug"). */
54
+ export function routePatternFromContext(ctx: SeoHeadContext | undefined | null): string | undefined {
55
+ const match = ctx?.match;
56
+ const pattern = match?.fullPath ?? match?.routeId;
57
+ return pattern ? normalizePathname(pattern) : undefined;
58
+ }
59
+
60
+ /** The route pattern of the leaf match, for the root, which renders above it. */
61
+ export function leafRoutePatternFromContext(ctx: SeoHeadContext | undefined | null): string | undefined {
62
+ const matches = ctx?.matches ?? [];
63
+ const leaf = matches[matches.length - 1];
64
+ const pattern = leaf?.fullPath ?? leaf?.routeId;
65
+ return pattern ? normalizePathname(pattern) : undefined;
66
+ }
67
+
68
+ const present = <T>(value: T | null | undefined): value is T =>
69
+ value !== null && value !== undefined && !(typeof value === "string" && value.trim() === "");
70
+
71
+ /**
72
+ * The override for one page, merged field by field: the exact pathname wins,
73
+ * the route pattern fills what it leaves unset.
74
+ */
75
+ export function findOverride(
76
+ seo: SeoRecord | null | undefined,
77
+ pathname: string,
78
+ routePattern?: string,
79
+ ): SeoPageOverride | null {
80
+ if (!seo?.pages) return null;
81
+ const exact = seo.pages[normalizePathname(pathname)];
82
+ const pattern = routePattern ? seo.pages[normalizePathname(routePattern)] : undefined;
83
+ if (!exact && !pattern) return null;
84
+ const merged: SeoPageOverride = {};
85
+ for (const key of ["title", "description", "image", "canonical", "noindex"] as const) {
86
+ const value = present(exact?.[key]) ? exact?.[key] : pattern?.[key];
87
+ if (present(value)) (merged as Record<string, unknown>)[key] = value;
88
+ }
89
+ return merged;
90
+ }
91
+
92
+ /** Fill `{title}` and `{site}` (or `{site.name}`) in a template. */
93
+ export function fillTemplate(template: string, values: { title: string; site: string }): string {
94
+ return template
95
+ .replace(/\{title\}/g, values.title)
96
+ .replace(/\{site(?:\.name)?\}/g, values.site)
97
+ .trim();
98
+ }
99
+
100
+ /**
101
+ * The document title for a code title.
102
+ *
103
+ * The template is skipped when the title already names the site, so a code
104
+ * title of "Clay Studio | Pottery in Hackney" does not become
105
+ * "Clay Studio | Pottery in Hackney | Clay Studio".
106
+ */
107
+ export function applyTitleTemplate(title: string, siteName: string, template?: string | null): string {
108
+ const site = siteName.trim();
109
+ const base = title.trim();
110
+ if (!site) return base;
111
+ if (!base) return site;
112
+ if (base.toLowerCase().includes(site.toLowerCase())) return base;
113
+ const filled = fillTemplate(template?.trim() || DEFAULT_TITLE_TEMPLATE, { title: base, site });
114
+ return filled || base;
115
+ }
116
+
117
+ /** The canonical URL for a page: the override's, else origin + pathname. */
118
+ export function canonicalFor(
119
+ seo: SeoRecord,
120
+ pathname: string,
121
+ override?: SeoPageOverride | null,
122
+ options?: { entity?: boolean },
123
+ ): string {
124
+ if (present(override?.canonical)) return override!.canonical!;
125
+ const origin = (options?.entity ? seo.defaultWebsiteOrigin || seo.origin : seo.origin).replace(/\/+$/, "");
126
+ const path = normalizePathname(pathname);
127
+ return path === "/" ? `${origin}/` : `${origin}${path}`;
128
+ }
129
+
130
+ export type ResolvedPageSeo = {
131
+ /** The document title, template applied. */
132
+ title: string;
133
+ /** The title for share cards, without the site suffix. */
134
+ shareTitle: string;
135
+ description: string;
136
+ image?: string;
137
+ noindex: boolean;
138
+ /** Null when there is no record, so no canonical can be stated. */
139
+ canonical: string | null;
140
+ /** True when a creator override supplied at least one field. */
141
+ overridden: boolean;
142
+ };
143
+
144
+ export type ResolveInput = {
145
+ pathname: string;
146
+ routePattern?: string;
147
+ type?: SeoPageType;
148
+ title?: string;
149
+ description?: string;
150
+ image?: string;
151
+ noindex?: boolean;
152
+ /** Entity pages canonicalize to the profile's default website. */
153
+ entity?: boolean;
154
+ };
155
+
156
+ export function resolvePageSeo(seo: SeoRecord | null | undefined, input: ResolveInput): ResolvedPageSeo {
157
+ const codeTitle = input.title?.trim() ?? "";
158
+
159
+ if (!seo) {
160
+ // No record: today's behaviour, exactly. The code's values stand as given.
161
+ const title = codeTitle;
162
+ return {
163
+ title,
164
+ shareTitle: title,
165
+ description: input.description || title,
166
+ image: input.image || undefined,
167
+ noindex: !!input.noindex,
168
+ canonical: null,
169
+ overridden: false,
170
+ };
171
+ }
172
+
173
+ const override = findOverride(seo, input.pathname, input.routePattern);
174
+ const typeTemplate = input.type ? seo.typeTemplates?.[input.type] : undefined;
175
+ const site = seo.siteName ?? "";
176
+
177
+ let title: string;
178
+ let shareTitle: string;
179
+ if (present(override?.title)) {
180
+ // An approved title is what the creator signed off on, so it is used
181
+ // verbatim: no template, no suffix.
182
+ title = override!.title!.trim();
183
+ shareTitle = title;
184
+ } else {
185
+ shareTitle = codeTitle || site;
186
+ title = applyTitleTemplate(shareTitle, site, typeTemplate?.title || seo.titleTemplate);
187
+ }
188
+
189
+ const description =
190
+ (present(override?.description) ? override!.description! : undefined) ||
191
+ input.description ||
192
+ (typeTemplate?.description ? fillTemplate(typeTemplate.description, { title: shareTitle, site }) : undefined) ||
193
+ seo.defaultDescription ||
194
+ shareTitle;
195
+
196
+ const image = (present(override?.image) ? override!.image! : undefined) || input.image || seo.defaultImage || undefined;
197
+
198
+ const noindex =
199
+ seo.indexing === false ||
200
+ (typeof override?.noindex === "boolean" ? override.noindex : !!input.noindex);
201
+
202
+ return {
203
+ title,
204
+ shareTitle,
205
+ description,
206
+ image,
207
+ noindex,
208
+ canonical: canonicalFor(seo, input.pathname, override, { entity: input.entity }),
209
+ overridden: !!override && Object.keys(override).length > 0,
210
+ };
211
+ }
@@ -0,0 +1,61 @@
1
+ import {
2
+ pathnameFromContext,
3
+ resolvePageSeo,
4
+ routePatternFromContext,
5
+ seoRecordFromContext,
6
+ type ResolvedPageSeo,
7
+ } from "./resolve";
8
+ import type { SeoHeadContext, SeoHeadInput, SeoMetaEntry } from "./types";
9
+
10
+ /** JSON-LD nodes as head() meta entries. TanStack renders each as a script tag. */
11
+ export function jsonLdMeta(jsonLd?: object | object[] | null): SeoMetaEntry[] {
12
+ const nodes = Array.isArray(jsonLd) ? jsonLd : jsonLd ? [jsonLd] : [];
13
+ return nodes.filter((n) => !!n && typeof n === "object").map((n) => ({ "script:ld+json": n }));
14
+ }
15
+
16
+ /** The per-page share and robots tags for one resolved page. */
17
+ export function pageMeta(resolved: ResolvedPageSeo, ogType = "website"): SeoMetaEntry[] {
18
+ const { title, shareTitle, description, image, noindex, canonical } = resolved;
19
+ return [
20
+ { title },
21
+ { name: "description", content: description },
22
+ { property: "og:title", content: shareTitle },
23
+ { property: "og:description", content: description },
24
+ { property: "og:type", content: ogType },
25
+ ...(canonical ? [{ property: "og:url", content: canonical }] : []),
26
+ ...(image ? [{ property: "og:image", content: image }] : []),
27
+ { name: "twitter:card", content: "summary_large_image" },
28
+ { name: "twitter:title", content: shareTitle },
29
+ { name: "twitter:description", content: description },
30
+ ...(image ? [{ name: "twitter:image", content: image }] : []),
31
+ ...(noindex ? [{ name: "robots", content: "noindex, nofollow" }] : []),
32
+ ];
33
+ }
34
+
35
+ /**
36
+ * A route's SEO, declared in code with the creator's edits resolved over it.
37
+ *
38
+ * head: (ctx) => seoHead(ctx, { title: "About Clay Studio", description: "..." })
39
+ *
40
+ * Reads the site's SEO record from the root match (the root loader fetched it
41
+ * with `fetchSiteBootstrap`) and returns `{ meta }` ONLY. Never links: TanStack
42
+ * concatenates links across every match, so a canonical here would sit beside
43
+ * the root's and the page would carry two. The root's `siteSeoHead` is the one
44
+ * place a canonical comes from.
45
+ *
46
+ * With no record (an older backend, or a failed fetch) it emits what
47
+ * `buildHeadMeta` always did, minus the links.
48
+ */
49
+ export function seoHead(ctx: SeoHeadContext | undefined | null, input: SeoHeadInput): { meta: SeoMetaEntry[] } {
50
+ const seo = seoRecordFromContext(ctx);
51
+ const resolved = resolvePageSeo(seo, {
52
+ pathname: pathnameFromContext(ctx),
53
+ routePattern: routePatternFromContext(ctx),
54
+ type: input.type ?? "page",
55
+ title: input.title,
56
+ description: input.description,
57
+ image: input.image,
58
+ noindex: input.noindex,
59
+ });
60
+ return { meta: [...pageMeta(resolved), ...jsonLdMeta(input.jsonLd)] };
61
+ }
@@ -0,0 +1,97 @@
1
+ import { buildIdentitySchema, buildWebSiteSchema, identitySchemaId } from "../utils/structuredData";
2
+ import {
3
+ canonicalFor,
4
+ findOverride,
5
+ isHostedPath,
6
+ leafRoutePatternFromContext,
7
+ pathnameFromContext,
8
+ seoRecordFromContext,
9
+ } from "./resolve";
10
+ import { jsonLdMeta } from "./seoHead";
11
+ import type { SeoHeadContext, SeoLinkEntry, SeoMetaEntry } from "./types";
12
+
13
+ export type SiteSeoFallback = {
14
+ /** Used when there is no SEO record, e.g. the site config's name. */
15
+ siteName?: string | null;
16
+ description?: string | null;
17
+ image?: string | null;
18
+ };
19
+
20
+ /**
21
+ * Site-wide head tags, for the ROOT route's `head()`.
22
+ *
23
+ * head: (ctx) => siteSeoHead(ctx)
24
+ *
25
+ * Every tag here is a fallback a page can replace (TanStack keeps the deepest
26
+ * title and the deepest meta of each name), except two things only the root
27
+ * may emit:
28
+ *
29
+ * - the canonical link, because links are concatenated across matches rather
30
+ * than replaced. It is skipped under `/i/`, where the hosted page emits its
31
+ * own (an entity's canonical is on the default website, which only that page
32
+ * knows to say);
33
+ * - the WebSite and identity JSON-LD, on "/" only.
34
+ *
35
+ * `robots` is set here when the whole site is noindex or this path's override
36
+ * says so, so it holds even on a page whose code never calls `seoHead`.
37
+ */
38
+ export function siteSeoHead(
39
+ ctx: SeoHeadContext | undefined | null,
40
+ fallback: SiteSeoFallback = {},
41
+ ): { meta: SeoMetaEntry[]; links: SeoLinkEntry[] } {
42
+ const seo = seoRecordFromContext(ctx);
43
+ const pathname = pathnameFromContext(ctx);
44
+
45
+ if (!seo) {
46
+ const name = fallback.siteName?.trim();
47
+ return {
48
+ meta: [
49
+ ...(name ? [{ title: name }, { property: "og:site_name", content: name }] : []),
50
+ ...(fallback.description ? [{ name: "description", content: fallback.description }] : []),
51
+ ...(fallback.image ? [{ property: "og:image", content: fallback.image }] : []),
52
+ { name: "twitter:card", content: "summary_large_image" },
53
+ ],
54
+ links: [],
55
+ };
56
+ }
57
+
58
+ const override = findOverride(seo, pathname, leafRoutePatternFromContext(ctx));
59
+ const description = seo.defaultDescription || fallback.description || undefined;
60
+ const image = seo.defaultImage || fallback.image || undefined;
61
+ const noindex = seo.indexing === false || override?.noindex === true;
62
+
63
+ const meta: SeoMetaEntry[] = [
64
+ { title: seo.siteName },
65
+ ...(description ? [{ name: "description", content: description }] : []),
66
+ { property: "og:site_name", content: seo.siteName },
67
+ ...(image ? [{ property: "og:image", content: image }] : []),
68
+ { name: "twitter:card", content: "summary_large_image" },
69
+ ...(seo.verification?.google ? [{ name: "google-site-verification", content: seo.verification.google }] : []),
70
+ ...(seo.verification?.bing ? [{ name: "msvalidate.01", content: seo.verification.bing }] : []),
71
+ ...(noindex ? [{ name: "robots", content: "noindex, nofollow" }] : []),
72
+ ];
73
+
74
+ if (pathname === "/") {
75
+ const origin = seo.origin.replace(/\/+$/, "");
76
+ const identity = buildIdentitySchema(seo.identity, { origin });
77
+ meta.push(
78
+ ...jsonLdMeta(
79
+ [
80
+ buildWebSiteSchema({
81
+ name: seo.siteName,
82
+ url: `${origin}/`,
83
+ description: seo.defaultDescription,
84
+ publisherId: identity ? identitySchemaId(origin) : null,
85
+ }),
86
+ identity,
87
+ ].filter((n): n is Record<string, unknown> => !!n),
88
+ ),
89
+ );
90
+ }
91
+
92
+ const links: SeoLinkEntry[] = isHostedPath(pathname)
93
+ ? []
94
+ : [{ rel: "canonical", href: canonicalFor(seo, pathname, override) }];
95
+
96
+ return { meta, links };
97
+ }
@@ -0,0 +1,99 @@
1
+ // The SEO record, as `GET /public/websites/seo` returns it.
2
+ //
3
+ // The backend owns the data (settings, templates, the creator's approved page
4
+ // overrides); code owns the defaults. These types are the seam between the two,
5
+ // and the backend returns exactly this shape. Change one side, change both.
6
+
7
+ export type SeoPageType =
8
+ | "page"
9
+ | "product"
10
+ | "event"
11
+ | "course"
12
+ | "coaching"
13
+ | "blog_post"
14
+ | "blog_category"
15
+ | "podcast"
16
+ | "podcast_episode"
17
+ | "community"
18
+ | "series"
19
+ | "film";
20
+
21
+ export type SeoPageOverride = {
22
+ title?: string | null;
23
+ description?: string | null;
24
+ image?: string | null;
25
+ /** Absolute URL. */
26
+ canonical?: string | null;
27
+ noindex?: boolean | null;
28
+ };
29
+
30
+ export type SeoIdentity = {
31
+ kind: "person" | "organization" | "local_business";
32
+ name: string;
33
+ logo?: string | null;
34
+ sameAs: string[];
35
+ address?: { street?: string; locality?: string; region?: string; postalCode?: string; country?: string } | null;
36
+ phone?: string | null;
37
+ };
38
+
39
+ export type SeoRecord = {
40
+ websiteId: string;
41
+ /** "https://claystudio.com": the primary verified custom domain, else https://{subdomain}.{ROOT_DOMAIN}. */
42
+ origin: string;
43
+ isDefaultWebsite: boolean;
44
+ /** Origin of the profile's default website. Entity canonicals point here. */
45
+ defaultWebsiteOrigin: string;
46
+ siteName: string;
47
+ defaultDescription: string | null;
48
+ defaultImage: string | null;
49
+ /** Default "{title} | {site}". */
50
+ titleTemplate: string;
51
+ typeTemplates: Partial<Record<SeoPageType, { title?: string; description?: string }>>;
52
+ identity: SeoIdentity | null;
53
+ verification: { google?: string | null; bing?: string | null };
54
+ /** false = the whole site is noindex. */
55
+ indexing: boolean;
56
+ /**
57
+ * Key = exact pathname ("/about", "/i/store/mug") OR route pattern
58
+ * ("/team/$slug"). Entity overrides arrive already expanded to their /i/
59
+ * pathname.
60
+ */
61
+ pages: Record<string, SeoPageOverride>;
62
+ /** Max updatedAt of the inputs, ISO. */
63
+ version: string;
64
+ };
65
+
66
+ /** What a route's code says its SEO is, before any creator edit. */
67
+ export type SeoHeadInput = {
68
+ title: string;
69
+ description?: string;
70
+ image?: string;
71
+ noindex?: boolean;
72
+ type?: SeoPageType;
73
+ jsonLd?: object | object[];
74
+ };
75
+
76
+ /**
77
+ * The part of TanStack's `head()` context Forge reads.
78
+ *
79
+ * Structural rather than imported, so Forge does not pin a router version and a
80
+ * site can pass `ctx` straight through.
81
+ */
82
+ export type SeoMatchLike = {
83
+ pathname?: string;
84
+ routeId?: string;
85
+ fullPath?: string;
86
+ loaderData?: unknown;
87
+ };
88
+
89
+ export type SeoHeadContext = {
90
+ matches?: ReadonlyArray<SeoMatchLike | undefined>;
91
+ match?: SeoMatchLike;
92
+ params?: Record<string, string>;
93
+ loaderData?: unknown;
94
+ };
95
+
96
+ /** One `head()` meta entry. A JSON-LD node rides as `{ "script:ld+json": obj }`. */
97
+ export type SeoMetaEntry = Record<string, unknown>;
98
+
99
+ export type SeoLinkEntry = { rel: string; href: string };
@@ -129,3 +129,68 @@ describe("fetchSiteBootstrap", () => {
129
129
  expect(r.diagnostics?.probe).toBeUndefined();
130
130
  });
131
131
  });
132
+
133
+ describe("fetchSiteBootstrap: the SEO record", () => {
134
+ const RECORD = {
135
+ websiteId: "w1",
136
+ origin: "https://clay.example",
137
+ isDefaultWebsite: true,
138
+ defaultWebsiteOrigin: "https://clay.example",
139
+ siteName: "Clay",
140
+ defaultDescription: null,
141
+ defaultImage: null,
142
+ titleTemplate: "{title} | {site}",
143
+ typeTemplates: {},
144
+ identity: null,
145
+ verification: {},
146
+ indexing: true,
147
+ pages: {},
148
+ version: "2026-10-01T00:00:00.000Z",
149
+ };
150
+
151
+ it("fetches it alongside the rest when a websiteId is given", async () => {
152
+ const seen: string[] = [];
153
+ stubFetch((url) => {
154
+ seen.push(url);
155
+ if (url.includes("/websites/seo")) return json(RECORD);
156
+ return url.includes("/content") ? json({ version: 0, defaultLocale: "en", locales: ["en"], fields: {} }) : json({});
157
+ });
158
+ const r = await fetchSiteBootstrap({ apiUrl: API, profileId: "p1", websiteId: "w1", websiteVersionId: "v1" });
159
+ expect(seen).toContain(`${API}/public/websites/seo?websiteId=w1`);
160
+ expect(r.seo).toEqual(RECORD);
161
+ expect(r.diagnostics).toBeNull();
162
+ });
163
+
164
+ it("does not ask for it without a websiteId", async () => {
165
+ const seen: string[] = [];
166
+ stubFetch((url) => {
167
+ seen.push(url);
168
+ return json({ version: 0, defaultLocale: "en", locales: ["en"], fields: {} });
169
+ });
170
+ const r = await fetchSiteBootstrap({ apiUrl: API, websiteVersionId: "v1" });
171
+ expect(seen.some((u) => u.includes("/websites/seo"))).toBe(false);
172
+ expect(r.seo).toBeNull();
173
+ });
174
+
175
+ it("is null on a 404, a network error or a body that is not a record, and never a diagnostics failure", async () => {
176
+ for (const fail of [
177
+ () => new Response("no", { status: 404 }),
178
+ () => {
179
+ throw new TypeError("fetch failed");
180
+ },
181
+ () => json({ error: "nope" }),
182
+ ]) {
183
+ stubFetch((url) =>
184
+ url.includes("/websites/seo")
185
+ ? fail()
186
+ : url.includes("/content")
187
+ ? json({ version: 0, defaultLocale: "en", locales: ["en"], fields: {} })
188
+ : json({}),
189
+ );
190
+ const r = await fetchSiteBootstrap({ apiUrl: API, profileId: "p1", websiteId: "w1", websiteVersionId: "v1" });
191
+ expect(r.seo).toBeNull();
192
+ expect(r.diagnostics).toBeNull();
193
+ vi.unstubAllGlobals();
194
+ }
195
+ });
196
+ });
@@ -56,6 +56,8 @@ import { seoContextFromSiteConfig, type ReviewSchemaReview, type SiteSeoContext
56
56
  // each route that needs share tags.
57
57
  import { buildHeadMeta } from "../utils/headMeta";
58
58
  export type { SiteSeoContext };
59
+ import type { SeoRecord } from "../seo/types";
60
+ export type { SeoRecord };
59
61
  import type { SiteConfig } from "../data/queries/useWebsite";
60
62
  // Type-only, so nothing from the React data layer reaches this React-free entry.
61
63
  // Re-exported so a route loader can name the shape it got back without importing
@@ -241,20 +243,31 @@ export async function probeApi(apiUrl: string, opts?: { timeoutMs?: number }): P
241
243
  export async function fetchSiteBootstrap(opts: {
242
244
  apiUrl: string;
243
245
  profileId?: string;
246
+ /**
247
+ * The website being rendered. When given, the SEO record is fetched in the
248
+ * same round trip and returned as `seo`, for `siteSeoHead` / `seoHead`.
249
+ */
250
+ websiteId?: string;
244
251
  websiteVersionId?: string;
245
252
  state?: "draft" | "published";
246
253
  /** Set false to skip the reachability probe even when a fetch failed. */
247
254
  probe?: boolean;
248
- }): Promise<{ contentDoc: ContentDocument; siteConfig: SiteConfig | null; diagnostics: ForgeSsrDiagnostics | null }> {
249
- const { apiUrl, profileId, websiteVersionId, state = "published" } = opts;
250
-
251
- const [content, siteConfig] = await Promise.all([
255
+ }): Promise<{
256
+ contentDoc: ContentDocument;
257
+ siteConfig: SiteConfig | null;
258
+ seo: SeoRecord | null;
259
+ diagnostics: ForgeSsrDiagnostics | null;
260
+ }> {
261
+ const { apiUrl, profileId, websiteId, websiteVersionId, state = "published" } = opts;
262
+
263
+ const [content, siteConfig, seo] = await Promise.all([
252
264
  websiteVersionId
253
265
  ? getJsonResult<ContentDocument>(apiUrl, "/public/websites/content", { websiteVersionId, state })
254
266
  : Promise.resolve<JsonResult<ContentDocument>>({ ok: true, data: emptyContentDocument() }),
255
267
  profileId
256
268
  ? getJsonResult<SiteConfig>(apiUrl, "/public/websites/site-config", { profileId })
257
269
  : Promise.resolve<JsonResult<SiteConfig | null>>({ ok: true, data: null }),
270
+ websiteId ? fetchSeoRecordServer({ apiUrl, websiteId }) : Promise.resolve(null),
258
271
  ]);
259
272
 
260
273
  // A deploy with no version / no profile baked in isn't a failure — there was
@@ -270,10 +283,26 @@ export async function fetchSiteBootstrap(opts: {
270
283
  return {
271
284
  contentDoc: content.ok ? (content.data ?? emptyContentDocument()) : emptyContentDocument(),
272
285
  siteConfig: siteConfig.ok ? (siteConfig.data ?? null) : null,
286
+ // Not a diagnostics failure: a site renders correctly without it, on the
287
+ // code's own SEO, and an older API simply does not have the route.
288
+ seo,
273
289
  diagnostics,
274
290
  };
275
291
  }
276
292
 
293
+ /**
294
+ * The website's SEO record (settings, templates, approved page overrides).
295
+ *
296
+ * `null` on ANY failure, including a 404 from an API that predates the route
297
+ * and a body that is not a record: every SEO function treats `null` as "use
298
+ * the code's own values", so a missing record degrades to today's tags rather
299
+ * than to a broken page.
300
+ */
301
+ export async function fetchSeoRecordServer(opts: { apiUrl: string; websiteId: string }): Promise<SeoRecord | null> {
302
+ const record = await getJson<SeoRecord>(opts.apiUrl, "/public/websites/seo", { websiteId: opts.websiteId });
303
+ return record && typeof record === "object" && typeof record.siteName === "string" ? record : null;
304
+ }
305
+
277
306
  /** Fetch a single event by id or slug for SSR. */
278
307
  /**
279
308
  * Every published event, for SSR of the events list.
@@ -474,6 +474,11 @@ export interface PublicAuthUser {
474
474
  updatedAt: string;
475
475
  id: string;
476
476
  membership?: Membership;
477
+ /**
478
+ * Set when this is a read-only "Preview as client" session: a creator viewing
479
+ * the portal as this person. Null (or absent on older APIs) otherwise.
480
+ */
481
+ preview?: { previewedByName: string; expiresAt: string } | null;
477
482
  }
478
483
 
479
484
  export type UpdateLocalUserPayload = Pick<PublicAuthUser, "firstName">;
@@ -550,6 +555,12 @@ export interface PublicAuthContextType extends PublicAuthState {
550
555
  validateLoginCredentials: (data: LoginInput & { profileId: string }) => Promise<string>;
551
556
  verifyLoginCode: (email: string, code: string, profileId: string) => Promise<{ smartLinkPath: string | undefined }>;
552
557
  resendLoginCode: (email: string) => Promise<void>;
558
+ /**
559
+ * Sign in with a session another call already minted, e.g. the email-first
560
+ * flow's `signed_in` answer: stores the token pair exactly as the code
561
+ * sign-ins do and sets the user.
562
+ */
563
+ adoptSession: (session: { token: string; refreshToken?: string; account: PublicAuthUser }) => void;
553
564
  logout: (persist?: boolean) => Promise<void>;
554
565
  updateLocalUser: (data: UpdateLocalUserPayload) => void;
555
566
  clearErrorMessage: () => void;