Commit working map with markers

This commit is contained in:
2025-08-18 00:51:11 +02:00
parent bf061bff08
commit 75453a7303
24 changed files with 515 additions and 402 deletions

View File

@@ -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>

View File

@@ -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>

View File

View File

@@ -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>
);
}

View File

@@ -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="&copy; 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>
);
}

View 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
View 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='&copy; <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>
)
}

View 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 />
)
}

View 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>
)
}