@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.
@@ -3,6 +3,15 @@ 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"
7
+ import {
8
+ LEGACY_UTM_FIRST_COOKIE,
9
+ LEGACY_UTM_LAST_COOKIE,
10
+ LEGACY_VISITOR_COOKIE,
11
+ UTM_FIRST_COOKIE,
12
+ UTM_LAST_COOKIE,
13
+ VISITOR_COOKIE,
14
+ } from "../lib/cookie-names"
6
15
 
7
16
  /**
8
17
  * Reads Meta + GA4 attribution signals from the current Next.js server
@@ -72,14 +81,19 @@ export async function getTrackingAttribution(
72
81
  // attribution.ts on first tracking call. Surfaces server-side here
73
82
  // so the order.placed CAPI Purchase can include it as external_id
74
83
  // (alongside customer_id when both exist — Meta accepts an array).
75
- anonId = cookieStore.get("_1c_anon")?.value
84
+ anonId =
85
+ cookieStore.get(VISITOR_COOKIE)?.value ?? cookieStore.get(LEGACY_VISITOR_COOKIE)?.value
76
86
  gaCookieRaw = cookieStore.get("_ga")?.value
77
87
  // _1c_utm_first / _1c_utm_last — JSON-encoded UTM tuples written
78
88
  // by browser-side captureUtmsFromUrl(). First-touch records the
79
89
  // acquisition campaign (365-day TTL); last-touch records the
80
90
  // closer (90-day TTL, refreshed on each UTM-bearing visit).
81
- utmFirstRaw = cookieStore.get("_1c_utm_first")?.value
82
- utmLastRaw = cookieStore.get("_1c_utm_last")?.value
91
+ utmFirstRaw =
92
+ cookieStore.get(UTM_FIRST_COOKIE)?.value ??
93
+ cookieStore.get(LEGACY_UTM_FIRST_COOKIE)?.value
94
+ utmLastRaw =
95
+ cookieStore.get(UTM_LAST_COOKIE)?.value ??
96
+ cookieStore.get(LEGACY_UTM_LAST_COOKIE)?.value
83
97
 
84
98
  // _ga_<MEASUREMENT_ID> uses the GA4 measurementId (e.g., G-ABCDEF1234)
85
99
  // with the "G-" prefix stripped: cookie name = `_ga_ABCDEF1234`.
@@ -110,6 +124,35 @@ export async function getTrackingAttribution(
110
124
  applyUtmCookie(utmFirstRaw, "utm_first", result)
111
125
  applyUtmCookie(utmLastRaw, "utm_last", result)
112
126
 
127
+ // Ad-click identifiers — TikTok's ttclid / _ttp and Google's gclid /
128
+ // gbraid / wbraid, captured into cookies by captureClickIdsFromUrl().
129
+ // They travel the same road as the UTMs: cart.metadata at checkout,
130
+ // order.metadata at completion, and from there into the TikTok Events
131
+ // API `user` block. Without ttclid TikTok cannot tie a conversion back
132
+ // to the click that produced it, which is the number the advertiser
133
+ // looks at first.
134
+ try {
135
+ const cookieStore = await cookies()
136
+ // The key map is a plain tuple list (attribution.ts owns the cookie
137
+ // names), so the write goes through one narrow cast rather than a
138
+ // per-key branch that would have to be edited for every new platform.
139
+ const sink = result as Record<string, unknown>
140
+ for (const [cookieName, metadataKey] of CLICK_ID_METADATA_KEYS) {
141
+ const value = cookieStore.get(cookieName)?.value
142
+ if (value) {
143
+ // Written encoded by the capture; decoded once here so the wire
144
+ // carries the id the platform actually issued.
145
+ try {
146
+ sink[metadataKey] = decodeURIComponent(value)
147
+ } catch {
148
+ sink[metadataKey] = value
149
+ }
150
+ }
151
+ }
152
+ } catch {
153
+ // best-effort — attribution never blocks a checkout
154
+ }
155
+
113
156
  // _ga cookie format: "GA1.1.<client_id>.<timestamp>" — backend wants
114
157
  // the full <client_id>.<timestamp> portion (the canonical GA client_id).
115
158
  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
+ }
@@ -1,133 +1,187 @@
1
- /**
2
- * @cartbase/storefront/tracking — Meta Pixel + GA4 + Rybbit + Consent Mode v2.
3
- *
4
- * Ported from `@1click/ui/src/tracking` (v2.3.1) with the Cartbase seams:
5
- * - config comes from `GET /api/store/integrations` → `tracking` block
6
- * via `getTrackingConfig(client)` (never env vars)
7
- * - server-side CAPI / GA4-MP sending is Cartbase-backend-owned (the
8
- * `order.placed` forwarder) — this package fires CLIENT events only
9
- * and writes attribution for the server to inherit
10
- * - Purchase dedupe: Pixel `eventID = "purchase_" + order.display_id`
11
- * (trackPurchase builds it); GA4 `transaction_id =
12
- * String(order.display_id)`
13
- *
14
- * Public surface:
15
- * - <ConsentInit> / <ConsentBanner> / <ConsentSettingsLink> — Consent
16
- * Mode v2 (config from GET /api/store/consent; `_1c_consent` seam)
17
- * - <MetaPixel pixelId> / <GA4 measurementId> / <Rybbit siteId> — layout tags
18
- * - Meta Pixel client helpers: trackViewContent / trackAddToCart /
19
- * trackInitiateCheckout / trackPurchase / trackLead
20
- * - GA4 client helpers: trackGAViewItem / trackGAAddToCart /
21
- * trackGABeginCheckout / trackGAPurchase / setEnhancedConversions
22
- * - Rybbit client helpers: trackRybbit* (queue-buffered)
23
- * - getTrackingConfig(client) — the public tag config
24
- * - getTrackingAttribution(clientHints?, opts?) server-side signal
25
- * gathering for the cart.metadata writeback (consent-gated)
26
- * - getEngagementTimeMsec() / initEngagementTime() time-on-session
27
- *
28
- * Server vs client split (per Next.js App Router rules):
29
- * - get-tracking-attribution.ts imports `next/headers` (server-only by
30
- * construction); get-tracking-config.ts is isomorphic
31
- * - meta-pixel.tsx, ga4.tsx, rybbit.tsx, fbq.ts, gtag.ts,
32
- * rybbit-events.ts, attribution.ts, use-engagement-time.ts,
33
- * consent-banner.tsx are `"use client"`
34
- * - types.ts, consent.ts, consent-init.tsx are universal
35
- *
36
- * Importers should usually pull from the subpath that matches their
37
- * environment. The barrel re-exports everything, but tree-shaking and
38
- * Next.js's RSC boundary detection both work better with subpaths.
39
- */
40
-
41
- export { MetaPixel, updatePixelAdvancedMatching } from "./meta-pixel"
42
- export { GA4 } from "./ga4"
43
- export { Rybbit } from "./rybbit"
44
- export { ConsentInit } from "./consent-init"
45
- export { ConsentBanner, ConsentBannerCard, ConsentSettingsLink } from "./consent-banner"
46
- export {
47
- applyConsent,
48
- setConsent,
49
- openConsentSettings,
50
- readConsentCookie,
51
- writeConsentCookie,
52
- shouldRenderBanner,
53
- pickConsentCopy,
54
- CONSENT_COOKIE,
55
- CONSENT_MAX_AGE_SECONDS,
56
- CONSENT_OPEN_EVENT,
57
- CONSENT_INIT_SNIPPET,
58
- type ConsentChoices,
59
- type ConsentCopy,
60
- type ConsentLayout,
61
- type ConsentMode,
62
- type ConsentSettings,
63
- } from "./consent"
64
- export {
65
- trackViewContent,
66
- trackAddToCart,
67
- trackInitiateCheckout,
68
- trackPurchase,
69
- trackLead,
70
- generateEventId,
71
- type LeadContext,
72
- } from "./fbq"
73
- export {
74
- setTrackingDefaults,
75
- getOrCreateAnonId,
76
- getOrCreateFbp,
77
- getOrCreateFbc,
78
- getKnownVisitor,
79
- rememberKnownVisitor,
80
- captureUtmsFromUrl,
81
- getCapturedFirstTouchUtms,
82
- getCapturedLastTouchUtms,
83
- sha256Hex,
84
- normaliseEmailForHash,
85
- normalisePhoneForHash,
86
- type KnownVisitor,
87
- type CapturedUtms,
88
- } from "./attribution"
89
- export {
90
- trackGAViewItem,
91
- trackGAAddToCart,
92
- trackGABeginCheckout,
93
- trackGAPurchase,
94
- setEnhancedConversions,
95
- type EnhancedConversionsInput,
96
- } from "./gtag"
97
- export {
98
- trackRybbitViewItem,
99
- trackRybbitAddToCart,
100
- trackRybbitBeginCheckout,
101
- trackRybbitPurchase,
102
- type RybbitViewItemData,
103
- type RybbitAddToCartData,
104
- type RybbitBeginCheckoutData,
105
- type RybbitPurchaseData,
106
- } from "./rybbit-events"
107
- export { getTrackingConfig } from "./get-tracking-config"
108
- export { getTrackingAttribution } from "./get-tracking-attribution"
109
- export {
110
- getEngagementTimeMsec,
111
- initEngagementTime,
112
- } from "./use-engagement-time"
113
-
114
- export type {
115
- TrackingConfig,
116
- TrackingConfigResponse,
117
- TrackingAttribution,
118
- TrackingClientHints,
119
- MetaContentItem,
120
- ViewContentData,
121
- AddToCartData,
122
- InitiateCheckoutData,
123
- PurchaseData,
124
- LeadData,
125
- } from "./types"
126
-
127
- export type {
128
- GA4Item,
129
- GA4ViewItemData,
130
- GA4AddToCartData,
131
- GA4BeginCheckoutData,
132
- GA4PurchaseData,
133
- } from "./gtag"
1
+ /**
2
+ * @cartbase/storefront/tracking — Meta Pixel + GA4 + Rybbit + Consent Mode v2.
3
+ *
4
+ * Ported from `@1click/ui/src/tracking` (v2.3.1) with the Cartbase seams:
5
+ * - config comes from `GET /api/store/integrations` → `tracking` block
6
+ * via `getTrackingConfig(client)` (never env vars)
7
+ * - server-side CAPI / GA4-MP sending is Cartbase-backend-owned (the
8
+ * `order.placed` forwarder) — this package fires CLIENT events only
9
+ * and writes attribution for the server to inherit
10
+ * - Purchase dedupe: Pixel `eventID = "purchase_" + order.display_id`
11
+ * (trackPurchase builds it); GA4 `transaction_id =
12
+ * String(order.display_id)`
13
+ *
14
+ * Public surface:
15
+ * - <ConsentInit> / <ConsentBanner> / <ConsentSettingsLink> — Consent
16
+ * Mode v2 (config from GET /api/store/consent; `_1c_consent` seam)
17
+ * - <StorefrontTags client> EVERY tag the store configured, mounted
18
+ * from its own config. This is the one a storefront should mount;
19
+ * the individual tags below are for a layout that needs control.
20
+ * - <TrackInit /> captures UTMs and ad-click ids on the landing page
21
+ * - <PageViews /> one pageview per navigation, client-side ones
22
+ * included; the platform derives page type + entity id from the path
23
+ * - <LiveHeartbeat /> pings while a tab is open, so the merchant's live
24
+ * visitor count means "right now" rather than "recently active"
25
+ * (they exist only there) and starts the engagement clock.
26
+ * - <MetaPixel pixelId> / <GoogleTag measurementId adsConversionId> /
27
+ * <TikTokPixel pixelId> / <Gtm containerId> / <Rybbit siteId> —
28
+ * individual layout tags (<GA4> is <GoogleTag> with only the
29
+ * analytics destination)
30
+ * - ONE call per commerce moment, every vendor at once (prefer these):
31
+ * trackProductView / trackCartAdd / trackCheckoutStart /
32
+ * trackOrderPurchase
33
+ * - Meta Pixel client helpers: trackViewContent / trackAddToCart /
34
+ * trackInitiateCheckout / trackPurchase / trackLead
35
+ * - GA4 client helpers: trackGAViewItem / trackGAAddToCart /
36
+ * trackGABeginCheckout / trackGAPurchase / setEnhancedConversions
37
+ * - Rybbit client helpers: trackRybbit* (queue-buffered)
38
+ * - getTrackingConfig(client) the public tag config
39
+ * - getTrackingAttribution(clientHints?, opts?) — server-side signal
40
+ * gathering for the cart.metadata writeback (consent-gated)
41
+ * - getEngagementTimeMsec() / initEngagementTime() time-on-session
42
+ *
43
+ * Server vs client split (per Next.js App Router rules):
44
+ * - get-tracking-attribution.ts imports `next/headers` (server-only by
45
+ * construction); get-tracking-config.ts is isomorphic
46
+ * - meta-pixel.tsx, ga4.tsx, rybbit.tsx, fbq.ts, gtag.ts,
47
+ * rybbit-events.ts, attribution.ts, use-engagement-time.ts,
48
+ * consent-banner.tsx are `"use client"`
49
+ * - types.ts, consent.ts, consent-init.tsx are universal
50
+ *
51
+ * Importers should usually pull from the subpath that matches their
52
+ * environment. The barrel re-exports everything, but tree-shaking and
53
+ * Next.js's RSC boundary detection both work better with subpaths.
54
+ */
55
+
56
+ export { MetaPixel, updatePixelAdvancedMatching } from "./meta-pixel"
57
+ export { GA4, GoogleTag } from "./ga4"
58
+ export { Gtm } from "./gtm"
59
+ export { TikTokPixel } from "./tiktok-pixel"
60
+ export { StorefrontTags } from "./storefront-tags"
61
+ export { TrackInit } from "./track-init"
62
+ // The live-visitor heartbeat. Mounted by <StorefrontTags> so a storefront
63
+ // gets it without knowing it exists.
64
+ export { LiveHeartbeat } from "./live-heartbeat"
65
+ // Every navigation, automatically. Mounted by <StorefrontTags>; the page
66
+ // type and the record it was about are derived by the platform, never
67
+ // declared here.
68
+ export { PageViews } from "./page-views"
69
+ export { TrackOrderPurchase } from "./track-order-purchase"
70
+ export { Rybbit } from "./rybbit"
71
+ export { ConsentInit } from "./consent-init"
72
+ export { ConsentBanner, ConsentBannerCard, ConsentSettingsLink } from "./consent-banner"
73
+ export {
74
+ applyConsent,
75
+ setConsent,
76
+ openConsentSettings,
77
+ readConsentCookie,
78
+ writeConsentCookie,
79
+ shouldRenderBanner,
80
+ pickConsentCopy,
81
+ CONSENT_COOKIE,
82
+ CONSENT_MAX_AGE_SECONDS,
83
+ CONSENT_OPEN_EVENT,
84
+ CONSENT_INIT_SNIPPET,
85
+ type ConsentChoices,
86
+ type ConsentCopy,
87
+ type ConsentLayout,
88
+ type ConsentMode,
89
+ type ConsentSettings,
90
+ } from "./consent"
91
+ export {
92
+ trackViewContent,
93
+ trackAddToCart,
94
+ trackInitiateCheckout,
95
+ trackPurchase,
96
+ trackLead,
97
+ generateEventId,
98
+ type LeadContext,
99
+ } from "./fbq"
100
+ export {
101
+ setTrackingDefaults,
102
+ getOrCreateAnonId,
103
+ getOrCreateFbp,
104
+ getOrCreateFbc,
105
+ getKnownVisitor,
106
+ rememberKnownVisitor,
107
+ captureUtmsFromUrl,
108
+ captureClickIdsFromUrl,
109
+ getCapturedFirstTouchUtms,
110
+ getCapturedLastTouchUtms,
111
+ sha256Hex,
112
+ normaliseEmailForHash,
113
+ normalisePhoneForHash,
114
+ normalisePhoneForGoogleHash,
115
+ CLICK_ID_COOKIES,
116
+ CLICK_ID_METADATA_KEYS,
117
+ type KnownVisitor,
118
+ type CapturedUtms,
119
+ } from "./attribution"
120
+ export {
121
+ trackProductView,
122
+ trackCartAdd,
123
+ trackCheckoutStart,
124
+ trackOrderPurchase,
125
+ type TrackedLine,
126
+ type TrackedOrder,
127
+ } from "./events"
128
+ export {
129
+ trackTikTokViewContent,
130
+ trackTikTokAddToCart,
131
+ trackTikTokInitiateCheckout,
132
+ trackTikTokPurchase,
133
+ applyTikTokConsent,
134
+ tiktokPurchaseEventId,
135
+ type TikTokContentItem,
136
+ } from "./ttq"
137
+ export {
138
+ trackGoogleAdsPurchase,
139
+ googleAdsPurchaseSendTo,
140
+ type GoogleAdsPurchaseInput,
141
+ } from "./google-ads"
142
+ export {
143
+ trackGAViewItem,
144
+ trackGAAddToCart,
145
+ trackGABeginCheckout,
146
+ trackGAPurchase,
147
+ setEnhancedConversions,
148
+ type EnhancedConversionsInput,
149
+ } from "./gtag"
150
+ export {
151
+ trackRybbitViewItem,
152
+ trackRybbitAddToCart,
153
+ trackRybbitBeginCheckout,
154
+ trackRybbitPurchase,
155
+ type RybbitViewItemData,
156
+ type RybbitAddToCartData,
157
+ type RybbitBeginCheckoutData,
158
+ type RybbitPurchaseData,
159
+ } from "./rybbit-events"
160
+ export { getTrackingConfig } from "./get-tracking-config"
161
+ export { useTrackingConfig } from "./use-tracking-config"
162
+ export { getTrackingAttribution } from "./get-tracking-attribution"
163
+ export {
164
+ getEngagementTimeMsec,
165
+ initEngagementTime,
166
+ } from "./use-engagement-time"
167
+
168
+ export type {
169
+ TrackingConfig,
170
+ TrackingConfigResponse,
171
+ TrackingAttribution,
172
+ TrackingClientHints,
173
+ MetaContentItem,
174
+ ViewContentData,
175
+ AddToCartData,
176
+ InitiateCheckoutData,
177
+ PurchaseData,
178
+ LeadData,
179
+ } from "./types"
180
+
181
+ export type {
182
+ GA4Item,
183
+ GA4ViewItemData,
184
+ GA4AddToCartData,
185
+ GA4BeginCheckoutData,
186
+ GA4PurchaseData,
187
+ } from "./gtag"
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Safe interpolation of a configured id into a vendor's inline snippet.
3
+ *
4
+ * Every tag component here injects the vendor's own bootstrap through
5
+ * `dangerouslySetInnerHTML`, with the merchant's pixel / measurement /
6
+ * conversion id written into it. Those ids arrive from the store's
7
+ * integration settings, which means they are typed by a person in the
8
+ * admin, and a value carrying a quote would close the string literal and
9
+ * run whatever follows it on every page of that storefront.
10
+ *
11
+ * On a single-merchant site that is only self-inflicted. On a platform it
12
+ * is one tenant's admin user writing script into that tenant's public
13
+ * storefront, so the escape is not optional.
14
+ *
15
+ * Returns the value as a COMPLETE single-quoted JavaScript literal,
16
+ * quotes included, so callers cannot forget to quote it themselves:
17
+ *
18
+ * `fbq('init', ${jsStringLiteral(pixelId)});`
19
+ */
20
+ const LINE_SEPARATORS = new RegExp("[\u2028\u2029]", "g")
21
+
22
+ export function jsStringLiteral(value: string): string {
23
+ const escaped = String(value)
24
+ .replace(/\\/g, "\\\\")
25
+ .replace(/'/g, "\\'")
26
+ .replace(/\r/g, "\\r")
27
+ .replace(/\n/g, "\\n")
28
+ // `</script` inside an inline script closes the element wherever it
29
+ // appears, quoted or not.
30
+ .replace(/<\//g, "<\\/")
31
+ // U+2028 and U+2029 are line terminators to a JavaScript parser, so
32
+ // they break a string literal exactly like a newline does. The class
33
+ // is built from escapes rather than written literally, because a
34
+ // source file carrying those characters raw is its own version of
35
+ // this problem (it breaks the file that fixes it).
36
+ .replace(LINE_SEPARATORS, (ch) =>
37
+ ch.charCodeAt(0) === 0x2028 ? "\\u2028" : "\\u2029"
38
+ )
39
+ return `'${escaped}'`
40
+ }
41
+
42
+ /**
43
+ * The same value for a URL query parameter (the loader `src`), where the
44
+ * hazard is a crafted id breaking out of the parameter rather than out of
45
+ * a string literal.
46
+ */
47
+ export function urlParam(value: string): string {
48
+ return encodeURIComponent(String(value))
49
+ }