@cartbase/storefront 0.22.0 → 0.23.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/README.md +11 -0
- package/package.json +274 -274
- package/src/api/checkout.ts +12 -0
- package/src/api/integrations.ts +8 -3
- package/src/checkout/carrier-marks.ts +10 -49
- package/src/checkout/checkout-client.tsx +1 -6
- package/src/checkout/fulfillment-option.ts +3 -1
- package/src/checkout/index.ts +1 -1
- package/src/checkout/pickup-option.ts +32 -8
- package/src/checkout/pickup-point-selector.tsx +326 -367
- package/src/checkout/pickup-points.ts +64 -0
- package/src/checkout/shipping-method-list.tsx +40 -157
- package/src/checkout/use-checkout-orchestration.ts +44 -172
|
@@ -1,372 +1,331 @@
|
|
|
1
|
-
"use client"
|
|
2
|
-
|
|
3
|
-
import { useCallback, useEffect, useMemo, useState } from "react"
|
|
4
|
-
|
|
5
|
-
import type { StorefrontClient } from "../api/http"
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
type
|
|
9
|
-
|
|
10
|
-
} from "../
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
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
|
-
/**
|
|
57
|
-
provider: string
|
|
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
|
+
/** Carrier adapter id from the shipping option contract. */
|
|
57
|
+
provider: string
|
|
58
58
|
/** Which kind of point this option delivers to. */
|
|
59
59
|
mode: "office" | "locker"
|
|
60
|
-
/**
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
const MIN_SEARCH_CHARS = 2
|
|
72
|
-
|
|
73
|
-
export function PickupPointSelector({
|
|
74
|
-
client,
|
|
60
|
+
/** Adapter-declared catalogue scope. */
|
|
61
|
+
scope: "city" | "global"
|
|
62
|
+
/** The address the shopper has typed so far, for "nearest to me". */
|
|
63
|
+
userCity: string
|
|
64
|
+
userAddress: string
|
|
65
|
+
selectedPoint: PickupPoint | null
|
|
66
|
+
/** Reports the choice, and the carrier's own key names beside it, so the
|
|
67
|
+
* checkout writes the point under the names this carrier's booking reads. */
|
|
68
|
+
onSelect: (point: PickupPoint | null, keys: PickupPointKeys | null) => void
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const MIN_SEARCH_CHARS = 2
|
|
72
|
+
|
|
73
|
+
export function PickupPointSelector({
|
|
74
|
+
client,
|
|
75
75
|
provider,
|
|
76
76
|
mode,
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
const
|
|
84
|
-
const
|
|
85
|
-
const [
|
|
86
|
-
const [
|
|
87
|
-
const [
|
|
88
|
-
const [
|
|
89
|
-
const [
|
|
90
|
-
const [
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
)
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
if (
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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]
|
|
77
|
+
scope,
|
|
78
|
+
userCity,
|
|
79
|
+
userAddress,
|
|
80
|
+
selectedPoint,
|
|
81
|
+
onSelect,
|
|
82
|
+
}: PickupPointSelectorProps) {
|
|
83
|
+
const labels = useCheckoutLabels()
|
|
84
|
+
const locale = useContext(StorefrontLocaleContext)?.code ?? "en"
|
|
85
|
+
const [points, setPoints] = useState<PickupPoint[]>([])
|
|
86
|
+
const [keys, setKeys] = useState<PickupPointKeys | null>(null)
|
|
87
|
+
const [coords, setCoords] = useState<{ lat: number; lng: number } | null>(null)
|
|
88
|
+
const [search, setSearch] = useState("")
|
|
89
|
+
const [showSearch, setShowSearch] = useState(false)
|
|
90
|
+
const [loading, setLoading] = useState(true)
|
|
91
|
+
const [failed, setFailed] = useState(false)
|
|
92
|
+
|
|
93
|
+
// One complete catalogue per store/carrier/mode/language, shared across
|
|
94
|
+
// mounts. Address edits only geocode; they never reload the carrier list.
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
let alive = true
|
|
97
|
+
setLoading(true)
|
|
98
|
+
setFailed(false)
|
|
99
|
+
setPoints([])
|
|
100
|
+
setKeys(null)
|
|
101
|
+
loadPickupPoints(client, provider, mode, locale).then((answer) => {
|
|
102
|
+
if (!alive) return
|
|
103
|
+
setPoints(answer.points.filter((point) => point.mode === mode))
|
|
104
|
+
setKeys(answer.keys)
|
|
105
|
+
}).catch(() => {
|
|
106
|
+
if (alive) setFailed(true)
|
|
107
|
+
}).finally(() => {
|
|
108
|
+
if (alive) setLoading(false)
|
|
109
|
+
})
|
|
110
|
+
return () => { alive = false }
|
|
111
|
+
}, [client, provider, mode, locale])
|
|
112
|
+
|
|
113
|
+
useEffect(() => {
|
|
114
|
+
let alive = true
|
|
115
|
+
setCoords(null)
|
|
116
|
+
const timer = setTimeout(() => {
|
|
117
|
+
if (userCity && userAddress) {
|
|
118
|
+
geocodeAddress(userCity, userAddress).then((position) => {
|
|
119
|
+
if (alive) setCoords(position)
|
|
120
|
+
}).catch(() => {})
|
|
121
|
+
}
|
|
122
|
+
}, 350)
|
|
123
|
+
return () => { alive = false; clearTimeout(timer) }
|
|
124
|
+
}, [userCity, userAddress])
|
|
125
|
+
|
|
126
|
+
const eligiblePoints = useMemo(
|
|
127
|
+
() => (scope === "city" ? pickupPointsInCity(points, userCity) : points),
|
|
128
|
+
[points, scope, userCity]
|
|
183
129
|
)
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
</
|
|
295
|
-
)}
|
|
296
|
-
|
|
297
|
-
{!selectedPoint && (
|
|
298
|
-
<div>
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
<
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
{
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
130
|
+
const nearest = useMemo(() => nearestPickupPoints(eligiblePoints, coords, userCity),
|
|
131
|
+
[eligiblePoints, coords, userCity])
|
|
132
|
+
const searchResults = useMemo(() => searchPickupPoints(eligiblePoints, search),
|
|
133
|
+
[eligiblePoints, search])
|
|
134
|
+
|
|
135
|
+
const choose = useCallback(
|
|
136
|
+
(point: PickupPoint | null) => {
|
|
137
|
+
onSelect(point, point ? keys : null)
|
|
138
|
+
setShowSearch(false)
|
|
139
|
+
setSearch("")
|
|
140
|
+
},
|
|
141
|
+
[onSelect, keys]
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
const renderPoint = useCallback(
|
|
145
|
+
(point: PickupPoint, distance: number | null) => (
|
|
146
|
+
<button
|
|
147
|
+
key={`${point.provider}-${point.id}`}
|
|
148
|
+
type="button"
|
|
149
|
+
onClick={() => choose(point)}
|
|
150
|
+
className={cn(
|
|
151
|
+
"flex items-start gap-3 w-full px-3.5 py-3 text-left transition-all duration-150 rounded-lg",
|
|
152
|
+
"bg-card hover:bg-muted"
|
|
153
|
+
)}
|
|
154
|
+
>
|
|
155
|
+
<div className="w-8 h-8 rounded-full bg-muted flex items-center justify-center flex-shrink-0 mt-0.5">
|
|
156
|
+
<svg
|
|
157
|
+
className="w-4 h-4 text-muted-foreground"
|
|
158
|
+
fill="none"
|
|
159
|
+
viewBox="0 0 24 24"
|
|
160
|
+
stroke="currentColor"
|
|
161
|
+
strokeWidth={2}
|
|
162
|
+
aria-hidden="true"
|
|
163
|
+
>
|
|
164
|
+
<path
|
|
165
|
+
strokeLinecap="round"
|
|
166
|
+
strokeLinejoin="round"
|
|
167
|
+
d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z"
|
|
168
|
+
/>
|
|
169
|
+
<path
|
|
170
|
+
strokeLinecap="round"
|
|
171
|
+
strokeLinejoin="round"
|
|
172
|
+
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"
|
|
173
|
+
/>
|
|
174
|
+
</svg>
|
|
175
|
+
</div>
|
|
176
|
+
|
|
177
|
+
<div className="flex-1 min-w-0">
|
|
178
|
+
<p className="text-sm font-medium text-foreground leading-tight">
|
|
179
|
+
{point.name}
|
|
180
|
+
</p>
|
|
181
|
+
<p className="text-xs text-muted-foreground mt-0.5">
|
|
182
|
+
{[point.address, point.city].filter(Boolean).join(", ")}
|
|
183
|
+
</p>
|
|
184
|
+
</div>
|
|
185
|
+
|
|
186
|
+
{distance !== null && distance > 0 && (
|
|
187
|
+
<span className="text-xs font-medium text-muted-foreground flex-shrink-0 mt-1">
|
|
188
|
+
{formatDistance(distance)}
|
|
189
|
+
</span>
|
|
190
|
+
)}
|
|
191
|
+
</button>
|
|
192
|
+
),
|
|
193
|
+
[choose]
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
if (loading) {
|
|
197
|
+
return (
|
|
198
|
+
<div className="px-4 py-6 flex items-center justify-center">
|
|
199
|
+
<div className="w-5 h-5 border-2 border-border border-t-muted-foreground rounded-full animate-spin" />
|
|
200
|
+
<span className="ml-2 text-sm text-muted-foreground">
|
|
201
|
+
{labels.pickupLoading}
|
|
202
|
+
</span>
|
|
203
|
+
</div>
|
|
204
|
+
)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return (
|
|
208
|
+
<div className="px-4 pb-4 pt-2 space-y-3">
|
|
209
|
+
{selectedPoint && (
|
|
210
|
+
<div className="flex items-center gap-2 px-3 py-2 bg-primary/10 border border-primary/30 rounded-lg">
|
|
211
|
+
<svg
|
|
212
|
+
className="w-4 h-4 text-primary flex-shrink-0"
|
|
213
|
+
fill="none"
|
|
214
|
+
viewBox="0 0 24 24"
|
|
215
|
+
stroke="currentColor"
|
|
216
|
+
strokeWidth={2}
|
|
217
|
+
aria-hidden="true"
|
|
218
|
+
>
|
|
219
|
+
<path
|
|
220
|
+
strokeLinecap="round"
|
|
221
|
+
strokeLinejoin="round"
|
|
222
|
+
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
223
|
+
/>
|
|
224
|
+
</svg>
|
|
225
|
+
<p className="text-sm font-medium text-primary">{selectedPoint.name}</p>
|
|
226
|
+
<button
|
|
227
|
+
type="button"
|
|
228
|
+
onClick={() => choose(null)}
|
|
229
|
+
className="ml-auto text-xs text-primary hover:underline"
|
|
230
|
+
>
|
|
231
|
+
{labels.pickupChange}
|
|
232
|
+
</button>
|
|
233
|
+
</div>
|
|
234
|
+
)}
|
|
235
|
+
|
|
236
|
+
{/* The carrier answered nothing: said plainly, with the search still
|
|
237
|
+
open, because a shopper who knows their office by name can find it
|
|
238
|
+
even when the town query came back empty. */}
|
|
239
|
+
{!selectedPoint && failed && (
|
|
240
|
+
<p className="text-sm text-muted-foreground">{labels.pickupUnavailable}</p>
|
|
241
|
+
)}
|
|
242
|
+
|
|
243
|
+
{!selectedPoint && !failed && nearest.length > 0 && (
|
|
244
|
+
<div>
|
|
245
|
+
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2">
|
|
246
|
+
{mode === "locker" ? labels.pickupNearestLockers : labels.pickupNearestOffices}
|
|
247
|
+
</p>
|
|
248
|
+
<div className="space-y-1.5">
|
|
249
|
+
{nearest.map(({ point, distance }) =>
|
|
250
|
+
renderPoint(point, distance)
|
|
251
|
+
)}
|
|
252
|
+
</div>
|
|
253
|
+
</div>
|
|
254
|
+
)}
|
|
255
|
+
|
|
256
|
+
{!selectedPoint && (
|
|
257
|
+
<div>
|
|
258
|
+
{!showSearch ? (
|
|
259
|
+
<button
|
|
260
|
+
type="button"
|
|
261
|
+
onClick={() => setShowSearch(true)}
|
|
262
|
+
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
|
263
|
+
>
|
|
264
|
+
<svg
|
|
265
|
+
className="w-4 h-4"
|
|
266
|
+
fill="none"
|
|
267
|
+
viewBox="0 0 24 24"
|
|
268
|
+
stroke="currentColor"
|
|
269
|
+
strokeWidth={2}
|
|
270
|
+
aria-hidden="true"
|
|
271
|
+
>
|
|
272
|
+
<path
|
|
273
|
+
strokeLinecap="round"
|
|
274
|
+
strokeLinejoin="round"
|
|
275
|
+
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
|
|
276
|
+
/>
|
|
277
|
+
</svg>
|
|
278
|
+
{mode === "locker"
|
|
279
|
+
? labels.pickupSearchLocker
|
|
280
|
+
: labels.pickupSearchOffice}
|
|
281
|
+
</button>
|
|
282
|
+
) : (
|
|
283
|
+
<div>
|
|
284
|
+
<div className="relative">
|
|
285
|
+
<svg
|
|
286
|
+
className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground"
|
|
287
|
+
fill="none"
|
|
288
|
+
viewBox="0 0 24 24"
|
|
289
|
+
stroke="currentColor"
|
|
290
|
+
strokeWidth={2}
|
|
291
|
+
aria-hidden="true"
|
|
292
|
+
>
|
|
293
|
+
<path
|
|
294
|
+
strokeLinecap="round"
|
|
295
|
+
strokeLinejoin="round"
|
|
296
|
+
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
|
|
297
|
+
/>
|
|
298
|
+
</svg>
|
|
299
|
+
<input
|
|
300
|
+
type="text"
|
|
301
|
+
value={search}
|
|
302
|
+
onChange={(e) => setSearch(e.target.value)}
|
|
303
|
+
placeholder={
|
|
304
|
+
mode === "locker"
|
|
305
|
+
? labels.pickupSearchLockerPlaceholder
|
|
306
|
+
: labels.pickupSearchOfficePlaceholder
|
|
307
|
+
}
|
|
308
|
+
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"
|
|
309
|
+
autoFocus
|
|
310
|
+
/>
|
|
311
|
+
</div>
|
|
312
|
+
|
|
313
|
+
{searchResults.length > 0 && (
|
|
314
|
+
<div className="mt-2 space-y-1 max-h-[240px] overflow-y-auto">
|
|
315
|
+
{searchResults.map((point) => renderPoint(point, null))}
|
|
316
|
+
</div>
|
|
317
|
+
)}
|
|
318
|
+
|
|
319
|
+
{search.trim().length >= MIN_SEARCH_CHARS &&
|
|
320
|
+
searchResults.length === 0 && (
|
|
321
|
+
<p className="mt-2 text-xs text-muted-foreground text-center py-3">
|
|
322
|
+
{labels.pickupNoResults} "{search}"
|
|
323
|
+
</p>
|
|
324
|
+
)}
|
|
325
|
+
</div>
|
|
326
|
+
)}
|
|
327
|
+
</div>
|
|
328
|
+
)}
|
|
329
|
+
</div>
|
|
330
|
+
)
|
|
331
|
+
}
|