@cartbase/storefront 0.22.0 → 0.23.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.
@@ -0,0 +1,64 @@
1
+ import type { StorefrontClient } from "../api/http"
2
+ import { listPickupPoints, type PickupPointsResponse, type StorePickupPoint } from "../api/integrations"
3
+ import { distanceMeters, normalizeForMatch } from "./geocode"
4
+
5
+ // The reference pickers cache the full catalogue. Scope it to the SDK client
6
+ // (the store), carrier, mode and language instead of sharing one global list.
7
+ const catalogues = new WeakMap<StorefrontClient, Map<string, {
8
+ expires: number
9
+ promise: Promise<PickupPointsResponse>
10
+ }>>()
11
+
12
+ export function loadPickupPoints(
13
+ client: StorefrontClient,
14
+ provider: string,
15
+ mode: "office" | "locker",
16
+ locale: string
17
+ ): Promise<PickupPointsResponse> {
18
+ let cache = catalogues.get(client)
19
+ if (!cache) catalogues.set(client, cache = new Map())
20
+ const key = JSON.stringify([provider, mode, locale])
21
+ const hit = cache.get(key)
22
+ if (hit && hit.expires > Date.now()) return hit.promise
23
+ const promise = listPickupPoints(client, provider, { mode, catalogue: true, locale })
24
+ .catch((error) => {
25
+ if (cache.get(key)?.promise === promise) cache.delete(key)
26
+ throw error
27
+ })
28
+ cache.set(key, { expires: Date.now() + 10 * 60 * 1000, promise })
29
+ return promise
30
+ }
31
+
32
+ /** A city-scoped adapter's eligible points, comparing both scripts. */
33
+ export function pickupPointsInCity(points: StorePickupPoint[], city: string): StorePickupPoint[] {
34
+ const needle = normalizeForMatch(city.trim())
35
+ if (!needle) return points
36
+ return points.filter((point) => normalizeForMatch(point.city).includes(needle))
37
+ }
38
+
39
+ /** The reference's full catalogue → distance sort → nearest three, in that order. */
40
+ export function nearestPickupPoints(
41
+ points: StorePickupPoint[],
42
+ position: { lat: number; lng: number } | null,
43
+ city: string
44
+ ): Array<{ point: StorePickupPoint; distance: number | null }> {
45
+ if (position && Number.isFinite(position.lat) && Number.isFinite(position.lng)) {
46
+ const located = points.filter((p) =>
47
+ p.latitude !== null && p.longitude !== null &&
48
+ Number.isFinite(p.latitude) && Number.isFinite(p.longitude)
49
+ )
50
+ if (located.length) return located.map((point) => ({ point,
51
+ distance: distanceMeters(position.lat, position.lng, point.latitude!, point.longitude!),
52
+ })).sort((a, b) => a.distance - b.distance).slice(0, 3)
53
+ }
54
+ return pickupPointsInCity(points, city).slice(0, 3).map((point) => ({ point, distance: null }))
55
+ }
56
+
57
+ /** The same catalogue powers search, including streets and either script. */
58
+ export function searchPickupPoints(points: StorePickupPoint[], search: string): StorePickupPoint[] {
59
+ const needle = normalizeForMatch(search.trim())
60
+ if (needle.length < 2) return []
61
+ return points.filter((point) => normalizeForMatch(
62
+ [point.name, point.city, point.address, point.postal_code].join(" ")
63
+ ).includes(needle)).slice(0, 8)
64
+ }
@@ -1,41 +1,33 @@
1
1
  "use client"
2
2
 
