@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
package/src/index.ts CHANGED
@@ -138,12 +138,16 @@ export * from "./data/queries/useEngagement";
138
138
  export * from "./data/queries/usePaymentFlow";
139
139
  export * from "./data/queries/useOrders";
140
140
  export * from "./data/queries/useAuthActions";
141
+ // Email-first sign-in (`/public/sessions/email/*`): start, verify, complete.
142
+ export * from "./data/queries/useEmailSignIn";
141
143
  export * from "./data/queries/useCoupons";
142
144
  export * from "./data/queries/useAccountSettings";
143
145
  export * from "./data/queries/useDonations";
144
146
  export * from "./data/queries/useOffers";
145
147
  export * from "./data/queries/useShipping";
146
148
  export * from "./data/queries/useWebsite";
149
+ // The site app's settings for this host, and the Home rule (`useAppHomeHref`).
150
+ export * from "./data/queries/useSiteApp";
147
151
  export * from "./data/queries/useWebsiteAgent";
148
152
  export * from "./data/queries/useCurrencies";
149
153
  export * from "./data/queries/useChat";
@@ -151,6 +155,18 @@ export * from "./data/queries/useCallChat";
151
155
  export * from "./data/queries/useCallBreakouts";
152
156
  export * from "./data/queries/useAnalytics";
153
157
  export * from "./data/queries/useCommunity";
158
+ // The community AI on the member side: its info, DM, thumbs, "catch me up".
159
+ export * from "./data/queries/useCommunityAgent";
160
+ // Moderation from the site: the queue, the log, and the moderator actions.
161
+ export * from "./data/queries/useCommunityModeration";
162
+ // The community wiki as members and visitors read it.
163
+ export * from "./data/queries/useCommunityWiki";
164
+ // The member directory and map.
165
+ export * from "./data/queries/useCommunityMembers";
166
+ // The classroom tab: the courses a community's tiers include.
167
+ export * from "./data/queries/useCommunityClassroom";
168
+ // The community calendar: calls, promoted events and going / not going.
169
+ export * from "./data/queries/useCommunityCalendar";
154
170
  // Communities: the list, one community, the viewer's state in it, invites,
155
171
  // upgrades and the fan's memberships across communities.
156
172
  export * from "./data/queries/useCommunities";
@@ -176,6 +192,7 @@ export * from "./content";
176
192
  export * from "./utils/attribution";
177
193
  export * from "./utils/landing";
178
194
  export * from "./utils/metaPixel";
195
+ export * from "./utils/metaEvents";
179
196
  export * from "./utils/cookieConsent";
180
197
  export * from "./utils/structuredData";
181
198
  // Paystack inline checkout: the ONE implementation of the popup, exported so a
@@ -191,6 +208,9 @@ export * from "./utils/bookingSecret";
191
208
  // app can use them without inheriting the fan-site route tree, and so a fix
192
209
  // reaches existing sites through the normal Forge publish.
193
210
  export * from "./utils/headMeta";
211
+ // SEO: seoHead per route, siteSeoHead in the root, applySeoToHead for hosted
212
+ // pages. Also importable on its own from `@tribe-nest/forge/seo`.
213
+ export * from "./seo";
194
214
  export * from "./utils/formatDateTime";
195
215
  // The ONE membership access predicate + the messaging that is deliberately
196
216
  // separate from it. Every surface that gates on a membership imports from here.
@@ -47,15 +47,20 @@ describe("internal page links", () => {
47
47
  "communities/$slug/home",
48
48
  "communities/$slug/home/calendar",
49
49
  "communities/$slug/home/chat",
50
+ "communities/$slug/home/classroom",
50
51
  "communities/$slug/home/collections",
51
52
  "communities/$slug/home/collections/$collectionId",
52
53
  "communities/$slug/home/leaderboard",
54
+ "communities/$slug/home/members",
55
+ "communities/$slug/home/moderation",
53
56
  "communities/$slug/home/notifications",
54
57
  "communities/$slug/home/posts",
55
58
  "communities/$slug/home/profile/$accountId",
56
59
  "communities/$slug/home/spaces",
57
60
  "communities/$slug/home/spaces/$spaceId",
58
61
  "communities/$slug/home/spaces/posts/$postId",
62
+ "communities/$slug/home/wiki",
63
+ "communities/$slug/home/wiki/$page",
59
64
  ]);
