@cartbase/storefront 0.21.0 → 0.22.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -0
- package/package.json +274 -261
- package/src/api/checkout.ts +15 -0
- package/src/api/http.ts +9 -0
- package/src/api/integrations.ts +126 -0
- 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 +328 -0
- package/src/checkout/pickup-points.ts +65 -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/lib/stripe-env.ts +25 -0
- package/src/locales/bg.ts +37 -1
- package/src/locales/es.ts +35 -1
- 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
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import type { Cart, CartLineItem } from "../api/carts"
|
|
2
|
+
import { isProductLine } from "../lib/cart-helpers"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* checkoutTotals — the ONE place the checkout's displayed numbers are
|
|
6
|
+
* computed. The desktop summary and both mobile bars read it, so the total
|
|
7
|
+
* a shopper sees at the top of a phone, above the Buy button and in the
|
|
8
|
+
* right column is the same number by construction, not by three copies of
|
|
9
|
+
* the same formula kept in step by hand.
|
|
10
|
+
*
|
|
11
|
+
* Cartbase's payment-method fee is CART-LEVEL decoration
|
|
12
|
+
* (`payment_method_fee_total` / `payment_method_fee_label`, already folded
|
|
13
|
+
* into `cart.total` while a live method session exists,
|
|
14
|
+
* docs/storefront/checkout.md). It is never a line item, so there is no fee
|
|
15
|
+
* row to subtract out of the item list or the subtotal. `isProductLine`
|
|
16
|
+
* stays as a defensive filter for a cart ported from a platform that did
|
|
17
|
+
* carry one.
|
|
18
|
+
*
|
|
19
|
+
* Optimistic values: the deferred-checkout architecture keeps the shipping
|
|
20
|
+
* method and the method session off the cart until the Buy click, so a
|
|
21
|
+
* shopper who picks a courier or cash on delivery would otherwise watch an
|
|
22
|
+
* unchanged total. Each override has three states: null means no
|
|
23
|
+
* prediction, 0 means predict none, positive means predict that amount.
|
|
24
|
+
* Pure function, no React: it is unit-tested on its own.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
export type CheckoutTotalsInput = {
|
|
28
|
+
cart: Cart
|
|
29
|
+
/** Optimistic shipping total during method selection, or null. */
|
|
30
|
+
optimisticShippingCost: number | null
|
|
31
|
+
/** Optimistic payment-method fee during tender selection, or null. */
|
|
32
|
+
optimisticMethodFee?: number | null
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type CheckoutTotals = {
|
|
36
|
+
/** Product rows only, newest first, as every summary lists them. */
|
|
37
|
+
productItems: CartLineItem[]
|
|
38
|
+
/** Units, not rows: two of one product count as two. */
|
|
39
|
+
itemCount: number
|
|
40
|
+
/** Products before shipping, fee and tax. */
|
|
41
|
+
productSubtotal: number
|
|
42
|
+
/** The shipping figure to print, or null when it cannot be known yet. */
|
|
43
|
+
shippingCost: number | null
|
|
44
|
+
/** True when a figure is known; false prints "calculated at checkout". */
|
|
45
|
+
shippingKnown: boolean
|
|
46
|
+
/** The method fee to print; 0 hides the row. */
|
|
47
|
+
methodFeeAmount: number
|
|
48
|
+
/** The grand total to print, optimistic values applied. */
|
|
49
|
+
displayTotal: number
|
|
50
|
+
/** Gift-card tender covering part of the total (never moves the total). */
|
|
51
|
+
giftCardTotal: number
|
|
52
|
+
/** What is left to pay after the tender. */
|
|
53
|
+
giftCardRemainder: number
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* What the shopper still owes after any gift-card tender, which is the
|
|
58
|
+
* number the payment section answers to and NOT the total: a gift card is
|
|
59
|
+
* a tender, so it never moves the total, it covers part of it.
|
|
60
|
+
*
|
|
61
|
+
* The same arithmetic the platform does at prepare-checkout, where the
|
|
62
|
+
* charge is the collection's amount minus the tender, floored at zero.
|
|
63
|
+
* Keeping it here as one line means the screen and the server cannot
|
|
64
|
+
* disagree about whether an order asks for money.
|
|
65
|
+
*/
|
|
66
|
+
export function amountDueAfterTender(
|
|
67
|
+
displayTotal: number,
|
|
68
|
+
giftCardTotal?: number | null
|
|
69
|
+
): number {
|
|
70
|
+
return Math.max((displayTotal ?? 0) - (giftCardTotal ?? 0), 0)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* True when the order asks the shopper for nothing.
|
|
75
|
+
*
|
|
76
|
+
* Half a cent, not an exact zero: these are major units carrying
|
|
77
|
+
* optimistic deltas, so a fully covered total can land on 0.000000001 and
|
|
78
|
+
* an `=== 0` would put a card form in front of a shopper who owes nothing.
|
|
79
|
+
*/
|
|
80
|
+
export function isNothingToPay(amountDue: number): boolean {
|
|
81
|
+
return amountDue < 0.005
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Is this order being invoiced to a COMPANY?
|
|
86
|
+
*
|
|
87
|
+
* It decides whether the summary prints a VAT row at all. A consumer is
|
|
88
|
+
* shown the price they pay and nothing else: the VAT is inside that price,
|
|
89
|
+
* it is not a charge they can do anything about, and a row for it is noise
|
|
90
|
+
* on the screen where they are deciding to buy (Alexander, 2026-09-18). A
|
|
91
|
+
* company is the opposite case, because the buyer reclaims the VAT and has
|
|
92
|
+
* to see it.
|
|
93
|
+
*
|
|
94
|
+
* The signals are the platform's own, in the platform's own precedence
|
|
95
|
+
* (`src/lib/documents/compose.ts`, the buyer block): what the shopper typed
|
|
96
|
+
* at purchase, then the company they belong to. `metadata.company_name` is
|
|
97
|
+
* read too, because the kit's Company invoice fields land in the cart's
|
|
98
|
+
* metadata until they are moved onto the cart's own `invoice_to`.
|
|
99
|
+
*/
|
|
100
|
+
export function isCompanyOrder(cart: Cart): boolean {
|
|
101
|
+
const row = cart as unknown as {
|
|
102
|
+
invoice_to?: unknown
|
|
103
|
+
company_id?: string | null
|
|
104
|
+
}
|
|
105
|
+
if (row.invoice_to && typeof row.invoice_to === "object") return true
|
|
106
|
+
if (typeof row.company_id === "string" && row.company_id) return true
|
|
107
|
+
const typed = (cart.metadata as Record<string, unknown> | null)?.company_name
|
|
108
|
+
return typeof typed === "string" && typed.trim().length > 0
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function checkoutTotals({
|
|
112
|
+
cart,
|
|
113
|
+
optimisticShippingCost,
|
|
114
|
+
optimisticMethodFee,
|
|
115
|
+
}: CheckoutTotalsInput): CheckoutTotals {
|
|
116
|
+
const productItems = (cart.items ?? [])
|
|
117
|
+
.filter(isProductLine)
|
|
118
|
+
.slice()
|
|
119
|
+
.sort((a, b) => ((a.created_at ?? "") > (b.created_at ?? "") ? -1 : 1))
|
|
120
|
+
|
|
121
|
+
const itemCount = productItems.reduce((sum, item) => sum + item.quantity, 0)
|
|
122
|
+
|
|
123
|
+
const shippingCost =
|
|
124
|
+
optimisticShippingCost !== null ? optimisticShippingCost : cart.shipping_total ?? null
|
|
125
|
+
const shippingKnown = shippingCost !== null && shippingCost !== undefined
|
|
126
|
+
|
|
127
|
+
const realMethodFeeAmount = cart.payment_method_fee_total ?? 0
|
|
128
|
+
const methodFeeAmount =
|
|
129
|
+
optimisticMethodFee !== null && optimisticMethodFee !== undefined
|
|
130
|
+
? optimisticMethodFee
|
|
131
|
+
: realMethodFeeAmount
|
|
132
|
+
|
|
133
|
+
let displayTotal = cart.total ?? 0
|
|
134
|
+
if (optimisticShippingCost !== null) {
|
|
135
|
+
displayTotal = displayTotal - (cart.shipping_total ?? 0) + optimisticShippingCost
|
|
136
|
+
}
|
|
137
|
+
if (optimisticMethodFee !== null && optimisticMethodFee !== undefined) {
|
|
138
|
+
displayTotal = displayTotal - realMethodFeeAmount + methodFeeAmount
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
productItems,
|
|
143
|
+
itemCount,
|
|
144
|
+
productSubtotal: cart.item_total ?? 0,
|
|
145
|
+
shippingCost: shippingKnown ? shippingCost : null,
|
|
146
|
+
shippingKnown,
|
|
147
|
+
methodFeeAmount,
|
|
148
|
+
displayTotal,
|
|
149
|
+
giftCardTotal: cart.gift_card_total ?? 0,
|
|
150
|
+
giftCardRemainder: cart.gift_card_remainder ?? 0,
|
|
151
|
+
}
|
|
152
|
+
}
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useEffect, useMemo, useRef } from "react"
|
|
4
|
+
|
|
5
|
+
import { updateCart, type Cart } from "../api/carts"
|
|
6
|
+
import type { StorefrontClient } from "../api/http"
|
|
7
|
+
import { isProductLine } from "../lib/cart-helpers"
|
|
8
|
+
import { readBrowserAttribution } from "../tracking/attribution"
|
|
9
|
+
import { CONSENT_CHANGED_EVENT, consentDecision } from "../tracking/consent"
|
|
10
|
+
import {
|
|
11
|
+
trackCheckoutPaymentInfo,
|
|
12
|
+
trackCheckoutShippingInfo,
|
|
13
|
+
trackCheckoutStart,
|
|
14
|
+
type TrackedLine,
|
|
15
|
+
} from "../tracking/events"
|
|
16
|
+
import { setEnhancedConversions } from "../tracking/gtag"
|
|
17
|
+
import { updatePixelAdvancedMatching } from "../tracking/meta-pixel"
|
|
18
|
+
import { markFiredOnce } from "../tracking/once"
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* useCheckoutFunnel — the three checkout-step events, the ad platforms'
|
|
22
|
+
* identity signals, and where the order came from. Mounted by
|
|
23
|
+
* `useCheckoutOrchestration`, so a store gets the whole funnel by using
|
|
24
|
+
* the kit's checkout and a store with its own layout gets it by using the
|
|
25
|
+
* hook. Nobody wires an event by hand, and nobody can forget one.
|
|
26
|
+
*
|
|
27
|
+
* This is a platform guarantee rather than a merchant chore, the same
|
|
28
|
+
* ruling as the checkout error log: every vendor helper already existed in
|
|
29
|
+
* this package, and every checkout built on it fired NONE of them, because
|
|
30
|
+
* calling them was left to the store. Our own reference storefront and
|
|
31
|
+
* evoo both shipped a checkout that told Meta, GA4, TikTok and Rybbit
|
|
32
|
+
* nothing between the cart and the order.
|
|
33
|
+
*
|
|
34
|
+
* What fires, each at most once per cart:
|
|
35
|
+
*
|
|
36
|
+
* begin_checkout / InitiateCheckout — on arrival
|
|
37
|
+
* add_shipping_info / AddShippingInfo — contact and address complete
|
|
38
|
+
* add_payment_info / AddPaymentInfo — delivery and payment both chosen
|
|
39
|
+
*
|
|
40
|
+
* Deduplication is layered on purpose, because these are driven by FORM
|
|
41
|
+
* STATE and not by one-shot clicks. A shopper who switches courier,
|
|
42
|
+
* toggles card and cash, fixes a typo or refreshes would otherwise emit
|
|
43
|
+
* the same event over and over, which wrecks the funnel ratios and teaches
|
|
44
|
+
* Meta's optimizer that the event is worthless. The two layers are
|
|
45
|
+
* `markFiredOnce` (sessionStorage, so it survives a refresh) and a
|
|
46
|
+
* deterministic event id per cart (so Meta collapses anything that defeats
|
|
47
|
+
* the guard).
|
|
48
|
+
*
|
|
49
|
+
* Every helper underneath no-ops when its tag is absent, so a store with
|
|
50
|
+
* one pixel configured fires exactly that one.
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
export type UseCheckoutFunnelOptions = {
|
|
54
|
+
client: StorefrontClient
|
|
55
|
+
cart: Cart
|
|
56
|
+
/** OFF switches the whole funnel, for a store that fires its own. */
|
|
57
|
+
enabled?: boolean
|
|
58
|
+
/**
|
|
59
|
+
* The total the shopper is looking at (the hook's `optimisticTotal`),
|
|
60
|
+
* which is what the Purchase event will report once shipping and any
|
|
61
|
+
* method fee are on the cart. Reporting the raw cart total instead would
|
|
62
|
+
* understate every step by the delivery price.
|
|
63
|
+
*/
|
|
64
|
+
value: number
|
|
65
|
+
/** Every required contact and address field is filled. */
|
|
66
|
+
allRequiredFilled: boolean
|
|
67
|
+
/** The address is good enough to pay against. */
|
|
68
|
+
addressReady: boolean
|
|
69
|
+
/** A delivery method, and any office or locker it needs, are chosen. */
|
|
70
|
+
deliveryReady: boolean
|
|
71
|
+
/** The chosen courier's name, for `shipping_tier`. */
|
|
72
|
+
shippingTier?: string
|
|
73
|
+
/** Which tender the shopper settled on. */
|
|
74
|
+
paymentTab: "card" | "cod"
|
|
75
|
+
/** The live address form, for the identity signals. */
|
|
76
|
+
formData: Record<string, string>
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function useCheckoutFunnel({
|
|
80
|
+
client,
|
|
81
|
+
cart,
|
|
82
|
+
enabled = true,
|
|
83
|
+
value,
|
|
84
|
+
allRequiredFilled,
|
|
85
|
+
addressReady,
|
|
86
|
+
deliveryReady,
|
|
87
|
+
shippingTier,
|
|
88
|
+
paymentTab,
|
|
89
|
+
formData,
|
|
90
|
+
}: UseCheckoutFunnelOptions): void {
|
|
91
|
+
const cartId = cart?.id
|
|
92
|
+
const currency = (cart?.currency_code || "EUR").toUpperCase()
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The cart as the ad platforms want it. Product rows only: a payment
|
|
96
|
+
* surcharge is not something the shopper chose to buy, and sending it as
|
|
97
|
+
* purchase intent pollutes catalogue mapping on both Meta and TikTok.
|
|
98
|
+
*
|
|
99
|
+
* `productId` is the catalogue key on purpose. Meta's content_ids,
|
|
100
|
+
* TikTok's content_id and the feed's <g:id> have to be the SAME value or
|
|
101
|
+
* the event matches no catalogue entry, which costs dynamic ads and
|
|
102
|
+
* product-level reporting. GA4 keeps variant granularity, which
|
|
103
|
+
* `trackedLines` carries alongside.
|
|
104
|
+
*/
|
|
105
|
+
const lines = useMemo<TrackedLine[] | null>(() => {
|
|
106
|
+
const items = (cart?.items ?? []).filter(isProductLine)
|
|
107
|
+
if (items.length === 0) return null
|
|
108
|
+
return items.map((item) => ({
|
|
109
|
+
productId: item.product_id || item.variant_id || item.id,
|
|
110
|
+
variantId: item.variant_id || item.id,
|
|
111
|
+
title: item.product_title || item.title || "",
|
|
112
|
+
quantity: Number(item.quantity) || 1,
|
|
113
|
+
price: Number(item.unit_price) || 0,
|
|
114
|
+
}))
|
|
115
|
+
}, [cart?.items])
|
|
116
|
+
|
|
117
|
+
// The value at the moment each step fires. Held in a ref so a total that
|
|
118
|
+
// moves (a promo code, a courier) cannot re-run the effects: the guard
|
|
119
|
+
// would swallow the second fire anyway, and the dependency churn only
|
|
120
|
+
// makes the effects look like they might fire again.
|
|
121
|
+
const valueRef = useRef(value)
|
|
122
|
+
valueRef.current = value
|
|
123
|
+
const linesRef = useRef(lines)
|
|
124
|
+
linesRef.current = lines
|
|
125
|
+
const tierRef = useRef(shippingTier)
|
|
126
|
+
tierRef.current = shippingTier
|
|
127
|
+
|
|
128
|
+
// ── Step 1: arrival ────────────────────────────────────────────────
|
|
129
|
+
useEffect(() => {
|
|
130
|
+
if (!enabled || !cartId || !linesRef.current) return
|
|
131
|
+
if (!markFiredOnce("begin_checkout", cartId)) return
|
|
132
|
+
|
|
133
|
+
trackCheckoutStart({
|
|
134
|
+
lines: linesRef.current,
|
|
135
|
+
currency,
|
|
136
|
+
value: valueRef.current,
|
|
137
|
+
})
|
|
138
|
+
// Arrival fires once per cart; the refs carry the rest.
|
|
139
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
140
|
+
}, [enabled, cartId, currency])
|
|
141
|
+
|
|
142
|
+
// ── Where the order came from, onto the cart ───────────────────────
|
|
143
|
+
//
|
|
144
|
+
// The checkout is the one moment in the funnel where a cart certainly
|
|
145
|
+
// exists and the order has not been placed yet, and cart completion
|
|
146
|
+
// copies `cart.metadata` onto `order.metadata`, which the platform's
|
|
147
|
+
// `order.placed` forwarder reads to build the CAPI and Events API
|
|
148
|
+
// payloads. Before this the ad-click ids captured on the landing page
|
|
149
|
+
// reached the browser's cookie jar and stopped there: a checkout is a
|
|
150
|
+
// client component, and the reader that gathers them
|
|
151
|
+
// (`getTrackingAttribution`) runs only on a server.
|
|
152
|
+
//
|
|
153
|
+
// Consent governs the write, by the store's own rule: a banner means
|
|
154
|
+
// nothing is written until the shopper agrees, no banner means the store
|
|
155
|
+
// collects no consent and there is nothing to wait for. A shopper who
|
|
156
|
+
// accepts while standing here is heard, which is why this listens rather
|
|
157
|
+
// than reading once on arrival.
|
|
158
|
+
const cartMetadataRef = useRef(cart?.metadata)
|
|
159
|
+
cartMetadataRef.current = cart?.metadata
|
|
160
|
+
|
|
161
|
+
useEffect(() => {
|
|
162
|
+
if (!enabled || !cartId) return
|
|
163
|
+
let cancelled = false
|
|
164
|
+
|
|
165
|
+
const attempt = () => {
|
|
166
|
+
if (cancelled) return
|
|
167
|
+
const consent = consentDecision()
|
|
168
|
+
if (!consent.ads && !consent.analytics) return
|
|
169
|
+
// Once per cart, whether it lands on arrival or on a later Accept.
|
|
170
|
+
if (!markFiredOnce("attribution", cartId)) return
|
|
171
|
+
|
|
172
|
+
const attribution = readBrowserAttribution()
|
|
173
|
+
if (Object.keys(attribution).length === 0) return
|
|
174
|
+
|
|
175
|
+
const existing = (cartMetadataRef.current ?? {}) as Record<string, unknown>
|
|
176
|
+
// Skip a write that would change nothing: a cart update is a round
|
|
177
|
+
// trip and a re-render for the host.
|
|
178
|
+
const changed = Object.entries(attribution).some(
|
|
179
|
+
([key, v]) => existing[key] !== v
|
|
180
|
+
)
|
|
181
|
+
if (!changed) return
|
|
182
|
+
|
|
183
|
+
// Best effort, always: attribution must never be able to block a
|
|
184
|
+
// checkout. Metadata is replaced wholesale by the store API, so the
|
|
185
|
+
// cart's own keys (the carrier's office, the company's invoice
|
|
186
|
+
// fields) are spread back first.
|
|
187
|
+
void updateCart(client, cartId, {
|
|
188
|
+
metadata: { ...existing, ...attribution },
|
|
189
|
+
}).catch(() => {})
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
attempt()
|
|
193
|
+
window.addEventListener(CONSENT_CHANGED_EVENT, attempt)
|
|
194
|
+
return () => {
|
|
195
|
+
cancelled = true
|
|
196
|
+
window.removeEventListener(CONSENT_CHANGED_EVENT, attempt)
|
|
197
|
+
}
|
|
198
|
+
}, [enabled, cartId, client])
|
|
199
|
+
|
|
200
|
+
// ── Step 2: contact and address complete ───────────────────────────
|
|
201
|
+
// `allRequiredFilled` covers email, both names, the address line, city,
|
|
202
|
+
// postcode and phone, so this fires on a genuinely complete form and
|
|
203
|
+
// never on partial typing. It is the highest match-quality event in the
|
|
204
|
+
// funnel: every identity field is known at this exact moment.
|
|
205
|
+
useEffect(() => {
|
|
206
|
+
if (!enabled || !cartId || !linesRef.current) return
|
|
207
|
+
if (!allRequiredFilled) return
|
|
208
|
+
if (!markFiredOnce("add_shipping_info", cartId)) return
|
|
209
|
+
|
|
210
|
+
trackCheckoutShippingInfo({
|
|
211
|
+
lines: linesRef.current,
|
|
212
|
+
currency,
|
|
213
|
+
value: valueRef.current,
|
|
214
|
+
cartId,
|
|
215
|
+
// Omitted when the shopper completed the address before choosing a
|
|
216
|
+
// courier, which is the usual order.
|
|
217
|
+
...(tierRef.current ? { shippingTier: tierRef.current } : {}),
|
|
218
|
+
})
|
|
219
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
220
|
+
}, [enabled, cartId, currency, allRequiredFilled])
|
|
221
|
+
|
|
222
|
+
// ── Step 3: everything needed to pay ───────────────────────────────
|
|
223
|
+
// The trigger is address and delivery ready, which is the moment the Buy
|
|
224
|
+
// button becomes usable, carrying the tender they settled on.
|
|
225
|
+
useEffect(() => {
|
|
226
|
+
if (!enabled || !cartId || !linesRef.current) return
|
|
227
|
+
if (!addressReady || !deliveryReady) return
|
|
228
|
+
if (!markFiredOnce("add_payment_info", cartId)) return
|
|
229
|
+
|
|
230
|
+
trackCheckoutPaymentInfo({
|
|
231
|
+
lines: linesRef.current,
|
|
232
|
+
currency,
|
|
233
|
+
value: valueRef.current,
|
|
234
|
+
cartId,
|
|
235
|
+
paymentType: paymentTab === "cod" ? "cod" : "card",
|
|
236
|
+
})
|
|
237
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
238
|
+
}, [enabled, cartId, currency, addressReady, deliveryReady, paymentTab])
|
|
239
|
+
|
|
240
|
+
// ── Identity, as it is typed ───────────────────────────────────────
|
|
241
|
+
// Re-init Meta's Pixel with the hashed email, phone, name, city and
|
|
242
|
+
// postcode the moment they land in the form, and hand Google the same
|
|
243
|
+
// signals for Enhanced Conversions. From here on EVERY browser-side
|
|
244
|
+
// event carries advanced matching, the Purchase on the confirmation page
|
|
245
|
+
// included, and the raw values are remembered so the platform's
|
|
246
|
+
// server-side sending inherits them.
|
|
247
|
+
//
|
|
248
|
+
// Re-firing on every change is deliberate and safe: Meta's spec says a
|
|
249
|
+
// re-init MERGES advanced-matching parameters, and Google's `set`
|
|
250
|
+
// merges user_data. Waiting for blur would leave a fast typist's
|
|
251
|
+
// identity stale for the rest of the session.
|
|
252
|
+
const email = formData.email ?? cart?.email ?? ""
|
|
253
|
+
const phone = formData["shipping_address.phone"] ?? ""
|
|
254
|
+
const firstName = formData["shipping_address.first_name"] ?? ""
|
|
255
|
+
const lastName = formData["shipping_address.last_name"] ?? ""
|
|
256
|
+
const city = formData["shipping_address.city"] ?? ""
|
|
257
|
+
const postalCode = formData["shipping_address.postal_code"] ?? ""
|
|
258
|
+
const province = formData["shipping_address.province"] ?? ""
|
|
259
|
+
// The shopper's own country, never a constant: a kit that hardcoded one
|
|
260
|
+
// would mismatch every store outside it, and a wrong country is worse
|
|
261
|
+
// than none because it occupies the field the platform would otherwise
|
|
262
|
+
// fall back from.
|
|
263
|
+
const country = formData["shipping_address.country_code"] ?? ""
|
|
264
|
+
|
|
265
|
+
useEffect(() => {
|
|
266
|
+
if (!enabled) return
|
|
267
|
+
if (!email && !phone && !firstName && !lastName && !city && !postalCode) {
|
|
268
|
+
return
|
|
269
|
+
}
|
|
270
|
+
const visitor = {
|
|
271
|
+
...(email ? { email } : {}),
|
|
272
|
+
...(phone ? { phone } : {}),
|
|
273
|
+
...(firstName ? { firstName } : {}),
|
|
274
|
+
...(lastName ? { lastName } : {}),
|
|
275
|
+
...(city ? { city } : {}),
|
|
276
|
+
...(postalCode ? { postalCode } : {}),
|
|
277
|
+
...(country ? { country } : {}),
|
|
278
|
+
}
|
|
279
|
+
// Meta calls the province `state`, Google calls it `region`, and each
|
|
280
|
+
// vendor is given its own name for it.
|
|
281
|
+
void updatePixelAdvancedMatching({
|
|
282
|
+
...visitor,
|
|
283
|
+
...(province ? { state: province } : {}),
|
|
284
|
+
})
|
|
285
|
+
// The same person, the other vendor. The phone is normalised to E.164
|
|
286
|
+
// for Google inside the helper, which is a different string from the
|
|
287
|
+
// digits-only one Meta wants; the two hashes are not interchangeable.
|
|
288
|
+
void setEnhancedConversions({
|
|
289
|
+
...visitor,
|
|
290
|
+
...(province ? { region: province } : {}),
|
|
291
|
+
})
|
|
292
|
+
}, [
|
|
293
|
+
enabled,
|
|
294
|
+
email,
|
|
295
|
+
phone,
|
|
296
|
+
firstName,
|
|
297
|
+
lastName,
|
|
298
|
+
city,
|
|
299
|
+
postalCode,
|
|
300
|
+
province,
|
|
301
|
+
country,
|
|
302
|
+
])
|
|
303
|
+
}
|