@cartbase/storefront 0.22.0 → 0.22.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.
@@ -1,372 +1,328 @@
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} &quot;{search}&quot;
364
- </p>
365
- )}
366
- </div>
367
- )}
368
- </div>
369
- )}
370
- </div>
371
- )
372
- }
1
+ "use client"
2
+
3
+ import { useCallback, useContext, useEffect, useMemo, useState } from "react"
4
+
5
+ import type { StorefrontClient } from "../api/http"
6
+ import {
7
+ type PickupPointKeys,
8
+ type StorePickupPoint,
9
+ } from "../api/integrations"
10
+ import { cn } from "../lib/utils"
11
+ import { formatDistance, geocodeAddress } from "./geocode"
12
+ import { StorefrontLocaleContext } from "../locales/context"
13
+ import { loadPickupPoints, nearestPickupPoints, pickupPointsInCity, searchPickupPoints } from "./pickup-points"
14
+ import { useCheckoutLabels } from "./context"
15
+
16
+ /**
17
+ * PickupPointSelector — ONE picker, every carrier.
18
+ *
19
+ * The shopper chose "to an office" or "to a locker"; this is where they say
20
+ * WHICH one. The shape is the one Alenika's checkout has carried since it was
21
+ * built, and the one this kit ported for Econt alone: the three nearest
22
+ * points to the address just typed, each with its distance, and a door to
23
+ * search the whole catalogue when none of them is the right one.
24
+ *
25
+ * WHY IT REPLACED THREE COMPONENTS (2026-09-18). The kit had a picker per
26
+ * carrier: Econt's fetched the carrier's own public catalogue straight from
27
+ * the browser, BoxNow's and Pigeon's each called a hand-written store route
28
+ * of their own, and Speedy had none at all, because Speedy's catalogue is
29
+ * credentialed and a browser can never ask for it. So a store could offer
30
+ * delivery to a Speedy office
31
+ * and give the shopper no way to name one. Every carrier is the same
32
+ * problem, so it is one component against one door
33
+ * (`GET /api/store/integrations/:provider/pickup-points`), and a carrier the
34
+ * platform connects tomorrow arrives with a working picker and no new code
35
+ * here.
36
+ *
37
+ * Like the Mindpages and Alenika pickers, load the COMPLETE eligible
38
+ * catalogue once, then rank and search locally. The platform adapter owns
39
+ * credentials, language and office/locker filtering. Never rank a capped
40
+ * town-search response: an omitted office can never become the nearest.
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 MIN_SEARCH_CHARS = 2
70
+
71
+ export function PickupPointSelector({
72
+ client,
73
+ provider,
74
+ mode,
75
+ userCity,
76
+ userAddress,
77
+ selectedPoint,
78
+ onSelect,
79
+ }: PickupPointSelectorProps) {
80
+ const labels = useCheckoutLabels()
81
+ const locale = useContext(StorefrontLocaleContext)?.code ?? "en"
82
+ const [points, setPoints] = useState<PickupPoint[]>([])
83
+ const [keys, setKeys] = useState<PickupPointKeys | null>(null)
84
+ const [coords, setCoords] = useState<{ lat: number; lng: number } | null>(null)
85
+ const [search, setSearch] = useState("")
86
+ const [showSearch, setShowSearch] = useState(false)
87
+ const [loading, setLoading] = useState(true)
88
+ const [failed, setFailed] = useState(false)
89
+
90
+ // One complete catalogue per store/carrier/mode/language, shared across
91
+ // mounts. Address edits only geocode; they never reload the carrier list.
92
+ useEffect(() => {
93
+ let alive = true
94
+ setLoading(true)
95
+ setFailed(false)
96
+ setPoints([])
97
+ setKeys(null)
98
+ loadPickupPoints(client, provider, mode, locale).then((answer) => {
99
+ if (!alive) return
100
+ setPoints(answer.points.filter((point) => point.mode === mode))
101
+ setKeys(answer.keys)
102
+ }).catch(() => {
103
+ if (alive) setFailed(true)
104
+ }).finally(() => {
105
+ if (alive) setLoading(false)
106
+ })
107
+ return () => { alive = false }
108
+ }, [client, provider, mode, locale])
109
+
110
+ useEffect(() => {
111
+ let alive = true
112
+ setCoords(null)
113
+ const timer = setTimeout(() => {
114
+ if (userCity && userAddress) {
115
+ geocodeAddress(userCity, userAddress).then((position) => {
116
+ if (alive) setCoords(position)
117
+ }).catch(() => {})
118
+ }
119
+ }, 350)
120
+ return () => { alive = false; clearTimeout(timer) }
121
+ }, [userCity, userAddress])
122
+
123
+ // Alenika's BoxNow picker locks both nearest and search to the town.
124
+ // Econt (and the shared office pattern used for Speedy) searches all offices.
125
+ const eligiblePoints = useMemo(() => provider === "boxnow"
126
+ ? pickupPointsInCity(points, userCity) : points, [points, provider, userCity])
127
+ const nearest = useMemo(() => nearestPickupPoints(eligiblePoints, coords, userCity),
128
+ [eligiblePoints, coords, userCity])
129
+ const searchResults = useMemo(() => searchPickupPoints(eligiblePoints, search),
130
+ [eligiblePoints, search])
131
+
132
+ const choose = useCallback(
133
+ (point: PickupPoint | null) => {
134
+ onSelect(point, point ? keys : null)
135
+ setShowSearch(false)
136
+ setSearch("")
137
+ },
138
+ [onSelect, keys]
139
+ )
140
+
141
+ const renderPoint = useCallback(
142
+ (point: PickupPoint, distance: number | null) => (
143
+ <button
144
+ key={`${point.provider}-${point.id}`}
145
+ type="button"
146
+ onClick={() => choose(point)}
147
+ className={cn(
148
+ "flex items-start gap-3 w-full px-3.5 py-3 text-left transition-all duration-150 rounded-lg",
149
+ "bg-card hover:bg-muted"
150
+ )}
151
+ >
152
+ <div className="w-8 h-8 rounded-full bg-muted flex items-center justify-center flex-shrink-0 mt-0.5">
153
+ <svg
154
+ className="w-4 h-4 text-muted-foreground"
155
+ fill="none"
156
+ viewBox="0 0 24 24"
157
+ stroke="currentColor"
158
+ strokeWidth={2}
159
+ aria-hidden="true"
160
+ >
161
+ <path
162
+ strokeLinecap="round"
163
+ strokeLinejoin="round"
164
+ d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z"
165
+ />
166
+ <path
167
+ strokeLinecap="round"
168
+ strokeLinejoin="round"
169
+ 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"
170
+ />
171
+ </svg>
172
+ </div>
173
+
174
+ <div className="flex-1 min-w-0">
175
+ <p className="text-sm font-medium text-foreground leading-tight">
176
+ {point.name}
177
+ </p>
178
+ <p className="text-xs text-muted-foreground mt-0.5">
179
+ {[point.address, point.city].filter(Boolean).join(", ")}
180
+ </p>
181
+ </div>
182
+
183
+ {distance !== null && distance > 0 && (
184
+ <span className="text-xs font-medium text-muted-foreground flex-shrink-0 mt-1">
185
+ {formatDistance(distance)}
186
+ </span>
187
+ )}
188
+ </button>
189
+ ),
190
+ [choose]
191
+ )
192
+
193
+ if (loading) {
194
+ return (
195
+ <div className="px-4 py-6 flex items-center justify-center">
196
+ <div className="w-5 h-5 border-2 border-border border-t-muted-foreground rounded-full animate-spin" />
197
+ <span className="ml-2 text-sm text-muted-foreground">
198
+ {labels.pickupLoading}
199
+ </span>
200
+ </div>
201
+ )
202
+ }
203
+
204
+ return (
205
+ <div className="px-4 pb-4 pt-2 space-y-3">
206
+ {selectedPoint && (
207
+ <div className="flex items-center gap-2 px-3 py-2 bg-primary/10 border border-primary/30 rounded-lg">
208
+ <svg
209
+ className="w-4 h-4 text-primary flex-shrink-0"
210
+ fill="none"
211
+ viewBox="0 0 24 24"
212
+ stroke="currentColor"
213
+ strokeWidth={2}
214
+ aria-hidden="true"
215
+ >
216
+ <path
217
+ strokeLinecap="round"
218
+ strokeLinejoin="round"
219
+ d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
220
+ />
221
+ </svg>
222
+ <p className="text-sm font-medium text-primary">{selectedPoint.name}</p>
223
+ <button
224
+ type="button"
225
+ onClick={() => choose(null)}
226
+ className="ml-auto text-xs text-primary hover:underline"
227
+ >
228
+ {labels.pickupChange}
229
+ </button>
230
+ </div>
231
+ )}
232
+
233
+ {/* The carrier answered nothing: said plainly, with the search still
234
+ open, because a shopper who knows their office by name can find it
235
+ even when the town query came back empty. */}
236
+ {!selectedPoint && failed && (
237
+ <p className="text-sm text-muted-foreground">{labels.pickupUnavailable}</p>
238
+ )}
239
+
240
+ {!selectedPoint && !failed && nearest.length > 0 && (
241
+ <div>
242
+ <p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2">
243
+ {mode === "locker" ? labels.pickupNearestLockers : labels.pickupNearestOffices}
244
+ </p>
245
+ <div className="space-y-1.5">
246
+ {nearest.map(({ point, distance }) =>
247
+ renderPoint(point, distance)
248
+ )}
249
+ </div>
250
+ </div>
251
+ )}
252
+
253
+ {!selectedPoint && (
254
+ <div>
255
+ {!showSearch ? (
256
+ <button
257
+ type="button"
258
+ onClick={() => setShowSearch(true)}
259
+ className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
260
+ >
261
+ <svg
262
+ className="w-4 h-4"
263
+ fill="none"
264
+ viewBox="0 0 24 24"
265
+ stroke="currentColor"
266
+ strokeWidth={2}
267
+ aria-hidden="true"
268
+ >
269
+ <path
270
+ strokeLinecap="round"
271
+ strokeLinejoin="round"
272
+ d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
273
+ />
274
+ </svg>
275
+ {mode === "locker"
276
+ ? labels.pickupSearchLocker
277
+ : labels.pickupSearchOffice}
278
+ </button>
279
+ ) : (
280
+ <div>
281
+ <div className="relative">
282
+ <svg
283
+ className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground"
284
+ fill="none"
285
+ viewBox="0 0 24 24"
286
+ stroke="currentColor"
287
+ strokeWidth={2}
288
+ aria-hidden="true"
289
+ >
290
+ <path
291
+ strokeLinecap="round"
292
+ strokeLinejoin="round"
293
+ d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
294
+ />
295
+ </svg>
296
+ <input
297
+ type="text"
298
+ value={search}
299
+ onChange={(e) => setSearch(e.target.value)}
300
+ placeholder={
301
+ mode === "locker"
302
+ ? labels.pickupSearchLockerPlaceholder
303
+ : labels.pickupSearchOfficePlaceholder
304
+ }
305
+ 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"
306
+ autoFocus
307
+ />
308
+ </div>
309
+
310
+ {searchResults.length > 0 && (
311
+ <div className="mt-2 space-y-1 max-h-[240px] overflow-y-auto">
312
+ {searchResults.map((point) => renderPoint(point, null))}
313
+ </div>
314
+ )}
315
+
316
+ {search.trim().length >= MIN_SEARCH_CHARS &&
317
+ searchResults.length === 0 && (
318
+ <p className="mt-2 text-xs text-muted-foreground text-center py-3">
319
+ {labels.pickupNoResults} &quot;{search}&quot;
320
+ </p>
321
+ )}
322
+ </div>
323
+ )}
324
+ </div>
325
+ )}
326
+ </div>
327
+ )
328
+ }