@cartbase/storefront 0.1.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 (176) hide show
  1. package/package.json +226 -0
  2. package/src/api/auth.ts +108 -0
  3. package/src/api/carts.ts +506 -0
  4. package/src/api/categories.ts +184 -0
  5. package/src/api/checkout.ts +440 -0
  6. package/src/api/collections.ts +130 -0
  7. package/src/api/consent.ts +75 -0
  8. package/src/api/content.ts +125 -0
  9. package/src/api/customers.ts +307 -0
  10. package/src/api/gift-cards.ts +112 -0
  11. package/src/api/http.ts +122 -0
  12. package/src/api/index.ts +29 -0
  13. package/src/api/integrations.ts +130 -0
  14. package/src/api/menus.ts +77 -0
  15. package/src/api/metaobjects.ts +136 -0
  16. package/src/api/orders.ts +290 -0
  17. package/src/api/products.ts +303 -0
  18. package/src/api/redirects.ts +37 -0
  19. package/src/api/regions.ts +141 -0
  20. package/src/api/reviews.ts +259 -0
  21. package/src/api/search.ts +133 -0
  22. package/src/api/types.ts +91 -0
  23. package/src/cart-drawer/cart-drawer.tsx +86 -0
  24. package/src/cart-drawer/context.tsx +569 -0
  25. package/src/cart-drawer/continue-shopping.tsx +28 -0
  26. package/src/cart-drawer/cross-sell-carousel.tsx +212 -0
  27. package/src/cart-drawer/cross-sell-sidebar.tsx +158 -0
  28. package/src/cart-drawer/empty.tsx +52 -0
  29. package/src/cart-drawer/free-gift.tsx +71 -0
  30. package/src/cart-drawer/gift-wrap.tsx +83 -0
  31. package/src/cart-drawer/header.tsx +52 -0
  32. package/src/cart-drawer/index.ts +69 -0
  33. package/src/cart-drawer/item/index.tsx +164 -0
  34. package/src/cart-drawer/item/quantity.tsx +100 -0
  35. package/src/cart-drawer/item/upsell.tsx +110 -0
  36. package/src/cart-drawer/item/variant.tsx +46 -0
  37. package/src/cart-drawer/labels-bg.ts +72 -0
  38. package/src/cart-drawer/labels.ts +119 -0
  39. package/src/cart-drawer/notes.tsx +131 -0
  40. package/src/cart-drawer/payment-badges.tsx +96 -0
  41. package/src/cart-drawer/promo-banner.tsx +43 -0
  42. package/src/cart-drawer/rewards-points.tsx +78 -0
  43. package/src/cart-drawer/sticky-footer.tsx +73 -0
  44. package/src/cart-drawer/summary-breakdown.tsx +196 -0
  45. package/src/cart-drawer/template.tsx +225 -0
  46. package/src/cart-drawer/tiered-progress.tsx +168 -0
  47. package/src/checkout/address-error-copy.ts +119 -0
  48. package/src/checkout/address-form.tsx +224 -0
  49. package/src/checkout/address-select.tsx +79 -0
  50. package/src/checkout/boxnow-locker-selector.tsx +410 -0
  51. package/src/checkout/checkout-client.tsx +222 -0
  52. package/src/checkout/company-details.tsx +94 -0
  53. package/src/checkout/compare-addresses.ts +40 -0
  54. package/src/checkout/context.tsx +76 -0
  55. package/src/checkout/discount-section.tsx +218 -0
  56. package/src/checkout/econt-office-selector.tsx +332 -0
  57. package/src/checkout/error-message.tsx +25 -0
  58. package/src/checkout/geocode.ts +154 -0
  59. package/src/checkout/gift-card-section.tsx +224 -0
  60. package/src/checkout/index.ts +74 -0
  61. package/src/checkout/labels-bg.ts +128 -0
  62. package/src/checkout/labels.ts +263 -0
  63. package/src/checkout/line-item-card.tsx +152 -0
  64. package/src/checkout/order-summary.tsx +524 -0
  65. package/src/checkout/payment-button.tsx +373 -0
  66. package/src/checkout/payment-error-copy.ts +269 -0
  67. package/src/checkout/payment-method-list.tsx +365 -0
  68. package/src/checkout/payment-wrapper.tsx +102 -0
  69. package/src/checkout/promotion-error-copy.ts +124 -0
  70. package/src/checkout/shipping-method-list.tsx +335 -0
  71. package/src/checkout/stripe-wrapper.tsx +165 -0
  72. package/src/checkout/use-checkout-orchestration.ts +1504 -0
  73. package/src/common/cart-button-client.tsx +39 -0
  74. package/src/common/cart-button.tsx +28 -0
  75. package/src/common/country-select.tsx +65 -0
  76. package/src/common/delete-button.tsx +66 -0
  77. package/src/common/index.ts +17 -0
  78. package/src/common/language-select.tsx +78 -0
  79. package/src/common/localized-link.tsx +45 -0
  80. package/src/common/skeleton.tsx +29 -0
  81. package/src/index.ts +12 -0
  82. package/src/lib/cart-helpers.ts +113 -0
  83. package/src/lib/dual-price.tsx +73 -0
  84. package/src/lib/get-percentage-diff.ts +5 -0
  85. package/src/lib/get-product-price.ts +133 -0
  86. package/src/lib/hooks/use-intersection.ts +30 -0
  87. package/src/lib/hooks/use-toggle-state.ts +25 -0
  88. package/src/lib/money.ts +73 -0
  89. package/src/lib/payment-constants.ts +66 -0
  90. package/src/lib/product.ts +22 -0
  91. package/src/lib/sort-products.ts +63 -0
  92. package/src/lib/store-api-error.ts +36 -0
  93. package/src/lib/utils.ts +16 -0
  94. package/src/order/context.tsx +32 -0
  95. package/src/order/index.ts +63 -0
  96. package/src/order/labels-bg.ts +39 -0
  97. package/src/order/labels.ts +79 -0
  98. package/src/order/order-address-card.tsx +47 -0
  99. package/src/order/order-completed-template.tsx +165 -0
  100. package/src/order/order-confirmation-header.tsx +65 -0
  101. package/src/order/order-delivery-card.tsx +258 -0
  102. package/src/order/order-help-section.tsx +47 -0
  103. package/src/order/order-item.tsx +201 -0
  104. package/src/order/order-items-list.tsx +52 -0
  105. package/src/order/order-payment-card.tsx +95 -0
  106. package/src/order/order-timeline.tsx +141 -0
  107. package/src/order/order-totals.tsx +245 -0
  108. package/src/primitives/field.tsx +125 -0
  109. package/src/primitives/select-field.tsx +77 -0
  110. package/src/primitives/ui/accordion.tsx +61 -0
  111. package/src/primitives/ui/button.tsx +68 -0
  112. package/src/primitives/ui/collapsible.tsx +16 -0
  113. package/src/primitives/ui/dialog.tsx +112 -0
  114. package/src/primitives/ui/input.tsx +30 -0
  115. package/src/primitives/ui/label.tsx +31 -0
  116. package/src/primitives/ui/popover.tsx +38 -0
  117. package/src/primitives/ui/select.tsx +163 -0
  118. package/src/primitives/ui/sheet.tsx +131 -0
  119. package/src/primitives/ui/tabs.tsx +62 -0
  120. package/src/products/context.tsx +34 -0
  121. package/src/products/image-gallery.tsx +43 -0
  122. package/src/products/index.ts +44 -0
  123. package/src/products/labels-bg.ts +35 -0
  124. package/src/products/labels.ts +57 -0
  125. package/src/products/mobile-actions.tsx +180 -0
  126. package/src/products/option-select.tsx +67 -0
  127. package/src/products/preview-price.tsx +36 -0
  128. package/src/products/product-actions-wrapper.tsx +58 -0
  129. package/src/products/product-actions.tsx +217 -0
  130. package/src/products/product-info.tsx +43 -0
  131. package/src/products/product-preview.tsx +49 -0
  132. package/src/products/product-price.tsx +69 -0
  133. package/src/products/product-tabs.tsx +169 -0
  134. package/src/products/product-template.tsx +114 -0
  135. package/src/products/purchase-options.tsx +130 -0
  136. package/src/products/related-products.tsx +86 -0
  137. package/src/products/thumbnail.tsx +71 -0
  138. package/src/products/variant-matching.ts +71 -0
  139. package/src/reviews-ui/helpers.ts +174 -0
  140. package/src/reviews-ui/index.ts +74 -0
  141. package/src/reviews-ui/labels-bg.ts +91 -0
  142. package/src/reviews-ui/labels.ts +199 -0
  143. package/src/reviews-ui/photo-upload.tsx +345 -0
  144. package/src/reviews-ui/review-list.tsx +249 -0
  145. package/src/reviews-ui/review-widget.tsx +224 -0
  146. package/src/reviews-ui/review-wizard.tsx +560 -0
  147. package/src/reviews-ui/star-badge.tsx +104 -0
  148. package/src/reviews-ui/wizard-state.ts +81 -0
  149. package/src/store/category-template.tsx +129 -0
  150. package/src/store/collection-template.tsx +139 -0
  151. package/src/store/index.ts +41 -0
  152. package/src/store/labels-bg.ts +22 -0
  153. package/src/store/labels.ts +52 -0
  154. package/src/store/paginated-products.tsx +116 -0
  155. package/src/store/pagination.tsx +103 -0
  156. package/src/store/search-params.ts +256 -0
  157. package/src/store/search-template.tsx +249 -0
  158. package/src/store/skeleton-product-grid.tsx +26 -0
  159. package/src/store/sort-select.tsx +81 -0
  160. package/src/store/store-template.tsx +65 -0
  161. package/src/tracking/attribution.ts +418 -0
  162. package/src/tracking/consent-banner.tsx +355 -0
  163. package/src/tracking/consent-init.tsx +44 -0
  164. package/src/tracking/consent.ts +243 -0
  165. package/src/tracking/fbq.ts +168 -0
  166. package/src/tracking/ga4.tsx +49 -0
  167. package/src/tracking/get-tracking-attribution.ts +224 -0
  168. package/src/tracking/get-tracking-config.ts +50 -0
  169. package/src/tracking/gtag.ts +200 -0
  170. package/src/tracking/index.ts +133 -0
  171. package/src/tracking/meta-pixel.tsx +166 -0
  172. package/src/tracking/rybbit-events.ts +242 -0
  173. package/src/tracking/rybbit.tsx +40 -0
  174. package/src/tracking/types.ts +185 -0
  175. package/src/tracking/use-engagement-time.ts +58 -0
  176. package/tailwind-preset.cjs +72 -0
