@cartbase/storefront 0.19.0 → 0.20.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/package.json +258 -258
- package/src/api/auth.ts +108 -108
- package/src/api/carts.ts +523 -523
- package/src/api/categories.ts +184 -184
- package/src/api/checkout.ts +526 -526
- package/src/api/collections.ts +130 -130
- package/src/api/consent.ts +75 -75
- package/src/api/content.ts +125 -125
- package/src/api/customers.ts +303 -303
- package/src/api/gift-cards.ts +112 -112
- package/src/api/http.ts +180 -180
- package/src/api/index.ts +30 -30
- package/src/api/menus.ts +77 -77
- package/src/api/metaobjects.ts +136 -136
- package/src/api/orders.ts +290 -290
- package/src/api/redirects.ts +37 -37
- package/src/api/regions.ts +200 -200
- package/src/api/reviews.ts +22 -1
- package/src/api/search.ts +163 -163
- package/src/api/store.ts +35 -35
- package/src/api/types.ts +91 -91
- package/src/cart-drawer/context.tsx +778 -778
- package/src/cart-drawer/cross-sell-carousel.tsx +211 -211
- package/src/cart-drawer/cross-sell-sidebar.tsx +158 -158
- package/src/cart-drawer/gift-wrap.tsx +82 -82
- package/src/cart-drawer/item/index.tsx +162 -162
- package/src/cart-drawer/item/upsell.tsx +110 -110
- package/src/cart-drawer/labels.ts +123 -123
- package/src/cart-drawer/mutation-queue.ts +78 -78
- package/src/cart-drawer/notes.tsx +131 -131
- package/src/cart-drawer/sticky-footer.tsx +73 -73
- package/src/cart-drawer/summary-breakdown.tsx +197 -197
- package/src/checkout/address-error-copy.ts +117 -117
- package/src/checkout/boxnow-locker-selector.tsx +410 -410
- package/src/checkout/compare-addresses.ts +40 -40
- package/src/checkout/discount-section.tsx +218 -218
- package/src/checkout/error-copy-codes.ts +63 -63
- package/src/checkout/geocode.ts +154 -154
- package/src/checkout/gift-card-section.tsx +224 -224
- package/src/checkout/index.ts +72 -72
- package/src/checkout/labels.ts +495 -495
- package/src/checkout/payment-button.tsx +372 -372
- package/src/checkout/payment-error-copy.ts +154 -154
- package/src/checkout/promotion-error-copy.ts +91 -91
- package/src/common/country-flag.tsx +52 -52
- package/src/common/country-select.tsx +11 -11
- package/src/common/index.ts +20 -20
- package/src/common/market-select.tsx +57 -57
- package/src/index.ts +12 -12
- package/src/lib/country-name.ts +59 -59
- package/src/lib/get-product-price.ts +133 -133
- package/src/lib/media-image.tsx +39 -39
- package/src/lib/payment-constants.ts +53 -53
- package/src/lib/platform.ts +13 -13
- package/src/lib/price.tsx +39 -39
- package/src/lib/store-api-error.ts +36 -36
- package/src/lib/variant-caption.ts +32 -32
- package/src/locales/bg.ts +3 -2
- package/src/locales/context.ts +37 -37
- package/src/locales/en.ts +26 -26
- package/src/locales/es.ts +3 -2
- package/src/locales/index.ts +19 -19
- package/src/locales/provider.tsx +59 -59
- package/src/locales/types.ts +77 -77
- package/src/order/index.ts +62 -62
- package/src/order/labels.ts +79 -79
- package/src/order/order-totals.tsx +250 -250
- package/src/primitives/select-field.tsx +93 -93
- package/src/products/image-gallery.tsx +43 -43
- package/src/products/option-select.tsx +67 -67
- package/src/products/product-info.tsx +5 -5
- package/src/products/product-price.tsx +69 -69
- package/src/products/product-promises.tsx +61 -61
- package/src/products/product-specs.tsx +65 -65
- package/src/products/product-tabs.tsx +123 -123
- package/src/products/purchase-options.tsx +130 -130
- package/src/products/sets.ts +110 -110
- package/src/products/variant-matching.ts +71 -71
- package/src/products/variant-url.ts +74 -74
- package/src/reviews-ui/index.ts +12 -0
- package/src/reviews-ui/labels.ts +5 -2
- package/src/reviews-ui/lightbox-state.ts +46 -46
- package/src/reviews-ui/review-header.tsx +166 -0
- package/src/reviews-ui/review-lightbox.tsx +271 -271
- package/src/reviews-ui/review-list.tsx +193 -191
- package/src/reviews-ui/review-widget.tsx +63 -71
- package/src/reviews-ui/star-badge.tsx +6 -4
- package/src/reviews-ui/widget-options.ts +55 -0
- package/src/store/category-template.tsx +136 -136
- package/src/store/index.ts +40 -40
- package/src/tracking/chatgpt-pixel.tsx +99 -99
- package/src/tracking/consent-init.tsx +62 -62
- package/src/tracking/events.ts +348 -348
- package/src/tracking/ga4.tsx +93 -93
- package/src/tracking/google-ads.ts +84 -84
- package/src/tracking/gtm.tsx +60 -60
- package/src/tracking/inline-script.ts +49 -49
- package/src/tracking/oaiq.ts +206 -206
- package/src/tracking/tiktok-pixel.tsx +91 -91
- package/src/tracking/track-init.tsx +56 -56
- package/src/tracking/track-order-purchase.tsx +122 -122
- package/src/tracking/ttq.ts +180 -180
- package/src/tracking/use-tracking-config.ts +54 -54
- package/theme/index.css +25 -25
|
@@ -1,372 +1,372 @@
|
|
|
1
|
-
"use client"
|
|
2
|
-
|
|
3
|
-
import { useElements, useStripe } from "@stripe/react-stripe-js"
|
|
4
|
-
import { useContext, useEffect, useRef, useState } from "react"
|
|
5
|
-
|
|
6
|
-
import { Price } from "../lib/price"
|
|
7
|
-
import { cn } from "../lib/utils"
|
|
8
|
-
import { translatePaymentError } from "./payment-error-copy"
|
|
9
|
-
import { useCheckoutLabels } from "./context"
|
|
10
|
-
import { ErrorMessage } from "./error-message"
|
|
11
|
-
import { StripeContext } from "./stripe-wrapper"
|
|
12
|
-
import type { CheckoutLogError } from "./use-checkout-orchestration"
|
|
13
|
-
|
|
14
|
-
/** Cycle interval for the processing-state messages, in ms. */
|
|
15
|
-
const PROCESSING_MESSAGE_INTERVAL_MS = 1800
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* PaymentButton — top-level "place order" button.
|
|
19
|
-
*
|
|
20
|
-
* Ported from `@1click/ui/src/checkout/payment-button.tsx` (v2.3.1) with
|
|
21
|
-
* the Cartbase data seam: the `cart` prop is structural (only `id`, `total`,
|
|
22
|
-
* `currency_code` are read — any decorated Cartbase cart satisfies it), and
|
|
23
|
-
* the `logCheckoutError` Supabase writer becomes the optional `logError`
|
|
24
|
-
* callback (Cartbase has no store-side log endpoint; every production log
|
|
25
|
-
* point is preserved through the callback).
|
|
26
|
-
*
|
|
27
|
-
* The button delegates entirely to the orchestration hook's
|
|
28
|
-
* `performBuyClick` callback. It knows NOTHING about how the order is
|
|
29
|
-
* placed; it just gathers the local Stripe primitives (when on the card
|
|
30
|
-
* tab) and hands them off.
|
|
31
|
-
*
|
|
32
|
-
* The flow that runs on click:
|
|
33
|
-
* 1. orchestration.performBuyClick({ submit, stripe, elements })
|
|
34
|
-
* - flushAddressSave()
|
|
35
|
-
* - (card) elements.submit()
|
|
36
|
-
* - prepareCheckout(...) ← writes everything atomically
|
|
37
|
-
* - (card) stripe.confirmPayment ← may redirect for 3DS; SKIPPED on
|
|
38
|
-
* the zero-remainder gift path (docs/storefront/gift-cards.md)
|
|
39
|
-
* - placeOrder() ← POST /api/store/carts/:id/complete
|
|
40
|
-
*
|
|
41
|
-
* The button is always rendered inside `<StripeElementsScope>` so
|
|
42
|
-
* `useStripe()`/`useElements()` resolve when the scope is active. On
|
|
43
|
-
* COD-only carts (no Stripe key) the scope passes through and the
|
|
44
|
-
* stripe/elements hooks return null — the COD path skips all Stripe
|
|
45
|
-
* work, so that's fine.
|
|
46
|
-
*/
|
|
47
|
-
|
|
48
|
-
type BuyClickStripeBundle = {
|
|
49
|
-
submit: () => Promise<{ error?: { message?: string } | null }>
|
|
50
|
-
stripe: {
|
|
51
|
-
confirmPayment: (args: {
|
|
52
|
-
elements: unknown
|
|
53
|
-
clientSecret: string
|
|
54
|
-
confirmParams: { return_url: string }
|
|
55
|
-
redirect: "if_required"
|
|
56
|
-
}) => Promise<{ error?: { message?: string } | null }>
|
|
57
|
-
}
|
|
58
|
-
elements: unknown
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
type PaymentButtonProps = {
|
|
62
|
-
/** Structural cart — only `id`, `total`, `currency_code` are read. */
|
|
63
|
-
cart: { id: string; total?: number | null; currency_code: string }
|
|
64
|
-
paymentTab: "card" | "cod"
|
|
65
|
-
notReady: boolean
|
|
66
|
-
performBuyClick: (stripeBundle?: BuyClickStripeBundle) => Promise<void>
|
|
67
|
-
/**
|
|
68
|
-
* Set to the live `event.complete` boolean from Stripe's
|
|
69
|
-
* `<PaymentElement onChange>`. When `false` on the card path, the Buy
|
|
70
|
-
* button stays disabled even when cart-level prerequisites are met,
|
|
71
|
-
* preventing the click → cryptic "Could not retrieve elements store"
|
|
72
|
-
* failure when the user hasn't filled in payment details.
|
|
73
|
-
*
|
|
74
|
-
* Ignored on the COD path. Defaults to `true`.
|
|
75
|
-
*/
|
|
76
|
-
paymentElementComplete?: boolean
|
|
77
|
-
/**
|
|
78
|
-
* Display total in main currency units. Pass `useCheckoutOrchestration`'s
|
|
79
|
-
* `optimisticTotal` so the button reflects the same number the order
|
|
80
|
-
* summary shows, even pre-Buy when shipping & COD fee aren't on the
|
|
81
|
-
* cart yet. Falls back to `cart.total` for callers that haven't wired
|
|
82
|
-
* it up.
|
|
83
|
-
*/
|
|
84
|
-
total?: number
|
|
85
|
-
/** Operational-visibility sink (successor of @1click's logCheckoutError). */
|
|
86
|
-
logError?: CheckoutLogError
|
|
87
|
-
"data-testid"?: string
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
function OrderButton({
|
|
91
|
-
onClick,
|
|
92
|
-
disabled,
|
|
93
|
-
loading,
|
|
94
|
-
total,
|
|
95
|
-
currencyCode,
|
|
96
|
-
testId,
|
|
97
|
-
label,
|
|
98
|
-
}: {
|
|
99
|
-
onClick: () => void
|
|
100
|
-
disabled: boolean
|
|
101
|
-
loading: boolean
|
|
102
|
-
total?: number
|
|
103
|
-
currencyCode?: string
|
|
104
|
-
testId?: string
|
|
105
|
-
label: string
|
|
106
|
-
}) {
|
|
107
|
-
return (
|
|
108
|
-
<button
|
|
109
|
-
type="button"
|
|
110
|
-
onClick={onClick}
|
|
111
|
-
disabled={disabled}
|
|
112
|
-
data-testid={testId}
|
|
113
|
-
className={cn(
|
|
114
|
-
"w-full h-14 bg-foreground text-card text-base font-semibold rounded-xl",
|
|
115
|
-
"flex items-center justify-center gap-2.5 transition-all",
|
|
116
|
-
"hover:bg-foreground/90 active:scale-[0.99]",
|
|
117
|
-
"disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-foreground"
|
|
118
|
-
)}
|
|
119
|
-
>
|
|
120
|
-
{loading ? (
|
|
121
|
-
<svg className="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24">
|
|
122
|
-
<circle
|
|
123
|
-
className="opacity-25"
|
|
124
|
-
cx="12"
|
|
125
|
-
cy="12"
|
|
126
|
-
r="10"
|
|
127
|
-
stroke="currentColor"
|
|
128
|
-
strokeWidth="3"
|
|
129
|
-
/>
|
|
130
|
-
<path
|
|
131
|
-
className="opacity-75"
|
|
132
|
-
fill="currentColor"
|
|
133
|
-
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
134
|
-
/>
|
|
135
|
-
</svg>
|
|
136
|
-
) : (
|
|
137
|
-
<>
|
|
138
|
-
<svg
|
|
139
|
-
className="w-4 h-4"
|
|
140
|
-
fill="none"
|
|
141
|
-
viewBox="0 0 24 24"
|
|
142
|
-
stroke="currentColor"
|
|
143
|
-
strokeWidth={2}
|
|
144
|
-
>
|
|
145
|
-
<path
|
|
146
|
-
strokeLinecap="round"
|
|
147
|
-
strokeLinejoin="round"
|
|
148
|
-
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"
|
|
149
|
-
/>
|
|
150
|
-
</svg>
|
|
151
|
-
<span className="inline-flex items-center gap-1.5">
|
|
152
|
-
{label}
|
|
153
|
-
{total !== undefined && currencyCode && (
|
|
154
|
-
<span className="inline-flex items-center gap-1">
|
|
155
|
-
<span aria-hidden="true"> · </span>
|
|
156
|
-
<Price
|
|
157
|
-
amount={total}
|
|
158
|
-
currencyCode={currencyCode}
|
|
159
|
-
className="font-semibold"
|
|
160
|
-
/>
|
|
161
|
-
</span>
|
|
162
|
-
)}
|
|
163
|
-
</span>
|
|
164
|
-
</>
|
|
165
|
-
)}
|
|
166
|
-
</button>
|
|
167
|
-
)
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
export function PaymentButton({
|
|
171
|
-
cart,
|
|
172
|
-
paymentTab,
|
|
173
|
-
notReady,
|
|
174
|
-
performBuyClick,
|
|
175
|
-
paymentElementComplete = true,
|
|
176
|
-
total,
|
|
177
|
-
logError,
|
|
178
|
-
"data-testid": dataTestId,
|
|
179
|
-
}: PaymentButtonProps) {
|
|
180
|
-
const labels = useCheckoutLabels()
|
|
181
|
-
const stripeReady = useContext(StripeContext)
|
|
182
|
-
// `useStripe()`/`useElements()` THROW when no <Elements> provider is
|
|
183
|
-
// mounted — which is the documented state on COD/pp_manual-only stores
|
|
184
|
-
// without Stripe credentials (`StripeElementsScope` passthrough mode,
|
|
185
|
-
// components.md). Tolerate the missing scope: null here is equivalent
|
|
186
|
-
// to "Stripe not ready", and the card path is additionally gated by
|
|
187
|
-
// `stripeReady`. Hook order stays stable across renders because the
|
|
188
|
-
// provider's presence is fixed per mount (module-level Stripe key).
|
|
189
|
-
let stripe: ReturnType<typeof useStripe> = null
|
|
190
|
-
let elements: ReturnType<typeof useElements> = null
|
|
191
|
-
try {
|
|
192
|
-
stripe = useStripe()
|
|
193
|
-
elements = useElements()
|
|
194
|
-
} catch {
|
|
195
|
-
// no Elements scope (COD-only store) — proceed with stripe = null
|
|
196
|
-
}
|
|
197
|
-
const [submitting, setSubmitting] = useState(false)
|
|
198
|
-
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
|
199
|
-
|
|
200
|
-
// ── Processing-state cycling messages ───────────────────────────────
|
|
201
|
-
// While `submitting` is true, cycle through a short list of messages
|
|
202
|
-
// narrating what's actually happening server-side. Loops back to 0
|
|
203
|
-
// on long flows (3DS challenges, slow networks). Card and COD have
|
|
204
|
-
// separate sequences because the card path includes a bank handshake
|
|
205
|
-
// step that doesn't apply to COD.
|
|
206
|
-
//
|
|
207
|
-
// Why this exists: a spinning button alone reads as "thinking" but
|
|
208
|
-
// not "thinking about WHAT" — customers get nervous on the 3-30s
|
|
209
|
-
// wait. Stripe / Booking.com / Apple Pay all narrate the same way.
|
|
210
|
-
// Trust > novelty during the moment money leaves the account.
|
|
211
|
-
const [messageIndex, setMessageIndex] = useState(0)
|
|
212
|
-
useEffect(() => {
|
|
213
|
-
if (!submitting) {
|
|
214
|
-
setMessageIndex(0)
|
|
215
|
-
return
|
|
216
|
-
}
|
|
217
|
-
const messages =
|
|
218
|
-
paymentTab === "card" ? labels.processingCard : labels.processingCod
|
|
219
|
-
if (!messages || messages.length === 0) return
|
|
220
|
-
const id = setInterval(() => {
|
|
221
|
-
setMessageIndex((i) => (i + 1) % messages.length)
|
|
222
|
-
}, PROCESSING_MESSAGE_INTERVAL_MS)
|
|
223
|
-
return () => clearInterval(id)
|
|
224
|
-
}, [submitting, paymentTab, labels.processingCard, labels.processingCod])
|
|
225
|
-
|
|
226
|
-
const processingMessages =
|
|
227
|
-
paymentTab === "card" ? labels.processingCard : labels.processingCod
|
|
228
|
-
const currentProcessingMessage =
|
|
229
|
-
submitting && processingMessages?.length
|
|
230
|
-
? processingMessages[messageIndex % processingMessages.length]
|
|
231
|
-
: null
|
|
232
|
-
|
|
233
|
-
// Hard re-entry guard. `submitting` state alone isn't enough: React
|
|
234
|
-
// batches state updates so two synchronous clicks both pass the
|
|
235
|
-
// submitting check before the first one's setState commits. A ref
|
|
236
|
-
// flips synchronously and blocks the second click cold.
|
|
237
|
-
const inFlightRef = useRef(false)
|
|
238
|
-
|
|
239
|
-
const isCardPath = paymentTab === "card"
|
|
240
|
-
const cardReady = !isCardPath || (stripeReady && !!stripe && !!elements)
|
|
241
|
-
const cardComplete = !isCardPath || paymentElementComplete
|
|
242
|
-
|
|
243
|
-
const disabled = notReady || !cardReady || !cardComplete || submitting
|
|
244
|
-
|
|
245
|
-
const handleClick = async () => {
|
|
246
|
-
if (inFlightRef.current) return
|
|
247
|
-
inFlightRef.current = true
|
|
248
|
-
setSubmitting(true)
|
|
249
|
-
setErrorMessage(null)
|
|
250
|
-
|
|
251
|
-
try {
|
|
252
|
-
const stripeBundle: BuyClickStripeBundle | undefined =
|
|
253
|
-
isCardPath && stripe && elements
|
|
254
|
-
? {
|
|
255
|
-
submit: () => elements.submit(),
|
|
256
|
-
stripe: stripe as unknown as BuyClickStripeBundle["stripe"],
|
|
257
|
-
elements,
|
|
258
|
-
}
|
|
259
|
-
: undefined
|
|
260
|
-
|
|
261
|
-
await performBuyClick(stripeBundle)
|
|
262
|
-
|
|
263
|
-
// performBuyClick navigates on success (placeOrder →
|
|
264
|
-
// window.location.assign / the host's onOrderPlaced router push).
|
|
265
|
-
// If we get here without a navigation Stripe returned a
|
|
266
|
-
// non-terminal status (processing, requires_action without
|
|
267
|
-
// auto-redirect, etc.) — release the lock so the user can retry.
|
|
268
|
-
inFlightRef.current = false
|
|
269
|
-
setSubmitting(false)
|
|
270
|
-
} catch (err: unknown) {
|
|
271
|
-
// Next.js's `redirect()` throws a NEXT_REDIRECT error — that is
|
|
272
|
-
// success, not failure. The Cartbase default path navigates via
|
|
273
|
-
// window.location.assign (no throw), but a store routing
|
|
274
|
-
// `onOrderPlaced` through a server action still hits this; don't
|
|
275
|
-
// translate it — let it propagate so the navigation happens.
|
|
276
|
-
const e = err as {
|
|
277
|
-
digest?: string
|
|
278
|
-
message?: string
|
|
279
|
-
type?: string
|
|
280
|
-
code?: string
|
|
281
|
-
decline_code?: string
|
|
282
|
-
name?: string
|
|
283
|
-
stack?: string
|
|
284
|
-
}
|
|
285
|
-
const isNextRedirect =
|
|
286
|
-
typeof e?.digest === "string" && e.digest.startsWith("NEXT_REDIRECT")
|
|
287
|
-
if (isNextRedirect) {
|
|
288
|
-
throw err
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
// eslint-disable-next-line no-console
|
|
292
|
-
console.error("[buy-click] FAILED", {
|
|
293
|
-
message: e?.message,
|
|
294
|
-
raw: err,
|
|
295
|
-
})
|
|
296
|
-
|
|
297
|
-
// Operational sink so the actual error is readable without needing
|
|
298
|
-
// the customer's browser console. This is the catch-all for any
|
|
299
|
-
// error that escapes performBuyClick (stripe.confirmPayment,
|
|
300
|
-
// prepareCheckout, placeOrder).
|
|
301
|
-
let dump = ""
|
|
302
|
-
try {
|
|
303
|
-
dump = JSON.stringify(err, Object.getOwnPropertyNames(err as object)).slice(
|
|
304
|
-
0,
|
|
305
|
-
2000
|
|
306
|
-
)
|
|
307
|
-
} catch {
|
|
308
|
-
dump = String(err)
|
|
309
|
-
}
|
|
310
|
-
logError?.(
|
|
311
|
-
"place_order_error",
|
|
312
|
-
e?.message ?? String(err) ?? "unknown",
|
|
313
|
-
{
|
|
314
|
-
path: isCardPath ? "card" : "cod",
|
|
315
|
-
err_name: e?.name,
|
|
316
|
-
err_type: e?.type,
|
|
317
|
-
err_code: e?.code,
|
|
318
|
-
decline_code: e?.decline_code,
|
|
319
|
-
cart_id: cart.id,
|
|
320
|
-
full_error_json: dump,
|
|
321
|
-
}
|
|
322
|
-
)
|
|
323
|
-
|
|
324
|
-
const translated = translatePaymentError(err, isCardPath ? "card" : "cod", labels.paymentErrors)
|
|
325
|
-
setErrorMessage(translated)
|
|
326
|
-
inFlightRef.current = false
|
|
327
|
-
setSubmitting(false)
|
|
328
|
-
}
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
return (
|
|
332
|
-
<>
|
|
333
|
-
<OrderButton
|
|
334
|
-
onClick={handleClick}
|
|
335
|
-
disabled={disabled}
|
|
336
|
-
loading={submitting}
|
|
337
|
-
total={total ?? cart.total ?? undefined}
|
|
338
|
-
currencyCode={cart.currency_code}
|
|
339
|
-
testId={dataTestId}
|
|
340
|
-
label={labels.placeOrder}
|
|
341
|
-
/>
|
|
342
|
-
{/*
|
|
343
|
-
Cycling processing message. Fixed-height wrapper prevents layout
|
|
344
|
-
shift when the message appears/disappears. aria-live="polite"
|
|
345
|
-
announces each message swap to screen readers.
|
|
346
|
-
`key={messageIndex}` re-mounts the span on every cycle so
|
|
347
|
-
Tailwind's animate-in fade-in fires fresh each time.
|
|
348
|
-
*/}
|
|
349
|
-
<div
|
|
350
|
-
className="mt-3 h-5 flex items-center justify-center"
|
|
351
|
-
aria-live="polite"
|
|
352
|
-
>
|
|
353
|
-
{currentProcessingMessage && (
|
|
354
|
-
<span
|
|
355
|
-
key={messageIndex}
|
|
356
|
-
className="text-sm text-muted-foreground animate-in fade-in duration-500"
|
|
357
|
-
>
|
|
358
|
-
{currentProcessingMessage}
|
|
359
|
-
</span>
|
|
360
|
-
)}
|
|
361
|
-
</div>
|
|
362
|
-
<ErrorMessage
|
|
363
|
-
error={errorMessage}
|
|
364
|
-
data-testid={
|
|
365
|
-
isCardPath
|
|
366
|
-
? "stripe-payment-error-message"
|
|
367
|
-
: "manual-payment-error-message"
|
|
368
|
-
}
|
|
369
|
-
/>
|
|
370
|
-
</>
|
|
371
|
-
)
|
|
372
|
-
}
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useElements, useStripe } from "@stripe/react-stripe-js"
|
|
4
|
+
import { useContext, useEffect, useRef, useState } from "react"
|
|
5
|
+
|
|
6
|
+
import { Price } from "../lib/price"
|
|
7
|
+
import { cn } from "../lib/utils"
|
|
8
|
+
import { translatePaymentError } from "./payment-error-copy"
|
|
9
|
+
import { useCheckoutLabels } from "./context"
|
|
10
|
+
import { ErrorMessage } from "./error-message"
|
|
11
|
+
import { StripeContext } from "./stripe-wrapper"
|
|
12
|
+
import type { CheckoutLogError } from "./use-checkout-orchestration"
|
|
13
|
+
|
|
14
|
+
/** Cycle interval for the processing-state messages, in ms. */
|
|
15
|
+
const PROCESSING_MESSAGE_INTERVAL_MS = 1800
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* PaymentButton — top-level "place order" button.
|
|
19
|
+
*
|
|
20
|
+
* Ported from `@1click/ui/src/checkout/payment-button.tsx` (v2.3.1) with
|
|
21
|
+
* the Cartbase data seam: the `cart` prop is structural (only `id`, `total`,
|
|
22
|
+
* `currency_code` are read — any decorated Cartbase cart satisfies it), and
|
|
23
|
+
* the `logCheckoutError` Supabase writer becomes the optional `logError`
|
|
24
|
+
* callback (Cartbase has no store-side log endpoint; every production log
|
|
25
|
+
* point is preserved through the callback).
|
|
26
|
+
*
|
|
27
|
+
* The button delegates entirely to the orchestration hook's
|
|
28
|
+
* `performBuyClick` callback. It knows NOTHING about how the order is
|
|
29
|
+
* placed; it just gathers the local Stripe primitives (when on the card
|
|
30
|
+
* tab) and hands them off.
|
|
31
|
+
*
|
|
32
|
+
* The flow that runs on click:
|
|
33
|
+
* 1. orchestration.performBuyClick({ submit, stripe, elements })
|
|
34
|
+
* - flushAddressSave()
|
|
35
|
+
* - (card) elements.submit()
|
|
36
|
+
* - prepareCheckout(...) ← writes everything atomically
|
|
37
|
+
* - (card) stripe.confirmPayment ← may redirect for 3DS; SKIPPED on
|
|
38
|
+
* the zero-remainder gift path (docs/storefront/gift-cards.md)
|
|
39
|
+
* - placeOrder() ← POST /api/store/carts/:id/complete
|
|
40
|
+
*
|
|
41
|
+
* The button is always rendered inside `<StripeElementsScope>` so
|
|
42
|
+
* `useStripe()`/`useElements()` resolve when the scope is active. On
|
|
43
|
+
* COD-only carts (no Stripe key) the scope passes through and the
|
|
44
|
+
* stripe/elements hooks return null — the COD path skips all Stripe
|
|
45
|
+
* work, so that's fine.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
type BuyClickStripeBundle = {
|
|
49
|
+
submit: () => Promise<{ error?: { message?: string } | null }>
|
|
50
|
+
stripe: {
|
|
51
|
+
confirmPayment: (args: {
|
|
52
|
+
elements: unknown
|
|
53
|
+
clientSecret: string
|
|
54
|
+
confirmParams: { return_url: string }
|
|
55
|
+
redirect: "if_required"
|
|
56
|
+
}) => Promise<{ error?: { message?: string } | null }>
|
|
57
|
+
}
|
|
58
|
+
elements: unknown
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
type PaymentButtonProps = {
|
|
62
|
+
/** Structural cart — only `id`, `total`, `currency_code` are read. */
|
|
63
|
+
cart: { id: string; total?: number | null; currency_code: string }
|
|
64
|
+
paymentTab: "card" | "cod"
|
|
65
|
+
notReady: boolean
|
|
66
|
+
performBuyClick: (stripeBundle?: BuyClickStripeBundle) => Promise<void>
|
|
67
|
+
/**
|
|
68
|
+
* Set to the live `event.complete` boolean from Stripe's
|
|
69
|
+
* `<PaymentElement onChange>`. When `false` on the card path, the Buy
|
|
70
|
+
* button stays disabled even when cart-level prerequisites are met,
|
|
71
|
+
* preventing the click → cryptic "Could not retrieve elements store"
|
|
72
|
+
* failure when the user hasn't filled in payment details.
|
|
73
|
+
*
|
|
74
|
+
* Ignored on the COD path. Defaults to `true`.
|
|
75
|
+
*/
|
|
76
|
+
paymentElementComplete?: boolean
|
|
77
|
+
/**
|
|
78
|
+
* Display total in main currency units. Pass `useCheckoutOrchestration`'s
|
|
79
|
+
* `optimisticTotal` so the button reflects the same number the order
|
|
80
|
+
* summary shows, even pre-Buy when shipping & COD fee aren't on the
|
|
81
|
+
* cart yet. Falls back to `cart.total` for callers that haven't wired
|
|
82
|
+
* it up.
|
|
83
|
+
*/
|
|
84
|
+
total?: number
|
|
85
|
+
/** Operational-visibility sink (successor of @1click's logCheckoutError). */
|
|
86
|
+
logError?: CheckoutLogError
|
|
87
|
+
"data-testid"?: string
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function OrderButton({
|
|
91
|
+
onClick,
|
|
92
|
+
disabled,
|
|
93
|
+
loading,
|
|
94
|
+
total,
|
|
95
|
+
currencyCode,
|
|
96
|
+
testId,
|
|
97
|
+
label,
|
|
98
|
+
}: {
|
|
99
|
+
onClick: () => void
|
|
100
|
+
disabled: boolean
|
|
101
|
+
loading: boolean
|
|
102
|
+
total?: number
|
|
103
|
+
currencyCode?: string
|
|
104
|
+
testId?: string
|
|
105
|
+
label: string
|
|
106
|
+
}) {
|
|
107
|
+
return (
|
|
108
|
+
<button
|
|
109
|
+
type="button"
|
|
110
|
+
onClick={onClick}
|
|
111
|
+
disabled={disabled}
|
|
112
|
+
data-testid={testId}
|
|
113
|
+
className={cn(
|
|
114
|
+
"w-full h-14 bg-foreground text-card text-base font-semibold rounded-xl",
|
|
115
|
+
"flex items-center justify-center gap-2.5 transition-all",
|
|
116
|
+
"hover:bg-foreground/90 active:scale-[0.99]",
|
|
117
|
+
"disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-foreground"
|
|
118
|
+
)}
|
|
119
|
+
>
|
|
120
|
+
{loading ? (
|
|
121
|
+
<svg className="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24">
|
|
122
|
+
<circle
|
|
123
|
+
className="opacity-25"
|
|
124
|
+
cx="12"
|
|
125
|
+
cy="12"
|
|
126
|
+
r="10"
|
|
127
|
+
stroke="currentColor"
|
|
128
|
+
strokeWidth="3"
|
|
129
|
+
/>
|
|
130
|
+
<path
|
|
131
|
+
className="opacity-75"
|
|
132
|
+
fill="currentColor"
|
|
133
|
+
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
134
|
+
/>
|
|
135
|
+
</svg>
|
|
136
|
+
) : (
|
|
137
|
+
<>
|
|
138
|
+
<svg
|
|
139
|
+
className="w-4 h-4"
|
|
140
|
+
fill="none"
|
|
141
|
+
viewBox="0 0 24 24"
|
|
142
|
+
stroke="currentColor"
|
|
143
|
+
strokeWidth={2}
|
|
144
|
+
>
|
|
145
|
+
<path
|
|
146
|
+
strokeLinecap="round"
|
|
147
|
+
strokeLinejoin="round"
|
|
148
|
+
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"
|
|
149
|
+
/>
|
|
150
|
+
</svg>
|
|
151
|
+
<span className="inline-flex items-center gap-1.5">
|
|
152
|
+
{label}
|
|
153
|
+
{total !== undefined && currencyCode && (
|
|
154
|
+
<span className="inline-flex items-center gap-1">
|
|
155
|
+
<span aria-hidden="true"> · </span>
|
|
156
|
+
<Price
|
|
157
|
+
amount={total}
|
|
158
|
+
currencyCode={currencyCode}
|
|
159
|
+
className="font-semibold"
|
|
160
|
+
/>
|
|
161
|
+
</span>
|
|
162
|
+
)}
|
|
163
|
+
</span>
|
|
164
|
+
</>
|
|
165
|
+
)}
|
|
166
|
+
</button>
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function PaymentButton({
|
|
171
|
+
cart,
|
|
172
|
+
paymentTab,
|
|
173
|
+
notReady,
|
|
174
|
+
performBuyClick,
|
|
175
|
+
paymentElementComplete = true,
|
|
176
|
+
total,
|
|
177
|
+
logError,
|
|
178
|
+
"data-testid": dataTestId,
|
|
179
|
+
}: PaymentButtonProps) {
|
|
180
|
+
const labels = useCheckoutLabels()
|
|
181
|
+
const stripeReady = useContext(StripeContext)
|
|
182
|
+
// `useStripe()`/`useElements()` THROW when no <Elements> provider is
|
|
183
|
+
// mounted — which is the documented state on COD/pp_manual-only stores
|
|
184
|
+
// without Stripe credentials (`StripeElementsScope` passthrough mode,
|
|
185
|
+
// components.md). Tolerate the missing scope: null here is equivalent
|
|
186
|
+
// to "Stripe not ready", and the card path is additionally gated by
|
|
187
|
+
// `stripeReady`. Hook order stays stable across renders because the
|
|
188
|
+
// provider's presence is fixed per mount (module-level Stripe key).
|
|
189
|
+
let stripe: ReturnType<typeof useStripe> = null
|
|
190
|
+
let elements: ReturnType<typeof useElements> = null
|
|
191
|
+
try {
|
|
192
|
+
stripe = useStripe()
|
|
193
|
+
elements = useElements()
|
|
194
|
+
} catch {
|
|
195
|
+
// no Elements scope (COD-only store) — proceed with stripe = null
|
|
196
|
+
}
|
|
197
|
+
const [submitting, setSubmitting] = useState(false)
|
|
198
|
+
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
|
199
|
+
|
|
200
|
+
// ── Processing-state cycling messages ───────────────────────────────
|
|
201
|
+
// While `submitting` is true, cycle through a short list of messages
|
|
202
|
+
// narrating what's actually happening server-side. Loops back to 0
|
|
203
|
+
// on long flows (3DS challenges, slow networks). Card and COD have
|
|
204
|
+
// separate sequences because the card path includes a bank handshake
|
|
205
|
+
// step that doesn't apply to COD.
|
|
206
|
+
//
|
|
207
|
+
// Why this exists: a spinning button alone reads as "thinking" but
|
|
208
|
+
// not "thinking about WHAT" — customers get nervous on the 3-30s
|
|
209
|
+
// wait. Stripe / Booking.com / Apple Pay all narrate the same way.
|
|
210
|
+
// Trust > novelty during the moment money leaves the account.
|
|
211
|
+
const [messageIndex, setMessageIndex] = useState(0)
|
|
212
|
+
useEffect(() => {
|
|
213
|
+
if (!submitting) {
|
|
214
|
+
setMessageIndex(0)
|
|
215
|
+
return
|
|
216
|
+
}
|
|
217
|
+
const messages =
|
|
218
|
+
paymentTab === "card" ? labels.processingCard : labels.processingCod
|
|
219
|
+
if (!messages || messages.length === 0) return
|
|
220
|
+
const id = setInterval(() => {
|
|
221
|
+
setMessageIndex((i) => (i + 1) % messages.length)
|
|
222
|
+
}, PROCESSING_MESSAGE_INTERVAL_MS)
|
|
223
|
+
return () => clearInterval(id)
|
|
224
|
+
}, [submitting, paymentTab, labels.processingCard, labels.processingCod])
|
|
225
|
+
|
|
226
|
+
const processingMessages =
|
|
227
|
+
paymentTab === "card" ? labels.processingCard : labels.processingCod
|
|
228
|
+
const currentProcessingMessage =
|
|
229
|
+
submitting && processingMessages?.length
|
|
230
|
+
? processingMessages[messageIndex % processingMessages.length]
|
|
231
|
+
: null
|
|
232
|
+
|
|
233
|
+
// Hard re-entry guard. `submitting` state alone isn't enough: React
|
|
234
|
+
// batches state updates so two synchronous clicks both pass the
|
|
235
|
+
// submitting check before the first one's setState commits. A ref
|
|
236
|
+
// flips synchronously and blocks the second click cold.
|
|
237
|
+
const inFlightRef = useRef(false)
|
|
238
|
+
|
|
239
|
+
const isCardPath = paymentTab === "card"
|
|
240
|
+
const cardReady = !isCardPath || (stripeReady && !!stripe && !!elements)
|
|
241
|
+
const cardComplete = !isCardPath || paymentElementComplete
|
|
242
|
+
|
|
243
|
+
const disabled = notReady || !cardReady || !cardComplete || submitting
|
|
244
|
+
|
|
245
|
+
const handleClick = async () => {
|
|
246
|
+
if (inFlightRef.current) return
|
|
247
|
+
inFlightRef.current = true
|
|
248
|
+
setSubmitting(true)
|
|
249
|
+
setErrorMessage(null)
|
|
250
|
+
|
|
251
|
+
try {
|
|
252
|
+
const stripeBundle: BuyClickStripeBundle | undefined =
|
|
253
|
+
isCardPath && stripe && elements
|
|
254
|
+
? {
|
|
255
|
+
submit: () => elements.submit(),
|
|
256
|
+
stripe: stripe as unknown as BuyClickStripeBundle["stripe"],
|
|
257
|
+
elements,
|
|
258
|
+
}
|
|
259
|
+
: undefined
|
|
260
|
+
|
|
261
|
+
await performBuyClick(stripeBundle)
|
|
262
|
+
|
|
263
|
+
// performBuyClick navigates on success (placeOrder →
|
|
264
|
+
// window.location.assign / the host's onOrderPlaced router push).
|
|
265
|
+
// If we get here without a navigation Stripe returned a
|
|
266
|
+
// non-terminal status (processing, requires_action without
|
|
267
|
+
// auto-redirect, etc.) — release the lock so the user can retry.
|
|
268
|
+
inFlightRef.current = false
|
|
269
|
+
setSubmitting(false)
|
|
270
|
+
} catch (err: unknown) {
|
|
271
|
+
// Next.js's `redirect()` throws a NEXT_REDIRECT error — that is
|
|
272
|
+
// success, not failure. The Cartbase default path navigates via
|
|
273
|
+
// window.location.assign (no throw), but a store routing
|
|
274
|
+
// `onOrderPlaced` through a server action still hits this; don't
|
|
275
|
+
// translate it — let it propagate so the navigation happens.
|
|
276
|
+
const e = err as {
|
|
277
|
+
digest?: string
|
|
278
|
+
message?: string
|
|
279
|
+
type?: string
|
|
280
|
+
code?: string
|
|
281
|
+
decline_code?: string
|
|
282
|
+
name?: string
|
|
283
|
+
stack?: string
|
|
284
|
+
}
|
|
285
|
+
const isNextRedirect =
|
|
286
|
+
typeof e?.digest === "string" && e.digest.startsWith("NEXT_REDIRECT")
|
|
287
|
+
if (isNextRedirect) {
|
|
288
|
+
throw err
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// eslint-disable-next-line no-console
|
|
292
|
+
console.error("[buy-click] FAILED", {
|
|
293
|
+
message: e?.message,
|
|
294
|
+
raw: err,
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
// Operational sink so the actual error is readable without needing
|
|
298
|
+
// the customer's browser console. This is the catch-all for any
|
|
299
|
+
// error that escapes performBuyClick (stripe.confirmPayment,
|
|
300
|
+
// prepareCheckout, placeOrder).
|
|
301
|
+
let dump = ""
|
|
302
|
+
try {
|
|
303
|
+
dump = JSON.stringify(err, Object.getOwnPropertyNames(err as object)).slice(
|
|
304
|
+
0,
|
|
305
|
+
2000
|
|
306
|
+
)
|
|
307
|
+
} catch {
|
|
308
|
+
dump = String(err)
|
|
309
|
+
}
|
|
310
|
+
logError?.(
|
|
311
|
+
"place_order_error",
|
|
312
|
+
e?.message ?? String(err) ?? "unknown",
|
|
313
|
+
{
|
|
314
|
+
path: isCardPath ? "card" : "cod",
|
|
315
|
+
err_name: e?.name,
|
|
316
|
+
err_type: e?.type,
|
|
317
|
+
err_code: e?.code,
|
|
318
|
+
decline_code: e?.decline_code,
|
|
319
|
+
cart_id: cart.id,
|
|
320
|
+
full_error_json: dump,
|
|
321
|
+
}
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
const translated = translatePaymentError(err, isCardPath ? "card" : "cod", labels.paymentErrors)
|
|
325
|
+
setErrorMessage(translated)
|
|
326
|
+
inFlightRef.current = false
|
|
327
|
+
setSubmitting(false)
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return (
|
|
332
|
+
<>
|
|
333
|
+
<OrderButton
|
|
334
|
+
onClick={handleClick}
|
|
335
|
+
disabled={disabled}
|
|
336
|
+
loading={submitting}
|
|
337
|
+
total={total ?? cart.total ?? undefined}
|
|
338
|
+
currencyCode={cart.currency_code}
|
|
339
|
+
testId={dataTestId}
|
|
340
|
+
label={labels.placeOrder}
|
|
341
|
+
/>
|
|
342
|
+
{/*
|
|
343
|
+
Cycling processing message. Fixed-height wrapper prevents layout
|
|
344
|
+
shift when the message appears/disappears. aria-live="polite"
|
|
345
|
+
announces each message swap to screen readers.
|
|
346
|
+
`key={messageIndex}` re-mounts the span on every cycle so
|
|
347
|
+
Tailwind's animate-in fade-in fires fresh each time.
|
|
348
|
+
*/}
|
|
349
|
+
<div
|
|
350
|
+
className="mt-3 h-5 flex items-center justify-center"
|
|
351
|
+
aria-live="polite"
|
|
352
|
+
>
|
|
353
|
+
{currentProcessingMessage && (
|
|
354
|
+
<span
|
|
355
|
+
key={messageIndex}
|
|
356
|
+
className="text-sm text-muted-foreground animate-in fade-in duration-500"
|
|
357
|
+
>
|
|
358
|
+
{currentProcessingMessage}
|
|
359
|
+
</span>
|
|
360
|
+
)}
|
|
361
|
+
</div>
|
|
362
|
+
<ErrorMessage
|
|
363
|
+
error={errorMessage}
|
|
364
|
+
data-testid={
|
|
365
|
+
isCardPath
|
|
366
|
+
? "stripe-payment-error-message"
|
|
367
|
+
: "manual-payment-error-message"
|
|
368
|
+
}
|
|
369
|
+
/>
|
|
370
|
+
</>
|
|
371
|
+
)
|
|
372
|
+
}
|