@cartbase/storefront 0.21.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.
- package/README.md +9 -0
- package/package.json +274 -261
- package/src/api/checkout.ts +15 -0
- package/src/api/http.ts +9 -0
- package/src/api/integrations.ts +126 -0
- 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 +328 -0
- package/src/checkout/pickup-points.ts +65 -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/lib/stripe-env.ts +25 -0
- package/src/locales/bg.ts +37 -1
- package/src/locales/es.ts +35 -1
- 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,328 @@
|
|
|
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} "{search}"
|
|
320
|
+
</p>
|
|
321
|
+
)}
|
|
322
|
+
</div>
|
|
323
|
+
)}
|
|
324
|
+
</div>
|
|
325
|
+
)}
|
|
326
|
+
</div>
|
|
327
|
+
)
|
|
328
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { StorefrontClient } from "../api/http"
|
|
2
|
+
import { listPickupPoints, type PickupPointsResponse, type StorePickupPoint } from "../api/integrations"
|
|
3
|
+
import { distanceMeters, normalizeForMatch } from "./geocode"
|
|
4
|
+
|
|
5
|
+
// The reference pickers cache the full catalogue. Scope it to the SDK client
|
|
6
|
+
// (the store), carrier, mode and language instead of sharing one global list.
|
|
7
|
+
const catalogues = new WeakMap<StorefrontClient, Map<string, {
|
|
8
|
+
expires: number
|
|
9
|
+
promise: Promise<PickupPointsResponse>
|
|
10
|
+
}>>()
|
|
11
|
+
|
|
12
|
+
export function loadPickupPoints(
|
|
13
|
+
client: StorefrontClient,
|
|
14
|
+
provider: string,
|
|
15
|
+
mode: "office" | "locker",
|
|
16
|
+
locale: string
|
|
17
|
+
): Promise<PickupPointsResponse> {
|
|
18
|
+
let cache = catalogues.get(client)
|
|
19
|
+
if (!cache) catalogues.set(client, cache = new Map())
|
|
20
|
+
const key = JSON.stringify([provider, mode, locale])
|
|
21
|
+
const hit = cache.get(key)
|
|
22
|
+
if (hit && hit.expires > Date.now()) return hit.promise
|
|
23
|
+
const promise = listPickupPoints(client, provider, { mode, catalogue: true, locale })
|
|
24
|
+
.catch((error) => {
|
|
25
|
+
if (cache.get(key)?.promise === promise) cache.delete(key)
|
|
26
|
+
throw error
|
|
27
|
+
})
|
|
28
|
+
cache.set(key, { expires: Date.now() + 10 * 60 * 1000, promise })
|
|
29
|
+
return promise
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** BoxNow's reference picker city-locks both lists, comparing both scripts. */
|
|
33
|
+
export function pickupPointsInCity(points: StorePickupPoint[], city: string): StorePickupPoint[] {
|
|
34
|
+
const needle = normalizeForMatch(city.trim())
|
|
35
|
+
if (!needle) return points
|
|
36
|
+
return points.filter((point) => normalizeForMatch(point.city).includes(needle) ||
|
|
37
|
+
(point.provider === "boxnow" && normalizeForMatch(point.address).includes(needle)))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The reference's full catalogue → distance sort → nearest three, in that order. */
|
|
41
|
+
export function nearestPickupPoints(
|
|
42
|
+
points: StorePickupPoint[],
|
|
43
|
+
position: { lat: number; lng: number } | null,
|
|
44
|
+
city: string
|
|
45
|
+
): Array<{ point: StorePickupPoint; distance: number | null }> {
|
|
46
|
+
if (position && Number.isFinite(position.lat) && Number.isFinite(position.lng)) {
|
|
47
|
+
const located = points.filter((p) =>
|
|
48
|
+
p.latitude !== null && p.longitude !== null &&
|
|
49
|
+
Number.isFinite(p.latitude) && Number.isFinite(p.longitude)
|
|
50
|
+
)
|
|
51
|
+
if (located.length) return located.map((point) => ({ point,
|
|
52
|
+
distance: distanceMeters(position.lat, position.lng, point.latitude!, point.longitude!),
|
|
53
|
+
})).sort((a, b) => a.distance - b.distance).slice(0, 3)
|
|
54
|
+
}
|
|
55
|
+
return pickupPointsInCity(points, city).slice(0, 3).map((point) => ({ point, distance: null }))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The same catalogue powers search, including streets and either script. */
|
|
59
|
+
export function searchPickupPoints(points: StorePickupPoint[], search: string): StorePickupPoint[] {
|
|
60
|
+
const needle = normalizeForMatch(search.trim())
|
|
61
|
+
if (needle.length < 2) return []
|
|
62
|
+
return points.filter((point) => normalizeForMatch(
|
|
63
|
+
[point.name, point.city, point.address, point.postal_code].join(" ")
|
|
64
|
+
).includes(needle)).slice(0, 8)
|
|
65
|
+
}
|