@cartbase/storefront 0.22.1 → 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.
package/README.md CHANGED
@@ -47,12 +47,14 @@ NEXT_PUBLIC_CARTBASE_URL= # optional: only to point at a local or s
47
47
 
48
48
  ### Pickup points
49
49
 
50
- The checkout pickup picker loads each carrier's complete eligible catalogue
51
- through the store API, then shows the nearest three to the typed address.
52
- Its search accepts Latin or Cyrillic; the mounted locale pack selects
53
- Speedy's display language. Offices exclude automated lockers. BoxNow keeps
54
- both lists within the checkout city. No carrier credentials or copied
55
- catalogue are needed in the storefront.
50
+ The checkout pickup picker loads each carrier's complete eligible catalogue
51
+ through the store API, then shows the nearest three to the typed address.
52
+ Its search accepts Latin or Cyrillic; the mounted locale pack selects
53
+ Speedy's display language. Offices exclude automated lockers. BoxNow keeps
54
+ both lists within the checkout city. No carrier credentials or copied
55
+ catalogue are needed in the storefront. Every shipping option carries the
56
+ connected adapter's identity, mark, destination mode and pickup scope, so the
57
+ same picker and logo path work for every carrier without storefront branches.
56
58
 
57
59
  ### Tracking, in three lines
58
60
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cartbase/storefront",
3
- "version": "0.22.1",
3
+ "version": "0.23.0",
4
4
  "description": "Storefront SDK + UI component library for Cartbase stores: typed API client, checkout orchestration, cart drawer, product/catalog components, tracking. Source-shipped TypeScript — add it to transpilePackages.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -49,6 +49,18 @@ export interface StoreShippingOption {
49
49
  service_zone_id: string | null
50
50
  shipping_profile_id: string | null
51
51
  data: Record<string, unknown> | null
52
+ /** Carrier identity and destination, resolved by the platform adapter. */
53
+ carrier?: {
54
+ id: string
55
+ name: string
56
+ mark_url: string | null
57
+ fulfillment_option: {
58
+ id: string
59
+ mode: "address" | "office" | "locker"
60
+ label: string
61
+ } | null
62
+ pickup_scope: "city" | "global"
63
+ } | null
52
64
  /** The option's display label (e.g. "Express") as `{ label }`, or null. */
53
65
  type: { label: string } | null
54
66
  /** Flat price in the cart currency; null without `cart_id` or when no price row matches. */
@@ -1,53 +1,14 @@
1
- /**
2
- * The carrier's own mark beside its name on a checkout shipping row.
3
- *
4
- * A shopper picks a courier by recognising it, and until 2026-09-18 a store
5
- * got names only unless it hunted down logo files and passed a map of its
6
- * own (store-package card). The marks are the platform's: the same files
7
- * the admin's integrations screens wear, served from the platform the store
8
- * already talks to, so a carrier we add tomorrow arrives with its mark and
9
- * no store copies an image anywhere.
10
- *
11
- * Keyed by the carrier's fulfillment option id, which the platform's
12
- * registry owns and a shipping option carries in `data.id`. Both modes of a
13
- * carrier wear the same mark: it names the courier, not the destination.
14
- */
15
-
16
- const MARK: Record<string, { carrier: string; alt: string }> = {
17
- "econt-address": { carrier: "econt", alt: "Econt" },
18
- "econt-office": { carrier: "econt", alt: "Econt" },
19
- "speedy-address": { carrier: "speedy", alt: "Speedy" },
20
- "speedy-office": { carrier: "speedy", alt: "Speedy" },
21
- "boxnow-locker": { carrier: "boxnow", alt: "BOX NOW" },
22
- "pigeon-address": { carrier: "pigeon", alt: "Pigeon Express" },
23
- "pigeon-office": { carrier: "pigeon", alt: "Pigeon Express" },
24
- "pigeon-locker": { carrier: "pigeon", alt: "Pigeon Express" },
25
- }
1
+ import type { StoreShippingOption } from "../api/checkout"
26
2
 
27
3
  /**
28
- * The default `logoByFulfillmentOptionId` map for a checkout's shipping
29
- * list. A store that passes its own map keeps it: this is the floor, not a
30
- * ceiling.
31
- *
32
- * `origin` is the SDK client's own (`client.origin`), which in a storefront
33
- * is the shop's own address, because the store API ships no CORS headers and
34
- * every storefront reaches it through a same-origin `/api/store/*` rewrite.
35
- * So the mark is asked for under that same path, which is the one address a
36
- * storefront can always reach; the platform answers it with the file it
37
- * already serves. Pointing at the platform's own `/integrations/…` instead
38
- * is what made every mark 404, since that path is not proxied and the shop's
39
- * own domain has no such file (found on the workbench before release).
4
+ * The carrier's mark beside its checkout row. Identity and the same-origin
5
+ * mark path arrive resolved by the platform adapter; the package keeps no
6
+ * carrier list.
40
7
  */
