@tribe-nest/forge 3.59.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 (266) hide show
  1. package/package.json +2 -1
  2. package/src/_tests/testCommunity.tsx +28 -0
  3. package/src/contexts/CartContext.tsx +34 -1
  4. package/src/contexts/CommunityContext.tsx +192 -0
  5. package/src/contexts/PublicAuthContext.tsx +15 -3
  6. package/src/data/queries/_tests/metaSignals.spec.tsx +130 -0
  7. package/src/data/queries/useAccount.ts +33 -2
  8. package/src/data/queries/useAnalytics.ts +6 -6
  9. package/src/data/queries/useCallChat.ts +2 -2
  10. package/src/data/queries/useChat.ts +52 -40
  11. package/src/data/queries/useCheckouts.ts +2 -1
  12. package/src/data/queries/useCoachingAvailability.ts +10 -1
  13. package/src/data/queries/useCommunities.ts +345 -0
  14. package/src/data/queries/useCommunity.ts +450 -102
  15. package/src/data/queries/useCommunityAgent.ts +247 -0
  16. package/src/data/queries/useCommunityCalendar.ts +150 -0
  17. package/src/data/queries/useCommunityClassroom.ts +75 -0
  18. package/src/data/queries/useCommunityMembers.ts +108 -0
  19. package/src/data/queries/useCommunityModeration.ts +327 -0
  20. package/src/data/queries/useCommunityWiki.ts +137 -0
  21. package/src/data/queries/useCourses.ts +2 -0
  22. package/src/data/queries/useEmailSignIn.ts +79 -0
  23. package/src/data/queries/useEvents.ts +6 -1
  24. package/src/data/queries/useForms.ts +5 -1
  25. package/src/data/queries/useLeadMagnet.ts +6 -2
  26. package/src/data/queries/useMembership.ts +32 -7
  27. package/src/data/queries/useMembershipCalls.ts +116 -7
  28. package/src/data/queries/useMyBookings.ts +24 -1
  29. package/src/data/queries/useMyTickets.ts +24 -1
  30. package/src/data/queries/useNotifications.ts +6 -2
  31. package/src/data/queries/useOrders.ts +2 -1
  32. package/src/data/queries/usePWA.ts +111 -35
  33. package/src/data/queries/usePostCollections.ts +28 -16
  34. package/src/data/queries/usePosts.ts +16 -14
  35. package/src/data/queries/useSiteApp.ts +130 -0
  36. package/src/data/queries/useSubscriptions.ts +28 -9
  37. package/src/i18n/de.json +891 -2
  38. package/src/i18n/en.json +891 -2
  39. package/src/index.ts +36 -7
  40. package/src/runtime/pages.spec.ts +36 -0
  41. package/src/runtime/pages.ts +52 -27
  42. package/src/server/_tests/buildWebManifest.spec.ts +1 -1
  43. package/src/server/_tests/forgeServiceWorkerScript.spec.ts +4 -4
  44. package/src/types/models.ts +11 -0
  45. package/src/ui/analytics/ForgeAnalytics.tsx +64 -34
  46. package/src/ui/analytics/PageMetaPixel.tsx +23 -8
  47. package/src/ui/analytics/_tests/ForgeAnalytics.meta.spec.tsx +129 -0
  48. package/src/ui/analytics/_tests/metaCommerceEvents.spec.tsx +159 -0
  49. package/src/ui/analytics/useMetaEvents.ts +78 -0
  50. package/src/ui/format/_tests/membershipGate.spec.ts +6 -6
  51. package/src/ui/format/membershipGate.ts +19 -15
  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/communityGate.spec.tsx +80 -0
  56. package/src/ui/headless/_tests/communityHarness.tsx +212 -0
  57. package/src/ui/headless/_tests/communityMembers.spec.tsx +316 -0
  58. package/src/ui/headless/_tests/communityPoll.spec.tsx +389 -0
  59. package/src/ui/headless/_tests/communityProvider.spec.tsx +151 -0
  60. package/src/ui/headless/_tests/memberHomeBar.spec.ts +84 -0
  61. package/src/ui/headless/_tests/siteAppBar.spec.ts +83 -0
  62. package/src/ui/headless/_tests/siteAppHarness.ts +19 -0
  63. package/src/ui/headless/_tests/siteAppHome.spec.ts +226 -0
  64. package/src/ui/headless/_tests/siteAppPrompts.spec.ts +185 -0
  65. package/src/ui/headless/_tests/useCommunityInvite.spec.tsx +104 -0
  66. package/src/ui/headless/_tests/useCommunityJoin.spec.tsx +596 -0
  67. package/src/ui/headless/_tests/useCommunityMembership.spec.tsx +179 -0
  68. package/src/ui/headless/_tests/useCommunityModeration.spec.tsx +420 -0
  69. package/src/ui/headless/_tests/useCommunityReads.spec.tsx +295 -0
  70. package/src/ui/headless/_tests/useCommunityUpgrade.spec.tsx +157 -0
  71. package/src/ui/headless/_tests/useCommunityWiki.spec.tsx +224 -0
  72. package/src/ui/headless/_tests/useSiteApp.spec.tsx +52 -0
  73. package/src/ui/headless/_tests/wikiMediaImage.spec.tsx +132 -0
  74. package/src/ui/headless/auth/useEmailCodeSignIn.ts +186 -0
  75. package/src/ui/headless/auth/useSignupForm.ts +30 -3
  76. package/src/ui/headless/chat/useChatAttachmentUpload.ts +9 -6
  77. package/src/ui/headless/chat/useChatRoom.ts +35 -1
  78. package/src/ui/headless/communities/CommunityGate.tsx +67 -0
  79. package/src/ui/headless/communities/index.ts +96 -0
  80. package/src/ui/headless/communities/paths.ts +36 -0
  81. package/src/ui/headless/communities/useCommunity.ts +140 -0
  82. package/src/ui/headless/communities/useCommunityAbout.ts +71 -0
  83. package/src/ui/headless/communities/useCommunityCalendar.ts +156 -0
  84. package/src/ui/headless/communities/useCommunityClassroom.ts +84 -0
  85. package/src/ui/headless/communities/useCommunityHome.ts +91 -0
  86. package/src/ui/headless/communities/useCommunityInvite.ts +116 -0
  87. package/src/ui/headless/communities/useCommunityJoin.ts +494 -0
  88. package/src/ui/headless/communities/useCommunityMembership.ts +182 -0
  89. package/src/ui/headless/communities/useCommunityUpgrade.ts +213 -0
  90. package/src/ui/headless/communities/useCommunityWiki.ts +266 -0
  91. package/src/ui/headless/communities/useMyMemberships.ts +59 -0
  92. package/src/ui/headless/community/communityPoll.ts +297 -0
  93. package/src/ui/headless/community/countries.ts +123 -0
  94. package/src/ui/headless/community/index.ts +81 -1
  95. package/src/ui/headless/community/linkPreview.ts +71 -0
  96. package/src/ui/headless/community/richLexical.ts +27 -2
  97. package/src/ui/headless/community/useCommunityAgent.ts +285 -0
  98. package/src/ui/headless/community/useCommunityAttachmentUpload.ts +7 -3
  99. package/src/ui/headless/community/useCommunityMembers.ts +175 -0
  100. package/src/ui/headless/community/useCommunityModeration.ts +300 -0
  101. package/src/ui/headless/community/useCommunitySpaceFeed.ts +22 -4
  102. package/src/ui/headless/community/useCommunityThread.ts +16 -3
  103. package/src/ui/headless/community/useMentionComposer.ts +17 -9
  104. package/src/ui/headless/community/useRichMentionComposer.ts +21 -5
  105. package/src/ui/headless/downloads/index.ts +8 -0
  106. package/src/ui/headless/downloads/useDownloadPage.ts +134 -0
  107. package/src/ui/headless/index.ts +21 -4
  108. package/src/ui/headless/memberHome/index.ts +11 -1
  109. package/src/ui/headless/memberHome/memberHomeBar.ts +91 -0
  110. package/src/ui/headless/memberHome/useMemberHomeNav.ts +84 -44
  111. package/src/ui/headless/membership/_tests/membershipCheckoutRefetch.spec.tsx +8 -0
  112. package/src/ui/headless/membership/_tests/useMembershipCheckoutApplication.spec.tsx +5 -4
  113. package/src/ui/headless/membership/_tests/useMembershipCheckoutQuestionnaire.spec.tsx +1 -1
  114. package/src/ui/headless/membership/_tests/useMembershipCheckoutTrial.spec.tsx +1 -1
  115. package/src/ui/headless/membership/useMembershipCheckout.ts +89 -22
  116. package/src/ui/headless/membership/useMembershipGateNotice.ts +25 -7
  117. package/src/ui/headless/portal/index.ts +57 -0
  118. package/src/ui/headless/portal/usePortal.ts +274 -0
  119. package/src/ui/headless/portal/usePortalFiles.ts +68 -0
  120. package/src/ui/headless/portal/usePortalMessages.ts +189 -0
  121. package/src/ui/headless/portal/usePortalPreview.ts +142 -0
  122. package/src/ui/headless/posts/upsell.ts +54 -8
  123. package/src/ui/headless/posts/useMemberPostsFeed.ts +6 -5
  124. package/src/ui/headless/posts/usePostCollectionDetail.ts +7 -4
  125. package/src/ui/headless/posts/usePostCollectionsList.ts +6 -5
  126. package/src/ui/headless/posts/usePostItem.ts +6 -5
  127. package/src/ui/headless/siteApp/appBar.ts +102 -0
  128. package/src/ui/headless/siteApp/appHome.ts +344 -0
  129. package/src/ui/headless/siteApp/appPrompts.ts +229 -0
  130. package/src/ui/headless/siteApp/index.ts +58 -0
  131. package/src/ui/headless/siteApp/useAppHome.ts +210 -0
  132. package/src/ui/headless/siteApp/useAppInstallRequest.ts +16 -0
  133. package/src/ui/headless/work/index.ts +2 -0
  134. package/src/ui/headless/work/useWorkPortal.ts +37 -26
  135. package/src/ui/index.ts +28 -12
  136. package/src/ui/styled/AccountDashboard.tsx +754 -602
  137. package/src/ui/styled/AiAgentWidget.tsx +13 -1
  138. package/src/ui/styled/ChatRoom.tsx +126 -16
  139. package/src/ui/styled/Checkout.tsx +8 -0
  140. package/src/ui/styled/CheckoutConfirmation.tsx +8 -0
  141. package/src/ui/styled/CoachingConfirmation.tsx +8 -0
  142. package/src/ui/styled/CoachingDetail.tsx +2 -0
  143. package/src/ui/styled/CourseConfirmation.tsx +5 -0
  144. package/src/ui/styled/CourseDetail.tsx +2 -0
  145. package/src/ui/styled/DownloadPage.tsx +263 -0
  146. package/src/ui/styled/EventConfirmation.tsx +10 -0
  147. package/src/ui/styled/EventDetail.tsx +10 -0
  148. package/src/ui/styled/FilmLibrary.tsx +22 -9
  149. package/src/ui/styled/MembershipCheckout.tsx +37 -17
  150. package/src/ui/styled/MembershipTierCallout.tsx +20 -1
  151. package/src/ui/styled/MembershipTiers.tsx +49 -11
  152. package/src/ui/styled/MusicLinkPage.tsx +5 -5
  153. package/src/ui/styled/Paywall.tsx +1 -1
  154. package/src/ui/styled/ProductDetail.tsx +12 -0
  155. package/src/ui/styled/PwaRegistration.tsx +5 -1
  156. package/src/ui/styled/SignupForm.tsx +18 -3
  157. package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +6 -6
  158. package/src/ui/styled/_tests/AccountDashboardMemberships.spec.tsx +151 -0
  159. package/src/ui/styled/_tests/AccountDashboardRentals.spec.tsx +1 -1
  160. package/src/ui/styled/_tests/AccountNotifications.spec.tsx +135 -0
  161. package/src/ui/styled/_tests/CourseCallsSection.spec.tsx +7 -1
  162. package/src/ui/styled/_tests/DownloadPage.spec.tsx +68 -0
  163. package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +54 -11
  164. package/src/ui/styled/_tests/membershipTierCallout.spec.tsx +1 -1
  165. package/src/ui/styled/_tests/membershipTiersCuratedAccess.spec.tsx +1 -1
  166. package/src/ui/styled/app/AppBar.tsx +298 -0
  167. package/src/ui/styled/app/AppHome.tsx +1139 -0
  168. package/src/ui/styled/app/AppHomeMark.tsx +66 -0
  169. package/src/ui/styled/app/AppPrompts.tsx +464 -0
  170. package/src/ui/styled/app/_tests/AppBar.spec.tsx +96 -0
  171. package/src/ui/styled/app/_tests/AppHome.spec.tsx +209 -0
  172. package/src/ui/styled/app/_tests/AppPrompts.spec.tsx +232 -0
  173. package/src/ui/styled/app/index.ts +7 -0
  174. package/src/ui/styled/chat/ChatThreadPanel.tsx +29 -4
  175. package/src/ui/styled/chat/MessageBubble.tsx +43 -6
  176. package/src/ui/styled/communities/CommunitiesList.tsx +88 -0
  177. package/src/ui/styled/communities/CommunityAbout.tsx +145 -0
  178. package/src/ui/styled/communities/CommunityCalendar.tsx +484 -0
  179. package/src/ui/styled/communities/CommunityClassroom.tsx +302 -0
  180. package/src/ui/styled/communities/CommunityHome.tsx +298 -0
  181. package/src/ui/styled/communities/CommunityJoinSheet.tsx +544 -0
  182. package/src/ui/styled/communities/CommunityMembershipMenu.tsx +249 -0
  183. package/src/ui/styled/communities/CommunityPending.tsx +49 -0
  184. package/src/ui/styled/communities/CommunityPublicWiki.tsx +75 -0
  185. package/src/ui/styled/communities/CommunityUpgradeSheet.tsx +137 -0
  186. package/src/ui/styled/communities/CommunityWiki.tsx +289 -0
  187. package/src/ui/styled/communities/MyMembershipsList.tsx +115 -0
  188. package/src/ui/styled/communities/_tests/CommunityAppHome.spec.tsx +92 -0
  189. package/src/ui/styled/communities/_tests/CommunityClassroomCalendar.spec.tsx +453 -0
  190. package/src/ui/styled/communities/_tests/CommunityMobile.spec.tsx +399 -0
  191. package/src/ui/styled/communities/_tests/CommunityPages.spec.tsx +102 -0
  192. package/src/ui/styled/communities/_tests/CommunityWiki.spec.tsx +223 -0
  193. package/src/ui/styled/communities/index.ts +19 -0
  194. package/src/ui/styled/communities/shared.tsx +177 -0
  195. package/src/ui/styled/community/CommunityAgent.tsx +286 -0
  196. package/src/ui/styled/community/CommunityComposer.tsx +266 -15
  197. package/src/ui/styled/community/CommunityFeed.tsx +50 -1
  198. package/src/ui/styled/community/CommunityLinkPreview.tsx +159 -0
  199. package/src/ui/styled/community/CommunityMemberGlobe.tsx +171 -0
  200. package/src/ui/styled/community/CommunityMemberProfile.tsx +137 -4
  201. package/src/ui/styled/community/CommunityMembers.tsx +387 -0
  202. package/src/ui/styled/community/CommunityModeration.tsx +587 -0
  203. package/src/ui/styled/community/CommunityModerationState.tsx +127 -0
  204. package/src/ui/styled/community/CommunityModeratorMenu.tsx +317 -0
  205. package/src/ui/styled/community/CommunityNotifications.tsx +52 -6
  206. package/src/ui/styled/community/CommunityPollCard.tsx +250 -0
  207. package/src/ui/styled/community/CommunityPostCard.tsx +110 -34
  208. package/src/ui/styled/community/CommunityPostDetail.tsx +157 -71
  209. package/src/ui/styled/community/CommunityRichText.tsx +177 -32
  210. package/src/ui/styled/community/CommunitySpaces.tsx +49 -14
  211. package/src/ui/styled/community/_tests/CommunityAgent.spec.tsx +571 -0
  212. package/src/ui/styled/community/_tests/CommunityComposer.spec.tsx +4 -1
  213. package/src/ui/styled/community/_tests/CommunityLinkPreview.spec.tsx +167 -0
  214. package/src/ui/styled/community/_tests/CommunityMembers.spec.tsx +137 -0
  215. package/src/ui/styled/community/_tests/CommunityModeration.spec.tsx +291 -0
  216. package/src/ui/styled/community/_tests/CommunityPoll.spec.tsx +386 -0
  217. package/src/ui/styled/community/_tests/CommunityPostDetail.spec.tsx +4 -1
  218. package/src/ui/styled/community/index.ts +37 -2
  219. package/src/ui/styled/forge-utilities.css +253 -0
  220. package/src/ui/styled/members/CallRecordingActions.tsx +182 -0
  221. package/src/ui/styled/members/CallTranscriptViewer.tsx +424 -0
  222. package/src/ui/styled/members/MemberCallsCalendar.tsx +41 -54
  223. package/src/ui/styled/members/MemberHomeLayout.tsx +327 -29
  224. package/src/ui/styled/members/MemberPostCard.tsx +9 -5
  225. package/src/ui/styled/members/MemberPostsFeed.tsx +3 -2
  226. package/src/ui/styled/members/PostCollectionDetail.tsx +18 -10
  227. package/src/ui/styled/members/PostCollectionsGrid.tsx +24 -10
  228. package/src/ui/styled/members/_tests/MemberCallsCalendar.spec.tsx +167 -8
  229. package/src/ui/styled/members/callTranscriptText.ts +67 -0
  230. package/src/ui/styled/members/index.ts +8 -0
  231. package/src/ui/styled/portal/PortalAccountSection.tsx +61 -0
  232. package/src/ui/styled/portal/PortalActivity.tsx +167 -0
  233. package/src/ui/styled/portal/PortalBilling.tsx +197 -0
  234. package/src/ui/styled/portal/PortalFiles.tsx +279 -0
  235. package/src/ui/styled/portal/PortalHome.tsx +401 -0
  236. package/src/ui/styled/portal/PortalMessages.tsx +300 -0
  237. package/src/ui/styled/portal/PortalPreviewBanner.tsx +75 -0
  238. package/src/ui/styled/portal/PortalProjects.tsx +407 -0
  239. package/src/ui/styled/portal/PortalShell.tsx +397 -0
  240. package/src/ui/styled/portal/_tests/PortalAppHome.spec.tsx +90 -0
  241. package/src/ui/styled/portal/_tests/PortalFiles.spec.tsx +159 -0
  242. package/src/ui/styled/portal/_tests/PortalHome.spec.tsx +148 -0
  243. package/src/ui/styled/portal/_tests/PortalMessages.spec.tsx +168 -0
  244. package/src/ui/styled/portal/_tests/PortalPreviewBanner.spec.tsx +132 -0
  245. package/src/ui/styled/portal/_tests/portalNav.spec.ts +123 -0
  246. package/src/ui/styled/portal/index.ts +13 -0
  247. package/src/ui/styled/portal/portalNav.ts +260 -0
  248. package/src/ui/styled/portal/shared.tsx +206 -0
  249. package/src/ui/styled/work/WorkInviteAccept.tsx +228 -168
  250. package/src/ui/styled/work/WorkPortalProjects.tsx +68 -120
  251. package/src/ui/styled/work/WorkProjectDocuments.tsx +99 -128
  252. package/src/ui/styled/work/WorkProjectInvoices.tsx +91 -86
  253. package/src/ui/styled/work/WorkProjectReport.tsx +11 -17
  254. package/src/ui/styled/work/WorkReportBody.tsx +223 -248
  255. package/src/ui/styled/work/WorkTaskAttachments.tsx +23 -29
  256. package/src/ui/styled/work/WorkTaskComments.tsx +75 -102
  257. package/src/ui/styled/work/WorkTaskDetail.tsx +48 -59
  258. package/src/ui/styled/work/WorkTokenReport.tsx +8 -16
  259. package/src/ui/styled/work/index.ts +1 -1
  260. package/src/utils/_tests/metaEvents.spec.ts +264 -0
  261. package/src/utils/_tests/safeRedirect.spec.ts +2 -2
  262. package/src/utils/landing.ts +19 -6
  263. package/src/utils/metaEvents.ts +302 -0
  264. package/src/ui/headless/membership/MembershipGate.tsx +0 -44
  265. package/src/ui/styled/_tests/AccountDashboardCommunity.spec.tsx +0 -149
  266. package/src/ui/styled/_tests/AccountDashboardTrial.spec.tsx +0 -105
