@cartbase/storefront 0.5.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.
@@ -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
+ }
@@ -3,6 +3,7 @@ import { cookies, headers } from "next/headers"
3
3
  import { getTrackingConfig } from "./get-tracking-config"
4
4
  import type { StorefrontClient } from "../api/http"
5
5
  import type { TrackingAttribution, TrackingClientHints } from "./types"
6
+ import { CLICK_ID_METADATA_KEYS } from "./attribution"
6
7
 
7
8
  /**
8
9
  * Reads Meta + GA4 attribution signals from the current Next.js server
@@ -110,6 +111,35 @@ export async function getTrackingAttribution(
110
111
  applyUtmCookie(utmFirstRaw, "utm_first", result)
111
112
  applyUtmCookie(utmLastRaw, "utm_last", result)
112
113
 
114
+ // Ad-click identifiers — TikTok's ttclid / _ttp and Google's gclid /
115
+ // gbraid / wbraid, captured into cookies by captureClickIdsFromUrl().
116
+ // They travel the same road as the UTMs: cart.metadata at checkout,
117
+ // order.metadata at completion, and from there into the TikTok Events
118
+ // API `user` block. Without ttclid TikTok cannot tie a conversion back
119
+ // to the click that produced it, which is the number the advertiser
120
+ // looks at first.
121
+ try {
122
+ const cookieStore = await cookies()
123
+ // The key map is a plain tuple list (attribution.ts owns the cookie
124
+ // names), so the write goes through one narrow cast rather than a
125
+ // per-key branch that would have to be edited for every new platform.
126
+ const sink = result as Record<string, unknown>
127
+ for (const [cookieName, metadataKey] of CLICK_ID_METADATA_KEYS) {
128
+ const value = cookieStore.get(cookieName)?.value
129
+ if (value) {
130
+ // Written encoded by the capture; decoded once here so the wire
131
+ // carries the id the platform actually issued.
132
+ try {
133
+ sink[metadataKey] = decodeURIComponent(value)
134
+ } catch {
135
+ sink[metadataKey] = value
136
+ }
137
+ }
138
+ }
139
+ } catch {
140
+ // best-effort — attribution never blocks a checkout
141
+ }
142
+
113
143
  // _ga cookie format: "GA1.1.<client_id>.<timestamp>" — backend wants
114
144
  // the full <client_id>.<timestamp> portion (the canonical GA client_id).
115
145
  if (gaCookieRaw) {
@@ -0,0 +1,84 @@
1
+ "use client"
2
+
3
+ import type { TrackingConfig } from "./types"
4
+
5
+ /**
6
+ * Google Ads conversion tracking — browser side.
7
+ *
8
+ * Rides the SAME gtag instance <GoogleTag> loads for GA4; there is
9
+ * deliberately no loader in this file (see the comment block in ./ga4.tsx
10
+ * and https://support.google.com/google-ads/answer/2476688).
11
+ *
12
+ * DEDUP, and it is the whole reason this file is careful. Google Ads
13
+ * collapses two conversions that share a conversion action AND a
14
+ * `transaction_id` (https://support.google.com/google-ads/answer/6386790).
15
+ * Every vendor snippet ships `'transaction_id': ''`, and an empty value
16
+ * dedupes nothing — so every re-open of the confirmation page counts
17
+ * another purchase. That is not a hypothetical: order emails link back to
18
+ * the confirmation page, so the page is re-opened days after the sale, by
19
+ * the same buyer, routinely. We always send `String(order.display_id)`,
20
+ * the same key GA4's Measurement Protocol already uses.
21
+ *
22
+ * No-ops when gtag is absent (Ads not configured, server render, blocked
23
+ * script), so callers need no guards.
24
+ */
25
+
26
+ type GtagFn = (...args: unknown[]) => void
27
+
28
+ function safeGtag(): GtagFn | null {
29
+ if (typeof window === "undefined") return null
30
+ const fn = (window as unknown as { gtag?: GtagFn }).gtag
31
+ return typeof fn === "function" ? fn : null
32
+ }
33
+
34
+ /**
35
+ * Build the `send_to` Google Ads expects for the purchase conversion:
36
+ * "AW-XXXXXXXXXX/Label".
37
+ *
38
+ * Returns null when either half is missing, so callers skip the
39
+ * conversion entirely instead of firing a malformed `send_to` that Google
40
+ * accepts and silently drops. The label is genuinely optional in the
41
+ * admin: a merchant can save the account id before creating the
42
+ * conversion action.
43
+ */
44
+ export function googleAdsPurchaseSendTo(config: TrackingConfig): string | null {
45
+ const conversionId = config.googleAds?.conversionId?.trim()
46
+ const label = config.googleAds?.conversionLabel?.trim()
47
+ if (!conversionId || !label) return null
48
+ return `${conversionId}/${label}`
49
+ }
50
+
51
+ export type GoogleAdsPurchaseInput = {
52
+ /** Full send_to value from `googleAdsPurchaseSendTo`. */
53
+ sendTo: string
54
+ /** Order total actually collected, in `currency`. */
55
+ value: number
56
+ /** ISO 4217, e.g. "EUR". */
57
+ currency: string
58
+ /** THE dedup key. Always `String(order.display_id)`. */
59
+ transactionId: string
60
+ /**
61
+ * Whether this order is the buyer's first. Computed once server-side by
62
+ * the order.placed forwarder and persisted on the order, so the browser
63
+ * and the server cannot disagree. Omitted entirely when unknown: a
64
+ * wrong value is worse than an absent one for new-customer bidding.
65
+ */
66
+ newCustomer?: boolean
67
+ }
68
+
69
+ export function trackGoogleAdsPurchase(input: GoogleAdsPurchaseInput): void {
70
+ const gtag = safeGtag()
71
+ if (!gtag || !input.sendTo) return
72
+
73
+ const payload: Record<string, unknown> = {
74
+ send_to: input.sendTo,
75
+ value: input.value,
76
+ currency: input.currency,
77
+ transaction_id: input.transactionId,
78
+ }
79
+ if (typeof input.newCustomer === "boolean") {
80
+ payload.new_customer = input.newCustomer
81
+ }
82
+
83
+ gtag("event", "conversion", payload)
84
+ }
@@ -1,6 +1,10 @@
1
1
  "use client"