41
- export function carrierMarks(
42
- origin: string
43
- ): Record<string, { src: string; alt: string }> {
44
- const base = origin.replace(/\/+$/, "")
45
- const marks: Record<string, { src: string; alt: string }> = {}
46
- for (const [optionId, mark] of Object.entries(MARK)) {
47
- marks[optionId] = {
48
- src: `${base}/api/store/integrations/marks/${mark.carrier}`,
49
- alt: mark.alt,
50
- }
51
- }
52
- return marks
8
+ export function carrierMark(
9
+ option: Pick<StoreShippingOption, "carrier">
10
+ ): { src: string; alt: string } | null {
11
+ const carrier = option.carrier
12
+ if (!carrier?.mark_url) return null
13
+ return { src: carrier.mark_url, alt: carrier.name }
53
14
  }
@@ -7,7 +7,6 @@ import type { Cart } from "../api/carts"
7
7
  import type { StoreShippingOption } from "../api/checkout"
8
8
  import type { StoreCustomer } from "../api/customers"
9
9
  import { CheckoutAddressForm } from "./address-form"
10
- import { carrierMarks } from "./carrier-marks"
11
10
  import { DiscountSection } from "./discount-section"
12
11
  import { MobileCheckoutBottomBar } from "./mobile-checkout-bottom-bar"
13
12
  import { MobileCheckoutTopBar } from "./mobile-checkout-top-bar"
@@ -214,11 +213,7 @@ export function CheckoutClient({
214
213
  userCity: o.formData["shipping_address.city"] ?? "",
215
214
  userAddress: o.formData["shipping_address.address_1"] ?? "",
216
215
  }}
217
- // Every carrier wears its own mark without the store lifting a
218
- // finger; a store that passes its own map keeps it.
219
- logoByFulfillmentOptionId={
220
- logoByFulfillmentOptionId ?? carrierMarks(client.origin)
221
- }
216
+ logoByFulfillmentOptionId={logoByFulfillmentOptionId}
222
217
  />
223
218
 
224
219
  <CheckoutPaymentMethodList
@@ -16,8 +16,10 @@ import type { StoreShippingOption } from "../api/checkout"
16
16
  * because a cart carried over from that platform still speaks it.
17
17
  */
18
18
  export function fulfillmentOptionId(
19
- option: Pick<StoreShippingOption, "data"> | null | undefined
19
+ option: Pick<StoreShippingOption, "data" | "carrier"> | null | undefined
20
20
  ): string | null {
21
+ const resolved = option?.carrier?.fulfillment_option?.id
22
+ if (typeof resolved === "string" && resolved) return resolved
21
23
  const data = option?.data as
22
24
  | { fulfillment_option?: unknown; id?: unknown }
23
25
  | null
@@ -44,7 +44,7 @@ export {
44
44
  type PickupPoint,
45
45
  } from "./pickup-point-selector"
46
46
  export { pickupOptionOf, type PickupOption } from "./pickup-option"
47
- export { carrierMarks } from "./carrier-marks"
47
+ export { carrierMark } from "./carrier-marks"
48
48
  export { AddressSelect } from "./address-select"
49
49
  export { CompanyDetails } from "./company-details"
50
50
  export { DiscountSection } from "./discount-section"
@@ -1,11 +1,11 @@
1
+ import type { StoreShippingOption } from "../api/checkout"
2
+
1
3
  /**
2
4
  * Does this shipping option deliver to a POINT, and to whose?
3
5
  *
4
- * Every carrier names its fulfillment options `<carrier>-<mode>`, because the
5
- * platform's registry declares them that way and the same ids key the carrier
6
- * marks and the order's own destination editor. So one reader answers for all
7
- * of them, and a carrier connected tomorrow opens its picker with no line
8
- * added anywhere.
6
+ * The platform resolves provider, mode and picker scope through the carrier
7
+ * adapter. The string form remains as rollout compatibility for older store
8
+ * API responses.
9
9
  *
10
10
  * `address` is the door: there is no point to choose, so it answers null.
11
11
  *
@@ -17,19 +17,43 @@
17
17
  */
18
18
 
19
19
  export type PickupOption = {
20
- /** `econt`, `speedy`, `boxnow`, `pigeon`. */
20
+ /** Carrier adapter id. */
21
21
  provider: string
22
22
  mode: "office" | "locker"
23
+ scope: "city" | "global"
23
24
  }
24
25
 
25
26
  export function pickupOptionOf(
26
- fulfillmentOptionId: string | null | undefined
27
+ input:
28
+ | string
29
+ | Partial<Pick<StoreShippingOption, "carrier" | "data">>
30
+ | null
31
+ | undefined
27
32
  ): PickupOption | null {
33
+ if (typeof input === "object" && input) {
34
+ const carrier = input.carrier
35
+ const mode = carrier?.fulfillment_option?.mode
36
+ if (carrier && (mode === "office" || mode === "locker")) {
37
+ return { provider: carrier.id, mode, scope: carrier.pickup_scope }
38
+ }
39
+ const data = input.data as
40
+ | { fulfillment_option?: unknown; id?: unknown }
41
+ | null
42
+ | undefined
43
+ const legacy =
44
+ typeof data?.fulfillment_option === "string"
45
+ ? data.fulfillment_option
46
+ : typeof data?.id === "string"
47
+ ? data.id
48
+ : null
49
+ return pickupOptionOf(legacy)
50
+ }
51
+ const fulfillmentOptionId = input
28
52
  if (!fulfillmentOptionId) return null
29
53
  const dash = fulfillmentOptionId.lastIndexOf("-")
30
54
  if (dash <= 0) return null
31
55
  const mode = fulfillmentOptionId.slice(dash + 1)
32
56
  if (mode !== "office" && mode !== "locker") return null
33
57
  const provider = fulfillmentOptionId.slice(0, dash)
34
- return provider ? { provider, mode } : null
58
+ return provider ? { provider, mode, scope: "global" } : null
35
59
  }
@@ -53,10 +53,12 @@ export type PickupPoint = StorePickupPoint
53
53
  type PickupPointSelectorProps = {
54
54
  /** The SDK transport — the points come from the store, never the carrier. */
55
55
  client: StorefrontClient
56
- /** `econt`, `speedy`, `boxnow`, `pigeon`, from the shipping option. */
56
+ /** Carrier adapter id from the shipping option contract. */
57
57
  provider: string
58
- /** Which kind of point this option delivers to. */
59
- mode: "office" | "locker"
58
+ /** Which kind of point this option delivers to. */
59
+ mode: "office" | "locker"
60
+ /** Adapter-declared catalogue scope. */
61
+ scope: "city" | "global"
60
62
  /** The address the shopper has typed so far, for "nearest to me". */
61
63
  userCity: string
62
64
  userAddress: string
@@ -70,8 +72,9 @@ const MIN_SEARCH_CHARS = 2
70
72
 
71
73
  export function PickupPointSelector({
72
74
  client,
73
- provider,
74
- mode,
75
+ provider,
76
+ mode,
77
+ scope,
75
78
  userCity,
76
79
  userAddress,
77
80
  selectedPoint,
@@ -120,10 +123,10 @@ export function PickupPointSelector({
120
123
  return () => { alive = false; clearTimeout(timer) }
121
124
  }, [userCity, userAddress])
122
125
 
123
- // Alenika's BoxNow picker locks both nearest and search to the town.
124
- // Econt (and the shared office pattern used for Speedy) searches all offices.
125
- const eligiblePoints = useMemo(() => provider === "boxnow"
126
- ? pickupPointsInCity(points, userCity) : points, [points, provider, userCity])
126
+ const eligiblePoints = useMemo(
127
+ () => (scope === "city" ? pickupPointsInCity(points, userCity) : points),
128
+ [points, scope, userCity]
129
+ )
127
130
  const nearest = useMemo(() => nearestPickupPoints(eligiblePoints, coords, userCity),
128
131
  [eligiblePoints, coords, userCity])
129
132
  const searchResults = useMemo(() => searchPickupPoints(eligiblePoints, search),
@@ -29,12 +29,11 @@ export function loadPickupPoints(
29
29
  return promise
30
30
  }
31
31
 
32
- /** BoxNow's reference picker city-locks both lists, comparing both scripts. */
32
+ /** A city-scoped adapter's eligible points, comparing both scripts. */
33
33
  export function pickupPointsInCity(points: StorePickupPoint[], city: string): StorePickupPoint[] {
34
34
  const needle = normalizeForMatch(city.trim())
35
35
  if (!needle) return points
36
- return points.filter((point) => normalizeForMatch(point.city).includes(needle) ||
37
- (point.provider === "boxnow" && normalizeForMatch(point.address).includes(needle)))
36
+ return points.filter((point) => normalizeForMatch(point.city).includes(needle))
38
37
  }
39
38
 
40
39
  /** The reference's full catalogue → distance sort → nearest three, in that order. */
@@ -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,