@cartbase/storefront 0.17.0 → 0.18.1

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 (108) hide show
  1. package/LICENSE +21 -21
  2. package/package.json +3 -1
  3. package/src/api/auth.ts +108 -108
  4. package/src/api/carts.ts +523 -523
  5. package/src/api/categories.ts +184 -184
  6. package/src/api/checkout.ts +526 -526
  7. package/src/api/collections.ts +130 -130
  8. package/src/api/consent.ts +75 -75
  9. package/src/api/content.ts +125 -125
  10. package/src/api/customers.ts +303 -303
  11. package/src/api/gift-cards.ts +112 -112
  12. package/src/api/http.ts +180 -180
  13. package/src/api/index.ts +30 -30
  14. package/src/api/menus.ts +77 -77
  15. package/src/api/metaobjects.ts +136 -136
  16. package/src/api/orders.ts +290 -290
  17. package/src/api/products.ts +43 -1
  18. package/src/api/redirects.ts +37 -37
  19. package/src/api/regions.ts +200 -200
  20. package/src/api/reviews.ts +259 -259
  21. package/src/api/search.ts +163 -163
  22. package/src/api/store.ts +35 -35
  23. package/src/api/types.ts +91 -91
  24. package/src/cart-drawer/context.tsx +367 -168
  25. package/src/cart-drawer/cross-sell-carousel.tsx +211 -211
  26. package/src/cart-drawer/cross-sell-sidebar.tsx +158 -158
  27. package/src/cart-drawer/gift-wrap.tsx +82 -82
  28. package/src/cart-drawer/index.ts +73 -68
  29. package/src/cart-drawer/item/index.tsx +162 -162
  30. package/src/cart-drawer/item/quantity.tsx +8 -14
  31. package/src/cart-drawer/item/upsell.tsx +110 -110
  32. package/src/cart-drawer/labels.ts +123 -123
  33. package/src/cart-drawer/mutation-queue.ts +78 -0
  34. package/src/cart-drawer/notes.tsx +131 -131
  35. package/src/cart-drawer/sticky-footer.tsx +73 -73
  36. package/src/cart-drawer/summary-breakdown.tsx +197 -197
  37. package/src/checkout/address-error-copy.ts +117 -117
  38. package/src/checkout/boxnow-locker-selector.tsx +410 -410
  39. package/src/checkout/compare-addresses.ts +40 -40
  40. package/src/checkout/discount-section.tsx +218 -218
  41. package/src/checkout/error-copy-codes.ts +63 -63
  42. package/src/checkout/geocode.ts +154 -154
  43. package/src/checkout/gift-card-section.tsx +224 -224
  44. package/src/checkout/index.ts +72 -72
  45. package/src/checkout/labels.ts +495 -495
  46. package/src/checkout/payment-button.tsx +372 -372
  47. package/src/checkout/payment-error-copy.ts +154 -154
  48. package/src/checkout/promotion-error-copy.ts +91 -91
  49. package/src/checkout/use-checkout-orchestration.ts +1604 -1592
  50. package/src/common/country-flag.tsx +52 -52
  51. package/src/common/country-select.tsx +11 -11
  52. package/src/common/index.ts +20 -20
  53. package/src/common/market-select.tsx +57 -57
  54. package/src/index.ts +12 -12
  55. package/src/lib/cookie-names.ts +14 -0
  56. package/src/lib/country-name.ts +59 -59
  57. package/src/lib/get-product-price.ts +133 -133
  58. package/src/lib/media-image.tsx +39 -39
  59. package/src/lib/payment-constants.ts +53 -53
  60. package/src/lib/platform.ts +13 -13
  61. package/src/lib/price.tsx +39 -39
  62. package/src/lib/store-api-error.ts +36 -36
  63. package/src/lib/variant-caption.ts +32 -32
  64. package/src/locales/bg.ts +467 -467
  65. package/src/locales/context.ts +37 -37
  66. package/src/locales/en.ts +26 -26
  67. package/src/locales/es.ts +466 -466
  68. package/src/locales/index.ts +19 -19
  69. package/src/locales/provider.tsx +59 -59
  70. package/src/locales/types.ts +77 -77
  71. package/src/order/index.ts +62 -62
  72. package/src/order/labels.ts +79 -79
  73. package/src/order/order-totals.tsx +250 -250
  74. package/src/primitives/select-field.tsx +93 -93
  75. package/src/products/image-gallery.tsx +43 -43
  76. package/src/products/index.ts +68 -59
  77. package/src/products/option-select.tsx +67 -67
  78. package/src/products/product-actions-wrapper.tsx +63 -62
  79. package/src/products/product-actions.tsx +151 -150
  80. package/src/products/product-price.tsx +69 -69
  81. package/src/products/product-promises.tsx +61 -61
  82. package/src/products/product-specs.tsx +65 -65
  83. package/src/products/product-tabs.tsx +123 -123
  84. package/src/products/product-template.tsx +163 -162
  85. package/src/products/purchase-options.tsx +130 -130
  86. package/src/products/sets.ts +110 -0
  87. package/src/products/use-product-actions.ts +199 -164
  88. package/src/products/variant-matching.ts +71 -71
  89. package/src/products/variant-url.ts +74 -74
  90. package/src/reviews-ui/index.ts +73 -73
  91. package/src/store/category-template.tsx +136 -136
  92. package/src/store/index.ts +40 -40
  93. package/src/tracking/chatgpt-pixel.tsx +99 -99
  94. package/src/tracking/consent-init.tsx +62 -62
  95. package/src/tracking/events.ts +348 -348
  96. package/src/tracking/ga4.tsx +93 -93
  97. package/src/tracking/google-ads.ts +84 -84
  98. package/src/tracking/gtm.tsx +60 -60
  99. package/src/tracking/inline-script.ts +49 -49
  100. package/src/tracking/oaiq.ts +206 -206
  101. package/src/tracking/tiktok-pixel.tsx +91 -91
  102. package/src/tracking/track-init.tsx +56 -56
  103. package/src/tracking/track-order-purchase.tsx +122 -122
  104. package/src/tracking/ttq.ts +180 -180
  105. package/src/tracking/use-tracking-config.ts +54 -54
  106. package/theme/index.css +25 -25
  107. package/theme/theme.css +150 -150
  108. package/theme/tokens.css +106 -106
@@ -1,410 +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 Cartbase 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 Cartbase 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 Cartbase 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
- }
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 Cartbase 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 Cartbase 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 Cartbase 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
+ }