feat(settings): safe-to-spend toggle, move notifications from Profile to Settings, fix dark-mode readability
This commit is contained in:
parent
dc49eb9633
commit
8ef794a94a
|
|
@ -80,7 +80,9 @@
|
||||||
--secondary: 0.275 0.018 245;
|
--secondary: 0.275 0.018 245;
|
||||||
--secondary-foreground: 0.94 0.007 245;
|
--secondary-foreground: 0.94 0.007 245;
|
||||||
--muted: 0.275 0.016 245;
|
--muted: 0.275 0.016 245;
|
||||||
--muted-foreground: 0.76 0.012 245;
|
/* Secondary text. Raised 0.76 → 0.85 — at 0.76 labels and hints sat too
|
||||||
|
close to the card background (L 0.235) and strained the eyes. */
|
||||||
|
--muted-foreground: 0.85 0.012 245;
|
||||||
--accent: 0.32 0.045 158;
|
--accent: 0.32 0.045 158;
|
||||||
--accent-foreground: 0.965 0.006 245;
|
--accent-foreground: 0.965 0.006 245;
|
||||||
--destructive: 0.66 0.18 26;
|
--destructive: 0.66 0.18 26;
|
||||||
|
|
@ -172,20 +174,22 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Faded muted-text variants: keep them readable in dark mode — Tailwind's
|
||||||
|
raw /40–/70 alphas dissolve into the background. */
|
||||||
.dark .text-muted-foreground\/40 {
|
.dark .text-muted-foreground\/40 {
|
||||||
color: oklch(var(--muted-foreground) / 0.72);
|
color: oklch(var(--muted-foreground) / 0.8);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .text-muted-foreground\/50 {
|
.dark .text-muted-foreground\/50 {
|
||||||
color: oklch(var(--muted-foreground) / 0.78);
|
color: oklch(var(--muted-foreground) / 0.85);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .text-muted-foreground\/60 {
|
.dark .text-muted-foreground\/60 {
|
||||||
color: oklch(var(--muted-foreground) / 0.84);
|
color: oklch(var(--muted-foreground) / 0.9);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .text-muted-foreground\/70 {
|
.dark .text-muted-foreground\/70 {
|
||||||
color: oklch(var(--muted-foreground) / 0.9);
|
color: oklch(var(--muted-foreground) / 0.95);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Custom Scrollbar */
|
/* Custom Scrollbar */
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ function displayNameOf(profile) {
|
||||||
return profile.display_name || profile.displayName || profile.name || '';
|
return profile.display_name || profile.displayName || profile.name || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function asSettings(data) {
|
export function asSettings(data) {
|
||||||
return data?.settings || data?.notifications || data || {};
|
return data?.settings || data?.notifications || data || {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -360,7 +360,9 @@ function EditProfile({ profile, onSaved }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function NotificationPreferences({ settings, onSaved }) {
|
// Exported: rendered on the Settings page ("Notifications" section). Lives here
|
||||||
|
// because it shares asSettings/CheckRow/SectionCard with the rest of this file.
|
||||||
|
export function NotificationPreferences({ settings, onSaved }) {
|
||||||
const [form, setForm] = useState(settings);
|
const [form, setForm] = useState(settings);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
|
@ -427,7 +429,8 @@ const PUSH_CHANNELS = [
|
||||||
{ value: 'telegram', label: 'Telegram', urlLabel: 'Server URL / n/a', urlHint: 'Leave blank for api.telegram.org', tokenLabel: 'Bot token', chatIdLabel: 'Chat ID' },
|
{ value: 'telegram', label: 'Telegram', urlLabel: 'Server URL / n/a', urlHint: 'Leave blank for api.telegram.org', tokenLabel: 'Bot token', chatIdLabel: 'Chat ID' },
|
||||||
];
|
];
|
||||||
|
|
||||||
function PushNotifications({ settings, onSaved }) {
|
// Exported: rendered on the Settings page ("Notifications" section).
|
||||||
|
export function PushNotifications({ settings, onSaved }) {
|
||||||
const [enabled, setEnabled] = useState(!!settings.notify_push_enabled);
|
const [enabled, setEnabled] = useState(!!settings.notify_push_enabled);
|
||||||
const [channel, setChannel] = useState(settings.push_channel || 'ntfy');
|
const [channel, setChannel] = useState(settings.push_channel || 'ntfy');
|
||||||
const [url, setUrl] = useState(settings.push_url || '');
|
const [url, setUrl] = useState(settings.push_url || '');
|
||||||
|
|
@ -898,7 +901,6 @@ function ProfileNav() {
|
||||||
const items = [
|
const items = [
|
||||||
['#account', 'Account'],
|
['#account', 'Account'],
|
||||||
['#security', 'Security'],
|
['#security', 'Security'],
|
||||||
['#notifications', 'Notifications'],
|
|
||||||
['#privacy', 'Privacy'],
|
['#privacy', 'Privacy'],
|
||||||
];
|
];
|
||||||
return (
|
return (
|
||||||
|
|
@ -950,7 +952,7 @@ export default function ProfilePage() {
|
||||||
<div className="mb-6 flex items-start justify-between gap-4">
|
<div className="mb-6 flex items-start justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Profile</h1>
|
<h1 className="text-2xl font-bold tracking-tight">Profile</h1>
|
||||||
<p className="text-sm text-muted-foreground mt-0.5">Manage your account, notification preferences, and password.</p>
|
<p className="text-sm text-muted-foreground mt-0.5">Manage your account, security, and privacy. Notification preferences live in Settings.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="hidden sm:flex items-center gap-2 rounded-full border border-border bg-muted/30 px-3 py-1.5 text-xs text-muted-foreground">
|
<div className="hidden sm:flex items-center gap-2 rounded-full border border-border bg-muted/30 px-3 py-1.5 text-xs text-muted-foreground">
|
||||||
<ShieldCheck className="h-3.5 w-3.5 text-emerald-500" />
|
<ShieldCheck className="h-3.5 w-3.5 text-emerald-500" />
|
||||||
|
|
@ -968,10 +970,7 @@ export default function ProfilePage() {
|
||||||
<div id="security" className="scroll-mt-6">
|
<div id="security" className="scroll-mt-6">
|
||||||
<ChangePassword />
|
<ChangePassword />
|
||||||
</div>
|
</div>
|
||||||
<div id="notifications" className="scroll-mt-6 space-y-4">
|
{/* Notification preferences moved to Settings → Notifications */}
|
||||||
{!loading && <NotificationPreferences settings={settings} onSaved={setSettings} />}
|
|
||||||
{!loading && <PushNotifications settings={settings} onSaved={() => api.profileSettings().then(setSettings).catch(() => {})} />}
|
|
||||||
</div>
|
|
||||||
<div id="privacy" className="scroll-mt-6">
|
<div id="privacy" className="scroll-mt-6">
|
||||||
{!loading && <PrivacySettings settings={settings} onSaved={setSettings} />}
|
{!loading && <PrivacySettings settings={settings} onSaved={setSettings} />}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -13,12 +13,36 @@ import {
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { useTheme } from '@/contexts/ThemeContext';
|
import { useTheme } from '@/contexts/ThemeContext';
|
||||||
import { useAuth } from '@/hooks/useAuth';
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
|
import { NotificationPreferences, PushNotifications, asSettings } from '@/pages/ProfilePage';
|
||||||
|
|
||||||
export const LINK_IMPORT_PREF_KEY = 'link_import_ask';
|
export const LINK_IMPORT_PREF_KEY = 'link_import_ask';
|
||||||
export function getLinkImportPref() {
|
export function getLinkImportPref() {
|
||||||
return localStorage.getItem(LINK_IMPORT_PREF_KEY) !== 'false';
|
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 ─────────────────────────────────────────────────────────────
|
// ─── Card wrapper ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function SectionCard({ title, children }) {
|
function SectionCard({ title, children }) {
|
||||||
|
|
@ -255,6 +279,7 @@ export default function SettingsPage() {
|
||||||
tracker_bank_projection_banner_snoozed_until: '',
|
tracker_bank_projection_banner_snoozed_until: '',
|
||||||
tracker_show_search_sort: 'true',
|
tracker_show_search_sort: 'true',
|
||||||
tracker_show_summary_cards: 'true',
|
tracker_show_summary_cards: 'true',
|
||||||
|
tracker_show_safe_to_spend: 'true',
|
||||||
tracker_show_overdue_command_center: 'true',
|
tracker_show_overdue_command_center: 'true',
|
||||||
tracker_show_drift_insights: 'true',
|
tracker_show_drift_insights: 'true',
|
||||||
};
|
};
|
||||||
|
|
@ -289,6 +314,7 @@ export default function SettingsPage() {
|
||||||
tracker_bank_projection_banner_snoozed_until: settings.tracker_bank_projection_banner_snoozed_until || '',
|
tracker_bank_projection_banner_snoozed_until: settings.tracker_bank_projection_banner_snoozed_until || '',
|
||||||
tracker_show_search_sort: settings.tracker_show_search_sort,
|
tracker_show_search_sort: settings.tracker_show_search_sort,
|
||||||
tracker_show_summary_cards: settings.tracker_show_summary_cards,
|
tracker_show_summary_cards: settings.tracker_show_summary_cards,
|
||||||
|
tracker_show_safe_to_spend: settings.tracker_show_safe_to_spend,
|
||||||
tracker_show_overdue_command_center: settings.tracker_show_overdue_command_center,
|
tracker_show_overdue_command_center: settings.tracker_show_overdue_command_center,
|
||||||
tracker_show_drift_insights: settings.tracker_show_drift_insights,
|
tracker_show_drift_insights: settings.tracker_show_drift_insights,
|
||||||
});
|
});
|
||||||
|
|
@ -329,7 +355,7 @@ export default function SettingsPage() {
|
||||||
{/* Page header — flat on background */}
|
{/* Page header — flat on background */}
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Settings</h1>
|
<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>
|
<p className="text-sm text-muted-foreground mt-0.5">Manage your display, billing, and notification preferences</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Appearance */}
|
{/* Appearance */}
|
||||||
|
|
@ -400,6 +426,16 @@ export default function SettingsPage() {
|
||||||
aria-label="Show Summary cards"
|
aria-label="Show Summary cards"
|
||||||
/>
|
/>
|
||||||
</SettingRow>
|
</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
|
<SettingRow
|
||||||
label="Overdue Command Center"
|
label="Overdue Command Center"
|
||||||
description="Show the quick-action panel for overdue bills."
|
description="Show the quick-action panel for overdue bills."
|
||||||
|
|
@ -467,6 +503,11 @@ export default function SettingsPage() {
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Notifications — email + push reminder preferences (save independently) */}
|
||||||
|
<div id="notifications" className="mt-6 scroll-mt-24">
|
||||||
|
<NotificationsSection />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="calendar-feed" className="mt-6 scroll-mt-24">
|
<div id="calendar-feed" className="mt-6 scroll-mt-24">
|
||||||
<SectionCard title="Calendar Feed">
|
<SectionCard title="Calendar Feed">
|
||||||
<CalendarFeedManager />
|
<CalendarFeedManager />
|
||||||
|
|
|
||||||
|
|
@ -387,6 +387,7 @@ export default function TrackerPage() {
|
||||||
const showSearchSort = settingEnabled(trackerSettings.tracker_show_search_sort);
|
const showSearchSort = settingEnabled(trackerSettings.tracker_show_search_sort);
|
||||||
const showSummaryCards = settingEnabled(trackerSettings.tracker_show_summary_cards);
|
const showSummaryCards = settingEnabled(trackerSettings.tracker_show_summary_cards);
|
||||||
const showOverdueCommandCenter = settingEnabled(trackerSettings.tracker_show_overdue_command_center);
|
const showOverdueCommandCenter = settingEnabled(trackerSettings.tracker_show_overdue_command_center);
|
||||||
|
const showSafeToSpend = settingEnabled(trackerSettings.tracker_show_safe_to_spend);
|
||||||
const showDriftInsights = settingEnabled(trackerSettings.tracker_show_drift_insights);
|
const showDriftInsights = settingEnabled(trackerSettings.tracker_show_drift_insights);
|
||||||
const showBankProjectionBanner = settingEnabled(trackerSettings.tracker_show_bank_projection_banner) &&
|
const showBankProjectionBanner = settingEnabled(trackerSettings.tracker_show_bank_projection_banner) &&
|
||||||
(!bannerSnoozedUntil || bannerSnoozedUntil <= today);
|
(!bannerSnoozedUntil || bannerSnoozedUntil <= today);
|
||||||
|
|
@ -848,7 +849,7 @@ export default function TrackerPage() {
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* ── Safe to Spend ── */}
|
{/* ── Safe to Spend ── */}
|
||||||
{!isError && !loading && isCurrentMonth && cashflow && (
|
{!isError && !loading && showSafeToSpend && isCurrentMonth && cashflow && (
|
||||||
<CashFlowCard
|
<CashFlowCard
|
||||||
cashflow={cashflow}
|
cashflow={cashflow}
|
||||||
onSetStartingAmounts={() => setEditStartingOpen(true)}
|
onSetStartingAmounts={() => setEditStartingOpen(true)}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ const USER_SETTING_KEYS = [
|
||||||
'tracker_bank_projection_banner_snoozed_until',
|
'tracker_bank_projection_banner_snoozed_until',
|
||||||
'tracker_show_search_sort',
|
'tracker_show_search_sort',
|
||||||
'tracker_show_summary_cards',
|
'tracker_show_summary_cards',
|
||||||
|
'tracker_show_safe_to_spend',
|
||||||
'tracker_show_overdue_command_center',
|
'tracker_show_overdue_command_center',
|
||||||
'tracker_show_drift_insights',
|
'tracker_show_drift_insights',
|
||||||
'tracker_table_columns',
|
'tracker_table_columns',
|
||||||
|
|
@ -28,6 +29,7 @@ const USER_SETTING_DEFAULTS = {
|
||||||
tracker_bank_projection_banner_snoozed_until: '',
|
tracker_bank_projection_banner_snoozed_until: '',
|
||||||
tracker_show_search_sort: 'true',
|
tracker_show_search_sort: 'true',
|
||||||
tracker_show_summary_cards: 'true',
|
tracker_show_summary_cards: 'true',
|
||||||
|
tracker_show_safe_to_spend: 'true',
|
||||||
tracker_show_overdue_command_center: 'true',
|
tracker_show_overdue_command_center: 'true',
|
||||||
tracker_show_drift_insights: 'true',
|
tracker_show_drift_insights: 'true',
|
||||||
tracker_table_columns: '["due","expected","previous","paid","paid_date","status","action","notes"]',
|
tracker_table_columns: '["due","expected","previous","paid","paid_date","status","action","notes"]',
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue