@cartbase/storefront 0.20.1 → 0.22.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.
- package/package.json +274 -258
- package/src/api/checkout.ts +15 -0
- package/src/api/http.ts +10 -0
- package/src/api/integrations.ts +121 -0
- package/src/api/store.ts +21 -0
- package/src/cart-drawer/payment-badges.tsx +36 -96
- package/src/checkout/card-offer.ts +68 -0
- package/src/checkout/carrier-marks.ts +53 -0
- package/src/checkout/checkout-client.tsx +71 -13
- package/src/checkout/checkout-error-screen.tsx +113 -0
- package/src/checkout/discount-section.tsx +88 -56
- package/src/checkout/fulfillment-option.ts +30 -0
- package/src/checkout/index.ts +41 -6
- package/src/checkout/labels.ts +98 -0
- package/src/checkout/line-item-card.tsx +35 -83
- package/src/checkout/mobile-checkout-bottom-bar.tsx +132 -0
- package/src/checkout/mobile-checkout-top-bar.tsx +94 -0
- package/src/checkout/mobile-order-summary-body.tsx +172 -0
- package/src/checkout/order-summary.tsx +85 -179
- package/src/checkout/payment-button.tsx +25 -8
- package/src/checkout/payment-method-list.tsx +51 -13
- package/src/checkout/payment-wrapper.tsx +57 -13
- package/src/checkout/pickup-option.ts +35 -0
- package/src/checkout/pickup-point-selector.tsx +372 -0
- package/src/checkout/pigeon-office-selector.tsx +379 -0
- package/src/checkout/shipping-method-list.tsx +102 -10
- package/src/checkout/summary-math.ts +152 -0
- package/src/checkout/use-checkout-funnel.ts +303 -0
- package/src/checkout/use-checkout-orchestration.ts +384 -30
- package/src/common/icons/cartbase-mark.ts +9 -0
- package/src/common/icons/payment-marks.ts +73 -0
- package/src/common/icons/social-marks.ts +61 -0
- package/src/common/index.ts +15 -0
- package/src/common/payment-icons.tsx +54 -0
- package/src/common/powered-by-cartbase.tsx +47 -0
- package/src/common/social-links.tsx +65 -0
- package/src/lib/stripe-env.ts +25 -0
- package/src/locales/bg.ts +39 -0
- package/src/locales/es.ts +37 -0
- package/src/store/labels.ts +10 -0
- package/src/tracking/attribution.ts +83 -0
- package/src/tracking/consent.ts +53 -0
- package/src/tracking/events.ts +113 -0
- package/src/tracking/fbq.ts +48 -0
- package/src/tracking/gtag.ts +32 -0
- package/src/tracking/index.ts +32 -1
- package/src/tracking/once.ts +137 -0
- package/src/tracking/rybbit-events.ts +42 -0
- package/src/tracking/ttq.ts +24 -0
- package/src/tracking/types.ts +34 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Does this shipping option deliver to a POINT, and to whose?
|
|
3
|
+
*
|
|
4
|
+
* Every carrier names its fulfillment options `<carrier>-<mode>`, because the
|
|
5
|
+
* platform's registry declares them that way and the same ids key the carrier
|
|
6
|
+
* marks and the order's own destination editor. So one reader answers for all
|
|
7
|
+
* of them, and a carrier connected tomorrow opens its picker with no line
|
|
8
|
+
* added anywhere.
|
|
9
|
+
*
|
|
10
|
+
* `address` is the door: there is no point to choose, so it answers null.
|
|
11
|
+
*
|
|
12
|
+
* This is the whole reason Speedy had no office picker until 2026-09-18. The
|
|
13
|
+
* checkout asked the question three times, once per carrier, as
|
|
14
|
+
* `id === "econt-office"`, `id === "boxnow-locker"`, `id === "pigeon-office"`,
|
|
15
|
+
* so a fourth carrier was invisible to it however well the platform served
|
|
16
|
+
* that carrier's catalogue.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export type PickupOption = {
|
|
20
|
+
/** `econt`, `speedy`, `boxnow`, `pigeon`. */
|
|
21
|
+
provider: string
|
|
22
|
+
mode: "office" | "locker"
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function pickupOptionOf(
|
|
26
|
+
fulfillmentOptionId: string | null | undefined
|
|
27
|
+
): PickupOption | null {
|
|
28
|
+
if (!fulfillmentOptionId) return null
|
|
29
|
+
const dash = fulfillmentOptionId.lastIndexOf("-")
|
|
30
|
+
if (dash <= 0) return null
|
|
31
|
+
const mode = fulfillmentOptionId.slice(dash + 1)
|
|
32
|
+
if (mode !== "office" && mode !== "locker") return null
|
|
33
|
+
const provider = fulfillmentOptionId.slice(0, dash)
|
|
34
|
+
return provider ? { provider, mode } : null
|
|
35
|
+
}
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useMemo, useState } from "react"
|
|
4
|
+
|
|
5
|
+
import type { StorefrontClient } from "../api/http"
|
|
6
|
+
import {
|
|
7
|
+
listPickupPoints,
|
|
8
|
+
type PickupPointKeys,
|
|
9
|
+
type StorePickupPoint,
|
|
10
|
+
} from "../api/integrations"
|
|
11
|
+
import { cn } from "../lib/utils"
|
|
12
|
+
import { distanceMeters, formatDistance, geocodeAddress } from "./geocode"
|
|
13
|
+
import { useCheckoutLabels } from "./context"
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* PickupPointSelector — ONE picker, every carrier.
|
|
17
|
+
*
|
|
18
|
+
* The shopper chose "to an office" or "to a locker"; this is where they say
|
|
19
|
+
* WHICH one. The shape is the one Alenika's checkout has carried since it was
|
|
20
|
+
* built, and the one this kit ported for Econt alone: the three nearest
|
|
21
|
+
* points to the address just typed, each with its distance, and a door to
|
|
22
|
+
* search the whole catalogue when none of them is the right one.
|
|
23
|
+
*
|
|
24
|
+
* WHY IT REPLACED THREE COMPONENTS (2026-09-18). The kit had a picker per
|
|
25
|
+
* carrier: Econt's fetched the carrier's own public catalogue straight from
|
|
26
|
+
* the browser, BoxNow's and Pigeon's each called a hand-written store route
|
|
27
|
+
* of their own, and Speedy had none at all, because Speedy's catalogue is
|
|
28
|
+
* credentialed and a browser can never ask for it. So a store could offer
|
|
29
|
+
* delivery to a Speedy office
|
|
30
|
+
* and give the shopper no way to name one. Every carrier is the same
|
|
31
|
+
* problem, so it is one component against one door
|
|
32
|
+
* (`GET /api/store/integrations/:provider/pickup-points`), and a carrier the
|
|
33
|
+
* platform connects tomorrow arrives with a working picker and no new code
|
|
34
|
+
* here.
|
|
35
|
+
*
|
|
36
|
+
* WHERE THE SEARCH HAPPENS is the platform's business, not this component's.
|
|
37
|
+
* Carriers that search (Speedy, Pigeon) are asked; carriers that publish a
|
|
38
|
+
* whole catalogue (Econt, BoxNow) are held per store and ranked there. This
|
|
39
|
+
* component types into one query parameter either way, which is why it can
|
|
40
|
+
* afford to be one component.
|
|
41
|
+
*
|
|
42
|
+
* NEAREST needs coordinates, and all four carriers publish them. The
|
|
43
|
+
* shopper's own position is geocoded from the city and street they typed
|
|
44
|
+
* (OpenStreetMap, `./geocode`), never from the device: a checkout that asks
|
|
45
|
+
* for location permission to show three addresses is a checkout people
|
|
46
|
+
* abandon. No address yet, or no geocode hit, and the list falls back to the
|
|
47
|
+
* points in the typed city, which is what a shopper would have scrolled to
|
|
48
|
+
* anyway.
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
export type PickupPoint = StorePickupPoint
|
|
52
|
+
|
|
53
|
+
type PickupPointSelectorProps = {
|
|
54
|
+
/** The SDK transport — the points come from the store, never the carrier. */
|
|
55
|
+
client: StorefrontClient
|
|
56
|
+
/** `econt`, `speedy`, `boxnow`, `pigeon`, from the shipping option. */
|
|
57
|
+
provider: string
|
|
58
|
+
/** Which kind of point this option delivers to. */
|
|
59
|
+
mode: "office" | "locker"
|
|
60
|
+
/** The address the shopper has typed so far, for "nearest to me". */
|
|
61
|
+
userCity: string
|
|
62
|
+
userAddress: string
|
|
63
|
+
selectedPoint: PickupPoint | null
|
|
64
|
+
/** Reports the choice, and the carrier's own key names beside it, so the
|
|
65
|
+
* checkout writes the point under the names this carrier's booking reads. */
|
|
66
|
+
onSelect: (point: PickupPoint | null, keys: PickupPointKeys | null) => void
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const NEAREST_COUNT = 3
|
|
70
|
+
const SEARCH_RESULTS = 8
|
|
71
|
+
const MIN_SEARCH_CHARS = 2
|
|
72
|
+
|
|
73
|
+
export function PickupPointSelector({
|
|
74
|
+
client,
|
|
75
|
+
provider,
|
|
76
|
+
mode,
|
|
77
|
+
userCity,
|
|
78
|
+
userAddress,
|
|
79
|
+
selectedPoint,
|
|
80
|
+
onSelect,
|
|
81
|
+
}: PickupPointSelectorProps) {
|
|
82
|
+
const labels = useCheckoutLabels()
|
|
83
|
+
const [points, setPoints] = useState<PickupPoint[]>([])
|
|
84
|
+
const [keys, setKeys] = useState<PickupPointKeys | null>(null)
|
|
85
|
+
const [coords, setCoords] = useState<{ lat: number; lng: number } | null>(null)
|
|
86
|
+
const [search, setSearch] = useState("")
|
|
87
|
+
const [searchResults, setSearchResults] = useState<PickupPoint[]>([])
|
|
88
|
+
const [showSearch, setShowSearch] = useState(false)
|
|
89
|
+
const [loading, setLoading] = useState(true)
|
|
90
|
+
const [failed, setFailed] = useState(false)
|
|
91
|
+
|
|
92
|
+
// The nearby set: the carrier's own answer for the typed town, plus the
|
|
93
|
+
// shopper's coordinates, asked for together. The town is the query because
|
|
94
|
+
// a catalogue of every office in the country ranked by distance to Sofia is
|
|
95
|
+
// a slower way to reach the same three rows.
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
let alive = true
|
|
98
|
+
setLoading(true)
|
|
99
|
+
setFailed(false)
|
|
100
|
+
Promise.all([
|
|
101
|
+
listPickupPoints(client, provider, { mode, q: userCity, limit: 50 }).catch(
|
|
102
|
+
() => null
|
|
103
|
+
),
|
|
104
|
+
userCity && userAddress
|
|
105
|
+
? geocodeAddress(userCity, userAddress).catch(() => null)
|
|
106
|
+
: Promise.resolve(null),
|
|
107
|
+
]).then(([answer, position]) => {
|
|
108
|
+
if (!alive) return
|
|
109
|
+
if (!answer) {
|
|
110
|
+
setFailed(true)
|
|
111
|
+
setPoints([])
|
|
112
|
+
} else {
|
|
113
|
+
setPoints(answer.points)
|
|
114
|
+
setKeys(answer.keys)
|
|
115
|
+
}
|
|
116
|
+
setCoords(position)
|
|
117
|
+
setLoading(false)
|
|
118
|
+
})
|
|
119
|
+
return () => {
|
|
120
|
+
alive = false
|
|
121
|
+
}
|
|
122
|
+
}, [client, provider, mode, userCity, userAddress])
|
|
123
|
+
|
|
124
|
+
const nearest = useMemo(() => {
|
|
125
|
+
if (!points.length) return []
|
|
126
|
+
if (coords) {
|
|
127
|
+
const withCoords = points.filter(
|
|
128
|
+
(p) => p.latitude !== null && p.longitude !== null
|
|
129
|
+
)
|
|
130
|
+
if (withCoords.length) {
|
|
131
|
+
return withCoords
|
|
132
|
+
.map((point) => ({
|
|
133
|
+
point,
|
|
134
|
+
distance: distanceMeters(
|
|
135
|
+
coords.lat,
|
|
136
|
+
coords.lng,
|
|
137
|
+
point.latitude!,
|
|
138
|
+
point.longitude!
|
|
139
|
+
),
|
|
140
|
+
}))
|
|
141
|
+
.sort((a, b) => a.distance - b.distance)
|
|
142
|
+
.slice(0, NEAREST_COUNT)
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
// No position, or a carrier whose rows carry none: the first answers for
|
|
146
|
+
// the typed town, which the platform already ranked town-first.
|
|
147
|
+
return points.slice(0, NEAREST_COUNT).map((point) => ({ point, distance: 0 }))
|
|
148
|
+
}, [points, coords])
|
|
149
|
+
|
|
150
|
+
// Searching asks the store, because the carrier may be the one searching.
|
|
151
|
+
// Debounced, so a typed word is one call and not one per letter.
|
|
152
|
+
useEffect(() => {
|
|
153
|
+
const q = search.trim()
|
|
154
|
+
if (q.length < MIN_SEARCH_CHARS) {
|
|
155
|
+
setSearchResults([])
|
|
156
|
+
return
|
|
157
|
+
}
|
|
158
|
+
let alive = true
|
|
159
|
+
const timer = setTimeout(() => {
|
|
160
|
+
listPickupPoints(client, provider, { mode, q, limit: SEARCH_RESULTS })
|
|
161
|
+
.then((answer) => {
|
|
162
|
+
if (!alive) return
|
|
163
|
+
setSearchResults(answer.points)
|
|
164
|
+
setKeys(answer.keys)
|
|
165
|
+
})
|
|
166
|
+
.catch(() => {
|
|
167
|
+
if (alive) setSearchResults([])
|
|
168
|
+
})
|
|
169
|
+
}, 250)
|
|
170
|
+
return () => {
|
|
171
|
+
alive = false
|
|
172
|
+
clearTimeout(timer)
|
|
173
|
+
}
|
|
174
|
+
}, [search, client, provider, mode])
|
|
175
|
+
|
|
176
|
+
const choose = useCallback(
|
|
177
|
+
(point: PickupPoint | null) => {
|
|
178
|
+
onSelect(point, point ? keys : null)
|
|
179
|
+
setShowSearch(false)
|
|
180
|
+
setSearch("")
|
|
181
|
+
},
|
|
182
|
+
[onSelect, keys]
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
const renderPoint = useCallback(
|
|
186
|
+
(point: PickupPoint, distance: number | null) => (
|
|
187
|
+
<button
|
|
188
|
+
key={`${point.provider}-${point.id}`}
|
|
189
|
+
type="button"
|
|
190
|
+
onClick={() => choose(point)}
|
|
191
|
+
className={cn(
|
|
192
|
+
"flex items-start gap-3 w-full px-3.5 py-3 text-left transition-all duration-150 rounded-lg",
|
|
193
|
+
"bg-card hover:bg-muted"
|
|
194
|
+
)}
|
|
195
|
+
>
|
|
196
|
+
<div className="w-8 h-8 rounded-full bg-muted flex items-center justify-center flex-shrink-0 mt-0.5">
|
|
197
|
+
<svg
|
|
198
|
+
className="w-4 h-4 text-muted-foreground"
|
|
199
|
+
fill="none"
|
|
200
|
+
viewBox="0 0 24 24"
|
|
201
|
+
stroke="currentColor"
|
|
202
|
+
strokeWidth={2}
|
|
203
|
+
aria-hidden="true"
|
|
204
|
+
>
|
|
205
|
+
<path
|
|
206
|
+
strokeLinecap="round"
|
|
207
|
+
strokeLinejoin="round"
|
|
208
|
+
d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z"
|
|
209
|
+
/>
|
|
210
|
+
<path
|
|
211
|
+
strokeLinecap="round"
|
|
212
|
+
strokeLinejoin="round"
|
|
213
|
+
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"
|
|
214
|
+
/>
|
|
215
|
+
</svg>
|
|
216
|
+
</div>
|
|
217
|
+
|
|
218
|
+
<div className="flex-1 min-w-0">
|
|
219
|
+
<p className="text-sm font-medium text-foreground leading-tight">
|
|
220
|
+
{point.name}
|
|
221
|
+
</p>
|
|
222
|
+
<p className="text-xs text-muted-foreground mt-0.5">
|
|
223
|
+
{[point.address, point.city].filter(Boolean).join(", ")}
|
|
224
|
+
</p>
|
|
225
|
+
</div>
|
|
226
|
+
|
|
227
|
+
{distance !== null && distance > 0 && (
|
|
228
|
+
<span className="text-xs font-medium text-muted-foreground flex-shrink-0 mt-1">
|
|
229
|
+
{formatDistance(distance)}
|
|
230
|
+
</span>
|
|
231
|
+
)}
|
|
232
|
+
</button>
|
|
233
|
+
),
|
|
234
|
+
[choose]
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
if (loading) {
|
|
238
|
+
return (
|
|
239
|
+
<div className="px-4 py-6 flex items-center justify-center">
|
|
240
|
+
<div className="w-5 h-5 border-2 border-border border-t-muted-foreground rounded-full animate-spin" />
|
|
241
|
+
<span className="ml-2 text-sm text-muted-foreground">
|
|
242
|
+
{labels.pickupLoading}
|
|
243
|
+
</span>
|
|
244
|
+
</div>
|
|
245
|
+
)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return (
|
|
249
|
+
<div className="px-4 pb-4 pt-2 space-y-3">
|
|
250
|
+
{selectedPoint && (
|
|
251
|
+
<div className="flex items-center gap-2 px-3 py-2 bg-primary/10 border border-primary/30 rounded-lg">
|
|
252
|
+
<svg
|
|
253
|
+
className="w-4 h-4 text-primary flex-shrink-0"
|
|
254
|
+
fill="none"
|
|
255
|
+
viewBox="0 0 24 24"
|
|
256
|
+
stroke="currentColor"
|
|
257
|
+
strokeWidth={2}
|
|
258
|
+
aria-hidden="true"
|
|
259
|
+
>
|
|
260
|
+
<path
|
|
261
|
+
strokeLinecap="round"
|
|
262
|
+
strokeLinejoin="round"
|
|
263
|
+
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
264
|
+
/>
|
|
265
|
+
</svg>
|
|
266
|
+
<p className="text-sm font-medium text-primary">{selectedPoint.name}</p>
|
|
267
|
+
<button
|
|
268
|
+
type="button"
|
|
269
|
+
onClick={() => choose(null)}
|
|
270
|
+
className="ml-auto text-xs text-primary hover:underline"
|
|
271
|
+
>
|
|
272
|
+
{labels.pickupChange}
|
|
273
|
+
</button>
|
|
274
|
+
</div>
|
|
275
|
+
)}
|
|
276
|
+
|
|
277
|
+
{/* The carrier answered nothing: said plainly, with the search still
|
|
278
|
+
open, because a shopper who knows their office by name can find it
|
|
279
|
+
even when the town query came back empty. */}
|
|
280
|
+
{!selectedPoint && failed && (
|
|
281
|
+
<p className="text-sm text-muted-foreground">{labels.pickupUnavailable}</p>
|
|
282
|
+
)}
|
|
283
|
+
|
|
284
|
+
{!selectedPoint && !failed && nearest.length > 0 && (
|
|
285
|
+
<div>
|
|
286
|
+
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2">
|
|
287
|
+
{mode === "locker" ? labels.pickupNearestLockers : labels.pickupNearestOffices}
|
|
288
|
+
</p>
|
|
289
|
+
<div className="space-y-1.5">
|
|
290
|
+
{nearest.map(({ point, distance }) =>
|
|
291
|
+
renderPoint(point, coords ? distance : null)
|
|
292
|
+
)}
|
|
293
|
+
</div>
|
|
294
|
+
</div>
|
|
295
|
+
)}
|
|
296
|
+
|
|
297
|
+
{!selectedPoint && (
|
|
298
|
+
<div>
|
|
299
|
+
{!showSearch ? (
|
|
300
|
+
<button
|
|
301
|
+
type="button"
|
|
302
|
+
onClick={() => setShowSearch(true)}
|
|
303
|
+
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
|
304
|
+
>
|
|
305
|
+
<svg
|
|
306
|
+
className="w-4 h-4"
|
|
307
|
+
fill="none"
|
|
308
|
+
viewBox="0 0 24 24"
|
|
309
|
+
stroke="currentColor"
|
|
310
|
+
strokeWidth={2}
|
|
311
|
+
aria-hidden="true"
|
|
312
|
+
>
|
|
313
|
+
<path
|
|
314
|
+
strokeLinecap="round"
|
|
315
|
+
strokeLinejoin="round"
|
|
316
|
+
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
|
|
317
|
+
/>
|
|
318
|
+
</svg>
|
|
319
|
+
{mode === "locker"
|
|
320
|
+
? labels.pickupSearchLocker
|
|
321
|
+
: labels.pickupSearchOffice}
|
|
322
|
+
</button>
|
|
323
|
+
) : (
|
|
324
|
+
<div>
|
|
325
|
+
<div className="relative">
|
|
326
|
+
<svg
|
|
327
|
+
className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground"
|
|
328
|
+
fill="none"
|
|
329
|
+
viewBox="0 0 24 24"
|
|
330
|
+
stroke="currentColor"
|
|
331
|
+
strokeWidth={2}
|
|
332
|
+
aria-hidden="true"
|
|
333
|
+
>
|
|
334
|
+
<path
|
|
335
|
+
strokeLinecap="round"
|
|
336
|
+
strokeLinejoin="round"
|
|
337
|
+
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
|
|
338
|
+
/>
|
|
339
|
+
</svg>
|
|
340
|
+
<input
|
|
341
|
+
type="text"
|
|
342
|
+
value={search}
|
|
343
|
+
onChange={(e) => setSearch(e.target.value)}
|
|
344
|
+
placeholder={
|
|
345
|
+
mode === "locker"
|
|
346
|
+
? labels.pickupSearchLockerPlaceholder
|
|
347
|
+
: labels.pickupSearchOfficePlaceholder
|
|
348
|
+
}
|
|
349
|
+
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"
|
|
350
|
+
autoFocus
|
|
351
|
+
/>
|
|
352
|
+
</div>
|
|
353
|
+
|
|
354
|
+
{searchResults.length > 0 && (
|
|
355
|
+
<div className="mt-2 space-y-1 max-h-[240px] overflow-y-auto">
|
|
356
|
+
{searchResults.map((point) => renderPoint(point, null))}
|
|
357
|
+
</div>
|
|
358
|
+
)}
|
|
359
|
+
|
|
360
|
+
{search.trim().length >= MIN_SEARCH_CHARS &&
|
|
361
|
+
searchResults.length === 0 && (
|
|
362
|
+
<p className="mt-2 text-xs text-muted-foreground text-center py-3">
|
|
363
|
+
{labels.pickupNoResults} "{search}"
|
|
364
|
+
</p>
|
|
365
|
+
)}
|
|
366
|
+
</div>
|
|
367
|
+
)}
|
|
368
|
+
</div>
|
|
369
|
+
)}
|
|
370
|
+
</div>
|
|
371
|
+
)
|
|
372
|
+
}
|