@cartbase/storefront 0.22.1 → 0.24.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.24.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. */
@@ -177,9 +177,11 @@ export interface StorePickupPoint {
177
177
  provider: string
178
178
  mode: "office" | "locker"
179
179
  /** THEIR id, exactly as the order will carry it. */
180
- id: string
181
- name: string
182
- city: string
180
+ id: string
181
+ name: string
182
+ /** Alternate script/transliteration supplied by the carrier. */
183
+ secondary_name?: string
184
+ city: string
183
185
  address: string
184
186
  postal_code: string
185
187
  phone: string
@@ -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
  }
@@ -5,9 +5,9 @@ import type { Appearance, StripeElementsOptions } from "@stripe/stripe-js"
5
5
  import type { StorefrontClient } from "../api/http"
6
6
  import type { Cart } from "../api/carts"
7
7
  import type { StoreShippingOption } from "../api/checkout"
8
- import type { StoreCustomer } from "../api/customers"
8
+ import type { StoreCustomer } from "../api/customers"
9
+ import type { PaymentMethod } from "../common/payment-icons"
9
10
  import { CheckoutAddressForm } from "./address-form"
10
- import { carrierMarks } from "./carrier-marks"
11
11
  import { DiscountSection } from "./discount-section"
12
12
  import { MobileCheckoutBottomBar } from "./mobile-checkout-bottom-bar"
13
13
  import { MobileCheckoutTopBar } from "./mobile-checkout-top-bar"
