@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
@@ -1,10 +1,12 @@
1
1
  import { useEffect, useRef } from "react";
2
2
  import { useTrackEvent, newEventId } from "../../data/queries/useAnalytics";
3
3
  import { useSiteConfig } from "../../data/queries/useWebsite";
4
- import { initMetaPixelScoped, fireMetaEventSingle } from "../../utils/metaPixel";
4
+ import { initMetaPixelScoped } from "../../utils/metaPixel";
5
+ import { setMetaPolicy, trackMeta } from "../../utils/metaEvents";
5
6
  import { setCurrentPageEventId, isProfilePixelSuppressed } from "../../utils/analyticsBus";
6
7
  import { useCookieConsent } from "../headless/consent/useCookieConsent";
7
8
  import { createEngagementTracker, scrollDepthOf, type EngagementTracker } from "../../utils/engagementTracker";
9
+ import { onCLS, onINP, onLCP, type Metric } from "web-vitals";
8
10
 
9
11
  export interface ForgeAnalyticsProps {
10
12
  /**
@@ -17,7 +19,7 @@ export interface ForgeAnalyticsProps {
17
19
  metaPixelId?: string;
18
20
  /** Auto-track clicks on links/buttons (and any `[data-track]` element). Default `true`. */
19
21
  trackClicks?: boolean;
20
- /** Master switch — set `false` (e.g. in the editor/preview) to record nothing. Default `true`. */
22
+ /** Master switch: set `false` (e.g. in the editor/preview) to record nothing. Default `true`. */
21
23
  enabled?: boolean;
22
24
  /**
23
25
  * Honour the visitor's cookie choice (from `<CookieConsent />`). Default
@@ -43,7 +45,7 @@ export interface ForgeAnalyticsProps {
43
45
  }
44
46
 
45
47
  /**
46
- * The full resolved path the visitor is on (e.g. `/i/store/abc-123`) — never a
48
+ * The full resolved path the visitor is on (e.g. `/i/store/abc-123`), never a
47
49
  * route template like `store/:id`. This is the value the admin analytics "top
48
50
  * pages" report groups on (`event_data->>'pathname'`), so a code site's real
49
51
  * routes show up per-page instead of collapsed into a parameterised template.
@@ -53,12 +55,67 @@ function currentPathname(): string {
53
55
  return window.location.pathname;
54
56
  }
55
57
 
56
- /** Full route including query — kept alongside `pathname` for detail. */
58
+ /** Full route including query, kept alongside `pathname` for detail. */
57
59
  function currentPage(): string {
58
60
  if (typeof window === "undefined") return "";
59
61
  return window.location.pathname + window.location.search;
60
62
  }
61
63
 
64
+ /**
65
+ * Web Vitals (LCP, INP, CLS), for the SEO tab's page speed report.
66
+ *
67
+ * `web-vitals` callbacks cannot be unregistered, so they are registered once
68
+ * per page load, the first time first-party analytics is allowed, and report
69
+ * through whichever `<ForgeAnalytics />` is mounted now (`vitalsSink`). With
70
+ * none mounted, or analytics switched off, a report is dropped. Observers are
71
+ * buffered, so a visitor who consents after the page painted still reports its
72
+ * LCP.
73
+ *
74
+ * Each metric instance is sent once (its first report): a page view, or a
75
+ * restore from the back/forward cache, which `web-vitals` treats as a new one.
76
+ * The path is the page the metric belongs to: the URL `web-vitals` names for
77
+ * it, else the page the document loaded on, since an SPA navigation does not
78
+ * start a new measurement.
79
+ */
80
+ type VitalReport = { name: Metric["name"]; value: number; rating: Metric["rating"]; path: string };
81
+ let vitalsSink: ((report: VitalReport) => void) | null = null;
82
+ let vitalsRegistered = false;
83
+ const vitalsSent = new Set<string>();
84
+
85
+ function pathOfMetric(metric: Metric, loadPath: string): string {
86
+ try {
87
+ if (metric.navigationURL) return new URL(metric.navigationURL, window.location.href).pathname;
88
+ } catch {
89
+ // fall through to the page the document loaded on
90
+ }
91
+ return loadPath;
92
+ }
93
+
94
+ function registerWebVitals(): void {
95
+ if (vitalsRegistered || typeof window === "undefined") return;
96
+ vitalsRegistered = true;
97
+ const loadPath = currentPathname();
98
+ const report = (metric: Metric) => {
99
+ if (vitalsSent.has(metric.id) || !vitalsSink) return;
100
+ vitalsSent.add(metric.id);
101
+ vitalsSink({ name: metric.name, value: metric.value, rating: metric.rating, path: pathOfMetric(metric, loadPath) });
102
+ };
103
+ try {
104
+ onLCP(report);
105
+ onINP(report);
106
+ onCLS(report);
107
+ } catch {
108
+ // An unsupported browser measures nothing; never break the page for it.
109
+ }
110
+ }
111
+
112
+ /** For tests: forget the registration and what was sent, as a fresh page load would. */
113
+ export function __resetWebVitalsForTests(): void {
114
+ vitalsSink = null;
115
+ vitalsRegistered = false;
116
+ vitalsSent.clear();
117
+ }
118
+
62
119
  /** Engaged means the visitor can see the page and it has focus. */
63
120
  function isEngaged(): boolean {
64
121
  return typeof document !== "undefined" && document.visibilityState === "visible" && document.hasFocus();
@@ -73,12 +130,14 @@ function isEngaged(): boolean {
73
130
  * - Auto click tracking on links/buttons (opt out with `trackClicks={false}`).
74
131
  * - Time on page and scroll depth, as `engagement` events (engaged time only:
75
132
  * visible and focused).
133
+ * - Page speed: LCP, INP and CLS (Web Vitals), as one `web_vital` event per
134
+ * metric per page view, for the SEO tab.
76
135
  * - Optional Meta Pixel: pass `metaPixelId` to init the pixel and mirror each
77
136
  * page view as a Meta `PageView` (event-id matched for CAPI de-dup).
78
137
  *
79
138
  * Consent-aware: with `requireConsent` (default), it reads the shared cookie
80
- * consent — first-party analytics wait for `performance`, the Meta Pixel waits
81
- * for `marketing` — so pairing it with `<CookieConsent />` is GDPR-correct out
139
+ * consent (first-party analytics wait for `performance`, the Meta Pixel waits
140
+ * for `marketing`), so pairing it with `<CookieConsent />` is GDPR-correct out
82
141
  * of the box. Router-agnostic: SPA navigations are detected via the History API,
83
142
  * so it works under any router (or none). It renders nothing.
84
143
  *
@@ -93,18 +152,28 @@ export function ForgeAnalytics({
93
152
  }: ForgeAnalyticsProps) {
94
153
  const cookieless = firstPartyMode === "cookieless";
95
154
  // Resolve the pixel from the tenant's runtime settings when not explicitly
96
- // passed (so changing it needs no rebuild). An explicit prop — including `""`
97
- // to disable — always wins.
98
- const { data: siteConfig } = useSiteConfig();
155
+ // passed (so changing it needs no rebuild). An explicit prop (including `""`
156
+ // to disable) always wins.
157
+ const siteConfigQuery = useSiteConfig();
158
+ const siteConfig = siteConfigQuery.data;
99
159
  const pixelId = metaPixelId ?? siteConfig?.metaPixelId ?? undefined;
160
+ // The pixel is known once it was passed, or the site config has answered.
161
+ // Until then Meta events fired elsewhere (a detail page mounting first) wait.
162
+ const pixelResolved = metaPixelId !== undefined || !!siteConfig || !siteConfigQuery.isLoading;
100
163
  const { performance: perfConsent, marketing: mktConsent } = useCookieConsent();
101
164
  // Session storage follows performance consent. In `consent` mode first-party
102
165
  // only runs with that consent, so it always stores; in `cookieless` mode it
103
166
  // runs regardless but only stores a device session once the visitor turns
104
- // Performance on — otherwise it stays cookieless (backend derives the id).
167
+ // Performance on; otherwise it stays cookieless (backend derives the id).
105
168
  const storeSession = cookieless ? perfConsent : true;
106
- const { track, trackBeacon } = useTrackEvent({ storeSession });
107
- const lastPage = useRef<string | null>(null);
169
+ const { track, trackBeacon, apiUrl, subdomain } = useTrackEvent({ storeSession });
170
+ // The page the current event id belongs to, and the last page each half
171
+ // recorded. Tracked separately because consent (and the pixel id) arrive
172
+ // AFTER mount: with one shared "last page", the first-party view consumed the
173
+ // page and the Meta PageView for it never fired once consent loaded.
174
+ const pageView = useRef<{ page: string; eventId: string } | null>(null);
175
+ const lastFirstPartyPage = useRef<string | null>(null);
176
+ const lastMetaPage = useRef<string | null>(null);
108
177
  // Time on page. Pings go by beacon: most are sent as the page is hidden or
109
178
  // unloaded, which cancels an ordinary request.
110
179
  const beaconRef = useRef(trackBeacon);
@@ -118,13 +187,29 @@ export function ForgeAnalytics({
118
187
  }
119
188
 
120
189
  // Consent gates. The Meta Pixel always needs `marketing`. First-party analytics
121
- // needs `performance` — UNLESS it's cookieless (stores nothing without consent),
190
+ // needs `performance`, UNLESS it's cookieless (stores nothing without consent),
122
191
  // in which case it's treated as essential and runs anyway. Flipping a gate to
123
192
  // `true` (visitor accepts) re-runs the effects, recording the current page.
124
193
  const firstPartyOn = enabled && (cookieless || !requireConsent || perfConsent);
125
194
  const metaOn = enabled && !!pixelId && (!requireConsent || mktConsent);
126
195
 
127
- // Init the Meta pixel once (scoped — no auto PageView; we fire it ourselves so
196
+ // Publish the Meta policy for code outside this component (cart, checkout,
197
+ // detail pages, create mutations). Declared before the page-view effect so
198
+ // the policy is current when that effect fires.
199
+ const currency = siteConfig?.currency;
200
+ useEffect(() => {
201
+ setMetaPolicy({
202
+ pixelId: pixelId || undefined,
203
+ enabled,
204
+ requireConsent,
205
+ apiUrl,
206
+ subdomain,
207
+ currency,
208
+ resolved: pixelResolved,
209
+ });
210
+ }, [pixelId, enabled, requireConsent, apiUrl, subdomain, currency, pixelResolved]);
211
+
212
+ // Init the Meta pixel once (scoped: no auto PageView; we fire it ourselves so
128
213
  // it doesn't leak into a per-page campaign pixel).
129
214
  useEffect(() => {
130
215
  if (!metaOn || !pixelId) return;
@@ -136,18 +221,20 @@ export function ForgeAnalytics({
136
221
  // which keeps this free of any router dependency.
137
222
  useEffect(() => {
138
223
  if (typeof window === "undefined") return;
139
- if (!firstPartyOn && !metaOn) return; // nothing consented yet — wait
224
+ if (!firstPartyOn && !metaOn) return; // nothing consented yet, wait
140
225
 
141
226
  const sendPageView = () => {
142
227
  const page = currentPage();
143
- if (page === lastPage.current) return; // de-dupe repeated fires for the same URL
144
- lastPage.current = page;
228
+ if (pageView.current?.page !== page) {
229
+ pageView.current = { page, eventId: newEventId() };
230
+ // Publish the id so a per-page <PageMetaPixel/> (and its server CAPI twin)
231
+ // can dedupe against the same page view.
232
+ setCurrentPageEventId(pageView.current.eventId);
233
+ }
234
+ const eventId = pageView.current.eventId;
145
235
 
146
- const eventId = newEventId();
147
- // Publish the id so a per-page <PageMetaPixel/> (and its server CAPI twin)
148
- // can dedupe against the same page view.
149
- setCurrentPageEventId(eventId);
150
- if (firstPartyOn) {
236
+ if (firstPartyOn && lastFirstPartyPage.current !== page) {
237
+ lastFirstPartyPage.current = page;
151
238
  // Close the previous page's time before this page view is recorded, so
152
239
  // the ping lands on the page it measured.
153
240
  engagement.current?.flush();
@@ -162,14 +249,16 @@ export function ForgeAnalytics({
162
249
  eventId,
163
250
  });
164
251
  }
165
- // Fire the profile pixel to ITSELF (trackSingle), and skip it on routes a
166
- // campaign pixel has taken over (PageMetaPixel override). Deferred a tick
167
- // so a page pixel mounting on this route can register its suppression first.
168
- if (metaOn && pixelId) {
252
+ // Fire the profile pixel to ITSELF (trackSingle) plus its server copy,
253
+ // with the page view's id, and skip both on routes a campaign pixel has
254
+ // taken over (PageMetaPixel override; it sends its own server copy).
255
+ // Deferred a tick so a page pixel mounting on this route can register its
256
+ // suppression first.
257
+ if (metaOn && pixelId && lastMetaPage.current !== page) {
258
+ lastMetaPage.current = page;
169
259
  const path = currentPathname();
170
- const pixel = pixelId;
171
260
  queueMicrotask(() => {
172
- if (!isProfilePixelSuppressed(path)) fireMetaEventSingle(pixel, "PageView", {}, eventId);
261
+ if (!isProfilePixelSuppressed(path)) trackMeta("PageView", undefined, { eventId });
173
262
  });
174
263
  }
175
264
  };
@@ -232,7 +321,19 @@ export function ForgeAnalytics({
232
321
  };
233
322
  }, [firstPartyOn]);
234
323
 
235
- // Click tracking — first-party only, one delegated listener for the document.
324
+ // Web Vitals: first-party only, like the page views they belong to. Sent by
325
+ // beacon, because INP and CLS are reported as the page is hidden.
326
+ useEffect(() => {
327
+ if (!firstPartyOn || typeof window === "undefined") return;
328
+ const sink = (report: VitalReport) => beaconRef.current("web_vital", report);
329
+ vitalsSink = sink;
330
+ registerWebVitals();
331
+ return () => {
332
+ if (vitalsSink === sink) vitalsSink = null;
333
+ };
334
+ }, [firstPartyOn]);
335
+
336
+ // Click tracking: first-party only, one delegated listener for the document.
236
337
  useEffect(() => {
237
338
  if (!firstPartyOn || !trackClicks || typeof document === "undefined") return;
238
339
 
@@ -246,11 +347,10 @@ export function ForgeAnalytics({
246
347
  //
247
348
  // This listener cannot know that a component already sent a richer event
248
349
  // for the same click, so without a way to stand down, anything that tracks
249
- // its own clicks is counted twice. Worse than the double count: this
250
- // listener's payload carries no `eventId`, so the resulting CAPI `Lead`
251
- // has nothing to dedupe against, and Meta sees two conversions for one
252
- // action. Any element that fires its own event must carry
253
- // `data-track-skip`.
350
+ // its own clicks is counted twice in first-party analytics. A click is
351
+ // not a Meta event (components that turn one into a conversion fire it
352
+ // themselves, through `trackMeta` or `usePageMetaPixel`). Any element
353
+ // that reports its own click must carry `data-track-skip`.
254
354
  if (el.closest("[data-track-skip]")) return;
255
355
 
256
356
  const anchor = el.closest("a") as HTMLAnchorElement | null;
@@ -6,6 +6,12 @@ import {
6
6
  unsuppressProfilePixel,
7
7
  } from "../../utils/analyticsBus";
8
8
  import { useCookieConsent } from "../headless/consent/useCookieConsent";
9
+ import { sendPageMetaEvent, type MetaCustomData } from "../../utils/metaEvents";
10
+ import { newEventId } from "../../data/queries/useAnalytics";
11
+
12
+ function currentPathname(): string {
13
+ return typeof window === "undefined" ? "" : window.location.pathname;
14
+ }
9
15
 
10
16
  // useLayoutEffect warns during SSR; fall back to useEffect on the server.
11
17
  const useIsoLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect;
@@ -57,7 +63,11 @@ export function PageMetaPixel({
57
63
  if (!allowed || !pixelId || typeof window === "undefined") return;
58
64
  initMetaPixelScoped(pixelId);
59
65
  if (trackPageView) {
60
- fireMetaEventSingle(pixelId, "PageView", {}, getCurrentPageEventId() ?? undefined);
66
+ // One id for the pixel and its server copy: the page view's own, so the
67
+ // pair dedupes on Meta.
68
+ const eventId = getCurrentPageEventId() ?? newEventId();
69
+ fireMetaEventSingle(pixelId, "PageView", {}, eventId);
70
+ sendPageMetaEvent({ eventName: "PageView", eventId, pathname: currentPathname() });
61
71
  }
62
72
  }, [allowed, pixelId, trackPageView]);
63
73
 
@@ -65,12 +75,14 @@ export function PageMetaPixel({
65
75
  }
66
76
 
67
77
  /**
68
- * Imperative companion to `<PageMetaPixel/>` — returns `fire(event, data?)` to
69
- * send conversion events (e.g. `Lead` on a CTA click) to the campaign pixel,
70
- * gated on marketing consent and reusing the current page view's event id.
78
+ * Imperative companion to `<PageMetaPixel/>`. Returns `fire(event, data?, eventId?)`
79
+ * to send a conversion event (e.g. `Lead` on a CTA click) to the campaign pixel
80
+ * AND its server copy, gated on marketing consent. Both halves carry one id:
81
+ * the one passed, else a new one per call (an action is its own event, never
82
+ * the page view). Returns that id.
71
83
  *
72
84
  * const { fire } = usePageMetaPixel("123456789");
73
- * <button onClick={() => fire("Lead", { content_name: "Pre-save" })}>…</button>
85
+ * <button onClick={() => fire("Lead", { content_name: "Pre-save" })}>...</button>
74
86
  */
75
87
  export function usePageMetaPixel(pixelId: string, opts?: { requireConsent?: boolean }) {
76
88
  const requireConsent = opts?.requireConsent ?? true;
@@ -84,9 +96,12 @@ export function usePageMetaPixel(pixelId: string, opts?: { requireConsent?: bool
84
96
 
85
97
  return {
86
98
  allowed,
87
- fire: (eventName: string, data: Record<string, unknown> = {}) => {
88
- if (!allowed || !pixelId) return;
89
- fireMetaEventSingle(pixelId, eventName, data, getCurrentPageEventId() ?? undefined);
99
+ fire: (eventName: string, data: Record<string, unknown> = {}, eventId?: string): string | undefined => {
100
+ if (!allowed || !pixelId) return undefined;
101
+ const id = eventId ?? newEventId();
102
+ fireMetaEventSingle(pixelId, eventName, data, id);
103
+ sendPageMetaEvent({ eventName, eventId: id, pathname: currentPathname(), customData: data as MetaCustomData });
104
+ return id;
90
105
  },
91
106
  };
92
107
  }
@@ -0,0 +1,129 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
3
+ import { render, act, cleanup } from "@testing-library/react";
4
+
5
+ /**
6
+ * The page view's Meta pair: the profile pixel's PageView and its server copy
7
+ * (POST /public/websites/meta-event) carry the id the first-party page view
8
+ * already has, so Meta keeps one. Consent loads AFTER mount, which used to
9
+ * mean the first page never sent a Meta PageView at all.
10
+ */
11
+
12
+ const tracked: { eventType: string; data: Record<string, unknown> }[] = [];
13
+ let seq = 0;
14
+
15
+ vi.mock("../../../data/queries/useAnalytics", () => ({
16
+ newEventId: () => `evt-${++seq}`,
17
+ useTrackEvent: () => ({
18
+ track: (eventType: string, data: Record<string, unknown>) => tracked.push({ eventType, data }),
19
+ trackBeacon: () => {},
20
+ apiUrl: "https://api.test",
21
+ subdomain: "artist",
22
+ }),
23
+ }));
24
+ vi.mock("../../../data/queries/useWebsite", () => ({
25
+ useSiteConfig: () => ({ data: { metaPixelId: "999", currency: "USD" }, isLoading: false }),
26
+ }));
27
+
28
+ import { ForgeAnalytics } from "../ForgeAnalytics";
29
+ import { resetMetaPolicy } from "../../../utils/metaEvents";
30
+ import { saveCookieConsent, GRANT_ALL, DENY_ALL } from "../../../utils/cookieConsent";
31
+ import { suppressProfilePixel, unsuppressProfilePixel } from "../../../utils/analyticsBus";
32
+
33
+ let fetchSpy: ReturnType<typeof vi.fn>;
34
+ let fbq: ReturnType<typeof vi.fn>;
35
+
36
+ const metaPosts = () =>
37
+ fetchSpy.mock.calls
38
+ .filter((c) => String(c[0]).endsWith("/public/websites/meta-event"))
39
+ .map((c) => JSON.parse((c[1] as RequestInit).body as string));
40
+
41
+ const flush = () => act(async () => {});
42
+
43
+ beforeEach(() => {
44
+ tracked.length = 0;
45
+ seq = 0;
46
+ resetMetaPolicy();
47
+ window.localStorage.clear();
48
+ fetchSpy = vi.fn().mockResolvedValue({ ok: true });
49
+ vi.stubGlobal("fetch", fetchSpy);
50
+ fbq = vi.fn();
51
+ (window as unknown as { fbq: unknown }).fbq = fbq;
52
+ window.history.replaceState({}, "", "/first");
53
+ });
54
+
55
+ afterEach(() => {
56
+ cleanup();
57
+ vi.unstubAllGlobals();
58
+ resetMetaPolicy();
59
+ });
60
+
61
+ describe("<ForgeAnalytics /> Meta PageView", () => {
62
+ it("sends the pixel PageView and its server copy with the page view's id, once consent loads", async () => {
63
+ saveCookieConsent(GRANT_ALL);
64
+ render(<ForgeAnalytics trackClicks={false} firstPartyMode="cookieless" />);
65
+ await flush();
66
+
67
+ const pageView = tracked.find((t) => t.eventType === "page_view");
68
+ expect(pageView?.data.eventId).toBe("evt-1");
69
+ expect(metaPosts()).toEqual([
70
+ {
71
+ subdomain: "artist",
72
+ pixel: "profile",
73
+ eventName: "PageView",
74
+ eventId: "evt-1",
75
+ eventSourceUrl: "http://localhost:3000/first",
76
+ metaSignals: { pageUrl: "http://localhost:3000/first", marketingConsent: true },
77
+ },
78
+ ]);
79
+ expect(fbq).toHaveBeenCalledWith("trackSingle", "999", "PageView", {}, { eventID: "evt-1" });
80
+ // One first-party page view, not a second one when consent arrived.
81
+ expect(tracked.filter((t) => t.eventType === "page_view")).toHaveLength(1);
82
+ });
83
+
84
+ it("sends a fresh pair on an SPA navigation", async () => {
85
+ saveCookieConsent(GRANT_ALL);
86
+ render(<ForgeAnalytics trackClicks={false} firstPartyMode="cookieless" />);
87
+ await flush();
88
+ act(() => window.history.pushState({}, "", "/second"));
89
+ await flush();
90
+
91
+ expect(metaPosts().map((b) => [b.eventId, b.eventSourceUrl])).toEqual([
92
+ ["evt-1", "http://localhost:3000/first"],
93
+ ["evt-2", "http://localhost:3000/second"],
94
+ ]);
95
+ });
96
+
97
+ it("sends no server copy where a campaign pixel took the page over", async () => {
98
+ saveCookieConsent(GRANT_ALL);
99
+ suppressProfilePixel("/first");
100
+ render(<ForgeAnalytics trackClicks={false} firstPartyMode="cookieless" />);
101
+ await flush();
102
+ unsuppressProfilePixel("/first");
103
+ expect(metaPosts()).toEqual([]);
104
+ });
105
+
106
+ it("sends nothing to Meta when marketing was declined", async () => {
107
+ saveCookieConsent(DENY_ALL);
108
+ render(<ForgeAnalytics trackClicks={false} firstPartyMode="cookieless" />);
109
+ await flush();
110
+ expect(metaPosts()).toEqual([]);
111
+ expect(tracked.filter((t) => t.eventType === "page_view")).toHaveLength(1);
112
+ });
113
+
114
+ it("sends a plain click first-party only", async () => {
115
+ saveCookieConsent(GRANT_ALL);
116
+ const { container } = render(
117
+ <>
118
+ <ForgeAnalytics firstPartyMode="cookieless" />
119
+ <button type="button">Buy</button>
120
+ </>,
121
+ );
122
+ await flush();
123
+ const before = metaPosts().length;
124
+ act(() => container.querySelector("button")!.click());
125
+ await flush();
126
+ expect(tracked.some((t) => t.eventType === "click")).toBe(true);
127
+ expect(metaPosts()).toHaveLength(before);
128
+ });
129
+ });
@@ -0,0 +1,129 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
3
+ import { render, act, cleanup } from "@testing-library/react";
4
+
5
+ /**
6
+ * Web Vitals reporting (SEO step 4). `web-vitals` is mocked: each `onX`
7
+ * registration is captured so a test can fire the metric the browser would.
8
+ * What is asserted: one `web_vital` event per metric instance, by beacon, with
9
+ * `{ name, value, rating, path }`; nothing at all without first-party consent;
10
+ * the cookieless mode still reporting; and registration happening once per
11
+ * page load however often the component mounts.
12
+ */
13
+
14
+ type Metric = {
15
+ name: "LCP" | "INP" | "CLS";
16
+ value: number;
17
+ rating: "good" | "needs-improvement" | "poor";
18
+ id: string;
19
+ navigationURL?: string;
20
+ };
21
+ const handlers: Record<string, Array<(m: Metric) => void>> = { LCP: [], INP: [], CLS: [] };
22
+ vi.mock("web-vitals", () => ({
23
+ onLCP: (cb: (m: Metric) => void) => handlers.LCP.push(cb),
24
+ onINP: (cb: (m: Metric) => void) => handlers.INP.push(cb),
25
+ onCLS: (cb: (m: Metric) => void) => handlers.CLS.push(cb),
26
+ }));
27
+
28
+ const calls: { via: "track" | "beacon"; eventType: string; data: Record<string, unknown> }[] = [];
29
+ vi.mock("../../../data/queries/useAnalytics", () => ({
30
+ newEventId: () => "evt",
31
+ useTrackEvent: () => ({
32
+ track: (eventType: string, data: Record<string, unknown>) => calls.push({ via: "track", eventType, data }),
33
+ trackBeacon: (eventType: string, data: Record<string, unknown>) => calls.push({ via: "beacon", eventType, data }),
34
+ }),
35
+ }));
36
+ vi.mock("../../../data/queries/useWebsite", () => ({ useSiteConfig: () => ({ data: undefined }) }));
37
+ const consent = { performance: true, marketing: false };
38
+ vi.mock("../../headless/consent/useCookieConsent", () => ({ useCookieConsent: () => consent }));
39
+
40
+ import { ForgeAnalytics, __resetWebVitalsForTests } from "../ForgeAnalytics";
41
+
42
+ const fire = (metric: Metric) => {
43
+ for (const cb of handlers[metric.name]) cb(metric);
44
+ };
45
+ const vitals = () => calls.filter((c) => c.eventType === "web_vital").map((c) => [c.via, c.data]);
46
+
47
+ describe("<ForgeAnalytics /> web vitals", () => {
48
+ beforeEach(() => {
49
+ calls.length = 0;
50
+ for (const key of Object.keys(handlers)) handlers[key].length = 0;
51
+ consent.performance = true;
52
+ __resetWebVitalsForTests();
53
+ window.history.replaceState({}, "", "/classes?ref=x");
54
+ });
55
+
56
+ afterEach(() => {
57
+ cleanup();
58
+ });
59
+
60
+ it("sends LCP, INP and CLS once each, by beacon, with the page the document loaded on", () => {
61
+ render(<ForgeAnalytics trackClicks={false} />);
62
+ expect(handlers.LCP).toHaveLength(1);
63
+ expect(handlers.INP).toHaveLength(1);
64
+ expect(handlers.CLS).toHaveLength(1);
65
+
66
+ // An SPA navigation does not start a new measurement.
67
+ act(() => window.history.pushState({}, "", "/about"));
68
+ fire({ name: "LCP", value: 1820.4, rating: "good", id: "v5-lcp-1" });
69
+ fire({ name: "CLS", value: 0.04, rating: "good", id: "v5-cls-1" });
70
+ fire({ name: "INP", value: 260, rating: "needs-improvement", id: "v5-inp-1" });
71
+ // A later report of the same instance (CLS grows) is not sent again.
72
+ fire({ name: "CLS", value: 0.3, rating: "poor", id: "v5-cls-1" });
73
+
74
+ expect(vitals()).toEqual([
75
+ ["beacon", { name: "LCP", value: 1820.4, rating: "good", path: "/classes" }],
76
+ ["beacon", { name: "CLS", value: 0.04, rating: "good", path: "/classes" }],
77
+ ["beacon", { name: "INP", value: 260, rating: "needs-improvement", path: "/classes" }],
78
+ ]);
79
+ });
80
+
81
+ it("sends a new instance (a back/forward cache restore) with the URL web-vitals names", () => {
82
+ render(<ForgeAnalytics trackClicks={false} />);
83
+ fire({ name: "LCP", value: 900, rating: "good", id: "a" });
84
+ fire({ name: "LCP", value: 300, rating: "good", id: "b", navigationURL: "https://site.test/events/x?y=1" });
85
+ expect(vitals().map(([, data]) => (data as { path: string }).path)).toEqual(["/classes", "/events/x"]);
86
+ });
87
+
88
+ it("measures nothing without performance consent, and starts once it is given", () => {
89
+ consent.performance = false;
90
+ const { rerender } = render(<ForgeAnalytics trackClicks={false} />);
91
+ expect(handlers.LCP).toHaveLength(0);
92
+ fire({ name: "LCP", value: 1000, rating: "good", id: "early" });
93
+ expect(vitals()).toEqual([]);
94
+
95
+ consent.performance = true;
96
+ rerender(<ForgeAnalytics trackClicks={false} />);
97
+ expect(handlers.LCP).toHaveLength(1);
98
+ // The observers are buffered, so the LCP that painted before consent still reports.
99
+ fire({ name: "LCP", value: 1000, rating: "good", id: "early" });
100
+ expect(vitals()).toEqual([["beacon", { name: "LCP", value: 1000, rating: "good", path: "/classes" }]]);
101
+ });
102
+
103
+ it("reports in cookieless mode without performance consent", () => {
104
+ consent.performance = false;
105
+ render(<ForgeAnalytics trackClicks={false} firstPartyMode="cookieless" />);
106
+ fire({ name: "INP", value: 120, rating: "good", id: "i1" });
107
+ expect(vitals()).toEqual([["beacon", { name: "INP", value: 120, rating: "good", path: "/classes" }]]);
108
+ });
109
+
110
+ it("records nothing when disabled, and nothing after the component unmounts", () => {
111
+ const { unmount } = render(<ForgeAnalytics enabled={false} />);
112
+ expect(handlers.LCP).toHaveLength(0);
113
+ unmount();
114
+
115
+ const mounted = render(<ForgeAnalytics trackClicks={false} />);
116
+ mounted.unmount();
117
+ fire({ name: "LCP", value: 1000, rating: "good", id: "late" });
118
+ expect(vitals()).toEqual([]);
119
+ });
120
+
121
+ it("registers once per page load however often it mounts", () => {
122
+ render(<ForgeAnalytics trackClicks={false} />);
123
+ cleanup();
124
+ render(<ForgeAnalytics trackClicks={false} />);
125
+ expect(handlers.LCP).toHaveLength(1);
126
+ fire({ name: "LCP", value: 700, rating: "good", id: "once" });
127
+ expect(vitals()).toHaveLength(1);
128
+ });
129
+ });