@tribe-nest/forge 3.60.0 → 3.63.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 +5 -2
- package/src/contexts/CartContext.tsx +34 -1
- 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 +10 -1
- package/src/data/queries/useCheckouts.ts +2 -1
- package/src/data/queries/useCoachingAvailability.ts +10 -1
- package/src/data/queries/useCommunities.ts +2 -0
- package/src/data/queries/useCommunity.ts +362 -18
- 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/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/useSiteApp.ts +130 -0
- package/src/data/queries/useSubscriptions.ts +6 -1
- package/src/i18n/de.json +746 -1
- package/src/i18n/en.json +746 -1
- package/src/index.ts +20 -0
- package/src/runtime/pages.spec.ts +5 -0
- package/src/runtime/pages.ts +32 -6
- package/src/seo/_tests/seo.spec.ts +326 -0
- package/src/seo/applySeoToHead.ts +98 -0
- package/src/seo/index.ts +9 -0
- package/src/seo/resolve.ts +211 -0
- package/src/seo/seoHead.ts +61 -0
- package/src/seo/siteSeoHead.ts +97 -0
- package/src/seo/types.ts +99 -0
- package/src/server/_tests/siteBootstrap.spec.ts +65 -0
- package/src/server/index.ts +33 -4
- package/src/types/models.ts +11 -0
- package/src/ui/analytics/ForgeAnalytics.tsx +134 -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/ForgeAnalytics.webVitals.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/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/communityHarness.tsx +10 -3
- 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/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/useCommunityJoin.spec.tsx +228 -14
- package/src/ui/headless/_tests/useCommunityMembership.spec.tsx +29 -1
- package/src/ui/headless/_tests/useCommunityModeration.spec.tsx +420 -0
- package/src/ui/headless/_tests/useCommunityReads.spec.tsx +11 -2
- 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 +20 -1
- package/src/ui/headless/chat/useChatAttachmentUpload.ts +9 -6
- package/src/ui/headless/chat/useChatRoom.ts +35 -1
- package/src/ui/headless/communities/index.ts +41 -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 +3 -0
- package/src/ui/headless/communities/useCommunityJoin.ts +73 -23
- package/src/ui/headless/communities/useCommunityMembership.ts +48 -2
- package/src/ui/headless/communities/useCommunityWiki.ts +266 -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/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 +13 -0
- package/src/ui/headless/memberHome/index.ts +10 -0
- package/src/ui/headless/memberHome/memberHomeBar.ts +91 -0
- package/src/ui/headless/memberHome/useMemberHomeNav.ts +56 -18
- 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/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 +11 -0
- package/src/ui/styled/AccountDashboard.tsx +712 -352
- 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/MusicLinkPage.tsx +5 -5
- 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 +5 -5
- package/src/ui/styled/_tests/AccountNotifications.spec.tsx +135 -0
- package/src/ui/styled/_tests/DownloadPage.spec.tsx +68 -0
- 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/CommunityAbout.tsx +6 -1
- 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 +134 -11
- package/src/ui/styled/communities/CommunityJoinSheet.tsx +119 -19
- package/src/ui/styled/communities/CommunityMembershipMenu.tsx +168 -52
- package/src/ui/styled/communities/CommunityPublicWiki.tsx +75 -0
- package/src/ui/styled/communities/CommunityWiki.tsx +289 -0
- package/src/ui/styled/communities/MyMembershipsList.tsx +31 -5
- 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/CommunityWiki.spec.tsx +223 -0
- package/src/ui/styled/communities/index.ts +9 -0
- package/src/ui/styled/communities/shared.tsx +39 -11
- 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/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/index.ts +37 -2
- package/src/ui/styled/forge-utilities.css +234 -0
- package/src/ui/styled/members/CallRecordingActions.tsx +182 -0
- package/src/ui/styled/members/MemberCallsCalendar.tsx +13 -158
- package/src/ui/styled/members/MemberHomeLayout.tsx +295 -15
- package/src/ui/styled/members/MemberPostsFeed.tsx +3 -2
- 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/landing.spec.ts +39 -0
- package/src/utils/_tests/metaEvents.spec.ts +264 -0
- package/src/utils/_tests/structuredDataSeo.spec.ts +247 -0
- package/src/utils/landing.ts +36 -10
- package/src/utils/metaEvents.ts +302 -0
- package/src/utils/structuredData.ts +339 -1
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
buildBlogPostingSchema,
|
|
4
|
+
buildBreadcrumbSchema,
|
|
5
|
+
buildEventSchema,
|
|
6
|
+
buildIdentitySchema,
|
|
7
|
+
buildPodcastEpisodeSchema,
|
|
8
|
+
buildPodcastSeriesSchema,
|
|
9
|
+
buildWebSiteSchema,
|
|
10
|
+
type EventSchemaSource,
|
|
11
|
+
} from "../structuredData";
|
|
12
|
+
|
|
13
|
+
afterEach(() => vi.useRealTimers());
|
|
14
|
+
|
|
15
|
+
const URL_ = "https://claystudio.com/i/events/late-show";
|
|
16
|
+
|
|
17
|
+
const EVENT: EventSchemaSource = {
|
|
18
|
+
title: "Late Show",
|
|
19
|
+
description: "<p>An evening of <b>clay</b>.</p>",
|
|
20
|
+
dateTime: "2026-11-01T19:00:00.000Z",
|
|
21
|
+
endDateTime: "2026-11-01T22:00:00.000Z",
|
|
22
|
+
doorsOpenAt: "2026-11-01T18:30:00.000Z",
|
|
23
|
+
status: "on_sale",
|
|
24
|
+
cancelledAt: null,
|
|
25
|
+
type: "physical",
|
|
26
|
+
address: { name: "The Hall", street: "1 Mare St", city: "London", country: "GB", zipCode: "E8 1AA" },
|
|
27
|
+
media: [{ url: "https://cdn.example/poster.jpg", type: "image" }],
|
|
28
|
+
tickets: [
|
|
29
|
+
{ title: "General", price: 15, quantity: 100, sold: 10 },
|
|
30
|
+
{ title: "VIP", price: 40, quantity: 10, sold: 10 },
|
|
31
|
+
{ title: "Hidden", price: 1, quantity: 10, sold: 0, isHidden: true },
|
|
32
|
+
{ title: "Members", price: 5, quantity: 10, sold: 0, membershipGate: { allowed: false } as never },
|
|
33
|
+
],
|
|
34
|
+
bookingFee: { currency: "GBP" },
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
describe("buildEventSchema", () => {
|
|
38
|
+
it("describes a physical event with a Place, status, mode and one offer per public tier", () => {
|
|
39
|
+
const s = buildEventSchema(EVENT, { url: URL_ })!;
|
|
40
|
+
expect(s).toMatchObject({
|
|
41
|
+
"@context": "https://schema.org",
|
|
42
|
+
"@type": "Event",
|
|
43
|
+
name: "Late Show",
|
|
44
|
+
startDate: EVENT.dateTime,
|
|
45
|
+
endDate: EVENT.endDateTime,
|
|
46
|
+
doorTime: EVENT.doorsOpenAt,
|
|
47
|
+
eventStatus: "https://schema.org/EventScheduled",
|
|
48
|
+
eventAttendanceMode: "https://schema.org/OfflineEventAttendanceMode",
|
|
49
|
+
description: "An evening of clay .",
|
|
50
|
+
image: "https://cdn.example/poster.jpg",
|
|
51
|
+
url: URL_,
|
|
52
|
+
location: {
|
|
53
|
+
"@type": "Place",
|
|
54
|
+
name: "The Hall",
|
|
55
|
+
address: {
|
|
56
|
+
"@type": "PostalAddress",
|
|
57
|
+
streetAddress: "1 Mare St",
|
|
58
|
+
addressLocality: "London",
|
|
59
|
+
postalCode: "E8 1AA",
|
|
60
|
+
addressCountry: "GB",
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
expect(s.offers).toEqual([
|
|
65
|
+
{ "@type": "Offer", name: "General", price: 15, priceCurrency: "GBP", availability: "https://schema.org/InStock", url: URL_ },
|
|
66
|
+
{ "@type": "Offer", name: "VIP", price: 40, priceCurrency: "GBP", availability: "https://schema.org/SoldOut", url: URL_ },
|
|
67
|
+
]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("gives a virtual event a VirtualLocation and a hybrid event both", () => {
|
|
71
|
+
const v = buildEventSchema({ ...EVENT, type: "virtual" }, { url: URL_ })!;
|
|
72
|
+
expect(v.eventAttendanceMode).toBe("https://schema.org/OnlineEventAttendanceMode");
|
|
73
|
+
expect(v.location).toEqual({ "@type": "VirtualLocation", url: URL_ });
|
|
74
|
+
const h = buildEventSchema({ ...EVENT, type: "hybrid" }, { url: URL_ })!;
|
|
75
|
+
expect(h.eventAttendanceMode).toBe("https://schema.org/MixedEventAttendanceMode");
|
|
76
|
+
expect((h.location as unknown[]).map((l) => (l as { "@type": string })["@type"])).toEqual(["Place", "VirtualLocation"]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("marks a cancelled event and sells nothing", () => {
|
|
80
|
+
const s = buildEventSchema({ ...EVENT, status: "cancelled" })!;
|
|
81
|
+
expect(s.eventStatus).toBe("https://schema.org/EventCancelled");
|
|
82
|
+
expect(s).not.toHaveProperty("offers");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("emits no offers without a currency, and none for a members-only event", () => {
|
|
86
|
+
expect(buildEventSchema({ ...EVENT, bookingFee: null })).not.toHaveProperty("offers");
|
|
87
|
+
expect(buildEventSchema({ ...EVENT, bookingFee: null }, { currency: "USD" })!.offers).toBeDefined();
|
|
88
|
+
expect(buildEventSchema({ ...EVENT, membershipGate: { allowed: false } })).not.toHaveProperty("offers");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("says PreOrder for a tier that is not on sale yet", () => {
|
|
92
|
+
vi.useFakeTimers();
|
|
93
|
+
vi.setSystemTime(new Date("2026-10-01T00:00:00.000Z"));
|
|
94
|
+
const s = buildEventSchema({
|
|
95
|
+
...EVENT,
|
|
96
|
+
tickets: [{ title: "Early", price: 10, quantity: 5, sold: 0, availableFrom: "2026-10-10T00:00:00.000Z" }],
|
|
97
|
+
})!;
|
|
98
|
+
expect(s.offers).toMatchObject({ availability: "https://schema.org/PreOrder", validFrom: "2026-10-10T00:00:00.000Z" });
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("names the organizer from the identity", () => {
|
|
102
|
+
const s = buildEventSchema(EVENT, { organizer: { kind: "person", name: "Ada" }, origin: "https://claystudio.com" })!;
|
|
103
|
+
expect(s.organizer).toEqual({ "@type": "Person", name: "Ada", url: "https://claystudio.com" });
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("returns null with no title or no start", () => {
|
|
107
|
+
expect(buildEventSchema({ ...EVENT, title: " " })).toBeNull();
|
|
108
|
+
expect(buildEventSchema({ ...EVENT, dateTime: "" })).toBeNull();
|
|
109
|
+
expect(buildEventSchema(null)).toBeNull();
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe("buildBlogPostingSchema", () => {
|
|
114
|
+
it("builds a BlogPosting with author and publisher from an organization identity", () => {
|
|
115
|
+
const s = buildBlogPostingSchema(
|
|
116
|
+
{
|
|
117
|
+
title: "Glazes",
|
|
118
|
+
content: "<p>How we glaze.</p>",
|
|
119
|
+
publishedAt: "2026-09-01T00:00:00.000Z",
|
|
120
|
+
media: [{ url: "https://cdn.example/g.jpg", type: "image/jpeg" }, { url: "https://cdn.example/a.mp3", type: "audio/mpeg" }],
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
url: "https://claystudio.com/i/blog/glazes",
|
|
124
|
+
identity: { kind: "organization", name: "Clay Studio", logo: "https://cdn.example/logo.png" },
|
|
125
|
+
origin: "https://claystudio.com",
|
|
126
|
+
},
|
|
127
|
+
)!;
|
|
128
|
+
const org = { "@type": "Organization", name: "Clay Studio", url: "https://claystudio.com", logo: "https://cdn.example/logo.png" };
|
|
129
|
+
expect(s).toEqual({
|
|
130
|
+
"@context": "https://schema.org",
|
|
131
|
+
"@type": "BlogPosting",
|
|
132
|
+
headline: "Glazes",
|
|
133
|
+
description: "How we glaze.",
|
|
134
|
+
datePublished: "2026-09-01T00:00:00.000Z",
|
|
135
|
+
dateModified: "2026-09-01T00:00:00.000Z",
|
|
136
|
+
image: "https://cdn.example/g.jpg",
|
|
137
|
+
url: "https://claystudio.com/i/blog/glazes",
|
|
138
|
+
mainEntityOfPage: { "@type": "WebPage", "@id": "https://claystudio.com/i/blog/glazes" },
|
|
139
|
+
author: org,
|
|
140
|
+
publisher: org,
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("uses a person as author only, and clamps a long headline", () => {
|
|
145
|
+
const s = buildBlogPostingSchema({ title: "x".repeat(200) }, { identity: { kind: "person", name: "Ada" } })!;
|
|
146
|
+
expect((s.headline as string).length).toBe(110);
|
|
147
|
+
expect(s.author).toEqual({ "@type": "Person", name: "Ada" });
|
|
148
|
+
expect(s).not.toHaveProperty("publisher");
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe("podcast schemas", () => {
|
|
153
|
+
it("builds a PodcastSeries, preferring the show's own author", () => {
|
|
154
|
+
expect(
|
|
155
|
+
buildPodcastSeriesSchema(
|
|
156
|
+
{ title: "Kiln Talk", description: "Weekly.", coverImage: { url: "https://cdn.example/c.jpg" }, author: "Ada" },
|
|
157
|
+
{ url: "https://claystudio.com/i/podcasts/kiln-talk" },
|
|
158
|
+
),
|
|
159
|
+
).toEqual({
|
|
160
|
+
"@context": "https://schema.org",
|
|
161
|
+
"@type": "PodcastSeries",
|
|
162
|
+
name: "Kiln Talk",
|
|
163
|
+
description: "Weekly.",
|
|
164
|
+
image: "https://cdn.example/c.jpg",
|
|
165
|
+
url: "https://claystudio.com/i/podcasts/kiln-talk",
|
|
166
|
+
author: { "@type": "Person", name: "Ada" },
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("builds a PodcastEpisode with its audio, duration and series", () => {
|
|
171
|
+
expect(
|
|
172
|
+
buildPodcastEpisodeSchema(
|
|
173
|
+
{
|
|
174
|
+
title: "Ep 1",
|
|
175
|
+
content: "<p>Hello</p>",
|
|
176
|
+
publishedAt: "2026-09-02T00:00:00.000Z",
|
|
177
|
+
media: [{ url: "https://cdn.example/1.mp3", type: "audio/mpeg", durationSec: 3725 }],
|
|
178
|
+
},
|
|
179
|
+
{ url: "https://claystudio.com/i/podcasts/kiln-talk/ep-1", series: { name: "Kiln Talk", url: "https://claystudio.com/i/podcasts/kiln-talk" } },
|
|
180
|
+
),
|
|
181
|
+
).toEqual({
|
|
182
|
+
"@context": "https://schema.org",
|
|
183
|
+
"@type": "PodcastEpisode",
|
|
184
|
+
name: "Ep 1",
|
|
185
|
+
description: "Hello",
|
|
186
|
+
datePublished: "2026-09-02T00:00:00.000Z",
|
|
187
|
+
url: "https://claystudio.com/i/podcasts/kiln-talk/ep-1",
|
|
188
|
+
timeRequired: "PT1H2M",
|
|
189
|
+
associatedMedia: { "@type": "MediaObject", contentUrl: "https://cdn.example/1.mp3", encodingFormat: "audio/mpeg", duration: "PT1H2M" },
|
|
190
|
+
partOfSeries: { "@type": "PodcastSeries", name: "Kiln Talk", url: "https://claystudio.com/i/podcasts/kiln-talk" },
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
describe("site schemas", () => {
|
|
196
|
+
it("builds Person, Organization and LocalBusiness identities", () => {
|
|
197
|
+
expect(buildIdentitySchema({ kind: "person", name: "Ada", logo: "https://x/a.jpg", sameAs: [] })).toEqual({
|
|
198
|
+
"@context": "https://schema.org",
|
|
199
|
+
"@type": "Person",
|
|
200
|
+
name: "Ada",
|
|
201
|
+
image: "https://x/a.jpg",
|
|
202
|
+
});
|
|
203
|
+
expect(buildIdentitySchema({ kind: "organization", name: "Clay", logo: "https://x/l.png", sameAs: ["https://x.com/clay"] }, { origin: "https://clay.com/" })).toEqual({
|
|
204
|
+
"@context": "https://schema.org",
|
|
205
|
+
"@type": "Organization",
|
|
206
|
+
"@id": "https://clay.com/#identity",
|
|
207
|
+
url: "https://clay.com",
|
|
208
|
+
name: "Clay",
|
|
209
|
+
logo: "https://x/l.png",
|
|
210
|
+
sameAs: ["https://x.com/clay"],
|
|
211
|
+
});
|
|
212
|
+
expect(buildIdentitySchema({ kind: "local_business", name: "Clay", sameAs: [], address: {}, phone: "123" })).toEqual({
|
|
213
|
+
"@context": "https://schema.org",
|
|
214
|
+
"@type": "LocalBusiness",
|
|
215
|
+
name: "Clay",
|
|
216
|
+
telephone: "123",
|
|
217
|
+
});
|
|
218
|
+
expect(buildIdentitySchema({ kind: "person", name: "", sameAs: [] })).toBeNull();
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("builds a WebSite linked to its publisher", () => {
|
|
222
|
+
expect(buildWebSiteSchema({ name: "Clay", url: "https://clay.com/", publisherId: "https://clay.com/#identity" })).toEqual({
|
|
223
|
+
"@context": "https://schema.org",
|
|
224
|
+
"@type": "WebSite",
|
|
225
|
+
name: "Clay",
|
|
226
|
+
url: "https://clay.com/",
|
|
227
|
+
publisher: { "@id": "https://clay.com/#identity" },
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("builds a BreadcrumbList, and nothing for a single crumb", () => {
|
|
232
|
+
expect(
|
|
233
|
+
buildBreadcrumbSchema([
|
|
234
|
+
{ name: "Clay", url: "https://clay.com/" },
|
|
235
|
+
{ name: "Store", url: "https://clay.com/i/store" },
|
|
236
|
+
]),
|
|
237
|
+
).toEqual({
|
|
238
|
+
"@context": "https://schema.org",
|
|
239
|
+
"@type": "BreadcrumbList",
|
|
240
|
+
itemListElement: [
|
|
241
|
+
{ "@type": "ListItem", position: 1, name: "Clay", item: "https://clay.com/" },
|
|
242
|
+
{ "@type": "ListItem", position: 2, name: "Store", item: "https://clay.com/i/store" },
|
|
243
|
+
],
|
|
244
|
+
});
|
|
245
|
+
expect(buildBreadcrumbSchema([{ name: "Clay", url: "https://clay.com/" }])).toBeNull();
|
|
246
|
+
});
|
|
247
|
+
});
|
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,31 +97,44 @@ 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
|
}
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
/** The pathname of a stored landing URL, or undefined when it cannot be read. */
|
|
107
|
+
function landingPathOf(landingUrl: string | undefined): string | undefined {
|
|
108
|
+
if (!landingUrl) return undefined;
|
|
109
|
+
try {
|
|
110
|
+
const path = new URL(landingUrl).pathname;
|
|
111
|
+
return path.startsWith("/") ? path : undefined;
|
|
112
|
+
} catch {
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
106
117
|
/**
|
|
107
|
-
* Returns the first-touch `landingUtm` bag
|
|
108
|
-
*
|
|
109
|
-
*
|
|
118
|
+
* Returns the first-touch `landingUtm` bag, `referrer` and `landingPath` (the
|
|
119
|
+
* pathname of the first page, so a sale can be credited to the blog post that
|
|
120
|
+
* brought the buyer) for the checkout payload, or null when nothing is stored /
|
|
121
|
+
* it expired. `landingUtm` is undefined when the bag is empty so we don't ship `{}`.
|
|
110
122
|
*/
|
|
111
|
-
export function readLanding(): { landingUtm?: Record<string, string>; referrer?: string } | null {
|
|
123
|
+
export function readLanding(): { landingUtm?: Record<string, string>; referrer?: string; landingPath?: string } | null {
|
|
112
124
|
const snapshot = readSnapshot();
|
|
113
125
|
if (!snapshot) return null;
|
|
114
126
|
const hasParams = Object.keys(snapshot.landingUtm).length > 0;
|
|
115
127
|
return {
|
|
116
128
|
landingUtm: hasParams ? snapshot.landingUtm : undefined,
|
|
117
129
|
referrer: snapshot.referrer || undefined,
|
|
130
|
+
landingPath: landingPathOf(snapshot.landingUrl),
|
|
118
131
|
};
|
|
119
132
|
}
|
|
120
133
|
|
|
121
134
|
/**
|
|
122
135
|
* Fire-and-forget POST of the stored first-touch snapshot to the visit-level
|
|
123
136
|
* landing beacon. Records a landing_session server-side even when the visitor
|
|
124
|
-
* never buys
|
|
137
|
+
* never buys, powering per-campaign visit counts + external click-id matching.
|
|
125
138
|
* Call ONCE on fresh capture (when captureLandingFromUrl returned true), so a
|
|
126
139
|
* visit is counted a single time within the 30-day window.
|
|
127
140
|
*/
|
|
@@ -151,3 +164,16 @@ export function postLandingBeacon(apiUrl: string, profileId: string, attribution
|
|
|
151
164
|
// Beacon is best-effort; never disrupt the page.
|
|
152
165
|
}
|
|
153
166
|
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* One click id from the stored first-touch landing, with the moment it was
|
|
170
|
+
* captured (ms). Meta's `fbc` is `fb.1.<capture ms>.<fbclid>`, so the time is
|
|
171
|
+
* part of the value, not a detail. Null when nothing is stored, it expired, or
|
|
172
|
+
* the first landing carried no such param.
|
|
173
|
+
*/
|
|
174
|
+
export function readLandingClickId(param: string): { value: string; at: number } | null {
|
|
175
|
+
const snapshot = readSnapshot();
|
|
176
|
+
const value = snapshot?.landingUtm[param];
|
|
177
|
+
if (!snapshot || !value) return null;
|
|
178
|
+
return { value, at: snapshot.at };
|
|
179
|
+
}
|