@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
@@ -0,0 +1,276 @@
1
+ "use client"
2
+
3
+ import { useEffect, useId, useMemo, useRef, useState } from "react"
4
+ import { Search, MapPin, Sliders } from "lucide-react"
5
+ import * as Popover from "@radix-ui/react-popover"
6
+ import * as ScrollArea from "@radix-ui/react-scroll-area"
7
+
8
+ import { Button } from "@/components/ui/Button"
9
+ import { useQuoteForm } from "@/contexts/quote-form"
10
+ import { usePlacesAutocomplete } from "@/lib/usePlacesAutocomplete"
11
+ import Input from "@/components/ui/Input"
12
+ import { useTranslation } from "react-i18next"
13
+
14
+ function getItemLabel(item) {
15
+ // Support a few common shapes:
16
+ // - Google Places autocomplete style: { placePrediction: { text: { text } } }
17
+ // - Simple: { label }, { description }, { text }
18
+ return (
19
+ item?.label ??
20
+ item?.description ??
21
+ item?.text ??
22
+ item?.placePrediction?.text?.text ??
23
+ item?.placePrediction?.structuredFormat?.mainText?.text ??
24
+ ""
25
+ )
26
+ }
27
+
28
+ function getItemSecondary(item) {
29
+ return (
30
+ item?.secondary ??
31
+ item?.secondary_text ??
32
+ item?.placePrediction?.structuredFormat?.secondaryText?.text ??
33
+ item?.placePrediction?.structuredFormat?.secondaryText ??
34
+ ""
35
+ )
36
+ }
37
+
38
+ function getItemId(item, fallback) {
39
+ return (
40
+ item?.id ??
41
+ item?.place_id ??
42
+ item?.placePrediction?.placeId ??
43
+ fallback
44
+ )
45
+ }
46
+
47
+ export function QuoteFormSearch() {
48
+
49
+ const { t } = useTranslation()
50
+ const [searchQuery, setSearchQuery] = useState("")
51
+ const [showFilters, setShowFilters] = useState(false)
52
+
53
+ // Radix dropdown state
54
+ const [open, setOpen] = useState(false)
55
+ const [activeIndex, setActiveIndex] = useState(-1)
56
+
57
+ const { location, searchRadius, setSearchRadius, setSearchLocation, setUseLocationType, geoLocation } = useQuoteForm()
58
+
59
+ const { results, isLoading, error } = usePlacesAutocomplete({
60
+ query: searchQuery,
61
+ apiKey: "AIzaSyAnJmWEU1r63DiRWHkjczxzHyIEq3dhj4M",
62
+ center: { lat: 37.7937, lng: -122.3965 },
63
+ radiusMeters: 10000,
64
+ types: ["locality", "postal_code"],
65
+ })
66
+
67
+ const items = useMemo(() => Array.isArray(results) ? results : [], [results])
68
+
69
+ const inputRef = useRef(null)
70
+ const listId = useId()
71
+ const activeId = activeIndex >= 0 ? `${listId}-opt-${activeIndex}` : undefined
72
+
73
+ // When results change while open, reset the highlighted row
74
+ useEffect(() => {
75
+ if (!open) return
76
+ if (isLoading) return
77
+ setActiveIndex(items.length ? 0 : -1)
78
+ }, [open, isLoading, items.length])
79
+
80
+ const commitSelection = (item) => {
81
+ const label = getItemLabel(item)
82
+ if (!label) return
83
+
84
+ setSearchQuery(label)
85
+ setOpen(false)
86
+
87
+ // Keep focus like Google
88
+ requestAnimationFrame(() => inputRef.current?.focus())
89
+
90
+ const locationObject = {
91
+ "latitude": item.location?.lat ?? null,
92
+ "longitude": item.location?.lng ?? null,
93
+ "accuracy": null,
94
+ "altitude": 0,
95
+ "altitudeAccuracy": null,
96
+ "heading": null,
97
+ "speed": null,
98
+ "city": item.city ?? null,
99
+ "region": item.region ?? null,
100
+ "country": item.country ?? null,
101
+ "postal": item.postalCode ?? null,
102
+ "timezone": null,
103
+ "ip": null,
104
+ "type": "search",
105
+ "raw": item
106
+ }
107
+
108
+ setSearchLocation(locationObject)
109
+ setUseLocationType('search')
110
+
111
+ // If your store locator has a method to actually run the search / set coords,
112
+ // this is where you’d call it, e.g.:
113
+ // setSelectedPlace(item)
114
+ // runSearch({ placeId: ..., radius: searchRadius })
115
+ }
116
+
117
+ const onKeyDown = (e) => {
118
+ // open dropdown with arrows
119
+ if (!open && (e.key === "ArrowDown" || e.key === "ArrowUp")) {
120
+ setOpen(true)
121
+ return
122
+ }
123
+ if (!open) return
124
+
125
+ switch (e.key) {
126
+ case "ArrowDown": {
127
+ e.preventDefault()
128
+ setActiveIndex((i) => Math.min(i + 1, items.length - 1))
129
+ break
130
+ }
131
+ case "ArrowUp": {
132
+ e.preventDefault()
133
+ setActiveIndex((i) => Math.max(i - 1, 0))
134
+ break
135
+ }
136
+ case "Enter": {
137
+ if (activeIndex >= 0 && items[activeIndex]) {
138
+ e.preventDefault()
139
+ commitSelection(items[activeIndex])
140
+ }
141
+ break
142
+ }
143
+ case "Escape": {
144
+ e.preventDefault()
145
+ setOpen(false)
146
+ break
147
+ }
148
+ default:
149
+ break
150
+ }
151
+ }
152
+
153
+ const handleUseCurrentLocation = () => {
154
+ setUseLocationType('geolocation')
155
+ setOpen(false)
156
+ setSearchQuery("")
157
+ }
158
+
159
+ const showEmpty =
160
+ open &&
161
+ !isLoading &&
162
+ !error &&
163
+ searchQuery.trim().length > 0 &&
164
+ items.length === 0
165
+
166
+ return (
167
+ <div className="max-w-3xl mx-auto">
168
+ <div className="rounded-xl p-6">
169
+ <div className="flex flex-row gap-4">
170
+ <Popover.Root open={open} onOpenChange={setOpen}>
171
+ <Popover.Anchor asChild>
172
+ <div className="flex-1 relative">
173
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-muted-foreground" />
174
+ <Input
175
+ ref={inputRef}
176
+ type="text"
177
+ placeholder={location?.city ? t('default:location_search_placeholder_w_location', { location: location.city }) : t('default:location_search_placeholder')}
178
+ value={searchQuery}
179
+ onChange={(e) => {
180
+ setSearchQuery(e.target.value)
181
+ setOpen(true)
182
+ }}
183
+ onFocus={() => setOpen(true)}
184
+ onKeyDown={onKeyDown}
185
+ className="pl-10 h-12"
186
+ role="combobox"
187
+ aria-autocomplete="list"
188
+ aria-expanded={open}
189
+ aria-controls={listId}
190
+ aria-activedescendant={activeId}
191
+ />
192
+ </div>
193
+ </Popover.Anchor>
194
+
195
+ <Popover.Content
196
+ sideOffset={6}
197
+ align="start"
198
+ onOpenAutoFocus={(e) => e.preventDefault()} // keep focus in input
199
+ className="w-[var(--radix-popover-trigger-width)] max-w-[560px] rounded-xl border rounded bg-background shadow-lg overflow-hidden"
200
+ >
201
+ <ScrollArea.Root className="max-h-80">
202
+ <ScrollArea.Viewport>
203
+ <div id={listId} role="listbox" className="p-2">
204
+ {error ? (
205
+ <div className="px-3 py-2 text-sm rounded-md border border-destructive/30 bg-destructive/10 text-destructive">
206
+ {String(error)}
207
+ </div>
208
+ ) : null}
209
+
210
+ {isLoading ? (
211
+ <div className="px-3 py-2 text-sm text-muted-foreground">
212
+ {t('default:loading')}
213
+ </div>
214
+ ) : null}
215
+
216
+ {showEmpty ? (
217
+ <div className="px-3 py-2 text-sm text-muted-foreground">
218
+ {t('default:no_results')}
219
+ </div>
220
+ ) : null}
221
+
222
+ {!isLoading && !error && items.map((item, idx) => {
223
+ const active = idx === activeIndex
224
+ const label = getItemLabel(item)
225
+ const secondary = getItemSecondary(item)
226
+ const id = getItemId(item, `${idx}`)
227
+
228
+ if (!label) return null
229
+
230
+ return (
231
+ <div
232
+ key={id}
233
+ id={`${listId}-opt-${idx}`}
234
+ role="option"
235
+ aria-selected={active}
236
+ onMouseEnter={() => setActiveIndex(idx)}
237
+ onMouseDown={(e) => e.preventDefault()} // keep focus
238
+ onClick={() => commitSelection(item)}
239
+ className={[
240
+ "cursor-pointer rounded-lg px-3 py-2",
241
+ active ? "bg-muted" : "hover:bg-muted/60",
242
+ ].join(" ")}
243
+ >
244
+ <div className="text-sm text-left font-medium leading-tight">
245
+ {label}
246
+ </div>
247
+ {secondary ? (
248
+ <div className="text-xs text-left text-muted-foreground mt-1 leading-tight">
249
+ {secondary}
250
+ </div>
251
+ ) : null}
252
+ </div>
253
+ )
254
+ })}
255
+ </div>
256
+ </ScrollArea.Viewport>
257
+
258
+ <ScrollArea.Scrollbar orientation="vertical" className="flex select-none touch-none p-0.5 bg-transparent">
259
+ <ScrollArea.Thumb className="flex-1 bg-border rounded-full" />
260
+ </ScrollArea.Scrollbar>
261
+ </ScrollArea.Root>
262
+ </Popover.Content>
263
+ </Popover.Root>
264
+ </div>
265
+ <div className="mt-2 flex items-center justify-center gap-2 text-sm text-muted-foreground">
266
+ <MapPin className="w-4 h-4" />
267
+ <button className="text-primary hover:underline font-medium" type="button" onClick={handleUseCurrentLocation}>
268
+ {t('default:use_my_location')}
269
+ </button>
270
+ </div>
271
+ </div>
272
+ </div>
273
+ )
274
+ }
275
+
276
+ export default QuoteFormSearch
@@ -0,0 +1,65 @@
1
+ import { useState } from "react"
2
+ import { MapPin, Phone, Clock, Navigation, Star } from "lucide-react"
3
+ import { Button } from "@/components/ui/button"
4
+ import StoreListItem from '@/components/QuoteFormStoreListItem'
5
+ import StoreListNoneFound from "@/components/StoreListNoneFound"
6
+ import LoadingPuff from "@/components/ui/LoadingPuff"
7
+ import { distanceBetweenPoints } from '@/lib/utils/measure'
8
+ import { useQuoteForm } from '@/contexts/quote-form'
9
+
10
+ export function QuoteFormStoreList() {
11
+
12
+ const { stores,
13
+ location,
14
+ products,
15
+ setStepIndex,
16
+ selectedStoreId, setSelectedStoreId, setMessageDialogOpen, setMessageStoreId, searchIsLoading, searchError, baseUrl, focusedStoreId, setFocusedStoreId } = useQuoteForm()
17
+
18
+ const handleSelect = (storeId) => {
19
+ setSelectedStoreId(storeId)
20
+ setStepIndex(1)
21
+ }
22
+
23
+ return (
24
+ <div className="space-y-4">
25
+ {!searchIsLoading && !searchError && stores.length === 0 && (
26
+ <StoreListNoneFound />
27
+ )}
28
+ {searchIsLoading && (
29
+ <div className="flex items-center justify-center h-48">
30
+ <LoadingPuff />
31
+ </div>
32
+ )}
33
+ {!searchIsLoading && !searchError && stores.map((store) => (
34
+ <StoreListItem
35
+ key={store.id}
36
+ id={store.id}
37
+ name={store.retailer_name}
38
+ placeId={store.place_id}
39
+ addressLineOne={store.address?.street_line_one}
40
+ addressLineTwo={store.address?.street_line_two}
41
+ city={store.address?.city}
42
+ province={store.address?.province}
43
+ postalCode={store.address?.postal_code}
44
+ country={store.address?.country}
45
+ distance={distanceBetweenPoints(location.latitude, location.longitude, store.address?.latitude, store.address?.longitude)}
46
+ phone={store.phone_number}
47
+ hours={store.retailer_location_hours}
48
+ services={[store.store_type]}
49
+ isFocused={focusedStoreId === store.id}
50
+ handleFocus={setFocusedStoreId}
51
+ href={`${baseUrl}/${store.id}`}
52
+ handleSelect={handleSelect}
53
+ locationProducts={store.retailer_location_products}
54
+ requestedProducts={products}
55
+ />
56
+ ))}
57
+ {!searchIsLoading && searchError && (
58
+ <div className="text-red-600 text-center">
59
+ An error occurred while searching for stores. Please try again.
60
+ </div>
61
+ )}
62
+ </div>
63
+ )
64
+ }
65
+ export default QuoteFormStoreList
@@ -0,0 +1,134 @@
1
+ import { MapPin, Phone, Clock, Check, CheckCheck, CircleAlert, ChevronDown, Info } from "lucide-react"
2
+ import { Button } from "@/components/ui/button"
3
+ import { Badge } from "@/components/ui/badge"
4
+ import * as Collapsible from "@radix-ui/react-collapsible";
5
+ import { metersToMiles } from "@/lib/utils/measure"
6
+ import CollapsibleStoreHours from "@/components/CollapsibleStoreHours"
7
+ import { useTranslation } from "react-i18next"
8
+ import useProductAvailability from "@/lib/useProductAvailability";
9
+
10
+ const StoreListItem = ({
11
+ id,
12
+ name,
13
+ placeId,
14
+ addressLineOne,
15
+ addressLineTwo,
16
+ city,
17
+ province,
18
+ postalCode,
19
+ country,
20
+ phone,
21
+ hours,
22
+ distance,
23
+ services = [],
24
+ isFocused,
25
+ handleFocus,
26
+ handleSelect,
27
+ requestedProducts = [],
28
+ locationProducts = [],
29
+ }) => {
30
+ const { t } = useTranslation()
31
+
32
+ const directionsUrl = `https://www.google.com/maps/dir/?api=1&destination=${name}&destination_place_id=${placeId}`
33
+
34
+ const { availability, allAvailable, allStocked, noneStocked, noneAvailable, availabilitySummary } = useProductAvailability(locationProducts, requestedProducts)
35
+
36
+ const handleStoreSelected = (e) => {
37
+ e.stopPropagation()
38
+ handleSelect(id)
39
+ }
40
+
41
+ return (
42
+ <div
43
+ className={`bg-card border rounded-xl p-6 transition-all hover:shadow-lg cursor-pointer ${isFocused ? "ring-2 ring-primary shadow-lg" : ""
44
+ }`}
45
+ onClick={() => handleFocus(id)}
46
+ >
47
+ <div className="flex flex-col md:flex-row md:items-start md:justify-between gap-4">
48
+ <div className="flex-1">
49
+ <div className="flex items-start justify-between gap-4 mb-3">
50
+ <div>
51
+ <h3 className="font-bold text-lg text-left mb-1">{name}</h3>
52
+ </div>
53
+ </div>
54
+
55
+ <div className="space-y-2 text-sm">
56
+ <div className="flex items-start gap-2">
57
+ <MapPin className="w-4 h-4 text-muted-foreground mt-0.5 shrink-0" />
58
+ <div>
59
+ <p className="font-medium text-left">{addressLineOne}</p>
60
+ <p className="text-muted-foreground text-left">{city}, {province} {postalCode}</p>
61
+ <p className="text-primary font-medium mt-1 text-left">{t('default:distance_away', { distance: metersToMiles(distance).toFixed(1), distanceUnit: 'miles' })}</p>
62
+ </div>
63
+ </div>
64
+
65
+ <div className="flex items-top gap-2">
66
+ <div className="mt-2.5">
67
+ <Clock className="w-4 h-4 text-muted-foreground" />
68
+ </div>
69
+ <span className="text-muted-foreground"><CollapsibleStoreHours hours={hours} /></span>
70
+ </div>
71
+ </div>
72
+
73
+ {availabilitySummary === 'allStocked' &&
74
+ <div className="flex items-center shrink mt-3 px-3 py-1 bg-green-50 text-green-600 text-xs font-medium rounded-full">
75
+ <CheckCheck className="w-4 h-4 text-green-600 me-1" />
76
+ All Items Stocked
77
+ </div>
78
+ }
79
+
80
+ {availabilitySummary === 'allAvailable' &&
81
+ <div
82
+ className="flex items-center shrink mt-3 px-3 py-1 bg-orange-50 text-orange-600 text-xs font-medium rounded-full"
83
+ >
84
+ <Info className="w-4 me-1 h-4 text-orange-600" />
85
+ Available by Special Order
86
+ </div>
87
+ }
88
+
89
+ {availabilitySummary === 'mixed' &&
90
+ <div >
91
+ <div className="flex items-center gap-2 mt-4 text-sm">
92
+ <span className="font-bold w-28 text-wrap">Braxton</span>
93
+ <span
94
+ className="flex items-center px-3 py-1 bg-orange-50 text-orange-600 text-xs font-medium rounded-full"
95
+ >
96
+ <Info className="w-4 me-1 h-4 text-orange-600" />
97
+ Special Order
98
+ </span>
99
+ </div>
100
+ <div className="flex items-center gap-2 mt-4 text-sm">
101
+ <span className="font-bold w-28 text-wrap">Juno</span>
102
+ <span
103
+ className="flex items-center px-3 py-1 bg-green-50 text-green-600 text-xs font-medium rounded-full"
104
+ >
105
+ <CheckCheck className="w-4 me-1 h-4 text-green-600" />
106
+ Stocked
107
+ </span>
108
+ </div>
109
+ </div>
110
+ }
111
+
112
+ <div className="flex flex-wrap gap-2 mt-4">
113
+ {services.map((service) => (
114
+ <span
115
+ key={service}
116
+ className="px-3 py-1 bg-primary/10 text-primary text-xs font-medium rounded-full"
117
+ >
118
+ {service}
119
+ </span>
120
+ ))}
121
+ </div>
122
+ </div>
123
+
124
+ <div className="flex md:flex-col gap-2">
125
+ <Button onClick={handleStoreSelected} variant="default" className="whitespace-nowrap">
126
+ {t('default:select_store')}
127
+ </Button>
128
+ </div>
129
+ </div>
130
+ </div>
131
+ )
132
+ }
133
+
134
+ export default StoreListItem
@@ -0,0 +1,16 @@
1
+ import LeadForm from '@/components/LeadForm'
2
+ import { useQuoteForm } from '@/contexts/quote-form'
3
+
4
+ const QuoteLeadForm = ({ onClose, onSuccess }) => {
5
+
6
+ const { organizationId, location, selectedStoreId, products } = useQuoteForm()
7
+
8
+ const handleSuccess = (formValues, lead) => {
9
+ onSuccess && onSuccess({ formValues, lead })
10
+ }
11
+ return (
12
+ <LeadForm onClose={onClose} products={products} activityType="LeadActivity::QuoteRequest" onSuccess={handleSuccess} location={location} organizationId={organizationId} selectedLocationId={selectedStoreId} />
13
+ )
14
+ }
15
+
16
+ export default QuoteLeadForm
@@ -0,0 +1,96 @@
1
+ // The import part of the map is that you need to let the map managed its own center
2
+ // and zoom state internally. Using external state management (like React state) can lead to
3
+ // performance issues and a suboptimal user experience.
4
+ import { useEffect, useState, useRef } from 'react'
5
+ import { Map, useMap } from '@vis.gl/react-google-maps'
6
+ import QuoteMapMarker from '@/components/QuoteMapMarker'
7
+ import { useQuoteForm } from '@/contexts/quote-form'
8
+
9
+ const QuoteMap = () => {
10
+
11
+ const { location, stores, focusedStoreId, setFocusedStoreId, setSelectedStoreId, setStepIndex } = useQuoteForm()
12
+ const map = useMap()
13
+
14
+ const zoomIn = () => {
15
+ if (!map) return;
16
+ const currentZoom = map.getZoom();
17
+ map.setZoom(currentZoom + 1);
18
+ }
19
+
20
+ const zoomOut = () => {
21
+ if (!map) return;
22
+ const currentZoom = map.getZoom();
23
+ map.setZoom(currentZoom - 1);
24
+ }
25
+
26
+ useEffect(() => {
27
+ if (!map) return;
28
+ if (location?.latitude && location?.longitude) {
29
+ map.panTo({ lat: location.latitude, lng: location.longitude })
30
+ map.setZoom(9)
31
+ }
32
+ }, [location, map]);
33
+
34
+ useEffect(() => {
35
+ if (!map || !focusedStoreId) return;
36
+ const focusedStore = stores.find(store => store.id === focusedStoreId)
37
+ if (focusedStore && focusedStore.address) {
38
+ map.panTo({ lat: focusedStore.address.latitude * 1, lng: focusedStore.address.longitude * 1 })
39
+ }
40
+ }, [focusedStoreId, stores, map]);
41
+
42
+ const handleMarkerClick = (storeId) => {
43
+ setFocusedStoreId(storeId)
44
+ }
45
+
46
+ const handleMarkerSelect = (storeId) => {
47
+ setSelectedStoreId(storeId)
48
+ setStepIndex(1)
49
+ }
50
+
51
+ return (
52
+ <div className="relative">
53
+ <div className="bg-muted rounded-xl overflow-hidden border shadow-md h-[300px]">
54
+ {/* Map placeholder - Replace with actual map integration (Google Maps, Mapbox, etc.) */}
55
+ <div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-primary/5 to-muted">
56
+ <div className="text-center w-full">
57
+
58
+ <Map
59
+ style={{ width: '100%', height: '300px' }}
60
+ defaultCenter={{ lat: 22.54992, lng: 0 }}
61
+ defaultZoom={3}
62
+ gestureHandling='greedy'
63
+ disableDefaultUI
64
+ mapId="b1e4f5f5f8c7e2d9"
65
+ >
66
+ {stores.map((store) => (
67
+ <QuoteMapMarker key={store.id} store={store} isSelected={focusedStoreId === store.id} onClick={handleMarkerClick} onSelect={handleMarkerSelect} />
68
+ ))}
69
+ </Map>
70
+ </div>
71
+ </div>
72
+
73
+ {/* Map controls overlay */}
74
+ <div className="absolute top-4 right-4 flex flex-col gap-2">
75
+ <button className="bg-card border shadow-md rounded-lg p-2 hover:bg-accent transition-colors" onClick={zoomIn}>
76
+ <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
77
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
78
+ </svg>
79
+ </button>
80
+ <button className="bg-card border shadow-md rounded-lg p-2 hover:bg-accent transition-colors" onClick={zoomOut}>
81
+ <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
82
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 12H4" />
83
+ </svg>
84
+ </button>
85
+ </div>
86
+
87
+ {/* Results count badge */}
88
+ <div className="absolute bottom-4 left-1/2 -translate-x-1/2 bg-card border shadow-lg rounded-full px-4 py-2 text-sm font-medium">
89
+ <span className="text-primary">{stores.length}</span> stores found
90
+ </div>
91
+ </div>
92
+ </div>
93
+ )
94
+ }
95
+
96
+ export default QuoteMap
@@ -0,0 +1,56 @@
1
+ import { useState, useCallback } from 'react'
2
+ import { MapPin, Navigation } from 'lucide-react'
3
+ import { AdvancedMarker, InfoWindow, useAdvancedMarkerRef } from '@vis.gl/react-google-maps'
4
+ import { Button } from "@/components/ui/button"
5
+ import { info } from 'autoprefixer'
6
+
7
+ const StoreLocatorMapMarker = ({ store, isSelected, onClick, onSelect }) => {
8
+
9
+ const [infoWindowShown, setInfoWindowShown] = useState(false)
10
+ const [markerRef, marker] = useAdvancedMarkerRef()
11
+
12
+ const handleMarkerClick = useCallback(() => {
13
+ onClick && onClick(store.id)
14
+ }, [store.id, onClick])
15
+
16
+ const handleClose = useCallback(() => setInfoWindowShown(false), [])
17
+
18
+ const showInfoWindow = isSelected
19
+
20
+ const handleSelectClick = (e) => {
21
+ e.stopPropagation()
22
+ onSelect && onSelect(store.id)
23
+ }
24
+
25
+ return (
26
+ <AdvancedMarker
27
+ position={{
28
+ lat: store.address?.latitude * 1,
29
+ lng: store.address?.longitude * 1,
30
+ }}
31
+ ref={markerRef}
32
+ title={store.name}
33
+ onClick={handleMarkerClick}
34
+ >
35
+ <MapPin className={`w-8 h-8 hover:cursor-pointer ${isSelected ? 'text-primary fill-white z-10' : 'z-1 fill-white text-muted-foreground'}`} size={32} />
36
+ {showInfoWindow && (
37
+ <InfoWindow anchor={marker} headerContent={<h3 className="font-bold text-sm align-center text-left mb-2">{store.retailer_name}</h3>} onClose={handleClose}>
38
+ <p className="text-xs text-left text-muted-foreground">
39
+ {store.address?.street_line_one}
40
+ {store.address?.street_line_two ? `, ${store.address?.street_line_two}` : ''}
41
+ </p>
42
+ <p className="text-xs text-left text-muted-foreground mb-2">
43
+ {store.address?.city}, {store.address?.province} {store.address?.postal_code}
44
+ </p>
45
+ <div className="flex flex-row gap-2 mb-1">
46
+ <Button className="flex-1 md:flex-row" size="sm" onClick={handleSelectClick}>
47
+ Select
48
+ </Button>
49
+ </div>
50
+ </InfoWindow>
51
+ )}
52
+ </AdvancedMarker>
53
+ )
54
+ }
55
+
56
+ export default StoreLocatorMapMarker
@@ -0,0 +1,24 @@
1
+ const StaticMap = ({ center, zoom, size = "400x600", markers }) => {
2
+
3
+ // https://maps.googleapis.com/maps/api/staticmap?center=Brooklyn+Bridge,New+York,NY&zoom=13&size=600x300&maptype=roadmap&markers=color:blue%7Clabel:S%7C40.702147,-74.015794&markers=color:green%7Clabel:G%7C40.711614,-74.012318&markers=color:red%7Clabel:C%7C40.718217,-73.998284&key=YOUR_API_KEY&signature=YOUR_SIGNATURE
4
+ const params = new URLSearchParams()
5
+ params.append('center', center)
6
+ params.append('zoom', zoom)
7
+ params.append('size', size)
8
+ params.append('maptype', 'roadmap')
9
+
10
+ markers.forEach(marker => {
11
+ const markerParam = `${marker.lat},${marker.lng}`
12
+ params.append('markers', markerParam)
13
+ })
14
+
15
+ // Note: Replace 'YOUR_API_KEY' with an actual Google Maps Static API key
16
+ params.append('key', 'AIzaSyAnJmWEU1r63DiRWHkjczxzHyIEq3dhj4M')
17
+
18
+ const src = `https://maps.googleapis.com/maps/api/staticmap?${params.toString()}`
19
+ return (
20
+ <img src={src} alt="Static Map" />
21
+ )
22
+ }
23
+
24
+ export default StaticMap