@colabcommerce/elements 0.0.4 → 0.9.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 (99) hide show
  1. package/.pnp.cjs +16484 -0
  2. package/.pnp.loader.mjs +2126 -0
  3. package/.yarn/install-state.gz +0 -0
  4. package/.yarn/releases/yarn-4.12.0.cjs +942 -0
  5. package/.yarnrc.yml +1 -0
  6. package/README.md +60 -41
  7. package/cypress/fixtures/example.json +5 -0
  8. package/cypress/support/commands.js +25 -0
  9. package/cypress/support/component-index.html +15 -0
  10. package/cypress/support/component.js +26 -0
  11. package/cypress.config.js +10 -0
  12. package/eslint.config.js +32 -0
  13. package/index.html +13 -0
  14. package/package.json +91 -67
  15. package/playground/index.html +14 -0
  16. package/playground/main.jsx +36 -0
  17. package/public/vite.svg +1 -0
  18. package/src/App.css +0 -0
  19. package/src/App.jsx +65 -0
  20. package/src/components/CollapsibleStoreHours/index.jsx +269 -0
  21. package/src/components/HoursList/index.jsx +225 -0
  22. package/src/components/LeadForm/index.jsx +241 -0
  23. package/src/components/MessageDialog/index.jsx +169 -0
  24. package/src/components/QuoteForm/index.jsx +82 -0
  25. package/src/components/QuoteFormSearch/index.jsx +276 -0
  26. package/src/components/QuoteFormStoreList/index.jsx +65 -0
  27. package/src/components/QuoteFormStoreListItem/index.jsx +134 -0
  28. package/src/components/QuoteLeadForm/index.jsx +16 -0
  29. package/src/components/QuoteMap/index.jsx +96 -0
  30. package/src/components/QuoteMapMarker/index.jsx +56 -0
  31. package/src/components/StaticMap/index.jsx +24 -0
  32. package/src/components/Store/index.jsx +44 -0
  33. package/src/components/StoreContact/index.jsx +96 -0
  34. package/src/components/StoreInfo/index.jsx +50 -0
  35. package/src/components/StoreList/index.jsx +59 -0
  36. package/src/components/StoreListItem/index.jsx +99 -0
  37. package/src/components/StoreListItem/indexStoreListItem.cy.jsx +30 -0
  38. package/src/components/StoreListNoneFound/index.jsx +16 -0
  39. package/src/components/StoreLocator/index.jsx +43 -0
  40. package/src/components/StoreLocatorMap/index.jsx +93 -0
  41. package/src/components/StoreLocatorMapMarker/index.jsx +55 -0
  42. package/src/components/StoreLocatorMessageDialog/index.jsx +20 -0
  43. package/src/components/StoreLocatorSearch/index.jsx +316 -0
  44. package/src/components/StoreMap/index.jsx +30 -0
  45. package/src/components/StoreMeta/index.jsx +7 -0
  46. package/src/components/StoreProducts/index.jsx +112 -0
  47. package/src/components/ui/Badge/index.jsx +46 -0
  48. package/src/components/ui/Button/index.jsx +56 -0
  49. package/src/components/ui/Button/indexButton.cy.jsx +9 -0
  50. package/src/components/ui/Card/index.jsx +90 -0
  51. package/src/components/ui/Input/index.jsx +19 -0
  52. package/src/components/ui/Input/indexInput.cy.jsx +9 -0
  53. package/src/components/ui/LoadingPuff/index.jsx +10 -0
  54. package/src/components/ui/Panel/index.jsx +23 -0
  55. package/src/components/ui/PhoneNumberInput/index.jsx +17 -0
  56. package/src/contexts/quote-form.jsx +94 -0
  57. package/src/contexts/store-locator.jsx +83 -0
  58. package/src/contexts/store.jsx +59 -0
  59. package/src/contexts/translations.jsx +11 -0
  60. package/src/dist.css +229 -0
  61. package/src/entries/QuoteForm.js +2 -0
  62. package/src/entries/Store.js +2 -0
  63. package/src/entries/StoreLocator.js +2 -0
  64. package/src/entries/StoreLocatorProvider.js +2 -0
  65. package/src/entries/styles.js +2 -0
  66. package/src/entries/useStoreLocator.js +2 -0
  67. package/src/i18n/defaultResources.js +19 -0
  68. package/src/i18n/index.js +44 -0
  69. package/src/i18n/mergeResources.js +22 -0
  70. package/src/index.css +214 -0
  71. package/src/lib/addressComponentsToAddress.js +43 -0
  72. package/src/lib/productSchema.js +6 -0
  73. package/src/lib/useGeolocation.js +266 -0
  74. package/src/lib/useHours.js +205 -0
  75. package/src/lib/usePlacesAutocomplete.js +288 -0
  76. package/src/lib/useProductAvailability.js +38 -0
  77. package/src/lib/useRudderAnalytics.js +50 -0
  78. package/src/lib/useSearchResults.js +102 -0
  79. package/src/lib/useStoreLocatorConfig.js +50 -0
  80. package/src/lib/utils/cn.js +6 -0
  81. package/src/lib/utils/measure.js +31 -0
  82. package/src/locales/en/default.json +58 -0
  83. package/src/locales/es/default.json +58 -0
  84. package/src/locales/fr/default.json +58 -0
  85. package/src/locales/it/default.json +58 -0
  86. package/src/main.jsx +10 -0
  87. package/vite.config.js +60 -53
  88. package/dist/CartForm.js +0 -617
  89. package/dist/Container-CU_WrBOi.js +0 -22
  90. package/dist/Modal-DTBKy_6d.js +0 -863
  91. package/dist/ProductForm.js +0 -343
  92. package/dist/Retailer.js +0 -3637
  93. package/dist/StoreLocator.js +0 -797
  94. package/dist/addressComponentsToAddress-DCL-K8mn.js +0 -1932
  95. package/dist/browser-ponyfill-DcK7_cJB.js +0 -339
  96. package/dist/globals-B8-hYoIU.js +0 -8518
  97. package/dist/index-CqSfhXDd.js +0 -137
  98. package/dist/index-FM02Uq_P.js +0 -100
  99. package/dist/style.css +0 -1
