@cartbase/storefront 0.21.0 → 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 +14 -1
- package/src/api/checkout.ts +15 -0
- package/src/api/http.ts +9 -0
- package/src/api/integrations.ts +121 -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 +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/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
|
@@ -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,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The storefront's own Stripe, from env — the FALLBACK, not the source.
|
|
3
|
+
*
|
|
4
|
+
* A Cartbase store's card credentials come from the store: the `pp_stripe`
|
|
5
|
+
* entry of `GET /api/store/payment-providers` carries the publishable key
|
|
6
|
+
* and, under Connect, the merchant's account id, because under Connect the
|
|
7
|
+
* key is Cartbase's and only the platform knows the pairing. Connecting
|
|
8
|
+
* Stripe in the admin is the entire setup; nothing is configured here.
|
|
9
|
+
*
|
|
10
|
+
* These two variables are what the Medusa storefronts we ported from used
|
|
11
|
+
* (`NEXT_PUBLIC_STRIPE_KEY` in mindpages' payment-wrapper), and they are
|
|
12
|
+
* right for a storefront that IS one shop with one Stripe account. They stay
|
|
13
|
+
* supported for a store that runs its own Stripe outside the platform, and
|
|
14
|
+
* so that a storefront which already set them keeps working.
|
|
15
|
+
*
|
|
16
|
+
* Read at module load, like any `NEXT_PUBLIC_*` value: Next inlines them at
|
|
17
|
+
* build time, so they cannot be read dynamically anyway.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export const envStripeKey = process.env.NEXT_PUBLIC_STRIPE_KEY
|
|
21
|
+
|
|
22
|
+
export const envStripeAccountId = process.env.NEXT_PUBLIC_STRIPE_ACCOUNT_ID
|
|
23
|
+
|
|
24
|
+
/** True when the storefront carries its own Stripe key in env. */
|
|
25
|
+
export const hasEnvStripeKey = (): boolean => !!envStripeKey
|