@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,410 @@
1
+ "use client"
2
+
3
+ import { useCallback, useEffect, useMemo, useState } from "react"
4
+
5
+ import type { StorefrontClient } from "../api/http"
6
+ import { listBoxNowLockers, type BoxNowLocker } from "../api/integrations"
7
+ import { cn } from "../lib/utils"
8
+ import {
9
+ distanceMeters,
10
+ formatDistance,
11
+ geocodeAddress,
12
+ normalizeForMatch,
13
+ } from "./geocode"
14
+ import { useCheckoutLabels } from "./context"
15
+
16
+ /**
17
+ * BoxNowLockerSelector — full BoxNow locker picker for Bulgarian shipping.
18
+ *
19
+ * Ported from `@1click/ui/src/checkout/boxnow-locker-selector.tsx` (v2.3.1)
20
+ * with the barter data seam: the locker directory comes from
21
+ * `listBoxNowLockers(client)` (`@cartbase/storefront/api/integrations`,
22
+ * GET /api/store/integrations/boxnow/lockers — backend owns BoxNow API auth
23
+ * + the 10-min cache; docs/storefront/integrations.md). The @1click data
24
+ * helper returned `{ok, lockers}`; the barter SDK THROWS on 503 (not
25
+ * configured) / 502 (carrier failure), so the fetch wrapper catches and
26
+ * maps both to the same "temporarily unavailable" state. Availability
27
+ * should be discovered via `carriers.boxnow.lockers_url` on the
28
+ * integrations config instead of probing for the 503.
29
+ *
30
+ * The CHOSEN locker is client state only — the orchestration hook writes it
31
+ * into `carrier_metadata` (boxnow_locker_id/title/address/postal) exactly
32
+ * once at Buy click via prepare-checkout (docs/storefront/checkout.md).
33
+ *
34
+ * Mirrors EcontOfficeSelector's UX 1:1 (loading spinner, selected pill,
35
+ * "search for another" expandable, nearest-3 by haversine distance).
36
+ *
37
+ * Data shape differences from Econt handled here:
38
+ * - title (not name)
39
+ * - addressLine1 / addressLine2 (not address.street/num)
40
+ * - postalCode (not address.city.name)
41
+ * - lat / lng on the root (numbers or null — the barter route coerces
42
+ * malformed carrier coordinates to null, never NaN)
43
+ * - No isAPS / code / phones
44
+ *
45
+ * Edge cases (503/502/empty) show a single "временно недостъпно" message.
46
+ */
47
+
48
+ export type { BoxNowLocker }
49
+
50
+ type BoxNowLockerSelectorProps = {
51
+ /** The SDK transport — the locker directory is fetched through it. */
52
+ client: StorefrontClient
53
+ userCity: string
54
+ userAddress: string
55
+ selectedLocker: BoxNowLocker | null
56
+ onSelect: (locker: BoxNowLocker | null) => void
57
+ }
58
+
59
+ type LockersState =
60
+ | { status: "loading" }
61
+ | { status: "ready"; lockers: BoxNowLocker[] }
62
+ | { status: "error" }
63
+
64
+ // Module-level cache so switching between shipping rows doesn't re-hit
65
+ // the backend on every toggle. (One store per app — the storefront
66
+ // package is mounted for a single tenant, so a per-module cache is safe.)
67
+ let lockersCache: BoxNowLocker[] | null = null
68
+ let lockersPromise: Promise<BoxNowLocker[] | null> | null = null
69
+
70
+ async function fetchLockers(
71
+ client: StorefrontClient
72
+ ): Promise<BoxNowLocker[] | null> {
73
+ if (lockersCache) return lockersCache
74
+ if (lockersPromise) return lockersPromise
75
+
76
+ lockersPromise = listBoxNowLockers(client)
77
+ .then((res) => {
78
+ lockersCache = res.lockers
79
+ return lockersCache
80
+ })
81
+ .catch(() => {
82
+ // 503 boxnow-not-configured / 502 carrier failure / network error —
83
+ // all render the same "temporarily unavailable" state. Reset the
84
+ // promise so a remount can retry (the cache stays empty on failure).
85
+ lockersPromise = null
86
+ return null
87
+ })
88
+
89
+ return lockersPromise
90
+ }
91
+
92
+ export function BoxNowLockerSelector({
93
+ client,
94
+ userCity,
95
+ userAddress,
96
+ selectedLocker,
97
+ onSelect,
98
+ }: BoxNowLockerSelectorProps) {
99
+ const labels = useCheckoutLabels()
100
+ const [lockersState, setLockersState] = useState<LockersState>({
101
+ status: "loading",
102
+ })
103
+ const [userCoords, setUserCoords] = useState<{
104
+ lat: number
105
+ lng: number
106
+ } | null>(null)
107
+ const [search, setSearch] = useState("")
108
+ const [showSearch, setShowSearch] = useState(false)
109
+
110
+ useEffect(() => {
111
+ setLockersState({ status: "loading" })
112
+ Promise.all([
113
+ fetchLockers(client),
114
+ userCity && userAddress
115
+ ? geocodeAddress(userCity, userAddress)
116
+ : Promise.resolve(null),
117
+ ]).then(([fetchedLockers, coords]) => {
118
+ if (!fetchedLockers || fetchedLockers.length === 0) {
119
+ setLockersState({ status: "error" })
120
+ } else {
121
+ setLockersState({ status: "ready", lockers: fetchedLockers })
122
+ }
123
+ setUserCoords(coords)
124
+ })
125
+ }, [client, userCity, userAddress])
126
+
127
+ const lockers =
128
+ lockersState.status === "ready" ? lockersState.lockers : []
129
+
130
+ // City-lock: every list (nearest + search) is restricted to the user's
131
+ // checkout city. BoxNow stores the city in addressLine2, occasionally
132
+ // in addressLine1 too. If the user hasn't entered a city yet, fall
133
+ // back to all lockers so the UI doesn't render empty on first paint.
134
+ //
135
+ // Both sides are transliterated Cyrillic→Latin so "Sofia" matches
136
+ // "София" and vice versa. Idempotent on already-Latin input.
137
+ const cityNormalized = normalizeForMatch(userCity.trim())
138
+ const cityLockedLockers = useMemo(() => {
139
+ if (!cityNormalized) return lockers
140
+ return lockers.filter((l) => {
141
+ const line1 = normalizeForMatch(l.addressLine1 ?? "")
142
+ const line2 = normalizeForMatch(l.addressLine2 ?? "")
143
+ return line2.includes(cityNormalized) || line1.includes(cityNormalized)
144
+ })
145
+ }, [lockers, cityNormalized])
146
+
147
+ const nearestLockers = useMemo(() => {
148
+ if (!cityLockedLockers.length) return []
149
+
150
+ const geocodedLockers = cityLockedLockers.filter(
151
+ (l) =>
152
+ typeof l.lat === "number" &&
153
+ typeof l.lng === "number" &&
154
+ !Number.isNaN(l.lat) &&
155
+ !Number.isNaN(l.lng)
156
+ )
157
+
158
+ if (userCoords && geocodedLockers.length > 0) {
159
+ return geocodedLockers
160
+ .map((l) => ({
161
+ locker: l,
162
+ distance: distanceMeters(
163
+ userCoords.lat,
164
+ userCoords.lng,
165
+ l.lat as number,
166
+ l.lng as number
167
+ ),
168
+ }))
169
+ .sort((a, b) => a.distance - b.distance)
170
+ .slice(0, 3)
171
+ }
172
+
173
+ // No coords available — just show the first 3 city-locked lockers.
174
+ return cityLockedLockers
175
+ .slice(0, 3)
176
+ .map((l) => ({ locker: l, distance: 0 }))
177
+ }, [cityLockedLockers, userCoords])
178
+
179
+ const searchResults = useMemo(() => {
180
+ if (!search.trim() || search.trim().length < 2) return []
181
+ // Normalize query and locker fields through Cyrillic→Latin so the
182
+ // user can search "Vitosha" and match "Витоша" (and vice versa).
183
+ const q = normalizeForMatch(search.trim())
184
+ // Search is also city-locked — only lockers in the user's city.
185
+ // Uncapped; container scrolls.
186
+ return cityLockedLockers.filter((l) => {
187
+ const title = normalizeForMatch(l.title ?? "")
188
+ const line1 = normalizeForMatch(l.addressLine1 ?? "")
189
+ const line2 = normalizeForMatch(l.addressLine2 ?? "")
190
+ const postal = (l.postalCode ?? "").toLowerCase()
191
+ return (
192
+ title.includes(q) ||
193
+ line1.includes(q) ||
194
+ line2.includes(q) ||
195
+ postal.includes(q)
196
+ )
197
+ })
198
+ }, [cityLockedLockers, search])
199
+
200
+ const renderLocker = useCallback(
201
+ (locker: BoxNowLocker, distance: number | null, isSelected: boolean) => (
202
+ <button
203
+ key={locker.id}
204
+ type="button"
205
+ onClick={() => {
206
+ onSelect(locker)
207
+ setShowSearch(false)
208
+ setSearch("")
209
+ }}
210
+ className={cn(
211
+ "flex items-start gap-3 w-full px-3.5 py-3 text-left transition-all duration-150 rounded-lg",
212
+ isSelected ? "bg-primary/10" : "bg-card hover:bg-muted"
213
+ )}
214
+ style={
215
+ isSelected
216
+ ? { boxShadow: "inset 0 0 0 1.5px oklch(var(--primary))" }
217
+ : undefined
218
+ }
219
+ >
220
+ <div className="w-8 h-8 rounded-full bg-muted flex items-center justify-center flex-shrink-0 mt-0.5">
221
+ <svg
222
+ className="w-4 h-4 text-muted-foreground"
223
+ fill="none"
224
+ viewBox="0 0 24 24"
225
+ stroke="currentColor"
226
+ strokeWidth={2}
227
+ >
228
+ <path
229
+ strokeLinecap="round"
230
+ strokeLinejoin="round"
231
+ d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z"
232
+ />
233
+ <path
234
+ strokeLinecap="round"
235
+ strokeLinejoin="round"
236
+ d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z"
237
+ />
238
+ </svg>
239
+ </div>
240
+
241
+ <div className="flex-1 min-w-0">
242
+ <p className="text-sm font-medium text-foreground leading-tight">
243
+ {locker.title}
244
+ </p>
245
+ <p className="text-xs text-muted-foreground mt-0.5">
246
+ {locker.addressLine1}
247
+ {locker.addressLine2 ? `, ${locker.addressLine2}` : ""}
248
+ {locker.postalCode ? `, ${locker.postalCode}` : ""}
249
+ </p>
250
+ </div>
251
+
252
+ {distance !== null && distance > 0 && (
253
+ <span className="text-xs font-medium text-muted-foreground flex-shrink-0 mt-1">
254
+ {formatDistance(distance)}
255
+ </span>
256
+ )}
257
+ </button>
258
+ ),
259
+ [onSelect]
260
+ )
261
+
262
+ if (lockersState.status === "loading") {
263
+ return (
264
+ <div className="px-4 py-6 flex items-center justify-center">
265
+ <div className="w-5 h-5 border-2 border-border border-t-text-muted rounded-full animate-spin" />
266
+ <span className="ml-2 text-sm text-muted-foreground">
267
+ {labels.boxnowLoadingLockers}
268
+ </span>
269
+ </div>
270
+ )
271
+ }
272
+
273
+ if (lockersState.status === "error") {
274
+ return (
275
+ <div className="px-4 py-4">
276
+ <div className="p-3 rounded-lg bg-muted border border-border">
277
+ <p className="text-sm text-muted-foreground">
278
+ {labels.boxnowUnavailable}
279
+ </p>
280
+ </div>
281
+ </div>
282
+ )
283
+ }
284
+
285
+ // User's city has no lockers — explicit dead-end message so they pick
286
+ // a different shipping method instead of staring at silence.
287
+ if (cityNormalized && cityLockedLockers.length === 0 && !selectedLocker) {
288
+ return (
289
+ <div className="px-4 py-4">
290
+ <div className="p-3 rounded-lg bg-muted border border-border">
291
+ <p className="text-sm text-muted-foreground">
292
+ {labels.boxnowNoLockersInCity}
293
+ </p>
294
+ </div>
295
+ </div>
296
+ )
297
+ }
298
+
299
+ return (
300
+ <div className="px-4 pb-4 pt-2 space-y-3">
301
+ {selectedLocker && (
302
+ <div className="flex items-center gap-2 px-3 py-2 bg-primary/10 border border-primary/30 rounded-lg">
303
+ <svg
304
+ className="w-4 h-4 text-primary flex-shrink-0"
305
+ fill="none"
306
+ viewBox="0 0 24 24"
307
+ stroke="currentColor"
308
+ strokeWidth={2}
309
+ >
310
+ <path
311
+ strokeLinecap="round"
312
+ strokeLinejoin="round"
313
+ d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
314
+ />
315
+ </svg>
316
+ <p className="text-sm font-medium text-primary">
317
+ {selectedLocker.title}
318
+ </p>
319
+ <button
320
+ type="button"
321
+ onClick={() => onSelect(null)}
322
+ className="ml-auto text-xs text-primary hover:underline"
323
+ >
324
+ {labels.boxnowChange}
325
+ </button>
326
+ </div>
327
+ )}
328
+
329
+ {!selectedLocker && nearestLockers.length > 0 && (
330
+ <div>
331
+ <p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2">
332
+ {labels.boxnowNearestLockers}
333
+ </p>
334
+ <div className="space-y-1.5">
335
+ {nearestLockers.map(({ locker, distance }) =>
336
+ renderLocker(locker, distance, false)
337
+ )}
338
+ </div>
339
+ </div>
340
+ )}
341
+
342
+ {!selectedLocker && (
343
+ <div>
344
+ {!showSearch ? (
345
+ <button
346
+ type="button"
347
+ onClick={() => setShowSearch(true)}
348
+ className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
349
+ >
350
+ <svg
351
+ className="w-4 h-4"
352
+ fill="none"
353
+ viewBox="0 0 24 24"
354
+ stroke="currentColor"
355
+ strokeWidth={2}
356
+ >
357
+ <path
358
+ strokeLinecap="round"
359
+ strokeLinejoin="round"
360
+ d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
361
+ />
362
+ </svg>
363
+ {labels.boxnowSearchAnother}
364
+ </button>
365
+ ) : (
366
+ <div>
367
+ <div className="relative">
368
+ <svg
369
+ className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground"
370
+ fill="none"
371
+ viewBox="0 0 24 24"
372
+ stroke="currentColor"
373
+ strokeWidth={2}
374
+ >
375
+ <path
376
+ strokeLinecap="round"
377
+ strokeLinejoin="round"
378
+ d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
379
+ />
380
+ </svg>
381
+ <input
382
+ type="text"
383
+ value={search}
384
+ onChange={(e) => setSearch(e.target.value)}
385
+ placeholder={labels.boxnowSearchPlaceholder}
386
+ className="w-full h-10 pl-9 pr-3 text-sm rounded-lg border border-border bg-card focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-all"
387
+ autoFocus
388
+ />
389
+ </div>
390
+
391
+ {searchResults.length > 0 && (
392
+ <div className="mt-2 space-y-1 max-h-[240px] overflow-y-auto">
393
+ {searchResults.map((locker) =>
394
+ renderLocker(locker, null, false)
395
+ )}
396
+ </div>
397
+ )}
398
+
399
+ {search.trim().length >= 2 && searchResults.length === 0 && (
400
+ <p className="mt-2 text-xs text-muted-foreground text-center py-3">
401
+ {labels.boxnowNoResults} "{search}"
402
+ </p>
403
+ )}
404
+ </div>
405
+ )}
406
+ </div>
407
+ )}
408
+ </div>
409
+ )
410
+ }
@@ -0,0 +1,222 @@
1
+ "use client"
2
+
3
+ import type { Appearance, StripeElementsOptions } from "@stripe/stripe-js"
4
+
5
+ import type { StorefrontClient } from "../api/http"
6
+ import type { Cart } from "../api/carts"
7
+ import type { StoreShippingOption } from "../api/checkout"
8
+ import type { StoreCustomer } from "../api/customers"
9
+ import { CheckoutAddressForm } from "./address-form"
10
+ import { OrderSummary } from "./order-summary"
11
+ import { CheckoutPaymentMethodList } from "./payment-method-list"
12
+ import { CheckoutShippingMethodList } from "./shipping-method-list"
13
+ import { PaymentWrapper } from "./payment-wrapper"
14
+ import {
15
+ useCheckoutOrchestration,
16
+ type CheckoutCodConfig,
17
+ type CheckoutLogError,
18
+ type PaymentProviderLike,
19
+ } from "./use-checkout-orchestration"
20
+
21
+ /**
22
+ * CheckoutClient — single-page checkout layout. The orchestration logic
23
+ * (address state, shipping select, carrier metadata, payment-tab race
24
+ * guards, 3DS return handling, completed-cart detection) lives in the
25
+ * `useCheckoutOrchestration` hook so any store with a custom layout
26
+ * shares the same hardened state machine and inherits future fixes
27
+ * automatically.
28
+ *
29
+ * Ported from `@1click/ui/src/checkout/checkout-client.tsx` (v2.3.1) with
30
+ * the barter data seam:
31
+ *
32
+ * - Takes the SDK `client` — every server call (address autosave,
33
+ * prepare-checkout, sync, complete, promo/gift-card apply, quantity
34
+ * changes) runs through it.
35
+ * - The listings arrive as SDK DTOs: `listShippingOptions` /
36
+ * `listPaymentProviders` (both rule-filtered server-side) — fetch them
37
+ * in the host and pass down (docs/storefront/checkout.md).
38
+ * - `codConfig` = the integrations config `cod` block
39
+ * (`getIntegrationsConfig(client).cod`) — the COD fee prediction is
40
+ * NEVER hardcoded (docs/storefront/integrations.md).
41
+ * - `onCartChange` closes the loop for summary mutations: the decorated
42
+ * cart returned by promo/gift-card/quantity calls is handed up so the
43
+ * host updates its cart state (`router.refresh()` in an RSC app) AND
44
+ * the hook's `syncPaymentAmount` re-aligns any pending session.
45
+ * - The mobile top/bottom summary bars of the @1click layout are not in
46
+ * this batch's scope; the desktop-first layout stands alone and
47
+ * stores compose their own mobile bars from the hook + OrderSummary.
48
+ */
49
+
50
+ type CheckoutClientProps = {
51
+ /** The SDK transport — all server calls go through it. */
52
+ client: StorefrontClient
53
+ cart: Cart
54
+ customer: StoreCustomer | null
55
+ availableShippingMethods: StoreShippingOption[] | null
56
+ availablePaymentMethods: PaymentProviderLike[] | null
57
+ countryCode: string
58
+ /** Countries for the address form (barter regions embed none). */
59
+ countries?: Array<{ iso_2: string; display_name: string }>
60
+ paymentMethodFilter?: (
61
+ methods: PaymentProviderLike[] | null,
62
+ selectedShippingOption: StoreShippingOption | null
63
+ ) => PaymentProviderLike[] | null
64
+ /** The integrations config `cod` block — drives the optimistic COD fee. */
65
+ codConfig?: CheckoutCodConfig
66
+ /** Show the gift-card widget in the summary (default true). */
67
+ showGiftCards?: boolean
68
+ logoByFulfillmentOptionId?: Record<string, { src: string; alt: string }>
69
+ /** Stripe Elements appearance + fonts forwarded to PaymentWrapper. */
70
+ appearance?: Appearance
71
+ fonts?: StripeElementsOptions["fonts"]
72
+ /** Order-confirmed path template override (`{id}` / `{country}`). */
73
+ orderConfirmedPath?: string
74
+ /** Custom post-complete navigation (next/navigation router push). */
75
+ onOrderPlaced?: Parameters<
76
+ typeof useCheckoutOrchestration
77
+ >[0]["onOrderPlaced"]
78
+ /** Consent-gated tracking-attribution resolver (see the hook's JSDoc). */
79
+ resolveTrackingMetadata?: () => Record<string, unknown> | undefined
80
+ /** Operational-visibility sink for checkout errors. */
81
+ logError?: CheckoutLogError
82
+ /** Receives every decorated cart returned by a summary mutation. */
83
+ onCartChange?: (cart: Cart) => void
84
+ }
85
+
86
+ export function CheckoutClient({
87
+ client,
88
+ cart,
89
+ customer,
90
+ availableShippingMethods,
91
+ availablePaymentMethods,
92
+ countryCode,
93
+ countries,
94
+ paymentMethodFilter,
95
+ codConfig,
96
+ showGiftCards = true,
97
+ logoByFulfillmentOptionId,
98
+ appearance,
99
+ fonts,
100
+ orderConfirmedPath,
101
+ onOrderPlaced,
102
+ resolveTrackingMetadata,
103
+ logError,
104
+ onCartChange,
105
+ }: CheckoutClientProps) {
106
+ const o = useCheckoutOrchestration({
107
+ client,
108
+ cart,
109
+ customer,
110
+ availableShippingMethods,
111
+ availablePaymentMethods,
112
+ countryCode,
113
+ countries,
114
+ paymentMethodFilter,
115
+ codConfig,
116
+ orderConfirmedPath,
117
+ onOrderPlaced,
118
+ resolveTrackingMetadata,
119
+ logError,
120
+ })
121
+
122
+ const buyButtonNotReady = !o.deliveryReady || !o.addressReady
123
+
124
+ // Summary mutations (promo/gift-card apply, quantity change) move the
125
+ // total — re-align any pending payment session in place so the Stripe
126
+ // client_secret survives (sync-payment-amount matrix; pre-Buy it no-ops
127
+ // with `no_payment_collection`). Then hand the fresh cart to the host.
128
+ const handleCartChange = (updated: Cart) => {
129
+ void o.syncPaymentAmount().catch(() => {})
130
+ onCartChange?.(updated)
131
+ }
132
+
133
+ return (
134
+ <PaymentWrapper
135
+ cart={cart}
136
+ amount={o.optimisticTotalCents}
137
+ appearance={appearance}
138
+ fonts={fonts}
139
+ >
140
+ <div className="max-w-[1140px] mx-auto px-5 py-8 sm:py-12">
141
+ <div className="flex flex-col sm:flex-row sm:justify-center gap-8 sm:gap-12">
142
+ {/* ═══ LEFT: FORM ════════════════════════════════════════════ */}
143
+ <div className="w-full sm:max-w-[480px]">
144
+ <CheckoutAddressForm
145
+ formData={o.formData}
146
+ onChange={o.handleFormChange}
147
+ onBlur={o.handleFieldBlur}
148
+ savedAddresses={o.addressesInRegion ?? undefined}
149
+ onSelectSavedAddress={o.setFormAddress}
150
+ countries={o.regionCountries}
151
+ addressInput={o.addressInput}
152
+ addressError={o.addressError}
153
+ pulseFields={o.pulseFields}
154
+ />
155
+
156
+ <CheckoutShippingMethodList
157
+ shippingMethods={o.shippingMethods}
158
+ selectedShippingMethodId={o.selectedShippingMethod}
159
+ calculatedPricesMap={o.calculatedPricesMap}
160
+ isLoadingPrices={o.isLoadingPrices}
161
+ shippingLoading={o.shippingLoading}
162
+ shippingError={o.shippingError}
163
+ onSelect={o.handleSelectShipping}
164
+ addressReady={o.addressReady}
165
+ currencyCode={cart.currency_code}
166
+ econt={{
167
+ selectedOffice: o.selectedEcontOffice,
168
+ onSelectOffice: o.handleSelectEcontOffice,
169
+ userCity: o.formData["shipping_address.city"] ?? "",
170
+ userAddress: o.formData["shipping_address.address_1"] ?? "",
171
+ }}
172
+ boxnow={{
173
+ client,
174
+ selectedLocker: o.selectedBoxnowLocker,
175
+ onSelectLocker: o.handleSelectBoxnowLocker,
176
+ userCity: o.formData["shipping_address.city"] ?? "",
177
+ userAddress: o.formData["shipping_address.address_1"] ?? "",
178
+ }}
179
+ logoByFulfillmentOptionId={logoByFulfillmentOptionId}
180
+ />
181
+
182
+ <CheckoutPaymentMethodList
183
+ cart={cart}
184
+ hasCard={o.hasCard}
185
+ hasCod={o.hasCod}
186
+ paymentTab={o.paymentTab}
187
+ onPaymentTab={o.handlePaymentTab}
188
+ deliveryReady={o.deliveryReady}
189
+ paymentError={o.paymentError}
190
+ onPaymentElementChange={o.handlePaymentElementChange}
191
+ performBuyClick={o.performBuyClick}
192
+ buyButtonNotReady={buyButtonNotReady}
193
+ buyButtonNotReadyReason={
194
+ !o.addressReady ? "address" : !o.deliveryReady ? "delivery" : null
195
+ }
196
+ total={o.optimisticTotal}
197
+ logError={logError}
198
+ />
199
+ </div>
200
+
201
+ {/* ═══ RIGHT: ORDER SUMMARY (desktop only) ═══════════════════ */}
202
+ <div className="hidden sm:block sm:w-[420px] flex-shrink-0">
203
+ <div className="sm:sticky sm:top-6">
204
+ <OrderSummary
205
+ client={client}
206
+ cart={o.summaryCart}
207
+ optimisticShippingCost={o.optimisticShippingCost}
208
+ onOptimisticShippingClear={() =>
209
+ o.setOptimisticShippingCost(null)
210
+ }
211
+ optimisticCodFee={o.optimisticCodFee}
212
+ onOptimisticCodFeeClear={() => o.setOptimisticCodFee(null)}
213
+ showGiftCards={showGiftCards}
214
+ onCartChange={handleCartChange}
215
+ />
216
+ </div>
217
+ </div>
218
+ </div>
219
+ </div>
220
+ </PaymentWrapper>
221
+ )
222
+ }