@@ -0,0 +1,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 { 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 barter data seam: the `cart` prop is structural (only `id`, `total`,
22
+ * `currency_code` are read — any decorated barter cart satisfies it), and
23
+ * the `logCheckoutError` Supabase writer becomes the optional `logError`
24
+ * callback (barter 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 barter 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
+ }
@@ -0,0 +1,269 @@
1
+ /**
2
+ * Translates payment / order-completion errors into user-friendly
3
+ * Bulgarian copy for the checkout payment surface.
4
+ *
5
+ * Ported from `@1click/ui/src/checkout/payment-error-copy.ts` (v2.3.1) and
6
+ * adapted to the barter error envelope: the store API throws `StoreApiError`
7
+ * with a STRUCTURED `code` (docs/storefront/checkout.md lists every code per
8
+ * endpoint), so translation is **code-first** — the substring patterns from
9
+ * the Medusa era are kept as the fallback layer for Stripe.js browser errors
10
+ * (which have no barter code) and any legacy wire.
11
+ *
12
+ * Why this exists: Stripe and API errors are technical English strings
13
+ * ("Your card's security code is incorrect.", "fetch failed"). Showing them
14
+ * to a Bulgarian shopper at the moment of payment failure is the worst UX
15
+ * surface possible — it looks like the site is broken, not like the customer
16
+ * made a typo. Unknown errors get the generic fallback — showing a
17
+ * half-translated message is worse than a clean fallback.
18
+ */
19
+
20
+ type ErrorContext = "card" | "cod"
21
+
22
+ const GENERIC_CARD =
23
+ "Плащането не може да бъде обработено в момента. Моля, опитайте отново или изберете друг метод на плащане."
24
+
25
+ const GENERIC_COD =
26
+ "Поръчката не може да бъде финализирана в момента. Моля, опитайте отново след малко."
27
+
28
+ /**
29
+ * Barter store-API error codes → BG copy. Covers EVERY code documented for
30
+ * POST /api/store/carts/:id/complete, prepare-checkout,
31
+ * sync-payment-amount and refresh-payment-if-terminal
32
+ * (docs/storefront/checkout.md + carts.completeCart JSDoc). Code-first:
33
+ * matched before any substring pattern.
34
+ */
35
+ export const PAYMENT_ERROR_CODE_COPY: Record<string, string> = {
36
+ // — complete: validation family (400) —
37
+ cart_email_required:
38
+ "Въведете имейл адрес, за да завършите поръчката.",
39
+ cart_empty: "Количката е празна. Добавете продукти, за да продължите.",
40
+ shipping_address_required:
41
+ "Въведете адрес за доставка, за да завършите поръчката.",
42
+ shipping_method_required:
43
+ "Изберете метод на доставка преди да продължите.",
44
+ payment_collection_required:
45
+ "Плащането не е подготвено. Моля, опитайте отново.",
46
+ payment_session_required:
47
+ "Плащането не е подготвено. Моля, опитайте отново.",
48
+ insufficient_inventory:
49
+ "Един от продуктите вече не е наличен в избраното количество. Моля, обновете количката.",
50
+ // — checkout-rules security boundary (400) —
51
+ checkout_method_hidden:
52
+ "Избраният метод вече не е наличен за тази поръчка. Моля, изберете друг метод и опитайте отново.",
53
+ // — payment family (402) —
54
+ requires_action:
55
+ "Банката изисква допълнително потвърждение. Моля, следвайте инструкциите.",
56
+ payment_not_authorized:
57
+ "Плащането не беше одобрено. Моля, опитайте отново или изберете друг метод.",
58
+ payment_not_initiated:
59
+ "Сесията за плащане е изтекла. Моля, презаредете страницата и опитайте отново.",
60
+ payment_incomplete:
61
+ "Картите за подарък вече не покриват цялата сума. Моля, изберете начин на плащане за остатъка.",
62
+ gift_card_insufficient_balance:
63
+ "Наличността по картата за подарък вече не е достатъчна. Моля, премахнете я или изберете друг начин на плащане.",
64
+ gift_card_not_redeemable:
65
+ "Картата за подарък вече не може да бъде използвана. Моля, премахнете я и опитайте отново.",
66
+ // — access / concurrency —
67
+ account_required:
68
+ "За да завършите поръчката, е необходимо да влезете в профила си.",
69
+ cart_locked:
70
+ "Поръчката се обработва в момента. Моля, изчакайте няколко секунди.",
71
+ cart_completed:
72
+ "Тази поръчка вече беше финализирана. Моля, проверете имейла си за потвърждение.",
73
+ cart_not_found: "Сесията на количката изтече. Моля, презаредете страницата.",
74
+ // — prepare-checkout family —
75
+ shipping_option_not_found:
76
+ "Избраният метод на доставка вече не е наличен. Моля, изберете друг.",
77
+ invalid_provider:
78
+ "Избраният начин на плащане не е валиден. Моля, изберете друг метод.",
79
+ shipping_price_missing:
80
+ "Цената за доставка не може да бъде определена. Моля, изберете друг метод на доставка.",
81
+ stripe_not_configured:
82
+ "Плащането с карта временно не е налично. Моля, изберете друг метод.",
83
+ validation_failed:
84
+ "Данните за поръчката са непълни. Моля, проверете формата и опитайте отново.",
85
+ }
86
+
87
+ /**
88
+ * Gift-card apply/remove codes → BG copy (POST/DELETE
89
+ * /api/store/carts/:id/gift-cards — docs/storefront/gift-cards.md).
90
+ * The API deliberately answers unknown/disabled/expired/depleted/foreign
91
+ * codes with ONE generic `invalid_gift_card` (no code-existence oracle) —
92
+ * the copy mirrors that: one honest generic message.
93
+ */
94
+ export const GIFT_CARD_ERROR_CODE_COPY: Record<string, string> = {
95
+ invalid_gift_card:
96
+ "Кодът на картата за подарък не е валиден. Моля, проверете изписването.",
97
+ rate_limited:
98
+ "Твърде много опити. Моля, изчакайте няколко минути и опитайте отново.",
99
+ cart_completed:
100
+ "Поръчката вече е финализирана. Моля, проверете имейла си за потвърждение.",
101
+ cart_not_found: "Сесията на количката изтече. Моля, презаредете страницата.",
102
+ validation_failed:
103
+ "Невалиден код. Моля, проверете изписването и опитайте отново.",
104
+ }
105
+
106
+ const GIFT_CARD_GENERIC =
107
+ "Картата за подарък не може да бъде приложена в момента. Моля, опитайте отново."
108
+
109
+ /**
110
+ * Map of Stripe error codes / decline codes / common substrings to BG copy.
111
+ * Substring match is case-insensitive. First match wins, so order from
112
+ * most-specific to most-generic. Kept verbatim from the production port —
113
+ * Stripe.js browser errors carry no barter `code`, so this layer still does
114
+ * the heavy lifting on the card path.
115
+ *
116
+ * Stripe error code reference:
117
+ * https://docs.stripe.com/error-codes
118
+ */
119
+ const STRIPE_PATTERNS: Array<{ match: RegExp; copy: string }> = [
120
+ // Card declined — generic
121
+ {
122
+ match: /card[\s_-]?declined|card_declined/i,
123
+ copy: "Картата е отказана от банката. Моля, опитайте с друга карта или се свържете с банката си.",
124
+ },
125
+ // Insufficient funds
126
+ {
127
+ match: /insufficient[\s_]?funds/i,
128
+ copy: "Недостатъчна наличност по картата. Моля, опитайте с друга карта.",
129
+ },
130
+ // Expired card
131
+ {
132
+ match: /expired[\s_]?card|card[\s_]?has[\s_]?expired/i,
133
+ copy: "Картата е изтекла. Моля, опитайте с друга карта.",
134
+ },
135
+ // Wrong CVC / security code
136
+ {
137
+ match: /incorrect[\s_]?cvc|invalid[\s_]?cvc|cvc[\s_]?check/i,
138
+ copy: "Невалиден код за сигурност (CVC). Моля, проверете трите цифри на гърба на картата.",
139
+ },
140
+ // Wrong card number
141
+ {
142
+ match: /incorrect[\s_]?number|invalid[\s_]?number/i,
143
+ copy: "Невалиден номер на карта. Моля, проверете цифрите.",
144
+ },
145
+ // Wrong / invalid expiry
146
+ {
147
+ match: /invalid[\s_]?expir|incorrect[\s_]?expir/i,
148
+ copy: "Невалидна дата на изтичане. Моля, проверете месеца и годината.",
149
+ },
150
+ // 3DS / authentication required
151
+ {
152
+ match: /authentication[\s_]?required|3d[\s_]?secure/i,
153
+ copy: "Банката изисква допълнително потвърждение. Моля, следвайте инструкциите.",
154
+ },
155
+ // Processing error
156
+ {
157
+ match: /processing[\s_]?error/i,
158
+ copy: "Грешка при обработка на картата. Моля, опитайте отново след малко.",
159
+ },
160
+ // Rate limit
161
+ {
162
+ match: /rate[\s_]?limit/i,
163
+ copy: "Твърде много опити за плащане. Моля, изчакайте няколко минути и опитайте отново.",
164
+ },
165
+ // Terminal-state PI (the bug class refresh-payment-if-terminal heals)
166
+ {
167
+ match: /terminal[\s_]?state|payment[\s_]?intent[\s_]?(?:is|in)[\s_]?(?:a[\s_]?)?terminal/i,
168
+ copy: "Сесията за плащане е изтекла. Моля, презаредете страницата и опитайте отново.",
169
+ },
170
+ // Amount mismatch — happens when cart total drifts from PI amount
171
+ {
172
+ match: /amount[\s_]?mismatch|amount[\s_]?does[\s_]?not[\s_]?match/i,
173
+ copy: "Сумата на поръчката се промени. Моля, презаредете страницата и опитайте отново.",
174
+ },
175
+ // Network / fetch failure
176
+ {
177
+ match: /failed[\s_]?to[\s_]?fetch|network[\s_]?error|networkerror/i,
178
+ copy: "Няма връзка със сървъра. Моля, проверете интернет връзката и опитайте отново.",
179
+ },
180
+ ]
181
+
182
+ /** Legacy Medusa-era API substrings — kept as the last fallback layer. */
183
+ const API_PATTERNS: Array<{ match: RegExp; copy: string }> = [
184
+ // Cart already completed — likely a double-submit racing through
185
+ {
186
+ match: /cart[\s_]?(?:is[\s_]?)?already[\s_]?completed|already[\s_]?an[\s_]?order/i,
187
+ copy: "Тази поръчка вече беше финализирана. Моля, проверете имейла си за потвърждение.",
188
+ },
189
+ // Out of stock
190
+ {
191
+ match: /not[\s_]?enough[\s_]?stock|insufficient[\s_]?stock|out[\s_]?of[\s_]?stock|insufficient[\s_]?inventory/i,
192
+ copy: "Един от продуктите вече не е наличен в избраното количество. Моля, обновете количката.",
193
+ },
194
+ // Region / country mismatch
195
+ {
196
+ match: /no[\s_]?region|invalid[\s_]?region/i,
197
+ copy: "Регионът на доставка не е валиден. Моля, презаредете страницата.",
198
+ },
199
+ // No shipping method
200
+ {
201
+ match: /no[\s_]?shipping[\s_]?method|shipping[\s_]?method[\s_]?not[\s_]?found/i,
202
+ copy: "Изберете метод на доставка преди да продължите.",
203
+ },
204
+ ]
205
+
206
+ /** Pull the structured barter error code out of any thrown value. */
207
+ function extractCode(err: unknown): string | null {
208
+ if (err && typeof err === "object") {
209
+ const code = (err as { code?: unknown }).code
210
+ if (typeof code === "string" && code.length > 0) return code
211
+ }
212
+ return null
213
+ }
214
+
215
+ /**
216
+ * Translate any thrown / returned error into customer-facing Bulgarian.
217
+ * Code-first (barter `StoreApiError.code`), then Stripe substring patterns,
218
+ * then legacy API substrings, then the per-context generic.
219
+ *
220
+ * @param err - the error from a try/catch or a Stripe error response
221
+ * @param context - "card" for Stripe path, "cod" for manual path
222
+ */
223
+ export function translatePaymentError(
224
+ err: unknown,
225
+ context: ErrorContext
226
+ ): string {
227
+ const code = extractCode(err)
228
+ if (code && PAYMENT_ERROR_CODE_COPY[code]) return PAYMENT_ERROR_CODE_COPY[code]
229
+
230
+ const raw = extractMessage(err)
231
+ if (!raw) return context === "card" ? GENERIC_CARD : GENERIC_COD
232
+
233
+ for (const { match, copy } of STRIPE_PATTERNS) {
234
+ if (match.test(raw)) return copy
235
+ }
236
+ for (const { match, copy } of API_PATTERNS) {
237
+ if (match.test(raw)) return copy
238
+ }
239
+ return context === "card" ? GENERIC_CARD : GENERIC_COD
240
+ }
241
+
242
+ /**
243
+ * Translate a gift-card apply/remove failure into customer-facing Bulgarian.
244
+ * Consumed by `GiftCardSection`. Code-first; falls back to one clean generic
245
+ * (the API's anti-oracle design means there is nothing more specific to say).
246
+ */
247
+ export function translateGiftCardError(err: unknown): string {
248
+ const code = extractCode(err)
249
+ if (code && GIFT_CARD_ERROR_CODE_COPY[code]) return GIFT_CARD_ERROR_CODE_COPY[code]
250
+ return GIFT_CARD_GENERIC
251
+ }
252
+
253
+ function extractMessage(err: unknown): string {
254
+ if (!err) return ""
255
+ if (typeof err === "string") return err
256
+ if (err instanceof Error) return err.message
257
+ if (typeof err === "object" && err !== null) {
258
+ const e = err as {
259
+ message?: string
260
+ code?: string
261
+ decline_code?: string
262
+ type?: string
263
+ }
264
+ return [e.message, e.code, e.decline_code, e.type]
265
+ .filter(Boolean)
266
+ .join(" ")
267
+ }
268
+ return String(err)
269
+ }