@cartbase/storefront 0.17.0 → 0.18.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 (107) hide show
  1. package/LICENSE +21 -21
  2. package/package.json +258 -256
  3. package/src/api/auth.ts +108 -108
  4. package/src/api/carts.ts +523 -523
  5. package/src/api/categories.ts +184 -184
  6. package/src/api/checkout.ts +526 -526
  7. package/src/api/collections.ts +130 -130
  8. package/src/api/consent.ts +75 -75
  9. package/src/api/content.ts +125 -125
  10. package/src/api/customers.ts +303 -303
  11. package/src/api/gift-cards.ts +112 -112
  12. package/src/api/http.ts +180 -180
  13. package/src/api/index.ts +30 -30
  14. package/src/api/menus.ts +77 -77
  15. package/src/api/metaobjects.ts +136 -136
  16. package/src/api/orders.ts +290 -290
  17. package/src/api/products.ts +43 -1
  18. package/src/api/redirects.ts +37 -37
  19. package/src/api/regions.ts +200 -200
  20. package/src/api/reviews.ts +259 -259
  21. package/src/api/search.ts +163 -163
  22. package/src/api/store.ts +35 -35
  23. package/src/api/types.ts +91 -91
  24. package/src/cart-drawer/context.tsx +277 -164
  25. package/src/cart-drawer/cross-sell-carousel.tsx +211 -211
  26. package/src/cart-drawer/cross-sell-sidebar.tsx +158 -158
  27. package/src/cart-drawer/gift-wrap.tsx +82 -82
  28. package/src/cart-drawer/index.ts +71 -68
  29. package/src/cart-drawer/item/index.tsx +162 -162
  30. package/src/cart-drawer/item/quantity.tsx +8 -14
  31. package/src/cart-drawer/item/upsell.tsx +110 -110
  32. package/src/cart-drawer/labels.ts +123 -123
  33. package/src/cart-drawer/mutation-queue.ts +78 -0
  34. package/src/cart-drawer/notes.tsx +131 -131
  35. package/src/cart-drawer/sticky-footer.tsx +73 -73
  36. package/src/cart-drawer/summary-breakdown.tsx +197 -197
  37. package/src/checkout/address-error-copy.ts +117 -117
  38. package/src/checkout/boxnow-locker-selector.tsx +410 -410
  39. package/src/checkout/compare-addresses.ts +40 -40
  40. package/src/checkout/discount-section.tsx +218 -218
  41. package/src/checkout/error-copy-codes.ts +63 -63
  42. package/src/checkout/geocode.ts +154 -154
  43. package/src/checkout/gift-card-section.tsx +224 -224
  44. package/src/checkout/index.ts +72 -72
  45. package/src/checkout/labels.ts +495 -495
  46. package/src/checkout/payment-button.tsx +372 -372
  47. package/src/checkout/payment-error-copy.ts +154 -154
  48. package/src/checkout/promotion-error-copy.ts +91 -91
  49. package/src/checkout/use-checkout-orchestration.ts +1592 -1592
  50. package/src/common/country-flag.tsx +52 -52
  51. package/src/common/country-select.tsx +11 -11
  52. package/src/common/index.ts +20 -20
  53. package/src/common/market-select.tsx +57 -57
  54. package/src/index.ts +12 -12
  55. package/src/lib/country-name.ts +59 -59
  56. package/src/lib/get-product-price.ts +133 -133
  57. package/src/lib/media-image.tsx +39 -39
  58. package/src/lib/payment-constants.ts +53 -53
  59. package/src/lib/platform.ts +13 -13
  60. package/src/lib/price.tsx +39 -39
  61. package/src/lib/store-api-error.ts +36 -36
  62. package/src/lib/variant-caption.ts +32 -32
  63. package/src/locales/bg.ts +467 -467
  64. package/src/locales/context.ts +37 -37
  65. package/src/locales/en.ts +26 -26
  66. package/src/locales/es.ts +466 -466
  67. package/src/locales/index.ts +19 -19
  68. package/src/locales/provider.tsx +59 -59
  69. package/src/locales/types.ts +77 -77
  70. package/src/order/index.ts +62 -62
  71. package/src/order/labels.ts +79 -79
  72. package/src/order/order-totals.tsx +250 -250
  73. package/src/primitives/select-field.tsx +93 -93
  74. package/src/products/image-gallery.tsx +43 -43
  75. package/src/products/index.ts +68 -59
  76. package/src/products/option-select.tsx +67 -67
  77. package/src/products/product-actions-wrapper.tsx +63 -62
  78. package/src/products/product-actions.tsx +151 -150
  79. package/src/products/product-price.tsx +69 -69
  80. package/src/products/product-promises.tsx +61 -61
  81. package/src/products/product-specs.tsx +65 -65
  82. package/src/products/product-tabs.tsx +123 -123
  83. package/src/products/product-template.tsx +163 -162
  84. package/src/products/purchase-options.tsx +130 -130
  85. package/src/products/sets.ts +110 -0
  86. package/src/products/use-product-actions.ts +199 -164
  87. package/src/products/variant-matching.ts +71 -71
  88. package/src/products/variant-url.ts +74 -74
  89. package/src/reviews-ui/index.ts +73 -73
  90. package/src/store/category-template.tsx +136 -136
  91. package/src/store/index.ts +40 -40
  92. package/src/tracking/chatgpt-pixel.tsx +99 -99
  93. package/src/tracking/consent-init.tsx +62 -62
  94. package/src/tracking/events.ts +348 -348
  95. package/src/tracking/ga4.tsx +93 -93
  96. package/src/tracking/google-ads.ts +84 -84
  97. package/src/tracking/gtm.tsx +60 -60
  98. package/src/tracking/inline-script.ts +49 -49
  99. package/src/tracking/oaiq.ts +206 -206
  100. package/src/tracking/tiktok-pixel.tsx +91 -91
  101. package/src/tracking/track-init.tsx +56 -56
  102. package/src/tracking/track-order-purchase.tsx +122 -122
  103. package/src/tracking/ttq.ts +180 -180
  104. package/src/tracking/use-tracking-config.ts +54 -54
  105. package/theme/index.css +25 -25
  106. package/theme/theme.css +150 -150
  107. package/theme/tokens.css +106 -106
@@ -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
+ }