'use client' import { Fragment, useEffect, useState } from 'react' import { cn } from '../../lib/cn' import { Button } from '../Button' import { Checkbox } from '../Checkbox' import { Field } from '../Field' import { Select } from '../Select' import { Surface } from '../Surface' import { Text } from '../Text' export interface NotificationPreferencesChannel { id: string /** Column heading — “Email”, “Push”. */ label: string } export interface NotificationPreferencesEvent { id: string label: string description?: string /** Rows are grouped under this heading, in first-seen order. */ category: string /** Channels this event can go to. Omit for all of them. */ channels?: string[] } /** Event id → the channel ids it is delivered on. */ export type NotificationPreferencesValue = Record export type NotificationPreferencesDigest = 'off' | 'daily' | 'weekly' export interface NotificationPreferencesSavePayload { value: NotificationPreferencesValue digest: NotificationPreferencesDigest } export interface NotificationPreferencesProps { events: NotificationPreferencesEvent[] /** The saved selections. Edits are a draft against this until saved. */ value: NotificationPreferencesValue /** The saved digest frequency. */ digest?: NotificationPreferencesDigest /** Called with the draft. Return a promise to show the saving state. */ onSave: (payload: NotificationPreferencesSavePayload) => void | Promise channels?: NotificationPreferencesChannel[] /** Merged last, so it wins. */ className?: string } const DEFAULT_CHANNELS: NotificationPreferencesChannel[] = [ { id: 'email', label: 'Email' }, { id: 'push', label: 'Push' }, { id: 'in-app', label: 'In-app' }, { id: 'sms', label: 'SMS' }, ] const DIGESTS = [ { value: 'off' as const, label: 'Send each one as it happens' }, { value: 'daily' as const, label: 'Daily digest' }, { value: 'weekly' as const, label: 'Weekly digest' }, ] const key = (value: NotificationPreferencesValue, digest: string) => JSON.stringify([Object.keys(value).sort().map((id) => [id, [...value[id]].sort()]), digest]) /** * Which events reach you, and where — a grid of events down the side and * channels across the top, because that is the shape of the decision. * * Every checkbox is named for both its row and its column (“Email for Mentions”), * since a screen reader moving cell to cell otherwise hears “checkbox, checked” * forty times. Each column heading carries an “all” box that shows the mixed * state when some rows are on, which is the fastest way to turn SMS off entirely. * * Changes are a draft until saved. Preferences saved on every click make an * exploratory toggle a real one, and give no moment to see what changed; here * Save lights up only when the draft differs, and Discard puts it back. */ export function NotificationPreferences({ events, value, digest = 'off', onSave, channels = DEFAULT_CHANNELS, className, }: NotificationPreferencesProps) { const [draft, setDraft] = useState(value) const [draftDigest, setDraftDigest] = useState(digest) const [saving, setSaving] = useState(false) const savedKey = key(value, digest) // A new saved value from the caller resets the draft — after a save lands, or // when the settings load. useEffect(() => { setDraft(value) setDraftDigest(digest) }, [savedKey]) const dirty = key(draft, draftDigest) !== savedKey const supports = (event: NotificationPreferencesEvent, channel: string) => !event.channels || event.channels.includes(channel) const isOn = (eventId: string, channel: string) => draft[eventId]?.includes(channel) ?? false const set = (eventId: string, channel: string, on: boolean) => setDraft((current) => { const list = (current[eventId] ?? []).filter((id) => id !== channel) return { ...current, [eventId]: on ? [...list, channel] : list } }) const setColumn = (channel: string, on: boolean) => setDraft((current) => { const next = { ...current } for (const event of events) { if (!supports(event, channel)) continue const list = (next[event.id] ?? []).filter((id) => id !== channel) next[event.id] = on ? [...list, channel] : list } return next }) const categories = [...new Set(events.map((event) => event.category))] const save = async () => { setSaving(true) try { await onSave({ value: draft, digest: draftDigest }) } finally { setSaving(false) } } return (
) })} {categories.map((category) => ( {events .filter((event) => event.category === category) .map((event) => ( {channels.map((channel) => ( ))} ))} ))}
Notification delivery by event and channel
{channels.map((channel) => { const eligible = events.filter((event) => supports(event, channel.id)) const on = eligible.filter((event) => isOn(event.id, channel.id)).length return (
{channel.label} 0 && on === eligible.length} indeterminate={on > 0 && on < eligible.length} disabled={eligible.length === 0} onChange={() => setColumn(channel.id, on !== eligible.length)} />
{category}
{event.label} {event.description && ( {event.description} )} {supports(event, channel.id) ? ( set(event.id, channel.id, change.target.checked)} /> ) : ( {`${channel.label} not available`} )}