@cartbase/storefront 0.7.0 → 0.8.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/README.md +4 -5
- package/package.json +248 -243
- package/src/api/carts.ts +16 -1
- package/src/api/http.ts +17 -9
- package/src/api/index.ts +1 -0
- package/src/api/store.ts +35 -0
- package/src/checkout/geocode.ts +1 -1
- package/src/lib/cookie-names.ts +45 -0
- package/src/lib/platform.ts +13 -0
- package/src/lib/visitor.ts +74 -0
- package/src/tracking/attribution.ts +121 -105
- package/src/tracking/get-tracking-attribution.ts +16 -3
- package/src/tracking/index.ts +11 -0
- package/src/tracking/live-heartbeat.tsx +63 -0
- package/src/tracking/page-views.tsx +96 -0
- package/src/tracking/storefront-tags.tsx +75 -67
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { Suspense, useEffect, useRef } from "react"
|
|
4
|
+
import { usePathname, useSearchParams } from "next/navigation"
|
|
5
|
+
|
|
6
|
+
import { getTabSessionId, platformCollectorPresent, readVisitorId } from "../lib/visitor"
|
|
7
|
+
import { readConsentCookie } from "./consent"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* PAGEVIEWS — every route, automatically.
|
|
11
|
+
*
|
|
12
|
+
* Mounted once by <StorefrontTags>, this fires on every navigation the
|
|
13
|
+
* router performs, client-side ones included. That is the whole design:
|
|
14
|
+
* NOT a list of instrumented pages. Home, collections, blog posts, content
|
|
15
|
+
* pages, search, account and 404s are all captured without a single
|
|
16
|
+
* per-page call, which is the only shape that survives an agent inventing
|
|
17
|
+
* routes the platform has never seen.
|
|
18
|
+
*
|
|
19
|
+
* WHAT IT DELIBERATELY DOES NOT SEND: what the page IS. No `pageType` prop,
|
|
20
|
+
* no per-page declaration. The platform derives the page type and the id of
|
|
21
|
+
* the record the page was about from the path and its own catalog, because
|
|
22
|
+
* a declaration is a guarantee living in merchant code, and merchant code
|
|
23
|
+
* forgets. A route nobody anticipated still resolves, because the last path
|
|
24
|
+
* segment is looked up as a handle in the store's own catalog.
|
|
25
|
+
*
|
|
26
|
+
* The path is first-party (`/_cb/pageview` on the merchant's own domain),
|
|
27
|
+
* which keeps it out of ad blockers and away from third-party cookie rules.
|
|
28
|
+
*
|
|
29
|
+
* Query VALUES never leave the browser: only the keys travel, because a
|
|
30
|
+
* search term is shopper input and signed links live in query strings.
|
|
31
|
+
*/
|
|
32
|
+
const ENDPOINT = "/_cb/pageview"
|
|
33
|
+
|
|
34
|
+
function PageViewsInner() {
|
|
35
|
+
const pathname = usePathname()
|
|
36
|
+
const searchParams = useSearchParams()
|
|
37
|
+
// The referrer for a client-side navigation is the page you came FROM,
|
|
38
|
+
// which the browser cannot tell us — document.referrer only ever holds
|
|
39
|
+
// the page that loaded the app.
|
|
40
|
+
const lastPath = useRef<string | null>(null)
|
|
41
|
+
|
|
42
|
+
const search = searchParams?.toString() ?? ""
|
|
43
|
+
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
if (!pathname) return
|
|
46
|
+
// The deploy-injected collector already reports every navigation.
|
|
47
|
+
if (platformCollectorPresent()) return
|
|
48
|
+
|
|
49
|
+
const previous = lastPath.current
|
|
50
|
+
lastPath.current = pathname
|
|
51
|
+
|
|
52
|
+
const consent = readConsentCookie()
|
|
53
|
+
const referrer = previous
|
|
54
|
+
? `${window.location.origin}${previous}`
|
|
55
|
+
: typeof document !== "undefined" && document.referrer
|
|
56
|
+
? document.referrer
|
|
57
|
+
: undefined
|
|
58
|
+
|
|
59
|
+
const body = JSON.stringify({
|
|
60
|
+
path: pathname,
|
|
61
|
+
query_keys: search ? Array.from(new URLSearchParams(search).keys()).slice(0, 20) : undefined,
|
|
62
|
+
referrer,
|
|
63
|
+
title: typeof document !== "undefined" ? document.title : undefined,
|
|
64
|
+
session_id: getTabSessionId(),
|
|
65
|
+
device_id: readVisitorId(),
|
|
66
|
+
// undefined when the store runs no banner, which the platform reads
|
|
67
|
+
// as "nothing to honour" rather than as a refusal.
|
|
68
|
+
consent: consent ? consent.analytics : undefined,
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
void fetch(ENDPOINT, {
|
|
72
|
+
method: "POST",
|
|
73
|
+
headers: { "content-type": "application/json" },
|
|
74
|
+
body,
|
|
75
|
+
keepalive: true,
|
|
76
|
+
}).catch(() => undefined)
|
|
77
|
+
// `search` is in the deps on purpose: a filter or a search query change
|
|
78
|
+
// is a new pageview, and a merchant reading a search report needs it.
|
|
79
|
+
}, [pathname, search])
|
|
80
|
+
|
|
81
|
+
return null
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The Suspense boundary is INSIDE the component, not left to the consumer.
|
|
86
|
+
* `useSearchParams` opts a page out of static rendering unless it sits
|
|
87
|
+
* under one, and a storefront must not be able to lose its own static
|
|
88
|
+
* pages by mounting analytics. Nobody can wire this wrong.
|
|
89
|
+
*/
|
|
90
|
+
export function PageViews() {
|
|
91
|
+
return (
|
|
92
|
+
<Suspense fallback={null}>
|
|
93
|
+
<PageViewsInner />
|
|
94
|
+
</Suspense>
|
|
95
|
+
)
|
|
96
|
+
}
|
|
@@ -1,67 +1,75 @@
|
|
|
1
|
-
import { GoogleTag } from "./ga4"
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import
|
|
7
|
-
import
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
<
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
}
|
|
1
|
+
import { GoogleTag } from "./ga4"
|
|
2
|
+
import { LiveHeartbeat } from "./live-heartbeat"
|
|
3
|
+
import { PageViews } from "./page-views"
|
|
4
|
+
import { Gtm } from "./gtm"
|
|
5
|
+
import { MetaPixel } from "./meta-pixel"
|
|
6
|
+
import { TikTokPixel } from "./tiktok-pixel"
|
|
7
|
+
import { getTrackingConfig } from "./get-tracking-config"
|
|
8
|
+
import type { StorefrontClient } from "../api/http"
|
|
9
|
+
import type { TrackingConfig } from "./types"
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* StorefrontTags — every marketing tag the store has configured, mounted
|
|
13
|
+
* from the store's own config.
|
|
14
|
+
*
|
|
15
|
+
* THE POINT: a merchant who saves their pixel ids in the admin gets those
|
|
16
|
+
* tags on their storefront, with nothing else to do and no code to write.
|
|
17
|
+
* Before this existed the package EXPORTED tags and no storefront mounted
|
|
18
|
+
* them, so the admin could serve a Meta pixel id, a GA4 id and a Google
|
|
19
|
+
* Ads conversion id that reached the browser and did nothing. Adding a
|
|
20
|
+
* vendor is one line HERE now, and every store built on the package gains
|
|
21
|
+
* it at the next deploy.
|
|
22
|
+
*
|
|
23
|
+
* Consent: nothing extra to wire. <ConsentInit> sets the Consent Mode v2
|
|
24
|
+
* defaults synchronously ahead of these, the Meta and TikTok snippets read
|
|
25
|
+
* the shared consent cookie before they are allowed to write cookies, and
|
|
26
|
+
* `applyConsent()` relays a live decision to all three vendors. Mount this
|
|
27
|
+
* AFTER <ConsentInit> and the gate holds.
|
|
28
|
+
*
|
|
29
|
+
* Google is deliberately ONE tag with two destinations rather than two
|
|
30
|
+
* loaders — see the comment block in ./ga4.tsx. It is also why the Ads
|
|
31
|
+
* conversion inherits the consent gate for free.
|
|
32
|
+
*
|
|
33
|
+
* Server component: pass a `client` and it fetches the config (cached),
|
|
34
|
+
* or pass an already-fetched `config` when the layout has one in hand.
|
|
35
|
+
* Renders nothing for a vendor the store has not configured, so a store
|
|
36
|
+
* running only GA4 ships exactly one tag.
|
|
37
|
+
*/
|
|
38
|
+
export async function StorefrontTags({
|
|
39
|
+
client,
|
|
40
|
+
config,
|
|
41
|
+
}: {
|
|
42
|
+
/** Used to fetch the tracking config when `config` is not supplied. */
|
|
43
|
+
client?: StorefrontClient
|
|
44
|
+
/** Pre-fetched tracking block, e.g. from a layout that already has it. */
|
|
45
|
+
config?: TrackingConfig
|
|
46
|
+
}) {
|
|
47
|
+
let tracking: TrackingConfig | undefined = config
|
|
48
|
+
if (!tracking) {
|
|
49
|
+
if (!client) return null
|
|
50
|
+
try {
|
|
51
|
+
tracking = await getTrackingConfig(client)
|
|
52
|
+
} catch {
|
|
53
|
+
// A tracking-config failure must never take a storefront down.
|
|
54
|
+
return null
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<>
|
|
60
|
+
{/* Cartbase's own analytics, always on and never a merchant setting:
|
|
61
|
+
it is what feeds the merchant's own dashboard, so it does not
|
|
62
|
+
depend on any vendor id being configured. The vendor tags below
|
|
63
|
+
only appear when the merchant has entered their ids. */}
|
|
64
|
+
<PageViews />
|
|
65
|
+
<LiveHeartbeat />
|
|
66
|
+
<MetaPixel pixelId={tracking.facebookPixel?.pixelId} />
|
|
67
|
+
<TikTokPixel pixelId={tracking.tiktok?.pixelId} />
|
|
68
|
+
<GoogleTag
|
|
69
|
+
measurementId={tracking.ga4?.measurementId}
|
|
70
|
+
adsConversionId={tracking.googleAds?.conversionId}
|
|
71
|
+
/>
|
|
72
|
+
<Gtm containerId={tracking.gtm?.containerId} />
|
|
73
|
+
</>
|
|
74
|
+
)
|
|
75
|
+
}
|