@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.
Files changed (176) hide show
  1. package/package.json +226 -0
  2. package/src/api/auth.ts +108 -0
  3. package/src/api/carts.ts +506 -0
  4. package/src/api/categories.ts +184 -0
  5. package/src/api/checkout.ts +440 -0
  6. package/src/api/collections.ts +130 -0
  7. package/src/api/consent.ts +75 -0
  8. package/src/api/content.ts +125 -0
  9. package/src/api/customers.ts +307 -0
  10. package/src/api/gift-cards.ts +112 -0
  11. package/src/api/http.ts +122 -0
  12. package/src/api/index.ts +29 -0
  13. package/src/api/integrations.ts +130 -0
  14. package/src/api/menus.ts +77 -0
  15. package/src/api/metaobjects.ts +136 -0
  16. package/src/api/orders.ts +290 -0
  17. package/src/api/products.ts +303 -0
  18. package/src/api/redirects.ts +37 -0
  19. package/src/api/regions.ts +141 -0
  20. package/src/api/reviews.ts +259 -0
  21. package/src/api/search.ts +133 -0
  22. package/src/api/types.ts +91 -0
  23. package/src/cart-drawer/cart-drawer.tsx +86 -0
  24. package/src/cart-drawer/context.tsx +569 -0
  25. package/src/cart-drawer/continue-shopping.tsx +28 -0
  26. package/src/cart-drawer/cross-sell-carousel.tsx +212 -0
  27. package/src/cart-drawer/cross-sell-sidebar.tsx +158 -0
  28. package/src/cart-drawer/empty.tsx +52 -0
  29. package/src/cart-drawer/free-gift.tsx +71 -0
  30. package/src/cart-drawer/gift-wrap.tsx +83 -0
  31. package/src/cart-drawer/header.tsx +52 -0
  32. package/src/cart-drawer/index.ts +69 -0
  33. package/src/cart-drawer/item/index.tsx +164 -0
  34. package/src/cart-drawer/item/quantity.tsx +100 -0
  35. package/src/cart-drawer/item/upsell.tsx +110 -0
  36. package/src/cart-drawer/item/variant.tsx +46 -0
  37. package/src/cart-drawer/labels-bg.ts +72 -0
  38. package/src/cart-drawer/labels.ts +119 -0
  39. package/src/cart-drawer/notes.tsx +131 -0
  40. package/src/cart-drawer/payment-badges.tsx +96 -0
  41. package/src/cart-drawer/promo-banner.tsx +43 -0
  42. package/src/cart-drawer/rewards-points.tsx +78 -0
  43. package/src/cart-drawer/sticky-footer.tsx +73 -0
  44. package/src/cart-drawer/summary-breakdown.tsx +196 -0
  45. package/src/cart-drawer/template.tsx +225 -0
  46. package/src/cart-drawer/tiered-progress.tsx +168 -0
  47. package/src/checkout/address-error-copy.ts +119 -0
  48. package/src/checkout/address-form.tsx +224 -0
  49. package/src/checkout/address-select.tsx +79 -0
  50. package/src/checkout/boxnow-locker-selector.tsx +410 -0
  51. package/src/checkout/checkout-client.tsx +222 -0
  52. package/src/checkout/company-details.tsx +94 -0
  53. package/src/checkout/compare-addresses.ts +40 -0
  54. package/src/checkout/context.tsx +76 -0
  55. package/src/checkout/discount-section.tsx +218 -0
  56. package/src/checkout/econt-office-selector.tsx +332 -0
  57. package/src/checkout/error-message.tsx +25 -0
  58. package/src/checkout/geocode.ts +154 -0
  59. package/src/checkout/gift-card-section.tsx +224 -0
  60. package/src/checkout/index.ts +74 -0
  61. package/src/checkout/labels-bg.ts +128 -0
  62. package/src/checkout/labels.ts +263 -0
  63. package/src/checkout/line-item-card.tsx +152 -0
  64. package/src/checkout/order-summary.tsx +524 -0
  65. package/src/checkout/payment-button.tsx +373 -0
  66. package/src/checkout/payment-error-copy.ts +269 -0
  67. package/src/checkout/payment-method-list.tsx +365 -0
  68. package/src/checkout/payment-wrapper.tsx +102 -0
  69. package/src/checkout/promotion-error-copy.ts +124 -0
  70. package/src/checkout/shipping-method-list.tsx +335 -0
  71. package/src/checkout/stripe-wrapper.tsx +165 -0
  72. package/src/checkout/use-checkout-orchestration.ts +1504 -0
  73. package/src/common/cart-button-client.tsx +39 -0
  74. package/src/common/cart-button.tsx +28 -0
  75. package/src/common/country-select.tsx +65 -0
  76. package/src/common/delete-button.tsx +66 -0
  77. package/src/common/index.ts +17 -0
  78. package/src/common/language-select.tsx +78 -0
  79. package/src/common/localized-link.tsx +45 -0
  80. package/src/common/skeleton.tsx +29 -0
  81. package/src/index.ts +12 -0
  82. package/src/lib/cart-helpers.ts +113 -0
  83. package/src/lib/dual-price.tsx +73 -0
  84. package/src/lib/get-percentage-diff.ts +5 -0
  85. package/src/lib/get-product-price.ts +133 -0
  86. package/src/lib/hooks/use-intersection.ts +30 -0
  87. package/src/lib/hooks/use-toggle-state.ts +25 -0
  88. package/src/lib/money.ts +73 -0
  89. package/src/lib/payment-constants.ts +66 -0
  90. package/src/lib/product.ts +22 -0
  91. package/src/lib/sort-products.ts +63 -0
  92. package/src/lib/store-api-error.ts +36 -0
  93. package/src/lib/utils.ts +16 -0
  94. package/src/order/context.tsx +32 -0
  95. package/src/order/index.ts +63 -0
  96. package/src/order/labels-bg.ts +39 -0
  97. package/src/order/labels.ts +79 -0
  98. package/src/order/order-address-card.tsx +47 -0
  99. package/src/order/order-completed-template.tsx +165 -0
  100. package/src/order/order-confirmation-header.tsx +65 -0
  101. package/src/order/order-delivery-card.tsx +258 -0
  102. package/src/order/order-help-section.tsx +47 -0
  103. package/src/order/order-item.tsx +201 -0
  104. package/src/order/order-items-list.tsx +52 -0
  105. package/src/order/order-payment-card.tsx +95 -0
  106. package/src/order/order-timeline.tsx +141 -0
  107. package/src/order/order-totals.tsx +245 -0
  108. package/src/primitives/field.tsx +125 -0
  109. package/src/primitives/select-field.tsx +77 -0
  110. package/src/primitives/ui/accordion.tsx +61 -0
  111. package/src/primitives/ui/button.tsx +68 -0
  112. package/src/primitives/ui/collapsible.tsx +16 -0
  113. package/src/primitives/ui/dialog.tsx +112 -0
  114. package/src/primitives/ui/input.tsx +30 -0
  115. package/src/primitives/ui/label.tsx +31 -0
  116. package/src/primitives/ui/popover.tsx +38 -0
  117. package/src/primitives/ui/select.tsx +163 -0
  118. package/src/primitives/ui/sheet.tsx +131 -0
  119. package/src/primitives/ui/tabs.tsx +62 -0
  120. package/src/products/context.tsx +34 -0
  121. package/src/products/image-gallery.tsx +43 -0
  122. package/src/products/index.ts +44 -0
  123. package/src/products/labels-bg.ts +35 -0
  124. package/src/products/labels.ts +57 -0
  125. package/src/products/mobile-actions.tsx +180 -0
  126. package/src/products/option-select.tsx +67 -0
  127. package/src/products/preview-price.tsx +36 -0
  128. package/src/products/product-actions-wrapper.tsx +58 -0
  129. package/src/products/product-actions.tsx +217 -0
  130. package/src/products/product-info.tsx +43 -0
  131. package/src/products/product-preview.tsx +49 -0
  132. package/src/products/product-price.tsx +69 -0
  133. package/src/products/product-tabs.tsx +169 -0
  134. package/src/products/product-template.tsx +114 -0
  135. package/src/products/purchase-options.tsx +130 -0
  136. package/src/products/related-products.tsx +86 -0
  137. package/src/products/thumbnail.tsx +71 -0
  138. package/src/products/variant-matching.ts +71 -0
  139. package/src/reviews-ui/helpers.ts +174 -0
  140. package/src/reviews-ui/index.ts +74 -0
  141. package/src/reviews-ui/labels-bg.ts +91 -0
  142. package/src/reviews-ui/labels.ts +199 -0
  143. package/src/reviews-ui/photo-upload.tsx +345 -0
  144. package/src/reviews-ui/review-list.tsx +249 -0
  145. package/src/reviews-ui/review-widget.tsx +224 -0
  146. package/src/reviews-ui/review-wizard.tsx +560 -0
  147. package/src/reviews-ui/star-badge.tsx +104 -0
  148. package/src/reviews-ui/wizard-state.ts +81 -0
  149. package/src/store/category-template.tsx +129 -0
  150. package/src/store/collection-template.tsx +139 -0
  151. package/src/store/index.ts +41 -0
  152. package/src/store/labels-bg.ts +22 -0
  153. package/src/store/labels.ts +52 -0
  154. package/src/store/paginated-products.tsx +116 -0
  155. package/src/store/pagination.tsx +103 -0
  156. package/src/store/search-params.ts +256 -0
  157. package/src/store/search-template.tsx +249 -0
  158. package/src/store/skeleton-product-grid.tsx +26 -0
  159. package/src/store/sort-select.tsx +81 -0
  160. package/src/store/store-template.tsx +65 -0
  161. package/src/tracking/attribution.ts +418 -0
  162. package/src/tracking/consent-banner.tsx +355 -0
  163. package/src/tracking/consent-init.tsx +44 -0
  164. package/src/tracking/consent.ts +243 -0
  165. package/src/tracking/fbq.ts +168 -0
  166. package/src/tracking/ga4.tsx +49 -0
  167. package/src/tracking/get-tracking-attribution.ts +224 -0
  168. package/src/tracking/get-tracking-config.ts +50 -0
  169. package/src/tracking/gtag.ts +200 -0
  170. package/src/tracking/index.ts +133 -0
  171. package/src/tracking/meta-pixel.tsx +166 -0
  172. package/src/tracking/rybbit-events.ts +242 -0
  173. package/src/tracking/rybbit.tsx +40 -0
  174. package/src/tracking/types.ts +185 -0
  175. package/src/tracking/use-engagement-time.ts +58 -0
  176. 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
+ }