@cartbase/storefront 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +74 -58
- package/package.json +248 -233
- package/src/api/carts.ts +16 -1
- package/src/api/http.ts +17 -9
- package/src/api/index.ts +1 -0
- package/src/api/integrations.ts +118 -117
- package/src/api/store.ts +35 -0
- package/src/checkout/geocode.ts +1 -1
- package/src/lib/cookie-names.ts +45 -0
- package/src/lib/platform.ts +13 -0
- package/src/lib/visitor.ts +74 -0
- package/src/tracking/attribution.ts +153 -11
- package/src/tracking/consent.ts +14 -0
- package/src/tracking/events.ts +294 -0
- package/src/tracking/ga4.tsx +93 -49
- package/src/tracking/get-tracking-attribution.ts +46 -3
- package/src/tracking/google-ads.ts +84 -0
- package/src/tracking/gtag.ts +26 -13
- package/src/tracking/gtm.tsx +60 -0
- package/src/tracking/index.ts +187 -133
- package/src/tracking/inline-script.ts +49 -0
- package/src/tracking/live-heartbeat.tsx +63 -0
- package/src/tracking/page-views.tsx +96 -0
- package/src/tracking/storefront-tags.tsx +75 -0
- package/src/tracking/tiktok-pixel.tsx +83 -0
- package/src/tracking/track-init.tsx +56 -0
- package/src/tracking/track-order-purchase.tsx +122 -0
- package/src/tracking/ttq.ts +180 -0
- package/src/tracking/types.ts +23 -0
- package/src/tracking/use-tracking-config.ts +54 -0
|
@@ -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
|
|
@@ -265,6 +281,38 @@ export function normalisePhoneForHash(raw: string): string {
|
|
|
265
281
|
return digits
|
|
266
282
|
}
|
|
267
283
|
|
|
284
|
+
/**
|
|
285
|
+
* Normalise a phone for GOOGLE, which is not the same string as Meta's.
|
|
286
|
+
*
|
|
287
|
+
* Google requires E.164 WITH the leading plus: "must be 11 to 15 digits
|
|
288
|
+
* including a plus sign (+) prefix and country code with no dashes,
|
|
289
|
+
* parentheses, or spaces"
|
|
290
|
+
* (https://support.google.com/google-ads/answer/13258081). Meta requires
|
|
291
|
+
* digits only. Two different strings, two different digests, and a shared
|
|
292
|
+
* normaliser can only ever serve one of them.
|
|
293
|
+
*
|
|
294
|
+
* This existing as a separate function is the fix for a real defect:
|
|
295
|
+
* `setEnhancedConversions` fed Meta's digits-only value into Google's
|
|
296
|
+
* `sha256_phone_number`, which matched nobody, for as long as it shipped.
|
|
297
|
+
* It fails silently — gtag accepts the hash, no error is raised anywhere,
|
|
298
|
+
* and the only symptom is enhanced conversions recovering fewer
|
|
299
|
+
* conversions than they should. Whoever refactors these two into one
|
|
300
|
+
* "shared" helper will recreate it.
|
|
301
|
+
*
|
|
302
|
+
* Returns undefined when the result could not be a real E.164 number:
|
|
303
|
+
* hashing a malformed number is strictly worse than sending nothing,
|
|
304
|
+
* because it cannot match AND it occupies the field Google would
|
|
305
|
+
* otherwise fall back from.
|
|
306
|
+
*/
|
|
307
|
+
export function normalisePhoneForGoogleHash(raw: string): string | undefined {
|
|
308
|
+
const digits = normalisePhoneForHash(raw)
|
|
309
|
+
if (!digits) return undefined
|
|
310
|
+
// Google's stated range, country code included. A Bulgarian mobile is
|
|
311
|
+
// 12 digits (359 plus 9 national).
|
|
312
|
+
if (digits.length < 11 || digits.length > 15) return undefined
|
|
313
|
+
return "+" + digits
|
|
314
|
+
}
|
|
315
|
+
|
|
268
316
|
// ── UTM attribution capture ──────────────────────────────────────────
|
|
269
317
|
//
|
|
270
318
|
// Captures `utm_source / utm_medium / utm_campaign / utm_term /
|
|
@@ -341,8 +389,8 @@ function readUtmsFromSearch(search: string): CapturedUtms | null {
|
|
|
341
389
|
/** Read + parse one of the two UTM cookies. Defensive — malformed JSON
|
|
342
390
|
* or shape drift returns null so the caller treats it as missing
|
|
343
391
|
* instead of crashing. */
|
|
344
|
-
function readUtmsCookie(name: string): CapturedUtms | null {
|
|
345
|
-
const raw = getCookie(name)
|
|
392
|
+
function readUtmsCookie(name: string, legacy?: string): CapturedUtms | null {
|
|
393
|
+
const raw = getCookie(name) ?? (legacy ? getCookie(legacy) : undefined)
|
|
346
394
|
if (!raw) return null
|
|
347
395
|
try {
|
|
348
396
|
const parsed = JSON.parse(decodeURIComponent(raw)) as Partial<CapturedUtms>
|
|
@@ -388,7 +436,7 @@ export function captureUtmsFromUrl(): void {
|
|
|
388
436
|
// First-touch: write only when the cookie is currently absent.
|
|
389
437
|
// Re-arriving with a fresh UTM does NOT overwrite the original
|
|
390
438
|
// acquisition record — that's the whole point of "first-touch".
|
|
391
|
-
if (!readUtmsCookie(UTM_FIRST_COOKIE)) {
|
|
439
|
+
if (!readUtmsCookie(UTM_FIRST_COOKIE, LEGACY_UTM_FIRST_COOKIE)) {
|
|
392
440
|
writeUtmsCookie(UTM_FIRST_COOKIE, utms, UTM_FIRST_TTL_DAYS)
|
|
393
441
|
}
|
|
394
442
|
// Last-touch: always overwrite when the URL carries UTMs. Refreshes
|
|
@@ -404,7 +452,7 @@ export function captureUtmsFromUrl(): void {
|
|
|
404
452
|
* cookies() API) instead — this function is browser-only.
|
|
405
453
|
*/
|
|
406
454
|
export function getCapturedFirstTouchUtms(): CapturedUtms | null {
|
|
407
|
-
return readUtmsCookie(UTM_FIRST_COOKIE)
|
|
455
|
+
return readUtmsCookie(UTM_FIRST_COOKIE, LEGACY_UTM_FIRST_COOKIE)
|
|
408
456
|
}
|
|
409
457
|
|
|
410
458
|
/**
|
|
@@ -414,5 +462,99 @@ export function getCapturedFirstTouchUtms(): CapturedUtms | null {
|
|
|
414
462
|
* this is browser-only.
|
|
415
463
|
*/
|
|
416
464
|
export function getCapturedLastTouchUtms(): CapturedUtms | null {
|
|
417
|
-
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
|
+
}
|
|
418
560
|
}
|
package/src/tracking/consent.ts
CHANGED
|
@@ -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
|
/**
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
trackAddToCart,
|
|
5
|
+
trackInitiateCheckout,
|
|
6
|
+
trackPurchase,
|
|
7
|
+
trackViewContent,
|
|
8
|
+
} from "./fbq"
|
|
9
|
+
import {
|
|
10
|
+
trackGAAddToCart,
|
|
11
|
+
trackGABeginCheckout,
|
|
12
|
+
trackGAPurchase,
|
|
13
|
+
trackGAViewItem,
|
|
14
|
+
} from "./gtag"
|
|
15
|
+
import {
|
|
16
|
+
trackRybbitAddToCart,
|
|
17
|
+
trackRybbitBeginCheckout,
|
|
18
|
+
trackRybbitPurchase,
|
|
19
|
+
trackRybbitViewItem,
|
|
20
|
+
} from "./rybbit-events"
|
|
21
|
+
import {
|
|
22
|
+
trackTikTokAddToCart,
|
|
23
|
+
trackTikTokInitiateCheckout,
|
|
24
|
+
trackTikTokPurchase,
|
|
25
|
+
trackTikTokViewContent,
|
|
26
|
+
} from "./ttq"
|
|
27
|
+
import { googleAdsPurchaseSendTo, trackGoogleAdsPurchase } from "./google-ads"
|
|
28
|
+
import type { TrackingConfig } from "./types"
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* ONE call per commerce moment, every vendor at once.
|
|
32
|
+
*
|
|
33
|
+
* The per-vendor helpers beside this file stay exported and stay usable.
|
|
34
|
+
* This is the door a storefront should reach for anyway, because the
|
|
35
|
+
* failure these functions prevent is the most common one in tracking and
|
|
36
|
+
* it is invisible: a store adds a fifth vendor, updates four call sites,
|
|
37
|
+
* misses the fifth, and nobody notices until an ad account has been
|
|
38
|
+
* optimising on partial data for a month. We have watched it happen to a
|
|
39
|
+
* product page whose buy box predated a shared helper: ViewContent and
|
|
40
|
+
* InitiateCheckout were present, AddToCart was missing from the single
|
|
41
|
+
* most-used add path in the store.
|
|
42
|
+
*
|
|
43
|
+
* With one function per moment, a vendor is added HERE, once, and every
|
|
44
|
+
* surface that already calls it gains the vendor for free.
|
|
45
|
+
*
|
|
46
|
+
* Every helper underneath no-ops when its tag is absent, so passing a
|
|
47
|
+
* config with only two vendors configured fires exactly those two.
|
|
48
|
+
*
|
|
49
|
+
* Identifier discipline, and it is not cosmetic:
|
|
50
|
+
* - `productId` is the catalogue key. Meta's content_ids, TikTok's
|
|
51
|
+
* content_id and the feed's <g:id> must be the SAME value or the
|
|
52
|
+
* event matches no catalogue entry, which costs dynamic ads and
|
|
53
|
+
* product-level reporting on both platforms.
|
|
54
|
+
* - GA4 is the exception on purpose: its items are keyed by VARIANT,
|
|
55
|
+
* because GA4 reports on what was actually bought rather than
|
|
56
|
+
* matching an ads catalogue.
|
|
57
|
+
* - Purchase dedup keys are derived from `displayId` inside the
|
|
58
|
+
* helpers, never passed in, so the browser and the server cannot
|
|
59
|
+
* drift apart.
|
|
60
|
+
*/
|
|
61
|
+
|
|
62
|
+
/** One line, in the shape every vendor is derived from. */
|
|
63
|
+
export type TrackedLine = {
|
|
64
|
+
/** Catalogue key. Meta + TikTok content ids, and the feed's <g:id>. */
|
|
65
|
+
productId: string
|
|
66
|
+
/** What was actually bought. GA4 item_id. */
|
|
67
|
+
variantId?: string
|
|
68
|
+
title: string
|
|
69
|
+
quantity: number
|
|
70
|
+
/** Unit price, in `currency`. */
|
|
71
|
+
price: number
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export type TrackedOrder = {
|
|
75
|
+
/** THE dedup key across every vendor and both sides of the wire. */
|
|
76
|
+
displayId: string | number
|
|
77
|
+
currency: string
|
|
78
|
+
/** Order total actually collected. */
|
|
79
|
+
value: number
|
|
80
|
+
tax?: number
|
|
81
|
+
shipping?: number
|
|
82
|
+
coupon?: string
|
|
83
|
+
lines: TrackedLine[]
|
|
84
|
+
/**
|
|
85
|
+
* First order for this buyer. Computed server-side by the order.placed
|
|
86
|
+
* forwarder and persisted on the order as `metadata.customer_type`;
|
|
87
|
+
* read it from there rather than deriving it in the browser, so the two
|
|
88
|
+
* sides tell the ad platforms the same thing. Omit when unknown.
|
|
89
|
+
*/
|
|
90
|
+
customerType?: "new" | "returning"
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const gaItems = (lines: TrackedLine[], currency: string) =>
|
|
94
|
+
lines.map((line, index) => ({
|
|
95
|
+
item_id: line.variantId || line.productId,
|
|
96
|
+
item_name: line.title,
|
|
97
|
+
quantity: line.quantity,
|
|
98
|
+
price: line.price,
|
|
99
|
+
currency,
|
|
100
|
+
index,
|
|
101
|
+
}))
|
|
102
|
+
|
|
103
|
+
const metaContents = (lines: TrackedLine[]) =>
|
|
104
|
+
lines.map((line) => ({
|
|
105
|
+
id: line.productId,
|
|
106
|
+
quantity: line.quantity,
|
|
107
|
+
item_price: line.price,
|
|
108
|
+
}))
|
|
109
|
+
|
|
110
|
+
const tiktokContents = (lines: TrackedLine[]) =>
|
|
111
|
+
lines.map((line) => ({
|
|
112
|
+
content_id: line.productId,
|
|
113
|
+
content_name: line.title,
|
|
114
|
+
quantity: line.quantity,
|
|
115
|
+
price: line.price,
|
|
116
|
+
}))
|
|
117
|
+
|
|
118
|
+
const unitCount = (lines: TrackedLine[]) =>
|
|
119
|
+
lines.reduce((sum, line) => sum + (Number(line.quantity) || 0), 0)
|
|
120
|
+
|
|
121
|
+
/** Product page view. */
|
|
122
|
+
export function trackProductView(input: {
|
|
123
|
+
line: TrackedLine
|
|
124
|
+
currency: string
|
|
125
|
+
value: number
|
|
126
|
+
}): void {
|
|
127
|
+
const { line, currency, value } = input
|
|
128
|
+
trackViewContent({
|
|
129
|
+
content_ids: [line.productId],
|
|
130
|
+
content_type: "product",
|
|
131
|
+
currency,
|
|
132
|
+
value,
|
|
133
|
+
})
|
|
134
|
+
trackTikTokViewContent({
|
|
135
|
+
contentId: line.productId,
|
|
136
|
+
contentName: line.title,
|
|
137
|
+
currency,
|
|
138
|
+
value,
|
|
139
|
+
})
|
|
140
|
+
trackGAViewItem({ currency, value, items: gaItems([line], currency) })
|
|
141
|
+
trackRybbitViewItem({
|
|
142
|
+
item_id: line.variantId || line.productId,
|
|
143
|
+
item_name: line.title,
|
|
144
|
+
currency,
|
|
145
|
+
value,
|
|
146
|
+
})
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Add to cart, from ANY surface — listing card, buy box, upsell rail. */
|
|
150
|
+
export function trackCartAdd(input: {
|
|
151
|
+
line: TrackedLine
|
|
152
|
+
currency: string
|
|
153
|
+
value: number
|
|
154
|
+
}): void {
|
|
155
|
+
const { line, currency, value } = input
|
|
156
|
+
trackAddToCart({
|
|
157
|
+
content_ids: [line.productId],
|
|
158
|
+
content_type: "product",
|
|
159
|
+
currency,
|
|
160
|
+
value,
|
|
161
|
+
contents: metaContents([line]),
|
|
162
|
+
})
|
|
163
|
+
trackTikTokAddToCart({
|
|
164
|
+
contentId: line.productId,
|
|
165
|
+
contentName: line.title,
|
|
166
|
+
quantity: line.quantity,
|
|
167
|
+
price: line.price,
|
|
168
|
+
currency,
|
|
169
|
+
value,
|
|
170
|
+
})
|
|
171
|
+
trackGAAddToCart({ currency, value, items: gaItems([line], currency) })
|
|
172
|
+
trackRybbitAddToCart({
|
|
173
|
+
item_id: line.variantId || line.productId,
|
|
174
|
+
item_name: line.title,
|
|
175
|
+
quantity: line.quantity,
|
|
176
|
+
currency,
|
|
177
|
+
value,
|
|
178
|
+
})
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Checkout started. */
|
|
182
|
+
export function trackCheckoutStart(input: {
|
|
183
|
+
lines: TrackedLine[]
|
|
184
|
+
currency: string
|
|
185
|
+
value: number
|
|
186
|
+
coupon?: string
|
|
187
|
+
}): void {
|
|
188
|
+
const { lines, currency, value, coupon } = input
|
|
189
|
+
trackInitiateCheckout({
|
|
190
|
+
content_ids: lines.map((line) => line.productId),
|
|
191
|
+
content_type: "product",
|
|
192
|
+
currency,
|
|
193
|
+
value,
|
|
194
|
+
num_items: unitCount(lines),
|
|
195
|
+
contents: metaContents(lines),
|
|
196
|
+
})
|
|
197
|
+
trackTikTokInitiateCheckout({
|
|
198
|
+
contents: tiktokContents(lines),
|
|
199
|
+
currency,
|
|
200
|
+
value,
|
|
201
|
+
})
|
|
202
|
+
trackGABeginCheckout({
|
|
203
|
+
currency,
|
|
204
|
+
value,
|
|
205
|
+
items: gaItems(lines, currency),
|
|
206
|
+
...(coupon ? { coupon } : {}),
|
|
207
|
+
})
|
|
208
|
+
trackRybbitBeginCheckout({
|
|
209
|
+
item_ids: lines.map((line) => line.variantId || line.productId),
|
|
210
|
+
num_items: unitCount(lines),
|
|
211
|
+
currency,
|
|
212
|
+
value,
|
|
213
|
+
})
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Order confirmed — the money event.
|
|
218
|
+
*
|
|
219
|
+
* Google Ads only fires when the store configured BOTH the account id
|
|
220
|
+
* and the purchase conversion label, which is why the config is a
|
|
221
|
+
* parameter here: `googleAdsPurchaseSendTo` returns null otherwise, and a
|
|
222
|
+
* malformed `send_to` is accepted by Google and silently dropped.
|
|
223
|
+
*
|
|
224
|
+
* Everything here is deduped against the server: Meta on
|
|
225
|
+
* `purchase_<displayId>`, TikTok on `tt_purchase_<displayId>`, GA4 and
|
|
226
|
+
* Google Ads on the transaction id, which is `displayId` itself.
|
|
227
|
+
*/
|
|
228
|
+
export function trackOrderPurchase(
|
|
229
|
+
order: TrackedOrder,
|
|
230
|
+
config?: TrackingConfig
|
|
231
|
+
): void {
|
|
232
|
+
const {
|
|
233
|
+
displayId,
|
|
234
|
+
currency,
|
|
235
|
+
value,
|
|
236
|
+
lines,
|
|
237
|
+
tax,
|
|
238
|
+
shipping,
|
|
239
|
+
coupon,
|
|
240
|
+
customerType,
|
|
241
|
+
} = order
|
|
242
|
+
const transactionId = String(displayId)
|
|
243
|
+
|
|
244
|
+
trackPurchase(
|
|
245
|
+
{
|
|
246
|
+
content_ids: lines.map((line) => line.productId),
|
|
247
|
+
content_type: "product",
|
|
248
|
+
currency,
|
|
249
|
+
value,
|
|
250
|
+
num_items: unitCount(lines),
|
|
251
|
+
contents: metaContents(lines),
|
|
252
|
+
},
|
|
253
|
+
displayId
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
trackTikTokPurchase({
|
|
257
|
+
contents: tiktokContents(lines),
|
|
258
|
+
currency,
|
|
259
|
+
value,
|
|
260
|
+
displayId,
|
|
261
|
+
customerType,
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
trackGAPurchase({
|
|
265
|
+
transaction_id: transactionId,
|
|
266
|
+
currency,
|
|
267
|
+
value,
|
|
268
|
+
items: gaItems(lines, currency),
|
|
269
|
+
...(typeof tax === "number" ? { tax } : {}),
|
|
270
|
+
...(typeof shipping === "number" ? { shipping } : {}),
|
|
271
|
+
...(coupon ? { coupon } : {}),
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
trackRybbitPurchase({
|
|
275
|
+
transaction_id: transactionId,
|
|
276
|
+
item_ids: lines.map((line) => line.variantId || line.productId),
|
|
277
|
+
num_items: unitCount(lines),
|
|
278
|
+
currency,
|
|
279
|
+
value,
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
const sendTo = config ? googleAdsPurchaseSendTo(config) : null
|
|
283
|
+
if (sendTo) {
|
|
284
|
+
trackGoogleAdsPurchase({
|
|
285
|
+
sendTo,
|
|
286
|
+
value,
|
|
287
|
+
currency,
|
|
288
|
+
transactionId,
|
|
289
|
+
...(customerType
|
|
290
|
+
? { newCustomer: customerType === "new" }
|
|
291
|
+
: {}),
|
|
292
|
+
})
|
|
293
|
+
}
|
|
294
|
+
}
|
package/src/tracking/ga4.tsx
CHANGED
|
@@ -1,49 +1,93 @@
|
|
|
1
|
-
"use client"
|
|
2
|
-
|
|
3
|
-
import Script from "next/script"
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* `
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import Script from "next/script"
|
|
4
|
+
|
|
5
|
+
import { jsStringLiteral, urlParam } from "./inline-script"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* GoogleTag — THE single Google tag for a storefront.
|
|
9
|
+
*
|
|
10
|
+
* Loads `https://www.googletagmanager.com/gtag/js` once and issues one
|
|
11
|
+
* `config` per destination: GA4 for analytics, Google Ads for conversions
|
|
12
|
+
* and remarketing audiences.
|
|
13
|
+
*
|
|
14
|
+
* ONE loader, TWO configs, and that is Google's own instruction: when a
|
|
15
|
+
* page already carries a Google tag you add the Ads conversion ID with an
|
|
16
|
+
* additional `config` command, never a second `<script src>`
|
|
17
|
+
* (https://support.google.com/google-ads/answer/2476688). Every agency
|
|
18
|
+
* onboarding email pastes a second loader anyway, contradicting the line
|
|
19
|
+
* printed above it in the same email. Take the instruction, not the
|
|
20
|
+
* snippet.
|
|
21
|
+
*
|
|
22
|
+
* The Ads `config` is also what powers Google Ads remarketing audiences.
|
|
23
|
+
* It is not optional for an account that intends to retarget, and linking
|
|
24
|
+
* GA4 to Ads does not substitute for it.
|
|
25
|
+
*
|
|
26
|
+
* Consent needs nothing here: <ConsentInit> sets the Consent Mode v2
|
|
27
|
+
* defaults synchronously ahead of this loader, and because the Ads
|
|
28
|
+
* destination rides the same gtag instance, `ad_storage` /
|
|
29
|
+
* `ad_user_data` / `ad_personalization` gate it with no extra wiring.
|
|
30
|
+
* That is the direct payoff of refusing the second script tag.
|
|
31
|
+
*
|
|
32
|
+
* Renders nothing when neither id is configured, so a layout can mount it
|
|
33
|
+
* unconditionally.
|
|
34
|
+
*
|
|
35
|
+
* `send_page_view` stays at its default, so the initial page_view fires
|
|
36
|
+
* on load. SPA route changes are NOT auto-tracked by gtag; a storefront
|
|
37
|
+
* that wants per-route page_views calls `gtag('event', 'page_view', …)`
|
|
38
|
+
* from a route-change effect. Take care doing that: a `router.replace`
|
|
39
|
+
* that only rewrites a query parameter reads as a navigation to gtag, to
|
|
40
|
+
* the Meta pixel and to TikTok alike, and every vendor then counts two
|
|
41
|
+
* views of one page.
|
|
42
|
+
*/
|
|
43
|
+
export function GoogleTag({
|
|
44
|
+
measurementId,
|
|
45
|
+
adsConversionId,
|
|
46
|
+
}: {
|
|
47
|
+
/** GA4 destination, e.g. "G-8BLJ6CW7VX". */
|
|
48
|
+
measurementId?: string
|
|
49
|
+
/** Google Ads destination, e.g. "AW-18150814603". */
|
|
50
|
+
adsConversionId?: string
|
|
51
|
+
}) {
|
|
52
|
+
const destinations = [measurementId, adsConversionId].filter(
|
|
53
|
+
(id): id is string => Boolean(id && id.trim())
|
|
54
|
+
)
|
|
55
|
+
if (destinations.length === 0) return null
|
|
56
|
+
|
|
57
|
+
// Which id lands in the loader URL is cosmetic — gtag treats every
|
|
58
|
+
// `config` equally once loaded. GA4 leads when present so the URL stays
|
|
59
|
+
// what it has always been for stores that only run analytics.
|
|
60
|
+
const loaderId = destinations[0]
|
|
61
|
+
|
|
62
|
+
const initSnippet = `
|
|
63
|
+
window.dataLayer = window.dataLayer || [];
|
|
64
|
+
function gtag(){dataLayer.push(arguments);}
|
|
65
|
+
gtag('js', new Date());
|
|
66
|
+
${destinations.map((id) => `gtag('config', ${jsStringLiteral(id)});`).join("\n")}
|
|
67
|
+
`.trim()
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<>
|
|
71
|
+
<Script
|
|
72
|
+
id="google-tag-loader"
|
|
73
|
+
strategy="afterInteractive"
|
|
74
|
+
src={`https://www.googletagmanager.com/gtag/js?id=${urlParam(loaderId)}`}
|
|
75
|
+
/>
|
|
76
|
+
<Script
|
|
77
|
+
id="google-tag-init"
|
|
78
|
+
strategy="afterInteractive"
|
|
79
|
+
dangerouslySetInnerHTML={{ __html: initSnippet }}
|
|
80
|
+
/>
|
|
81
|
+
</>
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* GA4 — the analytics-only door, kept because storefronts mount it by
|
|
87
|
+
* this name. It is <GoogleTag> with one destination; a store that also
|
|
88
|
+
* runs Google Ads should mount <GoogleTag> with both ids instead, since
|
|
89
|
+
* two loaders on one page is the thing Google tells you not to do.
|
|
90
|
+
*/
|
|
91
|
+
export function GA4({ measurementId }: { measurementId?: string }) {
|
|
92
|
+
return <GoogleTag measurementId={measurementId} />
|
|
93
|
+
}
|