@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,335 @@
1
+ "use client"
2
+
3
+ import type { StorefrontClient } from "../api/http"
4
+ import type { StoreShippingOption } from "../api/checkout"
5
+ import type { BoxNowLocker } from "../api/integrations"
6
+ import { DualPrice } from "../lib/dual-price"
7
+ import { cn } from "../lib/utils"
8
+ import { useCheckoutLabels } from "./context"
9
+ import {
10
+ EcontOfficeSelector,
11
+ type EcontOffice,
12
+ } from "./econt-office-selector"
13
+ import { BoxNowLockerSelector } from "./boxnow-locker-selector"
14
+ import { ErrorMessage } from "./error-message"
15
+
16
+ /**
17
+ * CheckoutShippingMethodList — radio-style list of shipping options.
18
+ * When the user selects an Econt-office / BoxNow-locker option, the
19
+ * matching picker expands inline inside that row.
20
+ *
21
+ * Ported from `@1click/ui/src/checkout/shipping-method-list.tsx` (v2.3.1)
22
+ * with the barter data seam: options are `StoreShippingOption` rows from
23
+ * `checkout.listShippingOptions(client, {cart_id})` — rule-filtered and
24
+ * display-ordered SERVER-SIDE (checkout rules + `checkout_method_order`,
25
+ * docs/storefront/checkout.md); this component only renders what arrives.
26
+ * The BoxNow picker needs the SDK transport (its locker directory is a
27
+ * store-API call), so the `boxnow` config gains a `client`.
28
+ *
29
+ * Detection uses the stable fulfillment-option id set by the backend
30
+ * provider (`shipping_option.data.id`) — NOT the display name. Admins
31
+ * rename options freely, and Bulgarian labels overlap ("До точен адрес с
32
+ * ЕКОНТ" contains "еконт" but is address delivery, not office).
33
+ *
34
+ * Presentational — state + handlers come from the orchestration hook.
35
+ */
36
+
37
+ type CheckoutShippingMethodListProps = {
38
+ shippingMethods: StoreShippingOption[]
39
+ selectedShippingMethodId: string | null
40
+ calculatedPricesMap: Record<string, number>
41
+ isLoadingPrices: boolean
42
+ shippingLoading: boolean
43
+ shippingError: string | null
44
+ onSelect: (id: string) => void
45
+ addressReady: boolean
46
+ currencyCode: string
47
+ /** Optional: Econt office state + handler for inline-expand rows */
48
+ econt?: {
49
+ detect?: (option: StoreShippingOption) => boolean
50
+ selectedOffice: EcontOffice | null
51
+ onSelectOffice: (office: EcontOffice | null) => void
52
+ userCity: string
53
+ userAddress: string
54
+ }
55
+ /** Optional: BoxNow locker state + handler for inline-expand rows */
56
+ boxnow?: {
57
+ detect?: (option: StoreShippingOption) => boolean
58
+ /** SDK transport for the locker-directory fetch. */
59
+ client: StorefrontClient
60
+ selectedLocker: BoxNowLocker | null
61
+ onSelectLocker: (locker: BoxNowLocker | null) => void
62
+ userCity: string
63
+ userAddress: string
64
+ }
65
+ /**
66
+ * Optional per-store carrier branding. Keyed by the stable fulfillment
67
+ * option id (shipping_option.data.id) — "econt-office", "boxnow-locker",
68
+ * etc. If a match is found, a small logo renders between the radio and
69
+ * the option name. Stores that don't supply a map get the unbranded
70
+ * (radio + text only) layout.
71
+ */
72
+ logoByFulfillmentOptionId?: Record<string, { src: string; alt: string }>
73
+ /** Show the shipping options as a read-only price PREVIEW before the
74
+ * address is entered, instead of the "enter your address" placeholder.
75
+ * Rows render with their names + (flat) prices + carrier logos but are
76
+ * non-selectable — a hint tells the shopper to enter their address to
77
+ * choose. For flat-priced stores this lets people see the cost up front
78
+ * (a common reason carts stall) without letting them pick a method that
79
+ * needs a city (Econt office / BoxNow locker). Default `false` keeps the
80
+ * classic address-gated placeholder for every existing store. */
81
+ previewWhenAddressNotReady?: boolean
82
+ }
83
+
84
+ // Detection uses the stable fulfillment-option id set by the backend
85
+ // provider (shipping_option.data.id) — NOT the display name.
86
+ const getFulfillmentOptionId = (
87
+ option: StoreShippingOption
88
+ ): string | null => {
89
+ const data = option.data as { id?: string } | undefined | null
90
+ return typeof data?.id === "string" ? data.id : null
91
+ }
92
+
93
+ const defaultEcontDetect = (option: StoreShippingOption): boolean => {
94
+ return getFulfillmentOptionId(option) === "econt-office"
95
+ }
96
+
97
+ const defaultBoxnowDetect = (option: StoreShippingOption): boolean => {
98
+ return getFulfillmentOptionId(option) === "boxnow-locker"
99
+ }
100
+
101
+ export function CheckoutShippingMethodList({
102
+ shippingMethods,
103
+ selectedShippingMethodId,
104
+ calculatedPricesMap,
105
+ isLoadingPrices,
106
+ shippingLoading,
107
+ shippingError,
108
+ onSelect,
109
+ addressReady,
110
+ currencyCode,
111
+ econt,
112
+ boxnow,
113
+ logoByFulfillmentOptionId,
114
+ previewWhenAddressNotReady = false,
115
+ }: CheckoutShippingMethodListProps) {
116
+ const labels = useCheckoutLabels()
117
+ const detectEcont = econt?.detect ?? defaultEcontDetect
118
+ const detectBoxnow = boxnow?.detect ?? defaultBoxnowDetect
119
+
120
+ // Preview mode: address not filled yet, but the store wants the options
121
+ // shown as a read-only price preview rather than a placeholder. Rows are
122
+ // rendered but locked (no selection) — see `previewWhenAddressNotReady`.
123
+ const showPreview = !addressReady && previewWhenAddressNotReady
124
+ // Only the classic (non-preview) not-ready state dims + hides the list.
125
+ const gatePlaceholder = !addressReady && !previewWhenAddressNotReady
126
+
127
+ return (
128
+ <div
129
+ className={cn(
130
+ "mt-8 transition-opacity duration-300",
131
+ gatePlaceholder && "opacity-30 pointer-events-none select-none"
132
+ )}
133
+ >
134
+ <h2 className="text-lg font-semibold text-foreground mb-4 tracking-tight">
135
+ {labels.shippingServices}
136
+ </h2>
137
+
138
+ {gatePlaceholder ? (
139
+ <div className="p-4 bg-muted rounded-lg border border-border">
140
+ <p className="text-sm text-muted-foreground">{labels.deliveryDisabled}</p>
141
+ </div>
142
+ ) : shippingMethods.length === 0 ? (
143
+ <div className="p-4 bg-muted rounded-lg border border-border">
144
+ <p className="text-sm text-muted-foreground">{labels.noShippingOptions}</p>
145
+ </div>
146
+ ) : (
147
+ <div
148
+ className={cn(
149
+ showPreview &&
150
+ "rounded-[2px] border border-dashed border-border/80 bg-muted/40 px-3.5 pt-3 pb-3.5"
151
+ )}
152
+ >
153
+ {showPreview && (
154
+ <div className="flex items-center gap-2 mb-3">
155
+ <svg
156
+ viewBox="0 0 24 24"
157
+ fill="none"
158
+ stroke="currentColor"
159
+ strokeWidth={2}
160
+ className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0"
161
+ aria-hidden="true"
162
+ >
163
+ <rect x="5" y="11" width="14" height="10" rx="2" />
164
+ <path d="M8 11V7a4 4 0 0 1 8 0v4" />
165
+ </svg>
166
+ <p className="text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">
167
+ {labels.deliveryPreviewHint}
168
+ </p>
169
+ </div>
170
+ )}
171
+ <div
172
+ className={cn(
173
+ "space-y-2",
174
+ showPreview &&
175
+ "grayscale opacity-60 pointer-events-none select-none"
176
+ )}
177
+ >
178
+ {shippingMethods.map((option) => {
179
+ // `cantCalc` fades a calculated-price option to opacity-40
180
+ // when its price never resolved. Critically gated on BOTH
181
+ // `!isLoadingPrices` AND `!shippingLoading` so we don't
182
+ // visually punish an option just because the user is
183
+ // mid-courier-switch — the in-flight switch would otherwise
184
+ // leave a brief window where prices are calculated=true but
185
+ // the new option's price hasn't landed in the map yet,
186
+ // making it look broken. (Barter serves flat prices today;
187
+ // the calculated branch is forward-compat —
188
+ // docs/storefront/checkout.md.)
189
+ const cantCalc =
190
+ (option.price_type as string) === "calculated" &&
191
+ !isLoadingPrices &&
192
+ !shippingLoading &&
193
+ typeof calculatedPricesMap[option.id] !== "number"
194
+ const selected = option.id === selectedShippingMethodId
195
+ const price =
196
+ option.price_type === "flat"
197
+ ? option.amount
198
+ : calculatedPricesMap[option.id]
199
+ const isFree = price === 0
200
+ const isEcontOffice = econt && detectEcont(option)
201
+ const isBoxnowLocker =
202
+ boxnow && !isEcontOffice && detectBoxnow(option)
203
+ const hasExpanded = selected && (isEcontOffice || isBoxnowLocker)
204
+ const logo = logoByFulfillmentOptionId
205
+ ? logoByFulfillmentOptionId[getFulfillmentOptionId(option) ?? ""]
206
+ : undefined
207
+
208
+ return (
209
+ <div
210
+ key={option.id}
211
+ className={cn(
212
+ "rounded-lg border overflow-hidden transition-colors duration-150",
213
+ selected
214
+ ? "border-primary bg-primary/5"
215
+ : "border-border bg-card hover:border-muted-foreground"
216
+ )}
217
+ >
218
+ <button
219
+ type="button"
220
+ disabled={cantCalc || shippingLoading || showPreview}
221
+ onClick={showPreview ? undefined : () => onSelect(option.id)}
222
+ className={cn(
223
+ "flex items-center w-full px-4 py-3.5 text-left",
224
+ cantCalc && "opacity-40 cursor-not-allowed",
225
+ showPreview && "cursor-default"
226
+ )}
227
+ >
228
+ <div
229
+ className={cn(
230
+ "w-[18px] h-[18px] rounded-full border-2 flex items-center justify-center flex-shrink-0 mr-3 transition-colors",
231
+ selected ? "border-primary" : "border-border"
232
+ )}
233
+ >
234
+ {selected && (
235
+ <div className="w-2 h-2 rounded-full bg-primary" />
236
+ )}
237
+ </div>
238
+ {logo && (
239
+ <span
240
+ className="relative flex items-center justify-center w-10 h-7 mr-3 flex-shrink-0"
241
+ aria-hidden="true"
242
+ >
243
+ {/* eslint-disable-next-line @next/next/no-img-element */}
244
+ <img
245
+ src={logo.src}
246
+ alt={logo.alt}
247
+ className="max-w-full max-h-full object-contain"
248
+ />
249
+ </span>
250
+ )}
251
+ <div className="flex-1 min-w-0">
252
+ <span className="text-sm font-medium text-foreground">
253
+ {option.name}
254
+ </span>
255
+ {selected && isEcontOffice && econt?.selectedOffice && (
256
+ <p className="text-xs text-muted-foreground mt-0.5">
257
+ {econt.selectedOffice.name}
258
+ </p>
259
+ )}
260
+ {selected && isBoxnowLocker && boxnow?.selectedLocker && (
261
+ <p className="text-xs text-muted-foreground mt-0.5">
262
+ {boxnow.selectedLocker.title}
263
+ </p>
264
+ )}
265
+ </div>
266
+ <span
267
+ className={cn(
268
+ "text-sm font-semibold ml-4 flex-shrink-0",
269
+ isFree ? "text-success" : "text-foreground"
270
+ )}
271
+ >
272
+ {price !== undefined && price !== null ? (
273
+ isFree ? (
274
+ labels.shippingFree
275
+ ) : (
276
+ <DualPrice amount={price} currencyCode={currencyCode} />
277
+ )
278
+ ) : isLoadingPrices ? (
279
+ <svg
280
+ className="animate-spin w-4 h-4 text-muted-foreground"
281
+ fill="none"
282
+ viewBox="0 0 24 24"
283
+ >
284
+ <circle
285
+ className="opacity-25"
286
+ cx="12"
287
+ cy="12"
288
+ r="10"
289
+ stroke="currentColor"
290
+ strokeWidth="3"
291
+ />
292
+ <path
293
+ className="opacity-75"
294
+ fill="currentColor"
295
+ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
296
+ />
297
+ </svg>
298
+ ) : (
299
+ "-"
300
+ )}
301
+ </span>
302
+ </button>
303
+
304
+ {hasExpanded && isEcontOffice && econt && (
305
+ <EcontOfficeSelector
306
+ userCity={econt.userCity}
307
+ userAddress={econt.userAddress}
308
+ selectedOffice={econt.selectedOffice}
309
+ onSelect={econt.onSelectOffice}
310
+ />
311
+ )}
312
+
313
+ {hasExpanded && isBoxnowLocker && boxnow && (
314
+ <BoxNowLockerSelector
315
+ client={boxnow.client}
316
+ userCity={boxnow.userCity}
317
+ userAddress={boxnow.userAddress}
318
+ selectedLocker={boxnow.selectedLocker}
319
+ onSelect={boxnow.onSelectLocker}
320
+ />
321
+ )}
322
+ </div>
323
+ )
324
+ })}
325
+ </div>
326
+ </div>
327
+ )}
328
+
329
+ <ErrorMessage
330
+ error={shippingError}
331
+ data-testid="delivery-error-message"
332
+ />
333
+ </div>
334
+ )
335
+ }
@@ -0,0 +1,165 @@
1
+ "use client"
2
+
3
+ import { Elements, useElements } from "@stripe/react-stripe-js"
4
+ import type {
5
+ Appearance,
6
+ Stripe,
7
+ StripeElementsOptions,
8
+ } from "@stripe/stripe-js"
9
+ import {
10
+ createContext,
11
+ useContext,
12
+ useEffect,
13
+ type ReactNode,
14
+ } from "react"
15
+
16
+ /**
17
+ * Stripe Elements scope — context + scoped wrapper, **deferred-intent** mode.
18
+ *
19
+ * Ported from `@1click/ui/src/checkout/stripe-wrapper.tsx` (v2.3.1)
20
+ * unchanged — no data seam; the PaymentIntent seam moved server-side into
21
+ * barter's POST /api/store/carts/:id/prepare-checkout.
22
+ *
23
+ * `<Elements>` is mounted with `mode: 'payment'`, `amount`, `currency` — NO
24
+ * PaymentIntent exists on the backend yet. The PI is minted at Buy click by
25
+ * `prepareCheckout` (idempotency key = session id, docs/storefront/
26
+ * checkout.md); the storefront then calls
27
+ * `stripe.confirmPayment({ elements, clientSecret })`.
28
+ *
29
+ * Why deferred-intent (verified against Stripe docs).
30
+ *
31
+ * The previous eager-session model created a PaymentIntent on payment-
32
+ * tab switch. Every cart-amount change rotated the PI, forcing
33
+ * `<Elements>` to remount on a new client_secret and blanking the
34
+ * user's typed-in card form.
35
+ *
36
+ * Stripe's deferred-intent flow
37
+ * (https://docs.stripe.com/payments/accept-a-payment-deferred) lets
38
+ * `<Elements>` mount with just `mode/amount/currency`. When the
39
+ * amount changes (shipping switch, COD-fee toggle), call
40
+ * `elements.update({ amount })` and the iframe updates in place — no
41
+ * remount, no card-form blank-out.
42
+ *
43
+ * Boolean `StripeContext` is preserved for back-compat (consumers like
44
+ * payment-method-list.tsx read it as `stripeReady`). It derives from the
45
+ * scope's `ready` flag (Stripe.js promise + valid key).
46
+ */
47
+
48
+ // Boolean context kept for the existing `useContext(StripeContext)`
49
+ // usage in payment-method-list.tsx and any storefront-side consumers.
50
+ export const StripeContext = createContext(false)
51
+
52
+ type StripeScopeValue = {
53
+ /** True when Stripe.js promise + publishable key are available. */
54
+ ready: boolean
55
+ stripePromise: Promise<Stripe | null> | null
56
+ /** Optimistic total in the smallest currency unit (cents/stotinki). */
57
+ amount: number
58
+ currency: string
59
+ appearance?: Appearance
60
+ fonts?: StripeElementsOptions["fonts"]
61
+ }
62
+
63
+ const StripeScopeContext = createContext<StripeScopeValue>({
64
+ ready: false,
65
+ stripePromise: null,
66
+ amount: 0,
67
+ currency: "eur",
68
+ })
69
+
70
+ export function StripeScopeProvider({
71
+ value,
72
+ children,
73
+ }: {
74
+ value: StripeScopeValue
75
+ children: ReactNode
76
+ }) {
77
+ return (
78
+ <StripeContext.Provider value={value.ready}>
79
+ <StripeScopeContext.Provider value={value}>
80
+ {children}
81
+ </StripeScopeContext.Provider>
82
+ </StripeContext.Provider>
83
+ )
84
+ }
85
+
86
+ export function useStripeScope(): StripeScopeValue {
87
+ return useContext(StripeScopeContext)
88
+ }
89
+
90
+ /**
91
+ * StripeElementsScope — wraps children in Stripe's `<Elements>` provider
92
+ * in deferred-intent mode. Children mount inside `<Elements>` so
93
+ * `useStripe()` / `useElements()` work for the payment widget AND the
94
+ * Buy button.
95
+ *
96
+ * Behaviour when Stripe isn't ready (no stripePromise / no key):
97
+ * - default → renders `fallback` (or null)
98
+ * - `passthrough` mode → renders children directly, without `<Elements>`
99
+ *
100
+ * Use `passthrough` when wrapping a section that contains BOTH Stripe-
101
+ * dependent UI (PaymentElement) AND non-Stripe UI (a manual / COD
102
+ * branch). Without it, COD-only carts (no Stripe key) would render
103
+ * nothing inside the wrapper.
104
+ *
105
+ * Amount syncing: the inner `<AmountSync>` calls `elements.update({ amount })`
106
+ * whenever the optimistic total changes. No remount.
107
+ */
108
+ export function StripeElementsScope({
109
+ fallback = null,
110
+ passthrough = false,
111
+ children,
112
+ }: {
113
+ fallback?: ReactNode
114
+ passthrough?: boolean
115
+ children: ReactNode
116
+ }) {
117
+ const { ready, stripePromise, amount, currency, appearance, fonts } =
118
+ useStripeScope()
119
+
120
+ if (!ready || !stripePromise) {
121
+ return <>{passthrough ? children : fallback}</>
122
+ }
123
+
124
+ // `loader: "always"` forces Elements to fetch the payment-method config
125
+ // up front so a config error (bad key, network) surfaces via
126
+ // onLoadError instead of silently failing on first interaction.
127
+ const options: StripeElementsOptions = {
128
+ mode: "payment",
129
+ amount,
130
+ currency: currency.toLowerCase(),
131
+ appearance,
132
+ fonts,
133
+ loader: "always",
134
+ }
135
+
136
+ return (
137
+ <Elements options={options} stripe={stripePromise}>
138
+ <AmountSync amount={amount} currency={currency} />
139
+ {children}
140
+ </Elements>
141
+ )
142
+ }
143
+
144
+ /**
145
+ * Empty component that calls `elements.update({ amount, currency })`
146
+ * whenever the props change. Stripe's `elements.update` is the
147
+ * documented way to push amount/currency changes into a deferred-intent
148
+ * `<Elements>` instance without remounting it.
149
+ *
150
+ * Reference: https://docs.stripe.com/payments/accept-a-payment-deferred
151
+ */
152
+ function AmountSync({
153
+ amount,
154
+ currency,
155
+ }: {
156
+ amount: number
157
+ currency: string
158
+ }) {
159
+ const elements = useElements()
160
+ useEffect(() => {
161
+ if (!elements) return
162
+ elements.update({ amount, currency: currency.toLowerCase() })
163
+ }, [elements, amount, currency])
164
+ return null
165
+ }