2026-05-28 02:34:24 -05:00
|
|
|
import React, { useState, useEffect, useCallback } from 'react';
|
2026-05-03 19:51:57 -05:00
|
|
|
import { useNavigate } from 'react-router-dom';
|
|
|
|
|
import { toast } from 'sonner';
|
2026-06-07 15:53:46 -05:00
|
|
|
import { AlertCircle, CalendarDays, Copy, Eye, KeyRound, Moon, RefreshCw, ShieldOff, Sun, Users } from 'lucide-react';
|
2026-05-03 19:51:57 -05:00
|
|
|
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';
|
2026-06-06 23:04:53 -05:00
|
|
|
import { Switch } from '@/components/ui/switch';
|
2026-05-03 19:51:57 -05:00
|
|
|
import { useTheme } from '@/contexts/ThemeContext';
|
|
|
|
|
import { useAuth } from '@/hooks/useAuth';
|
|
|
|
|
|
2026-06-06 23:04:53 -05:00
|
|
|
export const LINK_IMPORT_PREF_KEY = 'link_import_ask';
|
|
|
|
|
export function getLinkImportPref() {
|
|
|
|
|
return localStorage.getItem(LINK_IMPORT_PREF_KEY) !== 'false';
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-03 19:51:57 -05:00
|
|
|
// ─── 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 (
|
2026-05-04 13:14:32 -05:00
|
|
|
<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">
|
2026-05-03 19:51:57 -05:00
|
|
|
<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>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-09 13:03:36 -05:00
|
|
|
// ─── 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>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-06 23:04:53 -05:00
|
|
|
// ─── 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>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-07 15:53:46 -05:00
|
|
|
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>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-03 19:51:57 -05:00
|
|
|
// ─── SettingsPage ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export default function SettingsPage() {
|
|
|
|
|
const DEFAULTS = {
|
|
|
|
|
currency: 'USD',
|
|
|
|
|
date_format: 'MM/DD/YYYY',
|
|
|
|
|
grace_period_days: 3,
|
2026-05-30 14:33:55 -05:00
|
|
|
drift_threshold_pct: '5',
|
2026-05-03 19:51:57 -05:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const [settings, setSettings] = useState(DEFAULTS);
|
2026-05-28 02:34:24 -05:00
|
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
|
const [loadError, setLoadError] = useState(null);
|
|
|
|
|
const [saving, setSaving] = useState(false);
|
2026-05-03 19:51:57 -05:00
|
|
|
|
2026-05-28 02:34:24 -05:00
|
|
|
const loadSettings = useCallback(() => {
|
|
|
|
|
setLoading(true);
|
|
|
|
|
setLoadError(null);
|
2026-05-03 19:51:57 -05:00
|
|
|
api.settings()
|
|
|
|
|
.then((d) => setSettings({ ...DEFAULTS, ...d }))
|
2026-05-28 02:34:24 -05:00
|
|
|
.catch((err) => setLoadError(err.message || 'Failed to load settings'))
|
2026-05-03 19:51:57 -05:00
|
|
|
.finally(() => setLoading(false));
|
|
|
|
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
|
|
2026-05-28 02:34:24 -05:00
|
|
|
useEffect(() => { loadSettings(); }, [loadSettings]);
|
|
|
|
|
|
2026-05-03 19:51:57 -05:00
|
|
|
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,
|
2026-05-30 14:33:55 -05:00
|
|
|
drift_threshold_pct: settings.drift_threshold_pct,
|
2026-05-03 19:51:57 -05:00
|
|
|
});
|
|
|
|
|
toast.success('Settings saved.');
|
|
|
|
|
} catch (err) {
|
|
|
|
|
toast.error(err.message || 'Failed to save settings.');
|
|
|
|
|
} finally {
|
|
|
|
|
setSaving(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (loading) {
|
|
|
|
|
return (
|
2026-05-09 13:03:36 -05:00
|
|
|
<div className="flex items-center justify-center py-12">
|
|
|
|
|
<SettingsSkeleton />
|
2026-05-03 19:51:57 -05:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-28 02:34:24 -05:00
|
|
|
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>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-03 19:51:57 -05:00
|
|
|
return (
|
|
|
|
|
<div>
|
|
|
|
|
|
|
|
|
|
{/* Page header — flat on background */}
|
|
|
|
|
<div className="mb-8">
|
|
|
|
|
<h1 className="text-2xl font-bold tracking-tight">Settings</h1>
|
2026-05-15 22:45:38 -05:00
|
|
|
<p className="text-sm text-muted-foreground mt-0.5">Manage your display and billing preferences</p>
|
2026-05-03 19:51:57 -05:00
|
|
|
</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">
|
2026-06-06 23:04:53 -05:00
|
|
|
<LinkImportToggle />
|
2026-05-03 19:51:57 -05:00
|
|
|
<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>
|
2026-05-30 14:33:55 -05:00
|
|
|
<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>
|
2026-05-03 19:51:57 -05:00
|
|
|
</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>
|
|
|
|
|
|
2026-06-07 15:53:46 -05:00
|
|
|
<div id="calendar-feed" className="mt-6 scroll-mt-24">
|
|
|
|
|
<CalendarFeedSection />
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-05-03 19:51:57 -05:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|