BillTracker/client/pages/SettingsPage.jsx

592 lines
23 KiB
JavaScript

import React, { useState, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner';
import { AlertCircle, CalendarDays, Copy, Eye, KeyRound, Moon, RefreshCw, ShieldOff, 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 {
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';
export const LINK_IMPORT_PREF_KEY = 'link_import_ask';
export function getLinkImportPref() {
return localStorage.getItem(LINK_IMPORT_PREF_KEY) !== 'false';
}
// ─── 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 CalendarFeedSection() {
const [feed, setFeed] = useState(null);
const [preview, setPreview] = useState([]);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(null);
const loadFeed = useCallback(async () => {
setLoading(true);
try {
const data = await api.calendarFeed();
setFeed(data);
if (data?.active) {
const nextPreview = await api.calendarFeedPreview(10);
setPreview(nextPreview.events || []);
} else {
setPreview([]);
}
} catch (err) {
toast.error(err.message || 'Failed to load calendar feed.');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { loadFeed(); }, [loadFeed]);
const active = !!feed?.active && !!feed?.feed_url;
async function createFeed() {
setBusy('create');
try {
const data = await api.createCalendarFeed();
setFeed(data);
const nextPreview = await api.calendarFeedPreview(10);
setPreview(nextPreview.events || []);
toast.success('Calendar feed created.');
} catch (err) {
toast.error(err.message || 'Failed to create calendar feed.');
} finally {
setBusy(null);
}
}
async function copyFeedUrl() {
if (!feed?.feed_url) return;
try {
await navigator.clipboard.writeText(feed.feed_url);
toast.success('Calendar feed URL copied.');
} catch {
toast.error('Copy failed. Select the URL and copy it manually.');
}
}
async function regenerateFeed() {
setBusy('regenerate');
try {
const data = await api.regenerateCalendarFeed();
setFeed(data);
const nextPreview = await api.calendarFeedPreview(10);
setPreview(nextPreview.events || []);
toast.success('Calendar feed regenerated. Update any subscribed calendars with the new URL.');
} catch (err) {
toast.error(err.message || 'Failed to regenerate calendar feed.');
} finally {
setBusy(null);
}
}
async function revokeFeed() {
setBusy('revoke');
try {
const data = await api.revokeCalendarFeed();
setFeed(data);
setPreview([]);
toast.success('Calendar feed revoked.');
} catch (err) {
toast.error(err.message || 'Failed to revoke calendar feed.');
} finally {
setBusy(null);
}
}
return (
<SectionCard title="Calendar Feed">
<div className="px-4 py-4 sm:px-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<CalendarDays className="h-4 w-4 text-primary" />
<p className="text-sm font-medium">Subscribe from Apple Calendar, Google Calendar, Android, or Outlook</p>
</div>
<p className="mt-1 max-w-2xl text-xs leading-5 text-muted-foreground">
This creates a private ICS feed URL. Calendar apps refresh subscribed feeds on their own schedule, so Google and Apple may take time to show updates.
</p>
</div>
{!loading && !active && (
<Button size="sm" onClick={createFeed} disabled={!!busy} className="w-full gap-2 sm:w-auto">
<KeyRound className="h-3.5 w-3.5" />
{busy === 'create' ? 'Creating...' : 'Create Feed'}
</Button>
)}
</div>
{loading && (
<div className="mt-4 h-24 animate-pulse rounded-lg bg-muted/50" />
)}
{!loading && active && (
<div className="mt-4 space-y-4">
<div className="rounded-lg border border-amber-500/25 bg-amber-500/[0.08] px-3 py-2 text-xs leading-5 text-amber-800 dark:text-amber-200">
Anyone with this URL can see the bill events in this feed. Regenerate or revoke it if it was shared somewhere it should not be.
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<Input value={feed.feed_url} readOnly className="min-w-0 flex-1 font-mono text-xs" aria-label="Calendar feed URL" />
<Button size="sm" variant="outline" onClick={copyFeedUrl} className="gap-2">
<Copy className="h-3.5 w-3.5" />
Copy
</Button>
<Button asChild size="sm" variant="outline" className="gap-2">
<a href={feed.feed_url} target="_blank" rel="noreferrer">
<Eye className="h-3.5 w-3.5" />
Preview ICS
</a>
</Button>
</div>
<div className="grid gap-3 text-xs text-muted-foreground lg:grid-cols-4">
<div className="rounded-md bg-muted/35 p-3">
<p className="font-medium text-foreground">Apple Calendar</p>
<p className="mt-1">Add a calendar subscription using the copied URL. The feed uses all-day DATE events to avoid timezone shifts.</p>
</div>
<div className="rounded-md bg-muted/35 p-3">
<p className="font-medium text-foreground">Google Calendar</p>
<p className="mt-1">Use Google Calendar on the web: Other calendars, From URL. Android sync follows your Google Calendar settings.</p>
</div>
<div className="rounded-md bg-muted/35 p-3">
<p className="font-medium text-foreground">Outlook</p>
<p className="mt-1">Subscribe from Outlook on the web with this URL. Imported copies will not update; subscribed calendars will.</p>
</div>
<div className="rounded-md bg-muted/35 p-3">
<p className="font-medium text-foreground">Duplicate Safety</p>
<p className="mt-1">Bill Tracker emits stable event IDs per bill cycle, so subscribed calendars can update without double-adding events.</p>
</div>
</div>
<div className="rounded-lg border border-border/70">
<div className="flex items-center justify-between border-b border-border/60 px-3 py-2">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Next events</p>
<span className="text-xs text-muted-foreground">
Last fetched: {feed.last_used_at ? new Date(feed.last_used_at).toLocaleString() : 'Not yet'}
</span>
</div>
<div className="divide-y divide-border/50">
{preview.length === 0 && (
<p className="px-3 py-4 text-sm text-muted-foreground">No upcoming bill events in the preview window.</p>
)}
{preview.map(event => (
<div key={event.uid} className="flex items-center justify-between gap-3 px-3 py-2 text-sm">
<div className="min-w-0">
<p className="truncate font-medium">{event.name}</p>
<p className="text-xs text-muted-foreground">{event.due_date} · {event.cycle_type}</p>
</div>
<span className="tracker-number shrink-0 text-xs font-semibold">${Number(event.amount || 0).toFixed(2)}</span>
</div>
))}
</div>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:justify-end">
<Button size="sm" variant="outline" onClick={regenerateFeed} disabled={!!busy} className="gap-2">
<RefreshCw className="h-3.5 w-3.5" />
{busy === 'regenerate' ? 'Regenerating...' : 'Regenerate URL'}
</Button>
<Button size="sm" variant="outline" onClick={revokeFeed} disabled={!!busy} className="gap-2 border-destructive/30 text-destructive hover:bg-destructive/10">
<ShieldOff className="h-3.5 w-3.5" />
{busy === 'revoke' ? 'Revoking...' : 'Revoke Feed'}
</Button>
</div>
</div>
)}
</div>
</SectionCard>
);
}
// ─── SettingsPage ─────────────────────────────────────────────────────────────
export default function SettingsPage() {
const DEFAULTS = {
currency: 'USD',
date_format: 'MM/DD/YYYY',
grace_period_days: 3,
drift_threshold_pct: '5',
};
const [settings, setSettings] = useState(DEFAULTS);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState(null);
const [saving, setSaving] = useState(false);
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 set = (k, v) => setSettings((p) => ({ ...p, [k]: v }));
const handleSave = async () => {
setSaving(true);
try {
await api.saveSettings({
currency: settings.currency,
date_format: settings.date_format,
grace_period_days: settings.grace_period_days,
drift_threshold_pct: settings.drift_threshold_pct,
});
toast.success('Settings saved.');
} catch (err) {
toast.error(err.message || 'Failed to save settings.');
} finally {
setSaving(false);
}
};
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 */}
<div className="mb-8">
<h1 className="text-2xl font-bold tracking-tight">Settings</h1>
<p className="text-sm text-muted-foreground mt-0.5">Manage your display and billing preferences</p>
</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>
{/* 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) => set('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) => set('drift_threshold_pct', e.target.value)}
className="w-20 font-mono"
/>
<span className="text-sm text-muted-foreground">%</span>
</div>
</SettingRow>
</SectionCard>
{/* Save button — right-aligned below all cards */}
<div className="flex justify-end mt-6">
<Button size="sm" onClick={handleSave} disabled={saving}>
{saving ? 'Saving…' : 'Save Settings'}
</Button>
</div>
<div id="calendar-feed" className="mt-6 scroll-mt-24">
<CalendarFeedSection />
</div>
</div>
);
}