@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.
@@ -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 { Gtm } from "./gtm"
3
- import { MetaPixel } from "./meta-pixel"
4
- import { TikTokPixel } from "./tiktok-pixel"
5
- import { getTrackingConfig } from "./get-tracking-config"
6
- import type { StorefrontClient } from "../api/http"
7
- import type { TrackingConfig } from "./types"
8
-
9
- /**
10
- * StorefrontTags — every marketing tag the store has configured, mounted
11
- * from the store's own config.
12
- *
13
- * THE POINT: a merchant who saves their pixel ids in the admin gets those
14
- * tags on their storefront, with nothing else to do and no code to write.
15
- * Before this existed the package EXPORTED tags and no storefront mounted
16
- * them, so the admin could serve a Meta pixel id, a GA4 id and a Google
17
- * Ads conversion id that reached the browser and did nothing. Adding a
18
- * vendor is one line HERE now, and every store built on the package gains
19
- * it at the next deploy.
20
- *
21
- * Consent: nothing extra to wire. <ConsentInit> sets the Consent Mode v2
22
- * defaults synchronously ahead of these, the Meta and TikTok snippets read
23
- * the shared consent cookie before they are allowed to write cookies, and
24
- * `applyConsent()` relays a live decision to all three vendors. Mount this
25
- * AFTER <ConsentInit> and the gate holds.
26
- *
27
- * Google is deliberately ONE tag with two destinations rather than two
28
- * loaders — see the comment block in ./ga4.tsx. It is also why the Ads
29
- * conversion inherits the consent gate for free.
30
- *
31
- * Server component: pass a `client` and it fetches the config (cached),
32
- * or pass an already-fetched `config` when the layout has one in hand.
33
- * Renders nothing for a vendor the store has not configured, so a store
34
- * running only GA4 ships exactly one tag.
35
- */
36
- export async function StorefrontTags({
37
- client,
38
- config,
39
- }: {
40
- /** Used to fetch the tracking config when `config` is not supplied. */
41
- client?: StorefrontClient
42
- /** Pre-fetched tracking block, e.g. from a layout that already has it. */
43
- config?: TrackingConfig
44
- }) {
45
- let tracking: TrackingConfig | undefined = config
46
- if (!tracking) {
47
- if (!client) return null
48
- try {
49
- tracking = await getTrackingConfig(client)
50
- } catch {
51
- // A tracking-config failure must never take a storefront down.
52
- return null
53
- }
54
- }
55
-
56
- return (
57
- <>
58
- <MetaPixel pixelId={tracking.facebookPixel?.pixelId} />
59
- <TikTokPixel pixelId={tracking.tiktok?.pixelId} />
60
- <GoogleTag
61
- measurementId={tracking.ga4?.measurementId}
62
- adsConversionId={tracking.googleAds?.conversionId}
63
- />
64
- <Gtm containerId={tracking.gtm?.containerId} />
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
+ }