@@ -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,159 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
3
+ import { act, renderHook } from "@testing-library/react";
4
+ import type { ReactNode } from "react";
5
+ import { useMetaInitiateCheckout, useMetaPurchase, useMetaViewContent } from "../useMetaEvents";
6
+ import { resetMetaPolicy, setMetaPolicy } from "../../../utils/metaEvents";
7
+ import { saveCookieConsent, GRANT_ALL } from "../../../utils/cookieConsent";
8
+ import { CartProvider, useCart, type CartItem, type TicketCartItem } from "../../../contexts/CartContext";
9
+ import { ProductDeliveryType } from "../../../types/models";
10
+
11
+ /**
12
+ * The commerce funnel's Meta events fire once per act: a re-render, a cart
13
+ * that refreshes, or a confirmation page reloaded in the same tab must not
14
+ * count a second ViewContent, InitiateCheckout or Purchase.
15
+ */
16
+
17
+ let fetchSpy: ReturnType<typeof vi.fn>;
18
+ let fbq: ReturnType<typeof vi.fn>;
19
+
20
+ const posted = () => fetchSpy.mock.calls.map((c) => JSON.parse((c[1] as RequestInit).body as string));
21
+ const pixel = () => fbq.mock.calls.filter((c) => c[0] === "trackSingle");
22
+
23
+ beforeEach(() => {
24
+ resetMetaPolicy();
25
+ window.localStorage.clear();
26
+ window.sessionStorage.clear();
27
+ fetchSpy = vi.fn().mockResolvedValue({ ok: true });
28
+ vi.stubGlobal("fetch", fetchSpy);
29
+ fbq = vi.fn();
30
+ (window as unknown as { fbq: unknown }).fbq = fbq;
31
+ saveCookieConsent(GRANT_ALL);
32
+ setMetaPolicy({
33
+ pixelId: "777",
34
+ enabled: true,
35
+ requireConsent: true,
36
+ apiUrl: "https://api.test",
37
+ subdomain: "artist",
38
+ currency: "GBP",
39
+ resolved: true,
40
+ });
41
+ });
42
+
43
+ afterEach(() => {
44
+ vi.unstubAllGlobals();
45
+ resetMetaPolicy();
46
+ });
47
+
48
+ describe("InitiateCheckout", () => {
49
+ const line = { productId: "p1", quantity: 2 };
50
+
51
+ it("fires once per mount, after the cart is ready and not empty", () => {
52
+ const { rerender } = renderHook((props: Parameters<typeof useMetaInitiateCheckout>[0]) => useMetaInitiateCheckout(props), {
53
+ initialProps: { ready: false, cartItems: [], ticketItems: [], subTotal: 0 } as Parameters<typeof useMetaInitiateCheckout>[0],
54
+ });
55
+ expect(posted()).toEqual([]);
56
+
57
+ rerender({ ready: true, cartItems: [line], ticketItems: [{ eventId: "e1", tickets: { t1: 3 } }], subTotal: 80 });
58
+ rerender({ ready: true, cartItems: [line], ticketItems: [], subTotal: 50 });
59
+
60
+ expect(posted()).toHaveLength(1);
61
+ expect(posted()[0]).toMatchObject({
62
+ pixel: "profile",
63
+ eventName: "InitiateCheckout",
64
+ customData: { value: 80, currency: "GBP", num_items: 5, content_ids: ["p1", "e1"], content_type: "product" },
65
+ });
66
+ expect(pixel()[0][4]).toEqual({ eventID: posted()[0].eventId });
67
+ });
68
+
69
+ it("does not fire for an empty cart", () => {
70
+ renderHook(() => useMetaInitiateCheckout({ ready: true, cartItems: [], ticketItems: [], subTotal: 0 }));
71
+ expect(posted()).toEqual([]);
72
+ });
73
+ });
74
+
75
+ describe("Purchase", () => {
76
+ it("fires once with the record id, and not again after a reload", () => {
77
+ const props = { recordId: "order-9", paid: true, value: 30, currency: "usd" };
78
+ const first = renderHook(() => useMetaPurchase(props));
79
+ first.rerender();
80
+ first.unmount();
81
+ // A reload: a fresh mount in the same tab.
82
+ renderHook(() => useMetaPurchase(props));
83
+
84
+ expect(pixel()).toEqual([["trackSingle", "777", "Purchase", { value: 30, currency: "USD" }, { eventID: "order-9" }]]);
85
+ expect(fetchSpy).not.toHaveBeenCalled();
86
+ });
87
+
88
+ it("waits for the record to be paid", () => {
89
+ const { rerender } = renderHook((p: { paid: boolean }) => useMetaPurchase({ recordId: "order-10", value: 30, ...p }), {
90
+ initialProps: { paid: false },
91
+ });
92
+ expect(pixel()).toEqual([]);
93
+ rerender({ paid: true });
94
+ expect(pixel()).toHaveLength(1);
95
+ });
96
+ });
97
+
98
+ describe("ViewContent", () => {
99
+ it("fires once per entity and again for a different one", () => {
100
+ const { rerender } = renderHook((e: { id: string; name: string; value: number }) => useMetaViewContent(e), {
101
+ initialProps: { id: "c1", name: "Course", value: 99 },
102
+ });
103
+ rerender({ id: "c1", name: "Course", value: 99 });
104
+ rerender({ id: "c2", name: "Other", value: 10 });
105
+
106
+ expect(posted().map((b) => [b.eventName, b.customData])).toEqual([
107
+ ["ViewContent", { content_ids: ["c1"], content_name: "Course", content_type: "product", value: 99, currency: "GBP" }],
108
+ ["ViewContent", { content_ids: ["c2"], content_name: "Other", content_type: "product", value: 10, currency: "GBP" }],
109
+ ]);
110
+ });
111
+ });
112
+
113
+ describe("AddToCart", () => {
114
+ const wrapper = ({ children }: { children: ReactNode }) => <CartProvider>{children}</CartProvider>;
115
+ const item: CartItem = {
116
+ productId: "prod-1",
117
+ productVariantId: "var-1",
118
+ title: "Tour Tee",
119
+ price: 25,
120
+ isGift: false,
121
+ canIncreaseQuantity: true,
122
+ quantity: 2,
123
+ payWhatYouWant: false,
124
+ deliveryType: ProductDeliveryType.Physical,
125
+ };
126
+
127
+ it("fires for a new line and not for a duplicate", () => {
128
+ const { result } = renderHook(() => useCart(), { wrapper });
129
+ act(() => void result.current.addToCart(item));
130
+ act(() => void result.current.addToCart(item));
131
+
132
+ expect(posted().map((b) => [b.eventName, b.customData])).toEqual([
133
+ [
134
+ "AddToCart",
135
+ { content_ids: ["prod-1"], content_name: "Tour Tee", content_type: "product", value: 50, num_items: 2, currency: "GBP" },
136
+ ],
137
+ ]);
138
+ });
139
+
140
+ it("fires for tickets only when the selection grows", () => {
141
+ const { result } = renderHook(() => useCart(), { wrapper });
142
+ const selection = (qty: number): TicketCartItem => ({
143
+ eventId: "event-1",
144
+ eventSlug: "tour",
145
+ eventTitle: "Spring Tour",
146
+ tickets: { ga: qty },
147
+ ticketMeta: { ga: { title: "GA", price: 15 } },
148
+ });
149
+ act(() => result.current.setTickets(selection(2)));
150
+ act(() => result.current.setTickets(selection(1)));
151
+ act(() => result.current.setTickets(selection(3)));
152
+
153
+ expect(posted().map((b) => [b.customData.num_items, b.customData.value])).toEqual([
154
+ [2, 30],
155
+ [2, 30],
156
+ ]);
157
+ expect(posted()[0].customData.content_ids).toEqual(["event-1"]);
158
+ });
159
+ });
@@ -0,0 +1,78 @@
1
+ import { useEffect, useRef } from "react";
2
+ import { trackMeta, trackPurchase } from "../../utils/metaEvents";
3
+
4
+ export type MetaViewContentInput = {
5
+ id: string;
6
+ name?: string;
7
+ /** The lowest (or selected) price, major units. Ignored unless a finite number. */
8
+ value?: number | string | null;
9
+ };
10
+
11
+ /**
12
+ * Fire `ViewContent` once per loaded entity: a new id (navigating to another
13
+ * product) fires again, a re-render does not. Pass `null` while loading.
14
+ */
15
+ export function useMetaViewContent(entity: MetaViewContentInput | null | undefined): void {
16
+ const firedFor = useRef<string | null>(null);
17
+ const id = entity?.id;
18
+ const name = entity?.name;
19
+ const value = entity?.value;
20
+ useEffect(() => {
21
+ if (!id || firedFor.current === id) return;
22
+ firedFor.current = id;
23
+ const n = value === null || value === undefined || value === "" ? NaN : Number(value);
24
+ trackMeta("ViewContent", {
25
+ content_ids: [id],
26
+ content_name: name,
27
+ content_type: "product",
28
+ value: Number.isFinite(n) ? n : undefined,
29
+ });
30
+ }, [id, name, value]);
31
+ }
32
+
33
+ /**
34
+ * Fire `InitiateCheckout` once per mount, as soon as the saved cart has loaded
35
+ * and holds something. Value is the cart's own estimate, before shipping and
36
+ * coupons.
37
+ */
38
+ export function useMetaInitiateCheckout(input: {
39
+ ready: boolean;
40
+ cartItems: { productId: string; quantity: number }[];
41
+ ticketItems: { eventId: string; tickets: Record<string, number> }[];
42
+ subTotal: number;
43
+ }): void {
44
+ const fired = useRef(false);
45
+ const { ready, cartItems, ticketItems, subTotal } = input;
46
+ const hasLines = cartItems.length > 0 || ticketItems.length > 0;
47
+ useEffect(() => {
48
+ if (fired.current || !ready || !hasLines) return;
49
+ fired.current = true;
50
+ const numItems =
51
+ cartItems.reduce((sum, item) => sum + (item.quantity || 0), 0) +
52
+ ticketItems.reduce((sum, t) => sum + Object.values(t.tickets).reduce((a, b) => a + b, 0), 0);
53
+ trackMeta("InitiateCheckout", {
54
+ value: subTotal,
55
+ num_items: numItems,
56
+ content_ids: [...cartItems.map((item) => item.productId), ...ticketItems.map((t) => t.eventId)],
57
+ content_type: "product",
58
+ });
59
+ }, [ready, hasLines, cartItems, ticketItems, subTotal]);
60
+ }
61
+
62
+ /**
63
+ * Fire the browser copy of `Purchase` on a confirmation page once the record
64
+ * is paid. The id is the record id the server bridge used, so the two dedupe;
65
+ * a reload in the same tab does not fire it again.
66
+ */
67
+ export function useMetaPurchase(input: {
68
+ recordId?: string | null;
69
+ paid: boolean;
70
+ value?: number | string | null;
71
+ currency?: string | null;
72
+ }): void {
73
+ const { recordId, paid, value, currency } = input;
74
+ useEffect(() => {
75
+ if (!paid || !recordId) return;
76
+ trackPurchase({ recordId, value, currency });
77
+ }, [paid, recordId, value, currency]);
78
+ }
@@ -72,7 +72,7 @@ describe("parseMembershipGateError", () => {
72
72
  });
