3 sections
Switch cards
Settings as cards that each turn one thing on or off, with what it changes.
Automation switches with what each one changes
switch-cards-01Every workflow in the store as a switch card, two up: when a timed one goes out next, what an event-driven one did this fortnight, and what a pause would skip.
import * as React from "react"
import {
Building2Icon,
CalendarClockIcon,
ChartLineIcon,
ClipboardListIcon,
CreditCardIcon,
FileTextIcon,
FlagIcon,
GaugeIcon,
HourglassIcon,
ListTodoIcon,
MailIcon,
MessageCircleIcon,
MessageSquareIcon,
PackageIcon,
ReceiptIcon,
RotateCwIcon,
ShoppingCartIcon,
SplitIcon,
TagIcon,
TicketIcon,
UserCheckIcon,
UserPlusIcon,
UsersIcon,
type LucideIcon,
} from "lucide-react"
import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { AutomationSwitches, type AutomationCard } from "./switch-cards-01.client"
import { HISTORY_DAYS, automations, setAutomation, type Automation, type Result, type WorkflowIcon } from "./switch-cards-01.data"
/** The icon each workflow step may wear, by the name the store gives it. */
const ICONS: Record<WorkflowIcon, LucideIcon> = {
"building-2": Building2Icon,
"calendar-clock": CalendarClockIcon,
"chart-line": ChartLineIcon,
"clipboard-list": ClipboardListIcon,
"credit-card": CreditCardIcon,
"file-text": FileTextIcon,
flag: FlagIcon,
gauge: GaugeIcon,
hourglass: HourglassIcon,
"list-todo": ListTodoIcon,
mail: MailIcon,
"message-circle": MessageCircleIcon,
"message-square": MessageSquareIcon,
package: PackageIcon,
receipt: ReceiptIcon,
"rotate-cw": RotateCwIcon,
"shopping-cart": ShoppingCartIcon,
split: SplitIcon,
tag: TagIcon,
ticket: TicketIcon,
"user-check": UserCheckIcon,
"user-plus": UserPlusIcon,
users: UsersIcon,
}
/**
* The store's automations as switch cards, two up from `md`: each card turns
* one workflow on or off and says what that changes — when a timed one goes
* out next, or what an event-driven one did over the last fortnight and how
* much a pause would skip. A draft's switch is held off, with the reason.
*
* A server component. It reads the workflows through `./switch-cards-01.data`,
* draws each trigger's icon, and hands the island `switchAutomation`, a server
* function defined here, which saves the switch in the store and answers with
* a `Result`.
*/
export function SwitchCards01() {
const titleId = React.useId()
async function switchAutomation(id: string, on: boolean): Promise<Result<Automation>> {
"use server"
return setAutomation(id, on)
}
const cards: AutomationCard[] = automations().map((automation) => {
const Icon = ICONS[automation.icon]
return { ...automation, icon: <Icon /> }
})
return (
<section
data-section="switch-cards-01"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background p-4 text-foreground md:p-6"
>
<div className="mx-auto max-w-7xl">
<Card>
<CardHeader>
<CardTitle>
<h2 id={titleId}>Automations</h2>
</CardTitle>
<CardDescription>Each switch starts or pauses one workflow · runs counted over the last {HISTORY_DAYS} days</CardDescription>
</CardHeader>
<AutomationSwitches automations={cards} onSwitch={switchAutomation} />
</Card>
</div>
</section>
)
}
export default SwitchCards01"use client"
import * as React from "react"
import { CardContent, CardFooter } from "@/components/ui/card"
import { Callout } from "@/components/ui/callout"
import { Field, FieldContent, FieldDescription, FieldLabel, FieldTitle } from "@/components/ui/field"
import { Spinner } from "@/components/ui/spinner"
import { Switch } from "@/components/ui/switch"
import type { Automation, Result } from "./switch-cards-01.data"
/** An automation with its trigger's icon already drawn: the section draws it, so the island carries no icon map. */
export type AutomationCard = Omit<Automation, "icon"> & { icon: React.ReactNode }
/** Saves one switch: the section's server action. */
export type SwitchAutomation = (id: string, on: boolean) => Promise<Result<Automation>>
/**
* The automations as switch cards. Each card is one control with one name:
* the card is the switch's label, the switch is named by the card's title
* alone, and the trigger line and the line under it — what this position of
* the switch changes — describe it. The line changes with the switch.
*
* A switch saves at once, through the section's server action. While it is
* saving it holds still (read-only, never disabled, so the focus stays on it)
* and says it is busy. A refusal puts the switch back and says why under that
* card; a save is confirmed in the status line in the footer.
*/
export function AutomationSwitches({ automations, onSwitch }: { automations: AutomationCard[]; onSwitch: SwitchAutomation }) {
const id = React.useId()
const [on, setOn] = React.useState<ReadonlyMap<string, boolean>>(() => new Map(automations.map((card) => [card.id, card.on])))
const [saving, setSaving] = React.useState<ReadonlySet<string>>(new Set())
const [refusals, setRefusals] = React.useState<ReadonlyMap<string, string>>(new Map())
const [status, setStatus] = React.useState("")
const put = <T,>(map: ReadonlyMap<string, T>, key: string, value: T | undefined) => {
const next = new Map(map)
if (value === undefined) next.delete(key)
else next.set(key, value)
return next
}
function settle(card: AutomationCard, wanted: boolean, result: Result<Automation>) {
setSaving((current) => new Set([...current].filter((key) => key !== card.id)))
if (result.ok) {
setOn((current) => put(current, card.id, result.data.on))
setStatus(`Saved · ${card.name} is ${result.data.on ? "on" : "off"}.`)
return
}
setOn((current) => put(current, card.id, !wanted))
setRefusals((current) => put(current, card.id, result.error.message))
setStatus("")
}
function toggle(card: AutomationCard, wanted: boolean) {
setOn((current) => put(current, card.id, wanted))
setSaving((current) => new Set(current).add(card.id))
setRefusals((current) => put(current, card.id, undefined))
void onSwitch(card.id, wanted).then(
(result) => settle(card, wanted, result),
() => settle(card, wanted, { ok: false, error: { code: "network", message: `${card.name} was not saved. Check the connection and try again.` } })
)
}
const count = [...on.values()].filter(Boolean).length
return (
<>
<CardContent className="grid gap-3 md:grid-cols-2">
{automations.map((card) => {
const checked = on.get(card.id) ?? card.on
const busy = saving.has(card.id)
const refusal = refusals.get(card.id)
const part = (name: string) => `${id}-${card.id}-${name}`
return (
<div key={card.id} data-automation={card.id} className="flex min-w-0 flex-col gap-2">
<FieldLabel htmlFor={part("switch")} className="h-full">
<Field orientation="horizontal" data-disabled={card.blocked ? "true" : undefined} className="h-full gap-3">
<span
aria-hidden="true"
className="flex size-8 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground [&_svg]:size-4"
>
{card.icon}
</span>
<FieldContent className="min-w-0 gap-1">
<FieldTitle id={part("title")}>{card.name}</FieldTitle>
<FieldDescription id={part("hint")} className="text-xs">
{card.hint}
</FieldDescription>
<Line id={part("line")} text={checked ? card.lines.on : card.lines.off} busy={busy} />
</FieldContent>
<Switch
id={part("switch")}
checked={checked}
disabled={Boolean(card.blocked)}
readOnly={busy}
aria-busy={busy || undefined}
aria-labelledby={part("title")}
aria-describedby={`${part("hint")} ${part("line")}`}
onCheckedChange={(next) => toggle(card, next)}
className="mt-0.5"
/>
</Field>
</FieldLabel>
{refusal ? (
<Callout variant="danger" role="alert" title={`${card.name} was not changed`}>
{refusal}
</Callout>
) : null}
</div>
)
})}
</CardContent>
<CardFooter className="justify-between gap-4 text-sm">
<p role="status" aria-live="polite" className="min-h-5 min-w-0 text-foreground">
{status}
</p>
<p className="shrink-0 text-muted-foreground tabular-nums">
{count} of {automations.length} on
</p>
</CardFooter>
</>
)
}
/**
* What the switch's position changes: "On · 15 runs in the last 14 days".
* The state word leads in the label weight so a column of cards scans by it;
* the words, not the tint, carry the state.
*/
function Line({ id, text, busy }: { id: string; text: string; busy: boolean }) {
const cut = text.indexOf(" · ")
const head = cut === -1 ? text : text.slice(0, cut)
const rest = cut === -1 ? "" : text.slice(cut)
return (
<p id={id} data-slot="switch-card-line" className="text-sm font-normal text-pretty text-foreground tabular-nums">
{busy ? <Spinner aria-hidden="true" role={undefined} aria-label={undefined} className="me-1.5 inline size-3.5 align-[-0.125em]" /> : null}
<span className="font-medium">{head}</span>
{rest}
</p>
)
}/**
* What `switch-cards-01` reads and the one thing it writes: the store's
* automations in `db.workflows` — what starts each one, what it did over the
* fortnight of runs the store keeps, and when a timed one goes out next — and
* `setAutomation`, which the section's server action calls to switch one on
* or off.
*
* Every line a card shows is written here, on the server, for both positions
* of its switch: dates are pinned to UTC and "now" is `REFERENCE_DATE`, so the
* server and the browser never disagree about a day. Selectors read `db`
* inside functions only; the island takes the rows as props and never imports
* this module.
*/
import { REFERENCE_DATE, db, type Result, type Workflow, type WorkflowIcon } from "@/lib/sample-data"
export type { Result, WorkflowIcon }
/** How far back the store keeps a workflow's runs, in days. */
export const HISTORY_DAYS = 14
const DAY_MS = 86_400_000
const WEEK_MS = 7 * DAY_MS
const WEEKDAY = new Intl.DateTimeFormat("en-US", { weekday: "long", timeZone: "UTC" })
const DAY = new Intl.DateTimeFormat("en-US", { weekday: "long", month: "short", day: "numeric", timeZone: "UTC" })
/** An automation as its card draws it. */
export type Automation = {
id: string
name: string
on: boolean
/** The trigger's icon, by name; the section draws it. */
icon: WorkflowIcon
/** What starts it, as the store words it: "Store · a paid order comes in". */
hint: string
/** What the card says for each position of its switch — what turning it on or off changes. */
lines: { on: string; off: string }
/** Why the switch cannot move: a draft is not finished. Unset for a workflow that can run. */
blocked?: string
}
/** A timed workflow's slot: the weekday and the UTC time its runs start at. */
type Slot = { weekday: number; hour: number; minute: number }
const two = (value: number) => String(value).padStart(2, "0")
const plural = (count: number, one: string, many: string) => `${count} ${count === 1 ? one : many}`
/**
* The weekly slot a scheduled workflow runs in, read off the runs it made —
* the store lays a timed workflow's runs down on its schedule, so they all
* share one weekday and one time. Undefined for a workflow started by an event.
*/
function slotOf(workflow: Workflow): Slot | undefined {
const trigger = workflow.nodes.find((node) => node.kind === "trigger")
const last = workflow.runs.at(-1)
if (!trigger?.summary.startsWith("Schedule") || !last) return undefined
const at = last.startedAt
return { weekday: at.getUTCDay(), hour: at.getUTCHours(), minute: at.getUTCMinutes() }
}
/** The first time the slot comes round after `REFERENCE_DATE`. */
function nextAfterNow(slot: Slot): Date {
const now = REFERENCE_DATE
const today = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), slot.hour, slot.minute)
const ahead = (slot.weekday - now.getUTCDay() + 7) % 7
const candidate = today + ahead * DAY_MS
return new Date(candidate > now.getTime() ? candidate : candidate + WEEK_MS)
}
function automationOf(workflow: Workflow): Automation {
const trigger = workflow.nodes.find((node) => node.kind === "trigger")
const steps = workflow.nodes.filter((node) => node.kind !== "trigger").length
const icon = trigger?.icon ?? workflow.nodes[0]?.icon ?? "flag"
const base = { id: workflow.id, name: workflow.name, on: workflow.status === "active", icon }
if (workflow.status === "draft") {
const blocked = trigger
? "Draft · finish it in the editor before it can run"
: "Draft · it needs a trigger before it can run"
const hint = trigger ? trigger.summary : `No trigger yet · ${plural(steps, "step", "steps")} drawn`
return { ...base, hint, lines: { on: blocked, off: blocked }, blocked }
}
const slot = slotOf(workflow)
if (slot) {
const time = `${two(slot.hour)}:${two(slot.minute)} UTC`
const next = DAY.format(nextAfterNow(slot))
const last = workflow.runs.at(-1)
const weekday = WEEKDAY.format(nextAfterNow(slot))
return {
...base,
hint: `Schedule · every ${weekday} at ${time}`,
lines: {
on: `On · goes out ${weekday}s at ${time}, next on ${next}`,
off: last ? `Off · ${next} is skipped; it last went out ${DAY.format(last.startedAt)}` : `Off · ${next} is skipped`,
},
}
}
const runs = workflow.runs.length
const failed = workflow.runs.filter((run) => run.status === "failed").length
const perWeek = Math.round(runs / (HISTORY_DAYS / 7))
return {
...base,
hint: trigger?.summary ?? "No trigger yet",
lines: {
on: `On · ${plural(runs, "run", "runs")} in the last ${HISTORY_DAYS} days, ${failed === 0 ? "none failed" : `${failed} failed`}`,
off:
perWeek === 0
? `Off · it has not run in the last ${HISTORY_DAYS} days`
: `Off · about ${plural(perWeek, "run", "runs")} a week will be skipped`,
},
}
}
/** Every automation in the store, in the order the store keeps them. */
export function automations(): Automation[] {
return db.workflows.all().map(automationOf)
}
/**
* Switches a workflow on or off. A draft is refused: it is not finished, and
* the store would run whatever it has. The position is re-read from the row
* the store hands back, never taken from the caller.
*
* Both arguments are checked for their shape first: a server function is
* called with whatever a request carries, so only a workflow's id and a
* literal `true` or `false` get as far as the store — `"false"` switches
* nothing on.
*/
export async function setAutomation(id: unknown, on: unknown): Promise<Result<Automation>> {
const workflow = typeof id === "string" ? db.workflows.all().find((entry) => entry.id === id) : undefined
if (!workflow) return { ok: false, error: { code: "not_found", message: "That automation no longer exists." } }
if (typeof on !== "boolean") return { ok: false, error: { code: "invalid", message: `Say whether ${workflow.name} is on or off.` } }
if (workflow.status === "draft")
return { ok: false, error: { code: "draft", message: `${workflow.name} is a draft. Finish it in the editor before switching it on.` } }
const saved = await db.workflows.update(workflow.id, { status: on ? "active" : "paused" })
return saved.ok ? { ok: true, data: automationOf(saved.data) } : saved
}$
npx shadcn@latest add @vibra/switch-cards-01Email switches under one master
switch-cards-02Every kind of notification as a switch card with the emails a week it sends, under a master checkbox that reads all, none or some, and turns them all on or off.
import * as React from "react"
import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { EmailSwitches } from "./switch-cards-02.client"
import { WINDOW_DAYS, emailCategories, saveEmailPreferences, type EmailKind, type Result } from "./switch-cards-02.data"
/**
* Email notifications as a group of switch cards under one master: every
* kind of notification the inbox receives, each with how many emails a week
* it would send at the pace of the last 30 days, and a master that reads
* "some" when some are on. Security stays on, and says why.
*
* A server component. It counts the inbox through `./switch-cards-02.data`
* and hands the island `saveEmails`, a server function defined here, which
* checks the set it is sent and answers with a `Result`.
*/
export function SwitchCards02() {
const titleId = React.useId()
async function saveEmails(on: EmailKind[]): Promise<Result<{ on: EmailKind[] }>> {
"use server"
return saveEmailPreferences(on)
}
return (
<section
data-section="switch-cards-02"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background p-4 text-foreground md:p-6"
>
<div className="mx-auto max-w-7xl">
<Card>
<CardHeader>
<CardTitle>
<h2 id={titleId}>Email notifications</h2>
</CardTitle>
<CardDescription>Which notifications also reach your email · paced by the last {WINDOW_DAYS} days of your inbox</CardDescription>
</CardHeader>
<EmailSwitches categories={emailCategories()} windowDays={WINDOW_DAYS} onSave={saveEmails} />
</Card>
</div>
</section>
)
}
export default SwitchCards02"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
import { CardContent, CardFooter } from "@/components/ui/card"
import { Callout } from "@/components/ui/callout"
import { Checkbox } from "@/components/ui/checkbox"
import { Field, FieldContent, FieldDescription, FieldLabel, FieldTitle } from "@/components/ui/field"
import { Switch } from "@/components/ui/switch"
import type { EmailCategory, EmailKind, Result } from "./switch-cards-02.data"
/** Saves the kinds that reach the member's email: the section's server action. */
export type SaveEmailPreferences = (on: EmailKind[]) => Promise<Result<{ on: EmailKind[] }>>
/** Emails a week at the window's pace, in words: "about 3 emails a week", "fewer than one email a week". */
function pace(count: number, days: number): string {
const weekly = Math.round((count * 7) / days)
if (count === 0) return "no emails"
if (weekly < 1) return "fewer than one email a week"
return `about ${weekly} ${weekly === 1 ? "email" : "emails"} a week`
}
/** "mentions, assignments and security" */
function listed(words: string[]): string {
if (words.length <= 1) return words.join("")
return `${words.slice(0, -1).join(", ")} and ${words.at(-1)}`
}
/**
* The email switches under one master. Each kind of notification is a switch
* card named by its title and described by what it is and what its position
* changes; the master above them is a checkbox, because only a checkbox can
* say "some": checked when every kind is on, clear when none is, mixed in
* between — and pressed, it turns them all on, or all off when all were on.
* A kind that has to stay on is held on, with the reason.
*
* Every change saves the whole set through the section's server action. The
* newest request wins: an answer to an older one is dropped. A refusal puts
* the switches back where the last save left them and says why beside them;
* a save is confirmed in the footer's status line.
*/
export function EmailSwitches({
categories,
windowDays,
onSave,
}: {
categories: EmailCategory[]
windowDays: number
onSave: SaveEmailPreferences
}) {
const id = React.useId()
const part = (name: string) => `${id}-${name}`
const initial = React.useMemo(() => new Set(categories.filter((category) => category.on).map((category) => category.kind)), [categories])
const [on, setOn] = React.useState<ReadonlySet<EmailKind>>(initial)
const [refusal, setRefusal] = React.useState<string | null>(null)
const [status, setStatus] = React.useState("")
const saved = React.useRef<ReadonlySet<EmailKind>>(initial)
const latest = React.useRef(0)
const switchable = categories.filter((category) => !category.locked)
const allOn = switchable.every((category) => on.has(category.kind))
const someOn = switchable.some((category) => on.has(category.kind))
const onCount = switchable.filter((category) => on.has(category.kind)).length
const emailed = categories.filter((category) => on.has(category.kind)).reduce((total, category) => total + category.count, 0)
function apply(next: ReadonlySet<EmailKind>) {
const ticket = ++latest.current
setOn(next)
setRefusal(null)
const kinds = categories.map((category) => category.kind).filter((kind) => next.has(kind))
const answer = (result: Result<{ on: EmailKind[] }>) => {
if (ticket !== latest.current) return
if (result.ok) {
saved.current = new Set(result.data.on)
setOn(saved.current)
const names = categories.filter((category) => saved.current.has(category.kind)).map((category) => category.label.toLowerCase())
const count = categories.filter((category) => saved.current.has(category.kind)).reduce((total, category) => total + category.count, 0)
setStatus(`Saved · ${listed(names)} emails, ${pace(count, windowDays)}.`)
return
}
setOn(saved.current)
setRefusal(result.error.message)
setStatus("")
}
void onSave(kinds).then(answer, () =>
answer({ ok: false, error: { code: "network", message: "Your choices were not saved. Check the connection and try again." } })
)
}
function toggle(kind: EmailKind, wanted: boolean) {
const next = new Set(on)
if (wanted) next.add(kind)
else next.delete(kind)
apply(next)
}
function toggleAll() {
const locked = categories.filter((category) => category.locked && on.has(category.kind)).map((category) => category.kind)
apply(new Set(allOn ? locked : [...locked, ...switchable.map((category) => category.kind)]))
}
return (
<>
<CardContent className="grid gap-6 lg:grid-cols-[18rem_minmax(0,1fr)] lg:gap-8">
<div className="flex flex-col gap-4 lg:border-e lg:border-border lg:pe-8">
<div className="flex items-start gap-3">
<Checkbox
id={part("all")}
checked={allOn}
indeterminate={someOn && !allOn}
aria-labelledby={part("all-label")}
aria-describedby={part("all-summary")}
onCheckedChange={toggleAll}
className="mt-0.5"
/>
<div className="flex min-w-0 flex-col gap-1">
<label id={part("all-label")} htmlFor={part("all")} className="text-sm leading-5 font-medium">
All notification emails
</label>
<p id={part("all-summary")} className="text-sm text-muted-foreground tabular-nums">
{onCount} of {switchable.length} on · {pace(emailed, windowDays)}
</p>
</div>
</div>
<p className="flex items-baseline gap-2 border-t border-border pt-4 max-lg:hidden">
<span data-slot="switch-cards-emails-value" className="text-3xl font-bold tabular-nums">
{Math.round((emailed * 7) / windowDays)}
</span>
<span className="text-sm text-muted-foreground">emails a week at the pace of your last {windowDays} days</span>
</p>
<p className="text-xs text-muted-foreground lg:border-t lg:border-border lg:pt-4">
Every notification still lands in your inbox in the app. A switch decides only whether it also comes by email.
</p>
</div>
<div className="flex min-w-0 flex-col gap-2">
{refusal ? (
<Callout variant="danger" role="alert" title="Your email settings were not saved">
{refusal}
</Callout>
) : null}
{categories.map((category) => {
const checked = on.has(category.kind)
const line = category.locked
? category.locked
: checked
? `On · ${pace(category.count, windowDays)}`
: "Off · they stay in your inbox"
const cut = line.indexOf(" · ")
return (
<FieldLabel key={category.kind} htmlFor={part(category.kind)} data-kind={category.kind}>
<Field orientation="horizontal" className="gap-3">
<FieldContent className="min-w-0 gap-1">
<FieldTitle id={part(`${category.kind}-title`)}>{category.label}</FieldTitle>
<FieldDescription id={part(`${category.kind}-hint`)} className="text-xs tabular-nums">
{category.description} · {category.count} in the last {windowDays} days
</FieldDescription>
<p id={part(`${category.kind}-line`)} className={cn("text-sm font-normal text-pretty tabular-nums", category.locked ? "text-muted-foreground" : "text-foreground")}>
<span className="font-medium">{line.slice(0, cut)}</span>
{line.slice(cut)}
</p>
</FieldContent>
<Switch
id={part(category.kind)}
checked={checked}
disabled={Boolean(category.locked)}
aria-labelledby={part(`${category.kind}-title`)}
aria-describedby={`${part(`${category.kind}-hint`)} ${part(`${category.kind}-line`)}`}
onCheckedChange={(next) => toggle(category.kind, next)}
className="mt-0.5"
/>
</Field>
</FieldLabel>
)
})}
</div>
</CardContent>
<CardFooter className="gap-0 text-sm">
<p role="status" aria-live="polite" className="min-h-5 text-foreground">
{status}
</p>
{status ? null : <p className="text-muted-foreground">Changes save as you switch.</p>}
</CardFooter>
</>
)
}/**
* What `switch-cards-02` reads and the one thing it saves: how much of each
* kind of notification the signed-in member's inbox took in over the last 30
* days (`db.notifications`), which is how many emails each switch lets
* through, and `saveEmailPreferences`, the section's server action behind the
* switches.
*
* Counts are read inside a function, on the server, back from
* `REFERENCE_DATE`. The island takes the rows as props and does its own
* arithmetic on them; it never imports this module.
*/
import { REFERENCE_DATE, db, type Notification, type Result } from "@/lib/sample-data"
export type { Result }
/** The window the counts cover, in days. */
export const WINDOW_DAYS = 30
const DAY_MS = 86_400_000
export type EmailKind = Notification["kind"]
/** One kind of notification, as a switch card offers it. */
export type EmailCategory = {
kind: EmailKind
label: string
description: string
/** How many the inbox took in over the last `WINDOW_DAYS` days. */
count: number
/** Where the switch starts. */
on: boolean
/** Why the switch cannot move, when it cannot. */
locked?: string
}
/**
* The kinds in the order the cards stand, with what each one is and where its
* switch starts: the product's defaults, since a member's choices are theirs
* and the store keeps none. Security is on and stays on.
*/
const KINDS: { kind: EmailKind; label: string; description: string; on: boolean; locked?: string }[] = [
{ kind: "mention", label: "Mentions", description: "A teammate mentions you in a comment", on: true },
{ kind: "assignment", label: "Assignments", description: "A task lands in your queue", on: true },
{ kind: "billing", label: "Billing", description: "An invoice is ready or a payment needs you", on: true },
{ kind: "system", label: "Maintenance", description: "Scheduled work starts and finishes", on: false },
{
kind: "security",
label: "Security",
description: "A sign-in from a device you have not used",
on: true,
locked: "Always on · a sign-in you did not make cannot wait in the inbox",
},
]
/** The kinds the member may switch, and the one they may not. */
export const LOCKED_KINDS: EmailKind[] = KINDS.filter((entry) => entry.locked).map((entry) => entry.kind)
/** Every kind of notification, with how many arrived in the window. */
export function emailCategories(): EmailCategory[] {
const from = REFERENCE_DATE.getTime() - WINDOW_DAYS * DAY_MS
const recent = db.notifications.all().filter((entry) => entry.at.getTime() > from && entry.at.getTime() <= REFERENCE_DATE.getTime())
return KINDS.map((entry) => ({
...entry,
count: recent.filter((notification) => notification.kind === entry.kind).length,
}))
}
/** The longest name a kind could have: anything longer is not one. */
const KIND_LENGTH = 32
/**
* Saves which kinds reach the member's email. It takes a list of kinds' names
* and nothing else — no more names than there are kinds, none longer than a
* kind's — every one must be a kind the store knows, and a locked kind cannot
* be left out: the server decides that, whatever the browser sent. Answers
* with the kinds that are on, in the cards' order.
*/
export async function saveEmailPreferences(on: unknown): Promise<Result<{ on: EmailKind[] }>> {
if (!Array.isArray(on) || on.length > KINDS.length || !on.every((kind) => typeof kind === "string" && kind.length <= KIND_LENGTH))
return { ok: false, error: { code: "invalid", message: "Say which kinds of email to send, by name." } }
const known = new Set<string>(KINDS.map((entry) => entry.kind))
const unknown = on.filter((kind) => !known.has(kind))
if (unknown.length > 0) return { ok: false, error: { code: "unknown_kind", message: `There is no ${unknown.join(" or ")} email to send.` } }
const missing = LOCKED_KINDS.filter((kind) => !on.includes(kind))
if (missing.length > 0) {
const names = KINDS.filter((entry) => missing.includes(entry.kind)).map((entry) => entry.label)
return { ok: false, error: { code: "locked", message: `${names.join(" and ")} emails stay on: they tell you about sign-ins you did not make.` } }
}
return { ok: true, data: { on: KINDS.map((entry) => entry.kind).filter((kind) => on.includes(kind)) } }
}$
npx shadcn@latest add @vibra/switch-cards-02Policy switch that asks before it turns off
switch-cards-03The workspace's two-factor requirement as one switch card that confirms in a dialog before turning off, beside who is covered today and who is not.
import * as React from "react"
import { getInitials } from "@/lib/format"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { UsageMeter } from "@/components/ui/usage-meter"
import { TwoFactorSwitch } from "./switch-cards-03.client"
import { setTwoFactorRequired, twoFactorCoverage, type Result } from "./switch-cards-03.data"
const ROLE: Record<string, string> = { owner: "Owner", admin: "Admin", member: "Member", viewer: "Viewer" }
export type SwitchCards03Props = {
/** Opens the question the switch asks before it turns off — for a preview; false when installed. */
defaultOpen?: boolean
}
/**
* A workspace's two-factor requirement as one switch card that asks before it
* turns off: the question names how many active members have not set it up
* and what changes for them. Beside the card, who is covered today — a meter
* of the active members with two-factor, and the faces of those without it,
* with when each last signed in.
*
* A server component. It counts the members through `./switch-cards-03.data`,
* draws the coverage, and hands the island `setRequired`, a server function
* defined here, which answers with a `Result`.
*/
export function SwitchCards03({ defaultOpen = false }: SwitchCards03Props) {
const titleId = React.useId()
const listId = React.useId()
const { active, enrolled, unenrolled } = twoFactorCoverage()
async function setRequired(required: boolean): Promise<Result<{ required: boolean; unenrolled: number }>> {
"use server"
return setTwoFactorRequired(required)
}
const coverage = (
<div className="grid gap-5 border-t border-border pt-5 md:grid-cols-[16rem_minmax(0,1fr)] md:gap-8">
<UsageMeter label="Set up two-factor" used={enrolled} limit={active} unit="members" tone="default" className="self-start" />
{unenrolled.length > 0 ? (
<div className="flex min-w-0 flex-col gap-3">
<h3 id={listId} className="text-xs font-medium text-muted-foreground">
Not set up yet · {unenrolled.length} of {active}
</h3>
<ul aria-labelledby={listId} className="grid gap-x-6 gap-y-3 sm:grid-cols-2 xl:grid-cols-3">
{unenrolled.map((person) => (
<li key={person.id} className="flex min-w-0 items-center gap-3">
<Avatar size="sm">
<AvatarImage src={person.face} alt="" />
<AvatarFallback className="bg-foreground text-2xs text-background">{getInitials(person.name)}</AvatarFallback>
</Avatar>
<div className="flex min-w-0 flex-col">
<span className="truncate text-sm font-medium">{person.name}</span>
<span className="truncate text-xs text-muted-foreground tabular-nums">
{ROLE[person.role]} · last signed in {person.lastSeen}
</span>
</div>
</li>
))}
</ul>
</div>
) : null}
</div>
)
return (
<section
data-section="switch-cards-03"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background p-4 text-foreground md:p-6"
>
<div className="mx-auto max-w-7xl">
<Card>
<CardHeader>
<CardTitle>
<h2 id={titleId}>Sign-in security</h2>
</CardTitle>
<CardDescription>A workspace policy · it applies to all {active} active members</CardDescription>
</CardHeader>
<TwoFactorSwitch active={active} enrolled={enrolled} coverage={coverage} defaultOpen={defaultOpen} onSet={setRequired} />
</Card>
</div>
</section>
)
}
export default SwitchCards03"use client"
import * as React from "react"
import { ShieldCheckIcon } from "lucide-react"
import { Callout } from "@/components/ui/callout"
import { CardContent, CardFooter } from "@/components/ui/card"
import { ConfirmDialog } from "@/components/ui/confirm-dialog"
import { Field, FieldContent, FieldDescription, FieldLabel, FieldTitle } from "@/components/ui/field"
import { Spinner } from "@/components/ui/spinner"
import { Switch } from "@/components/ui/switch"
import type { Result } from "./switch-cards-03.data"
/** Turns the requirement on or off: the section's server action. */
export type SetTwoFactor = (required: boolean) => Promise<Result<{ required: boolean; unenrolled: number }>>
const people = (count: number) => `${count} ${count === 1 ? "member" : "members"}`
/**
* The two-factor requirement as one switch card. Turning it on is safe and
* saves at once; turning it off weakens every account that has not set
* two-factor up, so it asks first, in a dialog that says who that is and what
* changes — and the safe answer holds the focus. Either answer hands the
* focus back to the switch. While a change saves, the switch holds still and
* says it is busy; a refusal says why under the card, a save is confirmed in
* the footer's status line.
*/
export function TwoFactorSwitch({
active,
enrolled,
coverage,
defaultRequired = true,
defaultOpen = false,
onSet,
}: {
active: number
enrolled: number
/** The coverage panel: who has set it up and who has not, drawn by the section. */
coverage: React.ReactNode
defaultRequired?: boolean
/** Opens the question on the first render, as if the switch had just been pressed off — for a preview. */
defaultOpen?: boolean
onSet: SetTwoFactor
}) {
const id = React.useId()
const part = (name: string) => `${id}-${name}`
const control = React.useRef<HTMLElement>(null)
const [required, setRequired] = React.useState(defaultRequired)
const [asking, setAsking] = React.useState(defaultOpen && defaultRequired)
const [saving, setSaving] = React.useState(false)
const [refusal, setRefusal] = React.useState<string | null>(null)
const [status, setStatus] = React.useState("")
const unenrolled = active - enrolled
const line = required
? unenrolled === 0
? `On · all ${people(active)} use it`
: `On · ${enrolled} of ${active} active members use it; ${people(unenrolled)} ${unenrolled === 1 ? "is" : "are"} asked at their next sign-in`
: unenrolled === 0
? `Off · members choose; all ${active} use it today`
: `Off · members choose; ${people(unenrolled)} sign in with a password alone`
const cut = line.indexOf(" · ")
async function save(next: boolean) {
setSaving(true)
setRefusal(null)
let result: Awaited<ReturnType<SetTwoFactor>>
try {
result = await onSet(next)
} catch {
result = { ok: false, error: { code: "network", message: "The change was not saved. Check the connection and try again." } }
}
setSaving(false)
if (!result.ok) {
setRefusal(result.error.message)
setStatus("")
return
}
const left = result.data.unenrolled
setRequired(result.data.required)
setStatus(
result.data.required
? `Two-factor is required. ${left === 0 ? "Every active member has set it up." : `${people(left)} ${left === 1 ? "is" : "are"} asked to set it up at their next sign-in.`}`
: `Two-factor is optional now. ${left === 0 ? "Every active member still uses it." : `${people(left)} sign in with a password alone.`}`
)
}
return (
<>
<CardContent className="flex flex-col gap-5">
<div className="flex min-w-0 flex-col gap-3">
<FieldLabel htmlFor={part("switch")}>
<Field orientation="horizontal" className="gap-3">
<span aria-hidden="true" className="flex size-9 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground">
<ShieldCheckIcon className="size-4" />
</span>
<FieldContent className="min-w-0 gap-1">
<FieldTitle id={part("title")}>Require two-factor sign-in</FieldTitle>
<FieldDescription id={part("hint")}>
Everyone signs in with a password and a code from an authenticator app.
</FieldDescription>
<p id={part("line")} className="text-sm font-normal text-pretty text-foreground tabular-nums">
{saving ? <Spinner aria-hidden="true" role={undefined} aria-label={undefined} className="me-1.5 inline size-3.5 align-[-0.125em]" /> : null}
<span className="font-medium">{line.slice(0, cut)}</span>
{line.slice(cut)}
</p>
</FieldContent>
<Switch
ref={control}
id={part("switch")}
checked={required}
readOnly={saving}
aria-busy={saving || undefined}
aria-labelledby={part("title")}
aria-describedby={`${part("hint")} ${part("line")}`}
onCheckedChange={(next) => (next ? void save(true) : setAsking(true))}
className="mt-0.5"
/>
</Field>
</FieldLabel>
{refusal ? (
<Callout variant="danger" role="alert" title="The requirement was not changed">
{refusal}
</Callout>
) : null}
</div>
{coverage}
</CardContent>
<CardFooter className="gap-0 text-sm">
<p role="status" aria-live="polite" className="min-h-5 text-foreground">
{status}
</p>
{status ? null : <p className="text-muted-foreground">A change applies at each member’s next sign-in.</p>}
</CardFooter>
<ConfirmDialog
open={asking}
onOpenChange={setAsking}
finalFocus={control}
title="Stop requiring two-factor sign-in?"
description={
unenrolled === 0
? `All ${active} active members have set it up, but anyone who joins next can skip it.`
: `${unenrolled} of the ${active} active members ${unenrolled === 1 ? "has" : "have"} not set it up. Once it is off, they keep signing in with a password alone, and anyone who joins next can skip it too.`
}
cancelText="Keep it required"
confirmText="Stop requiring it"
variant="destructive"
onConfirm={() => save(false)}
/>
</>
)
}/**
* What `switch-cards-03` reads and the one thing it changes: who in
* `db.members` can sign in today — the active members, and which of them have
* set up two-factor — and `setTwoFactorRequired`, the section's server action
* behind the switch.
*
* Read inside functions, on the server; dates are written here in UTC, so
* the server and the browser print the same day. The island takes the rows as
* props and never imports this module.
*/
import { avatarFor } from "@/lib/avatars"
import { db, type Member, type Result } from "@/lib/sample-data"
export type { Result }
const LAST_SEEN = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: "UTC" })
/** An active member who has not set up two-factor yet. */
export type Unenrolled = {
id: string
name: string
role: Member["role"]
face: string
/** When they last signed in, as a reader says it: "Sep 2". */
lastSeen: string
}
export type TwoFactorCoverage = {
/** Members who can sign in: invited and deactivated people cannot. */
active: number
/** Of those, the ones with two-factor set up. */
enrolled: number
/** The rest, the most recently seen first. */
unenrolled: Unenrolled[]
}
/** Who two-factor covers today, and who it does not. */
export function twoFactorCoverage(): TwoFactorCoverage {
const active = db.members.all().filter((member) => member.status === "active")
const unenrolled = active
.filter((member) => !member.twoFactor)
.sort((a, b) => b.lastActiveAt.getTime() - a.lastActiveAt.getTime())
.map((member) => ({
id: member.id,
name: member.name,
role: member.role,
face: avatarFor(member.name),
lastSeen: LAST_SEEN.format(member.lastActiveAt),
}))
return { active: active.length, enrolled: active.length - unenrolled.length, unenrolled }
}
/**
* Turns the workspace's two-factor requirement on or off. The count it
* answers with is re-read from the store, never taken from the caller: how
* many active members still sign in with a password alone.
*/
export async function setTwoFactorRequired(required: unknown): Promise<Result<{ required: boolean; unenrolled: number }>> {
if (typeof required !== "boolean")
return { ok: false, error: { code: "invalid", message: "Say whether two-factor is required: on or off." } }
return { ok: true, data: { required, unenrolled: twoFactorCoverage().unenrolled.length } }
}$
npx shadcn@latest add @vibra/switch-cards-03