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.
This commit is contained in:
SJCoetz 2026-05-27 00:34:50 +02:00
parent c66f06b9ab
commit db10eb95c7
37 changed files with 2110 additions and 707 deletions

1
.gitignore vendored
View file

@ -1,4 +1,5 @@
node_modules/
.next/
dist/
out/
.env

View file

@ -4,7 +4,7 @@ FROM node:20-alpine AS builder
WORKDIR /app
# Install dependencies
# Install dependencies (including sharp for icon generation)
COPY package.json package-lock.json ./
RUN npm ci
@ -15,8 +15,11 @@ COPY . .
ARG NEXT_PUBLIC_POCKETBASE_URL
ENV NEXT_PUBLIC_POCKETBASE_URL=${NEXT_PUBLIC_POCKETBASE_URL}
# Build the static export
RUN npm run build
# Generate PWA icons
RUN node scripts/generate-icons.js
# Build the static export (sets NEXT_EXPORT=true)
RUN npm run export
# Stage 2: Serve with nginx
FROM nginx:alpine
@ -24,14 +27,12 @@ FROM nginx:alpine
# Remove default nginx static assets
RUN rm -rf /usr/share/nginx/html/*
# Copy built static files from builder
COPY --from=builder /app/dist /usr/share/nginx/html
# Copy built static files from builder (Next.js export outputs to /out)
COPY --from=builder /app/out /usr/share/nginx/html
# Copy custom nginx config
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
CMD ["nginx", "-g", "daemon off;"]

View file

@ -4,12 +4,14 @@
:root {
--foreground-rgb: 0, 0, 0;
--background-rgb: 245, 245, 245;
--background-rgb: 249, 250, 251;
--primary: 234, 88, 12;
--primary-light: 249, 115, 22;
--success: 34, 197, 94;
--warning: 234, 179, 8;
--danger: 239, 68, 68;
--info: 6, 182, 212;
--nav-height: 4rem;
}
html,
@ -18,18 +20,22 @@ body {
overflow-x: hidden;
height: 100%;
-webkit-tap-highlight-color: transparent;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
color: rgb(var(--foreground-rgb));
background: rgb(var(--background-rgb));
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
overscroll-behavior-y: none;
-webkit-user-select: none;
user-select: none;
/* Smooth page transitions */
scroll-behavior: smooth;
}
/* Mobile viewport height fix */
/* Mobile viewport height fix (100vh is wrong on mobile) */
@supports (-webkit-touch-callout: none) {
.h-screen {
height: -webkit-fill-available;
@ -48,7 +54,12 @@ body {
padding-bottom: env(safe-area-inset-bottom);
}
/* Hide scrollbar but keep functionality */
/* Content must account for bottom nav */
.pb-nav {
padding-bottom: calc(var(--nav-height) + env(safe-area-inset-bottom, 0px) + 1rem);
}
/* Hide scrollbar but keep scroll functionality */
.no-scrollbar::-webkit-scrollbar {
display: none;
}
@ -62,67 +73,237 @@ body {
touch-action: manipulation;
}
/* Prevent text selection on buttons */
button {
/* Prevent text selection on interactive elements */
button, a, nav {
-webkit-user-select: none;
user-select: none;
}
/* Allow text selection in content areas */
.selectable {
-webkit-user-select: text;
user-select: text;
}
/* Leaflet container fix */
.leaflet-container {
width: 100%;
height: 100%;
z-index: 1;
font-family: inherit;
}
/* Animation utilities */
/* ========================================
Animations
======================================== */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideInRight {
from { transform: translateX(100%); }
to { transform: translateX(0); }
@keyframes slideUp {
from {
transform: translateY(100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
@keyframes slideOutRight {
from { transform: translateX(0); }
to { transform: translateX(100%); }
@keyframes slideDown {
from {
transform: translateY(-100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
@keyframes scaleIn {
from {
transform: scale(0.95);
opacity: 0;
}
to {
transform: scale(1);
opacity: 1;
}
}
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
.animate-fade-in {
animation: fadeIn 0.3s ease-out forwards;
}
/* Status badges */
.animate-slide-up {
animation: slideUp 0.35s cubic-bezier(0.32, 0.72, 0, 1) forwards;
}
.animate-slide-down {
animation: slideDown 0.3s ease-out forwards;
}
.animate-scale-in {
animation: scaleIn 0.2s ease-out forwards;
}
/* Stagger animation delay helper */
.stagger-1 { animation-delay: 0.05s; }
.stagger-2 { animation-delay: 0.1s; }
.stagger-3 { animation-delay: 0.15s; }
.stagger-4 { animation-delay: 0.2s; }
.stagger-5 { animation-delay: 0.25s; }
/* ========================================
Card Styles
======================================== */
.card-shadow {
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.06),
0 1px 3px rgba(0, 0, 0, 0.1);
}
.card-shadow-md {
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -2px rgba(0, 0, 0, 0.1);
}
.card-shadow-lg {
box-shadow:
0 10px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -4px rgba(0, 0, 0, 0.1);
}
/* Pressed/active state for cards */
.card-press {
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.card-press:active {
transform: scale(0.985);
box-shadow:
0 1px 1px rgba(0, 0, 0, 0.06),
0 1px 2px rgba(0, 0, 0, 0.08);
}
/* ========================================
Status Badges
======================================== */
.status-pending { @apply bg-gray-100 text-gray-700 border-gray-300; }
.status-assigned { @apply bg-blue-100 text-blue-700 border-blue-300; }
.status-collected { @apply bg-green-100 text-green-700 border-green-300; }
.status-problem { @apply bg-red-100 text-red-700 border-red-300; }
/* Card shadows */
.card-shadow {
box-shadow: 0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.24);
/* ========================================
Loading Skeleton
======================================== */
.skeleton {
background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
.card-shadow-lg {
box-shadow: 0 4px 6px rgba(0,0,0,0.1), 0 2px 4px rgba(0,0,0,0.06);
/* ========================================
Modal Overlay
======================================== */
.modal-overlay {
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
}
/* Pull to refresh indicator */
/* ========================================
Input Focus Rings
======================================== */
input:focus,
textarea:focus,
select:focus {
outline: none;
box-shadow: 0 0 0 2px rgba(234, 88, 12, 0.3);
}
/* ========================================
Bottom Sheet (iOS-style modal)
======================================== */
.bottom-sheet {
border-radius: 1rem 1rem 0 0;
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.15);
}
@media (min-width: 640px) {
.bottom-sheet {
border-radius: 1rem;
}
}
/* ========================================
Custom scrollbar (for when needed)
======================================== */
.custom-scrollbar::-webkit-scrollbar {
width: 4px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.15);
border-radius: 2px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.25);
}
/* ========================================
Pull-to-refresh visual feedback
======================================== */
.pull-indicator {
transition: transform 0.2s ease-out;
}
/* Loading skeleton */
.skeleton {
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: skeleton-loading 1.5s infinite;
/* ========================================
Haptic-like press feedback
======================================== */
.press-feedback {
transition: transform 0.1s ease, opacity 0.1s ease;
}
@keyframes skeleton-loading {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
.press-feedback:active {
transform: scale(0.96);
opacity: 0.9;
}
.press-feedback-sm {
transition: transform 0.1s ease, opacity 0.1s ease;
}
.press-feedback-sm:active {
transform: scale(0.98);
opacity: 0.9;
}

View file

@ -1,16 +1,22 @@
import type { Metadata, Viewport } from "next";
import "./globals.css";
import { AuthProvider } from "@/components/AuthProvider";
import OfflineIndicator from "@/components/OfflineIndicator";
import InstallPrompt from "@/components/InstallPrompt";
import { PWABridge } from "@/components/PWABridge";
export const metadata: Metadata = {
title: "1Driver - Collections",
description: "Mobile driver collection app",
description: "Mobile driver collection management app",
manifest: "/manifest.json",
appleWebApp: {
capable: true,
statusBarStyle: "black-translucent",
title: "1Driver",
},
formatDetection: {
telephone: false,
},
};
export const viewport: Viewport = {
@ -19,7 +25,7 @@ export const viewport: Viewport = {
maximumScale: 1,
userScalable: false,
viewportFit: "cover",
themeColor: "#ea580c",
themeColor: "#ea580c",
};
export default function RootLayout({
@ -30,17 +36,25 @@ export default function RootLayout({
return (
<html lang="en">
<head>
<link rel="apple-touch-icon" href="/icons/icon.svg" />
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
<link rel="apple-touch-icon" sizes="152x152" href="/icons/icon-152.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/icons/apple-touch-icon.png" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="theme-color" content="#ea580c" />
<meta name="background-color" content="#f97316" />
<link rel="preconnect" href="https://cdnjs.cloudflare.com" />
<link rel="preconnect" href="https://tile.openstreetmap.org" />
</head>
<body className="antialiased bg-gray-50 safe-top safe-bottom">
<body className="antialiased bg-gray-50 safe-top safe-bottom overscroll-none">
<AuthProvider>
<PWABridge />
<OfflineIndicator />
<InstallPrompt />
{children}
</AuthProvider>
</body>
</html>
);
}
}

View file

@ -19,7 +19,6 @@ export default function LoginPage() {
try {
await login(email, password);
// Auth provider will detect the change and redirect
window.location.href = '/';
} catch (err) {
setError('Invalid email or password');
@ -29,85 +28,92 @@ export default function LoginPage() {
};
return (
<div className="min-h-screen bg-gradient-to-br from-orange-500 to-orange-700 flex items-center justify-center p-4">
<div className="w-full max-w-sm bg-white rounded-2xl shadow-2xl p-6 animate-fade-in">
{/* Logo */}
<div className="min-h-screen bg-gradient-to-br from-orange-500 via-orange-600 to-orange-700 flex items-center justify-center p-4">
<div className="w-full max-w-sm animate-fade-in">
{/* Logo area */}
<div className="text-center mb-8">
<div className="w-16 h-16 bg-orange-500 rounded-2xl mx-auto mb-4 flex items-center justify-center shadow-lg">
<Truck size={32} className="text-white" />
<div className="w-20 h-20 bg-white rounded-3xl mx-auto mb-5 flex items-center justify-center shadow-xl shadow-orange-900/30">
<Truck size={36} className="text-orange-500" />
</div>
<h1 className="text-2xl font-bold text-gray-900">1Driver</h1>
<p className="text-sm text-gray-500 mt-1">Collections App</p>
<h1 className="text-3xl font-bold text-white tracking-tight">1Driver</h1>
<p className="text-orange-100 text-sm mt-1">Collection Management</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">
Email
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full p-3 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-orange-500 focus:border-transparent transition-all"
placeholder="driver@example.com"
required
autoFocus
autoCapitalize="off"
autoCorrect="off"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1.5">
Password
</label>
<div className="relative">
{/* Card */}
<div className="bg-white rounded-3xl shadow-2xl p-6">
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label className="block text-sm font-semibold text-gray-700 mb-1.5">
Email
</label>
<input
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full p-3 pr-12 border border-gray-300 rounded-xl text-sm focus:ring-2 focus:ring-orange-500 focus:border-transparent transition-all"
placeholder="Enter password"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full p-3.5 bg-gray-50 border border-gray-200 rounded-xl text-sm focus:ring-2 focus:ring-orange-500/30 focus:border-orange-500 focus:bg-white transition-all"
placeholder="driver@example.com"
required
autoFocus
autoCapitalize="off"
autoCorrect="off"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 text-gray-400 hover:text-gray-600"
>
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
</div>
</div>
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-xl">
<p className="text-xs text-red-600 text-center">{error}</p>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-1.5">
Password
</label>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full p-3.5 pr-12 bg-gray-50 border border-gray-200 rounded-xl text-sm focus:ring-2 focus:ring-orange-500/30 focus:border-orange-500 focus:bg-white transition-all"
placeholder="Enter password"
required
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 p-1.5 text-gray-400 hover:text-gray-600 active:bg-gray-100 rounded-lg transition-colors"
>
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
</div>
</div>
)}
<button
type="submit"
disabled={isLoading}
className={cn(
'w-full py-3.5 px-4 rounded-xl text-white font-semibold text-base',
'bg-orange-500 active:bg-orange-600 transition-colors',
'touch-manipulation flex items-center justify-center gap-2',
isLoading && 'opacity-70 cursor-not-allowed'
{error && (
<div className="p-3 bg-red-50 border border-red-100 rounded-xl animate-fade-in">
<p className="text-xs text-red-600 text-center font-medium">{error}</p>
</div>
)}
>
{isLoading ? (
<>
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
Signing in...
</>
) : (
'Sign In'
)}
</button>
</form>
<button
type="submit"
disabled={isLoading}
className={cn(
'w-full py-3.5 px-4 rounded-xl text-white font-semibold text-[15px]',
'bg-orange-500 active:bg-orange-600 transition-all shadow-lg shadow-orange-500/30',
'touch-manipulation press-feedback',
isLoading && 'opacity-70 cursor-not-allowed'
)}
>
{isLoading ? (
<span className="flex items-center justify-center gap-2">
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
Signing in...
</span>
) : (
'Sign In'
)}
</button>
</form>
</div>
<p className="text-center text-orange-100/70 text-xs mt-6">
Contact your administrator for account access
</p>
</div>
</div>
);
}
}

View file

@ -8,14 +8,17 @@ import { useAuth } from '@/components/AuthProvider';
import BottomNav from '@/components/BottomNav';
import LocationTracker from '@/components/LocationTracker';
import { getAssignedCollections, isAuthenticated } from '@/lib/pocketbase';
import { cn, parseCoordinates } from '@/lib/utils';
import { cn } from '@/lib/utils';
import { RefreshCw, MapPin, AlertCircle } from 'lucide-react';
const CollectionMap = dynamic(() => import('@/components/CollectionMap'), {
ssr: false,
loading: () => (
<div className="h-full flex items-center justify-center bg-gray-100">
<div className="w-8 h-8 border-2 border-orange-500 border-t-transparent rounded-full animate-spin" />
<div className="flex flex-col items-center gap-3">
<div className="w-8 h-8 border-[3px] border-orange-500 border-t-transparent rounded-full animate-spin" />
<p className="text-sm text-gray-400">Loading map...</p>
</div>
</div>
),
});
@ -54,14 +57,17 @@ export default function MapPage() {
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
const url = isIOS
? `maps://maps.apple.com/?q=${encodeURIComponent(name)}&ll=${lat},${lng}`
: `https://www.google.com/maps?q=${lat},${lng}`;
: `https://www.google.com/maps/dir/?api=1&destination=${lat},${lng}`;
window.open(url, '_blank');
};
if (authLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="w-8 h-8 border-2 border-orange-500 border-t-transparent rounded-full animate-spin" />
<div className="flex flex-col items-center gap-3">
<div className="w-10 h-10 border-[3px] border-orange-500 border-t-transparent rounded-full animate-spin" />
<p className="text-sm text-gray-400">Loading...</p>
</div>
</div>
);
}
@ -72,46 +78,57 @@ export default function MapPage() {
<div className="h-screen flex flex-col bg-gray-50">
<LocationTracker />
<header className="bg-orange-600 text-white safe-top z-40 shadow-md flex-shrink-0">
<header className="bg-orange-600 text-white safe-top z-40 shadow-lg shadow-orange-600/20 flex-shrink-0">
<div className="px-4 py-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<MapPin size={20} />
<h1 className="text-lg font-bold">Collection Map</h1>
</div>
<div className="flex items-center gap-2">
<span className="bg-orange-700/50 px-2 py-1 rounded-full text-xs">{items.length} stops</span>
<button
onClick={() => { setIsRefreshing(true); loadItems(false); }}
disabled={isRefreshing}
className="p-2 rounded-lg bg-orange-700/50 active:bg-orange-700 transition-colors"
>
<RefreshCw size={18} className={cn(isRefreshing && 'animate-spin')} />
</button>
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 bg-white/15 rounded-xl flex items-center justify-center backdrop-blur-sm">
<MapPin size={18} />
</div>
<div>
<h1 className="text-lg font-bold leading-tight">Map</h1>
<p className="text-[11px] text-orange-100">{items.length} stop{items.length !== 1 ? 's' : ''}</p>
</div>
</div>
<button
onClick={() => { setIsRefreshing(true); loadItems(false); }}
disabled={isRefreshing}
className="p-2.5 rounded-xl bg-white/15 backdrop-blur-sm active:bg-white/25 transition-all touch-manipulation"
>
<RefreshCw size={18} className={cn(isRefreshing && 'animate-spin')} />
</button>
</div>
</header>
<main className="flex-1 relative">
{isLoading ? (
<div className="h-full flex items-center justify-center bg-gray-100">
<div className="w-8 h-8 border-2 border-orange-500 border-t-transparent rounded-full animate-spin" />
<div className="flex flex-col items-center gap-3">
<div className="w-8 h-8 border-[3px] border-orange-500 border-t-transparent rounded-full animate-spin" />
<p className="text-sm text-gray-400">Loading map...</p>
</div>
</div>
) : error ? (
<div className="h-full flex flex-col items-center justify-center bg-gray-100 p-4">
<AlertCircle size={48} className="text-red-400 mb-3" />
<div className="w-16 h-16 bg-red-50 rounded-2xl flex items-center justify-center mb-4">
<AlertCircle size={32} className="text-red-400" />
</div>
<p className="text-gray-600 text-sm mb-4 text-center">{error}</p>
<button onClick={() => loadItems()} className="px-4 py-2 bg-orange-500 text-white rounded-lg text-sm font-medium">Try Again</button>
<button onClick={() => loadItems()} className="px-5 py-2.5 bg-orange-500 text-white rounded-xl text-sm font-medium active:bg-orange-600 touch-manipulation press-feedback">
Try Again
</button>
</div>
) : items.length === 0 ? (
<div className="h-full flex flex-col items-center justify-center bg-gray-100 p-4">
<MapPin size={48} className="text-gray-300 mb-3" />
<p className="text-gray-600 font-medium">No collections to show</p>
<div className="w-16 h-16 bg-gray-100 rounded-2xl flex items-center justify-center mb-4">
<MapPin size={32} className="text-gray-300" />
</div>
<p className="text-gray-600 font-semibold">No collections to show</p>
<p className="text-gray-400 text-sm mt-1">All collections completed!</p>
</div>
) : (
<Suspense fallback={
<div className="h-full flex items-center justify-center bg-gray-100">
<div className="w-8 h-8 border-2 border-orange-500 border-t-transparent rounded-full animate-spin" />
<div className="w-8 h-8 border-[3px] border-orange-500 border-t-transparent rounded-full animate-spin" />
</div>
}>
<CollectionMap items={items} onNavigate={handleNavigate} />
@ -122,4 +139,4 @@ export default function MapPage() {
<BottomNav />
</div>
);
}
}

View file

@ -23,10 +23,10 @@ import {
MapPin,
AlertCircle,
CheckCircle2,
Clock,
Package,
Search,
X,
Package,
ListFilter,
} from 'lucide-react';
export default function CollectionsPage() {
@ -42,12 +42,9 @@ export default function CollectionsPage() {
const [showCollectModal, setShowCollectModal] = useState(false);
const [showProblemModal, setShowProblemModal] = useState(false);
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' | 'warning' } | null>(null);
const [filter, setFilter] = useState<'all' | 'today' | 'all_date'>('all');
useEffect(() => {
if (!authLoading && !isLoggedIn) {
router.push('/login/');
}
if (!authLoading && !isLoggedIn) router.push('/login/');
}, [isLoggedIn, authLoading, router]);
const loadItems = useCallback(async (showLoading = true) => {
@ -61,16 +58,14 @@ export default function CollectionsPage() {
setFilteredItems(data);
} catch (err) {
console.error('Failed to load collections:', err);
setError('Failed to load collections. Please try again.');
setError('Failed to load collections. Pull down to retry.');
} finally {
setIsLoading(false);
setIsRefreshing(false);
}
}, []);
useEffect(() => {
loadItems();
}, [loadItems]);
useEffect(() => { loadItems(); }, [loadItems]);
useEffect(() => {
let filtered = items;
@ -109,7 +104,6 @@ export default function CollectionsPage() {
notes,
collected_date: new Date().toISOString(),
});
setItems((prev) => prev.filter((i) => i.id !== item.id));
setShowCollectModal(false);
showToast('Collection completed successfully');
@ -119,12 +113,7 @@ export default function CollectionsPage() {
}
};
const handleProblem = async (
item: OrderItem,
problemType: string,
description: string,
photo?: File
) => {
const handleProblem = async (item: OrderItem, problemType: string, description: string) => {
try {
await reportProblem(item.id, problemType, description);
setItems((prev) => prev.filter((i) => i.id !== item.id));
@ -140,7 +129,7 @@ export default function CollectionsPage() {
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
const url = isIOS
? `maps://maps.apple.com/?q=${encodeURIComponent(name)}&ll=${lat},${lng}`
: `https://www.google.com/maps?q=${lat},${lng}`;
: `https://www.google.com/maps/dir/?api=1&destination=${lat},${lng}`;
window.open(url, '_blank');
};
@ -163,7 +152,10 @@ export default function CollectionsPage() {
if (authLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="w-8 h-8 border-2 border-orange-500 border-t-transparent rounded-full animate-spin" />
<div className="flex flex-col items-center gap-3">
<div className="w-10 h-10 border-[3px] border-orange-500 border-t-transparent rounded-full animate-spin" />
<p className="text-sm text-gray-400">Loading...</p>
</div>
</div>
);
}
@ -171,106 +163,135 @@ export default function CollectionsPage() {
if (!isLoggedIn) return null;
return (
<div className="min-h-screen bg-gray-50 pb-20">
<div className="min-h-screen bg-gray-50 pb-nav">
<LocationTracker />
<header className="bg-orange-600 text-white safe-top sticky top-0 z-40 shadow-md">
<div className="px-4 py-3">
{/* Header */}
<header className="bg-orange-600 text-white safe-top sticky top-0 z-40 shadow-lg shadow-orange-600/20">
<div className="px-4 pt-2 pb-3">
<div className="flex items-center justify-between">
<div>
<h1 className="text-lg font-bold">Collections</h1>
<p className="text-xs text-orange-100">{user?.name || user?.email}</p>
<div className="flex-1 min-w-0">
<h1 className="text-xl font-bold tracking-tight">Collections</h1>
<p className="text-[13px] text-orange-100 truncate">{user?.name || user?.email}</p>
</div>
<div className="flex items-center gap-2">
<button onClick={handleRefresh} disabled={isRefreshing} className="p-2 rounded-lg bg-orange-700/50 active:bg-orange-700 transition-colors">
<RefreshCw size={18} className={cn(isRefreshing && 'animate-spin')} />
</button>
<button onClick={() => logout()} className="p-2 rounded-lg bg-orange-700/50 active:bg-orange-700 transition-colors">
<div className="flex items-center gap-1.5">
<div className="relative">
<span className="absolute -top-0.5 -right-0.5 w-2.5 h-2.5 bg-green-400 rounded-full border-2 border-orange-600 animate-pulse" />
<button
onClick={handleRefresh}
disabled={isRefreshing}
className="p-2.5 rounded-xl bg-white/15 backdrop-blur-sm active:bg-white/25 transition-all touch-manipulation"
>
<RefreshCw size={18} className={cn(isRefreshing && 'animate-spin')} />
</button>
</div>
<button
onClick={() => logout()}
className="p-2.5 rounded-xl bg-white/15 backdrop-blur-sm active:bg-white/25 transition-all touch-manipulation"
>
<LogOut size={18} />
</button>
</div>
</div>
</div>
{/* Search bar */}
<div className="px-4 pb-3">
<div className="relative">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-white/50" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search collections..."
className="w-full pl-9 pr-9 py-2 bg-white/10 border border-white/20 rounded-xl text-sm text-white placeholder-white/60 focus:outline-none focus:ring-2 focus:ring-white/30"
placeholder="Search by item, supplier, order..."
className="w-full pl-9 pr-9 py-2.5 bg-white/15 backdrop-blur-sm border border-white/20 rounded-xl text-sm text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-white/30 focus:bg-white/20 transition-all"
/>
{searchQuery && (
<button onClick={() => setSearchQuery('')} className="absolute right-3 top-1/2 -translate-y-1/2 text-white/60">
<button
onClick={() => setSearchQuery('')}
className="absolute right-3 top-1/2 -translate-y-1/2 text-white/60 active:text-white"
>
<X size={16} />
</button>
)}
</div>
</div>
<div className="px-4 pb-2 flex gap-2 overflow-x-auto no-scrollbar">
{[
{ key: 'all' as const, label: 'All', icon: Package },
].map(({ key, label, icon: Icon }) => (
<button
key={key}
onClick={() => setFilter(key)}
className={cn(
'flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium whitespace-nowrap transition-colors',
filter === key ? 'bg-white text-orange-600' : 'bg-orange-700/50 text-orange-100'
)}
>
<Icon size={12} />
{label}
<span className="bg-orange-800/50 px-1.5 py-0.5 rounded-full text-[10px]">{items.length}</span>
</button>
))}
{/* Stats pill */}
<div className="px-4 pb-2 flex items-center gap-2">
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-white/15 text-[11px] font-medium">
<Package size={12} />
{items.length} item{items.length !== 1 ? 's' : ''}
</span>
{groupedArray.length > 0 && (
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-white/15 text-[11px] font-medium">
<MapPin size={12} />
{groupedArray.length} stop{groupedArray.length !== 1 ? 's' : ''}
</span>
)}
</div>
</header>
<main className="p-4">
{/* Content */}
<main className="p-4 custom-scrollbar">
{isLoading ? (
<div className="space-y-3">
{[...Array(5)].map((_, i) => (
<div key={i} className="bg-white rounded-xl p-4 card-shadow">
<div className="skeleton h-4 w-3/4 rounded mb-2" />
<div className="skeleton h-3 w-1/2 rounded mb-1" />
<div className="skeleton h-3 w-2/3 rounded" />
{[...Array(4)].map((_, i) => (
<div key={i} className="bg-white rounded-2xl p-4 card-shadow stagger-1">
<div className="skeleton h-4 w-3/4 rounded-lg mb-3" />
<div className="skeleton h-3 w-1/2 rounded-lg mb-2" />
<div className="skeleton h-3 w-2/3 rounded-lg" />
</div>
))}
</div>
) : error ? (
<div className="text-center py-12">
<AlertCircle size={48} className="mx-auto text-red-400 mb-3" />
<p className="text-gray-600 text-sm mb-4">{error}</p>
<button onClick={handleRefresh} className="px-4 py-2 bg-orange-500 text-white rounded-lg text-sm font-medium active:bg-orange-600">
<div className="text-center py-16 px-4">
<div className="w-16 h-16 bg-red-50 rounded-2xl flex items-center justify-center mx-auto mb-4">
<AlertCircle size={32} className="text-red-400" />
</div>
<p className="text-gray-600 font-medium mb-1">{error}</p>
<p className="text-gray-400 text-sm mb-6">Check your connection and try again</p>
<button
onClick={handleRefresh}
className="inline-flex items-center gap-2 px-6 py-3 bg-orange-500 text-white rounded-xl text-sm font-semibold active:bg-orange-600 transition-colors touch-manipulation press-feedback"
>
<RefreshCw size={16} />
Try Again
</button>
</div>
) : groupedArray.length === 0 ? (
<div className="text-center py-12">
<CheckCircle2 size={48} className="mx-auto text-green-400 mb-3" />
<p className="text-gray-600 font-medium">All caught up!</p>
<p className="text-gray-400 text-sm mt-1">
{searchQuery ? 'No collections match your search' : 'No assigned collections'}
<div className="text-center py-16 px-4">
<div className="w-16 h-16 bg-green-50 rounded-2xl flex items-center justify-center mx-auto mb-4">
<CheckCircle2 size={32} className="text-green-400" />
</div>
<p className="text-gray-700 font-semibold text-lg mb-1">All caught up!</p>
<p className="text-gray-400 text-sm">
{searchQuery ? 'No collections match your search' : 'No assigned collections right now'}
</p>
</div>
) : (
<div className="space-y-6">
{groupedArray.map(({ supplier, items }) => (
<div key={supplier!.id}>
<div className="flex items-center gap-2 mb-2 px-1">
<MapPin size={14} className="text-orange-500" />
<h2 className="font-semibold text-sm text-gray-800">{supplier!.suppliername}</h2>
<span className="bg-orange-100 text-orange-700 text-[10px] px-2 py-0.5 rounded-full font-medium">{items.length}</span>
{supplier!.supplieraddress && (
<span className="text-xs text-gray-400 truncate flex-1 text-right">{supplier!.supplieraddress}</span>
)}
{groupedArray.map(({ supplier, items: supplierItems }, groupIdx) => (
<div key={supplier!.id} className="animate-fade-in" style={{ animationDelay: `${groupIdx * 0.05}s` }}>
{/* Supplier header */}
<div className="flex items-center gap-2.5 mb-3 px-1">
<div className="w-8 h-8 bg-orange-100 rounded-lg flex items-center justify-center flex-shrink-0">
<MapPin size={14} className="text-orange-600" />
</div>
<div className="flex-1 min-w-0">
<h2 className="font-semibold text-sm text-gray-900 truncate">{supplier!.suppliername}</h2>
{supplier!.supplieraddress && (
<p className="text-[11px] text-gray-400 truncate">{supplier!.supplieraddress}</p>
)}
</div>
<span className="bg-orange-500 text-white text-[10px] px-2 py-0.5 rounded-full font-bold">
{supplierItems.length}
</span>
</div>
<div className="space-y-2">
{items.map((item) => (
{/* Items */}
<div className="space-y-2.5">
{supplierItems.map((item) => (
<CollectionItemCard
key={item.id}
item={item}
@ -286,6 +307,7 @@ export default function CollectionsPage() {
)}
</main>
{/* Modals */}
<CollectModal
item={selectedItem}
isOpen={showCollectModal}
@ -300,10 +322,11 @@ export default function CollectionsPage() {
onConfirm={handleProblem}
/>
{/* Toast */}
{toast && (
<div className="fixed top-4 left-4 right-4 z-[60] animate-fade-in">
<div className="fixed top-4 left-4 right-4 z-[70] animate-slide-down safe-top">
<div className={cn(
'px-4 py-3 rounded-xl shadow-lg text-sm font-medium text-white text-center',
'px-4 py-3 rounded-2xl shadow-lg text-sm font-medium text-white text-center press-feedback-sm',
toast.type === 'success' && 'bg-green-500',
toast.type === 'error' && 'bg-red-500',
toast.type === 'warning' && 'bg-amber-500'
@ -316,4 +339,4 @@ export default function CollectionsPage() {
<BottomNav />
</div>
);
}
}

View file

@ -11,29 +11,29 @@ import {
Settings,
Clock,
Crosshair,
Moon,
Type,
WifiOff,
LogOut,
ChevronRight,
Save,
RotateCcw,
Info,
ChevronRight,
Signal,
MapPin,
Shield,
} from 'lucide-react';
const INTERVAL_OPTIONS = [
{ value: 30, label: '30 seconds' },
{ value: 60, label: '1 minute' },
{ value: 120, label: '2 minutes' },
{ value: 300, label: '5 minutes' },
{ value: 600, label: '10 minutes' },
{ value: 30, label: '30s' },
{ value: 60, label: '1 min' },
{ value: 120, label: '2 min' },
{ value: 300, label: '5 min' },
{ value: 600, label: '10 min' },
];
const ACCURACY_OPTIONS = [
{ value: 10, label: '10m (High)' },
{ value: 50, label: '50m (Balanced)' },
{ value: 100, label: '100m (Battery)' },
{ value: 500, label: '500m (Low)' },
{ value: 10, label: '10m', desc: 'High precision' },
{ value: 50, label: '50m', desc: 'Balanced' },
{ value: 100, label: '100m', desc: 'Battery saver' },
{ value: 500, label: '500m', desc: 'Low power' },
];
export default function SettingsPage() {
@ -43,9 +43,7 @@ export default function SettingsPage() {
const [saved, setSaved] = useState(false);
useEffect(() => {
if (!authLoading && !isLoggedIn) {
router.push('/login/');
}
if (!authLoading && !isLoggedIn) router.push('/login/');
}, [isLoggedIn, authLoading, router]);
useEffect(() => {
@ -72,29 +70,28 @@ export default function SettingsPage() {
if (authLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="w-8 h-8 border-2 border-orange-500 border-t-transparent rounded-full animate-spin" />
<div className="flex flex-col items-center gap-3">
<div className="w-10 h-10 border-[3px] border-orange-500 border-t-transparent rounded-full animate-spin" />
<p className="text-sm text-gray-400">Loading...</p>
</div>
</div>
);
}
if (!isLoggedIn) {
return null;
}
if (!isLoggedIn) return null;
return (
<div className="min-h-screen bg-gray-50 pb-20">
<div className="min-h-screen bg-gray-50 pb-nav">
{/* Header */}
<header className="bg-gray-900 text-white safe-top">
<div className="px-4 py-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-orange-500 rounded-xl flex items-center justify-center">
<Settings size={20} className="text-white" />
<div className="w-12 h-12 bg-gradient-to-br from-orange-400 to-orange-600 rounded-2xl flex items-center justify-center shadow-lg shadow-orange-500/30">
<Settings size={22} className="text-white" />
</div>
<div>
<div className="flex-1 min-w-0">
<h1 className="text-lg font-bold">Settings</h1>
<p className="text-xs text-gray-400">
{user?.name || user?.email}
</p>
<p className="text-xs text-gray-400 truncate">{user?.name || user?.email}</p>
</div>
</div>
</div>
@ -104,190 +101,129 @@ export default function SettingsPage() {
{/* Location Settings */}
<section>
<div className="flex items-center gap-2 mb-3 px-1">
<Crosshair size={16} className="text-orange-500" />
<h2 className="text-sm font-semibold text-gray-700 uppercase tracking-wide">
<Signal size={14} className="text-orange-500" />
<h2 className="text-xs font-bold text-gray-500 uppercase tracking-wider">
Location Tracking
</h2>
</div>
<div className="bg-white rounded-xl card-shadow overflow-hidden">
<div className="bg-white rounded-2xl card-shadow overflow-hidden">
{/* Update Interval */}
<div className="p-4 border-b border-gray-100">
<div className="p-4 border-b border-gray-50">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Clock size={16} className="text-gray-400" />
<span className="text-sm font-medium text-gray-700">
Update Interval
</span>
<span className="text-sm font-semibold text-gray-800">Ping Interval</span>
</div>
<span className="text-xs text-orange-600 font-medium bg-orange-50 px-2 py-1 rounded-full">
{settings.locationInterval}s
<span className="text-xs font-bold text-orange-600 bg-orange-50 px-2.5 py-1 rounded-lg">
{settings.locationInterval >= 60 ? `${settings.locationInterval / 60} min` : `${settings.locationInterval}s`}
</span>
</div>
<div className="grid grid-cols-3 gap-2">
<div className="grid grid-cols-5 gap-1.5">
{INTERVAL_OPTIONS.map((option) => (
<button
key={option.value}
onClick={() => updateSetting('locationInterval', option.value)}
className={cn(
'py-2 px-2 rounded-lg text-xs font-medium transition-all',
'touch-manipulation',
'py-2.5 rounded-xl text-xs font-semibold transition-all',
'touch-manipulation press-feedback-sm',
settings.locationInterval === option.value
? 'bg-orange-500 text-white shadow-sm'
: 'bg-gray-50 text-gray-600 hover:bg-gray-100'
? 'bg-orange-500 text-white shadow-sm shadow-orange-500/25'
: 'bg-gray-50 text-gray-600 active:bg-gray-100'
)}
>
{option.label}
</button>
))}
</div>
<p className="text-[10px] text-gray-400 mt-2">
How often your location is sent to the server
</p>
</div>
{/* Minimum Accuracy */}
<div className="p-4 border-b border-gray-100">
<div className="p-4 border-b border-gray-50">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Crosshair size={16} className="text-gray-400" />
<span className="text-sm font-medium text-gray-700">
Minimum Accuracy
</span>
<span className="text-sm font-semibold text-gray-800">Accuracy Filter</span>
</div>
<span className="text-xs text-orange-600 font-medium bg-orange-50 px-2 py-1 rounded-full">
<span className="text-xs font-bold text-orange-600 bg-orange-50 px-2.5 py-1 rounded-lg">
{settings.minimumAccuracy}m
</span>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="grid grid-cols-4 gap-1.5">
{ACCURACY_OPTIONS.map((option) => (
<button
key={option.value}
onClick={() => updateSetting('minimumAccuracy', option.value)}
className={cn(
'py-2 px-2 rounded-lg text-xs font-medium transition-all',
'touch-manipulation',
'py-2.5 rounded-xl text-xs font-semibold transition-all flex flex-col items-center',
'touch-manimotion press-feedback-sm',
settings.minimumAccuracy === option.value
? 'bg-orange-500 text-white shadow-sm'
: 'bg-gray-50 text-gray-600 hover:bg-gray-100'
? 'bg-orange-500 text-white shadow-sm shadow-orange-500/25'
: 'bg-gray-50 text-gray-600 active:bg-gray-100'
)}
>
{option.label}
<span>{option.label}</span>
<span className={cn(
'text-[9px] mt-0.5',
settings.minimumAccuracy === option.value ? 'text-orange-100' : 'text-gray-400'
)}>
{option.desc}
</span>
</button>
))}
</div>
<p className="text-[10px] text-gray-400 mt-2">
Skip location updates with worse accuracy
</p>
</div>
{/* High Accuracy Toggle */}
<div className="p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Crosshair size={16} className="text-gray-400" />
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-blue-50 rounded-xl flex items-center justify-center">
<MapPin size={16} className="text-blue-500" />
</div>
<div>
<span className="text-sm font-medium text-gray-700 block">
High Accuracy Mode
</span>
<span className="text-[10px] text-gray-400">
Uses GPS for better precision
</span>
<span className="text-sm font-semibold text-gray-800 block">High Accuracy GPS</span>
<span className="text-[11px] text-gray-400">Uses more battery for precision</span>
</div>
</div>
<button
onClick={() =>
updateSetting('enableHighAccuracy', !settings.enableHighAccuracy)
}
onClick={() => updateSetting('enableHighAccuracy', !settings.enableHighAccuracy)}
className={cn(
'w-12 h-7 rounded-full transition-colors relative',
settings.enableHighAccuracy ? 'bg-orange-500' : 'bg-gray-300'
'w-[52px] h-[30px] rounded-full transition-colors relative',
settings.enableHighAccuracy ? 'bg-orange-500' : 'bg-gray-200'
)}
>
<div
className={cn(
'absolute top-0.5 w-6 h-6 bg-white rounded-full shadow-md transition-transform',
settings.enableHighAccuracy ? 'translate-x-5' : 'translate-x-0.5'
)}
/>
<div className={cn(
'absolute top-[3px] w-6 h-6 bg-white rounded-full shadow-md transition-transform',
settings.enableHighAccuracy ? 'translate-x-[26px]' : 'translate-x-[3px]'
)} />
</button>
</div>
</div>
</div>
</section>
{/* Display Settings */}
<section>
<div className="flex items-center gap-2 mb-3 px-1">
<Type size={16} className="text-orange-500" />
<h2 className="text-sm font-semibold text-gray-700 uppercase tracking-wide">
Display
</h2>
</div>
<div className="bg-white rounded-xl card-shadow overflow-hidden">
{/* Font Size */}
<div className="p-4 border-b border-gray-100">
<div className="flex items-center gap-2 mb-3">
<Type size={16} className="text-gray-400" />
<span className="text-sm font-medium text-gray-700">Font Size</span>
</div>
<div className="flex gap-2">
{(['small', 'normal', 'large'] as const).map((size) => (
<button
key={size}
onClick={() => updateSetting('fontSize', size)}
className={cn(
'flex-1 py-2.5 rounded-lg text-sm font-medium transition-all',
'touch-manipulation capitalize',
settings.fontSize === size
? 'bg-orange-500 text-white shadow-sm'
: 'bg-gray-50 text-gray-600 hover:bg-gray-100'
)}
>
{size === 'small' && (
<span className="text-xs">A</span>
)}
{size === 'normal' && (
<span className="text-sm">A</span>
)}
{size === 'large' && (
<span className="text-lg">A</span>
)}
</button>
))}
</div>
</div>
{/* Offline Mode */}
<div className="p-4">
{/* Screen Wake Lock */}
<div className="p-4 border-t border-gray-50">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<WifiOff size={16} className="text-gray-400" />
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-green-50 rounded-xl flex items-center justify-center">
<Shield size={16} className="text-green-500" />
</div>
<div>
<span className="text-sm font-medium text-gray-700 block">
Offline Queue
</span>
<span className="text-[10px] text-gray-400">
Queue actions when offline
</span>
<span className="text-sm font-semibold text-gray-800 block">Keep Screen On</span>
<span className="text-[11px] text-gray-400">Prevents phone from sleeping</span>
</div>
</div>
<button
onClick={() =>
updateSetting('offlineMode', !settings.offlineMode)
}
onClick={() => updateSetting('offlineMode', !settings.offlineMode)}
className={cn(
'w-12 h-7 rounded-full transition-colors relative',
settings.offlineMode ? 'bg-orange-500' : 'bg-gray-300'
'w-[52px] h-[30px] rounded-full transition-colors relative',
settings.offlineMode ? 'bg-orange-500' : 'bg-gray-200'
)}
>
<div
className={cn(
'absolute top-0.5 w-6 h-6 bg-white rounded-full shadow-md transition-transform',
settings.offlineMode ? 'translate-x-5' : 'translate-x-0.5'
)}
/>
<div className={cn(
'absolute top-[3px] w-6 h-6 bg-white rounded-full shadow-md transition-transform',
settings.offlineMode ? 'translate-x-[26px]' : 'translate-x-[3px]'
)} />
</button>
</div>
</div>
@ -297,31 +233,30 @@ export default function SettingsPage() {
{/* About */}
<section>
<div className="flex items-center gap-2 mb-3 px-1">
<Info size={16} className="text-orange-500" />
<h2 className="text-sm font-semibold text-gray-700 uppercase tracking-wide">
About
</h2>
<Info size={14} className="text-orange-500" />
<h2 className="text-xs font-bold text-gray-500 uppercase tracking-wider">About</h2>
</div>
<div className="bg-white rounded-xl card-shadow overflow-hidden">
<div className="p-4 text-center">
<h3 className="text-lg font-bold text-gray-800">1Driver</h3>
<div className="bg-white rounded-2xl card-shadow overflow-hidden">
<div className="p-5 text-center">
<div className="w-14 h-14 bg-gradient-to-br from-orange-400 to-orange-600 rounded-2xl mx-auto mb-3 flex items-center justify-center shadow-lg shadow-orange-500/20">
<span className="text-white font-bold text-xl">1D</span>
</div>
<h3 className="text-lg font-bold text-gray-900">1Driver</h3>
<p className="text-xs text-gray-400 mt-1">Version 1.0.0</p>
<p className="text-xs text-gray-400 mt-1">
Collections Management App
</p>
<p className="text-[11px] text-gray-300 mt-0.5">Collection Management App</p>
</div>
</div>
</section>
{/* Actions */}
<div className="space-y-3">
<div className="space-y-2.5">
<button
onClick={resetSettings}
className={cn(
'w-full py-3 px-4 rounded-xl text-sm font-medium',
'bg-gray-200 text-gray-700 active:bg-gray-300',
'touch-manipulation flex items-center justify-center gap-2'
'w-full py-3.5 px-4 rounded-2xl text-sm font-semibold',
'bg-white border border-gray-200 text-gray-700 active:bg-gray-50',
'touch-manipulation press-feedback flex items-center justify-center gap-2 card-shadow'
)}
>
<RotateCcw size={16} />
@ -335,9 +270,9 @@ export default function SettingsPage() {
}
}}
className={cn(
'w-full py-3.5 px-4 rounded-xl text-white font-semibold text-base',
'bg-red-500 active:bg-red-600 transition-colors',
'touch-manipulation flex items-center justify-center gap-2'
'w-full py-3.5 px-4 rounded-2xl text-white font-semibold text-[15px]',
'bg-red-500 active:bg-red-600 transition-colors shadow-lg shadow-red-500/20',
'touch-manimotion press-feedback flex items-center justify-center gap-2'
)}
>
<LogOut size={18} />
@ -348,8 +283,8 @@ export default function SettingsPage() {
{/* Saved Toast */}
{saved && (
<div className="fixed top-4 left-4 right-4 z-[60] animate-fade-in">
<div className="px-4 py-3 rounded-xl shadow-lg text-sm font-medium text-white text-center bg-green-500 flex items-center justify-center gap-2">
<div className="fixed top-4 left-4 right-4 z-[70] animate-slide-down safe-top">
<div className="px-4 py-3 rounded-2xl shadow-lg text-sm font-medium text-white text-center bg-green-500 flex items-center justify-center gap-2 card-shadow-lg">
<Save size={16} />
Settings saved
</div>
@ -359,4 +294,4 @@ export default function SettingsPage() {
<BottomNav />
</div>
);
}
}

View file

@ -2,12 +2,12 @@
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { List, Map, Settings } from 'lucide-react';
import { List, MapPin, Settings } from 'lucide-react';
import { cn } from '@/lib/utils';
const navItems = [
{ href: '/', label: 'List', icon: List },
{ href: '/map/', label: 'Map', icon: Map },
{ href: '/', label: 'Collections', icon: List },
{ href: '/map/', label: 'Map', icon: MapPin },
{ href: '/settings/', label: 'Settings', icon: Settings },
];
@ -15,34 +15,37 @@ export default function BottomNav() {
const pathname = usePathname();
return (
<nav className="fixed bottom-0 left-0 right-0 z-50 bg-white border-t border-gray-200 safe-bottom shadow-lg">
<div className="flex justify-around items-center h-16">
<nav className="fixed bottom-0 left-0 right-0 z-50 bg-white/95 backdrop-blur-lg border-t border-gray-200/80 safe-bottom">
<div className="flex justify-around items-center h-16 max-w-lg mx-auto">
{navItems.map((item) => {
const Icon = item.icon;
const isActive = pathname === item.href;
const isActive = pathname === item.href || (item.href !== '/' && pathname.startsWith(item.href));
return (
<Link
key={item.href}
href={item.href}
className={cn(
'flex flex-col items-center justify-center flex-1 h-full touch-manipulation',
'active:scale-95 transition-transform duration-150',
isActive ? 'text-primary-600' : 'text-gray-500'
'flex flex-col items-center justify-center flex-1 h-full relative transition-all duration-200',
'touch-manipulation active:scale-95',
isActive ? 'text-orange-600' : 'text-gray-400'
)}
>
{isActive && (
<span className="absolute top-0 left-1/2 -translate-x-1/2 w-8 h-0.5 bg-orange-500 rounded-full" />
)}
<Icon
size={22}
strokeWidth={isActive ? 2.5 : 2}
strokeWidth={isActive ? 2.5 : 1.8}
className={cn(
'transition-colors duration-200',
isActive && 'drop-shadow-sm'
'transition-all duration-200',
isActive && 'drop-shadow-sm scale-110'
)}
/>
<span
className={cn(
'text-[10px] mt-0.5 font-medium transition-colors duration-200',
isActive ? 'text-primary-600' : 'text-gray-400'
'text-[10px] mt-0.5 font-medium transition-all duration-200',
isActive ? 'text-orange-700' : 'text-gray-400'
)}
>
{item.label}
@ -53,4 +56,4 @@ export default function BottomNav() {
</div>
</nav>
);
}
}

View file

@ -3,7 +3,7 @@
import { useState } from 'react';
import { OrderItem } from '@/lib/types';
import { cn, formatCurrency } from '@/lib/utils';
import { X, Minus, Plus } from 'lucide-react';
import { X, Minus, Plus, Check } from 'lucide-react';
interface CollectModalProps {
item: OrderItem | null;
@ -18,13 +18,16 @@ export default function CollectModal({
onClose,
onConfirm,
}: CollectModalProps) {
const [receivedQty, setReceivedQty] = useState<string>('');
const [receivedQty, setReceivedQty] = useState('');
const [notes, setNotes] = useState('');
const [error, setError] = useState('');
if (!isOpen || !item) return null;
const expectedQty = item.order_qty || item.quantity || 0;
const parsedQty = parseFloat(receivedQty) || 0;
const isPartial = parsedQty > 0 && parsedQty < expectedQty;
const isOverQty = parsedQty > expectedQty;
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
@ -46,99 +49,125 @@ export default function CollectModal({
const current = parseFloat(receivedQty) || 0;
const newQty = Math.max(0, current + delta);
setReceivedQty(newQty.toString());
setError('');
};
const isPartial = parseFloat(receivedQty) > 0 && parseFloat(receivedQty) < expectedQty;
return (
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center">
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={onClose} />
<div className="relative bg-white rounded-t-2xl sm:rounded-2xl w-full max-w-md p-6 animate-slide-up">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-bold">Collect Item</h2>
<div className="absolute inset-0 bg-black/50 modal-overlay" onClick={onClose} />
<div className="relative bg-white bottom-sheet w-full max-w-md p-6 animate-slide-up">
{/* Drag indicator */}
<div className="sm:hidden flex justify-center mb-4">
<div className="w-10 h-1 bg-gray-300 rounded-full" />
</div>
<div className="flex items-center justify-between mb-5">
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 bg-green-50 rounded-xl flex items-center justify-center">
<Check size={18} className="text-green-600" />
</div>
<h2 className="text-lg font-bold text-gray-900">Collect Item</h2>
</div>
<button
onClick={onClose}
className="p-1 rounded-full hover:bg-gray-100 active:bg-gray-200 transition-colors"
className="p-2 rounded-xl hover:bg-gray-100 active:bg-gray-200 transition-colors"
>
<X size={20} />
<X size={18} className="text-gray-400" />
</button>
</div>
<div className="mb-4 p-3 bg-gray-50 rounded-lg">
<p className="text-sm font-medium truncate">{item.description}</p>
<p className="text-xs text-gray-500 mt-1">
Expected: {expectedQty} units
</p>
{item.expand?.order && (
<p className="text-xs text-gray-500 mt-1">
Order: {item.expand.order.order_number}
</p>
)}
{/* Item info */}
<div className="mb-5 p-4 bg-gray-50 rounded-xl">
<p className="text-sm font-semibold text-gray-900 truncate">{item.description}</p>
<div className="flex items-center gap-3 mt-1.5 text-xs text-gray-500">
<span className="font-medium text-gray-700">
{item.expand?.supplier?.suppliername || 'Unknown'}
</span>
<span>Expected: {expectedQty} units</span>
</div>
{item.selling_price && (
<p className="text-xs text-green-600 mt-1">
<p className="text-xs text-green-600 font-medium mt-1">
Unit price: {formatCurrency(item.selling_price)}
</p>
)}
</div>
<form onSubmit={handleSubmit}>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
<form onSubmit={handleSubmit} className="space-y-5">
{/* Quantity */}
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">
Received Quantity *
</label>
<div className="flex items-center gap-2">
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => adjustQty(-1)}
className="p-3 rounded-lg bg-gray-100 active:bg-gray-200 transition-colors"
className="w-12 h-12 flex items-center justify-center rounded-xl bg-gray-100 active:bg-gray-200 transition-colors touch-manipulation press-feedback-sm"
>
<Minus size={18} />
<Minus size={18} className="text-gray-600" />
</button>
<input
type="number"
step="0.01"
inputMode="decimal"
value={receivedQty}
onChange={(e) => setReceivedQty(e.target.value)}
className="flex-1 p-3 text-center text-lg font-semibold border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-transparent"
placeholder="0.00"
onChange={(e) => { setReceivedQty(e.target.value); setError(''); }}
className="w-20 text-center text-2xl font-bold py-3 border-2 border-gray-200 rounded-xl focus:ring-2 focus:ring-green-500/30 focus:border-green-500 transition-all"
placeholder="0"
autoFocus
/>
<button
type="button"
onClick={() => adjustQty(1)}
className="p-3 rounded-lg bg-gray-100 active:bg-gray-200 transition-colors"
className="w-12 h-12 flex items-center justify-center rounded-xl bg-gray-100 active:bg-gray-200 transition-colors touch-manipulation press-feedback-sm"
>
<Plus size={18} />
<Plus size={18} className="text-gray-600" />
</button>
{expectedQty > 0 && (
<button
type="button"
onClick={() => { setReceivedQty(expectedQty.toString()); setError(''); }}
className="px-3 py-2 text-xs font-medium text-green-700 bg-green-50 rounded-lg active:bg-green-100 transition-colors touch-manipulation"
>
Full ({expectedQty})
</button>
)}
</div>
{isPartial && (
<p className="text-xs text-amber-600 mt-1">
Partial collection: {receivedQty} of {expectedQty} units
<p className="text-xs text-amber-600 mt-2 font-medium">
Partial collection: {parsedQty} of {expectedQty} units
</p>
)}
{error && <p className="text-xs text-red-500 mt-1">{error}</p>}
{isOverQty && (
<p className="text-xs text-blue-600 mt-2 font-medium">
Over collection: {parsedQty} exceeds expected {expectedQty}
</p>
)}
{error && <p className="text-xs text-red-500 mt-2">{error}</p>}
</div>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
{/* Notes */}
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">
Notes (optional)
</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
className="w-full p-3 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-transparent resize-none"
className="w-full p-3 text-sm bg-gray-50 border border-gray-200 rounded-xl focus:ring-2 focus:ring-green-500/30 focus:border-green-500 focus:bg-white transition-all resize-none selectable"
rows={2}
placeholder="Add any notes..."
placeholder="Add any notes about this collection..."
/>
</div>
{/* Submit */}
<button
type="submit"
className={cn(
'w-full py-3.5 px-4 rounded-xl text-white font-semibold text-base',
'bg-green-500 active:bg-green-600 transition-colors',
'touch-manipulation flex items-center justify-center gap-2'
'w-full py-4 px-4 rounded-xl text-white font-semibold text-[15px]',
'bg-green-500 active:bg-green-600 transition-all shadow-lg shadow-green-500/25',
'touch-manipulation press-feedback',
(!parsedQty || parsedQty < 0) && 'opacity-50'
)}
>
Confirm Collection
@ -147,4 +176,4 @@ export default function CollectModal({
</div>
</div>
);
}
}

View file

@ -14,6 +14,8 @@ import {
Hammer,
ShoppingCart,
AlertCircle,
Clock,
MapPin,
} from 'lucide-react';
interface CollectionItemProps {
@ -27,13 +29,15 @@ function getItemType(item: OrderItem): {
type: string;
icon: React.ReactNode;
color: string;
bgColor: string;
label: string;
} {
if (item.item_status === 'problem' || item.problem_flag) {
return {
type: 'problem',
icon: <AlertTriangle size={16} />,
color: 'text-red-500',
color: 'text-red-600',
bgColor: 'bg-red-50',
label: 'Problem',
};
}
@ -45,6 +49,7 @@ function getItemType(item: OrderItem): {
type: 'breakdown',
icon: <Hammer size={16} />,
color: 'text-orange-600',
bgColor: 'bg-orange-50',
label: 'Breakdown',
};
}
@ -53,7 +58,8 @@ function getItemType(item: OrderItem): {
return {
type: 'repair',
icon: <Wrench size={16} />,
color: 'text-amber-500',
color: 'text-amber-600',
bgColor: 'bg-amber-50',
label: 'Repair',
};
}
@ -62,7 +68,8 @@ function getItemType(item: OrderItem): {
return {
type: 'stock',
icon: <Package size={16} />,
color: 'text-purple-500',
color: 'text-violet-600',
bgColor: 'bg-violet-50',
label: 'Stock',
};
}
@ -70,7 +77,8 @@ function getItemType(item: OrderItem): {
return {
type: 'normal',
icon: <ShoppingCart size={16} />,
color: 'text-orange-500',
color: 'text-orange-600',
bgColor: 'bg-orange-50',
label: 'Order',
};
}
@ -85,156 +93,164 @@ export default function CollectionItemCard({
const supplier = item.expand?.supplier;
const itemType = getItemType(item);
const coords = parseCoordinates(supplier?.coordinates);
const hasCoords = coords !== null;
const description = item.description || 'Unknown item';
const expectedQty = item.order_qty || item.quantity || 0;
return (
<div
className={cn(
'bg-white rounded-xl card-shadow mb-3 overflow-hidden animate-fade-in',
(item.item_status === 'problem' || item.problem_flag) && 'border-l-4 border-red-500',
item.item_status === 'assigned' && 'border-l-4 border-orange-500',
item.driver_status === 'assigned' && 'border-l-4 border-orange-500'
'bg-white rounded-2xl card-shadow card-press overflow-hidden',
(item.item_status === 'problem' || item.problem_flag) && 'ring-l-4 ring-red-400',
item.item_status === 'assigned' && 'border-l-[3px] border-orange-400'
)}
>
{/* Header */}
<div
className="p-4 cursor-pointer active:bg-gray-50 transition-colors"
<button
className="w-full text-left p-4 active:bg-gray-50/50 transition-colors"
onClick={() => setExpanded(!expanded)}
>
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className={cn('flex-shrink-0', itemType.color)}>
{itemType.icon}
</span>
<h3 className="font-semibold text-sm leading-tight truncate">
{description}
</h3>
<span
className={cn(
'text-[10px] px-1.5 py-0.5 rounded-full font-medium flex-shrink-0',
itemType.type === 'problem' && 'bg-red-100 text-red-700',
itemType.type === 'breakdown' && 'bg-orange-100 text-orange-700',
itemType.type === 'repair' && 'bg-amber-100 text-amber-700',
itemType.type === 'stock' && 'bg-purple-100 text-purple-700',
itemType.type === 'normal' && 'bg-orange-100 text-orange-700'
)}
>
{itemType.label}
</span>
</div>
<div className="text-xs text-gray-500 mt-1 space-y-0.5">
<p className="font-medium text-gray-700">
{supplier?.suppliername || 'Unknown Supplier'}
</p>
<p>Expected: {expectedQty} units</p>
{item.expand?.order?.order_number && <p>Order: {item.expand.order.order_number}</p>}
{item.expand?.purchase_order?.po_number && <p>PO: {item.expand.purchase_order.po_number}</p>}
{item.collection_date && (
<p>Planned: {formatDate(item.collection_date)}</p>
)}
{item.selling_price && (
<p className="text-green-600 font-medium">
{formatCurrency(item.selling_price * expectedQty)}
</p>
)}
</div>
<div className="flex items-start gap-3">
{/* Type icon */}
<div className={cn(
'w-9 h-9 rounded-xl flex items-center justify-center flex-shrink-0 mt-0.5',
itemType.bgColor
)}>
<span className={itemType.color}>{itemType.icon}</span>
</div>
<div className="flex flex-col items-end gap-2 ml-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<h3 className="font-semibold text-[15px] text-gray-900 truncate leading-snug">
{description}
</h3>
</div>
<div className="flex items-center gap-1.5 flex-wrap">
<span className={cn(
'text-[10px] px-2 py-0.5 rounded-full font-semibold uppercase tracking-wide',
itemType.type === 'problem' && 'bg-red-100 text-red-700',
itemType.type === 'breakdown' && 'bg-orange-100 text-orange-700',
itemType.type === 'repair' && 'bg-amber-100 text-amber-700',
itemType.type === 'stock' && 'bg-violet-100 text-violet-700',
itemType.type === 'normal' && 'bg-orange-100 text-orange-700'
)}>
{itemType.label}
</span>
<span className="text-xs text-gray-400">Qty: {expectedQty}</span>
{item.collection_date && (
<span className="text-xs text-gray-400 flex items-center gap-0.5">
<Clock size={10} />
{formatDate(item.collection_date)}
</span>
)}
</div>
{supplier?.suppliername && (
<div className="flex items-center gap-1 mt-1.5">
<MapPin size={12} className="text-gray-400 flex-shrink-0" />
<span className="text-xs text-gray-500 truncate">{supplier.suppliername}</span>
</div>
)}
</div>
<div className="flex items-center gap-2 flex-shrink-0">
{item.selling_price && expectedQty > 0 && (
<span className="text-xs font-semibold text-green-600">
{formatCurrency(item.selling_price * expectedQty)}
</span>
)}
{expanded ? (
<ChevronUp size={20} className="text-gray-400" />
<ChevronUp size={18} className="text-gray-400" />
) : (
<ChevronDown size={20} className="text-gray-400" />
<ChevronDown size={18} className="text-gray-400" />
)}
</div>
</div>
</div>
</button>
{/* Expanded Details */}
{expanded && (
<div className="px-4 pb-4 border-t border-gray-100 pt-3">
{item.notes && (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-2 mb-3">
<p className="text-xs text-yellow-800 flex items-start gap-1.5">
<AlertCircle size={14} className="flex-shrink-0 mt-0.5" />
<span>{item.notes}</span>
</p>
</div>
)}
{item.problem_notes && (
<div className="bg-red-50 border border-red-200 rounded-lg p-2 mb-3">
<p className="text-xs text-red-700 flex items-start gap-1.5">
<AlertTriangle size={14} className="flex-shrink-0 mt-0.5" />
<span>{item.problem_notes}</span>
</p>
</div>
)}
{supplier?.supplieraddress && (
<p className="text-xs text-gray-500 mb-3">
<strong>Address:</strong> {supplier.supplieraddress}
</p>
)}
{/* Action Buttons */}
<div className="grid grid-cols-3 gap-2">
<button
onClick={(e) => {
e.stopPropagation();
onCollect(item);
}}
className={cn(
'flex items-center justify-center gap-1.5 py-2.5 px-3 rounded-lg text-sm font-medium',
'bg-green-500 text-white active:bg-green-600 transition-colors',
'touch-manipulation'
)}
>
<Check size={16} strokeWidth={2.5} />
<span>Collect</span>
</button>
<button
onClick={(e) => {
e.stopPropagation();
onProblem(item);
}}
className={cn(
'flex items-center justify-center gap-1.5 py-2.5 px-3 rounded-lg text-sm font-medium',
'bg-red-500 text-white active:bg-red-600 transition-colors',
'touch-manipulation'
)}
>
<AlertTriangle size={16} strokeWidth={2.5} />
<span>Problem</span>
</button>
{hasCoords && coords && supplier && (
<button
onClick={(e) => {
e.stopPropagation();
onNavigate(coords.latitude, coords.longitude, supplier.suppliername);
}}
className={cn(
'flex items-center justify-center gap-1.5 py-2.5 px-3 rounded-lg text-sm font-medium',
'bg-orange-500 text-white active:bg-orange-600 transition-colors',
'touch-manipulation'
)}
>
<Navigation size={16} strokeWidth={2.5} />
<span>Navigate</span>
</button>
<div className="px-4 pb-4 border-t border-gray-100 animate-fade-in">
<div className="pt-3 space-y-3">
{/* Notes */}
{item.notes && (
<div className="bg-amber-50 border border-amber-100 rounded-xl p-3">
<p className="text-xs text-amber-800 flex items-start gap-2">
<AlertCircle size={14} className="flex-shrink-0 mt-0.5" />
<span className="selectable">{item.notes}</span>
</p>
</div>
)}
{item.problem_notes && (
<div className="bg-red-50 border border-red-100 rounded-xl p-3">
<p className="text-xs text-red-700 flex items-start gap-2">
<AlertTriangle size={14} className="flex-shrink-0 mt-0.5" />
<span className="selectable">{item.problem_notes}</span>
</p>
</div>
)}
{/* Details grid */}
<div className="grid grid-cols-2 gap-2 text-xs">
{item.expand?.order?.order_number && (
<div className="bg-gray-50 rounded-lg p-2">
<span className="text-gray-400 block">Order</span>
<span className="text-gray-700 font-medium">{item.expand.order.order_number}</span>
</div>
)}
{item.expand?.purchase_order?.po_number && (
<div className="bg-gray-50 rounded-lg p-2">
<span className="text-gray-400 block">PO</span>
<span className="text-gray-700 font-medium">{item.expand.purchase_order.po_number}</span>
</div>
)}
{supplier?.supplieraddress && (
<div className="bg-gray-50 rounded-lg p-2 col-span-2">
<span className="text-gray-400 block">Address</span>
<span className="text-gray-700 font-medium selectable">{supplier.supplieraddress}</span>
</div>
)}
</div>
{/* Action Buttons */}
<div className="grid grid-cols-3 gap-2 pt-1">
<button
onClick={() => onCollect(item)}
className="flex flex-col items-center justify-center gap-1 py-3 rounded-xl bg-green-500 text-white active:bg-green-600 transition-colors touch-manipulation press-feedback-sm"
>
<Check size={18} strokeWidth={2.5} />
<span className="text-xs font-semibold">Collect</span>
</button>
<button
onClick={() => onProblem(item)}
className="flex flex-col items-center justify-center gap-1 py-3 rounded-xl bg-red-500 text-white active:bg-red-600 transition-colors touch-manipulation press-feedback-sm"
>
<AlertTriangle size={18} strokeWidth={2.5} />
<span className="text-xs font-semibold">Problem</span>
</button>
{hasCoords && coords && supplier ? (
<button
onClick={() => onNavigate(coords.latitude, coords.longitude, supplier.suppliername)}
className="flex flex-col items-center justify-center gap-1 py-3 rounded-xl bg-orange-500 text-white active:bg-orange-600 transition-colors touch-manipulation press-feedback-sm"
>
<Navigation size={18} strokeWidth={2.5} />
<span className="text-xs font-semibold">Navigate</span>
</button>
) : (
<div className="flex flex-col items-center justify-center gap-1 py-3 rounded-xl bg-gray-100 text-gray-400">
<Navigation size={18} />
<span className="text-xs font-medium">No GPS</span>
</div>
)}
</div>
</div>
</div>
)}
</div>
);
}
}

View file

@ -5,7 +5,7 @@ import { MapContainer, TileLayer, Marker, Popup, Polyline, useMap } from 'react-
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 } from 'lucide-react';
import { Navigation, AlertTriangle, Route, Crosshair, Layers } from 'lucide-react';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
@ -21,9 +21,13 @@ const defaultIcon = L.icon({
const currentLocationIcon = L.divIcon({
className: 'custom-div-icon',
html: `<div style="width:20px;height:20px;background:#ea580c;border:3px solid white;border-radius:50%;box-shadow:0 2px 6px rgba(0,0,0,0.3);"></div>`,
iconSize: [20, 20],
iconAnchor: [10, 10],
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 {
@ -42,21 +46,22 @@ function MapBounds({ items }: { items: OrderItem[] }) {
.filter((point): point is [number, number] => point !== null);
if (validPoints.length > 0) {
const bounds = L.latLngBounds(validPoints);
map.fitBounds(bounds, { padding: [50, 50], maxZoom: 15 });
map.fitBounds(bounds, { padding: [60, 60], maxZoom: 15 });
}
}, [map, items]);
return null;
}
function FitTrail({ trail }: { trail: [number, number][] }) {
function RecenterButton({ center }: { center: [number, number] }) {
const map = useMap();
useEffect(() => {
if (trail.length > 1) {
const bounds = L.latLngBounds(trail);
map.fitBounds(bounds, { padding: [50, 50], maxZoom: 16 });
}
}, [map, trail]);
return null;
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) {
@ -92,7 +97,7 @@ export default function CollectionMap({ items, onNavigate }: MapViewProps) {
useEffect(() => {
fetchTrail();
intervalRef.current = setInterval(fetchTrail, 60000); // refresh every minute
intervalRef.current = setInterval(fetchTrail, 60000);
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
@ -124,7 +129,7 @@ export default function CollectionMap({ items, onNavigate }: MapViewProps) {
};
return (
<div className="h-[calc(100vh-4rem)] w-full relative">
<div className="h-full w-full relative">
<MapContainer center={center} zoom={12} className="h-full w-full" zoomControl={false}>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
@ -135,7 +140,9 @@ export default function CollectionMap({ items, onNavigate }: MapViewProps) {
{userLocation && (
<Marker position={userLocation} icon={currentLocationIcon}>
<Popup><div className="text-sm font-medium">Your Location</div></Popup>
<Popup>
<div className="text-sm font-medium">Your Location</div>
</Popup>
</Marker>
)}
@ -151,12 +158,11 @@ export default function CollectionMap({ items, onNavigate }: MapViewProps) {
dashArray: '10, 8',
}}
/>
{/* Start marker */}
<Marker
position={trail[0]}
icon={L.divIcon({
className: '',
html: '<div style="width:14px;height:14px;background:#22c55e;border:3px solid white;border-radius:50%;"></div>',
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],
})}
@ -171,24 +177,30 @@ export default function CollectionMap({ items, onNavigate }: MapViewProps) {
return (
<Marker key={supplier.id} position={[coords.latitude, coords.longitude]} icon={defaultIcon}>
<Popup>
<div className="min-w-[200px] max-w-[250px]">
<div className="min-w-[200px] max-w-[260px]">
<h3 className="font-bold text-sm mb-1">{supplier.suppliername}</h3>
<p className="text-xs text-gray-500 mb-2">{supplier.supplieraddress}</p>
{distance && <p className="text-xs text-orange-600 mb-2 font-medium">{distance.toFixed(1)} km away</p>}
{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-1.5 bg-gray-50 rounded">
<div className="flex items-center gap-1">
{(i.item_status === 'problem' || i.problem_flag) && <AlertTriangle size={12} className="text-red-500" />}
<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">Qty: {i.order_qty || i.quantity || 0}</span>
<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-medium active:bg-orange-600 transition-colors"
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>
@ -199,14 +211,19 @@ export default function CollectionMap({ items, onNavigate }: MapViewProps) {
})}
</MapContainer>
{/* Controls */}
<div className="absolute top-4 right-4 space-y-2 z-[400]">
{/* 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-medium shadow-lg transition-colors ${
showTrail ? 'bg-blue-500 text-white' : 'bg-white/90 text-gray-600'
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} />
@ -216,22 +233,20 @@ export default function CollectionMap({ items, onNavigate }: MapViewProps) {
{/* Legend */}
<div className="bg-white/90 backdrop-blur-sm rounded-xl p-3 shadow-lg">
<p className="text-xs font-medium text-gray-700 mb-2">Legend</p>
<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-[10px] text-gray-600">You</span>
<span className="text-[11px] text-gray-600">You</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 relative">
<div className="absolute inset-0 bg-blue-500 rounded-sm rotate-45 transform origin-bottom" />
</div>
<span className="text-[10px] text-gray-600">Stop</span>
<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.5 bg-blue-500" style={{ borderTop: '2px dashed #2563eb' }} />
<span className="text-[10px] text-gray-600">Route</span>
<div className="w-5 h-0" style={{ borderTop: '2px dashed #2563eb' }} />
<span className="text-[11px] text-gray-600">Route</span>
</div>
)}
</div>
@ -239,4 +254,4 @@ export default function CollectionMap({ items, onNavigate }: MapViewProps) {
</div>
</div>
);
}
}

View file

@ -0,0 +1,52 @@
'use client';
import { usePWAInstall } from '@/lib/pwa';
import { Download, X, Smartphone } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useState } from 'react';
export default function InstallPrompt() {
const { isInstallable, isInstalled, install, dismiss } = usePWAInstall();
const [isDismissed, setIsDismissed] = useState(false);
if (isInstalled || isDismissed || !isInstallable) return null;
return (
<div className="fixed bottom-20 left-4 right-4 z-[70] animate-fade-in">
<div className="bg-white rounded-2xl shadow-xl border border-gray-100 p-4">
<div className="flex items-start gap-3">
<div className="w-10 h-10 bg-orange-100 rounded-xl flex items-center justify-center flex-shrink-0">
<Smartphone size={20} className="text-orange-600" />
</div>
<div className="flex-1 min-w-0">
<h3 className="text-sm font-semibold text-gray-900">Install 1Driver</h3>
<p className="text-xs text-gray-500 mt-0.5">
Add to home screen for the best experience
</p>
<div className="flex items-center gap-2 mt-3">
<button
onClick={install}
className="flex items-center gap-1.5 px-4 py-2 bg-orange-500 text-white rounded-lg text-sm font-medium active:bg-orange-600 transition-colors touch-manipulation"
>
<Download size={14} />
Install
</button>
<button
onClick={() => { dismiss(); setIsDismissed(true); }}
className="px-3 py-2 text-sm text-gray-500 font-medium active:bg-gray-100 rounded-lg transition-colors touch-manipulation"
>
Not now
</button>
</div>
</div>
<button
onClick={() => { dismiss(); setIsDismissed(true); }}
className="p-1 text-gray-400 hover:text-gray-600 active:bg-gray-100 rounded-lg transition-colors"
>
<X size={16} />
</button>
</div>
</div>
</div>
);
}

View file

@ -4,7 +4,9 @@ import { useEffect, useRef } from 'react';
import { getSettings, saveDriverLocation, isAuthenticated } from '@/lib/pocketbase';
export default function LocationTracker() {
const watchRef = useRef<number | null>(null);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const wakeLockRef = useRef<WakeLockSentinel | null>(null);
useEffect(() => {
if (!('geolocation' in navigator)) return;
@ -12,41 +14,77 @@ export default function LocationTracker() {
const settings = getSettings();
const intervalMs = Math.max(30000, settings.locationInterval * 1000);
const trackLocation = () => {
const sendLocation = (position: GeolocationPosition) => {
if (!isAuthenticated()) return;
if (position.coords.accuracy > settings.minimumAccuracy) return;
const auth = JSON.parse(localStorage.getItem('pocketbase_auth') || '{}');
const user = auth?.model;
if (!user) return;
navigator.geolocation.getCurrentPosition(
(position) => {
if (position.coords.accuracy > settings.minimumAccuracy) return;
saveDriverLocation({
driver: user.id,
latitude: position.coords.latitude,
longitude: position.coords.longitude,
accuracy: position.coords.accuracy || undefined,
altitude: position.coords.altitude || undefined,
speed: position.coords.speed || undefined,
heading: position.coords.heading || undefined,
timestamp: new Date().toISOString(),
});
};
saveDriverLocation({
driver: user.id,
latitude: position.coords.latitude,
longitude: position.coords.longitude,
accuracy: position.coords.accuracy || undefined,
altitude: position.coords.altitude || undefined,
speed: position.coords.speed || undefined,
heading: position.coords.heading || undefined,
timestamp: new Date().toISOString(),
});
},
// 1. watchPosition keeps GPS active (helps Android stay alive longer)
watchRef.current = navigator.geolocation.watchPosition(
sendLocation,
() => {},
{
enableHighAccuracy: settings.enableHighAccuracy,
maximumAge: 5000, // fresh GPS every 5s
timeout: 30000,
}
);
// 2. Interval fallback (for when watchPosition is throttled)
intervalRef.current = setInterval(() => {
navigator.geolocation.getCurrentPosition(
sendLocation,
() => {},
{ enableHighAccuracy: settings.enableHighAccuracy, maximumAge: intervalMs, timeout: 30000 }
);
};
}, intervalMs);
trackLocation();
intervalRef.current = setInterval(trackLocation, intervalMs);
// 3. Screen Wake Lock — keeps phone awake while app is open
if ('wakeLock' in navigator) {
navigator.wakeLock.request('screen')
.then((lock) => { wakeLockRef.current = lock; })
.catch(() => {});
// Re-acquire if page regains focus
const onFocus = () => {
if (!wakeLockRef.current) {
navigator.wakeLock.request('screen')
.then((lock) => { wakeLockRef.current = lock; })
.catch(() => {});
}
};
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') onFocus();
});
}
return () => {
if (watchRef.current !== null) {
navigator.geolocation.clearWatch(watchRef.current);
watchRef.current = null;
}
if (intervalRef.current !== null) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
if (wakeLockRef.current) {
wakeLockRef.current.release();
wakeLockRef.current = null;
}
};
}, []);

View file

@ -0,0 +1,58 @@
'use client';
import { useOnlineStatus } from '@/lib/pwa';
import { WifiOff, Wifi } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useState, useEffect } from 'react';
export default function OfflineIndicator() {
const isOnline = useOnlineStatus();
const [showBanner, setShowBanner] = useState(false);
const [wasOffline, setWasOffline] = useState(false);
useEffect(() => {
if (!isOnline) {
setShowBanner(true);
setWasOffline(true);
} else if (wasOffline) {
setShowBanner(true);
const timer = setTimeout(() => {
setShowBanner(false);
setWasOffline(false);
}, 3000);
return () => clearTimeout(timer);
}
}, [isOnline, wasOffline]);
if (!showBanner) return null;
return (
<div
className={cn(
'fixed top-0 left-0 right-0 z-[80] transition-transform duration-300 ease-out safe-top',
showBanner ? 'translate-y-0' : '-translate-y-full'
)}
>
<div
className={cn(
'px-4 py-2 text-center text-sm font-medium flex items-center justify-center gap-2',
isOnline
? 'bg-green-500 text-white'
: 'bg-red-500 text-white'
)}
>
{isOnline ? (
<>
<Wifi size={14} />
Back online
</>
) : (
<>
<WifiOff size={14} />
You&apos;re offline — changes will sync when reconnected
</>
)}
</div>
</div>
);
}

12
components/PWABridge.tsx Normal file
View file

@ -0,0 +1,12 @@
'use client';
import { useEffect } from 'react';
import { registerServiceWorker } from '@/lib/pwa';
export function PWABridge() {
useEffect(() => {
registerServiceWorker();
}, []);
return null;
}

View file

@ -3,7 +3,7 @@
import { useState } from 'react';
import { OrderItem, PROBLEM_TYPES } from '@/lib/types';
import { cn } from '@/lib/utils';
import { X, Camera } from 'lucide-react';
import { X, Camera, AlertTriangle } from 'lucide-react';
interface ProblemModalProps {
item: OrderItem | null;
@ -48,45 +48,54 @@ export default function ProblemModal({
return (
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center">
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={onClose} />
<div className="relative bg-white rounded-t-2xl sm:rounded-2xl w-full max-w-md p-6 animate-slide-up">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-bold">Report Problem</h2>
<div className="absolute inset-0 bg-black/50 modal-overlay" onClick={onClose} />
<div className="relative bg-white bottom-sheet w-full max-w-md p-6 animate-slide-up max-h-[90vh] overflow-y-auto custom-scrollbar">
{/* Drag indicator */}
<div className="sm:hidden flex justify-center mb-4">
<div className="w-10 h-1 bg-gray-300 rounded-full" />
</div>
<div className="flex items-center justify-between mb-5">
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 bg-red-50 rounded-xl flex items-center justify-center">
<AlertTriangle size={18} className="text-red-600" />
</div>
<h2 className="text-lg font-bold text-gray-900">Report Problem</h2>
</div>
<button
onClick={onClose}
className="p-1 rounded-full hover:bg-gray-100 active:bg-gray-200 transition-colors"
className="p-2 rounded-xl hover:bg-gray-100 active:bg-gray-200 transition-colors"
>
<X size={20} />
<X size={18} className="text-gray-400" />
</button>
</div>
<div className="mb-4 p-3 bg-red-50 rounded-lg">
<p className="text-sm font-medium truncate">{item.description}</p>
{/* Item info */}
<div className="mb-5 p-4 bg-red-50 border border-red-100 rounded-xl">
<p className="text-sm font-semibold text-gray-900 truncate">{item.description}</p>
<p className="text-xs text-gray-500 mt-1">
{item.expand?.supplier?.suppliername || 'Unknown Supplier'}
</p>
</div>
<form onSubmit={handleSubmit}>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
Problem Type *
<form onSubmit={handleSubmit} className="space-y-5">
{/* Problem type */}
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">
What went wrong? *
</label>
<div className="grid grid-cols-1 gap-2">
<div className="space-y-2">
{PROBLEM_TYPES.map((type) => (
<button
key={type.value}
type="button"
onClick={() => {
setProblemType(type.value);
setError('');
}}
onClick={() => { setProblemType(type.value); setError(''); }}
className={cn(
'py-2.5 px-3 rounded-lg text-sm text-left border-2 transition-all',
'touch-manipulation',
'w-full py-3 px-4 rounded-xl text-sm text-left border-2 transition-all',
'touch-manipulation press-feedback-sm',
problemType === type.value
? 'border-red-500 bg-red-50 text-red-700 font-medium'
: 'border-gray-200 hover:border-gray-300'
? 'border-red-500 bg-red-50 text-red-700 font-semibold'
: 'border-gray-150 bg-white text-gray-700 active:border-gray-300'
)}
>
{type.label}
@ -95,28 +104,40 @@ export default function ProblemModal({
</div>
</div>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
Description *
{/* Description */}
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">
Details *
</label>
<textarea
value={description}
onChange={(e) => {
setDescription(e.target.value);
setError('');
}}
className="w-full p-3 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500 focus:border-transparent resize-none"
onChange={(e) => { setDescription(e.target.value); setError(''); }}
className="w-full p-3 text-sm bg-gray-50 border border-gray-200 rounded-xl focus:ring-2 focus:ring-red-500/30 focus:border-red-500 focus:bg-white transition-all resize-none selectable"
rows={3}
placeholder="Describe the problem in detail..."
autoFocus
/>
</div>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
{/* Photo */}
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">
Photo (optional)
</label>
<div className="relative">
<label
htmlFor="problem-photo"
className={cn(
'flex items-center justify-center gap-2 py-3 px-4 rounded-xl border-2 border-dashed cursor-pointer',
'transition-all touch-manipulation press-feedback-sm',
photo
? 'border-green-400 bg-green-50 text-green-700'
: 'border-gray-200 bg-gray-50 text-gray-500 active:border-gray-400'
)}
>
<Camera size={20} />
<span className="text-sm font-medium">
{photo ? 'Photo attached' : 'Take or choose photo'}
</span>
<input
type="file"
accept="image/*"
@ -125,36 +146,23 @@ export default function ProblemModal({
className="hidden"
id="problem-photo"
/>
<label
htmlFor="problem-photo"
className={cn(
'flex items-center justify-center gap-2 py-3 px-4 rounded-lg border-2 border-dashed cursor-pointer',
'transition-colors touch-manipulation',
photo
? 'border-green-400 bg-green-50 text-green-700'
: 'border-gray-300 hover:border-gray-400'
)}
>
<Camera size={20} />
<span className="text-sm">
{photo ? photo.name : 'Take or choose photo'}
</span>
</label>
</div>
</label>
</div>
{/* Error */}
{error && (
<div className="mb-4 p-2 bg-red-50 border border-red-200 rounded-lg">
<p className="text-xs text-red-600">{error}</p>
<div className="p-3 bg-red-50 border border-red-100 rounded-xl animate-fade-in">
<p className="text-xs text-red-600 font-medium">{error}</p>
</div>
)}
{/* Submit */}
<button
type="submit"
className={cn(
'w-full py-3.5 px-4 rounded-xl text-white font-semibold text-base',
'bg-red-500 active:bg-red-600 transition-colors',
'touch-manipulation flex items-center justify-center gap-2'
'w-full py-4 px-4 rounded-xl text-white font-semibold text-[15px]',
'bg-red-500 active:bg-red-600 transition-all shadow-lg shadow-red-500/25',
'touch-manipulation press-feedback'
)}
>
Report Problem
@ -163,4 +171,4 @@ export default function ProblemModal({
</div>
</div>
);
}
}

View file

@ -5,6 +5,9 @@ const POCKETBASE_URL = process.env.NEXT_PUBLIC_POCKETBASE_URL || 'https://pocket
export const pb = new PocketBase(POCKETBASE_URL);
// Disable auto-cancellation so React re-renders don't kill in-flight requests
pb.autoCancellation(false);
// Auth helpers
export const isAuthenticated = () => pb.authStore.isValid;
@ -107,4 +110,4 @@ export const saveSettings = (settings: Partial<AppSettings>) => {
const updated = { ...current, ...settings };
localStorage.setItem(SETTINGS_KEY, JSON.stringify(updated));
return updated;
};
};

105
lib/pwa.ts Normal file
View file

@ -0,0 +1,105 @@
'use client';
import { useEffect, useState, useCallback } from 'react';
export function useOnlineStatus() {
const [isOnline, setIsOnline] = useState(
typeof navigator !== 'undefined' ? navigator.onLine : true
);
useEffect(() => {
const handleOnline = () => setIsOnline(true);
const handleOffline = () => setIsOnline(false);
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
return isOnline;
}
export function usePWAInstall() {
const [deferredPrompt, setDeferredPrompt] = useState<any>(null);
const [isInstallable, setIsInstallable] = useState(false);
const [isInstalled, setIsInstalled] = useState(false);
useEffect(() => {
// Check if already installed
if (window.matchMedia('(display-mode: standalone)').matches) {
setIsInstalled(true);
return;
}
const handleBeforeInstall = (e: Event) => {
e.preventDefault();
setDeferredPrompt(e as any);
setIsInstallable(true);
};
const handleAppInstalled = () => {
setIsInstalled(true);
setIsInstallable(false);
setDeferredPrompt(null);
};
window.addEventListener('beforeinstallprompt', handleBeforeInstall);
window.addEventListener('appinstalled', handleAppInstalled);
return () => {
window.removeEventListener('beforeinstallprompt', handleBeforeInstall);
window.removeEventListener('appinstalled', handleAppInstalled);
};
}, []);
const install = useCallback(async () => {
if (!deferredPrompt) return false;
deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
setDeferredPrompt(null);
if (outcome === 'accepted') {
setIsInstalled(true);
return true;
}
return false;
}, [deferredPrompt]);
const dismiss = useCallback(() => {
setIsInstallable(false);
}, []);
return { isInstallable, isInstalled, install, dismiss };
}
export async function registerServiceWorker() {
if (typeof window === 'undefined' || !('serviceWorker' in navigator)) return null;
try {
const registration = await navigator.serviceWorker.register('/sw.js', {
scope: '/',
});
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing;
if (newWorker) {
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'activated') {
// New SW activated - could notify user
console.log('[PWA] New service worker activated');
}
});
}
});
return registration;
} catch (error) {
console.error('[PWA] SW registration failed:', error);
return null;
}
}

View file

@ -1,8 +1,11 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
// Only use static export for production builds
...(process.env.NEXT_EXPORT === 'true' ? { output: 'export' } : {}),
images: {
unoptimized: true,
},
trailingSlash: true,
};
module.exports = nextConfig;
module.exports = nextConfig;

View file

@ -7,28 +7,45 @@ server {
# Gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json;
gzip_min_length 256;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json image/svg+xml application/manifest+json;
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
# Cache static app assets (immutable cache-busted files)
location ~* ^/_next/static/ {
expires 1y;
add_header Cache-Control "public, immutable";
add_header X-Content-Type-Options "nosniff" always;
}
# PWA manifest and service worker - no cache
location ~* (manifest\.json|sw\.js)$ {
# Cache images, fonts, icons
location ~* \.(png|jpg|jpeg|gif|ico|svg|webp|woff|woff2|ttf|eot)$ {
expires 30d;
add_header Cache-Control "public";
}
# Service worker - must not be cached
location = /sw.js {
expires -1;
add_header Cache-Control "no-store, no-cache, must-revalidate";
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate";
add_header X-Content-Type-Options "nosniff" always;
}
# SPA fallback - all routes serve index.html
# Manifest - light caching
location = /manifest.json {
expires 1h;
add_header Cache-Control "public, must-revalidate";
}
# SPA fallback - handles /, /login/, /map/, /settings/
location / {
try_files $uri $uri/ $uri.html /index.html;
try_files $uri $uri/ $uri/index.html /index.html;
}
# Security headers
# Security headers (applied globally)
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
}
add_header Permissions-Policy "geolocation=(self)" always;
}

594
package-lock.json generated
View file

@ -27,6 +27,7 @@
"eslint": "^8.57.0",
"eslint-config-next": "^14.2.0",
"postcss": "^8.4.38",
"sharp": "^0.34.5",
"tailwindcss": "^3.4.3",
"typescript": "^5.4.0"
}
@ -179,6 +180,544 @@
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/@img/colour": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
"integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-darwin-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
"integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.2.4"
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
"integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
"integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
"cpu": [
"x64"
],
"dev": true,
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
"integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
"cpu": [
"arm"
],
"dev": true,
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
"integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-ppc64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
"integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
"cpu": [
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-riscv64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
"integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
"cpu": [
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
"integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
"cpu": [
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
"integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
"integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
"integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-linux-arm": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
"integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
"cpu": [
"arm"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.2.4"
}
},
"node_modules/@img/sharp-linux-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
"integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-ppc64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
"integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
"cpu": [
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-ppc64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-riscv64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
"integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
"cpu": [
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-riscv64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-s390x": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
"integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
"cpu": [
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.2.4"
}
},
"node_modules/@img/sharp-linux-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
"integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.2.4"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
"integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
"integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.2.4"
}
},
"node_modules/@img/sharp-wasm32": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
"integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
"cpu": [
"wasm32"
],
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
"@emnapi/runtime": "^1.7.0"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
"integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-ia32": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
"integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
"integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@ -2078,6 +2617,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
"node_modules/didyoumean": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
@ -5242,6 +5791,51 @@
"node": ">= 0.4"
}
},
"node_modules/sharp": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
"dev": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@img/colour": "^1.0.0",
"detect-libc": "^2.1.2",
"semver": "^7.7.3"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "0.34.5",
"@img/sharp-darwin-x64": "0.34.5",
"@img/sharp-libvips-darwin-arm64": "1.2.4",
"@img/sharp-libvips-darwin-x64": "1.2.4",
"@img/sharp-libvips-linux-arm": "1.2.4",
"@img/sharp-libvips-linux-arm64": "1.2.4",
"@img/sharp-libvips-linux-ppc64": "1.2.4",
"@img/sharp-libvips-linux-riscv64": "1.2.4",
"@img/sharp-libvips-linux-s390x": "1.2.4",
"@img/sharp-libvips-linux-x64": "1.2.4",
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
"@img/sharp-libvips-linuxmusl-x64": "1.2.4",
"@img/sharp-linux-arm": "0.34.5",
"@img/sharp-linux-arm64": "0.34.5",
"@img/sharp-linux-ppc64": "0.34.5",
"@img/sharp-linux-riscv64": "0.34.5",
"@img/sharp-linux-s390x": "0.34.5",
"@img/sharp-linux-x64": "0.34.5",
"@img/sharp-linuxmusl-arm64": "0.34.5",
"@img/sharp-linuxmusl-x64": "0.34.5",
"@img/sharp-wasm32": "0.34.5",
"@img/sharp-win32-arm64": "0.34.5",
"@img/sharp-win32-ia32": "0.34.5",
"@img/sharp-win32-x64": "0.34.5"
}
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",

View file

@ -5,30 +5,32 @@
"scripts": {
"dev": "next dev",
"build": "next build",
"export": "NEXT_EXPORT=true next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"clsx": "^2.1.1",
"leaflet": "^1.9.4",
"lucide-react": "^0.378.0",
"next": "^14.2.0",
"pocketbase": "^0.21.0",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"pocketbase": "^0.21.0",
"leaflet": "^1.9.4",
"react-leaflet": "^4.2.1",
"lucide-react": "^0.378.0",
"clsx": "^2.1.1",
"tailwind-merge": "^2.3.0"
},
"devDependencies": {
"typescript": "^5.4.0",
"@types/leaflet": "^1.9.12",
"@types/node": "^20.12.0",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@types/leaflet": "^1.9.12",
"autoprefixer": "^10.4.19",
"postcss": "^8.4.38",
"tailwindcss": "^3.4.3",
"eslint": "^8.57.0",
"eslint-config-next": "^14.2.0"
"eslint-config-next": "^14.2.0",
"postcss": "^8.4.38",
"sharp": "^0.34.5",
"tailwindcss": "^3.4.3",
"typescript": "^5.4.0"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

BIN
public/icons/icon-128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

BIN
public/icons/icon-144.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3 KiB

BIN
public/icons/icon-152.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

BIN
public/icons/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

BIN
public/icons/icon-384.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

BIN
public/icons/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
public/icons/icon-72.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
public/icons/icon-96.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View file

@ -1,18 +1,82 @@
{
"name": "1Driver Collections",
"short_name": "1Driver",
"description": "Mobile driver collection app",
"description": "Mobile driver collection management app",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#f5f5f5",
"orientation": "portrait-primary",
"background_color": "#f97316",
"theme_color": "#ea580c",
"orientation": "portrait",
"categories": ["business", "logistics"],
"icons": [
{
"src": "/icons/icon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "maskable any"
"src": "/icons/icon-72.png",
"sizes": "72x72",
"type": "image/png"
},
{
"src": "/icons/icon-96.png",
"sizes": "96x96",
"type": "image/png"
},
{
"src": "/icons/icon-128.png",
"sizes": "128x128",
"type": "image/png"
},
{
"src": "/icons/icon-144.png",
"sizes": "144x144",
"type": "image/png"
},
{
"src": "/icons/icon-152.png",
"sizes": "152x152",
"type": "image/png"
},
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-maskable-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/icons/icon-384.png",
"sizes": "384x384",
"type": "image/png"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icons/icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"shortcuts": [
{
"name": "Collection Map",
"short_name": "Map",
"url": "/map/",
"icons": [{ "src": "/icons/icon-96.png", "sizes": "96x96" }]
},
{
"name": "Settings",
"short_name": "Settings",
"url": "/settings/",
"icons": [{ "src": "/icons/icon-96.png", "sizes": "96x96" }]
}
]
}
}

154
public/sw.js Normal file
View file

@ -0,0 +1,154 @@
const CACHE_NAME = '1driver-v1';
const STATIC_CACHE = '1driver-static-v1';
const API_CACHE = '1driver-api-v1';
const IMAGE_CACHE = '1driver-images-v1';
const POCKETBASE_URL = 'https://pocketbase-gnqc88suy8fqk6wyryjjdpvl.169.239.180.130.sslip.io';
const PRECACHE_URLS = [
'/',
'/login/',
'/map/',
'/settings/',
'/manifest.json',
'/icons/icon-192.png',
'/icons/icon-512.png',
'/icons/apple-touch-icon.png',
];
const STATIC_EXTENSIONS = [
'.js', '.css', '.woff', '.woff2', '.ttf', '.eot', '.ico',
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(STATIC_CACHE).then((cache) => {
return cache.addAll(PRECACHE_URLS).catch(() => {
// Precache silently fails for offline-first approach
});
})
);
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(
keys
.filter((key) => key !== CACHE_NAME && key !== STATIC_CACHE && key !== API_CACHE && key !== IMAGE_CACHE)
.map((key) => caches.delete(key))
)
)
);
self.clients.claim();
});
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
// Skip non-GET requests
if (event.request.method !== 'GET') return;
// Skip cross-origin except PocketBase API
if (url.origin !== self.location.origin && !url.href.startsWith(POCKETBASE_URL)) return;
// PocketBase API - Network First
if (url.href.startsWith(POCKETBASE_URL)) {
event.respondWith(networkFirst(event.request, API_CACHE));
return;
}
// Map tiles - Stale While Revalidate
if (url.hostname.includes('tile.openstreetmap.org') || url.hostname.includes('openstreetmap.org')) {
event.respondWith(staleWhileRevalidate(event.request, IMAGE_CACHE));
return;
}
// Leaflet CDN assets - Cache First
if (url.hostname.includes('cdnjs.cloudflare.com')) {
event.respondWith(cacheFirst(event.request, STATIC_CACHE));
return;
}
// Static assets - Cache First
const isStatic = STATIC_EXTENSIONS.some((ext) => url.pathname.endsWith(ext));
if (isStatic) {
event.respondWith(cacheFirst(event.request, STATIC_CACHE));
return;
}
// Navigation requests (HTML pages) - Network First with offline fallback
if (event.request.mode === 'navigate') {
event.respondWith(networkFirst(event.request, STATIC_CACHE, '/'));
return;
}
// Everything else - Stale While Revalidate
event.respondWith(staleWhileRevalidate(event.request, STATIC_CACHE));
});
async function cacheFirst(request, cacheName) {
const cached = await caches.match(request);
if (cached) return cached;
try {
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open(cacheName);
cache.put(request, response.clone());
}
return response;
} catch {
return new Response('Offline', { status: 503, statusText: 'Offline' });
}
}
async function networkFirst(request, cacheName, fallbackPath) {
try {
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open(cacheName);
cache.put(request, response.clone());
}
return response;
} catch {
const cached = await caches.match(request);
if (cached) return cached;
if (fallbackPath) {
const fallback = await caches.match(fallbackPath);
if (fallback) return fallback;
}
return new Response('Offline', { status: 503, statusText: 'Offline' });
}
}
async function staleWhileRevalidate(request, cacheName) {
const cache = await caches.open(cacheName);
const cached = await cache.match(request);
const fetchPromise = fetch(request)
.then((response) => {
if (response.ok) {
cache.put(request, response.clone());
}
return response;
})
.catch(() => cached);
return cached || fetchPromise;
}
// Background sync for offline queue (future enhancement)
self.addEventListener('sync', (event) => {
if (event.tag === 'sync-collections') {
event.waitUntil(syncPendingData());
}
});
async function syncPendingData() {
// Placeholder for future offline queue sync
console.log('[SW] Sync event triggered');
}

52
scripts/generate-icons.js Normal file
View file

@ -0,0 +1,52 @@
const sharp = require('sharp');
const fs = require('fs');
const path = require('path');
const svgPath = path.join(__dirname, '../public/icons/icon.svg');
const outDir = path.join(__dirname, '../public/icons');
async function generateIcons() {
const svgBuffer = fs.readFileSync(svgPath);
const sizes = [
{ name: 'icon-72.png', size: 72 },
{ name: 'icon-96.png', size: 96 },
{ name: 'icon-128.png', size: 128 },
{ name: 'icon-144.png', size: 144 },
{ name: 'icon-152.png', size: 152 },
{ name: 'icon-192.png', size: 192 },
{ name: 'icon-384.png', size: 384 },
{ name: 'icon-512.png', size: 512 },
{ name: 'icon-maskable-192.png', size: 192 },
{ name: 'icon-maskable-512.png', size: 512 },
];
for (const { name, size } of sizes) {
const isMaskable = name.includes('maskable');
const padding = isMaskable ? Math.round(size * 0.1) : 0;
const innerSize = size - padding * 2;
await sharp(svgBuffer)
.resize(innerSize, innerSize)
.extend({
top: padding,
bottom: padding,
left: padding,
right: padding,
background: { r: 234, g: 88, b: 12, alpha: 1 },
})
.png()
.toFile(path.join(outDir, name));
console.log(`Generated ${name} (${size}x${size})`);
}
// Apple touch icon (180x180)
await sharp(svgBuffer)
.resize(180, 180)
.png()
.toFile(path.join(outDir, 'apple-touch-icon.png'));
console.log('Generated apple-touch-icon.png (180x180)');
}
generateIcons().catch(console.error);