@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.
Files changed (42) hide show
  1. package/README.md +9 -0
  2. package/package.json +274 -261
  3. package/src/api/checkout.ts +15 -0
  4. package/src/api/http.ts +9 -0
  5. package/src/api/integrations.ts +126 -0
  6. package/src/checkout/card-offer.ts +68 -0
  7. package/src/checkout/carrier-marks.ts +53 -0
  8. package/src/checkout/checkout-client.tsx +71 -13
  9. package/src/checkout/checkout-error-screen.tsx +113 -0
  10. package/src/checkout/discount-section.tsx +88 -56
  11. package/src/checkout/fulfillment-option.ts +30 -0
  12. package/src/checkout/index.ts +41 -6
  13. package/src/checkout/labels.ts +98 -0
  14. package/src/checkout/line-item-card.tsx +35 -83
  15. package/src/checkout/mobile-checkout-bottom-bar.tsx +132 -0
  16. package/src/checkout/mobile-checkout-top-bar.tsx +94 -0
  17. package/src/checkout/mobile-order-summary-body.tsx +172 -0
  18. package/src/checkout/order-summary.tsx +85 -179
  19. package/src/checkout/payment-button.tsx +25 -8
  20. package/src/checkout/payment-method-list.tsx +51 -13
  21. package/src/checkout/payment-wrapper.tsx +57 -13
  22. package/src/checkout/pickup-option.ts +35 -0
  23. package/src/checkout/pickup-point-selector.tsx +328 -0
  24. package/src/checkout/pickup-points.ts +65 -0
  25. package/src/checkout/pigeon-office-selector.tsx +379 -0
  26. package/src/checkout/shipping-method-list.tsx +102 -10
  27. package/src/checkout/summary-math.ts +152 -0
  28. package/src/checkout/use-checkout-funnel.ts +303 -0
  29. package/src/checkout/use-checkout-orchestration.ts +384 -30
  30. package/src/lib/stripe-env.ts +25 -0
  31. package/src/locales/bg.ts +37 -1
  32. package/src/locales/es.ts +35 -1
  33. package/src/tracking/attribution.ts +83 -0
  34. package/src/tracking/consent.ts +53 -0
  35. package/src/tracking/events.ts +113 -0
  36. package/src/tracking/fbq.ts +48 -0
  37. package/src/tracking/gtag.ts +32 -0
  38. package/src/tracking/index.ts +32 -1
  39. package/src/tracking/once.ts +137 -0
  40. package/src/tracking/rybbit-events.ts +42 -0
  41. package/src/tracking/ttq.ts +24 -0
  42. 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} &quot;{search}&quot;
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 stable fulfillment-option id set by the backend
85
- // provider (shipping_option.data.id) NOT the display name.
86
- const getFulfillmentOptionId = (
87
- option: StoreShippingOption
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 hasExpanded = selected && (isEcontOffice || isBoxnowLocker)
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
  })}