@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
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { useEffect, useRef } from "react";
|
|
2
2
|
import { useTrackEvent, newEventId } from "../../data/queries/useAnalytics";
|
|
3
3
|
import { useSiteConfig } from "../../data/queries/useWebsite";
|
|
4
|
-
import { initMetaPixelScoped
|
|
4
|
+
import { initMetaPixelScoped } from "../../utils/metaPixel";
|
|
5
|
+
import { setMetaPolicy, trackMeta } from "../../utils/metaEvents";
|
|
5
6
|
import { setCurrentPageEventId, isProfilePixelSuppressed } from "../../utils/analyticsBus";
|
|
6
7
|
import { useCookieConsent } from "../headless/consent/useCookieConsent";
|
|
7
8
|
import { createEngagementTracker, scrollDepthOf, type EngagementTracker } from "../../utils/engagementTracker";
|
|
9
|
+
import { onCLS, onINP, onLCP, type Metric } from "web-vitals";
|
|
8
10
|
|
|
9
11
|
export interface ForgeAnalyticsProps {
|
|
10
12
|
/**
|
|
@@ -17,7 +19,7 @@ export interface ForgeAnalyticsProps {
|
|
|
17
19
|
metaPixelId?: string;
|
|
18
20
|
/** Auto-track clicks on links/buttons (and any `[data-track]` element). Default `true`. */
|
|
19
21
|
trackClicks?: boolean;
|
|
20
|
-
/** Master switch
|
|
22
|
+
/** Master switch: set `false` (e.g. in the editor/preview) to record nothing. Default `true`. */
|
|
21
23
|
enabled?: boolean;
|
|
22
24
|
/**
|
|
23
25
|
* Honour the visitor's cookie choice (from `<CookieConsent />`). Default
|
|
@@ -43,7 +45,7 @@ export interface ForgeAnalyticsProps {
|
|
|
43
45
|
}
|
|
44
46
|
|
|
45
47
|
/**
|
|
46
|
-
* The full resolved path the visitor is on (e.g. `/i/store/abc-123`)
|
|
48
|
+
* The full resolved path the visitor is on (e.g. `/i/store/abc-123`), never a
|
|
47
49
|
* route template like `store/:id`. This is the value the admin analytics "top
|
|
48
50
|
* pages" report groups on (`event_data->>'pathname'`), so a code site's real
|
|
49
51
|
* routes show up per-page instead of collapsed into a parameterised template.
|
|
@@ -53,12 +55,67 @@ function currentPathname(): string {
|
|
|
53
55
|
return window.location.pathname;
|
|
54
56
|
}
|
|
55
57
|
|
|
56
|
-
/** Full route including query
|
|
58
|
+
/** Full route including query, kept alongside `pathname` for detail. */
|
|
57
59
|
function currentPage(): string {
|
|
58
60
|
if (typeof window === "undefined") return "";
|
|
59
61
|
return window.location.pathname + window.location.search;
|
|
60
62
|
}
|
|
61
63
|
|
|
64
|
+
/**
|
|
65
|
+
* Web Vitals (LCP, INP, CLS), for the SEO tab's page speed report.
|
|
66
|
+
*
|
|
67
|
+
* `web-vitals` callbacks cannot be unregistered, so they are registered once
|
|
68
|
+
* per page load, the first time first-party analytics is allowed, and report
|
|
69
|
+
* through whichever `<ForgeAnalytics />` is mounted now (`vitalsSink`). With
|
|
70
|
+
* none mounted, or analytics switched off, a report is dropped. Observers are
|
|
71
|
+
* buffered, so a visitor who consents after the page painted still reports its
|
|
72
|
+
* LCP.
|
|
73
|
+
*
|
|
74
|
+
* Each metric instance is sent once (its first report): a page view, or a
|
|
75
|
+
* restore from the back/forward cache, which `web-vitals` treats as a new one.
|
|
76
|
+
* The path is the page the metric belongs to: the URL `web-vitals` names for
|
|
77
|
+
* it, else the page the document loaded on, since an SPA navigation does not
|
|
78
|
+
* start a new measurement.
|
|
79
|
+
*/
|
|
80
|
+
type VitalReport = { name: Metric["name"]; value: number; rating: Metric["rating"]; path: string };
|
|
81
|
+
let vitalsSink: ((report: VitalReport) => void) | null = null;
|
|
82
|
+
let vitalsRegistered = false;
|
|
83
|
+
const vitalsSent = new Set<string>();
|
|
84
|
+
|
|
85
|
+
function pathOfMetric(metric: Metric, loadPath: string): string {
|
|
86
|
+
try {
|
|
87
|
+
if (metric.navigationURL) return new URL(metric.navigationURL, window.location.href).pathname;
|
|
88
|
+
} catch {
|
|
89
|
+
// fall through to the page the document loaded on
|
|
90
|
+
}
|
|
91
|
+
return loadPath;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function registerWebVitals(): void {
|
|
95
|
+
if (vitalsRegistered || typeof window === "undefined") return;
|
|
96
|
+
vitalsRegistered = true;
|
|
97
|
+
const loadPath = currentPathname();
|
|
98
|
+
const report = (metric: Metric) => {
|
|
99
|
+
if (vitalsSent.has(metric.id) || !vitalsSink) return;
|
|
100
|
+
vitalsSent.add(metric.id);
|
|
101
|
+
vitalsSink({ name: metric.name, value: metric.value, rating: metric.rating, path: pathOfMetric(metric, loadPath) });
|
|
102
|
+
};
|
|
103
|
+
try {
|
|
104
|
+
onLCP(report);
|
|
105
|
+
onINP(report);
|
|
106
|
+
onCLS(report);
|
|
107
|
+
} catch {
|
|
108
|
+
// An unsupported browser measures nothing; never break the page for it.
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** For tests: forget the registration and what was sent, as a fresh page load would. */
|
|
113
|
+
export function __resetWebVitalsForTests(): void {
|
|
114
|
+
vitalsSink = null;
|
|
115
|
+
vitalsRegistered = false;
|
|
116
|
+
vitalsSent.clear();
|
|
117
|
+
}
|
|
118
|
+
|
|
62
119
|
/** Engaged means the visitor can see the page and it has focus. */
|
|
63
120
|
function isEngaged(): boolean {
|
|
64
121
|
return typeof document !== "undefined" && document.visibilityState === "visible" && document.hasFocus();
|
|
@@ -73,12 +130,14 @@ function isEngaged(): boolean {
|
|
|
73
130
|
* - Auto click tracking on links/buttons (opt out with `trackClicks={false}`).
|
|
74
131
|
* - Time on page and scroll depth, as `engagement` events (engaged time only:
|
|
75
132
|
* visible and focused).
|
|
133
|
+
* - Page speed: LCP, INP and CLS (Web Vitals), as one `web_vital` event per
|
|
134
|
+
* metric per page view, for the SEO tab.
|
|
76
135
|
* - Optional Meta Pixel: pass `metaPixelId` to init the pixel and mirror each
|
|
77
136
|
* page view as a Meta `PageView` (event-id matched for CAPI de-dup).
|
|
78
137
|
*
|
|
79
138
|
* Consent-aware: with `requireConsent` (default), it reads the shared cookie
|
|
80
|
-
* consent
|
|
81
|
-
* for `marketing`
|
|
139
|
+
* consent (first-party analytics wait for `performance`, the Meta Pixel waits
|
|
140
|
+
* for `marketing`), so pairing it with `<CookieConsent />` is GDPR-correct out
|
|
82
141
|
* of the box. Router-agnostic: SPA navigations are detected via the History API,
|
|
83
142
|
* so it works under any router (or none). It renders nothing.
|
|
84
143
|
*
|
|
@@ -93,18 +152,28 @@ export function ForgeAnalytics({
|
|
|
93
152
|
}: ForgeAnalyticsProps) {
|
|
94
153
|
const cookieless = firstPartyMode === "cookieless";
|
|
95
154
|
// Resolve the pixel from the tenant's runtime settings when not explicitly
|
|
96
|
-
// passed (so changing it needs no rebuild). An explicit prop
|
|
97
|
-
// to disable
|
|
98
|
-
const
|
|
155
|
+
// passed (so changing it needs no rebuild). An explicit prop (including `""`
|
|
156
|
+
// to disable) always wins.
|
|
157
|
+
const siteConfigQuery = useSiteConfig();
|
|
158
|
+
const siteConfig = siteConfigQuery.data;
|
|
99
159
|
const pixelId = metaPixelId ?? siteConfig?.metaPixelId ?? undefined;
|
|
160
|
+
// The pixel is known once it was passed, or the site config has answered.
|
|
161
|
+
// Until then Meta events fired elsewhere (a detail page mounting first) wait.
|
|
162
|
+
const pixelResolved = metaPixelId !== undefined || !!siteConfig || !siteConfigQuery.isLoading;
|
|
100
163
|
const { performance: perfConsent, marketing: mktConsent } = useCookieConsent();
|
|
101
164
|
// Session storage follows performance consent. In `consent` mode first-party
|
|
102
165
|
// only runs with that consent, so it always stores; in `cookieless` mode it
|
|
103
166
|
// runs regardless but only stores a device session once the visitor turns
|
|
104
|
-
// Performance on
|
|
167
|
+
// Performance on; otherwise it stays cookieless (backend derives the id).
|
|
105
168
|
const storeSession = cookieless ? perfConsent : true;
|
|
106
|
-
const { track, trackBeacon } = useTrackEvent({ storeSession });
|
|
107
|
-
|
|
169
|
+
const { track, trackBeacon, apiUrl, subdomain } = useTrackEvent({ storeSession });
|
|
170
|
+
// The page the current event id belongs to, and the last page each half
|
|
171
|
+
// recorded. Tracked separately because consent (and the pixel id) arrive
|
|
172
|
+
// AFTER mount: with one shared "last page", the first-party view consumed the
|
|
173
|
+
// page and the Meta PageView for it never fired once consent loaded.
|
|
174
|
+
const pageView = useRef<{ page: string; eventId: string } | null>(null);
|
|
175
|
+
const lastFirstPartyPage = useRef<string | null>(null);
|
|
176
|
+
const lastMetaPage = useRef<string | null>(null);
|
|
108
177
|
// Time on page. Pings go by beacon: most are sent as the page is hidden or
|
|
109
178
|
// unloaded, which cancels an ordinary request.
|
|
110
179
|
const beaconRef = useRef(trackBeacon);
|
|
@@ -118,13 +187,29 @@ export function ForgeAnalytics({
|
|
|
118
187
|
}
|
|
119
188
|
|
|
120
189
|
// Consent gates. The Meta Pixel always needs `marketing`. First-party analytics
|
|
121
|
-
// needs `performance
|
|
190
|
+
// needs `performance`, UNLESS it's cookieless (stores nothing without consent),
|
|
122
191
|
// in which case it's treated as essential and runs anyway. Flipping a gate to
|
|
123
192
|
// `true` (visitor accepts) re-runs the effects, recording the current page.
|
|
124
193
|
const firstPartyOn = enabled && (cookieless || !requireConsent || perfConsent);
|
|
125
194
|
const metaOn = enabled && !!pixelId && (!requireConsent || mktConsent);
|
|
126
195
|
|
|
127
|
-
//
|
|
196
|
+
// Publish the Meta policy for code outside this component (cart, checkout,
|
|
197
|
+
// detail pages, create mutations). Declared before the page-view effect so
|
|
198
|
+
// the policy is current when that effect fires.
|
|
199
|
+
const currency = siteConfig?.currency;
|
|
200
|
+
useEffect(() => {
|
|
201
|
+
setMetaPolicy({
|
|
202
|
+
pixelId: pixelId || undefined,
|
|
203
|
+
enabled,
|
|
204
|
+
requireConsent,
|
|
205
|
+
apiUrl,
|
|
206
|
+
subdomain,
|
|
207
|
+
currency,
|
|
208
|
+
resolved: pixelResolved,
|
|
209
|
+
});
|
|
210
|
+
}, [pixelId, enabled, requireConsent, apiUrl, subdomain, currency, pixelResolved]);
|
|
211
|
+
|
|
212
|
+
// Init the Meta pixel once (scoped: no auto PageView; we fire it ourselves so
|
|
128
213
|
// it doesn't leak into a per-page campaign pixel).
|
|
129
214
|
useEffect(() => {
|
|
130
215
|
if (!metaOn || !pixelId) return;
|
|
@@ -136,18 +221,20 @@ export function ForgeAnalytics({
|
|
|
136
221
|
// which keeps this free of any router dependency.
|
|
137
222
|
useEffect(() => {
|
|
138
223
|
if (typeof window === "undefined") return;
|
|
139
|
-
if (!firstPartyOn && !metaOn) return; // nothing consented yet
|
|
224
|
+
if (!firstPartyOn && !metaOn) return; // nothing consented yet, wait
|
|
140
225
|
|
|
141
226
|
const sendPageView = () => {
|
|
142
227
|
const page = currentPage();
|
|
143
|
-
if (page
|
|
144
|
-
|
|
228
|
+
if (pageView.current?.page !== page) {
|
|
229
|
+
pageView.current = { page, eventId: newEventId() };
|
|
230
|
+
// Publish the id so a per-page <PageMetaPixel/> (and its server CAPI twin)
|
|
231
|
+
// can dedupe against the same page view.
|
|
232
|
+
setCurrentPageEventId(pageView.current.eventId);
|
|
233
|
+
}
|
|
234
|
+
const eventId = pageView.current.eventId;
|
|
145
235
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
// can dedupe against the same page view.
|
|
149
|
-
setCurrentPageEventId(eventId);
|
|
150
|
-
if (firstPartyOn) {
|
|
236
|
+
if (firstPartyOn && lastFirstPartyPage.current !== page) {
|
|
237
|
+
lastFirstPartyPage.current = page;
|
|
151
238
|
// Close the previous page's time before this page view is recorded, so
|
|
152
239
|
// the ping lands on the page it measured.
|
|
153
240
|
engagement.current?.flush();
|
|
@@ -162,14 +249,16 @@ export function ForgeAnalytics({
|
|
|
162
249
|
eventId,
|
|
163
250
|
});
|
|
164
251
|
}
|
|
165
|
-
// Fire the profile pixel to ITSELF (trackSingle)
|
|
166
|
-
//
|
|
167
|
-
//
|
|
168
|
-
|
|
252
|
+
// Fire the profile pixel to ITSELF (trackSingle) plus its server copy,
|
|
253
|
+
// with the page view's id, and skip both on routes a campaign pixel has
|
|
254
|
+
// taken over (PageMetaPixel override; it sends its own server copy).
|
|
255
|
+
// Deferred a tick so a page pixel mounting on this route can register its
|
|
256
|
+
// suppression first.
|
|
257
|
+
if (metaOn && pixelId && lastMetaPage.current !== page) {
|
|
258
|
+
lastMetaPage.current = page;
|
|
169
259
|
const path = currentPathname();
|
|
170
|
-
const pixel = pixelId;
|
|
171
260
|
queueMicrotask(() => {
|
|
172
|
-
if (!isProfilePixelSuppressed(path))
|
|
261
|
+
if (!isProfilePixelSuppressed(path)) trackMeta("PageView", undefined, { eventId });
|
|
173
262
|
});
|
|
174
263
|
}
|
|
175
264
|
};
|
|
@@ -232,7 +321,19 @@ export function ForgeAnalytics({
|
|
|
232
321
|
};
|
|
233
322
|
}, [firstPartyOn]);
|
|
234
323
|
|
|
235
|
-
//
|
|
324
|
+
// Web Vitals: first-party only, like the page views they belong to. Sent by
|
|
325
|
+
// beacon, because INP and CLS are reported as the page is hidden.
|
|
326
|
+
useEffect(() => {
|
|
327
|
+
if (!firstPartyOn || typeof window === "undefined") return;
|
|
328
|
+
const sink = (report: VitalReport) => beaconRef.current("web_vital", report);
|
|
329
|
+
vitalsSink = sink;
|
|
330
|
+
registerWebVitals();
|
|
331
|
+
return () => {
|
|
332
|
+
if (vitalsSink === sink) vitalsSink = null;
|
|
333
|
+
};
|
|
334
|
+
}, [firstPartyOn]);
|
|
335
|
+
|
|
336
|
+
// Click tracking: first-party only, one delegated listener for the document.
|
|
236
337
|
useEffect(() => {
|
|
237
338
|
if (!firstPartyOn || !trackClicks || typeof document === "undefined") return;
|
|
238
339
|
|
|
@@ -246,11 +347,10 @@ export function ForgeAnalytics({
|
|
|
246
347
|
//
|
|
247
348
|
// This listener cannot know that a component already sent a richer event
|
|
248
349
|
// for the same click, so without a way to stand down, anything that tracks
|
|
249
|
-
// its own clicks is counted twice
|
|
250
|
-
//
|
|
251
|
-
//
|
|
252
|
-
//
|
|
253
|
-
// `data-track-skip`.
|
|
350
|
+
// its own clicks is counted twice in first-party analytics. A click is
|
|
351
|
+
// not a Meta event (components that turn one into a conversion fire it
|
|
352
|
+
// themselves, through `trackMeta` or `usePageMetaPixel`). Any element
|
|
353
|
+
// that reports its own click must carry `data-track-skip`.
|
|
254
354
|
if (el.closest("[data-track-skip]")) return;
|
|
255
355
|
|
|
256
356
|
const anchor = el.closest("a") as HTMLAnchorElement | null;
|
|
@@ -6,6 +6,12 @@ import {
|
|
|
6
6
|
unsuppressProfilePixel,
|
|
7
7
|
} from "../../utils/analyticsBus";
|
|
8
8
|
import { useCookieConsent } from "../headless/consent/useCookieConsent";
|
|
9
|
+
import { sendPageMetaEvent, type MetaCustomData } from "../../utils/metaEvents";
|
|
10
|
+
import { newEventId } from "../../data/queries/useAnalytics";
|
|
11
|
+
|
|
12
|
+
function currentPathname(): string {
|
|
13
|
+
return typeof window === "undefined" ? "" : window.location.pathname;
|
|
14
|
+
}
|
|
9
15
|
|
|
10
16
|
// useLayoutEffect warns during SSR; fall back to useEffect on the server.
|
|
11
17
|
const useIsoLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect;
|
|
@@ -57,7 +63,11 @@ export function PageMetaPixel({
|
|
|
57
63
|
if (!allowed || !pixelId || typeof window === "undefined") return;
|
|
58
64
|
initMetaPixelScoped(pixelId);
|
|
59
65
|
if (trackPageView) {
|
|
60
|
-
|
|
66
|
+
// One id for the pixel and its server copy: the page view's own, so the
|
|
67
|
+
// pair dedupes on Meta.
|
|
68
|
+
const eventId = getCurrentPageEventId() ?? newEventId();
|
|
69
|
+
fireMetaEventSingle(pixelId, "PageView", {}, eventId);
|
|
70
|
+
sendPageMetaEvent({ eventName: "PageView", eventId, pathname: currentPathname() });
|
|
61
71
|
}
|
|
62
72
|
}, [allowed, pixelId, trackPageView]);
|
|
63
73
|
|
|
@@ -65,12 +75,14 @@ export function PageMetaPixel({
|
|
|
65
75
|
}
|
|
66
76
|
|
|
67
77
|
/**
|
|
68
|
-
* Imperative companion to `<PageMetaPixel
|
|
69
|
-
* send conversion
|
|
70
|
-
* gated on marketing consent
|
|
78
|
+
* Imperative companion to `<PageMetaPixel/>`. Returns `fire(event, data?, eventId?)`
|
|
79
|
+
* to send a conversion event (e.g. `Lead` on a CTA click) to the campaign pixel
|
|
80
|
+
* AND its server copy, gated on marketing consent. Both halves carry one id:
|
|
81
|
+
* the one passed, else a new one per call (an action is its own event, never
|
|
82
|
+
* the page view). Returns that id.
|
|
71
83
|
*
|
|
72
84
|
* const { fire } = usePageMetaPixel("123456789");
|
|
73
|
-
* <button onClick={() => fire("Lead", { content_name: "Pre-save" })}
|
|
85
|
+
* <button onClick={() => fire("Lead", { content_name: "Pre-save" })}>...</button>
|
|
74
86
|
*/
|
|
75
87
|
export function usePageMetaPixel(pixelId: string, opts?: { requireConsent?: boolean }) {
|
|
76
88
|
const requireConsent = opts?.requireConsent ?? true;
|
|
@@ -84,9 +96,12 @@ export function usePageMetaPixel(pixelId: string, opts?: { requireConsent?: bool
|
|
|
84
96
|
|
|
85
97
|
return {
|
|
86
98
|
allowed,
|
|
87
|
-
fire: (eventName: string, data: Record<string, unknown> = {}) => {
|
|
88
|
-
if (!allowed || !pixelId) return;
|
|
89
|
-
|
|
99
|
+
fire: (eventName: string, data: Record<string, unknown> = {}, eventId?: string): string | undefined => {
|
|
100
|
+
if (!allowed || !pixelId) return undefined;
|
|
101
|
+
const id = eventId ?? newEventId();
|
|
102
|
+
fireMetaEventSingle(pixelId, eventName, data, id);
|
|
103
|
+
sendPageMetaEvent({ eventName, eventId: id, pathname: currentPathname(), customData: data as MetaCustomData });
|
|
104
|
+
return id;
|
|
90
105
|
},
|
|
91
106
|
};
|
|
92
107
|
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
3
|
+
import { render, act, cleanup } from "@testing-library/react";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The page view's Meta pair: the profile pixel's PageView and its server copy
|
|
7
|
+
* (POST /public/websites/meta-event) carry the id the first-party page view
|
|
8
|
+
* already has, so Meta keeps one. Consent loads AFTER mount, which used to
|
|
9
|
+
* mean the first page never sent a Meta PageView at all.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const tracked: { eventType: string; data: Record<string, unknown> }[] = [];
|
|
13
|
+
let seq = 0;
|
|
14
|
+
|
|
15
|
+
vi.mock("../../../data/queries/useAnalytics", () => ({
|
|
16
|
+
newEventId: () => `evt-${++seq}`,
|
|
17
|
+
useTrackEvent: () => ({
|
|
18
|
+
track: (eventType: string, data: Record<string, unknown>) => tracked.push({ eventType, data }),
|
|
19
|
+
trackBeacon: () => {},
|
|
20
|
+
apiUrl: "https://api.test",
|
|
21
|
+
subdomain: "artist",
|
|
22
|
+
}),
|
|
23
|
+
}));
|
|
24
|
+
vi.mock("../../../data/queries/useWebsite", () => ({
|
|
25
|
+
useSiteConfig: () => ({ data: { metaPixelId: "999", currency: "USD" }, isLoading: false }),
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
import { ForgeAnalytics } from "../ForgeAnalytics";
|
|
29
|
+
import { resetMetaPolicy } from "../../../utils/metaEvents";
|
|
30
|
+
import { saveCookieConsent, GRANT_ALL, DENY_ALL } from "../../../utils/cookieConsent";
|
|
31
|
+
import { suppressProfilePixel, unsuppressProfilePixel } from "../../../utils/analyticsBus";
|
|
32
|
+
|
|
33
|
+
let fetchSpy: ReturnType<typeof vi.fn>;
|
|
34
|
+
let fbq: ReturnType<typeof vi.fn>;
|
|
35
|
+
|
|
36
|
+
const metaPosts = () =>
|
|
37
|
+
fetchSpy.mock.calls
|
|
38
|
+
.filter((c) => String(c[0]).endsWith("/public/websites/meta-event"))
|
|
39
|
+
.map((c) => JSON.parse((c[1] as RequestInit).body as string));
|
|
40
|
+
|
|
41
|
+
const flush = () => act(async () => {});
|
|
42
|
+
|
|
43
|
+
beforeEach(() => {
|
|
44
|
+
tracked.length = 0;
|
|
45
|
+
seq = 0;
|
|
46
|
+
resetMetaPolicy();
|
|
47
|
+
window.localStorage.clear();
|
|
48
|
+
fetchSpy = vi.fn().mockResolvedValue({ ok: true });
|
|
49
|
+
vi.stubGlobal("fetch", fetchSpy);
|
|
50
|
+
fbq = vi.fn();
|
|
51
|
+
(window as unknown as { fbq: unknown }).fbq = fbq;
|
|
52
|
+
window.history.replaceState({}, "", "/first");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
afterEach(() => {
|
|
56
|
+
cleanup();
|
|
57
|
+
vi.unstubAllGlobals();
|
|
58
|
+
resetMetaPolicy();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("<ForgeAnalytics /> Meta PageView", () => {
|
|
62
|
+
it("sends the pixel PageView and its server copy with the page view's id, once consent loads", async () => {
|
|
63
|
+
saveCookieConsent(GRANT_ALL);
|
|
64
|
+
render(<ForgeAnalytics trackClicks={false} firstPartyMode="cookieless" />);
|
|
65
|
+
await flush();
|
|
66
|
+
|
|
67
|
+
const pageView = tracked.find((t) => t.eventType === "page_view");
|
|
68
|
+
expect(pageView?.data.eventId).toBe("evt-1");
|
|
69
|
+
expect(metaPosts()).toEqual([
|
|
70
|
+
{
|
|
71
|
+
subdomain: "artist",
|
|
72
|
+
pixel: "profile",
|
|
73
|
+
eventName: "PageView",
|
|
74
|
+
eventId: "evt-1",
|
|
75
|
+
eventSourceUrl: "http://localhost:3000/first",
|
|
76
|
+
metaSignals: { pageUrl: "http://localhost:3000/first", marketingConsent: true },
|
|
77
|
+
},
|
|
78
|
+
]);
|
|
79
|
+
expect(fbq).toHaveBeenCalledWith("trackSingle", "999", "PageView", {}, { eventID: "evt-1" });
|
|
80
|
+
// One first-party page view, not a second one when consent arrived.
|
|
81
|
+
expect(tracked.filter((t) => t.eventType === "page_view")).toHaveLength(1);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("sends a fresh pair on an SPA navigation", async () => {
|
|
85
|
+
saveCookieConsent(GRANT_ALL);
|
|
86
|
+
render(<ForgeAnalytics trackClicks={false} firstPartyMode="cookieless" />);
|
|
87
|
+
await flush();
|
|
88
|
+
act(() => window.history.pushState({}, "", "/second"));
|
|
89
|
+
await flush();
|
|
90
|
+
|
|
91
|
+
expect(metaPosts().map((b) => [b.eventId, b.eventSourceUrl])).toEqual([
|
|
92
|
+
["evt-1", "http://localhost:3000/first"],
|
|
93
|
+
["evt-2", "http://localhost:3000/second"],
|
|
94
|
+
]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("sends no server copy where a campaign pixel took the page over", async () => {
|
|
98
|
+
saveCookieConsent(GRANT_ALL);
|
|
99
|
+
suppressProfilePixel("/first");
|
|
100
|
+
render(<ForgeAnalytics trackClicks={false} firstPartyMode="cookieless" />);
|
|
101
|
+
await flush();
|
|
102
|
+
unsuppressProfilePixel("/first");
|
|
103
|
+
expect(metaPosts()).toEqual([]);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("sends nothing to Meta when marketing was declined", async () => {
|
|
107
|
+
saveCookieConsent(DENY_ALL);
|
|
108
|
+
render(<ForgeAnalytics trackClicks={false} firstPartyMode="cookieless" />);
|
|
109
|
+
await flush();
|
|
110
|
+
expect(metaPosts()).toEqual([]);
|
|
111
|
+
expect(tracked.filter((t) => t.eventType === "page_view")).toHaveLength(1);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("sends a plain click first-party only", async () => {
|
|
115
|
+
saveCookieConsent(GRANT_ALL);
|
|
116
|
+
const { container } = render(
|
|
117
|
+
<>
|
|
118
|
+
<ForgeAnalytics firstPartyMode="cookieless" />
|
|
119
|
+
<button type="button">Buy</button>
|
|
120
|
+
</>,
|
|
121
|
+
);
|
|
122
|
+
await flush();
|
|
123
|
+
const before = metaPosts().length;
|
|
124
|
+
act(() => container.querySelector("button")!.click());
|
|
125
|
+
await flush();
|
|
126
|
+
expect(tracked.some((t) => t.eventType === "click")).toBe(true);
|
|
127
|
+
expect(metaPosts()).toHaveLength(before);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
3
|
+
import { render, act, cleanup } from "@testing-library/react";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Web Vitals reporting (SEO step 4). `web-vitals` is mocked: each `onX`
|
|
7
|
+
* registration is captured so a test can fire the metric the browser would.
|
|
8
|
+
* What is asserted: one `web_vital` event per metric instance, by beacon, with
|
|
9
|
+
* `{ name, value, rating, path }`; nothing at all without first-party consent;
|
|
10
|
+
* the cookieless mode still reporting; and registration happening once per
|
|
11
|
+
* page load however often the component mounts.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
type Metric = {
|
|
15
|
+
name: "LCP" | "INP" | "CLS";
|
|
16
|
+
value: number;
|
|
17
|
+
rating: "good" | "needs-improvement" | "poor";
|
|
18
|
+
id: string;
|
|
19
|
+
navigationURL?: string;
|
|
20
|
+
};
|
|
21
|
+
const handlers: Record<string, Array<(m: Metric) => void>> = { LCP: [], INP: [], CLS: [] };
|
|
22
|
+
vi.mock("web-vitals", () => ({
|
|
23
|
+
onLCP: (cb: (m: Metric) => void) => handlers.LCP.push(cb),
|
|
24
|
+
onINP: (cb: (m: Metric) => void) => handlers.INP.push(cb),
|
|
25
|
+
onCLS: (cb: (m: Metric) => void) => handlers.CLS.push(cb),
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
const calls: { via: "track" | "beacon"; eventType: string; data: Record<string, unknown> }[] = [];
|
|
29
|
+
vi.mock("../../../data/queries/useAnalytics", () => ({
|
|
30
|
+
newEventId: () => "evt",
|
|
31
|
+
useTrackEvent: () => ({
|
|
32
|
+
track: (eventType: string, data: Record<string, unknown>) => calls.push({ via: "track", eventType, data }),
|
|
33
|
+
trackBeacon: (eventType: string, data: Record<string, unknown>) => calls.push({ via: "beacon", eventType, data }),
|
|
34
|
+
}),
|
|
35
|
+
}));
|
|
36
|
+
vi.mock("../../../data/queries/useWebsite", () => ({ useSiteConfig: () => ({ data: undefined }) }));
|
|
37
|
+
const consent = { performance: true, marketing: false };
|
|
38
|
+
vi.mock("../../headless/consent/useCookieConsent", () => ({ useCookieConsent: () => consent }));
|
|
39
|
+
|
|
40
|
+
import { ForgeAnalytics, __resetWebVitalsForTests } from "../ForgeAnalytics";
|
|
41
|
+
|
|
42
|
+
const fire = (metric: Metric) => {
|
|
43
|
+
for (const cb of handlers[metric.name]) cb(metric);
|
|
44
|
+
};
|
|
45
|
+
const vitals = () => calls.filter((c) => c.eventType === "web_vital").map((c) => [c.via, c.data]);
|
|
46
|
+
|
|
47
|
+
describe("<ForgeAnalytics /> web vitals", () => {
|
|
48
|
+
beforeEach(() => {
|
|
49
|
+
calls.length = 0;
|
|
50
|
+
for (const key of Object.keys(handlers)) handlers[key].length = 0;
|
|
51
|
+
consent.performance = true;
|
|
52
|
+
__resetWebVitalsForTests();
|
|
53
|
+
window.history.replaceState({}, "", "/classes?ref=x");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
afterEach(() => {
|
|
57
|
+
cleanup();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("sends LCP, INP and CLS once each, by beacon, with the page the document loaded on", () => {
|
|
61
|
+
render(<ForgeAnalytics trackClicks={false} />);
|
|
62
|
+
expect(handlers.LCP).toHaveLength(1);
|
|
63
|
+
expect(handlers.INP).toHaveLength(1);
|
|
64
|
+
expect(handlers.CLS).toHaveLength(1);
|
|
65
|
+
|
|
66
|
+
// An SPA navigation does not start a new measurement.
|
|
67
|
+
act(() => window.history.pushState({}, "", "/about"));
|
|
68
|
+
fire({ name: "LCP", value: 1820.4, rating: "good", id: "v5-lcp-1" });
|
|
69
|
+
fire({ name: "CLS", value: 0.04, rating: "good", id: "v5-cls-1" });
|
|
70
|
+
fire({ name: "INP", value: 260, rating: "needs-improvement", id: "v5-inp-1" });
|
|
71
|
+
// A later report of the same instance (CLS grows) is not sent again.
|
|
72
|
+
fire({ name: "CLS", value: 0.3, rating: "poor", id: "v5-cls-1" });
|
|
73
|
+
|
|
74
|
+
expect(vitals()).toEqual([
|
|
75
|
+
["beacon", { name: "LCP", value: 1820.4, rating: "good", path: "/classes" }],
|
|
76
|
+
["beacon", { name: "CLS", value: 0.04, rating: "good", path: "/classes" }],
|
|
77
|
+
["beacon", { name: "INP", value: 260, rating: "needs-improvement", path: "/classes" }],
|
|
78
|
+
]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("sends a new instance (a back/forward cache restore) with the URL web-vitals names", () => {
|
|
82
|
+
render(<ForgeAnalytics trackClicks={false} />);
|
|
83
|
+
fire({ name: "LCP", value: 900, rating: "good", id: "a" });
|
|
84
|
+
fire({ name: "LCP", value: 300, rating: "good", id: "b", navigationURL: "https://site.test/events/x?y=1" });
|
|
85
|
+
expect(vitals().map(([, data]) => (data as { path: string }).path)).toEqual(["/classes", "/events/x"]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("measures nothing without performance consent, and starts once it is given", () => {
|
|
89
|
+
consent.performance = false;
|
|
90
|
+
const { rerender } = render(<ForgeAnalytics trackClicks={false} />);
|
|
91
|
+
expect(handlers.LCP).toHaveLength(0);
|
|
92
|
+
fire({ name: "LCP", value: 1000, rating: "good", id: "early" });
|
|
93
|
+
expect(vitals()).toEqual([]);
|
|
94
|
+
|
|
95
|
+
consent.performance = true;
|
|
96
|
+
rerender(<ForgeAnalytics trackClicks={false} />);
|
|
97
|
+
expect(handlers.LCP).toHaveLength(1);
|
|
98
|
+
// The observers are buffered, so the LCP that painted before consent still reports.
|
|
99
|
+
fire({ name: "LCP", value: 1000, rating: "good", id: "early" });
|
|
100
|
+
expect(vitals()).toEqual([["beacon", { name: "LCP", value: 1000, rating: "good", path: "/classes" }]]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("reports in cookieless mode without performance consent", () => {
|
|
104
|
+
consent.performance = false;
|
|
105
|
+
render(<ForgeAnalytics trackClicks={false} firstPartyMode="cookieless" />);
|
|
106
|
+
fire({ name: "INP", value: 120, rating: "good", id: "i1" });
|
|
107
|
+
expect(vitals()).toEqual([["beacon", { name: "INP", value: 120, rating: "good", path: "/classes" }]]);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("records nothing when disabled, and nothing after the component unmounts", () => {
|
|
111
|
+
const { unmount } = render(<ForgeAnalytics enabled={false} />);
|
|
112
|
+
expect(handlers.LCP).toHaveLength(0);
|
|
113
|
+
unmount();
|
|
114
|
+
|
|
115
|
+
const mounted = render(<ForgeAnalytics trackClicks={false} />);
|
|
116
|
+
mounted.unmount();
|
|
117
|
+
fire({ name: "LCP", value: 1000, rating: "good", id: "late" });
|
|
118
|
+
expect(vitals()).toEqual([]);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("registers once per page load however often it mounts", () => {
|
|
122
|
+
render(<ForgeAnalytics trackClicks={false} />);
|
|
123
|
+
cleanup();
|
|
124
|
+
render(<ForgeAnalytics trackClicks={false} />);
|
|
125
|
+
expect(handlers.LCP).toHaveLength(1);
|
|
126
|
+
fire({ name: "LCP", value: 700, rating: "good", id: "once" });
|
|
127
|
+
expect(vitals()).toHaveLength(1);
|
|
128
|
+
});
|
|
129
|
+
});
|