- rename `supplier` field to `suppliers` in OrderItem interface and PocketBase expand queries - complete visual rebrand to warm cream (#f2f1ed) and dark brown (#26251e) color palette with custom CSS variables - add accessibility improvements: skip-to-main content link, focus-visible styles, reduced motion support, and proper ARIA labels - update all component styling to match new theme, including offline indicator, install prompt, login page, modals, map, bottom nav and global CSS - standardize status colors: use green for success, red for errors, retain orange (#f54e00) as primary accent CTAs
271 lines
No EOL
10 KiB
TypeScript
271 lines
No EOL
10 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useMemo, useState, useCallback, useRef } from 'react';
|
|
import { MapContainer, TileLayer, Marker, Popup, Polyline, useMap } from 'react-leaflet';
|
|
import { OrderItem, Supplier } from '@/lib/types';
|
|
import { formatCurrency, calculateDistance, parseCoordinates } from '@/lib/utils';
|
|
import { getDriverTrail, isAuthenticated, getCurrentUser } from '@/lib/pocketbase';
|
|
import { Navigation, AlertTriangle, Route, Crosshair } from 'lucide-react';
|
|
import L from 'leaflet';
|
|
|
|
const defaultIcon = L.icon({
|
|
iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon.png',
|
|
iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon-2x.png',
|
|
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png',
|
|
iconSize: [25, 41],
|
|
iconAnchor: [12, 41],
|
|
popupAnchor: [1, -34],
|
|
shadowSize: [41, 41],
|
|
});
|
|
|
|
const currentLocationIcon = L.divIcon({
|
|
className: 'custom-div-icon',
|
|
html: `<div style="position:relative;">
|
|
<div style="width:22px;height:22px;background:#ea580c;border:3px solid white;border-radius:50%;box-shadow:0 2px 8px rgba(0,0,0,0.3);position:relative;z-index:2;"></div>
|
|
<div style="width:40px;height:40px;background:rgba(234,88,12,0.15);border-radius:50%;position:absolute;top:-9px;left:-9px;z-index:1;animation:pulse 2s infinite;"></div>
|
|
</div>
|
|
<style>@keyframes pulse{0%,100%{transform:scale(1);opacity:1}50%{transform:scale(1.5);opacity:0}}</style>`,
|
|
iconSize: [22, 22],
|
|
iconAnchor: [11, 11],
|
|
});
|
|
|
|
interface MapViewProps {
|
|
items: OrderItem[];
|
|
onNavigate: (lat: number, lng: number, name: string) => void;
|
|
}
|
|
|
|
function MapBounds({ items }: { items: OrderItem[] }) {
|
|
const map = useMap();
|
|
useEffect(() => {
|
|
const validPoints = items
|
|
.map((i) => {
|
|
const coords = parseCoordinates(i.expand?.supplier?.coordinates);
|
|
return coords ? [coords.latitude, coords.longitude] as [number, number] : null;
|
|
})
|
|
.filter((point): point is [number, number] => point !== null);
|
|
if (validPoints.length > 0) {
|
|
const bounds = L.latLngBounds(validPoints);
|
|
map.fitBounds(bounds, { padding: [60, 60], maxZoom: 15 });
|
|
}
|
|
}, [map, items]);
|
|
return null;
|
|
}
|
|
|
|
function RecenterButton({ center }: { center: [number, number] }) {
|
|
const map = useMap();
|
|
return (
|
|
<button
|
|
onClick={() => map.setView(center, map.getZoom(), { animate: true })}
|
|
aria-label="Recenter on me"
|
|
className="flex items-center justify-center w-10 h-10 bg-[#e6e5e0]/95 backdrop-blur-sm rounded-xl active:bg-[#ebeae5] transition-all touch-manipulation press-feedback-sm"
|
|
style={{ boxShadow: '0 14px 32px rgba(0,0,0,0.18), 0 0 0 1px rgba(38,37,30,0.1)' }}
|
|
>
|
|
<Crosshair size={18} className="text-[#26251e]" />
|
|
</button>
|
|
);
|
|
}
|
|
|
|
export default function CollectionMap({ items, onNavigate }: MapViewProps) {
|
|
const [userLocation, setUserLocation] = useState<[number, number] | null>(null);
|
|
const [trail, setTrail] = useState<[number, number][]>([]);
|
|
const [showTrail, setShowTrail] = useState(true);
|
|
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
|
|
// Get current position
|
|
useEffect(() => {
|
|
if ('geolocation' in navigator) {
|
|
navigator.geolocation.getCurrentPosition(
|
|
(pos) => setUserLocation([pos.coords.latitude, pos.coords.longitude]),
|
|
() => {},
|
|
{ enableHighAccuracy: false, timeout: 30000 }
|
|
);
|
|
}
|
|
}, []);
|
|
|
|
// Fetch trail data periodically
|
|
const fetchTrail = useCallback(async () => {
|
|
if (!isAuthenticated()) return;
|
|
const user = getCurrentUser();
|
|
if (!user) return;
|
|
const data = await getDriverTrail(user.id);
|
|
const points = data
|
|
.filter((loc) => loc.latitude && loc.longitude)
|
|
.map((loc) => [loc.latitude, loc.longitude] as [number, number]);
|
|
if (points.length > 0) {
|
|
setTrail(points);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
fetchTrail();
|
|
intervalRef.current = setInterval(fetchTrail, 60000);
|
|
return () => {
|
|
if (intervalRef.current) clearInterval(intervalRef.current);
|
|
};
|
|
}, [fetchTrail]);
|
|
|
|
// Group by supplier
|
|
const suppliers = useMemo(() => {
|
|
const map = new Map<string, { supplier: Supplier; items: OrderItem[]; coords: { latitude: number; longitude: number } }>();
|
|
items.forEach((item) => {
|
|
if (!item.expand?.supplier) return;
|
|
const supplier = item.expand.supplier;
|
|
const coords = parseCoordinates(supplier.coordinates);
|
|
if (!coords) return;
|
|
const key = supplier.id;
|
|
if (!map.has(key)) {
|
|
map.set(key, { supplier, items: [], coords });
|
|
}
|
|
map.get(key)!.items.push(item);
|
|
});
|
|
return Array.from(map.values());
|
|
}, [items]);
|
|
|
|
const defaultCenter: [number, number] = [-33.9249, 18.4241];
|
|
const center = userLocation || defaultCenter;
|
|
|
|
const getDistance = (coords: { latitude: number; longitude: number }) => {
|
|
if (!userLocation) return null;
|
|
return calculateDistance(userLocation[0], userLocation[1], coords.latitude, coords.longitude);
|
|
};
|
|
|
|
return (
|
|
<div className="absolute inset-0">
|
|
<MapContainer
|
|
center={center}
|
|
zoom={12}
|
|
className="absolute inset-0 z-0"
|
|
zoomControl={false}
|
|
style={{ height: '100%', width: '100%' }}
|
|
>
|
|
<TileLayer
|
|
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
|
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
|
/>
|
|
|
|
<MapBounds items={items} />
|
|
|
|
{userLocation && (
|
|
<Marker position={userLocation} icon={currentLocationIcon}>
|
|
<Popup>
|
|
<div className="text-sm font-medium">Your Location</div>
|
|
</Popup>
|
|
</Marker>
|
|
)}
|
|
|
|
{/* Trail polyline */}
|
|
{showTrail && trail.length > 1 && (
|
|
<>
|
|
<Polyline
|
|
positions={trail}
|
|
pathOptions={{
|
|
color: '#1f8a65',
|
|
weight: 4,
|
|
opacity: 0.7,
|
|
dashArray: '10, 8',
|
|
}}
|
|
/>
|
|
<Marker
|
|
position={trail[0]}
|
|
icon={L.divIcon({
|
|
className: '',
|
|
html: '<div style="width:14px;height:14px;background:#1f8a65;border:3px solid #f2f1ed;border-radius:50%;box-shadow:0 1px 4px rgba(0,0,0,0.3);"></div>',
|
|
iconSize: [14, 14],
|
|
iconAnchor: [7, 7],
|
|
})}
|
|
>
|
|
<Popup><div className="text-xs font-medium">Trip Start</div></Popup>
|
|
</Marker>
|
|
</>
|
|
)}
|
|
|
|
{suppliers.map(({ supplier, items: supplierItems, coords }) => {
|
|
const distance = getDistance(coords);
|
|
return (
|
|
<Marker key={supplier.id} position={[coords.latitude, coords.longitude]} icon={defaultIcon}>
|
|
<Popup>
|
|
<div className="min-w-[200px] max-w-[260px]">
|
|
<h3 className="font-bold text-sm mb-1">{supplier.suppliername}</h3>
|
|
{supplier.supplieraddress && (
|
|
<p className="text-xs text-gray-500 mb-2">{supplier.supplieraddress}</p>
|
|
)}
|
|
{distance && (
|
|
<p className="text-xs text-[#f54e00] mb-2 font-semibold">
|
|
{distance < 1 ? `${Math.round(distance * 1000)}m` : `${distance.toFixed(1)} km`} away
|
|
</p>
|
|
)}
|
|
<div className="space-y-1.5 mb-3">
|
|
{supplierItems.map((i) => (
|
|
<div key={i.id} className="text-xs p-2 bg-[#ebeae5] rounded-lg">
|
|
<div className="flex items-center gap-1.5">
|
|
{i.problem_flag && <AlertTriangle size={12} className="text-[#b3224a] flex-shrink-0" />}
|
|
<span className="font-medium truncate">{i.description}</span>
|
|
</div>
|
|
<span className="text-[rgba(38,37,30,0.55)] text-[11px]">Qty: {i.order_qty || i.quantity || 0}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<button
|
|
onClick={() => onNavigate(coords.latitude, coords.longitude, supplier.suppliername)}
|
|
className="w-full flex items-center justify-center gap-1.5 py-2 px-3 bg-[#f54e00] text-white rounded-lg text-sm font-semibold active:bg-[#d44200] transition-colors"
|
|
>
|
|
<Navigation size={14} /> Navigate
|
|
</button>
|
|
</div>
|
|
</Popup>
|
|
</Marker>
|
|
);
|
|
})}
|
|
</MapContainer>
|
|
|
|
{/* Floating controls */}
|
|
<div className="absolute top-3 right-3 space-y-2 z-[400]">
|
|
{/* Recenter */}
|
|
{userLocation && (
|
|
<RecenterButton center={userLocation} />
|
|
)}
|
|
|
|
{/* Trail toggle */}
|
|
{trail.length > 1 && (
|
|
<button
|
|
onClick={() => setShowTrail(!showTrail)}
|
|
aria-pressed={showTrail}
|
|
className={`flex items-center gap-1.5 px-3 py-2 rounded-xl text-xs font-semibold transition-colors press-feedback-sm ${
|
|
showTrail
|
|
? 'bg-[#26251e] text-[#f2f1ed]'
|
|
: 'bg-[#e6e5e0]/95 backdrop-blur-sm text-[#26251e]'
|
|
}`}
|
|
style={{ boxShadow: '0 14px 32px rgba(0,0,0,0.18), 0 0 0 1px rgba(38,37,30,0.1)' }}
|
|
>
|
|
<Route size={14} />
|
|
Trail
|
|
</button>
|
|
)}
|
|
|
|
{/* Legend */}
|
|
<div
|
|
className="bg-[#e6e5e0]/95 backdrop-blur-sm rounded-xl p-3"
|
|
style={{ boxShadow: '0 14px 32px rgba(0,0,0,0.18), 0 0 0 1px rgba(38,37,30,0.1)' }}
|
|
>
|
|
<p className="text-[10px] font-bold text-[rgba(38,37,30,0.55)] uppercase tracking-wider mb-2">Legend</p>
|
|
<div className="space-y-1.5">
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-3 h-3 rounded-full bg-[#f54e00] border-2 border-white shadow-sm" />
|
|
<span className="text-[11px] text-[#26251e]">You</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-3 h-3.5 bg-[#26251e]/70 rounded-sm" />
|
|
<span className="text-[11px] text-[#26251e]">Stop</span>
|
|
</div>
|
|
{trail.length > 1 && (
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-5 h-0" style={{ borderTop: '2px dashed #1f8a65' }} />
|
|
<span className="text-[11px] text-[#26251e]">Route</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
} |