WFDASHDRIVER-APP/app/settings/page.tsx
Dave 30c30ed821 feat: invoice match on by default with the WFDASH address built in
The match never ran on drivers' phones: it was off by default and the
build had no WFDASH address, so photos were stored but nothing collected.
Now it is on for every driver (switched on once for phones saved while
it was off; a later "off" sticks) and falls back to wfdash.apphub.co.za.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 10:46:27 +02:00

415 lines
No EOL
18 KiB
TypeScript

'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 { DEFAULT_WFDASH_URL } from '@/lib/invoiceCheck';
import { cn } from '@/lib/utils';
import { setDarkMode, applyDarkMode } from '@/lib/theme';
import {
Settings,
Clock,
Crosshair,
LogOut,
Save,
RotateCcw,
Info,
ChevronRight,
Signal,
MapPin,
Shield,
Moon,
Sun,
} from 'lucide-react';
const INTERVAL_OPTIONS = [
{ 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', 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() {
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 });
// Dark mode also needs to flip the <html> class — the rest of
// AppSettings is purely persisted, but darkMode drives a class
// Tailwind keys off of, so we apply it inline here.
if (key === 'darkMode') setDarkMode(Boolean(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-[#f2f1ed]">
<div className="flex flex-col items-center gap-3">
<div className="w-10 h-10 border-[3px] border-[#f54e00] border-t-transparent rounded-full animate-spin" />
<p className="text-sm text-[rgba(38,37,30,0.55)]">Loading...</p>
</div>
</div>
);
}
if (!isLoggedIn) return null;
return (
<div className="min-h-screen bg-[#f2f1ed] pb-nav">
{/* Header — Cursor cream surface, dark ink icon tile */}
<header className="bg-[#e6e5e0] text-[#26251e] safe-top border-b border-[rgba(38,37,30,0.1)]">
<div className="px-4 py-4">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-[#26251e] rounded-2xl flex items-center justify-center" style={{ boxShadow: '0 4px 12px rgba(0,0,0,0.15)' }}>
<Settings size={22} className="text-white" />
</div>
<div className="flex-1 min-w-0">
<h1 className="text-lg font-bold text-[#26251e]">Settings</h1>
<p className="text-xs text-[rgba(38,37,30,0.55)] truncate">{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">
<Signal size={14} className="text-[#f54e00]" />
<h2 className="text-xs font-bold text-[rgba(38,37,30,0.55)] uppercase tracking-wider">
Location Tracking
</h2>
</div>
<div className="bg-[#e6e5e0] rounded-2xl overflow-hidden border border-[rgba(38,37,30,0.1)]">
{/* Update Interval */}
<div className="p-4 border-b border-[rgba(38,37,30,0.08)]">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Clock size={16} className="text-[rgba(38,37,30,0.55)]" />
<span className="text-sm font-semibold text-[#26251e]">Ping Interval</span>
</div>
<span className="text-xs font-bold text-[#f54e00] bg-[#ebeae5] px-2.5 py-1 rounded-lg">
{settings.locationInterval >= 60 ? `${settings.locationInterval / 60} min` : `${settings.locationInterval}s`}
</span>
</div>
<div className="grid grid-cols-5 gap-1.5">
{INTERVAL_OPTIONS.map((option) => (
<button
key={option.value}
onClick={() => updateSetting('locationInterval', option.value)}
aria-pressed={settings.locationInterval === option.value}
className={cn(
'py-2.5 rounded-xl text-xs font-semibold transition-all',
'touch-manipulation press-feedback-sm',
settings.locationInterval === option.value
? 'bg-[#26251e] text-white'
: 'bg-[#ebeae5] text-[rgba(38,37,30,0.7)] active:bg-[#e1e0db]'
)}
>
{option.label}
</button>
))}
</div>
</div>
{/* Minimum Accuracy */}
<div className="p-4 border-b border-[rgba(38,37,30,0.08)]">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Crosshair size={16} className="text-[rgba(38,37,30,0.55)]" />
<span className="text-sm font-semibold text-[#26251e]">Accuracy Filter</span>
</div>
<span className="text-xs font-bold text-[#f54e00] bg-[#ebeae5] px-2.5 py-1 rounded-lg">
{settings.minimumAccuracy}m
</span>
</div>
<div className="grid grid-cols-4 gap-1.5">
{ACCURACY_OPTIONS.map((option) => (
<button
key={option.value}
onClick={() => updateSetting('minimumAccuracy', option.value)}
aria-pressed={settings.minimumAccuracy === option.value}
className={cn(
'py-2.5 rounded-xl text-xs font-semibold transition-all flex flex-col items-center',
'touch-manipulation press-feedback-sm',
settings.minimumAccuracy === option.value
? 'bg-[#26251e] text-white'
: 'bg-[#ebeae5] text-[rgba(38,37,30,0.7)] active:bg-[#e1e0db]'
)}
>
<span>{option.label}</span>
<span className={cn(
'text-[9px] mt-0.5',
settings.minimumAccuracy === option.value ? 'text-[rgba(242,241,237,0.7)]' : 'text-[rgba(38,37,30,0.55)]'
)}>
{option.desc}
</span>
</button>
))}
</div>
</div>
{/* High Accuracy Toggle */}
<div className="p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-[#ebeae5] rounded-xl flex items-center justify-center">
<MapPin size={16} className="text-[#26251e]" />
</div>
<div>
<span className="text-sm font-semibold text-[#26251e] block">High Accuracy GPS</span>
<span className="text-[11px] text-[rgba(38,37,30,0.55)]">Uses more battery for precision</span>
</div>
</div>
<button
onClick={() => updateSetting('enableHighAccuracy', !settings.enableHighAccuracy)}
role="switch"
aria-checked={settings.enableHighAccuracy}
aria-label="High Accuracy GPS"
className={cn(
'w-[52px] h-[30px] rounded-full transition-colors relative',
settings.enableHighAccuracy ? 'bg-[#f54e00]' : 'bg-[rgba(38,37,30,0.15)]'
)}
>
<div className={cn(
'absolute top-[3px] w-6 h-6 bg-white rounded-full shadow-md transition-transform',
settings.enableHighAccuracy ? 'translate-x-[26px]' : 'translate-x-[3px]'
)} />
</button>
</div>
</div>
{/* Screen Wake Lock */}
<div className="p-4 border-t border-[rgba(38,37,30,0.08)]">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-[#e6f1ec] rounded-xl flex items-center justify-center">
<Shield size={16} className="text-[#125a3e]" />
</div>
<div>
<span className="text-sm font-semibold text-[#26251e] block">Keep Screen On</span>
<span className="text-[11px] text-[rgba(38,37,30,0.55)]">Prevents phone from sleeping</span>
</div>
</div>
<button
onClick={() => updateSetting('offlineMode', !settings.offlineMode)}
role="switch"
aria-checked={settings.offlineMode}
aria-label="Keep screen on"
className={cn(
'w-[52px] h-[30px] rounded-full transition-colors relative',
settings.offlineMode ? 'bg-[#f54e00]' : 'bg-[rgba(38,37,30,0.15)]'
)}
>
<div className={cn(
'absolute top-[3px] w-6 h-6 bg-white rounded-full shadow-md transition-transform',
settings.offlineMode ? 'translate-x-[26px]' : 'translate-x-[3px]'
)} />
</button>
</div>
</div>
</div>
</section>
{/* Appearance */}
<section>
<div className="flex items-center gap-2 mb-3 px-1">
{settings.darkMode
? <Moon size={14} className="text-[#f54e00]" />
: <Sun size={14} className="text-[#f54e00]" />}
<h2 className="text-xs font-bold text-[rgba(38,37,30,0.55)] uppercase tracking-wider">
Appearance
</h2>
</div>
<div className="bg-[#e6e5e0] rounded-2xl overflow-hidden border border-[rgba(38,37,30,0.1)]">
<div className="p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-[#ebeae5] rounded-xl flex items-center justify-center">
{settings.darkMode
? <Moon size={16} className="text-[#26251e]" />
: <Sun size={16} className="text-[#f54e00]" />}
</div>
<div>
<span className="text-sm font-semibold text-[#26251e] block">Dark Mode</span>
<span className="text-[11px] text-[rgba(38,37,30,0.55)]">Easier on the eyes at night</span>
</div>
</div>
<button
onClick={() => updateSetting('darkMode', !settings.darkMode)}
role="switch"
aria-checked={settings.darkMode}
aria-label="Dark mode"
className={cn(
'w-[52px] h-[30px] rounded-full transition-colors relative',
settings.darkMode ? 'bg-[#f54e00]' : 'bg-[rgba(38,37,30,0.15)]'
)}
>
<div className={cn(
'absolute top-[3px] w-6 h-6 bg-white rounded-full shadow-md transition-transform',
settings.darkMode ? 'translate-x-[26px]' : 'translate-x-[3px]'
)} />
</button>
</div>
</div>
</div>
</section>
{/* Beta helpers */}
<section>
<div className="flex items-center gap-2 mb-3 px-1">
<Shield size={14} className="text-[#f54e00]" />
<h2 className="text-xs font-bold text-[rgba(38,37,30,0.55)] uppercase tracking-wider">
Beta
</h2>
</div>
<div className="bg-[#e6e5e0] rounded-2xl overflow-hidden border border-[rgba(38,37,30,0.1)]">
<div className="p-4 space-y-3">
<div className="flex items-center justify-between">
<div className="pr-3">
<span className="text-sm font-semibold text-[#26251e] block">Invoice check</span>
<span className="text-[11px] text-[rgba(38,37,30,0.55)]">
After you photograph an invoice, items that match it exactly are marked collected (you can undo). Anything unsure is left for you.
</span>
</div>
<button
onClick={() => updateSetting('aiCheckEnabled', !settings.aiCheckEnabled)}
role="switch"
aria-checked={settings.aiCheckEnabled}
aria-label="Invoice check"
className={cn(
'w-[52px] h-[30px] rounded-full transition-colors relative flex-shrink-0',
settings.aiCheckEnabled ? 'bg-[#f54e00]' : 'bg-[rgba(38,37,30,0.15)]'
)}
>
<div className={cn(
'absolute top-[3px] w-6 h-6 bg-white rounded-full shadow-md transition-transform',
settings.aiCheckEnabled ? 'translate-x-[26px]' : 'translate-x-[3px]'
)} />
</button>
</div>
{settings.aiCheckEnabled && (
<label className="block">
<span className="text-[11px] text-[rgba(38,37,30,0.55)]">WFDASH address (leave empty to use the default)</span>
<input
type="url"
inputMode="url"
value={settings.aiCheckUrl}
onChange={(e) => updateSetting('aiCheckUrl', e.target.value.trim())}
placeholder={process.env.NEXT_PUBLIC_WFDASH_URL || DEFAULT_WFDASH_URL}
className="mt-1 w-full h-11 rounded-xl border border-[rgba(38,37,30,0.15)] bg-white px-3 text-[14px]"
/>
</label>
)}
</div>
</div>
</section>
{/* About */}
<section>
<div className="flex items-center gap-2 mb-3 px-1">
<Info size={14} className="text-[#f54e00]" />
<h2 className="text-xs font-bold text-[rgba(38,37,30,0.55)] uppercase tracking-wider">About</h2>
</div>
<div className="bg-[#e6e5e0] rounded-2xl overflow-hidden border border-[rgba(38,37,30,0.1)]">
<div className="p-5 text-center">
<div className="w-14 h-14 bg-[#26251e] rounded-2xl mx-auto mb-3 flex items-center justify-center" style={{ boxShadow: '0 4px 12px rgba(0,0,0,0.15)' }}>
<span className="text-white font-bold text-xl">1D</span>
</div>
<h3 className="text-lg font-bold text-[#26251e]">1Driver</h3>
<p className="text-xs text-[rgba(38,37,30,0.55)] mt-1">Version 1.0.0</p>
<p className="text-[11px] text-[rgba(38,37,30,0.4)] mt-0.5">Collection Management App</p>
</div>
</div>
</section>
{/* Actions */}
<div className="space-y-2.5">
<button
onClick={resetSettings}
className={cn(
'w-full py-3.5 px-4 rounded-2xl text-sm font-semibold',
'bg-[#e6e5e0] border border-[rgba(38,37,30,0.1)] text-[#26251e] active:bg-[#ebeae5]',
'touch-manipulation press-feedback 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-2xl text-white font-semibold text-[15px]',
'bg-[#cf2d56] active:bg-[#8a1b3a] transition-colors',
'touch-manipulation press-feedback flex items-center justify-center gap-2'
)}
style={{ boxShadow: '0 4px 12px rgba(207,45,86,0.3)' }}
>
<LogOut size={18} />
Sign Out
</button>
</div>
</main>
{/* Saved Toast */}
{saved && (
<div className="fixed top-4 left-4 right-4 z-[70] animate-slide-down safe-top">
<div
className="px-4 py-3 rounded-2xl text-sm font-medium text-white text-center bg-[#1f8a65] flex items-center justify-center gap-2"
role="status"
aria-live="polite"
style={{ boxShadow: '0 14px 32px rgba(0,0,0,0.18)' }}
>
<Save size={16} />
Settings saved
</div>
</div>
)}
<BottomNav />
</div>
);
}