@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/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-client-id` required for anon reads (RLS scope)
8
- * - `x-publishable-api-key` optional; scopes catalog/carts to the key's
9
- * sales channels when set
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.mindpages.bg` */
24
+ /** Deployment origin, e.g. `https://admin.cartbase.ai` */
23
25
  baseUrl: string
24
- /** Tenant id — required for anon reads. */
25
- clientId: string
26
- /** Publishable API key (channel scope). Optional for single-channel stores. */
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.clientId) throw new Error("StorefrontClient: clientId is required")
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"
@@ -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
+ }
@@ -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": "barter-storefront/1.0" },
144
+ headers: { "User-Agent": "cartbase-storefront/1.0" },
145
145
  })
146
146
  const data = await res.json()
147
147
  if (data?.[0]) {
@@ -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
- const ANON_ID_COOKIE = "_1c_anon"
33
- const KNOWN_VISITOR_LS = "_1c_visitor_v1"
34
- const UTM_FIRST_COOKIE = "_1c_utm_first"
35
- const UTM_LAST_COOKIE = "_1c_utm_last"
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 = window.localStorage.getItem(KNOWN_VISITOR_LS)
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 = cookieStore.get("_1c_anon")?.value
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 = cookieStore.get("_1c_utm_first")?.value
83
- utmLastRaw = cookieStore.get("_1c_utm_last")?.value
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`.
@@ -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
+ }