@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 +8 -6
- package/package.json +1 -1
- package/src/api/checkout.ts +12 -0
- package/src/api/integrations.ts +5 -3
- package/src/checkout/carrier-marks.ts +10 -49
- package/src/checkout/checkout-client.tsx +38 -25
- package/src/checkout/fulfillment-option.ts +3 -1
- package/src/checkout/index.ts +1 -1
- package/src/checkout/order-summary.tsx +30 -21
- package/src/checkout/payment-method-list.tsx +66 -29
- package/src/checkout/pickup-option.ts +32 -8
- package/src/checkout/pickup-point-selector.tsx +17 -13
- package/src/checkout/pickup-points.ts +2 -3
- package/src/checkout/shipping-method-list.tsx +40 -157
- package/src/checkout/use-checkout-orchestration.ts +84 -183
|
@@ -29,12 +29,11 @@ export function loadPickupPoints(
|
|
|
29
29
|
return promise
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
/**
|
|
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
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
|
|
10
|
-
|
|
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 {
|
|
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
|
|
27
|
-
*
|
|
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
|
-
*
|
|
35
|
-
*
|
|
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
|
|
86
|
-
*
|
|
87
|
-
*
|
|
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
|
|
107
|
-
* option id
|
|
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 (
|
|
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
|
-
|
|
130
|
-
|
|
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
|
-
|
|
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
|
-
|
|
248
|
-
const
|
|
249
|
-
|
|
250
|
-
const
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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 &&
|
|
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 &&
|
|
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
|
|
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
|
-
|
|
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
|
|
@@ -1044,9 +977,37 @@ export function useCheckoutOrchestration({
|
|
|
1044
977
|
// The offline tab = a merchant METHOD (the COD switch, else the first
|
|
1045
978
|
// manual method) — sessions initiate by payment_method_id, provider NULL.
|
|
1046
979
|
const offlineMethod = findOfflineMethod(effectiveAvailablePaymentMethods)
|
|
1047
|
-
const hasCod = !!offlineMethod
|
|
1048
|
-
const codMethodId = offlineMethod?.payment_method_id
|
|
1049
|
-
const offlineIsCodKind = offlineMethod?.kind === "cod"
|
|
980
|
+
const hasCod = !!offlineMethod
|
|
981
|
+
const codMethodId = offlineMethod?.payment_method_id
|
|
982
|
+
const offlineIsCodKind = offlineMethod?.kind === "cod"
|
|
983
|
+
|
|
984
|
+
// Preserve the platform listing order all the way into the checkout UI.
|
|
985
|
+
// The API has already applied checkout_method_order; collapsing the rows
|
|
986
|
+
// into card/COD booleans used to throw that order away.
|
|
987
|
+
const paymentMethodOrder = useMemo(() => {
|
|
988
|
+
const order: Array<"card" | "cod"> = []
|
|
989
|
+
for (const entry of effectiveAvailablePaymentMethods ?? []) {
|
|
990
|
+
if (
|
|
991
|
+
hasCard &&
|
|
992
|
+
"id" in entry &&
|
|
993
|
+
entry.id === cardEntry?.id &&
|
|
994
|
+
!order.includes("card")
|
|
995
|
+
) {
|
|
996
|
+
order.push("card")
|
|
997
|
+
}
|
|
998
|
+
if (
|
|
999
|
+
hasCod &&
|
|
1000
|
+
"payment_method_id" in entry &&
|
|
1001
|
+
entry.payment_method_id === codMethodId &&
|
|
1002
|
+
!order.includes("cod")
|
|
1003
|
+
) {
|
|
1004
|
+
order.push("cod")
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
if (hasCard && !order.includes("card")) order.push("card")
|
|
1008
|
+
if (hasCod && !order.includes("cod")) order.push("cod")
|
|
1009
|
+
return order
|
|
1010
|
+
}, [effectiveAvailablePaymentMethods, hasCard, cardEntry?.id, hasCod, codMethodId])
|
|
1050
1011
|
|
|
1051
1012
|
// The store offers a card it cannot mount: reported once, to the merchant,
|
|
1052
1013
|
// because the shopper must never be the one who discovers it. Pre-2026-09-18
|
|
@@ -1063,12 +1024,12 @@ export function useCheckoutOrchestration({
|
|
|
1063
1024
|
)
|
|
1064
1025
|
}, [cardEntry, cardOffer.mountable, logError])
|
|
1065
1026
|
|
|
1066
|
-
// Default tab:
|
|
1067
|
-
// used to seed from the cart's pending session provider; in the
|
|
1068
|
-
// deferred-intent model there is no session at mount.
|
|
1069
|
-
const [paymentTab, setPaymentTab] = useState<"card" | "cod">(
|
|
1070
|
-
hasCard ? "card" : "cod"
|
|
1071
|
-
)
|
|
1027
|
+
// Default tab: the first method in the merchant's checkout order. The eager-session model
|
|
1028
|
+
// used to seed from the cart's pending session provider; in the
|
|
1029
|
+
// deferred-intent model there is no session at mount.
|
|
1030
|
+
const [paymentTab, setPaymentTab] = useState<"card" | "cod">(
|
|
1031
|
+
paymentMethodOrder[0] ?? (hasCard ? "card" : "cod")
|
|
1032
|
+
)
|
|
1072
1033
|
|
|
1073
1034
|
// Optimistic method-fee state. Painted instantly on tab toggle so the
|
|
1074
1035
|
// totals row shows the predicted fee BEFORE the server applies it (at
|
|
@@ -1172,9 +1133,7 @@ export function useCheckoutOrchestration({
|
|
|
1172
1133
|
// specific destination (e.g. picking direct address after BoxNow
|
|
1173
1134
|
// locker). The carrier's own keys are never eagerly written, so only
|
|
1174
1135
|
// our own note is cleared beside the state.
|
|
1175
|
-
|
|
1176
|
-
setSelectedEcontOffice(null)
|
|
1177
|
-
setSelectedPickupPoint(null)
|
|
1136
|
+
setSelectedPickupPoint(null)
|
|
1178
1137
|
setPickupKeys(null)
|
|
1179
1138
|
rememberChoice({
|
|
1180
1139
|
_checkout_shipping_option: id,
|
|
@@ -1207,9 +1166,6 @@ export function useCheckoutOrchestration({
|
|
|
1207
1166
|
const selectedFulfillmentOptionId = useMemo(() => {
|
|
1208
1167
|
return fulfillmentOptionId(selectedShippingOption)
|
|
1209
1168
|
}, [selectedShippingOption])
|
|
1210
|
-
const selectedIsBoxnow = selectedFulfillmentOptionId === "boxnow-locker"
|
|
1211
|
-
const selectedIsEcont = selectedFulfillmentOptionId === "econt-office"
|
|
1212
|
-
const selectedIsPigeon = selectedFulfillmentOptionId === "pigeon-office"
|
|
1213
1169
|
|
|
1214
1170
|
/**
|
|
1215
1171
|
* WHICH carrier and WHICH kind of point this option delivers to, read from
|
|
@@ -1218,38 +1174,28 @@ export function useCheckoutOrchestration({
|
|
|
1218
1174
|
* them that way, so a carrier connected tomorrow is understood here without
|
|
1219
1175
|
* a line being added. `address` is the door and has no point to pick.
|
|
1220
1176
|
*/
|
|
1221
|
-
const selectedPickup = useMemo(
|
|
1222
|
-
() => pickupOptionOf(
|
|
1223
|
-
[
|
|
1224
|
-
)
|
|
1225
|
-
|
|
1226
|
-
// Defensive: trust cart.metadata for locker/office IDs in addition to
|
|
1227
|
-
// local React state.
|
|
1228
|
-
//
|
|
1229
|
-
//
|
|
1230
|
-
// timing / hydration race). Without this fallback, the local-null kept
|
|
1177
|
+
const selectedPickup = useMemo(
|
|
1178
|
+
() => pickupOptionOf(selectedShippingOption),
|
|
1179
|
+
[selectedShippingOption]
|
|
1180
|
+
)
|
|
1181
|
+
|
|
1182
|
+
// Defensive: trust cart.metadata for locker/office IDs in addition to
|
|
1183
|
+
// local React state. A mobile picker can update cart.metadata while its
|
|
1184
|
+
// local point remains stale after a touch/hydration race. Without this
|
|
1185
|
+
// fallback, the local-null kept
|
|
1231
1186
|
// `deliveryReady` false and the entire payment section turned into a
|
|
1232
1187
|
// ghost — even though the cart server-side knew the locker was set.
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
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.
|
|
1188
|
+
|
|
1189
|
+
// An option that delivers to a point is not ready until the point is
|
|
1190
|
+
// named, whatever the carrier.
|
|
1241
1191
|
const pickupSatisfied =
|
|
1242
1192
|
!selectedPickup ||
|
|
1243
1193
|
(!!selectedPickupPoint && selectedPickupPoint.provider === selectedPickup.provider)
|
|
1244
1194
|
|
|
1245
|
-
const deliveryReady =
|
|
1246
|
-
(!!selectedShippingMethod ||
|
|
1247
|
-
(cart?.shipping_methods?.length ?? 0) > 0) &&
|
|
1248
|
-
|
|
1249
|
-
// The legacy pickers answer for their own carriers.
|
|
1250
|
-
(selectedIsBoxnow && (!!selectedBoxnowLocker || hasBoxnowLockerInCart)) ||
|
|
1251
|
-
(selectedIsEcont && (!!selectedEcontOffice || hasEcontOfficeInCart)) ||
|
|
1252
|
-
(selectedIsPigeon && (!!selectedPigeonOffice || hasPigeonOfficeInCart)))
|
|
1195
|
+
const deliveryReady =
|
|
1196
|
+
(!!selectedShippingMethod ||
|
|
1197
|
+
(cart?.shipping_methods?.length ?? 0) > 0) &&
|
|
1198
|
+
pickupSatisfied
|
|
1253
1199
|
|
|
1254
1200
|
// Reconcile paymentTab with currently-available methods. When the
|
|
1255
1201
|
// store's paymentMethodFilter strips a method in response to a shipping
|
|
@@ -1511,39 +1457,7 @@ export function useCheckoutOrchestration({
|
|
|
1511
1457
|
// state. Called by PaymentButton on click.
|
|
1512
1458
|
const buildPrepareCheckoutPayload =
|
|
1513
1459
|
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
|
-
}
|
|
1460
|
+
const carrierMetadata: Record<string, unknown> = {}
|
|
1547
1461
|
// The carrier-agnostic pickup point (2026-09-18). The names come from
|
|
1548
1462
|
// the platform's destination registry with the point itself, so this
|
|
1549
1463
|
// writes what THIS carrier's booking reads without knowing which
|
|
@@ -1606,12 +1520,11 @@ export function useCheckoutOrchestration({
|
|
|
1606
1520
|
...tender,
|
|
1607
1521
|
}
|
|
1608
1522
|
}, [
|
|
1609
|
-
formData,
|
|
1610
|
-
selectedShippingMethod,
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
paymentTab,
|
|
1523
|
+
formData,
|
|
1524
|
+
selectedShippingMethod,
|
|
1525
|
+
selectedPickupPoint,
|
|
1526
|
+
pickupKeys,
|
|
1527
|
+
paymentTab,
|
|
1615
1528
|
cardId,
|
|
1616
1529
|
codMethodId,
|
|
1617
1530
|
nothingToPay,
|
|
@@ -1658,18 +1571,16 @@ export function useCheckoutOrchestration({
|
|
|
1658
1571
|
const stateSnapshot = {
|
|
1659
1572
|
cart_id: cart.id,
|
|
1660
1573
|
paymentTab,
|
|
1661
|
-
cardId,
|
|
1662
|
-
codMethodId,
|
|
1663
|
-
selectedShippingMethod,
|
|
1664
|
-
|
|
1665
|
-
? {
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
}
|
|
1672
|
-
: null,
|
|
1574
|
+
cardId,
|
|
1575
|
+
codMethodId,
|
|
1576
|
+
selectedShippingMethod,
|
|
1577
|
+
selectedPickupPoint: selectedPickupPoint
|
|
1578
|
+
? {
|
|
1579
|
+
provider: selectedPickupPoint.provider,
|
|
1580
|
+
id: selectedPickupPoint.id,
|
|
1581
|
+
name: selectedPickupPoint.name,
|
|
1582
|
+
}
|
|
1583
|
+
: null,
|
|
1673
1584
|
hasStripeBundle: !!stripeBundle,
|
|
1674
1585
|
}
|
|
1675
1586
|
dbg("[buy-click] STATE", stateSnapshot)
|
|
@@ -1853,11 +1764,9 @@ export function useCheckoutOrchestration({
|
|
|
1853
1764
|
cart.id,
|
|
1854
1765
|
paymentTab,
|
|
1855
1766
|
cardId,
|
|
1856
|
-
codMethodId,
|
|
1857
|
-
selectedShippingMethod,
|
|
1858
|
-
|
|
1859
|
-
selectedBoxnowLocker,
|
|
1860
|
-
selectedPigeonOffice,
|
|
1767
|
+
codMethodId,
|
|
1768
|
+
selectedShippingMethod,
|
|
1769
|
+
selectedPickupPoint,
|
|
1861
1770
|
flushAddressSave,
|
|
1862
1771
|
buildPrepareCheckoutPayload,
|
|
1863
1772
|
placeOrder,
|
|
@@ -1891,12 +1800,9 @@ export function useCheckoutOrchestration({
|
|
|
1891
1800
|
shippingMethods,
|
|
1892
1801
|
calculatedPricesMap,
|
|
1893
1802
|
isLoadingPrices,
|
|
1894
|
-
selectedShippingMethod,
|
|
1895
|
-
selectedShippingOption,
|
|
1896
|
-
selectedFulfillmentOptionId,
|
|
1897
|
-
selectedIsBoxnow,
|
|
1898
|
-
selectedIsEcont,
|
|
1899
|
-
selectedIsPigeon,
|
|
1803
|
+
selectedShippingMethod,
|
|
1804
|
+
selectedShippingOption,
|
|
1805
|
+
selectedFulfillmentOptionId,
|
|
1900
1806
|
shippingLoading,
|
|
1901
1807
|
shippingError,
|
|
1902
1808
|
optimisticShippingCost,
|
|
@@ -1904,21 +1810,16 @@ export function useCheckoutOrchestration({
|
|
|
1904
1810
|
handleSelectShipping,
|
|
1905
1811
|
|
|
1906
1812
|
// Carriers
|
|
1907
|
-
|
|
1908
|
-
handleSelectEcontOffice,
|
|
1909
|
-
selectedBoxnowLocker,
|
|
1910
|
-
handleSelectBoxnowLocker,
|
|
1911
|
-
selectedPigeonOffice,
|
|
1912
|
-
handleSelectPigeonOffice,
|
|
1913
|
-
/** The pickup point, whatever the carrier, and which carrier and kind of
|
|
1813
|
+
/** The pickup point, whatever the carrier, and which carrier and kind of
|
|
1914
1814
|
* point the chosen option asks for. Null on a door delivery. */
|
|
1915
1815
|
selectedPickupPoint,
|
|
1916
1816
|
handleSelectPickupPoint,
|
|
1917
1817
|
selectedPickup,
|
|
1918
1818
|
|
|
1919
1819
|
// Payment
|
|
1920
|
-
paymentTab,
|
|
1921
|
-
|
|
1820
|
+
paymentTab,
|
|
1821
|
+
paymentMethodOrder,
|
|
1822
|
+
hasCard,
|
|
1922
1823
|
hasCod,
|
|
1923
1824
|
cardId,
|
|
1924
1825
|
/** The store's own Stripe, off the card entry — what `PaymentWrapper`
|