@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.
@@ -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
@@ -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
  }
@@ -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
+ }
@@ -1,49 +1,93 @@
1
- "use client"
2
-
3
- import Script from "next/script"
4
-
5
- /**
6
- * GA4 — Google Analytics 4 base script loader.
7
- *
8
- * Loads `https://www.googletagmanager.com/gtag/js?id=<measurementId>` via
9
- * Next.js `<Script strategy="afterInteractive">` and initialises gtag with
10
- * the given measurement ID. Once loaded, gtag automatically sets the
11
- * `_ga` and `_ga_<MEASUREMENT_ID>` first-party cookies, which the server
12
- * action `getTrackingAttribution` then reads on cart completion to
13
- * forward `ga_client_id` / `ga_session_id` into the order metadata for
14
- * GA4 Measurement Protocol Purchase events.
15
- *
16
- * Renders nothing when `measurementId` is falsy every consuming layout
17
- * can call this unconditionally; the script is only injected when the
18
- * admin has configured GA4.
19
- *
20
- * `send_page_view: true` (default) — initial page_view fires automatically
21
- * on script load. Subsequent SPA route changes are NOT auto-tracked by
22
- * gtag; storefronts that need per-route page_views should call
23
- * `gtag('event', 'page_view', { page_path })` from a route-change effect.
24
- */
25
- export function GA4({ measurementId }: { measurementId?: string }) {
26
- if (!measurementId) return null
27
-
28
- const initSnippet = `
29
- window.dataLayer = window.dataLayer || [];
30
- function gtag(){dataLayer.push(arguments);}
31
- gtag('js', new Date());
32
- gtag('config', '${measurementId}');
33
- `.trim()
34
-
35
- return (
36
- <>
37
- <Script
38
- id="ga4-loader"
39
- strategy="afterInteractive"
40
- src={`https://www.googletagmanager.com/gtag/js?id=${measurementId}`}
41
- />
42
- <Script
43
- id="ga4-init"
44
- strategy="afterInteractive"
45
- dangerouslySetInnerHTML={{ __html: initSnippet }}
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
+ }