/settings/notificationsNotification settings
A channel by event matrix — email, in-app and push against every kind of notification the workspace sends — with the digest window under it.
The page is a server component inside AppShell. The rows of the matrix are the kinds of notification db.notifications actually holds, so a kind the product has never sent never appears, and the headline counts them; which cells start on is a rule over the kind rather than a stored list. Every flip is its own save: the switch does not move until toggleNotification accepts it, and the one rule that outranks a preference — security alerts have to reach you somewhere — is enforced by the action and written under the channel that tried to break it. The digest is a form of its own, reusing PeriodSelect's windows rather than inventing a parallel vocabulary, and the chosen value rides to the action in a hidden input so an impossible one is refused rather than assumed. Preferences have no row of their own, so an accepted change is appended to db.auditEvents against the address the account last acted from, never an invented one. A row is marked aria-busy while its own answer is in flight and never disabled, so the switch the reader just pressed keeps focus; the cells still waiting are a set and each completion applies only its own cell, so two flips in the air cannot overwrite one another whichever of them answers first. The section list beside the page is the nav's own: the leaves of the `/settings` entry in this block's `nav.ts`, in nav order, resolved through the same NAV_ICONS table the sidebar reads. Nothing lists the sections twice, so editing that one file — or letting a template replace it — moves the sidebar and the sub-nav together, and the sub-nav can never offer a section the product has no page for. Composes AppShell, PageHeader, SettingsLayout, SectionHeader, Card, ToggleRow and PeriodSelect.
Preview
import { REFERENCE_DATE } from "@/lib/sample-data"
import { AppShell } from "@/components/ui/app-shell"
import { PageHeader } from "@/components/ui/page-header"
import { SettingsLayout } from "@/components/ui/settings-layout"
import { signOut } from "./actions"
import { ChannelMatrix } from "./components/channel-matrix"
import { DigestForm } from "./components/digest-form"
import { SETTINGS_SECTIONS } from "./components/settings-sections"
import {
CHANNELS,
currentUser,
DEFAULT_DIGEST,
defaultPreferences,
deliveryAddress,
DIGESTS,
DIGEST_LABELS,
notificationEvents,
notificationTotal,
shellNotifications,
} from "./data"
import { NAV, ROUTE } from "./nav"
/**
* Notification preferences. The page is a server component inside the shell:
* it reads the kinds of notification this workspace actually sends out of
* `db.notifications` and hands them, with the channels, to the matrix — which
* is the only place a flip happens.
*/
export default function SettingsNotificationsPage() {
const events = notificationEvents()
return (
<AppShell
nav={NAV}
activeHref={ROUTE}
user={currentUser()}
notifications={shellNotifications()}
now={REFERENCE_DATE}
onSignOut={signOut}
>
<PageHeader
title="Notifications"
description={`Northwind has sent ${notificationTotal()} notifications in ${events.length} kinds. Choose where each kind reaches you.`}
/>
<SettingsLayout nav={SETTINGS_SECTIONS} activeHref={ROUTE}>
<div className="flex flex-col gap-6">
<ChannelMatrix channels={CHANNELS} events={events} initial={defaultPreferences()} />
<DigestForm
digest={DEFAULT_DIGEST}
options={DIGESTS.map((value) => ({
value,
label: `Send it ${DIGEST_LABELS[value]}`,
}))}
address={deliveryAddress()}
/>
</div>
</SettingsLayout>
</AppShell>
)
}Install
npx shadcn@latest add @vibra/settings-notificationsNeeds the @vibra registry in your components.json — set it up once.
Source
import { REFERENCE_DATE } from "@/lib/sample-data"
import { AppShell } from "@/components/ui/app-shell"
import { PageHeader } from "@/components/ui/page-header"
import { SettingsLayout } from "@/components/ui/settings-layout"
import { signOut } from "./actions"
import { ChannelMatrix } from "./components/channel-matrix"
import { DigestForm } from "./components/digest-form"
import { SETTINGS_SECTIONS } from "./components/settings-sections"
import {
CHANNELS,
currentUser,
DEFAULT_DIGEST,
defaultPreferences,
deliveryAddress,
DIGESTS,
DIGEST_LABELS,
notificationEvents,
notificationTotal,
shellNotifications,
} from "./data"
import { NAV, ROUTE } from "./nav"
/**
* Notification preferences. The page is a server component inside the shell:
* it reads the kinds of notification this workspace actually sends out of
* `db.notifications` and hands them, with the channels, to the matrix — which
* is the only place a flip happens.
*/
export default function SettingsNotificationsPage() {
const events = notificationEvents()
return (
<AppShell
nav={NAV}
activeHref={ROUTE}
user={currentUser()}
notifications={shellNotifications()}
now={REFERENCE_DATE}
onSignOut={signOut}
>
<PageHeader
title="Notifications"
description={`Northwind has sent ${notificationTotal()} notifications in ${events.length} kinds. Choose where each kind reaches you.`}
/>
<SettingsLayout nav={SETTINGS_SECTIONS} activeHref={ROUTE}>
<div className="flex flex-col gap-6">
<ChannelMatrix channels={CHANNELS} events={events} initial={defaultPreferences()} />
<DigestForm
digest={DEFAULT_DIGEST}
options={DIGESTS.map((value) => ({
value,
label: `Send it ${DIGEST_LABELS[value]}`,
}))}
address={deliveryAddress()}
/>
</div>
</SettingsLayout>
</AppShell>
)
}import { type NavConfig } from "@/lib/nav-config"
/** The route this page is installed at. AppShell matches the nav against it. */
export const ROUTE = "/settings/notifications"
/**
* This product's navigation, as plain data. AppShell resolves the icon names
* and works out which item is current from the route, so nothing here is a
* component and nothing here says "I am the page you are on".
*
* Settings is a disclosure with one leaf per settings page, so this page's own
* route is a leaf: a parent with `items` renders as a button and has no anchor
* for `aria-current="page"` to land on.
*/
export const NAV: NavConfig = {
brand: { name: "Northwind", initial: "N", href: "/saas", caption: "Production" },
groups: [
{
label: "Workspace",
items: [
{ title: "Overview", href: "/saas", icon: "layout-dashboard" },
{ title: "Customers", href: "/ecommerce/customers", icon: "users" },
{ title: "Revenue", href: "/saas/revenue", icon: "credit-card" },
{ title: "Monitoring", href: "/engineering/monitoring", icon: "activity" },
],
},
{
label: "Account",
items: [
{
title: "Settings",
href: "/settings",
icon: "settings",
items: [
// The parent route is a real page (the workspace settings block),
// so it gets a leaf of its own — a parent with `items` renders as
// a disclosure button and is never a link.
{ title: "General", href: "/settings", icon: "settings" },
{ title: "Profile", href: "/settings/profile", icon: "user-round" },
{ title: "Security", href: "/settings/security", icon: "shield" },
{ title: "Notifications", href: "/settings/notifications", icon: "bell" },
{ title: "API keys", href: "/settings/api-keys", icon: "key-round" },
{ title: "Integrations", href: "/settings/integrations", icon: "plug" },
],
},
],
},
],
// Pinned under the groups, where the old secondary links sat.
footer: [{ title: "Support", href: "/support", icon: "life-buoy" }],
}/**
* What this page reads. The rows in the matrix are the kinds of notification
* this workspace actually sends — read off `db.notifications`, so a kind the
* product has never sent never appears — and the count beside each one is how
* many of them are in the inbox. The channels are the vocabulary the actions
* validate a flip against, which is why they are stated here rather than in
* the component that draws them. Which pairs start on is a rule over the kind,
* not a list. "Now" is `REFERENCE_DATE`.
*/
import { getInitials } from "@/lib/format"
import { db, type Member, type Notification } from "@/lib/sample-data"
export type Channel = { id: string; label: string; description: string }
/** The three ways this product can reach someone. */
export const CHANNELS: Channel[] = [
{
id: "email",
label: "Email",
description: "Sent to your sign-in address, one message per event.",
},
{
id: "in-app",
label: "In-app",
description: "Collected under the bell, and marked read when you open them.",
},
{
id: "push",
label: "Push",
description: "Delivered to a paired device, whether or not the app is open.",
},
]
export type NotificationEvent = {
id: Notification["kind"]
label: string
description: string
/** How many of this kind are in the inbox today. */
count: number
}
// The order a reader cares about them in, most personal first.
const KIND_ORDER: Notification["kind"][] = ["mention", "assignment", "billing", "security", "system"]
const KIND_COPY: Record<Notification["kind"], { label: string; description: string }> = {
mention: { label: "Mentions", description: "When someone names you in a comment or a thread." },
assignment: {
label: "Assignments",
description: "When a task or an incident lands in your queue.",
},
billing: { label: "Billing", description: "Invoices, failed payments, and plan changes." },
security: {
label: "Security",
description: "New sign-ins, key changes, and changes to what someone may see.",
},
system: {
label: "System",
description: "Maintenance windows and status changes, about once a month.",
},
}
/** The kinds of notification the workspace sends, and how many it has sent. */
export function notificationEvents(): NotificationEvent[] {
const counts = new Map<Notification["kind"], number>()
for (const notification of db.notifications.all()) {
counts.set(notification.kind, (counts.get(notification.kind) ?? 0) + 1)
}
return KIND_ORDER.filter((kind) => counts.has(kind)).map((kind) => ({
id: kind,
...KIND_COPY[kind],
count: counts.get(kind) ?? 0,
}))
}
/** How many notifications the workspace has sent this reader, all kinds. */
export function notificationTotal(): number {
return db.notifications.all().length
}
/** The kind of alert that has to reach you somewhere, whatever else is off. */
export const REQUIRED_EVENT: Notification["kind"] = "security"
/** One cell of the matrix, as both actions and the form name it. */
export function pairKey(channel: string, event: string): string {
return `${channel}:${event}`
}
/**
* Which cells start on — a rule over the kind rather than a stored list.
* Everything shows up in the app; email carries everything the product has an
* opinion about you for; push is reserved for the two that cannot wait.
*/
export function defaultPreferences(): string[] {
const urgent: Notification["kind"][] = ["security", "mention"]
return notificationEvents().flatMap((event) => [
...(event.id === "system" ? [] : [pairKey("email", event.id)]),
pairKey("in-app", event.id),
...(urgent.includes(event.id) ? [pairKey("push", event.id)] : []),
])
}
/** How often the digest goes out. The values PeriodSelect already speaks. */
export const DIGESTS = ["24h", "7d", "30d"] as const
export type Digest = (typeof DIGESTS)[number]
/** The digest the account starts on: once a week. */
export const DEFAULT_DIGEST: Digest = "7d"
/** The plain-English name of a digest window, for the copy a save reports back. */
export const DIGEST_LABELS: Record<Digest, string> = {
"24h": "every day",
"7d": "every week",
"30d": "every month",
}
function ownerRow(): Member {
return db.members.all().find((member) => member.role === "owner") ?? db.members.all()[0]
}
/** The person looking at the page: whoever owns this workspace. */
export function currentUser() {
const owner = ownerRow()
return { name: owner.name, email: owner.email, initials: getInitials(owner.name), avatarUrl: owner.avatarUrl }
}
/**
* The address this account last acted from, read off the audit trail. A change
* recorded here is recorded against it rather than against a made-up number.
*/
export function currentAddress(): string | undefined {
const owner = ownerRow()
return db.auditEvents
.all()
.filter((event) => event.actor === owner.id)
.sort((a, b) => b.at.getTime() - a.at.getTime())[0]?.ip
}
/** The address every email in the matrix above would go to. */
export function deliveryAddress(): string {
return ownerRow().email
}
/** The bell's contents: the newest notifications, unread first in the panel. */
export function shellNotifications() {
return db.notifications
.all()
.sort((a, b) => b.at.getTime() - a.at.getTime())
.slice(0, 6)
.map(({ id, title, description, at, read, href }) => ({ id, title, description, at, read, href }))
}"use server"
import { mockAuthAdapter } from "@/lib/auth-adapter"
import { db, fields, invalidInput, isForm, REFERENCE_DATE, type Result } from "@/lib/sample-data"
import {
CHANNELS,
currentAddress,
DIGESTS,
DIGEST_LABELS,
notificationEvents,
pairKey,
REQUIRED_EVENT,
type Digest,
} from "./data"
export type DigestState = Result<{ digest: Digest; label: string }> | null
/**
* Notification preferences have no row of their own; a change is a trail entry.
* The address is the one this account last acted from — the trail already knows
* it, and inventing a number here would put a fact in db that is not one.
*/
async function record(action: string, resourceId: string): Promise<void> {
const owner = db.members.all().find((member) => member.role === "owner")
const ip = currentAddress()
if (!owner || !ip) return
await db.auditEvents.create({
at: REFERENCE_DATE,
actor: owner.id,
action,
resource: "member",
resourceId,
ip,
})
}
/**
* Flips one cell of the matrix. There is no preferences table to write to, so
* the action's job is the vocabulary and the one rule that outranks a
* preference: security alerts have to reach you somewhere. `enabledAfter` is
* what the matrix would look like once this flip lands — the caller holds the
* state, the action decides whether that state is allowed.
*/
export async function toggleNotification(input: {
channel: string
event: string
enabled: boolean
enabledAfter: string[]
}): Promise<Result<{ channel: string; event: string; enabled: boolean }>> {
const { channel, event, enabled, enabledAfter } = fields(input)
if (typeof channel !== "string" || !CHANNELS.some((known) => known.id === channel)) {
return { ok: false, error: { code: "invalid_input", message: "No such channel." } }
}
if (typeof event !== "string" || !notificationEvents().some((known) => known.id === event)) {
return { ok: false, error: { code: "invalid_input", message: "No such notification." } }
}
if (typeof enabled !== "boolean" || !Array.isArray(enabledAfter)) {
return invalidInput("Turn one notification on or off from the matrix.")
}
const reachable = CHANNELS.some((known) =>
enabledAfter.includes(pairKey(known.id, REQUIRED_EVENT))
)
if (!reachable) {
return {
ok: false,
error: {
code: "invalid_input",
message: "Security alerts have to reach you somewhere. Leave at least one channel on.",
},
}
}
await record(enabled ? "enabled" : "disabled", `${channel}:${event}`)
return { ok: true, data: { channel, event, enabled } }
}
/**
* Sets how often the digest goes out. The form's own submit, so a value the
* select could not have produced is refused against the field it came from.
*/
export async function saveDigest(
_previous: DigestState,
formData: FormData
): Promise<Result<{ digest: Digest; label: string }>> {
if (!isForm(formData)) return invalidInput("Send the digest as the form sends it.", "digest")
const value = formData.get("digest")
const digest = typeof value === "string" ? value : ""
if (!DIGESTS.includes(digest as Digest)) {
return {
ok: false,
error: { code: "invalid_input", field: "digest", message: "Choose a digest from the list." },
}
}
await record("updated", "digest")
return { ok: true, data: { digest: digest as Digest, label: DIGEST_LABELS[digest as Digest] } }
}
/**
* The one thing the shell calls. A server action so the page can stay a server
* component and still hand the shell something to call, and a `Result` so the
* caller reads the same success-or-error shape every mutation returns.
*/
export async function signOut(): Promise<Result<{ signedOut: true }>> {
await mockAuthAdapter.signOut()
return { ok: true, data: { signedOut: true } }
}"use client"
import * as React from "react"
import { Card, CardContent, CardHeader } from "@/components/ui/card"
import { SectionHeader } from "@/components/ui/section-header"
import { ToggleRow } from "@/components/ui/toggle-row"
import { toggleNotification } from "../actions"
export type MatrixChannel = { id: string; label: string; description: string }
export type MatrixEvent = { id: string; label: string; description: string }
type ChannelCardProps = {
channel: MatrixChannel
events: MatrixEvent[]
enabled: string[]
error?: string
onFlip: (channel: string, event: string, next: boolean) => void
/** Every cell an action is currently answering for, as `channel:event` keys. */
saving: ReadonlySet<string>
/** Only the first card explains the kinds; the rest are the same five names. */
describe: boolean
}
/** One channel, one row per kind of notification: the matrix, a column at a time. */
function ChannelCard({
channel,
events,
enabled,
error,
onFlip,
saving,
describe,
}: ChannelCardProps) {
return (
<Card>
<CardHeader>
<SectionHeader as="h2" title={channel.label} description={channel.description} />
</CardHeader>
<CardContent className="flex flex-col">
{events.map((event) => (
<ToggleRow
key={event.id}
id={`${channel.id}-${event.id}`}
label={event.label}
description={describe ? event.description : undefined}
checked={enabled.includes(`${channel.id}:${event.id}`)}
// Busy, never disabled: disabling the switch that was just clicked
// takes focus off it mid-round-trip and drops the reader back at
// the top of the page.
aria-busy={saving.has(`${channel.id}:${event.id}`) || undefined}
onCheckedChange={(next) => onFlip(channel.id, event.id, next)}
/>
))}
{error ? (
<p role="alert" className="pt-3 text-sm text-danger">
{error}
</p>
) : null}
</CardContent>
</Card>
)
}
/** `list` with one cell turned on or off, and no duplicate if it was already on. */
function withCell(list: string[], key: string, on: boolean): string[] {
if (on) return list.includes(key) ? list : [...list, key]
return list.filter((entry) => entry !== key)
}
export type ChannelMatrixProps = {
channels: MatrixChannel[]
events: MatrixEvent[]
/** The cells that start on, as `channel:event` keys. */
initial: string[]
}
/**
* The channel × event matrix. Each flip is its own save: the switch does not
* move until the action accepts it, and a refusal is written under the channel
* that produced it rather than swallowed.
*/
export function ChannelMatrix({ channels, events, initial }: ChannelMatrixProps) {
const [enabled, setEnabled] = React.useState(initial)
const [refusal, setRefusal] = React.useState<{ channel: string; message: string }>()
const [saved, setSaved] = React.useState<string>()
// Two things a flip has to read the moment it is dispatched, which state
// handed to a closure cannot give it once a second flip is already in the
// air: which cells are still waiting, and what the matrix would look like if
// everything dispatched so far lands. Both live in refs and are mirrored
// into state only for what the rows render.
const pendingRef = React.useRef<ReadonlySet<string>>(new Set())
// Its own empty set rather than the ref's: reading a ref during render is
// exactly what the ref is here to avoid, and both start empty anyway —
// markPending is the one place either of them is ever written.
const [pending, setPending] = React.useState<ReadonlySet<string>>(() => new Set())
const projected = React.useRef(initial)
function markPending(key: string, waiting: boolean) {
const next = new Set(pendingRef.current)
if (waiting) next.add(key)
else next.delete(key)
pendingRef.current = next
setPending(next)
}
async function flip(channel: string, event: string, next: boolean) {
const key = `${channel}:${event}`
// One answer at a time for a given cell; every other row stays live.
if (pendingRef.current.has(key)) return
const enabledAfter = withCell(projected.current, key, next)
projected.current = enabledAfter
markPending(key, true)
const result = await toggleNotification({ channel, event, enabled: next, enabledAfter })
markPending(key, false)
if (!result.ok) {
// This cell never moved, and the guard above means nothing else was
// answering for it — so it goes back to exactly what it was.
projected.current = withCell(projected.current, key, !next)
setSaved(undefined)
setRefusal({ channel, message: result.error.message })
return
}
setRefusal(undefined)
// Functional, and only this cell: a flip that started earlier may have
// landed in between, and overwriting the whole list with a closure's copy
// would throw its answer away.
setEnabled((current) => withCell(current, key, next))
const eventLabel = events.find((entry) => entry.id === event)?.label ?? event
const channelLabel = channels.find((entry) => entry.id === channel)?.label ?? channel
setSaved(`${eventLabel} by ${channelLabel.toLowerCase()} is ${next ? "on" : "off"}.`)
}
return (
<>
{channels.map((channel, index) => (
<ChannelCard
key={channel.id}
channel={channel}
events={events}
enabled={enabled}
error={refusal?.channel === channel.id ? refusal.message : undefined}
onFlip={flip}
saving={pending}
describe={index === 0}
/>
))}
{/* Named, because the digest card below keeps a status region of its own
and two unnamed ones are indistinguishable to a reader moving between
them. */}
<p aria-label="Notification changes" role="status" className="text-sm text-muted-foreground">
{saved ?? null}
</p>
</>
)
}"use client"
import * as React from "react"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card"
import { PeriodSelect, type Period } from "@/components/ui/period-select"
import { SectionHeader } from "@/components/ui/section-header"
import { saveDigest, type DigestState } from "../actions"
export type DigestOption = { value: string; label: string }
export type DigestFormProps = {
digest: string
options: DigestOption[]
address: string
}
/**
* How often the roll-up goes out. PeriodSelect already speaks in windows, so
* the digest reuses its values rather than inventing a parallel vocabulary;
* the chosen window rides to the action in a hidden input, which is what makes
* an impossible value something the action can refuse rather than assume.
*/
export function DigestForm({ digest, options, address }: DigestFormProps) {
const [state, formAction, pending] = React.useActionState<DigestState, FormData>(saveDigest, null)
const [value, setValue] = React.useState(digest)
const error = state && !state.ok ? state.error : undefined
return (
<Card>
<CardHeader>
<SectionHeader
as="h2"
title="Digest"
description={`One roll-up of everything above, to ${address}.`}
/>
</CardHeader>
<form action={formAction}>
<CardContent className="flex flex-col gap-2">
<input type="hidden" name="digest" value={value} />
<PeriodSelect
aria-label="Digest frequency"
value={value as Period}
options={options.map((option) => ({ value: option.value as Period, label: option.label }))}
allowCustom={false}
onValueChange={(period) => setValue(period)}
/>
{error ? (
<p role="alert" className="text-sm text-danger">
{error.message}
</p>
) : null}
</CardContent>
<CardFooter className="mt-4 justify-between gap-3">
<p aria-label="Digest changes" role="status" className="text-sm text-muted-foreground">
{state?.ok ? `Digest saved. It goes out ${state.data.label}.` : null}
</p>
<Button type="submit" disabled={pending} aria-busy={pending || undefined}>
{pending ? "Saving…" : "Save digest"}
</Button>
</CardFooter>
</form>
</Card>
)
}import { flattenNav, type NavConfig } from "@/lib/nav-config"
import { navIcon } from "@/components/ui/app-shell/icons"
import { type SettingsNavItem } from "@/components/ui/settings-layout"
import { NAV } from "../nav"
// Not `SETTINGS_HREF`: this is the entry to look up, not a link. The template
// generator reads any *href constant holding an absolute path as a route a
// page links to, and would stub "/settings" for a product that has no page
// there — the same reason a page nav's own ROUTE is not called a href.
/** The route the settings area is rooted at. */
const SETTINGS_ROOT = "/settings"
/**
* The settings area's own sections, as SettingsLayout wants them: the leaves of
* the nav's `/settings` entry, in nav order. Read from the nav rather than
* written out a second time, because a template replaces `nav.ts` with its own
* — a hand-listed set would then offer sections that product has no page for,
* and the sub-nav beside the page would disagree with the sidebar above it.
*
* `findNavItem` is not the lookup: these navs give `/settings` a leaf of its
* own (General, the area's front page), and that leaf ties with its parent on
* href length, so the entry that owns the list has to be asked for directly.
* An entry with no leaves is its own only section.
*
* Each section is a real route, so these stay plain anchors — no `onNavigate`,
* no client state — and SettingsLayout marks the one matching `activeHref` as
* the current page.
*/
export function settingsSections(nav: NavConfig = NAV): SettingsNavItem[] {
const entries = flattenNav(nav).filter((item) => item.href === SETTINGS_ROOT)
// The parent wins over its own General leaf, which shares its href: the one
// that carries the list is the one being asked for. `findNavItem` would hand
// back the leaf instead, since equal href lengths go to the later item.
const settings = entries.find((item) => item.items?.length) ?? entries[0]
if (!settings) return []
const leaves = settings.items?.length ? settings.items : [settings]
return leaves.map((leaf) => {
// A NavConfig names its icons rather than holding them; this is the table
// the sidebar resolves them through, so the two cannot drift.
const Icon = navIcon(leaf.icon)
return { title: leaf.title, href: leaf.href, icon: Icon ? <Icon /> : undefined }
})
}
/** This block's own sections: the nav it ships with, resolved once. */
export const SETTINGS_SECTIONS: SettingsNavItem[] = settingsSections()