@cartbase/storefront 0.7.0 → 0.9.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.
@@ -3,7 +3,7 @@
3
3
  import type { StorefrontClient } from "../api/http"
4
4
  import type { StoreShippingOption } from "../api/checkout"
5
5
  import type { BoxNowLocker } from "../api/integrations"
6
- import { DualPrice } from "../lib/dual-price"
6
+ import { Price } from "../lib/price"
7
7
  import { cn } from "../lib/utils"
8
8
  import { useCheckoutLabels } from "./context"
9
9
  import {
@@ -273,7 +273,7 @@ export function CheckoutShippingMethodList({
273
273
  isFree ? (
274
274
  labels.shippingFree
275
275
  ) : (
276
- <DualPrice amount={price} currencyCode={currencyCode} />
276
+ <Price amount={price} currencyCode={currencyCode} />
277
277
  )
278
278
  ) : isLoadingPrices ? (
279
279
  <svg
@@ -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
+ }
package/src/lib/money.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * Money utilities — pure, side-effect-free currency formatting helpers.
3
3
  *
4
4
  * Extracted from mindpages-storefront src/lib/util/money.ts + isEmpty.ts.
5
- * Used by DualPrice and any other price rendering in the library.
5
+ * Used by Price and any other price rendering in the library.
6
6
  */
7
7
 
8
8
  function isEmpty(value: unknown): boolean {
@@ -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,39 @@
1
+ import * as React from "react"
2
+
3
+ import { convertToLocale } from "./money"
4
+
5
+ /**
6
+ * Price — render an amount in its own currency, and nothing else.
7
+ *
8
+ * Replaces `DualPrice` (deleted 2026-09-13, Alexander: "clean and clear
9
+ * the dual pricing"). That component appended a grey BGN leg to every EUR
10
+ * price for Bulgaria's euro-changeover disclosure rule, with the statutory
11
+ * 1.95583 rate baked in. Cartbase prices in EUR (see the EUR-only law) and
12
+ * the second currency belongs to a store's own locale work if it ever
13
+ * wants one, not to every price in the library.
14
+ *
15
+ * Stateless and side-effect-free, so it renders in server and client
16
+ * components alike, and it is deliberately NOT a client component: the old
17
+ * one carried "use client" for no reason, which pulled every price
18
+ * rendering into the client bundle.
19
+ *
20
+ * @example
21
+ * <Price amount={19.99} currencyCode="eur" /> // "€19.99"
22
+ */
23
+
24
+ export type PriceProps = {
25
+ /** Amount in major units of the currency (19.99 for €19.99). */
26
+ amount: number
27
+ /** ISO currency code, case-insensitive. */
28
+ currencyCode: string
29
+ /** Class for the span. */
30
+ className?: string
31
+ }
32
+
33
+ export function Price({ amount, currencyCode, className }: PriceProps): React.ReactElement {
34
+ return (
35
+ <span className={className}>
36
+ {convertToLocale({ amount, currency_code: currencyCode })}
37
+ </span>
38
+ )
39
+ }
@@ -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,5 @@
1
1
  import { ExternalLink, MapPin, PackageSearch, Truck } from "lucide-react"
2
- import { DualPrice } from "../lib/dual-price"
2
+ import { Price } from "../lib/price"
3
3
  import type { StoreOrderFulfillment } from "../api/orders"
4
4
  import { defaultOrderLabels, type OrderLabels } from "./labels"
5
5
 
@@ -245,7 +245,7 @@ function ShippingMethodRow({
245
245
  {amount === 0 ? (
246
246
  <span className="text-sm font-medium text-success">{labels.free}</span>
247
247
  ) : (
248
- <DualPrice
248
+ <Price
249
249
  amount={amount}
250
250
  currencyCode={currencyCode}
251
251
  className="text-sm font-medium text-foreground"
@@ -1,6 +1,6 @@
1
1
  import Image from "next/image"
2
2
  import { ImageOff } from "lucide-react"
3
- import { DualPrice } from "../lib/dual-price"
3
+ import { Price } from "../lib/price"
4
4
  import type { StoreOrderItem } from "../api/orders"
5
5
  import { defaultOrderLabels, type OrderLabels } from "./labels"
6
6
 
@@ -177,14 +177,14 @@ export function OrderItem({ item, currencyCode, labels }: OrderItemProps) {
177
177
  <div className="text-right flex-shrink-0">
178
178
  {hasDiscount && (
179
179
  <span className="text-xs text-muted-foreground line-through block leading-none mb-0.5">
180
- <DualPrice
180
+ <Price
181
181
  amount={item.originalTotal ?? 0}
182
182
  currencyCode={currencyCode}
183
183
  className="text-xs text-muted-foreground"
184
184
  />
185
185
  </span>
186
186
  )}
187
- <DualPrice
187
+ <Price
188
188
  amount={item.total}
189
189
  currencyCode={currencyCode}
190
190
  className={`text-sm font-bold ${
@@ -1,4 +1,4 @@
1
- import { DualPrice } from "../lib/dual-price"
1
+ import { Price } from "../lib/price"
2
2
  import { findFeeLine, type LineLike } from "../lib/cart-helpers"
3
3
  import { defaultOrderLabels, type OrderLabels } from "./labels"
4
4
 
@@ -217,14 +217,14 @@ export function OrderTotals({
217
217
  ) : row.negative ? (
218
218
  <span className="text-sm text-success">
219
219
  -{" "}
220
- <DualPrice
220
+ <Price
221
221
  amount={row.amount}
222
222
  currencyCode={currencyCode}
223
223
  className="text-sm text-success"
224
224
  />
225
225
  </span>
226
226
  ) : (
227
- <DualPrice
227
+ <Price
228
228
  amount={row.amount}
229
229
  currencyCode={currencyCode}
230
230
  className="text-sm text-foreground"
@@ -237,7 +237,7 @@ export function OrderTotals({
237
237
  <div className="h-px bg-border my-3" />
238
238
  <div className="flex justify-between items-baseline">
239
239
  <span className="text-[15px] font-bold text-foreground">{l.total}</span>
240
- <DualPrice
240
+ <Price
241
241
  amount={total}
242
242
  currencyCode={currencyCode}
243
243
  className="text-xl font-bold text-foreground tracking-tight"
@@ -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,112 @@ 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
+ * OpenAI's click id, from `oppref` on the landing URL.
501
+ *
502
+ * Their own SDK stores the same parameter in a first-party `__oppref`
503
+ * cookie at init, and we capture it here anyway: the Conversions API takes
504
+ * `oppref` on the server event explicitly, and this module runs on the
505
+ * landing page whether or not their script loaded or was consented to. Ours
506
+ * is the one the server reads.
507
+ */
508
+ const OPPREF_COOKIE = "_1c_oppref"
509
+
510
+ /** TikTok's ttclid lifetime follows the CTA window in Attribution
511
+ * Manager; Google's conversion window tops out at 90 days. One TTL
512
+ * covers both, and each platform applies its own window on top and
513
+ * ignores anything older. */
514
+ const CLICK_ID_TTL_DAYS = 90
515
+
516
+ /** Cookie name → the URL parameter it is captured from. */
517
+ const CLICK_ID_CAPTURE: ReadonlyArray<readonly [string, string]> = [
518
+ [TTCLID_COOKIE, "ttclid"],
519
+ [GCLID_COOKIE, "gclid"],
520
+ [GBRAID_COOKIE, "gbraid"],
521
+ [WBRAID_COOKIE, "wbraid"],
522
+ [OPPREF_COOKIE, "oppref"],
523
+ ]
524
+
525
+ /**
526
+ * The cookies the click ids live in, and the flat `order.metadata` keys
527
+ * the checkout writeback maps them onto. `ttp` is in the list because it
528
+ * is READ from TikTok's own cookie; it is never written here.
529
+ */
530
+ export const CLICK_ID_COOKIES = {
531
+ ttclid: TTCLID_COOKIE,
532
+ gclid: GCLID_COOKIE,
533
+ gbraid: GBRAID_COOKIE,
534
+ wbraid: WBRAID_COOKIE,
535
+ ttp: "_ttp",
536
+ oppref: OPPREF_COOKIE,
537
+ } as const
538
+
539
+ /** cookie name → order.metadata key (code truth for the server side:
540
+ * `TrackingAttributionMeta` in src/lib/tracking/constants.ts). */
541
+ export const CLICK_ID_METADATA_KEYS: ReadonlyArray<readonly [string, string]> = [
542
+ [CLICK_ID_COOKIES.ttclid, "tt_ttclid"],
543
+ [CLICK_ID_COOKIES.ttp, "tt_ttp"],
544
+ [CLICK_ID_COOKIES.gclid, "google_gclid"],
545
+ [CLICK_ID_COOKIES.gbraid, "google_gbraid"],
546
+ [CLICK_ID_COOKIES.wbraid, "google_wbraid"],
547
+ [CLICK_ID_COOKIES.oppref, "oai_oppref"],
548
+ ]
549
+
550
+ /**
551
+ * Capture every ad-click identifier present on the current URL.
552
+ *
553
+ * Idempotent and safe to call on every mount, next to
554
+ * `captureUtmsFromUrl()`: a URL carrying no click id leaves every
555
+ * existing cookie untouched.
556
+ */
557
+ export function captureClickIdsFromUrl(): void {
558
+ if (!isBrowser()) return
559
+
560
+ let params: URLSearchParams
561
+ try {
562
+ params = new URLSearchParams(window.location.search)
563
+ } catch {
564
+ return
565
+ }
566
+
567
+ for (const [cookieName, param] of CLICK_ID_CAPTURE) {
568
+ const raw = params.get(param)
569
+ if (raw && raw.trim().length > 0) {
570
+ setCookie(cookieName, encodeURIComponent(raw.trim()), CLICK_ID_TTL_DAYS)
571
+ }
572
+ }
450
573
  }
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
- }
@@ -0,0 +1,92 @@
1
+ "use client"
2
+
3
+ import Script from "next/script"
4
+
5
+ import { CONSENT_COOKIE } from "./consent"
6
+ import { jsStringLiteral } from "./inline-script"
7
+
8
+ /**
9
+ * ChatGptPixel — the OpenAI measurement pixel, the ChatGPT Ads twin of
10
+ * <MetaPixel> and <TikTokPixel>.
11
+ *
12
+ * The loader is OpenAI's own, verbatim from
13
+ * https://developers.openai.com/ads/measurement-pixel: a command queue on
14
+ * `window.oaiq` that buffers calls until the SDK arrives from
15
+ * bzrcdn.openai.com, injected with `<Script strategy="afterInteractive">`.
16
+ *
17
+ * CONSENT IS CLOSED BEFORE INIT, and that order is the whole point. Their
18
+ * consent default is TRUE, so an unconsented visitor is measured unless we
19
+ * say otherwise first:
20
+ *
21
+ * oaiq("consent", false) // queued BEFORE init — no pings
22
+ * oaiq("init", { pixelId })
23
+ * oaiq("consent", true) // only once the visitor accepted
24
+ *
25
+ * The script is LOADED either way, like the Meta and TikTok gates beside
26
+ * it, so a later grant costs no round trip. But the page view is fired
27
+ * only AFTER a grant, and that is deliberate rather than cautious: their
28
+ * docs state that with consent false the pixel sends no measurement
29
+ * pings, and they do NOT document whether a call made while consent was
30
+ * false is released on a later grant. TikTok documents exactly that
31
+ * (`holdConsent` queues, `grantConsent` flushes) and our TikTok gate
32
+ * relies on it. Nothing here relies on behaviour OpenAI has not written
33
+ * down: an unconsented page view is not sent, and not counted on later.
34
+ *
35
+ * The decision is read from the shared `_1c_consent` cookie inside the
36
+ * snippet, before anything can be sent, and live changes arrive through
37
+ * `applyConsent()` in ./consent.ts — there is no cookie watcher.
38
+ *
39
+ * The page view is ours to fire at all: their pixel sends nothing at init,
40
+ * unlike Meta's and TikTok's base code.
41
+ *
42
+ * Their SDK also captures `oppref` off the landing URL into a first-party
43
+ * `__oppref` cookie by itself. Our attribution module captures the same
44
+ * parameter into `_1c_oppref` independently, so the server-side
45
+ * Conversions API event can carry the click even on a visit where this
46
+ * script never loaded at all.
47
+ *
48
+ * Renders nothing when `pixelId` is falsy, so a layout can mount it
49
+ * unconditionally.
50
+ */
51
+ export function ChatGptPixel({ pixelId }: { pixelId?: string }) {
52
+ if (!pixelId) return null
53
+
54
+ const id = jsStringLiteral(pixelId)
55
+
56
+ const initSnippet = `
57
+ (function (w, d, s, u) {
58
+ if (w.oaiq) return;
59
+ var q = function () { q.q.push(arguments); };
60
+ q.q = [];
61
+ w.oaiq = q;
62
+ var js = d.createElement(s);
63
+ js.async = true;
64
+ js.src = u;
65
+ var f = d.getElementsByTagName(s)[0];
66
+ f.parentNode.insertBefore(js, f);
67
+ })(window, document, "script", "https://bzrcdn.openai.com/sdk/oaiq.min.js");
68
+
69
+ oaiq("consent", false);
70
+ oaiq("init", { pixelId: ${id} });
71
+
72
+ (function (d) {
73
+ var ads = false;
74
+ try {
75
+ var m = d.cookie.match(/(?:^|; )${CONSENT_COOKIE}=([^;]*)/);
76
+ if (m) { ads = !!JSON.parse(decodeURIComponent(m[1])).ads; }
77
+ } catch (e) {}
78
+ if (ads) {
79
+ oaiq("consent", true);
80
+ oaiq("measure", "page_viewed", { type: "contents" });
81
+ }
82
+ })(document);
83
+ `.trim()
84
+
85
+ return (
86
+ <Script
87
+ id="chatgpt-pixel-init"
88
+ strategy="afterInteractive"
89
+ dangerouslySetInnerHTML={{ __html: initSnippet }}
90
+ />
91
+ )
92
+ }