Onboarding
The first minutes in a product: a welcome, a checklist and the first steps.
Welcome with the one next step
onboarding-01A new member greeted by name and face, the team and role they joined, and the one step worth doing next — with the link that does it and a way to set it aside.
import * as React from "react"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { NextStepPanel } from "./onboarding-01.client"
import { WORKSPACE_NAME, welcome } from "./onboarding-01.data"
/**
* The first thing a new member sees: one framed card that greets them by
* name, says what they walked into — the team, their role, what is already
* assigned to them — and puts the one step worth doing next beside it, with
* the link that does it. The steps behind that one wait their turn.
*
* A server component: it reads the person and their steps through
* `./onboarding-01.data` and hands the steps to the island, which only keeps
* track of which ones were set aside.
*/
export function Onboarding01() {
const titleId = React.useId()
const person = welcome()
return (
<section
data-section="onboarding-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}>
Welcome to {WORKSPACE_NAME}, {person.firstName}
</h2>
</CardTitle>
<CardDescription>The team you joined, and the one thing worth doing next.</CardDescription>
</CardHeader>
<CardContent className="grid p-0 md:grid-cols-[minmax(0,5fr)_minmax(0,7fr)]">
<div className="flex flex-col gap-6 border-b p-(--card-spacing) md:border-e md:border-b-0 md:p-6">
<div className="flex items-center gap-3">
<Avatar className="size-12">
<AvatarImage src={person.face} alt="" />
<AvatarFallback>{person.initials}</AvatarFallback>
</Avatar>
<div className="flex min-w-0 flex-col">
<p className="truncate text-sm font-semibold">{person.name}</p>
<p className="text-sm text-muted-foreground">
{person.role} since {person.joinedOn}
</p>
</div>
</div>
<dl className="flex flex-col divide-y">
{person.facts.map((fact) => (
<div
key={fact.label}
className="grid gap-1 py-3 first:pt-0 last:pb-0 sm:grid-cols-[8rem_minmax(0,1fr)] sm:gap-3"
>
<dt className="text-sm text-muted-foreground">{fact.label}</dt>
<dd className="flex flex-col gap-0.5">
<span className="text-sm font-medium tabular-nums">{fact.value}</span>
<span className="text-xs text-pretty text-muted-foreground">{fact.note}</span>
</dd>
</div>
))}
</dl>
</div>
<NextStepPanel steps={person.steps} />
</CardContent>
</Card>
</div>
</section>
)
}
export default Onboarding01"use client"
import * as React from "react"
import Link from "next/link"
import {
ArrowRightIcon,
CircleCheckIcon,
FolderKanbanIcon,
ListTodoIcon,
ShieldCheckIcon,
type LucideIcon,
} from "lucide-react"
import { Button, buttonVariants } from "@/components/ui/button"
import type { NextStep, NextStepIcon } from "./onboarding-01.data"
/** Icon vocabulary: a step names its icon, this file draws it. */
const ICONS: Record<NextStepIcon, LucideIcon> = {
shield: ShieldCheckIcon,
tasks: ListTodoIcon,
projects: FolderKanbanIcon,
}
/**
* One step at a time: the first one still open, why it is worth doing now and
* the link that goes and does it. "Not now" sets it aside for this visit and
* brings up the next; when every step has been set aside the panel says so
* and offers them back. What changed is said in an always-mounted status
* line, so a reader who is not looking at the panel hears which step came up.
*/
export function NextStepPanel({ steps }: { steps: NextStep[] }) {
const headingId = React.useId()
const [skipped, setSkipped] = React.useState<string[]>([])
const [said, setSaid] = React.useState("")
const restoreRef = React.useRef<HTMLButtonElement>(null)
const skipRef = React.useRef<HTMLButtonElement>(null)
const moved = React.useRef(false)
const remaining = steps.filter((step) => !skipped.includes(step.id))
const current = remaining[0]
const after = remaining[1]
// Setting the last step aside takes the "Not now" button away, and the
// focus with it; it lands on the button that brings the steps back. The
// steps coming back put it on "Not now" again.
React.useEffect(() => {
if (!moved.current) return
moved.current = false
;(current ? skipRef.current : restoreRef.current)?.focus()
}, [current])
function skip() {
if (!current) return
moved.current = !after
setSkipped((list) => [...list, current.id])
setSaid(after ? `Set aside for now. Next: ${after.title}.` : "Set aside for now. That was the last step.")
}
function restore() {
if (!steps.length) return
moved.current = true
setSkipped([])
setSaid(`The steps are back. First: ${steps[0].title}.`)
}
// Nothing open at all — a second factor on and nothing assigned yet — is its
// own state, not a list with every step set aside: there is nothing to bring back.
if (!steps.length)
return (
<div data-slot="next-step" className="flex flex-col gap-5 p-(--card-spacing) md:p-6">
<p className="type-eyebrow">Your next step</p>
<div className="flex items-start gap-4">
<span
aria-hidden="true"
className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-surface text-foreground ring-1 ring-border"
>
<CircleCheckIcon className="size-5" />
</span>
<div className="flex min-w-0 flex-col gap-1.5">
<h3 className="text-base leading-6 font-semibold text-balance">Nothing needs you right now</h3>
<p className="max-w-prose text-sm text-pretty text-muted-foreground">
Your second factor is on and nothing is assigned to you yet. New steps show up here as work comes your
way.
</p>
</div>
</div>
</div>
)
const Icon = current ? ICONS[current.icon] : CircleCheckIcon
return (
<div data-slot="next-step" className="flex flex-col gap-5 p-(--card-spacing) md:p-6">
<div className="flex items-baseline justify-between gap-3">
<p className="type-eyebrow">Your next step</p>
{current ? (
<p className="text-xs text-muted-foreground tabular-nums">
{steps.indexOf(current) + 1} of {steps.length}
</p>
) : null}
</div>
<div className="flex items-start gap-4">
<span
aria-hidden="true"
className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-surface text-foreground ring-1 ring-border"
>
<Icon className="size-5" />
</span>
<div className="flex min-w-0 flex-col gap-1.5">
<h3 id={headingId} className="text-base leading-6 font-semibold text-balance">
{current ? current.title : "Nothing else needs you right now"}
</h3>
<p className="max-w-prose text-sm text-pretty text-muted-foreground">
{current
? current.why
: `You set ${steps.length === 1 ? "the step" : `all ${steps.length} steps`} aside for this visit. They stay in Settings whenever you want them.`}
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-2 sm:ps-14">
{current ? (
<>
<Link href={current.href} aria-describedby={headingId} className={buttonVariants()}>
{current.action}
<ArrowRightIcon data-icon="inline-end" aria-hidden="true" />
</Link>
<Button ref={skipRef} type="button" variant="ghost" onClick={skip}>
Not now
</Button>
</>
) : (
<Button ref={restoreRef} type="button" variant="outline" onClick={restore}>
Show the steps again
</Button>
)}
</div>
{current && after ? (
<p className="border-t pt-4 text-xs text-muted-foreground sm:ps-14">
After this: <span className="text-foreground">{after.title}</span>
</p>
) : null}
<p role="status" aria-live="polite" className="sr-only">
{said}
</p>
</div>
)
}/**
* What `onboarding-01` reads: the person the welcome is for — the workspace's
* newest active member — what the workspace already holds for them, and the
* steps still open to them, each one a question asked of `db`. A step is on
* the list because the rows say it is undone: two-factor while their member
* row has it off, their tasks while any assigned to them are open, their
* projects while those tasks belong to some.
*
* Selectors only, reading `db` inside functions on the server. The island
* takes the steps as plain props and never imports this module.
*/
import { getInitials } from "@/lib/format"
import { db, type Member } from "@/lib/sample-data"
/** The workspace the welcome is written for. */
export const WORKSPACE_NAME = "Northwind Analytics"
/** The icon a step is drawn with: vocabulary the island resolves, never a component here. */
export type NextStepIcon = "shield" | "tasks" | "projects"
/** One thing worth doing next, with where it is done. */
export type NextStep = {
id: string
icon: NextStepIcon
title: string
/** Why it is worth doing now, read off the rows. */
why: string
/** The words on the link that goes and does it. */
action: string
href: string
}
/** A figure beside the welcome: what it counts, and what it means for this person. */
export type WelcomeFact = { label: string; value: string; note: string }
export type Welcome = {
name: string
firstName: string
initials: string
face?: string
/** The role as the workspace names it: "Member". */
role: string
joinedOn: string
facts: WelcomeFact[]
steps: NextStep[]
}
const DAY = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" })
const SHORT_DAY = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: "UTC" })
const plural = (count: number, one: string, many: string) => `${count} ${count === 1 ? one : many}`
/** Whose first day it is: the active member who joined most recently. The owner founded the place, so never them. */
export function newcomer(): Member {
const active = db.members.all().filter((member) => member.status === "active" && member.role !== "owner")
const newest = [...active].sort((a, b) => b.joinedAt.getTime() - a.joinedAt.getTime())[0]
if (!newest) throw new Error("onboarding-01 welcomes the newest active member, and the workspace has none")
return newest
}
/** "Farid Meyer and Iris Costa", "A, B and 2 more": a short list of names in a sentence. */
function nameList(names: string[]): string {
if (names.length <= 2) return names.join(" and ")
return `${names.slice(0, 2).join(", ")} and ${plural(names.length - 2, "other", "others")}`
}
/**
* The welcome for the newest member: who they are in the workspace, three
* facts about the team they joined, and every step still open to them, in
* the order worth doing them.
*/
export function welcome(): Welcome {
const person = newcomer()
const members = db.members.all()
const active = members.filter((member) => member.status === "active")
const teammates = active.filter((member) => member.id !== person.id)
const admins = teammates.filter((member) => member.role === "admin" || member.role === "owner")
const role = db.roles.all().find((entry) => entry.name.toLowerCase() === person.role)
const open = db.tasks
.all()
.filter((task) => task.assignee === person.id && task.status !== "done")
.sort((a, b) => a.dueAt.getTime() - b.dueAt.getTime())
const projects = db.projects.all().filter((project) => open.some((task) => task.projectId === project.id))
const protectedCount = teammates.filter((member) => member.twoFactor).length
const steps: NextStep[] = []
if (!person.twoFactor)
steps.push({
id: "two-factor",
icon: "shield",
title: "Turn on two-factor authentication",
why: `${protectedCount} of your ${plural(teammates.length, "teammate", "teammates")} already sign in with a second factor. It takes a minute with an authenticator app.`,
action: "Turn it on",
href: "/settings/security",
})
if (open.length > 0)
steps.push({
id: "tasks",
icon: "tasks",
title: `Pick up the ${plural(open.length, "task", "tasks")} assigned to you`,
why: `The first is due ${SHORT_DAY.format(open[0].dueAt)}: “${open[0].title}”.`,
action: "Open my tasks",
href: "/projects/tasks",
})
if (projects.length > 0)
steps.push({
id: "projects",
icon: "projects",
title: `Meet the ${plural(projects.length, "project", "projects")} your work belongs to`,
why: `${nameList(projects.map((project) => project.name))}: each has its timeline, its files and the people on it.`,
action: "See my projects",
href: "/projects",
})
return {
name: person.name,
firstName: person.name.split(" ")[0],
initials: getInitials(person.name),
face: person.avatarUrl,
role: role?.name ?? person.role,
joinedOn: DAY.format(person.joinedAt),
facts: [
{
label: "Your team",
value: plural(teammates.length, "person", "people"),
note: `${plural(admins.length, "admin looks", "admins look")} after the workspace settings.`,
},
{
label: "Your role",
value: role?.name ?? person.role,
note: role?.description ?? "",
},
{
label: "Assigned to you",
value: plural(open.length, "open task", "open tasks"),
note: projects.length
? `Across ${plural(projects.length, "project", "projects")}.`
: "Nothing is waiting on you yet.",
},
],
steps,
}
}npx shadcn@latest add @vibra/onboarding-01Setup checklist with real progress
onboarding-02Six checks an owner runs before the company is let in, each answered by the store; three finish from the card through a server function, the rest link to their page.
import * as React from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { SetupChecklist } from "./onboarding-02.client"
import { WORKSPACE_NAME, runStep, setupSteps, type StepId } from "./onboarding-02.data"
/**
* The owner's checklist before the rest of the company is let in: six checks
* against the workspace as it stands, in one framed card. Each check's circle
* is filled by the store, never by a click on it; three can be finished from
* the card, and the other three open the page where they are done.
*
* A server component. It reads the checks through `./onboarding-02.data` and
* hands the island `run`, a server function defined here: it does one step
* against the rows it re-reads, and answers with the list read again.
*/
export function Onboarding02() {
const titleId = React.useId()
async function run(id: StepId) {
"use server"
return runStep(id)
}
return (
<section
data-section="onboarding-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}>Get {WORKSPACE_NAME} ready for everyone</h2>
</CardTitle>
<CardDescription>
Six checks against the workspace as it stands. A step with a button can be finished from here.
</CardDescription>
</CardHeader>
<CardContent className="md:p-6">
<SetupChecklist initial={setupSteps()} run={run} />
</CardContent>
</Card>
</div>
</section>
)
}
export default Onboarding02"use client"
import * as React from "react"
import Link from "next/link"
import { CircleCheckIcon } from "lucide-react"
import { AsyncButton } from "@/components/ui/async-button"
import { buttonVariants } from "@/components/ui/button"
import { Callout } from "@/components/ui/callout"
import { OnboardingChecklist } from "@/components/ui/onboarding-checklist"
import type { Result, SetupStep, StepId } from "./onboarding-02.data"
export type RunStep = (id: StepId) => Promise<Result<{ message: string; steps: SetupStep[] }>>
/**
* The checklist, with nothing to tick by hand: a step's circle fills when the
* store says it is done. A step that can be done from here carries a button
* that runs it on the server; the answer brings the whole list back read
* again, so the bar only moves when the rows did. One that has to be done on
* its own page carries a link there. What a run did is said in the status
* line under the list; a refusal opens a danger callout in the same place,
* naming the step, and the two never share a slot.
*/
export function SetupChecklist({ initial, run }: { initial: SetupStep[]; run: RunStep }) {
const [steps, setSteps] = React.useState(initial)
const [said, setSaid] = React.useState("")
const [refusal, setRefusal] = React.useState<{ step: string; message: string } | null>(null)
const listRef = React.useRef<HTMLDivElement>(null)
const landFocus = React.useRef(false)
// A finished step loses its button, and the focus with it: it goes on to the
// next thing there is to do, or to the list once nothing is left.
React.useEffect(() => {
if (!landFocus.current) return
landFocus.current = false
const next = listRef.current?.querySelector<HTMLElement>(
"[data-slot=onboarding-checklist-action] :is(a, button, [role=link])"
)
;(next ?? listRef.current)?.focus()
}, [steps])
async function complete(step: SetupStep) {
setRefusal(null)
// The AsyncButton frees itself whatever happens; a call that never reached
// the server is this card's to report, in the same callout as a refusal.
let result: Awaited<ReturnType<RunStep>>
try {
result = await run(step.id)
} catch {
result = { ok: false, error: { code: "network", message: "The step did not reach the server. Try again." } }
}
if (!result.ok) {
setSaid("")
setRefusal({ step: step.title, message: result.error.message })
return
}
const passing = result.data.steps.filter((entry) => entry.done).length
landFocus.current = true
setSteps(result.data.steps)
setSaid(`${result.data.message} ${passing} of ${result.data.steps.length} checks pass.`)
}
function action(step: SetupStep) {
if (!step.action) return undefined
// The label alone ("Resend it") says what, not which step: the step's title
// rides along for a screen reader.
const which = <span className="sr-only">: {step.title}</span>
if (step.action.kind === "link")
return (
<Link href={step.action.href} className={buttonVariants({ variant: "outline", size: "sm" })}>
{step.action.label}
{which}
</Link>
)
return (
<AsyncButton type="button" variant="outline" size="sm" onClick={() => complete(step)}>
{step.action.label}
{which}
</AsyncButton>
)
}
return (
<div className="flex flex-col gap-4">
<div ref={listRef} role="group" tabIndex={-1} aria-label="Setup steps" className="rounded-md outline-none">
<OnboardingChecklist
title="Ready for everyone"
className="gap-4 rounded-none border-0 p-0"
steps={steps.map((step) => ({
id: step.id,
title: step.title,
description: step.status,
done: step.done,
action: action(step),
}))}
/>
</div>
{refusal ? (
<Callout variant="danger" role="alert" title={`Couldn’t finish “${refusal.step}”`}>
{refusal.message}
</Callout>
) : null}
<p role="status" aria-live="polite" className="flex min-h-5 items-start gap-2 text-sm">
{said ? (
<>
<CircleCheckIcon aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-success" />
{said}
</>
) : null}
</p>
</div>
)
}/**
* What `onboarding-02` reads and the three things it can do: six checks the
* workspace owner runs before everyone else is let in, each answered by the
* rows as they stand — whether the team has joined, whether an invitation has
* been left unanswered or has lapsed, whether an API key was made and never
* used, and how many people sign in with a second factor. Nothing on the list
* is ticked by hand: a step is done when `db` says so, and `runStep` is what
* changes `db` for the three that can be done from here.
*
* Selectors and the one write, reading `db` inside functions on the server.
* The island takes the steps as plain props and calls the server function its
* section hands it; it never imports this module.
*/
import { REFERENCE_DATE, db, type Result } from "@/lib/sample-data"
export type { Result }
/** The workspace the checks are run against. */
export const WORKSPACE_NAME = "Northwind Analytics"
/** How long an invitation may sit unanswered before it is worth sending again, in days. */
export const STALE_DAYS = 3
/** How long a resent invitation stays good for, in days. */
export const RESENT_FOR_DAYS = 14
const DAY_MS = 86_400_000
const DAY = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" })
export type StepId = "workspace" | "team" | "unanswered" | "expired" | "unused-keys" | "two-factor"
/** The steps `runStep` can finish from the card: the rest are done where their work is. */
export const RUNNABLE: readonly StepId[] = ["unanswered", "expired", "unused-keys"]
/** What finishes a step: a press here, or a link to the page where it is done. */
export type StepAction = { kind: "run"; label: string } | { kind: "link"; label: string; href: string }
export type SetupStep = {
id: StepId
title: string
/** Where the workspace stands on it, in a sentence read off the rows. */
status: string
done: boolean
/** Only while the step is undone. */
action?: StepAction
}
const plural = (count: number, one: string, many: string) => `${count} ${count === 1 ? one : many}`
/** "A and B", "A, B and C". */
function listed(items: string[]): string {
return items.length <= 1 ? items.join("") : `${items.slice(0, -1).join(", ")} and ${items.at(-1)}`
}
/** The invitations still open that went out more than `STALE_DAYS` ago. */
function unanswered() {
const cutoff = REFERENCE_DATE.getTime() - STALE_DAYS * DAY_MS
return db.invitations
.all()
.filter((invitation) => invitation.status === "pending" && invitation.sentAt.getTime() < cutoff)
}
const expired = () => db.invitations.all().filter((invitation) => invitation.status === "expired")
const unusedKeys = () => db.apiKeys.all().filter((key) => key.status === "active" && !key.lastUsedAt)
/** The six checks, in the order an owner runs them, each answered by the rows as they are now. */
export function setupSteps(): SetupStep[] {
const members = db.members.all()
const owner = members.find((member) => member.role === "owner")
const active = members.filter((member) => member.status === "active")
const open = db.invitations.all().filter((invitation) => invitation.status === "pending")
const waiting = unanswered()
const lapsed = expired()
const keys = unusedKeys()
const protectedCount = active.filter((member) => member.twoFactor).length
const waited = (sentAt: Date) => Math.floor((REFERENCE_DATE.getTime() - sentAt.getTime()) / DAY_MS)
return [
{
id: "workspace",
title: "Create the workspace",
status: owner
? `${WORKSPACE_NAME}, set up by ${owner.name} on ${DAY.format(owner.joinedAt)}.`
: `${WORKSPACE_NAME} has no owner.`,
done: Boolean(owner),
},
{
id: "team",
title: "Invite your team",
status: `${plural(active.length, "person is", "people are")} in the workspace; ${plural(open.length, "invitation is", "invitations are")} still open.`,
done: active.length > 1,
...(active.length > 1 ? {} : { action: { kind: "link" as const, label: "Invite", href: "/settings/members" } }),
},
{
id: "unanswered",
title: "Nudge the invitations nobody has answered",
status: waiting.length
? `${listed(waiting.map((invitation) => `${invitation.email} has waited ${plural(waited(invitation.sentAt), "day", "days")}`))}.`
: `Every open invitation went out in the last ${STALE_DAYS} days.`,
done: waiting.length === 0,
...(waiting.length
? { action: { kind: "run" as const, label: waiting.length === 1 ? "Resend it" : `Resend ${waiting.length}` } }
: {}),
},
{
id: "expired",
title: "Clear out invitations that lapsed",
status: lapsed.length
? `${plural(lapsed.length, "invitation", "invitations")} ran out before anyone answered.`
: "No invitation has lapsed.",
done: lapsed.length === 0,
...(lapsed.length ? { action: { kind: "run" as const, label: `Remove ${lapsed.length}` } } : {}),
},
{
id: "unused-keys",
title: "Revoke the API keys nobody has used",
status: keys.length
? `${listed(keys.map((key) => `“${key.name}”`))} ${keys.length === 1 ? "was" : "were"} made and never used.`
: "Every active key has been used.",
done: keys.length === 0,
...(keys.length ? { action: { kind: "run" as const, label: `Revoke ${keys.length}` } } : {}),
},
{
id: "two-factor",
title: "Have everyone sign in with two-factor",
status: `${protectedCount} of ${plural(active.length, "person has", "people have")} a second factor on.`,
done: protectedCount === active.length,
...(protectedCount === active.length
? {}
: { action: { kind: "link" as const, label: "Require it", href: "/settings/security" } }),
},
]
}
const refuse = (code: string, message: string): Result<never> => ({ ok: false, error: { code, message } })
/**
* Does one step from the card, re-reading the rows it acts on rather than
* trusting the list the reader was shown: an invitation someone else resent,
* or a key someone used in the meantime, is left alone. Answers with what it
* did and the whole list read again, so the progress the reader sees is the
* store's. The id arrives as the page sent it, so anything that is not one of
* the three runnable ids — whatever its shape — is refused.
*/
export async function runStep(id: unknown): Promise<Result<{ message: string; steps: SetupStep[] }>> {
if (typeof id !== "string" || !RUNNABLE.includes(id as StepId))
return refuse("not_here", "That step is done on its own page, not from this card.")
if (id === "unanswered") {
const waiting = unanswered()
if (!waiting.length)
return refuse(
"nothing_to_do",
`Nothing to resend: every open invitation went out in the last ${STALE_DAYS} days.`
)
for (const invitation of waiting) {
const saved = await db.invitations.update(invitation.id, {
sentAt: REFERENCE_DATE,
expiresAt: new Date(REFERENCE_DATE.getTime() + RESENT_FOR_DAYS * DAY_MS),
})
if (!saved.ok) return saved
}
return {
ok: true,
data: {
message: `Resent ${plural(waiting.length, "invitation", "invitations")}, good for ${RESENT_FOR_DAYS} more days.`,
steps: setupSteps(),
},
}
}
if (id === "expired") {
const lapsed = expired()
if (!lapsed.length) return refuse("nothing_to_do", "Nothing to remove: no invitation has lapsed.")
for (const invitation of lapsed) {
const saved = await db.invitations.update(invitation.id, { status: "revoked" })
if (!saved.ok) return saved
}
return {
ok: true,
data: {
message: `Removed ${plural(lapsed.length, "lapsed invitation", "lapsed invitations")}.`,
steps: setupSteps(),
},
}
}
const keys = unusedKeys()
if (!keys.length) return refuse("nothing_to_do", "Nothing to revoke: every active key has been used.")
for (const key of keys) {
const saved = await db.apiKeys.update(key.id, { status: "revoked" })
if (!saved.ok) return saved
}
return {
ok: true,
data: { message: `Revoked ${plural(keys.length, "key", "keys")} nobody had used.`, steps: setupSteps() },
}
}npx shadcn@latest add @vibra/onboarding-02Workspace setup stepper
onboarding-03A new workspace in three steps — a name with the address that follows it, invitations with a role each, a review — where Back never loses a word and a taken address is caught before the review.
import * as React from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { WorkspaceSetup } from "./onboarding-03.client"
import {
APP_HOST,
MAX_INVITES,
checkAddress,
createWorkspace,
inviteRoles,
type NewWorkspace,
} from "./onboarding-03.data"
/**
* A new workspace in three steps, in one framed card: its name and the
* address that follows the name, the people to invite with a role each, and
* a review before anything is created. The stepper sits across the top of
* the sheet; the step under it is a form of its own.
*
* A server component. It reads the roles through `./onboarding-03.data`,
* draws what each one can do, and hands the island two server functions
* defined here: `check`, which asks whether an address is free, and `create`,
* which checks everything again before it answers.
*/
export function Onboarding03() {
const titleId = React.useId()
const roles = inviteRoles()
async function check(slug: string) {
"use server"
return checkAddress(slug)
}
async function create(input: NewWorkspace) {
"use server"
return createWorkspace(input)
}
return (
<section
data-section="onboarding-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">
{/* A wizard reads at a wizard's width: the card holds to one comfortable
column and sits in the middle of the band. */}
<Card className="mx-auto max-w-3xl">
<CardHeader>
<CardTitle>
<h2 id={titleId}>Set up a workspace</h2>
</CardTitle>
<CardDescription>Three steps. Everything you type stays put when you go back.</CardDescription>
</CardHeader>
<CardContent className="p-0">
<WorkspaceSetup
host={APP_HOST}
roles={roles}
maxInvites={MAX_INVITES}
check={check}
create={create}
roleGuide={
<dl className="grid max-w-2xl gap-3 border-t pt-4 text-xs sm:grid-cols-3">
{roles.map((role) => (
<div key={role.value} className="flex flex-col gap-0.5">
<dt className="font-medium">{role.label}</dt>
<dd className="text-pretty text-muted-foreground">{role.description}</dd>
</div>
))}
</dl>
}
/>
</CardContent>
</Card>
</div>
</section>
)
}
export default Onboarding03"use client"
import * as React from "react"
import Link from "next/link"
import { CircleCheckIcon, PlusIcon, XIcon } from "lucide-react"
import { isEmail } from "@/lib/validation"
import { Button, buttonVariants } from "@/components/ui/button"
import { Callout } from "@/components/ui/callout"
import { Field, FieldDescription, FieldError, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { InputGroup, InputGroupAddon, InputGroupInput, InputGroupText } from "@/components/ui/input-group"
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select"
import { Spinner } from "@/components/ui/spinner"
import { Stepper, StepperActions, type Step } from "@/components/ui/stepper"
import type { Created, Invite, InviteRole, NewWorkspace, Result } from "./onboarding-03.data"
const STEPS: Step[] = [
{ id: "name", title: "Name", description: "What to call it" },
{ id: "members", title: "Members", description: "Who will use it", optional: true },
{ id: "review", title: "Review", description: "Check and create" },
]
const HEADINGS = ["Name the workspace", "Invite the people who will use it", "Check it, then create it"]
const SLUG = /^[a-z](?:[a-z0-9]|-(?=[a-z0-9])){2,31}$/
const SLUG_RULE = "Use 3 to 32 lowercase letters, numbers and dashes, starting with a letter."
/** "Northwind Labs" → "northwind-labs": the address a name suggests, until the reader writes their own. */
function suggest(name: string): string {
const slug = name
.normalize("NFD")
.replace(/[̀-ͯ]/g, "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
return slug.replace(/^-+/, "").slice(0, 32).replace(/-+$/, "")
}
/** Which step holds the field a message is about. */
const stepOf = (field: string) => (field === "name" || field === "slug" ? 0 : field.startsWith("invite-") ? 1 : 2)
const plural = (count: number) => `${count} ${count === 1 ? "invitation is" : "invitations are"}`
type Row = Invite & { key: number }
export type WorkspaceSetupProps = {
host: string
roles: InviteRole[]
/** What each role can do, drawn by the section under the invitations. */
roleGuide?: React.ReactNode
/** How many people the list can hold; the server refuses more. */
maxInvites?: number
/** Whether an address can be had; asked when the first step is left. */
check: (slug: string) => Promise<Result<{ slug: string }>>
create: (input: NewWorkspace) => Promise<Result<Created>>
}
/**
* Three steps — the name and its address, the people to invite, a review —
* with everything the reader typed held here, so Back and a press on a
* finished step never lose a word. The step being worked on is the stepper's
* `aria-current="step"`; each move puts the focus on the step's heading and
* says where the reader is in the status line. The address is checked against
* the host when the first step is left, and everything is checked again when
* the workspace is created: a refusal about a field takes the reader back to
* the step that holds it, with the message under the field.
*/
export function WorkspaceSetup({ host, roles, roleGuide, maxInvites = 50, check, create }: WorkspaceSetupProps) {
const uid = React.useId()
const id = (field: string) => `${uid}-${field}`
const [step, setStep] = React.useState(0)
const [name, setName] = React.useState("")
const [ownSlug, setOwnSlug] = React.useState<string | null>(null)
const [rows, setRows] = React.useState<Row[]>([{ key: 0, email: "", role: "member" }])
const [errors, setErrors] = React.useState<Record<string, string>>({})
const [refusal, setRefusal] = React.useState<{ title: string; message: string } | null>(null)
const [pending, setPending] = React.useState(false)
const [created, setCreated] = React.useState<Created | null>(null)
const [said, setSaid] = React.useState("")
const heading = React.useRef<HTMLHeadingElement>(null)
const focusNext = React.useRef<string | null>(null)
const nextKey = React.useRef(1)
const slug = ownSlug ?? suggest(name)
const invites = rows.filter((row) => row.email.trim())
const roleName = (value: Invite["role"]) => roles.find((role) => role.value === value)?.label ?? value
React.useEffect(() => {
const target = focusNext.current
if (!target) return
focusNext.current = null
;(target === "heading" ? heading.current : document.getElementById(id(target)))?.focus()
})
function go(to: number) {
setStep(to)
setRefusal(null)
focusNext.current = "heading"
setSaid(`Step ${to + 1} of ${STEPS.length}: ${STEPS[to].title}.`)
}
/** Shows what was found under the fields it is about and puts the focus on the first; true when anything was. */
function refuse(found: Record<string, string>) {
setErrors(found)
const first = Object.keys(found)[0]
if (first) focusNext.current = first
return Boolean(first)
}
function problems(at: number): Record<string, string> {
const found: Record<string, string> = {}
if (at === 0 && !name.trim()) found.name = "Give the workspace a name."
if (at === 0 && !SLUG.test(slug)) found.slug = SLUG_RULE
const seen = new Set<string>()
if (at === 1)
rows.forEach((row, index) => {
const email = row.email.trim().toLowerCase()
if (email && (email.length > 254 || !isEmail(email)))
found[`invite-${index}`] = "Enter an address like name@example.com."
else if (email && seen.has(email)) found[`invite-${index}`] = "That address is on the list already."
seen.add(email)
})
return found
}
async function next() {
if (pending || refuse(problems(step))) return
setRefusal(null)
// A call that never reaches the server is refused like any other, in the
// callout, and the button is freed whatever happened.
if (step === 0) {
setPending(true)
let checked: Result<{ slug: string }>
try {
checked = await check(slug)
} catch {
const message = "The check did not reach the server. Try again."
return setRefusal({ title: "The address was not checked", message })
} finally {
setPending(false)
}
if (!checked.ok) return refuse({ [checked.error.field ?? "slug"]: checked.error.message })
}
if (step < 2) return go(step + 1)
setPending(true)
let result: Result<Created>
try {
result = await create({ name, slug, invites: rows.map(({ email, role }) => ({ email, role })) })
} catch {
const message = "The request did not reach the server. Try again."
return setRefusal({ title: "The workspace was not created", message })
} finally {
setPending(false)
}
if (result.ok) {
setCreated(result.data)
focusNext.current = "heading"
const count = result.data.invites.length
return setSaid(
`${result.data.name} is ready at ${result.data.url}.${count ? ` ${plural(count)} on the way.` : ""}`
)
}
const field = result.error.field
if (!field) return setRefusal({ title: "The workspace was not created", message: result.error.message })
go(stepOf(field))
refuse({ [field]: result.error.message })
}
function edit(key: number, patch: Partial<Invite>, field?: string) {
setRows((list) => list.map((row) => (row.key === key ? { ...row, ...patch } : row)))
if (field && errors[field])
setErrors((all) => Object.fromEntries(Object.entries(all).filter(([key]) => key !== field)))
}
/** Takes a row off the list; the focus goes to the row that takes its place, or the one before. */
function remove(index: number) {
const gone = rows[index]
const left = rows.length - 1
setRows((list) => list.filter((row) => row.key !== gone.key))
setErrors({})
focusNext.current = `invite-${Math.min(index, left - 1)}`
setSaid(
`Removed ${gone.email.trim() || `person ${index + 1}`}. ${left} ${left === 1 ? "person" : "people"} left on the list.`
)
}
/** What a control needs to be found, marked and described: its id, its state and the words that say why. */
const wire = (field: string, hint = false) => ({
id: id(field),
"aria-invalid": errors[field] ? (true as const) : undefined,
"aria-describedby":
[hint ? id(`${field}-hint`) : "", errors[field] ? id(`${field}-error`) : ""].filter(Boolean).join(" ") ||
undefined,
})
const error = (field: string) =>
errors[field] ? <FieldError id={id(`${field}-error`)}>{errors[field]}</FieldError> : null
const required = <span className="font-normal text-muted-foreground">(required)</span>
const done = created ? (
<div className="flex flex-col items-start gap-4 p-(--card-spacing) md:p-6">
<CircleCheckIcon aria-hidden="true" className="size-6 text-success" />
<h3 ref={heading} tabIndex={-1} className="text-base font-semibold outline-none">
{created.name} is ready
</h3>
<p className="max-w-prose text-sm text-muted-foreground">
It lives at <span className="font-mono text-foreground">{created.url}</span>.{" "}
{created.invites.length
? `${plural(created.invites.length)} on the way; each is good for 14 days.`
: "Invite people from Settings whenever you are ready."}
</p>
<Link href={`/${created.slug}`} className={buttonVariants()}>
Open {created.name}
</Link>
</div>
) : null
return (
<div data-slot="workspace-setup">
{done ?? (
<form
noValidate
onSubmit={(event) => {
event.preventDefault()
void next()
}}
>
<nav aria-label="Workspace setup progress" className="border-b p-(--card-spacing) md:px-6">
<Stepper steps={STEPS} activeStep={step} onStepClick={go} />
</nav>
<div className="flex flex-col gap-5 p-(--card-spacing) md:p-6">
<h3 ref={heading} tabIndex={-1} className="text-sm font-semibold outline-none">
{HEADINGS[step]}
</h3>
{step === 0 ? (
<div className="grid max-w-xl gap-5">
<Field data-invalid={Boolean(errors.name) || undefined}>
<FieldLabel htmlFor={id("name")}>Workspace name {required}</FieldLabel>
<Input
{...wire("name")}
value={name}
required
autoComplete="organization"
placeholder="Northwind Labs"
onChange={(event) => {
setName(event.target.value)
setErrors({})
}}
/>
{error("name")}
</Field>
<Field data-invalid={Boolean(errors.slug) || undefined}>
<FieldLabel htmlFor={id("slug")}>Workspace URL {required}</FieldLabel>
<InputGroup>
<InputGroupAddon>
<InputGroupText className="font-mono">{host}/</InputGroupText>
</InputGroupAddon>
<InputGroupInput
{...wire("slug", true)}
value={slug}
required
spellCheck={false}
autoComplete="off"
className="font-mono"
placeholder="northwind-labs"
onChange={(event) => {
setOwnSlug(event.target.value.toLowerCase())
setErrors({})
}}
/>
</InputGroup>
<FieldDescription id={id("slug-hint")}>
{ownSlug === null
? "Follows the name until you change it."
: "Yours now; the name no longer changes it."}{" "}
{SLUG_RULE}
</FieldDescription>
{error("slug")}
</Field>
</div>
) : null}
{step === 1 ? (
<div className="flex flex-col gap-4">
<ul className="flex max-w-2xl flex-col gap-3">
{rows.map((row, index) => (
<li
key={row.key}
className="grid grid-cols-[minmax(0,1fr)_auto] gap-2 sm:grid-cols-[minmax(0,1fr)_9rem_auto]"
>
<Input
{...wire(`invite-${index}`)}
type="email"
value={row.email}
placeholder="name@example.com"
autoComplete="off"
aria-label={`Email address, person ${index + 1}`}
onChange={(event) => edit(row.key, { email: event.target.value }, `invite-${index}`)}
/>
<NativeSelect
className="order-3 w-full max-sm:col-span-2 sm:order-none"
value={row.role}
aria-label={`Role, person ${index + 1}`}
onChange={(event) => edit(row.key, { role: event.target.value as Invite["role"] })}
>
{roles.map((role) => (
<NativeSelectOption key={role.value} value={role.value}>
{role.label}
</NativeSelectOption>
))}
</NativeSelect>
<Button
type="button"
variant="ghost"
size="icon"
aria-label={`Remove person ${index + 1}`}
disabled={rows.length === 1}
onClick={() => remove(index)}
>
<XIcon aria-hidden="true" />
</Button>
{errors[`invite-${index}`] ? (
<div className="order-4 col-span-full sm:order-none">{error(`invite-${index}`)}</div>
) : null}
</li>
))}
</ul>
{rows.length < maxInvites ? (
<Button
type="button"
variant="outline"
size="sm"
className="self-start"
onClick={() => {
focusNext.current = `invite-${rows.length}`
setRows((list) => [...list, { key: nextKey.current++, email: "", role: "member" }])
}}
>
<PlusIcon aria-hidden="true" data-icon="inline-start" />
Add another person
</Button>
) : (
<p className="text-sm text-muted-foreground">
A new workspace starts with {maxInvites} invitations at most. Invite the rest from Settings.
</p>
)}
{roleGuide}
</div>
) : null}
{step === 2 ? (
<dl className="grid max-w-2xl divide-y text-sm">
{[
["Name", name.trim()],
["Address", <span key="url" className="font-mono">{`${host}/${slug}`}</span>],
[
"Invitations",
invites.length
? invites.map((row) => `${row.email.trim()} (${roleName(row.role)})`).join(", ")
: "Nobody yet: invite people from Settings later.",
],
].map(([term, value]) => (
<div
key={String(term)}
className="grid gap-1 py-3 first:pt-0 sm:grid-cols-[7rem_minmax(0,1fr)] sm:gap-3"
>
<dt className="text-muted-foreground">{term}</dt>
<dd className="break-words">{value}</dd>
</div>
))}
</dl>
) : null}
{refusal ? (
<Callout variant="danger" role="alert" title={refusal.title}>
{refusal.message}
</Callout>
) : null}
<StepperActions className="border-t pt-4" onBack={step > 0 ? () => go(step - 1) : undefined}>
{/* A real submit, so Enter in a field moves on as the button does. */}
<Button type="submit" disabled={pending} focusableWhenDisabled aria-busy={pending || undefined}>
{pending ? <Spinner aria-hidden="true" /> : null}
{step === 2 ? "Create workspace" : step === 1 && !invites.length ? "Skip for now" : "Continue"}
</Button>
</StepperActions>
</div>
</form>
)}
{/* Always mounted, so each move and the creation itself are announced as they happen. */}
<p role="status" aria-live="polite" className="sr-only">
{said}
</p>
</div>
)
}/**
* What `onboarding-03` reads and checks: the roles a new workspace can hand
* out, as `db.roles` describes them, and the addresses already taken on the
* host — every company on the books has its own workspace there, so its slug
* is spoken for. `checkAddress` and `createWorkspace` are what the section's
* server functions call: every rule the island checks as the reader types is
* checked again here, against the input as it arrives, because the island's
* checks are a courtesy and these are the ones that count.
*
* Selectors and checks, reading `db` inside functions on the server. The
* island takes the roles as plain props and never imports this module.
*/
import { isEmail } from "@/lib/validation"
import { db, type Result } from "@/lib/sample-data"
export type { Result }
/** Where every workspace lives: `app.northwind.example/<slug>`. */
export const APP_HOST = "app.northwind.example"
/** A role a new member can be given: the three `db.roles` a workspace invites with. */
export type InviteRole = { value: "admin" | "member" | "viewer"; label: string; description: string }
export type Invite = { email: string; role: InviteRole["value"] }
export type NewWorkspace = { name: string; slug: string; invites: Invite[] }
/** What a created workspace says back: where it lives and who is on their way. */
export type Created = { name: string; slug: string; url: string; invites: Invite[] }
/** Lowercase letters, numbers and single dashes, starting with a letter: 3 to 32 of them. */
const SLUG = /^[a-z](?:[a-z0-9]|-(?=[a-z0-9])){2,31}$/
const INVITABLE: InviteRole["value"][] = ["admin", "member", "viewer"]
/** The most invitations a new workspace starts with; the rest are sent from Settings. */
export const MAX_INVITES = 50
/**
* The first path segments the app keeps for its own pages. A workspace at one
* of them would sit where `app.northwind.example/settings` or `/projects` already is,
* so no workspace can have them.
*/
export const RESERVED_SLUGS: readonly string[] = [
"account",
"admin",
"api",
"billing",
"forgot-password",
"help",
"invitations",
"members",
"new",
"projects",
"settings",
"sign-in",
"sign-out",
"sign-up",
]
/** The longest an address can be (RFC 5321), checked before any pattern sees it. */
const MAX_EMAIL = 254
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value)
/** The roles on offer, in order of reach, each with what `db.roles` says it can do. */
export function inviteRoles(): InviteRole[] {
const roles = db.roles.all()
return INVITABLE.flatMap((value) => {
const role = roles.find((entry) => entry.name.toLowerCase() === value)
return role ? [{ value, label: role.name, description: role.description }] : []
})
}
/** "Blue Harbor Labs" → "blue-harbor-labs": how a company's name becomes its address. */
function slugOf(company: string): string {
return company
.normalize("NFD")
.replace(/[̀-ͯ]/g, "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
}
/** Every address already spoken for: one per company on the books. */
export function takenSlugs(): Set<string> {
return new Set(db.customers.all().map((customer) => slugOf(customer.company)))
}
const refuse = (code: string, message: string, field?: string): Result<never> => ({
ok: false,
error: { code, message, ...(field ? { field } : {}) },
})
/**
* Whether an address can be had: a string, well formed, not one of the app's
* own pages, and nobody's already. Suggests the first free one when it is
* taken. The slug arrives as the page sent it, so its type and length are
* checked before the pattern sees it.
*/
export async function checkAddress(slug: unknown): Promise<Result<{ slug: string }>> {
if (typeof slug !== "string" || slug.length > 32 || !SLUG.test(slug))
return refuse("invalid_input", "Use 3 to 32 lowercase letters, numbers and dashes, starting with a letter.", "slug")
if (RESERVED_SLUGS.includes(slug))
return refuse("reserved", `${APP_HOST}/${slug} is one of Northwind's own pages. Pick another address.`, "slug")
const taken = takenSlugs()
if (!taken.has(slug)) return { ok: true, data: { slug } }
let suffix = 2
while (taken.has(`${slug}-${suffix}`)) suffix++
return refuse("taken", `${APP_HOST}/${slug} is taken. ${APP_HOST}/${slug}-${suffix} is free.`, "slug")
}
/**
* Creates the workspace from what the reader typed, checking all of it again:
* the name, the address, and every invitation — a malformed address or one
* listed twice is refused on the row that holds it (`field` is `invite-<n>`),
* and a role the workspace does not hand out is refused outright. The input
* arrives as the page sent it, so every part of it is checked for its shape
* first: anything else is refused as a Result, never thrown.
*/
export async function createWorkspace(input: unknown): Promise<Result<Created>> {
if (!isRecord(input) || !Array.isArray(input.invites))
return refuse("invalid_input", "Send a name, an address and a list of invitations.")
if (typeof input.name !== "string") return refuse("invalid_input", "Give the workspace a name.", "name")
const name = input.name.trim()
if (!name) return refuse("invalid_input", "Give the workspace a name.", "name")
if (name.length > 48) return refuse("invalid_input", "Keep the name to 48 characters.", "name")
const address = await checkAddress(input.slug)
if (!address.ok) return address
const { slug } = address.data
if (input.invites.length > MAX_INVITES)
return refuse(
"too_many",
`A new workspace starts with ${MAX_INVITES} invitations at most. Invite the rest from Settings once it exists.`
)
const seen = new Set<string>()
const invites: Invite[] = []
for (const [index, invite] of input.invites.entries()) {
const field = `invite-${index}`
if (!isRecord(invite) || typeof invite.email !== "string")
return refuse("invalid_input", "Enter an address like name@example.com.", field)
const email = invite.email.trim().toLowerCase()
if (!email) continue
if (email.length > MAX_EMAIL || !isEmail(email))
return refuse("invalid_input", "Enter an address like name@example.com.", field)
if (seen.has(email)) return refuse("duplicate", "That address is on the list already.", field)
const role = INVITABLE.find((value) => value === invite.role)
if (!role) return refuse("invalid_input", "Pick a role for this person.", field)
seen.add(email)
invites.push({ email, role })
}
// Nothing is stored here: this is where your own store creates the workspace
// row and sends the invitations. The demo answers as if it had.
return { ok: true, data: { name, slug, url: `${APP_HOST}/${slug}`, invites } }
}npx shadcn@latest add @vibra/onboarding-03Use-case questionnaire with toggle cards
onboarding-04Three questions, one at a time — what the workspace is for, as six toggle cards that say what is already there for each, how many people will use it, and who sees the dashboards first.
import * as React from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Questionnaire } from "./onboarding-04.client"
import { AUDIENCES, TEAM_SIZES, saveAnswers, purposes, type Answers } from "./onboarding-04.data"
/**
* Three questions that decide what a new workspace opens on, one at a time in
* one framed card: what it is for — six toggle cards, each saying what the
* workspace already holds for that job — how many people will use it, and who
* sees the dashboards first. The questions asked so far run down the side of
* the sheet on a wide screen, each answered one a way back to it.
*
* A server component. It counts what each use case would draw on through
* `./onboarding-04.data` and hands the island `save`, a server function
* defined here that checks every answer again before it answers.
*/
export function Onboarding04() {
const titleId = React.useId()
async function save(answers: Answers) {
"use server"
return saveAnswers(answers)
}
return (
<section
data-section="onboarding-04"
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}>Tell us what Northwind is for</h2>
</CardTitle>
<CardDescription>Three questions, and the workspace opens on the dashboards you need.</CardDescription>
</CardHeader>
<CardContent className="p-0">
<Questionnaire purposes={purposes()} teamSizes={TEAM_SIZES} audiences={AUDIENCES} save={save} />
</CardContent>
</Card>
</div>
</section>
)
}
export default Onboarding04"use client"
import * as React from "react"
import {
ActivityIcon,
CheckIcon,
CircleCheckIcon,
HandshakeIcon,
ServerIcon,
ShoppingBagIcon,
UsersIcon,
WalletIcon,
type LucideIcon,
} from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Callout } from "@/components/ui/callout"
import { FieldError } from "@/components/ui/field"
import { SelectableCardGroup } from "@/components/ui/selectable-card"
import { Spinner } from "@/components/ui/spinner"
import { StepperActions } from "@/components/ui/stepper"
import type { Answers, Choice, Result, Setup, Purpose, PurposeId } from "./onboarding-04.data"
const ICONS: Record<PurposeId, LucideIcon> = {
revenue: WalletIcon,
pipeline: HandshakeIcon,
usage: ActivityIcon,
engineering: ServerIcon,
people: UsersIcon,
store: ShoppingBagIcon,
}
type Question = "purposes" | "team" | "audience"
const QUESTIONS: Record<Question, { title: string; hint: string; missing: string }> = {
purposes: {
title: "What will you use Northwind for?",
hint: "Pick as many as apply. Each one opens a dashboard of its own.",
missing: "Pick at least one thing to set up.",
},
team: { title: "How many people will use it?", hint: "It decides what is asked next.", missing: "Pick a team size." },
audience: {
title: "Who should see the dashboards first?",
hint: "You can share any of them later.",
missing: "Pick who sees them first.",
},
}
export type QuestionnaireProps = {
purposes: Purpose[]
teamSizes: Choice[]
audiences: Choice[]
save: (answers: Answers) => Promise<Result<Setup>>
}
/**
* One question at a time, with the answers so far kept here: Back, or a press
* on an answered question in the list, never loses one. A workspace of one
* skips the question about sharing, and the count says so. The first question
* is a set of toggle cards — each a checkbox, pressed or not — and the others
* pick one card of a few. Each move puts the focus on the question and says
* where the reader is; the answers are checked again on the server, and a
* refusal about one of them takes the reader back to it.
*/
export function Questionnaire({ purposes, teamSizes, audiences, save }: QuestionnaireProps) {
const uid = React.useId()
const [answers, setAnswers] = React.useState<Answers>({ purposes: [], team: "", audience: "" })
const [at, setAt] = React.useState<Question | "summary">("purposes")
const [missing, setMissing] = React.useState<string | null>(null)
const [refusal, setRefusal] = React.useState<string | null>(null)
const [pending, setPending] = React.useState(false)
const [setup, setSetup] = React.useState<Setup | null>(null)
const [said, setSaid] = React.useState("")
const heading = React.useRef<HTMLHeadingElement>(null)
const moved = React.useRef(false)
const asked: Question[] = answers.team === "solo" ? ["purposes", "team"] : ["purposes", "team", "audience"]
const index = at === "summary" ? asked.length : asked.indexOf(at)
const answered = (question: Question) =>
question === "purposes" ? answers.purposes.length > 0 : Boolean(answers[question])
const label = (question: Question) => {
if (question === "purposes")
return answers.purposes.map((id) => purposes.find((option) => option.id === id)?.title).join(", ")
const choices = question === "team" ? teamSizes : audiences
return choices.find((choice) => choice.value === answers[question])?.label ?? ""
}
React.useEffect(() => {
if (!moved.current) return
moved.current = false
heading.current?.focus()
}, [at, setup])
function go(to: Question | "summary") {
moved.current = true
setAt(to)
setMissing(null)
setRefusal(null)
setSaid(
to === "summary"
? "All questions answered. Check the set-up, then save it."
: `Question ${asked.indexOf(to) + 1} of ${asked.length}: ${QUESTIONS[to].title}`
)
}
async function next() {
if (at !== "summary") {
if (!answered(at)) return setMissing(QUESTIONS[at].missing)
return go(asked[index + 1] ?? "summary")
}
setPending(true)
setRefusal(null)
let result: Result<Setup>
try {
result = await save(answers)
} catch {
result = { ok: false, error: { code: "network", message: "The answers did not reach the server. Try again." } }
} finally {
setPending(false)
}
if (!result.ok) {
const field = result.error.field as Question | undefined
if (field && field in QUESTIONS) {
go(field)
return setMissing(result.error.message)
}
return setRefusal(result.error.message)
}
moved.current = true
setSetup(result.data)
setSaid(`Saved. Northwind opens on ${result.data.home}.`)
}
const choose = (question: Question, value: string | string[] | null) => {
setMissing(null)
setAnswers((current) => ({ ...current, [question]: value ?? "" }))
}
const headingId = `${uid}-heading`
const hintId = `${uid}-hint`
const errorId = `${uid}-error`
const described = missing ? `${hintId} ${errorId}` : hintId
return (
<div className="grid lg:grid-cols-[minmax(0,4fr)_minmax(0,8fr)]">
<nav aria-label="Questions" className="hidden border-e p-6 lg:block">
<ol className="flex flex-col gap-1">
{asked.map((question, position) => {
const current = at === question && !setup
const done = answered(question) && !current
const content = (
<>
<span
aria-hidden="true"
className="mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full border text-2xs tabular-nums"
>
{done ? <CheckIcon className="size-3" /> : position + 1}
</span>
<span className="flex min-w-0 flex-col gap-0.5">
<span className={cn(!current && !done && "text-muted-foreground")}>{QUESTIONS[question].title}</span>
{done ? <span className="truncate text-xs text-muted-foreground">{label(question)}</span> : null}
</span>
</>
)
const row = "flex w-full items-start gap-3 rounded-md px-2 py-2 text-start text-sm"
return (
<li key={question} aria-current={current ? "step" : undefined}>
{done && !setup ? (
<button type="button" onClick={() => go(question)} className={cn(row, "focus-ring hover:bg-muted")}>
{content}
</button>
) : (
<div className={cn(row, current && "bg-brand-muted font-medium")}>{content}</div>
)}
</li>
)
})}
</ol>
</nav>
<div className="flex flex-col gap-5 p-(--card-spacing) md:p-6">
{/* Below lg the list of questions is gone, so where the reader is has to
be said here: the words stay readable, and only the bars are hidden. */}
<div data-slot="question-progress" className="flex items-center gap-3 lg:hidden">
<p className="text-xs text-muted-foreground tabular-nums">
{at === "summary" || setup ? "All questions answered" : `Question ${index + 1} of ${asked.length}`}
</p>
<span aria-hidden="true" className="flex flex-1 gap-1">
{asked.map((question, position) => (
<span
key={question}
className={cn(
"h-1 flex-1 rounded-full",
position < index || setup ? "bg-primary" : position === index ? "bg-foreground/40" : "bg-muted"
)}
/>
))}
</span>
</div>
{setup ? (
<div className="flex flex-col items-start gap-3">
<CircleCheckIcon aria-hidden="true" className="size-6 text-success" />
<h3 ref={heading} tabIndex={-1} className="text-base font-semibold outline-none">
Northwind opens on {setup.home}
</h3>
<p className="max-w-prose text-sm text-pretty text-muted-foreground">
{setup.dashboards.length > 1
? `${setup.dashboards.slice(1).join(" and ")} ${setup.dashboards.length === 2 ? "is" : "are"} beside it. `
: ""}
{setup.shared}
</p>
</div>
) : at === "summary" ? (
<>
<h3 ref={heading} tabIndex={-1} className="text-base font-semibold outline-none">
Here is what Northwind will set up
</h3>
<dl className="grid max-w-2xl divide-y text-sm">
{asked.map((question) => (
<div
key={question}
className="grid gap-1 py-3 first:pt-0 sm:grid-cols-[minmax(0,2fr)_minmax(0,3fr)] sm:gap-4"
>
<dt className="text-muted-foreground">{QUESTIONS[question].title}</dt>
<dd>{label(question)}</dd>
</div>
))}
</dl>
</>
) : (
<fieldset className="min-w-0">
<legend className="mb-1">
<h3 id={headingId} ref={heading} tabIndex={-1} className="text-base font-semibold outline-none">
{QUESTIONS[at].title}
</h3>
</legend>
<p id={hintId} className="mb-4 text-sm text-muted-foreground">
{QUESTIONS[at].hint}
</p>
{at === "purposes" ? (
<SelectableCardGroup
type="multiple"
columns={3}
aria-labelledby={headingId}
aria-describedby={described}
value={answers.purposes}
onValueChange={(value) => choose("purposes", value)}
options={purposes.map((option) => {
const Icon = ICONS[option.id]
return {
value: option.id,
title: option.title,
icon: <Icon />,
description: (
<>
{option.summary}
<span className="mt-1 block text-foreground tabular-nums">{option.holds}</span>
</>
),
}
})}
/>
) : (
<SelectableCardGroup
type="single"
columns={2}
aria-labelledby={headingId}
aria-describedby={described}
value={answers[at] || null}
onValueChange={(value) => choose(at, value)}
options={(at === "team" ? teamSizes : audiences).map((choice) => ({
value: choice.value,
title: choice.label,
description: choice.hint,
}))}
/>
)}
{missing ? (
<FieldError id={errorId} className="mt-3">
{missing}
</FieldError>
) : null}
</fieldset>
)}
{refusal ? (
<Callout variant="danger" role="alert" title="Nothing was saved">
{refusal}
</Callout>
) : null}
{setup ? null : (
<StepperActions className="border-t pt-4" onBack={index > 0 ? () => go(asked[index - 1]) : undefined}>
<Button
type="button"
disabled={pending}
focusableWhenDisabled
aria-busy={pending || undefined}
onClick={() => void next()}
>
{pending ? <Spinner aria-hidden="true" /> : null}
{at === "summary" ? "Save and open Northwind" : "Continue"}
</Button>
</StepperActions>
)}
<p role="status" aria-live="polite" className="sr-only">
{said}
</p>
</div>
</div>
)
}/**
* What `onboarding-04` reads and saves: the jobs Northwind can be set up for,
* each with what the workspace already holds for it — the invoices, the open
* deals, the sessions, the deploys, the people and the orders counted in
* `db` — so a reader picks a use case knowing what it will show them on the
* first day. The questions and their answers are copy; `saveAnswers` is what
* the section's server function calls, and it checks every answer again.
*
* Selectors and the one check, reading `db` inside functions on the server.
* The island takes the options as plain props and never imports this module.
*/
import { formatNumber } from "@/lib/format"
import { REFERENCE_DATE, db, type Result } from "@/lib/sample-data"
export type { Result }
export type PurposeId = "revenue" | "pipeline" | "usage" | "engineering" | "people" | "store"
/** One thing the workspace can be for: what it shows, the dashboard it opens, and what is already there for it. */
export type Purpose = { id: PurposeId; title: string; summary: string; dashboard: string; holds: string }
export type Choice = { value: string; label: string; hint: string }
export type Answers = { purposes: string[]; team: string; audience: string }
/** What the answers set up: the dashboards in the order picked, the one it opens on, and who can see them. */
export type Setup = { dashboards: string[]; home: string; shared: string }
/** How many people will use it. "solo" skips the question about who sees the dashboards: there is nobody else. */
export const TEAM_SIZES: Choice[] = [
{ value: "solo", label: "Just me", hint: "Nobody else to share with yet." },
{ value: "small", label: "2 to 10 people", hint: "One team, working together." },
{ value: "medium", label: "11 to 50 people", hint: "A few teams, each with its own view." },
{ value: "large", label: "More than 50", hint: "Departments, roles and an access policy." },
]
/** Who sees the dashboards first. */
export const AUDIENCES: Choice[] = [
{ value: "everyone", label: "Everyone in the workspace", hint: "Each dashboard is shared as soon as it is built." },
{ value: "admins", label: "Admins first", hint: "Admins check the numbers, then share them." },
{ value: "me", label: "Only me for now", hint: "Nothing is shared until you share it." },
]
/** What each audience means once the dashboards exist. */
const SHARED: Record<string, string> = {
everyone: "Everyone in the workspace can see them.",
admins: "Admins see them first, then share them.",
me: "Only you can see them until you share them.",
}
const HOUR_MS = 3_600_000
/** The six jobs, each with a count read off the rows it would draw on. */
export function purposes(): Purpose[] {
const since = REFERENCE_DATE.getTime() - 24 * HOUR_MS
const sessions = db.sessions
.all()
.filter((row) => row.at.getTime() > since)
.reduce((total, row) => total + row.sessions, 0)
const openDeals = db.deals.all().filter((deal) => deal.stage !== "won" && deal.stage !== "lost").length
const openIncidents = db.incidents.all().filter((incident) => incident.status !== "resolved").length
const count = (value: number, one: string, many: string) => `${formatNumber(value)} ${value === 1 ? one : many}`
return [
{
id: "revenue",
title: "Revenue and billing",
summary: "Recurring revenue, churn and what is still owed.",
dashboard: "Revenue",
holds: `${count(db.invoices.all().length, "invoice", "invoices")} and ${count(db.subscriptions.all().length, "subscription", "subscriptions")} already here`,
},
{
id: "pipeline",
title: "Sales pipeline",
summary: "Deals by stage, and the quarter they add up to.",
dashboard: "Pipeline",
holds: `${count(openDeals, "open deal", "open deals")} already here`,
},
{
id: "usage",
title: "Product usage",
summary: "Sessions, response times and errors, country by country.",
dashboard: "Usage",
holds: `${count(sessions, "session", "sessions")} in the last day`,
},
{
id: "engineering",
title: "Engineering health",
summary: "Deploys, failures and the time it takes to recover.",
dashboard: "Delivery",
holds: `${count(db.deployments.all().length, "deploy", "deploys")}, ${count(openIncidents, "incident", "incidents")} open`,
},
{
id: "people",
title: "Team and people",
summary: "Headcount, leave and who reports to whom.",
dashboard: "People",
holds: `${count(db.employees.all().length, "person", "people")} on the books`,
},
{
id: "store",
title: "Store and orders",
summary: "Orders, stock and the products that sell.",
dashboard: "Orders",
holds: `${count(db.orders.all().length, "order", "orders")} across ${count(db.products.all().length, "product", "products")}`,
},
]
}
const refuse = (field: keyof Answers | undefined, message: string): Result<never> => ({
ok: false,
error: { code: "invalid_input", message, ...(field ? { field } : {}) },
})
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value)
/**
* Saves the answers, checking each one again: at least one use case, each a
* known one and picked once, a team size, and an audience unless the reader
* works alone — a workspace of one shares with nobody, so it is set to "Only
* me" whatever was sent. The answers arrive as the page sent them, so their
* shape is checked first and anything else is refused as a Result, never
* thrown. Answers with what the workspace will open on.
*/
export async function saveAnswers(input: unknown): Promise<Result<Setup>> {
if (!isRecord(input) || !Array.isArray(input.purposes))
return refuse(undefined, "Send the use cases, the team size and the audience.")
const offered = purposes()
const ids = input.purposes
if (!ids.length) return refuse("purposes", "Pick at least one thing to set up.")
if (ids.length > offered.length || new Set(ids).size !== ids.length)
return refuse("purposes", "Pick each use case once.")
const picked = ids.map((id) => offered.find((option) => option.id === id))
if (picked.some((option) => !option)) return refuse("purposes", "One of those is not something Northwind sets up.")
const team = TEAM_SIZES.find((size) => size.value === input.team)?.value
if (!team) return refuse("team", "Say how many people will use it.")
const audience = team === "solo" ? "me" : AUDIENCES.find((option) => option.value === input.audience)?.value
if (!audience) return refuse("audience", "Say who should see the dashboards first.")
// Nothing is stored here: this is where your own store keeps the answers
// and builds the dashboards. The demo answers as if it had.
const dashboards = picked.map((option) => option!.dashboard)
return { ok: true, data: { dashboards, home: dashboards[0], shared: SHARED[audience] } }
}npx shadcn@latest add @vibra/onboarding-04Invite teammates with faces
onboarding-05The newest people at the company who are not in the workspace yet, each a toggle onto the list, beside the list itself — a role each, anyone else by address, and the seats they will take.
import * as React from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { InviteTeam } from "./onboarding-05.client"
import {
MAX_PER_SEND,
WORKSPACE,
seats,
sender,
sendInvitations,
suggestions,
type InviteInput,
} from "./onboarding-05.data"
/**
* Inviting the team, in one framed card: on one side the newest people at
* the company who are not in the workspace yet, each with their face and
* what they do; on the other the list being put together — a role for each,
* anyone else added by address, and the seats they will take against the
* ones the plan includes.
*
* A server component. It reads the people and the seats through
* `./onboarding-05.data` and hands the island `send`, a server function
* defined here that checks the list again and writes the invitations.
*/
export function Onboarding05() {
const titleId = React.useId()
async function send(list: InviteInput[]) {
"use server"
return sendInvitations(list)
}
return (
<section
data-section="onboarding-05"
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}>Invite your team to {WORKSPACE.name}</h2>
</CardTitle>
<CardDescription>
The newest people at {WORKSPACE.domain} who are not here yet, and anyone else by address.
</CardDescription>
</CardHeader>
<CardContent className="p-0">
<InviteTeam people={suggestions()} seats={seats()} from={sender()} maxPerSend={MAX_PER_SEND} send={send} />
</CardContent>
</Card>
</div>
</section>
)
}
export default Onboarding05"use client"
import * as React from "react"
import { CheckIcon, CircleCheckIcon, PlusIcon, XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { avatarFor } from "@/lib/avatars"
import { isEmail } from "@/lib/validation"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { Callout } from "@/components/ui/callout"
import { FieldError } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select"
import { Spinner } from "@/components/ui/spinner"
import { UsageMeter } from "@/components/ui/usage-meter"
import { UserCell } from "@/components/ui/user-cell"
import type { InviteInput, Result, Role, Seats, Sent, Suggestion } from "./onboarding-05.data"
const ROLES: { value: Role; label: string }[] = [
{ value: "member", label: "Member" },
{ value: "admin", label: "Admin" },
{ value: "viewer", label: "Viewer" },
]
/** "omar.haddad@example.com" → "Omar Haddad", a name to draw a face for; an address with no name in it goes by itself. */
function nameFrom(email: string): string {
const local = email.split("@")[0] ?? email
const name = local
.split(/[._-]+/)
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ")
return /[\p{L}\p{N}]/u.test(name) ? name : email
}
const initials = (name: string) =>
name
.split(" ")
.map((part) => part.charAt(0))
.join("")
.slice(0, 2)
/** Addresses are compared the way the server compares them: without case. */
const same = (a: string, b: string) => a.toLowerCase() === b.toLowerCase()
const plural = (count: number, one: string, many: string) => `${count} ${count === 1 ? one : many}`
/** Focus that lands after the render it waits for: a refused field with its message, a gone row's neighbour. */
function useFocusAfterRender() {
const [request, setRequest] = React.useState<{ id: string } | null>(null)
React.useEffect(() => {
if (request) document.getElementById(request.id)?.focus()
}, [request])
return React.useCallback((id: string) => setRequest({ id }), [])
}
type Pick = { email: string; name: string; face?: string; about?: string; role: Role }
export type InviteTeamProps = {
people: Suggestion[]
seats: Seats
/** The owner the invitations come from. */
from: string
/** How many people one send can carry; the server refuses more. */
maxPerSend?: number
send: (list: InviteInput[]) => Promise<Result<Sent>>
}
/**
* Two halves of one job: the people at the company not in the workspace yet,
* each a toggle that puts them on the list, and the list itself — a role each,
* anyone else added by address, and the seats in parts against the plan's.
* Sending goes through the server, which checks the list again; a refusal
* about a person sits under their row with the focus on it, and what went out
* is said in the status line. Adding and taking off are said too, and a row
* that goes hands the focus on. A sent invitation takes its toggle away.
*/
export function InviteTeam({ people, seats, from, maxPerSend = 50, send }: InviteTeamProps) {
const uid = React.useId()
const [picked, setPicked] = React.useState<Pick[]>([])
/** The addresses sent from here, lowercased as the server wrote them. */
const [invited, setInvited] = React.useState<string[]>([])
const [address, setAddress] = React.useState("")
const [addressError, setAddressError] = React.useState<string | null>(null)
const [rowError, setRowError] = React.useState<{ email: string; message: string } | null>(null)
const [refusal, setRefusal] = React.useState<string | null>(null)
const [pending, setPending] = React.useState(false)
const [said, setSaid] = React.useState("")
const [noted, setNoted] = React.useState("")
const [invitations, setInvitations] = React.useState(seats.invitations)
const focusAfterRender = useFocusAfterRender()
const isPicked = (email: string) => picked.some((entry) => same(entry.email, email))
const wasInvited = (email: string) => invited.includes(email.toLowerCase())
function toggle(person: Suggestion) {
setPicked((list) =>
list.some((entry) => same(entry.email, person.email))
? list.filter((entry) => !same(entry.email, person.email))
: [...list, { email: person.email, name: person.name, face: person.face, about: person.about, role: "member" }]
)
}
function add(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
const email = address.trim().toLowerCase()
const problem = !email
? "Enter an address to add."
: email.length > 254 || !isEmail(email)
? "Enter an address like name@example.com."
: isPicked(email) || wasInvited(email)
? "That address is on the list already."
: picked.length >= maxPerSend
? `One send carries ${maxPerSend} people at most. Send this list, then add more.`
: null
setAddressError(problem)
if (problem) return focusAfterRender(`${uid}-address`)
const name = nameFrom(email)
setPicked((list) => [...list, { email, name, face: avatarFor(name), role: "member" }])
setAddress("")
setNoted(`Added ${email} to the list as a member.`)
}
/** Takes someone off the list; the focus goes to the row that takes their place, or back to the address field. */
function takeOff(entry: Pick, index: number) {
const left = picked.length - 1
setPicked((list) => list.filter((row) => !same(row.email, entry.email)))
if (rowError && same(rowError.email, entry.email)) setRowError(null)
focusAfterRender(left ? `${uid}-remove-${Math.min(index, left - 1)}` : `${uid}-address`)
setNoted(
`Took ${entry.name} off the list. ${left ? `${plural(left, "person is", "people are")} on it.` : "The list is empty."}`
)
}
async function submit() {
setRefusal(null)
setRowError(null)
if (!picked.length) return setRefusal("Pick someone from the list, or add an address, first.")
setPending(true)
let result: Result<Sent>
try {
result = await send(picked.map(({ email, role }) => ({ email, role })))
} catch {
const message = "The invitations did not reach the server. Try again."
result = { ok: false, error: { code: "network", message } }
} finally {
setPending(false)
}
if (!result.ok) {
const index = Number(result.error.field?.replace("invite-", ""))
if (picked[index]) {
setRowError({ email: picked[index].email, message: result.error.message })
return focusAfterRender(`${uid}-role-${index}`)
}
return setRefusal(result.error.message)
}
const { emails, expires, extraSeats } = result.data
setInvited((list) => [...list, ...emails])
setInvitations((count) => count + emails.length)
setPicked([])
setNoted("")
const extra = extraSeats
? ` ${extraSeats} ${extraSeats === 1 ? "is" : "are"} past the seats ${seats.plan} includes and billed as extra.`
: ""
setSaid(
`Invited ${emails.length} ${emails.length === 1 ? "person" : "people"} from ${from}, good until ${expires}.${extra}`
)
}
// Who holds the seats, in parts: members (in or invited) and the invitations
// still open, then what this list would add, then what goes past the plan.
const limit = seats.included
const taken = seats.members + invitations
const used = taken + picked.length
const over = limit === null ? 0 : Math.max(0, used - Math.max(limit, taken))
const parts = `${plural(seats.members, "member", "members")} and ${plural(invitations, "open invitation", "open invitations")}`
const seatLine = [
limit === null
? `${parts} take ${taken} seats, and ${seats.plan} has no seat limit.`
: `${parts} take ${taken} of the ${limit} seats ${seats.plan} includes.`,
picked.length ? `This list adds ${picked.length}.` : "",
limit === null || used < limit
? ""
: !seats.extraSeats
? `${seats.plan} holds ${limit} people and bills no extra seats.`
: over
? `${over} past the ${limit} included, each billed as an extra seat from the day they join.`
: "Anyone else you invite is billed as an extra seat.",
]
.filter(Boolean)
.join(" ")
return (
<div className="grid lg:grid-cols-[minmax(0,7fr)_minmax(0,5fr)]">
<div className="flex flex-col gap-3 border-b p-(--card-spacing) md:p-6 lg:border-e lg:border-b-0">
<h3 className="text-sm font-semibold">Not in the workspace yet</h3>
{people.length ? (
<ul className="flex flex-col divide-y">
{people.map((person) => {
const on = isPicked(person.email)
return (
<li key={person.email} className="flex items-center justify-between gap-3 py-2.5 first:pt-0 last:pb-0">
<UserCell name={person.name} src={person.face} description={person.about} className="min-w-0" />
{wasInvited(person.email) ? (
<span className="flex shrink-0 items-center gap-1.5 text-xs text-muted-foreground">
<CheckIcon aria-hidden="true" className="size-3.5 text-success" />
Invited
</span>
) : (
<Button
type="button"
variant="outline"
size="sm"
aria-pressed={on}
aria-label={`Invite ${person.name}`}
onClick={() => toggle(person)}
className={cn("shrink-0", on && "border-transparent bg-brand-muted hover:bg-brand-muted")}
>
{on ? (
<CheckIcon aria-hidden="true" data-icon="inline-start" />
) : (
<PlusIcon aria-hidden="true" data-icon="inline-start" />
)}
Invite
</Button>
)}
</li>
)
})}
</ul>
) : (
<p className="text-sm text-pretty text-muted-foreground">
Everyone at the company is in the workspace or invited already. Add anyone else by address.
</p>
)}
</div>
<div className="flex flex-col gap-4 p-(--card-spacing) md:p-6">
<h3 className="text-sm font-semibold">
To invite <span className="font-normal text-muted-foreground tabular-nums">({picked.length})</span>
</h3>
<form noValidate onSubmit={add} className="flex flex-col gap-2">
<Label htmlFor={`${uid}-address`}>Invite by email</Label>
<div className="flex gap-2">
<Input
id={`${uid}-address`}
type="email"
value={address}
placeholder="name@example.com"
autoComplete="off"
aria-invalid={addressError ? true : undefined}
aria-describedby={addressError ? `${uid}-address-error` : undefined}
onChange={(event) => {
setAddress(event.target.value)
setAddressError(null)
}}
/>
<Button type="submit" variant="outline">
Add
</Button>
</div>
{addressError ? <FieldError id={`${uid}-address-error`}>{addressError}</FieldError> : null}
</form>
{picked.length ? (
<ul className="flex flex-col divide-y">
{picked.map((entry, index) => {
const error = rowError && same(rowError.email, entry.email) ? rowError.message : null
return (
<li key={entry.email} className="flex flex-col gap-1.5 py-2.5 first:pt-0">
<div className="flex items-center gap-2">
<Avatar size="sm">
<AvatarImage src={entry.face} alt="" />
<AvatarFallback>{initials(entry.name)}</AvatarFallback>
</Avatar>
<span className="min-w-0 flex-1 truncate text-sm">{entry.about ? entry.name : entry.email}</span>
<NativeSelect
id={`${uid}-role-${index}`}
size="sm"
value={entry.role}
aria-label={`Role for ${entry.name}`}
aria-invalid={error ? true : undefined}
aria-describedby={error ? `${uid}-row-${index}` : undefined}
onChange={(event) => {
const role = event.target.value as Role
setPicked((list) => list.map((row) => (same(row.email, entry.email) ? { ...row, role } : row)))
}}
>
{ROLES.map((role) => (
<NativeSelectOption key={role.value} value={role.value}>
{role.label}
</NativeSelectOption>
))}
</NativeSelect>
<Button
id={`${uid}-remove-${index}`}
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Take ${entry.name} off the list`}
onClick={() => takeOff(entry, index)}
>
<XIcon aria-hidden="true" />
</Button>
</div>
{error ? <FieldError id={`${uid}-row-${index}`}>{error}</FieldError> : null}
</li>
)
})}
</ul>
) : (
<p className="text-sm text-muted-foreground">Nobody yet. Pick people from the list, or add an address.</p>
)}
<div className="flex flex-col gap-1.5 border-t pt-4">
<UsageMeter label={`Seats on ${seats.plan}`} used={used} limit={limit} tone="auto" size="sm" />
<p className="text-xs text-pretty text-muted-foreground">{seatLine}</p>
</div>
{refusal ? (
<Callout variant="danger" role="alert" title="Nothing was sent">
{refusal}
</Callout>
) : null}
<Button
type="button"
className="self-start"
disabled={pending}
focusableWhenDisabled
aria-busy={pending || undefined}
onClick={() => void submit()}
>
{pending ? <Spinner aria-hidden="true" /> : null}
{picked.length === 1
? "Send invitation"
: picked.length
? `Send ${picked.length} invitations`
: "Send invitations"}
</Button>
<p role="status" aria-live="polite" className="flex min-h-5 items-start gap-2 text-sm">
{said ? (
<>
<CircleCheckIcon aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-success" />
{said}
</>
) : null}
</p>
{/* Changes to the list are news for a screen reader only: the list itself shows them. */}
<p role="status" aria-live="polite" className="sr-only">
{noted}
</p>
</div>
</div>
)
}/**
* What `onboarding-05` reads and writes: the people at the company who are
* not in the workspace yet — the newest hires in `db.employees`, drawn with
* the faces the store gives them — how many seats the workspace's plan
* includes and how many are spoken for, and `sendInvitations`, which checks
* the list again against the members and the open invitations and writes
* one `db.invitations` row per person.
*
* Selectors and the one write, reading `db` inside functions on the server.
* The island takes the people and the seats as plain props and never imports
* this module.
*/
import { isEmail } from "@/lib/validation"
import { REFERENCE_DATE, WORKSPACE_PLAN, db, seatUsage, type Invitation, type Plan, type Result } from "@/lib/sample-data"
export type { Result }
/** The workspace, the plan it is on and the company's own domain. */
export const WORKSPACE = { name: "Northwind Analytics", plan: WORKSPACE_PLAN, domain: "northwind.example" }
/** How long an invitation stays good for, in days. */
export const VALID_FOR_DAYS = 14
/** How many people to suggest. */
const SUGGESTED = 6
/** The most invitations one send can carry; more go in the next. */
export const MAX_PER_SEND = 50
/** The longest an address can be (RFC 5321), checked before any pattern sees it. */
const MAX_EMAIL = 254
const ROLES: readonly Role[] = ["admin", "member", "viewer"]
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value)
export type Role = Invitation["role"]
/** Someone at the company who is not in the workspace yet. */
export type Suggestion = { email: string; name: string; face?: string; about: string }
export type InviteInput = { email: string; role: Role }
/**
* The seats on the workspace's plan and who takes them: members who are in or
* invited, and the invitations still open. `included` is null for a plan with
* no limit; `extraSeats` says whether the plan bills people past it.
*/
export type Seats = { plan: string; included: number | null; extraSeats: boolean; members: number; invitations: number }
/** What went out: to whom, until when, and how many of them are past the seats the plan includes. */
export type Sent = { emails: string[]; expires: string; extraSeats: number }
const DAY_MS = 86_400_000
const DAY = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: "UTC" })
/** Every address already in the workspace or waiting on an answer, lowercased. */
function spokenFor(): Set<string> {
const members = db.members.all().filter((member) => member.status !== "deactivated")
const open = db.invitations.all().filter((invitation) => invitation.status === "pending")
return new Set([...members, ...open].map((row) => row.email.toLowerCase()))
}
/** The newest hires at the company who are neither members nor already invited. */
export function suggestions(): Suggestion[] {
const taken = spokenFor()
return [...db.employees.all()]
.sort((a, b) => b.startedAt.getTime() - a.startedAt.getTime())
.filter((employee) => !taken.has(employee.email.toLowerCase()))
.slice(0, SUGGESTED)
.map((employee) => ({
email: employee.email,
name: employee.name,
face: employee.avatarUrl,
about: `${employee.role}, ${employee.department}`,
}))
}
/**
* How a plan's seats are read, here and in onboarding-07 alike. `seatsIncluded`
* are the seats its price covers, and a plan whose features say "Unlimited
* seats" covers any number (null). Past its seats a paid plan bills each
* person as an extra seat; Free takes no card, so it holds its seats and no
* more.
*/
export function seatReading(plan: Plan): { included: number | null; extraSeats: boolean } {
const unlimited = plan.features.some((feature) => /^unlimited seats$/i.test(feature.trim()))
return { included: unlimited ? null : plan.seatsIncluded, extraSeats: plan.priceMonthlyCents > 0 }
}
/** The seats the plan includes and the ones already spoken for — `seatUsage()`, the count every page shows. */
export function seats(): Seats {
const { plan, members, invitations } = seatUsage()
return { plan: plan.name, ...seatReading(plan), members, invitations }
}
/** Who the invitations come from: the workspace's owner. */
export function sender(): string {
return db.members.all().find((member) => member.role === "owner")?.name ?? WORKSPACE.name
}
const refuse = (code: string, message: string, field?: string): Result<never> => ({
ok: false,
error: { code, message, ...(field ? { field } : {}) },
})
/**
* Sends the invitations, re-reading everything the list could be wrong about:
* each address is checked again, and one already in the workspace or already
* invited is refused on its row (`invite-<n>`) — nothing is half-sent. Writes
* a pending `db.invitations` row per person, good for `VALID_FOR_DAYS` days,
* and says how many of them go past the seats the plan includes: on a paid
* plan those are billed as extra seats, which is the plan's business and not
* a reason to turn a teammate away (a plan that bills none refuses instead,
* as `seatReading` says). The list arrives as the page sent it, so its shape is
* checked first — a list of people, each an address and a role, at most
* `MAX_PER_SEND` of them — and anything else is refused as a Result, never
* thrown, before a single row is written.
*/
export async function sendInvitations(list: unknown): Promise<Result<Sent>> {
if (!Array.isArray(list)) return refuse("invalid_input", "Send a list of people to invite.")
if (!list.length) return refuse("empty", "Pick someone to invite first.")
if (list.length > MAX_PER_SEND)
return refuse("too_many", `Send at most ${MAX_PER_SEND} invitations at a time, and the rest after.`)
const taken = spokenFor()
const seen = new Set<string>()
const checked: InviteInput[] = []
for (const [index, invite] of list.entries()) {
const field = `invite-${index}`
if (!isRecord(invite) || typeof invite.email !== "string")
return refuse("invalid_input", "Enter an address like name@example.com.", field)
const email = invite.email.trim().toLowerCase()
if (email.length > MAX_EMAIL || !isEmail(email))
return refuse("invalid_input", "Enter an address like name@example.com.", field)
if (seen.has(email)) return refuse("duplicate", `${email} is on the list twice.`, field)
if (taken.has(email)) return refuse("taken", `${email} is already in the workspace or invited.`, field)
const role = ROLES.find((value) => value === invite.role)
if (!role) return refuse("invalid_input", "Pick a role.", field)
seen.add(email)
checked.push({ email, role })
}
const { plan, included, extraSeats, members, invitations } = seats()
const used = members + invitations
// A plan that bills no extra seats holds its seats and no more (see `seatReading`).
if (included !== null && !extraSeats && used + checked.length > included)
return refuse(
"no_seats",
`${plan} holds ${included} people and ${used} seats are taken. Move to a paid plan to invite more.`
)
const owner = db.members.all().find((member) => member.role === "owner")
const expiresAt = new Date(REFERENCE_DATE.getTime() + VALID_FOR_DAYS * DAY_MS)
for (const invite of checked) {
const created = await db.invitations.create({
email: invite.email,
role: invite.role,
invitedBy: owner?.id ?? "",
sentAt: REFERENCE_DATE,
expiresAt,
status: "pending",
})
if (!created.ok) return created
}
return {
ok: true,
data: {
emails: [...seen],
expires: DAY.format(expiresAt),
extraSeats: included === null ? 0 : Math.max(0, used + checked.length - Math.max(included, used)),
},
}
}npx shadcn@latest add @vibra/onboarding-05Connect a data source
onboarding-06The places a workspace's data can live, as cards to pick one from, beside the form that connects it — tried on the server before anything is kept, with a webhook's address and secret to copy.
import * as React from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { ConnectSource } from "./onboarding-06.client"
import { sources, testSource, type SourceId } from "./onboarding-06.data"
/**
* Connecting the first data source, in one framed card: the places the data
* can live as a list to pick from, and beside it the form for the one picked
* — the fields that source needs, or for a webhook the address and secret to
* give it — with a button that tries the connection before anything is kept.
*
* A server component. It reads the sources and the regions they can send to
* through `./onboarding-06.data` and hands the island `test`, a server
* function defined here that tries the connection and answers in words.
*/
export function Onboarding06() {
const titleId = React.useId()
async function test(source: SourceId, values: Record<string, string>) {
"use server"
return testSource(source, values)
}
return (
<section
data-section="onboarding-06"
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}>Connect your first data source</h2>
</CardTitle>
<CardDescription>Northwind tries the connection before it keeps anything.</CardDescription>
</CardHeader>
<CardContent className="p-0">
<ConnectSource sources={sources()} test={test} />
</CardContent>
</Card>
</div>
</section>
)
}
export default Onboarding06"use client"
import * as React from "react"
import {
ActivityIcon,
CircleCheckIcon,
DatabaseIcon,
ReceiptTextIcon,
WebhookIcon,
type LucideIcon,
} from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Callout } from "@/components/ui/callout"
import { CopyButton } from "@/components/ui/copy-button"
import { Field, FieldDescription, FieldError, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select"
import { PasswordInput } from "@/components/ui/password-input"
import { SelectableCardGroup } from "@/components/ui/selectable-card"
import { Spinner } from "@/components/ui/spinner"
import { StatusBadge } from "@/components/ui/status-badge"
import type { Connected, Result, Source, SourceField, SourceId } from "./onboarding-06.data"
const ICONS: Record<Source["icon"], LucideIcon> = {
database: DatabaseIcon,
receipt: ReceiptTextIcon,
activity: ActivityIcon,
webhook: WebhookIcon,
}
/** Focus that waits for the render it depends on: a refused field is focused once its message is on the page. */
function useFocusAfterRender() {
const [request, setRequest] = React.useState<{ id: string } | null>(null)
React.useEffect(() => {
if (request) document.getElementById(request.id)?.focus()
}, [request])
return React.useCallback((id: string) => setRequest({ id }), [])
}
export type ConnectSourceProps = {
sources: Source[]
test: (source: SourceId, values: Record<string, string>) => Promise<Result<Connected>>
}
/**
* The sources as a list of cards to pick one from, and beside it the form
* that connects the one picked. What is typed into each source's form is
* kept while another is looked at, so picking a different card loses
* nothing. "Test and connect" goes to the server: a field that cannot work is
* refused under that field with the focus on it, a connection that cannot be
* made is refused in a danger callout under the form, and one that works
* marks its card Connected and says what happens next in the status line.
*/
export function ConnectSource({ sources, test }: ConnectSourceProps) {
const uid = React.useId()
const id = (source: SourceId, field: string) => `${uid}-${source}-${field}`
const [chosen, setChosen] = React.useState<SourceId>(sources[0].id)
const [values, setValues] = React.useState<Record<string, Record<string, string>>>(() =>
Object.fromEntries(
sources.map((source) => [
source.id,
Object.fromEntries(source.fields.map((field) => [field.name, field.initial ?? ""])),
])
)
)
const [error, setError] = React.useState<{ field: string; message: string } | null>(null)
const [refusal, setRefusal] = React.useState<string | null>(null)
const [connected, setConnected] = React.useState<Partial<Record<SourceId, string>>>({})
const [pending, setPending] = React.useState(false)
const [said, setSaid] = React.useState("")
const focusAfterRender = useFocusAfterRender()
const source = sources.find((entry) => entry.id === chosen) ?? sources[0]
function pick(next: string | null) {
if (!next) return
setChosen(next as SourceId)
setError(null)
setRefusal(null)
setSaid("")
}
async function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
setError(null)
setRefusal(null)
setPending(true)
let result: Result<Connected>
try {
result = await test(source.id, values[source.id])
} catch {
result = { ok: false, error: { code: "network", message: "The test did not reach the server. Try again." } }
} finally {
setPending(false)
}
if (!result.ok) {
const field = result.error.field
if (!field) return setRefusal(result.error.message)
setError({ field, message: result.error.message })
return focusAfterRender(id(source.id, field))
}
setConnected((list) => ({ ...list, [source.id]: result.data.message }))
setSaid(result.data.message)
}
function control(field: SourceField) {
const fieldId = id(source.id, field.name)
const invalid = error?.field === field.name
const describedBy =
[field.hint ? `${fieldId}-hint` : "", invalid ? `${fieldId}-error` : ""].filter(Boolean).join(" ") || undefined
const shared = {
id: fieldId,
value: values[source.id][field.name],
"aria-invalid": invalid || undefined,
"aria-describedby": describedBy,
onChange: (event: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const value = event.target.value
setValues((all) => ({ ...all, [source.id]: { ...all[source.id], [field.name]: value } }))
if (invalid) setError(null)
},
}
if (field.kind === "select")
return (
<NativeSelect {...shared} className="w-full">
{field.options?.map((option) => (
<NativeSelectOption key={option} value={option}>
{option}
</NativeSelectOption>
))}
</NativeSelect>
)
const text = {
...shared,
placeholder: field.placeholder,
autoComplete: "off",
spellCheck: false,
className: cn(field.mono && "font-mono"),
}
return field.kind === "secret" ? <PasswordInput {...text} autoComplete="new-password" /> : <Input {...text} />
}
const Icon = ICONS[source.icon]
return (
<div className="grid lg:grid-cols-[minmax(0,5fr)_minmax(0,7fr)]">
<fieldset className="min-w-0 border-b p-(--card-spacing) md:p-6 lg:border-e lg:border-b-0">
<legend className="float-left mb-3 w-full text-sm font-semibold">Where the data lives</legend>
<SelectableCardGroup
type="single"
columns={1}
aria-label="Where the data lives"
value={chosen}
onValueChange={pick}
options={sources.map((entry) => {
const EntryIcon = ICONS[entry.icon]
return {
value: entry.id,
title: entry.title,
description: entry.description,
icon: <EntryIcon />,
badge: connected[entry.id] ? <StatusBadge status="connected" variant="success" size="sm" /> : undefined,
}
})}
/>
</fieldset>
<form
noValidate
onSubmit={submit}
aria-labelledby={`${uid}-title`}
className="flex flex-col gap-5 p-(--card-spacing) md:p-6"
>
<div className="flex items-start gap-3">
<span
aria-hidden="true"
className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-surface ring-1 ring-border"
>
<Icon className="size-4" />
</span>
<div className="flex flex-col gap-0.5">
<h3 id={`${uid}-title`} className="text-sm font-semibold">
Connect {source.title}
</h3>
<p className="text-sm text-muted-foreground">{source.description}</p>
</div>
</div>
{source.endpoint ? (
<dl className="grid gap-3 text-sm">
{[
{ term: "Send events to", value: source.endpoint.url, copy: "Copy the webhook address" },
{ term: "Signing secret", value: source.endpoint.secret, copy: "Copy the signing secret" },
].map(({ term, value, copy }) => (
<div key={term} className="flex flex-col gap-1">
<dt className="font-medium">{term}</dt>
<dd className="flex items-center gap-1 rounded-lg bg-surface py-1 ps-2.5 pe-1 ring-1 ring-border">
<code className="min-w-0 flex-1 truncate font-mono text-xs">{value}</code>
<CopyButton value={value} label={copy} size="icon-sm" />
</dd>
</div>
))}
</dl>
) : (
<div className="grid gap-4 sm:grid-cols-2">
{source.fields.map((field) => (
<Field
key={`${source.id}-${field.name}`}
data-invalid={error?.field === field.name || undefined}
className={cn(source.fields.length % 2 === 1 && field === source.fields.at(-1) && "sm:col-span-2")}
>
<FieldLabel htmlFor={id(source.id, field.name)}>{field.label}</FieldLabel>
{control(field)}
{field.hint ? (
<FieldDescription id={`${id(source.id, field.name)}-hint`}>{field.hint}</FieldDescription>
) : null}
{error?.field === field.name ? (
<FieldError id={`${id(source.id, field.name)}-error`}>{error.message}</FieldError>
) : null}
</Field>
))}
</div>
)}
{refusal ? (
<Callout variant="danger" role="alert" title={`Could not connect ${source.title}`}>
{refusal}
</Callout>
) : null}
<div className="flex flex-wrap items-center gap-3 border-t pt-4">
<Button type="submit" disabled={pending} focusableWhenDisabled aria-busy={pending || undefined}>
{pending ? <Spinner aria-hidden="true" /> : null}
{connected[source.id] && source.fields.length ? "Test again" : source.action}
</Button>
{/* The run that just finished is announced; a source connected earlier says so quietly. */}
<p role="status" aria-live="polite" className="flex min-w-0 flex-1 items-start gap-2 text-sm">
{said ? (
<>
<CircleCheckIcon aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-success" />
{said}
</>
) : null}
</p>
{!said && connected[source.id] ? (
<p className="flex min-w-0 flex-1 items-start gap-2 text-sm text-muted-foreground">
<CircleCheckIcon aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-success" />
{connected[source.id]}
</p>
) : null}
</div>
</form>
</div>
)
}/**
* What `onboarding-06` offers and checks: four places a workspace's data can
* come from — two kinds anyone has, a database and a webhook, and two
* services with made-up names that stand for a billing system and an event
* pipeline — and `testSource`, which is what the section's server function
* calls to try a connection before it is saved. The regions an event pipeline
* can send to are the ones `db.services` runs in, most-used first, and the
* first of them is where Northwind's own connections come from.
*
* Selectors and checks, reading `db` inside functions on the server. The
* island takes the sources as plain props and never imports this module.
*/
import { REFERENCE_DATE, db, type Result } from "@/lib/sample-data"
export type { Result }
export type SourceId = "database" | "duesbrook" | "clickbrook" | "webhook"
/** One field a source asks for. `secret` fields are masked; `mono` ones are keys and hosts. */
export type SourceField = {
name: string
label: string
kind?: "text" | "secret" | "select"
mono?: boolean
placeholder?: string
hint?: string
options?: string[]
initial?: string
}
export type Source = {
id: SourceId
title: string
description: string
icon: "database" | "receipt" | "activity" | "webhook"
fields: SourceField[]
/** What the button that tries the connection says. */
action: string
/** For a webhook: the address to send to and the secret that signs it. */
endpoint?: { url: string; secret: string }
}
/** Every region Northwind runs services in, most-used first. */
export function regions(): string[] {
const counts = new Map<string, number>()
for (const service of db.services.all()) counts.set(service.region, (counts.get(service.region) ?? 0) + 1)
return [...counts.entries()].sort((a, b) => b[1] - a[1]).map(([region]) => region)
}
/** The four sources on offer, each with the fields it asks for. */
export function sources(): Source[] {
const [home, ...others] = regions()
return [
{
id: "database",
title: "SQL database",
description: "A database Northwind can reach over the internet, read with a user of its own.",
icon: "database",
action: "Test and connect",
fields: [
{
name: "host",
label: "Host",
mono: true,
placeholder: "db.example.com",
hint: `Connections come from ${home}.`,
},
{ name: "port", label: "Port", mono: true, initial: "5432" },
{ name: "database", label: "Database", mono: true, placeholder: "analytics" },
{
name: "user",
label: "Username",
mono: true,
placeholder: "northwind_reader",
hint: "A read-only user is enough.",
},
{ name: "password", label: "Password", kind: "secret" },
],
},
{
id: "duesbrook",
title: "Duesbrook",
description: "Invoices, subscriptions and payments from the billing system.",
icon: "receipt",
action: "Test and connect",
fields: [
{
name: "key",
label: "Restricted API key",
kind: "secret",
mono: true,
placeholder: "dues_live_…",
hint: "Starts dues_live_ or dues_test_. Read access to billing is all it needs.",
},
],
},
{
id: "clickbrook",
title: "Clickbrook",
description: "Product events as they happen: sign-ups, page views, feature use.",
icon: "activity",
action: "Test and connect",
fields: [
{ name: "key", label: "Write key", kind: "secret", mono: true, placeholder: "32 characters, 0–9 and a–f" },
{ name: "region", label: "Region", kind: "select", options: [home, ...others], initial: home },
],
},
{
id: "webhook",
title: "Webhook",
description: "Anything that can send an HTTP POST: point it at the address below.",
icon: "webhook",
action: "Send a test event",
fields: [],
endpoint: { url: "https://hooks.northwind.example/in/northwind-analytics", secret: "nwhk_4f9c2a7e81d34b06" },
},
]
}
export type Connected = { source: SourceId; message: string }
const refuse = (message: string, field?: string): Result<never> => ({
ok: false,
error: { code: field ? "invalid_input" : "unreachable", message, ...(field ? { field } : {}) },
})
const HOST = /^(?=.{3,253}$)[a-z0-9-]+(\.[a-z0-9-]+)+$/i
/** One octet as a decimal number from 0 to 255, written without a leading zero. */
const OCTET = "(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)"
/**
* An IPv4 address, strictly: four decimal octets from 0 to 255 and nothing
* else. `127.1`, `0x7f.0.0.1` and `0177.0.0.1` are not written that way — but a
* resolver reads each of them as 127.0.0.1 all the same — so they are not
* addresses here, and as names they fail too (below).
*/
const IPV4 = new RegExp(`^${OCTET}\\.${OCTET}\\.${OCTET}\\.${OCTET}$`)
/** The last labels of names that only resolve inside a network. */
const LOCAL_NAMES = ["local", "internal", "localhost"]
/**
* Where a host points: nowhere real, somewhere a server on the internet can
* never reach, or the internet.
*
* A strict IPv4 address is read by its range: this network, this machine, a
* private, shared (carrier-grade) or link-local range, and multicast and
* reserved space are all out of reach. Anything else is a name, and a name's
* last label is never all digits — no top-level domain is — so `1.2.3` and
* every other spelling of an address that is not four decimal octets is
* refused as a name, not waved through as one. A name that only resolves
* inside a network is out of reach. Octets and labels are matched whole, so
* `10.example.com` and `localhost-db.example.com` are public names.
*
* The check is on the spelling. A real connector checks the address the name
* resolves to as well, since a public name can point anywhere.
*/
function reach(host: string): "invalid" | "private" | "public" {
const ip = IPV4.exec(host)
if (ip) {
const [a, b] = ip.slice(1).map(Number)
const internal =
a === 0 ||
a === 10 ||
a === 127 ||
(a === 100 && b >= 64 && b <= 127) ||
(a === 169 && b === 254) ||
(a === 172 && b >= 16 && b <= 31) ||
(a === 192 && b === 168) ||
a >= 224
return internal ? "private" : "public"
}
const last = host.toLowerCase().split(".").at(-1)!
if (/^\d+$/.test(last)) return "invalid"
return LOCAL_NAMES.includes(last) ? "private" : "public"
}
const TIME = new Intl.DateTimeFormat("en-US", { hour: "2-digit", minute: "2-digit", hour12: false, timeZone: "UTC" })
const SOURCE_IDS: readonly SourceId[] = ["database", "duesbrook", "clickbrook", "webhook"]
/** The longest value any field takes; a host name is at most 253 characters. */
const MAX_VALUE = 256
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value)
/**
* Tries a connection with what the reader typed and answers in words: a field
* that cannot work is refused under that field, a host no server on the
* internet can reach is refused as a whole, and a connection that works says
* what happens next. Nothing is stored here: a connection that works is where
* your own store keeps the source and starts reading it, and the demo answers
* as if it had.
*
* Both arguments arrive as the page sent them. A source that is not one of the
* four is refused, whatever its shape; the values have to be a set of named
* strings, and a field whose value is not a string, or is longer than any
* field takes, is refused under that field before a pattern sees it.
*/
export async function testSource(requested: unknown, values: unknown): Promise<Result<Connected>> {
const source = SOURCE_IDS.find((entry) => entry === requested)
if (!source) return refuse("That is not a source Northwind connects to. Pick one from the list.")
if (!isRecord(values)) return refuse("Send the connection's settings, one value per field.")
const text: Record<string, string> = {}
for (const field of sources().find((entry) => entry.id === source)!.fields) {
const raw = Object.hasOwn(values, field.name) ? values[field.name] : ""
if (typeof raw !== "string" || raw.length > MAX_VALUE)
return refuse(`${field.label}: enter it as text, ${MAX_VALUE} characters at most.`, field.name)
text[field.name] = raw.trim()
}
const value = (name: string) => text[name] ?? ""
const home = regions()[0]
if (source === "database") {
const host = value("host")
if (!HOST.test(host) || reach(host) === "invalid") return refuse("Enter a host name like db.example.com.", "host")
if (reach(host) === "private")
return refuse(
`${host} is a private address, and Northwind connects from ${home} over the internet. Use the database's public host.`
)
const port = /^\d{1,5}$/.test(value("port")) ? Number(value("port")) : 0
if (port < 1 || port > 65535) return refuse("A port is a whole number from 1 to 65535.", "port")
if (!value("database")) return refuse("Name the database to read.", "database")
if (!value("user")) return refuse("Enter the username Northwind signs in as.", "user")
if (!value("password")) return refuse("Enter that user's password.", "password")
return {
ok: true,
data: {
source,
message: `Connected to ${value("database")} on ${value("host")}. Northwind reads it every 15 minutes, starting now.`,
},
}
}
if (source === "duesbrook") {
const match = /^dues_(live|test)_[A-Za-z0-9]{24}$/.exec(value("key"))
if (!match)
return refuse(
"That is not a Duesbrook key: it starts dues_live_ or dues_test_ and 24 letters and numbers follow.",
"key"
)
return {
ok: true,
data: {
source,
message:
match[1] === "test"
? "Connected with a test key: Northwind will read Duesbrook's test data only."
: "Connected to Duesbrook. The last 24 months of billing are being read now.",
},
}
}
if (source === "clickbrook") {
if (!/^[0-9a-f]{32}$/.test(value("key"))) return refuse("A write key is 32 characters, 0–9 and a–f.", "key")
if (!regions().includes(value("region"))) return refuse("Pick the region your events are sent to.", "region")
return {
ok: true,
data: { source, message: `Connected to Clickbrook in ${value("region")}. New events appear within a minute.` },
}
}
return {
ok: true,
data: {
source,
message: `A test event arrived at ${TIME.format(REFERENCE_DATE)} UTC, signed and on time. The webhook is ready.`,
},
}
}npx shadcn@latest add @vibra/onboarding-06Pick a plan with the team's size
onboarding-07How many people will use it, monthly or yearly, and the four plans side by side — each priced for the period, with the seats its price covers — over a line that adds the choice up, trial and all.
import * as React from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { PlanPicker } from "./onboarding-07.client"
import { TRIAL_DAYS, plans, startPlan, teamSize, trialEnds, type Choice } from "./onboarding-07.data"
/**
* Choosing the plan a new workspace starts on, in one framed card: how many
* people will use it, monthly or yearly, and the four plans side by side —
* each with its price for the period, the people it holds and what else it
* includes — over a line that adds the choice up, trial and all.
*
* A server component. It reads the plans and the team's size through
* `./onboarding-07.data` and hands the island `start`, a server function
* defined here that looks the plan up again before it starts anything.
*/
export function Onboarding07() {
const titleId = React.useId()
async function start(choice: Choice) {
"use server"
return startPlan(choice)
}
return (
<section
data-section="onboarding-07"
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}>Pick the plan to start on</h2>
</CardTitle>
<CardDescription>
Every paid plan starts with {TRIAL_DAYS} days free. Change plans at any time from Billing.
</CardDescription>
</CardHeader>
<CardContent className="p-0">
<PlanPicker plans={plans()} people={teamSize()} trialEnds={trialEnds()} start={start} />
</CardContent>
</Card>
</div>
</section>
)
}
export default Onboarding07"use client"
import * as React from "react"
import { CircleCheckIcon } from "lucide-react"
import { formatCurrency } from "@/lib/format"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Callout } from "@/components/ui/callout"
import { FieldDescription, FieldError, FieldLabel } from "@/components/ui/field"
import { NumberInput } from "@/components/ui/number-input"
import { SegmentedControl } from "@/components/ui/segmented-control"
import { SelectableCardGroup } from "@/components/ui/selectable-card"
import { Spinner } from "@/components/ui/spinner"
import type { Choice, Period, PlanOption, Result, Started } from "./onboarding-07.data"
const money = (cents: number) => formatCurrency(cents / 100, "USD", { maximumFractionDigits: cents % 100 ? 2 : 0 })
const saving = (plan: PlanOption) =>
plan.monthlyCents ? Math.round((1 - plan.yearlyCents / (plan.monthlyCents * 12)) * 100) : 0
const unit = (period: Period) => (period === "monthly" ? "month" : "year")
/** How many people past the seats a plan's price covers, for a team of this size. */
const beyond = (plan: PlanOption, people: number | null) =>
plan.seats === null || people === null ? 0 : Math.max(0, people - plan.seats)
/** Focus that waits for the render it depends on: a refused field is focused once its message is on the page. */
function useFocusAfterRender() {
const [request, setRequest] = React.useState<{ find: () => HTMLElement | null | undefined } | null>(null)
React.useEffect(() => {
request?.find()?.focus()
}, [request])
return React.useCallback((find: () => HTMLElement | null | undefined) => setRequest({ find }), [])
}
export type PlanPickerProps = {
plans: PlanOption[]
/** How many people will use it, to start with. */
people: number
trialEnds: string
start: (choice: Choice) => Promise<Result<Started>>
}
/**
* The team's size, the billing period and the plan, with a line under them
* that adds it up: what the plan costs, how many people its price covers,
* and when the trial ends. The cheapest plan that covers the team is picked
* to start with. A paid plan with fewer seats says how many people it would
* bill as extra seats; Free, which takes no card, says it is too small, and
* the server refuses it with the plan that covers the team. The start goes
* through the server, and what was started is said in the status line.
*/
export function PlanPicker({ plans, people: initial, trialEnds, start }: PlanPickerProps) {
const uid = React.useId()
const fitting = (count: number) =>
plans.find((plan) => plan.seats === null || plan.seats >= count) ?? plans[plans.length - 1]
const [people, setPeople] = React.useState<number | null>(initial)
const [period, setPeriod] = React.useState<Period>("monthly")
const [planId, setPlanId] = React.useState(() => fitting(initial).id)
const [error, setError] = React.useState<{ field: string; message: string } | null>(null)
const [refusal, setRefusal] = React.useState<string | null>(null)
const [pending, setPending] = React.useState(false)
const [said, setSaid] = React.useState("")
const focusAfterRender = useFocusAfterRender()
const plan = plans.find((entry) => entry.id === planId) ?? plans[0]
const cents = period === "monthly" ? plan.monthlyCents : plan.yearlyCents
const best = Math.max(...plans.map(saving))
const extra = beyond(plan, people)
async function submit() {
setError(null)
setRefusal(null)
setPending(true)
let result: Result<Started>
try {
result = await start({ planId, period, people: people ?? 0 })
} catch {
result = { ok: false, error: { code: "network", message: "The request did not reach the server. Try again." } }
} finally {
setPending(false)
}
if (!result.ok) {
const field = result.error.field
if (field === "people" || field === "plan") {
setError({ field, message: result.error.message })
return focusAfterRender(() =>
document.getElementById(`${uid}-${field}`)?.querySelector<HTMLElement>("input, [aria-checked=true]")
)
}
return setRefusal(result.error.message)
}
const started = result.data
const seats = started.extraSeats
? `, plus ${started.extraSeats} extra ${started.extraSeats === 1 ? "seat" : "seats"}`
: ""
setSaid(
started.trialEnds
? `${started.plan} is yours to try until ${started.trialEnds}. Nothing is charged before then, and then ${money(started.cents)} a ${unit(started.period)}${seats}.`
: `You are on ${started.plan}. There is nothing to pay and nothing to try.`
)
}
const describe = (field: string, hint?: string) =>
[hint, error?.field === field ? `${uid}-${field}-error` : ""].filter(Boolean).join(" ") || undefined
// Who the price is for, in the words the summary line uses.
const cover =
plan.seats === null
? "any number of people"
: plan.extraSeats
? `${plan.seats} people${extra ? `, and ${extra} more billed as extra seats` : ""}`
: `up to ${plan.seats} people`
return (
<div className="flex flex-col gap-6 p-(--card-spacing) md:p-6">
<div className="flex flex-col gap-5 sm:flex-row sm:items-end sm:justify-between">
<div id={`${uid}-people`} className="flex max-w-56 flex-col gap-2">
<FieldLabel htmlFor={`${uid}-people-input`}>People on the team</FieldLabel>
<NumberInput
id={`${uid}-people-input`}
value={people}
min={1}
max={10000}
onValueChange={(value) => {
setPeople(value)
setError(null)
}}
aria-invalid={error?.field === "people" || undefined}
aria-describedby={describe("people", `${uid}-people-hint`)}
/>
<FieldDescription id={`${uid}-people-hint`} className="text-xs">
{initial} are active in the workspace today.
</FieldDescription>
{error?.field === "people" ? <FieldError id={`${uid}-people-error`}>{error.message}</FieldError> : null}
</div>
<fieldset className="flex flex-col gap-2">
<legend className="float-left mb-2 text-sm font-medium">Billing</legend>
<SegmentedControl
aria-label="Billing"
value={period}
onValueChange={(value) => setPeriod(value as Period)}
options={[
{ value: "monthly", label: "Monthly" },
{ value: "yearly", label: `Yearly, save up to ${best}%` },
]}
/>
</fieldset>
</div>
<fieldset id={`${uid}-plan`} className="min-w-0">
<legend className="float-left mb-3 w-full text-sm font-medium">Plan</legend>
<SelectableCardGroup
type="single"
columns={4}
aria-label="Plan"
aria-describedby={describe("plan")}
value={planId}
onValueChange={(value) => {
if (value) setPlanId(value)
setError(null)
}}
options={plans.map((option) => {
const price = period === "monthly" ? option.monthlyCents : option.yearlyCents
const past = beyond(option, people)
return {
value: option.id,
title: option.name,
badge: option.popular ? <Badge variant="secondary">Most popular</Badge> : undefined,
description: (
<>
<span className="block text-sm font-semibold text-foreground tabular-nums">
{`${money(price)} a ${unit(period)}`}
{period === "yearly" && saving(option) ? (
<span className="font-normal text-muted-foreground">, saves {saving(option)}%</span>
) : null}
</span>
<span className="mt-1 block">
{option.seats === null
? "Any number of people"
: option.extraSeats
? `${option.seats} people included`
: `Up to ${option.seats} people`}
</span>
{past ? (
<span className="block text-warning">
{option.extraSeats ? `${past} more billed as extra seats` : `Too small for ${people} people`}
</span>
) : null}
<span className="mt-2 block">{option.features.join(" · ")}</span>
</>
),
}
})}
/>
{error?.field === "plan" ? (
<FieldError id={`${uid}-plan-error`} className="mt-3">
{error.message}
</FieldError>
) : null}
</fieldset>
{refusal ? (
<Callout variant="danger" role="alert" title="The plan was not started">
{refusal}
</Callout>
) : null}
<div className="flex flex-col gap-3 border-t pt-4 sm:flex-row sm:items-start sm:justify-between">
<div className="flex min-w-0 flex-col gap-2">
<p className="text-sm text-pretty">
<span className="font-medium">{plan.name}</span>
{cents ? `, ${money(cents)} a ${unit(period)} for ${cover}.` : `, for ${cover} at no cost.`}{" "}
<span className="text-muted-foreground">
{cents ? `The trial runs until ${trialEnds}; nothing is charged before then.` : "No card needed."}
</span>
</p>
<p role="status" aria-live="polite" className="flex items-start gap-2 text-sm">
{said ? (
<>
<CircleCheckIcon aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-success" />
{said}
</>
) : null}
</p>
</div>
<Button
type="button"
className="shrink-0"
disabled={pending}
focusableWhenDisabled
aria-busy={pending || undefined}
onClick={() => void submit()}
>
{pending ? <Spinner aria-hidden="true" /> : null}
{cents ? `Start the ${plan.name} trial` : `Start on ${plan.name}`}
</Button>
</div>
</div>
)
}/**
* What `onboarding-07` reads and checks: the four plans in `db.plans`, with
* what each costs by the month and by the year and how many people its price
* covers, the size of the team choosing — the workspace's active members —
* and the day a trial started today would end. `startPlan` is what the
* section's server function calls: it looks the plan up again and refuses one
* that cannot hold the team, whatever the page said.
*
* Selectors and the one check, reading `db` inside functions on the server.
* The island takes the plans as plain props and never imports this module.
*/
import { REFERENCE_DATE, db, type Plan, type Result } from "@/lib/sample-data"
export type { Result }
/** How long a paid plan can be tried before it is charged, in days. */
export const TRIAL_DAYS = 14
export type Period = "monthly" | "yearly"
export type PlanOption = {
id: string
name: string
monthlyCents: number
/** What a year costs when it is paid up front. */
yearlyCents: number
/** How many people its price covers, or null when it covers any number. */
seats: number | null
/** Whether people past `seats` are billed as extra seats; a plan without a card bills none. */
extraSeats: boolean
/** What it includes besides the seats, which the card says in its own words. */
features: string[]
popular: boolean
}
export type Choice = { planId: string; period: Period; people: number }
/** What was started: the plan, its price for the period, the trial's end on a paid plan, and the extra seats billed. */
export type Started = { plan: string; period: Period; cents: number; extraSeats: number; trialEnds?: string }
const DAY_MS = 86_400_000
const DAY = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" })
/**
* How a plan's seats are read, here and in onboarding-05 alike. `seatsIncluded`
* are the seats its price covers, and a plan whose features say "Unlimited
* seats" covers any number (null). Past its seats a paid plan bills each
* person as an extra seat; Free takes no card, so it holds its seats and no
* more.
*/
export function seatReading(plan: Plan): { included: number | null; extraSeats: boolean } {
const unlimited = plan.features.some((feature) => /^unlimited seats$/i.test(feature.trim()))
return { included: unlimited ? null : plan.seatsIncluded, extraSeats: plan.priceMonthlyCents > 0 }
}
/** Whether a plan's price covers a team of this size without an extra seat. */
export const covers = (plan: PlanOption, people: number) => plan.seats === null || plan.seats >= people
/** The plans, cheapest first, with the seat lines taken out of their features: the card counts seats itself. */
export function plans(): PlanOption[] {
return [...db.plans.all()]
.sort((a, b) => a.priceMonthlyCents - b.priceMonthlyCents)
.map((plan) => {
const { included, extraSeats } = seatReading(plan)
return {
id: plan.id,
name: plan.name,
monthlyCents: plan.priceMonthlyCents,
yearlyCents: plan.priceYearlyCents,
seats: included,
extraSeats,
features: plan.features.filter((feature) => !/\bseats?\b/i.test(feature)),
popular: Boolean(plan.popular),
}
})
}
/** How many people will use it: the workspace's active members. */
export function teamSize(): number {
return db.members.all().filter((member) => member.status === "active").length
}
/** The day a trial started now would end, in UTC. */
export function trialEnds(): string {
return DAY.format(new Date(REFERENCE_DATE.getTime() + TRIAL_DAYS * DAY_MS))
}
const refuse = (message: string, field?: string): Result<never> => ({
ok: false,
error: { code: "invalid_input", message, ...(field ? { field } : {}) },
})
/**
* Starts the plan the reader chose, after looking it up again: the team size
* has to be a whole number from 1, and the plan has to hold that many people.
* A paid plan holds anyone past its seats as an extra seat, which the answer
* counts; a plan without a card cannot, and is refused with the cheapest plan
* that covers the team. A paid plan starts on a trial that ends `TRIAL_DAYS`
* from now; Free has nothing to try. The choice arrives as the page sent it,
* so its shape is checked first and anything else is refused as a Result,
* never thrown.
*/
export async function startPlan(choice: unknown): Promise<Result<Started>> {
if (typeof choice !== "object" || choice === null || Array.isArray(choice))
return refuse("Send a plan, a billing period and the team's size.")
const { planId, period, people } = choice as Record<string, unknown>
if (typeof people !== "number" || !Number.isInteger(people) || people < 1 || people > 10_000)
return refuse("Say how many people will use it: a whole number from 1.", "people")
const all = plans()
const plan = typeof planId === "string" ? all.find((entry) => entry.id === planId) : undefined
if (!plan) return refuse("Pick a plan.", "plan")
if (period !== "monthly" && period !== "yearly") return refuse("Pick monthly or yearly billing.", "period")
if (!covers(plan, people) && !plan.extraSeats) {
const fits = all.find((entry) => covers(entry, people))
return refuse(
`${plan.name} holds ${plan.seats} people and the team is ${people}.${fits ? ` ${fits.name} covers ${people} without extra seats.` : ""}`,
"plan"
)
}
// Nothing is started here: this is where your own billing opens the trial
// or the subscription. The demo answers as if it had.
const cents = period === "monthly" ? plan.monthlyCents : plan.yearlyCents
const extraSeats = plan.seats === null ? 0 : Math.max(0, people - plan.seats)
return {
ok: true,
data: { plan: plan.name, period, cents, extraSeats, ...(cents > 0 ? { trialEnds: trialEnds() } : {}) },
}
}npx shadcn@latest add @vibra/onboarding-07All set: what was configured
onboarding-08The end of setup: every part of the workspace as it stands now — where it lives, the people, the plan, the data, sign-in and keys — each with a way to change it, beside the dated things that happen next.
import * as React from "react"
import Link from "next/link"
import {
ArrowRightIcon,
BuildingIcon,
CreditCardIcon,
DatabaseIcon,
KeyRoundIcon,
ShieldCheckIcon,
UsersIcon,
type LucideIcon,
} from "lucide-react"
import { AvatarGroup } from "@/components/ui/avatar-group"
import { buttonVariants } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import { EmailSummary } from "./onboarding-08.client"
import { WORKSPACE, emailSummary, summary, type SummaryIcon } from "./onboarding-08.data"
const ICONS: Record<SummaryIcon, LucideIcon> = {
workspace: BuildingIcon,
people: UsersIcon,
plan: CreditCardIcon,
data: DatabaseIcon,
security: ShieldCheckIcon,
keys: KeyRoundIcon,
}
/**
* The end of setup, in one framed card: everything the workspace was set up
* with — where it lives, who is in it, the plan, the data, how it signs in and
* its keys — each with where it stands now and a link to change it, beside
* the dated things that happen next. The footer strip opens the workspace and
* can email the summary to its owner.
*
* A server component. It reads the summary through `./onboarding-08.data` and
* hands the island `send`, a server function defined here that mails the
* summary to the owner's own address.
*/
export function Onboarding08() {
const titleId = React.useId()
const setup = summary()
async function send() {
"use server"
return emailSummary()
}
return (
<section
data-section="onboarding-08"
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}>{WORKSPACE.name} is ready</h2>
</CardTitle>
<CardDescription>
Set up by {setup.owner}, who opened it on {setup.since}. Here is everything it was set up with.
</CardDescription>
</CardHeader>
<CardContent className="grid p-0 lg:grid-cols-[minmax(0,8fr)_minmax(0,4fr)]">
<dl className="grid content-start sm:grid-cols-2">
{setup.items.map((item) => {
const Icon = ICONS[item.icon]
return (
<div
key={item.term}
className="flex flex-col gap-1.5 border-b p-(--card-spacing) sm:odd:border-e md:p-5"
>
<dt className="flex items-center gap-2 text-xs text-muted-foreground">
<Icon aria-hidden="true" className="size-4 shrink-0" />
{item.term}
</dt>
<dd className="flex flex-col gap-1 ps-6">
<span className="text-sm font-medium tabular-nums">{item.value}</span>
<span className="text-xs text-pretty text-muted-foreground">{item.detail}</span>
{item.icon === "people" ? (
<AvatarGroup
aria-hidden="true"
size="sm"
className="mt-1"
users={setup.people.map((person) => ({ name: person.name, src: person.face }))}
/>
) : null}
<Link
href={item.href}
aria-label={`Change ${item.term.toLowerCase()}`}
className="mt-1 w-fit text-xs text-foreground underline underline-offset-4"
>
Change
</Link>
</dd>
</div>
)
})}
</dl>
<div className="flex flex-col gap-4 p-(--card-spacing) md:p-5 lg:border-s">
<h3 className="text-sm font-semibold">What happens next</h3>
<ol className="flex flex-col gap-4">
{setup.next.map((thing) => (
<li key={thing.iso} className="grid grid-cols-[5.5rem_minmax(0,1fr)] gap-3 text-sm">
<time dateTime={thing.iso} className="text-muted-foreground tabular-nums">
{thing.date}
</time>
<span className="text-pretty">{thing.what}</span>
</li>
))}
</ol>
</div>
</CardContent>
<CardFooter className="flex-wrap gap-3">
<Link href="/" className={buttonVariants()}>
Open {WORKSPACE.name}
<ArrowRightIcon data-icon="inline-end" aria-hidden="true" />
</Link>
<EmailSummary send={send} />
</CardFooter>
</Card>
</div>
</section>
)
}
export default Onboarding08"use client"
import * as React from "react"
import { CircleCheckIcon, MailIcon } from "lucide-react"
import { AsyncButton } from "@/components/ui/async-button"
import { Callout } from "@/components/ui/callout"
import type { Result } from "./onboarding-08.data"
/**
* Sends the summary to the owner's own address and says where it went. The
* button holds its spinner while the server works; a refusal opens a danger
* callout above it, and the confirmation goes in the always-mounted status
* line beside it — never the same slot.
*/
export function EmailSummary({ send }: { send: () => Promise<Result<{ to: string }>> }) {
const [said, setSaid] = React.useState("")
const [refusal, setRefusal] = React.useState<string | null>(null)
async function run() {
setRefusal(null)
// The AsyncButton frees itself whatever happens; a request that never
// reached the server is this card's to report, like any refusal.
let result: Result<{ to: string }>
try {
result = await send()
} catch {
result = { ok: false, error: { code: "network", message: "The request did not reach the server. Try again." } }
}
if (!result.ok) {
setSaid("")
return setRefusal(result.error.message)
}
setSaid(`Sent to ${result.data.to}.`)
}
return (
<div className="flex flex-col gap-2">
{refusal ? (
<Callout variant="danger" role="alert" title="The summary was not sent">
{refusal}
</Callout>
) : null}
<div className="flex flex-wrap items-center gap-3">
<AsyncButton type="button" variant="outline" onClick={run}>
<MailIcon aria-hidden="true" data-icon="inline-start" />
Email me this summary
</AsyncButton>
<p role="status" aria-live="polite" className="flex min-w-0 items-center gap-1.5 text-sm">
{said ? (
<>
<CircleCheckIcon aria-hidden="true" className="size-4 shrink-0 text-success" />
{said}
</>
) : null}
</p>
</div>
</div>
)
}/**
* What `onboarding-08` reads: everything the workspace was set up with, as the
* rows now have it — where it lives (the region `db.services` runs in most),
* who is in it and who is still invited, the plan that holds them, the data
* connected to it, how it signs in and the keys it can be reached with — and
* the dated things that happen next, each read off a row or counted from
* `REFERENCE_DATE`. `emailSummary` is what the section's server function
* calls: it sends the summary to the owner's own address, never one the page
* names.
*
* Selectors only, reading `db` inside functions on the server. The island
* takes nothing from this module but types.
*/
import { REFERENCE_DATE, db, seatUsage, type Result } from "@/lib/sample-data"
export type { Result }
export const WORKSPACE = { name: "Northwind Analytics", url: "app.northwind.example/northwind-analytics" }
export type SummaryIcon = "workspace" | "people" | "plan" | "data" | "security" | "keys"
/** One thing that was set up: what it is, where it stands, and where to change it. */
export type SummaryItem = { icon: SummaryIcon; term: string; value: string; detail: string; href: string }
export type Person = { name: string; face?: string }
export type NextThing = { date: string; iso: string; what: string }
export type Summary = {
owner: string
since: string
items: SummaryItem[]
people: Person[]
next: NextThing[]
}
const DAY = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" })
const SHORT = new Intl.DateTimeFormat("en-US", { weekday: "short", month: "short", day: "numeric", timeZone: "UTC" })
const plural = (count: number, one: string, many: string) => `${count} ${count === 1 ? one : many}`
/** The next Monday after `REFERENCE_DATE`, at 07:00 UTC: when the weekly digest goes out. */
function nextDigest(): Date {
const date = new Date(REFERENCE_DATE)
const ahead = (8 - date.getUTCDay()) % 7 || 7
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate() + ahead, 7))
}
/** The whole set-up, read off the rows as they stand. */
export function summary(): Summary {
const members = db.members.all()
const owner = members.find((member) => member.role === "owner") ?? members[0]
const active = members.filter((member) => member.status === "active")
const open = db.invitations
.all()
.filter((invitation) => invitation.status === "pending")
.sort((a, b) => a.expiresAt.getTime() - b.expiresAt.getTime())
const regions = new Map<string, number>()
for (const service of db.services.all()) regions.set(service.region, (regions.get(service.region) ?? 0) + 1)
const region = [...regions.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? ""
const people = members.filter((member) => member.status !== "deactivated").length
const joining = people - active.length
// The workspace's plan and its seats are `seatUsage()`, the count every page
// shows: every member who is not deactivated — in, or invited and yet to
// join — and every open invitation, against the seats the plan includes.
const usage = seatUsage()
const seats = usage.used
const plan = usage.plan
const parts = `${plural(usage.members, "member", "members")} and ${plural(usage.invitations, "open invitation", "open invitations")}`
const connected = db.integrations.all().filter((integration) => integration.status !== "available")
const failing = connected.filter((integration) => integration.status === "error").length
const keys = db.apiKeys.all().filter((key) => key.status === "active")
const protectedCount = active.filter((member) => member.twoFactor).length
const nextInvoice = new Date(Date.UTC(REFERENCE_DATE.getUTCFullYear(), REFERENCE_DATE.getUTCMonth() + 1, 1))
const next: { at: Date; what: string }[] = [
{
at: nextDigest(),
what: `The first weekly digest reaches ${plural(active.length, "person", "people")} at 07:00 UTC.`,
},
...(open[0]
? [{ at: open[0].expiresAt, what: `The invitation to ${open[0].email} lapses unless it is accepted.` }]
: []),
{ at: nextInvoice, what: `The first ${plan.name} invoice is issued.` },
]
return {
owner: owner.name,
since: DAY.format(owner.joinedAt),
items: [
{
icon: "workspace",
term: "Workspace",
value: WORKSPACE.name,
detail: `${WORKSPACE.url}, data kept in ${region}`,
href: "/settings",
},
{
icon: "people",
term: "People",
value: plural(active.length, "person", "people"),
detail:
[
joining ? `${joining} more yet to join` : "",
open.length ? `${plural(open.length, "invitation", "invitations")} still open` : "",
]
.filter(Boolean)
.join(", ") || "Everyone invited has joined",
href: "/settings/members",
},
{
icon: "plan",
term: "Plan",
value: plan.name,
detail: usage.limit === null ? `${seats} seats taken: ${parts}` : `${seats} of ${usage.limit} seats taken: ${parts}`,
href: "/settings/billing",
},
{
icon: "data",
term: "Data",
value: plural(connected.length, "source connected", "sources connected"),
detail: failing ? `${plural(failing, "needs", "need")} attention` : "All of them are sending data",
href: "/settings/integrations",
},
{
icon: "security",
term: "Sign-in",
value: `${protectedCount} of ${active.length} use two-factor`,
detail:
protectedCount === active.length
? "Everyone has a second factor"
: `${active.length - protectedCount} still sign in with a password alone`,
href: "/settings/security",
},
{
icon: "keys",
term: "API keys",
value: plural(keys.length, "active key", "active keys"),
detail: keys.some((key) => !key.lastUsedAt)
? `${plural(keys.filter((key) => !key.lastUsedAt).length, "has", "have")} never been used`
: "Every one of them has been used",
href: "/settings/api-keys",
},
],
people: active.slice(0, 5).map((member) => ({ name: member.name, face: member.avatarUrl })),
next: next
.sort((a, b) => a.at.getTime() - b.at.getTime())
.map((thing) => ({ date: SHORT.format(thing.at), iso: thing.at.toISOString(), what: thing.what })),
}
}
/**
* Emails the summary to the owner. The address is read off the owner's row,
* never taken from the page, so the summary can only go to the person it is
* about. The mail itself is your mailer's to send; this answers with where it
* went.
*/
export async function emailSummary(): Promise<Result<{ to: string }>> {
const owner = db.members.all().find((member) => member.role === "owner")
if (!owner)
return { ok: false, error: { code: "no_owner", message: "The workspace has no owner to send the summary to." } }
return { ok: true, data: { to: owner.email } }
}npx shadcn@latest add @vibra/onboarding-08