feat: initialize project with Next.js, TypeScript, and Tailwind CSS

- Added package.json with dependencies for Next.js, React, and Tailwind CSS.
- Created postcss.config.js for Tailwind CSS and autoprefixer integration.
- Added SVG icon for the application in public/icons/icon.svg.
- Created manifest.json for PWA support with app details and icon.
- Configured Tailwind CSS in tailwind.config.js with custom colors and animations.
- Set up TypeScript configuration in tsconfig.json for strict type checking and module resolution.
This commit is contained in:
SJCoetz 2026-05-24 21:51:29 +02:00
commit b738bc7495
32 changed files with 9321 additions and 0 deletions

11
.dockerignore Normal file
View file

@ -0,0 +1,11 @@
node_modules
.next
dist
.git
.gitignore
README.md
.env
.env.local
.env.*.local
*.log
.DS_Store

5
.env.example Normal file
View file

@ -0,0 +1,5 @@
# PocketBase Configuration
NEXT_PUBLIC_POCKETBASE_URL=https://pocketbase-gnqc88suy8fqk6wyryjjdpvl.169.239.180.130.sslip.io
# This env var is embedded at build time (NEXT_PUBLIC_ prefix)
# Update this if your PocketBase URL changes

4
.gitignore vendored Normal file
View file

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

14
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,14 @@
{
"files.exclude": {
"**/.git": true,
"**/.svn": true,
"**/.hg": true,
"**/.DS_Store": true,
"**/Thumbs.db": true,
".claude": true,
".codex": true,
".gemini": true,
".venv": true,
".superdoc": true
}
}

39
Dockerfile Normal file
View file

@ -0,0 +1,39 @@
# Multi-stage build for Next.js static export
# Stage 1: Build the app
FROM node:20-alpine AS builder
WORKDIR /app
# Install dependencies
COPY package.json package-lock.json ./
RUN npm ci
# Copy source code
COPY . .
# Build argument for PocketBase URL
ARG NEXT_PUBLIC_POCKETBASE_URL
ENV NEXT_PUBLIC_POCKETBASE_URL=${NEXT_PUBLIC_POCKETBASE_URL}
# Build the static export
RUN npm run build
# Stage 2: Serve with nginx
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 custom nginx config
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:80/ || exit 1
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

108
README.md Normal file
View file

@ -0,0 +1,108 @@
# 1Driver - Mobile Collections App
A standalone mobile-first PWA app for drivers to manage collections in the field.
## Features
- **Mobile-first design** - Optimized for phone use with touch-friendly UI
- **PWA support** - Install as a standalone app on iOS and Android
- **Location tracking** - Background location pings at configurable intervals
- **Collection list** - View assigned collections grouped by supplier
- **Map view** - See all collection stops on an interactive Leaflet map
- **Offline support** - Queue actions when offline and sync when connected
- **Problem reporting** - Report issues with photos and problem types
## Screens
1. **List View** - Shows all assigned collections for today/tomorrow, grouped by supplier
2. **Map View** - Shows collection stops on a map with navigation integration
3. **Settings** - Configure location tracking interval, accuracy, and display options
## Tech Stack
- Next.js 14 (App Router)
- TypeScript
- Tailwind CSS
- PocketBase SDK
- Leaflet / React Leaflet
- Lucide Icons
## PocketBase Integration
The app connects to your PocketBase instance at:
```
https://pocketbase-gnqc88suy8fqk6wyryjjdpvl.169.239.180.130.sslip.io
```
### Required Collections
1. **collections** - The main collection items (already exists)
2. **collection_problems** - Problem reports
```
Fields:
- collection (relation to collections)
- problem_type (select: out_of_stock, partial_stock, quality_issue, wrong_item, other)
- description (text)
- photo (file)
- reported_at (date)
```
3. **driver_locations** - Location tracking data
```
Fields:
- driver (relation to users)
- latitude (number)
- longitude (number)
- accuracy (number, optional)
- altitude (number, optional)
- speed (number, optional)
- heading (number, optional)
- timestamp (date)
```
4. **order_items** - Order items (already exists)
5. **suppliers** - Supplier data with lat/lng (already exists)
## Development
```bash
# Install dependencies
npm install
# Run development server
npm run dev
# Build for production
npm run build
```
## Deployment
The app is configured for static export. Build output goes to the `dist/` folder.
For PWA deployment, serve the `dist/` folder with any static file server.
## iOS Installation
1. Open the app URL in Safari
2. Tap the Share button
3. Tap "Add to Home Screen"
4. The app will install as a standalone PWA
## Android Installation
1. Open the app URL in Chrome
2. Tap the menu (3 dots)
3. Tap "Add to Home Screen" or "Install App"
## Location Permissions
The app requires location permissions for:
- Showing your current position on the map
- Tracking driver locations at set intervals
- Calculating distances to collection stops
## Notes
- Location tracking runs in the background while the app is open
- The app uses the PocketBase `users` collection for authentication
- Collections are filtered to show only those assigned to the logged-in driver
- Status updates (collected/problem) sync immediately when online

128
app/globals.css Normal file
View file

@ -0,0 +1,128 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--foreground-rgb: 0, 0, 0;
--background-rgb: 245, 245, 245;
--primary: 234, 88, 12;
--success: 34, 197, 94;
--warning: 234, 179, 8;
--danger: 239, 68, 68;
--info: 6, 182, 212;
}
html,
body {
max-width: 100vw;
overflow-x: hidden;
height: 100%;
-webkit-tap-highlight-color: transparent;
}
body {
color: rgb(var(--foreground-rgb));
background: rgb(var(--background-rgb));
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
overscroll-behavior-y: none;
-webkit-user-select: none;
user-select: none;
}
/* Mobile viewport height fix */
@supports (-webkit-touch-callout: none) {
.h-screen {
height: -webkit-fill-available;
}
.min-h-screen {
min-height: -webkit-fill-available;
}
}
/* Safe area insets for notched phones */
.safe-top {
padding-top: env(safe-area-inset-top);
}
.safe-bottom {
padding-bottom: env(safe-area-inset-bottom);
}
/* Hide scrollbar but keep functionality */
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
/* Touch action optimization */
.touch-manipulation {
touch-action: manipulation;
}
/* Prevent text selection on buttons */
button {
-webkit-user-select: none;
user-select: none;
}
/* Leaflet container fix */
.leaflet-container {
width: 100%;
height: 100%;
z-index: 1;
}
/* Animation utilities */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes slideInRight {
from { transform: translateX(100%); }
to { transform: translateX(0); }
}
@keyframes slideOutRight {
from { transform: translateX(0); }
to { transform: translateX(100%); }
}
.animate-fade-in {
animation: fadeIn 0.3s ease-out forwards;
}
/* 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);
}
.card-shadow-lg {
box-shadow: 0 4px 6px rgba(0,0,0,0.1), 0 2px 4px rgba(0,0,0,0.06);
}
/* Pull to refresh indicator */
.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;
}
@keyframes skeleton-loading {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}

