@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.
- 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/cart-drawer/cross-sell-carousel.tsx +2 -3
- package/src/cart-drawer/gift-wrap.tsx +82 -83
- package/src/cart-drawer/item/index.tsx +4 -6
- package/src/cart-drawer/sticky-footer.tsx +73 -73
- package/src/checkout/geocode.ts +1 -1
- package/src/checkout/gift-card-section.tsx +2 -2
- package/src/checkout/line-item-card.tsx +3 -3
- package/src/checkout/order-summary.tsx +11 -12
- package/src/checkout/payment-button.tsx +2 -3
- package/src/checkout/shipping-method-list.tsx +2 -2
- package/src/lib/cookie-names.ts +45 -0
- package/src/lib/money.ts +1 -1
- package/src/lib/platform.ts +13 -0
- package/src/lib/price.tsx +39 -0
- package/src/lib/visitor.ts +74 -0
- package/src/order/order-delivery-card.tsx +2 -2
- package/src/order/order-item.tsx +3 -3
- package/src/order/order-totals.tsx +4 -4
- package/src/tracking/attribution.ts +134 -105
- package/src/tracking/chatgpt-pixel.tsx +92 -0
- package/src/tracking/consent.ts +12 -0
- package/src/tracking/events.ts +56 -2
- package/src/tracking/get-tracking-attribution.ts +16 -3
- package/src/tracking/index.ts +23 -0
- package/src/tracking/live-heartbeat.tsx +63 -0
- package/src/tracking/oaiq.ts +206 -0
- package/src/tracking/page-views.tsx +96 -0
- package/src/tracking/storefront-tags.tsx +77 -67
- package/src/tracking/types.ts +4 -0
- package/src/lib/dual-price.tsx +0 -73
|
@@ -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 {
|
|
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
|
-
<
|
|
276
|
+
<Price amount={price} currencyCode={currencyCode} />
|
|
277
277
|
)
|
|
278
278
|
) : isLoadingPrices ? (
|
|
279
279
|
<svg
|
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
|
+
}
|
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
|
|
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 {
|
|
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
|
-
<
|
|
248
|
+
<Price
|
|
249
249
|
amount={amount}
|
|
250
250
|
currencyCode={currencyCode}
|
|
251
251
|
className="text-sm font-medium text-foreground"
|
package/src/order/order-item.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import Image from "next/image"
|
|
2
2
|
import { ImageOff } from "lucide-react"
|
|
3
|
-
import {
|
|
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
|
-
<
|
|
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
|
-
<
|
|
187
|
+
<Price
|
|
188
188
|
amount={item.total}
|
|
189
189
|
currencyCode={currencyCode}
|
|
190
190
|
className={`text-sm font-bold ${
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
|
|
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,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
|
+
}
|