2026-05-24 19:51:29 +00:00
|
|
|
'use client';
|
|
|
|
|
|
2026-05-24 23:33:54 +00:00
|
|
|
import { useEffect, useMemo, useState, useCallback, useRef } from 'react';
|
2026-07-07 15:15:42 +00:00
|
|
|
import { MapContainer, TileLayer, Marker, Popup, Polyline, useMap, useMapEvents } from 'react-leaflet';
|
2026-05-26 23:17:56 +00:00
|
|
|
import { OrderItem, Supplier } from '@/lib/types';
|
2026-07-07 15:15:42 +00:00
|
|
|
import { formatCurrency, calculateDistance, parseCoordinates, clusterByProximity } from '@/lib/utils';
|
|
|
|
|
import { getItemDisplayDescription, getItemDisplayQty, getDriverTrail, isAuthenticated, getCurrentUser } from '@/lib/pocketbase';
|
|
|
|
|
import { Navigation, AlertTriangle, Route, Crosshair, Package } from 'lucide-react';
|
2026-05-24 19:51:29 +00:00
|
|
|
import L from 'leaflet';
|
|
|
|
|
|
2026-07-07 15:15:42 +00:00
|
|
|
const AREA_COLORS = [
|
|
|
|
|
'#f54e00', // orange
|
|
|
|
|
'#18714f', // emerald
|
|
|
|
|
'#6c5a99', // violet
|
|
|
|
|
'#cf2d56', // rose
|
|
|
|
|
'#8a5a02', // amber
|
|
|
|
|
'#26251e', // ink
|
|
|
|
|
'#2563eb', // blue
|
|
|
|
|
'#0d9488', // teal
|
|
|
|
|
'#7c3aed', // purple
|
|
|
|
|
'#dc2626', // red
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
// ────────────────────────────────────────────────────────────────────
|
|
|
|
|
// ONE shape for every waypoint. Every stop — singleton or cluster —
|
2026-07-07 16:28:19 +00:00
|
|
|
// uses the same 22px orange dot so the map reads as one product surface,
|
2026-07-07 15:15:42 +00:00
|
|
|
// not "Leaflet default + my clusters glued on top".
|
|
|
|
|
//
|
|
|
|
|
// Why a circle, not a pill:
|
|
|
|
|
// • At 10+ stops the pill stage was crowded — names overlapped and
|
|
|
|
|
// the silhouette grew with text length, so dense regions became
|
|
|
|
|
// unreadable.
|
|
|
|
|
// • A consistent dot keeps the map balanced at every zoom: silhouette
|
|
|
|
|
// is identical for 1 stop or 99, so the driver reads shape density
|
|
|
|
|
// (= how clustered) instead of having to re-parse variable icons.
|
|
|
|
|
//
|
2026-07-07 16:28:19 +00:00
|
|
|
// Where the label lives:
|
|
|
|
|
// • ALWAYS below the dot (was hover-only — drivers said they couldn't
|
|
|
|
|
// read stop names without tapping). The label is a cream chip on
|
|
|
|
|
// dark text, readable over any tile.
|
|
|
|
|
// • Singletons: just the supplier name ("Acme Hardware").
|
|
|
|
|
// • Clusters: supplier name + count ("Acme Hardware + 3 more"), so
|
|
|
|
|
// the driver knows it's a multi-stop without clicking.
|
|
|
|
|
// • On click: the popup lists every item at this pin (same as before).
|
2026-07-07 15:15:42 +00:00
|
|
|
// ────────────────────────────────────────────────────────────────────
|
2026-07-07 16:28:19 +00:00
|
|
|
const DOT_PX = 22; // fixed across every waypoint — silent unity
|
2026-07-07 15:15:42 +00:00
|
|
|
|
|
|
|
|
const buildWaypointIcon = (count: number, supplierName: string, areaColor?: string): L.DivIcon => {
|
|
|
|
|
const isMulti = count >= 2;
|
2026-07-07 16:28:19 +00:00
|
|
|
// Label content. Singletons get the supplier name; clusters get
|
|
|
|
|
// "Supplier + N more" so the driver reads the count without
|
|
|
|
|
// tapping. Fallback to a generic label when supplier is unknown.
|
|
|
|
|
let label = '';
|
|
|
|
|
if (supplierName) {
|
|
|
|
|
label = isMulti ? `${supplierName} + ${count - 1}` : supplierName;
|
|
|
|
|
} else {
|
|
|
|
|
label = isMulti ? `${count} stops` : 'Stop';
|
|
|
|
|
}
|
2026-07-07 15:15:42 +00:00
|
|
|
const dotColor = areaColor || '#f54e00';
|
|
|
|
|
|
|
|
|
|
const dotHtml = `
|
|
|
|
|
<div class="waypoint-dot-shadow"></div>
|
|
|
|
|
<div class="waypoint-dot" style="background:${dotColor};"></div>
|
2026-07-07 16:28:19 +00:00
|
|
|
<div class="waypoint-dot-name">${escapeHtml(label)}</div>
|
2026-07-07 15:15:42 +00:00
|
|
|
${isMulti
|
|
|
|
|
? `<div class="waypoint-dot-badge">${count > 99 ? '99+' : count}</div>`
|
|
|
|
|
: ''}
|
|
|
|
|
`;
|
|
|
|
|
|
2026-07-07 16:28:19 +00:00
|
|
|
// iconSize is the hit-box around the visual. The hit box itself
|
|
|
|
|
// stays small so pins near each other don't fight for clicks,
|
|
|
|
|
// but the iconAnchor shifts down to account for the label that
|
|
|
|
|
// hangs below the dot — otherwise the dot would visually sit
|
|
|
|
|
// above its true lat/lng.
|
2026-07-07 15:15:42 +00:00
|
|
|
const hit = DOT_PX + 8;
|
2026-07-07 16:28:19 +00:00
|
|
|
const labelOffset = 12; // approx vertical extent of the label chip
|
2026-07-07 15:15:42 +00:00
|
|
|
return L.divIcon({
|
|
|
|
|
className: 'waypoint-div-icon',
|
|
|
|
|
html: `<div class="waypoint-dot-wrap" style="width:${hit}px;height:${hit}px;">${dotHtml}</div>`,
|
2026-07-07 16:28:19 +00:00
|
|
|
iconSize: [hit, hit + labelOffset],
|
|
|
|
|
// Anchor on the dot center, NOT on the whole icon box. Leaflet
|
|
|
|
|
// positions the marker so this point lands on the lat/lng. We
|
|
|
|
|
// also shift up by half the label height so the label doesn't
|
|
|
|
|
// push the dot up off its true position.
|
2026-07-07 15:15:42 +00:00
|
|
|
iconAnchor: [hit / 2, hit / 2],
|
|
|
|
|
popupAnchor: [0, -(hit / 2) - 4],
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Escape supplier names for safe HTML interpolation inside divIcon.html.
|
|
|
|
|
// (Leaflet renders this html via document.write-ish paths that can
|
|
|
|
|
// break if a supplier name contains `<` or `&` — unlikely but cheap.)
|
|
|
|
|
function escapeHtml(s: string): string {
|
|
|
|
|
return String(s)
|
|
|
|
|
.replace(/&/g, '&')
|
|
|
|
|
.replace(/</g, '<')
|
|
|
|
|
.replace(/>/g, '>')
|
|
|
|
|
.replace(/"/g, '"')
|
|
|
|
|
.replace(/'/g, ''');
|
|
|
|
|
}
|
2026-05-24 19:51:29 +00:00
|
|
|
|
2026-07-07 15:15:42 +00:00
|
|
|
// Current-location marker (you) — distinct shape so it's never confused
|
|
|
|
|
// with a stop. Solid white-bordered dot + slow outer pulse.
|
2026-07-07 16:28:19 +00:00
|
|
|
// Color matches the brand `#f54e00` so the you-marker reads as part of
|
|
|
|
|
// the same orange family as the supplier waypoints (the previous
|
|
|
|
|
// `#ea580c` was Tailwind's default orange-600 and clashed).
|
2026-05-24 19:51:29 +00:00
|
|
|
const currentLocationIcon = L.divIcon({
|
|
|
|
|
className: 'custom-div-icon',
|
Add PWA support and UI redesign
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.
2026-05-26 22:34:50 +00:00
|
|
|
html: `<div style="position:relative;">
|
2026-07-07 16:28:19 +00:00
|
|
|
<div style="width:18px;height:18px;background:#f54e00;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:36px;height:36px;background:rgba(245,78,0,0.18);border-radius:50%;position:absolute;top:-9px;left:-9px;z-index:1;animation:pulse 2s infinite;"></div>
|
Add PWA support and UI redesign
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.
2026-05-26 22:34:50 +00:00
|
|
|
</div>
|
|
|
|
|
<style>@keyframes pulse{0%,100%{transform:scale(1);opacity:1}50%{transform:scale(1.5);opacity:0}}</style>`,
|
2026-07-07 15:15:42 +00:00
|
|
|
iconSize: [18, 18],
|
|
|
|
|
iconAnchor: [9, 9],
|
2026-05-24 19:51:29 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
interface MapViewProps {
|
2026-05-24 22:47:47 +00:00
|
|
|
items: OrderItem[];
|
2026-05-24 19:51:29 +00:00
|
|
|
onNavigate: (lat: number, lng: number, name: string) => void;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-07 15:15:42 +00:00
|
|
|
// One stop = one item's supplier coordinates. Multiple items at the same
|
|
|
|
|
// supplier count as multiple stops at that pin.
|
|
|
|
|
interface ResolvedPoint {
|
|
|
|
|
item: OrderItem;
|
|
|
|
|
supplier: Supplier;
|
|
|
|
|
coords: { latitude: number; longitude: number };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Resolve items to lat/lng; skip rows without a usable supplier location.
|
|
|
|
|
function resolvePoints(items: OrderItem[]): ResolvedPoint[] {
|
|
|
|
|
const out: ResolvedPoint[] = [];
|
|
|
|
|
for (const item of items) {
|
|
|
|
|
const supplier = item.expand?.supplier;
|
|
|
|
|
if (!supplier) continue;
|
|
|
|
|
const coords = parseCoordinates(supplier.coordinates);
|
|
|
|
|
if (!coords) continue;
|
|
|
|
|
out.push({ item, supplier, coords });
|
|
|
|
|
}
|
|
|
|
|
return out;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Cluster radius (in pixels) — points within this pixel distance get merged.
|
|
|
|
|
const CLUSTER_PX = 60;
|
|
|
|
|
|
|
|
|
|
interface Cluster {
|
|
|
|
|
points: ResolvedPoint[];
|
|
|
|
|
center: [number, number]; // average lat/lng of member points
|
|
|
|
|
bounds: L.LatLngBounds;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function clusterPoints(
|
|
|
|
|
points: ResolvedPoint[],
|
|
|
|
|
map: L.Map
|
|
|
|
|
): Cluster[] {
|
|
|
|
|
if (points.length === 0) return [];
|
|
|
|
|
const projected: Array<{ p: ResolvedPoint; px: [number, number] }> = [];
|
|
|
|
|
for (const p of points) {
|
|
|
|
|
const latLng = L.latLng(p.coords.latitude, p.coords.longitude);
|
|
|
|
|
const px = map.latLngToContainerPoint(latLng);
|
|
|
|
|
projected.push({ p, px: [px.x, px.y] });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Grid cluster: snap each point to a grid cell of size CLUSTER_PX.
|
|
|
|
|
// Two points sharing the same cell belong to the same cluster.
|
|
|
|
|
const cellSize = CLUSTER_PX;
|
|
|
|
|
const cellMap = new Map<string, ResolvedPoint[]>();
|
|
|
|
|
for (const { p, px } of projected) {
|
|
|
|
|
const cx = Math.floor(px[0] / cellSize);
|
|
|
|
|
const cy = Math.floor(px[1] / cellSize);
|
|
|
|
|
const key = `${cx}:${cy}`;
|
|
|
|
|
if (!cellMap.has(key)) cellMap.set(key, []);
|
|
|
|
|
cellMap.get(key)!.push(p);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const clusters: Cluster[] = [];
|
|
|
|
|
for (const pts of Array.from(cellMap.values())) {
|
|
|
|
|
const lats = pts.map((p: ResolvedPoint) => p.coords.latitude);
|
|
|
|
|
const lngs = pts.map((p: ResolvedPoint) => p.coords.longitude);
|
|
|
|
|
const centerLat = lats.reduce((a: number, b: number) => a + b, 0) / lats.length;
|
|
|
|
|
const centerLng = lngs.reduce((a: number, b: number) => a + b, 0) / lngs.length;
|
|
|
|
|
const bounds = L.latLngBounds(pts.map((p: ResolvedPoint) => [p.coords.latitude, p.coords.longitude]));
|
|
|
|
|
clusters.push({
|
|
|
|
|
points: pts,
|
|
|
|
|
center: [centerLat, centerLng],
|
|
|
|
|
bounds,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
return clusters;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function MapBounds({ points }: { points: ResolvedPoint[] }) {
|
2026-05-24 19:51:29 +00:00
|
|
|
const map = useMap();
|
|
|
|
|
useEffect(() => {
|
2026-07-07 15:15:42 +00:00
|
|
|
if (points.length === 0) return;
|
|
|
|
|
const bounds = L.latLngBounds(points.map((p) => [p.coords.latitude, p.coords.longitude]));
|
|
|
|
|
map.fitBounds(bounds, { padding: [60, 60], maxZoom: 15 });
|
|
|
|
|
}, [map, points]);
|
2026-05-24 23:33:54 +00:00
|
|
|
return null;
|
|
|
|
|
}
|
2026-05-24 19:51:29 +00:00
|
|
|
|
Add PWA support and UI redesign
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.
2026-05-26 22:34:50 +00:00
|
|
|
function RecenterButton({ center }: { center: [number, number] }) {
|
2026-05-24 23:33:54 +00:00
|
|
|
const map = useMap();
|
Add PWA support and UI redesign
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.
2026-05-26 22:34:50 +00:00
|
|
|
return (
|
|
|
|
|
<button
|
2026-07-07 15:15:42 +00:00
|
|
|
type="button"
|
Add PWA support and UI redesign
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.
2026-05-26 22:34:50 +00:00
|
|
|
onClick={() => map.setView(center, map.getZoom(), { animate: true })}
|
refactor(ui): rebrand app, fix supplier naming
- 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
2026-07-05 20:32:20 +00:00
|
|
|
aria-label="Recenter on me"
|
2026-07-07 15:15:42 +00:00
|
|
|
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 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#f54e00] focus-visible:ring-offset-2 focus-visible:ring-offset-[#f2f1ed]"
|
refactor(ui): rebrand app, fix supplier naming
- 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
2026-07-05 20:32:20 +00:00
|
|
|
style={{ boxShadow: '0 14px 32px rgba(0,0,0,0.18), 0 0 0 1px rgba(38,37,30,0.1)' }}
|
Add PWA support and UI redesign
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.
2026-05-26 22:34:50 +00:00
|
|
|
>
|
refactor(ui): rebrand app, fix supplier naming
- 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
2026-07-05 20:32:20 +00:00
|
|
|
<Crosshair size={18} className="text-[#26251e]" />
|
Add PWA support and UI redesign
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.
2026-05-26 22:34:50 +00:00
|
|
|
</button>
|
|
|
|
|
);
|
2026-05-24 19:51:29 +00:00
|
|
|
}
|
|
|
|
|
|
2026-07-07 15:15:42 +00:00
|
|
|
// MapFloatingControls — MUST live inside <MapContainer>. The RecenterButton
|
|
|
|
|
// and TrailToggle call useMap(), which requires a leaflet context. Earlier
|
|
|
|
|
// revisions tried a createPortal pattern but it failed because the
|
|
|
|
|
// dynamically-created host div was removed by React 18 strict-mode double-
|
|
|
|
|
// effect-invoke before the portal could populate it.
|
|
|
|
|
//
|
|
|
|
|
// The fix: render the FAB stack directly (no portal). The MapContainer
|
|
|
|
|
// takes its children's CSS positioning seriously — we use `position:
|
|
|
|
|
// absolute` on the wrapper, anchor to the map's container via the CSS
|
|
|
|
|
// variables leaflet sets, and accept that the FAB stack is part of the
|
|
|
|
|
// React tree rendered by MapContainer (which is fine — react-leaflet
|
|
|
|
|
// renders arbitrary children inside the map div).
|
|
|
|
|
function MapFloatingControls({
|
|
|
|
|
userLocation,
|
|
|
|
|
showTrail,
|
|
|
|
|
trailCount,
|
|
|
|
|
onToggleTrail,
|
|
|
|
|
}: {
|
|
|
|
|
userLocation: [number, number] | null;
|
|
|
|
|
showTrail: boolean;
|
|
|
|
|
trailCount: number;
|
|
|
|
|
onToggleTrail: () => void;
|
|
|
|
|
}) {
|
|
|
|
|
const map = useMap();
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
className="leaflet-fab-host"
|
|
|
|
|
style={{
|
|
|
|
|
position: 'absolute',
|
|
|
|
|
top: 12,
|
|
|
|
|
right: 12,
|
|
|
|
|
zIndex: 400,
|
|
|
|
|
display: 'flex',
|
|
|
|
|
flexDirection: 'column',
|
|
|
|
|
gap: 8,
|
|
|
|
|
pointerEvents: 'auto',
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{userLocation && (
|
|
|
|
|
<RecenterButton center={userLocation} />
|
|
|
|
|
)}
|
|
|
|
|
{trailCount > 1 && (
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={onToggleTrail}
|
|
|
|
|
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 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#f54e00] focus-visible:ring-offset-2 focus-visible:ring-offset-[#f2f1ed] ${
|
|
|
|
|
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>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Internal helper: re-cluster whenever the map moves or zooms.
|
|
|
|
|
|
|
|
|
|
function ClusterPopupBody({
|
|
|
|
|
cluster,
|
|
|
|
|
userLocation,
|
|
|
|
|
onNavigate,
|
|
|
|
|
}: {
|
|
|
|
|
cluster: Cluster;
|
|
|
|
|
userLocation: [number, number] | null;
|
|
|
|
|
onNavigate: (lat: number, lng: number, name: string) => void;
|
|
|
|
|
}) {
|
|
|
|
|
const supplier = cluster.points[0].supplier;
|
|
|
|
|
const distance = userLocation
|
|
|
|
|
? calculateDistance(userLocation[0], userLocation[1], cluster.center[0], cluster.center[1])
|
|
|
|
|
: null;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="min-w-[220px] max-w-[280px]">
|
|
|
|
|
<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>
|
|
|
|
|
)}
|
|
|
|
|
<div className="flex items-center gap-2 mb-2">
|
|
|
|
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-[#ebeae5] text-[10px] font-bold text-[#26251e]">
|
|
|
|
|
<Package size={10} aria-hidden="true" />
|
|
|
|
|
{cluster.points.length} stop{cluster.points.length !== 1 ? 's' : ''}
|
|
|
|
|
</span>
|
|
|
|
|
{distance && (
|
|
|
|
|
<span className="text-xs text-[#f54e00] font-semibold">
|
|
|
|
|
{distance < 1 ? `${Math.round(distance * 1000)}m` : `${distance.toFixed(1)} km`} away
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="space-y-1.5 mb-3 max-h-[180px] overflow-y-auto">
|
|
|
|
|
{cluster.points.map((p) => (
|
|
|
|
|
<div key={p.item.id} className="text-xs p-2 bg-[#ebeae5] rounded-lg">
|
|
|
|
|
<div className="flex items-center gap-1.5">
|
|
|
|
|
{p.item.problem_flag && <AlertTriangle size={12} className="text-[#b3224a] flex-shrink-0" />}
|
|
|
|
|
<span className="font-medium truncate">{getItemDisplayDescription(p.item)}</span>
|
|
|
|
|
{p.item.sku && (
|
|
|
|
|
<span className="text-[10px] text-[rgba(38,37,30,0.5)] font-mono">#{p.item.sku}</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
<span className="text-[rgba(38,37,30,0.55)] text-[11px]">
|
|
|
|
|
Qty: {getItemDisplayQty(p.item)}
|
|
|
|
|
{p.item.order_qty && p.item.quantity && p.item.order_qty !== p.item.quantity && (
|
|
|
|
|
<span className="ml-1 text-[rgba(38,37,30,0.4)]">
|
|
|
|
|
(cust: {p.item.quantity})
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => onNavigate(cluster.center[0], cluster.center[1], 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>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// A single cluster marker. Click on a multi-point cluster zooms into
|
|
|
|
|
// its bounds so the individual pins fan out; a single-point marker
|
|
|
|
|
// shows the popup normally. All clusters — count 1 or 99 — render the
|
|
|
|
|
// same `buildWaypointIcon` shape, so the map never mixes marker styles.
|
|
|
|
|
//
|
|
|
|
|
// Cluster zoom-in: we fitBounds with maxZoom 17 (street-level), then
|
|
|
|
|
// panBy(-y) so the cluster settles slightly above the FAB stack
|
|
|
|
|
// instead of behind it. Without the pan, the driver has to drag the
|
|
|
|
|
// map before they can read the popup.
|
|
|
|
|
function ClusterMarker({
|
|
|
|
|
cluster,
|
|
|
|
|
onNavigate,
|
|
|
|
|
userLocation,
|
|
|
|
|
areaColor,
|
|
|
|
|
}: {
|
|
|
|
|
cluster: Cluster;
|
|
|
|
|
userLocation: [number, number] | null;
|
|
|
|
|
onNavigate: (lat: number, lng: number, name: string) => void;
|
|
|
|
|
areaColor?: string;
|
|
|
|
|
}) {
|
|
|
|
|
const map = useMap();
|
|
|
|
|
const count = cluster.points.length;
|
|
|
|
|
// Use the SUPPLIER name as the cluster's primary label. Every point
|
|
|
|
|
// in this cluster shares a supplier (and in this dataset sometimes
|
|
|
|
|
// shares coords), so this is the right identity — not an item SKU.
|
|
|
|
|
const supplierName =
|
|
|
|
|
cluster.points[0].supplier.suppliername || 'Unknown supplier';
|
|
|
|
|
|
|
|
|
|
const handleClick = () => {
|
|
|
|
|
if (count === 1) {
|
|
|
|
|
// Singleton: just open the popup at the current zoom — moving the
|
|
|
|
|
// map on every single pin is disorienting when there are several
|
|
|
|
|
// singletons visible. The popup itself is the action.
|
|
|
|
|
const m = cluster.points[0];
|
|
|
|
|
map.panTo([m.coords.latitude, m.coords.longitude], { animate: true });
|
|
|
|
|
} else {
|
|
|
|
|
// Multi-point: fitBounds and zoom in DEEP. maxZoom 17 is full
|
|
|
|
|
// street detail; we then nudge the map down so the cluster
|
|
|
|
|
// appears just below the FAB controls instead of behind them.
|
|
|
|
|
map.fitBounds(cluster.bounds, {
|
|
|
|
|
padding: [40, 40],
|
|
|
|
|
maxZoom: 17,
|
|
|
|
|
animate: true,
|
|
|
|
|
});
|
|
|
|
|
// After fitBounds, shift the visible center down a touch so the
|
|
|
|
|
// pill badge isn't occluded by the top-right control stack.
|
|
|
|
|
// setTimeout because fitBounds updates the view synchronously but
|
|
|
|
|
// the panBy needs the new container size + zoom to be applied.
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
const size = map.getSize();
|
|
|
|
|
if (size.y > 0) {
|
|
|
|
|
map.panBy([0, -Math.round(size.y * 0.12)], { animate: true });
|
|
|
|
|
}
|
|
|
|
|
}, 60);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Marker
|
|
|
|
|
key={`w-${supplierName}-${count}`}
|
|
|
|
|
position={cluster.center}
|
|
|
|
|
icon={buildWaypointIcon(count, supplierName, areaColor)}
|
|
|
|
|
eventHandlers={{ click: handleClick }}
|
|
|
|
|
title={`${supplierName} — ${count} stop${count === 1 ? '' : 's'}`}
|
|
|
|
|
keyboard
|
|
|
|
|
alt={`Waypoint: ${supplierName} (${count} ${count === 1 ? 'stop' : 'stops'})`}
|
|
|
|
|
>
|
|
|
|
|
<Popup>
|
|
|
|
|
<ClusterPopupBody cluster={cluster} userLocation={userLocation} onNavigate={onNavigate} />
|
|
|
|
|
</Popup>
|
|
|
|
|
</Marker>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-24 22:47:47 +00:00
|
|
|
export default function CollectionMap({ items, onNavigate }: MapViewProps) {
|
2026-05-24 19:51:29 +00:00
|
|
|
const [userLocation, setUserLocation] = useState<[number, number] | null>(null);
|
2026-05-24 23:33:54 +00:00
|
|
|
const [trail, setTrail] = useState<[number, number][]>([]);
|
|
|
|
|
const [showTrail, setShowTrail] = useState(true);
|
|
|
|
|
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
2026-05-24 19:51:29 +00:00
|
|
|
|
2026-07-07 15:15:42 +00:00
|
|
|
const points = useMemo(() => resolvePoints(items), [items]);
|
|
|
|
|
|
|
|
|
|
// Proximity-based area coloring — items within 2km share a color
|
|
|
|
|
const areaColorMap = useMemo(() => {
|
|
|
|
|
const clusters = clusterByProximity(items, 2);
|
|
|
|
|
const map = new Map<string, number>();
|
|
|
|
|
clusters.forEach((cluster, idx) => {
|
|
|
|
|
cluster.items.forEach(item => {
|
|
|
|
|
map.set(item.id, idx % AREA_COLORS.length);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
return map;
|
|
|
|
|
}, [items]);
|
|
|
|
|
const [clusters, setClusters] = useState<Cluster[]>([]);
|
|
|
|
|
const mapReadyRef = useRef(false);
|
|
|
|
|
|
2026-05-24 23:33:54 +00:00
|
|
|
// Get current position
|
2026-05-24 19:51:29 +00:00
|
|
|
useEffect(() => {
|
|
|
|
|
if ('geolocation' in navigator) {
|
|
|
|
|
navigator.geolocation.getCurrentPosition(
|
2026-05-24 22:47:47 +00:00
|
|
|
(pos) => setUserLocation([pos.coords.latitude, pos.coords.longitude]),
|
|
|
|
|
() => {},
|
2026-05-24 20:31:35 +00:00
|
|
|
{ enableHighAccuracy: false, timeout: 30000 }
|
2026-05-24 19:51:29 +00:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-05-24 23:33:54 +00:00
|
|
|
// Fetch trail data periodically
|
|
|
|
|
const fetchTrail = useCallback(async () => {
|
|
|
|
|
if (!isAuthenticated()) return;
|
|
|
|
|
const user = getCurrentUser();
|
|
|
|
|
if (!user) return;
|
|
|
|
|
const data = await getDriverTrail(user.id);
|
2026-07-07 15:15:42 +00:00
|
|
|
const pts = data
|
2026-05-24 23:33:54 +00:00
|
|
|
.filter((loc) => loc.latitude && loc.longitude)
|
|
|
|
|
.map((loc) => [loc.latitude, loc.longitude] as [number, number]);
|
2026-07-07 15:15:42 +00:00
|
|
|
if (pts.length > 0) {
|
|
|
|
|
setTrail(pts);
|
2026-05-24 23:33:54 +00:00
|
|
|
}
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
fetchTrail();
|
Add PWA support and UI redesign
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.
2026-05-26 22:34:50 +00:00
|
|
|
intervalRef.current = setInterval(fetchTrail, 60000);
|
2026-05-24 23:33:54 +00:00
|
|
|
return () => {
|
|
|
|
|
if (intervalRef.current) clearInterval(intervalRef.current);
|
|
|
|
|
};
|
|
|
|
|
}, [fetchTrail]);
|
|
|
|
|
|
2026-05-24 22:47:47 +00:00
|
|
|
const defaultCenter: [number, number] = [-33.9249, 18.4241];
|
2026-05-24 19:51:29 +00:00
|
|
|
const center = userLocation || defaultCenter;
|
|
|
|
|
|
2026-05-24 22:47:47 +00:00
|
|
|
const getDistance = (coords: { latitude: number; longitude: number }) => {
|
2026-05-24 20:31:35 +00:00
|
|
|
if (!userLocation) return null;
|
2026-05-24 22:47:47 +00:00
|
|
|
return calculateDistance(userLocation[0], userLocation[1], coords.latitude, coords.longitude);
|
2026-05-24 19:51:29 +00:00
|
|
|
};
|
|
|
|
|
|
2026-07-07 15:15:42 +00:00
|
|
|
// Layer that wires up clustering reactively when items change.
|
|
|
|
|
//
|
|
|
|
|
// Why this used to infinite-loop: the original implementation called
|
|
|
|
|
// setClusters inside the moveend/zoomend handlers AND inside an
|
|
|
|
|
// effect that depended on `points`. Each parent render that produced
|
|
|
|
|
// a new `points` reference (or each moveend fire from Leaflet's
|
|
|
|
|
// internal layout invalidation) triggered another setState, which
|
|
|
|
|
// re-rendered, which produced another `points` reference, which
|
|
|
|
|
// fired the effect again — bounded only by React's depth limit.
|
|
|
|
|
//
|
|
|
|
|
// The fix: compute clusters in a layout-effect-free model. We
|
|
|
|
|
// subscribe to Leaflet's moveend/zoomend (the only times cluster
|
|
|
|
|
// pixel positions actually change) and re-cluster then. The initial
|
|
|
|
|
// cluster pass happens after the map container's first layout
|
|
|
|
|
// settles, gated by mapReadyRef so we never call latLngToContainer
|
|
|
|
|
// on an unmeasured map. Content-dedup via signature string kills
|
|
|
|
|
// redundant state churn even when Leaflet fires spurious moveend.
|
|
|
|
|
function ClusterSync() {
|
|
|
|
|
const map = useMap();
|
|
|
|
|
|
|
|
|
|
// Stable signature function — no deps so the ref never changes.
|
|
|
|
|
const sigOf = useCallback((cs: Cluster[]) =>
|
|
|
|
|
cs.map((c) => `${c.points.length}@${c.center[0].toFixed(4)},${c.center[1].toFixed(4)}`).join('|'),
|
|
|
|
|
[]);
|
|
|
|
|
|
|
|
|
|
// Initial clustering: run once after map layout is ready. We use
|
|
|
|
|
// a raf to wait for Leaflet's first container-size measurement,
|
|
|
|
|
// because latLngToContainerPoint() returns invalid coords before
|
|
|
|
|
// then — which would produce NaN-bearing clusters that flip the
|
|
|
|
|
// sig every frame.
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
let cancelled = false;
|
|
|
|
|
mapReadyRef.current = false;
|
|
|
|
|
// eslint-disable-next-line no-console
|
|
|
|
|
console.log('[ClusterSync] mount effect firing');
|
|
|
|
|
const initialFrame = requestAnimationFrame(() => {
|
|
|
|
|
if (cancelled) return;
|
|
|
|
|
mapReadyRef.current = true;
|
|
|
|
|
const next = clusterPoints(points, map);
|
|
|
|
|
// eslint-disable-next-line no-console
|
|
|
|
|
console.log('[ClusterSync] initial frame, sig=', sigOf(next));
|
|
|
|
|
setClusters((prev) => (sigOf(prev) === sigOf(next) ? prev : next));
|
|
|
|
|
});
|
|
|
|
|
return () => {
|
|
|
|
|
// eslint-disable-next-line no-console
|
|
|
|
|
console.log('[ClusterSync] mount cleanup');
|
|
|
|
|
cancelled = true;
|
|
|
|
|
cancelAnimationFrame(initialFrame);
|
|
|
|
|
};
|
|
|
|
|
// We intentionally don't include `points` or `sigOf` here — this
|
|
|
|
|
// is the FIRST snapshot only. Subsequent updates are driven by
|
|
|
|
|
// the points-effect and the map event handlers below.
|
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
}, [map]);
|
|
|
|
|
|
|
|
|
|
// Re-cluster when the points list itself changes (new items, tab
|
|
|
|
|
// switch, PB poll). Content-deduped.
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!mapReadyRef.current) return;
|
|
|
|
|
const next = clusterPoints(points, map);
|
|
|
|
|
// eslint-disable-next-line no-console
|
|
|
|
|
console.log('[ClusterSync] points change, next.sig=', sigOf(next));
|
|
|
|
|
setClusters((prev) => (sigOf(prev) === sigOf(next) ? prev : next));
|
|
|
|
|
}, [points, map, sigOf]);
|
|
|
|
|
|
|
|
|
|
// Re-cluster on pan/zoom — these are the only events where cluster
|
|
|
|
|
// pixel positions change. Read points from a ref so the handler
|
|
|
|
|
// subscription is stable across renders.
|
2026-07-07 18:43:17 +00:00
|
|
|
//
|
|
|
|
|
// Throttled: Leaflet fires `moveend` many times during a drag
|
|
|
|
|
// (once per animation frame, ish). Running `clusterPoints` on
|
|
|
|
|
// each one with 10+ stops is wasted work — the visible result
|
|
|
|
|
// only changes meaningfully once the user stops dragging.
|
|
|
|
|
// `coalesce` collapses bursts into one trailing-edge call so
|
|
|
|
|
// we re-cluster exactly once after the gesture settles.
|
2026-07-07 15:15:42 +00:00
|
|
|
const pointsRef = useRef(points);
|
|
|
|
|
useEffect(() => { pointsRef.current = points; }, [points]);
|
2026-07-07 18:43:17 +00:00
|
|
|
|
|
|
|
|
const recompute = useCallback(() => {
|
|
|
|
|
if (!mapReadyRef.current) return;
|
|
|
|
|
const next = clusterPoints(pointsRef.current, map);
|
|
|
|
|
setClusters((prev) => (sigOf(prev) === sigOf(next) ? prev : next));
|
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
}, [map]);
|
|
|
|
|
|
|
|
|
|
const coalesceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
|
const scheduleRecompute = useCallback(() => {
|
|
|
|
|
if (coalesceTimer.current !== null) clearTimeout(coalesceTimer.current);
|
|
|
|
|
coalesceTimer.current = setTimeout(() => {
|
|
|
|
|
coalesceTimer.current = null;
|
|
|
|
|
recompute();
|
|
|
|
|
}, 80);
|
|
|
|
|
}, [recompute]);
|
|
|
|
|
|
2026-07-07 15:15:42 +00:00
|
|
|
useMapEvents({
|
2026-07-07 18:43:17 +00:00
|
|
|
zoomend: recompute,
|
|
|
|
|
moveend: scheduleRecompute,
|
2026-07-07 15:15:42 +00:00
|
|
|
});
|
2026-07-07 18:43:17 +00:00
|
|
|
|
|
|
|
|
useEffect(() => () => {
|
|
|
|
|
if (coalesceTimer.current !== null) {
|
|
|
|
|
clearTimeout(coalesceTimer.current);
|
|
|
|
|
coalesceTimer.current = null;
|
|
|
|
|
}
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-07-07 15:15:42 +00:00
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-24 19:51:29 +00:00
|
|
|
return (
|
2026-05-26 23:17:56 +00:00
|
|
|
<div className="absolute inset-0">
|
|
|
|
|
<MapContainer
|
|
|
|
|
center={center}
|
|
|
|
|
zoom={12}
|
|
|
|
|
className="absolute inset-0 z-0"
|
|
|
|
|
zoomControl={false}
|
|
|
|
|
style={{ height: '100%', width: '100%' }}
|
|
|
|
|
>
|
2026-05-24 23:33:54 +00:00
|
|
|
<TileLayer
|
|
|
|
|
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
|
|
|
|
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
|
|
|
|
/>
|
|
|
|
|
|
2026-07-07 15:15:42 +00:00
|
|
|
<MapBounds points={points} />
|
|
|
|
|
<ClusterSync />
|
|
|
|
|
<MapFloatingControls
|
|
|
|
|
userLocation={userLocation}
|
|
|
|
|
showTrail={showTrail}
|
|
|
|
|
trailCount={trail.length}
|
|
|
|
|
onToggleTrail={() => setShowTrail((v) => !v)}
|
|
|
|
|
/>
|
2026-05-24 19:51:29 +00:00
|
|
|
|
|
|
|
|
{userLocation && (
|
|
|
|
|
<Marker position={userLocation} icon={currentLocationIcon}>
|
Add PWA support and UI redesign
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.
2026-05-26 22:34:50 +00:00
|
|
|
<Popup>
|
|
|
|
|
<div className="text-sm font-medium">Your Location</div>
|
|
|
|
|
</Popup>
|
2026-05-24 19:51:29 +00:00
|
|
|
</Marker>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-05-24 23:33:54 +00:00
|
|
|
{/* Trail polyline */}
|
|
|
|
|
{showTrail && trail.length > 1 && (
|
|
|
|
|
<>
|
|
|
|
|
<Polyline
|
|
|
|
|
positions={trail}
|
|
|
|
|
pathOptions={{
|
refactor(ui): rebrand app, fix supplier naming
- 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
2026-07-05 20:32:20 +00:00
|
|
|
color: '#1f8a65',
|
2026-05-24 23:33:54 +00:00
|
|
|
weight: 4,
|
|
|
|
|
opacity: 0.7,
|
|
|
|
|
dashArray: '10, 8',
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
<Marker
|
|
|
|
|
position={trail[0]}
|
|
|
|
|
icon={L.divIcon({
|
|
|
|
|
className: '',
|
refactor(ui): rebrand app, fix supplier naming
- 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
2026-07-05 20:32:20 +00:00
|
|
|
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>',
|
2026-05-24 23:33:54 +00:00
|
|
|
iconSize: [14, 14],
|
|
|
|
|
iconAnchor: [7, 7],
|
|
|
|
|
})}
|
|
|
|
|
>
|
|
|
|
|
<Popup><div className="text-xs font-medium">Trip Start</div></Popup>
|
|
|
|
|
</Marker>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-07-07 15:15:42 +00:00
|
|
|
{clusters.map((cluster, i) => {
|
|
|
|
|
// Find the majority area color among the points in this visual cluster
|
|
|
|
|
const colorCounts = new Map<number, number>();
|
|
|
|
|
for (const p of cluster.points) {
|
|
|
|
|
const ci = areaColorMap.get(p.item.id);
|
|
|
|
|
if (ci !== undefined) {
|
|
|
|
|
colorCounts.set(ci, (colorCounts.get(ci) || 0) + 1);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
let majorityColor: string | undefined;
|
|
|
|
|
if (colorCounts.size > 0) {
|
|
|
|
|
let maxCount = 0;
|
|
|
|
|
let maxIdx = 0;
|
|
|
|
|
Array.from(colorCounts.entries()).forEach(([idx, count]) => {
|
|
|
|
|
if (count > maxCount) { maxCount = count; maxIdx = idx; }
|
|
|
|
|
});
|
|
|
|
|
majorityColor = AREA_COLORS[maxIdx];
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-24 19:51:29 +00:00
|
|
|
return (
|
2026-07-07 15:15:42 +00:00
|
|
|
<ClusterMarker
|
|
|
|
|
key={`cm-${i}`}
|
|
|
|
|
cluster={cluster}
|
|
|
|
|
userLocation={userLocation}
|
|
|
|
|
onNavigate={onNavigate}
|
|
|
|
|
areaColor={majorityColor}
|
|
|
|
|
/>
|
2026-05-24 19:51:29 +00:00
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</MapContainer>
|
|
|
|
|
|
2026-07-07 15:15:42 +00:00
|
|
|
{/* Legend — pure UI, no leaflet hooks, safe to render outside MapContainer. */}
|
|
|
|
|
<div
|
|
|
|
|
className="absolute bottom-24 right-3 z-[400] 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-2">
|
|
|
|
|
<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">
|
|
|
|
|
{/* Miniature of the actual dot + a tiny corner badge so the legend
|
|
|
|
|
matches exactly what the map renders — not the old pill. */}
|
|
|
|
|
<div className="relative w-5 h-5 flex items-center justify-center">
|
|
|
|
|
<div className="w-3.5 h-3.5 rounded-full bg-[#f54e00] border border-white shadow-sm" />
|
|
|
|
|
<div className="absolute -top-0.5 -right-0.5 min-w-[12px] h-[12px] px-[3px] flex items-center justify-center rounded-full bg-[#26251e] text-[7px] font-bold text-[#f2f1ed] border border-white">
|
|
|
|
|
N
|
|
|
|
|
</div>
|
2026-05-24 23:33:54 +00:00
|
|
|
</div>
|
2026-07-07 15:15:42 +00:00
|
|
|
<span className="text-[11px] text-[#26251e]">Waypoint (N stops)</span>
|
|
|
|
|
</div>
|
|
|
|
|
{trail.length > 1 && (
|
2026-05-24 23:33:54 +00:00
|
|
|
<div className="flex items-center gap-2">
|
2026-07-07 15:15:42 +00:00
|
|
|
<div className="w-5 h-0" style={{ borderTop: '2px dashed #1f8a65' }} />
|
|
|
|
|
<span className="text-[11px] text-[#26251e]">Route</span>
|
2026-05-24 19:51:29 +00:00
|
|
|
</div>
|
2026-07-07 15:15:42 +00:00
|
|
|
)}
|
2026-05-24 19:51:29 +00:00
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-07-07 15:15:42 +00:00
|
|
|
|
|
|
|
|
{/* Empty-state overlay. Shown when the API returned items but none
|
|
|
|
|
of them have usable supplier coordinates (the most common cause
|
|
|
|
|
in this codebase: supplier row exists but coordinates field is
|
|
|
|
|
blank). The map still renders, so the driver can manually
|
|
|
|
|
navigate via the list view and search for the supplier. */}
|
|
|
|
|
{points.length === 0 && (
|
|
|
|
|
<div
|
|
|
|
|
role="status"
|
|
|
|
|
aria-live="polite"
|
|
|
|
|
className="absolute inset-x-0 bottom-24 mx-auto max-w-xs px-4 py-3 bg-[#e6e5e0]/95 backdrop-blur-sm rounded-xl text-center z-[400] focus-within:ring-2 focus-within:ring-[#f54e00]"
|
|
|
|
|
style={{ boxShadow: '0 14px 32px rgba(0,0,0,0.18), 0 0 0 1px rgba(38,37,30,0.1)' }}
|
|
|
|
|
>
|
|
|
|
|
<p className="text-sm font-semibold text-[#26251e]">No stops on the map yet</p>
|
|
|
|
|
<p className="text-[11px] text-[rgba(38,37,30,0.55)] mt-1">
|
|
|
|
|
Assigned pickups need a supplier address with coordinates. Use the list to add one.
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-05-24 19:51:29 +00:00
|
|
|
</div>
|
|
|
|
|
);
|
Add PWA support and UI redesign
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.
2026-05-26 22:34:50 +00:00
|
|
|
}
|