3
- import type { StorefrontClient } from "../api/http"
4
- import type { StoreShippingOption } from "../api/checkout"
5
- import type { BoxNowLocker, PigeonOffice } from "../api/integrations"
6
- import { Price } from "../lib/price"
7
- import { cn } from "../lib/utils"
8
- import { useCheckoutLabels } from "./context"
9
- import {
10
- EcontOfficeSelector,
11
- type EcontOffice,
12
- } from "./econt-office-selector"
13
- import { BoxNowLockerSelector } from "./boxnow-locker-selector"
14
- import { PigeonOfficeSelector } from "./pigeon-office-selector"
15
- import {
16
- PickupPointSelector,
17
- type PickupPoint,
3
+ import type { StorefrontClient } from "../api/http"
4
+ import type { StoreShippingOption } from "../api/checkout"
5
+ import { Price } from "../lib/price"
6
+ import { cn } from "../lib/utils"
7
+ import { useCheckoutLabels } from "./context"
8
+ import {
9
+ PickupPointSelector,
10
+ type PickupPoint,
18
11
  } from "./pickup-point-selector"
19
12
  import type { PickupPointKeys } from "../api/integrations"
20
- import { pickupOptionOf, type PickupOption } from "./pickup-option"
21
- import { fulfillmentOptionId } from "./fulfillment-option"
22
- import { ErrorMessage } from "./error-message"
13
+ import { pickupOptionOf, type PickupOption } from "./pickup-option"
14
+ import { fulfillmentOptionId } from "./fulfillment-option"
15
+ import { carrierMark } from "./carrier-marks"
16
+ import { ErrorMessage } from "./error-message"
23
17
 
24
18
  /**
25
19
  * CheckoutShippingMethodList — radio-style list of shipping options.
26
- * When the user selects an Econt-office / BoxNow-locker option, the
27
- * matching picker expands inline inside that row.
20
+ * When the user selects an adapter-declared office or locker option, the
21
+ * shared picker expands inline inside that row.
28
22
  *
29
23
  * Ported from `@1click/ui/src/checkout/shipping-method-list.tsx` (v2.3.1)
30
24
  * with the Cartbase data seam: options are `StoreShippingOption` rows from
31
25
  * `checkout.listShippingOptions(client, {cart_id})` — rule-filtered and
32
26
  * display-ordered SERVER-SIDE (checkout rules + `checkout_method_order`,
33
27
  * docs/storefront/checkout.md); this component only renders what arrives.
34
- * The BoxNow picker needs the SDK transport (its locker directory is a
35
- * store-API call), so the `boxnow` config gains a `client`.
36
- *
37
- * Detection uses the stable fulfillment-option id set by the backend
38
- * provider (`shipping_option.data.id`) — NOT the display name. Admins
28
+ * Pickup catalogues use the SDK transport through one shared picker.
29
+ * Detection uses the adapter-resolved fulfillment option returned by the
30
+ * backend, never the display name. Admins
39
31
  * rename options freely, and Bulgarian labels overlap ("До точен адрес с
40
32
  * ЕКОНТ" contains "еконт" but is address delivery, not office).
41
33
  *
@@ -52,42 +44,10 @@ type CheckoutShippingMethodListProps = {
52
44
  onSelect: (id: string) => void
53
45
  addressReady: boolean
54
46
  currencyCode: string
55
- /** Optional: Econt office state + handler for inline-expand rows */
56
- econt?: {
57
- detect?: (option: StoreShippingOption) => boolean
58
- selectedOffice: EcontOffice | null
59
- onSelectOffice: (office: EcontOffice | null) => void
60
- userCity: string
61
- userAddress: string
62
- }
63
- /** Optional: BoxNow locker state + handler for inline-expand rows */
64
- boxnow?: {
65
- detect?: (option: StoreShippingOption) => boolean
66
- /** SDK transport for the locker-directory fetch. */
67
- client: StorefrontClient
68
- selectedLocker: BoxNowLocker | null
69
- onSelectLocker: (locker: BoxNowLocker | null) => void
70
- userCity: string
71
- userAddress: string
72
- }
73
- /** Optional: Pigeon Express office state + handler for inline-expand rows */
74
- pigeon?: {
75
- detect?: (option: StoreShippingOption) => boolean
76
- /** SDK transport for the pickup-point directory (the carrier's own
77
- * catalogue is credentialed, so it is served by the platform). */
78
- client: StorefrontClient
79
- selectedOffice: PigeonOffice | null
80
- onSelectOffice: (office: PigeonOffice | null) => void
81
- userCity: string
82
- userAddress: string
83
- }
84
47
  /**
85
- * The pickup picker, for EVERY carrier (2026-09-18). One entry replaces
86
- * the three above: the option's own id says which carrier and whether it
87
- * is an office or a locker, and the platform answers for all of them
88
- * through one door, so a carrier connected tomorrow opens its picker with
89
- * nothing added here. The three above stay for stores that mount them;
90
- * where both are given, the carrier-specific one wins for its own carrier.
48
+ * The pickup picker for every carrier. The option's resolved carrier
49
+ * contract says whether it is an office or locker, and the platform
50
+ * answers through one catalogue door.
91
51
  */
92
52
  pickup?: {
93
53
  /** SDK transport — the catalogue is the store's answer, not the
@@ -103,11 +63,8 @@ type CheckoutShippingMethodListProps = {
103
63
  userAddress: string
104
64
  }
105
65
  /**
106
- * Optional per-store carrier branding. Keyed by the stable fulfillment
107
- * option id (shipping_option.data.id) "econt-office", "boxnow-locker",
108
- * etc. If a match is found, a small logo renders between the radio and
109
- * the option name. Stores that don't supply a map get the unbranded
110
- * (radio + text only) layout.
66
+ * Optional per-store carrier branding override, keyed by the stable
67
+ * fulfillment option id. Without one, the adapter's mark renders.
111
68
  */
112
69
  logoByFulfillmentOptionId?: Record<string, { src: string; alt: string }>
113
70
  /** Show the shipping options as a read-only price PREVIEW before the
@@ -116,7 +73,7 @@ type CheckoutShippingMethodListProps = {
116
73
  * non-selectable — a hint tells the shopper to enter their address to
117
74
  * choose. For flat-priced stores this lets people see the cost up front
118
75
  * (a common reason carts stall) without letting them pick a method that
119
- * needs a city (Econt office / BoxNow locker). Default `false` keeps the
76
+ * needs a city (office or locker). Default `false` keeps the
120
77
  * classic address-gated placeholder for every existing store. */
121
78
  previewWhenAddressNotReady?: boolean
122
79
  }
@@ -126,21 +83,9 @@ type CheckoutShippingMethodListProps = {
126
83
  const getFulfillmentOptionId = (option: StoreShippingOption): string | null =>
127
84
  fulfillmentOptionId(option)
128
85
 
129
- const defaultEcontDetect = (option: StoreShippingOption): boolean => {
130
- return getFulfillmentOptionId(option) === "econt-office"
131
- }
132
-
133
- const defaultBoxnowDetect = (option: StoreShippingOption): boolean => {
134
- return getFulfillmentOptionId(option) === "boxnow-locker"
135
- }
136
-
137
- const defaultPigeonDetect = (option: StoreShippingOption): boolean => {
138
- return getFulfillmentOptionId(option) === "pigeon-office"
139
- }
140
-
141
- /** Which carrier, and which kind of point, from the option's own id. */
142
- const detectPickup = (option: StoreShippingOption): PickupOption | null =>
143
- pickupOptionOf(getFulfillmentOptionId(option))
86
+ /** Which carrier, which point kind and scope, from its adapter contract. */
87
+ const detectPickup = (option: StoreShippingOption): PickupOption | null =>
88
+ pickupOptionOf(option)
144
89
 
145
90
  export function CheckoutShippingMethodList({
146
91
  shippingMethods,
@@ -152,18 +97,11 @@ export function CheckoutShippingMethodList({
152
97
  onSelect,
153
98
  addressReady,
154
99
  currencyCode,
155
- econt,
156
- boxnow,
157
- pigeon,
158
- pickup,
100
+ pickup,
159
101
  logoByFulfillmentOptionId,
160
102
  previewWhenAddressNotReady = false,
161
103
  }: CheckoutShippingMethodListProps) {
162
104
  const labels = useCheckoutLabels()
163
- const detectEcont = econt?.detect ?? defaultEcontDetect
164
- const detectBoxnow = boxnow?.detect ?? defaultBoxnowDetect
165
- const detectPigeon = pigeon?.detect ?? defaultPigeonDetect
166
-
167
105
  // Preview mode: address not filled yet, but the store wants the options
168
106
  // shown as a read-only price preview rather than a placeholder. Rows are
169
107
  // rendered but locked (no selection) — see `previewWhenAddressNotReady`.
@@ -244,26 +182,14 @@ export function CheckoutShippingMethodList({
244
182
  ? option.amount
245
183
  : calculatedPricesMap[option.id]
246
184
  const isFree = price === 0
247
- const isEcontOffice = econt && detectEcont(option)
248
- const isBoxnowLocker =
249
- boxnow && !isEcontOffice && detectBoxnow(option)
250
- const isPigeonOffice =
251
- pigeon && !isEcontOffice && !isBoxnowLocker && detectPigeon(option)
252
- // Any option that delivers to a point, whatever the carrier
253
- // (2026-09-18). The three flags above are the same question asked
254
- // per carrier, which is why Speedy had no picker; this one reads
255
- // the option's own id, so a carrier connected tomorrow opens its
256
- // picker with no line added here. It yields to a legacy picker
257
- // when the store still mounts one for that carrier.
258
- const pickupOption = pickup ? detectPickup(option) : null
259
- const isPickup =
260
- !!pickupOption && !isEcontOffice && !isBoxnowLocker && !isPigeonOffice
261
- const hasExpanded =
262
- selected &&
263
- (isEcontOffice || isBoxnowLocker || isPigeonOffice || isPickup)
264
- const logo = logoByFulfillmentOptionId
265
- ? logoByFulfillmentOptionId[getFulfillmentOptionId(option) ?? ""]
266
- : undefined
185
+ // Any option whose adapter resolves to a pickup destination.
186
+ const pickupOption = pickup ? detectPickup(option) : null
187
+ const isPickup = !!pickupOption
188
+ const hasExpanded = selected && isPickup
189
+ const logo =
190
+ logoByFulfillmentOptionId?.[getFulfillmentOptionId(option) ?? ""] ??
191
+ carrierMark(option) ??
192
+ undefined
267
193
 
268
194
  return (
269
195
  <div
@@ -312,22 +238,7 @@ export function CheckoutShippingMethodList({
312
238
  <span className="text-sm font-medium text-foreground">
313
239
  {option.name}
314
240
  </span>
315
- {selected && isEcontOffice && econt?.selectedOffice && (
316
- <p className="text-xs text-muted-foreground mt-0.5">
317
- {econt.selectedOffice.name}
318
- </p>
319
- )}
320
- {selected && isBoxnowLocker && boxnow?.selectedLocker && (
321
- <p className="text-xs text-muted-foreground mt-0.5">
322
- {boxnow.selectedLocker.title}
323
- </p>
324
- )}
325
- {selected && isPigeonOffice && pigeon?.selectedOffice && (
326
- <p className="text-xs text-muted-foreground mt-0.5">
327
- {pigeon.selectedOffice.name}
328
- </p>
329
- )}
330
- {selected && isPickup && pickup?.selectedPoint && (
241
+ {selected && isPickup && pickup?.selectedPoint && (
331
242
  <p className="text-xs text-muted-foreground mt-0.5">
332
243
  {pickup.selectedPoint.name}
333
244
  </p>
@@ -371,40 +282,12 @@ export function CheckoutShippingMethodList({
371
282
  </span>
372
283
  </button>
373
284
 
374
- {hasExpanded && isEcontOffice && econt && (
375
- <EcontOfficeSelector
376
- userCity={econt.userCity}
377
- userAddress={econt.userAddress}
378
- selectedOffice={econt.selectedOffice}
379
- onSelect={econt.onSelectOffice}
380
- />
381
- )}
382
-
383
- {hasExpanded && isBoxnowLocker && boxnow && (
384
- <BoxNowLockerSelector
385
- client={boxnow.client}
386
- userCity={boxnow.userCity}
387
- userAddress={boxnow.userAddress}
388
- selectedLocker={boxnow.selectedLocker}
389
- onSelect={boxnow.onSelectLocker}
390
- />
391
- )}
392
-
393
- {hasExpanded && isPigeonOffice && pigeon && (
394
- <PigeonOfficeSelector
395
- client={pigeon.client}
396
- userCity={pigeon.userCity}
397
- userAddress={pigeon.userAddress}
398
- selectedOffice={pigeon.selectedOffice}
399
- onSelect={pigeon.onSelectOffice}
400
- />
401
- )}
402
-
403
- {hasExpanded && isPickup && pickup && pickupOption && (
285
+ {hasExpanded && isPickup && pickup && pickupOption && (
404
286
  <PickupPointSelector
405
287
  client={pickup.client}
406
- provider={pickupOption.provider}
407
- mode={pickupOption.mode}
288
+ provider={pickupOption.provider}
289
+ mode={pickupOption.mode}
290
+ scope={pickupOption.scope}
408
291
  userCity={pickup.userCity}
409
292
  userAddress={pickup.userAddress}
410
293
  selectedPoint={pickup.selectedPoint}
@@ -49,11 +49,9 @@ import { clearCartCookie } from "../lib/cookie-names"
49
49
  import { useCartDrawer } from "../cart-drawer/context"
50
50
  import compareAddresses from "./compare-addresses"
51
51
  import { translateAddressError } from "./address-error-copy"
52
- import { translatePaymentError } from "./payment-error-copy"
53
- import { useCheckoutLabels, useOrderConfirmedPath } from "./context"
54
- import type { EcontOffice } from "./econt-office-selector"
55
- import type { BoxNowLocker, PigeonOffice } from "../api/integrations"
56
- import { fulfillmentOptionId } from "./fulfillment-option"
52
+ import { translatePaymentError } from "./payment-error-copy"
53
+ import { useCheckoutLabels, useOrderConfirmedPath } from "./context"
54
+ import { fulfillmentOptionId } from "./fulfillment-option"
57
55
  import { useCheckoutFunnel } from "./use-checkout-funnel"
58
56
  import { amountDueAfterTender, isNothingToPay } from "./summary-math"
59
57
  import { forgetFiredEvents } from "../tracking/once"
@@ -931,72 +929,7 @@ export function useCheckoutOrchestration({
931
929
  // Server-side, prepare-checkout removes the PREVIOUS prepare's carrier
932
930
  // keys before merging (the `_prepared_carrier_keys` marker), so
933
931
  // switching carriers can never leak stale fields into the order.
934
- const [selectedEcontOffice, setSelectedEcontOffice] =
935
- useState<EcontOffice | null>(
936
- cart?.metadata?.econt_office_code
937
- ? ({
938
- code: cart.metadata.econt_office_code as string,
939
- name: cart.metadata.econt_office_name as string,
940
- } as EcontOffice)
941
- : null
942
- )
943
-
944
- const handleSelectEcontOffice = useCallback(
945
- (office: EcontOffice | null) => {
946
- setSelectedEcontOffice(office)
947
- },
948
- []
949
- )
950
-
951
- const [selectedBoxnowLocker, setSelectedBoxnowLocker] =
952
- useState<BoxNowLocker | null>(
953
- cart?.metadata?.boxnow_locker_id
954
- ? ({
955
- id: cart.metadata.boxnow_locker_id as string,
956
- title: (cart.metadata.boxnow_locker_title as string) ?? "",
957
- addressLine1:
958
- (cart.metadata.boxnow_locker_address as string) ?? "",
959
- addressLine2: "",
960
- postalCode: (cart.metadata.boxnow_locker_postal as string) ?? "",
961
- country: "",
962
- lat: null,
963
- lng: null,
964
- note: "",
965
- } as BoxNowLocker)
966
- : null
967
- )
968
-
969
- const handleSelectBoxnowLocker = useCallback(
970
- (locker: BoxNowLocker | null) => {
971
- setSelectedBoxnowLocker(locker)
972
- },
973
- []
974
- )
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
- /**
932
+ /**
1000
933
  * THE pickup point, whatever the carrier (2026-09-18).
1001
934
  *
1002
935
  * The three states above are one state per carrier, written into one
@@ -1172,9 +1105,7 @@ export function useCheckoutOrchestration({
1172
1105
  // specific destination (e.g. picking direct address after BoxNow
1173
1106
  // locker). The carrier's own keys are never eagerly written, so only
1174
1107
  // our own note is cleared beside the state.
1175
- setSelectedBoxnowLocker(null)
1176
- setSelectedEcontOffice(null)
1177
- setSelectedPickupPoint(null)
1108
+ setSelectedPickupPoint(null)
1178
1109
  setPickupKeys(null)
1179
1110
  rememberChoice({
1180
1111
  _checkout_shipping_option: id,
@@ -1207,9 +1138,6 @@ export function useCheckoutOrchestration({
1207
1138
  const selectedFulfillmentOptionId = useMemo(() => {
1208
1139
  return fulfillmentOptionId(selectedShippingOption)
1209
1140
  }, [selectedShippingOption])
1210
- const selectedIsBoxnow = selectedFulfillmentOptionId === "boxnow-locker"
1211
- const selectedIsEcont = selectedFulfillmentOptionId === "econt-office"
1212
- const selectedIsPigeon = selectedFulfillmentOptionId === "pigeon-office"
1213
1141
 
1214
1142
  /**
1215
1143
  * WHICH carrier and WHICH kind of point this option delivers to, read from
@@ -1218,38 +1146,28 @@ export function useCheckoutOrchestration({
1218
1146
  * them that way, so a carrier connected tomorrow is understood here without
1219
1147
  * a line being added. `address` is the door and has no point to pick.
1220
1148
  */
1221
- const selectedPickup = useMemo(
1222
- () => pickupOptionOf(selectedFulfillmentOptionId),
1223
- [selectedFulfillmentOptionId]
1224
- )
1225
-
1226
- // Defensive: trust cart.metadata for locker/office IDs in addition to
1227
- // local React state. On mobile the BoxNow locker selector was seen
1228
- // firing its onSelect handler in a way that updated cart.metadata
1229
- // cleanly but left the local `selectedBoxnowLocker` stale (touch event
1230
- // timing / hydration race). Without this fallback, the local-null kept
1149
+ const selectedPickup = useMemo(
1150
+ () => pickupOptionOf(selectedShippingOption),
1151
+ [selectedShippingOption]
1152
+ )
1153
+
1154
+ // Defensive: trust cart.metadata for locker/office IDs in addition to
1155
+ // local React state. A mobile picker can update cart.metadata while its
1156
+ // local point remains stale after a touch/hydration race. Without this
1157
+ // fallback, the local-null kept
1231
1158
  // `deliveryReady` false and the entire payment section turned into a
1232
1159
  // ghost — even though the cart server-side knew the locker was set.
1233
- const hasBoxnowLockerInCart = !!cart?.metadata?.boxnow_locker_id
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.
1160
+
1161
+ // An option that delivers to a point is not ready until the point is
1162
+ // named, whatever the carrier.
1241
1163
  const pickupSatisfied =
1242
1164
  !selectedPickup ||
1243
1165
  (!!selectedPickupPoint && selectedPickupPoint.provider === selectedPickup.provider)
1244
1166
 
1245
- const deliveryReady =
1246
- (!!selectedShippingMethod ||
1247
- (cart?.shipping_methods?.length ?? 0) > 0) &&
1248
- (pickupSatisfied ||
1249
- // The legacy pickers answer for their own carriers.
1250
- (selectedIsBoxnow && (!!selectedBoxnowLocker || hasBoxnowLockerInCart)) ||
1251
- (selectedIsEcont && (!!selectedEcontOffice || hasEcontOfficeInCart)) ||
1252
- (selectedIsPigeon && (!!selectedPigeonOffice || hasPigeonOfficeInCart)))
1167
+ const deliveryReady =
1168
+ (!!selectedShippingMethod ||
1169
+ (cart?.shipping_methods?.length ?? 0) > 0) &&
1170
+ pickupSatisfied
1253
1171
 
1254
1172
  // Reconcile paymentTab with currently-available methods. When the
1255
1173
  // store's paymentMethodFilter strips a method in response to a shipping
@@ -1511,39 +1429,7 @@ export function useCheckoutOrchestration({
1511
1429
  // state. Called by PaymentButton on click.
1512
1430
  const buildPrepareCheckoutPayload =
1513
1431
  useCallback((): PrepareCheckoutInput => {
1514
- const carrierMetadata: Record<string, unknown> = {}
1515
- if (selectedEcontOffice) {
1516
- const addr = [
1517
- selectedEcontOffice.address?.street,
1518
- selectedEcontOffice.address?.num,
1519
- ]
1520
- .filter(Boolean)
1521
- .join(" ")
1522
- carrierMetadata.econt_office_code = selectedEcontOffice.code
1523
- carrierMetadata.econt_office_name = selectedEcontOffice.name
1524
- carrierMetadata.econt_office_city =
1525
- selectedEcontOffice.address?.city?.name || ""
1526
- carrierMetadata.econt_office_address = addr
1527
- carrierMetadata.econt_office_phone =
1528
- selectedEcontOffice.phones?.[0] || ""
1529
- }
1530
- if (selectedBoxnowLocker) {
1531
- carrierMetadata.boxnow_locker_id = selectedBoxnowLocker.id
1532
- carrierMetadata.boxnow_locker_title = selectedBoxnowLocker.title
1533
- carrierMetadata.boxnow_locker_address =
1534
- selectedBoxnowLocker.addressLine1 ?? ""
1535
- carrierMetadata.boxnow_locker_postal =
1536
- selectedBoxnowLocker.postalCode ?? ""
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
- }
1432
+ const carrierMetadata: Record<string, unknown> = {}
1547
1433
  // The carrier-agnostic pickup point (2026-09-18). The names come from
1548
1434
  // the platform's destination registry with the point itself, so this
1549
1435
  // writes what THIS carrier's booking reads without knowing which
@@ -1606,12 +1492,11 @@ export function useCheckoutOrchestration({
1606
1492
  ...tender,
1607
1493
  }
1608
1494
  }, [
1609
- formData,
1610
- selectedShippingMethod,
1611
- selectedEcontOffice,
1612
- selectedBoxnowLocker,
1613
- selectedPigeonOffice,
1614
- paymentTab,
1495
+ formData,
1496
+ selectedShippingMethod,
1497
+ selectedPickupPoint,
1498
+ pickupKeys,
1499
+ paymentTab,
1615
1500
  cardId,
1616
1501
  codMethodId,
1617
1502
  nothingToPay,
@@ -1658,18 +1543,16 @@ export function useCheckoutOrchestration({
1658
1543
  const stateSnapshot = {
1659
1544
  cart_id: cart.id,
1660
1545
  paymentTab,
1661
- cardId,
1662
- codMethodId,
1663
- selectedShippingMethod,
1664
- selectedEcontOffice: selectedEcontOffice
1665
- ? { code: selectedEcontOffice.code, name: selectedEcontOffice.name }
1666
- : null,
1667
- selectedBoxnowLocker: selectedBoxnowLocker
1668
- ? {
1669
- id: selectedBoxnowLocker.id,
1670
- title: selectedBoxnowLocker.title,
1671
- }
1672
- : null,
1546
+ cardId,
1547
+ codMethodId,
1548
+ selectedShippingMethod,
1549
+ selectedPickupPoint: selectedPickupPoint
1550
+ ? {
1551
+ provider: selectedPickupPoint.provider,
1552
+ id: selectedPickupPoint.id,
1553
+ name: selectedPickupPoint.name,
1554
+ }
1555
+ : null,
1673
1556
  hasStripeBundle: !!stripeBundle,
1674
1557
  }
1675
1558
  dbg("[buy-click] STATE", stateSnapshot)
@@ -1853,11 +1736,9 @@ export function useCheckoutOrchestration({
1853
1736
  cart.id,
1854
1737
  paymentTab,
1855
1738
  cardId,
1856
- codMethodId,
1857
- selectedShippingMethod,
1858
- selectedEcontOffice,
1859
- selectedBoxnowLocker,
1860
- selectedPigeonOffice,
1739
+ codMethodId,
1740
+ selectedShippingMethod,
1741
+ selectedPickupPoint,
1861
1742
  flushAddressSave,
1862
1743
  buildPrepareCheckoutPayload,
1863
1744
  placeOrder,
@@ -1891,12 +1772,9 @@ export function useCheckoutOrchestration({
1891
1772
  shippingMethods,
1892
1773
  calculatedPricesMap,
1893
1774
  isLoadingPrices,
1894
- selectedShippingMethod,
1895
- selectedShippingOption,
1896
- selectedFulfillmentOptionId,
1897
- selectedIsBoxnow,
1898
- selectedIsEcont,
1899
- selectedIsPigeon,
1775
+ selectedShippingMethod,
1776
+ selectedShippingOption,
1777
+ selectedFulfillmentOptionId,
1900
1778
  shippingLoading,
1901
1779
  shippingError,
1902
1780
  optimisticShippingCost,
@@ -1904,13 +1782,7 @@ export function useCheckoutOrchestration({
1904
1782
  handleSelectShipping,
1905
1783
 
1906
1784
  // Carriers
1907
- selectedEcontOffice,
1908
- handleSelectEcontOffice,
1909
- selectedBoxnowLocker,
1910
- handleSelectBoxnowLocker,
1911
- selectedPigeonOffice,
1912
- handleSelectPigeonOffice,
1913
- /** The pickup point, whatever the carrier, and which carrier and kind of
1785
+ /** The pickup point, whatever the carrier, and which carrier and kind of
1914
1786
  * point the chosen option asks for. Null on a door delivery. */
1915
1787
  selectedPickupPoint,
1916
1788
  handleSelectPickupPoint,