@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,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
+ }