@cartbase/storefront 0.21.0 → 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 +14 -1
- package/src/api/checkout.ts +15 -0
- package/src/api/http.ts +9 -0
- package/src/api/integrations.ts +121 -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 +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/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,379 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useMemo, useState } from "react"
|
|
4
|
+
|
|
5
|
+
import type { StorefrontClient } from "../api/http"
|
|
6
|
+
import { listPigeonOffices, type PigeonOffice } 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
|
+
* PigeonOfficeSelector — the office picker for Pigeon Express, the third
|
|
18
|
+
* carrier with pickup points and the last one whose options a shopper could
|
|
19
|
+
* not actually choose (store-package card, 2026-09-18).
|
|
20
|
+
*
|
|
21
|
+
* It mirrors the BoxNow and Econt pickers 1:1: the loading line, the chosen
|
|
22
|
+
* point as a pill with a way to change it, the three nearest points by
|
|
23
|
+
* distance, and a search that opens on demand. The directory comes from
|
|
24
|
+
* `listPigeonOffices(client)` (GET /api/store/integrations/pigeon/offices),
|
|
25
|
+
* because Pigeon's catalogue is credentialed and the browser can never ask
|
|
26
|
+
* the carrier itself; the platform holds the answer for ten minutes.
|
|
27
|
+
*
|
|
28
|
+
* Pigeon's own shape: `name`, a real `city` field (so the city lock reads a
|
|
29
|
+
* city instead of sniffing an address line), `postal_code`, and `latitude` /
|
|
30
|
+
* `longitude`. Its id is the carrier's catalogue id and the waybill needs it
|
|
31
|
+
* verbatim.
|
|
32
|
+
*
|
|
33
|
+
* The CHOSEN point is client state only: the orchestration hook writes
|
|
34
|
+
* `pigeon_office_id` and the point's name, city and address into
|
|
35
|
+
* `carrier_metadata` at the Buy click, once, exactly as it does for the
|
|
36
|
+
* other two carriers.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
export type { PigeonOffice }
|
|
40
|
+
|
|
41
|
+
type PigeonOfficeSelectorProps = {
|
|
42
|
+
/** The SDK transport — the point directory is fetched through it. */
|
|
43
|
+
client: StorefrontClient
|
|
44
|
+
userCity: string
|
|
45
|
+
userAddress: string
|
|
46
|
+
selectedOffice: PigeonOffice | null
|
|
47
|
+
onSelect: (office: PigeonOffice | null) => void
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
type OfficesState =
|
|
51
|
+
| { status: "loading" }
|
|
52
|
+
| { status: "ready"; offices: PigeonOffice[] }
|
|
53
|
+
| { status: "error" }
|
|
54
|
+
|
|
55
|
+
// One store per app, so a module-level cache is safe: switching between
|
|
56
|
+
// shipping rows must not ask the platform again.
|
|
57
|
+
let officesCache: PigeonOffice[] | null = null
|
|
58
|
+
let officesPromise: Promise<PigeonOffice[] | null> | null = null
|
|
59
|
+
|
|
60
|
+
async function fetchOffices(
|
|
61
|
+
client: StorefrontClient
|
|
62
|
+
): Promise<PigeonOffice[] | null> {
|
|
63
|
+
if (officesCache) return officesCache
|
|
64
|
+
if (officesPromise) return officesPromise
|
|
65
|
+
|
|
66
|
+
officesPromise = listPigeonOffices(client, { type: "office" })
|
|
67
|
+
.then((res) => {
|
|
68
|
+
officesCache = res.offices
|
|
69
|
+
return officesCache
|
|
70
|
+
})
|
|
71
|
+
.catch(() => {
|
|
72
|
+
// Not connected (503), the carrier refused (502), or the network
|
|
73
|
+
// failed: one "temporarily unavailable" line for all three. The
|
|
74
|
+
// promise is reset so a remount can try again.
|
|
75
|
+
officesPromise = null
|
|
76
|
+
return null
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
return officesPromise
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function PigeonOfficeSelector({
|
|
83
|
+
client,
|
|
84
|
+
userCity,
|
|
85
|
+
userAddress,
|
|
86
|
+
selectedOffice,
|
|
87
|
+
onSelect,
|
|
88
|
+
}: PigeonOfficeSelectorProps) {
|
|
89
|
+
const labels = useCheckoutLabels()
|
|
90
|
+
const [officesState, setOfficesState] = useState<OfficesState>({
|
|
91
|
+
status: "loading",
|
|
92
|
+
})
|
|
93
|
+
const [userCoords, setUserCoords] = useState<{ lat: number; lng: number } | null>(
|
|
94
|
+
null
|
|
95
|
+
)
|
|
96
|
+
const [search, setSearch] = useState("")
|
|
97
|
+
const [showSearch, setShowSearch] = useState(false)
|
|
98
|
+
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
setOfficesState({ status: "loading" })
|
|
101
|
+
Promise.all([
|
|
102
|
+
fetchOffices(client),
|
|
103
|
+
userCity && userAddress
|
|
104
|
+
? geocodeAddress(userCity, userAddress)
|
|
105
|
+
: Promise.resolve(null),
|
|
106
|
+
]).then(([fetched, coords]) => {
|
|
107
|
+
if (!fetched || fetched.length === 0) {
|
|
108
|
+
setOfficesState({ status: "error" })
|
|
109
|
+
} else {
|
|
110
|
+
setOfficesState({ status: "ready", offices: fetched })
|
|
111
|
+
}
|
|
112
|
+
setUserCoords(coords)
|
|
113
|
+
})
|
|
114
|
+
}, [client, userCity, userAddress])
|
|
115
|
+
|
|
116
|
+
const offices = officesState.status === "ready" ? officesState.offices : []
|
|
117
|
+
|
|
118
|
+
// City lock: both lists are restricted to the shopper's own town, matched
|
|
119
|
+
// through the Cyrillic to Latin transliteration so "Sofia" finds "София".
|
|
120
|
+
// With no town typed yet, everything shows rather than nothing.
|
|
121
|
+
const cityNormalized = normalizeForMatch(userCity.trim())
|
|
122
|
+
const cityLockedOffices = useMemo(() => {
|
|
123
|
+
if (!cityNormalized) return offices
|
|
124
|
+
return offices.filter((o) =>
|
|
125
|
+
normalizeForMatch(o.city ?? "").includes(cityNormalized)
|
|
126
|
+
)
|
|
127
|
+
}, [offices, cityNormalized])
|
|
128
|
+
|
|
129
|
+
const nearestOffices = useMemo(() => {
|
|
130
|
+
if (!cityLockedOffices.length) return []
|
|
131
|
+
|
|
132
|
+
const located = cityLockedOffices.filter(
|
|
133
|
+
(o) =>
|
|
134
|
+
typeof o.latitude === "number" &&
|
|
135
|
+
typeof o.longitude === "number" &&
|
|
136
|
+
!Number.isNaN(o.latitude) &&
|
|
137
|
+
!Number.isNaN(o.longitude)
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
if (userCoords && located.length > 0) {
|
|
141
|
+
return located
|
|
142
|
+
.map((o) => ({
|
|
143
|
+
office: o,
|
|
144
|
+
distance: distanceMeters(
|
|
145
|
+
userCoords.lat,
|
|
146
|
+
userCoords.lng,
|
|
147
|
+
o.latitude as number,
|
|
148
|
+
o.longitude as number
|
|
149
|
+
),
|
|
150
|
+
}))
|
|
151
|
+
.sort((a, b) => a.distance - b.distance)
|
|
152
|
+
.slice(0, 3)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return cityLockedOffices.slice(0, 3).map((o) => ({ office: o, distance: 0 }))
|
|
156
|
+
}, [cityLockedOffices, userCoords])
|
|
157
|
+
|
|
158
|
+
const searchResults = useMemo(() => {
|
|
159
|
+
if (!search.trim() || search.trim().length < 2) return []
|
|
160
|
+
const q = normalizeForMatch(search.trim())
|
|
161
|
+
return cityLockedOffices.filter((o) => {
|
|
162
|
+
const name = normalizeForMatch(o.name ?? "")
|
|
163
|
+
const address = normalizeForMatch(o.address ?? "")
|
|
164
|
+
const city = normalizeForMatch(o.city ?? "")
|
|
165
|
+
const postal = (o.postal_code ?? "").toLowerCase()
|
|
166
|
+
return (
|
|
167
|
+
name.includes(q) ||
|
|
168
|
+
address.includes(q) ||
|
|
169
|
+
city.includes(q) ||
|
|
170
|
+
postal.includes(q)
|
|
171
|
+
)
|
|
172
|
+
})
|
|
173
|
+
}, [cityLockedOffices, search])
|
|
174
|
+
|
|
175
|
+
const renderOffice = useCallback(
|
|
176
|
+
(office: PigeonOffice, distance: number | null, isSelected: boolean) => (
|
|
177
|
+
<button
|
|
178
|
+
key={office.id}
|
|
179
|
+
type="button"
|
|
180
|
+
onClick={() => {
|
|
181
|
+
onSelect(office)
|
|
182
|
+
setShowSearch(false)
|
|
183
|
+
setSearch("")
|
|
184
|
+
}}
|
|
185
|
+
className={cn(
|
|
186
|
+
"flex items-start gap-3 w-full px-3.5 py-3 text-left transition-all duration-150 rounded-lg",
|
|
187
|
+
isSelected ? "bg-primary/10" : "bg-card hover:bg-muted"
|
|
188
|
+
)}
|
|
189
|
+
style={
|
|
190
|
+
isSelected
|
|
191
|
+
? { boxShadow: "inset 0 0 0 1.5px oklch(var(--primary))" }
|
|
192
|
+
: undefined
|
|
193
|
+
}
|
|
194
|
+
>
|
|
195
|
+
<div className="w-8 h-8 rounded-full bg-muted flex items-center justify-center flex-shrink-0 mt-0.5">
|
|
196
|
+
<svg
|
|
197
|
+
className="w-4 h-4 text-muted-foreground"
|
|
198
|
+
fill="none"
|
|
199
|
+
viewBox="0 0 24 24"
|
|
200
|
+
stroke="currentColor"
|
|
201
|
+
strokeWidth={2}
|
|
202
|
+
>
|
|
203
|
+
<path
|
|
204
|
+
strokeLinecap="round"
|
|
205
|
+
strokeLinejoin="round"
|
|
206
|
+
d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z"
|
|
207
|
+
/>
|
|
208
|
+
<path
|
|
209
|
+
strokeLinecap="round"
|
|
210
|
+
strokeLinejoin="round"
|
|
211
|
+
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"
|
|
212
|
+
/>
|
|
213
|
+
</svg>
|
|
214
|
+
</div>
|
|
215
|
+
|
|
216
|
+
<div className="flex-1 min-w-0">
|
|
217
|
+
<p className="text-sm font-medium text-foreground leading-tight">
|
|
218
|
+
{office.name}
|
|
219
|
+
</p>
|
|
220
|
+
<p className="text-xs text-muted-foreground mt-0.5">
|
|
221
|
+
{office.address}
|
|
222
|
+
{office.city ? `, ${office.city}` : ""}
|
|
223
|
+
{office.postal_code ? `, ${office.postal_code}` : ""}
|
|
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
|
+
[onSelect]
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
if (officesState.status === "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-text-muted rounded-full animate-spin" />
|
|
241
|
+
<span className="ml-2 text-sm text-muted-foreground">
|
|
242
|
+
{labels.pigeonLoadingOffices}
|
|
243
|
+
</span>
|
|
244
|
+
</div>
|
|
245
|
+
)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (officesState.status === "error") {
|
|
249
|
+
return (
|
|
250
|
+
<div className="px-4 py-4">
|
|
251
|
+
<div className="p-3 rounded-lg bg-muted border border-border">
|
|
252
|
+
<p className="text-sm text-muted-foreground">{labels.pigeonUnavailable}</p>
|
|
253
|
+
</div>
|
|
254
|
+
</div>
|
|
255
|
+
)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// The shopper's town has no Pigeon office: say so, so they choose another
|
|
259
|
+
// way instead of looking at an empty box.
|
|
260
|
+
if (cityNormalized && cityLockedOffices.length === 0 && !selectedOffice) {
|
|
261
|
+
return (
|
|
262
|
+
<div className="px-4 py-4">
|
|
263
|
+
<div className="p-3 rounded-lg bg-muted border border-border">
|
|
264
|
+
<p className="text-sm text-muted-foreground">
|
|
265
|
+
{labels.pigeonNoOfficesInCity}
|
|
266
|
+
</p>
|
|
267
|
+
</div>
|
|
268
|
+
</div>
|
|
269
|
+
)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return (
|
|
273
|
+
<div className="px-4 pb-4 pt-2 space-y-3">
|
|
274
|
+
{selectedOffice && (
|
|
275
|
+
<div className="flex items-center gap-2 px-3 py-2 bg-primary/10 border border-primary/30 rounded-lg">
|
|
276
|
+
<svg
|
|
277
|
+
className="w-4 h-4 text-primary flex-shrink-0"
|
|
278
|
+
fill="none"
|
|
279
|
+
viewBox="0 0 24 24"
|
|
280
|
+
stroke="currentColor"
|
|
281
|
+
strokeWidth={2}
|
|
282
|
+
>
|
|
283
|
+
<path
|
|
284
|
+
strokeLinecap="round"
|
|
285
|
+
strokeLinejoin="round"
|
|
286
|
+
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
287
|
+
/>
|
|
288
|
+
</svg>
|
|
289
|
+
<p className="text-sm font-medium text-primary">{selectedOffice.name}</p>
|
|
290
|
+
<button
|
|
291
|
+
type="button"
|
|
292
|
+
onClick={() => onSelect(null)}
|
|
293
|
+
className="ml-auto text-xs text-primary hover:underline"
|
|
294
|
+
>
|
|
295
|
+
{labels.pigeonChange}
|
|
296
|
+
</button>
|
|
297
|
+
</div>
|
|
298
|
+
)}
|
|
299
|
+
|
|
300
|
+
{!selectedOffice && nearestOffices.length > 0 && (
|
|
301
|
+
<div>
|
|
302
|
+
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2">
|
|
303
|
+
{labels.pigeonNearestOffices}
|
|
304
|
+
</p>
|
|
305
|
+
<div className="space-y-1.5">
|
|
306
|
+
{nearestOffices.map(({ office, distance }) =>
|
|
307
|
+
renderOffice(office, distance, false)
|
|
308
|
+
)}
|
|
309
|
+
</div>
|
|
310
|
+
</div>
|
|
311
|
+
)}
|
|
312
|
+
|
|
313
|
+
{!selectedOffice && (
|
|
314
|
+
<div>
|
|
315
|
+
{!showSearch ? (
|
|
316
|
+
<button
|
|
317
|
+
type="button"
|
|
318
|
+
onClick={() => setShowSearch(true)}
|
|
319
|
+
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
|
320
|
+
>
|
|
321
|
+
<svg
|
|
322
|
+
className="w-4 h-4"
|
|
323
|
+
fill="none"
|
|
324
|
+
viewBox="0 0 24 24"
|
|
325
|
+
stroke="currentColor"
|
|
326
|
+
strokeWidth={2}
|
|
327
|
+
>
|
|
328
|
+
<path
|
|
329
|
+
strokeLinecap="round"
|
|
330
|
+
strokeLinejoin="round"
|
|
331
|
+
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
|
|
332
|
+
/>
|
|
333
|
+
</svg>
|
|
334
|
+
{labels.pigeonSearchAnother}
|
|
335
|
+
</button>
|
|
336
|
+
) : (
|
|
337
|
+
<div>
|
|
338
|
+
<div className="relative">
|
|
339
|
+
<svg
|
|
340
|
+
className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground"
|
|
341
|
+
fill="none"
|
|
342
|
+
viewBox="0 0 24 24"
|
|
343
|
+
stroke="currentColor"
|
|
344
|
+
strokeWidth={2}
|
|
345
|
+
>
|
|
346
|
+
<path
|
|
347
|
+
strokeLinecap="round"
|
|
348
|
+
strokeLinejoin="round"
|
|
349
|
+
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
|
|
350
|
+
/>
|
|
351
|
+
</svg>
|
|
352
|
+
<input
|
|
353
|
+
type="text"
|
|
354
|
+
value={search}
|
|
355
|
+
onChange={(e) => setSearch(e.target.value)}
|
|
356
|
+
placeholder={labels.pigeonSearchPlaceholder}
|
|
357
|
+
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"
|
|
358
|
+
autoFocus
|
|
359
|
+
/>
|
|
360
|
+
</div>
|
|
361
|
+
|
|
362
|
+
{searchResults.length > 0 && (
|
|
363
|
+
<div className="mt-2 space-y-1 max-h-[240px] overflow-y-auto">
|
|
364
|
+
{searchResults.map((office) => renderOffice(office, null, false))}
|
|
365
|
+
</div>
|
|
366
|
+
)}
|
|
367
|
+
|
|
368
|
+
{search.trim().length >= 2 && searchResults.length === 0 && (
|
|
369
|
+
<p className="mt-2 text-xs text-muted-foreground text-center py-3">
|
|
370
|
+
{labels.pigeonNoResults} "{search}"
|
|
371
|
+
</p>
|
|
372
|
+
)}
|
|
373
|
+
</div>
|
|
374
|
+
)}
|
|
375
|
+
</div>
|
|
376
|
+
)}
|
|
377
|
+
</div>
|
|
378
|
+
)
|
|
379
|
+
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import type { StorefrontClient } from "../api/http"
|
|
4
4
|
import type { StoreShippingOption } from "../api/checkout"
|
|
5
|
-
import type { BoxNowLocker } from "../api/integrations"
|
|
5
|
+
import type { BoxNowLocker, PigeonOffice } from "../api/integrations"
|
|
6
6
|
import { Price } from "../lib/price"
|
|
7
7
|
import { cn } from "../lib/utils"
|
|
8
8
|
import { useCheckoutLabels } from "./context"
|
|
@@ -11,6 +11,14 @@ import {
|
|
|
11
11
|
type EcontOffice,
|
|
12
12
|
} from "./econt-office-selector"
|
|
13
13
|
import { BoxNowLockerSelector } from "./boxnow-locker-selector"
|
|
14
|
+
import { PigeonOfficeSelector } from "./pigeon-office-selector"
|
|
15
|
+
import {
|
|
16
|
+
PickupPointSelector,
|
|
17
|
+
type PickupPoint,
|
|
18
|
+
} from "./pickup-point-selector"
|
|
19
|
+
import type { PickupPointKeys } from "../api/integrations"
|
|
20
|
+
import { pickupOptionOf, type PickupOption } from "./pickup-option"
|
|
21
|
+
import { fulfillmentOptionId } from "./fulfillment-option"
|
|
14
22
|
import { ErrorMessage } from "./error-message"
|
|
15
23
|
|
|
16
24
|
/**
|
|
@@ -62,6 +70,38 @@ type CheckoutShippingMethodListProps = {
|
|
|
62
70
|
userCity: string
|
|
63
71
|
userAddress: string
|
|
64
72
|
}
|
|
73
|
+
/** Optional: Pigeon Express office state + handler for inline-expand rows */
|
|
74
|
+
pigeon?: {
|
|
75
|
+
detect?: (option: StoreShippingOption) => boolean
|
|
76
|
+
/** SDK transport for the pickup-point directory (the carrier's own
|
|
77
|
+
* catalogue is credentialed, so it is served by the platform). */
|
|
78
|
+
client: StorefrontClient
|
|
79
|
+
selectedOffice: PigeonOffice | null
|
|
80
|
+
onSelectOffice: (office: PigeonOffice | null) => void
|
|
81
|
+
userCity: string
|
|
82
|
+
userAddress: string
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* The pickup picker, for EVERY carrier (2026-09-18). One entry replaces
|
|
86
|
+
* the three above: the option's own id says which carrier and whether it
|
|
87
|
+
* is an office or a locker, and the platform answers for all of them
|
|
88
|
+
* through one door, so a carrier connected tomorrow opens its picker with
|
|
89
|
+
* nothing added here. The three above stay for stores that mount them;
|
|
90
|
+
* where both are given, the carrier-specific one wins for its own carrier.
|
|
91
|
+
*/
|
|
92
|
+
pickup?: {
|
|
93
|
+
/** SDK transport — the catalogue is the store's answer, not the
|
|
94
|
+
* carrier's: Speedy's needs the merchant's credentials and can never be
|
|
95
|
+
* reached from a browser. */
|
|
96
|
+
client: StorefrontClient
|
|
97
|
+
selectedPoint: PickupPoint | null
|
|
98
|
+
onSelectPoint: (
|
|
99
|
+
point: PickupPoint | null,
|
|
100
|
+
keys: PickupPointKeys | null
|
|
101
|
+
) => void
|
|
102
|
+
userCity: string
|
|
103
|
+
userAddress: string
|
|
104
|
+
}
|
|
65
105
|
/**
|
|
66
106
|
* Optional per-store carrier branding. Keyed by the stable fulfillment
|
|
67
107
|
* option id (shipping_option.data.id) — "econt-office", "boxnow-locker",
|
|
@@ -81,14 +121,10 @@ type CheckoutShippingMethodListProps = {
|
|
|
81
121
|
previewWhenAddressNotReady?: boolean
|
|
82
122
|
}
|
|
83
123
|
|
|
84
|
-
// Detection uses the
|
|
85
|
-
//
|
|
86
|
-
const getFulfillmentOptionId = (
|
|
87
|
-
option
|
|
88
|
-
): string | null => {
|
|
89
|
-
const data = option.data as { id?: string } | undefined | null
|
|
90
|
-
return typeof data?.id === "string" ? data.id : null
|
|
91
|
-
}
|
|
124
|
+
// Detection uses the destination the merchant chose on the option, never
|
|
125
|
+
// its display name (fulfillment-option.ts owns where that value lives).
|
|
126
|
+
const getFulfillmentOptionId = (option: StoreShippingOption): string | null =>
|
|
127
|
+
fulfillmentOptionId(option)
|
|
92
128
|
|
|
93
129
|
const defaultEcontDetect = (option: StoreShippingOption): boolean => {
|
|
94
130
|
return getFulfillmentOptionId(option) === "econt-office"
|
|
@@ -98,6 +134,14 @@ const defaultBoxnowDetect = (option: StoreShippingOption): boolean => {
|
|
|
98
134
|
return getFulfillmentOptionId(option) === "boxnow-locker"
|
|
99
135
|
}
|
|
100
136
|
|
|
137
|
+
const defaultPigeonDetect = (option: StoreShippingOption): boolean => {
|
|
138
|
+
return getFulfillmentOptionId(option) === "pigeon-office"
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Which carrier, and which kind of point, from the option's own id. */
|
|
142
|
+
const detectPickup = (option: StoreShippingOption): PickupOption | null =>
|
|
143
|
+
pickupOptionOf(getFulfillmentOptionId(option))
|
|
144
|
+
|
|
101
145
|
export function CheckoutShippingMethodList({
|
|
102
146
|
shippingMethods,
|
|
103
147
|
selectedShippingMethodId,
|
|
@@ -110,12 +154,15 @@ export function CheckoutShippingMethodList({
|
|
|
110
154
|
currencyCode,
|
|
111
155
|
econt,
|
|
112
156
|
boxnow,
|
|
157
|
+
pigeon,
|
|
158
|
+
pickup,
|
|
113
159
|
logoByFulfillmentOptionId,
|
|
114
160
|
previewWhenAddressNotReady = false,
|
|
115
161
|
}: CheckoutShippingMethodListProps) {
|
|
116
162
|
const labels = useCheckoutLabels()
|
|
117
163
|
const detectEcont = econt?.detect ?? defaultEcontDetect
|
|
118
164
|
const detectBoxnow = boxnow?.detect ?? defaultBoxnowDetect
|
|
165
|
+
const detectPigeon = pigeon?.detect ?? defaultPigeonDetect
|
|
119
166
|
|
|
120
167
|
// Preview mode: address not filled yet, but the store wants the options
|
|
121
168
|
// shown as a read-only price preview rather than a placeholder. Rows are
|
|
@@ -200,7 +247,20 @@ export function CheckoutShippingMethodList({
|
|
|
200
247
|
const isEcontOffice = econt && detectEcont(option)
|
|
201
248
|
const isBoxnowLocker =
|
|
202
249
|
boxnow && !isEcontOffice && detectBoxnow(option)
|
|
203
|
-
const
|
|
250
|
+
const isPigeonOffice =
|
|
251
|
+
pigeon && !isEcontOffice && !isBoxnowLocker && detectPigeon(option)
|
|
252
|
+
// Any option that delivers to a point, whatever the carrier
|
|
253
|
+
// (2026-09-18). The three flags above are the same question asked
|
|
254
|
+
// per carrier, which is why Speedy had no picker; this one reads
|
|
255
|
+
// the option's own id, so a carrier connected tomorrow opens its
|
|
256
|
+
// picker with no line added here. It yields to a legacy picker
|
|
257
|
+
// when the store still mounts one for that carrier.
|
|
258
|
+
const pickupOption = pickup ? detectPickup(option) : null
|
|
259
|
+
const isPickup =
|
|
260
|
+
!!pickupOption && !isEcontOffice && !isBoxnowLocker && !isPigeonOffice
|
|
261
|
+
const hasExpanded =
|
|
262
|
+
selected &&
|
|
263
|
+
(isEcontOffice || isBoxnowLocker || isPigeonOffice || isPickup)
|
|
204
264
|
const logo = logoByFulfillmentOptionId
|
|
205
265
|
? logoByFulfillmentOptionId[getFulfillmentOptionId(option) ?? ""]
|
|
206
266
|
: undefined
|
|
@@ -262,6 +322,16 @@ export function CheckoutShippingMethodList({
|
|
|
262
322
|
{boxnow.selectedLocker.title}
|
|
263
323
|
</p>
|
|
264
324
|
)}
|
|
325
|
+
{selected && isPigeonOffice && pigeon?.selectedOffice && (
|
|
326
|
+
<p className="text-xs text-muted-foreground mt-0.5">
|
|
327
|
+
{pigeon.selectedOffice.name}
|
|
328
|
+
</p>
|
|
329
|
+
)}
|
|
330
|
+
{selected && isPickup && pickup?.selectedPoint && (
|
|
331
|
+
<p className="text-xs text-muted-foreground mt-0.5">
|
|
332
|
+
{pickup.selectedPoint.name}
|
|
333
|
+
</p>
|
|
334
|
+
)}
|
|
265
335
|
</div>
|
|
266
336
|
<span
|
|
267
337
|
className={cn(
|
|
@@ -319,6 +389,28 @@ export function CheckoutShippingMethodList({
|
|
|
319
389
|
onSelect={boxnow.onSelectLocker}
|
|
320
390
|
/>
|
|
321
391
|
)}
|
|
392
|
+
|
|
393
|
+
{hasExpanded && isPigeonOffice && pigeon && (
|
|
394
|
+
<PigeonOfficeSelector
|
|
395
|
+
client={pigeon.client}
|
|
396
|
+
userCity={pigeon.userCity}
|
|
397
|
+
userAddress={pigeon.userAddress}
|
|
398
|
+
selectedOffice={pigeon.selectedOffice}
|
|
399
|
+
onSelect={pigeon.onSelectOffice}
|
|
400
|
+
/>
|
|
401
|
+
)}
|
|
402
|
+
|
|
403
|
+
{hasExpanded && isPickup && pickup && pickupOption && (
|
|
404
|
+
<PickupPointSelector
|
|
405
|
+
client={pickup.client}
|
|
406
|
+
provider={pickupOption.provider}
|
|
407
|
+
mode={pickupOption.mode}
|
|
408
|
+
userCity={pickup.userCity}
|
|
409
|
+
userAddress={pickup.userAddress}
|
|
410
|
+
selectedPoint={pickup.selectedPoint}
|
|
411
|
+
onSelect={pickup.onSelectPoint}
|
|
412
|
+
/>
|
|
413
|
+
)}
|
|
322
414
|
</div>
|
|
323
415
|
)
|
|
324
416
|
})}
|