@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,332 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useMemo, useState } from "react"
|
|
4
|
+
|
|
5
|
+
import { cn } from "../lib/utils"
|
|
6
|
+
import { distanceMeters, formatDistance, geocodeAddress } from "./geocode"
|
|
7
|
+
import { useCheckoutLabels } from "./context"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* EcontOfficeSelector — full Econt office picker for Bulgarian shipping.
|
|
11
|
+
*
|
|
12
|
+
* Ported from `@1click/ui/src/checkout/econt-office-selector.tsx` (v2.3.1)
|
|
13
|
+
* verbatim — this component has NO barter data seam: the office directory
|
|
14
|
+
* comes straight from Econt's public Nomenclatures endpoint (client-side,
|
|
15
|
+
* cached globally per page load), and the CHOSEN office is handed to the
|
|
16
|
+
* orchestration hook, which writes it into `carrier_metadata` exactly once
|
|
17
|
+
* at Buy click (POST /api/store/carts/:id/prepare-checkout —
|
|
18
|
+
* docs/storefront/checkout.md). The geocode helpers moved into the checkout
|
|
19
|
+
* family (`./geocode`) because the barter lib family doesn't ship one.
|
|
20
|
+
*
|
|
21
|
+
* Behavior:
|
|
22
|
+
* - On mount, fetches the full Econt offices list from the public Econt
|
|
23
|
+
* Nomenclatures endpoint (cached globally — one fetch per page load).
|
|
24
|
+
* - If the user has entered a shipping address (city + street), geocodes
|
|
25
|
+
* it via OpenStreetMap Nominatim and sorts the 3 nearest offices by
|
|
26
|
+
* haversine distance.
|
|
27
|
+
* - If geocoding fails or address is empty, falls back to filtering
|
|
28
|
+
* offices by city name.
|
|
29
|
+
* - Expanding "Search for another office" lets the user search across
|
|
30
|
+
* all offices by name/city/street.
|
|
31
|
+
*
|
|
32
|
+
* Bulgarian-specific logic (cleanAddress prefix stripping, Nominatim
|
|
33
|
+
* country="Bulgaria" constraint) stays in this file — it IS intrinsically
|
|
34
|
+
* Bulgarian. Stores that want a different country build their own selector.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
export type EcontOffice = {
|
|
38
|
+
id: number
|
|
39
|
+
code: string
|
|
40
|
+
name: string
|
|
41
|
+
nameEn: string
|
|
42
|
+
isAPS: boolean
|
|
43
|
+
address: {
|
|
44
|
+
city: { name: string }
|
|
45
|
+
street: string
|
|
46
|
+
num: string
|
|
47
|
+
location: { latitude: number; longitude: number } | null
|
|
48
|
+
}
|
|
49
|
+
phones: string[]
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
type EcontOfficeSelectorProps = {
|
|
53
|
+
userCity: string
|
|
54
|
+
userAddress: string
|
|
55
|
+
selectedOffice: EcontOffice | null
|
|
56
|
+
onSelect: (office: EcontOffice | null) => void
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let officesCache: EcontOffice[] | null = null
|
|
60
|
+
let officesFetchPromise: Promise<EcontOffice[]> | null = null
|
|
61
|
+
|
|
62
|
+
async function fetchOffices(): Promise<EcontOffice[]> {
|
|
63
|
+
if (officesCache) return officesCache
|
|
64
|
+
if (officesFetchPromise) return officesFetchPromise
|
|
65
|
+
|
|
66
|
+
officesFetchPromise = fetch(
|
|
67
|
+
"https://ee.econt.com/services/Nomenclatures/NomenclaturesService.getOffices.json",
|
|
68
|
+
{
|
|
69
|
+
method: "POST",
|
|
70
|
+
headers: { "Content-Type": "application/json" },
|
|
71
|
+
body: JSON.stringify({ countryCode: "BGR" }),
|
|
72
|
+
}
|
|
73
|
+
)
|
|
74
|
+
.then((r) => r.json())
|
|
75
|
+
.then((d) => {
|
|
76
|
+
officesCache = d.offices || []
|
|
77
|
+
return officesCache!
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
return officesFetchPromise
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function EcontOfficeSelector({
|
|
84
|
+
userCity,
|
|
85
|
+
userAddress,
|
|
86
|
+
selectedOffice,
|
|
87
|
+
onSelect,
|
|
88
|
+
}: EcontOfficeSelectorProps) {
|
|
89
|
+
const labels = useCheckoutLabels()
|
|
90
|
+
const [offices, setOffices] = useState<EcontOffice[]>([])
|
|
91
|
+
const [userCoords, setUserCoords] = useState<{
|
|
92
|
+
lat: number
|
|
93
|
+
lng: number
|
|
94
|
+
} | null>(null)
|
|
95
|
+
const [search, setSearch] = useState("")
|
|
96
|
+
const [showSearch, setShowSearch] = useState(false)
|
|
97
|
+
const [loading, setLoading] = useState(true)
|
|
98
|
+
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
setLoading(true)
|
|
101
|
+
Promise.all([
|
|
102
|
+
fetchOffices(),
|
|
103
|
+
userCity && userAddress
|
|
104
|
+
? geocodeAddress(userCity, userAddress)
|
|
105
|
+
: Promise.resolve(null),
|
|
106
|
+
]).then(([fetchedOffices, coords]) => {
|
|
107
|
+
setOffices(fetchedOffices)
|
|
108
|
+
setUserCoords(coords)
|
|
109
|
+
setLoading(false)
|
|
110
|
+
})
|
|
111
|
+
}, [userCity, userAddress])
|
|
112
|
+
|
|
113
|
+
const nearestOffices = useMemo(() => {
|
|
114
|
+
if (!offices.length) return []
|
|
115
|
+
|
|
116
|
+
if (userCoords) {
|
|
117
|
+
return offices
|
|
118
|
+
.filter((o) => o.address?.location?.latitude)
|
|
119
|
+
.map((o) => ({
|
|
120
|
+
office: o,
|
|
121
|
+
distance: distanceMeters(
|
|
122
|
+
userCoords.lat,
|
|
123
|
+
userCoords.lng,
|
|
124
|
+
o.address.location!.latitude,
|
|
125
|
+
o.address.location!.longitude
|
|
126
|
+
),
|
|
127
|
+
}))
|
|
128
|
+
.sort((a, b) => a.distance - b.distance)
|
|
129
|
+
.slice(0, 3)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const cityLower = userCity.toLowerCase()
|
|
133
|
+
const cityOffices = offices.filter(
|
|
134
|
+
(o) => o.address?.city?.name?.toLowerCase() === cityLower
|
|
135
|
+
)
|
|
136
|
+
return cityOffices.slice(0, 3).map((o) => ({ office: o, distance: 0 }))
|
|
137
|
+
}, [offices, userCoords, userCity])
|
|
138
|
+
|
|
139
|
+
const searchResults = useMemo(() => {
|
|
140
|
+
if (!search.trim() || search.trim().length < 2) return []
|
|
141
|
+
const q = search.toLowerCase()
|
|
142
|
+
return offices
|
|
143
|
+
.filter(
|
|
144
|
+
(o) =>
|
|
145
|
+
o.name?.toLowerCase().includes(q) ||
|
|
146
|
+
o.address?.city?.name?.toLowerCase().includes(q) ||
|
|
147
|
+
o.address?.street?.toLowerCase().includes(q)
|
|
148
|
+
)
|
|
149
|
+
.slice(0, 8)
|
|
150
|
+
}, [offices, search])
|
|
151
|
+
|
|
152
|
+
const renderOffice = useCallback(
|
|
153
|
+
(office: EcontOffice, distance: number | null, isSelected: boolean) => (
|
|
154
|
+
<button
|
|
155
|
+
key={office.id}
|
|
156
|
+
type="button"
|
|
157
|
+
onClick={() => {
|
|
158
|
+
onSelect(office)
|
|
159
|
+
setShowSearch(false)
|
|
160
|
+
setSearch("")
|
|
161
|
+
}}
|
|
162
|
+
className={cn(
|
|
163
|
+
"flex items-start gap-3 w-full px-3.5 py-3 text-left transition-all duration-150 rounded-lg",
|
|
164
|
+
isSelected ? "bg-primary/10" : "bg-card hover:bg-muted"
|
|
165
|
+
)}
|
|
166
|
+
style={
|
|
167
|
+
isSelected
|
|
168
|
+
? { boxShadow: "inset 0 0 0 1.5px oklch(var(--primary))" }
|
|
169
|
+
: undefined
|
|
170
|
+
}
|
|
171
|
+
>
|
|
172
|
+
<div className="w-8 h-8 rounded-full bg-muted flex items-center justify-center flex-shrink-0 mt-0.5">
|
|
173
|
+
<svg
|
|
174
|
+
className="w-4 h-4 text-muted-foreground"
|
|
175
|
+
fill="none"
|
|
176
|
+
viewBox="0 0 24 24"
|
|
177
|
+
stroke="currentColor"
|
|
178
|
+
strokeWidth={2}
|
|
179
|
+
>
|
|
180
|
+
<path
|
|
181
|
+
strokeLinecap="round"
|
|
182
|
+
strokeLinejoin="round"
|
|
183
|
+
d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z"
|
|
184
|
+
/>
|
|
185
|
+
<path
|
|
186
|
+
strokeLinecap="round"
|
|
187
|
+
strokeLinejoin="round"
|
|
188
|
+
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"
|
|
189
|
+
/>
|
|
190
|
+
</svg>
|
|
191
|
+
</div>
|
|
192
|
+
|
|
193
|
+
<div className="flex-1 min-w-0">
|
|
194
|
+
<p className="text-sm font-medium text-foreground leading-tight">
|
|
195
|
+
{office.name}
|
|
196
|
+
</p>
|
|
197
|
+
<p className="text-xs text-muted-foreground mt-0.5">
|
|
198
|
+
{office.address?.street}
|
|
199
|
+
{office.address?.num ? ` ${office.address.num}` : ""}
|
|
200
|
+
{office.address?.city?.name ? `, ${office.address.city.name}` : ""}
|
|
201
|
+
</p>
|
|
202
|
+
</div>
|
|
203
|
+
|
|
204
|
+
{distance !== null && distance > 0 && (
|
|
205
|
+
<span className="text-xs font-medium text-muted-foreground flex-shrink-0 mt-1">
|
|
206
|
+
{formatDistance(distance)}
|
|
207
|
+
</span>
|
|
208
|
+
)}
|
|
209
|
+
</button>
|
|
210
|
+
),
|
|
211
|
+
[onSelect]
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
if (loading) {
|
|
215
|
+
return (
|
|
216
|
+
<div className="px-4 py-6 flex items-center justify-center">
|
|
217
|
+
<div className="w-5 h-5 border-2 border-border border-t-text-muted rounded-full animate-spin" />
|
|
218
|
+
<span className="ml-2 text-sm text-muted-foreground">
|
|
219
|
+
{labels.econtLoadingOffices}
|
|
220
|
+
</span>
|
|
221
|
+
</div>
|
|
222
|
+
)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return (
|
|
226
|
+
<div className="px-4 pb-4 pt-2 space-y-3">
|
|
227
|
+
{selectedOffice && (
|
|
228
|
+
<div className="flex items-center gap-2 px-3 py-2 bg-primary/10 border border-primary/30 rounded-lg">
|
|
229
|
+
<svg
|
|
230
|
+
className="w-4 h-4 text-primary flex-shrink-0"
|
|
231
|
+
fill="none"
|
|
232
|
+
viewBox="0 0 24 24"
|
|
233
|
+
stroke="currentColor"
|
|
234
|
+
strokeWidth={2}
|
|
235
|
+
>
|
|
236
|
+
<path
|
|
237
|
+
strokeLinecap="round"
|
|
238
|
+
strokeLinejoin="round"
|
|
239
|
+
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
240
|
+
/>
|
|
241
|
+
</svg>
|
|
242
|
+
<p className="text-sm font-medium text-primary">{selectedOffice.name}</p>
|
|
243
|
+
<button
|
|
244
|
+
type="button"
|
|
245
|
+
onClick={() => onSelect(null)}
|
|
246
|
+
className="ml-auto text-xs text-primary hover:underline"
|
|
247
|
+
>
|
|
248
|
+
{labels.econtChange}
|
|
249
|
+
</button>
|
|
250
|
+
</div>
|
|
251
|
+
)}
|
|
252
|
+
|
|
253
|
+
{!selectedOffice && nearestOffices.length > 0 && (
|
|
254
|
+
<div>
|
|
255
|
+
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-2">
|
|
256
|
+
{labels.econtNearestOffices}
|
|
257
|
+
</p>
|
|
258
|
+
<div className="space-y-1.5">
|
|
259
|
+
{nearestOffices.map(({ office, distance }) =>
|
|
260
|
+
renderOffice(office, distance, false)
|
|
261
|
+
)}
|
|
262
|
+
</div>
|
|
263
|
+
</div>
|
|
264
|
+
)}
|
|
265
|
+
|
|
266
|
+
{!selectedOffice && (
|
|
267
|
+
<div>
|
|
268
|
+
{!showSearch ? (
|
|
269
|
+
<button
|
|
270
|
+
type="button"
|
|
271
|
+
onClick={() => setShowSearch(true)}
|
|
272
|
+
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
|
273
|
+
>
|
|
274
|
+
<svg
|
|
275
|
+
className="w-4 h-4"
|
|
276
|
+
fill="none"
|
|
277
|
+
viewBox="0 0 24 24"
|
|
278
|
+
stroke="currentColor"
|
|
279
|
+
strokeWidth={2}
|
|
280
|
+
>
|
|
281
|
+
<path
|
|
282
|
+
strokeLinecap="round"
|
|
283
|
+
strokeLinejoin="round"
|
|
284
|
+
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
|
|
285
|
+
/>
|
|
286
|
+
</svg>
|
|
287
|
+
{labels.econtSearchAnother}
|
|
288
|
+
</button>
|
|
289
|
+
) : (
|
|
290
|
+
<div>
|
|
291
|
+
<div className="relative">
|
|
292
|
+
<svg
|
|
293
|
+
className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground"
|
|
294
|
+
fill="none"
|
|
295
|
+
viewBox="0 0 24 24"
|
|
296
|
+
stroke="currentColor"
|
|
297
|
+
strokeWidth={2}
|
|
298
|
+
>
|
|
299
|
+
<path
|
|
300
|
+
strokeLinecap="round"
|
|
301
|
+
strokeLinejoin="round"
|
|
302
|
+
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
|
|
303
|
+
/>
|
|
304
|
+
</svg>
|
|
305
|
+
<input
|
|
306
|
+
type="text"
|
|
307
|
+
value={search}
|
|
308
|
+
onChange={(e) => setSearch(e.target.value)}
|
|
309
|
+
placeholder={labels.econtSearchPlaceholder}
|
|
310
|
+
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"
|
|
311
|
+
autoFocus
|
|
312
|
+
/>
|
|
313
|
+
</div>
|
|
314
|
+
|
|
315
|
+
{searchResults.length > 0 && (
|
|
316
|
+
<div className="mt-2 space-y-1 max-h-[240px] overflow-y-auto">
|
|
317
|
+
{searchResults.map((office) => renderOffice(office, null, false))}
|
|
318
|
+
</div>
|
|
319
|
+
)}
|
|
320
|
+
|
|
321
|
+
{search.trim().length >= 2 && searchResults.length === 0 && (
|
|
322
|
+
<p className="mt-2 text-xs text-muted-foreground text-center py-3">
|
|
323
|
+
{labels.econtNoResults} "{search}"
|
|
324
|
+
</p>
|
|
325
|
+
)}
|
|
326
|
+
</div>
|
|
327
|
+
)}
|
|
328
|
+
</div>
|
|
329
|
+
)}
|
|
330
|
+
</div>
|
|
331
|
+
)
|
|
332
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ErrorMessage — tiny inline error text component used throughout the
|
|
5
|
+
* checkout. Returns null when there is no error so it can be rendered
|
|
6
|
+
* unconditionally alongside forms.
|
|
7
|
+
*
|
|
8
|
+
* Ported from `@1click/ui/src/checkout/error-message.tsx` (v2.3.1)
|
|
9
|
+
* unchanged — no data seam.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export function ErrorMessage({
|
|
13
|
+
error,
|
|
14
|
+
"data-testid": dataTestid,
|
|
15
|
+
}: {
|
|
16
|
+
error?: string | null
|
|
17
|
+
"data-testid"?: string
|
|
18
|
+
}) {
|
|
19
|
+
if (!error) return null
|
|
20
|
+
return (
|
|
21
|
+
<div className="pt-2 text-destructive text-xs" data-testid={dataTestid}>
|
|
22
|
+
<span>{error}</span>
|
|
23
|
+
</div>
|
|
24
|
+
)
|
|
25
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Geo + Bulgarian place-name helpers for the carrier pickers (Econt office,
|
|
3
|
+
* BoxNow locker).
|
|
4
|
+
*
|
|
5
|
+
* Ported from `@1click/ui/src/lib/geocode.ts` (v2.3.1) verbatim — the lib
|
|
6
|
+
* family of `@cartbase/storefront` does not ship a geocode module, so the
|
|
7
|
+
* checkout family (its only consumer) carries it. Extra file beyond the
|
|
8
|
+
* pre-declared export list → exported from the family barrel only.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* transliterateBgToLatin — Bulgarian Cyrillic → Latin transliteration
|
|
13
|
+
* using the streamlined system (official BG government standard, used
|
|
14
|
+
* on passports and road signs).
|
|
15
|
+
*
|
|
16
|
+
* Purpose: when a Bulgarian user types "Sofia" but the data source has
|
|
17
|
+
* "София", substring matching fails. Normalize both sides through this
|
|
18
|
+
* function before comparing.
|
|
19
|
+
*
|
|
20
|
+
* Idempotent — Latin input passes through unchanged (just lowercased).
|
|
21
|
+
*/
|
|
22
|
+
const BG_CYRILLIC_TO_LATIN: Record<string, string> = {
|
|
23
|
+
а: "a", б: "b", в: "v", г: "g", д: "d", е: "e", ж: "zh",
|
|
24
|
+
з: "z", и: "i", й: "y", к: "k", л: "l", м: "m", н: "n",
|
|
25
|
+
о: "o", п: "p", р: "r", с: "s", т: "t", у: "u", ф: "f",
|
|
26
|
+
х: "h", ц: "ts", ч: "ch", ш: "sh", щ: "sht",
|
|
27
|
+
ъ: "a", ь: "y", ю: "yu", я: "ya",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function transliterateBgToLatin(s: string): string {
|
|
31
|
+
return s
|
|
32
|
+
.toLowerCase()
|
|
33
|
+
.split("")
|
|
34
|
+
.map((c) => BG_CYRILLIC_TO_LATIN[c] ?? c)
|
|
35
|
+
.join("")
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* normalizeForMatch — produce a canonical key for fuzzy place-name matching.
|
|
40
|
+
*
|
|
41
|
+
* Built on `transliterateBgToLatin` but additionally collapses common
|
|
42
|
+
* Bulgarian / South Slavic Latinization variants:
|
|
43
|
+
*
|
|
44
|
+
* - "София" → "sofiya" → "sofia" (strict transliteration → common)
|
|
45
|
+
* - "Sofia" → "sofia" → "sofia"
|
|
46
|
+
* - "Sofiya" → "sofiya" → "sofia"
|
|
47
|
+
* - "Sofija" → "sofija" → "sofia"
|
|
48
|
+
* - "Sofiia" → "sofiia" → "sofia"
|
|
49
|
+
*
|
|
50
|
+
* Rule: any "iy" / "ij" / "ii" before a vowel collapses to "i". This is
|
|
51
|
+
* the ONE pattern that breaks substring matching for Bulgarian cities.
|
|
52
|
+
* Strict transliteration emits "ya" for "я" (correct on passports), but
|
|
53
|
+
* BoxNow / Econt / OpenStreetMap commonly use the more colloquial "ia"
|
|
54
|
+
* spelling — so users typing "Sofia" must match data containing "София".
|
|
55
|
+
*
|
|
56
|
+
* Use this — not `transliterateBgToLatin` directly — wherever you compare
|
|
57
|
+
* a user-entered place name against an external data source's name.
|
|
58
|
+
* Reserve `transliterateBgToLatin` for outbound contexts (API queries,
|
|
59
|
+
* passport-shaped output) where the strict form is required.
|
|
60
|
+
*/
|
|
61
|
+
export function normalizeForMatch(s: string): string {
|
|
62
|
+
return transliterateBgToLatin(s)
|
|
63
|
+
.replace(/iy(?=[aeiouy])/g, "i")
|
|
64
|
+
.replace(/ij(?=[aeiouy])/g, "i")
|
|
65
|
+
.replace(/ii(?=[aeiouy])/g, "i")
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Shared geo helpers used by Bulgarian fulfillment selectors (Econt, BoxNow).
|
|
70
|
+
*
|
|
71
|
+
* Nominatim usage is rate-limited (1 req/s/IP per their ToS); for a
|
|
72
|
+
* typical checkout we only call once per address change, well under the
|
|
73
|
+
* limit.
|
|
74
|
+
*/
|
|
75
|
+
|
|
76
|
+
export function distanceMeters(
|
|
77
|
+
lat1: number,
|
|
78
|
+
lon1: number,
|
|
79
|
+
lat2: number,
|
|
80
|
+
lon2: number
|
|
81
|
+
): number {
|
|
82
|
+
const R = 6371000
|
|
83
|
+
const dLat = ((lat2 - lat1) * Math.PI) / 180
|
|
84
|
+
const dLon = ((lon2 - lon1) * Math.PI) / 180
|
|
85
|
+
const a =
|
|
86
|
+
Math.sin(dLat / 2) ** 2 +
|
|
87
|
+
Math.cos((lat1 * Math.PI) / 180) *
|
|
88
|
+
Math.cos((lat2 * Math.PI) / 180) *
|
|
89
|
+
Math.sin(dLon / 2) ** 2
|
|
90
|
+
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function formatDistance(meters: number): string {
|
|
94
|
+
if (meters < 1000) return `${Math.round(meters)} m`
|
|
95
|
+
return `${(meters / 1000).toFixed(1)} km`
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* cleanAddress — strip Bulgarian address prefixes/suffixes (ул., бул.,
|
|
100
|
+
* вх., ет., ап., etc.) to give Nominatim a better chance at a hit.
|
|
101
|
+
*/
|
|
102
|
+
export function cleanAddress(raw: string): string {
|
|
103
|
+
return raw
|
|
104
|
+
.replace(/^(ул|ul)\.?\s*/i, "")
|
|
105
|
+
.replace(/^(бул|bul)\.?\s*/i, "")
|
|
106
|
+
.replace(/^(пл|pl)\.?\s*/i, "")
|
|
107
|
+
.replace(/^(жк|zhk|jk)\.?\s*/i, "")
|
|
108
|
+
.replace(/^(ж\.к|zh\.k)\.?\s*/i, "")
|
|
109
|
+
.replace(/^(кв|kv)\.?\s*/i, "")
|
|
110
|
+
.replace(/^(с|s)\.?\s+/i, "")
|
|
111
|
+
.replace(/^(гр|gr)\.?\s*/i, "")
|
|
112
|
+
.replace(/[„""()]/g, "")
|
|
113
|
+
.replace(
|
|
114
|
+
/\s*[,;]?\s*(вх|vh|ет|et|fl|ап|ap|apt|офис|office|стая|room|секция|корпус|каб|entr|entrance)\.?\s*.*/i,
|
|
115
|
+
""
|
|
116
|
+
)
|
|
117
|
+
.replace(/\s*(бл|bl|block)\.?\s*(\d+)\s*.*/i, " $2")
|
|
118
|
+
.replace(/(\d+)\s*[А-Яа-яA-Za-z]$/, "$1")
|
|
119
|
+
.replace(/[,.\s]+$/, "")
|
|
120
|
+
.trim()
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* geocodeAddress — hit OpenStreetMap Nominatim for a Bulgarian address.
|
|
125
|
+
* Returns { lat, lng } or null when no hit / network error.
|
|
126
|
+
*/
|
|
127
|
+
export async function geocodeAddress(
|
|
128
|
+
city: string,
|
|
129
|
+
address: string
|
|
130
|
+
): Promise<{ lat: number; lng: number } | null> {
|
|
131
|
+
try {
|
|
132
|
+
const cleaned = cleanAddress(address)
|
|
133
|
+
const numMatch = cleaned.match(/^(.+?)\s+(\d+\S*)$/)
|
|
134
|
+
const street = numMatch ? `${numMatch[2]} ${numMatch[1]}` : cleaned
|
|
135
|
+
|
|
136
|
+
const url = new URL("https://nominatim.openstreetmap.org/search")
|
|
137
|
+
url.searchParams.set("city", city)
|
|
138
|
+
url.searchParams.set("street", street)
|
|
139
|
+
url.searchParams.set("country", "Bulgaria")
|
|
140
|
+
url.searchParams.set("format", "json")
|
|
141
|
+
url.searchParams.set("limit", "1")
|
|
142
|
+
|
|
143
|
+
const res = await fetch(url.toString(), {
|
|
144
|
+
headers: { "User-Agent": "barter-storefront/1.0" },
|
|
145
|
+
})
|
|
146
|
+
const data = await res.json()
|
|
147
|
+
if (data?.[0]) {
|
|
148
|
+
return { lat: parseFloat(data[0].lat), lng: parseFloat(data[0].lon) }
|
|
149
|
+
}
|
|
150
|
+
return null
|
|
151
|
+
} catch {
|
|
152
|
+
return null
|
|
153
|
+
}
|
|
154
|
+
}
|