@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.
Files changed (42) hide show
  1. package/README.md +9 -0
  2. package/package.json +274 -261
  3. package/src/api/checkout.ts +15 -0
  4. package/src/api/http.ts +9 -0
  5. package/src/api/integrations.ts +126 -0
  6. package/src/checkout/card-offer.ts +68 -0
  7. package/src/checkout/carrier-marks.ts +53 -0
  8. package/src/checkout/checkout-client.tsx +71 -13
  9. package/src/checkout/checkout-error-screen.tsx +113 -0
  10. package/src/checkout/discount-section.tsx +88 -56
  11. package/src/checkout/fulfillment-option.ts +30 -0
  12. package/src/checkout/index.ts +41 -6
  13. package/src/checkout/labels.ts +98 -0
  14. package/src/checkout/line-item-card.tsx +35 -83
  15. package/src/checkout/mobile-checkout-bottom-bar.tsx +132 -0
  16. package/src/checkout/mobile-checkout-top-bar.tsx +94 -0
  17. package/src/checkout/mobile-order-summary-body.tsx +172 -0
  18. package/src/checkout/order-summary.tsx +85 -179
  19. package/src/checkout/payment-button.tsx +25 -8
  20. package/src/checkout/payment-method-list.tsx +51 -13
  21. package/src/checkout/payment-wrapper.tsx +57 -13
  22. package/src/checkout/pickup-option.ts +35 -0
  23. package/src/checkout/pickup-point-selector.tsx +328 -0
  24. package/src/checkout/pickup-points.ts +65 -0
  25. package/src/checkout/pigeon-office-selector.tsx +379 -0
  26. package/src/checkout/shipping-method-list.tsx +102 -10
  27. package/src/checkout/summary-math.ts +152 -0
  28. package/src/checkout/use-checkout-funnel.ts +303 -0
  29. package/src/checkout/use-checkout-orchestration.ts +384 -30
  30. package/src/lib/stripe-env.ts +25 -0
  31. package/src/locales/bg.ts +37 -1
  32. package/src/locales/es.ts +35 -1
  33. package/src/tracking/attribution.ts +83 -0
  34. package/src/tracking/consent.ts +53 -0
  35. package/src/tracking/events.ts +113 -0
  36. package/src/tracking/fbq.ts +48 -0
  37. package/src/tracking/gtag.ts +32 -0
  38. package/src/tracking/index.ts +32 -1
  39. package/src/tracking/once.ts +137 -0
  40. package/src/tracking/rybbit-events.ts +42 -0
  41. package/src/tracking/ttq.ts +24 -0
  42. 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
+ }