@tribe-nest/forge 1.13.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 (255) hide show
  1. package/package.json +42 -0
  2. package/src/client/createForgeClient.ts +54 -0
  3. package/src/content/ForgeContentProvider.tsx +118 -0
  4. package/src/content/ForgeEditBridge.tsx +134 -0
  5. package/src/content/editFlag.ts +23 -0
  6. package/src/content/index.ts +28 -0
  7. package/src/content/resolve.ts +67 -0
  8. package/src/content/types.ts +97 -0
  9. package/src/content/useField.ts +64 -0
  10. package/src/contexts/AppAuthContext.tsx +185 -0
  11. package/src/contexts/AudioPlayerContext.tsx +389 -0
  12. package/src/contexts/CartContext.tsx +131 -0
  13. package/src/contexts/PublicAuthContext.tsx +333 -0
  14. package/src/contexts/useAppAdminGuard.ts +56 -0
  15. package/src/css.d.ts +5 -0
  16. package/src/data/collectionParams.ts +57 -0
  17. package/src/data/queries/useAccount.ts +71 -0
  18. package/src/data/queries/useAccountSettings.ts +82 -0
  19. package/src/data/queries/useAnalytics.ts +86 -0
  20. package/src/data/queries/useAuthActions.ts +28 -0
  21. package/src/data/queries/useBlog.ts +103 -0
  22. package/src/data/queries/useBlogComments.ts +96 -0
  23. package/src/data/queries/useBroadcasts.ts +31 -0
  24. package/src/data/queries/useCertificates.ts +58 -0
  25. package/src/data/queries/useChat.ts +267 -0
  26. package/src/data/queries/useCoachingAvailability.ts +69 -0
  27. package/src/data/queries/useCoachingProducts.ts +67 -0
  28. package/src/data/queries/useCohortPage.ts +17 -0
  29. package/src/data/queries/useCollections.ts +211 -0
  30. package/src/data/queries/useCommunity.ts +794 -0
  31. package/src/data/queries/useCoupons.ts +20 -0
  32. package/src/data/queries/useCourseAccess.ts +50 -0
  33. package/src/data/queries/useCourses.ts +91 -0
  34. package/src/data/queries/useCurrencies.ts +33 -0
  35. package/src/data/queries/useDocuments.ts +208 -0
  36. package/src/data/queries/useDonations.ts +29 -0
  37. package/src/data/queries/useEngagement.ts +115 -0
  38. package/src/data/queries/useEvents.ts +69 -0
  39. package/src/data/queries/useFinalize.ts +109 -0
  40. package/src/data/queries/useForms.ts +31 -0
  41. package/src/data/queries/useInvoice.ts +65 -0
  42. package/src/data/queries/useLeadMagnet.ts +96 -0
  43. package/src/data/queries/useMembership.ts +20 -0
  44. package/src/data/queries/useNotifications.ts +267 -0
  45. package/src/data/queries/useOffers.ts +56 -0
  46. package/src/data/queries/useOrders.ts +71 -0
  47. package/src/data/queries/usePWA.ts +101 -0
  48. package/src/data/queries/usePageActions.ts +36 -0
  49. package/src/data/queries/usePaymentFlow.ts +80 -0
  50. package/src/data/queries/usePaymentLink.ts +47 -0
  51. package/src/data/queries/usePodcast.ts +85 -0
  52. package/src/data/queries/usePostCollections.ts +48 -0
  53. package/src/data/queries/usePosts.ts +94 -0
  54. package/src/data/queries/useProducts.ts +108 -0
  55. package/src/data/queries/useReplay.ts +19 -0
  56. package/src/data/queries/useReviews.ts +122 -0
  57. package/src/data/queries/useShipping.ts +109 -0
  58. package/src/data/queries/useSubscriptions.ts +66 -0
  59. package/src/data/queries/useWebsite.ts +128 -0
  60. package/src/data/queries/useWebsiteAgent.ts +100 -0
  61. package/src/index.ts +98 -0
  62. package/src/provider/ForgeAppProvider.tsx +149 -0
  63. package/src/provider/ForgeProvider.tsx +91 -0
  64. package/src/provider/SiteConfigProvider.tsx +27 -0
  65. package/src/server/_tests/buildPwaHead.spec.ts +182 -0
  66. package/src/server/_tests/buildWebManifest.spec.ts +313 -0
  67. package/src/server/_tests/forgeServiceWorkerScript.spec.ts +251 -0
  68. package/src/server/index.ts +260 -0
  69. package/src/server/jobs.ts +104 -0
  70. package/src/server/pwa.ts +332 -0
  71. package/src/types/index.ts +74 -0
  72. package/src/types/models.ts +1313 -0
  73. package/src/ui/analytics/ForgeAnalytics.tsx +208 -0
  74. package/src/ui/analytics/PageMetaPixel.tsx +92 -0
  75. package/src/ui/content/Editable.tsx +90 -0
  76. package/src/ui/format/PriceDisplay.tsx +142 -0
  77. package/src/ui/format/useFormatCurrency.ts +92 -0
  78. package/src/ui/headless/agent/useAiAgent.ts +106 -0
  79. package/src/ui/headless/auth/useLoginFlow.ts +72 -0
  80. package/src/ui/headless/auth/useSignupForm.ts +85 -0
  81. package/src/ui/headless/broadcast/useBroadcastWatch.ts +59 -0
  82. package/src/ui/headless/chat/attachmentKind.ts +27 -0
  83. package/src/ui/headless/chat/useChatAttachmentUpload.ts +132 -0
  84. package/src/ui/headless/chat/useChatRoom.ts +223 -0
  85. package/src/ui/headless/chat/useMessageThread.ts +60 -0
  86. package/src/ui/headless/checkout/useCheckout.ts +438 -0
  87. package/src/ui/headless/coaching/useCoachingBooking.ts +223 -0
  88. package/src/ui/headless/community/index.ts +20 -0
  89. package/src/ui/headless/community/lexical.ts +131 -0
  90. package/src/ui/headless/community/replyTree.ts +51 -0
  91. package/src/ui/headless/community/richLexical.ts +314 -0
  92. package/src/ui/headless/community/useCommunityAttachmentUpload.ts +137 -0
  93. package/src/ui/headless/community/useCommunitySpaceFeed.ts +122 -0
  94. package/src/ui/headless/community/useCommunityThread.ts +95 -0
  95. package/src/ui/headless/community/useMentionComposer.ts +105 -0
  96. package/src/ui/headless/community/useRichMentionComposer.ts +193 -0
  97. package/src/ui/headless/consent/useCookieConsent.ts +81 -0
  98. package/src/ui/headless/course/useCourseCheckout.ts +99 -0
  99. package/src/ui/headless/dialog.tsx +14 -0
  100. package/src/ui/headless/document/index.ts +7 -0
  101. package/src/ui/headless/document/useContractSignature.ts +68 -0
  102. package/src/ui/headless/document/useDocumentSigningFlow.ts +92 -0
  103. package/src/ui/headless/document/useOtpCodeInput.ts +86 -0
  104. package/src/ui/headless/document/useSignaturePad.ts +180 -0
  105. package/src/ui/headless/donation/Donation.tsx +115 -0
  106. package/src/ui/headless/donation/DonationContext.tsx +223 -0
  107. package/src/ui/headless/donation/fees.ts +15 -0
  108. package/src/ui/headless/donation/index.ts +10 -0
  109. package/src/ui/headless/engagement/usePostEngagement.ts +119 -0
  110. package/src/ui/headless/event/useEventCheckout.ts +141 -0
  111. package/src/ui/headless/forms/useContactForm.ts +58 -0
  112. package/src/ui/headless/forms/useEmailListForm.ts +75 -0
  113. package/src/ui/headless/forms/useSectionedForm.ts +256 -0
  114. package/src/ui/headless/index.ts +60 -0
  115. package/src/ui/headless/invoice/useInvoicePayment.ts +48 -0
  116. package/src/ui/headless/memberHome/index.ts +7 -0
  117. package/src/ui/headless/memberHome/useMemberHomeNav.ts +82 -0
  118. package/src/ui/headless/membership/MembershipGate.tsx +39 -0
  119. package/src/ui/headless/membership/useMembershipCheckout.ts +121 -0
  120. package/src/ui/headless/offer/Offer.tsx +88 -0
  121. package/src/ui/headless/offer/OfferContext.tsx +162 -0
  122. package/src/ui/headless/offer/index.ts +8 -0
  123. package/src/ui/headless/paymentLink/usePaymentLinkPayment.ts +46 -0
  124. package/src/ui/headless/podcast/usePodcastPlayer.ts +96 -0
  125. package/src/ui/headless/posts/index.ts +13 -0
  126. package/src/ui/headless/posts/upsell.ts +41 -0
  127. package/src/ui/headless/posts/useMemberPostsFeed.ts +110 -0
  128. package/src/ui/headless/posts/usePostCollectionDetail.ts +56 -0
  129. package/src/ui/headless/posts/usePostCollectionsList.ts +90 -0
  130. package/src/ui/headless/posts/usePostItem.ts +81 -0
  131. package/src/ui/headless/reviews/index.ts +6 -0
  132. package/src/ui/headless/reviews/useHelpfulVote.ts +45 -0
  133. package/src/ui/headless/reviews/useReviewForm.ts +108 -0
  134. package/src/ui/headless/reviews/useReviewsList.ts +79 -0
  135. package/src/ui/headless/work/index.ts +25 -0
  136. package/src/ui/headless/work/useWorkPortal.ts +349 -0
  137. package/src/ui/index.ts +144 -0
  138. package/src/ui/payment/ForgePaymentProvider.tsx +45 -0
  139. package/src/ui/payment/ForgeStripePayment.tsx +171 -0
  140. package/src/ui/shell/TribeNestApp.tsx +121 -0
  141. package/src/ui/shell/shellGating.spec.ts +28 -0
  142. package/src/ui/shell/shellGating.ts +11 -0
  143. package/src/ui/styled/AccountDashboard.tsx +575 -0
  144. package/src/ui/styled/AiAgentWidget.tsx +152 -0
  145. package/src/ui/styled/AudioPlayer.tsx +123 -0
  146. package/src/ui/styled/BlogCategory.tsx +79 -0
  147. package/src/ui/styled/BlogComments.tsx +236 -0
  148. package/src/ui/styled/BlogList.tsx +101 -0
  149. package/src/ui/styled/BlogPost.tsx +160 -0
  150. package/src/ui/styled/BlogPostFooter.tsx +33 -0
  151. package/src/ui/styled/BotProtection.tsx +101 -0
  152. package/src/ui/styled/Button.tsx +88 -0
  153. package/src/ui/styled/Cart.tsx +276 -0
  154. package/src/ui/styled/ChatRoom.tsx +252 -0
  155. package/src/ui/styled/Checkout.tsx +988 -0
  156. package/src/ui/styled/CheckoutConfirmation.tsx +452 -0
  157. package/src/ui/styled/CoachingBooking.tsx +605 -0
  158. package/src/ui/styled/CoachingConfirmation.tsx +151 -0
  159. package/src/ui/styled/CoachingDetail.tsx +99 -0
  160. package/src/ui/styled/CohortPage.tsx +82 -0
  161. package/src/ui/styled/ConfirmSubscription.tsx +134 -0
  162. package/src/ui/styled/Confirmation.tsx +171 -0
  163. package/src/ui/styled/ContactForm.tsx +81 -0
  164. package/src/ui/styled/CookieConsent.tsx +239 -0
  165. package/src/ui/styled/CourseAccess.tsx +169 -0
  166. package/src/ui/styled/CourseCheckout.tsx +425 -0
  167. package/src/ui/styled/CourseConfirmation.tsx +191 -0
  168. package/src/ui/styled/CourseDetail.tsx +140 -0
  169. package/src/ui/styled/CoursesGrid.tsx +85 -0
  170. package/src/ui/styled/CurrencySwitcher.tsx +51 -0
  171. package/src/ui/styled/DonationButton.tsx +247 -0
  172. package/src/ui/styled/DonationPage.tsx +29 -0
  173. package/src/ui/styled/EmailListForm.tsx +88 -0
  174. package/src/ui/styled/EventConfirmation.tsx +228 -0
  175. package/src/ui/styled/EventCountdown.tsx +98 -0
  176. package/src/ui/styled/EventDetail.tsx +141 -0
  177. package/src/ui/styled/EventTickets.tsx +548 -0
  178. package/src/ui/styled/EventsList.tsx +83 -0
  179. package/src/ui/styled/ForgotPasswordForm.tsx +105 -0
  180. package/src/ui/styled/FormRenderer.tsx +249 -0
  181. package/src/ui/styled/InstallBanner.tsx +114 -0
  182. package/src/ui/styled/InvoiceConfirmation.tsx +50 -0
  183. package/src/ui/styled/InvoicePayment.tsx +141 -0
  184. package/src/ui/styled/LeadMagnet.tsx +93 -0
  185. package/src/ui/styled/Loading.tsx +56 -0
  186. package/src/ui/styled/LoginForm.tsx +134 -0
  187. package/src/ui/styled/Markdown.tsx +103 -0
  188. package/src/ui/styled/MembershipCheckout.tsx +197 -0
  189. package/src/ui/styled/MembershipTiers.tsx +115 -0
  190. package/src/ui/styled/OfferButton.tsx +181 -0
  191. package/src/ui/styled/PageActions.tsx +124 -0
  192. package/src/ui/styled/PaymentLinkConfirmation.tsx +50 -0
  193. package/src/ui/styled/PaymentLinkPayment.tsx +85 -0
  194. package/src/ui/styled/Paywall.tsx +54 -0
  195. package/src/ui/styled/PlayButton.tsx +48 -0
  196. package/src/ui/styled/PodcastEpisode.tsx +137 -0
  197. package/src/ui/styled/PodcastList.tsx +58 -0
  198. package/src/ui/styled/PodcastShow.tsx +164 -0
  199. package/src/ui/styled/PostsFeed.tsx +63 -0
  200. package/src/ui/styled/ProductDetail.tsx +616 -0
  201. package/src/ui/styled/ProductGrid.tsx +82 -0
  202. package/src/ui/styled/PushOptIn.tsx +65 -0
  203. package/src/ui/styled/PwaRegistration.tsx +58 -0
  204. package/src/ui/styled/ReactionBar.tsx +93 -0
  205. package/src/ui/styled/ReplayList.tsx +134 -0
  206. package/src/ui/styled/ResetPasswordForm.tsx +127 -0
  207. package/src/ui/styled/ReviewForm.tsx +292 -0
  208. package/src/ui/styled/ReviewsSection.tsx +260 -0
  209. package/src/ui/styled/SectionedFormRenderer.tsx +146 -0
  210. package/src/ui/styled/SignupForm.tsx +109 -0
  211. package/src/ui/styled/UserMenu.tsx +166 -0
  212. package/src/ui/styled/chat/ChatAttachments.tsx +172 -0
  213. package/src/ui/styled/chat/ChatComposer.tsx +153 -0
  214. package/src/ui/styled/chat/ChatThreadPanel.tsx +94 -0
  215. package/src/ui/styled/chat/MessageBubble.tsx +66 -0
  216. package/src/ui/styled/chat/NewDmDialog.tsx +78 -0
  217. package/src/ui/styled/community/CommunityComposer.tsx +358 -0
  218. package/src/ui/styled/community/CommunityFeed.tsx +483 -0
  219. package/src/ui/styled/community/CommunityLeaderboard.tsx +109 -0
  220. package/src/ui/styled/community/CommunityMediaGallery.tsx +116 -0
  221. package/src/ui/styled/community/CommunityMemberProfile.tsx +285 -0
  222. package/src/ui/styled/community/CommunityNotifications.tsx +222 -0
  223. package/src/ui/styled/community/CommunityPostCard.tsx +165 -0
  224. package/src/ui/styled/community/CommunityPostDetail.tsx +337 -0
  225. package/src/ui/styled/community/CommunityReactionBar.tsx +107 -0
  226. package/src/ui/styled/community/CommunityRichText.tsx +157 -0
  227. package/src/ui/styled/community/CommunitySpaces.tsx +174 -0
  228. package/src/ui/styled/community/MemberAvatar.tsx +74 -0
  229. package/src/ui/styled/community/index.ts +23 -0
  230. package/src/ui/styled/community/util.ts +15 -0
  231. package/src/ui/styled/members/MemberHomeLayout.tsx +98 -0
  232. package/src/ui/styled/members/MemberPostCard.tsx +392 -0
  233. package/src/ui/styled/members/MemberPostsFeed.tsx +219 -0
  234. package/src/ui/styled/members/PostCollectionDetail.tsx +227 -0
  235. package/src/ui/styled/members/PostCollectionsGrid.tsx +287 -0
  236. package/src/ui/styled/members/index.ts +7 -0
  237. package/src/ui/styled/rich-text.css +150 -0
  238. package/src/ui/styled/work/WorkInviteAccept.tsx +222 -0
  239. package/src/ui/styled/work/WorkPortalProjects.tsx +163 -0
  240. package/src/ui/styled/work/WorkProjectInvoices.tsx +113 -0
  241. package/src/ui/styled/work/WorkProjectReport.tsx +66 -0
  242. package/src/ui/styled/work/WorkReportBody.tsx +327 -0
  243. package/src/ui/styled/work/WorkTaskAttachments.tsx +67 -0
  244. package/src/ui/styled/work/WorkTaskComments.tsx +151 -0
  245. package/src/ui/styled/work/WorkTaskDetail.tsx +92 -0
  246. package/src/ui/styled/work/WorkTokenReport.tsx +49 -0
  247. package/src/ui/styled/work/index.ts +10 -0
  248. package/src/ui/theme/ForgeThemeProvider.tsx +121 -0
  249. package/src/ui/theme/contrast.ts +22 -0
  250. package/src/utils/analyticsBus.ts +31 -0
  251. package/src/utils/attribution.ts +68 -0
  252. package/src/utils/cookieConsent.ts +83 -0
  253. package/src/utils/landing.ts +153 -0
  254. package/src/utils/metaPixel.ts +122 -0
  255. package/src/utils/structuredData.ts +126 -0
