@cartbase/storefront 0.20.1 → 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.
Files changed (50) hide show
  1. package/package.json +274 -258
  2. package/src/api/checkout.ts +15 -0
  3. package/src/api/http.ts +10 -0
  4. package/src/api/integrations.ts +121 -0
  5. package/src/api/store.ts +21 -0
  6. package/src/cart-drawer/payment-badges.tsx +36 -96
  7. package/src/checkout/card-offer.ts +68 -0
  8. package/src/checkout/carrier-marks.ts +53 -0
  9. package/src/checkout/checkout-client.tsx +71 -13
  10. package/src/checkout/checkout-error-screen.tsx +113 -0
  11. package/src/checkout/discount-section.tsx +88 -56
  12. package/src/checkout/fulfillment-option.ts +30 -0
  13. package/src/checkout/index.ts +41 -6
  14. package/src/checkout/labels.ts +98 -0
  15. package/src/checkout/line-item-card.tsx +35 -83
  16. package/src/checkout/mobile-checkout-bottom-bar.tsx +132 -0
  17. package/src/checkout/mobile-checkout-top-bar.tsx +94 -0
  18. package/src/checkout/mobile-order-summary-body.tsx +172 -0
  19. package/src/checkout/order-summary.tsx +85 -179
  20. package/src/checkout/payment-button.tsx +25 -8
  21. package/src/checkout/payment-method-list.tsx +51 -13
  22. package/src/checkout/payment-wrapper.tsx +57 -13
  23. package/src/checkout/pickup-option.ts +35 -0
  24. package/src/checkout/pickup-point-selector.tsx +372 -0
  25. package/src/checkout/pigeon-office-selector.tsx +379 -0
  26. package/src/checkout/shipping-method-list.tsx +102 -10
  27. package/src/checkout/summary-math.ts +152 -0
  28. package/src/checkout/use-checkout-funnel.ts +303 -0
  29. package/src/checkout/use-checkout-orchestration.ts +384 -30
  30. package/src/common/icons/cartbase-mark.ts +9 -0
  31. package/src/common/icons/payment-marks.ts +73 -0
  32. package/src/common/icons/social-marks.ts +61 -0
  33. package/src/common/index.ts +15 -0
  34. package/src/common/payment-icons.tsx +54 -0
  35. package/src/common/powered-by-cartbase.tsx +47 -0
  36. package/src/common/social-links.tsx +65 -0
  37. package/src/lib/stripe-env.ts +25 -0
  38. package/src/locales/bg.ts +39 -0
  39. package/src/locales/es.ts +37 -0
  40. package/src/store/labels.ts +10 -0
  41. package/src/tracking/attribution.ts +83 -0
  42. package/src/tracking/consent.ts +53 -0
  43. package/src/tracking/events.ts +113 -0
  44. package/src/tracking/fbq.ts +48 -0
  45. package/src/tracking/gtag.ts +32 -0
  46. package/src/tracking/index.ts +32 -1
  47. package/src/tracking/once.ts +137 -0
  48. package/src/tracking/rybbit-events.ts +42 -0
  49. package/src/tracking/ttq.ts +24 -0
  50. package/src/tracking/types.ts +34 -0
@@ -1,16 +1,15 @@
1
1
  "use client"
2
2
 
3
- import { useEffect, useMemo, useState } from "react"
3
+ import { useEffect } from "react"
4
4
 
5
5
  import type { StorefrontClient } from "../api/http"
6
- import { updateLineItem, type Cart, type CartLineItem } from "../api/carts"
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 call `carts.updateLineItem` through the caller-supplied
35
- * `client`; every mutation hands the decorated cart to `onCartChange`.
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
- * Checkout is master-level: customers cannot remove items from the
51
- * order summary. Removal happens in the cart drawer only — the qty
52
- * pill here allows adjustment down to 1, not zero.
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
- * Ported from `@1click/ui/src/checkout/checkout-line-item.tsx` (v2.3.1);
55
- * lucide-react's Minus/Plus icons are inlined as SVGs so the checkout
56
- * family carries no icon-kit dependency (mirrors lib/payment-constants'
57
- * no-icons rule). `variant.title` → the flat `variant_title` column.
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
- {/* DESKTOP layout
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
- <div className="flex items-center justify-between gap-3">
191
- {qtyPill}
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
- </div>
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
- // Product rows only. Cartbase's COD fee is cart-level decoration (never a
262
- // line item), so this filter is defensive it also hides any
263
- // metadata-flagged fee line a legacy/ported cart might still carry.
264
- const allItems = cart.items ?? []
265
- const productItems = useMemo(
266
- () => allItems.filter(isProductLine),
267
- [allItems]
268
- )
269
- const itemCount = productItems.reduce((s, i) => s + i.quantity, 0)
270
-
271
- const shippingCost =
272
- optimisticShippingCost !== null ? optimisticShippingCost : cart.shipping_total
273
- const shippingKnown = shippingCost !== null && shippingCost !== undefined
274
-
275
- // Effective method fee: optimistic prediction wins until the cart
276
- // catches up. Three states (null / 0 / positive). Server truth is
277
- // `cart.payment_method_fee_total` (folded into cart.total while a live
278
- // method session exists docs/storefront/checkout.md).
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
- .slice()
349
- .sort((a, b) => ((a.created_at ?? "") > (b.created_at ?? "") ? -1 : 1))
350
- .map((item) => (
351
- <div key={item.id} className="py-5 first:pt-3 last:pb-3">
352
- <CheckoutLineItem
353
- client={client}
354
- cartId={cart.id}
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, paymentTab, labels.processingCard, labels.processingCod])
237
+ }, [submitting, isCardPath, labels.processingCard, labels.processingCod])
225
238
 
226
- const processingMessages =
227
- paymentTab === "card" ? labels.processingCard : labels.processingCod
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
- total={total ?? cart.total ?? undefined}
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
- /** Gate the whole payment section behind delivery-readiness (the
76
- * classic flow: payment stays dimmed + hidden until a shipping method
77
- * is chosen). Default `true` keeps that behavior. Pass `false` to keep
78
- * the payment section ALWAYS visible + interactive (Shopify-style
79
- * "everything on screen"); the Buy button still can't fire because
80
- * `buyButtonNotReady` gates it independently. */
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 = true,
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
- {hasCard && (
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
- {beforePaymentButton && (
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
- * Environment variables (read at module load time):
33
- * - NEXT_PUBLIC_STRIPE_KEY
34
- * - NEXT_PUBLIC_STRIPE_ACCOUNT_ID (optional, connect accounts)
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
- const stripeKey = process.env.NEXT_PUBLIC_STRIPE_KEY
38
-
39
- const stripeAccountId = process.env.NEXT_PUBLIC_STRIPE_ACCOUNT_ID
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
- const stripePromise = stripeKey
42
- ? loadStripe(
43
- stripeKey,
44
- stripeAccountId ? { stripeAccount: stripeAccountId } : undefined
45
- )
46
- : null
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