@cartbase/storefront 0.8.0 → 0.10.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 +7 -8
  2. package/src/cart-drawer/cross-sell-carousel.tsx +2 -3
  3. package/src/cart-drawer/gift-wrap.tsx +82 -83
  4. package/src/cart-drawer/index.ts +0 -1
  5. package/src/cart-drawer/item/index.tsx +4 -6
  6. package/src/cart-drawer/sticky-footer.tsx +73 -73
  7. package/src/checkout/address-error-copy.ts +117 -119
  8. package/src/checkout/discount-section.tsx +218 -218
  9. package/src/checkout/error-copy-codes.ts +63 -0
  10. package/src/checkout/gift-card-section.tsx +4 -4
  11. package/src/checkout/index.ts +4 -5
  12. package/src/checkout/labels.ts +218 -0
  13. package/src/checkout/line-item-card.tsx +3 -3
  14. package/src/checkout/order-summary.tsx +11 -12
  15. package/src/checkout/payment-button.tsx +372 -373
  16. package/src/checkout/payment-error-copy.ts +154 -269
  17. package/src/checkout/promotion-error-copy.ts +91 -124
  18. package/src/checkout/shipping-method-list.tsx +2 -2
  19. package/src/checkout/use-checkout-orchestration.ts +10 -8
  20. package/src/lib/money.ts +1 -1
  21. package/src/lib/price.tsx +39 -0
  22. package/src/locales/bg.ts +468 -0
  23. package/src/locales/en.ts +26 -0
  24. package/src/locales/es.ts +467 -0
  25. package/src/locales/index.ts +19 -0
  26. package/src/locales/provider.tsx +63 -0
  27. package/src/locales/types.ts +49 -0
  28. package/src/order/index.ts +0 -1
  29. package/src/order/order-delivery-card.tsx +2 -2
  30. package/src/order/order-item.tsx +3 -3
  31. package/src/order/order-totals.tsx +4 -4
  32. package/src/products/index.ts +0 -1
  33. package/src/reviews-ui/index.ts +0 -1
  34. package/src/store/index.ts +0 -1
  35. package/src/tracking/attribution.ts +13 -0
  36. package/src/tracking/chatgpt-pixel.tsx +92 -0
  37. package/src/tracking/consent-banner.tsx +5 -1
  38. package/src/tracking/consent.ts +12 -0
  39. package/src/tracking/events.ts +56 -2
  40. package/src/tracking/index.ts +12 -0
  41. package/src/tracking/oaiq.ts +206 -0
  42. package/src/tracking/storefront-tags.tsx +2 -0
  43. package/src/tracking/types.ts +4 -0
  44. package/src/cart-drawer/labels-bg.ts +0 -72
  45. package/src/checkout/labels-bg.ts +0 -128
  46. package/src/lib/dual-price.tsx +0 -73
  47. package/src/order/labels-bg.ts +0 -39
  48. package/src/products/labels-bg.ts +0 -35
  49. package/src/reviews-ui/labels-bg.ts +0 -91
  50. package/src/store/labels-bg.ts +0 -22
