@cartbase/storefront 0.20.1 → 0.22.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/package.json +274 -258
- package/src/api/checkout.ts +15 -0
- package/src/api/http.ts +10 -0
- package/src/api/integrations.ts +121 -0
- package/src/api/store.ts +21 -0
- package/src/cart-drawer/payment-badges.tsx +36 -96
- package/src/checkout/card-offer.ts +68 -0
- package/src/checkout/carrier-marks.ts +53 -0
- package/src/checkout/checkout-client.tsx +71 -13
- package/src/checkout/checkout-error-screen.tsx +113 -0
- package/src/checkout/discount-section.tsx +88 -56
- package/src/checkout/fulfillment-option.ts +30 -0
- package/src/checkout/index.ts +41 -6
- package/src/checkout/labels.ts +98 -0
- package/src/checkout/line-item-card.tsx +35 -83
- package/src/checkout/mobile-checkout-bottom-bar.tsx +132 -0
- package/src/checkout/mobile-checkout-top-bar.tsx +94 -0
- package/src/checkout/mobile-order-summary-body.tsx +172 -0
- package/src/checkout/order-summary.tsx +85 -179
- package/src/checkout/payment-button.tsx +25 -8
- package/src/checkout/payment-method-list.tsx +51 -13
- package/src/checkout/payment-wrapper.tsx +57 -13
- package/src/checkout/pickup-option.ts +35 -0
- package/src/checkout/pickup-point-selector.tsx +372 -0
- package/src/checkout/pigeon-office-selector.tsx +379 -0
- package/src/checkout/shipping-method-list.tsx +102 -10
- package/src/checkout/summary-math.ts +152 -0
- package/src/checkout/use-checkout-funnel.ts +303 -0
- package/src/checkout/use-checkout-orchestration.ts +384 -30
- package/src/common/icons/cartbase-mark.ts +9 -0
- package/src/common/icons/payment-marks.ts +73 -0
- package/src/common/icons/social-marks.ts +61 -0
- package/src/common/index.ts +15 -0
- package/src/common/payment-icons.tsx +54 -0
- package/src/common/powered-by-cartbase.tsx +47 -0
- package/src/common/social-links.tsx +65 -0
- package/src/lib/stripe-env.ts +25 -0
- package/src/locales/bg.ts +39 -0
- package/src/locales/es.ts +37 -0
- package/src/store/labels.ts +10 -0
- package/src/tracking/attribution.ts +83 -0
- package/src/tracking/consent.ts +53 -0
- package/src/tracking/events.ts +113 -0
- package/src/tracking/fbq.ts +48 -0
- package/src/tracking/gtag.ts +32 -0
- package/src/tracking/index.ts +32 -1
- package/src/tracking/once.ts +137 -0
- package/src/tracking/rybbit-events.ts +42 -0
- package/src/tracking/ttq.ts +24 -0
- package/src/tracking/types.ts +34 -0
|
@@ -547,6 +547,89 @@ export const CLICK_ID_METADATA_KEYS: ReadonlyArray<readonly [string, string]> =
|
|
|
547
547
|
[CLICK_ID_COOKIES.oppref, "oai_oppref"],
|
|
548
548
|
]
|
|
549
549
|
|
|
550
|
+
/**
|
|
551
|
+
* Everything the BROWSER can tell the cart about where this order came
|
|
552
|
+
* from, in the same flat keys the server-side `getTrackingAttribution()`
|
|
553
|
+
* produces: the Facebook pair and the visitor id, both UTM tuples, and
|
|
554
|
+
* every ad-click identifier.
|
|
555
|
+
*
|
|
556
|
+
* Why a browser-side twin exists at all. The click ids are captured on the
|
|
557
|
+
* landing page and have to reach the ORDER, and the one moment where a
|
|
558
|
+
* cart certainly exists and is not yet placed is the checkout the shopper
|
|
559
|
+
* is standing in. The server-side reader can only run in a server
|
|
560
|
+
* component or an action, so a checkout written as a client component (the
|
|
561
|
+
* kit's, and every store's) had no way to reach them; the ids sat in the
|
|
562
|
+
* jar until the order was gone. This reads the same cookies through
|
|
563
|
+
* `document.cookie` and hands back a payload for `cart.metadata`, which
|
|
564
|
+
* cart completion copies onto `order.metadata` and the `order.placed`
|
|
565
|
+
* forwarder reads.
|
|
566
|
+
*
|
|
567
|
+
* What it deliberately does NOT carry: the user agent, the client IP, the
|
|
568
|
+
* referer and the GA session id. Those are request facts, so the server
|
|
569
|
+
* reader stays the authority for them, and the two payloads merge by key
|
|
570
|
+
* without ever disagreeing.
|
|
571
|
+
*
|
|
572
|
+
* Consent is the CALLER's to check: this function only reads what is
|
|
573
|
+
* already on the device, but writing it onto an order is a choice the
|
|
574
|
+
* shopper's consent governs.
|
|
575
|
+
*/
|
|
576
|
+
export function readBrowserAttribution(): Record<string, string | number> {
|
|
577
|
+
if (!isBrowser()) return {}
|
|
578
|
+
const out: Record<string, string | number> = {}
|
|
579
|
+
|
|
580
|
+
// getOrCreate, not a plain read: the Pixel writes _fbp on init, and when
|
|
581
|
+
// an ad blocker or a slow script means it never did, ours is the value
|
|
582
|
+
// both sides will share (see the module header).
|
|
583
|
+
const fbp = getOrCreateFbp()
|
|
584
|
+
if (fbp) out.fb_fbp = fbp
|
|
585
|
+
const fbc = getOrCreateFbc()
|
|
586
|
+
if (fbc) out.fb_fbc = fbc
|
|
587
|
+
const anonId = getOrCreateAnonId()
|
|
588
|
+
if (anonId) out.fb_anon_id = anonId
|
|
589
|
+
|
|
590
|
+
const ga = getCookie("_ga")
|
|
591
|
+
if (ga) {
|
|
592
|
+
// "GA1.1.<client_id>.<timestamp>" — the canonical client id is the
|
|
593
|
+
// last two segments joined, exactly as the server reader takes it.
|
|
594
|
+
const parts = ga.split(".")
|
|
595
|
+
out.ga_client_id = parts.length >= 4 ? `${parts[2]}.${parts[3]}` : ga
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
const first = getCapturedFirstTouchUtms()
|
|
599
|
+
if (first) applyUtmTuple(first, "utm_first", out)
|
|
600
|
+
const last = getCapturedLastTouchUtms()
|
|
601
|
+
if (last) applyUtmTuple(last, "utm_last", out)
|
|
602
|
+
|
|
603
|
+
for (const [cookieName, metadataKey] of CLICK_ID_METADATA_KEYS) {
|
|
604
|
+
const value = getCookie(cookieName)
|
|
605
|
+
if (!value) continue
|
|
606
|
+
// Written encoded by the capture; decoded once here so the wire
|
|
607
|
+
// carries the id the platform actually issued.
|
|
608
|
+
try {
|
|
609
|
+
out[metadataKey] = decodeURIComponent(value)
|
|
610
|
+
} catch {
|
|
611
|
+
out[metadataKey] = value
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
return out
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/** Flatten one captured tuple onto the payload, dropping empty fields so a
|
|
619
|
+
* partial touch never writes null keys onto an order. */
|
|
620
|
+
function applyUtmTuple(
|
|
621
|
+
utms: CapturedUtms,
|
|
622
|
+
prefix: "utm_first" | "utm_last",
|
|
623
|
+
out: Record<string, string | number>
|
|
624
|
+
): void {
|
|
625
|
+
if (utms.utm_source) out[`${prefix}_source`] = utms.utm_source
|
|
626
|
+
if (utms.utm_medium) out[`${prefix}_medium`] = utms.utm_medium
|
|
627
|
+
if (utms.utm_campaign) out[`${prefix}_campaign`] = utms.utm_campaign
|
|
628
|
+
if (utms.utm_term) out[`${prefix}_term`] = utms.utm_term
|
|
629
|
+
if (utms.utm_content) out[`${prefix}_content`] = utms.utm_content
|
|
630
|
+
out[`${prefix}_captured_at`] = utms.captured_at
|
|
631
|
+
}
|
|
632
|
+
|
|
550
633
|
/**
|
|
551
634
|
* Capture every ad-click identifier present on the current URL.
|
|
552
635
|
*
|
package/src/tracking/consent.ts
CHANGED
|
@@ -51,6 +51,28 @@ export const CONSENT_MAX_AGE_SECONDS = 365 * 24 * 60 * 60
|
|
|
51
51
|
* (dispatched by `openConsentSettings()`, e.g. from a footer link). */
|
|
52
52
|
export const CONSENT_OPEN_EVENT = "1click:consent:open"
|
|
53
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Fired on `window` whenever a decision is applied, by the built-in banner
|
|
56
|
+
* or by an external CMP through `setConsent`. The detail is the choices.
|
|
57
|
+
*
|
|
58
|
+
* A component that must ACT on consent, rather than merely load a tag,
|
|
59
|
+
* needs to hear the answer as well as read it: the checkout writes where
|
|
60
|
+
* an order came from onto the cart, and a shopper who accepts while
|
|
61
|
+
* standing on the checkout page would otherwise be read once, before they
|
|
62
|
+
* chose, and never again. Same reasoning as `applyConsent` itself: the
|
|
63
|
+
* decision is relayed from the one door every choice already goes
|
|
64
|
+
* through, never polled for.
|
|
65
|
+
*/
|
|
66
|
+
export const CONSENT_CHANGED_EVENT = "cartbase:consent:changed"
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Where the store's consent switch is left for the browser by the
|
|
70
|
+
* `ConsentInit` snippet, which is the only component told whether the
|
|
71
|
+
* store collects consent at all (`consent.enabled`). A client component
|
|
72
|
+
* that has to apply the same rule reads it here instead of fetching.
|
|
73
|
+
*/
|
|
74
|
+
export const CONSENT_REQUIRED_FLAG = "__cartbase_consent_required"
|
|
75
|
+
|
|
54
76
|
export type ConsentChoices = {
|
|
55
77
|
/** analytics_storage */
|
|
56
78
|
analytics: boolean
|
|
@@ -143,6 +165,36 @@ export function applyConsent(choices: ConsentChoices): void {
|
|
|
143
165
|
w.oaiq("consent", choices.ads)
|
|
144
166
|
if (choices.ads) w.oaiq("measure", "page_viewed", { type: "contents" })
|
|
145
167
|
}
|
|
168
|
+
|
|
169
|
+
// And the same decision to our own side, for the surfaces that act on
|
|
170
|
+
// consent rather than load a tag (the checkout's attribution write).
|
|
171
|
+
try {
|
|
172
|
+
window.dispatchEvent(
|
|
173
|
+
new CustomEvent(CONSENT_CHANGED_EVENT, { detail: choices })
|
|
174
|
+
)
|
|
175
|
+
} catch {
|
|
176
|
+
// An environment with no CustomEvent: the vendors above were still told.
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* What applies RIGHT NOW: the visitor's stored choice, and before they
|
|
182
|
+
* make one, the store's own default.
|
|
183
|
+
*
|
|
184
|
+
* The rule is the snippet's rule, and there is only one of it (Alexander,
|
|
185
|
+
* 2026-09-14): a banner means consent is collected and nothing fires until
|
|
186
|
+
* it is given; no banner means the store collects none, so there is
|
|
187
|
+
* nothing to wait for. A caller that invented its own answer here would
|
|
188
|
+
* either write an EU shopper's identifiers before they agreed, or silence
|
|
189
|
+
* every store on earth that runs no banner.
|
|
190
|
+
*/
|
|
191
|
+
export function consentDecision(): ConsentChoices {
|
|
192
|
+
const stored = readConsentCookie()
|
|
193
|
+
if (stored) return stored
|
|
194
|
+
const required =
|
|
195
|
+
typeof window !== "undefined" &&
|
|
196
|
+
Boolean((window as unknown as Record<string, unknown>)[CONSENT_REQUIRED_FLAG])
|
|
197
|
+
return { analytics: !required, ads: !required, ts: 0 }
|
|
146
198
|
}
|
|
147
199
|
|
|
148
200
|
/**
|
|
@@ -206,6 +258,7 @@ export function consentInitSnippet(required: boolean): string {
|
|
|
206
258
|
if(m){c=JSON.parse(decodeURIComponent(m[1]));}
|
|
207
259
|
}catch(e){}
|
|
208
260
|
var ads=c?!!c.ads:${fallback}, an=c?!!c.analytics:${fallback};
|
|
261
|
+
window.${CONSENT_REQUIRED_FLAG}=${required};
|
|
209
262
|
window.dataLayer=window.dataLayer||[];
|
|
210
263
|
function gtag(){dataLayer.push(arguments);}
|
|
211
264
|
gtag('consent','default',{
|
package/src/tracking/events.ts
CHANGED
|
@@ -1,29 +1,37 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
|
+
trackAddPaymentInfo,
|
|
5
|
+
trackAddShippingInfo,
|
|
4
6
|
trackAddToCart,
|
|
5
7
|
trackInitiateCheckout,
|
|
6
8
|
trackPurchase,
|
|
7
9
|
trackViewContent,
|
|
8
10
|
} from "./fbq"
|
|
9
11
|
import {
|
|
12
|
+
trackGAAddPaymentInfo,
|
|
13
|
+
trackGAAddShippingInfo,
|
|
10
14
|
trackGAAddToCart,
|
|
11
15
|
trackGABeginCheckout,
|
|
12
16
|
trackGAPurchase,
|
|
13
17
|
trackGAViewItem,
|
|
14
18
|
} from "./gtag"
|
|
15
19
|
import {
|
|
20
|
+
trackRybbitAddPaymentInfo,
|
|
21
|
+
trackRybbitAddShippingInfo,
|
|
16
22
|
trackRybbitAddToCart,
|
|
17
23
|
trackRybbitBeginCheckout,
|
|
18
24
|
trackRybbitPurchase,
|
|
19
25
|
trackRybbitViewItem,
|
|
20
26
|
} from "./rybbit-events"
|
|
21
27
|
import {
|
|
28
|
+
trackTikTokAddPaymentInfo,
|
|
22
29
|
trackTikTokAddToCart,
|
|
23
30
|
trackTikTokInitiateCheckout,
|
|
24
31
|
trackTikTokPurchase,
|
|
25
32
|
trackTikTokViewContent,
|
|
26
33
|
} from "./ttq"
|
|
34
|
+
import { checkoutStepEventId } from "./once"
|
|
27
35
|
import {
|
|
28
36
|
minorUnits,
|
|
29
37
|
trackOpenAiCheckoutStarted,
|
|
@@ -259,6 +267,111 @@ export function trackCheckoutStart(input: {
|
|
|
259
267
|
})
|
|
260
268
|
}
|
|
261
269
|
|
|
270
|
+
/**
|
|
271
|
+
* Contact and delivery details complete.
|
|
272
|
+
*
|
|
273
|
+
* The highest match-quality moment in the whole funnel: email, phone,
|
|
274
|
+
* name, city and postcode are all known here, which is why the checkout
|
|
275
|
+
* re-inits the Pixel's advanced matching at the same instant.
|
|
276
|
+
*
|
|
277
|
+
* The event id is DERIVED from the cart, never passed in, for the same
|
|
278
|
+
* reason Purchase derives its own from the order: two tabs on one cart, a
|
|
279
|
+
* restored session or a retried request then collapse into one event on
|
|
280
|
+
* Meta's side instead of counting three times.
|
|
281
|
+
*/
|
|
282
|
+
export function trackCheckoutShippingInfo(input: {
|
|
283
|
+
lines: TrackedLine[]
|
|
284
|
+
currency: string
|
|
285
|
+
value: number
|
|
286
|
+
/** The cart the step belongs to; the dedup key is built from it. */
|
|
287
|
+
cartId: string
|
|
288
|
+
/** The courier chosen, omitted while the shopper has picked none. */
|
|
289
|
+
shippingTier?: string
|
|
290
|
+
coupon?: string
|
|
291
|
+
}): void {
|
|
292
|
+
const { lines, currency, value, cartId, shippingTier, coupon } = input
|
|
293
|
+
trackAddShippingInfo(
|
|
294
|
+
{
|
|
295
|
+
content_ids: lines.map((line) => line.productId),
|
|
296
|
+
content_type: "product",
|
|
297
|
+
currency,
|
|
298
|
+
value,
|
|
299
|
+
num_items: unitCount(lines),
|
|
300
|
+
contents: metaContents(lines),
|
|
301
|
+
...(shippingTier ? { shipping_tier: shippingTier } : {}),
|
|
302
|
+
},
|
|
303
|
+
{ eventId: checkoutStepEventId("AddShippingInfo", cartId) }
|
|
304
|
+
)
|
|
305
|
+
trackGAAddShippingInfo({
|
|
306
|
+
currency,
|
|
307
|
+
value,
|
|
308
|
+
items: gaItems(lines, currency),
|
|
309
|
+
...(shippingTier ? { shipping_tier: shippingTier } : {}),
|
|
310
|
+
...(coupon ? { coupon } : {}),
|
|
311
|
+
})
|
|
312
|
+
trackRybbitAddShippingInfo({
|
|
313
|
+
item_ids: lines.map((line) => line.variantId || line.productId),
|
|
314
|
+
num_items: unitCount(lines),
|
|
315
|
+
currency,
|
|
316
|
+
value,
|
|
317
|
+
...(shippingTier ? { shipping_tier: shippingTier } : {}),
|
|
318
|
+
})
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* The shopper has settled how they will pay.
|
|
323
|
+
*
|
|
324
|
+
* Cash on delivery counts. Meta defines the event as "payment information
|
|
325
|
+
* is added in the checkout flow" and GA4 defines `payment_type` as "the
|
|
326
|
+
* chosen method of payment", so choosing to pay the courier completes the
|
|
327
|
+
* step; it simply involves no card. Carrying `payment_type` is what keeps
|
|
328
|
+
* card and cash separable in reporting, which no funnel here could show
|
|
329
|
+
* before.
|
|
330
|
+
*/
|
|
331
|
+
export function trackCheckoutPaymentInfo(input: {
|
|
332
|
+
lines: TrackedLine[]
|
|
333
|
+
currency: string
|
|
334
|
+
value: number
|
|
335
|
+
cartId: string
|
|
336
|
+
/** "card" or "cod". */
|
|
337
|
+
paymentType?: string
|
|
338
|
+
coupon?: string
|
|
339
|
+
}): void {
|
|
340
|
+
const { lines, currency, value, cartId, paymentType, coupon } = input
|
|
341
|
+
trackAddPaymentInfo(
|
|
342
|
+
{
|
|
343
|
+
content_ids: lines.map((line) => line.productId),
|
|
344
|
+
content_type: "product",
|
|
345
|
+
currency,
|
|
346
|
+
value,
|
|
347
|
+
num_items: unitCount(lines),
|
|
348
|
+
contents: metaContents(lines),
|
|
349
|
+
...(paymentType ? { payment_type: paymentType } : {}),
|
|
350
|
+
},
|
|
351
|
+
{ eventId: checkoutStepEventId("AddPaymentInfo", cartId) }
|
|
352
|
+
)
|
|
353
|
+
trackTikTokAddPaymentInfo({
|
|
354
|
+
contents: tiktokContents(lines),
|
|
355
|
+
currency,
|
|
356
|
+
value,
|
|
357
|
+
...(paymentType ? { paymentType } : {}),
|
|
358
|
+
})
|
|
359
|
+
trackGAAddPaymentInfo({
|
|
360
|
+
currency,
|
|
361
|
+
value,
|
|
362
|
+
items: gaItems(lines, currency),
|
|
363
|
+
...(paymentType ? { payment_type: paymentType } : {}),
|
|
364
|
+
...(coupon ? { coupon } : {}),
|
|
365
|
+
})
|
|
366
|
+
trackRybbitAddPaymentInfo({
|
|
367
|
+
item_ids: lines.map((line) => line.variantId || line.productId),
|
|
368
|
+
num_items: unitCount(lines),
|
|
369
|
+
currency,
|
|
370
|
+
value,
|
|
371
|
+
...(paymentType ? { payment_type: paymentType } : {}),
|
|
372
|
+
})
|
|
373
|
+
}
|
|
374
|
+
|
|
262
375
|
/**
|
|
263
376
|
* Order confirmed — the money event.
|
|
264
377
|
*
|
package/src/tracking/fbq.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
3
|
import type {
|
|
4
|
+
AddPaymentInfoData,
|
|
5
|
+
AddShippingInfoData,
|
|
4
6
|
AddToCartData,
|
|
5
7
|
InitiateCheckoutData,
|
|
6
8
|
LeadData,
|
|
@@ -122,6 +124,52 @@ export function trackInitiateCheckout(data: InitiateCheckoutData): void {
|
|
|
122
124
|
})
|
|
123
125
|
}
|
|
124
126
|
|
|
127
|
+
/**
|
|
128
|
+
* The event id a mid-funnel checkout step carries. Callers pass the
|
|
129
|
+
* deterministic one from `checkoutStepEventId(event, cartId)` so two tabs
|
|
130
|
+
* on the same cart collapse into one event on Meta's side instead of
|
|
131
|
+
* counting twice.
|
|
132
|
+
*/
|
|
133
|
+
export type CheckoutStepContext = {
|
|
134
|
+
eventId?: string
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* AddShippingInfo — the shopper's contact and delivery details are
|
|
139
|
+
* complete. The highest match-quality moment in the funnel: email, phone,
|
|
140
|
+
* name, city and postcode are all known, which is why the advanced
|
|
141
|
+
* matching re-init runs beside it.
|
|
142
|
+
*
|
|
143
|
+
* `trackCustom`, not `track`: AddShippingInfo is not in Meta's standard
|
|
144
|
+
* event vocabulary, and calling `track()` with an unknown name makes the
|
|
145
|
+
* Pixel log a warning and treat it as custom anyway.
|
|
146
|
+
*/
|
|
147
|
+
export function trackAddShippingInfo(
|
|
148
|
+
data: AddShippingInfoData,
|
|
149
|
+
context: CheckoutStepContext = {}
|
|
150
|
+
): void {
|
|
151
|
+
const fbq = safeFbq()
|
|
152
|
+
if (!fbq) return
|
|
153
|
+
fbq(
|
|
154
|
+
"trackCustom",
|
|
155
|
+
"AddShippingInfo",
|
|
156
|
+
data as unknown as Record<string, unknown>,
|
|
157
|
+
{ eventID: context.eventId ?? generateEventId("AddShippingInfo") }
|
|
158
|
+
)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** AddPaymentInfo — the shopper has settled how they will pay. */
|
|
162
|
+
export function trackAddPaymentInfo(
|
|
163
|
+
data: AddPaymentInfoData,
|
|
164
|
+
context: CheckoutStepContext = {}
|
|
165
|
+
): void {
|
|
166
|
+
const fbq = safeFbq()
|
|
167
|
+
if (!fbq) return
|
|
168
|
+
fbq("track", "AddPaymentInfo", data as unknown as Record<string, unknown>, {
|
|
169
|
+
eventID: context.eventId ?? generateEventId("AddPaymentInfo"),
|
|
170
|
+
})
|
|
171
|
+
}
|
|
172
|
+
|
|
125
173
|
/**
|
|
126
174
|
* Purchase event — eventID MUST equal `purchase_${order.display_id}`
|
|
127
175
|
* to dedupe with the Cartbase backend's order.placed CAPI Purchase.
|
package/src/tracking/gtag.ts
CHANGED
|
@@ -56,6 +56,24 @@ type GA4BeginCheckoutData = {
|
|
|
56
56
|
coupon?: string
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
type GA4AddShippingInfoData = {
|
|
60
|
+
currency: string
|
|
61
|
+
value: number
|
|
62
|
+
items: GA4Item[]
|
|
63
|
+
/** The delivery method the shopper chose, GA4's own field name. */
|
|
64
|
+
shipping_tier?: string
|
|
65
|
+
coupon?: string
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
type GA4AddPaymentInfoData = {
|
|
69
|
+
currency: string
|
|
70
|
+
value: number
|
|
71
|
+
items: GA4Item[]
|
|
72
|
+
/** "card" or "cod" here: GA4 defines it as the chosen method of payment. */
|
|
73
|
+
payment_type?: string
|
|
74
|
+
coupon?: string
|
|
75
|
+
}
|
|
76
|
+
|
|
59
77
|
type GA4PurchaseData = {
|
|
60
78
|
transaction_id: string
|
|
61
79
|
currency: string
|
|
@@ -101,6 +119,18 @@ export function trackGABeginCheckout(data: GA4BeginCheckoutData): void {
|
|
|
101
119
|
gtag("event", "begin_checkout", data)
|
|
102
120
|
}
|
|
103
121
|
|
|
122
|
+
export function trackGAAddShippingInfo(data: GA4AddShippingInfoData): void {
|
|
123
|
+
const gtag = safeGtag()
|
|
124
|
+
if (!gtag) return
|
|
125
|
+
gtag("event", "add_shipping_info", data)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function trackGAAddPaymentInfo(data: GA4AddPaymentInfoData): void {
|
|
129
|
+
const gtag = safeGtag()
|
|
130
|
+
if (!gtag) return
|
|
131
|
+
gtag("event", "add_payment_info", data)
|
|
132
|
+
}
|
|
133
|
+
|
|
104
134
|
/**
|
|
105
135
|
* Purchase event — GA4 deduplicates by `transaction_id`. Pass the SAME
|
|
106
136
|
* `String(order.display_id)` here AND in the backend Measurement Protocol
|
|
@@ -209,5 +239,7 @@ export type {
|
|
|
209
239
|
GA4ViewItemData,
|
|
210
240
|
GA4AddToCartData,
|
|
211
241
|
GA4BeginCheckoutData,
|
|
242
|
+
GA4AddShippingInfoData,
|
|
243
|
+
GA4AddPaymentInfoData,
|
|
212
244
|
GA4PurchaseData,
|
|
213
245
|
}
|
package/src/tracking/index.ts
CHANGED
|
@@ -29,7 +29,10 @@
|
|
|
29
29
|
* analytics destination)
|
|
30
30
|
* - ONE call per commerce moment, every vendor at once (prefer these):
|
|
31
31
|
* trackProductView / trackCartAdd / trackCheckoutStart /
|
|
32
|
-
*
|
|
32
|
+
* trackCheckoutShippingInfo / trackCheckoutPaymentInfo /
|
|
33
|
+
* trackOrderPurchase. The three checkout moments are fired FOR a store
|
|
34
|
+
* by `useCheckoutOrchestration` (see `useCheckoutFunnel`), so a
|
|
35
|
+
* checkout built on the kit reports the whole funnel with no wiring
|
|
33
36
|
* - Meta Pixel client helpers: trackViewContent / trackAddToCart /
|
|
34
37
|
* trackInitiateCheckout / trackPurchase / trackLead
|
|
35
38
|
* - GA4 client helpers: trackGAViewItem / trackGAAddToCart /
|
|
@@ -76,6 +79,9 @@ export {
|
|
|
76
79
|
setConsent,
|
|
77
80
|
openConsentSettings,
|
|
78
81
|
readConsentCookie,
|
|
82
|
+
consentDecision,
|
|
83
|
+
CONSENT_CHANGED_EVENT,
|
|
84
|
+
CONSENT_REQUIRED_FLAG,
|
|
79
85
|
writeConsentCookie,
|
|
80
86
|
shouldRenderBanner,
|
|
81
87
|
pickConsentCopy,
|
|
@@ -95,11 +101,22 @@ export {
|
|
|
95
101
|
trackViewContent,
|
|
96
102
|
trackAddToCart,
|
|
97
103
|
trackInitiateCheckout,
|
|
104
|
+
trackAddShippingInfo,
|
|
105
|
+
trackAddPaymentInfo,
|
|
98
106
|
trackPurchase,
|
|
99
107
|
trackLead,
|
|
100
108
|
generateEventId,
|
|
109
|
+
type CheckoutStepContext,
|
|
101
110
|
type LeadContext,
|
|
102
111
|
} from "./fbq"
|
|
112
|
+
// The once-per-cart guard behind the checkout funnel's mid-funnel steps,
|
|
113
|
+
// and the deterministic event id that backs it up on Meta's side.
|
|
114
|
+
export {
|
|
115
|
+
markFiredOnce,
|
|
116
|
+
hasFired,
|
|
117
|
+
forgetFiredEvents,
|
|
118
|
+
checkoutStepEventId,
|
|
119
|
+
} from "./once"
|
|
103
120
|
export {
|
|
104
121
|
setTrackingDefaults,
|
|
105
122
|
getOrCreateAnonId,
|
|
@@ -109,6 +126,7 @@ export {
|
|
|
109
126
|
rememberKnownVisitor,
|
|
110
127
|
captureUtmsFromUrl,
|
|
111
128
|
captureClickIdsFromUrl,
|
|
129
|
+
readBrowserAttribution,
|
|
112
130
|
getCapturedFirstTouchUtms,
|
|
113
131
|
getCapturedLastTouchUtms,
|
|
114
132
|
sha256Hex,
|
|
@@ -124,6 +142,8 @@ export {
|
|
|
124
142
|
trackProductView,
|
|
125
143
|
trackCartAdd,
|
|
126
144
|
trackCheckoutStart,
|
|
145
|
+
trackCheckoutShippingInfo,
|
|
146
|
+
trackCheckoutPaymentInfo,
|
|
127
147
|
trackOrderPurchase,
|
|
128
148
|
type TrackedLine,
|
|
129
149
|
type TrackedOrder,
|
|
@@ -132,6 +152,7 @@ export {
|
|
|
132
152
|
trackTikTokViewContent,
|
|
133
153
|
trackTikTokAddToCart,
|
|
134
154
|
trackTikTokInitiateCheckout,
|
|
155
|
+
trackTikTokAddPaymentInfo,
|
|
135
156
|
trackTikTokPurchase,
|
|
136
157
|
applyTikTokConsent,
|
|
137
158
|
tiktokPurchaseEventId,
|
|
@@ -157,6 +178,8 @@ export {
|
|
|
157
178
|
trackGAViewItem,
|
|
158
179
|
trackGAAddToCart,
|
|
159
180
|
trackGABeginCheckout,
|
|
181
|
+
trackGAAddShippingInfo,
|
|
182
|
+
trackGAAddPaymentInfo,
|
|
160
183
|
trackGAPurchase,
|
|
161
184
|
setEnhancedConversions,
|
|
162
185
|
type EnhancedConversionsInput,
|
|
@@ -165,10 +188,14 @@ export {
|
|
|
165
188
|
trackRybbitViewItem,
|
|
166
189
|
trackRybbitAddToCart,
|
|
167
190
|
trackRybbitBeginCheckout,
|
|
191
|
+
trackRybbitAddShippingInfo,
|
|
192
|
+
trackRybbitAddPaymentInfo,
|
|
168
193
|
trackRybbitPurchase,
|
|
169
194
|
type RybbitViewItemData,
|
|
170
195
|
type RybbitAddToCartData,
|
|
171
196
|
type RybbitBeginCheckoutData,
|
|
197
|
+
type RybbitAddShippingInfoData,
|
|
198
|
+
type RybbitAddPaymentInfoData,
|
|
172
199
|
type RybbitPurchaseData,
|
|
173
200
|
} from "./rybbit-events"
|
|
174
201
|
export { getTrackingConfig } from "./get-tracking-config"
|
|
@@ -188,6 +215,8 @@ export type {
|
|
|
188
215
|
ViewContentData,
|
|
189
216
|
AddToCartData,
|
|
190
217
|
InitiateCheckoutData,
|
|
218
|
+
AddShippingInfoData,
|
|
219
|
+
AddPaymentInfoData,
|
|
191
220
|
PurchaseData,
|
|
192
221
|
LeadData,
|
|
193
222
|
} from "./types"
|
|
@@ -197,5 +226,7 @@ export type {
|
|
|
197
226
|
GA4ViewItemData,
|
|
198
227
|
GA4AddToCartData,
|
|
199
228
|
GA4BeginCheckoutData,
|
|
229
|
+
GA4AddShippingInfoData,
|
|
230
|
+
GA4AddPaymentInfoData,
|
|
200
231
|
GA4PurchaseData,
|
|
201
232
|
} from "./gtag"
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* once — "fire this tracking event at most once per cart" guard.
|
|
5
|
+
*
|
|
6
|
+
* Why this exists:
|
|
7
|
+
*
|
|
8
|
+
* The mid-funnel checkout events (add_shipping_info, add_payment_info)
|
|
9
|
+
* are driven by FORM STATE, not by a one-shot user action. A shopper
|
|
10
|
+
* can switch courier, toggle card and cash on delivery, correct a typo
|
|
11
|
+
* in their address or refresh the page, and each of those re-runs the
|
|
12
|
+
* effect that would fire the event. Without a guard one shopper emits a
|
|
13
|
+
* dozen add_payment_info events, which wrecks the funnel ratios in GA4
|
|
14
|
+
* and Rybbit and teaches Meta's optimizer that the event is worthless.
|
|
15
|
+
*
|
|
16
|
+
* A `useRef` guard is NOT enough: it lives in React memory and resets on
|
|
17
|
+
* every remount, so a refresh or a walk back into the checkout re-fires.
|
|
18
|
+
* The guard has to outlive the component.
|
|
19
|
+
*
|
|
20
|
+
* Design:
|
|
21
|
+
*
|
|
22
|
+
* - Keyed by `${event}:${cartId}` so a genuinely NEW cart (the shopper
|
|
23
|
+
* bought, then started a second order) fires its own events. The same
|
|
24
|
+
* cart never fires twice.
|
|
25
|
+
* - `sessionStorage`, not `localStorage`: the guard should expire when
|
|
26
|
+
* the browsing session does. A shopper returning tomorrow to the same
|
|
27
|
+
* abandoned cart is a new session and legitimately re-enters the
|
|
28
|
+
* funnel.
|
|
29
|
+
* - An in-memory Set mirrors the store, so repeated calls within one
|
|
30
|
+
* page never touch sessionStorage (Safari throws on quota and in
|
|
31
|
+
* private mode; we degrade to memory-only rather than fire twice).
|
|
32
|
+
*
|
|
33
|
+
* Belt and braces: callers pair this with a DETERMINISTIC `event_id` (see
|
|
34
|
+
* `checkoutStepEventId`) so that even if the guard is defeated, by two
|
|
35
|
+
* tabs on one cart or by storage cleared mid-session, Meta still collapses
|
|
36
|
+
* the duplicates server-side by event_id, exactly as it does for
|
|
37
|
+
* Purchase's `purchase_${display_id}`.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
const STORAGE_KEY = "cartbase:fired-events"
|
|
41
|
+
|
|
42
|
+
/** Mirrors sessionStorage so repeat calls in one page skip storage I/O. */
|
|
43
|
+
const memory = new Set<string>()
|
|
44
|
+
|
|
45
|
+
function readStore(): Set<string> {
|
|
46
|
+
if (memory.size > 0) return memory
|
|
47
|
+
if (typeof window === "undefined") return memory
|
|
48
|
+
try {
|
|
49
|
+
const raw = window.sessionStorage.getItem(STORAGE_KEY)
|
|
50
|
+
if (raw) {
|
|
51
|
+
const parsed = JSON.parse(raw) as unknown
|
|
52
|
+
if (Array.isArray(parsed)) {
|
|
53
|
+
for (const k of parsed) if (typeof k === "string") memory.add(k)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
} catch {
|
|
57
|
+
// Private mode, quota, corrupt JSON: memory-only from here.
|
|
58
|
+
}
|
|
59
|
+
return memory
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function persist(): void {
|
|
63
|
+
if (typeof window === "undefined") return
|
|
64
|
+
try {
|
|
65
|
+
window.sessionStorage.setItem(
|
|
66
|
+
STORAGE_KEY,
|
|
67
|
+
JSON.stringify(Array.from(memory))
|
|
68
|
+
)
|
|
69
|
+
} catch {
|
|
70
|
+
// Storage unavailable: the in-memory Set still dedupes this page.
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Returns true the FIRST time it is called for a given (event, cartId)
|
|
76
|
+
* pair and false every time after, marking the pair as fired.
|
|
77
|
+
*
|
|
78
|
+
* Callers treat it as the gate itself:
|
|
79
|
+
*
|
|
80
|
+
* if (!markFiredOnce("add_payment_info", cart.id)) return
|
|
81
|
+
* trackCheckoutPaymentInfo(...)
|
|
82
|
+
*
|
|
83
|
+
* Returns false when `cartId` is missing: a checkout-step event with no
|
|
84
|
+
* cart to key on cannot be deduped, and firing an undedupable event is
|
|
85
|
+
* worse than dropping it.
|
|
86
|
+
*/
|
|
87
|
+
export function markFiredOnce(
|
|
88
|
+
event: string,
|
|
89
|
+
cartId: string | undefined | null
|
|
90
|
+
): boolean {
|
|
91
|
+
if (!cartId) return false
|
|
92
|
+
if (typeof window === "undefined") return false
|
|
93
|
+
|
|
94
|
+
const key = `${event}:${cartId}`
|
|
95
|
+
const store = readStore()
|
|
96
|
+
if (store.has(key)) return false
|
|
97
|
+
|
|
98
|
+
store.add(key)
|
|
99
|
+
persist()
|
|
100
|
+
return true
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Read-only check, does not mark. For conditional UI and debugging. */
|
|
104
|
+
export function hasFired(
|
|
105
|
+
event: string,
|
|
106
|
+
cartId: string | undefined | null
|
|
107
|
+
): boolean {
|
|
108
|
+
if (!cartId || typeof window === "undefined") return false
|
|
109
|
+
return readStore().has(`${event}:${cartId}`)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Deterministic Meta `event_id` for a checkout-step event.
|
|
114
|
+
*
|
|
115
|
+
* Unlike `generateEventId()` (random, for events with no natural key),
|
|
116
|
+
* this derives the id from the cart, so the SAME logical action always
|
|
117
|
+
* produces the SAME id. That gives Meta a server-side dedup key: two tabs,
|
|
118
|
+
* a restored session or a retried request all collapse into one event
|
|
119
|
+
* instead of inflating the count. Same principle as Purchase's
|
|
120
|
+
* `purchase_${display_id}`.
|
|
121
|
+
*/
|
|
122
|
+
export function checkoutStepEventId(event: string, cartId: string): string {
|
|
123
|
+
return `${event}_${cartId}`
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Forget every guard for one cart. Called when the order is placed, so a
|
|
128
|
+
* shopper who starts a second order in the same session is not silenced by
|
|
129
|
+
* the keys of the cart they just bought.
|
|
130
|
+
*/
|
|
131
|
+
export function forgetFiredEvents(cartId: string | undefined | null): void {
|
|
132
|
+
if (!cartId) return
|
|
133
|
+
for (const key of Array.from(memory)) {
|
|
134
|
+
if (key.endsWith(`:${cartId}`)) memory.delete(key)
|
|
135
|
+
}
|
|
136
|
+
persist()
|
|
137
|
+
}
|