@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.
- package/package.json +226 -0
- package/src/api/auth.ts +108 -0
- package/src/api/carts.ts +506 -0
- package/src/api/categories.ts +184 -0
- package/src/api/checkout.ts +440 -0
- package/src/api/collections.ts +130 -0
- package/src/api/consent.ts +75 -0
- package/src/api/content.ts +125 -0
- package/src/api/customers.ts +307 -0
- package/src/api/gift-cards.ts +112 -0
- package/src/api/http.ts +122 -0
- package/src/api/index.ts +29 -0
- package/src/api/integrations.ts +130 -0
- package/src/api/menus.ts +77 -0
- package/src/api/metaobjects.ts +136 -0
- package/src/api/orders.ts +290 -0
- package/src/api/products.ts +303 -0
- package/src/api/redirects.ts +37 -0
- package/src/api/regions.ts +141 -0
- package/src/api/reviews.ts +259 -0
- package/src/api/search.ts +133 -0
- package/src/api/types.ts +91 -0
- package/src/cart-drawer/cart-drawer.tsx +86 -0
- package/src/cart-drawer/context.tsx +569 -0
- package/src/cart-drawer/continue-shopping.tsx +28 -0
- package/src/cart-drawer/cross-sell-carousel.tsx +212 -0
- package/src/cart-drawer/cross-sell-sidebar.tsx +158 -0
- package/src/cart-drawer/empty.tsx +52 -0
- package/src/cart-drawer/free-gift.tsx +71 -0
- package/src/cart-drawer/gift-wrap.tsx +83 -0
- package/src/cart-drawer/header.tsx +52 -0
- package/src/cart-drawer/index.ts +69 -0
- package/src/cart-drawer/item/index.tsx +164 -0
- package/src/cart-drawer/item/quantity.tsx +100 -0
- package/src/cart-drawer/item/upsell.tsx +110 -0
- package/src/cart-drawer/item/variant.tsx +46 -0
- package/src/cart-drawer/labels-bg.ts +72 -0
- package/src/cart-drawer/labels.ts +119 -0
- package/src/cart-drawer/notes.tsx +131 -0
- package/src/cart-drawer/payment-badges.tsx +96 -0
- package/src/cart-drawer/promo-banner.tsx +43 -0
- package/src/cart-drawer/rewards-points.tsx +78 -0
- package/src/cart-drawer/sticky-footer.tsx +73 -0
- package/src/cart-drawer/summary-breakdown.tsx +196 -0
- package/src/cart-drawer/template.tsx +225 -0
- package/src/cart-drawer/tiered-progress.tsx +168 -0
- package/src/checkout/address-error-copy.ts +119 -0
- package/src/checkout/address-form.tsx +224 -0
- package/src/checkout/address-select.tsx +79 -0
- package/src/checkout/boxnow-locker-selector.tsx +410 -0
- package/src/checkout/checkout-client.tsx +222 -0
- package/src/checkout/company-details.tsx +94 -0
- package/src/checkout/compare-addresses.ts +40 -0
- package/src/checkout/context.tsx +76 -0
- package/src/checkout/discount-section.tsx +218 -0
- package/src/checkout/econt-office-selector.tsx +332 -0
- package/src/checkout/error-message.tsx +25 -0
- package/src/checkout/geocode.ts +154 -0
- package/src/checkout/gift-card-section.tsx +224 -0
- package/src/checkout/index.ts +74 -0
- package/src/checkout/labels-bg.ts +128 -0
- package/src/checkout/labels.ts +263 -0
- package/src/checkout/line-item-card.tsx +152 -0
- package/src/checkout/order-summary.tsx +524 -0
- package/src/checkout/payment-button.tsx +373 -0
- package/src/checkout/payment-error-copy.ts +269 -0
- package/src/checkout/payment-method-list.tsx +365 -0
- package/src/checkout/payment-wrapper.tsx +102 -0
- package/src/checkout/promotion-error-copy.ts +124 -0
- package/src/checkout/shipping-method-list.tsx +335 -0
- package/src/checkout/stripe-wrapper.tsx +165 -0
- package/src/checkout/use-checkout-orchestration.ts +1504 -0
- package/src/common/cart-button-client.tsx +39 -0
- package/src/common/cart-button.tsx +28 -0
- package/src/common/country-select.tsx +65 -0
- package/src/common/delete-button.tsx +66 -0
- package/src/common/index.ts +17 -0
- package/src/common/language-select.tsx +78 -0
- package/src/common/localized-link.tsx +45 -0
- package/src/common/skeleton.tsx +29 -0
- package/src/index.ts +12 -0
- package/src/lib/cart-helpers.ts +113 -0
- package/src/lib/dual-price.tsx +73 -0
- package/src/lib/get-percentage-diff.ts +5 -0
- package/src/lib/get-product-price.ts +133 -0
- package/src/lib/hooks/use-intersection.ts +30 -0
- package/src/lib/hooks/use-toggle-state.ts +25 -0
- package/src/lib/money.ts +73 -0
- package/src/lib/payment-constants.ts +66 -0
- package/src/lib/product.ts +22 -0
- package/src/lib/sort-products.ts +63 -0
- package/src/lib/store-api-error.ts +36 -0
- package/src/lib/utils.ts +16 -0
- package/src/order/context.tsx +32 -0
- package/src/order/index.ts +63 -0
- package/src/order/labels-bg.ts +39 -0
- package/src/order/labels.ts +79 -0
- package/src/order/order-address-card.tsx +47 -0
- package/src/order/order-completed-template.tsx +165 -0
- package/src/order/order-confirmation-header.tsx +65 -0
- package/src/order/order-delivery-card.tsx +258 -0
- package/src/order/order-help-section.tsx +47 -0
- package/src/order/order-item.tsx +201 -0
- package/src/order/order-items-list.tsx +52 -0
- package/src/order/order-payment-card.tsx +95 -0
- package/src/order/order-timeline.tsx +141 -0
- package/src/order/order-totals.tsx +245 -0
- package/src/primitives/field.tsx +125 -0
- package/src/primitives/select-field.tsx +77 -0
- package/src/primitives/ui/accordion.tsx +61 -0
- package/src/primitives/ui/button.tsx +68 -0
- package/src/primitives/ui/collapsible.tsx +16 -0
- package/src/primitives/ui/dialog.tsx +112 -0
- package/src/primitives/ui/input.tsx +30 -0
- package/src/primitives/ui/label.tsx +31 -0
- package/src/primitives/ui/popover.tsx +38 -0
- package/src/primitives/ui/select.tsx +163 -0
- package/src/primitives/ui/sheet.tsx +131 -0
- package/src/primitives/ui/tabs.tsx +62 -0
- package/src/products/context.tsx +34 -0
- package/src/products/image-gallery.tsx +43 -0
- package/src/products/index.ts +44 -0
- package/src/products/labels-bg.ts +35 -0
- package/src/products/labels.ts +57 -0
- package/src/products/mobile-actions.tsx +180 -0
- package/src/products/option-select.tsx +67 -0
- package/src/products/preview-price.tsx +36 -0
- package/src/products/product-actions-wrapper.tsx +58 -0
- package/src/products/product-actions.tsx +217 -0
- package/src/products/product-info.tsx +43 -0
- package/src/products/product-preview.tsx +49 -0
- package/src/products/product-price.tsx +69 -0
- package/src/products/product-tabs.tsx +169 -0
- package/src/products/product-template.tsx +114 -0
- package/src/products/purchase-options.tsx +130 -0
- package/src/products/related-products.tsx +86 -0
- package/src/products/thumbnail.tsx +71 -0
- package/src/products/variant-matching.ts +71 -0
- package/src/reviews-ui/helpers.ts +174 -0
- package/src/reviews-ui/index.ts +74 -0
- package/src/reviews-ui/labels-bg.ts +91 -0
- package/src/reviews-ui/labels.ts +199 -0
- package/src/reviews-ui/photo-upload.tsx +345 -0
- package/src/reviews-ui/review-list.tsx +249 -0
- package/src/reviews-ui/review-widget.tsx +224 -0
- package/src/reviews-ui/review-wizard.tsx +560 -0
- package/src/reviews-ui/star-badge.tsx +104 -0
- package/src/reviews-ui/wizard-state.ts +81 -0
- package/src/store/category-template.tsx +129 -0
- package/src/store/collection-template.tsx +139 -0
- package/src/store/index.ts +41 -0
- package/src/store/labels-bg.ts +22 -0
- package/src/store/labels.ts +52 -0
- package/src/store/paginated-products.tsx +116 -0
- package/src/store/pagination.tsx +103 -0
- package/src/store/search-params.ts +256 -0
- package/src/store/search-template.tsx +249 -0
- package/src/store/skeleton-product-grid.tsx +26 -0
- package/src/store/sort-select.tsx +81 -0
- package/src/store/store-template.tsx +65 -0
- package/src/tracking/attribution.ts +418 -0
- package/src/tracking/consent-banner.tsx +355 -0
- package/src/tracking/consent-init.tsx +44 -0
- package/src/tracking/consent.ts +243 -0
- package/src/tracking/fbq.ts +168 -0
- package/src/tracking/ga4.tsx +49 -0
- package/src/tracking/get-tracking-attribution.ts +224 -0
- package/src/tracking/get-tracking-config.ts +50 -0
- package/src/tracking/gtag.ts +200 -0
- package/src/tracking/index.ts +133 -0
- package/src/tracking/meta-pixel.tsx +166 -0
- package/src/tracking/rybbit-events.ts +242 -0
- package/src/tracking/rybbit.tsx +40 -0
- package/src/tracking/types.ts +185 -0
- package/src/tracking/use-engagement-time.ts +58 -0
- 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
|
+
}
|