@cartbase/storefront 0.20.0 → 0.20.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.
- package/LICENSE +21 -21
- package/package.json +258 -258
- package/src/api/auth.ts +108 -108
- package/src/api/carts.ts +523 -523
- package/src/api/categories.ts +184 -184
- package/src/api/checkout.ts +526 -526
- package/src/api/collections.ts +130 -130
- package/src/api/consent.ts +75 -75
- package/src/api/content.ts +125 -125
- package/src/api/customers.ts +303 -303
- package/src/api/gift-cards.ts +112 -112
- package/src/api/http.ts +180 -180
- package/src/api/index.ts +30 -30
- package/src/api/menus.ts +77 -77
- package/src/api/metaobjects.ts +136 -136
- package/src/api/orders.ts +290 -290
- package/src/api/redirects.ts +37 -37
- package/src/api/regions.ts +200 -200
- package/src/api/search.ts +163 -163
- package/src/api/store.ts +35 -35
- package/src/api/types.ts +91 -91
- package/src/cart-drawer/context.tsx +778 -778
- package/src/cart-drawer/cross-sell-carousel.tsx +211 -211
- package/src/cart-drawer/cross-sell-sidebar.tsx +158 -158
- package/src/cart-drawer/gift-wrap.tsx +82 -82
- package/src/cart-drawer/item/index.tsx +162 -162
- package/src/cart-drawer/item/upsell.tsx +110 -110
- package/src/cart-drawer/labels.ts +123 -123
- package/src/cart-drawer/mutation-queue.ts +78 -78
- package/src/cart-drawer/notes.tsx +131 -131
- package/src/cart-drawer/sticky-footer.tsx +73 -73
- package/src/cart-drawer/summary-breakdown.tsx +197 -197
- package/src/checkout/address-error-copy.ts +117 -117
- package/src/checkout/boxnow-locker-selector.tsx +410 -410
- package/src/checkout/compare-addresses.ts +40 -40
- package/src/checkout/discount-section.tsx +218 -218
- package/src/checkout/error-copy-codes.ts +63 -63
- package/src/checkout/geocode.ts +154 -154
- package/src/checkout/gift-card-section.tsx +224 -224
- package/src/checkout/index.ts +72 -72
- package/src/checkout/labels.ts +495 -495
- package/src/checkout/payment-button.tsx +372 -372
- package/src/checkout/payment-error-copy.ts +154 -154
- package/src/checkout/promotion-error-copy.ts +91 -91
- package/src/common/country-flag.tsx +52 -52
- package/src/common/country-select.tsx +11 -11
- package/src/common/index.ts +20 -20
- package/src/common/market-select.tsx +57 -57
- package/src/index.ts +12 -12
- package/src/lib/country-name.ts +59 -59
- package/src/lib/get-product-price.ts +133 -133
- package/src/lib/media-image.tsx +39 -39
- package/src/lib/payment-constants.ts +53 -53
- package/src/lib/platform.ts +13 -13
- package/src/lib/price.tsx +39 -39
- package/src/lib/store-api-error.ts +36 -36
- package/src/lib/variant-caption.ts +32 -32
- package/src/locales/context.ts +37 -37
- package/src/locales/en.ts +26 -26
- package/src/locales/index.ts +19 -19
- package/src/locales/provider.tsx +59 -59
- package/src/locales/types.ts +77 -77
- package/src/order/index.ts +62 -62
- package/src/order/labels.ts +79 -79
- package/src/order/order-totals.tsx +250 -250
- package/src/primitives/select-field.tsx +93 -93
- package/src/products/image-gallery.tsx +43 -43
- package/src/products/option-select.tsx +67 -67
- package/src/products/product-info.tsx +5 -5
- package/src/products/product-price.tsx +69 -69
- package/src/products/product-promises.tsx +61 -61
- package/src/products/product-specs.tsx +65 -65
- package/src/products/product-tabs.tsx +123 -123
- package/src/products/purchase-options.tsx +130 -130
- package/src/products/sets.ts +110 -110
- package/src/products/variant-matching.ts +71 -71
- package/src/products/variant-url.ts +74 -74
- package/src/reviews-ui/lightbox-state.ts +46 -46
- package/src/reviews-ui/review-header.tsx +166 -166
- package/src/reviews-ui/review-lightbox.tsx +271 -271
- package/src/reviews-ui/review-list.tsx +193 -193
- package/src/reviews-ui/review-widget.tsx +219 -218
- package/src/reviews-ui/widget-options.ts +55 -55
- package/src/store/category-template.tsx +136 -136
- package/src/store/index.ts +40 -40
- package/src/tracking/chatgpt-pixel.tsx +99 -99
- package/src/tracking/consent-init.tsx +62 -62
- package/src/tracking/events.ts +348 -348
- package/src/tracking/ga4.tsx +93 -93
- package/src/tracking/google-ads.ts +84 -84
- package/src/tracking/gtm.tsx +60 -60
- package/src/tracking/inline-script.ts +49 -49
- package/src/tracking/oaiq.ts +206 -206
- package/src/tracking/tiktok-pixel.tsx +91 -91
- package/src/tracking/track-init.tsx +56 -56
- package/src/tracking/track-order-purchase.tsx +122 -122
- package/src/tracking/ttq.ts +180 -180
- package/src/tracking/use-tracking-config.ts +54 -54
- package/theme/index.css +25 -25
|
@@ -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
|
+
}
|