@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
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.
|
|
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": {
|
package/src/api/checkout.ts
CHANGED
|
@@ -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. */
|
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
|
|
@@ -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
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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
|
|
42
|
-
|
|
43
|
-
):
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
284
|
-
|
|
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
|
package/src/checkout/index.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
@@ -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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
{
|
|
180
|
-
|
|
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 && (
|