Commit working map with markers
This commit is contained in:
@@ -3,7 +3,11 @@ import { Container, Button, Card, Heading, Image, Text, SimpleGrid, GridItem } f
|
||||
|
||||
export default async function Articles() {
|
||||
const supabase = await createClient();
|
||||
const { data: articles } = await supabase.from("articles").select().order("published_at", {ascending: false}).limit(8);
|
||||
const {data: articles} = await supabase
|
||||
.from("articles")
|
||||
.select()
|
||||
.order("published_at", {ascending: false})
|
||||
.limit(8);
|
||||
|
||||
return (
|
||||
<Container>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {Box, Flex, Button, HStack, Wrap, Link, Text, Heading} from "@chakra-ui/react";
|
||||
import NextLink from "next/link";
|
||||
import Image from 'next/image'
|
||||
import {RiArrowRightLine, RiMailLine} from "react-icons/ri"
|
||||
import {MENU_ITEMS} from "../lib/constants";
|
||||
|
||||
@@ -47,7 +48,13 @@ export default function Banner() {
|
||||
variant="solid"
|
||||
asChild
|
||||
>
|
||||
<a href={item.href}><RiMailLine/> {item.label}</a>
|
||||
<a href={item.href}>
|
||||
<Image
|
||||
src={item.icon}
|
||||
alt={item.label}
|
||||
width={100}
|
||||
height={100}
|
||||
/><RiMailLine/> {item.label}</a>
|
||||
</Button>
|
||||
))}
|
||||
</Wrap>
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Box, Flex, Heading, HStack, Input, Stack, Text, Badge } from "@chakra-ui/react";
|
||||
import { useMemo, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import type { Company } from "./CompanyMap";
|
||||
|
||||
// dynamic import to avoid SSR issues
|
||||
const CompanyMap = dynamic(() => import("./CompanyMap"), { ssr: false });
|
||||
|
||||
export type CompaniesExplorerProps = {
|
||||
companies: Company[];
|
||||
industries?: Array<Company["industry"]>;
|
||||
initialIndustry?: Company["industry"] | "All";
|
||||
showAutoFit?: boolean;
|
||||
};
|
||||
|
||||
const defaultIndustries: Array<Company["industry"]> = [
|
||||
"Tech",
|
||||
"Retail",
|
||||
"Manufacturing",
|
||||
"Food",
|
||||
"Healthcare",
|
||||
];
|
||||
|
||||
export default function CompaniesExplorer({
|
||||
companies,
|
||||
industries = defaultIndustries,
|
||||
initialIndustry = "All",
|
||||
showAutoFit = true,
|
||||
}: CompaniesExplorerProps) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [industry, setIndustry] = useState<string>(initialIndustry);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
return companies.filter((c) => {
|
||||
const matchesQ =
|
||||
!q ||
|
||||
c.name.toLowerCase().includes(q) ||
|
||||
(c.address?.toLowerCase().includes(q) ?? false);
|
||||
const matchesInd = industry === "All" || c.industry === industry;
|
||||
return matchesQ && matchesInd;
|
||||
});
|
||||
}, [companies, query, industry]);
|
||||
|
||||
const selected = useMemo(
|
||||
() => filtered.find((c) => c.id === selectedId),
|
||||
[filtered, selectedId]
|
||||
);
|
||||
|
||||
return (
|
||||
<Flex h="100dvh" w="100%" p={4} gap={4} wrap={{ base: "wrap", lg: "nowrap" }}>
|
||||
{/* Left panel */}
|
||||
<Box
|
||||
w={{ base: "100%", lg: "35%" }}
|
||||
h={{ base: "48dvh", lg: "100%" }}
|
||||
borderRadius="2xl"
|
||||
p={4}
|
||||
bg="white"
|
||||
boxShadow="sm"
|
||||
overflow="hidden"
|
||||
>
|
||||
<HStack justify="space-between" mb={3}>
|
||||
<Heading size="md">Companies</Heading>
|
||||
</HStack>
|
||||
|
||||
<HStack mb={3} spacing={2}>
|
||||
<Input
|
||||
placeholder="Search by name or city..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
<Box as="select"
|
||||
value={industry}
|
||||
onChange={(e) => setIndustry(e.target.value)}
|
||||
maxW="44"
|
||||
borderWidth="1px"
|
||||
borderRadius="md"
|
||||
px={3}
|
||||
py={2}
|
||||
>
|
||||
<option value="All">All</option>
|
||||
{industries.map((i) => (
|
||||
<option key={i} value={i}>{i}</option>
|
||||
))}
|
||||
</Box>
|
||||
</HStack>
|
||||
|
||||
<Box overflowY="auto" h={{ base: "calc(48dvh - 120px)", lg: "calc(100% - 120px)" }} pr={2}>
|
||||
<Stack spacing={2}>
|
||||
{filtered.map((c) => {
|
||||
const active = c.id === selectedId;
|
||||
return (
|
||||
<Box
|
||||
key={c.id}
|
||||
p={3}
|
||||
borderRadius="xl"
|
||||
borderWidth={active ? "2px" : "1px"}
|
||||
borderColor={active ? "blue.400" : "gray.200"}
|
||||
bg={active ? "blue.50" : "transparent"}
|
||||
cursor="pointer"
|
||||
onClick={() => setSelectedId(c.id)}
|
||||
_hover={{ borderColor: "blue.300" }}
|
||||
>
|
||||
<HStack justify="space-between" align="start">
|
||||
<Stack spacing={0}>
|
||||
<Text fontWeight="semibold">{c.name}</Text>
|
||||
{c.address && (
|
||||
<Text fontSize="sm" color="gray.500">
|
||||
{c.address}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
<Badge>{c.industry}</Badge>
|
||||
</HStack>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
{filtered.length === 0 && (
|
||||
<Text color="gray.500" textAlign="center" mt={6}>
|
||||
No companies match your filters.
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Right panel */}
|
||||
<Box
|
||||
flex="1"
|
||||
h={{ base: "48dvh", lg: "100%" }}
|
||||
borderRadius="2xl"
|
||||
overflow="hidden"
|
||||
boxShadow="sm"
|
||||
>
|
||||
<CompanyMap
|
||||
companies={filtered}
|
||||
selectedId={selected?.id ?? null}
|
||||
onSelect={(id) => setSelectedId(id)}
|
||||
autoFit={showAutoFit}
|
||||
/>
|
||||
</Box>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Box, Badge, Flex, Text } from "@chakra-ui/react";
|
||||
import { MapContainer, TileLayer, Marker, Popup, useMap } from "react-leaflet";
|
||||
import L, { DivIcon, LatLngBoundsLiteral } from "leaflet";
|
||||
import "leaflet/dist/leaflet.css";
|
||||
import "leaflet.markercluster/dist/MarkerCluster.css";
|
||||
import "leaflet.markercluster/dist/MarkerCluster.Default.css";
|
||||
import MarkerClusterGroup from "react-leaflet-markercluster";
|
||||
import { useEffect, useMemo } from "react";
|
||||
|
||||
export type Company = {
|
||||
id: string;
|
||||
name: string;
|
||||
industry: "Tech" | "Retail" | "Manufacturing" | "Food" | "Healthcare";
|
||||
lat: number;
|
||||
lng: number;
|
||||
address?: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
companies: Company[];
|
||||
selectedId?: string | null;
|
||||
onSelect?: (id: string) => void;
|
||||
autoFit?: boolean;
|
||||
};
|
||||
|
||||
const industryEmoji: Record<Company["industry"], string> = {
|
||||
Tech: "💻",
|
||||
Retail: "🛒",
|
||||
Manufacturing: "🏭",
|
||||
Food: "🍽️",
|
||||
Healthcare: "🏥",
|
||||
};
|
||||
|
||||
const industryColor: Record<Company["industry"], string> = {
|
||||
Tech: "#3b82f6",
|
||||
Retail: "#f59e0b",
|
||||
Manufacturing: "#6b7280",
|
||||
Food: "#10b981",
|
||||
Healthcare: "#ef4444",
|
||||
};
|
||||
|
||||
function makeIcon(industry: Company["industry"], highlighted: boolean): DivIcon {
|
||||
const size = highlighted ? 40 : 32;
|
||||
const color = industryColor[industry];
|
||||
const emoji = industryEmoji[industry];
|
||||
const border = highlighted ? `3px solid ${color}` : `2px solid ${color}`;
|
||||
return L.divIcon({
|
||||
className: "company-div-icon",
|
||||
html: `
|
||||
<div style="
|
||||
width:${size}px;height:${size}px;border-radius:50%;
|
||||
display:flex;align-items:center;justify-content:center;
|
||||
background:#fff;border:${border};
|
||||
box-shadow:0 2px 8px rgba(0,0,0,.15);
|
||||
font-size:${Math.round(size * 0.55)}px;
|
||||
">${emoji}</div>
|
||||
`,
|
||||
iconSize: [size, size],
|
||||
iconAnchor: [size / 2, size / 2],
|
||||
popupAnchor: [0, -size / 2],
|
||||
});
|
||||
}
|
||||
|
||||
function FlyTo({ lat, lng }: { lat: number; lng: number }) {
|
||||
const map = useMap();
|
||||
useEffect(() => {
|
||||
map.flyTo([lat, lng], Math.max(map.getZoom(), 13), { duration: 0.7 });
|
||||
}, [lat, lng, map]);
|
||||
return null;
|
||||
}
|
||||
|
||||
function FitBoundsOnData({ points }: { points: { lat: number; lng: number }[] }) {
|
||||
const map = useMap();
|
||||
useEffect(() => {
|
||||
if (!points?.length) return;
|
||||
let bounds: LatLngBoundsLiteral = [
|
||||
[points[0].lat, points[0].lng],
|
||||
[points[0].lat, points[0].lng],
|
||||
];
|
||||
for (const p of points) {
|
||||
bounds = L.latLngBounds(bounds).extend([p.lat, p.lng]) as any;
|
||||
}
|
||||
map.fitBounds(bounds, { padding: [30, 30] });
|
||||
}, [points, map]);
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function CompanyMap({ companies, selectedId, onSelect, autoFit = true }: Props) {
|
||||
const selected = useMemo(
|
||||
() => companies.find((c) => c.id === selectedId),
|
||||
[companies, selectedId]
|
||||
);
|
||||
|
||||
const center = useMemo<[number, number]>(() => {
|
||||
if (!companies.length) return [46.0569, 14.5058]; // Ljubljana fallback
|
||||
const lat = companies.reduce((a, c) => a + c.lat, 0) / companies.length;
|
||||
const lng = companies.reduce((a, c) => a + c.lng, 0) / companies.length;
|
||||
return [lat, lng];
|
||||
}, [companies]);
|
||||
|
||||
return (
|
||||
<Box w="100%" h="100%" borderRadius="2xl" overflow="hidden" bg="white">
|
||||
<MapContainer
|
||||
center={center}
|
||||
zoom={6}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
preferCanvas
|
||||
>
|
||||
<TileLayer
|
||||
attribution="© OpenStreetMap"
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
|
||||
<MarkerClusterGroup chunkedLoading>
|
||||
{companies.map((c) => (
|
||||
<Marker
|
||||
key={c.id}
|
||||
position={[c.lat, c.lng]}
|
||||
icon={makeIcon(c.industry, c.id === selectedId)}
|
||||
eventHandlers={{
|
||||
click: () => onSelect?.(c.id),
|
||||
mouseover: (e) => e.target.openPopup(),
|
||||
}}
|
||||
>
|
||||
<Popup>
|
||||
<Flex direction="column" gap={1}>
|
||||
<Text fontWeight="bold">{c.name}</Text>
|
||||
<Badge
|
||||
w="fit-content"
|
||||
colorScheme="gray"
|
||||
style={{ background: industryColor[c.industry], color: "#fff" }}
|
||||
>
|
||||
{c.industry}
|
||||
</Badge>
|
||||
{c.address && <Text fontSize="sm">{c.address}</Text>}
|
||||
</Flex>
|
||||
</Popup>
|
||||
</Marker>
|
||||
))}
|
||||
</MarkerClusterGroup>
|
||||
|
||||
{autoFit && companies.length > 0 && !selected && (
|
||||
<FitBoundsOnData points={companies.map(({ lat, lng }) => ({ lat, lng }))} />
|
||||
)}
|
||||
{selected && <FlyTo lat={selected.lat} lng={selected.lng} />}
|
||||
</MapContainer>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
32
src/components/Map/ListingsMap.tsx
Normal file
32
src/components/Map/ListingsMap.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
|
||||
import { Container, Stack, Box, HStack, Button, Card, Heading, Image, Text, SimpleGrid, GridItem } from "@chakra-ui/react";
|
||||
import ServicesList from "./ServicesList";
|
||||
import MapWrapper from "./MapWrapper";
|
||||
|
||||
export default function ListingsMap() {
|
||||
|
||||
return (
|
||||
<Container maxW="full" px={4}>
|
||||
<Stack direction={{base: "column", md: "row"}} gap="5" h="80vh">
|
||||
<Box p="2"
|
||||
borderWidth="1px"
|
||||
borderColor="border.disabled"
|
||||
color="fg.disabled"
|
||||
w={{base: "100%", md: "50%"}}
|
||||
h="full"
|
||||
>
|
||||
<ServicesList />
|
||||
</Box>
|
||||
<Box p="2"
|
||||
borderWidth="1px"
|
||||
borderColor="border.disabled"
|
||||
color="fg.disabled"
|
||||
w={{base: "100%", md: "50%"}}
|
||||
h="full"
|
||||
>
|
||||
<MapWrapper />
|
||||
</Box>
|
||||
</Stack>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
252
src/components/Map/Map.tsx
Normal file
252
src/components/Map/Map.tsx
Normal file
@@ -0,0 +1,252 @@
|
||||
"use client";
|
||||
|
||||
import { MapContainer, TileLayer, Marker, Popup, useMapEvents } from "react-leaflet";
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import L from "leaflet";
|
||||
import markerIcon2x from "leaflet/dist/images/marker-icon-2x.png";
|
||||
import markerIcon from "leaflet/dist/images/marker-icon.png";
|
||||
import markerShadow from "leaflet/dist/images/marker-shadow.png";
|
||||
import { createClient } from "@/utils/supabase/client";
|
||||
|
||||
const DefaultIcon = L.icon({
|
||||
iconRetinaUrl: (markerIcon2x as unknown as { src: string }).src || (markerIcon2x as unknown as string),
|
||||
iconUrl: (markerIcon as unknown as { src: string }).src || (markerIcon as unknown as string),
|
||||
shadowUrl: (markerShadow as unknown as { src: string }).src || (markerShadow as unknown as string),
|
||||
iconSize: [25, 41],
|
||||
iconAnchor: [12, 41],
|
||||
popupAnchor: [1, -34],
|
||||
shadowSize: [41, 41],
|
||||
});
|
||||
|
||||
L.Marker.prototype.options.icon = DefaultIcon;
|
||||
|
||||
interface ServiceProvider {
|
||||
id: string;
|
||||
name: string;
|
||||
address: string;
|
||||
category: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
website?: string;
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
}
|
||||
|
||||
interface MapBounds {
|
||||
north: number;
|
||||
south: number;
|
||||
east: number;
|
||||
west: number;
|
||||
}
|
||||
|
||||
function MapBoundsTracker({
|
||||
onBoundsChange,
|
||||
userLocation
|
||||
}: {
|
||||
onBoundsChange: (bounds: MapBounds) => void;
|
||||
userLocation?: { lat: number; lng: number } | null;
|
||||
}) {
|
||||
const map = useMapEvents({
|
||||
moveend: () => {
|
||||
const bounds = map.getBounds();
|
||||
onBoundsChange({
|
||||
north: bounds.getNorth(),
|
||||
south: bounds.getSouth(),
|
||||
east: bounds.getEast(),
|
||||
west: bounds.getWest()
|
||||
});
|
||||
},
|
||||
zoomend: () => {
|
||||
const bounds = map.getBounds();
|
||||
onBoundsChange({
|
||||
north: bounds.getNorth(),
|
||||
south: bounds.getSouth(),
|
||||
east: bounds.getEast(),
|
||||
west: bounds.getWest()
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Center map on user location when it becomes available
|
||||
useEffect(() => {
|
||||
if (userLocation && map) {
|
||||
console.log("🎯 Centering map on user location:", userLocation);
|
||||
map.setView([userLocation.lat, userLocation.lng], 13);
|
||||
}
|
||||
}, [userLocation, map]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function Map(props: any) {
|
||||
const [visibleProviders, setVisibleProviders] = useState<ServiceProvider[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [userLocation, setUserLocation] = useState<{ lat: number; lng: number } | null>(null);
|
||||
const [mapCenter, setMapCenter] = useState<[number, number]>([46.1512, 14.9955]); // Default to Slovenia center
|
||||
const [locationPermissionRequested, setLocationPermissionRequested] = useState(false);
|
||||
|
||||
const fetchProvidersInBounds = useCallback(async (bounds: MapBounds) => {
|
||||
console.log("🗺️ Fetching providers for bounds:", bounds);
|
||||
setLoading(true);
|
||||
try {
|
||||
const supabase = createClient();
|
||||
|
||||
// Fetch all providers with coordinates, then filter by bounds
|
||||
const { data: allProviders, error } = await supabase
|
||||
.from("service_providers")
|
||||
.select("*");
|
||||
|
||||
console.log("🔍 Supabase query result:", { data: allProviders?.length, error });
|
||||
|
||||
if (error) {
|
||||
console.error("❌ Supabase error:", error);
|
||||
setVisibleProviders([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Filter to only providers with coordinates
|
||||
const providersWithCoords = allProviders?.filter(provider =>
|
||||
provider.latitude != null && provider.longitude != null
|
||||
) || [];
|
||||
|
||||
console.log("📊 Total providers with coordinates:", providersWithCoords?.length);
|
||||
|
||||
// Filter by bounds on the client side for now
|
||||
const serviceProviders = providersWithCoords?.filter(provider =>
|
||||
provider.latitude >= bounds.south &&
|
||||
provider.latitude <= bounds.north &&
|
||||
provider.longitude >= bounds.west &&
|
||||
provider.longitude <= bounds.east
|
||||
) || [];
|
||||
|
||||
console.log("📊 Providers in bounds:", serviceProviders?.length);
|
||||
|
||||
setVisibleProviders(serviceProviders);
|
||||
} catch (error) {
|
||||
console.error("Error fetching providers:", error);
|
||||
setVisibleProviders([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleBoundsChange = useCallback((bounds: MapBounds) => {
|
||||
fetchProvidersInBounds(bounds);
|
||||
}, [fetchProvidersInBounds]);
|
||||
|
||||
// Request user location on component mount
|
||||
useEffect(() => {
|
||||
if (!locationPermissionRequested && navigator.geolocation) {
|
||||
setLocationPermissionRequested(true);
|
||||
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => {
|
||||
const { latitude, longitude } = position.coords;
|
||||
console.log("📍 User location detected:", { latitude, longitude });
|
||||
const userLoc = { lat: latitude, lng: longitude };
|
||||
setUserLocation(userLoc);
|
||||
setMapCenter([latitude, longitude]);
|
||||
|
||||
// Fetch providers around user location (wider area)
|
||||
const userBounds: MapBounds = {
|
||||
north: latitude + 2.0,
|
||||
south: latitude - 2.0,
|
||||
east: longitude + 2.0,
|
||||
west: longitude - 2.0
|
||||
};
|
||||
console.log("👤 User location bounds:", userBounds);
|
||||
fetchProvidersInBounds(userBounds);
|
||||
},
|
||||
(error) => {
|
||||
console.warn("Geolocation error:", error.message);
|
||||
// Fall back to Slovenia bounds
|
||||
const sloveniaInitialBounds: MapBounds = {
|
||||
north: 46.9,
|
||||
south: 45.4,
|
||||
east: 16.6,
|
||||
west: 13.4
|
||||
};
|
||||
fetchProvidersInBounds(sloveniaInitialBounds);
|
||||
},
|
||||
{
|
||||
enableHighAccuracy: true,
|
||||
timeout: 10000,
|
||||
maximumAge: 300000 // 5 minutes
|
||||
}
|
||||
);
|
||||
}
|
||||
}, [locationPermissionRequested, fetchProvidersInBounds, setUserLocation, setMapCenter, setLocationPermissionRequested]);
|
||||
|
||||
return (
|
||||
<MapContainer
|
||||
center={mapCenter}
|
||||
zoom={8}
|
||||
scrollWheelZoom={true}
|
||||
style={{width: "100%", height: "100%"}}
|
||||
>
|
||||
<TileLayer
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
<MapBoundsTracker onBoundsChange={handleBoundsChange} userLocation={userLocation} />
|
||||
|
||||
{/* User location marker */}
|
||||
{userLocation && (
|
||||
<Marker position={[userLocation.lat, userLocation.lng]}>
|
||||
<Popup>
|
||||
<div>
|
||||
<h3 style={{ margin: "0 0 8px 0", fontSize: "14px", fontWeight: "bold" }}>
|
||||
📍 Your Location
|
||||
</h3>
|
||||
<p style={{ margin: "0", fontSize: "12px" }}>
|
||||
You are here
|
||||
</p>
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
)}
|
||||
|
||||
{!loading && visibleProviders.map((provider: ServiceProvider) => {
|
||||
if (provider.latitude && provider.longitude) {
|
||||
return (
|
||||
<Marker
|
||||
key={provider.id}
|
||||
position={[provider.latitude, provider.longitude]}
|
||||
>
|
||||
<Popup>
|
||||
<div>
|
||||
<h3 style={{ margin: "0 0 8px 0", fontSize: "14px", fontWeight: "bold" }}>
|
||||
{provider.name}
|
||||
</h3>
|
||||
{provider.category && (
|
||||
<p style={{ margin: "0 0 4px 0", fontSize: "12px", color: "#666" }}>
|
||||
{provider.category}
|
||||
</p>
|
||||
)}
|
||||
{provider.address && (
|
||||
<p style={{ margin: "0 0 8px 0", fontSize: "12px" }}>
|
||||
📍 {provider.address}
|
||||
</p>
|
||||
)}
|
||||
{provider.phone && (
|
||||
<p style={{ margin: "0 0 4px 0", fontSize: "12px" }}>
|
||||
📞 <a href={`tel:${provider.phone}`}>{provider.phone}</a>
|
||||
</p>
|
||||
)}
|
||||
{provider.website && (
|
||||
<p style={{ margin: "0", fontSize: "12px" }}>
|
||||
🌐 <a href={provider.website} target="_blank" rel="noopener noreferrer">
|
||||
Website
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</MapContainer>
|
||||
)
|
||||
}
|
||||
15
src/components/Map/MapWrapper.tsx
Normal file
15
src/components/Map/MapWrapper.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { Container, Stack, Box, HStack, Button, Card, Heading, Image, Text, SimpleGrid, GridItem } from "@chakra-ui/react";
|
||||
|
||||
const Map = dynamic(() => import("./Map"), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
export default function MapWrapper() {
|
||||
|
||||
return (
|
||||
<Map />
|
||||
)
|
||||
}
|
||||
125
src/components/Map/ServicesList.tsx
Normal file
125
src/components/Map/ServicesList.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import { Box, Heading, Text, Stack, VStack, HStack, Badge, Link } from "@chakra-ui/react";
|
||||
import { FiPhone, FiMail, FiMapPin, FiExternalLink } from "react-icons/fi";
|
||||
import { createClient } from '@/utils/supabase/server';
|
||||
|
||||
export default async function ServicesList() {
|
||||
const supabase = await createClient();
|
||||
const { data:providers } = await supabase.from("service_providers").select();
|
||||
|
||||
return (
|
||||
<VStack align="stretch" gap={4} h="full">
|
||||
<Box position="sticky" top={0} bg="white" zIndex={1} pb={2}>
|
||||
<Heading as="h3" size="lg" color="gray.800">
|
||||
Service Providers ({providers?.length || 0})
|
||||
</Heading>
|
||||
</Box>
|
||||
|
||||
{providers?.length === 0 ? (
|
||||
<Text color="gray.500">Ni vsebin</Text>
|
||||
) : (
|
||||
<Box
|
||||
flex="1"
|
||||
overflowY="auto"
|
||||
h="full"
|
||||
pr={2}
|
||||
css={{
|
||||
'&::-webkit-scrollbar': {
|
||||
width: '6px',
|
||||
},
|
||||
'&::-webkit-scrollbar-track': {
|
||||
background: '#f1f1f1',
|
||||
borderRadius: '3px',
|
||||
},
|
||||
'&::-webkit-scrollbar-thumb': {
|
||||
background: '#c1c1c1',
|
||||
borderRadius: '3px',
|
||||
},
|
||||
'&::-webkit-scrollbar-thumb:hover': {
|
||||
background: '#a8a8a8',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack gap={3}>
|
||||
{providers?.map((provider) => (
|
||||
<Box
|
||||
key={provider.id}
|
||||
p={3}
|
||||
borderWidth="1px"
|
||||
borderColor="gray.200"
|
||||
borderRadius="md"
|
||||
bg="white"
|
||||
shadow="sm"
|
||||
_hover={{
|
||||
shadow: "md",
|
||||
borderColor: "blue.300"
|
||||
}}
|
||||
transition="all 0.2s"
|
||||
cursor="pointer"
|
||||
>
|
||||
<VStack align="start" gap={1.5}>
|
||||
<HStack justify="space-between" w="full">
|
||||
<Heading
|
||||
as="h4"
|
||||
size="sm"
|
||||
color="gray.800"
|
||||
truncate
|
||||
maxW="60%"
|
||||
>
|
||||
{provider.name}
|
||||
</Heading>
|
||||
{provider.category && (
|
||||
<Badge colorScheme="blue" variant="subtle" fontSize="xs">
|
||||
{provider.category}
|
||||
</Badge>
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
{provider.address && (
|
||||
<HStack color="gray.600" fontSize="xs" gap={1}>
|
||||
<FiMapPin size={12} />
|
||||
<Text truncate>{provider.address}</Text>
|
||||
</HStack>
|
||||
)}
|
||||
|
||||
<HStack gap={3} fontSize="xs" flexWrap="wrap">
|
||||
{provider.phone && (
|
||||
<HStack color="gray.600" gap={1}>
|
||||
<FiPhone size={10} />
|
||||
<Link href={`tel:${provider.phone}`} color="blue.500">
|
||||
{provider.phone}
|
||||
</Link>
|
||||
</HStack>
|
||||
)}
|
||||
|
||||
{provider.email && (
|
||||
<HStack color="gray.600" gap={1}>
|
||||
<FiMail size={10} />
|
||||
<Link href={`mailto:${provider.email}`} color="blue.500">
|
||||
Email
|
||||
</Link>
|
||||
</HStack>
|
||||
)}
|
||||
|
||||
{provider.website && (
|
||||
<HStack color="gray.600" gap={1}>
|
||||
<FiExternalLink size={10} />
|
||||
<Link
|
||||
href={provider.website}
|
||||
color="blue.500"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Website
|
||||
</Link>
|
||||
</HStack>
|
||||
)}
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
</VStack>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user