@cartbase/storefront 0.1.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 +226 -0
- package/src/api/auth.ts +108 -0
- package/src/api/carts.ts +506 -0
- package/src/api/categories.ts +184 -0
- package/src/api/checkout.ts +440 -0
- package/src/api/collections.ts +130 -0
- package/src/api/consent.ts +75 -0
- package/src/api/content.ts +125 -0
- package/src/api/customers.ts +307 -0
- package/src/api/gift-cards.ts +112 -0
- package/src/api/http.ts +122 -0
- package/src/api/index.ts +29 -0
- package/src/api/integrations.ts +130 -0
- package/src/api/menus.ts +77 -0
- package/src/api/metaobjects.ts +136 -0
- package/src/api/orders.ts +290 -0
- package/src/api/products.ts +303 -0
- package/src/api/redirects.ts +37 -0
- package/src/api/regions.ts +141 -0
- package/src/api/reviews.ts +259 -0
- package/src/api/search.ts +133 -0
- package/src/api/types.ts +91 -0
- package/src/cart-drawer/cart-drawer.tsx +86 -0
- package/src/cart-drawer/context.tsx +569 -0
- package/src/cart-drawer/continue-shopping.tsx +28 -0
- package/src/cart-drawer/cross-sell-carousel.tsx +212 -0
- package/src/cart-drawer/cross-sell-sidebar.tsx +158 -0
- package/src/cart-drawer/empty.tsx +52 -0
- package/src/cart-drawer/free-gift.tsx +71 -0
- package/src/cart-drawer/gift-wrap.tsx +83 -0
- package/src/cart-drawer/header.tsx +52 -0
- package/src/cart-drawer/index.ts +69 -0
- package/src/cart-drawer/item/index.tsx +164 -0
- package/src/cart-drawer/item/quantity.tsx +100 -0
- package/src/cart-drawer/item/upsell.tsx +110 -0
- package/src/cart-drawer/item/variant.tsx +46 -0
- package/src/cart-drawer/labels-bg.ts +72 -0
- package/src/cart-drawer/labels.ts +119 -0
- package/src/cart-drawer/notes.tsx +131 -0
- package/src/cart-drawer/payment-badges.tsx +96 -0
- package/src/cart-drawer/promo-banner.tsx +43 -0
- package/src/cart-drawer/rewards-points.tsx +78 -0
- package/src/cart-drawer/sticky-footer.tsx +73 -0
- package/src/cart-drawer/summary-breakdown.tsx +196 -0
- package/src/cart-drawer/template.tsx +225 -0
- package/src/cart-drawer/tiered-progress.tsx +168 -0
- package/src/checkout/address-error-copy.ts +119 -0
- package/src/checkout/address-form.tsx +224 -0
- package/src/checkout/address-select.tsx +79 -0
- package/src/checkout/boxnow-locker-selector.tsx +410 -0
- package/src/checkout/checkout-client.tsx +222 -0
- package/src/checkout/company-details.tsx +94 -0
- package/src/checkout/compare-addresses.ts +40 -0
- package/src/checkout/context.tsx +76 -0
- package/src/checkout/discount-section.tsx +218 -0
- package/src/checkout/econt-office-selector.tsx +332 -0
- package/src/checkout/error-message.tsx +25 -0
- package/src/checkout/geocode.ts +154 -0
- package/src/checkout/gift-card-section.tsx +224 -0
- package/src/checkout/index.ts +74 -0
- package/src/checkout/labels-bg.ts +128 -0
- package/src/checkout/labels.ts +263 -0
- package/src/checkout/line-item-card.tsx +152 -0
- package/src/checkout/order-summary.tsx +524 -0
- package/src/checkout/payment-button.tsx +373 -0
- package/src/checkout/payment-error-copy.ts +269 -0
- package/src/checkout/payment-method-list.tsx +365 -0
- package/src/checkout/payment-wrapper.tsx +102 -0
- package/src/checkout/promotion-error-copy.ts +124 -0
- package/src/checkout/shipping-method-list.tsx +335 -0
- package/src/checkout/stripe-wrapper.tsx +165 -0
- package/src/checkout/use-checkout-orchestration.ts +1504 -0
- package/src/common/cart-button-client.tsx +39 -0
- package/src/common/cart-button.tsx +28 -0
- package/src/common/country-select.tsx +65 -0
- package/src/common/delete-button.tsx +66 -0
- package/src/common/index.ts +17 -0
- package/src/common/language-select.tsx +78 -0
- package/src/common/localized-link.tsx +45 -0
- package/src/common/skeleton.tsx +29 -0
- package/src/index.ts +12 -0
- package/src/lib/cart-helpers.ts +113 -0
- package/src/lib/dual-price.tsx +73 -0
- package/src/lib/get-percentage-diff.ts +5 -0
- package/src/lib/get-product-price.ts +133 -0
- package/src/lib/hooks/use-intersection.ts +30 -0
- package/src/lib/hooks/use-toggle-state.ts +25 -0
- package/src/lib/money.ts +73 -0
- package/src/lib/payment-constants.ts +66 -0
- package/src/lib/product.ts +22 -0
- package/src/lib/sort-products.ts +63 -0
- package/src/lib/store-api-error.ts +36 -0
- package/src/lib/utils.ts +16 -0
- package/src/order/context.tsx +32 -0
- package/src/order/index.ts +63 -0
- package/src/order/labels-bg.ts +39 -0
- package/src/order/labels.ts +79 -0
- package/src/order/order-address-card.tsx +47 -0
- package/src/order/order-completed-template.tsx +165 -0
- package/src/order/order-confirmation-header.tsx +65 -0
- package/src/order/order-delivery-card.tsx +258 -0
- package/src/order/order-help-section.tsx +47 -0
- package/src/order/order-item.tsx +201 -0
- package/src/order/order-items-list.tsx +52 -0
- package/src/order/order-payment-card.tsx +95 -0
- package/src/order/order-timeline.tsx +141 -0
- package/src/order/order-totals.tsx +245 -0
- package/src/primitives/field.tsx +125 -0
- package/src/primitives/select-field.tsx +77 -0
- package/src/primitives/ui/accordion.tsx +61 -0
- package/src/primitives/ui/button.tsx +68 -0
- package/src/primitives/ui/collapsible.tsx +16 -0
- package/src/primitives/ui/dialog.tsx +112 -0
- package/src/primitives/ui/input.tsx +30 -0
- package/src/primitives/ui/label.tsx +31 -0
- package/src/primitives/ui/popover.tsx +38 -0
- package/src/primitives/ui/select.tsx +163 -0
- package/src/primitives/ui/sheet.tsx +131 -0
- package/src/primitives/ui/tabs.tsx +62 -0
- package/src/products/context.tsx +34 -0
- package/src/products/image-gallery.tsx +43 -0
- package/src/products/index.ts +44 -0
- package/src/products/labels-bg.ts +35 -0
- package/src/products/labels.ts +57 -0
- package/src/products/mobile-actions.tsx +180 -0
- package/src/products/option-select.tsx +67 -0
- package/src/products/preview-price.tsx +36 -0
- package/src/products/product-actions-wrapper.tsx +58 -0
- package/src/products/product-actions.tsx +217 -0
- package/src/products/product-info.tsx +43 -0
- package/src/products/product-preview.tsx +49 -0
- package/src/products/product-price.tsx +69 -0
- package/src/products/product-tabs.tsx +169 -0
- package/src/products/product-template.tsx +114 -0
- package/src/products/purchase-options.tsx +130 -0
- package/src/products/related-products.tsx +86 -0
- package/src/products/thumbnail.tsx +71 -0
- package/src/products/variant-matching.ts +71 -0
- package/src/reviews-ui/helpers.ts +174 -0
- package/src/reviews-ui/index.ts +74 -0
- package/src/reviews-ui/labels-bg.ts +91 -0
- package/src/reviews-ui/labels.ts +199 -0
- package/src/reviews-ui/photo-upload.tsx +345 -0
- package/src/reviews-ui/review-list.tsx +249 -0
- package/src/reviews-ui/review-widget.tsx +224 -0
- package/src/reviews-ui/review-wizard.tsx +560 -0
- package/src/reviews-ui/star-badge.tsx +104 -0
- package/src/reviews-ui/wizard-state.ts +81 -0
- package/src/store/category-template.tsx +129 -0
- package/src/store/collection-template.tsx +139 -0
- package/src/store/index.ts +41 -0
- package/src/store/labels-bg.ts +22 -0
- package/src/store/labels.ts +52 -0
- package/src/store/paginated-products.tsx +116 -0
- package/src/store/pagination.tsx +103 -0
- package/src/store/search-params.ts +256 -0
- package/src/store/search-template.tsx +249 -0
- package/src/store/skeleton-product-grid.tsx +26 -0
- package/src/store/sort-select.tsx +81 -0
- package/src/store/store-template.tsx +65 -0
- package/src/tracking/attribution.ts +418 -0
- package/src/tracking/consent-banner.tsx +355 -0
- package/src/tracking/consent-init.tsx +44 -0
- package/src/tracking/consent.ts +243 -0
- package/src/tracking/fbq.ts +168 -0
- package/src/tracking/ga4.tsx +49 -0
- package/src/tracking/get-tracking-attribution.ts +224 -0
- package/src/tracking/get-tracking-config.ts +50 -0
- package/src/tracking/gtag.ts +200 -0
- package/src/tracking/index.ts +133 -0
- package/src/tracking/meta-pixel.tsx +166 -0
- package/src/tracking/rybbit-events.ts +242 -0
- package/src/tracking/rybbit.tsx +40 -0
- package/src/tracking/types.ts +185 -0
- package/src/tracking/use-engagement-time.ts +58 -0
- package/tailwind-preset.cjs +72 -0
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useMemo, useState } from "react"
|
|
4
|
+
|
|
5
|
+
import type { StorefrontClient } from "../api/http"
|
|
6
|
+
import { listBoxNowLockers, type BoxNowLocker } 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
|
+
* BoxNowLockerSelector — full BoxNow locker picker for Bulgarian shipping.
|
|
18
|
+
*
|
|
19
|
+
* Ported from `@1click/ui/src/checkout/boxnow-locker-selector.tsx` (v2.3.1)
|
|
20
|
+
* with the barter data seam: the locker directory comes from
|
|
21
|
+
* `listBoxNowLockers(client)` (`@cartbase/storefront/api/integrations`,
|
|
22
|
+
* GET /api/store/integrations/boxnow/lockers — backend owns BoxNow API auth
|
|
23
|
+
* + the 10-min cache; docs/storefront/integrations.md). The @1click data
|
|
24
|
+
* helper returned `{ok, lockers}`; the barter SDK THROWS on 503 (not
|
|
25
|
+
* configured) / 502 (carrier failure), so the fetch wrapper catches and
|
|
26
|
+
* maps both to the same "temporarily unavailable" state. Availability
|
|
27
|
+
* should be discovered via `carriers.boxnow.lockers_url` on the
|
|
28
|
+
* integrations config instead of probing for the 503.
|
|
29
|
+
*
|
|
30
|
+
* The CHOSEN locker is client state only — the orchestration hook writes it
|
|
31
|
+
* into `carrier_metadata` (boxnow_locker_id/title/address/postal) exactly
|
|
32
|
+
* once at Buy click via prepare-checkout (docs/storefront/checkout.md).
|
|
33
|
+
*
|
|
34
|
+
* Mirrors EcontOfficeSelector's UX 1:1 (loading spinner, selected pill,
|
|
35
|
+
* "search for another" expandable, nearest-3 by haversine distance).
|
|
36
|
+
*
|
|
37
|
+
* Data shape differences from Econt handled here:
|
|
38
|
+
* - title (not name)
|
|
39
|
+
* - addressLine1 / addressLine2 (not address.street/num)
|
|
40
|
+
* - postalCode (not address.city.name)
|
|
41
|
+
* - lat / lng on the root (numbers or null — the barter route coerces
|
|
42
|
+
* malformed carrier coordinates to null, never NaN)
|
|
43
|
+
* - No isAPS / code / phones
|
|
44
|
+
*
|
|
45
|
+
* Edge cases (503/502/empty) show a single "временно недостъпно" message.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
export type { BoxNowLocker }
|
|
49
|
+
|
|
50
|
+
type BoxNowLockerSelectorProps = {
|
|
51
|
+
/** The SDK transport — the locker directory is fetched through it. */
|
|
52
|
+
client: StorefrontClient
|
|
53
|
+
userCity: string
|
|
54
|
+
userAddress: string
|
|
55
|
+
selectedLocker: BoxNowLocker | null
|
|
56
|
+
onSelect: (locker: BoxNowLocker | null) => void
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
type LockersState =
|
|
60
|
+
| { status: "loading" }
|
|
61
|
+
| { status: "ready"; lockers: BoxNowLocker[] }
|
|
62
|
+
| { status: "error" }
|
|
63
|
+
|
|
64
|
+
// Module-level cache so switching between shipping rows doesn't re-hit
|
|
65
|
+
// the backend on every toggle. (One store per app — the storefront
|
|
66
|
+
// package is mounted for a single tenant, so a per-module cache is safe.)
|
|
67
|
+
let lockersCache: BoxNowLocker[] | null = null
|
|
68
|
+
let lockersPromise: Promise<BoxNowLocker[] | null> | null = null
|
|
69
|
+
|
|
70
|
+
async function fetchLockers(
|
|
71
|
+
client: StorefrontClient
|
|
72
|
+
): Promise<BoxNowLocker[] | null> {
|
|
73
|
+
if (lockersCache) return lockersCache
|
|
74
|
+
if (lockersPromise) return lockersPromise
|
|
75
|
+
|
|
76
|
+
lockersPromise = listBoxNowLockers(client)
|
|
77
|
+
.then((res) => {
|
|
78
|
+
lockersCache = res.lockers
|
|
79
|
+
return lockersCache
|
|
80
|
+
})
|
|
81
|
+
.catch(() => {
|
|
82
|
+
// 503 boxnow-not-configured / 502 carrier failure / network error —
|
|
83
|
+
// all render the same "temporarily unavailable" state. Reset the
|
|
84
|
+
// promise so a remount can retry (the cache stays empty on failure).
|
|
85
|
+
lockersPromise = null
|
|
86
|
+
return null
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
return lockersPromise
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function BoxNowLockerSelector({
|
|
93
|
+
client,
|
|
94
|
+
userCity,
|
|
95
|
+
userAddress,
|
|
96
|
+
selectedLocker,
|
|
97
|
+
onSelect,
|
|
98
|
+
}: BoxNowLockerSelectorProps) {
|
|
99
|
+
const labels = useCheckoutLabels()
|
|
100
|
+
const [lockersState, setLockersState] = useState<LockersState>({
|
|
101
|
+
status: "loading",
|
|
102
|
+
})
|
|
103
|
+
const [userCoords, setUserCoords] = useState<{
|
|
104
|
+
lat: number
|
|
105
|
+
lng: number
|
|
106
|
+
} | null>(null)
|
|
107
|
+
const [search, setSearch] = useState("")
|
|
108
|
+
const [showSearch, setShowSearch] = useState(false)
|
|
109
|
+
|
|
110
|
+
useEffect(() => {
|
|
111
|
+
setLockersState({ status: "loading" })
|
|
112
|
+
Promise.all([
|
|
113
|
+
fetchLockers(client),
|
|
114
|
+
userCity && userAddress
|
|
115
|
+
? geocodeAddress(userCity, userAddress)
|
|
116
|
+
: Promise.resolve(null),
|
|
117
|
+
]).then(([fetchedLockers, coords]) => {
|
|
118
|
+
if (!fetchedLockers || fetchedLockers.length === 0) {
|
|
119
|
+
setLockersState({ status: "error" })
|
|
120
|
+
} else {
|
|
121
|
+
setLockersState({ status: "ready", lockers: fetchedLockers })
|
|
122
|
+
}
|
|
123
|
+
setUserCoords(coords)
|
|
124
|
+
})
|
|
125
|
+
}, [client, userCity, userAddress])
|
|
126
|
+
|
|
127
|
+
const lockers =
|
|
128
|
+
lockersState.status === "ready" ? lockersState.lockers : []
|
|
129
|
+
|
|
130
|
+
// City-lock: every list (nearest + search) is restricted to the user's
|
|
131
|
+
// checkout city. BoxNow stores the city in addressLine2, occasionally
|
|
132
|
+
// in addressLine1 too. If the user hasn't entered a city yet, fall
|
|
133
|
+
// back to all lockers so the UI doesn't render empty on first paint.
|
|
134
|
+
//
|
|
135
|
+
// Both sides are transliterated Cyrillic→Latin so "Sofia" matches
|
|
136
|
+
// "София" and vice versa. Idempotent on already-Latin input.
|
|
137
|
+
const cityNormalized = normalizeForMatch(userCity.trim())
|
|
138
|
+
const cityLockedLockers = useMemo(() => {
|
|
139
|
+
if (!cityNormalized) return lockers
|
|
140
|
+
return lockers.filter((l) => {
|
|
141
|
+
const line1 = normalizeForMatch(l.addressLine1 ?? "")
|
|
142
|
+
const line2 = normalizeForMatch(l.addressLine2 ?? "")
|
|
143
|
+
return line2.includes(cityNormalized) || line1.includes(cityNormalized)
|
|
144
|
+
})
|
|
145
|
+
}, [lockers, cityNormalized])
|
|
146
|
+
|
|
147
|
+
const nearestLockers = useMemo(() => {
|
|
148
|
+
if (!cityLockedLockers.length) return []
|
|
149
|
+
|
|
150
|
+
const geocodedLockers = cityLockedLockers.filter(
|
|
151
|
+
(l) =>
|
|
152
|
+
typeof l.lat === "number" &&
|
|
153
|
+
typeof l.lng === "number" &&
|
|
154
|
+
!Number.isNaN(l.lat) &&
|
|
155
|
+
!Number.isNaN(l.lng)
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
if (userCoords && geocodedLockers.length > 0) {
|
|
159
|
+
return geocodedLockers
|
|
160
|
+
.map((l) => ({
|
|
161
|
+
locker: l,
|
|
162
|
+
distance: distanceMeters(
|
|
163
|
+
userCoords.lat,
|
|
164
|
+
userCoords.lng,
|
|
165
|
+
l.lat as number,
|
|
166
|
+
l.lng as number
|
|
167
|
+
),
|
|
168
|
+
}))
|
|
169
|
+
.sort((a, b) => a.distance - b.distance)
|
|
170
|
+
.slice(0, 3)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// No coords available — just show the first 3 city-locked lockers.
|
|
174
|
+
return cityLockedLockers
|
|
175
|
+
.slice(0, 3)
|
|
176
|
+
.map((l) => ({ locker: l, distance: 0 }))
|
|
177
|
+
}, [cityLockedLockers, userCoords])
|
|
178
|
+
|
|
179
|
+
const searchResults = useMemo(() => {
|
|
180
|
+
if (!search.trim() || search.trim().length < 2) return []
|
|
181
|
+
// Normalize query and locker fields through Cyrillic→Latin so the
|
|
182
|
+
// user can search "Vitosha" and match "Витоша" (and vice versa).
|
|
183
|
+
const q = normalizeForMatch(search.trim())
|
|
184
|
+
// Search is also city-locked — only lockers in the user's city.
|
|
185
|
+
// Uncapped; container scrolls.
|
|
186
|
+
return cityLockedLockers.filter((l) => {
|
|
187
|
+
const title = normalizeForMatch(l.title ?? "")
|
|
188
|
+
const line1 = normalizeForMatch(l.addressLine1 ?? "")
|
|
189
|
+
const line2 = normalizeForMatch(l.addressLine2 ?? "")
|
|
190
|
+
const postal = (l.postalCode ?? "").toLowerCase()
|
|
191
|
+
return (
|
|
192
|
+
title.includes(q) ||
|
|
193
|
+
line1.includes(q) ||
|
|
194
|
+
line2.includes(q) ||
|
|
195
|
+
postal.includes(q)
|
|
196
|
+
)
|
|
197
|
+
})
|
|
198
|
+
}, [cityLockedLockers, search])
|
|
199
|
+
|
|
200
|
+
const renderLocker = useCallback(
|
|
201
|
+
(locker: BoxNowLocker, distance: number | null, isSelected: boolean) => (
|
|
202
|
+
<button
|
|
203
|
+
key={locker.id}
|
|
204
|
+
type="button"
|
|
205
|
+
onClick={() => {
|
|
206
|
+
onSelect(locker)
|
|
207
|
+
setShowSearch(false)
|
|
208
|
+
setSearch("")
|
|
209
|
+
}}
|
|
210
|
+
className={cn(
|
|
211
|
+
"flex items-start gap-3 w-full px-3.5 py-3 text-left transition-all duration-150 rounded-lg",
|
|
212
|
+
isSelected ? "bg-primary/10" : "bg-card hover:bg-muted"
|
|
213
|
+
)}
|
|
214
|
+
style={
|
|
215
|
+
isSelected
|
|
216
|
+
? { boxShadow: "inset 0 0 0 1.5px oklch(var(--primary))" }
|
|
217
|
+
: undefined
|
|
218
|
+
}
|
|
219
|
+
>
|
|
220
|
+
<div className="w-8 h-8 rounded-full bg-muted flex items-center justify-center flex-shrink-0 mt-0.5">
|
|
221
|
+
<svg
|
|
222
|
+
className="w-4 h-4 text-muted-foreground"
|
|
223
|
+
fill="none"
|
|
224
|
+
viewBox="0 0 24 24"
|
|
225
|
+
stroke="currentColor"
|
|
226
|
+
strokeWidth={2}
|
|
227
|
+
>
|
|
228
|
+
<path
|
|
229
|
+
strokeLinecap="round"
|
|
230
|
+
strokeLinejoin="round"
|
|
231
|
+
d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z"
|
|
232
|
+
/>
|
|
233
|
+
<path
|
|
234
|
+
strokeLinecap="round"
|
|
235
|
+
strokeLinejoin="round"
|
|
236
|
+
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"
|
|
237
|
+
/>
|
|
238
|
+
</svg>
|
|
239
|
+
</div>
|
|
240
|
+
|
|
241
|
+
<div className="flex-1 min-w-0">
|
|
242
|
+
<p className="text-sm font-medium text-foreground leading-tight">
|
|
243
|
+
{locker.title}
|
|
244
|
+
</p>
|
|
245
|
+
<p className="text-xs text-muted-foreground mt-0.5">
|
|
246
|
+
{locker.addressLine1}
|
|
247
|
+
{locker.addressLine2 ? `, ${locker.addressLine2}` : ""}
|
|
248
|
+
{locker.postalCode ? `, ${locker.postalCode}` : ""}
|
|
249
|
+
</p>
|
|
250
|
+
</div>
|
|
251
|
+
|
|
252
|
+
{distance !== null && distance > 0 && (
|
|
253
|
+
<span className="text-xs font-medium text-muted-foreground flex-shrink-0 mt-1">
|
|
254
|
+
{formatDistance(distance)}
|
|
255
|
+
</span>
|
|
256
|
+
)}
|
|
257
|
+
</button>
|
|
258
|
+
),
|
|
259
|
+
[onSelect]
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
if (lockersState.status === "loading") {
|
|
263
|
+
return (
|
|
264
|
+
<div className="px-4 py-6 flex items-center justify-center">
|
|
265
|
+
<div className="w-5 h-5 border-2 border-border border-t-text-muted rounded-full animate-spin" />
|
|
266
|
+
<span className="ml-2 text-sm text-muted-foreground">
|
|
267
|
+
{labels.boxnowLoadingLockers}
|
|
268
|
+
</span>
|
|
269
|
+
</div>
|
|
270
|
+
)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (lockersState.status === "error") {
|
|
274
|
+
return (
|
|
275
|
+
<div className="px-4 py-4">
|
|
276
|
+
<div className="p-3 rounded-lg bg-muted border border-border">
|
|
277
|
+
<p className="text-sm text-muted-foreground">
|
|
278
|
+
{labels.boxnowUnavailable}
|
|
279
|
+
</p>
|
|
280
|
+
</div>
|
|
281
|
+
</div>
|
|
282
|
+
)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// User's city has no lockers — explicit dead-end message so they pick
|
|
286
|
+
// a different shipping method instead of staring at silence.
|
|
287
|
+
if (cityNormalized && cityLockedLockers.length === 0 && !selectedLocker) {
|
|
288
|
+
return (
|
|
289
|
+
<div className="px-4 py-4">
|
|
290
|
+
<div className="p-3 rounded-lg bg-muted border border-border">
|
|
291
|
+
<p className="text-sm text-muted-foreground">
|
|
292
|
+
{labels.boxnowNoLockersInCity}
|
|
293
|
+
</p>
|
|
294
|
+
</div>
|
|
295
|
+
</div>
|
|
296
|
+
)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return (
|
|
300
|
+
<div className="px-4 pb-4 pt-2 space-y-3">
|
|
301
|
+
{selectedLocker && (
|
|
302
|
+
<div className="flex items-center gap-2 px-3 py-2 bg-primary/10 border border-primary/30 rounded-lg">
|
|
303
|
+
<svg
|
|
304
|
+
className="w-4 h-4 text-primary flex-shrink-0"
|
|
305
|
+
fill="none"
|
|
306
|
+
viewBox="0 0 24 24"
|
|
307
|
+
stroke="currentColor"
|
|
308
|
+
strokeWidth={2}
|
|
309
|
+
>
|
|
310
|
+
<path
|
|
311
|
+
strokeLinecap="round"
|
|
312
|
+
strokeLinejoin="round"
|
|
313
|
+
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
314
|
+
/>
|
|
315
|
+
</svg>
|
|
316
|
+
<p className="text-sm font-medium text-primary">
|
|
317
|
+
{selectedLocker.title}
|
|
318
|
+
</p>
|
|
319
|
+
<button
|
|
320
|
+
type="button"
|
|
321
|
+
onClick={() => onSelect(null)}
|
|
322
|
+
className="ml-auto text-xs text-primary hover:underline"
|
|
323
|
+
>
|
|
324
|
+
{labels.boxnowChange}
|
|
325
|
+
</button>
|
|
326
|
+
</div>
|
|
327
|
+
)}
|
|
328
|
+
|
|
329
|
+
{!selectedLocker && nearestLockers.length > 0 && (
|
|
330
|
+
<div>
|
|
331
|
+
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2">
|
|
332
|
+
{labels.boxnowNearestLockers}
|
|
333
|
+
</p>
|
|
334
|
+
<div className="space-y-1.5">
|
|
335
|
+
{nearestLockers.map(({ locker, distance }) =>
|
|
336
|
+
renderLocker(locker, distance, false)
|
|
337
|
+
)}
|
|
338
|
+
</div>
|
|
339
|
+
</div>
|
|
340
|
+
)}
|
|
341
|
+
|
|
342
|
+
{!selectedLocker && (
|
|
343
|
+
<div>
|
|
344
|
+
{!showSearch ? (
|
|
345
|
+
<button
|
|
346
|
+
type="button"
|
|
347
|
+
onClick={() => setShowSearch(true)}
|
|
348
|
+
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
|
349
|
+
>
|
|
350
|
+
<svg
|
|
351
|
+
className="w-4 h-4"
|
|
352
|
+
fill="none"
|
|
353
|
+
viewBox="0 0 24 24"
|
|
354
|
+
stroke="currentColor"
|
|
355
|
+
strokeWidth={2}
|
|
356
|
+
>
|
|
357
|
+
<path
|
|
358
|
+
strokeLinecap="round"
|
|
359
|
+
strokeLinejoin="round"
|
|
360
|
+
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
|
|
361
|
+
/>
|
|
362
|
+
</svg>
|
|
363
|
+
{labels.boxnowSearchAnother}
|
|
364
|
+
</button>
|
|
365
|
+
) : (
|
|
366
|
+
<div>
|
|
367
|
+
<div className="relative">
|
|
368
|
+
<svg
|
|
369
|
+
className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground"
|
|
370
|
+
fill="none"
|
|
371
|
+
viewBox="0 0 24 24"
|
|
372
|
+
stroke="currentColor"
|
|
373
|
+
strokeWidth={2}
|
|
374
|
+
>
|
|
375
|
+
<path
|
|
376
|
+
strokeLinecap="round"
|
|
377
|
+
strokeLinejoin="round"
|
|
378
|
+
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
|
|
379
|
+
/>
|
|
380
|
+
</svg>
|
|
381
|
+
<input
|
|
382
|
+
type="text"
|
|
383
|
+
value={search}
|
|
384
|
+
onChange={(e) => setSearch(e.target.value)}
|
|
385
|
+
placeholder={labels.boxnowSearchPlaceholder}
|
|
386
|
+
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"
|
|
387
|
+
autoFocus
|
|
388
|
+
/>
|
|
389
|
+
</div>
|
|
390
|
+
|
|
391
|
+
{searchResults.length > 0 && (
|
|
392
|
+
<div className="mt-2 space-y-1 max-h-[240px] overflow-y-auto">
|
|
393
|
+
{searchResults.map((locker) =>
|
|
394
|
+
renderLocker(locker, null, false)
|
|
395
|
+
)}
|
|
396
|
+
</div>
|
|
397
|
+
)}
|
|
398
|
+
|
|
399
|
+
{search.trim().length >= 2 && searchResults.length === 0 && (
|
|
400
|
+
<p className="mt-2 text-xs text-muted-foreground text-center py-3">
|
|
401
|
+
{labels.boxnowNoResults} "{search}"
|
|
402
|
+
</p>
|
|
403
|
+
)}
|
|
404
|
+
</div>
|
|
405
|
+
)}
|
|
406
|
+
</div>
|
|
407
|
+
)}
|
|
408
|
+
</div>
|
|
409
|
+
)
|
|
410
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import type { Appearance, StripeElementsOptions } from "@stripe/stripe-js"
|
|
4
|
+
|
|
5
|
+
import type { StorefrontClient } from "../api/http"
|
|
6
|
+
import type { Cart } from "../api/carts"
|
|
7
|
+
import type { StoreShippingOption } from "../api/checkout"
|
|
8
|
+
import type { StoreCustomer } from "../api/customers"
|
|
9
|
+
import { CheckoutAddressForm } from "./address-form"
|
|
10
|
+
import { OrderSummary } from "./order-summary"
|
|
11
|
+
import { CheckoutPaymentMethodList } from "./payment-method-list"
|
|
12
|
+
import { CheckoutShippingMethodList } from "./shipping-method-list"
|
|
13
|
+
import { PaymentWrapper } from "./payment-wrapper"
|
|
14
|
+
import {
|
|
15
|
+
useCheckoutOrchestration,
|
|
16
|
+
type CheckoutCodConfig,
|
|
17
|
+
type CheckoutLogError,
|
|
18
|
+
type PaymentProviderLike,
|
|
19
|
+
} from "./use-checkout-orchestration"
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* CheckoutClient — single-page checkout layout. The orchestration logic
|
|
23
|
+
* (address state, shipping select, carrier metadata, payment-tab race
|
|
24
|
+
* guards, 3DS return handling, completed-cart detection) lives in the
|
|
25
|
+
* `useCheckoutOrchestration` hook so any store with a custom layout
|
|
26
|
+
* shares the same hardened state machine and inherits future fixes
|
|
27
|
+
* automatically.
|
|
28
|
+
*
|
|
29
|
+
* Ported from `@1click/ui/src/checkout/checkout-client.tsx` (v2.3.1) with
|
|
30
|
+
* the barter data seam:
|
|
31
|
+
*
|
|
32
|
+
* - Takes the SDK `client` — every server call (address autosave,
|
|
33
|
+
* prepare-checkout, sync, complete, promo/gift-card apply, quantity
|
|
34
|
+
* changes) runs through it.
|
|
35
|
+
* - The listings arrive as SDK DTOs: `listShippingOptions` /
|
|
36
|
+
* `listPaymentProviders` (both rule-filtered server-side) — fetch them
|
|
37
|
+
* in the host and pass down (docs/storefront/checkout.md).
|
|
38
|
+
* - `codConfig` = the integrations config `cod` block
|
|
39
|
+
* (`getIntegrationsConfig(client).cod`) — the COD fee prediction is
|
|
40
|
+
* NEVER hardcoded (docs/storefront/integrations.md).
|
|
41
|
+
* - `onCartChange` closes the loop for summary mutations: the decorated
|
|
42
|
+
* cart returned by promo/gift-card/quantity calls is handed up so the
|
|
43
|
+
* host updates its cart state (`router.refresh()` in an RSC app) AND
|
|
44
|
+
* the hook's `syncPaymentAmount` re-aligns any pending session.
|
|
45
|
+
* - The mobile top/bottom summary bars of the @1click layout are not in
|
|
46
|
+
* this batch's scope; the desktop-first layout stands alone and
|
|
47
|
+
* stores compose their own mobile bars from the hook + OrderSummary.
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
type CheckoutClientProps = {
|
|
51
|
+
/** The SDK transport — all server calls go through it. */
|
|
52
|
+
client: StorefrontClient
|
|
53
|
+
cart: Cart
|
|
54
|
+
customer: StoreCustomer | null
|
|
55
|
+
availableShippingMethods: StoreShippingOption[] | null
|
|
56
|
+
availablePaymentMethods: PaymentProviderLike[] | null
|
|
57
|
+
countryCode: string
|
|
58
|
+
/** Countries for the address form (barter regions embed none). */
|
|
59
|
+
countries?: Array<{ iso_2: string; display_name: string }>
|
|
60
|
+
paymentMethodFilter?: (
|
|
61
|
+
methods: PaymentProviderLike[] | null,
|
|
62
|
+
selectedShippingOption: StoreShippingOption | null
|
|
63
|
+
) => PaymentProviderLike[] | null
|
|
64
|
+
/** The integrations config `cod` block — drives the optimistic COD fee. */
|
|
65
|
+
codConfig?: CheckoutCodConfig
|
|
66
|
+
/** Show the gift-card widget in the summary (default true). */
|
|
67
|
+
showGiftCards?: boolean
|
|
68
|
+
logoByFulfillmentOptionId?: Record<string, { src: string; alt: string }>
|
|
69
|
+
/** Stripe Elements appearance + fonts forwarded to PaymentWrapper. */
|
|
70
|
+
appearance?: Appearance
|
|
71
|
+
fonts?: StripeElementsOptions["fonts"]
|
|
72
|
+
/** Order-confirmed path template override (`{id}` / `{country}`). */
|
|
73
|
+
orderConfirmedPath?: string
|
|
74
|
+
/** Custom post-complete navigation (next/navigation router push). */
|
|
75
|
+
onOrderPlaced?: Parameters<
|
|
76
|
+
typeof useCheckoutOrchestration
|
|
77
|
+
>[0]["onOrderPlaced"]
|
|
78
|
+
/** Consent-gated tracking-attribution resolver (see the hook's JSDoc). */
|
|
79
|
+
resolveTrackingMetadata?: () => Record<string, unknown> | undefined
|
|
80
|
+
/** Operational-visibility sink for checkout errors. */
|
|
81
|
+
logError?: CheckoutLogError
|
|
82
|
+
/** Receives every decorated cart returned by a summary mutation. */
|
|
83
|
+
onCartChange?: (cart: Cart) => void
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function CheckoutClient({
|
|
87
|
+
client,
|
|
88
|
+
cart,
|
|
89
|
+
customer,
|
|
90
|
+
availableShippingMethods,
|
|
91
|
+
availablePaymentMethods,
|
|
92
|
+
countryCode,
|
|
93
|
+
countries,
|
|
94
|
+
paymentMethodFilter,
|
|
95
|
+
codConfig,
|
|
96
|
+
showGiftCards = true,
|
|
97
|
+
logoByFulfillmentOptionId,
|
|
98
|
+
appearance,
|
|
99
|
+
fonts,
|
|
100
|
+
orderConfirmedPath,
|
|
101
|
+
onOrderPlaced,
|
|
102
|
+
resolveTrackingMetadata,
|
|
103
|
+
logError,
|
|
104
|
+
onCartChange,
|
|
105
|
+
}: CheckoutClientProps) {
|
|
106
|
+
const o = useCheckoutOrchestration({
|
|
107
|
+
client,
|
|
108
|
+
cart,
|
|
109
|
+
customer,
|
|
110
|
+
availableShippingMethods,
|
|
111
|
+
availablePaymentMethods,
|
|
112
|
+
countryCode,
|
|
113
|
+
countries,
|
|
114
|
+
paymentMethodFilter,
|
|
115
|
+
codConfig,
|
|
116
|
+
orderConfirmedPath,
|
|
117
|
+
onOrderPlaced,
|
|
118
|
+
resolveTrackingMetadata,
|
|
119
|
+
logError,
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
const buyButtonNotReady = !o.deliveryReady || !o.addressReady
|
|
123
|
+
|
|
124
|
+
// Summary mutations (promo/gift-card apply, quantity change) move the
|
|
125
|
+
// total — re-align any pending payment session in place so the Stripe
|
|
126
|
+
// client_secret survives (sync-payment-amount matrix; pre-Buy it no-ops
|
|
127
|
+
// with `no_payment_collection`). Then hand the fresh cart to the host.
|
|
128
|
+
const handleCartChange = (updated: Cart) => {
|
|
129
|
+
void o.syncPaymentAmount().catch(() => {})
|
|
130
|
+
onCartChange?.(updated)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return (
|
|
134
|
+
<PaymentWrapper
|
|
135
|
+
cart={cart}
|
|
136
|
+
amount={o.optimisticTotalCents}
|
|
137
|
+
appearance={appearance}
|
|
138
|
+
fonts={fonts}
|
|
139
|
+
>
|
|
140
|
+
<div className="max-w-[1140px] mx-auto px-5 py-8 sm:py-12">
|
|
141
|
+
<div className="flex flex-col sm:flex-row sm:justify-center gap-8 sm:gap-12">
|
|
142
|
+
{/* ═══ LEFT: FORM ════════════════════════════════════════════ */}
|
|
143
|
+
<div className="w-full sm:max-w-[480px]">
|
|
144
|
+
<CheckoutAddressForm
|
|
145
|
+
formData={o.formData}
|
|
146
|
+
onChange={o.handleFormChange}
|
|
147
|
+
onBlur={o.handleFieldBlur}
|
|
148
|
+
savedAddresses={o.addressesInRegion ?? undefined}
|
|
149
|
+
onSelectSavedAddress={o.setFormAddress}
|
|
150
|
+
countries={o.regionCountries}
|
|
151
|
+
addressInput={o.addressInput}
|
|
152
|
+
addressError={o.addressError}
|
|
153
|
+
pulseFields={o.pulseFields}
|
|
154
|
+
/>
|
|
155
|
+
|
|
156
|
+
<CheckoutShippingMethodList
|
|
157
|
+
shippingMethods={o.shippingMethods}
|
|
158
|
+
selectedShippingMethodId={o.selectedShippingMethod}
|
|
159
|
+
calculatedPricesMap={o.calculatedPricesMap}
|
|
160
|
+
isLoadingPrices={o.isLoadingPrices}
|
|
161
|
+
shippingLoading={o.shippingLoading}
|
|
162
|
+
shippingError={o.shippingError}
|
|
163
|
+
onSelect={o.handleSelectShipping}
|
|
164
|
+
addressReady={o.addressReady}
|
|
165
|
+
currencyCode={cart.currency_code}
|
|
166
|
+
econt={{
|
|
167
|
+
selectedOffice: o.selectedEcontOffice,
|
|
168
|
+
onSelectOffice: o.handleSelectEcontOffice,
|
|
169
|
+
userCity: o.formData["shipping_address.city"] ?? "",
|
|
170
|
+
userAddress: o.formData["shipping_address.address_1"] ?? "",
|
|
171
|
+
}}
|
|
172
|
+
boxnow={{
|
|
173
|
+
client,
|
|
174
|
+
selectedLocker: o.selectedBoxnowLocker,
|
|
175
|
+
onSelectLocker: o.handleSelectBoxnowLocker,
|
|
176
|
+
userCity: o.formData["shipping_address.city"] ?? "",
|
|
177
|
+
userAddress: o.formData["shipping_address.address_1"] ?? "",
|
|
178
|
+
}}
|
|
179
|
+
logoByFulfillmentOptionId={logoByFulfillmentOptionId}
|
|
180
|
+
/>
|
|
181
|
+
|
|
182
|
+
<CheckoutPaymentMethodList
|
|
183
|
+
cart={cart}
|
|
184
|
+
hasCard={o.hasCard}
|
|
185
|
+
hasCod={o.hasCod}
|
|
186
|
+
paymentTab={o.paymentTab}
|
|
187
|
+
onPaymentTab={o.handlePaymentTab}
|
|
188
|
+
deliveryReady={o.deliveryReady}
|
|
189
|
+
paymentError={o.paymentError}
|
|
190
|
+
onPaymentElementChange={o.handlePaymentElementChange}
|
|
191
|
+
performBuyClick={o.performBuyClick}
|
|
192
|
+
buyButtonNotReady={buyButtonNotReady}
|
|
193
|
+
buyButtonNotReadyReason={
|
|
194
|
+
!o.addressReady ? "address" : !o.deliveryReady ? "delivery" : null
|
|
195
|
+
}
|
|
196
|
+
total={o.optimisticTotal}
|
|
197
|
+
logError={logError}
|
|
198
|
+
/>
|
|
199
|
+
</div>
|
|
200
|
+
|
|
201
|
+
{/* ═══ RIGHT: ORDER SUMMARY (desktop only) ═══════════════════ */}
|
|
202
|
+
<div className="hidden sm:block sm:w-[420px] flex-shrink-0">
|
|
203
|
+
<div className="sm:sticky sm:top-6">
|
|
204
|
+
<OrderSummary
|
|
205
|
+
client={client}
|
|
206
|
+
cart={o.summaryCart}
|
|
207
|
+
optimisticShippingCost={o.optimisticShippingCost}
|
|
208
|
+
onOptimisticShippingClear={() =>
|
|
209
|
+
o.setOptimisticShippingCost(null)
|
|
210
|
+
}
|
|
211
|
+
optimisticCodFee={o.optimisticCodFee}
|
|
212
|
+
onOptimisticCodFeeClear={() => o.setOptimisticCodFee(null)}
|
|
213
|
+
showGiftCards={showGiftCards}
|
|
214
|
+
onCartChange={handleCartChange}
|
|
215
|
+
/>
|
|
216
|
+
</div>
|
|
217
|
+
</div>
|
|
218
|
+
</div>
|
|
219
|
+
</div>
|
|
220
|
+
</PaymentWrapper>
|
|
221
|
+
)
|
|
222
|
+
}
|