@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.
- package/.pnp.cjs +16484 -0
- package/.pnp.loader.mjs +2126 -0
- package/.yarn/install-state.gz +0 -0
- package/.yarn/releases/yarn-4.12.0.cjs +942 -0
- package/.yarnrc.yml +1 -0
- package/README.md +60 -41
- package/cypress/fixtures/example.json +5 -0
- package/cypress/support/commands.js +25 -0
- package/cypress/support/component-index.html +15 -0
- package/cypress/support/component.js +26 -0
- package/cypress.config.js +10 -0
- package/eslint.config.js +32 -0
- package/index.html +13 -0
- package/package.json +91 -67
- package/playground/index.html +14 -0
- package/playground/main.jsx +36 -0
- package/public/vite.svg +1 -0
- package/src/App.css +0 -0
- package/src/App.jsx +65 -0
- package/src/components/CollapsibleStoreHours/index.jsx +269 -0
- package/src/components/HoursList/index.jsx +225 -0
- package/src/components/LeadForm/index.jsx +241 -0
- package/src/components/MessageDialog/index.jsx +169 -0
- package/src/components/QuoteForm/index.jsx +82 -0
- package/src/components/QuoteFormSearch/index.jsx +276 -0
- package/src/components/QuoteFormStoreList/index.jsx +65 -0
- package/src/components/QuoteFormStoreListItem/index.jsx +134 -0
- package/src/components/QuoteLeadForm/index.jsx +16 -0
- package/src/components/QuoteMap/index.jsx +96 -0
- package/src/components/QuoteMapMarker/index.jsx +56 -0
- package/src/components/StaticMap/index.jsx +24 -0
- package/src/components/Store/index.jsx +44 -0
- package/src/components/StoreContact/index.jsx +96 -0
- package/src/components/StoreInfo/index.jsx +50 -0
- package/src/components/StoreList/index.jsx +59 -0
- package/src/components/StoreListItem/index.jsx +99 -0
- package/src/components/StoreListItem/indexStoreListItem.cy.jsx +30 -0
- package/src/components/StoreListNoneFound/index.jsx +16 -0
- package/src/components/StoreLocator/index.jsx +43 -0
- package/src/components/StoreLocatorMap/index.jsx +93 -0
- package/src/components/StoreLocatorMapMarker/index.jsx +55 -0
- package/src/components/StoreLocatorMessageDialog/index.jsx +20 -0
- package/src/components/StoreLocatorSearch/index.jsx +316 -0
- package/src/components/StoreMap/index.jsx +30 -0
- package/src/components/StoreMeta/index.jsx +7 -0
- package/src/components/StoreProducts/index.jsx +112 -0
- package/src/components/ui/Badge/index.jsx +46 -0
- package/src/components/ui/Button/index.jsx +56 -0
- package/src/components/ui/Button/indexButton.cy.jsx +9 -0
- package/src/components/ui/Card/index.jsx +90 -0
- package/src/components/ui/Input/index.jsx +19 -0
- package/src/components/ui/Input/indexInput.cy.jsx +9 -0
- package/src/components/ui/LoadingPuff/index.jsx +10 -0
- package/src/components/ui/Panel/index.jsx +23 -0
- package/src/components/ui/PhoneNumberInput/index.jsx +17 -0
- package/src/contexts/quote-form.jsx +94 -0
- package/src/contexts/store-locator.jsx +83 -0
- package/src/contexts/store.jsx +59 -0
- package/src/contexts/translations.jsx +11 -0
- package/src/dist.css +229 -0
- package/src/entries/QuoteForm.js +2 -0
- package/src/entries/Store.js +2 -0
- package/src/entries/StoreLocator.js +2 -0
- package/src/entries/StoreLocatorProvider.js +2 -0
- package/src/entries/styles.js +2 -0
- package/src/entries/useStoreLocator.js +2 -0
- package/src/i18n/defaultResources.js +19 -0
- package/src/i18n/index.js +44 -0
- package/src/i18n/mergeResources.js +22 -0
- package/src/index.css +214 -0
- package/src/lib/addressComponentsToAddress.js +43 -0
- package/src/lib/productSchema.js +6 -0
- package/src/lib/useGeolocation.js +266 -0
- package/src/lib/useHours.js +205 -0
- package/src/lib/usePlacesAutocomplete.js +288 -0
- package/src/lib/useProductAvailability.js +38 -0
- package/src/lib/useRudderAnalytics.js +50 -0
- package/src/lib/useSearchResults.js +102 -0
- package/src/lib/useStoreLocatorConfig.js +50 -0
- package/src/lib/utils/cn.js +6 -0
- package/src/lib/utils/measure.js +31 -0
- package/src/locales/en/default.json +58 -0
- package/src/locales/es/default.json +58 -0
- package/src/locales/fr/default.json +58 -0
- package/src/locales/it/default.json +58 -0
- package/src/main.jsx +10 -0
- package/vite.config.js +60 -53
- package/dist/CartForm.js +0 -617
- package/dist/Container-CU_WrBOi.js +0 -22
- package/dist/Modal-DTBKy_6d.js +0 -863
- package/dist/ProductForm.js +0 -343
- package/dist/Retailer.js +0 -3637
- package/dist/StoreLocator.js +0 -797
- package/dist/addressComponentsToAddress-DCL-K8mn.js +0 -1932
- package/dist/browser-ponyfill-DcK7_cJB.js +0 -339
- package/dist/globals-B8-hYoIU.js +0 -8518
- package/dist/index-CqSfhXDd.js +0 -137
- package/dist/index-FM02Uq_P.js +0 -100
- package/dist/style.css +0 -1
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import StoreInfo from '@/components/StoreInfo'
|
|
2
|
+
import StoreContact from '@/components/StoreContact'
|
|
3
|
+
import StoreMap from '@/components/StoreMap'
|
|
4
|
+
import StoreProducts from '@/components/StoreProducts'
|
|
5
|
+
import { StoreProvider } from '@/contexts/store'
|
|
6
|
+
import TranslationProvider from '@/contexts/translations'
|
|
7
|
+
|
|
8
|
+
const Store = ({ storeId, locale = "en", initialData = null }) => {
|
|
9
|
+
return (
|
|
10
|
+
<TranslationProvider options={{ lng: locale }}>
|
|
11
|
+
<StoreProvider id={storeId} initialData={initialData}>
|
|
12
|
+
<div className="cc">
|
|
13
|
+
<main className="flex-1">
|
|
14
|
+
{/* Store Hero Section */}
|
|
15
|
+
<StoreInfo />
|
|
16
|
+
|
|
17
|
+
{/* Store Details Grid */}
|
|
18
|
+
<section className="py-12 lg:py-16 bg-muted/30">
|
|
19
|
+
<div className="container mx-auto px-4 sm:px-6 lg:px-8">
|
|
20
|
+
<div className="grid lg:grid-cols-3 gap-8">
|
|
21
|
+
<div className="lg:col-span-2">
|
|
22
|
+
<StoreContact />
|
|
23
|
+
</div>
|
|
24
|
+
<div>
|
|
25
|
+
<StoreMap />
|
|
26
|
+
</div>
|
|
27
|
+
</div>
|
|
28
|
+
</div>
|
|
29
|
+
</section>
|
|
30
|
+
|
|
31
|
+
{/* Products Section */}
|
|
32
|
+
<section className="py-12 lg:py-16">
|
|
33
|
+
<div className="container mx-auto px-4 sm:px-6 lg:px-8">
|
|
34
|
+
<StoreProducts />
|
|
35
|
+
</div>
|
|
36
|
+
</section>
|
|
37
|
+
</main>
|
|
38
|
+
</div>
|
|
39
|
+
</StoreProvider>
|
|
40
|
+
</TranslationProvider>
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export default Store
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { Phone, Mail, MapPin, Clock, Calendar, Globe, MessageSquare } from "lucide-react"
|
|
4
|
+
import { Button } from "@/components/ui/button"
|
|
5
|
+
import { Card } from "@/components/ui/card"
|
|
6
|
+
import HoursList from "@/components/HoursList"
|
|
7
|
+
import { useStore } from "@/contexts/store"
|
|
8
|
+
|
|
9
|
+
export function StoreContact() {
|
|
10
|
+
|
|
11
|
+
const { store } = useStore()
|
|
12
|
+
|
|
13
|
+
const directionsUrl = `https://www.google.com/maps/dir/?api=1&destination=${store?.name}&destination_place_id=${store?.placeId}`
|
|
14
|
+
|
|
15
|
+
return (
|
|
16
|
+
<div className="space-y-6">
|
|
17
|
+
<Card className="p-6">
|
|
18
|
+
<h2 className="text-2xl font-bold mb-6">Contact Information</h2>
|
|
19
|
+
<div className="space-y-4">
|
|
20
|
+
<div className="flex items-start gap-4">
|
|
21
|
+
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
|
|
22
|
+
<MapPin className="w-5 h-5 text-primary" />
|
|
23
|
+
</div>
|
|
24
|
+
<div>
|
|
25
|
+
<div className="font-semibold mb-1">Address</div>
|
|
26
|
+
<div className="text-muted-foreground">
|
|
27
|
+
{store?.address?.street_line_one} {store?.address?.street_line_two && (`, ${store.address.street_line_two}`)}
|
|
28
|
+
<br />
|
|
29
|
+
{store?.address?.city}, {store?.address?.province} {store?.address?.postal_code}
|
|
30
|
+
</div>
|
|
31
|
+
<a className="px-0 h-auto mt-2 text-primary hover:underline py-3 " href={directionsUrl} target="_blank" rel="noopener noreferrer">
|
|
32
|
+
Get Directions →
|
|
33
|
+
</a>
|
|
34
|
+
</div>
|
|
35
|
+
</div>
|
|
36
|
+
|
|
37
|
+
{store?.phone_number &&
|
|
38
|
+
<div className="flex items-start gap-4">
|
|
39
|
+
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
|
|
40
|
+
<Phone className="w-5 h-5 text-primary" />
|
|
41
|
+
</div>
|
|
42
|
+
<div>
|
|
43
|
+
<div className="font-semibold mb-1">Phone</div>
|
|
44
|
+
<a href={`tel:${store?.phone_number?.phone_number}`} className="text-primary hover:underline">
|
|
45
|
+
{store?.phone_number?.formatted}
|
|
46
|
+
</a>
|
|
47
|
+
</div>
|
|
48
|
+
</div>
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
<div className="flex items-start gap-4">
|
|
52
|
+
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
|
|
53
|
+
<Globe className="w-5 h-5 text-primary" />
|
|
54
|
+
</div>
|
|
55
|
+
<div>
|
|
56
|
+
<div className="font-semibold mb-1">Website</div>
|
|
57
|
+
<a
|
|
58
|
+
href={store?.website}
|
|
59
|
+
className="text-primary hover:underline"
|
|
60
|
+
target="_blank"
|
|
61
|
+
rel="noopener noreferrer"
|
|
62
|
+
>
|
|
63
|
+
{store?.website}
|
|
64
|
+
</a>
|
|
65
|
+
</div>
|
|
66
|
+
</div>
|
|
67
|
+
</div>
|
|
68
|
+
|
|
69
|
+
<div className="flex flex-wrap gap-3 mt-6 pt-6 border-t">
|
|
70
|
+
<Button className="flex-1 sm:flex-none">
|
|
71
|
+
<Phone className="w-4 h-4 mr-2" />
|
|
72
|
+
Call Now
|
|
73
|
+
</Button>
|
|
74
|
+
<Button variant="outline" className="flex-1 sm:flex-none bg-transparent">
|
|
75
|
+
<MessageSquare className="w-4 h-4 mr-2" />
|
|
76
|
+
Send Message
|
|
77
|
+
</Button>
|
|
78
|
+
</div>
|
|
79
|
+
</Card>
|
|
80
|
+
|
|
81
|
+
<Card className="p-6">
|
|
82
|
+
<div className="flex items-center gap-3 mb-6">
|
|
83
|
+
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center">
|
|
84
|
+
<Clock className="w-5 h-5 text-primary" />
|
|
85
|
+
</div>
|
|
86
|
+
<h2 className="text-2xl font-bold">Hours of Operation</h2>
|
|
87
|
+
</div>
|
|
88
|
+
<div className="space-y-3">
|
|
89
|
+
<HoursList hours={store?.retailer_location_hours} />
|
|
90
|
+
</div>
|
|
91
|
+
</Card>
|
|
92
|
+
</div>
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export default StoreContact
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { Star, MapPin, Clock, Award, TrendingUp } from "lucide-react"
|
|
2
|
+
import { Badge } from "@/components/ui/badge"
|
|
3
|
+
import { useTranslation } from "react-i18next"
|
|
4
|
+
import { useStore } from "@/contexts/store"
|
|
5
|
+
import useHours from "@/lib/useHours"
|
|
6
|
+
|
|
7
|
+
export function StoreInfo() {
|
|
8
|
+
|
|
9
|
+
const { t } = useTranslation()
|
|
10
|
+
|
|
11
|
+
const { store } = useStore()
|
|
12
|
+
|
|
13
|
+
const { computed } = useHours({
|
|
14
|
+
hours: store?.retailer_location_hours,
|
|
15
|
+
t,
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<section className="bg-gradient-to-b from-primary/5 to-background py-12 lg:py-16">
|
|
20
|
+
<div className="container mx-auto px-4 sm:px-6 lg:px-8">
|
|
21
|
+
<div className="max-w-4xl">
|
|
22
|
+
<div className="flex flex-wrap items-center gap-3 mb-4">
|
|
23
|
+
<Badge variant="secondary" className="text-sm">
|
|
24
|
+
{t(`default:store.types.${store?.store_type?.toLowerCase()}`)}
|
|
25
|
+
</Badge>
|
|
26
|
+
</div>
|
|
27
|
+
|
|
28
|
+
<h1 className="text-4xl lg:text-5xl font-bold mb-4 text-balance">{store?.retailer_name}</h1>
|
|
29
|
+
|
|
30
|
+
<div className="flex flex-wrap items-center gap-4 mb-6">
|
|
31
|
+
<div className="flex items-center gap-2 text-muted-foreground">
|
|
32
|
+
<MapPin className="w-4 h-4" />
|
|
33
|
+
<span className="text-sm">{store?.address?.city}, {store?.address?.province}</span>
|
|
34
|
+
</div>
|
|
35
|
+
<div className="flex items-center gap-2 text-muted-foreground">
|
|
36
|
+
<Clock className="w-4 h-4" />
|
|
37
|
+
<span className={`text-sm font-medium ${computed.statusLabel === t('default:open') ? 'text-green-600' : 'text-red-600'}`}>{computed.statusLabel}</span>
|
|
38
|
+
</div>
|
|
39
|
+
</div>
|
|
40
|
+
|
|
41
|
+
<p className="text-lg text-muted-foreground leading-relaxed mb-6">
|
|
42
|
+
{t('default:store_romance', { name: store?.retailer_name || 'our store' })}
|
|
43
|
+
</p>
|
|
44
|
+
</div>
|
|
45
|
+
</div>
|
|
46
|
+
</section>
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export default StoreInfo
|
|
@@ -0,0 +1,59 @@
|
|
|
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/StoreListItem'
|
|
5
|
+
import StoreListNoneFound from "@/components/StoreListNoneFound"
|
|
6
|
+
import LoadingPuff from "@/components/ui/LoadingPuff"
|
|
7
|
+
import { distanceBetweenPoints } from '@/lib/utils/measure'
|
|
8
|
+
import { useStoreLocator } from '@/contexts/store-locator'
|
|
9
|
+
|
|
10
|
+
export function StoreList() {
|
|
11
|
+
|
|
12
|
+
const { stores, location, selectedStoreId, setSelectedStoreId, setMessageDialogOpen, setMessageStoreId, searchIsLoading, searchError, baseUrl } = useStoreLocator()
|
|
13
|
+
|
|
14
|
+
const handleMessageClick = (storeId) => {
|
|
15
|
+
setMessageStoreId(storeId)
|
|
16
|
+
setMessageDialogOpen(true)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<div className="space-y-4">
|
|
21
|
+
{!searchIsLoading && !searchError && stores.length === 0 && (
|
|
22
|
+
<StoreListNoneFound />
|
|
23
|
+
)}
|
|
24
|
+
{searchIsLoading && (
|
|
25
|
+
<div className="flex items-center justify-center h-48">
|
|
26
|
+
<LoadingPuff />
|
|
27
|
+
</div>
|
|
28
|
+
)}
|
|
29
|
+
{!searchIsLoading && !searchError && stores.map((store) => (
|
|
30
|
+
<StoreListItem
|
|
31
|
+
key={store.id}
|
|
32
|
+
id={store.id}
|
|
33
|
+
name={store.retailer_name}
|
|
34
|
+
placeId={store.place_id}
|
|
35
|
+
addressLineOne={store.address?.street_line_one}
|
|
36
|
+
addressLineTwo={store.address?.street_line_two}
|
|
37
|
+
city={store.address?.city}
|
|
38
|
+
province={store.address?.province}
|
|
39
|
+
postalCode={store.address?.postal_code}
|
|
40
|
+
country={store.address?.country}
|
|
41
|
+
distance={distanceBetweenPoints(location.latitude, location.longitude, store.address?.latitude, store.address?.longitude)}
|
|
42
|
+
phone={store.phone_number}
|
|
43
|
+
hours={store.retailer_location_hours}
|
|
44
|
+
services={[store.store_type]}
|
|
45
|
+
isSelected={selectedStoreId === store.id}
|
|
46
|
+
handleSelect={setSelectedStoreId}
|
|
47
|
+
href={`${baseUrl}/${store.id}`}
|
|
48
|
+
onMessageClick={handleMessageClick}
|
|
49
|
+
/>
|
|
50
|
+
))}
|
|
51
|
+
{!searchIsLoading && searchError && (
|
|
52
|
+
<div className="text-red-600 text-center">
|
|
53
|
+
An error occurred while searching for stores. Please try again.
|
|
54
|
+
</div>
|
|
55
|
+
)}
|
|
56
|
+
</div>
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
export default StoreList
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { MapPin, Phone, Clock, Navigation, MessageSquareText } from "lucide-react"
|
|
2
|
+
import { Button } from "@/components/ui/button"
|
|
3
|
+
import { metersToMiles } from "@/lib/utils/measure"
|
|
4
|
+
import CollapsibleStoreHours from "@/components/CollapsibleStoreHours"
|
|
5
|
+
import { useTranslation } from "react-i18next"
|
|
6
|
+
|
|
7
|
+
const StoreListItem = ({
|
|
8
|
+
id,
|
|
9
|
+
name,
|
|
10
|
+
placeId,
|
|
11
|
+
addressLineOne,
|
|
12
|
+
addressLineTwo,
|
|
13
|
+
city,
|
|
14
|
+
province,
|
|
15
|
+
postalCode,
|
|
16
|
+
country,
|
|
17
|
+
phone,
|
|
18
|
+
hours,
|
|
19
|
+
distance,
|
|
20
|
+
services = [],
|
|
21
|
+
isSelected,
|
|
22
|
+
handleSelect,
|
|
23
|
+
onMessageClick,
|
|
24
|
+
href,
|
|
25
|
+
}) => {
|
|
26
|
+
const { t } = useTranslation()
|
|
27
|
+
|
|
28
|
+
const directionsUrl = `https://www.google.com/maps/dir/?api=1&destination=${name}&destination_place_id=${placeId}`
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<div
|
|
32
|
+
className={`bg-card border rounded-xl p-6 transition-all hover:shadow-lg cursor-pointer ${isSelected ? "ring-2 ring-primary shadow-lg" : ""
|
|
33
|
+
}`}
|
|
34
|
+
onClick={() => handleSelect(id)}
|
|
35
|
+
>
|
|
36
|
+
<div className="flex flex-col md:flex-row md:items-start md:justify-between gap-4">
|
|
37
|
+
<div className="flex-1">
|
|
38
|
+
<div className="flex items-start justify-between gap-4 mb-3">
|
|
39
|
+
<div>
|
|
40
|
+
<h3 className="font-bold text-lg text-left mb-1">{name}</h3>
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|
|
43
|
+
|
|
44
|
+
<div className="space-y-2 text-sm">
|
|
45
|
+
<div className="flex items-start gap-2">
|
|
46
|
+
<MapPin className="w-4 h-4 text-muted-foreground mt-0.5 shrink-0" />
|
|
47
|
+
<div>
|
|
48
|
+
<p className="font-medium text-left">{addressLineOne}</p>
|
|
49
|
+
<p className="text-muted-foreground text-left">{city}, {province} {postalCode}</p>
|
|
50
|
+
<p className="text-primary font-medium mt-1 text-left">{t('default:distance_away', { distance: metersToMiles(distance).toFixed(1), distanceUnit: 'miles' })}</p>
|
|
51
|
+
</div>
|
|
52
|
+
</div>
|
|
53
|
+
|
|
54
|
+
<div className="flex items-center gap-2">
|
|
55
|
+
<Phone className="w-4 h-4 text-muted-foreground" />
|
|
56
|
+
<a href={`tel:${phone?.phone_number}`} className="hover:text-primary transition-colors">
|
|
57
|
+
{phone?.formatted}
|
|
58
|
+
</a>
|
|
59
|
+
</div>
|
|
60
|
+
|
|
61
|
+
<div className="flex items-top gap-2">
|
|
62
|
+
<div className="mt-2.5">
|
|
63
|
+
<Clock className="w-4 h-4 text-muted-foreground" />
|
|
64
|
+
</div>
|
|
65
|
+
<span className="text-muted-foreground"><CollapsibleStoreHours hours={hours} /></span>
|
|
66
|
+
</div>
|
|
67
|
+
</div>
|
|
68
|
+
|
|
69
|
+
<div className="flex flex-wrap gap-2 mt-4">
|
|
70
|
+
{services.map((service) => (
|
|
71
|
+
<span
|
|
72
|
+
key={service}
|
|
73
|
+
className="px-3 py-1 bg-primary/10 text-primary text-xs font-medium rounded-full"
|
|
74
|
+
>
|
|
75
|
+
{service}
|
|
76
|
+
</span>
|
|
77
|
+
))}
|
|
78
|
+
</div>
|
|
79
|
+
</div>
|
|
80
|
+
|
|
81
|
+
<div className="flex md:flex-col gap-2">
|
|
82
|
+
<a href={directionsUrl} target="_blank" rel="noopener noreferrer" className="flex-1 md:flex-none inline-flex items-center justify-center gap-2 rounded-md bg-primary text-primary-foreground text-sm font-medium h-8 px-4 hover:bg-primary/90 transition-colors">
|
|
83
|
+
<Navigation className="w-4 h-4" />
|
|
84
|
+
{t('default:get_directions')}
|
|
85
|
+
</a>
|
|
86
|
+
<Button variant="outline" className="flex-1 md:flex-none bg-transparent" size="sm" onClick={(e) => { e.stopPropagation(); onMessageClick(id); }}>
|
|
87
|
+
<MessageSquareText className="w-4 h-4" />
|
|
88
|
+
{t('default:message')}
|
|
89
|
+
</Button>
|
|
90
|
+
<a href={href} target="_blank" className="inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive bg-secondary text-secondary-foreground hover:bg-secondary/80 flex-1 md:flex-none bg-transparent" size="sm">
|
|
91
|
+
{t('default:view_details')}
|
|
92
|
+
</a>
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export default StoreListItem
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import StoreListItem from './index'
|
|
3
|
+
|
|
4
|
+
describe('<StoreListItem />', () => {
|
|
5
|
+
it('renders', () => {
|
|
6
|
+
// see: https://on.cypress.io/mounting-react
|
|
7
|
+
cy.mount(<StoreListItem
|
|
8
|
+
id="test-id"
|
|
9
|
+
name="Test Store"
|
|
10
|
+
addressLine1="123 Main St"
|
|
11
|
+
city="Anytown"
|
|
12
|
+
province="CA"
|
|
13
|
+
postalCode="12345"
|
|
14
|
+
distance="2.5 mi"
|
|
15
|
+
phone="(123) 456-7890"
|
|
16
|
+
hours={[
|
|
17
|
+
{ day: 0, open: true, open_time: "09:00", close_time: "17:00" },
|
|
18
|
+
{ day: 1, open: true, open_time: "09:00", close_time: "17:00" },
|
|
19
|
+
{ day: 2, open: true, open_time: "09:00", close_time: "17:00" },
|
|
20
|
+
{ day: 3, open: true, open_time: "09:00", close_time: "17:00" },
|
|
21
|
+
{ day: 4, open: true, open_time: "09:00", close_time: "17:00" },
|
|
22
|
+
{ day: 5, open: false },
|
|
23
|
+
{ day: 6, open: false },
|
|
24
|
+
]}
|
|
25
|
+
isSelected={false}
|
|
26
|
+
handleSelect={() => { }}
|
|
27
|
+
onMessageClick={() => { }}
|
|
28
|
+
/>)
|
|
29
|
+
})
|
|
30
|
+
})
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import LeadForm from '@/components/LeadForm'
|
|
2
|
+
|
|
3
|
+
const StoreListNoneFound = () => {
|
|
4
|
+
return (
|
|
5
|
+
<div className="text-center py-1">
|
|
6
|
+
<div className="pb-5">
|
|
7
|
+
<p className="text-muted-foreground">No stores found matching your criteria.</p>
|
|
8
|
+
<p className="text-sm">You can try adjusting your search filters or expanding the search radius.</p>
|
|
9
|
+
</div>
|
|
10
|
+
<h4 className="mb-4 text-lg font-semibold">Contact Our Authorized Online Retailer</h4>
|
|
11
|
+
<LeadForm storeName="our authorized online retailer" />
|
|
12
|
+
</div>
|
|
13
|
+
)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export default StoreListNoneFound
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) 2025 Colab Commerce <https://colabcommerce.com>
|
|
3
|
+
* All rights reserved.
|
|
4
|
+
*
|
|
5
|
+
* This is the main Store Locator component which ties together the search, list, map, and details components.
|
|
6
|
+
* Usage:
|
|
7
|
+
* <StoreLocator organizationId="org_123" locale="en-US" />
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { StoreLocatorProvider } from '@/contexts/store-locator'
|
|
11
|
+
import { APIProvider } from '@vis.gl/react-google-maps'
|
|
12
|
+
import StoreLocatorMap from '@/components/StoreLocatorMap'
|
|
13
|
+
import StoreLocatorSearch from '@/components/StoreLocatorSearch'
|
|
14
|
+
import StoreLocatorMessageDialog from '@/components/StoreLocatorMessageDialog'
|
|
15
|
+
import TranslationsProvider from '@/contexts/translations'
|
|
16
|
+
import StoreList from '@/components/StoreList'
|
|
17
|
+
|
|
18
|
+
const StoreLocator = ({ organizationId, locale, baseUrl = '/retailers' }) => {
|
|
19
|
+
return (
|
|
20
|
+
<TranslationsProvider options={{ lng: locale }}>
|
|
21
|
+
<StoreLocatorProvider organizationId={organizationId} locale={locale} baseUrl={baseUrl}>
|
|
22
|
+
<section className="py-12 lg:py-16 cc">
|
|
23
|
+
<div className="container mx-auto">
|
|
24
|
+
<div className="grid lg:grid-cols-2 gap-8">
|
|
25
|
+
<APIProvider apiKey={'AIzaSyAnJmWEU1r63DiRWHkjczxzHyIEq3dhj4M'}>
|
|
26
|
+
<StoreLocatorMap />
|
|
27
|
+
</APIProvider>
|
|
28
|
+
<div className="lg:order-first">
|
|
29
|
+
<div className="mb-8">
|
|
30
|
+
<StoreLocatorSearch />
|
|
31
|
+
</div>
|
|
32
|
+
<StoreList />
|
|
33
|
+
</div>
|
|
34
|
+
</div>
|
|
35
|
+
</div>
|
|
36
|
+
</section>
|
|
37
|
+
<StoreLocatorMessageDialog />
|
|
38
|
+
</StoreLocatorProvider>
|
|
39
|
+
</TranslationsProvider>
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export default StoreLocator
|
|
@@ -0,0 +1,93 @@
|
|
|
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 } from 'react'
|
|
5
|
+
import { MapPin } from 'lucide-react'
|
|
6
|
+
import { Map, useMap, AdvancedMarker } from '@vis.gl/react-google-maps'
|
|
7
|
+
import StoreLocatorMapMarker from '@/components/StoreLocatorMapMarker'
|
|
8
|
+
import { useStoreLocator } from '@/contexts/store-locator'
|
|
9
|
+
|
|
10
|
+
const StoreLocatorMap = () => {
|
|
11
|
+
|
|
12
|
+
const { location, stores, selectedStoreId, setSelectedStoreId } = useStoreLocator()
|
|
13
|
+
|
|
14
|
+
const map = useMap()
|
|
15
|
+
|
|
16
|
+
const zoomIn = () => {
|
|
17
|
+
if (!map) return;
|
|
18
|
+
const currentZoom = map.getZoom();
|
|
19
|
+
map.setZoom(currentZoom + 1);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const zoomOut = () => {
|
|
23
|
+
if (!map) return;
|
|
24
|
+
const currentZoom = map.getZoom();
|
|
25
|
+
map.setZoom(currentZoom - 1);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
if (!map) return;
|
|
30
|
+
if (location?.latitude && location?.longitude) {
|
|
31
|
+
map.panTo({ lat: location.latitude, lng: location.longitude })
|
|
32
|
+
map.setZoom(9)
|
|
33
|
+
}
|
|
34
|
+
}, [location, map]);
|
|
35
|
+
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
if (!map || !selectedStoreId) return;
|
|
38
|
+
const selectedStore = stores.find(store => store.id === selectedStoreId)
|
|
39
|
+
if (selectedStore && selectedStore.address) {
|
|
40
|
+
map.panTo({ lat: selectedStore.address.latitude * 1, lng: selectedStore.address.longitude * 1 })
|
|
41
|
+
}
|
|
42
|
+
}, [selectedStoreId, stores, map]);
|
|
43
|
+
|
|
44
|
+
const handleMarkerClick = (storeId) => {
|
|
45
|
+
setSelectedStoreId(storeId)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<div className="relative">
|
|
50
|
+
<div className="bg-muted rounded-xl overflow-hidden border shadow-md h-[600px] lg:sticky lg:top-24">
|
|
51
|
+
{/* Map placeholder - Replace with actual map integration (Google Maps, Mapbox, etc.) */}
|
|
52
|
+
<div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-primary/5 to-muted">
|
|
53
|
+
<div className="text-center w-full">
|
|
54
|
+
|
|
55
|
+
<Map
|
|
56
|
+
style={{ width: '100%', height: '600px' }}
|
|
57
|
+
defaultCenter={{ lat: 22.54992, lng: 0 }}
|
|
58
|
+
defaultZoom={3}
|
|
59
|
+
gestureHandling='greedy'
|
|
60
|
+
disableDefaultUI
|
|
61
|
+
mapId="b1e4f5f5f8c7e2d9"
|
|
62
|
+
>
|
|
63
|
+
{stores.map((store) => (
|
|
64
|
+
<StoreLocatorMapMarker key={store.id} store={store} isSelected={selectedStoreId === store.id} onClick={handleMarkerClick} />
|
|
65
|
+
))}
|
|
66
|
+
</Map>
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
|
|
70
|
+
{/* Map controls overlay */}
|
|
71
|
+
<div className="absolute top-4 right-4 flex flex-col gap-2">
|
|
72
|
+
<button className="bg-card border shadow-md rounded-lg p-2 hover:bg-accent transition-colors" onClick={zoomIn}>
|
|
73
|
+
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
74
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
|
75
|
+
</svg>
|
|
76
|
+
</button>
|
|
77
|
+
<button className="bg-card border shadow-md rounded-lg p-2 hover:bg-accent transition-colors" onClick={zoomOut}>
|
|
78
|
+
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
79
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 12H4" />
|
|
80
|
+
</svg>
|
|
81
|
+
</button>
|
|
82
|
+
</div>
|
|
83
|
+
|
|
84
|
+
{/* Results count badge */}
|
|
85
|
+
<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">
|
|
86
|
+
<span className="text-primary">{stores.length}</span> stores found
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export default StoreLocatorMap
|
|
@@ -0,0 +1,55 @@
|
|
|
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 }) => {
|
|
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
|
+
return (
|
|
21
|
+
<AdvancedMarker
|
|
22
|
+
position={{
|
|
23
|
+
lat: store.address?.latitude * 1,
|
|
24
|
+
lng: store.address?.longitude * 1,
|
|
25
|
+
}}
|
|
26
|
+
ref={markerRef}
|
|
27
|
+
title={store.name}
|
|
28
|
+
onClick={handleMarkerClick}
|
|
29
|
+
>
|
|
30
|
+
<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} />
|
|
31
|
+
{showInfoWindow && (
|
|
32
|
+
<InfoWindow anchor={marker} headerContent={<h3 className="font-bold text-sm align-center text-left mb-2">{store.retailer_name}</h3>} onClose={handleClose}>
|
|
33
|
+
<p className="text-sm text-left text-muted-foreground">
|
|
34
|
+
{store.address?.street_line_one}
|
|
35
|
+
{store.address?.street_line_two ? `, ${store.address?.street_line_two}` : ''}
|
|
36
|
+
</p>
|
|
37
|
+
<p className="text-sm text-left text-muted-foreground mb-4">
|
|
38
|
+
{store.address?.city}, {store.address?.province} {store.address?.postal_code}
|
|
39
|
+
</p>
|
|
40
|
+
<div className="flex flex-row gap-2 mb-1">
|
|
41
|
+
<Button className="flex-1 md:flex-row" size="sm">
|
|
42
|
+
<Navigation className="w-4 h-4" />
|
|
43
|
+
Directions
|
|
44
|
+
</Button>
|
|
45
|
+
<Button variant="outline" className="flex-1 md:flex-none bg-transparent" size="sm">
|
|
46
|
+
View Details
|
|
47
|
+
</Button>
|
|
48
|
+
</div>
|
|
49
|
+
</InfoWindow>
|
|
50
|
+
)}
|
|
51
|
+
</AdvancedMarker>
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export default StoreLocatorMapMarker
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import MessageDialog from "@/components/MessageDialog"
|
|
2
|
+
import { useStoreLocator } from "@/contexts/store-locator"
|
|
3
|
+
|
|
4
|
+
const StoreLocatorMessageDialog = () => {
|
|
5
|
+
|
|
6
|
+
const { messageDialogOpen, setMessageDialogOpen, messageStoreId, setMessageStoreId, stores } = useStoreLocator()
|
|
7
|
+
|
|
8
|
+
const store = stores.find(store => store.id === messageStoreId)
|
|
9
|
+
const storeName = store ? store.retailer_name : null
|
|
10
|
+
|
|
11
|
+
const handleClose = () => {
|
|
12
|
+
setMessageDialogOpen(false)
|
|
13
|
+
setMessageStoreId(null)
|
|
14
|
+
}
|
|
15
|
+
return (
|
|
16
|
+
<MessageDialog isOpen={messageDialogOpen} onClose={handleClose} storeName={storeName} />
|
|
17
|
+
)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export default StoreLocatorMessageDialog
|