@cartbase/storefront 0.6.0 → 0.7.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 CHANGED
@@ -1,58 +1,75 @@
1
- # @cartbase/storefront
2
-
3
- The storefront kit for the [Cartbase](https://cartbase.net) commerce
4
- platform: a fully-typed API client for every store endpoint, checkout
5
- orchestration, a production-grade cart drawer, product and catalog
6
- components, and consent-gated tracking — everything a storefront needs
7
- between your pages and your store.
8
-
9
- ```bash
10
- npm create cartbase my-store # scaffold with everything prewired
11
- # or add to an existing Next.js app:
12
- npm install @cartbase/storefront
13
- ```
14
-
15
- ## Setup
16
-
17
- The package is **source-shipped TypeScript** — tell Next.js to compile it:
18
-
19
- ```js
20
- // next.config.js
21
- module.exports = {
22
- transpilePackages: ["@cartbase/storefront"],
23
- }
24
- ```
25
-
26
- Three environment values wire it to your store (all in your Cartbase
27
- admin under **Settings**):
28
-
29
- ```bash
30
- NEXT_PUBLIC_CARTBASE_URL= # your store's API origin
31
- NEXT_PUBLIC_CARTBASE_CLIENT_ID= # your store's client id
32
- NEXT_PUBLIC_CARTBASE_PUBLISHABLE_KEY= # optional, channel-scoped catalogs
33
- ```
34
-
35
- ## What's inside
36
-
37
- | Import | What you get |
38
- |---|---|
39
- | `@cartbase/storefront/api` | Typed client for the whole store API — products, carts, checkout, orders, customers, auth, content, search, reviews |
40
- | `@cartbase/storefront/checkout` | The complete checkout: orchestration hook, address forms, shipping/payment method lists, Stripe wrapper, carrier pickers |
41
- | `@cartbase/storefront/cart-drawer` | Slide-out cart with upsells, tiers, gift options, notes |
42
- | `@cartbase/storefront/products` | PDP building blocks: gallery, options, prices, actions |
43
- | `@cartbase/storefront/store` | Listing pages: pagination, sorting, collection/category/search templates |
44
- | `@cartbase/storefront/order` | Order confirmation surfaces |
45
- | `@cartbase/storefront/tracking` | Consent banner + consent-gated Meta Pixel / GA4 / analytics |
46
- | `@cartbase/storefront/theme` | The design system: token names plus a filled default set of values (Tailwind 4, CSS-first) |
47
-
48
- ## Documentation
49
-
50
- The complete reference every endpoint with request/response shapes and
51
- working examples, plus a step-by-step build runbook — is written to be
52
- followed by a developer **or handed to a coding agent as-is**. It ships
53
- inside every `npm create cartbase` scaffold under `docs/`, and online at
54
- your store's docs site. Start with *Build a storefront — the runbook*.
55
-
56
- ## License
57
-
58
- MIT © Cartbase
1
+ # @cartbase/storefront
2
+
3
+ The storefront kit for the [Cartbase](https://cartbase.net) commerce
4
+ platform: a fully-typed API client for every store endpoint, checkout
5
+ orchestration, a production-grade cart drawer, product and catalog
6
+ components, and consent-gated tracking — everything a storefront needs
7
+ between your pages and your store.
8
+
9
+ ```bash
10
+ npm create cartbase my-store # scaffold with everything prewired
11
+ # or add to an existing Next.js app:
12
+ npm install @cartbase/storefront
13
+ ```
14
+
15
+ ## Setup
16
+
17
+ The package is **source-shipped TypeScript** — tell Next.js to compile it:
18
+
19
+ ```js
20
+ // next.config.js
21
+ module.exports = {
22
+ transpilePackages: ["@cartbase/storefront"],
23
+ }
24
+ ```
25
+
26
+ Three environment values wire it to your store (all in your Cartbase
27
+ admin under **Settings**):
28
+
29
+ ```bash
30
+ NEXT_PUBLIC_CARTBASE_URL= # your store's API origin
31
+ NEXT_PUBLIC_CARTBASE_CLIENT_ID= # your store's client id
32
+ NEXT_PUBLIC_CARTBASE_PUBLISHABLE_KEY= # optional, channel-scoped catalogs
33
+ ```
34
+
35
+ ## What's inside
36
+
37
+ | Import | What you get |
38
+ |---|---|
39
+ | `@cartbase/storefront/api` | Typed client for the whole store API — products, carts, checkout, orders, customers, auth, content, search, reviews |
40
+ | `@cartbase/storefront/checkout` | The complete checkout: orchestration hook, address forms, shipping/payment method lists, Stripe wrapper, carrier pickers |
41
+ | `@cartbase/storefront/cart-drawer` | Slide-out cart with upsells, tiers, gift options, notes |
42
+ | `@cartbase/storefront/products` | PDP building blocks: gallery, options, prices, actions |
43
+ | `@cartbase/storefront/store` | Listing pages: pagination, sorting, collection/category/search templates |
44
+ | `@cartbase/storefront/order` | Order confirmation surfaces |
45
+ | `@cartbase/storefront/tracking` | Consent banner and Consent Mode v2, every marketing tag the store configured mounted from its own config (Meta Pixel, TikTok, Google Analytics 4 and Google Ads on one tag, Google Tag Manager), and one call per commerce moment that fires all of them |
46
+ | `@cartbase/storefront/theme` | The design system: token names plus a filled default set of values (Tailwind 4, CSS-first) |
47
+
48
+ ### Tracking, in three lines
49
+
50
+ The merchant configures their pixels in the admin; the storefront mounts
51
+ what they configured. Nothing per vendor:
52
+
53
+ ```tsx
54
+ <ConsentInit /> // first child of <body>, sets the consent defaults
55
+ <StorefrontTags client={client} /> // every configured tag, gated by the above
56
+ <TrackInit /> // captures UTMs + ad-click ids on the landing page
57
+ ```
58
+
59
+ Then one call per moment (`trackProductView`, `trackCartAdd`,
60
+ `trackCheckoutStart`) and `<TrackOrderPurchase>` on the confirmation
61
+ page. Purchase events dedupe against the platform's own server-side
62
+ events automatically — the ids are derived from the order number on both
63
+ sides, so the browser event and the server event count as one conversion.
64
+
65
+ ## Documentation
66
+
67
+ The complete reference — every endpoint with request/response shapes and
68
+ working examples, plus a step-by-step build runbook — is written to be
69
+ followed by a developer **or handed to a coding agent as-is**. It ships
70
+ inside every `npm create cartbase` scaffold under `docs/`, and online at
71
+ your store's docs site. Start with *Build a storefront — the runbook*.
72
+
73
+ ## License
74
+
75
+ MIT © Cartbase
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cartbase/storefront",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Storefront SDK + UI component library for Cartbase stores: typed API client, checkout orchestration, cart drawer, product/catalog components, tracking. Source-shipped TypeScript — add it to transpilePackages.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -77,12 +77,22 @@
77
77
  "./tracking/consent-banner": "./src/tracking/consent-banner.tsx",
78
78
  "./tracking/meta-pixel": "./src/tracking/meta-pixel.tsx",
79
79
  "./tracking/ga4": "./src/tracking/ga4.tsx",
80
+ "./tracking/tiktok-pixel": "./src/tracking/tiktok-pixel.tsx",
81
+ "./tracking/ttq": "./src/tracking/ttq.ts",
82
+ "./tracking/events": "./src/tracking/events.ts",
83
+ "./tracking/storefront-tags": "./src/tracking/storefront-tags.tsx",
84
+ "./tracking/track-init": "./src/tracking/track-init.tsx",
85
+ "./tracking/track-order-purchase": "./src/tracking/track-order-purchase.tsx",
86
+ "./tracking/gtm": "./src/tracking/gtm.tsx",
87
+ "./tracking/inline-script": "./src/tracking/inline-script.ts",
88
+ "./tracking/google-ads": "./src/tracking/google-ads.ts",
80
89
  "./tracking/rybbit": "./src/tracking/rybbit.tsx",
81
90
  "./tracking/rybbit-events": "./src/tracking/rybbit-events.ts",
82
91
  "./tracking/fbq": "./src/tracking/fbq.ts",
83
92
  "./tracking/gtag": "./src/tracking/gtag.ts",
84
93
  "./tracking/attribution": "./src/tracking/attribution.ts",
85
94
  "./tracking/get-tracking-config": "./src/tracking/get-tracking-config.ts",
95
+ "./tracking/use-tracking-config": "./src/tracking/use-tracking-config.ts",
86
96
  "./tracking/get-tracking-attribution": "./src/tracking/get-tracking-attribution.ts",
87
97
  "./tracking/use-engagement-time": "./src/tracking/use-engagement-time.ts",
88
98
  "./checkout": "./src/checkout/index.ts",
@@ -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
- /** True when the store's consent CMP is enabled — mount tags ONLY through
46
- * the consent gate (`_1c_consent` / Consent Mode v2). */
47
- consent_required: boolean
48
- }
49
-
50
- /** The composed payload of GET /api/store/integrations. */
51
- export interface StoreIntegrationsConfig {
52
- /** Keyed by provider slug (e.g. `boxnow`, `econt`). Disabled carriers are
53
- * ABSENT, never `enabled: false`. */
54
- carriers: Record<string, PublicCarrierConfig>
55
- tracking: StoreTrackingBlock
56
- }
57
-
58
- /**
59
- * GET /api/store/integrations — everything a storefront needs at render/
60
- * checkout time about the store's integrations, secrets excluded by
61
- * construction.
62
- *
63
- * Auth: anon (x-client-id); `x-publishable-api-key` is VALIDATED when the
64
- * client sends one (unknown/revoked/foreign 400 invalid_publishable_key)
65
- * and may be omitted by single-channel storefronts.
66
- * Errors: 400 missing_client_id · 400 invalid_publishable_key.
67
- * Settings: admin Settings Integrations (per-provider enable/config);
68
- * consent settings drive `tracking.consent_required`.
69
- *
70
- * Storefront wiring: mount tags from `tracking` + consent state; Purchase
71
- * events MUST use `eventID = "purchase_" + order.display_id` so Meta
72
- * dedupes browser Pixel vs server CAPI; write TrackingAttribution keys into
73
- * `cart.metadata` (consent-gated) so server events inherit fbp/fbc/ga
74
- * signals.
75
- */
76
- export async function getIntegrationsConfig(
77
- client: StorefrontClient
78
- ): Promise<StoreIntegrationsConfig> {
79
- return client.get("/api/store/integrations")
80
- }
81
-
82
- /** One BoxNow locker (APM) for the checkout picker. */
83
- export interface BoxNowLocker {
84
- id: string
85
- title: string
86
- addressLine1: string
87
- addressLine2: string
88
- postalCode: string
89
- country: string
90
- /** Numbers or null — malformed carrier coordinates coerce to null, never NaN. */
91
- lat: number | null
92
- lng: number | null
93
- note: string
94
- }
95
-
96
- export interface BoxNowLockersResponse {
97
- lockers: BoxNowLocker[]
98
- }
99
-
100
- /**
101
- * GET /api/store/integrations/boxnow/lockers — the BoxNow locker directory
102
- * for the checkout locker picker. Cached hard (in-process 10-min TTL per
103
- * store + `Cache-Control: public, max-age=600, stale-while-revalidate=3600`)
104
- * locker locations change on a weeks timescale.
105
- *
106
- * Auth: anon (x-client-id).
107
- * Errors: 503 `{message, lockers: []}` when BoxNow is not configured/
108
- * enabled for the store · 502 `{message, lockers: []}` when the carrier
109
- * call fails. (Both carry the `lockers` key a picker can always map over
110
- * it.) Discover availability via `carriers.boxnow.lockers_url` on the
111
- * integrations config instead of probing for the 503.
112
- */
113
- export async function listBoxNowLockers(
114
- client: StorefrontClient
115
- ): Promise<BoxNowLockersResponse> {
116
- return client.get("/api/store/integrations/boxnow/lockers")
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
+ }
@@ -265,6 +265,38 @@ export function normalisePhoneForHash(raw: string): string {
265
265
  return digits
266
266
  }
267
267
 
268
+ /**
269
+ * Normalise a phone for GOOGLE, which is not the same string as Meta's.
270
+ *
271
+ * Google requires E.164 WITH the leading plus: "must be 11 to 15 digits
272
+ * including a plus sign (+) prefix and country code with no dashes,
273
+ * parentheses, or spaces"
274
+ * (https://support.google.com/google-ads/answer/13258081). Meta requires
275
+ * digits only. Two different strings, two different digests, and a shared
276
+ * normaliser can only ever serve one of them.
277
+ *
278
+ * This existing as a separate function is the fix for a real defect:
279
+ * `setEnhancedConversions` fed Meta's digits-only value into Google's
280
+ * `sha256_phone_number`, which matched nobody, for as long as it shipped.
281
+ * It fails silently — gtag accepts the hash, no error is raised anywhere,
282
+ * and the only symptom is enhanced conversions recovering fewer
283
+ * conversions than they should. Whoever refactors these two into one
284
+ * "shared" helper will recreate it.
285
+ *
286
+ * Returns undefined when the result could not be a real E.164 number:
287
+ * hashing a malformed number is strictly worse than sending nothing,
288
+ * because it cannot match AND it occupies the field Google would
289
+ * otherwise fall back from.
290
+ */
291
+ export function normalisePhoneForGoogleHash(raw: string): string | undefined {
292
+ const digits = normalisePhoneForHash(raw)
293
+ if (!digits) return undefined
294
+ // Google's stated range, country code included. A Bulgarian mobile is
295
+ // 12 digits (359 plus 9 national).
296
+ if (digits.length < 11 || digits.length > 15) return undefined
297
+ return "+" + digits
298
+ }
299
+
268
300
  // ── UTM attribution capture ──────────────────────────────────────────
269
301
  //
270
302
  // Captures `utm_source / utm_medium / utm_campaign / utm_term /
@@ -416,3 +448,97 @@ export function getCapturedFirstTouchUtms(): CapturedUtms | null {
416
448
  export function getCapturedLastTouchUtms(): CapturedUtms | null {
417
449
  return readUtmsCookie(UTM_LAST_COOKIE)
418
450
  }
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
+ }
@@ -117,6 +117,20 @@ export function applyConsent(choices: ConsentChoices): void {
117
117
  if (typeof w.fbq === "function") {
118
118
  w.fbq("consent", choices.ads ? "grant" : "revoke")
119
119
  }
120
+
121
+ // TikTok. `ttq` registers grantConsent / revokeConsent alongside track
122
+ // and page, and `setAndDefer` queues them before the SDK is fetched, so
123
+ // this works whether or not the pixel script has loaded yet.
124
+ //
125
+ // The decision is relayed HERE, in the one function every consent door
126
+ // already goes through (the built-in banner, setConsent for an external
127
+ // CMP), rather than by watching the cookie from outside. A vendor added
128
+ // to this list is live everywhere at once; a vendor that has to poll for
129
+ // the decision is a vendor that will one day miss it.
130
+ if (w.ttq && typeof w.ttq.grantConsent === "function") {
131
+ if (choices.ads) w.ttq.grantConsent()
132
+ else w.ttq.revokeConsent()
133
+ }
120
134
  }
121
135
 
122
136
  /**