@@ -1,373 +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 { DualPrice } from "../lib/dual-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
- <DualPrice
157
- amount={total}
158
- currencyCode={currencyCode}
159
- className="font-semibold"
160
- bgnClassName="text-card/70 text-[11px] ml-1"
161
- />
162
- </span>
163
- )}
164
- </span>
165
- </>
166
- )}
167
- </button>
168
- )
169
- }
170
-
171
- export function PaymentButton({
172
- cart,
173
- paymentTab,
174
- notReady,
175
- performBuyClick,
176
- paymentElementComplete = true,
177
- total,
178
- logError,
179
- "data-testid": dataTestId,
180
- }: PaymentButtonProps) {
181
- const labels = useCheckoutLabels()
182
- const stripeReady = useContext(StripeContext)
183
- // `useStripe()`/`useElements()` THROW when no <Elements> provider is
184
- // mounted which is the documented state on COD/pp_manual-only stores
185
- // without Stripe credentials (`StripeElementsScope` passthrough mode,
186
- // components.md). Tolerate the missing scope: null here is equivalent
187
- // to "Stripe not ready", and the card path is additionally gated by
188
- // `stripeReady`. Hook order stays stable across renders because the
189
- // provider's presence is fixed per mount (module-level Stripe key).
190
- let stripe: ReturnType<typeof useStripe> = null
191
- let elements: ReturnType<typeof useElements> = null
192
- try {
193
- stripe = useStripe()
194
- elements = useElements()
195
- } catch {
196
- // no Elements scope (COD-only store) — proceed with stripe = null
197
- }
198
- const [submitting, setSubmitting] = useState(false)
199
- const [errorMessage, setErrorMessage] = useState<string | null>(null)
200
-
201
- // ── Processing-state cycling messages ───────────────────────────────
202
- // While `submitting` is true, cycle through a short list of messages
203
- // narrating what's actually happening server-side. Loops back to 0
204
- // on long flows (3DS challenges, slow networks). Card and COD have
205
- // separate sequences because the card path includes a bank handshake
206
- // step that doesn't apply to COD.
207
- //
208
- // Why this exists: a spinning button alone reads as "thinking" but
209
- // not "thinking about WHAT" customers get nervous on the 3-30s
210
- // wait. Stripe / Booking.com / Apple Pay all narrate the same way.
211
- // Trust > novelty during the moment money leaves the account.
212
- const [messageIndex, setMessageIndex] = useState(0)
213
- useEffect(() => {
214
- if (!submitting) {
215
- setMessageIndex(0)
216
- return
217
- }
218
- const messages =
219
- paymentTab === "card" ? labels.processingCard : labels.processingCod
220
- if (!messages || messages.length === 0) return
221
- const id = setInterval(() => {
222
- setMessageIndex((i) => (i + 1) % messages.length)
223
- }, PROCESSING_MESSAGE_INTERVAL_MS)
224
- return () => clearInterval(id)
225
- }, [submitting, paymentTab, labels.processingCard, labels.processingCod])
226
-
227
- const processingMessages =
228
- paymentTab === "card" ? labels.processingCard : labels.processingCod
229
- const currentProcessingMessage =
230
- submitting && processingMessages?.length
231
- ? processingMessages[messageIndex % processingMessages.length]
232
- : null
233
-
234
- // Hard re-entry guard. `submitting` state alone isn't enough: React
235
- // batches state updates so two synchronous clicks both pass the
236
- // submitting check before the first one's setState commits. A ref
237
- // flips synchronously and blocks the second click cold.
238
- const inFlightRef = useRef(false)
239
-
240
- const isCardPath = paymentTab === "card"
241
- const cardReady = !isCardPath || (stripeReady && !!stripe && !!elements)
242
- const cardComplete = !isCardPath || paymentElementComplete
243
-
244
- const disabled = notReady || !cardReady || !cardComplete || submitting
245
-
246
- const handleClick = async () => {
247
- if (inFlightRef.current) return
248
- inFlightRef.current = true
249
- setSubmitting(true)
250
- setErrorMessage(null)
251
-
252
- try {
253
- const stripeBundle: BuyClickStripeBundle | undefined =
254
- isCardPath && stripe && elements
255
- ? {
256
- submit: () => elements.submit(),
257
- stripe: stripe as unknown as BuyClickStripeBundle["stripe"],
258
- elements,
259
- }
260
- : undefined
261
-
262
- await performBuyClick(stripeBundle)
263
-
264
- // performBuyClick navigates on success (placeOrder
265
- // window.location.assign / the host's onOrderPlaced router push).
266
- // If we get here without a navigation Stripe returned a
267
- // non-terminal status (processing, requires_action without
268
- // auto-redirect, etc.) release the lock so the user can retry.
269
- inFlightRef.current = false
270
- setSubmitting(false)
271
- } catch (err: unknown) {
272
- // Next.js's `redirect()` throws a NEXT_REDIRECT error that is
273
- // success, not failure. The Cartbase default path navigates via
274
- // window.location.assign (no throw), but a store routing
275
- // `onOrderPlaced` through a server action still hits this; don't
276
- // translate it let it propagate so the navigation happens.
277
- const e = err as {
278
- digest?: string
279
- message?: string
280
- type?: string
281
- code?: string
282
- decline_code?: string
283
- name?: string
284
- stack?: string
285
- }
286
- const isNextRedirect =
287
- typeof e?.digest === "string" && e.digest.startsWith("NEXT_REDIRECT")
288
- if (isNextRedirect) {
289
- throw err
290
- }
291
-
292
- // eslint-disable-next-line no-console
293
- console.error("[buy-click] FAILED", {
294
- message: e?.message,
295
- raw: err,
296
- })
297
-
298
- // Operational sink so the actual error is readable without needing
299
- // the customer's browser console. This is the catch-all for any
300
- // error that escapes performBuyClick (stripe.confirmPayment,
301
- // prepareCheckout, placeOrder).
302
- let dump = ""
303
- try {
304
- dump = JSON.stringify(err, Object.getOwnPropertyNames(err as object)).slice(
305
- 0,
306
- 2000
307
- )
308
- } catch {
309
- dump = String(err)
310
- }
311
- logError?.(
312
- "place_order_error",
313
- e?.message ?? String(err) ?? "unknown",
314
- {
315
- path: isCardPath ? "card" : "cod",
316
- err_name: e?.name,
317
- err_type: e?.type,
318
- err_code: e?.code,
319
- decline_code: e?.decline_code,
320
- cart_id: cart.id,
321
- full_error_json: dump,
322
- }
323
- )
324
-
325
- const translated = translatePaymentError(err, isCardPath ? "card" : "cod")
326
- setErrorMessage(translated)
327
- inFlightRef.current = false
328
- setSubmitting(false)
329
- }
330
- }
331
-
332
- return (
333
- <>
334
- <OrderButton
335
- onClick={handleClick}
336
- disabled={disabled}
337
- loading={submitting}
338
- total={total ?? cart.total ?? undefined}
339
- currencyCode={cart.currency_code}
340
- testId={dataTestId}
341
- label={labels.placeOrder}
342
- />
343
- {/*
344
- Cycling processing message. Fixed-height wrapper prevents layout
345
- shift when the message appears/disappears. aria-live="polite"
346
- announces each message swap to screen readers.
347
- `key={messageIndex}` re-mounts the span on every cycle so
348
- Tailwind's animate-in fade-in fires fresh each time.
349
- */}
350
- <div
351
- className="mt-3 h-5 flex items-center justify-center"
352
- aria-live="polite"
353
- >
354
- {currentProcessingMessage && (
355
- <span
356
- key={messageIndex}
357
- className="text-sm text-muted-foreground animate-in fade-in duration-500"
358
- >
359
- {currentProcessingMessage}
360
- </span>
361
- )}
362
- </div>
363
- <ErrorMessage
364
- error={errorMessage}
365
- data-testid={
366
- isCardPath
367
- ? "stripe-payment-error-message"
368
- : "manual-payment-error-message"
369
- }
370
- />
371
- </>
372
- )
373
- }
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
+ }