@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.
- package/package.json +42 -0
- package/src/client/createForgeClient.ts +54 -0
- package/src/content/ForgeContentProvider.tsx +118 -0
- package/src/content/ForgeEditBridge.tsx +134 -0
- package/src/content/editFlag.ts +23 -0
- package/src/content/index.ts +28 -0
- package/src/content/resolve.ts +67 -0
- package/src/content/types.ts +97 -0
- package/src/content/useField.ts +64 -0
- package/src/contexts/AppAuthContext.tsx +185 -0
- package/src/contexts/AudioPlayerContext.tsx +389 -0
- package/src/contexts/CartContext.tsx +131 -0
- package/src/contexts/PublicAuthContext.tsx +333 -0
- package/src/contexts/useAppAdminGuard.ts +56 -0
- package/src/css.d.ts +5 -0
- package/src/data/collectionParams.ts +57 -0
- package/src/data/queries/useAccount.ts +71 -0
- package/src/data/queries/useAccountSettings.ts +82 -0
- package/src/data/queries/useAnalytics.ts +86 -0
- package/src/data/queries/useAuthActions.ts +28 -0
- package/src/data/queries/useBlog.ts +103 -0
- package/src/data/queries/useBlogComments.ts +96 -0
- package/src/data/queries/useBroadcasts.ts +31 -0
- package/src/data/queries/useCertificates.ts +58 -0
- package/src/data/queries/useChat.ts +267 -0
- package/src/data/queries/useCoachingAvailability.ts +69 -0
- package/src/data/queries/useCoachingProducts.ts +67 -0
- package/src/data/queries/useCohortPage.ts +17 -0
- package/src/data/queries/useCollections.ts +211 -0
- package/src/data/queries/useCommunity.ts +794 -0
- package/src/data/queries/useCoupons.ts +20 -0
- package/src/data/queries/useCourseAccess.ts +50 -0
- package/src/data/queries/useCourses.ts +91 -0
- package/src/data/queries/useCurrencies.ts +33 -0
- package/src/data/queries/useDocuments.ts +208 -0
- package/src/data/queries/useDonations.ts +29 -0
- package/src/data/queries/useEngagement.ts +115 -0
- package/src/data/queries/useEvents.ts +69 -0
- package/src/data/queries/useFinalize.ts +109 -0
- package/src/data/queries/useForms.ts +31 -0
- package/src/data/queries/useInvoice.ts +65 -0
- package/src/data/queries/useLeadMagnet.ts +96 -0
- package/src/data/queries/useMembership.ts +20 -0
- package/src/data/queries/useNotifications.ts +267 -0
- package/src/data/queries/useOffers.ts +56 -0
- package/src/data/queries/useOrders.ts +71 -0
- package/src/data/queries/usePWA.ts +101 -0
- package/src/data/queries/usePageActions.ts +36 -0
- package/src/data/queries/usePaymentFlow.ts +80 -0
- package/src/data/queries/usePaymentLink.ts +47 -0
- package/src/data/queries/usePodcast.ts +85 -0
- package/src/data/queries/usePostCollections.ts +48 -0
- package/src/data/queries/usePosts.ts +94 -0
- package/src/data/queries/useProducts.ts +108 -0
- package/src/data/queries/useReplay.ts +19 -0
- package/src/data/queries/useReviews.ts +122 -0
- package/src/data/queries/useShipping.ts +109 -0
- package/src/data/queries/useSubscriptions.ts +66 -0
- package/src/data/queries/useWebsite.ts +128 -0
- package/src/data/queries/useWebsiteAgent.ts +100 -0
- package/src/index.ts +98 -0
- package/src/provider/ForgeAppProvider.tsx +149 -0
- package/src/provider/ForgeProvider.tsx +91 -0
- package/src/provider/SiteConfigProvider.tsx +27 -0
- package/src/server/_tests/buildPwaHead.spec.ts +182 -0
- package/src/server/_tests/buildWebManifest.spec.ts +313 -0
- package/src/server/_tests/forgeServiceWorkerScript.spec.ts +251 -0
- package/src/server/index.ts +260 -0
- package/src/server/jobs.ts +104 -0
- package/src/server/pwa.ts +332 -0
- package/src/types/index.ts +74 -0
- package/src/types/models.ts +1313 -0
- package/src/ui/analytics/ForgeAnalytics.tsx +208 -0
- package/src/ui/analytics/PageMetaPixel.tsx +92 -0
- package/src/ui/content/Editable.tsx +90 -0
- package/src/ui/format/PriceDisplay.tsx +142 -0
- package/src/ui/format/useFormatCurrency.ts +92 -0
- package/src/ui/headless/agent/useAiAgent.ts +106 -0
- package/src/ui/headless/auth/useLoginFlow.ts +72 -0
- package/src/ui/headless/auth/useSignupForm.ts +85 -0
- package/src/ui/headless/broadcast/useBroadcastWatch.ts +59 -0
- package/src/ui/headless/chat/attachmentKind.ts +27 -0
- package/src/ui/headless/chat/useChatAttachmentUpload.ts +132 -0
- package/src/ui/headless/chat/useChatRoom.ts +223 -0
- package/src/ui/headless/chat/useMessageThread.ts +60 -0
- package/src/ui/headless/checkout/useCheckout.ts +438 -0
- package/src/ui/headless/coaching/useCoachingBooking.ts +223 -0
- package/src/ui/headless/community/index.ts +20 -0
- package/src/ui/headless/community/lexical.ts +131 -0
- package/src/ui/headless/community/replyTree.ts +51 -0
- package/src/ui/headless/community/richLexical.ts +314 -0
- package/src/ui/headless/community/useCommunityAttachmentUpload.ts +137 -0
- package/src/ui/headless/community/useCommunitySpaceFeed.ts +122 -0
- package/src/ui/headless/community/useCommunityThread.ts +95 -0
- package/src/ui/headless/community/useMentionComposer.ts +105 -0
- package/src/ui/headless/community/useRichMentionComposer.ts +193 -0
- package/src/ui/headless/consent/useCookieConsent.ts +81 -0
- package/src/ui/headless/course/useCourseCheckout.ts +99 -0
- package/src/ui/headless/dialog.tsx +14 -0
- package/src/ui/headless/document/index.ts +7 -0
- package/src/ui/headless/document/useContractSignature.ts +68 -0
- package/src/ui/headless/document/useDocumentSigningFlow.ts +92 -0
- package/src/ui/headless/document/useOtpCodeInput.ts +86 -0
- package/src/ui/headless/document/useSignaturePad.ts +180 -0
- package/src/ui/headless/donation/Donation.tsx +115 -0
- package/src/ui/headless/donation/DonationContext.tsx +223 -0
- package/src/ui/headless/donation/fees.ts +15 -0
- package/src/ui/headless/donation/index.ts +10 -0
- package/src/ui/headless/engagement/usePostEngagement.ts +119 -0
- package/src/ui/headless/event/useEventCheckout.ts +141 -0
- package/src/ui/headless/forms/useContactForm.ts +58 -0
- package/src/ui/headless/forms/useEmailListForm.ts +75 -0
- package/src/ui/headless/forms/useSectionedForm.ts +256 -0
- package/src/ui/headless/index.ts +60 -0
- package/src/ui/headless/invoice/useInvoicePayment.ts +48 -0
- package/src/ui/headless/memberHome/index.ts +7 -0
- package/src/ui/headless/memberHome/useMemberHomeNav.ts +82 -0
- package/src/ui/headless/membership/MembershipGate.tsx +39 -0
- package/src/ui/headless/membership/useMembershipCheckout.ts +121 -0
- package/src/ui/headless/offer/Offer.tsx +88 -0
- package/src/ui/headless/offer/OfferContext.tsx +162 -0
- package/src/ui/headless/offer/index.ts +8 -0
- package/src/ui/headless/paymentLink/usePaymentLinkPayment.ts +46 -0
- package/src/ui/headless/podcast/usePodcastPlayer.ts +96 -0
- package/src/ui/headless/posts/index.ts +13 -0
- package/src/ui/headless/posts/upsell.ts +41 -0
- package/src/ui/headless/posts/useMemberPostsFeed.ts +110 -0
- package/src/ui/headless/posts/usePostCollectionDetail.ts +56 -0
- package/src/ui/headless/posts/usePostCollectionsList.ts +90 -0
- package/src/ui/headless/posts/usePostItem.ts +81 -0
- package/src/ui/headless/reviews/index.ts +6 -0
- package/src/ui/headless/reviews/useHelpfulVote.ts +45 -0
- package/src/ui/headless/reviews/useReviewForm.ts +108 -0
- package/src/ui/headless/reviews/useReviewsList.ts +79 -0
- package/src/ui/headless/work/index.ts +25 -0
- package/src/ui/headless/work/useWorkPortal.ts +349 -0
- package/src/ui/index.ts +144 -0
- package/src/ui/payment/ForgePaymentProvider.tsx +45 -0
- package/src/ui/payment/ForgeStripePayment.tsx +171 -0
- package/src/ui/shell/TribeNestApp.tsx +121 -0
- package/src/ui/shell/shellGating.spec.ts +28 -0
- package/src/ui/shell/shellGating.ts +11 -0
- package/src/ui/styled/AccountDashboard.tsx +575 -0
- package/src/ui/styled/AiAgentWidget.tsx +152 -0
- package/src/ui/styled/AudioPlayer.tsx +123 -0
- package/src/ui/styled/BlogCategory.tsx +79 -0
- package/src/ui/styled/BlogComments.tsx +236 -0
- package/src/ui/styled/BlogList.tsx +101 -0
- package/src/ui/styled/BlogPost.tsx +160 -0
- package/src/ui/styled/BlogPostFooter.tsx +33 -0
- package/src/ui/styled/BotProtection.tsx +101 -0
- package/src/ui/styled/Button.tsx +88 -0
- package/src/ui/styled/Cart.tsx +276 -0
- package/src/ui/styled/ChatRoom.tsx +252 -0
- package/src/ui/styled/Checkout.tsx +988 -0
- package/src/ui/styled/CheckoutConfirmation.tsx +452 -0
- package/src/ui/styled/CoachingBooking.tsx +605 -0
- package/src/ui/styled/CoachingConfirmation.tsx +151 -0
- package/src/ui/styled/CoachingDetail.tsx +99 -0
- package/src/ui/styled/CohortPage.tsx +82 -0
- package/src/ui/styled/ConfirmSubscription.tsx +134 -0
- package/src/ui/styled/Confirmation.tsx +171 -0
- package/src/ui/styled/ContactForm.tsx +81 -0
- package/src/ui/styled/CookieConsent.tsx +239 -0
- package/src/ui/styled/CourseAccess.tsx +169 -0
- package/src/ui/styled/CourseCheckout.tsx +425 -0
- package/src/ui/styled/CourseConfirmation.tsx +191 -0
- package/src/ui/styled/CourseDetail.tsx +140 -0
- package/src/ui/styled/CoursesGrid.tsx +85 -0
- package/src/ui/styled/CurrencySwitcher.tsx +51 -0
- package/src/ui/styled/DonationButton.tsx +247 -0
- package/src/ui/styled/DonationPage.tsx +29 -0
- package/src/ui/styled/EmailListForm.tsx +88 -0
- package/src/ui/styled/EventConfirmation.tsx +228 -0
- package/src/ui/styled/EventCountdown.tsx +98 -0
- package/src/ui/styled/EventDetail.tsx +141 -0
- package/src/ui/styled/EventTickets.tsx +548 -0
- package/src/ui/styled/EventsList.tsx +83 -0
- package/src/ui/styled/ForgotPasswordForm.tsx +105 -0
- package/src/ui/styled/FormRenderer.tsx +249 -0
- package/src/ui/styled/InstallBanner.tsx +114 -0
- package/src/ui/styled/InvoiceConfirmation.tsx +50 -0
- package/src/ui/styled/InvoicePayment.tsx +141 -0
- package/src/ui/styled/LeadMagnet.tsx +93 -0
- package/src/ui/styled/Loading.tsx +56 -0
- package/src/ui/styled/LoginForm.tsx +134 -0
- package/src/ui/styled/Markdown.tsx +103 -0
- package/src/ui/styled/MembershipCheckout.tsx +197 -0
- package/src/ui/styled/MembershipTiers.tsx +115 -0
- package/src/ui/styled/OfferButton.tsx +181 -0
- package/src/ui/styled/PageActions.tsx +124 -0
- package/src/ui/styled/PaymentLinkConfirmation.tsx +50 -0
- package/src/ui/styled/PaymentLinkPayment.tsx +85 -0
- package/src/ui/styled/Paywall.tsx +54 -0
- package/src/ui/styled/PlayButton.tsx +48 -0
- package/src/ui/styled/PodcastEpisode.tsx +137 -0
- package/src/ui/styled/PodcastList.tsx +58 -0
- package/src/ui/styled/PodcastShow.tsx +164 -0
- package/src/ui/styled/PostsFeed.tsx +63 -0
- package/src/ui/styled/ProductDetail.tsx +616 -0
- package/src/ui/styled/ProductGrid.tsx +82 -0
- package/src/ui/styled/PushOptIn.tsx +65 -0
- package/src/ui/styled/PwaRegistration.tsx +58 -0
- package/src/ui/styled/ReactionBar.tsx +93 -0
- package/src/ui/styled/ReplayList.tsx +134 -0
- package/src/ui/styled/ResetPasswordForm.tsx +127 -0
- package/src/ui/styled/ReviewForm.tsx +292 -0
- package/src/ui/styled/ReviewsSection.tsx +260 -0
- package/src/ui/styled/SectionedFormRenderer.tsx +146 -0
- package/src/ui/styled/SignupForm.tsx +109 -0
- package/src/ui/styled/UserMenu.tsx +166 -0
- package/src/ui/styled/chat/ChatAttachments.tsx +172 -0
- package/src/ui/styled/chat/ChatComposer.tsx +153 -0
- package/src/ui/styled/chat/ChatThreadPanel.tsx +94 -0
- package/src/ui/styled/chat/MessageBubble.tsx +66 -0
- package/src/ui/styled/chat/NewDmDialog.tsx +78 -0
- package/src/ui/styled/community/CommunityComposer.tsx +358 -0
- package/src/ui/styled/community/CommunityFeed.tsx +483 -0
- package/src/ui/styled/community/CommunityLeaderboard.tsx +109 -0
- package/src/ui/styled/community/CommunityMediaGallery.tsx +116 -0
- package/src/ui/styled/community/CommunityMemberProfile.tsx +285 -0
- package/src/ui/styled/community/CommunityNotifications.tsx +222 -0
- package/src/ui/styled/community/CommunityPostCard.tsx +165 -0
- package/src/ui/styled/community/CommunityPostDetail.tsx +337 -0
- package/src/ui/styled/community/CommunityReactionBar.tsx +107 -0
- package/src/ui/styled/community/CommunityRichText.tsx +157 -0
- package/src/ui/styled/community/CommunitySpaces.tsx +174 -0
- package/src/ui/styled/community/MemberAvatar.tsx +74 -0
- package/src/ui/styled/community/index.ts +23 -0
- package/src/ui/styled/community/util.ts +15 -0
- package/src/ui/styled/members/MemberHomeLayout.tsx +98 -0
- package/src/ui/styled/members/MemberPostCard.tsx +392 -0
- package/src/ui/styled/members/MemberPostsFeed.tsx +219 -0
- package/src/ui/styled/members/PostCollectionDetail.tsx +227 -0
- package/src/ui/styled/members/PostCollectionsGrid.tsx +287 -0
- package/src/ui/styled/members/index.ts +7 -0
- package/src/ui/styled/rich-text.css +150 -0
- package/src/ui/styled/work/WorkInviteAccept.tsx +222 -0
- package/src/ui/styled/work/WorkPortalProjects.tsx +163 -0
- package/src/ui/styled/work/WorkProjectInvoices.tsx +113 -0
- package/src/ui/styled/work/WorkProjectReport.tsx +66 -0
- package/src/ui/styled/work/WorkReportBody.tsx +327 -0
- package/src/ui/styled/work/WorkTaskAttachments.tsx +67 -0
- package/src/ui/styled/work/WorkTaskComments.tsx +151 -0
- package/src/ui/styled/work/WorkTaskDetail.tsx +92 -0
- package/src/ui/styled/work/WorkTokenReport.tsx +49 -0
- package/src/ui/styled/work/index.ts +10 -0
- package/src/ui/theme/ForgeThemeProvider.tsx +121 -0
- package/src/ui/theme/contrast.ts +22 -0
- package/src/utils/analyticsBus.ts +31 -0
- package/src/utils/attribution.ts +68 -0
- package/src/utils/cookieConsent.ts +83 -0
- package/src/utils/landing.ts +153 -0
- package/src/utils/metaPixel.ts +122 -0
- 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
|
+
};
|