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