package/src/index.css ADDED
@@ -0,0 +1,214 @@
1
+ /* This works for build, not dev. */
2
+ /* .cc {
3
+ @import "tailwindcss";
4
+ @import "react-phone-number-input/style.css";
5
+ } */
6
+
7
+ /* This can't be part of build as it leaks to the global scope. */
8
+ @import "tailwindcss";
9
+ @import "react-phone-number-input/style.css";
10
+ /* end */
11
+
12
+ @import "tw-animate-css";
13
+
14
+ /* Keep this if you want to continue using `dark:` variants on descendants */
15
+ @custom-variant dark (&:is(.cc.dark *));
16
+
17
+ .cc {
18
+
19
+ --background: #fdfdfd;
20
+ --foreground: #000000;
21
+ --color-black: #000000;
22
+ --color-white: #ffffff;
23
+ --card: #fdfdfd;
24
+ --card-foreground: #000000;
25
+ --popover: #fcfcfc;
26
+ --popover-foreground: #000000;
27
+ --primary: #7033ff;
28
+ --primary-foreground: #ffffff;
29
+ --secondary: #edf0f4;
30
+ --secondary-foreground: #080808;
31
+ --muted: #f5f5f5;
32
+ --muted-foreground: #525252;
33
+ --accent: #e2ebff;
34
+ --accent-foreground: #1e69dc;
35
+ --destructive: #e54b4f;
36
+ --destructive-foreground: #ffffff;
37
+ --border: #e7e7ee;
38
+ --input: #ebebeb;
39
+ --ring: #000000;
40
+ --chart-1: #4ac885;
41
+ --chart-2: #7033ff;
42
+ --chart-3: #fd822b;
43
+ --chart-4: #3276e4;
44
+ --chart-5: #747474;
45
+ --radius: 1.4rem;
46
+ --sidebar: #f5f8fb;
47
+ --sidebar-foreground: #000000;
48
+ --sidebar-primary: #000000;
49
+ --sidebar-primary-foreground: #ffffff;
50
+ --sidebar-accent: #ebebeb;
51
+ --sidebar-accent-foreground: #000000;
52
+ --sidebar-border: #ebebeb;
53
+ --sidebar-ring: #000000;
54
+ --shadow-x: 0px;
55
+ --shadow-y: 2px;
56
+ --shadow-blur: 3px;
57
+ --shadow-spread: 0px;
58
+ --shadow-opacity: 0.2;
59
+ --shadow-color: #000000;
60
+
61
+ /* ===== Scrollbar CSS ===== */
62
+ /* Firefox */
63
+ * {
64
+ scrollbar-width: thin;
65
+ scrollbar-color: #858585 #ffffff;
66
+ }
67
+
68
+ /* Chrome, Edge, and Safari */
69
+ *::-webkit-scrollbar {
70
+ width: 8px;
71
+ }
72
+
73
+ *::-webkit-scrollbar-track {
74
+ background: #ffffff;
75
+ }
76
+
77
+ *::-webkit-scrollbar-thumb {
78
+ background-color: #858585;
79
+ border-radius: 4px;
80
+ border: 3px solid #ffffff;
81
+ }
82
+
83
+ /* Phone Input Override */
84
+ .PhoneInputCountry {
85
+ @apply absolute left-8 top-0 h-12 flex items-center gap-2 px-3 bg-transparent;
86
+ }
87
+ }
88
+
89
+ .cc.dark {
90
+ --background: #1a1b1e;
91
+ --foreground: #f0f0f0;
92
+ --card: #222327;
93
+ --card-foreground: #f0f0f0;
94
+ --popover: #222327;
95
+ --popover-foreground: #f0f0f0;
96
+ --primary: #8c5cff;
97
+ --primary-foreground: #ffffff;
98
+ --secondary: #2a2c33;
99
+ --secondary-foreground: #f0f0f0;
100
+ --muted: #2a2c33;
101
+ --muted-foreground: #a0a0a0;
102
+ --accent: #1e293b;
103
+ --accent-foreground: #79c0ff;
104
+ --destructive: #f87171;
105
+ --destructive-foreground: #ffffff;
106
+ --border: #33353a;
107
+ --input: #33353a;
108
+ --ring: #8c5cff;
109
+ --chart-1: #4ade80;
110
+ --chart-2: #8c5cff;
111
+ --chart-3: #fca5a5;
112
+ --chart-4: #5993f4;
113
+ --chart-5: #a0a0a0;
114
+ --sidebar: #161618;
115
+ --sidebar-foreground: #f0f0f0;
116
+ --sidebar-primary: #8c5cff;
117
+ --sidebar-primary-foreground: #ffffff;
118
+ --sidebar-accent: #2a2c33;
119
+ --sidebar-accent-foreground: #8c5cff;
120
+ --sidebar-border: #33353a;
121
+ --sidebar-ring: #8c5cff;
122
+ --shadow-x: 0px;
123
+ --shadow-y: 2px;
124
+ --shadow-blur: 3px;
125
+ --shadow-spread: 0px;
126
+ --shadow-opacity: 0.2;
127
+ --shadow-color: #000000;
128
+ }
129
+
130
+ .cc {
131
+ @theme inline {
132
+ /* core colors */
133
+ --color-background: var(--background);
134
+ --color-foreground: var(--foreground);
135
+
136
+ --color-card: var(--card);
137
+ --color-card-foreground: var(--card-foreground);
138
+
139
+ --color-popover: var(--popover);
140
+ --color-popover-foreground: var(--popover-foreground);
141
+
142
+ --color-primary: var(--primary);
143
+ --color-primary-foreground: var(--primary-foreground);
144
+
145
+ --color-secondary: var(--secondary);
146
+ --color-secondary-foreground: var(--secondary-foreground);
147
+
148
+ --color-muted: var(--muted);
149
+ --color-muted-foreground: var(--muted-foreground);
150
+
151
+ --color-accent: var(--accent);
152
+ --color-accent-foreground: var(--accent-foreground);
153
+
154
+ --color-destructive: var(--destructive);
155
+ --color-destructive-foreground: var(--destructive-foreground);
156
+
157
+ /* borders & inputs */
158
+ --color-border: var(--border);
159
+ --color-input: var(--input);
160
+ --color-ring: var(--ring);
161
+
162
+ /* charts */
163
+ --color-chart-1: var(--chart-1);
164
+ --color-chart-2: var(--chart-2);
165
+ --color-chart-3: var(--chart-3);
166
+ --color-chart-4: var(--chart-4);
167
+ --color-chart-5: var(--chart-5);
168
+
169
+ /* sidebar */
170
+ --color-sidebar: var(--sidebar);
171
+ --color-sidebar-foreground: var(--sidebar-foreground);
172
+ --color-sidebar-primary: var(--sidebar-primary);
173
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
174
+ --color-sidebar-accent: var(--sidebar-accent);
175
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
176
+ --color-sidebar-border: var(--sidebar-border);
177
+ --color-sidebar-ring: var(--sidebar-ring);
178
+
179
+ /* radius */
180
+ --radius-sm: calc(var(--radius) - 4px);
181
+ --radius-md: calc(var(--radius) - 2px);
182
+ --radius-lg: var(--radius);
183
+ --radius-xl: calc(var(--radius) + 4px);
184
+
185
+ /* shadows (optional but nice) */
186
+ --shadow-2xs: var(--shadow-x) var(--shadow-y) color-mix(in srgb, var(--shadow-color) calc(var(--shadow-opacity) * 100%), transparent);
187
+
188
+ --shadow-xs: var(--shadow-x) var(--shadow-y) var(--shadow-blur) var(--shadow-spread) color-mix(in srgb, var(--shadow-color) calc(var(--shadow-opacity) * 100%), transparent);
189
+
190
+ --shadow-sm: var(--shadow-x) var(--shadow-y) calc(var(--shadow-blur) * 1.5) var(--shadow-spread) color-mix(in srgb, var(--shadow-color) calc(var(--shadow-opacity) * 100%), transparent);
191
+
192
+ --shadow-md: calc(var(--shadow-x) * 2) calc(var(--shadow-y) * 2) calc(var(--shadow-blur) * 3) calc(var(--shadow-spread) - 1px) color-mix(in srgb, var(--shadow-color) calc(var(--shadow-opacity) * 100%), transparent);
193
+
194
+ --shadow-lg: calc(var(--shadow-x) * 4) calc(var(--shadow-y) * 4) calc(var(--shadow-blur) * 5) calc(var(--shadow-spread) - 3px) color-mix(in srgb, var(--shadow-color) calc(var(--shadow-opacity) * 100%), transparent);
195
+
196
+ --shadow-xl: calc(var(--shadow-x) * 6) calc(var(--shadow-y) * 6) calc(var(--shadow-blur) * 7) calc(var(--shadow-spread) - 5px) color-mix(in srgb, var(--shadow-color) calc(var(--shadow-opacity) * 100%), transparent);
197
+
198
+ --shadow-2xl: calc(var(--shadow-x) * 8) calc(var(--shadow-y) * 8) calc(max(0px, var(--shadow-blur) * 9 - 8px)) calc(var(--shadow-spread) - 12px) color-mix(in srgb, var(--shadow-color) calc(var(--shadow-opacity) * 250%), transparent);
199
+
200
+ --shadow: var(--shadow-md);
201
+ --color-shadow-color: var(--shadow-color);
202
+ }
203
+ }
204
+
205
+ /* Scope base styles to the wrapper (no global leakage) */
206
+ @layer base {
207
+ .cc {
208
+ @apply bg-background text-foreground;
209
+ }
210
+
211
+ .cc * {
212
+ @apply border-border outline-ring/50;
213
+ }
214
+ }
@@ -0,0 +1,43 @@
1
+ function addressComponentsToAddress(components) {
2
+ // Helper to extract a component by type
3
+ const get = (type) =>
4
+ components.find((c) => c.types.includes(type))?.long_name || "";
5
+
6
+ // Line one: street number + route
7
+ const streetNumber = get("street_number");
8
+ const route = get("route");
9
+ const addressLine1 = [streetNumber, route].filter(Boolean).join(" ");
10
+
11
+ // Line two: subpremise, suite, unit, etc.
12
+ const subpremise = get("subpremise");
13
+ const addressLine2 = subpremise ? `Unit ${subpremise}` : "";
14
+
15
+ // City (Google may return locality or postal_town depending on country)
16
+ const city = get("locality") || get("postal_town") || get("sublocality") || "";
17
+
18
+ // Province/state/region
19
+ const province = get("administrative_area_level_1");
20
+
21
+ // Postal/ZIP code
22
+ const postalCode = get("postal_code");
23
+
24
+ // Country
25
+ const country = get("country");
26
+
27
+ // Update the country to be the short name (ISO code) if available
28
+ const countryShort = components.find((c) => c.types.includes("country"))?.short_name;
29
+ const finalCountry = countryShort || country;
30
+
31
+ // Return the structured address
32
+
33
+ return {
34
+ addressLine1,
35
+ addressLine2,
36
+ city,
37
+ province,
38
+ postalCode,
39
+ country: finalCountry,
40
+ };
41
+ }
42
+
43
+ export default addressComponentsToAddress;
@@ -0,0 +1,6 @@
1
+ const productSchema = {
2
+ type: 'object',
3
+ properties: {
4
+ sku: { type: 'string' },
5
+ },
6
+ }
@@ -0,0 +1,266 @@
1
+ /*
2
+ * Copyright (c) 2025 Colab Commerce <https://colabcommerce.com>
3
+ * All rights reserved.
4
+ *
5
+ * This hook provides geolocation using the browser's Geolocation API. As well
6
+ * as an IP based fallback based on ipapi.co lookups
7
+ * Usage:
8
+ * const { location, source, isLoading, error, refresh, isGeolocationAvailable, isGeolocationEnabled } = useGeolocation();
9
+ */
10
+
11
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
12
+ import { useGeolocated } from 'react-geolocated'
13
+ import { setKey, fromLatLng } from 'react-geocode'
14
+ import addressComponentsToAddress from '@/lib/addressComponentsToAddress'
15
+
16
+ const IPAPI_URL = 'https://ipapi.co/json?key=kGu4vlFKun6lmweTaFlSP54Ypbq1yWmJlPIy0qCxC7shPBO1qQ'
17
+
18
+ function toError(value) {
19
+ if (value instanceof Error) return value
20
+ if (typeof value === 'string') return new Error(value)
21
+ try {
22
+ return new Error(JSON.stringify(value))
23
+ } catch {
24
+ return new Error('Unknown error')
25
+ }
26
+ }
27
+
28
+ function coordsToLocation(coords) {
29
+ if (!coords) return null
30
+
31
+ return {
32
+ latitude: coords.latitude,
33
+ longitude: coords.longitude,
34
+ accuracy: coords.accuracy,
35
+ altitude: coords.altitude ?? null,
36
+ altitudeAccuracy: coords.altitudeAccuracy ?? null,
37
+ heading: coords.heading ?? null,
38
+ speed: coords.speed ?? null,
39
+ city: null,
40
+ region: null,
41
+ country: null,
42
+ postal: null,
43
+ timezone: null,
44
+ ip: null,
45
+ raw: coords,
46
+ }
47
+ }
48
+
49
+ function ipapiToLocation(payload) {
50
+ if (!payload || typeof payload !== 'object') return null
51
+
52
+ const latitude = Number(payload.latitude)
53
+ const longitude = Number(payload.longitude)
54
+
55
+ if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return null
56
+
57
+ return {
58
+ latitude,
59
+ longitude,
60
+ city: payload.city ?? null,
61
+ region: payload.region ?? null,
62
+ country: payload.country_name ?? payload.country ?? null,
63
+ postal: payload.postal ?? null,
64
+ timezone: payload.timezone ?? null,
65
+ ip: payload.ip ?? null,
66
+ raw: payload,
67
+ }
68
+ }
69
+
70
+ export default function useGeolocation(options = {}) {
71
+ const {
72
+ userDecisionTimeout = 10_000,
73
+ autoGeoLocate = true,
74
+ positionOptions = { enableHighAccuracy: false, maximumAge: 0, timeout: 10_000 },
75
+ } = options
76
+
77
+ const {
78
+ coords,
79
+ isGeolocationAvailable,
80
+ isGeolocationEnabled,
81
+ positionError,
82
+ getPosition,
83
+ } = useGeolocated({
84
+ positionOptions,
85
+ userDecisionTimeout,
86
+ suppressLocationOnMount: true,
87
+ watchLocationPermissionChange: true,
88
+ })
89
+
90
+ const [location, setLocation] = useState(null)
91
+ const [source, setSource] = useState(null) // 'geolocation' | 'ipapi' | null
92
+ const [isLoading, setIsLoading] = useState(true)
93
+ const [error, setError] = useState(null)
94
+
95
+ const requestIdRef = useRef(0)
96
+ const ipapiAttemptedRequestIdRef = useRef(null)
97
+ const ipapiAbortRef = useRef(null)
98
+ const autoStartedRef = useRef(false)
99
+
100
+ const stopIpapi = useCallback(() => {
101
+ if (ipapiAbortRef.current) {
102
+ console.log('stopIpapi: aborting inflight ipapi request')
103
+ ipapiAbortRef.current.abort()
104
+ ipapiAbortRef.current = null
105
+ }
106
+ }, [])
107
+
108
+ const fetchIpapi = useCallback(async (requestId, reasonError = null) => {
109
+ // Important: only attempt the IP fallback once per refresh cycle.
110
+ // Otherwise, if `positionError` stays set and IP lookup fails,
111
+ // the fallback effect can re-trigger infinitely.
112
+ console.log('fetchIpapi called', { requestId, currentRequestId: requestIdRef.current, attemptedRequestId: ipapiAttemptedRequestIdRef.current })
113
+ if (ipapiAttemptedRequestIdRef.current === requestId) return
114
+ ipapiAttemptedRequestIdRef.current = requestId
115
+
116
+ //stopIpapi()
117
+ const controller = new AbortController()
118
+ ipapiAbortRef.current = controller
119
+
120
+ try {
121
+ const res = await fetch(IPAPI_URL, {
122
+ method: 'GET',
123
+ headers: { Accept: 'application/json' },
124
+ cache: 'default',
125
+ signal: controller.signal,
126
+ })
127
+
128
+ if (!res.ok) {
129
+ throw new Error(`ipapi request failed: ${res.status} ${res.statusText}`)
130
+ }
131
+
132
+ const payload = await res.json()
133
+ const ipLocation = ipapiToLocation(payload)
134
+ if (!ipLocation) {
135
+ throw new Error('ipapi response did not include a valid latitude/longitude')
136
+ }
137
+
138
+ //if (requestIdRef.current !== requestId) return
139
+ setLocation(ipLocation)
140
+ setSource('ipapi')
141
+ setIsLoading(false)
142
+ setError(null)
143
+ } catch (err) {
144
+ if (controller.signal.aborted) return
145
+ if (requestIdRef.current !== requestId) return
146
+
147
+ const normalized = toError(err)
148
+ setIsLoading(false)
149
+ //setSource(null)
150
+ //setLocation(null)
151
+ setError(reasonError ? toError(reasonError) : normalized)
152
+ } finally {
153
+ if (ipapiAbortRef.current === controller) {
154
+ ipapiAbortRef.current = null
155
+ }
156
+ }
157
+ }, [stopIpapi])
158
+
159
+ const refresh = useCallback(() => {
160
+ requestIdRef.current += 1
161
+ const requestId = requestIdRef.current
162
+
163
+ //stopIpapi()
164
+ setIsLoading(true)
165
+ setError(null)
166
+ setSource(null)
167
+ setLocation(null)
168
+
169
+ if (!isGeolocationAvailable) {
170
+ fetchIpapi(requestId)
171
+ return
172
+ }
173
+
174
+ // If browser-level location services are disabled, skip straight to IP fallback.
175
+ if (isGeolocationEnabled === false) {
176
+ fetchIpapi(requestId)
177
+ return
178
+ }
179
+
180
+ // If auto geolocation is disabled, skip straight to IP fallback.
181
+ if (!autoGeoLocate) {
182
+ fetchIpapi(requestId)
183
+ return
184
+ }
185
+
186
+ getPosition()
187
+ }, [fetchIpapi, getPosition, isGeolocationAvailable, isGeolocationEnabled, stopIpapi, autoGeoLocate])
188
+
189
+ // Cleanup inflight IP lookup on unmount.
190
+ useEffect(() => {
191
+ return () => stopIpapi()
192
+ }, [stopIpapi])
193
+
194
+ // Kick off the initial request once, when availability is known.
195
+ // (react-geolocated updates its booleans after mount; don't auto-refresh on every update)
196
+ useEffect(() => {
197
+ if (autoStartedRef.current) return
198
+ if (typeof isGeolocationAvailable !== 'boolean') return
199
+ autoStartedRef.current = true
200
+ refresh()
201
+ }, [refresh])
202
+
203
+ // If we get browser coords, prefer them (even if IP fallback already ran).
204
+ const browserLocation = useMemo(() => coordsToLocation(coords), [coords])
205
+
206
+ useEffect(() => {
207
+ if (!browserLocation) return
208
+ setLocation(browserLocation)
209
+ setSource('geolocation')
210
+ setIsLoading(false)
211
+ setError(null)
212
+ }, [browserLocation])
213
+
214
+ // If we get browserLocation and the city is null, we try to augment it with geocoding.
215
+ useEffect(() => {
216
+ async function augmentWithGeocode() {
217
+ if (!browserLocation) return
218
+ if (browserLocation.city) return
219
+
220
+ try {
221
+
222
+ setKey('AIzaSyAnJmWEU1r63DiRWHkjczxzHyIEq3dhj4M')
223
+ const geocodeResults = await fromLatLng(browserLocation.latitude, browserLocation.longitude)
224
+ if (!geocodeResults.results || geocodeResults.results.length === 0) return
225
+
226
+ const firstResult = addressComponentsToAddress(geocodeResults?.results[0]?.address_components)
227
+ const augmented = {
228
+ ...browserLocation,
229
+ city: firstResult?.city ?? null,
230
+ region: firstResult.province ?? null,
231
+ country: firstResult.country ?? null,
232
+ postal: firstResult.postalCode ?? null,
233
+ }
234
+ setLocation(augmented)
235
+ } catch {
236
+ // Ignore geocoding errors.
237
+ }
238
+ }
239
+
240
+ augmentWithGeocode()
241
+ }, [browserLocation])
242
+
243
+ const requestId = requestIdRef.current
244
+ fetchIpapi(requestId)
245
+
246
+ // If browser geolocation errors (blocked/denied/unavailable/timeout), fall back to IP.
247
+ useEffect(() => {
248
+ if (!positionError) return
249
+ if (source === 'geolocation') return
250
+ if (location) return
251
+
252
+ const requestId = requestIdRef.current
253
+ fetchIpapi(requestId, positionError)
254
+ }, [fetchIpapi, location, positionError, source])
255
+
256
+ return {
257
+ location,
258
+ source,
259
+ isLoading,
260
+ error,
261
+ refresh,
262
+ isGeolocationAvailable,
263
+ isGeolocationEnabled,
264
+ }
265
+ }
266
+