60
65
  const removed = INTERNAL_PAGES.filter((page) => /^(membership|members|community|chat)(\/|$)/.test(page));
61
66
  expect(removed).toEqual([]);
@@ -9,12 +9,16 @@
9
9
  *
10
10
  * The page list is the CONTRACT between a site and the hosted pages, so it
11
11
  * normally only grows: removing a page breaks links on sites already published.
12
- * The one deliberate exception is Communities (2026-09-27). A profile can now
12
+ * The first deliberate exception is Communities (2026-09-27). A profile can now
13
13
  * run several communities, so the single-community member pages (`membership`,
14
14
  * `members/*`, `community/*`, `chat`) were removed with no redirects, because
15
15
  * nobody used them yet and there is no one community they could redirect to.
16
16
  * Every member page now lives under `communities/$slug`.
17
17
  *
18
+ * The second is the client portal (2026-10-01). `work/*` and the `?tab=` form
19
+ * of `account` folded into one portal under `account/*`, with no redirects:
20
+ * every producer of those links moved in the same change.
21
+ *
18
22
  * <Link {...internalLink("store/$slug", { slug: product.slug })}>Buy</Link>
19
23
  * navigate(internalLink("checkout"))
20
24
  * const href = internalPath("events/$slug", { slug });
@@ -29,6 +33,27 @@ export const INTERNAL_ROUTE = "/i/$" as const;
29
33
  /** Every hosted page, by its path pattern. Generated from the route table. */
