@cartbase/storefront 0.6.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 +74 -58
- package/package.json +248 -233
- package/src/api/carts.ts +16 -1
- package/src/api/http.ts +17 -9
- package/src/api/index.ts +1 -0
- package/src/api/integrations.ts +118 -117
- 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 +153 -11
- package/src/tracking/consent.ts +14 -0
- package/src/tracking/events.ts +294 -0
- package/src/tracking/ga4.tsx +93 -49
- package/src/tracking/get-tracking-attribution.ts +46 -3
- package/src/tracking/google-ads.ts +84 -0
- package/src/tracking/gtag.ts +26 -13
- package/src/tracking/gtm.tsx +60 -0
- package/src/tracking/index.ts +187 -133
- package/src/tracking/inline-script.ts +49 -0
- package/src/tracking/live-heartbeat.tsx +63 -0
- package/src/tracking/page-views.tsx +96 -0
- package/src/tracking/storefront-tags.tsx +75 -0
- package/src/tracking/tiktok-pixel.tsx +83 -0
- package/src/tracking/track-init.tsx +56 -0
- package/src/tracking/track-order-purchase.tsx +122 -0
- package/src/tracking/ttq.ts +180 -0
- package/src/tracking/types.ts +23 -0
- package/src/tracking/use-tracking-config.ts +54 -0
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/integrations.ts
CHANGED
|
@@ -1,117 +1,118 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @cartbase/storefront/api/integrations — store-public integrations config
|
|
3
|
-
* (couriers-port + tracking-integrations cards).
|
|
4
|
-
*
|
|
5
|
-
* Ground truth: src/app/api/store/integrations/{route.ts,boxnow/lockers/
|
|
6
|
-
* route.ts} + src/lib/integrations/store-config.ts +
|
|
7
|
-
* src/lib/tracking/store-config-block.ts.
|
|
8
|
-
*
|
|
9
|
-
* The config payload is COMPOSED from an ordered block registry — each
|
|
10
|
-
* block owns distinct top-level keys (`carriers`, `tracking` today; future
|
|
11
|
-
* blocks append). NOT wrapped in an envelope: the blocks ARE the top-level
|
|
12
|
-
* keys. (The `cod` block died with the 'cod' integration, 2026-08-11 —
|
|
13
|
-
* method fees now ride the payment listing entries themselves.)
|
|
14
|
-
*
|
|
15
|
-
* SECURITY LAW: every block is an explicit allowlist — credentials
|
|
16
|
-
* (carrier API keys, CAPI access_token, GA4 api_secret, Klaviyo
|
|
17
|
-
* private_key) can NEVER appear in this payload; the contract tests assert
|
|
18
|
-
* it key-by-key.
|
|
19
|
-
*/
|
|
20
|
-
|
|
21
|
-
import type { StorefrontClient } from "./http"
|
|
22
|
-
|
|
23
|
-
/** Public capability flags of one ENABLED carrier. */
|
|
24
|
-
export interface PublicCarrierConfig {
|
|
25
|
-
enabled: true
|
|
26
|
-
/** Carrier supports cash-on-delivery collection. */
|
|
27
|
-
cod: boolean
|
|
28
|
-
/** Office/pickup-point delivery. */
|
|
29
|
-
pickup_points: boolean
|
|
30
|
-
/** Locker/APM network. */
|
|
31
|
-
lockers: boolean
|
|
32
|
-
/** Present only when lockers=true — the public locker-directory endpoint
|
|
33
|
-
* (path relative to the API base, e.g. `/api/store/integrations/boxnow/lockers`). */
|
|
34
|
-
lockers_url?: string
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/** Public tag config — only ENABLED providers with a public id appear.
|
|
38
|
-
* Secrets can never appear here (explicit allowlist). */
|
|
39
|
-
export interface StoreTrackingBlock {
|
|
40
|
-
facebookPixel?: { pixelId: string }
|
|
41
|
-
gtm?: { containerId: string }
|
|
42
|
-
ga4?: { measurementId: string }
|
|
43
|
-
klaviyo?: { publicKey: string }
|
|
44
|
-
googleAds?: { conversionId: string; conversionLabel?: string }
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
* client
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
1
|
+
/**
|
|
2
|
+
* @cartbase/storefront/api/integrations — store-public integrations config
|
|
3
|
+
* (couriers-port + tracking-integrations cards).
|
|
4
|
+
*
|
|
5
|
+
* Ground truth: src/app/api/store/integrations/{route.ts,boxnow/lockers/
|
|
6
|
+
* route.ts} + src/lib/integrations/store-config.ts +
|
|
7
|
+
* src/lib/tracking/store-config-block.ts.
|
|
8
|
+
*
|
|
9
|
+
* The config payload is COMPOSED from an ordered block registry — each
|
|
10
|
+
* block owns distinct top-level keys (`carriers`, `tracking` today; future
|
|
11
|
+
* blocks append). NOT wrapped in an envelope: the blocks ARE the top-level
|
|
12
|
+
* keys. (The `cod` block died with the 'cod' integration, 2026-08-11 —
|
|
13
|
+
* method fees now ride the payment listing entries themselves.)
|
|
14
|
+
*
|
|
15
|
+
* SECURITY LAW: every block is an explicit allowlist — credentials
|
|
16
|
+
* (carrier API keys, CAPI access_token, GA4 api_secret, Klaviyo
|
|
17
|
+
* private_key) can NEVER appear in this payload; the contract tests assert
|
|
18
|
+
* it key-by-key.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { StorefrontClient } from "./http"
|
|
22
|
+
|
|
23
|
+
/** Public capability flags of one ENABLED carrier. */
|
|
24
|
+
export interface PublicCarrierConfig {
|
|
25
|
+
enabled: true
|
|
26
|
+
/** Carrier supports cash-on-delivery collection. */
|
|
27
|
+
cod: boolean
|
|
28
|
+
/** Office/pickup-point delivery. */
|
|
29
|
+
pickup_points: boolean
|
|
30
|
+
/** Locker/APM network. */
|
|
31
|
+
lockers: boolean
|
|
32
|
+
/** Present only when lockers=true — the public locker-directory endpoint
|
|
33
|
+
* (path relative to the API base, e.g. `/api/store/integrations/boxnow/lockers`). */
|
|
34
|
+
lockers_url?: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Public tag config — only ENABLED providers with a public id appear.
|
|
38
|
+
* Secrets can never appear here (explicit allowlist). */
|
|
39
|
+
export interface StoreTrackingBlock {
|
|
40
|
+
facebookPixel?: { pixelId: string }
|
|
41
|
+
gtm?: { containerId: string }
|
|
42
|
+
ga4?: { measurementId: string }
|
|
43
|
+
klaviyo?: { publicKey: string }
|
|
44
|
+
googleAds?: { conversionId: string; conversionLabel?: string }
|
|
45
|
+
tiktok?: { pixelId: string }
|
|
46
|
+
/** True when the store's consent CMP is enabled — mount tags ONLY through
|
|
47
|
+
* the consent gate (`_1c_consent` / Consent Mode v2). */
|
|
48
|
+
consent_required: boolean
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The composed payload of GET /api/store/integrations. */
|
|
52
|
+
export interface StoreIntegrationsConfig {
|
|
53
|
+
/** Keyed by provider slug (e.g. `boxnow`, `econt`). Disabled carriers are
|
|
54
|
+
* ABSENT, never `enabled: false`. */
|
|
55
|
+
carriers: Record<string, PublicCarrierConfig>
|
|
56
|
+
tracking: StoreTrackingBlock
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* GET /api/store/integrations — everything a storefront needs at render/
|
|
61
|
+
* checkout time about the store's integrations, secrets excluded by
|
|
62
|
+
* construction.
|
|
63
|
+
*
|
|
64
|
+
* Auth: anon (x-client-id); `x-publishable-api-key` is VALIDATED when the
|
|
65
|
+
* client sends one (unknown/revoked/foreign → 400 invalid_publishable_key)
|
|
66
|
+
* and may be omitted by single-channel storefronts.
|
|
67
|
+
* Errors: 400 missing_client_id · 400 invalid_publishable_key.
|
|
68
|
+
* Settings: admin → Settings → Integrations (per-provider enable/config);
|
|
69
|
+
* consent settings drive `tracking.consent_required`.
|
|
70
|
+
*
|
|
71
|
+
* Storefront wiring: mount tags from `tracking` + consent state; Purchase
|
|
72
|
+
* events MUST use `eventID = "purchase_" + order.display_id` so Meta
|
|
73
|
+
* dedupes browser Pixel vs server CAPI; write TrackingAttribution keys into
|
|
74
|
+
* `cart.metadata` (consent-gated) so server events inherit fbp/fbc/ga
|
|
75
|
+
* signals.
|
|
76
|
+
*/
|
|
77
|
+
export async function getIntegrationsConfig(
|
|
78
|
+
client: StorefrontClient
|
|
79
|
+
): Promise<StoreIntegrationsConfig> {
|
|
80
|
+
return client.get("/api/store/integrations")
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** One BoxNow locker (APM) for the checkout picker. */
|
|
84
|
+
export interface BoxNowLocker {
|
|
85
|
+
id: string
|
|
86
|
+
title: string
|
|
87
|
+
addressLine1: string
|
|
88
|
+
addressLine2: string
|
|
89
|
+
postalCode: string
|
|
90
|
+
country: string
|
|
91
|
+
/** Numbers or null — malformed carrier coordinates coerce to null, never NaN. */
|
|
92
|
+
lat: number | null
|
|
93
|
+
lng: number | null
|
|
94
|
+
note: string
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface BoxNowLockersResponse {
|
|
98
|
+
lockers: BoxNowLocker[]
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* GET /api/store/integrations/boxnow/lockers — the BoxNow locker directory
|
|
103
|
+
* for the checkout locker picker. Cached hard (in-process 10-min TTL per
|
|
104
|
+
* store + `Cache-Control: public, max-age=600, stale-while-revalidate=3600`)
|
|
105
|
+
* — locker locations change on a weeks timescale.
|
|
106
|
+
*
|
|
107
|
+
* Auth: anon (x-client-id).
|
|
108
|
+
* Errors: 503 `{message, lockers: []}` when BoxNow is not configured/
|
|
109
|
+
* enabled for the store · 502 `{message, lockers: []}` when the carrier
|
|
110
|
+
* call fails. (Both carry the `lockers` key — a picker can always map over
|
|
111
|
+
* it.) Discover availability via `carriers.boxnow.lockers_url` on the
|
|
112
|
+
* integrations config instead of probing for the 503.
|
|
113
|
+
*/
|
|
114
|
+
export async function listBoxNowLockers(
|
|
115
|
+
client: StorefrontClient
|
|
116
|
+
): Promise<BoxNowLockersResponse> {
|
|
117
|
+
return client.get("/api/store/integrations/boxnow/lockers")
|
|
118
|
+
}
|
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
|
+
}
|