@cartbase/storefront 0.20.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.
Files changed (99) hide show
  1. package/LICENSE +21 -21
  2. package/package.json +258 -258
  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/redirects.ts +37 -37
  18. package/src/api/regions.ts +200 -200
  19. package/src/api/search.ts +163 -163
  20. package/src/api/store.ts +35 -35
  21. package/src/api/types.ts +91 -91
  22. package/src/cart-drawer/context.tsx +778 -778
  23. package/src/cart-drawer/cross-sell-carousel.tsx +211 -211
  24. package/src/cart-drawer/cross-sell-sidebar.tsx +158 -158
  25. package/src/cart-drawer/gift-wrap.tsx +82 -82
  26. package/src/cart-drawer/item/index.tsx +162 -162
  27. package/src/cart-drawer/item/upsell.tsx +110 -110
  28. package/src/cart-drawer/labels.ts +123 -123
  29. package/src/cart-drawer/mutation-queue.ts +78 -78
  30. package/src/cart-drawer/notes.tsx +131 -131
  31. package/src/cart-drawer/sticky-footer.tsx +73 -73
  32. package/src/cart-drawer/summary-breakdown.tsx +197 -197
  33. package/src/checkout/address-error-copy.ts +117 -117
  34. package/src/checkout/boxnow-locker-selector.tsx +410 -410
  35. package/src/checkout/compare-addresses.ts +40 -40
  36. package/src/checkout/discount-section.tsx +218 -218
  37. package/src/checkout/error-copy-codes.ts +63 -63
  38. package/src/checkout/geocode.ts +154 -154
  39. package/src/checkout/gift-card-section.tsx +224 -224
  40. package/src/checkout/index.ts +72 -72
  41. package/src/checkout/labels.ts +495 -495
  42. package/src/checkout/payment-button.tsx +372 -372
  43. package/src/checkout/payment-error-copy.ts +154 -154
  44. package/src/checkout/promotion-error-copy.ts +91 -91
  45. package/src/common/country-flag.tsx +52 -52
  46. package/src/common/country-select.tsx +11 -11
  47. package/src/common/index.ts +20 -20
  48. package/src/common/market-select.tsx +57 -57
  49. package/src/index.ts +12 -12
  50. package/src/lib/country-name.ts +59 -59
  51. package/src/lib/get-product-price.ts +133 -133
  52. package/src/lib/media-image.tsx +39 -39
  53. package/src/lib/payment-constants.ts +53 -53
  54. package/src/lib/platform.ts +13 -13
  55. package/src/lib/price.tsx +39 -39
  56. package/src/lib/store-api-error.ts +36 -36
  57. package/src/lib/variant-caption.ts +32 -32
  58. package/src/locales/context.ts +37 -37
  59. package/src/locales/en.ts +26 -26
  60. package/src/locales/index.ts +19 -19
  61. package/src/locales/provider.tsx +59 -59
  62. package/src/locales/types.ts +77 -77
  63. package/src/order/index.ts +62 -62
  64. package/src/order/labels.ts +79 -79
  65. package/src/order/order-totals.tsx +250 -250
  66. package/src/primitives/select-field.tsx +93 -93
  67. package/src/products/image-gallery.tsx +43 -43
  68. package/src/products/option-select.tsx +67 -67
  69. package/src/products/product-info.tsx +5 -5
  70. package/src/products/product-price.tsx +69 -69
  71. package/src/products/product-promises.tsx +61 -61
  72. package/src/products/product-specs.tsx +65 -65
  73. package/src/products/product-tabs.tsx +123 -123
  74. package/src/products/purchase-options.tsx +130 -130
  75. package/src/products/sets.ts +110 -110
  76. package/src/products/variant-matching.ts +71 -71
  77. package/src/products/variant-url.ts +74 -74
  78. package/src/reviews-ui/lightbox-state.ts +46 -46
  79. package/src/reviews-ui/review-header.tsx +166 -166
  80. package/src/reviews-ui/review-lightbox.tsx +271 -271
  81. package/src/reviews-ui/review-list.tsx +193 -193
  82. package/src/reviews-ui/review-widget.tsx +219 -218
  83. package/src/reviews-ui/widget-options.ts +55 -55
  84. package/src/store/category-template.tsx +136 -136
  85. package/src/store/index.ts +40 -40
  86. package/src/tracking/chatgpt-pixel.tsx +99 -99
  87. package/src/tracking/consent-init.tsx +62 -62
  88. package/src/tracking/events.ts +348 -348
  89. package/src/tracking/ga4.tsx +93 -93
  90. package/src/tracking/google-ads.ts +84 -84
  91. package/src/tracking/gtm.tsx +60 -60
  92. package/src/tracking/inline-script.ts +49 -49
  93. package/src/tracking/oaiq.ts +206 -206
  94. package/src/tracking/tiktok-pixel.tsx +91 -91
  95. package/src/tracking/track-init.tsx +56 -56
  96. package/src/tracking/track-order-purchase.tsx +122 -122
  97. package/src/tracking/ttq.ts +180 -180
  98. package/src/tracking/use-tracking-config.ts +54 -54
  99. 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
+ }