@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,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} &quot;{search}&quot;
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
+ }