@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.
- package/package.json +2 -1
- package/src/_tests/testCommunity.tsx +28 -0
- package/src/contexts/CartContext.tsx +34 -1
- package/src/contexts/CommunityContext.tsx +192 -0
- package/src/contexts/PublicAuthContext.tsx +15 -3
- package/src/data/queries/_tests/metaSignals.spec.tsx +130 -0
- package/src/data/queries/useAccount.ts +33 -2
- package/src/data/queries/useAnalytics.ts +6 -6
- package/src/data/queries/useCallChat.ts +2 -2
- package/src/data/queries/useChat.ts +52 -40
- package/src/data/queries/useCheckouts.ts +2 -1
- package/src/data/queries/useCoachingAvailability.ts +10 -1
- package/src/data/queries/useCommunities.ts +345 -0
- package/src/data/queries/useCommunity.ts +450 -102
- package/src/data/queries/useCommunityAgent.ts +247 -0
- package/src/data/queries/useCommunityCalendar.ts +150 -0
- package/src/data/queries/useCommunityClassroom.ts +75 -0
- package/src/data/queries/useCommunityMembers.ts +108 -0
- package/src/data/queries/useCommunityModeration.ts +327 -0
- package/src/data/queries/useCommunityWiki.ts +137 -0
- package/src/data/queries/useCourses.ts +2 -0
- package/src/data/queries/useEmailSignIn.ts +79 -0
- package/src/data/queries/useEvents.ts +6 -1
- package/src/data/queries/useForms.ts +5 -1
- package/src/data/queries/useLeadMagnet.ts +6 -2
- package/src/data/queries/useMembership.ts +32 -7
- package/src/data/queries/useMembershipCalls.ts +116 -7
- package/src/data/queries/useMyBookings.ts +24 -1
- package/src/data/queries/useMyTickets.ts +24 -1
- package/src/data/queries/useNotifications.ts +6 -2
- package/src/data/queries/useOrders.ts +2 -1
- package/src/data/queries/usePWA.ts +111 -35
- package/src/data/queries/usePostCollections.ts +28 -16
- package/src/data/queries/usePosts.ts +16 -14
- package/src/data/queries/useSiteApp.ts +130 -0
- package/src/data/queries/useSubscriptions.ts +28 -9
- package/src/i18n/de.json +891 -2
- package/src/i18n/en.json +891 -2
- package/src/index.ts +36 -7
- package/src/runtime/pages.spec.ts +36 -0
- package/src/runtime/pages.ts +52 -27
- package/src/server/_tests/buildWebManifest.spec.ts +1 -1
- package/src/server/_tests/forgeServiceWorkerScript.spec.ts +4 -4
- package/src/types/models.ts +11 -0
- package/src/ui/analytics/ForgeAnalytics.tsx +64 -34
- package/src/ui/analytics/PageMetaPixel.tsx +23 -8
- package/src/ui/analytics/_tests/ForgeAnalytics.meta.spec.tsx +129 -0
- package/src/ui/analytics/_tests/metaCommerceEvents.spec.tsx +159 -0
- package/src/ui/analytics/useMetaEvents.ts +78 -0
- package/src/ui/format/_tests/membershipGate.spec.ts +6 -6
- package/src/ui/format/membershipGate.ts +19 -15
- package/src/ui/headless/_tests/communityAgent.spec.tsx +519 -0
- package/src/ui/headless/_tests/communityCalendar.spec.tsx +195 -0
- package/src/ui/headless/_tests/communityClassroom.spec.tsx +120 -0
- package/src/ui/headless/_tests/communityGate.spec.tsx +80 -0
- package/src/ui/headless/_tests/communityHarness.tsx +212 -0
- package/src/ui/headless/_tests/communityMembers.spec.tsx +316 -0
- package/src/ui/headless/_tests/communityPoll.spec.tsx +389 -0
- package/src/ui/headless/_tests/communityProvider.spec.tsx +151 -0
- package/src/ui/headless/_tests/memberHomeBar.spec.ts +84 -0
- package/src/ui/headless/_tests/siteAppBar.spec.ts +83 -0
- package/src/ui/headless/_tests/siteAppHarness.ts +19 -0
- package/src/ui/headless/_tests/siteAppHome.spec.ts +226 -0
- package/src/ui/headless/_tests/siteAppPrompts.spec.ts +185 -0
- package/src/ui/headless/_tests/useCommunityInvite.spec.tsx +104 -0
- package/src/ui/headless/_tests/useCommunityJoin.spec.tsx +596 -0
- package/src/ui/headless/_tests/useCommunityMembership.spec.tsx +179 -0
- package/src/ui/headless/_tests/useCommunityModeration.spec.tsx +420 -0
- package/src/ui/headless/_tests/useCommunityReads.spec.tsx +295 -0
- package/src/ui/headless/_tests/useCommunityUpgrade.spec.tsx +157 -0
- package/src/ui/headless/_tests/useCommunityWiki.spec.tsx +224 -0
- package/src/ui/headless/_tests/useSiteApp.spec.tsx +52 -0
- package/src/ui/headless/_tests/wikiMediaImage.spec.tsx +132 -0
- package/src/ui/headless/auth/useEmailCodeSignIn.ts +186 -0
- package/src/ui/headless/auth/useSignupForm.ts +30 -3
- package/src/ui/headless/chat/useChatAttachmentUpload.ts +9 -6
- package/src/ui/headless/chat/useChatRoom.ts +35 -1
- package/src/ui/headless/communities/CommunityGate.tsx +67 -0
- package/src/ui/headless/communities/index.ts +96 -0
- package/src/ui/headless/communities/paths.ts +36 -0
- package/src/ui/headless/communities/useCommunity.ts +140 -0
- package/src/ui/headless/communities/useCommunityAbout.ts +71 -0
- package/src/ui/headless/communities/useCommunityCalendar.ts +156 -0
- package/src/ui/headless/communities/useCommunityClassroom.ts +84 -0
- package/src/ui/headless/communities/useCommunityHome.ts +91 -0
- package/src/ui/headless/communities/useCommunityInvite.ts +116 -0
- package/src/ui/headless/communities/useCommunityJoin.ts +494 -0
- package/src/ui/headless/communities/useCommunityMembership.ts +182 -0
- package/src/ui/headless/communities/useCommunityUpgrade.ts +213 -0
- package/src/ui/headless/communities/useCommunityWiki.ts +266 -0
- package/src/ui/headless/communities/useMyMemberships.ts +59 -0
- package/src/ui/headless/community/communityPoll.ts +297 -0
- package/src/ui/headless/community/countries.ts +123 -0
- package/src/ui/headless/community/index.ts +81 -1
- package/src/ui/headless/community/linkPreview.ts +71 -0
- package/src/ui/headless/community/richLexical.ts +27 -2
- package/src/ui/headless/community/useCommunityAgent.ts +285 -0
- package/src/ui/headless/community/useCommunityAttachmentUpload.ts +7 -3
- package/src/ui/headless/community/useCommunityMembers.ts +175 -0
- package/src/ui/headless/community/useCommunityModeration.ts +300 -0
- package/src/ui/headless/community/useCommunitySpaceFeed.ts +22 -4
- package/src/ui/headless/community/useCommunityThread.ts +16 -3
- package/src/ui/headless/community/useMentionComposer.ts +17 -9
- package/src/ui/headless/community/useRichMentionComposer.ts +21 -5
- package/src/ui/headless/downloads/index.ts +8 -0
- package/src/ui/headless/downloads/useDownloadPage.ts +134 -0
- package/src/ui/headless/index.ts +21 -4
- package/src/ui/headless/memberHome/index.ts +11 -1
- package/src/ui/headless/memberHome/memberHomeBar.ts +91 -0
- package/src/ui/headless/memberHome/useMemberHomeNav.ts +84 -44
- package/src/ui/headless/membership/_tests/membershipCheckoutRefetch.spec.tsx +8 -0
- package/src/ui/headless/membership/_tests/useMembershipCheckoutApplication.spec.tsx +5 -4
- package/src/ui/headless/membership/_tests/useMembershipCheckoutQuestionnaire.spec.tsx +1 -1
- package/src/ui/headless/membership/_tests/useMembershipCheckoutTrial.spec.tsx +1 -1
- package/src/ui/headless/membership/useMembershipCheckout.ts +89 -22
- package/src/ui/headless/membership/useMembershipGateNotice.ts +25 -7
- package/src/ui/headless/portal/index.ts +57 -0
- package/src/ui/headless/portal/usePortal.ts +274 -0
- package/src/ui/headless/portal/usePortalFiles.ts +68 -0
- package/src/ui/headless/portal/usePortalMessages.ts +189 -0
- package/src/ui/headless/portal/usePortalPreview.ts +142 -0
- package/src/ui/headless/posts/upsell.ts +54 -8
- package/src/ui/headless/posts/useMemberPostsFeed.ts +6 -5
- package/src/ui/headless/posts/usePostCollectionDetail.ts +7 -4
- package/src/ui/headless/posts/usePostCollectionsList.ts +6 -5
- package/src/ui/headless/posts/usePostItem.ts +6 -5
- package/src/ui/headless/siteApp/appBar.ts +102 -0
- package/src/ui/headless/siteApp/appHome.ts +344 -0
- package/src/ui/headless/siteApp/appPrompts.ts +229 -0
- package/src/ui/headless/siteApp/index.ts +58 -0
- package/src/ui/headless/siteApp/useAppHome.ts +210 -0
- package/src/ui/headless/siteApp/useAppInstallRequest.ts +16 -0
- package/src/ui/headless/work/index.ts +2 -0
- package/src/ui/headless/work/useWorkPortal.ts +37 -26
- package/src/ui/index.ts +28 -12
- package/src/ui/styled/AccountDashboard.tsx +754 -602
- package/src/ui/styled/AiAgentWidget.tsx +13 -1
- package/src/ui/styled/ChatRoom.tsx +126 -16
- package/src/ui/styled/Checkout.tsx +8 -0
- package/src/ui/styled/CheckoutConfirmation.tsx +8 -0
- package/src/ui/styled/CoachingConfirmation.tsx +8 -0
- package/src/ui/styled/CoachingDetail.tsx +2 -0
- package/src/ui/styled/CourseConfirmation.tsx +5 -0
- package/src/ui/styled/CourseDetail.tsx +2 -0
- package/src/ui/styled/DownloadPage.tsx +263 -0
- package/src/ui/styled/EventConfirmation.tsx +10 -0
- package/src/ui/styled/EventDetail.tsx +10 -0
- package/src/ui/styled/FilmLibrary.tsx +22 -9
- package/src/ui/styled/MembershipCheckout.tsx +37 -17
- package/src/ui/styled/MembershipTierCallout.tsx +20 -1
- package/src/ui/styled/MembershipTiers.tsx +49 -11
- package/src/ui/styled/MusicLinkPage.tsx +5 -5
- package/src/ui/styled/Paywall.tsx +1 -1
- package/src/ui/styled/ProductDetail.tsx +12 -0
- package/src/ui/styled/PwaRegistration.tsx +5 -1
- package/src/ui/styled/SignupForm.tsx +18 -3
- package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +6 -6
- package/src/ui/styled/_tests/AccountDashboardMemberships.spec.tsx +151 -0
- package/src/ui/styled/_tests/AccountDashboardRentals.spec.tsx +1 -1
- package/src/ui/styled/_tests/AccountNotifications.spec.tsx +135 -0
- package/src/ui/styled/_tests/CourseCallsSection.spec.tsx +7 -1
- package/src/ui/styled/_tests/DownloadPage.spec.tsx +68 -0
- package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +54 -11
- package/src/ui/styled/_tests/membershipTierCallout.spec.tsx +1 -1
- package/src/ui/styled/_tests/membershipTiersCuratedAccess.spec.tsx +1 -1
- package/src/ui/styled/app/AppBar.tsx +298 -0
- package/src/ui/styled/app/AppHome.tsx +1139 -0
- package/src/ui/styled/app/AppHomeMark.tsx +66 -0
- package/src/ui/styled/app/AppPrompts.tsx +464 -0
- package/src/ui/styled/app/_tests/AppBar.spec.tsx +96 -0
- package/src/ui/styled/app/_tests/AppHome.spec.tsx +209 -0
- package/src/ui/styled/app/_tests/AppPrompts.spec.tsx +232 -0
- package/src/ui/styled/app/index.ts +7 -0
- package/src/ui/styled/chat/ChatThreadPanel.tsx +29 -4
- package/src/ui/styled/chat/MessageBubble.tsx +43 -6
- package/src/ui/styled/communities/CommunitiesList.tsx +88 -0
- package/src/ui/styled/communities/CommunityAbout.tsx +145 -0
- package/src/ui/styled/communities/CommunityCalendar.tsx +484 -0
- package/src/ui/styled/communities/CommunityClassroom.tsx +302 -0
- package/src/ui/styled/communities/CommunityHome.tsx +298 -0
- package/src/ui/styled/communities/CommunityJoinSheet.tsx +544 -0
- package/src/ui/styled/communities/CommunityMembershipMenu.tsx +249 -0
- package/src/ui/styled/communities/CommunityPending.tsx +49 -0
- package/src/ui/styled/communities/CommunityPublicWiki.tsx +75 -0
- package/src/ui/styled/communities/CommunityUpgradeSheet.tsx +137 -0
- package/src/ui/styled/communities/CommunityWiki.tsx +289 -0
- package/src/ui/styled/communities/MyMembershipsList.tsx +115 -0
- package/src/ui/styled/communities/_tests/CommunityAppHome.spec.tsx +92 -0
- package/src/ui/styled/communities/_tests/CommunityClassroomCalendar.spec.tsx +453 -0
- package/src/ui/styled/communities/_tests/CommunityMobile.spec.tsx +399 -0
- package/src/ui/styled/communities/_tests/CommunityPages.spec.tsx +102 -0
- package/src/ui/styled/communities/_tests/CommunityWiki.spec.tsx +223 -0
- package/src/ui/styled/communities/index.ts +19 -0
- package/src/ui/styled/communities/shared.tsx +177 -0
- package/src/ui/styled/community/CommunityAgent.tsx +286 -0
- package/src/ui/styled/community/CommunityComposer.tsx +266 -15
- package/src/ui/styled/community/CommunityFeed.tsx +50 -1
- package/src/ui/styled/community/CommunityLinkPreview.tsx +159 -0
- package/src/ui/styled/community/CommunityMemberGlobe.tsx +171 -0
- package/src/ui/styled/community/CommunityMemberProfile.tsx +137 -4
- package/src/ui/styled/community/CommunityMembers.tsx +387 -0
- package/src/ui/styled/community/CommunityModeration.tsx +587 -0
- package/src/ui/styled/community/CommunityModerationState.tsx +127 -0
- package/src/ui/styled/community/CommunityModeratorMenu.tsx +317 -0
- package/src/ui/styled/community/CommunityNotifications.tsx +52 -6
- package/src/ui/styled/community/CommunityPollCard.tsx +250 -0
- package/src/ui/styled/community/CommunityPostCard.tsx +110 -34
- package/src/ui/styled/community/CommunityPostDetail.tsx +157 -71
- package/src/ui/styled/community/CommunityRichText.tsx +177 -32
- package/src/ui/styled/community/CommunitySpaces.tsx +49 -14
- package/src/ui/styled/community/_tests/CommunityAgent.spec.tsx +571 -0
- package/src/ui/styled/community/_tests/CommunityComposer.spec.tsx +4 -1
- package/src/ui/styled/community/_tests/CommunityLinkPreview.spec.tsx +167 -0
- package/src/ui/styled/community/_tests/CommunityMembers.spec.tsx +137 -0
- package/src/ui/styled/community/_tests/CommunityModeration.spec.tsx +291 -0
- package/src/ui/styled/community/_tests/CommunityPoll.spec.tsx +386 -0
- package/src/ui/styled/community/_tests/CommunityPostDetail.spec.tsx +4 -1
- package/src/ui/styled/community/index.ts +37 -2
- package/src/ui/styled/forge-utilities.css +253 -0
- package/src/ui/styled/members/CallRecordingActions.tsx +182 -0
- package/src/ui/styled/members/CallTranscriptViewer.tsx +424 -0
- package/src/ui/styled/members/MemberCallsCalendar.tsx +41 -54
- package/src/ui/styled/members/MemberHomeLayout.tsx +327 -29
- package/src/ui/styled/members/MemberPostCard.tsx +9 -5
- package/src/ui/styled/members/MemberPostsFeed.tsx +3 -2
- package/src/ui/styled/members/PostCollectionDetail.tsx +18 -10
- package/src/ui/styled/members/PostCollectionsGrid.tsx +24 -10
- package/src/ui/styled/members/_tests/MemberCallsCalendar.spec.tsx +167 -8
- package/src/ui/styled/members/callTranscriptText.ts +67 -0
- package/src/ui/styled/members/index.ts +8 -0
- package/src/ui/styled/portal/PortalAccountSection.tsx +61 -0
- package/src/ui/styled/portal/PortalActivity.tsx +167 -0
- package/src/ui/styled/portal/PortalBilling.tsx +197 -0
- package/src/ui/styled/portal/PortalFiles.tsx +279 -0
- package/src/ui/styled/portal/PortalHome.tsx +401 -0
- package/src/ui/styled/portal/PortalMessages.tsx +300 -0
- package/src/ui/styled/portal/PortalPreviewBanner.tsx +75 -0
- package/src/ui/styled/portal/PortalProjects.tsx +407 -0
- package/src/ui/styled/portal/PortalShell.tsx +397 -0
- package/src/ui/styled/portal/_tests/PortalAppHome.spec.tsx +90 -0
- package/src/ui/styled/portal/_tests/PortalFiles.spec.tsx +159 -0
- package/src/ui/styled/portal/_tests/PortalHome.spec.tsx +148 -0
- package/src/ui/styled/portal/_tests/PortalMessages.spec.tsx +168 -0
- package/src/ui/styled/portal/_tests/PortalPreviewBanner.spec.tsx +132 -0
- package/src/ui/styled/portal/_tests/portalNav.spec.ts +123 -0
- package/src/ui/styled/portal/index.ts +13 -0
- package/src/ui/styled/portal/portalNav.ts +260 -0
- package/src/ui/styled/portal/shared.tsx +206 -0
- package/src/ui/styled/work/WorkInviteAccept.tsx +228 -168
- package/src/ui/styled/work/WorkPortalProjects.tsx +68 -120
- package/src/ui/styled/work/WorkProjectDocuments.tsx +99 -128
- package/src/ui/styled/work/WorkProjectInvoices.tsx +91 -86
- package/src/ui/styled/work/WorkProjectReport.tsx +11 -17
- package/src/ui/styled/work/WorkReportBody.tsx +223 -248
- package/src/ui/styled/work/WorkTaskAttachments.tsx +23 -29
- package/src/ui/styled/work/WorkTaskComments.tsx +75 -102
- package/src/ui/styled/work/WorkTaskDetail.tsx +48 -59
- package/src/ui/styled/work/WorkTokenReport.tsx +8 -16
- package/src/ui/styled/work/index.ts +1 -1
- package/src/utils/_tests/metaEvents.spec.ts +264 -0
- package/src/utils/_tests/safeRedirect.spec.ts +2 -2
- package/src/utils/landing.ts +19 -6
- package/src/utils/metaEvents.ts +302 -0
- package/src/ui/headless/membership/MembershipGate.tsx +0 -44
- package/src/ui/styled/_tests/AccountDashboardCommunity.spec.tsx +0 -149
- package/src/ui/styled/_tests/AccountDashboardTrial.spec.tsx +0 -105
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
3
|
+
import {
|
|
4
|
+
firePixelOnly,
|
|
5
|
+
fireServerMetaEvent,
|
|
6
|
+
readMetaSignals,
|
|
7
|
+
resetMetaPolicy,
|
|
8
|
+
sendPageMetaEvent,
|
|
9
|
+
setMetaPolicy,
|
|
10
|
+
trackMeta,
|
|
11
|
+
trackPurchase,
|
|
12
|
+
type MetaPolicy,
|
|
13
|
+
} from "../metaEvents";
|
|
14
|
+
import { saveCookieConsent, GRANT_ALL, DENY_ALL } from "../cookieConsent";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The browser half of Meta conversion tracking. Every event is a PAIR (pixel
|
|
18
|
+
* + server copy) under one id, so Meta counts it once; and nothing about the
|
|
19
|
+
* visitor's Meta identity (`fbp` / `fbc`) is read or sent without marketing
|
|
20
|
+
* consent.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const POLICY: MetaPolicy = {
|
|
24
|
+
pixelId: "123456",
|
|
25
|
+
enabled: true,
|
|
26
|
+
requireConsent: true,
|
|
27
|
+
apiUrl: "https://api.test/",
|
|
28
|
+
subdomain: "artist",
|
|
29
|
+
currency: "eur",
|
|
30
|
+
resolved: true,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
let fbq: ReturnType<typeof vi.fn>;
|
|
34
|
+
let fetchSpy: ReturnType<typeof vi.fn>;
|
|
35
|
+
|
|
36
|
+
function clearCookies() {
|
|
37
|
+
for (const name of ["_fbp", "_fbc"]) document.cookie = `${name}=; path=/; max-age=0`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function postedBodies(): Record<string, unknown>[] {
|
|
41
|
+
return fetchSpy.mock.calls.map((call) => JSON.parse((call[1] as RequestInit).body as string));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function pixelCalls(): unknown[][] {
|
|
45
|
+
return fbq.mock.calls.filter((c) => c[0] === "trackSingle");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
beforeEach(() => {
|
|
49
|
+
resetMetaPolicy();
|
|
50
|
+
window.localStorage.clear();
|
|
51
|
+
window.sessionStorage.clear();
|
|
52
|
+
clearCookies();
|
|
53
|
+
fbq = vi.fn();
|
|
54
|
+
(window as unknown as { fbq: unknown }).fbq = fbq;
|
|
55
|
+
fetchSpy = vi.fn().mockResolvedValue({ ok: true });
|
|
56
|
+
vi.stubGlobal("fetch", fetchSpy);
|
|
57
|
+
window.history.replaceState({}, "", "/shop");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
afterEach(() => {
|
|
61
|
+
vi.unstubAllGlobals();
|
|
62
|
+
resetMetaPolicy();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe("readMetaSignals", () => {
|
|
66
|
+
it("sends the _fbc cookie when it exists", () => {
|
|
67
|
+
saveCookieConsent(GRANT_ALL);
|
|
68
|
+
setMetaPolicy(POLICY);
|
|
69
|
+
document.cookie = "_fbp=fb.1.111.222; path=/";
|
|
70
|
+
document.cookie = "_fbc=fb.1.333.cookieclick; path=/";
|
|
71
|
+
window.localStorage.setItem(
|
|
72
|
+
"tn:landing",
|
|
73
|
+
JSON.stringify({ landingUtm: { fbclid: "landingclick" }, referrer: "", landingUrl: "x", at: Date.now() }),
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
expect(readMetaSignals()).toEqual({
|
|
77
|
+
pageUrl: "http://localhost:3000/shop",
|
|
78
|
+
marketingConsent: true,
|
|
79
|
+
fbp: "fb.1.111.222",
|
|
80
|
+
fbc: "fb.1.333.cookieclick",
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("builds fbc from the landing fbclid and its capture time when the cookie is missing", () => {
|
|
85
|
+
saveCookieConsent(GRANT_ALL);
|
|
86
|
+
setMetaPolicy(POLICY);
|
|
87
|
+
const at = Date.now() - 60_000;
|
|
88
|
+
window.localStorage.setItem(
|
|
89
|
+
"tn:landing",
|
|
90
|
+
JSON.stringify({ landingUtm: { fbclid: "AbC123" }, referrer: "", landingUrl: "x", at }),
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
expect(readMetaSignals().fbc).toBe(`fb.1.${at}.AbC123`);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("sends no fbp or fbc when marketing was declined, and says so", () => {
|
|
97
|
+
saveCookieConsent(DENY_ALL);
|
|
98
|
+
setMetaPolicy(POLICY);
|
|
99
|
+
document.cookie = "_fbp=fb.1.111.222; path=/";
|
|
100
|
+
document.cookie = "_fbc=fb.1.333.cookieclick; path=/";
|
|
101
|
+
window.localStorage.setItem(
|
|
102
|
+
"tn:landing",
|
|
103
|
+
JSON.stringify({ landingUtm: { fbclid: "AbC123" }, referrer: "", landingUrl: "x", at: Date.now() }),
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
expect(readMetaSignals()).toEqual({ pageUrl: "http://localhost:3000/shop", marketingConsent: false });
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("leaves marketingConsent out while the visitor has not decided", () => {
|
|
110
|
+
setMetaPolicy(POLICY);
|
|
111
|
+
document.cookie = "_fbp=fb.1.111.222; path=/";
|
|
112
|
+
expect(readMetaSignals()).toEqual({ pageUrl: "http://localhost:3000/shop" });
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("is true without a decision when the site does not require consent", () => {
|
|
116
|
+
setMetaPolicy({ ...POLICY, requireConsent: false });
|
|
117
|
+
document.cookie = "_fbp=fb.1.111.222; path=/";
|
|
118
|
+
expect(readMetaSignals()).toMatchObject({ marketingConsent: true, fbp: "fb.1.111.222" });
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe("trackMeta", () => {
|
|
123
|
+
it("fires the pixel and posts the server copy under the SAME event id", () => {
|
|
124
|
+
saveCookieConsent(GRANT_ALL);
|
|
125
|
+
setMetaPolicy(POLICY);
|
|
126
|
+
document.cookie = "_fbp=fb.1.111.222; path=/";
|
|
127
|
+
|
|
128
|
+
const id = trackMeta("AddToCart", { content_ids: ["p1"], value: 12.5 });
|
|
129
|
+
|
|
130
|
+
expect(pixelCalls()).toEqual([
|
|
131
|
+
["trackSingle", "123456", "AddToCart", { content_ids: ["p1"], value: 12.5, currency: "EUR" }, { eventID: id }],
|
|
132
|
+
]);
|
|
133
|
+
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
134
|
+
expect(fetchSpy.mock.calls[0][0]).toBe("https://api.test/public/websites/meta-event");
|
|
135
|
+
expect((fetchSpy.mock.calls[0][1] as RequestInit).keepalive).toBe(true);
|
|
136
|
+
expect(postedBodies()[0]).toEqual({
|
|
137
|
+
subdomain: "artist",
|
|
138
|
+
pixel: "profile",
|
|
139
|
+
eventName: "AddToCart",
|
|
140
|
+
eventId: id,
|
|
141
|
+
eventSourceUrl: "http://localhost:3000/shop",
|
|
142
|
+
customData: { content_ids: ["p1"], value: 12.5, currency: "EUR" },
|
|
143
|
+
metaSignals: { pageUrl: "http://localhost:3000/shop", marketingConsent: true, fbp: "fb.1.111.222" },
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("uses the event id it is given", () => {
|
|
148
|
+
saveCookieConsent(GRANT_ALL);
|
|
149
|
+
setMetaPolicy(POLICY);
|
|
150
|
+
expect(trackMeta("PageView", undefined, { eventId: "pv-1" })).toBe("pv-1");
|
|
151
|
+
expect(postedBodies()[0]).toMatchObject({ eventId: "pv-1", eventName: "PageView" });
|
|
152
|
+
expect(postedBodies()[0].customData).toBeUndefined();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("does nothing without marketing consent", () => {
|
|
156
|
+
saveCookieConsent(DENY_ALL);
|
|
157
|
+
setMetaPolicy(POLICY);
|
|
158
|
+
trackMeta("ViewContent", { content_ids: ["p1"] });
|
|
159
|
+
expect(pixelCalls()).toEqual([]);
|
|
160
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("does nothing while the visitor has not decided", () => {
|
|
164
|
+
setMetaPolicy(POLICY);
|
|
165
|
+
trackMeta("ViewContent", { content_ids: ["p1"] });
|
|
166
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("does nothing when the profile has no pixel", () => {
|
|
170
|
+
saveCookieConsent(GRANT_ALL);
|
|
171
|
+
setMetaPolicy({ ...POLICY, pixelId: undefined });
|
|
172
|
+
trackMeta("ViewContent", { content_ids: ["p1"] });
|
|
173
|
+
expect(pixelCalls()).toEqual([]);
|
|
174
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("does nothing when disabled (editor / preview)", () => {
|
|
178
|
+
saveCookieConsent(GRANT_ALL);
|
|
179
|
+
setMetaPolicy({ ...POLICY, enabled: false });
|
|
180
|
+
trackMeta("ViewContent", { content_ids: ["p1"] });
|
|
181
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("waits for the pixel id, then sends what was fired before it resolved", () => {
|
|
185
|
+
saveCookieConsent(GRANT_ALL);
|
|
186
|
+
trackMeta("ViewContent", { content_ids: ["p1"] }, { eventId: "vc-1" });
|
|
187
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
188
|
+
|
|
189
|
+
setMetaPolicy({ ...POLICY, pixelId: undefined, resolved: false });
|
|
190
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
191
|
+
|
|
192
|
+
setMetaPolicy(POLICY);
|
|
193
|
+
expect(postedBodies()).toEqual([expect.objectContaining({ eventId: "vc-1", eventName: "ViewContent" })]);
|
|
194
|
+
expect(pixelCalls()).toHaveLength(1);
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
describe("server-originated events", () => {
|
|
199
|
+
it("firePixelOnly fires the pixel and posts nothing", () => {
|
|
200
|
+
saveCookieConsent(GRANT_ALL);
|
|
201
|
+
setMetaPolicy(POLICY);
|
|
202
|
+
firePixelOnly("Lead", "sub-1", { content_name: "Contact" });
|
|
203
|
+
expect(pixelCalls()).toEqual([["trackSingle", "123456", "Lead", { content_name: "Contact" }, { eventID: "sub-1" }]]);
|
|
204
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("fireServerMetaEvent reads a response's metaEvent and ignores a response without one", () => {
|
|
208
|
+
saveCookieConsent(GRANT_ALL);
|
|
209
|
+
setMetaPolicy(POLICY);
|
|
210
|
+
fireServerMetaEvent({ id: "x" });
|
|
211
|
+
fireServerMetaEvent(undefined);
|
|
212
|
+
fireServerMetaEvent({ metaEvent: { name: "Schedule", eventId: "booking-1" } });
|
|
213
|
+
expect(pixelCalls()).toEqual([["trackSingle", "123456", "Schedule", {}, { eventID: "booking-1" }]]);
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
describe("sendPageMetaEvent", () => {
|
|
218
|
+
it("posts the page-pixel server copy with the pathname", () => {
|
|
219
|
+
setMetaPolicy(POLICY);
|
|
220
|
+
sendPageMetaEvent({ eventName: "Lead", eventId: "click-1", pathname: "/l/song", customData: { content_name: "Spotify" } });
|
|
221
|
+
expect(postedBodies()[0]).toMatchObject({
|
|
222
|
+
pixel: "page",
|
|
223
|
+
pathname: "/l/song",
|
|
224
|
+
eventName: "Lead",
|
|
225
|
+
eventId: "click-1",
|
|
226
|
+
customData: { content_name: "Spotify" },
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
describe("trackPurchase", () => {
|
|
232
|
+
beforeEach(() => {
|
|
233
|
+
saveCookieConsent(GRANT_ALL);
|
|
234
|
+
setMetaPolicy(POLICY);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("fires once per record id, with the record id as the event id", () => {
|
|
238
|
+
trackPurchase({ recordId: "order-1", value: "49.90", currency: "usd" });
|
|
239
|
+
trackPurchase({ recordId: "order-1", value: "49.90", currency: "usd" });
|
|
240
|
+
expect(pixelCalls()).toEqual([
|
|
241
|
+
["trackSingle", "123456", "Purchase", { value: 49.9, currency: "USD" }, { eventID: "order-1" }],
|
|
242
|
+
]);
|
|
243
|
+
expect(window.sessionStorage.getItem("tn:meta:purchase:order-1")).toBe("1");
|
|
244
|
+
// Purchase's server copy is the payment bridge's; the browser posts none.
|
|
245
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("falls back to the site currency", () => {
|
|
249
|
+
trackPurchase({ recordId: "booking-1", value: 20 });
|
|
250
|
+
expect(pixelCalls()[0][3]).toEqual({ value: 20, currency: "EUR" });
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("fires nothing for a free record", () => {
|
|
254
|
+
trackPurchase({ recordId: "order-2", value: 0, currency: "usd" });
|
|
255
|
+
expect(pixelCalls()).toEqual([]);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("does not mark a purchase fired when consent blocked it, so it can fire later", () => {
|
|
259
|
+
saveCookieConsent(DENY_ALL);
|
|
260
|
+
trackPurchase({ recordId: "order-3", value: 10 });
|
|
261
|
+
expect(pixelCalls()).toEqual([]);
|
|
262
|
+
expect(window.sessionStorage.getItem("tn:meta:purchase:order-3")).toBeNull();
|
|
263
|
+
});
|
|
264
|
+
});
|
|
@@ -72,11 +72,11 @@ describe("safeRedirectPath (C13)", () => {
|
|
|
72
72
|
describe("keeps legitimate in-app destinations", () => {
|
|
73
73
|
it.each([
|
|
74
74
|
"/i/account",
|
|
75
|
-
"/i/
|
|
75
|
+
"/i/communities",
|
|
76
76
|
"/i/courses/123",
|
|
77
77
|
"/i/checkout?step=2",
|
|
78
78
|
"/i/orders?status=paid&page=3",
|
|
79
|
-
"/i/
|
|
79
|
+
"/i/communities#section",
|
|
80
80
|
"/",
|
|
81
81
|
// A path that merely CONTAINS a scheme-looking segment is still a path.
|
|
82
82
|
"/i/redirect?to=https%3A%2F%2Fexample.com",
|
package/src/utils/landing.ts
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
//
|
|
3
3
|
// The FIRST time a visitor lands on the artist's site we snapshot the full
|
|
4
4
|
// query-param bag (every `utm_*`, ad click-id like `fbclid`/`gclid`/`ttclid`,
|
|
5
|
-
// and any other param
|
|
5
|
+
// and any other param, verbatim) plus `document.referrer` and the landing
|
|
6
6
|
// URL. Checkout reads it back and sends `landingUtm` + `referrer` with every
|
|
7
|
-
// purchase, so later attribution/ROAS can tie a sale to the ad that drove it
|
|
7
|
+
// purchase, so later attribution/ROAS can tie a sale to the ad that drove it,
|
|
8
8
|
// regardless of whether the ad was created on TribeNest or elsewhere.
|
|
9
9
|
//
|
|
10
10
|
// First-touch wins with a 30-day TTL: the very first landing is preserved for
|
|
@@ -23,7 +23,7 @@ export type LandingSnapshot = {
|
|
|
23
23
|
landingUrl: string;
|
|
24
24
|
/** Capture timestamp (ms) for the TTL check. */
|
|
25
25
|
at: number;
|
|
26
|
-
/** Stable per-visitor session id
|
|
26
|
+
/** Stable per-visitor session id; dedupes repeat beacons server-side. */
|
|
27
27
|
sessionId?: string;
|
|
28
28
|
};
|
|
29
29
|
|
|
@@ -70,7 +70,7 @@ function readSnapshot(): LandingSnapshot | null {
|
|
|
70
70
|
|
|
71
71
|
/**
|
|
72
72
|
* Snapshots the current URL's params + referrer into localStorage on the
|
|
73
|
-
* FIRST landing only (idempotent
|
|
73
|
+
* FIRST landing only (idempotent, safe to call on every render). Does NOT
|
|
74
74
|
* strip the params from the URL: the page's own ad pixels / analytics may
|
|
75
75
|
* still need them. Returns true when a fresh snapshot was written.
|
|
76
76
|
*/
|
|
@@ -97,7 +97,7 @@ export function captureLandingFromUrl(): boolean {
|
|
|
97
97
|
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot));
|
|
98
98
|
return true;
|
|
99
99
|
} catch {
|
|
100
|
-
// Quota / disabled storage
|
|
100
|
+
// Quota / disabled storage: quietly drop. The purchase still succeeds,
|
|
101
101
|
// only the landing attribution is lost.
|
|
102
102
|
return false;
|
|
103
103
|
}
|
|
@@ -121,7 +121,7 @@ export function readLanding(): { landingUtm?: Record<string, string>; referrer?:
|
|
|
121
121
|
/**
|
|
122
122
|
* Fire-and-forget POST of the stored first-touch snapshot to the visit-level
|
|
123
123
|
* landing beacon. Records a landing_session server-side even when the visitor
|
|
124
|
-
* never buys
|
|
124
|
+
* never buys, powering per-campaign visit counts + external click-id matching.
|
|
125
125
|
* Call ONCE on fresh capture (when captureLandingFromUrl returned true), so a
|
|
126
126
|
* visit is counted a single time within the 30-day window.
|
|
127
127
|
*/
|
|
@@ -151,3 +151,16 @@ export function postLandingBeacon(apiUrl: string, profileId: string, attribution
|
|
|
151
151
|
// Beacon is best-effort; never disrupt the page.
|
|
152
152
|
}
|
|
153
153
|
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* One click id from the stored first-touch landing, with the moment it was
|
|
157
|
+
* captured (ms). Meta's `fbc` is `fb.1.<capture ms>.<fbclid>`, so the time is
|
|
158
|
+
* part of the value, not a detail. Null when nothing is stored, it expired, or
|
|
159
|
+
* the first landing carried no such param.
|
|
160
|
+
*/
|
|
161
|
+
export function readLandingClickId(param: string): { value: string; at: number } | null {
|
|
162
|
+
const snapshot = readSnapshot();
|
|
163
|
+
const value = snapshot?.landingUtm[param];
|
|
164
|
+
if (!snapshot || !value) return null;
|
|
165
|
+
return { value, at: snapshot.at };
|
|
166
|
+
}
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
// Meta conversion events from the browser: one call fires the profile pixel
|
|
2
|
+
// AND posts the server (CAPI) copy, both carrying the same event id, so Meta
|
|
3
|
+
// counts the pair once. Nothing here can send one half without the other.
|
|
4
|
+
//
|
|
5
|
+
// The policy (which pixel, whether Meta may run, where the API is) is set by
|
|
6
|
+
// `<ForgeAnalytics/>` and read synchronously, so non-React code (the cart, a
|
|
7
|
+
// mutation) can fire an event without threading props or context through.
|
|
8
|
+
//
|
|
9
|
+
// Consent is read LIVE from the consent store at send time. Without marketing
|
|
10
|
+
// consent on a site that requires it, nothing is fired, posted, built or read:
|
|
11
|
+
// no pixel call, no server copy, no `fbp` / `fbc`.
|
|
12
|
+
|
|
13
|
+
import { newEventId } from "../data/queries/useAnalytics";
|
|
14
|
+
import { getCookieConsent } from "./cookieConsent";
|
|
15
|
+
import { readLandingClickId } from "./landing";
|
|
16
|
+
import { fireMetaEventSingle, initMetaPixelScoped, readMetaCookies } from "./metaPixel";
|
|
17
|
+
|
|
18
|
+
/** The browser signals every visitor-record create request carries (`metaSignals`). */
|
|
19
|
+
export type MetaSignals = {
|
|
20
|
+
fbp?: string;
|
|
21
|
+
fbc?: string;
|
|
22
|
+
pageUrl?: string;
|
|
23
|
+
/** True: allowed (or the site does not require consent). False: declined. Absent: undecided. */
|
|
24
|
+
marketingConsent?: boolean;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** What a create response carries when the server queued a Meta event for that record. */
|
|
28
|
+
export type ServerMetaEvent = { name: string; eventId: string; customData?: Record<string, unknown> };
|
|
29
|
+
|
|
30
|
+
export type MetaPolicy = {
|
|
31
|
+
/** The profile pixel id, if the profile has one. */
|
|
32
|
+
pixelId?: string;
|
|
33
|
+
/** False in the editor / preview: nothing is sent. */
|
|
34
|
+
enabled: boolean;
|
|
35
|
+
/** Whether marketing consent is required before Meta may run. */
|
|
36
|
+
requireConsent: boolean;
|
|
37
|
+
apiUrl?: string;
|
|
38
|
+
subdomain?: string;
|
|
39
|
+
/** The tenant's settlement currency, used when an event has a value but no currency. */
|
|
40
|
+
currency?: string;
|
|
41
|
+
/**
|
|
42
|
+
* False while the pixel id is still loading. Events fired before then wait
|
|
43
|
+
* (the detail page often mounts before the site config resolves).
|
|
44
|
+
*/
|
|
45
|
+
resolved: boolean;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export type MetaCustomData = {
|
|
49
|
+
value?: number;
|
|
50
|
+
currency?: string;
|
|
51
|
+
content_ids?: string[];
|
|
52
|
+
content_name?: string;
|
|
53
|
+
content_type?: "product" | "product_group";
|
|
54
|
+
content_category?: string;
|
|
55
|
+
num_items?: number;
|
|
56
|
+
search_string?: string;
|
|
57
|
+
[key: string]: unknown;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
let policy: MetaPolicy | null = null;
|
|
61
|
+
const MAX_PENDING = 25;
|
|
62
|
+
let pending: (() => void)[] = [];
|
|
63
|
+
|
|
64
|
+
function isReady(): boolean {
|
|
65
|
+
return !!policy && (policy.resolved || !policy.enabled);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Run now if the policy is known, else once it is (bounded queue). */
|
|
69
|
+
function whenReady(fn: () => void): void {
|
|
70
|
+
if (isReady()) {
|
|
71
|
+
fn();
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (pending.length < MAX_PENDING) pending.push(fn);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Set by `<ForgeAnalytics/>`. Flushes events that were waiting for it. */
|
|
78
|
+
export function setMetaPolicy(next: MetaPolicy): void {
|
|
79
|
+
policy = next;
|
|
80
|
+
if (!isReady() || pending.length === 0) return;
|
|
81
|
+
const queued = pending;
|
|
82
|
+
pending = [];
|
|
83
|
+
for (const fn of queued) {
|
|
84
|
+
try {
|
|
85
|
+
fn();
|
|
86
|
+
} catch {
|
|
87
|
+
// A tracking failure never breaks the page.
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function getMetaPolicy(): MetaPolicy | null {
|
|
93
|
+
return policy;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Test helper: forget the policy and anything queued. */
|
|
97
|
+
export function resetMetaPolicy(): void {
|
|
98
|
+
policy = null;
|
|
99
|
+
pending = [];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The consent half of the Meta gate: true when the site does not require
|
|
104
|
+
* consent or the visitor allowed marketing, false when they declined,
|
|
105
|
+
* undefined while they have not decided.
|
|
106
|
+
*/
|
|
107
|
+
export function readMarketingConsent(): boolean | undefined {
|
|
108
|
+
if (policy && !policy.requireConsent) return true;
|
|
109
|
+
const consent = getCookieConsent();
|
|
110
|
+
if (!consent) return undefined;
|
|
111
|
+
return consent.marketing;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Whether a profile-pixel event may fire right now. */
|
|
115
|
+
function profileAllowed(p: MetaPolicy | null): p is MetaPolicy & { pixelId: string } {
|
|
116
|
+
return !!p && p.enabled && !!p.pixelId && readMarketingConsent() === true;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function buildFbc(): string | undefined {
|
|
120
|
+
const landing = readLandingClickId("fbclid");
|
|
121
|
+
if (landing) return `fb.1.${landing.at}.${landing.value}`;
|
|
122
|
+
// The landing snapshot is written after the page's own effects run, so the
|
|
123
|
+
// very first page can still hold the click id only in its URL.
|
|
124
|
+
try {
|
|
125
|
+
const fbclid = new URL(window.location.href).searchParams.get("fbclid");
|
|
126
|
+
if (fbclid && fbclid.length <= 512) return `fb.1.${Date.now()}.${fbclid}`;
|
|
127
|
+
} catch {
|
|
128
|
+
// ignore
|
|
129
|
+
}
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The `metaSignals` body field for a create request. `fbp` / `fbc` are read
|
|
135
|
+
* (or built) only when marketing is allowed; otherwise they are never touched.
|
|
136
|
+
*/
|
|
137
|
+
export function readMetaSignals(): MetaSignals {
|
|
138
|
+
if (typeof window === "undefined") return {};
|
|
139
|
+
const marketingConsent = readMarketingConsent();
|
|
140
|
+
const signals: MetaSignals = { pageUrl: window.location.href };
|
|
141
|
+
if (marketingConsent !== undefined) signals.marketingConsent = marketingConsent;
|
|
142
|
+
if (marketingConsent === true) {
|
|
143
|
+
const { fbp, fbc } = readMetaCookies();
|
|
144
|
+
if (fbp) signals.fbp = fbp;
|
|
145
|
+
const builtFbc = fbc || buildFbc();
|
|
146
|
+
if (builtFbc) signals.fbc = builtFbc;
|
|
147
|
+
}
|
|
148
|
+
return signals;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Drop empty fields, keep `value` only when it is a finite number, default the currency. */
|
|
152
|
+
function normaliseCustomData(data: MetaCustomData | undefined, fallbackCurrency?: string): MetaCustomData | undefined {
|
|
153
|
+
if (!data) return undefined;
|
|
154
|
+
const out: MetaCustomData = {};
|
|
155
|
+
for (const [key, val] of Object.entries(data)) {
|
|
156
|
+
if (val === undefined || val === null || val === "") continue;
|
|
157
|
+
out[key] = val;
|
|
158
|
+
}
|
|
159
|
+
if ("value" in out) {
|
|
160
|
+
const n = Number(out.value);
|
|
161
|
+
if (Number.isFinite(n)) out.value = n;
|
|
162
|
+
else delete out.value;
|
|
163
|
+
}
|
|
164
|
+
if (typeof out.currency === "string") out.currency = out.currency.toUpperCase();
|
|
165
|
+
else if (out.value !== undefined && fallbackCurrency) out.currency = fallbackCurrency.toUpperCase();
|
|
166
|
+
if (Array.isArray(out.content_ids)) out.content_ids = out.content_ids.map(String).slice(0, 50);
|
|
167
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
type ServerCopy = {
|
|
171
|
+
pixel: "profile" | "page";
|
|
172
|
+
pathname?: string;
|
|
173
|
+
eventName: string;
|
|
174
|
+
eventId: string;
|
|
175
|
+
eventSourceUrl: string;
|
|
176
|
+
customData?: MetaCustomData;
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
/** POST the server copy. keepalive, like `trackBeacon`, so a navigation does not cancel it. */
|
|
180
|
+
function postServerCopy(p: MetaPolicy, copy: ServerCopy): void {
|
|
181
|
+
if (!p.apiUrl || !p.subdomain || typeof fetch === "undefined") return;
|
|
182
|
+
try {
|
|
183
|
+
void fetch(`${p.apiUrl.replace(/\/$/, "")}/public/websites/meta-event`, {
|
|
184
|
+
method: "POST",
|
|
185
|
+
headers: { "Content-Type": "application/json" },
|
|
186
|
+
keepalive: true,
|
|
187
|
+
body: JSON.stringify({ subdomain: p.subdomain, ...copy, metaSignals: readMetaSignals() }),
|
|
188
|
+
}).catch(() => {});
|
|
189
|
+
} catch {
|
|
190
|
+
// Best effort; never disrupt the page.
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function currentHref(): string {
|
|
195
|
+
return typeof window === "undefined" ? "" : window.location.href;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Fire a Meta event to the profile pixel AND post its server copy, with one
|
|
200
|
+
* event id. A no-op unless the profile has a pixel, Meta is enabled and
|
|
201
|
+
* marketing is allowed. Returns the event id.
|
|
202
|
+
*/
|
|
203
|
+
export function trackMeta(eventName: string, customData?: MetaCustomData, opts?: { eventId?: string }): string {
|
|
204
|
+
const eventId = opts?.eventId ?? newEventId();
|
|
205
|
+
if (typeof window === "undefined") return eventId;
|
|
206
|
+
const eventSourceUrl = currentHref();
|
|
207
|
+
whenReady(() => {
|
|
208
|
+
const p = policy;
|
|
209
|
+
if (!profileAllowed(p)) return;
|
|
210
|
+
const data = normaliseCustomData(customData, p.currency);
|
|
211
|
+
initMetaPixelScoped(p.pixelId);
|
|
212
|
+
fireMetaEventSingle(p.pixelId, eventName, data ?? {}, eventId);
|
|
213
|
+
postServerCopy(p, { pixel: "profile", eventName, eventId, eventSourceUrl, customData: data });
|
|
214
|
+
});
|
|
215
|
+
return eventId;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* The server copy of an event fired to a PAGE (campaign) pixel. The caller
|
|
220
|
+
* fires the pixel itself and decides consent for it (`<PageMetaPixel/>`).
|
|
221
|
+
*/
|
|
222
|
+
export function sendPageMetaEvent(input: {
|
|
223
|
+
eventName: string;
|
|
224
|
+
eventId: string;
|
|
225
|
+
pathname: string;
|
|
226
|
+
customData?: MetaCustomData;
|
|
227
|
+
}): void {
|
|
228
|
+
if (typeof window === "undefined") return;
|
|
229
|
+
const eventSourceUrl = currentHref();
|
|
230
|
+
whenReady(() => {
|
|
231
|
+
const p = policy;
|
|
232
|
+
if (!p || !p.enabled) return;
|
|
233
|
+
postServerCopy(p, {
|
|
234
|
+
pixel: "page",
|
|
235
|
+
pathname: input.pathname,
|
|
236
|
+
eventName: input.eventName,
|
|
237
|
+
eventId: input.eventId,
|
|
238
|
+
eventSourceUrl,
|
|
239
|
+
customData: normaliseCustomData(input.customData, p.currency),
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* The pixel copy of an event the SERVER already queued (a response's
|
|
246
|
+
* `metaEvent`). No server copy: it exists.
|
|
247
|
+
*/
|
|
248
|
+
export function firePixelOnly(eventName: string, eventId: string, customData?: MetaCustomData): void {
|
|
249
|
+
if (typeof window === "undefined" || !eventName || !eventId) return;
|
|
250
|
+
whenReady(() => {
|
|
251
|
+
const p = policy;
|
|
252
|
+
if (!profileAllowed(p)) return;
|
|
253
|
+
initMetaPixelScoped(p.pixelId);
|
|
254
|
+
fireMetaEventSingle(p.pixelId, eventName, normaliseCustomData(customData, p.currency) ?? {}, eventId);
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Fire the pixel copy of a response's `metaEvent`, if it carries one. */
|
|
259
|
+
export function fireServerMetaEvent(response: unknown): void {
|
|
260
|
+
const metaEvent = (response as { metaEvent?: ServerMetaEvent } | null | undefined)?.metaEvent;
|
|
261
|
+
if (!metaEvent || typeof metaEvent.name !== "string" || typeof metaEvent.eventId !== "string") return;
|
|
262
|
+
firePixelOnly(metaEvent.name, metaEvent.eventId, metaEvent.customData as MetaCustomData | undefined);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const PURCHASE_KEY_PREFIX = "tn:meta:purchase:";
|
|
266
|
+
|
|
267
|
+
function purchaseAlreadyFired(eventId: string): boolean {
|
|
268
|
+
try {
|
|
269
|
+
return window.sessionStorage.getItem(PURCHASE_KEY_PREFIX + eventId) === "1";
|
|
270
|
+
} catch {
|
|
271
|
+
return false;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function markPurchaseFired(eventId: string): void {
|
|
276
|
+
try {
|
|
277
|
+
window.sessionStorage.setItem(PURCHASE_KEY_PREFIX + eventId, "1");
|
|
278
|
+
} catch {
|
|
279
|
+
// Storage disabled: a reload may fire it again, and Meta dedupes on the id.
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* The browser copy of a Purchase the server bridge sends on payment. The id is
|
|
285
|
+
* the record id the server used, so the two dedupe. Pixel only (the server
|
|
286
|
+
* copy is the bridge's), at most once per tab per record.
|
|
287
|
+
*/
|
|
288
|
+
export function trackPurchase(input: { recordId: string; value: number | string | null | undefined; currency?: string | null }): void {
|
|
289
|
+
if (typeof window === "undefined" || !input.recordId) return;
|
|
290
|
+
const value = Number(input.value);
|
|
291
|
+
if (!Number.isFinite(value) || value <= 0) return;
|
|
292
|
+
const eventId = input.recordId;
|
|
293
|
+
if (purchaseAlreadyFired(eventId)) return;
|
|
294
|
+
whenReady(() => {
|
|
295
|
+
const p = policy;
|
|
296
|
+
if (!profileAllowed(p) || purchaseAlreadyFired(eventId)) return;
|
|
297
|
+
const currency = (input.currency || p.currency || "").toUpperCase() || undefined;
|
|
298
|
+
initMetaPixelScoped(p.pixelId);
|
|
299
|
+
fireMetaEventSingle(p.pixelId, "Purchase", currency ? { value, currency } : { value }, eventId);
|
|
300
|
+
markPurchaseFired(eventId);
|
|
301
|
+
});
|
|
302
|
+
}
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
import type { ReactNode } from "react";
|
|
2
|
-
import { usePublicAuth } from "../../../contexts/PublicAuthContext";
|
|
3
|
-
import { getMembershipAccess } from "../../../utils/membershipAccess";
|
|
4
|
-
|
|
5
|
-
export interface MembershipGateState {
|
|
6
|
-
hasAccess: boolean;
|
|
7
|
-
isAuthenticated: boolean;
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
export interface MembershipGateProps {
|
|
11
|
-
/**
|
|
12
|
-
* Tier ids that unlock the content. Omitted/empty → any active membership
|
|
13
|
-
* unlocks. (For per-item gates where the server already returns `hasAccess`,
|
|
14
|
-
* pass it via `access`.)
|
|
15
|
-
*/
|
|
16
|
-
tiers?: string[];
|
|
17
|
-
/** Override the access decision (e.g. server-provided `post.hasAccess`). */
|
|
18
|
-
access?: boolean;
|
|
19
|
-
children: ReactNode;
|
|
20
|
-
/** Rendered when locked — static node or a render-fn given the gate state. */
|
|
21
|
-
fallback?: ReactNode | ((state: MembershipGateState) => ReactNode);
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* Headless access gate. Renders children when the member has access, otherwise
|
|
26
|
-
* the fallback. Behavior (who has access) is fixed by the server/membership;
|
|
27
|
-
* the locked UI is entirely yours.
|
|
28
|
-
*/
|
|
29
|
-
export function MembershipGate({ tiers, access, children, fallback }: MembershipGateProps) {
|
|
30
|
-
const { user, isAuthenticated } = usePublicAuth();
|
|
31
|
-
const membership = user?.membership;
|
|
32
|
-
// The SERVER's access decision, not a `status === "active"` compare. That
|
|
33
|
-
// compare locked out members in `past_due`/`grace` — states the backend
|
|
34
|
-
// deliberately keeps access through — so a card that bounced cost them the
|
|
35
|
-
// content they were still paying for.
|
|
36
|
-
const membershipGrantsAccess = getMembershipAccess(membership).hasAccess;
|
|
37
|
-
const tierAllows = !tiers?.length || (membership ? tiers.includes(membership.membershipTierId) : false);
|
|
38
|
-
|
|
39
|
-
const hasAccess = access ?? (membershipGrantsAccess && tierAllows);
|
|
40
|
-
|
|
41
|
-
if (hasAccess) return <>{children}</>;
|
|
42
|
-
if (typeof fallback === "function") return <>{fallback({ hasAccess, isAuthenticated })}</>;
|
|
43
|
-
return <>{fallback ?? null}</>;
|
|
44
|
-
}
|