46
app/layout.tsx Normal file
View file

@ -0,0 +1,46 @@
import type { Metadata, Viewport } from "next";
import "./globals.css";
import { AuthProvider } from "@/components/AuthProvider";
export const metadata: Metadata = {
title: "1Driver - Collections",
description: "Mobile driver collection app",
manifest: "/manifest.json",
appleWebApp: {
capable: true,
statusBarStyle: "black-translucent",
title: "1Driver",
},
};
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
maximumScale: 1,
userScalable: false,
viewportFit: "cover",
themeColor: "#ea580c",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<head>
<link rel="apple-touch-icon" href="/icons/icon.svg" />
<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" />
</head>
<body className="antialiased bg-gray-50 safe-top safe-bottom">
<AuthProvider>
{children}
</AuthProvider>
</body>
</html>
);
}

113
app/login/page.tsx Normal file
View file

@ -0,0 +1,113 @@
'use client';
import { useState } from 'react';
import { login } from '@/lib/pocketbase';
import { cn } from '@/lib/utils';
import { Truck, Eye, EyeOff } from 'lucide-react';
export default function LoginPage() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setIsLoading(true);
try {
await login(email, password);
// Auth provider will detect the change and redirect
window.location.href = '/';
} catch (err) {
setError('Invalid email or password');
} finally {
setIsLoading(false);
}
};
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="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>
<h1 className="text-2xl font-bold text-gray-900">1Driver</h1>
<p className="text-sm text-gray-500 mt-1">Collections App</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">
<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"
required
/>
<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>
)}
<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'
)}
>
{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>
</div>
</div>
);
}

158
app/map/page.tsx Normal file
View file

