@cartbase/storefront 0.21.0 → 0.22.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/package.json +14 -1
- package/src/api/checkout.ts +15 -0
- package/src/api/http.ts +9 -0
- package/src/api/integrations.ts +121 -0
- package/src/checkout/card-offer.ts +68 -0
- package/src/checkout/carrier-marks.ts +53 -0
- package/src/checkout/checkout-client.tsx +71 -13
- package/src/checkout/checkout-error-screen.tsx +113 -0
- package/src/checkout/discount-section.tsx +88 -56
- package/src/checkout/fulfillment-option.ts +30 -0
- package/src/checkout/index.ts +41 -6
- package/src/checkout/labels.ts +98 -0
- package/src/checkout/line-item-card.tsx +35 -83
- package/src/checkout/mobile-checkout-bottom-bar.tsx +132 -0
- package/src/checkout/mobile-checkout-top-bar.tsx +94 -0
- package/src/checkout/mobile-order-summary-body.tsx +172 -0
- package/src/checkout/order-summary.tsx +85 -179
- package/src/checkout/payment-button.tsx +25 -8
- package/src/checkout/payment-method-list.tsx +51 -13
- package/src/checkout/payment-wrapper.tsx +57 -13
- package/src/checkout/pickup-option.ts +35 -0
- package/src/checkout/pickup-point-selector.tsx +372 -0
- package/src/checkout/pigeon-office-selector.tsx +379 -0
- package/src/checkout/shipping-method-list.tsx +102 -10
- package/src/checkout/summary-math.ts +152 -0
- package/src/checkout/use-checkout-funnel.ts +303 -0
- package/src/checkout/use-checkout-orchestration.ts +384 -30
- package/src/lib/stripe-env.ts +25 -0
- package/src/locales/bg.ts +37 -1
- package/src/locales/es.ts +35 -1
- package/src/tracking/attribution.ts +83 -0
- package/src/tracking/consent.ts +53 -0
- package/src/tracking/events.ts +113 -0
- package/src/tracking/fbq.ts +48 -0
- package/src/tracking/gtag.ts +32 -0
- package/src/tracking/index.ts +32 -1
- package/src/tracking/once.ts +137 -0
- package/src/tracking/rybbit-events.ts +42 -0
- package/src/tracking/ttq.ts +24 -0
- package/src/tracking/types.ts +34 -0
|
@@ -1,16 +1,15 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
|
-
import { useEffect
|
|
3
|
+
import { useEffect } from "react"
|
|
4
4
|
|
|
5
5
|
import type { StorefrontClient } from "../api/http"
|
|
6
|
-
import {
|
|
6
|
+
import type { Cart, CartLineItem } from "../api/carts"
|
|
7
7
|
import { Price } from "../lib/price"
|
|
8
|
-
import { isProductLine } from "../lib/cart-helpers"
|
|
9
|
-
import { cn } from "../lib/utils"
|
|
10
8
|
import { variantCaption } from "../lib/variant-caption"
|
|
11
9
|
import { useCheckoutLabels } from "./context"
|
|
12
10
|
import { DiscountSection } from "./discount-section"
|
|
13
11
|
import { GiftCardSection } from "./gift-card-section"
|
|
12
|
+
import { checkoutTotals, isCompanyOrder } from "./summary-math"
|
|
14
13
|
|
|
15
14
|
/**
|
|
16
15
|
* OrderSummary — the right column of the checkout: scrollable list of
|
|
@@ -31,8 +30,10 @@ import { GiftCardSection } from "./gift-card-section"
|
|
|
31
30
|
* UNDER the (unchanged) total — tender never moves totals
|
|
32
31
|
* (docs/storefront/gift-cards.md). `GiftCardSection` mounts next to
|
|
33
32
|
* `DiscountSection`.
|
|
34
|
-
* - Line rows
|
|
35
|
-
*
|
|
33
|
+
* - **Line rows do not write.** The summary states the order; it cannot
|
|
34
|
+
* change it (Alexander, 2026-09-18: a checkout is a checkout). The promo
|
|
35
|
+
* and gift-card doors stay, because a code is part of paying, and each
|
|
36
|
+
* still hands the decorated cart to `onCartChange`.
|
|
36
37
|
* - The flat `CheckoutLineItem` row (source: checkout-line-item.tsx,
|
|
37
38
|
* outside this batch's pre-declared file list) is ported INTO this file
|
|
38
39
|
* as the summary's row renderer and re-exported from the family barrel.
|
|
@@ -47,97 +48,35 @@ import { GiftCardSection } from "./gift-card-section"
|
|
|
47
48
|
* LineItemCard (which is itself a card), this component is borderless:
|
|
48
49
|
* rows sit on the parent card's surface with parent-provided dividers.
|
|
49
50
|
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
51
|
+
* **A CHECKOUT IS A CHECKOUT: the order cannot be edited from inside it**
|
|
52
|
+
* (Alexander, 2026-09-18). The row carried a quantity stepper, ported from
|
|
53
|
+
* the platform this kit came from, and it is gone: the cart is changed in
|
|
54
|
+
* the cart, and this column states what is being bought. A shopper who
|
|
55
|
+
* wants a different quantity goes back to the cart and returns, with
|
|
56
|
+
* everything they typed still in the form.
|
|
53
57
|
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
+
* The quantity stays VISIBLE, as a count on the picture rather than a
|
|
59
|
+
* control, because a shopper about to pay must see what they are paying
|
|
60
|
+
* for. Nothing here calls the cart's write endpoints any more.
|
|
61
|
+
*
|
|
62
|
+
* Ported from `@1click/ui/src/checkout/checkout-line-item.tsx` (v2.3.1).
|
|
63
|
+
* `variant.title` → the flat `variant_title` column.
|
|
58
64
|
*/
|
|
59
65
|
export function CheckoutLineItem({
|
|
60
|
-
client,
|
|
61
|
-
cartId,
|
|
62
66
|
item,
|
|
63
67
|
currencyCode,
|
|
64
|
-
onCartChange,
|
|
65
68
|
}: {
|
|
66
|
-
client: StorefrontClient
|
|
67
|
-
cartId: string
|
|
68
69
|
item: CartLineItem
|
|
69
70
|
currencyCode: string
|
|
71
|
+
/** @deprecated Unused: the checkout summary no longer writes. Accepted so
|
|
72
|
+
* a store passing the old props keeps compiling; remove them at leisure. */
|
|
73
|
+
client?: StorefrontClient
|
|
74
|
+
/** @deprecated Unused — see `client`. */
|
|
75
|
+
cartId?: string
|
|
76
|
+
/** @deprecated Unused — see `client`. */
|
|
70
77
|
onCartChange?: (cart: Cart) => void
|
|
71
78
|
}) {
|
|
72
79
|
const labels = useCheckoutLabels()
|
|
73
|
-
const [updating, setUpdating] = useState(false)
|
|
74
|
-
|
|
75
|
-
const handleQty = async (qty: number) => {
|
|
76
|
-
if (qty < 1) return
|
|
77
|
-
setUpdating(true)
|
|
78
|
-
try {
|
|
79
|
-
const { cart } = await updateLineItem(client, cartId, item.id, {
|
|
80
|
-
quantity: qty,
|
|
81
|
-
})
|
|
82
|
-
onCartChange?.(cart)
|
|
83
|
-
} catch {
|
|
84
|
-
// e.g. insufficient_inventory — the row snaps back to server truth
|
|
85
|
-
// on the next cart render.
|
|
86
|
-
} finally {
|
|
87
|
-
setUpdating(false)
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
// `self-start`: in the desktop column the pill is a flex item, and a flex
|
|
92
|
-
// item stretches to the column's width unless told otherwise. Without it
|
|
93
|
-
// the border ran the full width of the row with the controls huddled at
|
|
94
|
-
// the left (evoo checkout, 2026-09-14).
|
|
95
|
-
const qtyPill = (
|
|
96
|
-
<div className="inline-flex self-start items-center rounded-[2px] border border-border bg-card">
|
|
97
|
-
<button
|
|
98
|
-
type="button"
|
|
99
|
-
onClick={() => handleQty(item.quantity - 1)}
|
|
100
|
-
disabled={item.quantity <= 1 || updating}
|
|
101
|
-
aria-label={labels.qty}
|
|
102
|
-
className="w-8 h-8 flex items-center justify-center text-foreground hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"
|
|
103
|
-
>
|
|
104
|
-
<svg
|
|
105
|
-
className="w-3.5 h-3.5"
|
|
106
|
-
fill="none"
|
|
107
|
-
viewBox="0 0 24 24"
|
|
108
|
-
stroke="currentColor"
|
|
109
|
-
strokeWidth={2.25}
|
|
110
|
-
>
|
|
111
|
-
<path strokeLinecap="round" strokeLinejoin="round" d="M5 12h14" />
|
|
112
|
-
</svg>
|
|
113
|
-
</button>
|
|
114
|
-
<span className="min-w-[1.75rem] px-1 text-center text-xs font-semibold tabular-nums">
|
|
115
|
-
{item.quantity}
|
|
116
|
-
</span>
|
|
117
|
-
<button
|
|
118
|
-
type="button"
|
|
119
|
-
onClick={() => handleQty(item.quantity + 1)}
|
|
120
|
-
disabled={updating}
|
|
121
|
-
aria-label={labels.qty}
|
|
122
|
-
className="w-8 h-8 flex items-center justify-center text-foreground hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed"
|
|
123
|
-
>
|
|
124
|
-
<svg
|
|
125
|
-
className="w-3.5 h-3.5"
|
|
126
|
-
fill="none"
|
|
127
|
-
viewBox="0 0 24 24"
|
|
128
|
-
stroke="currentColor"
|
|
129
|
-
strokeWidth={2.25}
|
|
130
|
-
>
|
|
131
|
-
<path
|
|
132
|
-
strokeLinecap="round"
|
|
133
|
-
strokeLinejoin="round"
|
|
134
|
-
d="M12 5v14m-7-7h14"
|
|
135
|
-
/>
|
|
136
|
-
</svg>
|
|
137
|
-
</button>
|
|
138
|
-
</div>
|
|
139
|
-
)
|
|
140
|
-
|
|
141
80
|
const caption = variantCaption(item.variant_title)
|
|
142
81
|
const titleBlock = (
|
|
143
82
|
<div className="min-w-0">
|
|
@@ -151,12 +90,7 @@ export function CheckoutLineItem({
|
|
|
151
90
|
)
|
|
152
91
|
|
|
153
92
|
return (
|
|
154
|
-
<div
|
|
155
|
-
className={cn(
|
|
156
|
-
"relative flex gap-4 transition-opacity",
|
|
157
|
-
updating && "opacity-40 pointer-events-none"
|
|
158
|
-
)}
|
|
159
|
-
>
|
|
93
|
+
<div className="relative flex gap-4">
|
|
160
94
|
<div className="relative w-16 h-16 rounded-xl overflow-hidden flex-shrink-0 bg-muted border border-border">
|
|
161
95
|
{item.thumbnail ? (
|
|
162
96
|
// eslint-disable-next-line @next/next/no-img-element
|
|
@@ -166,35 +100,35 @@ export function CheckoutLineItem({
|
|
|
166
100
|
className="w-full h-full object-cover"
|
|
167
101
|
/>
|
|
168
102
|
) : null}
|
|
103
|
+
{/* How many, on the picture: the count a locked summary states
|
|
104
|
+
instead of offering. Hidden at one, where it says nothing. */}
|
|
105
|
+
{item.quantity > 1 && (
|
|
106
|
+
<span
|
|
107
|
+
aria-label={labels.qty}
|
|
108
|
+
className="absolute -top-1.5 -right-1.5 min-w-[1.25rem] h-5 px-1 rounded-full bg-foreground text-card text-[11px] font-semibold flex items-center justify-center tabular-nums"
|
|
109
|
+
>
|
|
110
|
+
{item.quantity}
|
|
111
|
+
</span>
|
|
112
|
+
)}
|
|
169
113
|
</div>
|
|
170
114
|
|
|
171
|
-
|
|
172
|
-
row 1: title (left) · price right-aligned, baseline of title line 1
|
|
173
|
-
row 2: qty pill (left) */}
|
|
174
|
-
<div className="hidden sm:flex flex-1 min-w-0 flex-col gap-2.5">
|
|
175
|
-
<div className="flex items-baseline justify-between gap-3">
|
|
176
|
-
{titleBlock}
|
|
177
|
-
<Price
|
|
178
|
-
amount={item.total ?? 0}
|
|
179
|
-
currencyCode={currencyCode}
|
|
180
|
-
className="text-sm font-bold text-foreground text-right flex-shrink-0"
|
|
181
|
-
/>
|
|
182
|
-
</div>
|
|
183
|
-
{qtyPill}
|
|
184
|
-
</div>
|
|
185
|
-
|
|
186
|
-
{/* MOBILE layout — title takes full content width, qty+price share
|
|
187
|
-
the row underneath. */}
|
|
188
|
-
<div className="sm:hidden flex-1 min-w-0 flex flex-col gap-2">
|
|
115
|
+
<div className="flex flex-1 min-w-0 items-baseline justify-between gap-3">
|
|
189
116
|
{titleBlock}
|
|
190
|
-
|
|
191
|
-
|
|
117
|
+
{/* Never €0.00 on a line that costs nothing. A gift a promotion
|
|
118
|
+
added, or a line a discount took to zero, reads as a pricing
|
|
119
|
+
fault at the moment of paying, and the cart drawer already says
|
|
120
|
+
the word (Alenika's rule, ported 2026-09-18). */}
|
|
121
|
+
{(item.total ?? 0) <= 0 ? (
|
|
122
|
+
<span className="text-xs font-bold text-success text-right flex-shrink-0 uppercase">
|
|
123
|
+
{labels.lineFree}
|
|
124
|
+
</span>
|
|
125
|
+
) : (
|
|
192
126
|
<Price
|
|
193
127
|
amount={item.total ?? 0}
|
|
194
128
|
currencyCode={currencyCode}
|
|
195
|
-
className="text-sm font-bold text-foreground text-right"
|
|
129
|
+
className="text-sm font-bold text-foreground text-right flex-shrink-0"
|
|
196
130
|
/>
|
|
197
|
-
|
|
131
|
+
)}
|
|
198
132
|
</div>
|
|
199
133
|
</div>
|
|
200
134
|
)
|
|
@@ -258,58 +192,26 @@ export function OrderSummary({
|
|
|
258
192
|
}: OrderSummaryProps) {
|
|
259
193
|
const labels = useCheckoutLabels()
|
|
260
194
|
|
|
261
|
-
//
|
|
262
|
-
//
|
|
263
|
-
//
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
//
|
|
195
|
+
// Every printed number comes from `checkoutTotals`, the same function the
|
|
196
|
+
// two mobile bars call, so a shopper cannot be shown one total at the top
|
|
197
|
+
// of a phone and another one here (summary-math.ts). It filters to product
|
|
198
|
+
// rows, sorts newest first, applies the optimistic overrides and reads the
|
|
199
|
+
// method fee from the cart's own decoration.
|
|
200
|
+
const {
|
|
201
|
+
productItems,
|
|
202
|
+
itemCount,
|
|
203
|
+
productSubtotal,
|
|
204
|
+
shippingCost,
|
|
205
|
+
shippingKnown,
|
|
206
|
+
methodFeeAmount,
|
|
207
|
+
displayTotal,
|
|
208
|
+
giftCardTotal,
|
|
209
|
+
giftCardRemainder,
|
|
210
|
+
} = checkoutTotals({ cart, optimisticShippingCost, optimisticMethodFee })
|
|
211
|
+
|
|
212
|
+
// Server truth for the clear-the-override effects below: the prediction
|
|
213
|
+
// is dropped the moment the cart itself carries the same figure.
|
|
279
214
|
const realMethodFeeAmount = cart.payment_method_fee_total ?? 0
|
|
280
|
-
const effectiveMethodFee =
|
|
281
|
-
optimisticMethodFee !== null && optimisticMethodFee !== undefined
|
|
282
|
-
? optimisticMethodFee
|
|
283
|
-
: realMethodFeeAmount
|
|
284
|
-
|
|
285
|
-
const displayTotal = useMemo(() => {
|
|
286
|
-
let total = cart.total ?? 0
|
|
287
|
-
if (optimisticShippingCost !== null) {
|
|
288
|
-
total = total - (cart.shipping_total ?? 0) + optimisticShippingCost
|
|
289
|
-
}
|
|
290
|
-
if (optimisticMethodFee !== null && optimisticMethodFee !== undefined) {
|
|
291
|
-
total = total - realMethodFeeAmount + effectiveMethodFee
|
|
292
|
-
}
|
|
293
|
-
return total
|
|
294
|
-
}, [
|
|
295
|
-
cart.total,
|
|
296
|
-
cart.shipping_total,
|
|
297
|
-
optimisticShippingCost,
|
|
298
|
-
optimisticMethodFee,
|
|
299
|
-
effectiveMethodFee,
|
|
300
|
-
realMethodFeeAmount,
|
|
301
|
-
])
|
|
302
|
-
|
|
303
|
-
// Subtotal: Cartbase's `item_total` is products-only (the fee is not a
|
|
304
|
-
// line item), so no fee subtraction is needed — direct server truth.
|
|
305
|
-
const productSubtotal = cart.item_total ?? 0
|
|
306
|
-
|
|
307
|
-
const methodFeeAmount = effectiveMethodFee
|
|
308
|
-
|
|
309
|
-
// Gift-card tender decoration — SERVER truth, rendered as-is. Totals
|
|
310
|
-
// never move; the tender rows sit under the total.
|
|
311
|
-
const giftCardTotal = cart.gift_card_total ?? 0
|
|
312
|
-
const giftCardRemainder = cart.gift_card_remainder ?? 0
|
|
313
215
|
|
|
314
216
|
// Clear optimistic override once server cart reflects the new shipping cost
|
|
315
217
|
useEffect(() => {
|
|
@@ -344,20 +246,14 @@ export function OrderSummary({
|
|
|
344
246
|
{/* Items section — bg-muted accent so the items zone reads as a
|
|
345
247
|
distinct surface from the totals section below. */}
|
|
346
248
|
<div className="bg-muted px-5 sm:px-6 py-2 divide-y divide-border border-b border-border">
|
|
347
|
-
{productItems
|
|
348
|
-
.
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
item={item}
|
|
356
|
-
currencyCode={cart.currency_code}
|
|
357
|
-
onCartChange={onCartChange}
|
|
358
|
-
/>
|
|
359
|
-
</div>
|
|
360
|
-
))}
|
|
249
|
+
{productItems.map((item) => (
|
|
250
|
+
<div key={item.id} className="py-5 first:pt-3 last:pb-3">
|
|
251
|
+
<CheckoutLineItem
|
|
252
|
+
item={item}
|
|
253
|
+
currencyCode={cart.currency_code}
|
|
254
|
+
/>
|
|
255
|
+
</div>
|
|
256
|
+
))}
|
|
361
257
|
</div>
|
|
362
258
|
|
|
363
259
|
<div className="px-5 sm:px-6 mt-4 space-y-3">
|
|
@@ -434,6 +330,15 @@ export function OrderSummary({
|
|
|
434
330
|
</div>
|
|
435
331
|
)}
|
|
436
332
|
|
|
333
|
+
{/* THE VAT ROW IS FOR A COMPANY (Alexander, 2026-09-18). A
|
|
334
|
+
consumer sees the price they pay: the VAT is already inside it,
|
|
335
|
+
there is nothing they can do about it, and a row for it is
|
|
336
|
+
noise where they are deciding to buy. It used to print whatever
|
|
337
|
+
the cart said, which before an address was a flat 0.00 beside
|
|
338
|
+
prices containing 20% — false, not merely useless. A company
|
|
339
|
+
buyer reclaims the VAT and must see it, so for them it prints,
|
|
340
|
+
and only when the figure is actually known. */}
|
|
341
|
+
{isCompanyOrder(cart) && (cart.tax_total ?? 0) > 0 && (
|
|
437
342
|
<div className="flex justify-between text-sm">
|
|
438
343
|
{/* The tax row's NAME always shows: it names the number beside
|
|
439
344
|
it. The explanation only shows if the store wrote one, because
|
|
@@ -469,6 +374,7 @@ export function OrderSummary({
|
|
|
469
374
|
className="font-medium text-foreground"
|
|
470
375
|
/>
|
|
471
376
|
</div>
|
|
377
|
+
)}
|
|
472
378
|
</div>
|
|
473
379
|
|
|
474
380
|
<div className="mx-5 sm:mx-6 mt-4 pt-4 border-t border-border">
|
|
@@ -62,6 +62,14 @@ type PaymentButtonProps = {
|
|
|
62
62
|
/** Structural cart — only `id`, `total`, `currency_code` are read. */
|
|
63
63
|
cart: { id: string; total?: number | null; currency_code: string }
|
|
64
64
|
paymentTab: "card" | "cod"
|
|
65
|
+
/**
|
|
66
|
+
* Nothing is left to pay (a promotion or gift cards cover the whole
|
|
67
|
+
* total). The button reads "Complete order" and carries no amount, and
|
|
68
|
+
* it never waits on Stripe: no card form was mounted, so waiting for one
|
|
69
|
+
* to be complete would leave the shopper with a button that can never
|
|
70
|
+
* enable.
|
|
71
|
+
*/
|
|
72
|
+
nothingToPay?: boolean
|
|
65
73
|
notReady: boolean
|
|
66
74
|
performBuyClick: (stripeBundle?: BuyClickStripeBundle) => Promise<void>
|
|
67
75
|
/**
|
|
@@ -170,6 +178,7 @@ function OrderButton({
|
|
|
170
178
|
export function PaymentButton({
|
|
171
179
|
cart,
|
|
172
180
|
paymentTab,
|
|
181
|
+
nothingToPay = false,
|
|
173
182
|
notReady,
|
|
174
183
|
performBuyClick,
|
|
175
184
|
paymentElementComplete = true,
|
|
@@ -197,6 +206,11 @@ export function PaymentButton({
|
|
|
197
206
|
const [submitting, setSubmitting] = useState(false)
|
|
198
207
|
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
|
199
208
|
|
|
209
|
+
// A cart with nothing left to pay takes no card path, whichever tab was
|
|
210
|
+
// selected before the total fell to zero: prepare mints no session and
|
|
211
|
+
// the cart completes on what already covers it.
|
|
212
|
+
const isCardPath = paymentTab === "card" && !nothingToPay
|
|
213
|
+
|
|
200
214
|
// ── Processing-state cycling messages ───────────────────────────────
|
|
201
215
|
// While `submitting` is true, cycle through a short list of messages
|
|
202
216
|
// narrating what's actually happening server-side. Loops back to 0
|
|
@@ -214,17 +228,19 @@ export function PaymentButton({
|
|
|
214
228
|
setMessageIndex(0)
|
|
215
229
|
return
|
|
216
230
|
}
|
|
217
|
-
const messages =
|
|
218
|
-
paymentTab === "card" ? labels.processingCard : labels.processingCod
|
|
231
|
+
const messages = isCardPath ? labels.processingCard : labels.processingCod
|
|
219
232
|
if (!messages || messages.length === 0) return
|
|
220
233
|
const id = setInterval(() => {
|
|
221
234
|
setMessageIndex((i) => (i + 1) % messages.length)
|
|
222
235
|
}, PROCESSING_MESSAGE_INTERVAL_MS)
|
|
223
236
|
return () => clearInterval(id)
|
|
224
|
-
}, [submitting,
|
|
237
|
+
}, [submitting, isCardPath, labels.processingCard, labels.processingCod])
|
|
225
238
|
|
|
226
|
-
|
|
227
|
-
|
|
239
|
+
// No bank is being talked to when there is nothing to pay, so the
|
|
240
|
+
// narration is the one that does not mention one.
|
|
241
|
+
const processingMessages = isCardPath
|
|
242
|
+
? labels.processingCard
|
|
243
|
+
: labels.processingCod
|
|
228
244
|
const currentProcessingMessage =
|
|
229
245
|
submitting && processingMessages?.length
|
|
230
246
|
? processingMessages[messageIndex % processingMessages.length]
|
|
@@ -236,7 +252,6 @@ export function PaymentButton({
|
|
|
236
252
|
// flips synchronously and blocks the second click cold.
|
|
237
253
|
const inFlightRef = useRef(false)
|
|
238
254
|
|
|
239
|
-
const isCardPath = paymentTab === "card"
|
|
240
255
|
const cardReady = !isCardPath || (stripeReady && !!stripe && !!elements)
|
|
241
256
|
const cardComplete = !isCardPath || paymentElementComplete
|
|
242
257
|
|
|
@@ -334,10 +349,12 @@ export function PaymentButton({
|
|
|
334
349
|
onClick={handleClick}
|
|
335
350
|
disabled={disabled}
|
|
336
351
|
loading={submitting}
|
|
337
|
-
|
|
352
|
+
// No amount beside a Complete order button: "· 0.00" is noise on
|
|
353
|
+
// the one order where the number carries no meaning.
|
|
354
|
+
total={nothingToPay ? undefined : total ?? cart.total ?? undefined}
|
|
338
355
|
currencyCode={cart.currency_code}
|
|
339
356
|
testId={dataTestId}
|
|
340
|
-
label={labels.placeOrder}
|
|
357
|
+
label={nothingToPay ? labels.completeOrder : labels.placeOrder}
|
|
341
358
|
/>
|
|
342
359
|
{/*
|
|
343
360
|
Cycling processing message. Fixed-height wrapper prevents layout
|
|
@@ -72,13 +72,29 @@ type CheckoutPaymentMethodListProps = {
|
|
|
72
72
|
* instead of reading a greyed button as broken. Only rendered while
|
|
73
73
|
* `buyButtonNotReady`. Omit / null to show no hint. */
|
|
74
74
|
buyButtonNotReadyReason?: "address" | "delivery" | null
|
|
75
|
-
/**
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
75
|
+
/**
|
|
76
|
+
* Gate the whole payment section behind delivery-readiness: it stays
|
|
77
|
+
* dimmed and unusable until a courier is chosen.
|
|
78
|
+
*
|
|
79
|
+
* DEFAULT `false` since 2026-09-18 (Alexander, from Alenika's checkout):
|
|
80
|
+
* everything a shopper has to do is on screen from arrival, and the Buy
|
|
81
|
+
* button carries the gate on its own, saying which step is missing. A
|
|
82
|
+
* dimmed block teaches nothing and reads as broken. Pass `true` for the
|
|
83
|
+
* older flow.
|
|
84
|
+
*/
|
|
81
85
|
gatePaymentUntilDelivery?: boolean
|
|
86
|
+
/**
|
|
87
|
+
* The order asks the shopper for no money: a promotion covered it
|
|
88
|
+
* entirely, or gift cards cover the whole total. Pass the hook's
|
|
89
|
+
* `nothingToPay`.
|
|
90
|
+
*
|
|
91
|
+
* There is no payment to choose, so no tender is offered and no card
|
|
92
|
+
* form is mounted; the section says the order is covered and the button
|
|
93
|
+
* becomes Complete order. Before this the payment block still ruled a
|
|
94
|
+
* zero-total checkout, so a shopper who owed nothing was shown a card
|
|
95
|
+
* field and could not finish without filling it in.
|
|
96
|
+
*/
|
|
97
|
+
nothingToPay?: boolean
|
|
82
98
|
/** Optional content rendered directly above the Place Order button. */
|
|
83
99
|
beforePaymentButton?: React.ReactNode
|
|
84
100
|
/** Display total in main currency units. Forwarded to `PaymentButton`.
|
|
@@ -111,7 +127,8 @@ export function CheckoutPaymentMethodList({
|
|
|
111
127
|
performBuyClick,
|
|
112
128
|
buyButtonNotReady,
|
|
113
129
|
buyButtonNotReadyReason,
|
|
114
|
-
gatePaymentUntilDelivery =
|
|
130
|
+
gatePaymentUntilDelivery = false,
|
|
131
|
+
nothingToPay = false,
|
|
115
132
|
beforePaymentButton,
|
|
116
133
|
total,
|
|
117
134
|
logError,
|
|
@@ -176,7 +193,16 @@ export function CheckoutPaymentMethodList({
|
|
|
176
193
|
logError?.("elements_load_error", message, {})
|
|
177
194
|
}
|
|
178
195
|
|
|
196
|
+
// What a layout puts between the payment choice and the Buy button: on a
|
|
197
|
+
// phone the order overview and the code field. It is rendered ONCE, and
|
|
198
|
+
// never inside the dimmed block, because a shopper who has not picked a
|
|
199
|
+
// courier yet must still be able to open their order and type a code.
|
|
200
|
+
const slot = beforePaymentButton ? (
|
|
201
|
+
<div className="mt-5">{beforePaymentButton}</div>
|
|
202
|
+
) : null
|
|
203
|
+
|
|
179
204
|
return (
|
|
205
|
+
<>
|
|
180
206
|
<div
|
|
181
207
|
className={cn(
|
|
182
208
|
"mt-8 transition-opacity duration-300",
|
|
@@ -214,7 +240,16 @@ export function CheckoutPaymentMethodList({
|
|
|
214
240
|
// whole point of moving away from PaymentWrapper-wraps-all.
|
|
215
241
|
<StripeElementsScope passthrough>
|
|
216
242
|
<div className="space-y-2">
|
|
217
|
-
{
|
|
243
|
+
{nothingToPay && (
|
|
244
|
+
// One line instead of a tender list. The shopper owes
|
|
245
|
+
// nothing, so every radio here would be a question with no
|
|
246
|
+
// answer and the card form a demand for details that will
|
|
247
|
+
// never be charged.
|
|
248
|
+
<div className="p-4 bg-muted rounded-lg border border-border">
|
|
249
|
+
<p className="text-sm text-foreground">{labels.nothingToPay}</p>
|
|
250
|
+
</div>
|
|
251
|
+
)}
|
|
252
|
+
{!nothingToPay && hasCard && (
|
|
218
253
|
<div
|
|
219
254
|
className={cn(
|
|
220
255
|
"rounded-lg border overflow-hidden transition-colors duration-150",
|
|
@@ -293,7 +328,7 @@ export function CheckoutPaymentMethodList({
|
|
|
293
328
|
</div>
|
|
294
329
|
)}
|
|
295
330
|
|
|
296
|
-
{hasCod && (
|
|
331
|
+
{!nothingToPay && hasCod && (
|
|
297
332
|
<div
|
|
298
333
|
className={cn(
|
|
299
334
|
"rounded-lg border overflow-hidden transition-colors duration-150",
|
|
@@ -334,7 +369,7 @@ export function CheckoutPaymentMethodList({
|
|
|
334
369
|
</div>
|
|
335
370
|
)}
|
|
336
371
|
|
|
337
|
-
{extraTabs?.map((tab) => (
|
|
372
|
+
{!nothingToPay && extraTabs?.map((tab) => (
|
|
338
373
|
<div
|
|
339
374
|
key={tab.id}
|
|
340
375
|
className={cn(
|
|
@@ -375,9 +410,7 @@ export function CheckoutPaymentMethodList({
|
|
|
375
410
|
data-testid="payment-error-message"
|
|
376
411
|
/>
|
|
377
412
|
|
|
378
|
-
{
|
|
379
|
-
<div className="mt-5">{beforePaymentButton}</div>
|
|
380
|
-
)}
|
|
413
|
+
{slot}
|
|
381
414
|
|
|
382
415
|
{!extraTab && buyButtonNotReady && buyButtonNotReadyReason && (
|
|
383
416
|
<div className="mt-4 flex items-center justify-center gap-1.5 text-muted-foreground">
|
|
@@ -407,6 +440,7 @@ export function CheckoutPaymentMethodList({
|
|
|
407
440
|
<PaymentButton
|
|
408
441
|
cart={cart}
|
|
409
442
|
paymentTab={paymentTab}
|
|
443
|
+
nothingToPay={nothingToPay}
|
|
410
444
|
notReady={buyButtonNotReady}
|
|
411
445
|
performBuyClick={performBuyClick}
|
|
412
446
|
paymentElementComplete={
|
|
@@ -421,5 +455,9 @@ export function CheckoutPaymentMethodList({
|
|
|
421
455
|
</StripeElementsScope>
|
|
422
456
|
)}
|
|
423
457
|
</div>
|
|
458
|
+
{/* Gated: the block above is the notice, dimmed, so the slot stands
|
|
459
|
+
below it, live, instead of being greyed out with the payment cards. */}
|
|
460
|
+
{paymentGated && slot}
|
|
461
|
+
</>
|
|
424
462
|
)
|
|
425
463
|
}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
|
-
import type { Appearance, StripeElementsOptions } from "@stripe/stripe-js"
|
|
3
|
+
import type { Appearance, Stripe, StripeElementsOptions } from "@stripe/stripe-js"
|
|
4
4
|
import { loadStripe } from "@stripe/stripe-js"
|
|
5
5
|
import type { ReactNode } from "react"
|
|
6
6
|
|
|
7
|
+
import { envStripeAccountId, envStripeKey } from "../lib/stripe-env"
|
|
7
8
|
import { StripeScopeProvider } from "./stripe-wrapper"
|
|
8
9
|
|
|
9
10
|
/**
|
|
@@ -29,21 +30,43 @@ import { StripeScopeProvider } from "./stripe-wrapper"
|
|
|
29
30
|
* <CheckoutClient ... />
|
|
30
31
|
* </PaymentWrapper>
|
|
31
32
|
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
33
|
+
* WHERE THE KEY COMES FROM (2026-09-18). The store tells us. The card entry
|
|
34
|
+
* of `GET /api/store/payment-providers` carries `publishable_key` and, for a
|
|
35
|
+
* Connect-linked store, `account_id`; `CheckoutClient` reads them off that
|
|
36
|
+
* entry and passes them here. That is the only correct source: under Stripe
|
|
37
|
+
* Connect the browser loads CARTBASE's publishable key and names the
|
|
38
|
+
* merchant's account, so neither value is something a merchant could know
|
|
39
|
+
* about their own shop.
|
|
40
|
+
*
|
|
41
|
+
* Until this release the pair came from `NEXT_PUBLIC_STRIPE_KEY` and
|
|
42
|
+
* `NEXT_PUBLIC_STRIPE_ACCOUNT_ID`, two env values every storefront had to
|
|
43
|
+
* set by hand. A merchant who connected Stripe in the admin and set no env
|
|
44
|
+
* got a checkout with no card option and no reason given. The env pair still
|
|
45
|
+
* works as a fallback, so a store that set it keeps running, and it is the
|
|
46
|
+
* escape hatch for a storefront pointed at a Stripe account the platform
|
|
47
|
+
* does not hold.
|
|
35
48
|
*/
|
|
36
49
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
50
|
+
/**
|
|
51
|
+
* One Stripe.js promise per key + account pair, for the life of the tab.
|
|
52
|
+
* `loadStripe` injects a script and must not run per render; memoising on
|
|
53
|
+
* the pair also means the Elements iframe survives re-renders, which is the
|
|
54
|
+
* whole point of the deferred-intent mount.
|
|
55
|
+
*/
|
|
56
|
+
const stripeCache = new Map<string, Promise<Stripe | null>>()
|
|
40
57
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
58
|
+
function stripeFor(
|
|
59
|
+
key: string | undefined,
|
|
60
|
+
account: string | undefined
|
|
61
|
+
): Promise<Stripe | null> | null {
|
|
62
|
+
if (!key) return null
|
|
63
|
+
const cacheKey = `${key}::${account ?? ""}`
|
|
64
|
+
const hit = stripeCache.get(cacheKey)
|
|
65
|
+
if (hit) return hit
|
|
66
|
+
const promise = loadStripe(key, account ? { stripeAccount: account } : undefined)
|
|
67
|
+
stripeCache.set(cacheKey, promise)
|
|
68
|
+
return promise
|
|
69
|
+
}
|
|
47
70
|
|
|
48
71
|
type PaymentWrapperProps = {
|
|
49
72
|
/** The decorated cart — only `currency_code` is read. */
|
|
@@ -68,6 +91,16 @@ type PaymentWrapperProps = {
|
|
|
68
91
|
* font — the iframe cannot see the parent document's loaded fonts.
|
|
69
92
|
*/
|
|
70
93
|
fonts?: StripeElementsOptions["fonts"]
|
|
94
|
+
/**
|
|
95
|
+
* The store's own card credentials, off the `pp_stripe` entry of
|
|
96
|
+
* `GET /api/store/payment-providers`. `CheckoutClient` passes them for
|
|
97
|
+
* you; a store mounting this wrapper by hand reads them from the same
|
|
98
|
+
* listing it already fetches for the checkout. Omitted falls back to
|
|
99
|
+
* `NEXT_PUBLIC_STRIPE_KEY` / `NEXT_PUBLIC_STRIPE_ACCOUNT_ID`.
|
|
100
|
+
*/
|
|
101
|
+
publishableKey?: string
|
|
102
|
+
/** The merchant's linked Stripe account (`acct_…`) under Connect. */
|
|
103
|
+
stripeAccountId?: string
|
|
71
104
|
children: ReactNode
|
|
72
105
|
}
|
|
73
106
|
|
|
@@ -76,8 +109,19 @@ export function PaymentWrapper({
|
|
|
76
109
|
amount,
|
|
77
110
|
appearance,
|
|
78
111
|
fonts,
|
|
112
|
+
publishableKey,
|
|
113
|
+
stripeAccountId,
|
|
79
114
|
children,
|
|
80
115
|
}: PaymentWrapperProps) {
|
|
116
|
+
// The store's answer wins; env is the fallback for a storefront that
|
|
117
|
+
// supplies its own Stripe. Taking the key and the account from the same
|
|
118
|
+
// source matters: a platform key paired with no account charges the
|
|
119
|
+
// platform, and a merchant account under the wrong key is rejected.
|
|
120
|
+
const fromStore = !!publishableKey
|
|
121
|
+
const stripePromise = fromStore
|
|
122
|
+
? stripeFor(publishableKey, stripeAccountId)
|
|
123
|
+
: stripeFor(envStripeKey, envStripeAccountId)
|
|
124
|
+
|
|
81
125
|
// "Ready" simply means: Stripe.js loadable. We no longer wait for a
|
|
82
126
|
// backend payment_session — deferred-intent mode mounts straight away.
|
|
83
127
|
const ready = !!stripePromise
|