@tribe-nest/forge 3.60.0 → 3.61.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 (207) hide show
  1. package/package.json +2 -1
  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 +17 -0
  34. package/src/runtime/pages.spec.ts +5 -0
  35. package/src/runtime/pages.ts +32 -6
  36. package/src/types/models.ts +11 -0
  37. package/src/ui/analytics/ForgeAnalytics.tsx +64 -34
  38. package/src/ui/analytics/PageMetaPixel.tsx +23 -8
  39. package/src/ui/analytics/_tests/ForgeAnalytics.meta.spec.tsx +129 -0
  40. package/src/ui/analytics/_tests/metaCommerceEvents.spec.tsx +159 -0
  41. package/src/ui/analytics/useMetaEvents.ts +78 -0
  42. package/src/ui/headless/_tests/communityAgent.spec.tsx +519 -0
  43. package/src/ui/headless/_tests/communityCalendar.spec.tsx +195 -0
  44. package/src/ui/headless/_tests/communityClassroom.spec.tsx +120 -0
  45. package/src/ui/headless/_tests/communityHarness.tsx +10 -3
  46. package/src/ui/headless/_tests/communityMembers.spec.tsx +316 -0
  47. package/src/ui/headless/_tests/communityPoll.spec.tsx +389 -0
  48. package/src/ui/headless/_tests/memberHomeBar.spec.ts +84 -0
  49. package/src/ui/headless/_tests/siteAppBar.spec.ts +83 -0
  50. package/src/ui/headless/_tests/siteAppHarness.ts +19 -0
  51. package/src/ui/headless/_tests/siteAppHome.spec.ts +226 -0
  52. package/src/ui/headless/_tests/siteAppPrompts.spec.ts +185 -0
  53. package/src/ui/headless/_tests/useCommunityJoin.spec.tsx +228 -14
  54. package/src/ui/headless/_tests/useCommunityMembership.spec.tsx +29 -1
  55. package/src/ui/headless/_tests/useCommunityModeration.spec.tsx +420 -0
  56. package/src/ui/headless/_tests/useCommunityReads.spec.tsx +11 -2
  57. package/src/ui/headless/_tests/useCommunityWiki.spec.tsx +224 -0
  58. package/src/ui/headless/_tests/useSiteApp.spec.tsx +52 -0
  59. package/src/ui/headless/_tests/wikiMediaImage.spec.tsx +132 -0
  60. package/src/ui/headless/auth/useEmailCodeSignIn.ts +186 -0
  61. package/src/ui/headless/auth/useSignupForm.ts +20 -1
  62. package/src/ui/headless/chat/useChatAttachmentUpload.ts +9 -6
  63. package/src/ui/headless/chat/useChatRoom.ts +35 -1
  64. package/src/ui/headless/communities/index.ts +41 -0
  65. package/src/ui/headless/communities/useCommunityCalendar.ts +156 -0
  66. package/src/ui/headless/communities/useCommunityClassroom.ts +84 -0
  67. package/src/ui/headless/communities/useCommunityHome.ts +3 -0
  68. package/src/ui/headless/communities/useCommunityJoin.ts +73 -23
  69. package/src/ui/headless/communities/useCommunityMembership.ts +48 -2
  70. package/src/ui/headless/communities/useCommunityWiki.ts +266 -0
  71. package/src/ui/headless/community/communityPoll.ts +297 -0
  72. package/src/ui/headless/community/countries.ts +123 -0
  73. package/src/ui/headless/community/index.ts +81 -1
  74. package/src/ui/headless/community/linkPreview.ts +71 -0
  75. package/src/ui/headless/community/richLexical.ts +27 -2
  76. package/src/ui/headless/community/useCommunityAgent.ts +285 -0
  77. package/src/ui/headless/community/useCommunityMembers.ts +175 -0
  78. package/src/ui/headless/community/useCommunityModeration.ts +300 -0
  79. package/src/ui/headless/community/useCommunitySpaceFeed.ts +22 -4
  80. package/src/ui/headless/community/useCommunityThread.ts +16 -3
  81. package/src/ui/headless/community/useMentionComposer.ts +17 -9
  82. package/src/ui/headless/community/useRichMentionComposer.ts +21 -5
  83. package/src/ui/headless/downloads/index.ts +8 -0
  84. package/src/ui/headless/downloads/useDownloadPage.ts +134 -0
  85. package/src/ui/headless/index.ts +13 -0
  86. package/src/ui/headless/memberHome/index.ts +10 -0
  87. package/src/ui/headless/memberHome/memberHomeBar.ts +91 -0
  88. package/src/ui/headless/memberHome/useMemberHomeNav.ts +56 -18
  89. package/src/ui/headless/portal/index.ts +57 -0
  90. package/src/ui/headless/portal/usePortal.ts +274 -0
  91. package/src/ui/headless/portal/usePortalFiles.ts +68 -0
  92. package/src/ui/headless/portal/usePortalMessages.ts +189 -0
  93. package/src/ui/headless/portal/usePortalPreview.ts +142 -0
  94. package/src/ui/headless/siteApp/appBar.ts +102 -0
  95. package/src/ui/headless/siteApp/appHome.ts +344 -0
  96. package/src/ui/headless/siteApp/appPrompts.ts +229 -0
  97. package/src/ui/headless/siteApp/index.ts +58 -0
  98. package/src/ui/headless/siteApp/useAppHome.ts +210 -0
  99. package/src/ui/headless/siteApp/useAppInstallRequest.ts +16 -0
  100. package/src/ui/headless/work/index.ts +2 -0
  101. package/src/ui/headless/work/useWorkPortal.ts +37 -26
  102. package/src/ui/index.ts +11 -0
  103. package/src/ui/styled/AccountDashboard.tsx +712 -352
  104. package/src/ui/styled/AiAgentWidget.tsx +13 -1
  105. package/src/ui/styled/ChatRoom.tsx +126 -16
  106. package/src/ui/styled/Checkout.tsx +8 -0
  107. package/src/ui/styled/CheckoutConfirmation.tsx +8 -0
  108. package/src/ui/styled/CoachingConfirmation.tsx +8 -0
  109. package/src/ui/styled/CoachingDetail.tsx +2 -0
  110. package/src/ui/styled/CourseConfirmation.tsx +5 -0
  111. package/src/ui/styled/CourseDetail.tsx +2 -0
  112. package/src/ui/styled/DownloadPage.tsx +263 -0
  113. package/src/ui/styled/EventConfirmation.tsx +10 -0
  114. package/src/ui/styled/EventDetail.tsx +10 -0
  115. package/src/ui/styled/FilmLibrary.tsx +22 -9
  116. package/src/ui/styled/MusicLinkPage.tsx +5 -5
  117. package/src/ui/styled/ProductDetail.tsx +12 -0
  118. package/src/ui/styled/PwaRegistration.tsx +5 -1
  119. package/src/ui/styled/SignupForm.tsx +18 -3
  120. package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +5 -5
  121. package/src/ui/styled/_tests/AccountNotifications.spec.tsx +135 -0
  122. package/src/ui/styled/_tests/DownloadPage.spec.tsx +68 -0
  123. package/src/ui/styled/app/AppBar.tsx +298 -0
  124. package/src/ui/styled/app/AppHome.tsx +1139 -0
  125. package/src/ui/styled/app/AppHomeMark.tsx +66 -0
  126. package/src/ui/styled/app/AppPrompts.tsx +464 -0
  127. package/src/ui/styled/app/_tests/AppBar.spec.tsx +96 -0
  128. package/src/ui/styled/app/_tests/AppHome.spec.tsx +209 -0
  129. package/src/ui/styled/app/_tests/AppPrompts.spec.tsx +232 -0
  130. package/src/ui/styled/app/index.ts +7 -0
  131. package/src/ui/styled/chat/ChatThreadPanel.tsx +29 -4
  132. package/src/ui/styled/chat/MessageBubble.tsx +43 -6
  133. package/src/ui/styled/communities/CommunityAbout.tsx +6 -1
  134. package/src/ui/styled/communities/CommunityCalendar.tsx +484 -0
  135. package/src/ui/styled/communities/CommunityClassroom.tsx +302 -0
  136. package/src/ui/styled/communities/CommunityHome.tsx +134 -11
  137. package/src/ui/styled/communities/CommunityJoinSheet.tsx +119 -19
  138. package/src/ui/styled/communities/CommunityMembershipMenu.tsx +168 -52
  139. package/src/ui/styled/communities/CommunityPublicWiki.tsx +75 -0
  140. package/src/ui/styled/communities/CommunityWiki.tsx +289 -0
  141. package/src/ui/styled/communities/MyMembershipsList.tsx +31 -5
  142. package/src/ui/styled/communities/_tests/CommunityAppHome.spec.tsx +92 -0
  143. package/src/ui/styled/communities/_tests/CommunityClassroomCalendar.spec.tsx +453 -0
  144. package/src/ui/styled/communities/_tests/CommunityMobile.spec.tsx +399 -0
  145. package/src/ui/styled/communities/_tests/CommunityWiki.spec.tsx +223 -0
  146. package/src/ui/styled/communities/index.ts +9 -0
  147. package/src/ui/styled/communities/shared.tsx +39 -11
  148. package/src/ui/styled/community/CommunityAgent.tsx +286 -0
  149. package/src/ui/styled/community/CommunityComposer.tsx +266 -15
  150. package/src/ui/styled/community/CommunityFeed.tsx +50 -1
  151. package/src/ui/styled/community/CommunityLinkPreview.tsx +159 -0
  152. package/src/ui/styled/community/CommunityMemberGlobe.tsx +171 -0
  153. package/src/ui/styled/community/CommunityMemberProfile.tsx +137 -4
  154. package/src/ui/styled/community/CommunityMembers.tsx +387 -0
  155. package/src/ui/styled/community/CommunityModeration.tsx +587 -0
  156. package/src/ui/styled/community/CommunityModerationState.tsx +127 -0
  157. package/src/ui/styled/community/CommunityModeratorMenu.tsx +317 -0
  158. package/src/ui/styled/community/CommunityNotifications.tsx +52 -6
  159. package/src/ui/styled/community/CommunityPollCard.tsx +250 -0
  160. package/src/ui/styled/community/CommunityPostCard.tsx +110 -34
  161. package/src/ui/styled/community/CommunityPostDetail.tsx +157 -71
  162. package/src/ui/styled/community/CommunityRichText.tsx +177 -32
  163. package/src/ui/styled/community/CommunitySpaces.tsx +49 -14
  164. package/src/ui/styled/community/_tests/CommunityAgent.spec.tsx +571 -0
  165. package/src/ui/styled/community/_tests/CommunityLinkPreview.spec.tsx +167 -0
  166. package/src/ui/styled/community/_tests/CommunityMembers.spec.tsx +137 -0
  167. package/src/ui/styled/community/_tests/CommunityModeration.spec.tsx +291 -0
  168. package/src/ui/styled/community/_tests/CommunityPoll.spec.tsx +386 -0
  169. package/src/ui/styled/community/index.ts +37 -2
  170. package/src/ui/styled/forge-utilities.css +234 -0
  171. package/src/ui/styled/members/CallRecordingActions.tsx +182 -0
  172. package/src/ui/styled/members/MemberCallsCalendar.tsx +13 -158
  173. package/src/ui/styled/members/MemberHomeLayout.tsx +295 -15
  174. package/src/ui/styled/members/MemberPostsFeed.tsx +3 -2
  175. package/src/ui/styled/members/index.ts +8 -0
  176. package/src/ui/styled/portal/PortalAccountSection.tsx +61 -0
  177. package/src/ui/styled/portal/PortalActivity.tsx +167 -0
  178. package/src/ui/styled/portal/PortalBilling.tsx +197 -0
  179. package/src/ui/styled/portal/PortalFiles.tsx +279 -0
  180. package/src/ui/styled/portal/PortalHome.tsx +401 -0
  181. package/src/ui/styled/portal/PortalMessages.tsx +300 -0
  182. package/src/ui/styled/portal/PortalPreviewBanner.tsx +75 -0
  183. package/src/ui/styled/portal/PortalProjects.tsx +407 -0
  184. package/src/ui/styled/portal/PortalShell.tsx +397 -0
  185. package/src/ui/styled/portal/_tests/PortalAppHome.spec.tsx +90 -0
  186. package/src/ui/styled/portal/_tests/PortalFiles.spec.tsx +159 -0
  187. package/src/ui/styled/portal/_tests/PortalHome.spec.tsx +148 -0
  188. package/src/ui/styled/portal/_tests/PortalMessages.spec.tsx +168 -0
  189. package/src/ui/styled/portal/_tests/PortalPreviewBanner.spec.tsx +132 -0
  190. package/src/ui/styled/portal/_tests/portalNav.spec.ts +123 -0
  191. package/src/ui/styled/portal/index.ts +13 -0
  192. package/src/ui/styled/portal/portalNav.ts +260 -0
  193. package/src/ui/styled/portal/shared.tsx +206 -0
  194. package/src/ui/styled/work/WorkInviteAccept.tsx +228 -168
  195. package/src/ui/styled/work/WorkPortalProjects.tsx +68 -120
  196. package/src/ui/styled/work/WorkProjectDocuments.tsx +99 -128
  197. package/src/ui/styled/work/WorkProjectInvoices.tsx +91 -86
  198. package/src/ui/styled/work/WorkProjectReport.tsx +11 -17
  199. package/src/ui/styled/work/WorkReportBody.tsx +223 -248
  200. package/src/ui/styled/work/WorkTaskAttachments.tsx +23 -29
  201. package/src/ui/styled/work/WorkTaskComments.tsx +75 -102
  202. package/src/ui/styled/work/WorkTaskDetail.tsx +48 -59
  203. package/src/ui/styled/work/WorkTokenReport.tsx +8 -16
  204. package/src/ui/styled/work/index.ts +1 -1
  205. package/src/utils/_tests/metaEvents.spec.ts +264 -0
  206. package/src/utils/landing.ts +19 -6
  207. package/src/utils/metaEvents.ts +302 -0
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
@@ -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];
@@ -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;
@@ -1,7 +1,8 @@
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";
@@ -17,7 +18,7 @@ export interface ForgeAnalyticsProps {
17
18
  metaPixelId?: string;
18
19
  /** Auto-track clicks on links/buttons (and any `[data-track]` element). Default `true`. */
19
20
  trackClicks?: boolean;
20
- /** Master switch — set `false` (e.g. in the editor/preview) to record nothing. Default `true`. */
21
+ /** Master switch: set `false` (e.g. in the editor/preview) to record nothing. Default `true`. */
21
22
  enabled?: boolean;
22
23
  /**
23
24
  * Honour the visitor's cookie choice (from `<CookieConsent />`). Default
@@ -43,7 +44,7 @@ export interface ForgeAnalyticsProps {
43
44
  }
44
45
 
45
46
  /**
46
- * The full resolved path the visitor is on (e.g. `/i/store/abc-123`) — never a
47
+ * The full resolved path the visitor is on (e.g. `/i/store/abc-123`), never a
47
48
  * route template like `store/:id`. This is the value the admin analytics "top
48
49
  * pages" report groups on (`event_data->>'pathname'`), so a code site's real
49
50
  * routes show up per-page instead of collapsed into a parameterised template.
@@ -53,7 +54,7 @@ function currentPathname(): string {
53
54
  return window.location.pathname;
54
55
  }
55
56
 
56
- /** Full route including query — kept alongside `pathname` for detail. */
57
+ /** Full route including query, kept alongside `pathname` for detail. */
57
58
  function currentPage(): string {
58
59
  if (typeof window === "undefined") return "";
59
60
  return window.location.pathname + window.location.search;
@@ -77,8 +78,8 @@ function isEngaged(): boolean {
77
78
  * page view as a Meta `PageView` (event-id matched for CAPI de-dup).
78
79
  *
79
80
  * 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
81
+ * consent (first-party analytics wait for `performance`, the Meta Pixel waits
82
+ * for `marketing`), so pairing it with `<CookieConsent />` is GDPR-correct out
82
83
  * of the box. Router-agnostic: SPA navigations are detected via the History API,
83
84
  * so it works under any router (or none). It renders nothing.
84
85
  *
@@ -93,18 +94,28 @@ export function ForgeAnalytics({
93
94
  }: ForgeAnalyticsProps) {
94
95
  const cookieless = firstPartyMode === "cookieless";
95
96
  // 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();
97
+ // passed (so changing it needs no rebuild). An explicit prop (including `""`
98
+ // to disable) always wins.
99
+ const siteConfigQuery = useSiteConfig();
100
+ const siteConfig = siteConfigQuery.data;
99
101
  const pixelId = metaPixelId ?? siteConfig?.metaPixelId ?? undefined;
102
+ // The pixel is known once it was passed, or the site config has answered.
103
+ // Until then Meta events fired elsewhere (a detail page mounting first) wait.
104
+ const pixelResolved = metaPixelId !== undefined || !!siteConfig || !siteConfigQuery.isLoading;
100
105
  const { performance: perfConsent, marketing: mktConsent } = useCookieConsent();
101
106
  // Session storage follows performance consent. In `consent` mode first-party
102
107
  // only runs with that consent, so it always stores; in `cookieless` mode it
103
108
  // runs regardless but only stores a device session once the visitor turns
104
- // Performance on — otherwise it stays cookieless (backend derives the id).
109
+ // Performance on; otherwise it stays cookieless (backend derives the id).
105
110
  const storeSession = cookieless ? perfConsent : true;
106
- const { track, trackBeacon } = useTrackEvent({ storeSession });
107
- const lastPage = useRef<string | null>(null);
111
+ const { track, trackBeacon, apiUrl, subdomain } = useTrackEvent({ storeSession });
112
+ // The page the current event id belongs to, and the last page each half
113
+ // recorded. Tracked separately because consent (and the pixel id) arrive
114
+ // AFTER mount: with one shared "last page", the first-party view consumed the
115
+ // page and the Meta PageView for it never fired once consent loaded.
116
+ const pageView = useRef<{ page: string; eventId: string } | null>(null);
117
+ const lastFirstPartyPage = useRef<string | null>(null);
118
+ const lastMetaPage = useRef<string | null>(null);
108
119
  // Time on page. Pings go by beacon: most are sent as the page is hidden or
109
120
  // unloaded, which cancels an ordinary request.
110
121
  const beaconRef = useRef(trackBeacon);
@@ -118,13 +129,29 @@ export function ForgeAnalytics({
118
129
  }
119
130
 
120
131
  // Consent gates. The Meta Pixel always needs `marketing`. First-party analytics
121
- // needs `performance` — UNLESS it's cookieless (stores nothing without consent),
132
+ // needs `performance`, UNLESS it's cookieless (stores nothing without consent),
122
133
  // in which case it's treated as essential and runs anyway. Flipping a gate to
123
134
  // `true` (visitor accepts) re-runs the effects, recording the current page.
124
135
  const firstPartyOn = enabled && (cookieless || !requireConsent || perfConsent);
125
136
  const metaOn = enabled && !!pixelId && (!requireConsent || mktConsent);
126
137
 
127
- // Init the Meta pixel once (scoped — no auto PageView; we fire it ourselves so
138
+ // Publish the Meta policy for code outside this component (cart, checkout,
139
+ // detail pages, create mutations). Declared before the page-view effect so
140
+ // the policy is current when that effect fires.
141
+ const currency = siteConfig?.currency;
142
+ useEffect(() => {
143
+ setMetaPolicy({
144
+ pixelId: pixelId || undefined,
145
+ enabled,
146
+ requireConsent,
147
+ apiUrl,
148
+ subdomain,
149
+ currency,
150
+ resolved: pixelResolved,
151
+ });
152
+ }, [pixelId, enabled, requireConsent, apiUrl, subdomain, currency, pixelResolved]);
153
+
154
+ // Init the Meta pixel once (scoped: no auto PageView; we fire it ourselves so
128
155
  // it doesn't leak into a per-page campaign pixel).
129
156
  useEffect(() => {
130
157
  if (!metaOn || !pixelId) return;
@@ -136,18 +163,20 @@ export function ForgeAnalytics({
136
163
  // which keeps this free of any router dependency.
137
164
  useEffect(() => {
138
165
  if (typeof window === "undefined") return;
139
- if (!firstPartyOn && !metaOn) return; // nothing consented yet — wait
166
+ if (!firstPartyOn && !metaOn) return; // nothing consented yet, wait
140
167
 
141
168
  const sendPageView = () => {
142
169
  const page = currentPage();
143
- if (page === lastPage.current) return; // de-dupe repeated fires for the same URL
144
- lastPage.current = page;
170
+ if (pageView.current?.page !== page) {
171
+ pageView.current = { page, eventId: newEventId() };
172
+ // Publish the id so a per-page <PageMetaPixel/> (and its server CAPI twin)
173
+ // can dedupe against the same page view.
174
+ setCurrentPageEventId(pageView.current.eventId);
175
+ }
176
+ const eventId = pageView.current.eventId;
145
177
 
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) {
178
+ if (firstPartyOn && lastFirstPartyPage.current !== page) {
179
+ lastFirstPartyPage.current = page;
151
180
  // Close the previous page's time before this page view is recorded, so
152
181
  // the ping lands on the page it measured.
153
182
  engagement.current?.flush();
@@ -162,14 +191,16 @@ export function ForgeAnalytics({
162
191
  eventId,
163
192
  });
164
193
  }
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) {
194
+ // Fire the profile pixel to ITSELF (trackSingle) plus its server copy,
195
+ // with the page view's id, and skip both on routes a campaign pixel has
196
+ // taken over (PageMetaPixel override; it sends its own server copy).
197
+ // Deferred a tick so a page pixel mounting on this route can register its
198
+ // suppression first.
199
+ if (metaOn && pixelId && lastMetaPage.current !== page) {
200
+ lastMetaPage.current = page;
169
201
  const path = currentPathname();
170
- const pixel = pixelId;
171
202
  queueMicrotask(() => {
172
- if (!isProfilePixelSuppressed(path)) fireMetaEventSingle(pixel, "PageView", {}, eventId);
203
+ if (!isProfilePixelSuppressed(path)) trackMeta("PageView", undefined, { eventId });
173
204
  });
174
205
  }
175
206
  };
@@ -232,7 +263,7 @@ export function ForgeAnalytics({
232
263
  };
233
264
  }, [firstPartyOn]);
234
265
 
235
- // Click tracking — first-party only, one delegated listener for the document.
266
+ // Click tracking: first-party only, one delegated listener for the document.
236
267
  useEffect(() => {
237
268
  if (!firstPartyOn || !trackClicks || typeof document === "undefined") return;
238
269
 
@@ -246,11 +277,10 @@ export function ForgeAnalytics({
246
277
  //
247
278
  // This listener cannot know that a component already sent a richer event
248
279
  // 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`.
280
+ // its own clicks is counted twice in first-party analytics. A click is
281
+ // not a Meta event (components that turn one into a conversion fire it
282
+ // themselves, through `trackMeta` or `usePageMetaPixel`). Any element
283
+ // that reports its own click must carry `data-track-skip`.
254
284
  if (el.closest("[data-track-skip]")) return;
255
285
 
256
286
  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
+ });