@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
|
@@ -40,7 +40,11 @@ import {
|
|
|
40
40
|
type StoreCustomer,
|
|
41
41
|
type UpdateCustomerInput,
|
|
42
42
|
} from "../api/customers"
|
|
43
|
-
import {
|
|
43
|
+
import { envStripeAccountId, envStripeKey } from "../lib/stripe-env"
|
|
44
|
+
import type { PickupPointKeys } from "../api/integrations"
|
|
45
|
+
import { resolveCardOffer } from "./card-offer"
|
|
46
|
+
import { pickupOptionOf } from "./pickup-option"
|
|
47
|
+
import type { PickupPoint } from "./pickup-point-selector"
|
|
44
48
|
import { clearCartCookie } from "../lib/cookie-names"
|
|
45
49
|
import { useCartDrawer } from "../cart-drawer/context"
|
|
46
50
|
import compareAddresses from "./compare-addresses"
|
|
@@ -48,7 +52,11 @@ import { translateAddressError } from "./address-error-copy"
|
|
|
48
52
|
import { translatePaymentError } from "./payment-error-copy"
|
|
49
53
|
import { useCheckoutLabels, useOrderConfirmedPath } from "./context"
|
|
50
54
|
import type { EcontOffice } from "./econt-office-selector"
|
|
51
|
-
import type { BoxNowLocker } from "../api/integrations"
|
|
55
|
+
import type { BoxNowLocker, PigeonOffice } from "../api/integrations"
|
|
56
|
+
import { fulfillmentOptionId } from "./fulfillment-option"
|
|
57
|
+
import { useCheckoutFunnel } from "./use-checkout-funnel"
|
|
58
|
+
import { amountDueAfterTender, isNothingToPay } from "./summary-math"
|
|
59
|
+
import { forgetFiredEvents } from "../tracking/once"
|
|
52
60
|
|
|
53
61
|
/**
|
|
54
62
|
* useCheckoutOrchestration — single source of truth for checkout-page
|
|
@@ -165,6 +173,20 @@ export type UseCheckoutOrchestrationOptions = {
|
|
|
165
173
|
* add your own sink; fire-and-forget either way.
|
|
166
174
|
*/
|
|
167
175
|
logError?: CheckoutLogError
|
|
176
|
+
/**
|
|
177
|
+
* The checkout funnel: begin_checkout on arrival, add_shipping_info when
|
|
178
|
+
* the address is complete, add_payment_info when the tender is chosen,
|
|
179
|
+
* each once per cart, to every tag the store configured; the ad
|
|
180
|
+
* platforms' identity signals as they are typed; and the ad-click ids
|
|
181
|
+
* captured on the landing page written onto the cart so they reach the
|
|
182
|
+
* order (see `useCheckoutFunnel`).
|
|
183
|
+
*
|
|
184
|
+
* ON by default, like the checkout error log: the helpers all lived in
|
|
185
|
+
* this package and every checkout built on it fired none of them,
|
|
186
|
+
* because calling them was the store's job. Pass `false` in a store that
|
|
187
|
+
* fires its own, so nothing is counted twice.
|
|
188
|
+
*/
|
|
189
|
+
trackFunnel?: boolean
|
|
168
190
|
/**
|
|
169
191
|
* Verbose [buy-click] console output for local debugging. OFF by default:
|
|
170
192
|
* the unguarded dumps used to print the full prepare-checkout payload —
|
|
@@ -203,6 +225,13 @@ const REQUIRED_ADDRESS_FIELDS = [
|
|
|
203
225
|
*/
|
|
204
226
|
const ADDRESS_AUTO_SAVE_DEBOUNCE_MS = 600
|
|
205
227
|
|
|
228
|
+
/**
|
|
229
|
+
* Where the typed checkout form is kept for the tab's lifetime, so a
|
|
230
|
+
* refresh or a walk back to the cart does not empty it. One key per app:
|
|
231
|
+
* a storefront is mounted for a single store.
|
|
232
|
+
*/
|
|
233
|
+
const FORM_STORAGE_KEY = "cartbase:checkout-form"
|
|
234
|
+
|
|
206
235
|
/** The offline tab's tender: a merchant METHOD entry (pp_* kill). The COD
|
|
207
236
|
* method (kind 'cod') wins; any manual method serves as the offline tab
|
|
208
237
|
* when no COD switch is on. The entry carries its own fee for prediction. */
|
|
@@ -269,6 +298,7 @@ export function useCheckoutOrchestration({
|
|
|
269
298
|
onOrderPlaced,
|
|
270
299
|
resolveTrackingMetadata,
|
|
271
300
|
logError: logErrorProp,
|
|
301
|
+
trackFunnel = true,
|
|
272
302
|
debug = false,
|
|
273
303
|
}: UseCheckoutOrchestrationOptions) {
|
|
274
304
|
// The store's language, for the sentences a shopper reads when a save
|
|
@@ -321,6 +351,17 @@ export function useCheckoutOrchestration({
|
|
|
321
351
|
[cart]
|
|
322
352
|
)
|
|
323
353
|
|
|
354
|
+
// A cart that was completed somewhere else (the other tab, the back
|
|
355
|
+
// button) is no cart: the browser forgets it here too, so the shopper's
|
|
356
|
+
// next Add starts a new one instead of being refused by a placed order.
|
|
357
|
+
// The order that was placed already cleared this in its own tab; this is
|
|
358
|
+
// the copy that never heard (store-package card, 2026-09-18).
|
|
359
|
+
useEffect(() => {
|
|
360
|
+
if (!cartIsCompleted) return
|
|
361
|
+
clearCartCookie()
|
|
362
|
+
void forgetCart()
|
|
363
|
+
}, [cartIsCompleted, forgetCart])
|
|
364
|
+
|
|
324
365
|
// ── Address form ────────────────────────────────────────────────────
|
|
325
366
|
const [addressError, setAddressError] = useState<string | null>(null)
|
|
326
367
|
const [, setAddressSaving] = useState(false)
|
|
@@ -369,6 +410,55 @@ export function useCheckoutOrchestration({
|
|
|
369
410
|
: true
|
|
370
411
|
)
|
|
371
412
|
|
|
413
|
+
// ── The typed form survives a refresh ───────────────────────────────
|
|
414
|
+
// A shopper who reloads, or comes back from the cart, used to find every
|
|
415
|
+
// field empty and start again (store-package card, 2026-09-18). The form
|
|
416
|
+
// is kept for the tab's lifetime in the shopper's own browser and never
|
|
417
|
+
// leaves it: sessionStorage dies with the tab, and nothing here is sent
|
|
418
|
+
// anywhere the address was not already going.
|
|
419
|
+
//
|
|
420
|
+
// Restore runs AFTER mount, never in the state initializer: that
|
|
421
|
+
// initializer also runs on the server, where there is no storage, and
|
|
422
|
+
// hydration would keep the server's empty seed. A ref gates the autosave
|
|
423
|
+
// so the seed cannot overwrite what is stored before it is read.
|
|
424
|
+
const formRestoredRef = useRef(false)
|
|
425
|
+
|
|
426
|
+
useEffect(() => {
|
|
427
|
+
if (formRestoredRef.current) return
|
|
428
|
+
try {
|
|
429
|
+
const stored = window.sessionStorage.getItem(FORM_STORAGE_KEY)
|
|
430
|
+
if (stored) {
|
|
431
|
+
const parsed = JSON.parse(stored) as Record<string, string>
|
|
432
|
+
setFormData((prev) => {
|
|
433
|
+
const merged = { ...prev }
|
|
434
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
435
|
+
// Only fill what is EMPTY. What the server already knows (the
|
|
436
|
+
// cart's own address, a signed-in customer's) always wins over
|
|
437
|
+
// something typed in an older shopping session.
|
|
438
|
+
if (typeof value === "string" && value && !merged[key]) {
|
|
439
|
+
merged[key] = value
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
return merged
|
|
443
|
+
})
|
|
444
|
+
}
|
|
445
|
+
} catch {
|
|
446
|
+
// Private mode, a full quota, a corrupt value: a checkout never dies
|
|
447
|
+
// for a convenience.
|
|
448
|
+
} finally {
|
|
449
|
+
formRestoredRef.current = true
|
|
450
|
+
}
|
|
451
|
+
}, [])
|
|
452
|
+
|
|
453
|
+
useEffect(() => {
|
|
454
|
+
if (!formRestoredRef.current) return
|
|
455
|
+
try {
|
|
456
|
+
window.sessionStorage.setItem(FORM_STORAGE_KEY, JSON.stringify(formData))
|
|
457
|
+
} catch {
|
|
458
|
+
// as above
|
|
459
|
+
}
|
|
460
|
+
}, [formData])
|
|
461
|
+
|
|
372
462
|
/**
|
|
373
463
|
* The store's own country list, fetched when the app did not pass one.
|
|
374
464
|
*
|
|
@@ -760,9 +850,17 @@ export function useCheckoutOrchestration({
|
|
|
760
850
|
const [optimisticShippingCost, setOptimisticShippingCost] = useState<
|
|
761
851
|
number | null
|
|
762
852
|
>(null)
|
|
853
|
+
// A method already on the cart wins, because that one is booked; failing
|
|
854
|
+
// that, the courier the shopper picked before the page reloaded
|
|
855
|
+
// (2026-09-18). Without the second the checkout forgot the choice on every
|
|
856
|
+
// refresh, since nothing is booked until Buy.
|
|
763
857
|
const [selectedShippingMethod, setSelectedShippingMethod] = useState<
|
|
764
858
|
string | null
|
|
765
|
-
>(
|
|
859
|
+
>(
|
|
860
|
+
cart?.shipping_methods?.at(-1)?.shipping_option_id ||
|
|
861
|
+
(cart?.metadata?._checkout_shipping_option as string | undefined) ||
|
|
862
|
+
null
|
|
863
|
+
)
|
|
766
864
|
|
|
767
865
|
// Every listed option is offerable: the Medusa-era pickup filter read an
|
|
768
866
|
// embedded fulfillment_set the Cartbase API never sends (it never fired),
|
|
@@ -875,17 +973,74 @@ export function useCheckoutOrchestration({
|
|
|
875
973
|
[]
|
|
876
974
|
)
|
|
877
975
|
|
|
976
|
+
// Pigeon Express keeps offices and lockers in ONE catalogue, told apart by
|
|
977
|
+
// which id key the order carries (`pigeon_office_id` here; the platform's
|
|
978
|
+
// destination registry owns both names).
|
|
979
|
+
const [selectedPigeonOffice, setSelectedPigeonOffice] =
|
|
980
|
+
useState<PigeonOffice | null>(
|
|
981
|
+
cart?.metadata?.pigeon_office_id
|
|
982
|
+
? ({
|
|
983
|
+
id: cart.metadata.pigeon_office_id as string,
|
|
984
|
+
name: (cart.metadata.pigeon_point_name as string) ?? "",
|
|
985
|
+
type: "office",
|
|
986
|
+
city: (cart.metadata.pigeon_point_city as string) ?? "",
|
|
987
|
+
address: (cart.metadata.pigeon_point_address as string) ?? "",
|
|
988
|
+
} as PigeonOffice)
|
|
989
|
+
: null
|
|
990
|
+
)
|
|
991
|
+
|
|
992
|
+
const handleSelectPigeonOffice = useCallback(
|
|
993
|
+
(office: PigeonOffice | null) => {
|
|
994
|
+
setSelectedPigeonOffice(office)
|
|
995
|
+
},
|
|
996
|
+
[]
|
|
997
|
+
)
|
|
998
|
+
|
|
999
|
+
/**
|
|
1000
|
+
* THE pickup point, whatever the carrier (2026-09-18).
|
|
1001
|
+
*
|
|
1002
|
+
* The three states above are one state per carrier, written into one
|
|
1003
|
+
* hardcoded metadata key each, which is why Speedy had no picker: adding a
|
|
1004
|
+
* carrier meant a fourth copy of all of it. This one holds the chosen point
|
|
1005
|
+
* AND the key names the carrier's own booking reads, which arrive with the
|
|
1006
|
+
* point from the platform's destination registry, so nothing here knows
|
|
1007
|
+
* what any carrier calls anything.
|
|
1008
|
+
*
|
|
1009
|
+
* It restores from the cart, because the choice is written to the cart when
|
|
1010
|
+
* it is made (`persistPickupPoint` below): a shopper who reloads the
|
|
1011
|
+
* checkout keeps the office they picked.
|
|
1012
|
+
*/
|
|
1013
|
+
const [selectedPickupPoint, setSelectedPickupPoint] =
|
|
1014
|
+
useState<PickupPoint | null>(
|
|
1015
|
+
(cart?.metadata?._checkout_pickup_point as PickupPoint | undefined) ?? null
|
|
1016
|
+
)
|
|
1017
|
+
const [pickupKeys, setPickupKeys] = useState<PickupPointKeys | null>(
|
|
1018
|
+
(cart?.metadata?._checkout_pickup_keys as PickupPointKeys | undefined) ?? null
|
|
1019
|
+
)
|
|
1020
|
+
|
|
878
1021
|
// ── Payment ─────────────────────────────────────────────────────────
|
|
879
1022
|
const [paymentError, setPaymentError] = useState<string | null>(null)
|
|
880
1023
|
|
|
881
|
-
|
|
882
|
-
|
|
1024
|
+
// ONE row answers both "is there a card?" and "how do we reach it?"
|
|
1025
|
+
// (2026-09-18). The processor entry carries its own publishable key and,
|
|
1026
|
+
// under Connect, the merchant's account, so the card tab and the Stripe
|
|
1027
|
+
// mount can never disagree about which Stripe this is. Reading them apart
|
|
1028
|
+
// was the old failure: a store could offer a card it had no way to charge.
|
|
1029
|
+
const cardOffer = useMemo(
|
|
1030
|
+
() =>
|
|
1031
|
+
resolveCardOffer(
|
|
1032
|
+
effectiveAvailablePaymentMethods as ReadonlyArray<
|
|
1033
|
+
Record<string, unknown>
|
|
1034
|
+
> | null,
|
|
1035
|
+
{ key: envStripeKey, accountId: envStripeAccountId }
|
|
1036
|
+
),
|
|
1037
|
+
[effectiveAvailablePaymentMethods]
|
|
883
1038
|
)
|
|
884
|
-
const
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
1039
|
+
const cardEntry = cardOffer.entry
|
|
1040
|
+
const cardPublishableKey = cardOffer.publishableKey
|
|
1041
|
+
const cardStripeAccountId = cardOffer.accountId
|
|
1042
|
+
const hasCard = !!cardEntry && cardOffer.mountable
|
|
1043
|
+
const cardId = hasCard ? cardEntry?.id : undefined
|
|
889
1044
|
// The offline tab = a merchant METHOD (the COD switch, else the first
|
|
890
1045
|
// manual method) — sessions initiate by payment_method_id, provider NULL.
|
|
891
1046
|
const offlineMethod = findOfflineMethod(effectiveAvailablePaymentMethods)
|
|
@@ -893,6 +1048,21 @@ export function useCheckoutOrchestration({
|
|
|
893
1048
|
const codMethodId = offlineMethod?.payment_method_id
|
|
894
1049
|
const offlineIsCodKind = offlineMethod?.kind === "cod"
|
|
895
1050
|
|
|
1051
|
+
// The store offers a card it cannot mount: reported once, to the merchant,
|
|
1052
|
+
// because the shopper must never be the one who discovers it. Pre-2026-09-18
|
|
1053
|
+
// storefronts read the key from their own env, so this also names the one
|
|
1054
|
+
// thing an un-upgraded store has to do.
|
|
1055
|
+
const cardUnmountableReported = useRef(false)
|
|
1056
|
+
useEffect(() => {
|
|
1057
|
+
if (!cardEntry || cardOffer.mountable || cardUnmountableReported.current) return
|
|
1058
|
+
cardUnmountableReported.current = true
|
|
1059
|
+
logError(
|
|
1060
|
+
"card_unmountable",
|
|
1061
|
+
"The store offers a card processor but no publishable key reached the browser, so the card option is hidden.",
|
|
1062
|
+
{ provider_id: cardEntry.id }
|
|
1063
|
+
)
|
|
1064
|
+
}, [cardEntry, cardOffer.mountable, logError])
|
|
1065
|
+
|
|
896
1066
|
// Default tab: card when available, else COD. The eager-session model
|
|
897
1067
|
// used to seed from the cart's pending session provider; in the
|
|
898
1068
|
// deferred-intent model there is no session at mount.
|
|
@@ -949,15 +1119,42 @@ export function useCheckoutOrchestration({
|
|
|
949
1119
|
[cart.id, client, cardId, codMethodId, offlineMethod]
|
|
950
1120
|
)
|
|
951
1121
|
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
1122
|
+
/**
|
|
1123
|
+
* REMEMBER WHAT THE SHOPPER CHOSE, across a reload (2026-09-18, evoo).
|
|
1124
|
+
*
|
|
1125
|
+
* Every choice on this page lived in React state until Buy, so a refresh,
|
|
1126
|
+
* a phone waking up, or a tab restored the next morning threw away the
|
|
1127
|
+
* courier and the office and left the shopper staring at an empty form
|
|
1128
|
+
* they had already filled in once.
|
|
1129
|
+
*
|
|
1130
|
+
* It is written under OUR OWN names (`_checkout_*`), never the carrier's.
|
|
1131
|
+
* The carrier's real keys are still written exactly once, at Buy, by
|
|
1132
|
+
* `prepareCheckout`, which is what keeps the stale-data bug class dead: an
|
|
1133
|
+
* eager write of `econt_office_code` beside a cart that had since moved to
|
|
1134
|
+
* BoxNow is what caused it, and nothing here writes a carrier's key. These
|
|
1135
|
+
* are the checkout's own notes to itself, and prepare-checkout overwrites
|
|
1136
|
+
* the real ones from them.
|
|
1137
|
+
*/
|
|
1138
|
+
const rememberChoice = useCallback(
|
|
1139
|
+
(patch: Record<string, unknown>) => {
|
|
1140
|
+
void updateCart(client, cart.id, { metadata: patch }).catch(() => {
|
|
1141
|
+
// Best effort: forgetting a choice on reload is a worse checkout,
|
|
1142
|
+
// never a broken one, so a failure here must not reach the shopper.
|
|
1143
|
+
})
|
|
1144
|
+
},
|
|
1145
|
+
[client, cart.id]
|
|
1146
|
+
)
|
|
1147
|
+
|
|
1148
|
+
// Shipping selection stays client state for the ORDER (no
|
|
1149
|
+
// addShippingMethod call, no metadata-clear updateCart: those wrote to the
|
|
1150
|
+
// cart between toggles and produced the stale-data bug class; the id is
|
|
1151
|
+
// sent once at Buy via prepareCheckout). What is remembered is the
|
|
1152
|
+
// CHOICE, under our own key, so a reload restores it.
|
|
957
1153
|
const handleSelectShipping = useCallback(
|
|
958
1154
|
(id: string) => {
|
|
959
1155
|
setShippingError(null)
|
|
960
1156
|
setSelectedShippingMethod(id)
|
|
1157
|
+
rememberChoice({ _checkout_shipping_option: id })
|
|
961
1158
|
|
|
962
1159
|
// Optimistic shipping cost — paint the totals row immediately so
|
|
963
1160
|
// the customer sees the right number before any network call.
|
|
@@ -973,23 +1170,58 @@ export function useCheckoutOrchestration({
|
|
|
973
1170
|
|
|
974
1171
|
// Switching shipping invalidates any previously-selected carrier-
|
|
975
1172
|
// specific destination (e.g. picking direct address after BoxNow
|
|
976
|
-
// locker).
|
|
1173
|
+
// locker). The carrier's own keys are never eagerly written, so only
|
|
1174
|
+
// our own note is cleared beside the state.
|
|
977
1175
|
setSelectedBoxnowLocker(null)
|
|
978
1176
|
setSelectedEcontOffice(null)
|
|
1177
|
+
setSelectedPickupPoint(null)
|
|
1178
|
+
setPickupKeys(null)
|
|
1179
|
+
rememberChoice({
|
|
1180
|
+
_checkout_shipping_option: id,
|
|
1181
|
+
_checkout_pickup_point: null,
|
|
1182
|
+
_checkout_pickup_keys: null,
|
|
1183
|
+
})
|
|
979
1184
|
},
|
|
980
|
-
[shippingMethods, calculatedPricesMap]
|
|
1185
|
+
[shippingMethods, calculatedPricesMap, rememberChoice]
|
|
1186
|
+
)
|
|
1187
|
+
|
|
1188
|
+
/**
|
|
1189
|
+
* The chosen pickup point, remembered the moment it is chosen. The keys
|
|
1190
|
+
* ride with it because they are what `prepareCheckout` writes the carrier's
|
|
1191
|
+
* real metadata from, and a reloaded page has no picker mounted yet to
|
|
1192
|
+
* fetch them again.
|
|
1193
|
+
*/
|
|
1194
|
+
const handleSelectPickupPoint = useCallback(
|
|
1195
|
+
(point: PickupPoint | null, keys: PickupPointKeys | null) => {
|
|
1196
|
+
setSelectedPickupPoint(point)
|
|
1197
|
+
setPickupKeys(keys)
|
|
1198
|
+
rememberChoice({
|
|
1199
|
+
_checkout_pickup_point: point,
|
|
1200
|
+
_checkout_pickup_keys: keys,
|
|
1201
|
+
})
|
|
1202
|
+
},
|
|
1203
|
+
[rememberChoice]
|
|
981
1204
|
)
|
|
982
1205
|
|
|
983
1206
|
// ── Delivery readiness ──────────────────────────────────────────────
|
|
984
1207
|
const selectedFulfillmentOptionId = useMemo(() => {
|
|
985
|
-
|
|
986
|
-
| { id?: string }
|
|
987
|
-
| undefined
|
|
988
|
-
| null
|
|
989
|
-
return typeof data?.id === "string" ? data.id : null
|
|
1208
|
+
return fulfillmentOptionId(selectedShippingOption)
|
|
990
1209
|
}, [selectedShippingOption])
|
|
991
1210
|
const selectedIsBoxnow = selectedFulfillmentOptionId === "boxnow-locker"
|
|
992
1211
|
const selectedIsEcont = selectedFulfillmentOptionId === "econt-office"
|
|
1212
|
+
const selectedIsPigeon = selectedFulfillmentOptionId === "pigeon-office"
|
|
1213
|
+
|
|
1214
|
+
/**
|
|
1215
|
+
* WHICH carrier and WHICH kind of point this option delivers to, read from
|
|
1216
|
+
* the option's own id (2026-09-18). Every carrier names its options the
|
|
1217
|
+
* same way, `<carrier>-<mode>`, because the platform's registry declares
|
|
1218
|
+
* them that way, so a carrier connected tomorrow is understood here without
|
|
1219
|
+
* a line being added. `address` is the door and has no point to pick.
|
|
1220
|
+
*/
|
|
1221
|
+
const selectedPickup = useMemo(
|
|
1222
|
+
() => pickupOptionOf(selectedFulfillmentOptionId),
|
|
1223
|
+
[selectedFulfillmentOptionId]
|
|
1224
|
+
)
|
|
993
1225
|
|
|
994
1226
|
// Defensive: trust cart.metadata for locker/office IDs in addition to
|
|
995
1227
|
// local React state. On mobile the BoxNow locker selector was seen
|
|
@@ -1000,12 +1232,24 @@ export function useCheckoutOrchestration({
|
|
|
1000
1232
|
// ghost — even though the cart server-side knew the locker was set.
|
|
1001
1233
|
const hasBoxnowLockerInCart = !!cart?.metadata?.boxnow_locker_id
|
|
1002
1234
|
const hasEcontOfficeInCart = !!cart?.metadata?.econt_office_code
|
|
1235
|
+
const hasPigeonOfficeInCart = !!cart?.metadata?.pigeon_office_id
|
|
1236
|
+
|
|
1237
|
+
// An option that delivers to a point is not ready until the point is
|
|
1238
|
+
// named, whatever the carrier. The three lines under it are the same rule
|
|
1239
|
+
// written per carrier, kept for stores still mounting the old components;
|
|
1240
|
+
// a store on the shared picker is answered by the first.
|
|
1241
|
+
const pickupSatisfied =
|
|
1242
|
+
!selectedPickup ||
|
|
1243
|
+
(!!selectedPickupPoint && selectedPickupPoint.provider === selectedPickup.provider)
|
|
1003
1244
|
|
|
1004
1245
|
const deliveryReady =
|
|
1005
1246
|
(!!selectedShippingMethod ||
|
|
1006
1247
|
(cart?.shipping_methods?.length ?? 0) > 0) &&
|
|
1007
|
-
(
|
|
1008
|
-
|
|
1248
|
+
(pickupSatisfied ||
|
|
1249
|
+
// The legacy pickers answer for their own carriers.
|
|
1250
|
+
(selectedIsBoxnow && (!!selectedBoxnowLocker || hasBoxnowLockerInCart)) ||
|
|
1251
|
+
(selectedIsEcont && (!!selectedEcontOffice || hasEcontOfficeInCart)) ||
|
|
1252
|
+
(selectedIsPigeon && (!!selectedPigeonOffice || hasPigeonOfficeInCart)))
|
|
1009
1253
|
|
|
1010
1254
|
// Reconcile paymentTab with currently-available methods. When the
|
|
1011
1255
|
// store's paymentMethodFilter strips a method in response to a shipping
|
|
@@ -1051,6 +1295,17 @@ export function useCheckoutOrchestration({
|
|
|
1051
1295
|
// cart instead of being refused by the completed one.
|
|
1052
1296
|
clearCartCookie()
|
|
1053
1297
|
await forgetCart()
|
|
1298
|
+
// The typed form goes with the cart: the next shopper on this browser,
|
|
1299
|
+
// or this one starting another order, begins on an empty form.
|
|
1300
|
+
try {
|
|
1301
|
+
window.sessionStorage.removeItem(FORM_STORAGE_KEY)
|
|
1302
|
+
} catch {
|
|
1303
|
+
// storage unavailable — nothing to clear
|
|
1304
|
+
}
|
|
1305
|
+
// The funnel guards for this cart go too. They are keyed by cart, so a
|
|
1306
|
+
// second order would fire its own events anyway; this keeps the store
|
|
1307
|
+
// from carrying a key for every cart the session ever bought.
|
|
1308
|
+
forgetFiredEvents(cart.id)
|
|
1054
1309
|
|
|
1055
1310
|
if (onOrderPlaced) {
|
|
1056
1311
|
onOrderPlaced(order)
|
|
@@ -1202,6 +1457,42 @@ export function useCheckoutOrchestration({
|
|
|
1202
1457
|
optimisticMethodFee,
|
|
1203
1458
|
])
|
|
1204
1459
|
|
|
1460
|
+
// ── Nothing left to pay ─────────────────────────────────────────────
|
|
1461
|
+
// A cart a promotion covered entirely, or one whose gift cards cover the
|
|
1462
|
+
// whole total, asks the shopper for no money. The platform already knows
|
|
1463
|
+
// this: prepare-checkout works out the charge as the collection's amount
|
|
1464
|
+
// minus the gift-card tender and, at zero, mints no provider session at
|
|
1465
|
+
// all, so the answer comes back with no client secret and no provider
|
|
1466
|
+
// and the cart completes on what is already on it (Stripe refuses a
|
|
1467
|
+
// zero-amount intent anyway).
|
|
1468
|
+
//
|
|
1469
|
+
// The checkout used to ignore that and put a card form in front of a
|
|
1470
|
+
// shopper with nothing to pay. The same arithmetic here, on the numbers
|
|
1471
|
+
// the shopper is looking at, so the payment section can stand down and
|
|
1472
|
+
// the Buy click can skip Stripe entirely.
|
|
1473
|
+
const amountDue = useMemo(
|
|
1474
|
+
() => amountDueAfterTender(optimisticTotal, cart?.gift_card_total),
|
|
1475
|
+
[optimisticTotal, cart?.gift_card_total]
|
|
1476
|
+
)
|
|
1477
|
+
const nothingToPay = isNothingToPay(amountDue)
|
|
1478
|
+
|
|
1479
|
+
// ── The funnel ──────────────────────────────────────────────────────
|
|
1480
|
+
// Every store on this hook reports the same three steps to the same
|
|
1481
|
+
// tags, writes the same identity signals and carries the same ad-click
|
|
1482
|
+
// ids onto the order, without wiring anything.
|
|
1483
|
+
useCheckoutFunnel({
|
|
1484
|
+
client,
|
|
1485
|
+
cart,
|
|
1486
|
+
enabled: trackFunnel,
|
|
1487
|
+
value: optimisticTotal,
|
|
1488
|
+
allRequiredFilled,
|
|
1489
|
+
addressReady,
|
|
1490
|
+
deliveryReady,
|
|
1491
|
+
shippingTier: selectedShippingOption?.name ?? undefined,
|
|
1492
|
+
paymentTab,
|
|
1493
|
+
formData,
|
|
1494
|
+
})
|
|
1495
|
+
|
|
1205
1496
|
// ── Optimistic total in cents (for Stripe Elements deferred-intent) ─
|
|
1206
1497
|
// Stripe's deferred-intent <Elements> needs `amount` + `currency` at
|
|
1207
1498
|
// mount time (no PaymentIntent on the backend yet). Same value as
|
|
@@ -1244,15 +1535,51 @@ export function useCheckoutOrchestration({
|
|
|
1244
1535
|
carrierMetadata.boxnow_locker_postal =
|
|
1245
1536
|
selectedBoxnowLocker.postalCode ?? ""
|
|
1246
1537
|
}
|
|
1538
|
+
if (selectedPigeonOffice) {
|
|
1539
|
+
// The platform's destination registry owns these names: the id key
|
|
1540
|
+
// says office or locker, and the three label keys are what the order
|
|
1541
|
+
// page and the waybill read back (src/lib/shipping/destinations.ts).
|
|
1542
|
+
carrierMetadata.pigeon_office_id = String(selectedPigeonOffice.id)
|
|
1543
|
+
carrierMetadata.pigeon_point_name = selectedPigeonOffice.name
|
|
1544
|
+
carrierMetadata.pigeon_point_city = selectedPigeonOffice.city ?? ""
|
|
1545
|
+
carrierMetadata.pigeon_point_address = selectedPigeonOffice.address ?? ""
|
|
1546
|
+
}
|
|
1547
|
+
// The carrier-agnostic pickup point (2026-09-18). The names come from
|
|
1548
|
+
// the platform's destination registry with the point itself, so this
|
|
1549
|
+
// writes what THIS carrier's booking reads without knowing which
|
|
1550
|
+
// carrier it is. The three blocks above are the same write done four
|
|
1551
|
+
// times by hand, kept for stores still mounting the old components;
|
|
1552
|
+
// this one is what a new carrier arrives through.
|
|
1553
|
+
if (selectedPickupPoint && pickupKeys) {
|
|
1554
|
+
carrierMetadata[pickupKeys.point] = selectedPickupPoint.id
|
|
1555
|
+
if (pickupKeys.name) carrierMetadata[pickupKeys.name] = selectedPickupPoint.name
|
|
1556
|
+
if (pickupKeys.city) carrierMetadata[pickupKeys.city] = selectedPickupPoint.city
|
|
1557
|
+
if (pickupKeys.address)
|
|
1558
|
+
carrierMetadata[pickupKeys.address] = selectedPickupPoint.address
|
|
1559
|
+
if (pickupKeys.phone) carrierMetadata[pickupKeys.phone] = selectedPickupPoint.phone
|
|
1560
|
+
if (pickupKeys.postal)
|
|
1561
|
+
carrierMetadata[pickupKeys.postal] = selectedPickupPoint.postal_code
|
|
1562
|
+
}
|
|
1247
1563
|
|
|
1248
1564
|
const shippingMethodId = selectedShippingMethod
|
|
1249
1565
|
if (!shippingMethodId) {
|
|
1250
1566
|
throw new Error("No shipping method selected")
|
|
1251
1567
|
}
|
|
1252
|
-
// The tender
|
|
1253
|
-
// method (provider-less session, pp_* kill).
|
|
1254
|
-
|
|
1255
|
-
|
|
1568
|
+
// The tender. On the card tab it is the processor, on the offline tab
|
|
1569
|
+
// the merchant method (a provider-less session, pp_* kill).
|
|
1570
|
+
//
|
|
1571
|
+
// With nothing left to pay the shopper was asked for no tender, so we
|
|
1572
|
+
// name whichever the store has: prepare-checkout requires exactly one
|
|
1573
|
+
// of the two even when the charge is zero, and at zero it mints no
|
|
1574
|
+
// session from either, so which one is named changes nothing. The
|
|
1575
|
+
// processor is preferred because a method could carry a fee.
|
|
1576
|
+
const tender = nothingToPay
|
|
1577
|
+
? cardId
|
|
1578
|
+
? { payment_provider: cardId }
|
|
1579
|
+
: codMethodId
|
|
1580
|
+
? { payment_method_id: codMethodId }
|
|
1581
|
+
: null
|
|
1582
|
+
: paymentTab === "card"
|
|
1256
1583
|
? cardId
|
|
1257
1584
|
? { payment_provider: cardId }
|
|
1258
1585
|
: null
|
|
@@ -1283,9 +1610,11 @@ export function useCheckoutOrchestration({
|
|
|
1283
1610
|
selectedShippingMethod,
|
|
1284
1611
|
selectedEcontOffice,
|
|
1285
1612
|
selectedBoxnowLocker,
|
|
1613
|
+
selectedPigeonOffice,
|
|
1286
1614
|
paymentTab,
|
|
1287
1615
|
cardId,
|
|
1288
1616
|
codMethodId,
|
|
1617
|
+
nothingToPay,
|
|
1289
1618
|
])
|
|
1290
1619
|
|
|
1291
1620
|
// ── Buy click ───────────────────────────────────────────────────────
|
|
@@ -1350,7 +1679,13 @@ export function useCheckoutOrchestration({
|
|
|
1350
1679
|
|
|
1351
1680
|
await flushAddressSave()
|
|
1352
1681
|
|
|
1353
|
-
|
|
1682
|
+
// Nothing to pay: no card form was ever shown, so there is no Stripe
|
|
1683
|
+
// form to validate and nothing to confirm. The prepare below writes
|
|
1684
|
+
// the address, the delivery and the tender, answers with no session,
|
|
1685
|
+
// and the cart completes on what already covers it.
|
|
1686
|
+
const cardPath = paymentTab === "card" && !nothingToPay
|
|
1687
|
+
|
|
1688
|
+
if (cardPath) {
|
|
1354
1689
|
if (!stripeBundle) {
|
|
1355
1690
|
// eslint-disable-next-line no-console
|
|
1356
1691
|
console.error("[buy-click] card path but no stripe bundle")
|
|
@@ -1399,7 +1734,7 @@ export function useCheckoutOrchestration({
|
|
|
1399
1734
|
const zeroRemainderGiftPath =
|
|
1400
1735
|
prep.client_secret === null && prep.provider_id === null
|
|
1401
1736
|
|
|
1402
|
-
if (
|
|
1737
|
+
if (cardPath && !zeroRemainderGiftPath) {
|
|
1403
1738
|
if (!stripeBundle || !prep.client_secret) {
|
|
1404
1739
|
// eslint-disable-next-line no-console
|
|
1405
1740
|
console.error(
|
|
@@ -1522,11 +1857,13 @@ export function useCheckoutOrchestration({
|
|
|
1522
1857
|
selectedShippingMethod,
|
|
1523
1858
|
selectedEcontOffice,
|
|
1524
1859
|
selectedBoxnowLocker,
|
|
1860
|
+
selectedPigeonOffice,
|
|
1525
1861
|
flushAddressSave,
|
|
1526
1862
|
buildPrepareCheckoutPayload,
|
|
1527
1863
|
placeOrder,
|
|
1528
1864
|
formData,
|
|
1529
1865
|
logError,
|
|
1866
|
+
nothingToPay,
|
|
1530
1867
|
]
|
|
1531
1868
|
)
|
|
1532
1869
|
|
|
@@ -1559,6 +1896,7 @@ export function useCheckoutOrchestration({
|
|
|
1559
1896
|
selectedFulfillmentOptionId,
|
|
1560
1897
|
selectedIsBoxnow,
|
|
1561
1898
|
selectedIsEcont,
|
|
1899
|
+
selectedIsPigeon,
|
|
1562
1900
|
shippingLoading,
|
|
1563
1901
|
shippingError,
|
|
1564
1902
|
optimisticShippingCost,
|
|
@@ -1570,12 +1908,24 @@ export function useCheckoutOrchestration({
|
|
|
1570
1908
|
handleSelectEcontOffice,
|
|
1571
1909
|
selectedBoxnowLocker,
|
|
1572
1910
|
handleSelectBoxnowLocker,
|
|
1911
|
+
selectedPigeonOffice,
|
|
1912
|
+
handleSelectPigeonOffice,
|
|
1913
|
+
/** The pickup point, whatever the carrier, and which carrier and kind of
|
|
1914
|
+
* point the chosen option asks for. Null on a door delivery. */
|
|
1915
|
+
selectedPickupPoint,
|
|
1916
|
+
handleSelectPickupPoint,
|
|
1917
|
+
selectedPickup,
|
|
1573
1918
|
|
|
1574
1919
|
// Payment
|
|
1575
1920
|
paymentTab,
|
|
1576
1921
|
hasCard,
|
|
1577
1922
|
hasCod,
|
|
1578
1923
|
cardId,
|
|
1924
|
+
/** The store's own Stripe, off the card entry — what `PaymentWrapper`
|
|
1925
|
+
* loads Stripe.js with. Undefined on a store that still sets the env
|
|
1926
|
+
* pair, or on a platform that has not been upgraded. */
|
|
1927
|
+
cardPublishableKey,
|
|
1928
|
+
cardStripeAccountId,
|
|
1579
1929
|
/** The offline tab's method (the COD switch or a manual method). */
|
|
1580
1930
|
codMethodId,
|
|
1581
1931
|
offlineMethod,
|
|
@@ -1586,6 +1936,10 @@ export function useCheckoutOrchestration({
|
|
|
1586
1936
|
setOptimisticMethodFee,
|
|
1587
1937
|
handlePaymentTab,
|
|
1588
1938
|
handlePaymentElementChange,
|
|
1939
|
+
/** What is still to be paid after any gift-card tender. */
|
|
1940
|
+
amountDue,
|
|
1941
|
+
/** True when the order asks the shopper for no money at all. */
|
|
1942
|
+
nothingToPay,
|
|
1589
1943
|
|
|
1590
1944
|
// Amount sync + recovery (SDK-wrapped)
|
|
1591
1945
|
syncPaymentAmount,
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GENERATED by scripts/build-icons.mjs from assets/icons/cartbase. Do not
|
|
3
|
+
* edit: change the SVG file and run the script. The Cartbase wordmark, Cartbase's own.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const CARTBASE_WORDMARK = {
|
|
7
|
+
viewBox: "0 0 378 98",
|
|
8
|
+
path: "M48.3649 79.4452C29.0672 79.4452 16.5705 66.8635 16.5705 48.5009C16.5705 29.6283 29.6623 17.0465 48.6199 17.0465C57.3762 17.0465 64.8572 20.362 70.638 25.2927L63.242 35.069C58.3113 31.0735 53.8057 29.2032 48.3649 29.2032C37.7384 29.2032 30.5974 36.7693 30.5974 47.9908C30.5974 59.5524 37.9085 67.2035 48.705 67.2035C54.2307 67.2035 58.6513 65.5033 63.667 61.4227L71.0631 71.2841C65.4523 76.2998 57.7162 79.4452 48.3649 79.4452ZM87.1349 79.4452C78.5488 79.4452 72.2579 74.8546 72.2579 65.5033C72.2579 56.577 79.5689 51.3913 89.1752 51.3913C93.1708 51.3913 97.1663 52.1564 100.142 53.6016V51.8164C100.142 46.3756 96.5713 44.3353 90.4504 44.3353C86.3698 44.3353 82.0342 45.2705 77.6986 46.7157L75.2333 37.4494C81.0141 35.2391 86.0298 34.2189 92.4907 34.2189C105.753 34.2189 113.489 38.8946 113.489 50.7962V78H101.332C101.332 75.9597 101.417 73.8344 101.757 70.4339H101.502C99.2066 75.4496 93.8509 79.4452 87.1349 79.4452ZM85.5197 64.3131C85.5197 67.7986 88.1551 69.2438 91.4705 69.2438C96.0612 69.2438 99.7167 66.4384 100.397 61.5927C98.1015 60.4026 94.701 59.4674 91.9806 59.4674C88.1551 59.4674 85.5197 61.2527 85.5197 64.3131ZM120.395 78V35.6641H132.382C132.382 37.6194 132.297 39.4046 131.957 42.7201H132.212C133.827 38.1295 138.163 34.2189 144.283 34.2189C146.664 34.2189 149.044 34.814 150.319 35.3241L147.004 46.6306C145.474 46.1206 144.028 45.8655 142.668 45.8655C137.398 45.8655 133.742 49.266 133.742 55.9819V78H120.395ZM172.797 79.4452C163.106 79.4452 157.92 73.9194 157.92 63.2079V46.2056H150.779V35.6641H157.92V25.5477L171.182 24.1025V35.6641H181.638V46.2056H171.182V62.7829C171.182 66.4384 172.712 68.3087 176.027 68.3087C177.473 68.3087 179.343 67.8836 180.873 67.2035L184.019 76.9799C180.533 78.6801 177.048 79.4452 172.797 79.4452ZM213.752 79.4452C207.121 79.4452 201.935 76.2998 199.47 71.7091H199.215C199.385 74.3445 199.385 76.1297 199.385 78H187.398V18.4917H200.83V27.928C200.83 33.1138 200.575 37.7044 199.385 42.8051H199.555C202.275 37.9594 207.631 34.2189 214.517 34.2189C225.314 34.2189 233.645 42.3801 233.645 56.747C233.645 70.519 225.059 79.4452 213.752 79.4452ZM210.352 68.3937C216.387 68.3937 220.383 63.803 220.383 56.9171C220.383 49.7761 216.387 45.2705 210.267 45.2705C204.401 45.2705 200.49 49.9461 200.49 56.9171C200.49 63.888 204.486 68.3937 210.352 68.3937ZM250.364 79.4452C241.778 79.4452 235.487 74.8546 235.487 65.5033C235.487 56.577 242.798 51.3913 252.405 51.3913C256.4 51.3913 260.396 52.1564 263.371 53.6016V51.8164C263.371 46.3756 259.801 44.3353 253.68 44.3353C249.599 44.3353 245.264 45.2705 240.928 46.7157L238.463 37.4494C244.243 35.2391 249.259 34.2189 255.72 34.2189C268.982 34.2189 276.718 38.8946 276.718 50.7962V78H264.561C264.561 75.9597 264.646 73.8344 264.986 70.4339H264.731C262.436 75.4496 257.08 79.4452 250.364 79.4452ZM248.749 64.3131C248.749 67.7986 251.384 69.2438 254.7 69.2438C259.29 69.2438 262.946 66.4384 263.626 61.5927C261.331 60.4026 257.93 59.4674 255.21 59.4674C251.384 59.4674 248.749 61.2527 248.749 64.3131ZM298.842 79.4452C292.381 79.4452 286.175 78.085 280.479 75.1096L284.475 65.1632C289.745 67.5435 294.251 68.7337 299.182 68.7337C302.752 68.7337 304.707 67.3735 304.707 65.1632C304.707 62.7829 302.837 61.6777 297.481 60.9976C287.365 59.7225 281.924 54.9618 281.924 47.8208C281.924 39.5747 288.98 34.2189 299.692 34.2189C304.707 34.2189 311.168 35.6641 315.419 37.7894L311.848 47.6508C308.193 45.6955 303.092 44.5053 299.522 44.5053C296.291 44.5053 294.421 45.6955 294.421 47.9908C294.421 50.1161 296.206 51.2213 300.372 51.7314C312.188 53.1766 317.289 57.3421 317.289 65.3332C317.289 73.9194 310.148 79.4452 298.842 79.4452ZM342.222 79.4452C329.3 79.4452 318.928 71.4541 318.928 56.407C318.928 41.7 329.98 34.2189 341.711 34.2189C353.528 34.2189 362.369 41.4449 362.369 54.7068C362.369 56.407 362.199 58.2773 361.859 60.0625H332.275C332.615 66.2684 337.631 69.1588 342.902 69.1588C347.067 69.1588 351.063 67.8836 354.293 65.2482L359.904 73.1543C355.568 77.3199 349.022 79.4452 342.222 79.4452ZM332.36 52.4114H349.703C349.788 46.9707 345.962 44.2503 341.371 44.2503C336.781 44.2503 332.955 46.8857 332.36 52.4114Z",
|
|
9
|
+
} as const
|