@@ -60,7 +60,11 @@ type CheckoutClientProps = {
60
60
  cart: Cart
61
61
  customer: StoreCustomer | null
62
62
  availableShippingMethods: StoreShippingOption[] | null
63
- availablePaymentMethods: PaymentProviderLike[] | null
63
+ availablePaymentMethods: PaymentProviderLike[] | null
64
+ /** Card and wallet marks returned by `GET /api/store/payment-marks`. */
65
+ paymentMarks?: readonly PaymentMethod[]
66
+ /** Store-owned terms notice, including links to its content pages. */
67
+ termsContent?: React.ReactNode
64
68
  /**
65
69
  * The country to PRESELECT when the cart has no shipping address yet.
66
70
  * Optional, like the hook's own: the offer comes from the store
@@ -77,8 +81,10 @@ type CheckoutClientProps = {
77
81
  methods: PaymentProviderLike[] | null,
78
82
  selectedShippingOption: StoreShippingOption | null
79
83
  ) => PaymentProviderLike[] | null
80
- /** Show the gift-card widget in the summary (default true). */
81
- showGiftCards?: boolean
84
+ /** Show the gift-card widget in the summary (default true). */
85
+ showGiftCards?: boolean
86
+ /** Show the security reassurance below the desktop total (default true). */
87
+ showSecureCheckout?: boolean
82
88
  logoByFulfillmentOptionId?: Record<string, { src: string; alt: string }>
83
89
  /** Stripe Elements appearance + fonts forwarded to PaymentWrapper. */
84
90
  appearance?: Appearance
@@ -108,11 +114,14 @@ export function CheckoutClient({
108
114
  cart,
109
115
  customer,
110
116
  availableShippingMethods,
111
- availablePaymentMethods,
117
+ availablePaymentMethods,
118
+ paymentMarks,
119
+ termsContent,
112
120
  countryCode,
113
121
  countries,
114
122
  paymentMethodFilter,
115
- showGiftCards = true,
123
+ showGiftCards = true,
124
+ showSecureCheckout = true,
116
125
  logoByFulfillmentOptionId,
117
126
  appearance,
118
127
  fonts,
@@ -166,9 +175,10 @@ export function CheckoutClient({
166
175
  <MobileCheckoutTopBar
167
176
  client={client}
168
177
  cart={o.summaryCart}
169
- optimisticShippingCost={o.optimisticShippingCost}
170
- optimisticMethodFee={o.optimisticMethodFee}
171
- showGiftCards={showGiftCards}
178
+ optimisticShippingCost={o.optimisticShippingCost}
179
+ optimisticMethodFee={o.optimisticMethodFee}
180
+ methodFeeLabel={o.paymentTab === "cod" ? o.offlineMethod?.fee_label ?? undefined : undefined}
181
+ showGiftCards={showGiftCards}
172
182
  onCartChange={handleCartChange}
173
183
  />
174
184
 
@@ -214,18 +224,18 @@ export function CheckoutClient({
214
224
  userCity: o.formData["shipping_address.city"] ?? "",
215
225
  userAddress: o.formData["shipping_address.address_1"] ?? "",
216
226
  }}
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
- }
227
+ logoByFulfillmentOptionId={logoByFulfillmentOptionId}
222
228
  />
223
229
 
224
230
  <CheckoutPaymentMethodList
225
231
  cart={cart}
226
- hasCard={o.hasCard}
227
- hasCod={o.hasCod}
228
- paymentTab={o.paymentTab}
232
+ hasCard={o.hasCard}
233
+ hasCod={o.hasCod}
234
+ offlineMethod={o.offlineMethod}
235
+ paymentMethodOrder={o.paymentMethodOrder}
236
+ paymentMarks={paymentMarks}
237
+ termsContent={termsContent}
238
+ paymentTab={o.paymentTab}
229
239
  onPaymentTab={o.handlePaymentTab}
230
240
  deliveryReady={o.deliveryReady}
231
241
  nothingToPay={o.nothingToPay}
@@ -257,9 +267,10 @@ export function CheckoutClient({
257
267
  <MobileCheckoutBottomBar
258
268
  client={client}
259
269
  cart={o.summaryCart}
260
- optimisticShippingCost={o.optimisticShippingCost}
261
- optimisticMethodFee={o.optimisticMethodFee}
262
- showGiftCards={showGiftCards}
270
+ optimisticShippingCost={o.optimisticShippingCost}
271
+ optimisticMethodFee={o.optimisticMethodFee}
272
+ methodFeeLabel={o.paymentTab === "cod" ? o.offlineMethod?.fee_label ?? undefined : undefined}
273
+ showGiftCards={showGiftCards}
263
274
  hideDiscount
264
275
  onCartChange={handleCartChange}
265
276
  />
@@ -278,10 +289,12 @@ export function CheckoutClient({
278
289
  onOptimisticShippingClear={() =>
279
290
  o.setOptimisticShippingCost(null)
280
291
  }
281
- optimisticMethodFee={o.optimisticMethodFee}
282
- onOptimisticMethodFeeClear={() => o.setOptimisticMethodFee(null)}
283
- showGiftCards={showGiftCards}
284
- onCartChange={handleCartChange}
292
+ optimisticMethodFee={o.optimisticMethodFee}
293
+ onOptimisticMethodFeeClear={() => o.setOptimisticMethodFee(null)}
294
+ methodFeeLabel={o.paymentTab === "cod" ? o.offlineMethod?.fee_label ?? undefined : undefined}
295
+ showGiftCards={showGiftCards}
296
+ showSecureCheckout={showSecureCheckout}
297
+ onCartChange={handleCartChange}
285
298
  />
286
299
  </div>
287
300
  </div>
@@ -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"
@@ -168,7 +168,9 @@ type OrderSummaryProps = {
168
168
  /** Show the gift-card widget. Default true; pass false for stores that
169
169
  * haven't enabled gift cards (the widget itself is harmless either way —
170
170
  * an unknown code gets the generic refusal). */
171
- showGiftCards?: boolean
171
+ showGiftCards?: boolean
172
+ /** Show the security reassurance below the total. Default true. */
173
+ showSecureCheckout?: boolean
172
174
  /**
173
175
  * Receives every decorated cart returned by a summary mutation
174
176
  * (quantity change, promo apply, gift-card apply/remove). Hosts update
@@ -186,9 +188,10 @@ export function OrderSummary({
186
188
  onOptimisticShippingClear,
187
189
  optimisticMethodFee,
188
190
  onOptimisticMethodFeeClear,
189
- methodFeeLabel,
190
- showGiftCards = true,
191
- onCartChange,
191
+ methodFeeLabel,
192
+ showGiftCards = true,
193
+ showSecureCheckout = true,
194
+ onCartChange,
192
195
  }: OrderSummaryProps) {
193
196
  const labels = useCheckoutLabels()
194
197
 
@@ -377,7 +380,11 @@ export function OrderSummary({
377
380
  )}
378
381
  </div>
379
382
 
380
- <div className="mx-5 sm:mx-6 mt-4 pt-4 border-t border-border">
383
+ <div
384
+ className={`mx-5 sm:mx-6 mt-4 pt-4 border-t border-border ${
385
+ showSecureCheckout ? "" : "pb-5"
386
+ }`}
387
+ >
381
388
  <div className="flex justify-between items-center">
382
389
  <span className="text-lg font-bold text-foreground">
383
390
  {labels.total}
@@ -420,22 +427,24 @@ export function OrderSummary({
420
427
  )}
421
428
  </div>
422
429
 
423
- <div className="px-5 sm:px-6 pb-5 pt-4 flex items-center justify-center gap-2 text-xs text-muted-foreground">
424
- <svg
425
- className="w-3.5 h-3.5"
426
- fill="none"
427
- viewBox="0 0 24 24"
428
- stroke="currentColor"
429
- strokeWidth={2}
430
- >
431
- <path
432
- strokeLinecap="round"
433
- strokeLinejoin="round"
434
- d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
435
- />
436
- </svg>
437
- <span>{labels.secureCheckout}</span>
438
- </div>
430
+ {showSecureCheckout && (
431
+ <div className="px-5 sm:px-6 pb-5 pt-4 flex items-center justify-center gap-2 text-xs text-muted-foreground">
432
+ <svg
433
+ className="w-3.5 h-3.5"
434
+ fill="none"
435
+ viewBox="0 0 24 24"
436
+ stroke="currentColor"
437
+ strokeWidth={2}
438
+ >
439
+ <path
440
+ strokeLinecap="round"
441
+ strokeLinejoin="round"
442
+ d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
443
+ />
444
+ </svg>
445
+ <span>{labels.secureCheckout}</span>
446
+ </div>
447
+ )}
439
448
  </div>
440
449
  </div>
441
450
  )
@@ -2,9 +2,11 @@
2
2
 
3
3
  import { PaymentElement } from "@stripe/react-stripe-js"
4
4
  import type { StripePaymentElementChangeEvent } from "@stripe/stripe-js"
5
- import { useContext, useEffect, useState } from "react"
6
-
7
- import { cn } from "../lib/utils"
5
+ import { useContext, useEffect, useState } from "react"
6
+
7
+ import type { StorePaymentMethodEntry } from "../api/checkout"
8
+ import { PaymentIcon, type PaymentMethod } from "../common/payment-icons"
9
+ import { cn } from "../lib/utils"
8
10
  import { useCheckoutLabels } from "./context"
9
11
  import { ErrorMessage } from "./error-message"
10
12
  import { PaymentButton } from "./payment-button"
@@ -34,9 +36,17 @@ import type { CheckoutLogError } from "./use-checkout-orchestration"
34
36
  type CheckoutPaymentMethodListProps = {
35
37
  /** Structural cart — forwarded to PaymentButton (`id`/`total`/`currency_code`). */
36
38
  cart: { id: string; total?: number | null; currency_code: string }
37
- hasCard: boolean
38
- hasCod: boolean
39
- paymentTab: "card" | "cod"
39
+ hasCard: boolean
40
+ hasCod: boolean
41
+ /** The merchant-configured method shown by the offline/COD tab. */
42
+ offlineMethod: StorePaymentMethodEntry | null
43
+ /** Platform-defined order after checkout rules have been applied. */
44
+ paymentMethodOrder: Array<"card" | "cod">
45
+ /** Card and wallet marks enabled for the connected payment provider. */
46
+ paymentMarks?: readonly PaymentMethod[]
47
+ /** Store-owned terms notice. Falls back to the locale's plain text. */
48
+ termsContent?: React.ReactNode
49
+ paymentTab: "card" | "cod"
40
50
  onPaymentTab: (tab: "card" | "cod") => void
41
51
  deliveryReady: boolean
42
52
  paymentError: string | null
@@ -117,9 +127,13 @@ type CheckoutPaymentMethodListProps = {
117
127
 
118
128
  export function CheckoutPaymentMethodList({
119
129
  cart,
120
- hasCard,
121
- hasCod,
122
- paymentTab,
130
+ hasCard,
131
+ hasCod,
132
+ offlineMethod,
133
+ paymentMethodOrder,
134
+ paymentMarks = [],
135
+ termsContent,
136
+ paymentTab,
123
137
  onPaymentTab,
124
138
  deliveryReady,
125
139
  paymentError,
@@ -138,7 +152,13 @@ export function CheckoutPaymentMethodList({
138
152
 
139
153
  // Which extra tab is active, if any. Selecting card/cod clears it; the
140
154
  // radio group stays single-select across built-in and extra tabs.
141
- const [extraTab, setExtraTab] = useState<string | null>(null)
155
+ const [extraTab, setExtraTab] = useState<string | null>(null)
156
+ const methodPosition = (method: "card" | "cod") => {
157
+ const position = paymentMethodOrder.indexOf(method)
158
+ return position === -1 ? paymentMethodOrder.length : position
159
+ }
160
+ const visiblePaymentMarks = paymentMarks.slice(0, 3)
161
+ const hiddenPaymentMarkCount = Math.max(0, paymentMarks.length - visiblePaymentMarks.length)
142
162
 
143
163
  // When the store opts out of gating (gatePaymentUntilDelivery=false),
144
164
  // the payment section is always shown + interactive regardless of
@@ -212,7 +232,9 @@ export function CheckoutPaymentMethodList({
212
232
  <h2 className="text-lg font-semibold text-foreground mb-1 tracking-tight">
213
233
  {labels.paymentMethod}
214
234
  </h2>
215
- <p className="text-xs text-muted-foreground mb-4">{labels.termsText}</p>
235
+ <p className="text-xs text-muted-foreground mb-4">
236
+ {termsContent ?? labels.termsText}
237
+ </p>
216
238
 
217
239
  {paymentGated ? (
218
240
  <div className="p-4 bg-muted rounded-lg border border-border">
@@ -239,7 +261,7 @@ export function CheckoutPaymentMethodList({
239
261
  // and tracking refs upstream are NOT torn down. That's the
240
262
  // whole point of moving away from PaymentWrapper-wraps-all.
241
263
  <StripeElementsScope passthrough>
242
- <div className="space-y-2">
264
+ <div className="flex flex-col gap-2">
243
265
  {nothingToPay && (
244
266
  // One line instead of a tender list. The shopper owes
245
267
  // nothing, so every radio here would be a question with no
@@ -250,8 +272,9 @@ export function CheckoutPaymentMethodList({
250
272
  </div>
251
273
  )}
252
274
  {!nothingToPay && hasCard && (
253
- <div
254
- className={cn(
275
+ <div
276
+ style={{ order: methodPosition("card") }}
277
+ className={cn(
255
278
  "rounded-lg border overflow-hidden transition-colors duration-150",
256
279
  paymentTab === "card" && !extraTab
257
280
  ? "border-primary bg-primary/5"
@@ -276,10 +299,22 @@ export function CheckoutPaymentMethodList({
276
299
  <div className="w-2 h-2 rounded-full bg-primary" />
277
300
  )}
278
301
  </div>
279
- <span className="text-sm font-medium text-foreground">
280
- {labels.payOnline}
281
- </span>
282
- </button>
302
+ <span className="text-sm font-medium text-foreground">
303
+ {labels.payOnline}
304
+ </span>
305
+ {visiblePaymentMarks.length > 0 && (
306
+ <span className="ml-auto flex items-center gap-1.5 pl-3">
307
+ {visiblePaymentMarks.map((method) => (
308
+ <PaymentIcon key={method} method={method} className="h-6" />
309
+ ))}
310
+ {hiddenPaymentMarkCount > 0 && (
311
+ <span className="flex h-6 min-w-8 items-center justify-center rounded border border-border bg-card px-1.5 text-xs font-medium text-foreground">
312
+ +{hiddenPaymentMarkCount}
313
+ </span>
314
+ )}
315
+ </span>
316
+ )}
317
+ </button>
283
318
 
284
319
  {paymentTab === "card" && !extraTab && (
285
320
  <div className="px-4 pb-4 pt-2">
@@ -329,8 +364,9 @@ export function CheckoutPaymentMethodList({
329
364
  )}
330
365
 
331
366
  {!nothingToPay && hasCod && (
332
- <div
333
- className={cn(
367
+ <div
368
+ style={{ order: methodPosition("cod") }}
369
+ className={cn(
334
370
  "rounded-lg border overflow-hidden transition-colors duration-150",
335
371
  paymentTab === "cod" && !extraTab
336
372
  ? "border-primary bg-primary/5"
@@ -355,23 +391,24 @@ export function CheckoutPaymentMethodList({
355
391
  <div className="w-2 h-2 rounded-full bg-primary" />
356
392
  )}
357
393
  </div>
358
- <span className="text-sm font-medium text-foreground">
359
- {labels.cashOnDelivery}
360
- </span>
394
+ <span className="text-sm font-medium text-foreground">
395
+ {offlineMethod?.name ?? labels.cashOnDelivery}
396
+ </span>
361
397
  </button>
362
398
  {paymentTab === "cod" && !extraTab && (
363
399
  <div className="px-4 pb-3 pt-0">
364
- <p className="text-xs text-muted-foreground ml-[30px]">
365
- {labels.codNote}
366
- </p>
400
+ <p className="text-xs text-muted-foreground ml-[30px]">
401
+ {offlineMethod?.instructions ?? labels.codNote}
402
+ </p>
367
403
  </div>
368
404
  )}
369
405
  </div>
370
406
  )}
371
407
 
372
- {!nothingToPay && extraTabs?.map((tab) => (
373
- <div
374
- key={tab.id}
408
+ {!nothingToPay && extraTabs?.map((tab, index) => (
409
+ <div
410
+ key={tab.id}
411
+ style={{ order: paymentMethodOrder.length + index }}
375
412
  className={cn(
376
413
  "rounded-lg border overflow-hidden transition-colors duration-150",
377
414
  extraTab === tab.id
@@ -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),
@@ -174,10 +177,11 @@ export function PickupPointSelector({
174
177
  <div className="flex-1 min-w-0">
175
178
  <p className="text-sm font-medium text-foreground leading-tight">
176
179
  {point.name}
177
- </p>
178
- <p className="text-xs text-muted-foreground mt-0.5">
179
- {[point.address, point.city].filter(Boolean).join(", ")}
180
- </p>
180
+ </p>
181
+ <p className="text-xs text-muted-foreground mt-0.5">
182
+ {point.secondary_name ||
183
+ [point.address, point.city].filter(Boolean).join(", ")}
184
+ </p>
181
185
  </div>
182
186
 
183
187
  {distance !== null && distance > 0 && (