73
73
  });
74
74
 
75
- it("matches on CODE, not on status — a read refusal is 401/403, a checkout refusal 400", () => {
75
+ it("matches on CODE, not on status: a read refusal is 401/403, a checkout refusal 400", () => {
76
76
  // Nothing here reads a status at all; the same payload parses whichever
77
77
  // status carried it. A client switching on 401 would render this as a
78
78
  // session timeout.
@@ -113,7 +113,7 @@ describe("joinTierNames", () => {
113
113
  expect(joinTierNames(["Gold", "Silver", "Bronze"])).toBe("Gold, Silver or Bronze");
114
114
  });
115
115
 
116
- it("drops blanks — an unresolved tier must not become an empty slot", () => {
116
+ it("drops blanks: an unresolved tier must not become an empty slot", () => {
117
117
  expect(joinTierNames(["", " ", "Gold"])).toBe("Gold");
118
118
  expect(joinTierNames([])).toBe("");
119
119
  });
@@ -160,18 +160,18 @@ describe("buildMembershipGateNotice", () => {
160
160
  expect(notice!.body).toBe("This ticket is for Gold members. Already one? Sign in and it unlocks.");
161
161
  });
162
162
 
163
- it("offers the TIER to someone already signed in — never sign-in again", () => {
163
+ it("offers the TIER to someone already signed in: never sign-in again", () => {
164
164
  const notice = buildMembershipGateNotice({
165
165
  gate: gate({ reason: "membership_required", requiredTierIds: ["gold", "plat"] }),
166
166
  tierNames: ["Gold", "Platinum"],
167
167
  itemLabel: "This course",
168
- membershipPath: "/i/membership",
168
+ membershipPath: "/i/communities/fan-club?tier=gold",
169
169
  });
170
170
 
171
171
  expect(notice).toMatchObject({
172
172
  reason: "membership_required",
173
173
  actionLabel: "View Gold or Platinum",
174
- actionHref: "/i/membership",
174
+ actionHref: "/i/communities/fan-club?tier=gold",
175
175
  });
176
176
  expect(notice!.body).toBe("This course is for Gold or Platinum members. Your current plan doesn't include it.");
177
177
  // Telling a signed-in member to sign in is a loop with no exit.
@@ -214,7 +214,7 @@ describe("buildMembershipGateNotice", () => {
214
214
  });
215
215
 
216
216
  it("uses default paths when the host names none", () => {
217
- expect(buildMembershipGateNotice({ gate: gate() })!.actionHref).toBe("/membership");
217
+ expect(buildMembershipGateNotice({ gate: gate() })!.actionHref).toBe("/i/communities");
218
218
  expect(buildMembershipGateNotice({ gate: gate({ reason: "sign_in_required" }) })!.actionHref).toBe("/login");
219
219
  });
220
220
  });
@@ -8,22 +8,22 @@ import type { MembershipGateReason, PublicMembershipGate } from "../../types/mod
8
8
  *
9
9
  * The gate shipped ENFORCED and UNANNOUNCED. A logged-out buyer saw an ordinary
10
10
  * ticket, chose a seat, typed their name and their email twice, and was refused
11
- * on the last screen with a `MEMBERSHIP_GATE` 400 — the restriction was
11
+ * on the last screen with a `MEMBERSHIP_GATE` 400: the restriction was
12
12
  * discoverable only by failing it. And the "members save X, sign in" funnel the
13
13
  * feature was justified by existed on no surface at all, because nothing drew a
14
14
  * way OUT of the refusal.
15
15
  *
16
16
  * ## Why it is pure, and why it lives in `format/`
17
17
  *
18
- * There are two rendering stacks — `apps/client` and the Forge SDK that code
19
- * websites build against — and a members-only badge that says one thing on an
18
+ * There are two rendering stacks (`apps/client` and the Forge SDK that code
19
+ * websites build against), and a members-only badge that says one thing on an
20
20
  * artist's PWA and another on their website is the same bug twice. So the words
21
21
  * and the resolving ACTION are decided once, here, with no React and no client;
22
22
  * each stack owns only how it looks. `apps/client` imports this from
23
23
  * `@tribe-nest/forge/ui`, exactly as it already does for the PWYW arithmetic.
24
24
  *
25
25
  * Pure and total also means the whole mapping is testable without a DOM or a
26
- * session — and getting the two reasons backwards is the failure mode that
26
+ * session, and getting the two reasons backwards is the failure mode that
27
27
  * matters most: telling someone who is already signed in to "sign in" is a loop
28
28
  * with no exit, and telling a logged-out visitor to "upgrade" asks them to buy a
29
29
  * membership they may already hold.
@@ -94,7 +94,7 @@ export function parseMembershipGateError(error: unknown): MembershipGateRefusal
94
94
  /**
95
95
  * Is this thing refused to the caller looking at it?
96
96
  *
97
- * `null` means "nothing gates it" — which is also what every read returns while
97
+ * `null` means "nothing gates it", which is also what every read returns while
98
98
  * the `membership_entitlement_gates` switch is off. Treating an absent gate as
99
99
  * "gated and permitted" would badge every item on every site the day the field
100
100
  * shipped, so absent must read as false here and nowhere else.
@@ -110,7 +110,7 @@ export type MembershipGateNotice = {
110
110
  badge: string;
111
111
  title: string;
112
112
  body: string;
113
- /** Primary CTA label — the way OUT of the refusal, which is the whole point. */
113
+ /** Primary CTA label: the way OUT of the refusal, which is the whole point. */
114
114
  actionLabel: string;
115
115
  /**
116
116
  * Where the CTA goes. `null` only when the caller passed no path to send them
@@ -120,12 +120,16 @@ export type MembershipGateNotice = {
120
120
  actionHref: string | null;
121
121
  };
122
122
 
123
- /** Default sign-in path. Overridden per site — code websites mount theirs under `/i`. */
123
+ /** Default sign-in path. Overridden per site: code websites mount theirs under `/i`. */
124
124
  export const MEMBERSHIP_GATE_LOGIN_PATH = "/login";
125
- /** Default tier-listing path, i.e. where "become a member" actually leads. */
126
- export const MEMBERSHIP_GATE_MEMBERSHIP_PATH = "/membership";
125
+ /**
126
+ * Default "become a member" path when the tier's community is not known: the
127
+ * communities list. `useMembershipGateNotice` sends the buyer to the About page
128
+ * of the community the required tier belongs to, with that tier preselected.
129
+ */
130
+ export const MEMBERSHIP_GATE_MEMBERSHIP_PATH = "/i/communities";
127
131
 
128
- /** "Gold", "Gold or Platinum", "Gold, Silver or Bronze" — ANY one unlocks, hence "or". */
132
+ /** "Gold", "Gold or Platinum", "Gold, Silver or Bronze". ANY one unlocks, hence "or". */
129
133
  export function joinTierNames(names: string[]): string {
130
134
  const clean = names.map((name) => name.trim()).filter(Boolean);
131
135
  if (clean.length === 0) return "";
@@ -133,7 +137,7 @@ export function joinTierNames(names: string[]): string {
133
137
  return `${clean.slice(0, -1).join(", ")} or ${clean[clean.length - 1]}`;
134
138
  }
135
139
 
136
- /** `path?redirect=<here>` — a sign-in that lands back on the thing they wanted. */
140
+ /** `path?redirect=<here>`: a sign-in that lands back on the thing they wanted. */
137
141
  export function membershipSignInHref(loginPath: string, currentPath: string): string {
138
142
  const target = safeRedirectPath(currentPath, "");
139
143
  return target ? `${loginPath}?redirect=${encodeURIComponent(target)}` : loginPath;
@@ -142,7 +146,7 @@ export function membershipSignInHref(loginPath: string, currentPath: string): st
142
146
  export interface BuildMembershipGateNoticeInput {
143
147
  /** The announced gate from a listing/detail read. */
144
148
  gate?: PublicMembershipGate | null;
145
- /** A `MEMBERSHIP_GATE` refusal from a failed purchase. Wins over `gate` — it is the newer fact. */
149
+ /** A `MEMBERSHIP_GATE` refusal from a failed purchase. Wins over `gate`: it is the newer fact. */
146
150
  refusal?: MembershipGateRefusal | null;
147
151
  /**
148
152
  * Names for `requiredTierIds`, already resolved by the caller (both stacks
@@ -150,7 +154,7 @@ export interface BuildMembershipGateNoticeInput {
150
154
  * 8f3c-…" is not a sentence anyone can act on.
151
155
  */
152
156
  tierNames?: string[];
153
- /** What the thing IS, in the buyer's words — "This ticket", "This course". */
157
+ /** What the thing IS, in the buyer's words: "This ticket", "This course". */
154
158
  itemLabel?: string;
155
159
  loginPath?: string;
156
160
  membershipPath?: string;
@@ -185,7 +189,7 @@ export function buildMembershipGateNotice(
185
189
  // The API's own sentence is deliberately NOT used for this half. It is
186
190
  // written for the refusal ("You must be a member to buy this"), and what
187
191
  // an anonymous visitor needs first is the possibility that they already
188
- // are one — which is the difference between a dead end and a funnel.
192
+ // are one, which is the difference between a dead end and a funnel.
189
193
  body: tiers
190
194
  ? `${item} is for ${tiers} members. Already one? Sign in and it unlocks.`
191
195
  : `${item} is for members. Already one? Sign in and it unlocks.`,
@@ -201,7 +205,7 @@ export function buildMembershipGateNotice(
201
205
  body: tiers
202
206
  ? `${item} is for ${tiers} members. Your current plan doesn't include it.`
203
207
  : `${item} is for members. Your current plan doesn't include it.`,
204
- // NOT "sign in" — they already are, and offering it again is a loop with no
208
+ // NOT "sign in": they already are, and offering it again is a loop with no
205
209
  // exit. The only move that changes anything is joining the tier.
206
210
  actionLabel: tiers ? `View ${tiers}` : "View membership",
207
211
  actionHref: membershipPath,