Add full PWA support (icons, manifest, service worker, pwa lib and bridge) and introduce install/offline UI. Generate app icons via a new scripts/generate-icons.js and include multiple icon sizes in public/icons; update manifest and layout head meta/preconnect. Revamp global styles (globals.css) with new utilities, animations, spacing for bottom nav, card shadows, skeletons and accessibility tweaks. Polish major screens (login, collections, map, settings) with improved headers, loading states, touch/press feedback, and layout adjustments; add new components (InstallPrompt, OfflineIndicator, PWABridge) and update various components to match new visual/UX patterns. Update Dockerfile to generate icons and use Next.js static export output (/out) for nginx, and bump package setup to support icon generation.
257 lines
No EOL
10 KiB
TypeScript
257 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, DriverLocation } from '@/lib/types';
|
|
import { formatCurrency, calculateDistance, parseCoordinates } from '@/lib/utils';
|
|
import { getDriverTrail, isAuthenticated, getCurrentUser } from '@/lib/pocketbase';
|
|
import { Navigation, AlertTriangle, Route, Crosshair, Layers } from 'lucide-react';
|
|
import L from 'leaflet';
|
|
import 'leaflet/dist/leaflet.css';
|
|
|
|
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 })}
|
|
className="flex items-center justify-center w-10 h-10 bg-white/90 backdrop-blur-sm rounded-xl shadow-lg active:bg-gray-100 transition-all touch-manipulation press-feedback-sm"
|
|
>
|
|
<Crosshair size={18} className="text-gray-700" />
|
|
</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="h-full w-full relative">
|
|
<MapContainer center={center} zoom={12} className="h-full w-full" zoomControl={false}>
|
|
<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: '#2563eb',
|
|
weight: 4,
|
|
opacity: 0.7,
|
|
dashArray: '10, 8',
|
|
}}
|
|
/>
|
|
<Marker
|
|
position={trail[0]}
|
|
icon={L.divIcon({
|
|
className: '',
|
|
html: '<div style="width:14px;height:14px;background:#22c55e;border:3px solid white;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-orange-600 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-gray-50 rounded-lg">
|
|
<div className="flex items-center gap-1.5">
|
|
{(i.item_status === 'problem' || i.problem_flag) && <AlertTriangle size={12} className="text-red-500 flex-shrink-0" />}
|
|
<span className="font-medium truncate">{i.description}</span>
|
|
</div>
|
|
<span className="text-gray-400 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-orange-500 text-white rounded-lg text-sm font-semibold active:bg-orange-600 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)}
|
|
className={`flex items-center gap-1.5 px-3 py-2 rounded-xl text-xs font-semibold shadow-lg transition-colors press-feedback-sm ${
|
|
showTrail ? 'bg-blue-500 text-white' : 'bg-white/90 backdrop-blur-sm text-gray-700'
|
|
}`}
|
|
>
|
|
<Route size={14} />
|
|
Trail
|
|
</button>
|
|
)}
|
|
|
|
{/* Legend */}
|
|
<div className="bg-white/90 backdrop-blur-sm rounded-xl p-3 shadow-lg">
|
|
<p className="text-[10px] font-bold text-gray-500 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-orange-500 border-2 border-white shadow-sm" />
|
|
<span className="text-[11px] text-gray-600">You</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-3 h-3.5 bg-blue-900/70 rounded-sm" />
|
|
<span className="text-[11px] text-gray-600">Stop</span>
|
|
</div>
|
|
{trail.length > 1 && (
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-5 h-0" style={{ borderTop: '2px dashed #2563eb' }} />
|
|
<span className="text-[11px] text-gray-600">Route</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
} |