@ -0,0 +1,158 @@
'use client';
import { useState, useEffect, useCallback, Suspense } from 'react';
import { useRouter } from 'next/navigation';
import dynamic from 'next/dynamic';
import { Collection } from '@/lib/types';
import { useAuth } from '@/components/AuthProvider';
import BottomNav from '@/components/BottomNav';
import LocationTracker from '@/components/LocationTracker';
import { getAssignedCollections, isAuthenticated } from '@/lib/pocketbase';
import { cn } from '@/lib/utils';
import { RefreshCw, MapPin, AlertCircle } from 'lucide-react';
// Dynamic import for Leaflet to avoid SSR issues
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>
),
});
export default function MapPage() {
const router = useRouter();
const { isLoggedIn, isLoading: authLoading } = useAuth();
const [collections, setCollections] = useState<Collection[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isRefreshing, setIsRefreshing] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
if (!authLoading && !isLoggedIn) {
router.push('/login/');
}
}, [isLoggedIn, authLoading, router]);
const loadCollections = useCallback(async (showLoading = true) => {
if (!isAuthenticated()) return;
if (showLoading) setIsLoading(true);
setError('');
try {
const data = await getAssignedCollections();
setCollections(data);
} catch (err) {
console.error('Failed to load collections:', err);
setError('Failed to load collections');
} finally {
setIsLoading(false);
setIsRefreshing(false);
}
}, []);
useEffect(() => {
loadCollections();
}, [loadCollections]);
const handleNavigate = (lat: number, lng: number, name: string) => {
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
let url: string;
if (isIOS) {
url = `maps://maps.apple.com/?q=${encodeURIComponent(name)}&ll=${lat},${lng}`;
} else {
url = `https://www.google.com/maps?q=${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>
);
}
if (!isLoggedIn) {
return null;
}
return (
<div className="h-screen flex flex-col bg-gray-50">
<LocationTracker />
{/* Header */}
<header className="bg-orange-600 text-white safe-top z-40 shadow-md 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">
{collections.length} stops
</span>
<button
onClick={() => {
setIsRefreshing(true);
loadCollections(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>
</div>
</header>
{/* Map */}
<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>
) : 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" />
<p className="text-gray-600 text-sm mb-4 text-center">{error}</p>
<button
onClick={() => loadCollections()}
className="px-4 py-2 bg-orange-500 text-white rounded-lg text-sm font-medium"
>
Try Again
</button>
</div>
) : collections.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>
<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>
}
>
<CollectionMap
collections={collections}
onNavigate={handleNavigate}
/>
</Suspense>
)}
</main>
<BottomNav />
</div>
);
}

432
app/page.tsx Normal file
View file

@ -0,0 +1,432 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { Collection } from '@/lib/types';
import { useAuth } from '@/components/AuthProvider';
import BottomNav from '@/components/BottomNav';
import LocationTracker from '@/components/LocationTracker';
import CollectionItemCard from '@/components/CollectionItemCard';
import CollectModal from '@/components/CollectModal';
import ProblemModal from '@/components/ProblemModal';
import {
getAssignedCollections,
updateCollectionStatus,
reportProblem,
isAuthenticated,
logout,
} from '@/lib/pocketbase';
import { cn, formatDate } from '@/lib/utils';
import {
RefreshCw,
LogOut,
MapPin,
AlertCircle,
CheckCircle2,
Clock,
Package,
Search,
X,
Filter,
} from 'lucide-react';
export default function CollectionsPage() {
const router = useRouter();
const { isLoggedIn, user, isLoading: authLoading } = useAuth();
const [collections, setCollections] = useState<Collection[]>([]);
const [filteredCollections, setFilteredCollections] = useState<Collection[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isRefreshing, setIsRefreshing] = useState(false);
const [error, setError] = useState('');
const [searchQuery, setSearchQuery] = useState('');
const [selectedCollection, setSelectedCollection] = useState<Collection | null>(null);
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' | 'tomorrow'>('all');
// Redirect if not authenticated
useEffect(() => {
if (!authLoading && !isLoggedIn) {
router.push('/login/');
}
}, [isLoggedIn, authLoading, router]);
const loadCollections = useCallback(async (showLoading = true) => {
if (!isAuthenticated()) return;
if (showLoading) setIsLoading(true);
setError('');
try {
const data = await getAssignedCollections();
setCollections(data);
setFilteredCollections(data);
} catch (err) {
console.error('Failed to load collections:', err);
setError('Failed to load collections. Please try again.');
} finally {
setIsLoading(false);
setIsRefreshing(false);
}
}, []);
useEffect(() => {
loadCollections();
}, [loadCollections]);
// Filter collections
useEffect(() => {
let filtered = collections;
// Search filter
if (searchQuery) {
const query = searchQuery.toLowerCase();
filtered = filtered.filter((c) => {
const desc = c.is_manual
? c.description
: c.expand?.order_item?.description || '';
const supplier = c.expand?.supplier?.suppliername || '';
const orderNum = c.expand?.order_item?.expand?.order?.order_number || '';
return (
desc.toLowerCase().includes(query) ||
supplier.toLowerCase().includes(query) ||
orderNum.toLowerCase().includes(query)
);
});
}
// Date filter
if (filter !== 'all') {
const today = new Date();
today.setHours(0, 0, 0, 0);
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
filtered = filtered.filter((c) => {
if (!c.planned_date) return filter === 'today';
const planned = new Date(c.planned_date);
planned.setHours(0, 0, 0, 0);
if (filter === 'today') {
return planned.getTime() === today.getTime();
} else if (filter === 'tomorrow') {
return planned.getTime() === tomorrow.getTime();
}
return true;
});
}
setFilteredCollections(filtered);
}, [collections, searchQuery, filter]);
const handleRefresh = async () => {
setIsRefreshing(true);
await loadCollections(false);
};
const showToast = (message: string, type: 'success' | 'error' | 'warning' = 'success') => {
setToast({ message, type });
setTimeout(() => setToast(null), 3000);
};
const handleCollect = async (collection: Collection, receivedQty: number, notes: string) => {
try {
await updateCollectionStatus(collection.id, 'collected', {
received_qty: receivedQty,
notes,
actual_date: new Date().toISOString(),
});
// Remove from list
setCollections((prev) => prev.filter((c) => c.id !== collection.id));
setShowCollectModal(false);
showToast('Collection completed successfully');
} catch (err) {
console.error('Failed to collect:', err);
showToast('Failed to update collection', 'error');
}
};
const handleProblem = async (
collection: Collection,
problemType: string,
description: string,
photo?: File
) => {
try {
await reportProblem(collection.id, problemType, description);
setCollections((prev) => prev.filter((c) => c.id !== collection.id));
setShowProblemModal(false);
showToast('Problem reported successfully', 'warning');
} catch (err) {
console.error('Failed to report problem:', err);
showToast('Failed to report problem', 'error');
}
};
const handleNavigate = (lat: number, lng: number, name: string) => {
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent);
let url: string;
if (isIOS) {
url = `maps://maps.apple.com/?q=${encodeURIComponent(name)}&ll=${lat},${lng}`;
} else {
url = `https://www.google.com/maps?q=${lat},${lng}`;
}
window.open(url, '_blank');
};
// Group by supplier
const groupedBySupplier = filteredCollections.reduce((acc, collection) => {
const supplier = collection.expand?.supplier;
if (!supplier) return acc;
const key = supplier.id;
if (!acc[key]) {
acc[key] = { supplier, items: [] };
}
acc[key].items.push(collection);
return acc;
}, {} as Record<string, { supplier: NonNullable<Collection['expand']>['supplier']; items: Collection[] }>);
const groupedArray = Object.values(groupedBySupplier).sort((a, b) =>
(a.supplier?.suppliername || '').localeCompare(b.supplier?.suppliername || '')
);
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>
);
}
if (!isLoggedIn) {
return null; // Will redirect
}
return (
<div className="min-h-screen bg-gray-50 pb-20">
<LocationTracker />
{/* Header */}
<header className="bg-orange-600 text-white safe-top sticky top-0 z-40 shadow-md">
<div className="px-4 py-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>
<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"
>
<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" />
<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"
/>
{searchQuery && (
<button
onClick={() => setSearchQuery('')}
className="absolute right-3 top-1/2 -translate-y-1/2 text-white/60"
>
<X size={16} />
</button>
)}
</div>
</div>
{/* Filter Tabs */}
<div className="px-4 pb-2 flex gap-2 overflow-x-auto no-scrollbar">
{[
{ key: 'all' as const, label: 'All', icon: Package },
{ key: 'today' as const, label: 'Today', icon: Clock },
{ key: 'tomorrow' as const, label: 'Tomorrow', icon: Clock },
].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]">
{key === 'all'
? collections.length
: key === 'today'
? collections.filter((c) => {
const today = new Date();
today.setHours(0, 0, 0, 0);
const planned = c.planned_date ? new Date(c.planned_date) : today;
planned.setHours(0, 0, 0, 0);
return planned.getTime() === today.getTime();
}).length
: collections.filter((c) => {
const today = new Date();
today.setHours(0, 0, 0, 0);
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
const planned = c.planned_date ? new Date(c.planned_date) : null;
if (!planned) return false;
planned.setHours(0, 0, 0, 0);
return planned.getTime() === tomorrow.getTime();
}).length}
</span>
</button>
))}
</div>
</header>
{/* Content */}
<main className="p-4">
{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" />
</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"
>
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 for this period'}
</p>
</div>
) : (
<div className="space-y-6">
{groupedArray.map(({ supplier, items }) => (
<div key={supplier!.id}>
{/* Supplier Header */}
<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!.address && (
<span className="text-xs text-gray-400 truncate flex-1 text-right">
{supplier!.address}
</span>
)}
</div>
{/* Items */}
<div className="space-y-2">
{items.map((collection) => (
<CollectionItemCard
key={collection.id}
collection={collection}
onCollect={(c) => {
setSelectedCollection(c);
setShowCollectModal(true);
}}
onProblem={(c) => {
setSelectedCollection(c);
setShowProblemModal(true);
}}
onNavigate={handleNavigate}
/>
))}
</div>
</div>
))}
</div>
)}
</main>
{/* Modals */}
<CollectModal
collection={selectedCollection}
isOpen={showCollectModal}
onClose={() => {
setShowCollectModal(false);
setSelectedCollection(null);
}}
onConfirm={handleCollect}
/>
<ProblemModal
collection={selectedCollection}
isOpen={showProblemModal}
onClose={() => {
setShowProblemModal(false);
setSelectedCollection(null);
}}
onConfirm={handleProblem}
/>
{/* Toast */}
{toast && (
<div className="fixed top-4 left-4 right-4 z-[60] animate-fade-in">
<div
className={cn(
'px-4 py-3 rounded-xl shadow-lg text-sm font-medium text-white text-center',
toast.type === 'success' && 'bg-green-500',
toast.type === 'error' && 'bg-red-500',
toast.type === 'warning' && 'bg-amber-500'
)}
>
{toast.message}
</div>
</div>
)}
<BottomNav />
</div>
);
}

362
app/settings/page.tsx Normal file
View file

