@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,365 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { PaymentElement } from "@stripe/react-stripe-js"
|
|
4
|
+
import type { StripePaymentElementChangeEvent } from "@stripe/stripe-js"
|
|
5
|
+
import { useContext, useEffect, useState } from "react"
|
|
6
|
+
|
|
7
|
+
import { cn } from "../lib/utils"
|
|
8
|
+
import { useCheckoutLabels } from "./context"
|
|
9
|
+
import { ErrorMessage } from "./error-message"
|
|
10
|
+
import { PaymentButton } from "./payment-button"
|
|
11
|
+
import { StripeContext, StripeElementsScope } from "./stripe-wrapper"
|
|
12
|
+
import type { CheckoutLogError } from "./use-checkout-orchestration"
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* CheckoutPaymentMethodList — online-payment vs cash-on-delivery radio
|
|
16
|
+
* list. The online tab hosts Stripe's `<PaymentElement />` in accordion
|
|
17
|
+
* layout, which renders every payment method enabled in the Stripe
|
|
18
|
+
* Dashboard (card, Apple Pay, Google Pay, Link, etc.) without any
|
|
19
|
+
* per-method code here.
|
|
20
|
+
*
|
|
21
|
+
* Ported from `@1click/ui/src/checkout/payment-method-list.tsx` (v2.3.1)
|
|
22
|
+
* with the barter data seam: the `cart` prop is structural, and the
|
|
23
|
+
* `logCheckoutError` Supabase writer becomes the optional `logError`
|
|
24
|
+
* callback. Provider semantics on the barter wire: the "card" tab is
|
|
25
|
+
* `pp_stripe`, the "cod" tab is `pp_cod` (native fee via the integrations
|
|
26
|
+
* `cod` block) or `pp_manual` (offline, no fee) — the orchestration hook
|
|
27
|
+
* resolves the ids (`hasCard`/`hasCod`/`cardId`/`codId`); both listings
|
|
28
|
+
* arrive rule-filtered server-side (`checkout.listPaymentProviders`,
|
|
29
|
+
* docs/storefront/checkout.md). COD stays a separate rail — it's a plain
|
|
30
|
+
* provider session, not a Stripe payment method.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
type CheckoutPaymentMethodListProps = {
|
|
34
|
+
/** Structural cart — forwarded to PaymentButton (`id`/`total`/`currency_code`). */
|
|
35
|
+
cart: { id: string; total?: number | null; currency_code: string }
|
|
36
|
+
hasCard: boolean
|
|
37
|
+
hasCod: boolean
|
|
38
|
+
paymentTab: "card" | "cod"
|
|
39
|
+
onPaymentTab: (tab: "card" | "cod") => void
|
|
40
|
+
deliveryReady: boolean
|
|
41
|
+
paymentError: string | null
|
|
42
|
+
/** Fired when the Stripe PaymentElement's completion state changes.
|
|
43
|
+
* `complete === true` means the user has filled in valid details for
|
|
44
|
+
* the currently selected method and the Place Order button can enable. */
|
|
45
|
+
onPaymentElementChange: (event: {
|
|
46
|
+
complete: boolean
|
|
47
|
+
selectedMethod: string | null
|
|
48
|
+
}) => void
|
|
49
|
+
/** Buy-click flow from `useCheckoutOrchestration`. Wraps prepareCheckout
|
|
50
|
+
* → stripe.confirmPayment (card; skipped on the zero-remainder gift
|
|
51
|
+
* path) → placeOrder. The button collects the local Stripe primitives
|
|
52
|
+
* and passes them in. */
|
|
53
|
+
performBuyClick: (stripeBundle?: {
|
|
54
|
+
submit: () => Promise<{ error?: { message?: string } | null }>
|
|
55
|
+
stripe: {
|
|
56
|
+
confirmPayment: (args: {
|
|
57
|
+
elements: unknown
|
|
58
|
+
clientSecret: string
|
|
59
|
+
confirmParams: { return_url: string }
|
|
60
|
+
redirect: "if_required"
|
|
61
|
+
}) => Promise<{ error?: { message?: string } | null }>
|
|
62
|
+
}
|
|
63
|
+
elements: unknown
|
|
64
|
+
}) => Promise<void>
|
|
65
|
+
/** True when address + shipping + carrier-destination are all filled.
|
|
66
|
+
* Needed to gate the Buy button alongside Stripe-element completion. */
|
|
67
|
+
buyButtonNotReady: boolean
|
|
68
|
+
/** Why the Buy button is still disabled — drives a short hint under it
|
|
69
|
+
* so a shopper who's looking at a live, interactive payment section
|
|
70
|
+
* (see `gatePaymentUntilDelivery={false}`) understands what's missing
|
|
71
|
+
* instead of reading a greyed button as broken. Only rendered while
|
|
72
|
+
* `buyButtonNotReady`. Omit / null to show no hint. */
|
|
73
|
+
buyButtonNotReadyReason?: "address" | "delivery" | null
|
|
74
|
+
/** Gate the whole payment section behind delivery-readiness (the
|
|
75
|
+
* classic flow: payment stays dimmed + hidden until a shipping method
|
|
76
|
+
* is chosen). Default `true` keeps that behavior. Pass `false` to keep
|
|
77
|
+
* the payment section ALWAYS visible + interactive (Shopify-style
|
|
78
|
+
* "everything on screen"); the Buy button still can't fire because
|
|
79
|
+
* `buyButtonNotReady` gates it independently. */
|
|
80
|
+
gatePaymentUntilDelivery?: boolean
|
|
81
|
+
/** Optional content rendered directly above the Place Order button. */
|
|
82
|
+
beforePaymentButton?: React.ReactNode
|
|
83
|
+
/** Display total in main currency units. Forwarded to `PaymentButton`.
|
|
84
|
+
* Pass `useCheckoutOrchestration`'s `optimisticTotal` so the button
|
|
85
|
+
* shows the same number as the order summary. Falls back to
|
|
86
|
+
* `cart.total` when omitted. */
|
|
87
|
+
total?: number
|
|
88
|
+
/** Operational-visibility sink (successor of @1click's logCheckoutError). */
|
|
89
|
+
logError?: CheckoutLogError
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function CheckoutPaymentMethodList({
|
|
93
|
+
cart,
|
|
94
|
+
hasCard,
|
|
95
|
+
hasCod,
|
|
96
|
+
paymentTab,
|
|
97
|
+
onPaymentTab,
|
|
98
|
+
deliveryReady,
|
|
99
|
+
paymentError,
|
|
100
|
+
onPaymentElementChange,
|
|
101
|
+
performBuyClick,
|
|
102
|
+
buyButtonNotReady,
|
|
103
|
+
buyButtonNotReadyReason,
|
|
104
|
+
gatePaymentUntilDelivery = true,
|
|
105
|
+
beforePaymentButton,
|
|
106
|
+
total,
|
|
107
|
+
logError,
|
|
108
|
+
}: CheckoutPaymentMethodListProps) {
|
|
109
|
+
const labels = useCheckoutLabels()
|
|
110
|
+
|
|
111
|
+
// When the store opts out of gating (gatePaymentUntilDelivery=false),
|
|
112
|
+
// the payment section is always shown + interactive regardless of
|
|
113
|
+
// delivery readiness. The Buy button remains independently gated by
|
|
114
|
+
// `buyButtonNotReady`, so nothing can be submitted early — this only
|
|
115
|
+
// changes visibility, not the order-placement guard.
|
|
116
|
+
const paymentGated = gatePaymentUntilDelivery && !deliveryReady
|
|
117
|
+
// Boolean context — true when Stripe.js is loaded. In deferred-intent
|
|
118
|
+
// mode, the iframe mounts as soon as Stripe is ready (no PI required).
|
|
119
|
+
const stripeReady = useContext(StripeContext)
|
|
120
|
+
|
|
121
|
+
// Local copy of Stripe's PaymentElement completion flag. Forwarded
|
|
122
|
+
// to the parent via `onPaymentElementChange` AND used here to gate
|
|
123
|
+
// the Buy button on the card path.
|
|
124
|
+
const [paymentElementComplete, setPaymentElementComplete] = useState(false)
|
|
125
|
+
|
|
126
|
+
// Reset completion flag on tab change. PaymentElement is unmounted
|
|
127
|
+
// when paymentTab !== "card" so a re-mount starts empty — without
|
|
128
|
+
// this reset, switching away then back would leave the button
|
|
129
|
+
// erroneously enabled from a previous fill.
|
|
130
|
+
useEffect(() => {
|
|
131
|
+
setPaymentElementComplete(false)
|
|
132
|
+
}, [paymentTab])
|
|
133
|
+
|
|
134
|
+
const handlePaymentElementChange = (event: StripePaymentElementChangeEvent) => {
|
|
135
|
+
setPaymentElementComplete(event.complete)
|
|
136
|
+
onPaymentElementChange({
|
|
137
|
+
complete: event.complete,
|
|
138
|
+
selectedMethod: event.value?.type ?? null,
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// PaymentElement load error handler — log only.
|
|
143
|
+
//
|
|
144
|
+
// In the eager-session model this used to call `refreshPaymentIfTerminal`
|
|
145
|
+
// because the cart could hold a stale client_secret pointing at a
|
|
146
|
+
// terminal Stripe PaymentIntent. In the deferred-intent model the PI
|
|
147
|
+
// is created at Buy click, so there's no stale-PI failure mode at
|
|
148
|
+
// mount time. We keep the logger to capture any other load issues
|
|
149
|
+
// (key misconfiguration, network, etc). Stores that mount Elements on
|
|
150
|
+
// an EXISTING client_secret (retry flows) should call the hook's
|
|
151
|
+
// `refreshPaymentIfTerminal` from this handler instead — reactively,
|
|
152
|
+
// never per render (docs/storefront/checkout.md).
|
|
153
|
+
const handlePaymentElementLoadError = (event: {
|
|
154
|
+
error?: { message?: string }
|
|
155
|
+
}) => {
|
|
156
|
+
const message = event?.error?.message ?? ""
|
|
157
|
+
if (typeof console !== "undefined") {
|
|
158
|
+
// eslint-disable-next-line no-console
|
|
159
|
+
console.warn("[PaymentElement] load error:", message || event)
|
|
160
|
+
}
|
|
161
|
+
logError?.("elements_load_error", message, {})
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return (
|
|
165
|
+
<div
|
|
166
|
+
className={cn(
|
|
167
|
+
"mt-8 transition-opacity duration-300",
|
|
168
|
+
paymentGated && "opacity-30 pointer-events-none select-none"
|
|
169
|
+
)}
|
|
170
|
+
>
|
|
171
|
+
<h2 className="text-lg font-semibold text-foreground mb-1 tracking-tight">
|
|
172
|
+
{labels.paymentMethod}
|
|
173
|
+
</h2>
|
|
174
|
+
<p className="text-xs text-muted-foreground mb-4">{labels.termsText}</p>
|
|
175
|
+
|
|
176
|
+
{paymentGated ? (
|
|
177
|
+
<div className="p-4 bg-muted rounded-lg border border-border">
|
|
178
|
+
<p className="text-sm text-muted-foreground">{labels.paymentDisabled}</p>
|
|
179
|
+
</div>
|
|
180
|
+
) : (
|
|
181
|
+
// ── Stripe Elements scope ──────────────────────────────────────
|
|
182
|
+
// Wraps the entire interactive payment section (radio cards +
|
|
183
|
+
// PaymentElement + PaymentButton) so `<PaymentButton>`'s
|
|
184
|
+
// useStripe()/useElements() hooks find an <Elements> ancestor.
|
|
185
|
+
//
|
|
186
|
+
// `passthrough` matters: COD-only carts have no Stripe key, so
|
|
187
|
+
// <Elements> can't mount. Without passthrough the wrapper would
|
|
188
|
+
// render `null` (its default fallback) and the COD radio +
|
|
189
|
+
// PaymentButton would disappear entirely — checkout dead for COD
|
|
190
|
+
// users. With passthrough, when Stripe isn't ready we render
|
|
191
|
+
// children directly (no <Elements>); the COD path doesn't use
|
|
192
|
+
// Stripe hooks so it works fine without context.
|
|
193
|
+
//
|
|
194
|
+
// Critically, this scope is INSIDE CheckoutPaymentMethodList,
|
|
195
|
+
// not at the checkout root. When the payment session rotates
|
|
196
|
+
// and a new client_secret arrives, only this section remounts
|
|
197
|
+
// — the orchestration tree, address form, shipping selector,
|
|
198
|
+
// and tracking refs upstream are NOT torn down. That's the
|
|
199
|
+
// whole point of moving away from PaymentWrapper-wraps-all.
|
|
200
|
+
<StripeElementsScope passthrough>
|
|
201
|
+
<div className="space-y-2">
|
|
202
|
+
{hasCard && (
|
|
203
|
+
<div
|
|
204
|
+
className={cn(
|
|
205
|
+
"rounded-lg border overflow-hidden transition-colors duration-150",
|
|
206
|
+
paymentTab === "card"
|
|
207
|
+
? "border-primary bg-primary/5"
|
|
208
|
+
: "border-border bg-card hover:border-muted-foreground"
|
|
209
|
+
)}
|
|
210
|
+
>
|
|
211
|
+
<button
|
|
212
|
+
type="button"
|
|
213
|
+
onClick={() => onPaymentTab("card")}
|
|
214
|
+
className="flex items-center w-full px-4 py-3.5 text-left"
|
|
215
|
+
>
|
|
216
|
+
<div
|
|
217
|
+
className={cn(
|
|
218
|
+
"w-[18px] h-[18px] rounded-full border-2 flex items-center justify-center flex-shrink-0 mr-3 transition-colors",
|
|
219
|
+
paymentTab === "card" ? "border-primary" : "border-border"
|
|
220
|
+
)}
|
|
221
|
+
>
|
|
222
|
+
{paymentTab === "card" && (
|
|
223
|
+
<div className="w-2 h-2 rounded-full bg-primary" />
|
|
224
|
+
)}
|
|
225
|
+
</div>
|
|
226
|
+
<span className="text-sm font-medium text-foreground">
|
|
227
|
+
{labels.payOnline}
|
|
228
|
+
</span>
|
|
229
|
+
</button>
|
|
230
|
+
|
|
231
|
+
{paymentTab === "card" && (
|
|
232
|
+
<div className="px-4 pb-4 pt-2">
|
|
233
|
+
{/*
|
|
234
|
+
Skeleton vs PaymentElement gated by stripeReady.
|
|
235
|
+
When ready=true we're inside <Elements> (outer
|
|
236
|
+
passthrough scope mounted it) and PaymentElement
|
|
237
|
+
can render. When ready=false (Stripe still
|
|
238
|
+
initialising) we show the skeleton — rendering
|
|
239
|
+
PaymentElement without an <Elements> ancestor
|
|
240
|
+
throws.
|
|
241
|
+
*/}
|
|
242
|
+
{stripeReady ? (
|
|
243
|
+
<PaymentElement
|
|
244
|
+
onChange={handlePaymentElementChange}
|
|
245
|
+
onLoadError={handlePaymentElementLoadError}
|
|
246
|
+
options={{
|
|
247
|
+
layout: "accordion",
|
|
248
|
+
// Intentionally NO `fields.billingDetails.address`
|
|
249
|
+
// override. Setting it to "never" puts Stripe into
|
|
250
|
+
// strict-completeness mode — every billing-details
|
|
251
|
+
// sub-field MUST be passed in confirmParams or it
|
|
252
|
+
// throws an IntegrationError naming the first
|
|
253
|
+
// missing one (country → state → next). For card-
|
|
254
|
+
// only payment methods (Card / Apple Pay / Google
|
|
255
|
+
// Pay), Stripe doesn't show address fields by
|
|
256
|
+
// default, so dropping the override has zero UI
|
|
257
|
+
// impact and eliminates the IntegrationError class
|
|
258
|
+
// entirely (production fix 2026-05-06). We still
|
|
259
|
+
// pass full billing_details to
|
|
260
|
+
// stripe.confirmPayment for AVS / Radar / 3DS
|
|
261
|
+
// risk scoring / dispute defense.
|
|
262
|
+
}}
|
|
263
|
+
/>
|
|
264
|
+
) : (
|
|
265
|
+
<div className="space-y-2.5 animate-pulse">
|
|
266
|
+
<div className="h-[44px] rounded-lg bg-muted" />
|
|
267
|
+
<div className="grid grid-cols-2 gap-2.5">
|
|
268
|
+
<div className="h-[44px] rounded-lg bg-muted" />
|
|
269
|
+
<div className="h-[44px] rounded-lg bg-muted" />
|
|
270
|
+
</div>
|
|
271
|
+
</div>
|
|
272
|
+
)}
|
|
273
|
+
</div>
|
|
274
|
+
)}
|
|
275
|
+
</div>
|
|
276
|
+
)}
|
|
277
|
+
|
|
278
|
+
{hasCod && (
|
|
279
|
+
<div
|
|
280
|
+
className={cn(
|
|
281
|
+
"rounded-lg border overflow-hidden transition-colors duration-150",
|
|
282
|
+
paymentTab === "cod"
|
|
283
|
+
? "border-primary bg-primary/5"
|
|
284
|
+
: "border-border bg-card hover:border-muted-foreground"
|
|
285
|
+
)}
|
|
286
|
+
>
|
|
287
|
+
<button
|
|
288
|
+
type="button"
|
|
289
|
+
onClick={() => onPaymentTab("cod")}
|
|
290
|
+
className="flex items-center w-full px-4 py-3.5 text-left"
|
|
291
|
+
>
|
|
292
|
+
<div
|
|
293
|
+
className={cn(
|
|
294
|
+
"w-[18px] h-[18px] rounded-full border-2 flex items-center justify-center flex-shrink-0 mr-3 transition-colors",
|
|
295
|
+
paymentTab === "cod" ? "border-primary" : "border-border"
|
|
296
|
+
)}
|
|
297
|
+
>
|
|
298
|
+
{paymentTab === "cod" && (
|
|
299
|
+
<div className="w-2 h-2 rounded-full bg-primary" />
|
|
300
|
+
)}
|
|
301
|
+
</div>
|
|
302
|
+
<span className="text-sm font-medium text-foreground">
|
|
303
|
+
{labels.cashOnDelivery}
|
|
304
|
+
</span>
|
|
305
|
+
</button>
|
|
306
|
+
{paymentTab === "cod" && (
|
|
307
|
+
<div className="px-4 pb-3 pt-0">
|
|
308
|
+
<p className="text-xs text-muted-foreground ml-[30px]">
|
|
309
|
+
{labels.codNote}
|
|
310
|
+
</p>
|
|
311
|
+
</div>
|
|
312
|
+
)}
|
|
313
|
+
</div>
|
|
314
|
+
)}
|
|
315
|
+
</div>
|
|
316
|
+
|
|
317
|
+
<ErrorMessage
|
|
318
|
+
error={paymentError}
|
|
319
|
+
data-testid="payment-error-message"
|
|
320
|
+
/>
|
|
321
|
+
|
|
322
|
+
{beforePaymentButton && (
|
|
323
|
+
<div className="mt-5">{beforePaymentButton}</div>
|
|
324
|
+
)}
|
|
325
|
+
|
|
326
|
+
{buyButtonNotReady && buyButtonNotReadyReason && (
|
|
327
|
+
<div className="mt-4 flex items-center justify-center gap-1.5 text-muted-foreground">
|
|
328
|
+
<svg
|
|
329
|
+
viewBox="0 0 24 24"
|
|
330
|
+
fill="none"
|
|
331
|
+
stroke="currentColor"
|
|
332
|
+
strokeWidth={2}
|
|
333
|
+
className="w-3.5 h-3.5 flex-shrink-0"
|
|
334
|
+
aria-hidden="true"
|
|
335
|
+
>
|
|
336
|
+
<rect x="5" y="11" width="14" height="10" rx="2" />
|
|
337
|
+
<path d="M8 11V7a4 4 0 0 1 8 0v4" />
|
|
338
|
+
</svg>
|
|
339
|
+
<span className="text-xs font-medium">
|
|
340
|
+
{buyButtonNotReadyReason === "address"
|
|
341
|
+
? labels.buyNeedsAddress
|
|
342
|
+
: labels.buyNeedsDelivery}
|
|
343
|
+
</span>
|
|
344
|
+
</div>
|
|
345
|
+
)}
|
|
346
|
+
|
|
347
|
+
<div className="mt-5">
|
|
348
|
+
<PaymentButton
|
|
349
|
+
cart={cart}
|
|
350
|
+
paymentTab={paymentTab}
|
|
351
|
+
notReady={buyButtonNotReady}
|
|
352
|
+
performBuyClick={performBuyClick}
|
|
353
|
+
paymentElementComplete={
|
|
354
|
+
paymentTab === "cod" ? true : paymentElementComplete
|
|
355
|
+
}
|
|
356
|
+
total={total}
|
|
357
|
+
logError={logError}
|
|
358
|
+
data-testid="submit-order-button"
|
|
359
|
+
/>
|
|
360
|
+
</div>
|
|
361
|
+
</StripeElementsScope>
|
|
362
|
+
)}
|
|
363
|
+
</div>
|
|
364
|
+
)
|
|
365
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import type { Appearance, StripeElementsOptions } from "@stripe/stripe-js"
|
|
4
|
+
import { loadStripe } from "@stripe/stripe-js"
|
|
5
|
+
import type { ReactNode } from "react"
|
|
6
|
+
|
|
7
|
+
import { StripeScopeProvider } from "./stripe-wrapper"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* PaymentWrapper — provides Stripe Elements scope context to descendants.
|
|
11
|
+
*
|
|
12
|
+
* Ported from `@1click/ui/src/checkout/payment-wrapper.tsx` (v2.3.1).
|
|
13
|
+
* Barter adaptations: the `cart` prop is the barter `Cart` DTO (only
|
|
14
|
+
* `currency_code` is read — typed structurally so any decorated cart
|
|
15
|
+
* satisfies it); env var naming gains `NEXT_PUBLIC_STRIPE_KEY` as the
|
|
16
|
+
* canonical name, with the Medusa-era fallbacks kept so ported storefronts
|
|
17
|
+
* keep working unchanged.
|
|
18
|
+
*
|
|
19
|
+
* As of the deferred-checkout architecture, this wrapper supplies an
|
|
20
|
+
* optimistic `amount` + `currency` (computed by the orchestration hook)
|
|
21
|
+
* to `<Elements>` so the iframe can mount and stay mounted across
|
|
22
|
+
* shipping/payment toggles. `elements.update({ amount })` keeps the
|
|
23
|
+
* displayed total in sync without remounting — see stripe-wrapper.tsx
|
|
24
|
+
* for the deferred-intent rationale.
|
|
25
|
+
*
|
|
26
|
+
* Stores mount this at the top of their checkout page:
|
|
27
|
+
*
|
|
28
|
+
* <PaymentWrapper cart={cart} amount={...} appearance={...} fonts={...}>
|
|
29
|
+
* <CheckoutClient ... />
|
|
30
|
+
* </PaymentWrapper>
|
|
31
|
+
*
|
|
32
|
+
* Environment variables (read at module load time):
|
|
33
|
+
* - NEXT_PUBLIC_STRIPE_KEY OR
|
|
34
|
+
* NEXT_PUBLIC_MEDUSA_PAYMENTS_PUBLISHABLE_KEY (legacy fallback)
|
|
35
|
+
* - NEXT_PUBLIC_MEDUSA_PAYMENTS_ACCOUNT_ID (optional, connect accounts)
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
const stripeKey =
|
|
39
|
+
process.env.NEXT_PUBLIC_STRIPE_KEY ||
|
|
40
|
+
process.env.NEXT_PUBLIC_MEDUSA_PAYMENTS_PUBLISHABLE_KEY
|
|
41
|
+
|
|
42
|
+
const stripeAccountId = process.env.NEXT_PUBLIC_MEDUSA_PAYMENTS_ACCOUNT_ID
|
|
43
|
+
|
|
44
|
+
const stripePromise = stripeKey
|
|
45
|
+
? loadStripe(
|
|
46
|
+
stripeKey,
|
|
47
|
+
stripeAccountId ? { stripeAccount: stripeAccountId } : undefined
|
|
48
|
+
)
|
|
49
|
+
: null
|
|
50
|
+
|
|
51
|
+
type PaymentWrapperProps = {
|
|
52
|
+
/** The decorated cart — only `currency_code` is read. */
|
|
53
|
+
cart: { currency_code?: string | null }
|
|
54
|
+
/**
|
|
55
|
+
* Optimistic total in the smallest currency unit (cents/stotinki).
|
|
56
|
+
* Computed by `useCheckoutOrchestration` (`optimisticTotalCents`) from
|
|
57
|
+
* the server cart total + optimistic shipping + optimistic COD fee.
|
|
58
|
+
* Changes to this prop propagate via `elements.update({ amount })`
|
|
59
|
+
* without remounting.
|
|
60
|
+
*/
|
|
61
|
+
amount: number
|
|
62
|
+
/**
|
|
63
|
+
* Optional per-store Stripe Elements appearance — brand colors, font,
|
|
64
|
+
* radius, etc. Forwarded to the StripeElementsScope so the
|
|
65
|
+
* `<Elements>` mount picks it up.
|
|
66
|
+
*/
|
|
67
|
+
appearance?: Appearance
|
|
68
|
+
/**
|
|
69
|
+
* Optional custom fonts for the Stripe Elements iframe. Required
|
|
70
|
+
* whenever `appearance.variables.fontFamily` references a custom
|
|
71
|
+
* font — the iframe cannot see the parent document's loaded fonts.
|
|
72
|
+
*/
|
|
73
|
+
fonts?: StripeElementsOptions["fonts"]
|
|
74
|
+
children: ReactNode
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function PaymentWrapper({
|
|
78
|
+
cart,
|
|
79
|
+
amount,
|
|
80
|
+
appearance,
|
|
81
|
+
fonts,
|
|
82
|
+
children,
|
|
83
|
+
}: PaymentWrapperProps) {
|
|
84
|
+
// "Ready" simply means: Stripe.js loadable. We no longer wait for a
|
|
85
|
+
// backend payment_session — deferred-intent mode mounts straight away.
|
|
86
|
+
const ready = !!stripePromise
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<StripeScopeProvider
|
|
90
|
+
value={{
|
|
91
|
+
ready,
|
|
92
|
+
stripePromise,
|
|
93
|
+
amount,
|
|
94
|
+
currency: cart.currency_code || "eur",
|
|
95
|
+
appearance,
|
|
96
|
+
fonts,
|
|
97
|
+
}}
|
|
98
|
+
>
|
|
99
|
+
{children}
|
|
100
|
+
</StripeScopeProvider>
|
|
101
|
+
)
|
|
102
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Translates promotion errors into user-friendly Bulgarian copy for the
|
|
3
|
+
* checkout discount-code surface.
|
|
4
|
+
*
|
|
5
|
+
* Ported from `@1click/ui/src/checkout/promotion-error-copy.ts` (v2.3.1)
|
|
6
|
+
* and adapted to the barter error envelope: the cart-promotions route
|
|
7
|
+
* (POST /api/store/carts/:id/promotions) throws structured codes —
|
|
8
|
+
* `promotion_not_found`, `promotion_inactive`, `promotion_misconfigured`,
|
|
9
|
+
* `promotion_unsupported_type`, plus the cart-level `cart_not_found` /
|
|
10
|
+
* `cart_completed` / `validation_failed` — so translation is code-first;
|
|
11
|
+
* the Medusa-era substrings stay as fallback.
|
|
12
|
+
*
|
|
13
|
+
* Why this exists: applying a promo code is the one checkout action a
|
|
14
|
+
* shopper triggers speculatively — they type a half-remembered code and
|
|
15
|
+
* hope. So it FAILS often, by design. Raw technical English at the
|
|
16
|
+
* highest-intent moment reads as "the site is broken".
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** Generic fallback — used when we can't recognise the error at all. */
|
|
20
|
+
const GENERIC =
|
|
21
|
+
"Кодът за отстъпка не може да бъде приложен в момента. Моля, проверете кода и опитайте отново."
|
|
22
|
+
|
|
23
|
+
/** Campaign-budget promotions are tracked per customer, so the engine can
|
|
24
|
+
* require an email on the cart before applying them. A shopper who jumps
|
|
25
|
+
* straight to the promo field without filling their details hits this. */
|
|
26
|
+
const NEEDS_EMAIL =
|
|
27
|
+
"Въведете имейл адрес по-горе, за да приложите този код за отстъпка."
|
|
28
|
+
|
|
29
|
+
const INVALID = "Този код за отстъпка не е валиден."
|
|
30
|
+
|
|
31
|
+
const EXPIRED = "Този код за отстъпка вече не е активен."
|
|
32
|
+
|
|
33
|
+
const EXHAUSTED =
|
|
34
|
+
"Промоцията е изчерпана и вече не може да бъде използвана."
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Barter promotion error codes → BG copy (POST/DELETE
|
|
38
|
+
* /api/store/carts/:id/promotions + src/lib/promotions/apply.ts).
|
|
39
|
+
*/
|
|
40
|
+
export const PROMOTION_ERROR_CODE_COPY: Record<string, string> = {
|
|
41
|
+
promotion_not_found: INVALID,
|
|
42
|
+
promotion_inactive: EXPIRED,
|
|
43
|
+
promotion_misconfigured: GENERIC,
|
|
44
|
+
promotion_unsupported_type: GENERIC,
|
|
45
|
+
cart_completed:
|
|
46
|
+
"Поръчката вече е финализирана. Моля, проверете имейла си за потвърждение.",
|
|
47
|
+
cart_not_found: "Сесията на количката изтече. Моля, презаредете страницата.",
|
|
48
|
+
validation_failed: INVALID,
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Known promo error substrings → BG copy. Case-insensitive, first match
|
|
53
|
+
* wins, most-specific first. Kept verbatim from the production port as the
|
|
54
|
+
* fallback layer.
|
|
55
|
+
*/
|
|
56
|
+
const PROMO_PATTERNS: Array<{ match: RegExp; copy: string }> = [
|
|
57
|
+
// Campaign budget needs an email on the cart.
|
|
58
|
+
{
|
|
59
|
+
match: /customer_email|campaign[\s_]?budget/i,
|
|
60
|
+
copy: NEEDS_EMAIL,
|
|
61
|
+
},
|
|
62
|
+
// Expired / no-longer-active promotion.
|
|
63
|
+
{
|
|
64
|
+
match: /expired|no[\s_]?longer[\s_]?(?:active|valid)|inactive/i,
|
|
65
|
+
copy: EXPIRED,
|
|
66
|
+
},
|
|
67
|
+
// Budget / usage limit reached.
|
|
68
|
+
{
|
|
69
|
+
match: /reached[\s_]?its[\s_]?limit|usage[\s_]?limit|budget[\s_]?(?:exceeded|reached)/i,
|
|
70
|
+
copy: EXHAUSTED,
|
|
71
|
+
},
|
|
72
|
+
// Invalid / unknown code (keep last of the specific set — broadest).
|
|
73
|
+
{
|
|
74
|
+
match: /is[\s_]?invalid|invalid[\s_]?data|not[\s_]?found|does(?:n'?t| not)[\s_]?exist/i,
|
|
75
|
+
copy: INVALID,
|
|
76
|
+
},
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Translate any thrown / returned promo error into customer-facing Bulgarian.
|
|
81
|
+
*
|
|
82
|
+
* @param err the error from a try/catch (a StoreApiError with a code, a
|
|
83
|
+
* Stripe-less network error, or an opaque render error)
|
|
84
|
+
* @param opts.hasEmail whether the cart currently has an email. When the
|
|
85
|
+
* error is opaque and there's no email, the overwhelmingly
|
|
86
|
+
* likely cause is a campaign-budget promo, so we give the
|
|
87
|
+
* actionable "enter your email" message instead of a vague
|
|
88
|
+
* fallback.
|
|
89
|
+
*/
|
|
90
|
+
export function translatePromotionError(
|
|
91
|
+
err: unknown,
|
|
92
|
+
opts?: { hasEmail?: boolean }
|
|
93
|
+
): string {
|
|
94
|
+
if (err && typeof err === "object") {
|
|
95
|
+
const code = (err as { code?: unknown }).code
|
|
96
|
+
if (typeof code === "string" && PROMOTION_ERROR_CODE_COPY[code]) {
|
|
97
|
+
return PROMOTION_ERROR_CODE_COPY[code]
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const raw = extractMessage(err)
|
|
102
|
+
|
|
103
|
+
for (const { match, copy } of PROMO_PATTERNS) {
|
|
104
|
+
if (match.test(raw)) return copy
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Unknown / opaque error. If the cart has no email yet, the
|
|
108
|
+
// campaign-budget case is by far the most common trigger — point the
|
|
109
|
+
// shopper at the fix rather than a dead end.
|
|
110
|
+
if (opts?.hasEmail === false) return NEEDS_EMAIL
|
|
111
|
+
|
|
112
|
+
return GENERIC
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function extractMessage(err: unknown): string {
|
|
116
|
+
if (!err) return ""
|
|
117
|
+
if (typeof err === "string") return err
|
|
118
|
+
if (err instanceof Error) return err.message
|
|
119
|
+
if (typeof err === "object" && err !== null) {
|
|
120
|
+
const e = err as { message?: string; code?: string; type?: string }
|
|
121
|
+
return [e.message, e.code, e.type].filter(Boolean).join(" ")
|
|
122
|
+
}
|
|
123
|
+
return String(err)
|
|
124
|
+
}
|