BillTracker/client/pages/SettingsPage.jsx

526 lines
20 KiB
JavaScript

import React, { useState, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner';
import { AlertCircle, Moon, RefreshCw, Sun, Users } from 'lucide-react';
import { api } from '@/api';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { CalendarFeedManager } from '@/components/CalendarFeedManager';
import {
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
} from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { useTheme } from '@/contexts/ThemeContext';
import { useAuth } from '@/hooks/useAuth';
import { useAutoSave } from '@/hooks/useAutoSave';
import { SaveStatus } from '@/components/ui/save-status';
import { NotificationPreferences, PushNotifications, asSettings } from '@/pages/ProfilePage';
export const LINK_IMPORT_PREF_KEY = 'link_import_ask';
export function getLinkImportPref() {
return localStorage.getItem(LINK_IMPORT_PREF_KEY) !== 'false';
}
// ─── Notifications (moved here from Profile — these are app-behavior
// preferences; Profile keeps identity, security, and privacy) ──────────────
function NotificationsSection() {
const [settings, setSettings] = useState(null);
const load = useCallback(() => {
api.profileSettings()
.then((data) => setSettings(asSettings(data)))
.catch((err) => toast.error(err.message || 'Failed to load notification settings.'));
}, []);
useEffect(() => { load(); }, [load]);
if (!settings) return null;
return (
<div className="space-y-4 mb-4">
<NotificationPreferences settings={settings} onSaved={setSettings} />
<PushNotifications settings={settings} onSaved={load} />
</div>
);
}
// ─── Card wrapper ─────────────────────────────────────────────────────────────
function SectionCard({ title, children }) {
return (
<div className="table-surface mb-4">
<div className="px-6 py-4 border-b border-border/50">
<h2 className="text-xs font-bold uppercase tracking-widest text-muted-foreground">{title}</h2>
</div>
<div className="divide-y divide-border/50">
{children}
</div>
</div>
);
}
// ─── Setting Row ──────────────────────────────────────────────────────────────
function SettingRow({ label, description, children }) {
return (
<div className="px-4 py-4 flex flex-col gap-3 sm:px-6 sm:flex-row sm:items-center sm:justify-between">
<div className="flex-1 min-w-0 sm:mr-8">
<p className="text-sm font-medium">{label}</p>
{description && (
<p className="text-xs text-muted-foreground mt-0.5">{description}</p>
)}
</div>
<div className="shrink-0">
{children}
</div>
</div>
);
}
// ─── Theme Card ───────────────────────────────────────────────────────────────
function ThemeCard({ value, label, icon: Icon, currentTheme, onSelect }) {
const selected = currentTheme === value;
return (
<button
type="button"
onClick={() => onSelect(value)}
className={cn(
'flex flex-col items-center gap-1.5 rounded-lg border px-3 py-2.5 text-xs font-medium transition-all',
'hover:border-muted-foreground/50',
selected
? 'border-primary bg-primary/5 text-primary'
: 'border-border bg-card text-muted-foreground',
)}
>
<Icon className="h-4 w-4" />
<span>{label}</span>
</button>
);
}
// ─── Appearance Section ───────────────────────────────────────────────────────
function AppearanceSection() {
const { theme, setTheme } = useTheme();
return (
<SectionCard title="Appearance">
<SettingRow label="Theme" description="Choose your preferred color scheme.">
<div className="flex gap-2">
<ThemeCard value="light" label="Light" icon={Sun} currentTheme={theme} onSelect={setTheme} />
<ThemeCard value="dark" label="Dark" icon={Moon} currentTheme={theme} onSelect={setTheme} />
</div>
</SettingRow>
</SectionCard>
);
}
function LoginModeRecoverySection() {
const navigate = useNavigate();
const { singleUserMode, refresh } = useAuth();
const [restoring, setRestoring] = useState(false);
if (!singleUserMode) return null;
const handleRestore = async () => {
setRestoring(true);
try {
await api.restoreMultiUserMode();
toast.success('Multi-user login restored.');
refresh();
navigate('/login', { replace: true });
} catch (err) {
toast.error(err.message || 'Failed to restore multi-user mode.');
} finally {
setRestoring(false);
}
};
return (
<SectionCard title="Login Mode">
<SettingRow
label="Single-user mode is active"
description="Restore the normal login screen so each user signs in with their own account."
>
<Button size="sm" variant="outline" onClick={handleRestore} disabled={restoring}>
<Users className="h-3.5 w-3.5 mr-1.5" />
{restoring ? 'Restoring…' : 'Restore Multi-User Mode'}
</Button>
</SettingRow>
</SectionCard>
);
}
// ─── Settings Skeleton ────────────────────────────────────────────────────────
function SettingsSkeleton() {
return (
<div>
{/* Page header */}
<div className="mb-8">
<h1 className="h-8 w-48 rounded-md bg-muted/50"></h1>
<p className="h-4 w-64 mt-2 rounded-md bg-muted/50"></p>
</div>
{/* Appearance */}
<div className="table-surface mb-4">
<div className="px-6 py-4 border-b border-border/50">
<h2 className="h-3 w-24 rounded bg-muted/50"></h2>
</div>
<div className="divide-y divide-border/50">
<div className="px-6 py-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex-1 min-w-0 sm:mr-8">
<p className="h-4 w-32 rounded-md bg-muted/50"></p>
<p className="h-3 w-48 mt-2 rounded-md bg-muted/50"></p>
</div>
<div className="shrink-0 flex gap-2">
<div className="h-12 w-20 rounded-lg bg-muted/50"></div>
<div className="h-12 w-20 rounded-lg bg-muted/50"></div>
</div>
</div>
</div>
</div>
{/* Login mode */}
<div className="table-surface mb-4">
<div className="px-6 py-4 border-b border-border/50">
<h2 className="h-3 w-24 rounded bg-muted/50"></h2>
</div>
<div className="divide-y divide-border/50">
<div className="px-6 py-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex-1 min-w-0 sm:mr-8">
<p className="h-4 w-48 rounded-md bg-muted/50"></p>
<p className="h-3 w-64 mt-2 rounded-md bg-muted/50"></p>
</div>
<div className="shrink-0 h-9 w-48 rounded-md bg-muted/50"></div>
</div>
</div>
</div>
{/* General */}
<div className="table-surface mb-4">
<div className="px-6 py-4 border-b border-border/50">
<h2 className="h-3 w-24 rounded bg-muted/50"></h2>
</div>
<div className="divide-y divide-border/50">
<div className="px-6 py-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex-1 min-w-0 sm:mr-8">
<p className="h-4 w-32 rounded-md bg-muted/50"></p>
<p className="h-3 w-48 mt-2 rounded-md bg-muted/50"></p>
</div>
<div className="shrink-0 h-9 w-32 rounded-md bg-muted/50"></div>
</div>
<div className="px-6 py-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex-1 min-w-0 sm:mr-8">
<p className="h-4 w-32 rounded-md bg-muted/50"></p>
<p className="h-3 w-48 mt-2 rounded-md bg-muted/50"></p>
</div>
<div className="shrink-0 h-9 w-32 rounded-md bg-muted/50"></div>
</div>
</div>
</div>
{/* Billing */}
<div className="table-surface mb-4">
<div className="px-6 py-4 border-b border-border/50">
<h2 className="h-3 w-24 rounded bg-muted/50"></h2>
</div>
<div className="divide-y divide-border/50">
<div className="px-6 py-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex-1 min-w-0 sm:mr-8">
<p className="h-4 w-32 rounded-md bg-muted/50"></p>
<p className="h-3 w-48 mt-2 rounded-md bg-muted/50"></p>
</div>
<div className="shrink-0 h-9 w-32 rounded-md bg-muted/50"></div>
</div>
</div>
</div>
</div>
);
}
// ─── Link-import preference toggle (localStorage-backed) ─────────────────────
function LinkImportToggle() {
const [enabled, setEnabled] = useState(getLinkImportPref);
function toggle(next) {
localStorage.setItem(LINK_IMPORT_PREF_KEY, String(next));
setEnabled(next);
toast.success(next
? 'Past payments will be offered for import when linking a bill.'
: 'Past payment import prompt is disabled.');
}
return (
<SettingRow
label="Ask to import past payments when linking"
description="When you connect a bill to bank transactions (via merchant rule or recommendation), offer to import matching past payments as well."
>
<Switch checked={enabled} onCheckedChange={toggle} aria-label="Ask to import past payments when linking" />
</SettingRow>
);
}
function settingsBool(value, fallback = true) {
if (value === undefined || value === null || value === '') return fallback;
return value === true || value === 'true';
}
// ─── SettingsPage ─────────────────────────────────────────────────────────────
export default function SettingsPage() {
const DEFAULTS = {
currency: 'USD',
date_format: 'MM/DD/YYYY',
grace_period_days: 3,
drift_threshold_pct: '5',
tracker_show_bank_projection_banner: 'true',
tracker_bank_projection_banner_snoozed_until: '',
tracker_show_search_sort: 'true',
tracker_show_summary_cards: 'true',
tracker_show_safe_to_spend: 'true',
tracker_show_overdue_command_center: 'true',
tracker_show_drift_insights: 'true',
};
const [settings, setSettings] = useState(DEFAULTS);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState(null);
const loadSettings = useCallback(() => {
setLoading(true);
setLoadError(null);
api.settings()
.then((d) => setSettings({ ...DEFAULTS, ...d }))
.catch((err) => setLoadError(err.message || 'Failed to load settings'))
.finally(() => setLoading(false));
}, []); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => { loadSettings(); }, [loadSettings]);
const buildPayload = (s) => ({
currency: s.currency,
date_format: s.date_format,
grace_period_days: s.grace_period_days,
drift_threshold_pct: s.drift_threshold_pct,
tracker_show_bank_projection_banner: s.tracker_show_bank_projection_banner,
tracker_bank_projection_banner_snoozed_until: s.tracker_bank_projection_banner_snoozed_until || '',
tracker_show_search_sort: s.tracker_show_search_sort,
tracker_show_summary_cards: s.tracker_show_summary_cards,
tracker_show_safe_to_spend: s.tracker_show_safe_to_spend,
tracker_show_overdue_command_center: s.tracker_show_overdue_command_center,
tracker_show_drift_insights: s.tracker_show_drift_insights,
});
// Auto-save: every change persists on its own — no Save button. Toggles and
// selects feel instant (short debounce); typed inputs get a longer one so we
// don't save half-typed numbers.
const { status: saveStatus, schedule } = useAutoSave(
(payload) => api.saveSettings(payload).catch((err) => {
toast.error(err.message || 'Failed to save settings.');
throw err;
}),
);
const set = (k, v, delay) => setSettings((p) => {
const next = { ...p, [k]: v };
schedule(buildPayload(next), delay);
return next;
});
const setTyped = (k, v) => set(k, v, 900); // for keystroke-driven inputs
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<SettingsSkeleton />
</div>
);
}
if (loadError) {
return (
<div className="flex flex-col items-center justify-center py-24 text-center rounded-xl border border-destructive/20 bg-destructive/5">
<AlertCircle className="h-10 w-10 text-destructive mb-3" />
<p className="text-sm font-medium text-foreground">Failed to load settings</p>
<p className="mt-1 text-xs text-muted-foreground">{loadError}</p>
<Button size="sm" variant="outline" onClick={loadSettings}
className="mt-4 gap-1.5 text-xs border-destructive/30 hover:bg-destructive/10 hover:text-destructive hover:border-destructive/50">
<RefreshCw className="h-3 w-3" />
Try again
</Button>
</div>
);
}
return (
<div>
{/* Page header — flat on background, live save status on the right */}
<div className="mb-8 flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-bold tracking-tight">Settings</h1>
<p className="text-sm text-muted-foreground mt-0.5">
Manage your display, billing, and notification preferences · changes save automatically
</p>
</div>
<div className="pt-1.5">
<SaveStatus status={saveStatus} />
</div>
</div>
{/* Appearance */}
<AppearanceSection />
{/* Login mode recovery */}
<LoginModeRecoverySection />
{/* General */}
<SectionCard title="General">
<SettingRow label="Currency" description="Default currency for bill amounts.">
<Select value={settings.currency} onValueChange={(v) => set('currency', v)}>
<SelectTrigger className="w-48">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="USD">USD US Dollar</SelectItem>
<SelectItem value="EUR">EUR Euro</SelectItem>
<SelectItem value="GBP">GBP British Pound</SelectItem>
<SelectItem value="CAD">CAD Canadian Dollar</SelectItem>
</SelectContent>
</Select>
</SettingRow>
<SettingRow label="Date format" description="How dates are displayed throughout the app.">
<Select value={settings.date_format} onValueChange={(v) => set('date_format', v)}>
<SelectTrigger className="w-48">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="MM/DD/YYYY">MM/DD/YYYY</SelectItem>
<SelectItem value="DD/MM/YYYY">DD/MM/YYYY</SelectItem>
<SelectItem value="YYYY-MM-DD">YYYY-MM-DD</SelectItem>
</SelectContent>
</Select>
</SettingRow>
</SectionCard>
{/* Tracker Layout */}
<SectionCard title="Tracker Layout">
<SettingRow
label="Bank Projection Banner"
description="Show the account balance and projected-after-bills banner on the tracker."
>
<Switch
checked={settingsBool(settings.tracker_show_bank_projection_banner)}
onCheckedChange={(checked) => set('tracker_show_bank_projection_banner', String(checked))}
aria-label="Show Bank Projection Banner"
/>
</SettingRow>
<SettingRow
label="Search & sort"
description="Show the tracker search, filters, and sort controls."
>
<Switch
checked={settingsBool(settings.tracker_show_search_sort)}
onCheckedChange={(checked) => set('tracker_show_search_sort', String(checked))}
aria-label="Show Search and sort"
/>
</SettingRow>
<SettingRow
label="Summary cards"
description="Show the bank balance, paid, overdue, previous month, and trend cards."
>
<Switch
checked={settingsBool(settings.tracker_show_summary_cards)}
onCheckedChange={(checked) => set('tracker_show_summary_cards', String(checked))}
aria-label="Show Summary cards"
/>
</SettingRow>
<SettingRow
label="Safe to Spend"
description="Show the safe-to-spend projection card with the payday countdown and upcoming bills."
>
<Switch
checked={settingsBool(settings.tracker_show_safe_to_spend)}
onCheckedChange={(checked) => set('tracker_show_safe_to_spend', String(checked))}
aria-label="Show Safe to Spend"
/>
</SettingRow>
<SettingRow
label="Overdue Command Center"
description="Show the quick-action panel for overdue bills."
>
<Switch
checked={settingsBool(settings.tracker_show_overdue_command_center)}
onCheckedChange={(checked) => set('tracker_show_overdue_command_center', String(checked))}
aria-label="Show Overdue Command Center"
/>
</SettingRow>
<SettingRow
label="Drift insights"
description="Show price-change and bill-drift insights on the tracker."
>
<Switch
checked={settingsBool(settings.tracker_show_drift_insights)}
onCheckedChange={(checked) => set('tracker_show_drift_insights', String(checked))}
aria-label="Show Drift insights"
/>
</SettingRow>
</SectionCard>
{/* Billing Behavior */}
<SectionCard title="Billing Behavior">
<LinkImportToggle />
<SettingRow
label="Grace period"
description="Days after the due date before a bill is marked overdue."
>
<div className="flex items-center gap-2">
<Input
type="number"
min={0}
max={30}
value={settings.grace_period_days}
onChange={(e) => setTyped('grace_period_days', parseInt(e.target.value, 10) || 0)}
className="w-20"
/>
<span className="text-sm text-muted-foreground">days</span>
</div>
</SettingRow>
<SettingRow
label="Price change sensitivity"
description="Flag a bill when recent payments differ from the expected amount by at least this percentage."
>
<div className="flex items-center gap-2">
<Input
type="number"
min={1}
max={25}
step={1}
value={settings.drift_threshold_pct ?? '5'}
onChange={(e) => setTyped('drift_threshold_pct', e.target.value)}
className="w-20 font-mono"
/>
<span className="text-sm text-muted-foreground">%</span>
</div>
</SettingRow>
</SectionCard>
{/* Notifications — email + push reminder preferences (auto-save too) */}
<div id="notifications" className="mt-6 scroll-mt-24">
<NotificationsSection />
</div>
<div id="calendar-feed" className="mt-6 scroll-mt-24">
<SectionCard title="Calendar Feed">
<CalendarFeedManager />
</SectionCard>
</div>
</div>
);
}