@ -0,0 +1,362 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/components/AuthProvider';
import BottomNav from '@/components/BottomNav';
import { getSettings, saveSettings, logout } from '@/lib/pocketbase';
import { AppSettings, DEFAULT_SETTINGS } from '@/lib/types';
import { cn } from '@/lib/utils';
import {
Settings,
Clock,
Crosshair,
Moon,
Type,
WifiOff,
LogOut,
ChevronRight,
Save,
RotateCcw,
Info,
} 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' },
];
const ACCURACY_OPTIONS = [
{ value: 10, label: '10m (High)' },
{ value: 50, label: '50m (Balanced)' },
{ value: 100, label: '100m (Battery)' },
{ value: 500, label: '500m (Low)' },
];
export default function SettingsPage() {
const router = useRouter();
const { isLoggedIn, user, isLoading: authLoading } = useAuth();
const [settings, setSettings] = useState<AppSettings>(DEFAULT_SETTINGS);
const [saved, setSaved] = useState(false);
useEffect(() => {
if (!authLoading && !isLoggedIn) {
router.push('/login/');
}
}, [isLoggedIn, authLoading, router]);
useEffect(() => {
setSettings(getSettings());
}, []);
const updateSetting = <K extends keyof AppSettings>(key: K, value: AppSettings[K]) => {
const updated = { ...settings, [key]: value };
setSettings(updated);
saveSettings({ [key]: value });
setSaved(true);
setTimeout(() => setSaved(false), 2000);
};
const resetSettings = () => {
if (confirm('Reset all settings to defaults?')) {
setSettings(DEFAULT_SETTINGS);
saveSettings(DEFAULT_SETTINGS);
setSaved(true);
setTimeout(() => setSaved(false), 2000);
}
};
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>
);
}
if (!isLoggedIn) {
return null;
}
return (
<div className="min-h-screen bg-gray-50 pb-20">
{/* 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>
<div>
<h1 className="text-lg font-bold">Settings</h1>
<p className="text-xs text-gray-400">
{user?.name || user?.email}
</p>
</div>
</div>
</div>
</header>
<main className="p-4 space-y-6">
{/* 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">
Location Tracking
</h2>
</div>
<div className="bg-white rounded-xl card-shadow overflow-hidden">
{/* Update Interval */}
<div className="p-4 border-b border-gray-100">
<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>
</div>
<span className="text-xs text-orange-600 font-medium bg-orange-50 px-2 py-1 rounded-full">
{settings.locationInterval}s
</span>
</div>
<div className="grid grid-cols-3 gap-2">
{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',
settings.locationInterval === option.value
? 'bg-orange-500 text-white shadow-sm'
: 'bg-gray-50 text-gray-600 hover: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="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>
</div>
<span className="text-xs text-orange-600 font-medium bg-orange-50 px-2 py-1 rounded-full">
{settings.minimumAccuracy}m
</span>
</div>
<div className="grid grid-cols-2 gap-2">
{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',
settings.minimumAccuracy === option.value
? 'bg-orange-500 text-white shadow-sm'
: 'bg-gray-50 text-gray-600 hover:bg-gray-100'
)}
>
{option.label}
</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>
<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>
</div>
</div>
<button
onClick={() =>
updateSetting('enableHighAccuracy', !settings.enableHighAccuracy)
}
className={cn(
'w-12 h-7 rounded-full transition-colors relative',
settings.enableHighAccuracy ? 'bg-orange-500' : 'bg-gray-300'
)}
>
<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'
)}
/>
</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">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<WifiOff size={16} className="text-gray-400" />
<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>
</div>
</div>
<button
onClick={() =>
updateSetting('offlineMode', !settings.offlineMode)
}
className={cn(
'w-12 h-7 rounded-full transition-colors relative',
settings.offlineMode ? 'bg-orange-500' : 'bg-gray-300'
)}
>
<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'
)}
/>
</button>
</div>
</div>
</div>
</section>
{/* 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>
</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>
<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>
</div>
</div>
</section>
{/* Actions */}
<div className="space-y-3">
<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'
)}
>
<RotateCcw size={16} />
Reset to Defaults
</button>
<button
onClick={() => {
if (confirm('Are you sure you want to sign out?')) {
logout();
}
}}
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'
)}
>
<LogOut size={18} />
Sign Out
</button>
</div>
</main>
{/* 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">
<Save size={16} />
Settings saved
</div>
</div>
)}
<BottomNav />
</div>
);
}

View file

@ -0,0 +1,52 @@
'use client';
import React, { createContext, useContext, useState, useEffect } from 'react';
import { pb, getCurrentUser, isAuthenticated } from '@/lib/pocketbase';
interface AuthContextType {
isLoggedIn: boolean;
user: ReturnType<typeof getCurrentUser>;
isLoading: boolean;
refresh: () => void;
}
const AuthContext = createContext<AuthContextType>({
isLoggedIn: false,
user: null,
isLoading: true,
refresh: () => {},
});
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [isLoggedIn, setIsLoggedIn] = useState(false);
const [user, setUser] = useState<ReturnType<typeof getCurrentUser>>(null);
const [isLoading, setIsLoading] = useState(true);
const refresh = () => {
const valid = isAuthenticated();
setIsLoggedIn(valid);
setUser(getCurrentUser());
};
useEffect(() => {
refresh();
setIsLoading(false);
// Listen for auth store changes
const unsubscribe = pb.authStore.onChange(() => {
refresh();
});
return () => {
unsubscribe();
};
}, []);
return (
<AuthContext.Provider value={{ isLoggedIn, user, isLoading, refresh }}>
{children}
</AuthContext.Provider>
);
}
export const useAuth = () => useContext(AuthContext);

56
components/BottomNav.tsx Normal file
View file

@ -0,0 +1,56 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { List, Map, Settings } from 'lucide-react';
import { cn } from '@/lib/utils';
const navItems = [
{ href: '/', label: 'List', icon: List },
{ href: '/map/', label: 'Map', icon: Map },
{ href: '/settings/', label: 'Settings', icon: Settings },
];
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">
{navItems.map((item) => {
const Icon = item.icon;
const isActive = pathname === 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'
)}
>
<Icon
size={22}
strokeWidth={isActive ? 2.5 : 2}
className={cn(
'transition-colors duration-200',
isActive && 'drop-shadow-sm'
)}
/>
<span
className={cn(
'text-[10px] mt-0.5 font-medium transition-colors duration-200',
isActive ? 'text-primary-600' : 'text-gray-400'
)}
>
{item.label}
</span>
</Link>
);
})}
</div>
</nav>
);
}

149
components/CollectModal.tsx Normal file
View file

@ -0,0 +1,149 @@
'use client';
import { useState } from 'react';
import { Collection } from '@/lib/types';
import { cn, formatCurrency } from '@/lib/utils';
import { X, Minus, Plus } from 'lucide-react';
interface CollectModalProps {
collection: Collection | null;
isOpen: boolean;
onClose: () => void;
onConfirm: (collection: Collection, receivedQty: number, notes: string) => void;
}
export default function CollectModal({
collection,
isOpen,
onClose,
onConfirm,
}: CollectModalProps) {
const [receivedQty, setReceivedQty] = useState<string>('');
const [notes, setNotes] = useState('');
const [error, setError] = useState('');
if (!isOpen || !collection) return null;
const expectedQty = collection.quantity || collection.expand?.order_item?.quantity || 0;
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setError('');
const qty = parseFloat(receivedQty);
if (isNaN(qty) || qty <= 0) {
setError('Please enter a valid quantity');
return;
}
onConfirm(collection, qty, notes);
setReceivedQty('');
setNotes('');
setError('');
};
const adjustQty = (delta: number) => {
const current = parseFloat(receivedQty) || 0;
const newQty = Math.max(0, current + delta);
setReceivedQty(newQty.toString());
};
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>
<button
onClick={onClose}
className="p-1 rounded-full hover:bg-gray-100 active:bg-gray-200 transition-colors"
>
<X size={20} />
</button>
</div>
<div className="mb-4 p-3 bg-gray-50 rounded-lg">
<p className="text-sm font-medium truncate">
{collection.is_manual
? collection.description
: collection.expand?.order_item?.description || 'Unknown item'}
</p>
<p className="text-xs text-gray-500 mt-1">
Expected: {expectedQty} {collection.unit || collection.expand?.order_item?.unit || 'units'}
</p>
{collection.expand?.order_item?.selling_price && (
<p className="text-xs text-green-600 mt-1">
Unit price: {formatCurrency(collection.expand.order_item.selling_price)}
</p>
)}
</div>
<form onSubmit={handleSubmit}>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
Received Quantity *
</label>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => adjustQty(-1)}
className="p-3 rounded-lg bg-gray-100 active:bg-gray-200 transition-colors"
>
<Minus size={18} />
</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"
autoFocus
/>
<button
type="button"
onClick={() => adjustQty(1)}
className="p-3 rounded-lg bg-gray-100 active:bg-gray-200 transition-colors"
>
<Plus size={18} />
</button>
</div>
{isPartial && (
<p className="text-xs text-amber-600 mt-1">
Partial collection: {receivedQty} of {expectedQty} units
</p>
)}
{error && <p className="text-xs text-red-500 mt-1">{error}</p>}
</div>
<div className="mb-4">
<label className="block text-sm font-medium 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"
rows={2}
placeholder="Add any notes..."
/>
</div>
<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'
)}
>
Confirm Collection
</button>
</form>
</div>
</div>
);
}

View file

@ -0,0 +1,240 @@
'use client';
import { useState } from 'react';
import { Collection } from '@/lib/types';
import { cn, formatDate, formatCurrency } from '@/lib/utils';
import {
Check,
AlertTriangle,
Navigation,
ChevronDown,
ChevronUp,
Package,
Wrench,
Hammer,
ShoppingCart,
AlertCircle,
} from 'lucide-react';
interface CollectionItemProps {
collection: Collection;
onCollect: (collection: Collection) => void;
onProblem: (collection: Collection) => void;
onNavigate: (lat: number, lng: number, name: string) => void;
}
function getItemType(collection: Collection): {
type: string;
icon: React.ReactNode;
color: string;
label: string;
} {
if (collection.status === 'problem') {
return {
type: 'problem',
icon: <AlertTriangle size={16} />,
color: 'text-red-500',
label: 'Problem',
};
}
const orderNumber = collection.expand?.order_item?.expand?.order?.order_number || '';
const desc = collection.description || collection.expand?.order_item?.description || '';
if (orderNumber.toLowerCase().includes('breakdown')) {
return {
type: 'breakdown',
icon: <Hammer size={16} />,
color: 'text-orange-600',
label: 'Breakdown',
};
}
if (orderNumber.toLowerCase().includes('repair')) {
return {
type: 'repair',
icon: <Wrench size={16} />,
color: 'text-amber-500',
label: 'Repair',
};
}
if (collection.is_manual) {
return {
type: 'manual',
icon: <Package size={16} />,
color: 'text-purple-500',
label: 'Manual',
};
}
return {
type: 'normal',
icon: <ShoppingCart size={16} />,
color: 'text-orange-500',
label: 'Order',
};
}
export default function CollectionItemCard({
collection,
onCollect,
onProblem,
onNavigate,
}: CollectionItemProps) {
const [expanded, setExpanded] = useState(false);
const [showActions, setShowActions] = useState(false);
const supplier = collection.expand?.supplier;
const orderItem = collection.expand?.order_item;
const itemType = getItemType(collection);
const hasCoords = supplier?.latitude && supplier?.longitude;
const description = collection.is_manual
? collection.description
: orderItem?.description || 'Unknown item';
const quantity = collection.quantity || orderItem?.quantity || 0;
const unit = collection.unit || orderItem?.unit || 'units';
const orderNumber = orderItem?.expand?.order?.order_number || '';
const poNumber = orderItem?.expand?.purchase_order?.po_number || '';
return (
<div
className={cn(
'bg-white rounded-xl card-shadow mb-3 overflow-hidden animate-fade-in',
collection.status === 'problem' && 'border-l-4 border-red-500',
collection.status === 'assigned' && 'border-l-4 border-orange-500',
collection.status === 'pending' && 'border-l-4 border-gray-300'
)}
>
{/* Header */}
<div
className="p-4 cursor-pointer active:bg-gray-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 === 'manual' && '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: {quantity} {unit}</p>
{orderNumber && <p>Order: {orderNumber}</p>}
{poNumber && <p>PO: {poNumber}</p>}
{collection.planned_date && (
<p>Planned: {formatDate(collection.planned_date)}</p>
)}
{orderItem?.selling_price && (
<p className="text-green-600 font-medium">
{formatCurrency(orderItem.selling_price * quantity)}
</p>
)}
</div>
</div>
<div className="flex flex-col items-end gap-2 ml-2">
{expanded ? (
<ChevronUp size={20} className="text-gray-400" />
) : (
<ChevronDown size={20} className="text-gray-400" />
)}
</div>
</div>
</div>
{/* Expanded Details */}
{expanded && (
<div className="px-4 pb-4 border-t border-gray-100 pt-3">
{collection.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>{collection.notes}</span>
</p>
</div>
)}
{supplier?.address && (
<p className="text-xs text-gray-500 mb-3">
<strong>Address:</strong> {supplier.address}
</p>
)}
{/* Action Buttons */}
<div className="grid grid-cols-3 gap-2">
<button
onClick={(e) => {
e.stopPropagation();
onCollect(collection);
}}
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(collection);
}}
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 && supplier && (
<button
onClick={(e) => {
e.stopPropagation();
onNavigate(supplier.latitude!, supplier.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>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,222 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { MapContainer, TileLayer, Marker, Popup, useMap } from 'react-leaflet';
import { Collection, Supplier } from '@/lib/types';
import { formatCurrency, calculateDistance } from '@/lib/utils';
import { Navigation, Check, AlertTriangle } from 'lucide-react';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
// Fix Leaflet default markers in Next.js
const defaultIcon = L.icon({
iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon.png',
iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon-2x.png',
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png',
iconSize: [25, 41],
iconAnchor: [12, 41],
popupAnchor: [1, -34],
shadowSize: [41, 41],
});
const currentLocationIcon = L.divIcon({
className: 'custom-div-icon',
html: `
<div style="
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],
});
interface MapViewProps {
collections: Collection[];
onNavigate: (lat: number, lng: number, name: string) => void;
}
function MapBounds({ collections }: { collections: Collection[] }) {
const map = useMap();
useEffect(() => {
const validPoints = collections
.filter((c) => c.expand?.supplier?.latitude && c.expand?.supplier?.longitude)
.map((c) => [c.expand!.supplier!.latitude!, c.expand!.supplier!.longitude!] as [number, number]);
if (validPoints.length > 0) {
const bounds = L.latLngBounds(validPoints);
map.fitBounds(bounds, { padding: [50, 50], maxZoom: 15 });
}
}, [map, collections]);
return null;
}
export default function CollectionMap({ collections, onNavigate }: MapViewProps) {
const [userLocation, setUserLocation] = useState<[number, number] | null>(null);
const [selectedSupplier, setSelectedSupplier] = useState<string | null>(null);
useEffect(() => {
if ('geolocation' in navigator) {
navigator.geolocation.getCurrentPosition(
(pos) => {
setUserLocation([pos.coords.latitude, pos.coords.longitude]);
},
(error) => console.error('Could not get location:', error),
{ enableHighAccuracy: true, timeout: 10000 }
);
const watchId = navigator.geolocation.watchPosition(
(pos) => {
setUserLocation([pos.coords.latitude, pos.coords.longitude]);
},
(error) => console.error('Watch error:', error),
{ enableHighAccuracy: true }
);
return () => navigator.geolocation.clearWatch(watchId);
}
}, []);
// Group collections by supplier
const suppliers = useMemo(() => {
const map = new Map<string, { supplier: Supplier; collections: Collection[] }>();
collections.forEach((collection) => {
if (!collection.expand?.supplier) return;
const supplier = collection.expand.supplier;
if (!supplier.latitude || !supplier.longitude) return;
const key = supplier.id;
if (!map.has(key)) {
map.set(key, { supplier, collections: [] });
}
map.get(key)!.collections.push(collection);
});
return Array.from(map.values());
}, [collections]);
const defaultCenter: [number, number] = [-33.9249, 18.4241]; // Cape Town default
const center = userLocation || defaultCenter;
const getSupplierDistance = (supplier: Supplier) => {
if (!userLocation || !supplier.latitude || !supplier.longitude) return null;
return calculateDistance(
userLocation[0],
userLocation[1],
supplier.latitude,
supplier.longitude
);
};
return (
<div className="h-[calc(100vh-4rem)] 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>'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<MapBounds collections={collections} />
{userLocation && (
<Marker position={userLocation} icon={currentLocationIcon}>
<Popup>
<div className="text-sm font-medium">Your Location</div>
</Popup>
</Marker>
)}
{suppliers.map(({ supplier, collections: supplierCollections }) => {
const distance = getSupplierDistance(supplier);
const hasProblem = supplierCollections.some((c) => c.status === 'problem');
return (
<Marker
key={supplier.id}
position={[supplier.latitude!, supplier.longitude!]}
icon={defaultIcon}
eventHandlers={{
click: () => setSelectedSupplier(supplier.id),
}}
>
<Popup>
<div className="min-w-[200px] max-w-[250px]">
<h3 className="font-bold text-sm mb-1">{supplier.suppliername}</h3>
<p className="text-xs text-gray-500 mb-2">{supplier.address}</p>
{distance && (
<p className="text-xs text-orange-600 mb-2 font-medium">
{distance.toFixed(1)} km away
</p>
)}
<div className="space-y-1.5 mb-3">
{supplierCollections.map((c) => (
<div
key={c.id}
className="text-xs p-1.5 bg-gray-50 rounded"
>
<div className="flex items-center gap-1">
{c.status === 'problem' && (
<AlertTriangle size={12} className="text-red-500" />
)}
<span className="font-medium truncate">
{c.is_manual
? c.description
: c.expand?.order_item?.description || 'Unknown'}
</span>
</div>
<span className="text-gray-400">
Qty: {c.quantity} {c.unit || c.expand?.order_item?.unit || 'units'}
</span>
</div>
))}
</div>
<button
onClick={() =>
onNavigate(supplier.latitude!, supplier.longitude!, supplier.suppliername)
}
className="w-full flex items-center justify-center gap-1.5 py-2 px-3 bg-blue-500 text-white rounded-lg text-sm font-medium active:bg-orange-600 transition-colors"
>
<Navigation size={14} />
Navigate
</button>
</div>
</Popup>
</Marker>
);
})}
</MapContainer>
{/* Legend overlay */}
<div className="absolute top-4 right-4 bg-white/90 backdrop-blur-sm rounded-xl p-3 shadow-lg z-[400]">
<p className="text-xs font-medium text-gray-700 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-blue-500 border-2 border-white shadow-sm" />
<span className="text-[10px] text-gray-600">Your Location</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">Collection Stop</span>
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,111 @@
'use client';
import { useEffect, useRef, useCallback } from 'react';
import { getSettings, saveDriverLocation, addToQueue, isAuthenticated } from '@/lib/pocketbase';
export default function LocationTracker() {
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const watchRef = useRef<number | null>(null);
const sendLocation = useCallback(async (position: GeolocationPosition) => {
if (!isAuthenticated()) return;
const user = JSON.parse(localStorage.getItem('pocketbase_auth') || '{}')?.model;
if (!user) return;
const locationData = {
driver: user.id,
latitude: position.coords.latitude,
longitude: position.coords.longitude,
accuracy: position.coords.accuracy,
altitude: position.coords.altitude || undefined,
speed: position.coords.speed || undefined,
heading: position.coords.heading || undefined,
timestamp: new Date().toISOString(),
};
try {
await saveDriverLocation(locationData);
} catch (error) {
console.error('Failed to save location:', error);
// Queue for sync when back online
addToQueue({
type: 'status_update',
data: locationData,
});
}
}, []);
useEffect(() => {
if (!('geolocation' in navigator)) {
console.warn('Geolocation not supported');
return;
}
const settings = getSettings();
const intervalMs = Math.max(10000, settings.locationInterval * 1000);
const startTracking = () => {
// Use watchPosition for more frequent updates
watchRef.current = navigator.geolocation.watchPosition(
sendLocation,
(error) => {
console.error('Geolocation error:', error);
},
{
enableHighAccuracy: settings.enableHighAccuracy,
maximumAge: intervalMs,
timeout: 10000,
}
);
// Also send on interval as backup
intervalRef.current = setInterval(() => {
navigator.geolocation.getCurrentPosition(
sendLocation,
(error) => console.error('Interval geolocation error:', error),
{
enableHighAccuracy: settings.enableHighAccuracy,
timeout: 10000,
}
);
}, intervalMs);
};
// Start tracking if authenticated
if (isAuthenticated()) {
startTracking();
}
// Listen for auth changes
const handleAuthChange = () => {
if (isAuthenticated()) {
startTracking();
} else {
stopTracking();
}
};
// Check periodically for auth state
const authCheckInterval = setInterval(handleAuthChange, 5000);
const stopTracking = () => {
if (watchRef.current !== null) {
navigator.geolocation.clearWatch(watchRef.current);
watchRef.current = null;
}
if (intervalRef.current !== null) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
};
return () => {
stopTracking();
clearInterval(authCheckInterval);
};
}, [sendLocation]);
// This component doesn't render anything
return null;
}

170
components/ProblemModal.tsx Normal file
View file

@ -0,0 +1,170 @@
'use client';
import { useState } from 'react';
import { Collection, PROBLEM_TYPES } from '@/lib/types';
import { cn } from '@/lib/utils';
import { X, Camera } from 'lucide-react';
interface ProblemModalProps {
collection: Collection | null;
isOpen: boolean;
onClose: () => void;
onConfirm: (collection: Collection, problemType: string, description: string, photo?: File) => void;
}
export default function ProblemModal({
collection,
isOpen,
onClose,
onConfirm,
}: ProblemModalProps) {
const [problemType, setProblemType] = useState('');
const [description, setDescription] = useState('');
const [photo, setPhoto] = useState<File | null>(null);
const [error, setError] = useState('');
if (!isOpen || !collection) return null;
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (!problemType) {
setError('Please select a problem type');
return;
}
if (!description.trim()) {
setError('Please describe the problem');
return;
}
onConfirm(collection, problemType, description, photo || undefined);
setProblemType('');
setDescription('');
setPhoto(null);
setError('');
};
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>
<button
onClick={onClose}
className="p-1 rounded-full hover:bg-gray-100 active:bg-gray-200 transition-colors"
>
<X size={20} />
</button>
</div>
<div className="mb-4 p-3 bg-red-50 rounded-lg">
<p className="text-sm font-medium truncate">
{collection.is_manual
? collection.description
: collection.expand?.order_item?.description || 'Unknown item'}
</p>
<p className="text-xs text-gray-500 mt-1">
{collection.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 *
</label>
<div className="grid grid-cols-1 gap-2">
{PROBLEM_TYPES.map((type) => (
<button
key={type.value}
type="button"
onClick={() => {
setProblemType(type.value);
setError('');
}}
className={cn(
'py-2.5 px-3 rounded-lg text-sm text-left border-2 transition-all',
'touch-manipulation',
problemType === type.value
? 'border-red-500 bg-red-50 text-red-700 font-medium'
: 'border-gray-200 hover:border-gray-300'
)}
>
{type.label}
</button>
))}
</div>
</div>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
Description *
</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"
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 (optional)
</label>
<div className="relative">
<input
type="file"
accept="image/*"
capture="environment"
onChange={(e) => setPhoto(e.target.files?.[0] || null)}
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>
</div>
{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>
)}
<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'
)}
>
Report Problem
</button>
</form>
</div>
</div>
);
}

149
lib/pocketbase.ts Normal file
View file

@ -0,0 +1,149 @@
import PocketBase from 'pocketbase';
import { AppSettings, DEFAULT_SETTINGS, Collection, DriverLocation } from './types';
const POCKETBASE_URL = process.env.NEXT_PUBLIC_POCKETBASE_URL || 'https://pocketbase-gnqc88suy8fqk6wyryjjdpvl.169.239.180.130.sslip.io';
export const pb = new PocketBase(POCKETBASE_URL);
// Auth helpers
export const isAuthenticated = () => pb.authStore.isValid;
export const getCurrentUser = () => pb.authStore.model;
export const logout = () => {
pb.authStore.clear();
if (typeof window !== 'undefined') {
window.location.href = '/login/';
}
};
export const login = async (email: string, password: string) => {
return await pb.collection('users').authWithPassword(email, password);
};
// Collection data helpers
export const getAssignedCollections = async () => {
const user = getCurrentUser();
if (!user) return [];
const today = new Date();
today.setHours(0, 0, 0, 0);
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 2);
const records = await pb.collection('collections').getFullList({
filter: `status = "assigned" && driver = "${user.id}" && planned_date <= "${tomorrow.toISOString()}"`,
expand: 'order_item,supplier',
sort: 'supplier.suppliername',
});
return records as unknown as Collection[];
};
export const getPendingCollections = async () => {
const records = await pb.collection('collections').getFullList({
filter: 'status = "pending" || status = "problem"',
expand: 'order_item,supplier',
sort: 'supplier.suppliername',
});
return records as unknown as Collection[];
};
export const updateCollectionStatus = async (
id: string,
status: string,
data: Partial<Collection>
) => {
return await pb.collection('collections').update(id, {
status,
...data,
});
};
export const reportProblem = async (
collectionId: string,
problemType: string,
description: string
) => {
// Update collection status
await pb.collection('collections').update(collectionId, {
status: 'problem',
notes: description,
});
// Create problem record
return await pb.collection('collection_problems').create({
collection: collectionId,
problem_type: problemType,
description,
});
};
// Location tracking
export const saveDriverLocation = async (location: Omit<DriverLocation, 'id'>) => {
return await pb.collection('driver_locations').create(location);
};
// Settings helpers
const SETTINGS_KEY = '1driver_settings';
export const getSettings = (): AppSettings => {
if (typeof window === 'undefined') return DEFAULT_SETTINGS;
const saved = localStorage.getItem(SETTINGS_KEY);
if (saved) {
try {
return { ...DEFAULT_SETTINGS, ...JSON.parse(saved) };
} catch {
return DEFAULT_SETTINGS;
}
}
return DEFAULT_SETTINGS;
};
export const saveSettings = (settings: Partial<AppSettings>) => {
if (typeof window === 'undefined') return;
const current = getSettings();
const updated = { ...current, ...settings };
localStorage.setItem(SETTINGS_KEY, JSON.stringify(updated));
return updated;
};
// Offline queue helpers
const QUEUE_KEY = '1driver_offline_queue';
export interface QueueItem {
id: string;
type: 'status_update' | 'problem_report';
data: Record<string, unknown>;
timestamp: string;
}
export const addToQueue = (item: Omit<QueueItem, 'id' | 'timestamp'>) => {
if (typeof window === 'undefined') return;
const queue = getQueue();
const newItem: QueueItem = {
...item,
id: crypto.randomUUID(),
timestamp: new Date().toISOString(),
};
queue.push(newItem);
localStorage.setItem(QUEUE_KEY, JSON.stringify(queue));
};
export const getQueue = (): QueueItem[] => {
if (typeof window === 'undefined') return [];
const saved = localStorage.getItem(QUEUE_KEY);
return saved ? JSON.parse(saved) : [];
};
export const removeFromQueue = (id: string) => {
if (typeof window === 'undefined') return;
const queue = getQueue().filter((item) => item.id !== id);
localStorage.setItem(QUEUE_KEY, JSON.stringify(queue));
};
export const clearQueue = () => {
if (typeof window === 'undefined') return;
localStorage.removeItem(QUEUE_KEY);
};

103
lib/types.ts Normal file
View file

@ -0,0 +1,103 @@
export interface Supplier {
id: string;
suppliername: string;
address: string;
latitude: number;
longitude: number;
collection?: string;
}
export interface OrderItem {
id: string;
description: string;
quantity: number;
order_qty: number;
unit: string;
selling_price?: number;
order?: string;
purchase_order?: string;
item_status: string;
expand?: {
order?: { order_number: string };
purchase_order?: { po_number: string };
};
}
export interface Collection {
id: string;
order_item: string;
supplier: string;
driver: string;
status: 'pending' | 'assigned' | 'collected' | 'problem';
quantity: number;
planned_date: string;
actual_date: string;
received_qty: number;
notes: string;
created_at: string;
updated_at: string;
is_manual: boolean;
description: string;
unit: string;
// Expanded fields
expand?: {
order_item?: OrderItem;
supplier?: Supplier;
driver?: Driver;
};
}
export interface CollectionProblem {
id: string;
collection: string;
problem_type: 'out_of_stock' | 'partial_stock' | 'quality_issue' | 'wrong_item' | 'other';
description: string;
photo?: string;
reported_at: string;
}
export interface Driver {
id: string;
email: string;
name: string;
username: string;
}
export interface DriverLocation {
id: string;
driver: string;
latitude: number;
longitude: number;
accuracy?: number;
altitude?: number;
speed?: number;
heading?: number;
timestamp: string;
battery_level?: number;
}
export interface AppSettings {
locationInterval: number; // in seconds
minimumAccuracy: number; // in meters
enableHighAccuracy: boolean;
darkMode: boolean;
fontSize: 'small' | 'normal' | 'large';
offlineMode: boolean;
}
export const DEFAULT_SETTINGS: AppSettings = {
locationInterval: 60,
minimumAccuracy: 50,
enableHighAccuracy: true,
darkMode: false,
fontSize: 'normal',
offlineMode: false,
};
export const PROBLEM_TYPES = [
{ value: 'out_of_stock', label: 'Out of Stock' },
{ value: 'partial_stock', label: 'Partial Stock' },
{ value: 'quality_issue', label: 'Quality Issue' },
{ value: 'wrong_item', label: 'Wrong Item' },
{ value: 'other', label: 'Other' },
] as const;

86
lib/utils.ts Normal file
View file

@ -0,0 +1,86 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatDate(dateString: string): string {
if (!dateString) return 'N/A';
const date = new Date(dateString);
return date.toLocaleDateString('en-ZA', {
day: '2-digit',
month: 'short',
year: 'numeric',
});
}
export function formatDateTime(dateString: string): string {
if (!dateString) return 'N/A';
const date = new Date(dateString);
return date.toLocaleString('en-ZA', {
day: '2-digit',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
export function formatCurrency(amount: number): string {
return new Intl.NumberFormat('en-ZA', {
style: 'currency',
currency: 'ZAR',
}).format(amount);
}
export function getRelativeTime(dateString: string): string {
const date = new Date(dateString);
const now = new Date();
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000);
if (diffInSeconds < 60) return 'Just now';
if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)}m ago`;
if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)}h ago`;
if (diffInSeconds < 604800) return `${Math.floor(diffInSeconds / 86400)}d ago`;
return formatDate(dateString);
}
export function debounce<T extends (...args: unknown[]) => unknown>(
func: T,
wait: number
): (...args: Parameters<T>) => void {
let timeout: ReturnType<typeof setTimeout>;
return (...args: Parameters<T>) => {
clearTimeout(timeout);
timeout = setTimeout(() => func(...args), wait);
};
}
export function groupBy<T>(array: T[], key: keyof T): Record<string, T[]> {
return array.reduce((result, item) => {
const groupKey = String(item[key]);
if (!result[groupKey]) {
result[groupKey] = [];
}
result[groupKey].push(item);
return result;
}, {} as Record<string, T[]>);
}
export function calculateDistance(
lat1: number,
lon1: number,
lat2: number,
lon2: number
): number {
const R = 6371; // Earth's radius in km
const dLat = (lat2 - lat1) * Math.PI / 180;
const dLon = (lon2 - lon1) * Math.PI / 180;
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
Math.sin(dLon / 2) * Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
}

5
next-env.d.ts vendored Normal file
View file

@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.

11
next.config.js Normal file
View file

@ -0,0 +1,11 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'export',
distDir: 'dist',
images: {
unoptimized: true,
},
trailingSlash: true,
};
module.exports = nextConfig;

34
nginx.conf Normal file
View file

@ -0,0 +1,34 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# 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;
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# PWA manifest and service worker - no cache
location ~* (manifest\.json|sw\.js)$ {
expires -1;
add_header Cache-Control "no-store, no-cache, must-revalidate";
}
# SPA fallback - all routes serve index.html
location / {
try_files $uri $uri/ $uri.html /index.html;
}
# Security headers
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;
}

6367
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

34
package.json Normal file
View file

@ -0,0 +1,34 @@
{
"name": "1driver",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"next": "^14.2.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/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"
}
}

6
postcss.config.js Normal file
View file

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

7
public/icons/icon.svg Normal file
View file

@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" rx="64" fill="#ea580c"/>
<circle cx="256" cy="200" r="80" fill="white" opacity="0.9"/>
<path d="M256 300 L256 380 M256 380 L200 440 M256 380 L312 440" stroke="white" stroke-width="24" stroke-linecap="round" fill="none"/>
<circle cx="256" cy="200" r="40" fill="#ea580c"/>
<path d="M236 200 L256 180 L276 200 L256 220 Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 445 B

18
public/manifest.json Normal file
View file

@ -0,0 +1,18 @@
{
"name": "1Driver Collections",
"short_name": "1Driver",
"description": "Mobile driver collection app",
"start_url": "/",
"display": "standalone",
"background_color": "#f5f5f5",
"theme_color": "#ea580c",
"orientation": "portrait",
"icons": [
{
"src": "/icons/icon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "maskable any"
}
]
}

41
tailwind.config.js Normal file
View file

@ -0,0 +1,41 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
'./app/**/*.{js,ts,jsx,tsx,mdx}',
'./components/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {
colors: {
primary: {
50: '#fff7ed',
100: '#ffedd5',
200: '#fed7aa',
300: '#fdba74',
400: '#fb923c',
500: '#f97316',
600: '#ea580c',
700: '#c2410c',
800: '#9a3412',
900: '#7c2d12',
},
},
spacing: {
'18': '4.5rem',
'22': '5.5rem',
},
animation: {
'fade-in': 'fadeIn 0.3s ease-out forwards',
'slide-up': 'slideUp 0.3s ease-out forwards',
'pulse-slow': 'pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite',
},
keyframes: {
slideUp: {
'0%': { transform: 'translateY(20px)', opacity: '0' },
'100%': { transform: 'translateY(0)', opacity: '1' },
},
},
},
},
plugins: [],
};

40
tsconfig.json Normal file
View file

@ -0,0 +1,40 @@
{
"compilerOptions": {
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
"dist/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}