30
34
  export const INTERNAL_PAGES = [
31
35
  "account",
36
+ "account/activity",
37
+ "account/bookings",
38
+ "account/courses",
39
+ "account/documents",
40
+ "account/files",
41
+ "account/films",
42
+ "account/invite",
43
+ "account/invoices",
44
+ "account/memberships",
45
+ "account/messages",
46
+ "account/notifications",
47
+ "account/orders",
48
+ "account/projects",
49
+ "account/projects/$projectId",
50
+ "account/projects/$projectId/tasks/$taskId",
51
+ "account/saved",
52
+ "account/settings",
53
+ "account/tickets",
54
+ "account/time-report/$token",
55
+ "account/waitlist",
56
+ "app",
32
57
  "blog",
33
58
  "blog-category/$slug",
34
59
  "blog/$slug",
@@ -45,15 +70,20 @@ export const INTERNAL_PAGES = [
45
70
  "communities/$slug/home",
46
71
  "communities/$slug/home/calendar",
47
72
  "communities/$slug/home/chat",
73
+ "communities/$slug/home/classroom",
48
74
  "communities/$slug/home/collections",
49
75
  "communities/$slug/home/collections/$collectionId",
50
76
  "communities/$slug/home/leaderboard",
77
+ "communities/$slug/home/members",
78
+ "communities/$slug/home/moderation",
51
79
  "communities/$slug/home/notifications",
52
80
  "communities/$slug/home/posts",
53
81
  "communities/$slug/home/profile/$accountId",
54
82
  "communities/$slug/home/spaces",
55
83
  "communities/$slug/home/spaces/$spaceId",
56
84
  "communities/$slug/home/spaces/posts/$postId",
85
+ "communities/$slug/home/wiki",
86
+ "communities/$slug/home/wiki/$page",
57
87
  "confirm-subscription",
58
88
  "course-access/$accessId",
59
89
  "courses",
@@ -62,6 +92,7 @@ export const INTERNAL_PAGES = [
62
92
  "courses/claim",
63
93
  "documents/$token",
64
94
  "donation",
95
+ "downloads/$token",
65
96
  "events",
66
97
  "events/$slug",
67
98
  "events/$slug/finalise",
@@ -91,11 +122,6 @@ export const INTERNAL_PAGES = [
91
122
  "store/$slug",
92
123
  "tickets/claim",
93
124
  "video-calls/$callId",
94
- "work",
95
- "work/accept-invite",
96
- "work/projects/$projectId",
97
- "work/projects/$projectId/tasks/$taskId",
98
- "work/time-report/$token",
99
125
  ] as const;
100
126
 
101
127
  export type InternalPage = (typeof INTERNAL_PAGES)[number];
@@ -0,0 +1,326 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ applySeoToHead,
4
+ applyTitleTemplate,
5
+ findOverride,
6
+ normalizePathname,
7
+ resolvePageSeo,
8
+ seoHead,
9
+ siteSeoHead,
10
+ type SeoHeadContext,
11
+ type SeoRecord,
12
+ } from "../index";
13
+ import { buildHeadMeta } from "../../utils/headMeta";
14
+
15
+ const RECORD: SeoRecord = {
16
+ websiteId: "w1",
17
+ origin: "https://claystudio.com",
18
+ isDefaultWebsite: true,
19
+ defaultWebsiteOrigin: "https://claystudio.com",
20
+ siteName: "Clay Studio",
21
+ defaultDescription: "A small pottery studio in Hackney.",
22
+ defaultImage: "https://cdn.example/share.jpg",
23
+ titleTemplate: "{title} | {site}",
24
+ typeTemplates: {},
25
+ identity: {
26
+ kind: "local_business",
27
+ name: "Clay Studio",
28
+ logo: "https://cdn.example/logo.png",
29
+ sameAs: ["https://instagram.com/claystudio", "not a url"],
30
+ address: { street: "1 Mare St", locality: "London", postalCode: "E8 1AA", country: "GB" },
31
+ phone: "+44 20 0000 0000",
32
+ },
33
+ verification: { google: "g-token", bing: "b-token" },
34
+ indexing: true,
35
+ pages: {},
36
+ version: "2026-10-01T00:00:00.000Z",
37
+ };
38
+
39
+ const record = (patch: Partial<SeoRecord> = {}): SeoRecord => ({ ...RECORD, ...patch });
40
+
41
+ /** A head() context shaped like TanStack's: root match first, leaf last. */
42
+ function ctx(pathname: string, seo: SeoRecord | null | undefined, fullPath = pathname): SeoHeadContext {
43
+ const leaf = { pathname, fullPath, routeId: fullPath, loaderData: {} };
44
+ return {
45
+ matches: [{ pathname: "/", fullPath: "/", routeId: "__root__", loaderData: { seo } }, leaf],
46
+ match: leaf,
47
+ params: {},
48
+ };
49
+ }
50
+
51
+ /** Title + every name/property entry, flattened for assertions. */
52
+ function tags(meta: Record<string, unknown>[]) {
53
+ const out: Record<string, unknown> = {};
54
+ for (const m of meta) {
55
+ if (typeof m.title === "string") out.title = m.title;
56
+ const key = (m.name ?? m.property) as string | undefined;
57
+ if (key) out[key] = m.content;
58
+ }
59
+ return out;
60
+ }
61
+ const jsonLd = (meta: Record<string, unknown>[]) =>
62
+ meta.filter((m) => "script:ld+json" in m).map((m) => m["script:ld+json"] as Record<string, unknown>);
63
+
64
+ describe("normalizePathname", () => {
65
+ it("treats a trailing slash, a query and a missing slash as the same page", () => {
66
+ expect(normalizePathname("/about/")).toBe("/about");
67
+ expect(normalizePathname("/about?x=1")).toBe("/about");
68
+ expect(normalizePathname("about")).toBe("/about");
69
+ expect(normalizePathname("/")).toBe("/");
70
+ expect(normalizePathname("")).toBe("/");
71
+ });
72
+ });
73
+
74
+ describe("applyTitleTemplate", () => {
75
+ it("applies the template", () => {
76
+ expect(applyTitleTemplate("About", "Clay Studio", "{title} | {site}")).toBe("About | Clay Studio");
77
+ });
78
+ it("accepts {site.name} as well as {site}", () => {
79
+ expect(applyTitleTemplate("About", "Clay Studio", "{site.name}: {title}")).toBe("Clay Studio: About");
80
+ });
81
+ it("skips the template when the title already names the site, in any case", () => {
82
+ expect(applyTitleTemplate("clay studio, Hackney", "Clay Studio", "{title} | {site}")).toBe("clay studio, Hackney");
83
+ });
84
+ it("falls back to the default template when the record's is blank", () => {
85
+ expect(applyTitleTemplate("About", "Clay Studio", " ")).toBe("About | Clay Studio");
86
+ });
87
+ });
88
+
89
+ describe("findOverride", () => {
90
+ it("prefers the exact pathname and fills gaps from the route pattern", () => {
91
+ const seo = record({
92
+ pages: {
93
+ "/team/ada": { title: "Ada, head potter" },
94
+ "/team/$slug": { title: "Team member", description: "Pattern description", noindex: true },
95
+ },
96
+ });
97
+ expect(findOverride(seo, "/team/ada/", "/team/$slug")).toEqual({
98
+ title: "Ada, head potter",
99
+ description: "Pattern description",
100
+ noindex: true,
101
+ });
102
+ });
103
+
104
+ it("ignores blank strings so an emptied field does not erase the code's value", () => {
105
+ const seo = record({ pages: { "/about": { title: " ", description: null } } });
106
+ expect(findOverride(seo, "/about")).toEqual({});
107
+ });
108
+ });
109
+
110
+ describe("resolvePageSeo", () => {
111
+ it("with no record, behaves exactly like the code's own values", () => {
112
+ expect(resolvePageSeo(null, { pathname: "/about", title: "About" })).toEqual({
113
+ title: "About",
114
+ shareTitle: "About",
115
+ description: "About",
116
+ image: undefined,
117
+ noindex: false,
118
+ canonical: null,
119
+ overridden: false,
120
+ });
121
+ });
122
+
123
+ it("uses a type template over the site template, and its description when the code has none", () => {
124
+ const seo = record({
125
+ typeTemplates: { event: { title: "{title} tickets | {site}", description: "Book {title} at {site}." } },
126
+ });
127
+ const r = resolvePageSeo(seo, { pathname: "/i/events/gig", title: "Late Show", type: "event", entity: true });
128
+ expect(r.title).toBe("Late Show tickets | Clay Studio");
129
+ expect(r.shareTitle).toBe("Late Show");
130
+ expect(r.description).toBe("Book Late Show at Clay Studio.");
131
+ });
132
+
133
+ it("falls back to site defaults for description and image", () => {
134
+ const r = resolvePageSeo(record(), { pathname: "/about", title: "About" });
135
+ expect(r.description).toBe(RECORD.defaultDescription);
136
+ expect(r.image).toBe(RECORD.defaultImage);
137
+ });
138
+
139
+ it("canonicalizes entities to the default website and pages to their own origin", () => {
140
+ const seo = record({ origin: "https://shop.example", defaultWebsiteOrigin: "https://claystudio.com" });
141
+ expect(resolvePageSeo(seo, { pathname: "/i/store/mug", title: "Mug", entity: true }).canonical).toBe(
142
+ "https://claystudio.com/i/store/mug",
143
+ );
144
+ expect(resolvePageSeo(seo, { pathname: "/about", title: "About" }).canonical).toBe("https://shop.example/about");
145
+ });
146
+
147
+ it("noindexes everything when the site is not indexing, whatever the code says", () => {
148
+ expect(resolvePageSeo(record({ indexing: false }), { pathname: "/", title: "Home" }).noindex).toBe(true);
149
+ });
150
+
151
+ it("lets an override's noindex:false beat the code's noindex", () => {
152
+ const seo = record({ pages: { "/thanks": { noindex: false } } });
153
+ expect(resolvePageSeo(seo, { pathname: "/thanks", title: "Thanks", noindex: true }).noindex).toBe(false);
154
+ });
155
+ });
156
+
157
+ describe("seoHead", () => {
158
+ it("with no record, emits today's buildHeadMeta tags and never links", () => {
159
+ const out = seoHead(ctx("/about", undefined), { title: "About", description: "Who we are", image: "https://x/i.jpg" });
160
+ expect(out).not.toHaveProperty("links");
161
+ const legacy = buildHeadMeta({ title: "About", description: "Who we are", image: "https://x/i.jpg" });
162
+ expect(tags(out.meta)).toEqual(tags(legacy.meta));
163
+ });
164
+
165
+ it("applies the title template to the code title", () => {
166
+ const out = seoHead(ctx("/about", RECORD), { title: "About", description: "Who we are" });
167
+ const t = tags(out.meta);
168
+ expect(t.title).toBe("About | Clay Studio");
169
+ expect(t["og:title"]).toBe("About");
170
+ expect(t.description).toBe("Who we are");
171
+ expect(t["og:url"]).toBe("https://claystudio.com/about");
172
+ expect(t.robots).toBeUndefined();
173
+ });
174
+
175
+ it("uses an override for the exact pathname, verbatim", () => {
176
+ const seo = record({ pages: { "/about": { title: "Our story", description: "Since 2019." } } });
177
+ const t = tags(seoHead(ctx("/about", seo), { title: "About", description: "Who we are" }).meta);
178
+ expect(t.title).toBe("Our story");
179
+ expect(t.description).toBe("Since 2019.");
180
+ });
181
+
182
+ it("uses an override keyed by the route pattern for a dynamic route", () => {
183
+ const seo = record({ pages: { "/team/$slug": { description: "One of our potters." } } });
184
+ const t = tags(seoHead(ctx("/team/ada", seo, "/team/$slug"), { title: "Ada" }).meta);
185
+ expect(t.title).toBe("Ada | Clay Studio");
186
+ expect(t.description).toBe("One of our potters.");
187
+ });
188
+
189
+ it("emits robots noindex from the code, from an override, and from a non-indexing site", () => {
190
+ expect(tags(seoHead(ctx("/x", RECORD), { title: "X", noindex: true }).meta).robots).toBe("noindex, nofollow");
191
+ const seo = record({ pages: { "/x": { noindex: true } } });
192
+ expect(tags(seoHead(ctx("/x", seo), { title: "X" }).meta).robots).toBe("noindex, nofollow");
193
+ expect(tags(seoHead(ctx("/x", record({ indexing: false })), { title: "X" }).meta).robots).toBe("noindex, nofollow");
194
+ });
195
+
196
+ it("emits each jsonLd node as a script:ld+json meta entry", () => {
197
+ const nodes = [{ "@type": "Thing", name: "a" }, { "@type": "Thing", name: "b" }];
198
+ expect(jsonLd(seoHead(ctx("/x", RECORD), { title: "X", jsonLd: nodes }).meta)).toEqual(nodes);
199
+ expect(jsonLd(seoHead(ctx("/x", RECORD), { title: "X", jsonLd: nodes[0] }).meta)).toEqual([nodes[0]]);
200
+ });
201
+ });
202
+
203
+ describe("siteSeoHead", () => {
204
+ it("emits the site fallbacks, verification and a canonical for the leaf path", () => {
205
+ const out = siteSeoHead(ctx("/about/", RECORD));
206
+ const t = tags(out.meta);
207
+ expect(t).toMatchObject({
208
+ title: "Clay Studio",
209
+ description: RECORD.defaultDescription,
210
+ "og:site_name": "Clay Studio",
211
+ "og:image": RECORD.defaultImage,
212
+ "twitter:card": "summary_large_image",
213
+ "google-site-verification": "g-token",
214
+ "msvalidate.01": "b-token",
215
+ });
216
+ expect(t.robots).toBeUndefined();
217
+ expect(out.links).toEqual([{ rel: "canonical", href: "https://claystudio.com/about" }]);
218
+ });
219
+
220
+ it("uses an override canonical", () => {
221
+ const seo = record({ pages: { "/about": { canonical: "https://claystudio.com/story" } } });
222
+ expect(siteSeoHead(ctx("/about", seo)).links).toEqual([{ rel: "canonical", href: "https://claystudio.com/story" }]);
223
+ });
224
+
225
+ it("emits no canonical under /i/, where the hosted page emits its own", () => {
226
+ expect(siteSeoHead(ctx("/i/store/mug", RECORD)).links).toEqual([]);
227
+ expect(siteSeoHead(ctx("/i", RECORD)).links).toEqual([]);
228
+ // A creator page that merely starts with "i" is not hosted.
229
+ expect(siteSeoHead(ctx("/ideas", RECORD)).links).toHaveLength(1);
230
+ });
231
+
232
+ it("noindexes when the site is not indexing or the path's override says so", () => {
233
+ expect(tags(siteSeoHead(ctx("/", record({ indexing: false }))).meta).robots).toBe("noindex, nofollow");
234
+ const seo = record({ pages: { "/team/$slug": { noindex: true } } });
235
+ expect(tags(siteSeoHead(ctx("/team/ada", seo, "/team/$slug")).meta).robots).toBe("noindex, nofollow");
236
+ });
237
+
238
+ it("emits WebSite and identity JSON-LD on / only", () => {
239
+ const home = jsonLd(siteSeoHead(ctx("/", RECORD)).meta);
240
+ expect(home.map((n) => n["@type"])).toEqual(["WebSite", "LocalBusiness"]);
241
+ expect(home[0]).toMatchObject({
242
+ name: "Clay Studio",
243
+ url: "https://claystudio.com/",
244
+ publisher: { "@id": "https://claystudio.com/#identity" },
245
+ });
246
+ expect(home[1]).toMatchObject({
247
+ "@id": "https://claystudio.com/#identity",
248
+ telephone: "+44 20 0000 0000",
249
+ sameAs: ["https://instagram.com/claystudio"],
250
+ address: { "@type": "PostalAddress", streetAddress: "1 Mare St", addressCountry: "GB" },
251
+ });
252
+ expect(jsonLd(siteSeoHead(ctx("/about", RECORD)).meta)).toEqual([]);
253
+ });
254
+
255
+ it("emits WebSite alone when there is no identity", () => {
256
+ const home = jsonLd(siteSeoHead(ctx("/", record({ identity: null }))).meta);
257
+ expect(home).toHaveLength(1);
258
+ expect(home[0]).not.toHaveProperty("publisher");
259
+ });
260
+
261
+ it("with no record, emits only the fallback it was given and no canonical", () => {
262
+ const out = siteSeoHead(ctx("/about", null), { siteName: "Clay" });
263
+ expect(out.links).toEqual([]);
264
+ expect(tags(out.meta)).toEqual({ title: "Clay", "og:site_name": "Clay", "twitter:card": "summary_large_image" });
265
+ expect(tags(siteSeoHead(ctx("/", undefined)).meta)).toEqual({ "twitter:card": "summary_large_image" });
266
+ });
267
+ });
268
+
269
+ describe("applySeoToHead", () => {
270
+ const productHead = () => ({
271
+ ...buildHeadMeta({ title: "Mug", description: "A mug.", image: "https://cdn.example/mug.jpg", ogType: "product" }),
272
+ scripts: [{ type: "application/ld+json", children: "{}" }],
273
+ });
274
+
275
+ it("returns the head untouched with no record", () => {
276
+ const head = productHead();
277
+ expect(applySeoToHead(head, null, "/i/store/mug", "product")).toBe(head);
278
+ });
279
+
280
+ it("templates the title, keeps the page's own tags and adds the entity canonical", () => {
281
+ const seo = record({ origin: "https://shop.example", defaultWebsiteOrigin: "https://claystudio.com" });
282
+ const out = applySeoToHead(productHead(), seo, "/i/store/mug", "product", "/i/store/$slug");
283
+ const t = tags(out.meta!);
284
+ expect(t.title).toBe("Mug | Clay Studio");
285
+ expect(t["og:title"]).toBe("Mug");
286
+ expect(t["og:type"]).toBe("product");
287
+ expect(t["og:site_name"]).toBe("Clay Studio");
288
+ expect(t["og:url"]).toBe("https://claystudio.com/i/store/mug");
289
+ expect(out.links).toEqual([{ rel: "canonical", href: "https://claystudio.com/i/store/mug" }]);
290
+ expect(out.scripts).toEqual([{ type: "application/ld+json", children: "{}" }]);
291
+ // Exactly one of each: the old entries were replaced, not appended to.
292
+ expect(out.meta!.filter((m) => m.name === "description")).toHaveLength(1);
293
+ expect(out.meta!.filter((m) => typeof m.title === "string")).toHaveLength(1);
294
+ });
295
+
296
+ it("canonicalizes an index page to its own website", () => {
297
+ const seo = record({ origin: "https://shop.example", defaultWebsiteOrigin: "https://claystudio.com" });
298
+ const out = applySeoToHead(buildHeadMeta({ title: "Store" }), seo, "/i/store", "page");
299
+ expect(out.links).toEqual([{ rel: "canonical", href: "https://shop.example/i/store" }]);
300
+ });
301
+
302
+ it("applies overrides by pathname and by pattern", () => {
303
+ const seo = record({
304
+ pages: {
305
+ "/i/store/mug": { title: "The best mug" },
306
+ "/i/store/$slug": { description: "Handmade in Hackney.", image: "https://cdn.example/o.jpg" },
307
+ },
308
+ });
309
+ const t = tags(applySeoToHead(productHead(), seo, "/i/store/mug", "product", "/i/store/$slug").meta!);
310
+ expect(t.title).toBe("The best mug");
311
+ expect(t.description).toBe("Handmade in Hackney.");
312
+ expect(t["og:image"]).toBe("https://cdn.example/o.jpg");
313
+ expect(t["twitter:image"]).toBe("https://cdn.example/o.jpg");
314
+ });
315
+
316
+ it("does not let buildHeadMeta's title-as-description beat the site default", () => {
317
+ const t = tags(applySeoToHead(buildHeadMeta({ title: "Events" }), RECORD, "/i/events", "page").meta!);
318
+ expect(t.description).toBe(RECORD.defaultDescription);
319
+ });
320
+
321
+ it("keeps a page's noindex and then states no canonical", () => {
322
+ const out = applySeoToHead(buildHeadMeta({ title: "Login", noindex: true }), RECORD, "/i/login", "page");
323
+ expect(tags(out.meta!).robots).toBe("noindex, nofollow");
324
+ expect(out.links).toEqual([]);
325
+ });
326
+ });
@@ -0,0 +1,98 @@
1
+ import { normalizePathname, resolvePageSeo } from "./resolve";
2
+ import type { SeoPageType, SeoRecord } from "./types";
3
+
4
+ type HeadEntry = Record<string, string | undefined>;
5
+
6
+ /** The `{ meta, links, scripts }` shape a hosted page's `head()` returns. */
7
+ export type HeadTags = {
8
+ meta?: HeadEntry[];
9
+ links?: HeadEntry[];
10
+ scripts?: HeadEntry[];
11
+ };
12
+
13
+ /**
14
+ * Meta this function owns and rewrites. Anything else a page set (`og:type`,
15
+ * `og:video`, a custom tag) passes through untouched.
16
+ */
17
+ const OWNED_META = new Set([
18
+ "description",
19
+ "robots",
20
+ "og:title",
21
+ "og:description",
22
+ "og:image",
23
+ "og:url",
24
+ "og:site_name",
25
+ "twitter:title",
26
+ "twitter:description",
27
+ "twitter:image",
28
+ ]);
29
+
30
+ const metaKey = (m: HeadEntry) => m.name ?? m.property;
31
+
32
+ /**
33
+ * Resolve the SEO record over a hosted `/i/` page's own head.
34
+ *
35
+ * The page's `head()` says what its code defaults are (title, description,
36
+ * image, noindex), exactly as before. This applies, on top of them, the
37
+ * creator's override for the pathname or pattern, the page type's template,
38
+ * the site defaults and the canonical.
39
+ *
40
+ * The canonical is emitted HERE, not by the site's root, because only the page
41
+ * knows it is an entity: a product, event or post canonicalizes to the
42
+ * profile's DEFAULT website (`defaultWebsiteOrigin`), so the same product shown
43
+ * on three websites is one result, not three. An index page (`/i/store`)
44
+ * belongs to the website it is on.
45
+ *
46
+ * With no record, the head is returned unchanged.
47
+ */
48
+ export function applySeoToHead<T extends HeadTags>(
49
+ head: T,
50
+ seo: SeoRecord | null | undefined,
51
+ pathname: string,
52
+ type: SeoPageType = "page",
53
+ routePattern?: string,
54
+ ): T {
55
+ if (!seo) return head;
56
+
57
+ const meta = head.meta ?? [];
58
+ const find = (key: string) => meta.find((m) => metaKey(m) === key)?.content;
59
+ const codeTitle = meta.find((m) => typeof m.title === "string")?.title ?? "";
60
+ const codeNoindex = /noindex/i.test(find("robots") ?? "");
61
+
62
+ const path = normalizePathname(pathname);
63
+ const resolved = resolvePageSeo(seo, {
64
+ pathname: path,
65
+ routePattern,
66
+ type,
67
+ title: codeTitle,
68
+ // `buildHeadMeta` falls back to the title when a page has no description.
69
+ // That fallback is not a description the code chose, so it must not beat
70
+ // the type template or the site default.
71
+ description: find("description") && find("description") !== codeTitle ? find("description") : undefined,
72
+ image: find("og:image"),
73
+ noindex: codeNoindex,
74
+ entity: type !== "page",
75
+ });
76
+
77
+ const kept = meta.filter((m) => typeof m.title !== "string" && !OWNED_META.has(metaKey(m) ?? ""));
78
+ const nextMeta: HeadEntry[] = [
79
+ { title: resolved.title },
80
+ { name: "description", content: resolved.description },
81
+ { property: "og:title", content: resolved.shareTitle },
82
+ { property: "og:description", content: resolved.description },
83
+ { property: "og:site_name", content: seo.siteName },
84
+ ...(resolved.canonical && !resolved.noindex ? [{ property: "og:url", content: resolved.canonical }] : []),
85
+ ...(resolved.image ? [{ property: "og:image", content: resolved.image }] : []),
86
+ ...kept,
87
+ { name: "twitter:title", content: resolved.shareTitle },
88
+ { name: "twitter:description", content: resolved.description },
89
+ ...(resolved.image ? [{ name: "twitter:image", content: resolved.image }] : []),
90
+ ...(resolved.noindex ? [{ name: "robots", content: "noindex, nofollow" }] : []),
91
+ ];
92
+
93
+ // A noindex page states no canonical: there is no indexed copy to point at.
94
+ const links = (head.links ?? []).filter((l) => l.rel !== "canonical");
95
+ if (resolved.canonical && !resolved.noindex) links.push({ rel: "canonical", href: resolved.canonical });
96
+
97
+ return { ...head, meta: nextMeta, links };
98
+ }
@@ -0,0 +1,9 @@
1
+ // SEO for code sites and hosted pages: code declares the defaults, the
2
+ // creator's approved edits live in the SEO record, and these functions resolve
3
+ // one over the other. Pure, React-free, so it is safe on the server entry and in
4
+ // a route's head().
5
+ export * from "./types";
6
+ export * from "./resolve";
7
+ export * from "./seoHead";
8
+ export * from "./siteSeoHead";
9
+ export * from "./applySeoToHead";