@@ -0,0 +1,208 @@
1
+ import { useEffect, useRef } from "react";
2
+ import { useTrackEvent, newEventId } from "../../data/queries/useAnalytics";
3
+ import { useSiteConfig } from "../../data/queries/useWebsite";
4
+ import { initMetaPixelScoped, fireMetaEventSingle } from "../../utils/metaPixel";
5
+ import { setCurrentPageEventId, isProfilePixelSuppressed } from "../../utils/analyticsBus";
6
+ import { useCookieConsent } from "../headless/consent/useCookieConsent";
7
+
8
+ export interface ForgeAnalyticsProps {
9
+ /**
10
+ * The tenant's Meta Pixel id. When set, the pixel is initialised once and a
11
+ * `PageView` is fired (with an event id for CAPI de-duping) on every route
12
+ * change. **Omit** to auto-resolve it from the tenant's runtime settings
13
+ * (`useSiteConfig().metaPixelId`) so changing the pixel needs no rebuild; pass
14
+ * `""` to explicitly disable it.
15
+ */
16
+ metaPixelId?: string;
17
+ /** Auto-track clicks on links/buttons (and any `[data-track]` element). Default `true`. */
18
+ trackClicks?: boolean;
19
+ /** Master switch — set `false` (e.g. in the editor/preview) to record nothing. Default `true`. */
20
+ enabled?: boolean;
21
+ /**
22
+ * Honour the visitor's cookie choice (from `<CookieConsent />`). Default
23
+ * `true`: first-party analytics wait for `performance` consent and the Meta
24
+ * Pixel waits for `marketing` consent, so nothing fires until the visitor
25
+ * opts in. Set `false` to track unconditionally (only where a banner isn't
26
+ * legally required).
27
+ */
28
+ requireConsent?: boolean;
29
+ /**
30
+ * How first-party (non-Meta) analytics behaves:
31
+ * - `"consent"` (default): gated on `performance` consent; stores a session id
32
+ * in `sessionStorage` for accurate per-session visits.
33
+ * - `"cookieless"`: Plausible-style. Always runs; with no `performance`
34
+ * consent it stores nothing (the backend derives a rotating per-day visitor
35
+ * hash), and once the visitor turns Performance on it upgrades to a stored
36
+ * `sessionStorage` session for more precise visits. Choose this so analytics
37
+ * works out of the box without storage consent, but improves with it.
38
+ *
39
+ * The Meta Pixel is always gated on `marketing` consent regardless of this.
40
+ */
41
+ firstPartyMode?: "consent" | "cookieless";
42
+ }
43
+
44
+ /**
45
+ * The full resolved path the visitor is on (e.g. `/i/store/abc-123`) — never a
46
+ * route template like `store/:id`. This is the value the admin analytics "top
47
+ * pages" report groups on (`event_data->>'pathname'`), so a code site's real
48
+ * routes show up per-page instead of collapsed into a parameterised template.
49
+ */
50
+ function currentPathname(): string {
51
+ if (typeof window === "undefined") return "";
52
+ return window.location.pathname;
53
+ }
54
+
55
+ /** Full route including query — kept alongside `pathname` for detail. */
56
+ function currentPage(): string {
57
+ if (typeof window === "undefined") return "";
58
+ return window.location.pathname + window.location.search;
59
+ }
60
+
61
+ /**
62
+ * Drop-in website analytics. Render once, high in the tree (inside
63
+ * `ForgeProvider`), and it wires the site into the platform's analytics:
64
+ *
65
+ * - Auto page views on every navigation → `POST /public/websites/track-event`,
66
+ * surfaced in the admin analytics dashboard (visits, pages, geo).
67
+ * - Auto click tracking on links/buttons (opt out with `trackClicks={false}`).
68
+ * - Optional Meta Pixel: pass `metaPixelId` to init the pixel and mirror each
69
+ * page view as a Meta `PageView` (event-id matched for CAPI de-dup).
70
+ *
71
+ * Consent-aware: with `requireConsent` (default), it reads the shared cookie
72
+ * consent — first-party analytics wait for `performance`, the Meta Pixel waits
73
+ * for `marketing` — so pairing it with `<CookieConsent />` is GDPR-correct out
74
+ * of the box. Router-agnostic: SPA navigations are detected via the History API,
75
+ * so it works under any router (or none). It renders nothing.
76
+ *
77
+ * <ForgeAnalytics metaPixelId={META_PIXEL_ID} />
78
+ */
79
+ export function ForgeAnalytics({
80
+ metaPixelId,
81
+ trackClicks = true,
82
+ enabled = true,
83
+ requireConsent = true,
84
+ firstPartyMode = "consent",
85
+ }: ForgeAnalyticsProps) {
86
+ const cookieless = firstPartyMode === "cookieless";
87
+ // Resolve the pixel from the tenant's runtime settings when not explicitly
88
+ // passed (so changing it needs no rebuild). An explicit prop — including `""`
89
+ // to disable — always wins.
90
+ const { data: siteConfig } = useSiteConfig();
91
+ const pixelId = metaPixelId ?? siteConfig?.metaPixelId ?? undefined;
92
+ const { performance: perfConsent, marketing: mktConsent } = useCookieConsent();
93
+ // Session storage follows performance consent. In `consent` mode first-party
94
+ // only runs with that consent, so it always stores; in `cookieless` mode it
95
+ // runs regardless but only stores a device session once the visitor turns
96
+ // Performance on — otherwise it stays cookieless (backend derives the id).
97
+ const storeSession = cookieless ? perfConsent : true;
98
+ const { track } = useTrackEvent({ storeSession });
99
+ const lastPage = useRef<string | null>(null);
100
+
101
+ // Consent gates. The Meta Pixel always needs `marketing`. First-party analytics
102
+ // needs `performance` — UNLESS it's cookieless (stores nothing without consent),
103
+ // in which case it's treated as essential and runs anyway. Flipping a gate to
104
+ // `true` (visitor accepts) re-runs the effects, recording the current page.
105
+ const firstPartyOn = enabled && (cookieless || !requireConsent || perfConsent);
106
+ const metaOn = enabled && !!pixelId && (!requireConsent || mktConsent);
107
+
108
+ // Init the Meta pixel once (scoped — no auto PageView; we fire it ourselves so
109
+ // it doesn't leak into a per-page campaign pixel).
110
+ useEffect(() => {
111
+ if (!metaOn || !pixelId) return;
112
+ initMetaPixelScoped(pixelId);
113
+ }, [metaOn, pixelId]);
114
+
115
+ // Page-view tracking on initial load + every SPA navigation. We detect
116
+ // navigation by patching pushState/replaceState and listening for popstate,
117
+ // which keeps this free of any router dependency.
118
+ useEffect(() => {
119
+ if (typeof window === "undefined") return;
120
+ if (!firstPartyOn && !metaOn) return; // nothing consented yet — wait
121
+
122
+ const sendPageView = () => {
123
+ const page = currentPage();
124
+ if (page === lastPage.current) return; // de-dupe repeated fires for the same URL
125
+ lastPage.current = page;
126
+
127
+ const eventId = newEventId();
128
+ // Publish the id so a per-page <PageMetaPixel/> (and its server CAPI twin)
129
+ // can dedupe against the same page view.
130
+ setCurrentPageEventId(eventId);
131
+ if (firstPartyOn) {
132
+ track("page_view", {
133
+ // `pathname` (full resolved path) is what the admin "top pages" report
134
+ // groups on; `page` keeps the query string for detail.
135
+ pathname: currentPathname(),
136
+ page,
137
+ pageTitle: typeof document !== "undefined" ? document.title : undefined,
138
+ referrer: typeof document !== "undefined" ? document.referrer || undefined : undefined,
139
+ eventId,
140
+ });
141
+ }
142
+ // Fire the profile pixel to ITSELF (trackSingle), and skip it on routes a
143
+ // campaign pixel has taken over (PageMetaPixel override). Deferred a tick
144
+ // so a page pixel mounting on this route can register its suppression first.
145
+ if (metaOn && pixelId) {
146
+ const path = currentPathname();
147
+ const pixel = pixelId;
148
+ queueMicrotask(() => {
149
+ if (!isProfilePixelSuppressed(path)) fireMetaEventSingle(pixel, "PageView", {}, eventId);
150
+ });
151
+ }
152
+ };
153
+
154
+ // Wrap the history methods so programmatic navigations emit an event.
155
+ const origPush = window.history.pushState;
156
+ const origReplace = window.history.replaceState;
157
+ const emit = () => window.dispatchEvent(new Event("forge:locationchange"));
158
+ window.history.pushState = function (...args) {
159
+ const r = origPush.apply(this, args as Parameters<typeof origPush>);
160
+ emit();
161
+ return r;
162
+ };
163
+ window.history.replaceState = function (...args) {
164
+ const r = origReplace.apply(this, args as Parameters<typeof origReplace>);
165
+ emit();
166
+ return r;
167
+ };
168
+ window.addEventListener("popstate", sendPageView);
169
+ window.addEventListener("forge:locationchange", sendPageView);
170
+
171
+ sendPageView(); // initial page
172
+
173
+ return () => {
174
+ window.history.pushState = origPush;
175
+ window.history.replaceState = origReplace;
176
+ window.removeEventListener("popstate", sendPageView);
177
+ window.removeEventListener("forge:locationchange", sendPageView);
178
+ };
179
+ }, [firstPartyOn, metaOn, pixelId, track]);
180
+
181
+ // Click tracking — first-party only, one delegated listener for the document.
182
+ useEffect(() => {
183
+ if (!firstPartyOn || !trackClicks || typeof document === "undefined") return;
184
+
185
+ const onClick = (e: MouseEvent) => {
186
+ const start = e.target as Element | null;
187
+ if (!start) return;
188
+ const el = start.closest("a,button,[data-track]") as HTMLElement | null;
189
+ if (!el) return;
190
+
191
+ const anchor = el.closest("a") as HTMLAnchorElement | null;
192
+ const text = (el.getAttribute("aria-label") || el.textContent || "").trim().slice(0, 120) || undefined;
193
+ track("click", {
194
+ pathname: currentPathname(),
195
+ page: currentPage(),
196
+ elementType: el.tagName.toLowerCase(),
197
+ text,
198
+ href: anchor?.href || undefined,
199
+ id: el.id || el.getAttribute("data-track") || undefined,
200
+ });
201
+ };
202
+
203
+ document.addEventListener("click", onClick, { capture: true });
204
+ return () => document.removeEventListener("click", onClick, { capture: true });
205
+ }, [firstPartyOn, trackClicks, track]);
206
+
207
+ return null;
208
+ }
@@ -0,0 +1,92 @@
1
+ import { useEffect, useLayoutEffect } from "react";
2
+ import { initMetaPixelScoped, fireMetaEventSingle } from "../../utils/metaPixel";
3
+ import {
4
+ getCurrentPageEventId,
5
+ suppressProfilePixel,
6
+ unsuppressProfilePixel,
7
+ } from "../../utils/analyticsBus";
8
+ import { useCookieConsent } from "../headless/consent/useCookieConsent";
9
+
10
+ // useLayoutEffect warns during SSR; fall back to useEffect on the server.
11
+ const useIsoLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect;
12
+
13
+ export interface PageMetaPixelProps {
14
+ /** The campaign pixel id for this page (a public Meta Pixel id). */
15
+ pixelId: string;
16
+ /** Fire a `PageView` to this pixel on mount. Default `true`. */
17
+ trackPageView?: boolean;
18
+ /** Gate on the visitor's `marketing` cookie consent. Default `true`. */
19
+ requireConsent?: boolean;
20
+ /**
21
+ * Suppress the global profile pixel's PageView on this route so only the
22
+ * campaign pixel fires here. Default `true`. Set `false` to fire both.
23
+ */
24
+ overrideProfilePixel?: boolean;
25
+ }
26
+
27
+ /**
28
+ * Per-page Meta Pixel for an AI-built landing / smart-link page. Drop it on the
29
+ * page with a campaign `pixelId`; it initialises that pixel and fires a
30
+ * `PageView` to it (reusing the current page view's event id so it dedupes with
31
+ * the server-side Custom Page CAPI event). By default it takes over from the
32
+ * site-wide profile pixel on this route. Fire conversion events (`Lead`,
33
+ * `ViewContent`, …) on CTAs via `usePageMetaPixel`.
34
+ *
35
+ * <PageMetaPixel pixelId="123456789" />
36
+ *
37
+ * Renders nothing.
38
+ */
39
+ export function PageMetaPixel({
40
+ pixelId,
41
+ trackPageView = true,
42
+ requireConsent = true,
43
+ overrideProfilePixel = true,
44
+ }: PageMetaPixelProps) {
45
+ const { marketing } = useCookieConsent();
46
+ const allowed = !requireConsent || marketing;
47
+
48
+ // Register suppression before ForgeAnalytics' deferred profile-pixel fire runs.
49
+ useIsoLayoutEffect(() => {
50
+ if (!overrideProfilePixel || typeof window === "undefined") return;
51
+ const path = window.location.pathname;
52
+ suppressProfilePixel(path);
53
+ return () => unsuppressProfilePixel(path);
54
+ }, [overrideProfilePixel]);
55
+
56
+ useEffect(() => {
57
+ if (!allowed || !pixelId || typeof window === "undefined") return;
58
+ initMetaPixelScoped(pixelId);
59
+ if (trackPageView) {
60
+ fireMetaEventSingle(pixelId, "PageView", {}, getCurrentPageEventId() ?? undefined);
61
+ }
62
+ }, [allowed, pixelId, trackPageView]);
63
+
64
+ return null;
65
+ }
66
+
67
+ /**
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.
71
+ *
72
+ * const { fire } = usePageMetaPixel("123456789");
73
+ * <button onClick={() => fire("Lead", { content_name: "Pre-save" })}>…</button>
74
+ */
75
+ export function usePageMetaPixel(pixelId: string, opts?: { requireConsent?: boolean }) {
76
+ const requireConsent = opts?.requireConsent ?? true;
77
+ const { marketing } = useCookieConsent();
78
+ const allowed = !requireConsent || marketing;
79
+
80
+ useEffect(() => {
81
+ if (!allowed || !pixelId) return;
82
+ initMetaPixelScoped(pixelId);
83
+ }, [allowed, pixelId]);
84
+
85
+ return {
86
+ allowed,
87
+ fire: (eventName: string, data: Record<string, unknown> = {}) => {
88
+ if (!allowed || !pixelId) return;
89
+ fireMetaEventSingle(pixelId, eventName, data, getCurrentPageEventId() ?? undefined);
90
+ },
91
+ };
92
+ }
@@ -0,0 +1,90 @@
1
+ import type { CSSProperties, ElementType, ReactNode } from "react";
2
+ import { useField } from "../../content/useField";
3
+ import type { ImageValue } from "../../content/types";
4
+
5
+ // Editable wrappers — the convenient way to bind content fields. Each registers
6
+ // a field via `useField` and renders its resolved value, spreading `fieldProps`
7
+ // so the visual overlay can locate it in edit mode (empty in published builds).
8
+
9
+ export type EditableTextProps = {
10
+ /** The bound field path, e.g. "home.hero.title". */
11
+ field: string;
12
+ /** Rendered until the creator edits this field. */
13
+ default?: string;
14
+ /** The element to render (default: span). */
15
+ as?: ElementType;
16
+ /** Human label for the field panel; falls back to the path. */
17
+ label?: string;
18
+ localized?: boolean;
19
+ style?: CSSProperties;
20
+ className?: string;
21
+ };
22
+
23
+ export function EditableText({
24
+ field,
25
+ default: def = "",
26
+ as: Tag = "span",
27
+ label,
28
+ localized,
29
+ ...rest
30
+ }: EditableTextProps) {
31
+ const { value, fieldProps } = useField<string>(field, { type: "text", default: def, label, localized });
32
+ return (
33
+ <Tag {...rest} {...fieldProps}>
34
+ {value}
35
+ </Tag>
36
+ );
37
+ }
38
+
39
+ export type EditableRichTextProps = Omit<EditableTextProps, "localized"> & { localized?: boolean };
40
+
41
+ export function EditableRichText({
42
+ field,
43
+ default: def = "",
44
+ as: Tag = "div",
45
+ label,
46
+ localized,
47
+ ...rest
48
+ }: EditableRichTextProps) {
49
+ const { value, fieldProps } = useField<string>(field, { type: "richtext", default: def, label, localized });
50
+ // Rich text is stored as HTML. The editor sanitizes on write.
51
+ return <Tag {...rest} {...fieldProps} dangerouslySetInnerHTML={{ __html: value }} />;
52
+ }
53
+
54
+ export type EditableImageProps = {
55
+ field: string;
56
+ /** Default image when the field is unedited. */
57
+ default?: ImageValue;
58
+ /** Fallback alt text. */
59
+ alt?: string;
60
+ label?: string;
61
+ style?: CSSProperties;
62
+ className?: string;
63
+ };
64
+
65
+ export function EditableImage({ field, default: def, alt, label, ...rest }: EditableImageProps) {
66
+ const { value, fieldProps } = useField<ImageValue | undefined>(field, {
67
+ type: "image",
68
+ default: def,
69
+ label,
70
+ localized: false,
71
+ });
72
+ const img = value ?? def;
73
+ const altText = typeof img?.alt === "string" ? img.alt : (alt ?? "");
74
+ if (!img?.url) return null;
75
+ return <img src={img.url} alt={altText} {...rest} {...fieldProps} />;
76
+ }
77
+
78
+ /** Render arbitrary children with a field marker (for composite editable regions). */
79
+ export function EditableRegion({
80
+ field,
81
+ children,
82
+ ...rest
83
+ }: { field: string; children: ReactNode } & { style?: CSSProperties; className?: string }) {
84
+ const { fieldProps } = useField(field, { type: "text", default: "" });
85
+ return (
86
+ <div {...rest} {...fieldProps}>
87
+ {children}
88
+ </div>
89
+ );
90
+ }
@@ -0,0 +1,142 @@
1
+ import React from "react";
2
+ import { formatCurrency } from "./useFormatCurrency";
3
+ import type { PublicTaxQuote } from "../../types/models";
4
+
5
+ /**
6
+ * <PriceDisplay/> — the one tax-aware price renderer (financial back-office).
7
+ *
8
+ * Two display modes, driven by the store's `pricesIncludeTax` setting:
9
+ *
10
+ * - **Exclusive** (US default, `pricesIncludeTax` falsy): renders the bare
11
+ * price. Tax appears only as a separate "Tax" line in cart/checkout
12
+ * summaries once the authoritative checkout `taxQuote` is known — that line
13
+ * is rendered by the summary itself, not by this component.
14
+ * - **Inclusive** (EU/UK style, `pricesIncludeTax` true): the price already
15
+ * contains tax, so it renders everywhere (listing + detail + checkout) with
16
+ * a subtle muted caption — `incl. tax` generically, or
17
+ * `incl. £10.00 VAT` once the contained tax amount is known.
18
+ *
19
+ * Deliberately **hook-free** so it works in every surface: Forge styled blocks
20
+ * (inside `ForgeThemeProvider`), frontend-shared Craft.js blocks (editor
21
+ * `themeSettings`), and plain client components. Formatting and the muted
22
+ * caption color are passed in by the caller; the caption falls back to
23
+ * inheriting the current color at reduced opacity, so it is always
24
+ * "muted foreground" relative to wherever it's dropped.
25
+ *
26
+ * DISPLAY ONLY — never computes or alters charge math. The authoritative
27
+ * numbers are the checkout `taxQuote` from every start-payment response.
28
+ */
29
+ export interface PriceDisplayProps {
30
+ /** Price in MAJOR units, in the store's display currency. */
31
+ amount: number;
32
+ /** Store display mode (`sales_tax_settings.prices_include_tax`). */
33
+ pricesIncludeTax?: boolean | null;
34
+ /** Contained tax in MAJOR units — pass only when known (checkout quote).
35
+ * Only rendered in inclusive mode ("incl. X tax"). */
36
+ taxAmount?: number | null;
37
+ /** Currency code of `amount` — used by the fallback formatter only. */
38
+ currency?: string;
39
+ /** Formatter for amounts (a block's `fmt`). Falls back to a static
40
+ * symbol+thousands format of `currency`. */
41
+ formatAmount?: (amount: number, currencyCode?: string) => string;
42
+ /** Word used in the inclusive caption, e.g. "VAT". Default "tax". */
43
+ taxLabel?: string;
44
+ /** Muted caption color (e.g. the theme's muted foreground). When omitted the
45
+ * caption inherits the current color at 0.65 opacity. */
46
+ mutedColor?: string;
47
+ /** Rendered right after the price, inside the price element (e.g. "/mo"). */
48
+ suffix?: React.ReactNode;
49
+ /** Render the caption inline after the price instead of on its own line. */
50
+ inlineCaption?: boolean;
51
+ className?: string;
52
+ style?: React.CSSProperties;
53
+ captionClassName?: string;
54
+ captionStyle?: React.CSSProperties;
55
+ }
56
+
57
+ /** The inclusive-mode caption text ("incl. tax" / "incl. £10.00 VAT"), or null
58
+ * when no caption should render (exclusive mode). Exported for callers that
59
+ * need the text without the markup. */
60
+ export function priceTaxCaption(opts: {
61
+ pricesIncludeTax?: boolean | null;
62
+ taxAmount?: number | null;
63
+ taxLabel?: string;
64
+ formatAmount?: (amount: number, currencyCode?: string) => string;
65
+ currency?: string;
66
+ }): string | null {
67
+ const { pricesIncludeTax, taxAmount, taxLabel = "tax", formatAmount, currency } = opts;
68
+ if (!pricesIncludeTax) return null;
69
+ if (taxAmount != null && taxAmount > 0) {
70
+ const fmt = formatAmount ?? ((n: number, c?: string) => formatCurrency(n, c ?? currency));
71
+ return `incl. ${fmt(taxAmount, currency)} ${taxLabel}`;
72
+ }
73
+ return `incl. ${taxLabel}`;
74
+ }
75
+
76
+ export function PriceDisplay({
77
+ amount,
78
+ pricesIncludeTax,
79
+ taxAmount,
80
+ currency,
81
+ formatAmount,
82
+ taxLabel = "tax",
83
+ mutedColor,
84
+ suffix,
85
+ inlineCaption = false,
86
+ className,
87
+ style,
88
+ captionClassName,
89
+ captionStyle,
90
+ }: PriceDisplayProps) {
91
+ const fmt = formatAmount ?? ((n: number, c?: string) => formatCurrency(n, c ?? currency));
92
+ const caption = priceTaxCaption({ pricesIncludeTax, taxAmount, taxLabel, formatAmount, currency });
93
+
94
+ const captionEl = caption ? (
95
+ <span
96
+ className={captionClassName}
97
+ style={{
98
+ display: inlineCaption ? "inline" : "block",
99
+ fontSize: "0.72em",
100
+ fontWeight: 400,
101
+ lineHeight: 1.3,
102
+ whiteSpace: "nowrap",
103
+ ...(mutedColor ? { color: mutedColor } : { opacity: 0.65 }),
104
+ ...(inlineCaption ? { marginLeft: "0.35em" } : {}),
105
+ ...captionStyle,
106
+ }}
107
+ >
108
+ {caption}
109
+ </span>
110
+ ) : null;
111
+
112
+ return (
113
+ <span className={className} style={{ display: inlineCaption ? "inline" : "inline-block", ...style }}>
114
+ <span>
115
+ {fmt(amount, currency)}
116
+ {suffix}
117
+ </span>
118
+ {captionEl}
119
+ </span>
120
+ );
121
+ }
122
+
123
+ /**
124
+ * The tax portion of a checkout summary, derived from the authoritative
125
+ * `taxQuote`. Normalizes the two modes for summary UIs:
126
+ * - exclusive → a "Tax" row to render between Subtotal/Shipping and Total;
127
+ * - inclusive → a muted "Includes X tax" note under the Total (the total
128
+ * does not grow).
129
+ * Returns null when there is no quote or no tax.
130
+ */
131
+ export function summarizeTaxQuote(
132
+ quote: PublicTaxQuote | null | undefined,
133
+ formatAmount?: (amount: number, currencyCode?: string) => string,
134
+ taxLabel = "tax",
135
+ ): { mode: "exclusive" | "inclusive"; label: string; formattedTax: string } | null {
136
+ if (!quote || !(quote.tax > 0)) return null;
137
+ const fmt = formatAmount ?? ((n: number, c?: string) => formatCurrency(n, c));
138
+ const formattedTax = fmt(quote.tax, quote.currency);
139
+ return quote.pricesIncludeTax
140
+ ? { mode: "inclusive", label: `Includes ${formattedTax} ${taxLabel}`, formattedTax }
141
+ : { mode: "exclusive", label: "Tax", formattedTax };
142
+ }
@@ -0,0 +1,92 @@
1
+ import { Currency, currencySymbol } from "../../types/models";
2
+ import { usePublicAuth } from "../../contexts/PublicAuthContext";
3
+ import { useSiteConfig } from "../../data/queries/useWebsite";
4
+
5
+ /** Thousands-separated, e.g. 1234.5 -> "1,234.5". */
6
+ const toLocaleString = (n: number): string => Number(n).toLocaleString("en-US");
7
+
8
+ /** Symbol for a currency code, falling back to USD. */
9
+ export const getCurrencySymbol = (currencyCode?: string): string => {
10
+ if (!currencyCode) return currencySymbol[Currency.USD];
11
+ return currencySymbol[currencyCode as Currency] || currencySymbol[Currency.USD];
12
+ };
13
+
14
+ /** Static format (no conversion) — `formatCurrency(19.99, "USD") -> "$19.99"`. */
15
+ export const formatCurrency = (amount: number | string, currencyCode?: string): string => {
16
+ const symbol = getCurrencySymbol(currencyCode);
17
+ const numAmount = typeof amount === "string" ? parseFloat(amount) : amount;
18
+ return `${symbol}${toLocaleString(Number(numAmount.toFixed(2)))}`;
19
+ };
20
+
21
+ /**
22
+ * Currency formatter that honors the visitor's selected currency and the
23
+ * tenant's exchange rates (both sourced from `usePublicAuth()`), converting the
24
+ * amount when a rate is available and falling back to the raw amount otherwise.
25
+ *
26
+ * The SOURCE currency (the currency an amount is in) defaults to the tenant's
27
+ * settlement currency from `useSiteConfig()` — the single source of truth — so
28
+ * callers just write `formatCurrency(amount)` with no baked currency constant.
29
+ * Pass an explicit code only when the amount is in a different, known currency
30
+ * (e.g. a payment's charged currency).
31
+ *
32
+ * This is the primitive every price/total in a Forge site should format through.
33
+ * Port of the client app's `useFormatCurrency`.
34
+ */
35
+ export const useFormatCurrency = () => {
36
+ const { currencies, userSelectedCurrency } = usePublicAuth();
37
+ const { data: siteConfig } = useSiteConfig();
38
+ const settlementCurrency = siteConfig?.currency;
39
+
40
+ const formatCurrencyValue = (amount: number | string, currencyCode?: string): string => {
41
+ const source = currencyCode ?? settlementCurrency;
42
+ if (!source) return "N/A";
43
+
44
+ const numAmount = typeof amount === "string" ? parseFloat(amount) : amount;
45
+ const noConversion = () => `${getCurrencySymbol(source)}${toLocaleString(Number(numAmount.toFixed(2)))}`;
46
+
47
+ if (!currencies || !currencies.exchangeRates || Object.keys(currencies.exchangeRates).length === 0) {
48
+ return noConversion();
49
+ }
50
+ if (userSelectedCurrency && userSelectedCurrency === source) {
51
+ return noConversion();
52
+ }
53
+
54
+ const exchangeRate = currencies.exchangeRates[source as Currency]?.[userSelectedCurrency as Currency];
55
+ if (!exchangeRate) return noConversion();
56
+
57
+ const convertedAmount = Number(numAmount) * exchangeRate;
58
+ const symbol = getCurrencySymbol(userSelectedCurrency as string);
59
+ return `${symbol}${toLocaleString(Number(convertedAmount.toFixed(2)))}`;
60
+ };
61
+
62
+ /**
63
+ * Convert a numeric amount from `fromCode` (defaults to the tenant settlement
64
+ * currency) into `toCode` (defaults to the visitor's selected currency) using
65
+ * the tenant exchange rates. Returns the amount unchanged when no rate is
66
+ * available or the currencies match. Used to total mixed-currency values
67
+ * (e.g. shipping rates) before formatting.
68
+ */
69
+ const convertCurrency = (amount: number, fromCode?: string, toCode?: string): number => {
70
+ const from = (fromCode ?? settlementCurrency) as Currency | undefined;
71
+ const target = (toCode ?? userSelectedCurrency) as Currency | undefined;
72
+ if (!from || !target || from === target) return amount;
73
+ const rate = currencies?.exchangeRates?.[from]?.[target];
74
+ return rate ? amount * rate : amount;
75
+ };
76
+
77
+ return { formatCurrency: formatCurrencyValue, convertCurrency };
78
+ };
79
+
80
+ /**
81
+ * The amount formatter a styled block should use: the caller's `formatAmount`
82
+ * override if given, otherwise the runtime `useFormatCurrency` formatter (whose
83
+ * source currency is the tenant's settlement currency from `useSiteConfig`).
84
+ * This keeps currency a single source of truth — a block formats money
85
+ * correctly with no `formatAmount` prop and no baked currency constant.
86
+ */
87
+ export const useAmountFormatter = (
88
+ override?: (amount: number, currencyCode?: string) => string,
89
+ ): ((amount: number, currencyCode?: string) => string) => {
90
+ const { formatCurrency } = useFormatCurrency();
91
+ return override ?? formatCurrency;
92
+ };