2
2
 
3
- import { sha256Hex, normaliseEmailForHash, normalisePhoneForHash } from "./attribution"
3
+ import {
4
+ sha256Hex,
5
+ normaliseEmailForHash,
6
+ normalisePhoneForGoogleHash,
7
+ } from "./attribution"
4
8
 
5
9
  /**
6
10
  * Typed wrappers around the global `gtag()` from GA4 (gtag.js).
@@ -114,16 +118,23 @@ export function trackGAPurchase(data: GA4PurchaseData): void {
114
118
  * these signals to recover conversions that browser cookies miss
115
119
  * (Safari ITP, iOS, ad-blockers).
116
120
  *
117
- * Hashing strategy mirrors the Meta side (attribution.ts):
118
- * - email / phone / first_name / last_name SHA-256 hex, lowercase
119
- * normalised, phone E.164-style digits-only with country code
120
- * - city / region / postal_code / country / street raw plain text
121
- * (Google's spec only documents sha256 variants for name fields
122
- * within the address block; geo fields are accepted as plain)
121
+ * Hashing:
122
+ * - email / first_name / last_name: SHA-256 hex of the trimmed,
123
+ * lowercased value, which is what both vendors want
124
+ * - phone: SHA-256 hex of E.164 WITH the leading plus, which is what
125
+ * GOOGLE wants and is NOT what Meta wants
126
+ * - city / region / postal_code / country / street: plain text
127
+ * (Google documents sha256 variants only for the name fields inside
128
+ * the address block; geo fields are accepted as plain)
123
129
  *
124
- * Same input on both sides produces same digest both vendors' match
125
- * engines see identical hashes for the same user. Storefront callers
126
- * pass RAW values; this function does the normalize+hash inline.
130
+ * The phone line is the one to read twice. This function used to reuse
131
+ * Meta's digits-only normaliser on the reasoning that both vendors should
132
+ * see identical digests for the same person. That was the bug: Google
133
+ * requires the plus, so the digest matched nobody, and nothing failed
134
+ * loudly enough to notice. Two vendors, two normalisers, on purpose.
135
+ *
136
+ * Storefront callers pass RAW values; this function normalises and hashes
137
+ * inline.
127
138
  *
128
139
  * Idempotent — safe to call repeatedly as new fields become known
129
140
  * (gtag.set merges user_data per Google's spec).
@@ -160,9 +171,11 @@ export async function setEnhancedConversions(
160
171
  )
161
172
  }
162
173
  if (input.phone) {
163
- userData.sha256_phone_number = await sha256Hex(
164
- normalisePhoneForHash(input.phone)
165
- )
174
+ // Undefined for anything that could not be a real E.164 number: a
175
+ // hash of a malformed phone cannot match, and it costs the field
176
+ // Google would otherwise fall back from.
177
+ const e164 = normalisePhoneForGoogleHash(input.phone)
178
+ if (e164) userData.sha256_phone_number = await sha256Hex(e164)
166
179
  }
167
180
 
168
181
  const address: Record<string, unknown> = {}
@@ -0,0 +1,60 @@
1
+ "use client"
2
+
3
+ import Script from "next/script"
4
+
5
+ import { jsStringLiteral, urlParam } from "./inline-script"
6
+
7
+ /**
8
+ * Gtm — Google Tag Manager container.
9
+ *
10
+ * The hub has served `tracking.gtm.containerId` to storefronts since the
11
+ * tracking card shipped, and until now nothing in this package mounted
12
+ * it, so a merchant who configured GTM got a container id in their page
13
+ * data and no container. Same shape of defect as Google Ads was.
14
+ *
15
+ * GTM is the merchant's own escape hatch: whatever tag we have not built
16
+ * a first-class integration for, they can deploy through their container
17
+ * without waiting for us. That makes it worth mounting properly rather
18
+ * than treating it as a lesser vendor.
19
+ *
20
+ * Consent: <ConsentInit> sets the Consent Mode v2 defaults synchronously
21
+ * before this loads, so tags inside the container inherit the gate, and
22
+ * `applyConsent()` pushes the visitor's decision to the same dataLayer.
23
+ * Nothing extra to wire here.
24
+ *
25
+ * The <noscript> iframe is part of Google's documented snippet and is
26
+ * what makes the container work for a scriptless visitor. Next renders
27
+ * it in <body>, which is where Google puts it.
28
+ *
29
+ * Renders nothing when `containerId` is falsy.
30
+ */
31
+ export function Gtm({ containerId }: { containerId?: string }) {
32
+ if (!containerId) return null
33
+
34
+ const initSnippet = `
35
+ (function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
36
+ new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
37
+ j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
38
+ 'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
39
+ })(window,document,'script','dataLayer',${jsStringLiteral(containerId)});
40
+ `.trim()
41
+
42
+ return (
43
+ <>
44
+ <Script
45
+ id="gtm-init"
46
+ strategy="afterInteractive"
47
+ dangerouslySetInnerHTML={{ __html: initSnippet }}
48
+ />
49
+ <noscript>
50
+ <iframe
51
+ src={`https://www.googletagmanager.com/ns.html?id=${urlParam(containerId)}`}
52
+ height="0"
53
+ width="0"
54
+ style={{ display: "none", visibility: "hidden" }}
55
+ title="Google Tag Manager"
56
+ />
57
+ </noscript>
58
+ </>
59
+ )
60
+ }