diff --git a/.gitignore b/.gitignore index 47c83f0..6b083ca 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules/ .next/ dist/ +out/ .env \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index fafce0f..e35fa07 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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;"] \ No newline at end of file diff --git a/app/globals.css b/app/globals.css index 33c8fb8..9805b7d 100644 --- a/app/globals.css +++ b/app/globals.css @@ -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; +} \ No newline at end of file diff --git a/app/layout.tsx b/app/layout.tsx index 5652308..99ff85d 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -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 ( - + + + + + + - + + + + {children} ); -} +} \ No newline at end of file diff --git a/app/login/page.tsx b/app/login/page.tsx index 15ef9b7..ea253fc 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -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 ( -
-
- {/* Logo */} +
+
+ {/* Logo area */}
-
- +
+
-

1Driver

-

Collections App

+

1Driver

+

Collection Management

-
-
- - 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" - /> -
- -
- -
+ {/* Card */} +
+ +
+ 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" /> -
-
- {error && ( -
-

{error}

+
+ +
+ 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 + /> + +
- )} -
)} - > - {isLoading ? ( - <> -
- Signing in... - - ) : ( - 'Sign In' - )} - - + + + +
+ +

+ Contact your administrator for account access +

); -} +} \ No newline at end of file diff --git a/app/map/page.tsx b/app/map/page.tsx index 84b91a8..76629a6 100644 --- a/app/map/page.tsx +++ b/app/map/page.tsx @@ -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: () => (
-
+
+
+

Loading map...

+
), }); @@ -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 (
-
+
+
+

Loading...

+
); } @@ -72,46 +78,57 @@ export default function MapPage() {
-
+
-
- -

Collection Map

-
-
- {items.length} stops - +
+
+ +
+
+

Map

+

{items.length} stop{items.length !== 1 ? 's' : ''}

+
+
{isLoading ? (
-
+
+
+

Loading map...

+
) : error ? (
- +
+ +

{error}

- +
) : items.length === 0 ? (
- -

No collections to show

+
+ +
+

No collections to show

All collections completed!

) : ( -
+
}> @@ -122,4 +139,4 @@ export default function MapPage() {
); -} +} \ No newline at end of file diff --git a/app/page.tsx b/app/page.tsx index 1686a2f..f4e0d4e 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -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 (
-
+
+
+

Loading...

+
); } @@ -171,106 +163,135 @@ export default function CollectionsPage() { if (!isLoggedIn) return null; return ( -
+
-
-
+ {/* Header */} +
+
-
-

Collections

-

{user?.name || user?.email}

+
+

Collections

+

{user?.name || user?.email}

-
- - +
+
+ {/* Search bar */}
- + 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 && ( - )}
-
- {[ - { key: 'all' as const, label: 'All', icon: Package }, - ].map(({ key, label, icon: Icon }) => ( - - ))} + {/* Stats pill */} +
+ + + {items.length} item{items.length !== 1 ? 's' : ''} + + {groupedArray.length > 0 && ( + + + {groupedArray.length} stop{groupedArray.length !== 1 ? 's' : ''} + + )}
-
+ {/* Content */} +
{isLoading ? (
- {[...Array(5)].map((_, i) => ( -
-
-
-
+ {[...Array(4)].map((_, i) => ( +
+
+
+
))}
) : error ? ( -
- -

{error}

-
) : groupedArray.length === 0 ? ( -
- -

All caught up!

-

- {searchQuery ? 'No collections match your search' : 'No assigned collections'} +

+
+ +
+

All caught up!

+

+ {searchQuery ? 'No collections match your search' : 'No assigned collections right now'}

) : (
- {groupedArray.map(({ supplier, items }) => ( -
-
- -

{supplier!.suppliername}

- {items.length} - {supplier!.supplieraddress && ( - {supplier!.supplieraddress} - )} + {groupedArray.map(({ supplier, items: supplierItems }, groupIdx) => ( +
+ {/* Supplier header */} +
+
+ +
+
+

{supplier!.suppliername}

+ {supplier!.supplieraddress && ( +

{supplier!.supplieraddress}

+ )} +
+ + {supplierItems.length} +
-
- {items.map((item) => ( + + {/* Items */} +
+ {supplierItems.map((item) => ( + {/* Modals */} + {/* Toast */} {toast && ( -
+
); -} +} \ No newline at end of file diff --git a/app/settings/page.tsx b/app/settings/page.tsx index 9992447..b59d308 100644 --- a/app/settings/page.tsx +++ b/app/settings/page.tsx @@ -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 (
-
+
+
+

Loading...

+
); } - if (!isLoggedIn) { - return null; - } + if (!isLoggedIn) return null; return ( -
+
{/* Header */}
-
- +
+
-
+

Settings

-

- {user?.name || user?.email} -

+

{user?.name || user?.email}

@@ -104,190 +101,129 @@ export default function SettingsPage() { {/* Location Settings */}
- -

+ +

Location Tracking

-
+
{/* Update Interval */} -
+
- - Update Interval - + Ping Interval
- - {settings.locationInterval}s + + {settings.locationInterval >= 60 ? `${settings.locationInterval / 60} min` : `${settings.locationInterval}s`}
-
+
{INTERVAL_OPTIONS.map((option) => ( ))}
-

- How often your location is sent to the server -

{/* Minimum Accuracy */} -
+
- - Minimum Accuracy - + Accuracy Filter
- + {settings.minimumAccuracy}m
-
+
{ACCURACY_OPTIONS.map((option) => ( ))}
-

- Skip location updates with worse accuracy -

{/* High Accuracy Toggle */}
-
- +
+
+ +
- - High Accuracy Mode - - - Uses GPS for better precision - + High Accuracy GPS + Uses more battery for precision
-
-
- {/* Display Settings */} -
-
- -

- Display -

-
- -
- {/* Font Size */} -
-
- - Font Size -
-
- {(['small', 'normal', 'large'] as const).map((size) => ( - - ))} -
-
- - {/* Offline Mode */} -
+ {/* Screen Wake Lock */} +
-
- +
+
+ +
- - Offline Queue - - - Queue actions when offline - + Keep Screen On + Prevents phone from sleeping
@@ -297,31 +233,30 @@ export default function SettingsPage() { {/* About */}
- -

- About -

+ +

About

-
-
-

1Driver

+
+
+
+ 1D +
+

1Driver

Version 1.0.0

-

- Collections Management App -

+

Collection Management App

{/* Actions */} -
+
-
-

{item.description}

-

- Expected: {expectedQty} units -

- {item.expand?.order && ( -

- Order: {item.expand.order.order_number} -

- )} + {/* Item info */} +
+

{item.description}

+
+ + {item.expand?.supplier?.suppliername || 'Unknown'} + + Expected: {expectedQty} units +
{item.selling_price && ( -

+

Unit price: {formatCurrency(item.selling_price)}

)}
-
-
-