Files
pn-new-crm/src/components/notifications/notification-preferences-form.tsx
Matt 4233aa3ac3 fix(audit-wave-9): standardize on Sheet for previews; doctrine in CLAUDE.md
Swap the one outlier (client-interests-tab.tsx) from Vaul Drawer to
Sheet side=right so every detail-preview surface uses the same
primitive. Document the doctrine: Sheet for side panels on both desktop
and mobile; Vaul Drawer reserved for mobile-only bottom-sheet UX
(currently just MoreSheet).

Closes ui/ux M11.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 11:50:07 +02:00

150 lines
4.7 KiB
TypeScript

'use client';
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { apiFetch } from '@/lib/api/client';
import { toastError } from '@/lib/api/toast-error';
interface Pref {
notificationType: string;
inApp: boolean;
email: boolean;
}
const KNOWN_TYPES: Array<{ key: string; label: string; description: string }> = [
{
key: 'mention',
label: 'Mentions',
description: 'When someone @-mentions you in a note.',
},
{
key: 'reminder_overdue',
label: 'Overdue Reminders',
description: 'When an interest reminder you own becomes overdue.',
},
{
key: 'interest_stage_changed',
label: 'Interest Stage Changes',
description: 'When a pipeline stage changes on an interest assigned to you.',
},
{
key: 'system_alert',
label: 'System Alerts',
description: 'Background job failures and maintenance notices.',
},
];
export function NotificationPreferencesForm() {
const qc = useQueryClient();
const { data, isLoading } = useQuery<Pref[]>({
queryKey: ['notifications', 'preferences'],
queryFn: () =>
apiFetch<{ data: Pref[] }>('/api/v1/notifications/preferences').then((r) => r.data),
});
// Key-based remount: body keyed on the server payload signature so its
// useState initializer re-runs when prefs land or change. Replaces the
// useEffect(setPrefs, [data]) sync the Compiler flagged.
const signature = data
? data.map((p) => `${p.notificationType}:${p.inApp ? 1 : 0}:${p.email ? 1 : 0}`).join('|')
: 'loading';
return (
<NotificationPreferencesFormBody key={signature} data={data} isLoading={isLoading} qc={qc} />
);
}
function NotificationPreferencesFormBody({
data,
isLoading,
qc,
}: {
data: Pref[] | undefined;
isLoading: boolean;
qc: ReturnType<typeof useQueryClient>;
}) {
const [prefs, setPrefs] = useState<Map<string, Pref>>(() => {
const map = new Map<string, Pref>();
for (const t of KNOWN_TYPES) {
map.set(t.key, { notificationType: t.key, inApp: true, email: true });
}
if (data) {
for (const p of data) {
map.set(p.notificationType, p);
}
}
return map;
});
const mutation = useMutation({
mutationFn: async () => {
const payload = { preferences: Array.from(prefs.values()) };
return apiFetch('/api/v1/notifications/preferences', {
method: 'PUT',
body: payload,
});
},
onSuccess: () => {
toast.success('Preferences saved');
qc.invalidateQueries({ queryKey: ['notifications', 'preferences'] });
},
onError: (err) => toastError(err),
});
function update(type: string, field: 'inApp' | 'email', value: boolean) {
setPrefs((prev) => {
const next = new Map(prev);
const existing = next.get(type) ?? { notificationType: type, inApp: true, email: true };
next.set(type, { ...existing, [field]: value });
return next;
});
}
if (isLoading) {
return <div className="text-sm text-muted-foreground">Loading preferences</div>;
}
return (
<div className="space-y-4">
<div className="rounded-lg border divide-y">
<div className="grid grid-cols-[1fr_auto_auto] gap-4 px-4 py-2 text-xs font-medium uppercase text-muted-foreground">
<div>Type</div>
<div className="w-16 text-center">In-app</div>
<div className="w-16 text-center">Email</div>
</div>
{KNOWN_TYPES.map((t) => {
const pref = prefs.get(t.key) ?? {
notificationType: t.key,
inApp: true,
email: true,
};
return (
<div
key={t.key}
className="grid grid-cols-[1fr_auto_auto] gap-4 px-4 py-3 items-center"
>
<div>
<div className="text-sm font-medium">{t.label}</div>
<div className="text-xs text-muted-foreground">{t.description}</div>
</div>
<div className="w-16 flex justify-center">
<Switch checked={pref.inApp} onCheckedChange={(v) => update(t.key, 'inApp', v)} />
</div>
<div className="w-16 flex justify-center">
<Switch checked={pref.email} onCheckedChange={(v) => update(t.key, 'email', v)} />
</div>
</div>
);
})}
</div>
<div className="flex justify-end">
<Button onClick={() => mutation.mutate()} disabled={mutation.isPending}>
{mutation.isPending ? 'Saving…' : 'Save preferences'}
</Button>
</div>
</div>
);
}