wulf-pulse/components/mobile/profile/ProfileNotificationMatrix.tsx
lorentz 577e236eb5 feat(09-04): ProfileThemeSection + ProfileNotificationMatrix
- ProfileThemeSection: 3-option radio rows (Light/Dark/System), immediate setTheme + PUT /api/me/theme, rollback on error
- ProfileNotificationMatrix: skeleton loading, empty state, single/multi-column switch grid, 400ms debounced PUT per cell
2026-05-10 07:40:53 -04:00

182 lines
5.7 KiB
TypeScript

'use client';
import { useState, useEffect, useRef } from 'react';
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
} from '@/components/ui/card';
import { Switch } from '@/components/ui/switch';
import { toast } from 'sonner';
import ProfileSectionSkeleton from './ProfileSectionSkeleton';
interface EventKey {
key: string;
displayLabel: string;
description: string | null;
sortOrder: number;
}
interface SubscriptionData {
eventKeys: EventKey[];
channelTypes: string[];
matrix: Record<string, Record<string, boolean>>;
}
export function ProfileNotificationMatrix() {
const [loading, setLoading] = useState(true);
const [data, setData] = useState<SubscriptionData | null>(null);
const [matrix, setMatrix] = useState<Record<string, Record<string, boolean>>>({});
// Track per-cell debounce timers keyed by "eventKey::channelType"
const pendingTimers = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
useEffect(() => {
fetch('/api/me/notification-subscriptions')
.then((r) => r.json())
.then((fetched: SubscriptionData) => {
setData(fetched);
setMatrix(fetched.matrix);
setLoading(false);
})
.catch(() => {
setLoading(false);
});
}, []);
function handleToggle(eventKey: string, channelType: string, newValue: boolean) {
// Optimistic update
setMatrix((prev) => ({
...prev,
[eventKey]: {
...(prev[eventKey] ?? {}),
[channelType]: newValue,
},
}));
const cellKey = `${eventKey}::${channelType}`;
// Clear any existing debounce timer for this cell
const existing = pendingTimers.current.get(cellKey);
if (existing) clearTimeout(existing);
const timer = setTimeout(async () => {
pendingTimers.current.delete(cellKey);
try {
const res = await fetch('/api/me/notification-subscriptions', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
event_key: eventKey,
channel_type: channelType,
enabled: newValue,
}),
});
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
toast.success('Preference saved');
} catch {
// Revert on error
setMatrix((prev) => ({
...prev,
[eventKey]: {
...(prev[eventKey] ?? {}),
[channelType]: !newValue,
},
}));
toast.error("Couldn't save preference");
}
}, 400);
pendingTimers.current.set(cellKey, timer);
}
if (loading) {
return <ProfileSectionSkeleton />;
}
return (
<Card>
<CardHeader className="px-4 pt-4 pb-0">
<CardTitle className="text-xl font-semibold">Notifications</CardTitle>
<CardDescription>
Choose which events trigger a personal notification.
</CardDescription>
</CardHeader>
<CardContent className="px-4 py-4">
{!data || data.channelTypes.length === 0 ? (
<p className="text-sm text-muted-foreground">
Configure a Teams or ntfy channel below to enable personal notifications.
</p>
) : data.channelTypes.length === 1 ? (
/* Single channel type — simple list, no header row */
<div className="divide-y">
{data.eventKeys.map((ek) => {
const channelType = data.channelTypes[0];
const checked = matrix[ek.key]?.[channelType] ?? true;
return (
<div
key={ek.key}
className="flex items-center justify-between min-h-[44px] py-3 gap-3"
>
<span className="text-sm flex-1">{ek.displayLabel}</span>
<Switch
checked={checked}
onCheckedChange={(val) => handleToggle(ek.key, channelType, val)}
aria-label={ek.displayLabel}
/>
</div>
);
})}
</div>
) : (
/* Multiple channel types — grid with header row */
<div>
{/* Header row */}
<div
className="grid mb-1"
style={{
gridTemplateColumns:
'minmax(0,1fr) ' + 'repeat(' + data.channelTypes.length + ', 4rem)',
}}
>
<span />
{data.channelTypes.map((ct) => (
<span key={ct} className="text-xs text-center capitalize text-muted-foreground">
{ct}
</span>
))}
</div>
{/* Per event-key rows */}
{data.eventKeys.map((ek) => (
<div
key={ek.key}
className="grid min-h-[44px] items-center py-3 border-t first:border-t-0"
style={{
gridTemplateColumns:
'minmax(0,1fr) ' + 'repeat(' + data.channelTypes.length + ', 4rem)',
}}
>
<span className="text-sm">{ek.displayLabel}</span>
{data.channelTypes.map((ct) => {
const checked = matrix[ek.key]?.[ct] ?? true;
return (
<div key={ct} className="flex items-center justify-center">
<Switch
checked={checked}
onCheckedChange={(val) => handleToggle(ek.key, ct, val)}
aria-label={`${ek.displayLabel}${ct}`}
/>
</div>
);
})}
</div>
))}
</div>
)}
</CardContent>
</Card>
);
}