@cartbase/storefront 0.23.0 → 0.24.1
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/package.json +1 -1
- package/src/api/checkout.ts +12 -0
- package/src/api/integrations.ts +5 -3
- package/src/checkout/checkout-client.tsx +45 -23
- package/src/checkout/discount-section.tsx +10 -8
- package/src/checkout/gift-card-section.tsx +10 -8
- package/src/checkout/latest-write-queue.ts +39 -0
- package/src/checkout/mobile-checkout-top-bar.tsx +1 -1
- package/src/checkout/order-summary.tsx +30 -21
- package/src/checkout/payment-method-list.tsx +66 -29
- package/src/checkout/pickup-point-selector.tsx +13 -12
- package/src/checkout/shipping-method-list.tsx +10 -10
- package/src/checkout/use-checkout-orchestration.ts +166 -66
- package/src/locales/bg.ts +4 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cartbase/storefront",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.1",
|
|
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": {
|
package/src/api/checkout.ts
CHANGED
|
@@ -146,6 +146,12 @@ export interface ListPaymentProvidersQuery {
|
|
|
146
146
|
* checkout; without it cart-dependent hide rules cannot match here.
|
|
147
147
|
*/
|
|
148
148
|
cart_id?: string
|
|
149
|
+
/**
|
|
150
|
+
* Evaluate payment rules for the delivery option the shopper just chose.
|
|
151
|
+
* This avoids waiting for the cart preference write before refreshing the
|
|
152
|
+
* server-authoritative payment list.
|
|
153
|
+
*/
|
|
154
|
+
shipping_option_id?: string
|
|
149
155
|
}
|
|
150
156
|
|
|
151
157
|
/** A connected PROCESSOR entry of GET /api/store/payment-providers. */
|
|
@@ -197,6 +203,12 @@ export const isMethodEntry = (
|
|
|
197
203
|
|
|
198
204
|
export interface PaymentProviderListResponse extends ListEnvelope {
|
|
199
205
|
payment_providers: StorePaymentEntry[]
|
|
206
|
+
/**
|
|
207
|
+
* Server-evaluated payment choices keyed by delivery option. CheckoutClient
|
|
208
|
+
* uses this to react synchronously during rapid shipping switches without
|
|
209
|
+
* reproducing the rules engine in the browser.
|
|
210
|
+
*/
|
|
211
|
+
payment_providers_by_shipping_option?: Record<string, StorePaymentEntry[]>
|
|
200
212
|
}
|
|
201
213
|
|
|
202
214
|
/**
|
package/src/api/integrations.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
@@ -5,7 +5,8 @@ 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
11
|
import { DiscountSection } from "./discount-section"
|
|
11
12
|
import { MobileCheckoutBottomBar } from "./mobile-checkout-bottom-bar"
|
|
@@ -58,8 +59,14 @@ type CheckoutClientProps = {
|
|
|
58
59
|
client: StorefrontClient
|
|
59
60
|
cart: Cart
|
|
60
61
|
customer: StoreCustomer | null
|
|
61
|
-
availableShippingMethods: StoreShippingOption[] | null
|
|
62
|
-
availablePaymentMethods: PaymentProviderLike[] | null
|
|
62
|
+
availableShippingMethods: StoreShippingOption[] | null
|
|
63
|
+
availablePaymentMethods: PaymentProviderLike[] | null
|
|
64
|
+
/** Server-evaluated tender lists keyed by delivery option id. */
|
|
65
|
+
paymentMethodsByShippingOption?: Record<string, PaymentProviderLike[]>
|
|
66
|
+
/** Card and wallet marks returned by `GET /api/store/payment-marks`. */
|
|
67
|
+
paymentMarks?: readonly PaymentMethod[]
|
|
68
|
+
/** Store-owned terms notice, including links to its content pages. */
|
|
69
|
+
termsContent?: React.ReactNode
|
|
63
70
|
/**
|
|
64
71
|
* The country to PRESELECT when the cart has no shipping address yet.
|
|
65
72
|
* Optional, like the hook's own: the offer comes from the store
|
|
@@ -76,8 +83,10 @@ type CheckoutClientProps = {
|
|
|
76
83
|
methods: PaymentProviderLike[] | null,
|
|
77
84
|
selectedShippingOption: StoreShippingOption | null
|
|
78
85
|
) => PaymentProviderLike[] | null
|
|
79
|
-
/** Show the gift-card widget in the summary (default true). */
|
|
80
|
-
showGiftCards?: boolean
|
|
86
|
+
/** Show the gift-card widget in the summary (default true). */
|
|
87
|
+
showGiftCards?: boolean
|
|
88
|
+
/** Show the security reassurance below the desktop total (default true). */
|
|
89
|
+
showSecureCheckout?: boolean
|
|
81
90
|
logoByFulfillmentOptionId?: Record<string, { src: string; alt: string }>
|
|
82
91
|
/** Stripe Elements appearance + fonts forwarded to PaymentWrapper. */
|
|
83
92
|
appearance?: Appearance
|
|
@@ -106,12 +115,16 @@ export function CheckoutClient({
|
|
|
106
115
|
client,
|
|
107
116
|
cart,
|
|
108
117
|
customer,
|
|
109
|
-
availableShippingMethods,
|
|
110
|
-
availablePaymentMethods,
|
|
118
|
+
availableShippingMethods,
|
|
119
|
+
availablePaymentMethods,
|
|
120
|
+
paymentMethodsByShippingOption,
|
|
121
|
+
paymentMarks,
|
|
122
|
+
termsContent,
|
|
111
123
|
countryCode,
|
|
112
124
|
countries,
|
|
113
125
|
paymentMethodFilter,
|
|
114
|
-
showGiftCards = true,
|
|
126
|
+
showGiftCards = true,
|
|
127
|
+
showSecureCheckout = true,
|
|
115
128
|
logoByFulfillmentOptionId,
|
|
116
129
|
appearance,
|
|
117
130
|
fonts,
|
|
@@ -126,8 +139,9 @@ export function CheckoutClient({
|
|
|
126
139
|
client,
|
|
127
140
|
cart,
|
|
128
141
|
customer,
|
|
129
|
-
availableShippingMethods,
|
|
130
|
-
availablePaymentMethods,
|
|
142
|
+
availableShippingMethods,
|
|
143
|
+
availablePaymentMethods,
|
|
144
|
+
paymentMethodsByShippingOption,
|
|
131
145
|
countryCode,
|
|
132
146
|
countries,
|
|
133
147
|
paymentMethodFilter,
|
|
@@ -165,9 +179,10 @@ export function CheckoutClient({
|
|
|
165
179
|
<MobileCheckoutTopBar
|
|
166
180
|
client={client}
|
|
167
181
|
cart={o.summaryCart}
|
|
168
|
-
optimisticShippingCost={o.optimisticShippingCost}
|
|
169
|
-
optimisticMethodFee={o.optimisticMethodFee}
|
|
170
|
-
|
|
182
|
+
optimisticShippingCost={o.optimisticShippingCost}
|
|
183
|
+
optimisticMethodFee={o.optimisticMethodFee}
|
|
184
|
+
methodFeeLabel={o.paymentTab === "cod" ? o.offlineMethod?.fee_label ?? undefined : undefined}
|
|
185
|
+
showGiftCards={showGiftCards}
|
|
171
186
|
onCartChange={handleCartChange}
|
|
172
187
|
/>
|
|
173
188
|
|
|
@@ -218,9 +233,13 @@ export function CheckoutClient({
|
|
|
218
233
|
|
|
219
234
|
<CheckoutPaymentMethodList
|
|
220
235
|
cart={cart}
|
|
221
|
-
hasCard={o.hasCard}
|
|
222
|
-
hasCod={o.hasCod}
|
|
223
|
-
|
|
236
|
+
hasCard={o.hasCard}
|
|
237
|
+
hasCod={o.hasCod}
|
|
238
|
+
offlineMethod={o.offlineMethod}
|
|
239
|
+
paymentMethodOrder={o.paymentMethodOrder}
|
|
240
|
+
paymentMarks={paymentMarks}
|
|
241
|
+
termsContent={termsContent}
|
|
242
|
+
paymentTab={o.paymentTab}
|
|
224
243
|
onPaymentTab={o.handlePaymentTab}
|
|
225
244
|
deliveryReady={o.deliveryReady}
|
|
226
245
|
nothingToPay={o.nothingToPay}
|
|
@@ -252,9 +271,10 @@ export function CheckoutClient({
|
|
|
252
271
|
<MobileCheckoutBottomBar
|
|
253
272
|
client={client}
|
|
254
273
|
cart={o.summaryCart}
|
|
255
|
-
optimisticShippingCost={o.optimisticShippingCost}
|
|
256
|
-
optimisticMethodFee={o.optimisticMethodFee}
|
|
257
|
-
|
|
274
|
+
optimisticShippingCost={o.optimisticShippingCost}
|
|
275
|
+
optimisticMethodFee={o.optimisticMethodFee}
|
|
276
|
+
methodFeeLabel={o.paymentTab === "cod" ? o.offlineMethod?.fee_label ?? undefined : undefined}
|
|
277
|
+
showGiftCards={showGiftCards}
|
|
258
278
|
hideDiscount
|
|
259
279
|
onCartChange={handleCartChange}
|
|
260
280
|
/>
|
|
@@ -273,10 +293,12 @@ export function CheckoutClient({
|
|
|
273
293
|
onOptimisticShippingClear={() =>
|
|
274
294
|
o.setOptimisticShippingCost(null)
|
|
275
295
|
}
|
|
276
|
-
optimisticMethodFee={o.optimisticMethodFee}
|
|
277
|
-
onOptimisticMethodFeeClear={() => o.setOptimisticMethodFee(null)}
|
|
278
|
-
|
|
279
|
-
|
|
296
|
+
optimisticMethodFee={o.optimisticMethodFee}
|
|
297
|
+
onOptimisticMethodFeeClear={() => o.setOptimisticMethodFee(null)}
|
|
298
|
+
methodFeeLabel={o.paymentTab === "cod" ? o.offlineMethod?.fee_label ?? undefined : undefined}
|
|
299
|
+
showGiftCards={showGiftCards}
|
|
300
|
+
showSecureCheckout={showSecureCheckout}
|
|
301
|
+
onCartChange={handleCartChange}
|
|
280
302
|
/>
|
|
281
303
|
</div>
|
|
282
304
|
</div>
|
|
@@ -107,7 +107,7 @@ export function DiscountSection({
|
|
|
107
107
|
}
|
|
108
108
|
|
|
109
109
|
const field = (
|
|
110
|
-
<div className={cn("
|
|
110
|
+
<div className={cn("grid grid-cols-[minmax(0,1fr)_auto] gap-2", !alwaysOpen && "mt-3")}>
|
|
111
111
|
<input
|
|
112
112
|
type="text"
|
|
113
113
|
value={code}
|
|
@@ -119,13 +119,13 @@ export function DiscountSection({
|
|
|
119
119
|
}
|
|
120
120
|
}}
|
|
121
121
|
placeholder="PROMO2024"
|
|
122
|
-
className="
|
|
122
|
+
className="w-full min-w-0 h-11 px-4 text-sm bg-card border border-border rounded-xl focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20 transition-all"
|
|
123
123
|
/>
|
|
124
124
|
<button
|
|
125
125
|
type="button"
|
|
126
126
|
onClick={handleApply}
|
|
127
127
|
disabled={loading || !code.trim()}
|
|
128
|
-
className="px-
|
|
128
|
+
className="px-4 h-11 text-sm font-semibold bg-foreground text-card rounded-xl hover:bg-foreground/90 transition-all disabled:opacity-40"
|
|
129
129
|
>
|
|
130
130
|
{labels.addCode}
|
|
131
131
|
</button>
|
|
@@ -150,7 +150,7 @@ export function DiscountSection({
|
|
|
150
150
|
<button
|
|
151
151
|
type="button"
|
|
152
152
|
onClick={() => setOpen(!open)}
|
|
153
|
-
className="
|
|
153
|
+
className="grid grid-cols-[2.5rem_minmax(0,1fr)_auto] items-center gap-3 w-full p-4 rounded-xl border border-border bg-card hover:border-primary transition-colors"
|
|
154
154
|
>
|
|
155
155
|
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-warning to-warning/70 flex items-center justify-center flex-shrink-0">
|
|
156
156
|
<svg
|
|
@@ -172,13 +172,15 @@ export function DiscountSection({
|
|
|
172
172
|
/>
|
|
173
173
|
</svg>
|
|
174
174
|
</div>
|
|
175
|
-
<div className="
|
|
176
|
-
<p className="text-sm font-semibold text-foreground">
|
|
175
|
+
<div className="min-w-0 text-left">
|
|
176
|
+
<p className="text-sm font-semibold leading-tight text-foreground truncate">
|
|
177
177
|
{labels.discountCode}
|
|
178
178
|
</p>
|
|
179
|
-
<p className="text-xs text-muted-foreground">
|
|
179
|
+
<p className="hidden min-[380px]:block text-xs text-muted-foreground truncate">
|
|
180
|
+
{labels.discountSub}
|
|
181
|
+
</p>
|
|
180
182
|
</div>
|
|
181
|
-
<span className="text-sm font-medium text-foreground flex items-center gap-1.5">
|
|
183
|
+
<span className="shrink-0 whitespace-nowrap text-sm font-medium text-foreground flex items-center gap-1.5">
|
|
182
184
|
{labels.addCode}
|
|
183
185
|
<svg
|
|
184
186
|
className={cn(
|
|
@@ -100,7 +100,7 @@ export function GiftCardSection({
|
|
|
100
100
|
<button
|
|
101
101
|
type="button"
|
|
102
102
|
onClick={() => setOpen(!open)}
|
|
103
|
-
className="
|
|
103
|
+
className="grid grid-cols-[2.5rem_minmax(0,1fr)_auto] items-center gap-3 w-full p-4 rounded-xl border border-border bg-card hover:border-primary transition-colors"
|
|
104
104
|
>
|
|
105
105
|
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-primary to-primary/70 flex items-center justify-center flex-shrink-0">
|
|
106
106
|
{/* Gift icon */}
|
|
@@ -118,13 +118,15 @@ export function GiftCardSection({
|
|
|
118
118
|
/>
|
|
119
119
|
</svg>
|
|
120
120
|
</div>
|
|
121
|
-
<div className="
|
|
122
|
-
<p className="text-sm font-semibold text-foreground">
|
|
121
|
+
<div className="min-w-0 text-left">
|
|
122
|
+
<p className="text-sm font-semibold leading-tight text-foreground truncate">
|
|
123
123
|
{labels.giftCard}
|
|
124
124
|
</p>
|
|
125
|
-
<p className="text-xs text-muted-foreground">
|
|
125
|
+
<p className="hidden min-[380px]:block text-xs text-muted-foreground truncate">
|
|
126
|
+
{labels.giftCardSub}
|
|
127
|
+
</p>
|
|
126
128
|
</div>
|
|
127
|
-
<span className="text-sm font-medium text-foreground flex items-center gap-1.5">
|
|
129
|
+
<span className="shrink-0 whitespace-nowrap text-sm font-medium text-foreground flex items-center gap-1.5">
|
|
128
130
|
{labels.addCode}
|
|
129
131
|
<svg
|
|
130
132
|
className={cn(
|
|
@@ -146,19 +148,19 @@ export function GiftCardSection({
|
|
|
146
148
|
</button>
|
|
147
149
|
|
|
148
150
|
{open && (
|
|
149
|
-
<div className="mt-3
|
|
151
|
+
<div className="mt-3 grid grid-cols-[minmax(0,1fr)_auto] gap-2">
|
|
150
152
|
<input
|
|
151
153
|
type="text"
|
|
152
154
|
value={code}
|
|
153
155
|
onChange={(e) => setCode(e.target.value.toUpperCase())}
|
|
154
156
|
placeholder={labels.giftCardPlaceholder}
|
|
155
|
-
className="
|
|
157
|
+
className="w-full min-w-0 h-11 px-4 text-sm bg-card border border-border rounded-xl focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20 transition-all"
|
|
156
158
|
/>
|
|
157
159
|
<button
|
|
158
160
|
type="button"
|
|
159
161
|
onClick={handleApply}
|
|
160
162
|
disabled={loading || !code.trim()}
|
|
161
|
-
className="px-
|
|
163
|
+
className="px-4 h-11 text-sm font-semibold bg-foreground text-card rounded-xl hover:bg-foreground/90 transition-all disabled:opacity-40"
|
|
162
164
|
>
|
|
163
165
|
{labels.addCode}
|
|
164
166
|
</button>
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serialize a rapidly changing preference while coalescing clicks that have
|
|
3
|
+
* not started yet. The write in flight finishes; then only the newest queued
|
|
4
|
+
* value is written. This guarantees that the final durable value is the last
|
|
5
|
+
* user intent even when network responses complete unpredictably.
|
|
6
|
+
*/
|
|
7
|
+
export function createLatestWriteQueue<T>() {
|
|
8
|
+
let queued: { value: T; write: (value: T) => Promise<unknown> } | null = null
|
|
9
|
+
let running = false
|
|
10
|
+
|
|
11
|
+
const drain = async () => {
|
|
12
|
+
if (running) return
|
|
13
|
+
running = true
|
|
14
|
+
try {
|
|
15
|
+
while (queued) {
|
|
16
|
+
const next = queued
|
|
17
|
+
queued = null
|
|
18
|
+
try {
|
|
19
|
+
await next.write(next.value)
|
|
20
|
+
} catch {
|
|
21
|
+
// Persistence is best effort. A newer queued value must still run,
|
|
22
|
+
// and checkout completion independently validates the final choice.
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
} finally {
|
|
26
|
+
running = false
|
|
27
|
+
// A push cannot interleave with synchronous code above, but keeping the
|
|
28
|
+
// final check makes the queue safe if its implementation later changes.
|
|
29
|
+
if (queued) void drain()
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
push(value: T, write: (value: T) => Promise<unknown>) {
|
|
35
|
+
queued = { value, write }
|
|
36
|
+
void drain()
|
|
37
|
+
},
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
424
|
-
<
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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">
|
|
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="
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
@@ -146,11 +146,11 @@ export function PickupPointSelector({
|
|
|
146
146
|
<button
|
|
147
147
|
key={`${point.provider}-${point.id}`}
|
|
148
148
|
type="button"
|
|
149
|
-
onClick={() => choose(point)}
|
|
150
|
-
className={cn(
|
|
151
|
-
"flex items-start gap-3 w-full px-3.5 py-3 text-left transition-
|
|
152
|
-
"bg-card hover:
|
|
153
|
-
)}
|
|
149
|
+
onClick={() => choose(point)}
|
|
150
|
+
className={cn(
|
|
151
|
+
"flex items-start gap-3 w-full px-3.5 py-3 text-left transition-colors duration-150 rounded-lg border border-transparent",
|
|
152
|
+
"bg-card hover:border-primary"
|
|
153
|
+
)}
|
|
154
154
|
>
|
|
155
155
|
<div className="w-8 h-8 rounded-full bg-muted flex items-center justify-center flex-shrink-0 mt-0.5">
|
|
156
156
|
<svg
|
|
@@ -175,12 +175,13 @@ export function PickupPointSelector({
|
|
|
175
175
|
</div>
|
|
176
176
|
|
|
177
177
|
<div className="flex-1 min-w-0">
|
|
178
|
-
<p className="text-sm font-medium text-foreground leading-tight">
|
|
179
|
-
{point.name}
|
|
180
|
-
</p>
|
|
181
|
-
<p className="text-xs text-muted-foreground mt-0.5">
|
|
182
|
-
{
|
|
183
|
-
|
|
178
|
+
<p className="text-sm font-medium text-foreground leading-tight uppercase">
|
|
179
|
+
{point.name}
|
|
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>
|
|
184
185
|
</div>
|
|
185
186
|
|
|
186
187
|
{distance !== null && distance > 0 && (
|
|
@@ -222,7 +223,7 @@ export function PickupPointSelector({
|
|
|
222
223
|
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
223
224
|
/>
|
|
224
225
|
</svg>
|
|
225
|
-
<p className="text-sm font-medium text-primary">{selectedPoint.name}</p>
|
|
226
|
+
<p className="text-sm font-medium text-primary uppercase">{selectedPoint.name}</p>
|
|
226
227
|
<button
|
|
227
228
|
type="button"
|
|
228
229
|
onClick={() => choose(null)}
|
|
@@ -195,10 +195,10 @@ export function CheckoutShippingMethodList({
|
|
|
195
195
|
<div
|
|
196
196
|
key={option.id}
|
|
197
197
|
className={cn(
|
|
198
|
-
"rounded-lg border overflow-hidden transition-colors duration-150",
|
|
199
|
-
selected
|
|
200
|
-
? "border-primary bg-primary/5"
|
|
201
|
-
: "border-border bg-card hover:border-
|
|
198
|
+
"rounded-lg border overflow-hidden transition-colors duration-150",
|
|
199
|
+
selected
|
|
200
|
+
? "border-primary bg-primary/5"
|
|
201
|
+
: "border-border bg-card hover:border-primary"
|
|
202
202
|
)}
|
|
203
203
|
>
|
|
204
204
|
<button
|
|
@@ -235,13 +235,13 @@ export function CheckoutShippingMethodList({
|
|
|
235
235
|
</span>
|
|
236
236
|
)}
|
|
237
237
|
<div className="flex-1 min-w-0">
|
|
238
|
-
<span className="text-sm font-medium text-foreground">
|
|
239
|
-
{option.name}
|
|
240
|
-
</span>
|
|
238
|
+
<span className="text-sm font-medium text-foreground uppercase">
|
|
239
|
+
{option.name}
|
|
240
|
+
</span>
|
|
241
241
|
{selected && isPickup && pickup?.selectedPoint && (
|
|
242
|
-
<p className="text-xs text-muted-foreground mt-0.5">
|
|
243
|
-
{pickup.selectedPoint.name}
|
|
244
|
-
</p>
|
|
242
|
+
<p className="text-xs text-muted-foreground mt-0.5 uppercase">
|
|
243
|
+
{pickup.selectedPoint.name}
|
|
244
|
+
</p>
|
|
245
245
|
)}
|
|
246
246
|
</div>
|
|
247
247
|
<span
|
|
@@ -25,8 +25,9 @@ import {
|
|
|
25
25
|
prepareCheckout,
|
|
26
26
|
refreshPaymentIfTerminal as refreshPaymentIfTerminalApi,
|
|
27
27
|
reportCheckoutError,
|
|
28
|
-
syncPaymentAmount as syncPaymentAmountApi,
|
|
29
|
-
isMethodEntry,
|
|
28
|
+
syncPaymentAmount as syncPaymentAmountApi,
|
|
29
|
+
isMethodEntry,
|
|
30
|
+
listPaymentProviders,
|
|
30
31
|
type PrepareCheckoutInput,
|
|
31
32
|
type RefreshPaymentResult,
|
|
32
33
|
type StorePaymentEntry,
|
|
@@ -54,7 +55,8 @@ import { useCheckoutLabels, useOrderConfirmedPath } from "./context"
|
|
|
54
55
|
import { fulfillmentOptionId } from "./fulfillment-option"
|
|
55
56
|
import { useCheckoutFunnel } from "./use-checkout-funnel"
|
|
56
57
|
import { amountDueAfterTender, isNothingToPay } from "./summary-math"
|
|
57
|
-
import { forgetFiredEvents } from "../tracking/once"
|
|
58
|
+
import { forgetFiredEvents } from "../tracking/once"
|
|
59
|
+
import { createLatestWriteQueue } from "./latest-write-queue"
|
|
58
60
|
|
|
59
61
|
/**
|
|
60
62
|
* useCheckoutOrchestration — single source of truth for checkout-page
|
|
@@ -122,8 +124,13 @@ export type UseCheckoutOrchestrationOptions = {
|
|
|
122
124
|
client: StorefrontClient
|
|
123
125
|
cart: Cart
|
|
124
126
|
customer: StoreCustomer | null
|
|
125
|
-
availableShippingMethods: StoreShippingOption[] | null
|
|
126
|
-
availablePaymentMethods: PaymentProviderLike[] | null
|
|
127
|
+
availableShippingMethods: StoreShippingOption[] | null
|
|
128
|
+
availablePaymentMethods: PaymentProviderLike[] | null
|
|
129
|
+
/**
|
|
130
|
+
* Server-evaluated payment availability keyed by delivery option. When
|
|
131
|
+
* supplied, a shipping click changes the tender list synchronously.
|
|
132
|
+
*/
|
|
133
|
+
paymentMethodsByShippingOption?: Record<string, PaymentProviderLike[]>
|
|
127
134
|
/** Default country code when the cart has no shipping address yet. */
|
|
128
135
|
countryCode?: string
|
|
129
136
|
/**
|
|
@@ -287,8 +294,9 @@ export function useCheckoutOrchestration({
|
|
|
287
294
|
client,
|
|
288
295
|
cart,
|
|
289
296
|
customer,
|
|
290
|
-
availableShippingMethods,
|
|
291
|
-
availablePaymentMethods,
|
|
297
|
+
availableShippingMethods,
|
|
298
|
+
availablePaymentMethods,
|
|
299
|
+
paymentMethodsByShippingOption,
|
|
292
300
|
countryCode = "",
|
|
293
301
|
countries,
|
|
294
302
|
paymentMethodFilter,
|
|
@@ -868,18 +876,49 @@ export function useCheckoutOrchestration({
|
|
|
868
876
|
[availableShippingMethods]
|
|
869
877
|
)
|
|
870
878
|
|
|
871
|
-
const selectedShippingOption = useMemo(
|
|
879
|
+
const selectedShippingOption = useMemo(
|
|
872
880
|
() =>
|
|
873
881
|
shippingMethods.find((sm) => sm.id === selectedShippingMethod) ?? null,
|
|
874
882
|
[shippingMethods, selectedShippingMethod]
|
|
875
|
-
)
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
+
)
|
|
884
|
+
|
|
885
|
+
// Payment rules can depend on the selected delivery option. The host's
|
|
886
|
+
// initial list is server-rendered; after a delivery choice is remembered
|
|
887
|
+
// on the cart, ask the same server-side rules door for the new list. A
|
|
888
|
+
// sequence guard prevents a slower earlier choice from winning a rapid
|
|
889
|
+
// switch between carriers.
|
|
890
|
+
const [serverAvailablePaymentMethods, setServerAvailablePaymentMethods] =
|
|
891
|
+
useState<PaymentProviderLike[] | null>(availablePaymentMethods)
|
|
892
|
+
const paymentListingRequest = useRef(0)
|
|
893
|
+
useEffect(() => {
|
|
894
|
+
const mapped = selectedShippingMethod
|
|
895
|
+
? paymentMethodsByShippingOption?.[selectedShippingMethod]
|
|
896
|
+
: undefined
|
|
897
|
+
setServerAvailablePaymentMethods(mapped ?? availablePaymentMethods)
|
|
898
|
+
}, [availablePaymentMethods, paymentMethodsByShippingOption, selectedShippingMethod])
|
|
899
|
+
const refreshPaymentMethods = useCallback(async (shippingOptionId: string) => {
|
|
900
|
+
const request = ++paymentListingRequest.current
|
|
901
|
+
try {
|
|
902
|
+
const result = await listPaymentProviders(client, {
|
|
903
|
+
cart_id: cart.id,
|
|
904
|
+
region_id: cart.region_id ?? undefined,
|
|
905
|
+
shipping_option_id: shippingOptionId,
|
|
906
|
+
})
|
|
907
|
+
if (request === paymentListingRequest.current) {
|
|
908
|
+
setServerAvailablePaymentMethods(result.payment_providers)
|
|
909
|
+
}
|
|
910
|
+
} catch {
|
|
911
|
+
// Keep the last good listing. Completion re-checks the same rule on
|
|
912
|
+
// the server, so a transient refresh failure cannot bypass it.
|
|
913
|
+
}
|
|
914
|
+
}, [client, cart.id, cart.region_id])
|
|
915
|
+
|
|
916
|
+
const effectiveAvailablePaymentMethods = useMemo(
|
|
917
|
+
() =>
|
|
918
|
+
paymentMethodFilter
|
|
919
|
+
? paymentMethodFilter(serverAvailablePaymentMethods, selectedShippingOption)
|
|
920
|
+
: serverAvailablePaymentMethods,
|
|
921
|
+
[paymentMethodFilter, serverAvailablePaymentMethods, selectedShippingOption]
|
|
883
922
|
)
|
|
884
923
|
|
|
885
924
|
// Calculated-rate price resolution. Cartbase serves flat prices today
|
|
@@ -977,9 +1016,37 @@ export function useCheckoutOrchestration({
|
|
|
977
1016
|
// The offline tab = a merchant METHOD (the COD switch, else the first
|
|
978
1017
|
// manual method) — sessions initiate by payment_method_id, provider NULL.
|
|
979
1018
|
const offlineMethod = findOfflineMethod(effectiveAvailablePaymentMethods)
|
|
980
|
-
const hasCod = !!offlineMethod
|
|
981
|
-
const codMethodId = offlineMethod?.payment_method_id
|
|
982
|
-
const offlineIsCodKind = offlineMethod?.kind === "cod"
|
|
1019
|
+
const hasCod = !!offlineMethod
|
|
1020
|
+
const codMethodId = offlineMethod?.payment_method_id
|
|
1021
|
+
const offlineIsCodKind = offlineMethod?.kind === "cod"
|
|
1022
|
+
|
|
1023
|
+
// Preserve the platform listing order all the way into the checkout UI.
|
|
1024
|
+
// The API has already applied checkout_method_order; collapsing the rows
|
|
1025
|
+
// into card/COD booleans used to throw that order away.
|
|
1026
|
+
const paymentMethodOrder = useMemo(() => {
|
|
1027
|
+
const order: Array<"card" | "cod"> = []
|
|
1028
|
+
for (const entry of effectiveAvailablePaymentMethods ?? []) {
|
|
1029
|
+
if (
|
|
1030
|
+
hasCard &&
|
|
1031
|
+
"id" in entry &&
|
|
1032
|
+
entry.id === cardEntry?.id &&
|
|
1033
|
+
!order.includes("card")
|
|
1034
|
+
) {
|
|
1035
|
+
order.push("card")
|
|
1036
|
+
}
|
|
1037
|
+
if (
|
|
1038
|
+
hasCod &&
|
|
1039
|
+
"payment_method_id" in entry &&
|
|
1040
|
+
entry.payment_method_id === codMethodId &&
|
|
1041
|
+
!order.includes("cod")
|
|
1042
|
+
) {
|
|
1043
|
+
order.push("cod")
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
if (hasCard && !order.includes("card")) order.push("card")
|
|
1047
|
+
if (hasCod && !order.includes("cod")) order.push("cod")
|
|
1048
|
+
return order
|
|
1049
|
+
}, [effectiveAvailablePaymentMethods, hasCard, cardEntry?.id, hasCod, codMethodId])
|
|
983
1050
|
|
|
984
1051
|
// The store offers a card it cannot mount: reported once, to the merchant,
|
|
985
1052
|
// because the shopper must never be the one who discovers it. Pre-2026-09-18
|
|
@@ -996,12 +1063,12 @@ export function useCheckoutOrchestration({
|
|
|
996
1063
|
)
|
|
997
1064
|
}, [cardEntry, cardOffer.mountable, logError])
|
|
998
1065
|
|
|
999
|
-
// Default tab:
|
|
1000
|
-
// used to seed from the cart's pending session provider; in the
|
|
1001
|
-
// deferred-intent model there is no session at mount.
|
|
1002
|
-
const [paymentTab, setPaymentTab] = useState<"card" | "cod">(
|
|
1003
|
-
hasCard ? "card" : "cod"
|
|
1004
|
-
)
|
|
1066
|
+
// Default tab: the first method in the merchant's checkout order. The eager-session model
|
|
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
|
+
paymentMethodOrder[0] ?? (hasCard ? "card" : "cod")
|
|
1071
|
+
)
|
|
1005
1072
|
|
|
1006
1073
|
// Optimistic method-fee state. Painted instantly on tab toggle so the
|
|
1007
1074
|
// totals row shows the predicted fee BEFORE the server applies it (at
|
|
@@ -1011,10 +1078,10 @@ export function useCheckoutOrchestration({
|
|
|
1011
1078
|
// - null → no prediction; render whatever the cart says
|
|
1012
1079
|
// - 0 → predict no fee (toggling to a fee-less tender)
|
|
1013
1080
|
// - positive → predict the fee at this amount
|
|
1014
|
-
const [optimisticMethodFee, setOptimisticMethodFee] = useState<number | null>(
|
|
1015
|
-
null
|
|
1016
|
-
)
|
|
1017
|
-
|
|
1081
|
+
const [optimisticMethodFee, setOptimisticMethodFee] = useState<number | null>(
|
|
1082
|
+
null
|
|
1083
|
+
)
|
|
1084
|
+
|
|
1018
1085
|
// Payment tab selection is client state only pre-Buy. No payment
|
|
1019
1086
|
// session is created until Buy click — this eliminates the entire
|
|
1020
1087
|
// class of session-rotation / amount-drift / iframe-remount bugs
|
|
@@ -1068,15 +1135,28 @@ export function useCheckoutOrchestration({
|
|
|
1068
1135
|
* are the checkout's own notes to itself, and prepare-checkout overwrites
|
|
1069
1136
|
* the real ones from them.
|
|
1070
1137
|
*/
|
|
1071
|
-
const rememberChoice = useCallback(
|
|
1072
|
-
(patch: Record<string, unknown>) => {
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1138
|
+
const rememberChoice = useCallback(
|
|
1139
|
+
(patch: Record<string, unknown>) => {
|
|
1140
|
+
return updateCart(client, cart.id, { metadata: patch })
|
|
1141
|
+
.then(() => true)
|
|
1142
|
+
.catch(() => {
|
|
1143
|
+
// Best effort: forgetting a choice on reload is a worse checkout,
|
|
1144
|
+
// never a broken one, so a failure here must not reach the shopper.
|
|
1145
|
+
return false
|
|
1146
|
+
})
|
|
1077
1147
|
},
|
|
1078
|
-
[client, cart.id]
|
|
1079
|
-
)
|
|
1148
|
+
[client, cart.id]
|
|
1149
|
+
)
|
|
1150
|
+
const choiceWrites = useMemo(
|
|
1151
|
+
() => createLatestWriteQueue<Record<string, unknown>>(),
|
|
1152
|
+
[]
|
|
1153
|
+
)
|
|
1154
|
+
const enqueueChoiceWrite = useCallback(
|
|
1155
|
+
(patch: Record<string, unknown>) => {
|
|
1156
|
+
choiceWrites.push(patch, rememberChoice)
|
|
1157
|
+
},
|
|
1158
|
+
[choiceWrites, rememberChoice]
|
|
1159
|
+
)
|
|
1080
1160
|
|
|
1081
1161
|
// Shipping selection stays client state for the ORDER (no
|
|
1082
1162
|
// addShippingMethod call, no metadata-clear updateCart: those wrote to the
|
|
@@ -1085,9 +1165,23 @@ export function useCheckoutOrchestration({
|
|
|
1085
1165
|
// CHOICE, under our own key, so a reload restores it.
|
|
1086
1166
|
const handleSelectShipping = useCallback(
|
|
1087
1167
|
(id: string) => {
|
|
1088
|
-
setShippingError(null)
|
|
1089
|
-
setSelectedShippingMethod(id)
|
|
1090
|
-
|
|
1168
|
+
setShippingError(null)
|
|
1169
|
+
setSelectedShippingMethod(id)
|
|
1170
|
+
|
|
1171
|
+
// The availability map came from the server with the checkout's first
|
|
1172
|
+
// read. Selecting one of its answers is immediate and deterministic;
|
|
1173
|
+
// no browser-side rule is being guessed. Older refreshes are invalidated
|
|
1174
|
+
// before they can repaint a newer click.
|
|
1175
|
+
const mappedPaymentMethods = paymentMethodsByShippingOption?.[id]
|
|
1176
|
+
if (mappedPaymentMethods) {
|
|
1177
|
+
paymentListingRequest.current += 1
|
|
1178
|
+
setServerAvailablePaymentMethods(mappedPaymentMethods)
|
|
1179
|
+
} else {
|
|
1180
|
+
// Backward compatibility for hosts/platforms without the map: ask the
|
|
1181
|
+
// rules endpoint about this prospective option immediately, without
|
|
1182
|
+
// waiting for the preference write to make a round trip first.
|
|
1183
|
+
void refreshPaymentMethods(id)
|
|
1184
|
+
}
|
|
1091
1185
|
|
|
1092
1186
|
// Optimistic shipping cost — paint the totals row immediately so
|
|
1093
1187
|
// the customer sees the right number before any network call.
|
|
@@ -1107,14 +1201,20 @@ export function useCheckoutOrchestration({
|
|
|
1107
1201
|
// our own note is cleared beside the state.
|
|
1108
1202
|
setSelectedPickupPoint(null)
|
|
1109
1203
|
setPickupKeys(null)
|
|
1110
|
-
|
|
1111
|
-
_checkout_shipping_option: id,
|
|
1112
|
-
_checkout_pickup_point: null,
|
|
1113
|
-
_checkout_pickup_keys: null,
|
|
1114
|
-
})
|
|
1115
|
-
},
|
|
1116
|
-
[
|
|
1117
|
-
|
|
1204
|
+
enqueueChoiceWrite({
|
|
1205
|
+
_checkout_shipping_option: id,
|
|
1206
|
+
_checkout_pickup_point: null,
|
|
1207
|
+
_checkout_pickup_keys: null,
|
|
1208
|
+
})
|
|
1209
|
+
},
|
|
1210
|
+
[
|
|
1211
|
+
shippingMethods,
|
|
1212
|
+
calculatedPricesMap,
|
|
1213
|
+
paymentMethodsByShippingOption,
|
|
1214
|
+
refreshPaymentMethods,
|
|
1215
|
+
enqueueChoiceWrite,
|
|
1216
|
+
]
|
|
1217
|
+
)
|
|
1118
1218
|
|
|
1119
1219
|
/**
|
|
1120
1220
|
* The chosen pickup point, remembered the moment it is chosen. The keys
|
|
@@ -1126,13 +1226,13 @@ export function useCheckoutOrchestration({
|
|
|
1126
1226
|
(point: PickupPoint | null, keys: PickupPointKeys | null) => {
|
|
1127
1227
|
setSelectedPickupPoint(point)
|
|
1128
1228
|
setPickupKeys(keys)
|
|
1129
|
-
|
|
1130
|
-
_checkout_pickup_point: point,
|
|
1131
|
-
_checkout_pickup_keys: keys,
|
|
1132
|
-
})
|
|
1133
|
-
},
|
|
1134
|
-
[
|
|
1135
|
-
)
|
|
1229
|
+
enqueueChoiceWrite({
|
|
1230
|
+
_checkout_pickup_point: point,
|
|
1231
|
+
_checkout_pickup_keys: keys,
|
|
1232
|
+
})
|
|
1233
|
+
},
|
|
1234
|
+
[enqueueChoiceWrite]
|
|
1235
|
+
)
|
|
1136
1236
|
|
|
1137
1237
|
// ── Delivery readiness ──────────────────────────────────────────────
|
|
1138
1238
|
const selectedFulfillmentOptionId = useMemo(() => {
|
|
@@ -1169,19 +1269,18 @@ export function useCheckoutOrchestration({
|
|
|
1169
1269
|
(cart?.shipping_methods?.length ?? 0) > 0) &&
|
|
1170
1270
|
pickupSatisfied
|
|
1171
1271
|
|
|
1172
|
-
// Reconcile paymentTab with currently-available methods.
|
|
1173
|
-
//
|
|
1174
|
-
//
|
|
1175
|
-
//
|
|
1176
|
-
//
|
|
1177
|
-
useEffect(() => {
|
|
1178
|
-
if (!
|
|
1179
|
-
if (paymentTab === "cod" && !hasCod && hasCard) {
|
|
1272
|
+
// Reconcile paymentTab with currently-available methods. The server map
|
|
1273
|
+
// changes the rendered list synchronously on a shipping click; this effect
|
|
1274
|
+
// then moves selection and fee state onto the first valid tender. It does
|
|
1275
|
+
// not wait for delivery readiness: a locker may still need its point, but
|
|
1276
|
+
// its payment rule is already true.
|
|
1277
|
+
useEffect(() => {
|
|
1278
|
+
if (paymentTab === "cod" && !hasCod && hasCard) {
|
|
1180
1279
|
handlePaymentTab("card")
|
|
1181
1280
|
} else if (paymentTab === "card" && !hasCard && hasCod) {
|
|
1182
1281
|
handlePaymentTab("cod")
|
|
1183
1282
|
}
|
|
1184
|
-
}, [
|
|
1283
|
+
}, [paymentTab, hasCard, hasCod, handlePaymentTab])
|
|
1185
1284
|
|
|
1186
1285
|
const handlePaymentElementChange = useCallback(
|
|
1187
1286
|
(_e: { complete: boolean; selectedMethod: string | null }) => {
|
|
@@ -1789,8 +1888,9 @@ export function useCheckoutOrchestration({
|
|
|
1789
1888
|
selectedPickup,
|
|
1790
1889
|
|
|
1791
1890
|
// Payment
|
|
1792
|
-
paymentTab,
|
|
1793
|
-
|
|
1891
|
+
paymentTab,
|
|
1892
|
+
paymentMethodOrder,
|
|
1893
|
+
hasCard,
|
|
1794
1894
|
hasCod,
|
|
1795
1895
|
cardId,
|
|
1796
1896
|
/** The store's own Stripe, off the card entry — what `PaymentWrapper`
|
package/src/locales/bg.ts
CHANGED
|
@@ -182,12 +182,12 @@ export const bg: StorefrontLocale = {
|
|
|
182
182
|
remove: "Премахни",
|
|
183
183
|
secureCheckout: "Защитено плащане",
|
|
184
184
|
|
|
185
|
-
giftCard: "
|
|
186
|
-
giftCardSub: "
|
|
185
|
+
giftCard: "Подаръчен ваучер",
|
|
186
|
+
giftCardSub: "Въведете код за ваучер",
|
|
187
187
|
giftCardPlaceholder: "GIFT-XXXX-XXXX",
|
|
188
|
-
giftCardApplied: "
|
|
188
|
+
giftCardApplied: "Подаръчни ваучери",
|
|
189
189
|
giftCardRemaining: "Остава за плащане",
|
|
190
|
-
giftCardEnding: "
|
|
190
|
+
giftCardEnding: "Ваучер, завършващ на",
|
|
191
191
|
|
|
192
192
|
companyInvoice: "Фактура за фирма",
|
|
193
193
|
companyName: "Име на фирма",
|