@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.
Files changed (50) hide show
  1. package/package.json +274 -258
  2. package/src/api/checkout.ts +15 -0
  3. package/src/api/http.ts +10 -0
  4. package/src/api/integrations.ts +121 -0
  5. package/src/api/store.ts +21 -0
  6. package/src/cart-drawer/payment-badges.tsx +36 -96
  7. package/src/checkout/card-offer.ts +68 -0
  8. package/src/checkout/carrier-marks.ts +53 -0
  9. package/src/checkout/checkout-client.tsx +71 -13
  10. package/src/checkout/checkout-error-screen.tsx +113 -0
  11. package/src/checkout/discount-section.tsx +88 -56
  12. package/src/checkout/fulfillment-option.ts +30 -0
  13. package/src/checkout/index.ts +41 -6
  14. package/src/checkout/labels.ts +98 -0
  15. package/src/checkout/line-item-card.tsx +35 -83
  16. package/src/checkout/mobile-checkout-bottom-bar.tsx +132 -0
  17. package/src/checkout/mobile-checkout-top-bar.tsx +94 -0
  18. package/src/checkout/mobile-order-summary-body.tsx +172 -0
  19. package/src/checkout/order-summary.tsx +85 -179
  20. package/src/checkout/payment-button.tsx +25 -8
  21. package/src/checkout/payment-method-list.tsx +51 -13
  22. package/src/checkout/payment-wrapper.tsx +57 -13
  23. package/src/checkout/pickup-option.ts +35 -0
  24. package/src/checkout/pickup-point-selector.tsx +372 -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/common/icons/cartbase-mark.ts +9 -0
  31. package/src/common/icons/payment-marks.ts +73 -0
  32. package/src/common/icons/social-marks.ts +61 -0
  33. package/src/common/index.ts +15 -0
  34. package/src/common/payment-icons.tsx +54 -0
  35. package/src/common/powered-by-cartbase.tsx +47 -0
  36. package/src/common/social-links.tsx +65 -0
  37. package/src/lib/stripe-env.ts +25 -0
  38. package/src/locales/bg.ts +39 -0
  39. package/src/locales/es.ts +37 -0
  40. package/src/store/labels.ts +10 -0
  41. package/src/tracking/attribution.ts +83 -0
  42. package/src/tracking/consent.ts +53 -0
  43. package/src/tracking/events.ts +113 -0
  44. package/src/tracking/fbq.ts +48 -0
  45. package/src/tracking/gtag.ts +32 -0
  46. package/src/tracking/index.ts +32 -1
  47. package/src/tracking/once.ts +137 -0
  48. package/src/tracking/rybbit-events.ts +42 -0
  49. package/src/tracking/ttq.ts +24 -0
  50. 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 { isStripeLike } from "../lib/payment-constants"
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
- >(cart?.shipping_methods?.at(-1)?.shipping_option_id || null)
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
- const hasCard = !!effectiveAvailablePaymentMethods?.some(
882
- (m) => "id" in m && isStripeLike(m.id)
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 cardId = (
885
- effectiveAvailablePaymentMethods?.find(
886
- (m) => "id" in m && isStripeLike(m.id)
887
- ) as { id: string } | undefined
888
- )?.id
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
- // Shipping selection is client state only. No addShippingMethod call,
953
- // no metadata-clear updateCart all of those wrote to the cart
954
- // between toggles and produced the stale-data bug class. The shipping
955
- // method ID is sent to the backend exactly once at Buy click via
956
- // prepareCheckout.
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). All client state no eager metadata-clear updateCart.
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
- const data = selectedShippingOption?.data as
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
- (!selectedIsBoxnow || !!selectedBoxnowLocker || hasBoxnowLockerInCart) &&
1008
- (!selectedIsEcont || !!selectedEcontOffice || hasEcontOfficeInCart)
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: card tab the processor; offline tab → the merchant
1253
- // method (provider-less session, pp_* kill).
1254
- const tender =
1255
- paymentTab === "card"
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
- if (paymentTab === "card") {
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 (paymentTab === "card" && !zeroRemainderGiftPath) {
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