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 (

{title}

{children}
); } // ─── Setting Row ────────────────────────────────────────────────────────────── function SettingRow({ label, description, children }) { return (

{label}

{description && (

{description}

)}
{children}
); } // ─── Theme Card ─────────────────────────────────────────────────────────────── function ThemeCard({ value, label, icon: Icon, currentTheme, onSelect }) { const selected = currentTheme === value; return ( ); } // ─── Appearance Section ─────────────────────────────────────────────────────── function AppearanceSection() { const { theme, setTheme } = useTheme(); return (
); } 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 ( ); } // ─── Settings Skeleton ──────────────────────────────────────────────────────── function SettingsSkeleton() { return (
{/* Page header */}

{/* Appearance */}

{/* Login mode */}

{/* General */}

{/* Billing */}

); } // ─── 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 ( ); } 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 (

Subscribe from Apple Calendar, Google Calendar, Android, or Outlook

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.

{!loading && !active && ( )}
{loading && (
)} {!loading && active && (
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.

Apple Calendar

Add a calendar subscription using the copied URL. The feed uses all-day DATE events to avoid timezone shifts.

Google Calendar

Use Google Calendar on the web: Other calendars, From URL. Android sync follows your Google Calendar settings.

Outlook

Subscribe from Outlook on the web with this URL. Imported copies will not update; subscribed calendars will.

Duplicate Safety

Bill Tracker emits stable event IDs per bill cycle, so subscribed calendars can update without double-adding events.

Next events

Last fetched: {feed.last_used_at ? new Date(feed.last_used_at).toLocaleString() : 'Not yet'}
{preview.length === 0 && (

No upcoming bill events in the preview window.

)} {preview.map(event => (

{event.name}

{event.due_date} · {event.cycle_type}

${Number(event.amount || 0).toFixed(2)}
))}
)}
); } // ─── 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 (
); } if (loadError) { return (

Failed to load settings

{loadError}

); } return (
{/* Page header — flat on background */}

Settings

Manage your display and billing preferences

{/* Appearance */} {/* Login mode recovery */} {/* General */} {/* Billing Behavior */}
set('grace_period_days', parseInt(e.target.value, 10) || 0)} className="w-20" /> days
set('drift_threshold_pct', e.target.value)} className="w-20 font-mono" /> %
{/* Save button — right-aligned below all cards */}
); }