@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
package/src/api/http.ts
CHANGED
|
@@ -4,9 +4,11 @@
|
|
|
4
4
|
* Every SDK function in this package is a thin typed wrapper over
|
|
5
5
|
* `storeFetch()` from a `StorefrontClient`. Auth model (store-api.md):
|
|
6
6
|
*
|
|
7
|
-
* - `x-
|
|
8
|
-
*
|
|
9
|
-
* sales channels
|
|
7
|
+
* - `x-publishable-api-key` — THE store's key: names the store on its own
|
|
8
|
+
* (one key, 2026-09-07) and scopes catalog and
|
|
9
|
+
* carts to the key's sales channels
|
|
10
|
+
* - `x-client-id` — the platform's own door (hosted builds carry it);
|
|
11
|
+
* optional, wins over the key when both are sent
|
|
10
12
|
* - `authorization: Bearer <jwt>` — customer session (passwordless code
|
|
11
13
|
* flow or supabase password login)
|
|
12
14
|
* - `x-locale` — optional storefront locale hint
|
|
@@ -19,12 +21,16 @@
|
|
|
19
21
|
import { StoreApiError } from "./types"
|
|
20
22
|
|
|
21
23
|
export interface StorefrontClientConfig {
|
|
22
|
-
/** Deployment origin, e.g. `https://admin.
|
|
24
|
+
/** Deployment origin, e.g. `https://admin.cartbase.ai` */
|
|
23
25
|
baseUrl: string
|
|
24
|
-
/**
|
|
25
|
-
|
|
26
|
-
|
|
26
|
+
/**
|
|
27
|
+
* The store's publishable key: names the store and scopes the catalog to
|
|
28
|
+
* the key's sales channels. The one input a merchant hands out; at least
|
|
29
|
+
* one of `publishableKey` and `clientId` is required.
|
|
30
|
+
*/
|
|
27
31
|
publishableKey?: string
|
|
32
|
+
/** Tenant id — the platform's own door. Optional when a key is given. */
|
|
33
|
+
clientId?: string
|
|
28
34
|
/** Called per request; return the customer JWT or null for guests. */
|
|
29
35
|
getAuthToken?: () => string | null | Promise<string | null>
|
|
30
36
|
/** Called per request; return the active locale code or null. */
|
|
@@ -50,7 +56,9 @@ export interface RequestOptions {
|
|
|
50
56
|
export class StorefrontClient {
|
|
51
57
|
constructor(private readonly config: StorefrontClientConfig) {
|
|
52
58
|
if (!config.baseUrl) throw new Error("StorefrontClient: baseUrl is required")
|
|
53
|
-
if (!config.
|
|
59
|
+
if (!config.publishableKey && !config.clientId) {
|
|
60
|
+
throw new Error("StorefrontClient: publishableKey (or clientId) is required")
|
|
61
|
+
}
|
|
54
62
|
}
|
|
55
63
|
|
|
56
64
|
/** The low-level typed request. Domain modules call this — apps rarely should. */
|
|
@@ -72,7 +80,7 @@ export class StorefrontClient {
|
|
|
72
80
|
}
|
|
73
81
|
|
|
74
82
|
const headers: Record<string, string> = {
|
|
75
|
-
"x-client-id": this.config.clientId,
|
|
83
|
+
...(this.config.clientId ? { "x-client-id": this.config.clientId } : {}),
|
|
76
84
|
...(this.config.publishableKey
|
|
77
85
|
? { "x-publishable-api-key": this.config.publishableKey }
|
|
78
86
|
: {}),
|
package/src/api/index.ts
CHANGED
|
@@ -13,6 +13,7 @@ export * as products from "./products"
|
|
|
13
13
|
export * as collections from "./collections"
|
|
14
14
|
export * as categories from "./categories"
|
|
15
15
|
export * as regions from "./regions"
|
|
16
|
+
export * as store from "./store"
|
|
16
17
|
export * as carts from "./carts"
|
|
17
18
|
export * as giftCards from "./gift-cards"
|
|
18
19
|
export * as checkout from "./checkout"
|
package/src/api/store.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @cartbase/storefront/api/store — the store's identity.
|
|
3
|
+
*
|
|
4
|
+
* Name, slug and brand (the merchant's Settings → Brand): what a
|
|
5
|
+
* storefront needs to title, head and foot itself without hardcoding a
|
|
6
|
+
* name. One anonymous read at layout level. Docs: docs/storefront/store.md.
|
|
7
|
+
*/
|
|
8
|
+
import type { StorefrontClient } from "./http"
|
|
9
|
+
|
|
10
|
+
export interface StoreBrand {
|
|
11
|
+
/** Primary logo, public URL, or null. */
|
|
12
|
+
logo_url: string | null
|
|
13
|
+
/** Square mark, public URL, or null. */
|
|
14
|
+
logo_square_url: string | null
|
|
15
|
+
/** `#rrggbb` or null. */
|
|
16
|
+
color_primary: string | null
|
|
17
|
+
/** `#rrggbb` or null. */
|
|
18
|
+
color_secondary: string | null
|
|
19
|
+
slogan: string | null
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface StoreIdentity {
|
|
23
|
+
/** The store's public id: what `<PlatformInit storeId>` mounts. */
|
|
24
|
+
id: string
|
|
25
|
+
/** The store's display name. */
|
|
26
|
+
name: string
|
|
27
|
+
/** The store's slug; its address is `{slug}.cartbase.net` until a custom domain. */
|
|
28
|
+
slug: string
|
|
29
|
+
brand: StoreBrand
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** GET /api/store/store — anon. */
|
|
33
|
+
export function getStore(client: StorefrontClient): Promise<{ store: StoreIdentity }> {
|
|
34
|
+
return client.request<{ store: StoreIdentity }>("/api/store/store")
|
|
35
|
+
}
|
package/src/checkout/geocode.ts
CHANGED
|
@@ -141,7 +141,7 @@ export async function geocodeAddress(
|
|
|
141
141
|
url.searchParams.set("limit", "1")
|
|
142
142
|
|
|
143
143
|
const res = await fetch(url.toString(), {
|
|
144
|
-
headers: { "User-Agent": "
|
|
144
|
+
headers: { "User-Agent": "cartbase-storefront/1.0" },
|
|
145
145
|
})
|
|
146
146
|
const data = await res.json()
|
|
147
147
|
if (data?.[0]) {
|
package/src/lib/cookie-names.ts
CHANGED
|
@@ -39,3 +39,48 @@ export function readCartCookie(
|
|
|
39
39
|
): string | undefined {
|
|
40
40
|
return get(CART_COOKIE) ?? get(LEGACY_CART_COOKIE)
|
|
41
41
|
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* THE VISITOR ID — our own per-browser id and the join key between browsing
|
|
45
|
+
* and money (ecommerce-analytics §3.1). It is OURS deliberately: the
|
|
46
|
+
* analytics engine is swappable, so a key owned by the engine would couple
|
|
47
|
+
* the whole platform to it.
|
|
48
|
+
*
|
|
49
|
+
* Written by the injected storefront proxy in a `Set-Cookie` header, never
|
|
50
|
+
* by `document.cookie`, and that is not a style preference: Safari's ITP
|
|
51
|
+
* caps JavaScript-written cookies at 7 days while server-set first-party
|
|
52
|
+
* cookies live up to 400. A JS-written id makes every returning Safari
|
|
53
|
+
* visitor read as new after a week.
|
|
54
|
+
*/
|
|
55
|
+
export const VISITOR_COOKIE = "_cartbase_visitor"
|
|
56
|
+
|
|
57
|
+
/** @deprecated Pre-rename name — read-only compat, never written. */
|
|
58
|
+
export const LEGACY_VISITOR_COOKIE = "_1c_anon"
|
|
59
|
+
|
|
60
|
+
/** First-touch UTM tuple, written once, 365 days. */
|
|
61
|
+
export const UTM_FIRST_COOKIE = "_cartbase_utm_first"
|
|
62
|
+
/** @deprecated Pre-rename name — read-only compat, never written. */
|
|
63
|
+
export const LEGACY_UTM_FIRST_COOKIE = "_1c_utm_first"
|
|
64
|
+
|
|
65
|
+
/** Last-touch UTM tuple, rewritten on every UTM-bearing visit, 90 days. */
|
|
66
|
+
export const UTM_LAST_COOKIE = "_cartbase_utm_last"
|
|
67
|
+
/** @deprecated Pre-rename name — read-only compat, never written. */
|
|
68
|
+
export const LEGACY_UTM_LAST_COOKIE = "_1c_utm_last"
|
|
69
|
+
|
|
70
|
+
/** Known-visitor marker (localStorage, not a cookie). */
|
|
71
|
+
export const KNOWN_VISITOR_KEY = "_cartbase_visitor_v1"
|
|
72
|
+
/** @deprecated Pre-rename key — read-only compat, never written. */
|
|
73
|
+
export const LEGACY_KNOWN_VISITOR_KEY = "_1c_visitor_v1"
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Read a value preferring the current name and falling back to the legacy
|
|
77
|
+
* one, so a visitor who arrived before the rename keeps their identity and
|
|
78
|
+
* their attribution instead of being counted as somebody new.
|
|
79
|
+
*/
|
|
80
|
+
export function readRenamedCookie(
|
|
81
|
+
get: (name: string) => string | undefined,
|
|
82
|
+
current: string,
|
|
83
|
+
legacy: string
|
|
84
|
+
): string | undefined {
|
|
85
|
+
return get(current) ?? get(legacy)
|
|
86
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The platform's origin (one key, store-birth-and-doors item 6, 2026-09-07):
|
|
3
|
+
* a constant, not an input. Every Cartbase store's API lives here; a
|
|
4
|
+
* storefront overrides it only to point at a local or staging platform
|
|
5
|
+
* (`NEXT_PUBLIC_CARTBASE_URL`), never to find its own store, which the
|
|
6
|
+
* publishable key does.
|
|
7
|
+
*/
|
|
8
|
+
export const CARTBASE_API_ORIGIN = "https://admin.cartbase.ai"
|
|
9
|
+
|
|
10
|
+
/** The origin to call: the override when set, else the platform. */
|
|
11
|
+
export function cartbaseApiOrigin(override?: string | null): string {
|
|
12
|
+
return (override && override.trim()) || CARTBASE_API_ORIGIN
|
|
13
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { VISITOR_COOKIE, LEGACY_VISITOR_COOKIE } from "./cookie-names"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* THE VISITOR'S TWO IDS. One module, because three copies of a cookie read
|
|
5
|
+
* had already appeared across the package and a fourth was about to.
|
|
6
|
+
*
|
|
7
|
+
* - The VISITOR id is the durable one. It is written by the platform's
|
|
8
|
+
* injected proxy as a `Set-Cookie` on the very first request, which is
|
|
9
|
+
* what makes it survive: Safari caps a cookie written by JavaScript at
|
|
10
|
+
* seven days, so a returning visitor read from a JS-written id looks new
|
|
11
|
+
* every week. It is the join key between a browsing session and the cart
|
|
12
|
+
* and order it becomes.
|
|
13
|
+
* - The TAB SESSION id is per tab and dies with it. Two tabs are two
|
|
14
|
+
* sessions, which is what a merchant counting live visitors sees on
|
|
15
|
+
* their own screen.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Reads the platform's first-party visitor cookie, preferring the current
|
|
20
|
+
* name and falling back to the pre-rename one. Server-side there is no
|
|
21
|
+
* document, and callers pass the value explicitly instead.
|
|
22
|
+
*/
|
|
23
|
+
export function readVisitorId(): string | undefined {
|
|
24
|
+
if (typeof document === "undefined") return undefined
|
|
25
|
+
for (const name of [VISITOR_COOKIE, LEGACY_VISITOR_COOKIE]) {
|
|
26
|
+
const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`))
|
|
27
|
+
if (match) return decodeURIComponent(match[1])
|
|
28
|
+
}
|
|
29
|
+
return undefined
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const TAB_SESSION_KEY = "_cartbase_live_session"
|
|
33
|
+
|
|
34
|
+
function randomId(): string {
|
|
35
|
+
return typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
|
|
36
|
+
? crypto.randomUUID()
|
|
37
|
+
: `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* One id per tab, shared by the heartbeat and the pageview tracker so the
|
|
42
|
+
* live count and the traffic report are talking about the same sessions.
|
|
43
|
+
*/
|
|
44
|
+
export function getTabSessionId(): string {
|
|
45
|
+
try {
|
|
46
|
+
const existing = window.sessionStorage.getItem(TAB_SESSION_KEY)
|
|
47
|
+
if (existing) return existing
|
|
48
|
+
const id = randomId()
|
|
49
|
+
window.sessionStorage.setItem(TAB_SESSION_KEY, id)
|
|
50
|
+
return id
|
|
51
|
+
} catch {
|
|
52
|
+
// Private mode with storage blocked: a per-mount id still produces an
|
|
53
|
+
// honest count, it just does not survive a reload.
|
|
54
|
+
return randomId()
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* True when the platform's injected collector is already running on this
|
|
60
|
+
* page. It claims the flag synchronously, before hydration, so any React
|
|
61
|
+
* component that checks this on mount sees a settled answer.
|
|
62
|
+
*
|
|
63
|
+
* Why it exists: every deployed storefront gets the collector injected at
|
|
64
|
+
* deploy time, and the scaffold ALSO mounts <StorefrontTags>. Without this
|
|
65
|
+
* check every store built from the scaffold would count every pageview
|
|
66
|
+
* twice, which is the kind of defect that looks like growth.
|
|
67
|
+
*/
|
|
68
|
+
export function platformCollectorPresent(): boolean {
|
|
69
|
+
try {
|
|
70
|
+
return Boolean((window as unknown as { __cartbase_collector?: unknown }).__cartbase_collector)
|
|
71
|
+
} catch {
|
|
72
|
+
return false
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
|
+
import {
|
|
4
|
+
KNOWN_VISITOR_KEY,
|
|
5
|
+
LEGACY_KNOWN_VISITOR_KEY,
|
|
6
|
+
LEGACY_UTM_FIRST_COOKIE,
|
|
7
|
+
LEGACY_UTM_LAST_COOKIE,
|
|
8
|
+
LEGACY_VISITOR_COOKIE,
|
|
9
|
+
UTM_FIRST_COOKIE,
|
|
10
|
+
UTM_LAST_COOKIE,
|
|
11
|
+
VISITOR_COOKIE,
|
|
12
|
+
} from "../lib/cookie-names"
|
|
13
|
+
|
|
3
14
|
/**
|
|
4
15
|
* Self-managed Facebook attribution helpers.
|
|
5
16
|
*
|
|
@@ -29,10 +40,13 @@
|
|
|
29
40
|
|
|
30
41
|
const FBP_COOKIE = "_fbp"
|
|
31
42
|
const FBC_COOKIE = "_fbc"
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
43
|
+
|
|
44
|
+
// The visitor id and the UTM tuples moved into THE cookie registry
|
|
45
|
+
// (cookie-names.ts) with the platform prefix, 2026-09-04. Every read
|
|
46
|
+
// prefers the new name and falls back to the pre-rename one, so a visitor
|
|
47
|
+
// who arrived earlier keeps their identity instead of being counted as new.
|
|
48
|
+
const ANON_ID_COOKIE = VISITOR_COOKIE
|
|
49
|
+
const KNOWN_VISITOR_LS = KNOWN_VISITOR_KEY
|
|
36
50
|
|
|
37
51
|
/** _fbp / _fbc TTL per Meta spec — 90 days first-party. */
|
|
38
52
|
const FB_COOKIE_TTL_DAYS = 90
|
|
@@ -157,7 +171,7 @@ export function getOrCreateFbc(): string | undefined {
|
|
|
157
171
|
* it to cart.metadata for the order.placed subscriber.
|
|
158
172
|
*/
|
|
159
173
|
export function getOrCreateAnonId(): string | undefined {
|
|
160
|
-
const existing = getCookie(ANON_ID_COOKIE)
|
|
174
|
+
const existing = getCookie(ANON_ID_COOKIE) ?? getCookie(LEGACY_VISITOR_COOKIE)
|
|
161
175
|
if (existing) return existing
|
|
162
176
|
if (!isBrowser()) return undefined
|
|
163
177
|
|
|
@@ -192,7 +206,9 @@ export function getKnownVisitor(): KnownVisitor {
|
|
|
192
206
|
let stored: KnownVisitor = {}
|
|
193
207
|
if (isBrowser()) {
|
|
194
208
|
try {
|
|
195
|
-
const raw =
|
|
209
|
+
const raw =
|
|
210
|
+
window.localStorage.getItem(KNOWN_VISITOR_LS) ??
|
|
211
|
+
window.localStorage.getItem(LEGACY_KNOWN_VISITOR_KEY)
|
|
196
212
|
if (raw) stored = JSON.parse(raw) as KnownVisitor
|
|
197
213
|
} catch {
|
|
198
214
|
// localStorage unavailable / parse error — return empty
|
|
@@ -373,8 +389,8 @@ function readUtmsFromSearch(search: string): CapturedUtms | null {
|
|
|
373
389
|
/** Read + parse one of the two UTM cookies. Defensive — malformed JSON
|
|
374
390
|
* or shape drift returns null so the caller treats it as missing
|
|
375
391
|
* instead of crashing. */
|
|
376
|
-
function readUtmsCookie(name: string): CapturedUtms | null {
|
|
377
|
-
const raw = getCookie(name)
|
|
392
|
+
function readUtmsCookie(name: string, legacy?: string): CapturedUtms | null {
|
|
393
|
+
const raw = getCookie(name) ?? (legacy ? getCookie(legacy) : undefined)
|
|
378
394
|
if (!raw) return null
|
|
379
395
|
try {
|
|
380
396
|
const parsed = JSON.parse(decodeURIComponent(raw)) as Partial<CapturedUtms>
|
|
@@ -420,7 +436,7 @@ export function captureUtmsFromUrl(): void {
|
|
|
420
436
|
// First-touch: write only when the cookie is currently absent.
|
|
421
437
|
// Re-arriving with a fresh UTM does NOT overwrite the original
|
|
422
438
|
// acquisition record — that's the whole point of "first-touch".
|
|
423
|
-
if (!readUtmsCookie(UTM_FIRST_COOKIE)) {
|
|
439
|
+
if (!readUtmsCookie(UTM_FIRST_COOKIE, LEGACY_UTM_FIRST_COOKIE)) {
|
|
424
440
|
writeUtmsCookie(UTM_FIRST_COOKIE, utms, UTM_FIRST_TTL_DAYS)
|
|
425
441
|
}
|
|
426
442
|
// Last-touch: always overwrite when the URL carries UTMs. Refreshes
|
|
@@ -436,7 +452,7 @@ export function captureUtmsFromUrl(): void {
|
|
|
436
452
|
* cookies() API) instead — this function is browser-only.
|
|
437
453
|
*/
|
|
438
454
|
export function getCapturedFirstTouchUtms(): CapturedUtms | null {
|
|
439
|
-
return readUtmsCookie(UTM_FIRST_COOKIE)
|
|
455
|
+
return readUtmsCookie(UTM_FIRST_COOKIE, LEGACY_UTM_FIRST_COOKIE)
|
|
440
456
|
}
|
|
441
457
|
|
|
442
458
|
/**
|
|
@@ -446,99 +462,99 @@ export function getCapturedFirstTouchUtms(): CapturedUtms | null {
|
|
|
446
462
|
* this is browser-only.
|
|
447
463
|
*/
|
|
448
464
|
export function getCapturedLastTouchUtms(): CapturedUtms | null {
|
|
449
|
-
return readUtmsCookie(UTM_LAST_COOKIE)
|
|
465
|
+
return readUtmsCookie(UTM_LAST_COOKIE, LEGACY_UTM_LAST_COOKIE)
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// ── Ad-click identifiers ─────────────────────────────────────────────
|
|
469
|
+
//
|
|
470
|
+
// Meta's `fbclid` is folded into the `_fbc` cookie above by Meta's own
|
|
471
|
+
// format. TikTok's and Google's click ids have no such carrier, and both
|
|
472
|
+
// platforms need them:
|
|
473
|
+
//
|
|
474
|
+
// ttclid appended by TikTok to the landing URL on an ad click.
|
|
475
|
+
// Without it TikTok cannot tie a conversion back to the click
|
|
476
|
+
// that caused it, which is the reporting an advertiser checks
|
|
477
|
+
// first. Forwarded to the Events API as `user.ttclid`.
|
|
478
|
+
// _ttp a first-party cookie the TikTok pixel writes ITSELF once
|
|
479
|
+
// first-party cookies are enabled in pixel settings. We never
|
|
480
|
+
// write it, only read it; forwarded as `user.ttp`.
|
|
481
|
+
// gclid Google's standard click id.
|
|
482
|
+
// gbraid iOS app-to-web variant.
|
|
483
|
+
// wbraid iOS web-to-web variant.
|
|
484
|
+
//
|
|
485
|
+
// A Google campaign can deliver any one of those three, so capturing
|
|
486
|
+
// gclid alone silently loses iOS traffic — the traffic most likely to be
|
|
487
|
+
// missing a cookie in the first place.
|
|
488
|
+
//
|
|
489
|
+
// Write-once semantics deliberately do NOT apply: a newer click of the
|
|
490
|
+
// same kind overwrites, because the most recent click is what the ad
|
|
491
|
+
// platforms attribute to. What must never happen is a later ORGANIC
|
|
492
|
+
// visit clearing the click that acquired the visitor, and that is why
|
|
493
|
+
// each cookie is only touched when its parameter is actually present.
|
|
494
|
+
|
|
495
|
+
const TTCLID_COOKIE = "_1c_ttclid"
|
|
496
|
+
const GCLID_COOKIE = "_1c_gclid"
|
|
497
|
+
const GBRAID_COOKIE = "_1c_gbraid"
|
|
498
|
+
const WBRAID_COOKIE = "_1c_wbraid"
|
|
499
|
+
|
|
500
|
+
/** TikTok's ttclid lifetime follows the CTA window in Attribution
|
|
501
|
+
* Manager; Google's conversion window tops out at 90 days. One TTL
|
|
502
|
+
* covers both, and each platform applies its own window on top and
|
|
503
|
+
* ignores anything older. */
|
|
504
|
+
const CLICK_ID_TTL_DAYS = 90
|
|
505
|
+
|
|
506
|
+
/** Cookie name → the URL parameter it is captured from. */
|
|
507
|
+
const CLICK_ID_CAPTURE: ReadonlyArray<readonly [string, string]> = [
|
|
508
|
+
[TTCLID_COOKIE, "ttclid"],
|
|
509
|
+
[GCLID_COOKIE, "gclid"],
|
|
510
|
+
[GBRAID_COOKIE, "gbraid"],
|
|
511
|
+
[WBRAID_COOKIE, "wbraid"],
|
|
512
|
+
]
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* The cookies the click ids live in, and the flat `order.metadata` keys
|
|
516
|
+
* the checkout writeback maps them onto. `ttp` is in the list because it
|
|
517
|
+
* is READ from TikTok's own cookie; it is never written here.
|
|
518
|
+
*/
|
|
519
|
+
export const CLICK_ID_COOKIES = {
|
|
520
|
+
ttclid: TTCLID_COOKIE,
|
|
521
|
+
gclid: GCLID_COOKIE,
|
|
522
|
+
gbraid: GBRAID_COOKIE,
|
|
523
|
+
wbraid: WBRAID_COOKIE,
|
|
524
|
+
ttp: "_ttp",
|
|
525
|
+
} as const
|
|
526
|
+
|
|
527
|
+
/** cookie name → order.metadata key (code truth for the server side:
|
|
528
|
+
* `TrackingAttributionMeta` in src/lib/tracking/constants.ts). */
|
|
529
|
+
export const CLICK_ID_METADATA_KEYS: ReadonlyArray<readonly [string, string]> = [
|
|
530
|
+
[CLICK_ID_COOKIES.ttclid, "tt_ttclid"],
|
|
531
|
+
[CLICK_ID_COOKIES.ttp, "tt_ttp"],
|
|
532
|
+
[CLICK_ID_COOKIES.gclid, "google_gclid"],
|
|
533
|
+
[CLICK_ID_COOKIES.gbraid, "google_gbraid"],
|
|
534
|
+
[CLICK_ID_COOKIES.wbraid, "google_wbraid"],
|
|
535
|
+
]
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Capture every ad-click identifier present on the current URL.
|
|
539
|
+
*
|
|
540
|
+
* Idempotent and safe to call on every mount, next to
|
|
541
|
+
* `captureUtmsFromUrl()`: a URL carrying no click id leaves every
|
|
542
|
+
* existing cookie untouched.
|
|
543
|
+
*/
|
|
544
|
+
export function captureClickIdsFromUrl(): void {
|
|
545
|
+
if (!isBrowser()) return
|
|
546
|
+
|
|
547
|
+
let params: URLSearchParams
|
|
548
|
+
try {
|
|
549
|
+
params = new URLSearchParams(window.location.search)
|
|
550
|
+
} catch {
|
|
551
|
+
return
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
for (const [cookieName, param] of CLICK_ID_CAPTURE) {
|
|
555
|
+
const raw = params.get(param)
|
|
556
|
+
if (raw && raw.trim().length > 0) {
|
|
557
|
+
setCookie(cookieName, encodeURIComponent(raw.trim()), CLICK_ID_TTL_DAYS)
|
|
558
|
+
}
|
|
559
|
+
}
|
|
450
560
|
}
|
|
451
|
-
|
|
452
|
-
// ── Ad-click identifiers ─────────────────────────────────────────────
|
|
453
|
-
//
|
|
454
|
-
// Meta's `fbclid` is folded into the `_fbc` cookie above by Meta's own
|
|
455
|
-
// format. TikTok's and Google's click ids have no such carrier, and both
|
|
456
|
-
// platforms need them:
|
|
457
|
-
//
|
|
458
|
-
// ttclid appended by TikTok to the landing URL on an ad click.
|
|
459
|
-
// Without it TikTok cannot tie a conversion back to the click
|
|
460
|
-
// that caused it, which is the reporting an advertiser checks
|
|
461
|
-
// first. Forwarded to the Events API as `user.ttclid`.
|
|
462
|
-
// _ttp a first-party cookie the TikTok pixel writes ITSELF once
|
|
463
|
-
// first-party cookies are enabled in pixel settings. We never
|
|
464
|
-
// write it, only read it; forwarded as `user.ttp`.
|
|
465
|
-
// gclid Google's standard click id.
|
|
466
|
-
// gbraid iOS app-to-web variant.
|
|
467
|
-
// wbraid iOS web-to-web variant.
|
|
468
|
-
//
|
|
469
|
-
// A Google campaign can deliver any one of those three, so capturing
|
|
470
|
-
// gclid alone silently loses iOS traffic — the traffic most likely to be
|
|
471
|
-
// missing a cookie in the first place.
|
|
472
|
-
//
|
|
473
|
-
// Write-once semantics deliberately do NOT apply: a newer click of the
|
|
474
|
-
// same kind overwrites, because the most recent click is what the ad
|
|
475
|
-
// platforms attribute to. What must never happen is a later ORGANIC
|
|
476
|
-
// visit clearing the click that acquired the visitor, and that is why
|
|
477
|
-
// each cookie is only touched when its parameter is actually present.
|
|
478
|
-
|
|
479
|
-
const TTCLID_COOKIE = "_1c_ttclid"
|
|
480
|
-
const GCLID_COOKIE = "_1c_gclid"
|
|
481
|
-
const GBRAID_COOKIE = "_1c_gbraid"
|
|
482
|
-
const WBRAID_COOKIE = "_1c_wbraid"
|
|
483
|
-
|
|
484
|
-
/** TikTok's ttclid lifetime follows the CTA window in Attribution
|
|
485
|
-
* Manager; Google's conversion window tops out at 90 days. One TTL
|
|
486
|
-
* covers both, and each platform applies its own window on top and
|
|
487
|
-
* ignores anything older. */
|
|
488
|
-
const CLICK_ID_TTL_DAYS = 90
|
|
489
|
-
|
|
490
|
-
/** Cookie name → the URL parameter it is captured from. */
|
|
491
|
-
const CLICK_ID_CAPTURE: ReadonlyArray<readonly [string, string]> = [
|
|
492
|
-
[TTCLID_COOKIE, "ttclid"],
|
|
493
|
-
[GCLID_COOKIE, "gclid"],
|
|
494
|
-
[GBRAID_COOKIE, "gbraid"],
|
|
495
|
-
[WBRAID_COOKIE, "wbraid"],
|
|
496
|
-
]
|
|
497
|
-
|
|
498
|
-
/**
|
|
499
|
-
* The cookies the click ids live in, and the flat `order.metadata` keys
|
|
500
|
-
* the checkout writeback maps them onto. `ttp` is in the list because it
|
|
501
|
-
* is READ from TikTok's own cookie; it is never written here.
|
|
502
|
-
*/
|
|
503
|
-
export const CLICK_ID_COOKIES = {
|
|
504
|
-
ttclid: TTCLID_COOKIE,
|
|
505
|
-
gclid: GCLID_COOKIE,
|
|
506
|
-
gbraid: GBRAID_COOKIE,
|
|
507
|
-
wbraid: WBRAID_COOKIE,
|
|
508
|
-
ttp: "_ttp",
|
|
509
|
-
} as const
|
|
510
|
-
|
|
511
|
-
/** cookie name → order.metadata key (code truth for the server side:
|
|
512
|
-
* `TrackingAttributionMeta` in src/lib/tracking/constants.ts). */
|
|
513
|
-
export const CLICK_ID_METADATA_KEYS: ReadonlyArray<readonly [string, string]> = [
|
|
514
|
-
[CLICK_ID_COOKIES.ttclid, "tt_ttclid"],
|
|
515
|
-
[CLICK_ID_COOKIES.ttp, "tt_ttp"],
|
|
516
|
-
[CLICK_ID_COOKIES.gclid, "google_gclid"],
|
|
517
|
-
[CLICK_ID_COOKIES.gbraid, "google_gbraid"],
|
|
518
|
-
[CLICK_ID_COOKIES.wbraid, "google_wbraid"],
|
|
519
|
-
]
|
|
520
|
-
|
|
521
|
-
/**
|
|
522
|
-
* Capture every ad-click identifier present on the current URL.
|
|
523
|
-
*
|
|
524
|
-
* Idempotent and safe to call on every mount, next to
|
|
525
|
-
* `captureUtmsFromUrl()`: a URL carrying no click id leaves every
|
|
526
|
-
* existing cookie untouched.
|
|
527
|
-
*/
|
|
528
|
-
export function captureClickIdsFromUrl(): void {
|
|
529
|
-
if (!isBrowser()) return
|
|
530
|
-
|
|
531
|
-
let params: URLSearchParams
|
|
532
|
-
try {
|
|
533
|
-
params = new URLSearchParams(window.location.search)
|
|
534
|
-
} catch {
|
|
535
|
-
return
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
for (const [cookieName, param] of CLICK_ID_CAPTURE) {
|
|
539
|
-
const raw = params.get(param)
|
|
540
|
-
if (raw && raw.trim().length > 0) {
|
|
541
|
-
setCookie(cookieName, encodeURIComponent(raw.trim()), CLICK_ID_TTL_DAYS)
|
|
542
|
-
}
|
|
543
|
-
}
|
|
544
|
-
}
|
|
@@ -4,6 +4,14 @@ import { getTrackingConfig } from "./get-tracking-config"
|
|
|
4
4
|
import type { StorefrontClient } from "../api/http"
|
|
5
5
|
import type { TrackingAttribution, TrackingClientHints } from "./types"
|
|
6
6
|
import { CLICK_ID_METADATA_KEYS } from "./attribution"
|
|
7
|
+
import {
|
|
8
|
+
LEGACY_UTM_FIRST_COOKIE,
|
|
9
|
+
LEGACY_UTM_LAST_COOKIE,
|
|
10
|
+
LEGACY_VISITOR_COOKIE,
|
|
11
|
+
UTM_FIRST_COOKIE,
|
|
12
|
+
UTM_LAST_COOKIE,
|
|
13
|
+
VISITOR_COOKIE,
|
|
14
|
+
} from "../lib/cookie-names"
|
|
7
15
|
|
|
8
16
|
/**
|
|
9
17
|
* Reads Meta + GA4 attribution signals from the current Next.js server
|
|
@@ -73,14 +81,19 @@ export async function getTrackingAttribution(
|
|
|
73
81
|
// attribution.ts on first tracking call. Surfaces server-side here
|
|
74
82
|
// so the order.placed CAPI Purchase can include it as external_id
|
|
75
83
|
// (alongside customer_id when both exist — Meta accepts an array).
|
|
76
|
-
anonId =
|
|
84
|
+
anonId =
|
|
85
|
+
cookieStore.get(VISITOR_COOKIE)?.value ?? cookieStore.get(LEGACY_VISITOR_COOKIE)?.value
|
|
77
86
|
gaCookieRaw = cookieStore.get("_ga")?.value
|
|
78
87
|
// _1c_utm_first / _1c_utm_last — JSON-encoded UTM tuples written
|
|
79
88
|
// by browser-side captureUtmsFromUrl(). First-touch records the
|
|
80
89
|
// acquisition campaign (365-day TTL); last-touch records the
|
|
81
90
|
// closer (90-day TTL, refreshed on each UTM-bearing visit).
|
|
82
|
-
utmFirstRaw =
|
|
83
|
-
|
|
91
|
+
utmFirstRaw =
|
|
92
|
+
cookieStore.get(UTM_FIRST_COOKIE)?.value ??
|
|
93
|
+
cookieStore.get(LEGACY_UTM_FIRST_COOKIE)?.value
|
|
94
|
+
utmLastRaw =
|
|
95
|
+
cookieStore.get(UTM_LAST_COOKIE)?.value ??
|
|
96
|
+
cookieStore.get(LEGACY_UTM_LAST_COOKIE)?.value
|
|
84
97
|
|
|
85
98
|
// _ga_<MEASUREMENT_ID> uses the GA4 measurementId (e.g., G-ABCDEF1234)
|
|
86
99
|
// with the "G-" prefix stripped: cookie name = `_ga_ABCDEF1234`.
|
package/src/tracking/index.ts
CHANGED
|
@@ -18,6 +18,10 @@
|
|
|
18
18
|
* from its own config. This is the one a storefront should mount;
|
|
19
19
|
* the individual tags below are for a layout that needs control.
|
|
20
20
|
* - <TrackInit /> — captures UTMs and ad-click ids on the landing page
|
|
21
|
+
* - <PageViews /> — one pageview per navigation, client-side ones
|
|
22
|
+
* included; the platform derives page type + entity id from the path
|
|
23
|
+
* - <LiveHeartbeat /> — pings while a tab is open, so the merchant's live
|
|
24
|
+
* visitor count means "right now" rather than "recently active"
|
|
21
25
|
* (they exist only there) and starts the engagement clock.
|
|
22
26
|
* - <MetaPixel pixelId> / <GoogleTag measurementId adsConversionId> /
|
|
23
27
|
* <TikTokPixel pixelId> / <Gtm containerId> / <Rybbit siteId> —
|
|
@@ -55,6 +59,13 @@ export { Gtm } from "./gtm"
|
|
|
55
59
|
export { TikTokPixel } from "./tiktok-pixel"
|
|
56
60
|
export { StorefrontTags } from "./storefront-tags"
|
|
57
61
|
export { TrackInit } from "./track-init"
|
|
62
|
+
// The live-visitor heartbeat. Mounted by <StorefrontTags> so a storefront
|
|
63
|
+
// gets it without knowing it exists.
|
|
64
|
+
export { LiveHeartbeat } from "./live-heartbeat"
|
|
65
|
+
// Every navigation, automatically. Mounted by <StorefrontTags>; the page
|
|
66
|
+
// type and the record it was about are derived by the platform, never
|
|
67
|
+
// declared here.
|
|
68
|
+
export { PageViews } from "./page-views"
|
|
58
69
|
export { TrackOrderPurchase } from "./track-order-purchase"
|
|
59
70
|
export { Rybbit } from "./rybbit"
|
|
60
71
|
export { ConsentInit } from "./consent-init"
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useEffect } from "react"
|
|
4
|
+
|
|
5
|
+
import { getTabSessionId, platformCollectorPresent, readVisitorId } from "../lib/visitor"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The live-visitor heartbeat.
|
|
9
|
+
*
|
|
10
|
+
* While this tab is open it pings the store's own domain every 15 seconds.
|
|
11
|
+
* The platform counts a visitor as present while their last ping is inside
|
|
12
|
+
* a 30 second window, so a closed tab, a killed browser, a dropped network
|
|
13
|
+
* or a locked phone all disappear within half a minute with nothing to
|
|
14
|
+
* detect them — no unload handler, which browsers do not guarantee anyway,
|
|
15
|
+
* especially on mobile.
|
|
16
|
+
*
|
|
17
|
+
* Why a heartbeat instead of counting recent events: a shopper reading one
|
|
18
|
+
* product page for four minutes sends nothing in between, so an
|
|
19
|
+
* event-window count either forgets them or keeps counting people who left.
|
|
20
|
+
* A ping answers "who is on the store right now" literally.
|
|
21
|
+
*
|
|
22
|
+
* The path is first-party (`/_cb/heartbeat` on the merchant's own domain),
|
|
23
|
+
* which is what keeps it out of ad blockers and away from third-party
|
|
24
|
+
* cookie rules. The platform's injected proxy forwards it.
|
|
25
|
+
*
|
|
26
|
+
* Costs nothing when it fails: every call is fire-and-forget with keepalive,
|
|
27
|
+
* a dropped ping is one missing dot on a chart.
|
|
28
|
+
*/
|
|
29
|
+
const HEARTBEAT_MS = 15_000
|
|
30
|
+
const ENDPOINT = "/_cb/heartbeat"
|
|
31
|
+
|
|
32
|
+
export function LiveHeartbeat() {
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
// The deploy-injected collector already does this on every page.
|
|
35
|
+
if (platformCollectorPresent()) return
|
|
36
|
+
const session = getTabSessionId()
|
|
37
|
+
|
|
38
|
+
const ping = () => {
|
|
39
|
+
const body = JSON.stringify({
|
|
40
|
+
session_id: session,
|
|
41
|
+
device_id: readVisitorId(),
|
|
42
|
+
// Path only. The platform drops query strings as well, because
|
|
43
|
+
// signed document links and login codes travel in them.
|
|
44
|
+
path: window.location.pathname,
|
|
45
|
+
})
|
|
46
|
+
void fetch(ENDPOINT, {
|
|
47
|
+
method: "POST",
|
|
48
|
+
headers: { "content-type": "application/json" },
|
|
49
|
+
body,
|
|
50
|
+
keepalive: true,
|
|
51
|
+
}).catch(() => undefined)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
ping()
|
|
55
|
+
// No visibility handling on purpose (the live-polling law): browsers
|
|
56
|
+
// already throttle timers in a hidden tab, and anything that guesses at
|
|
57
|
+
// attention will eventually guess wrong.
|
|
58
|
+
const timer = setInterval(ping, HEARTBEAT_MS)
|
|
59
|
+
return () => clearInterval(timer)
|
|
60
|
+
}, [])
|
|
61
|
+
|
|
62
|
+
return null
|
|
63
|
+
}
|