/setupSetup wizard
A four-step wizard — workspace, invitations, sources, done — that keeps the step it is on in the URL, validates each step before it moves, and finishes through a server action.
The step lives in the URL: `?step=` is where the wizard opens and what every move writes back, so a reload or a shared link lands on the same step and Back and Forward move through the wizard. The query is written relative to the current route, so the page keeps working when it is framed somewhere else. The regions on offer are the ones db.services runs in, the invite step opens with the invitations db still has pending, and the cards are the integrations that are available rather than already connected. Each step states what it insists on before it will move, and shows the message beside the field it names; completeSetup is a server action returning Result: it validates what the step calling it owns, and the wizard maps error.field to the step that holds it — showing the message there, moving to that step if it is not the one on screen, and falling back to a form-level alert for a field this wizard has no control for. The island reads useSearchParams, so the page wraps it in Suspense — a prerendered page that calls it outside one fails the build. Composes AppShell, PageHeader, Stepper, StepperActions, Card, SectionHeader, FormRow, Input, NativeSelect, TagInput and SelectableCardGroup.
Preview
import * as React from "react"
import { REFERENCE_DATE } from "@/lib/sample-data"
import { AppShell } from "@/components/ui/app-shell"
import { PageHeader } from "@/components/ui/page-header"
import { Skeleton } from "@/components/ui/skeleton"
import { signOut } from "./actions"
import { SetupWizard } from "./components/setup-wizard"
import {
connectableSources,
connectedSourceNames,
currentUser,
pendingInvites,
regions,
shellNotifications,
WORKSPACE,
} from "./data"
import { NAV, ROUTE } from "./nav"
/**
* The setup wizard. The page is a server component: it reads the regions, the
* invitations still pending and the sources still available through `db`, and
* hands them to one client island that owns the step. The island reads the
* `step` query, so it sits inside a Suspense boundary — a prerendered page
* that calls `useSearchParams` outside one fails the build.
*/
export default function SetupPage() {
const user = currentUser()
const available = regions()
return (
<AppShell
nav={NAV}
activeHref={ROUTE}
user={user}
notifications={shellNotifications()}
now={REFERENCE_DATE}
onSignOut={signOut}
>
<PageHeader
title="Set up Northwind"
description="Four steps, and the workspace is ready for the team. Each one is saved as you go."
/>
<React.Suspense fallback={<Skeleton className="h-96 w-full rounded-xl" />}>
<SetupWizard
regions={available}
sources={connectableSources()}
connected={connectedSourceNames()}
initialDraft={{
name: WORKSPACE.name,
region: available[0],
emails: pendingInvites(),
sourceIds: [],
}}
/>
</React.Suspense>
</AppShell>
)
}Install
npx shadcn@latest add @vibra/onboarding-setupNeeds the @vibra registry in your components.json — set it up once.
Source
import * as React from "react"
import { REFERENCE_DATE } from "@/lib/sample-data"
import { AppShell } from "@/components/ui/app-shell"
import { PageHeader } from "@/components/ui/page-header"
import { Skeleton } from "@/components/ui/skeleton"
import { signOut } from "./actions"
import { SetupWizard } from "./components/setup-wizard"
import {
connectableSources,
connectedSourceNames,
currentUser,
pendingInvites,
regions,
shellNotifications,
WORKSPACE,
} from "./data"
import { NAV, ROUTE } from "./nav"
/**
* The setup wizard. The page is a server component: it reads the regions, the
* invitations still pending and the sources still available through `db`, and
* hands them to one client island that owns the step. The island reads the
* `step` query, so it sits inside a Suspense boundary — a prerendered page
* that calls `useSearchParams` outside one fails the build.
*/
export default function SetupPage() {
const user = currentUser()
const available = regions()
return (
<AppShell
nav={NAV}
activeHref={ROUTE}
user={user}
notifications={shellNotifications()}
now={REFERENCE_DATE}
onSignOut={signOut}
>
<PageHeader
title="Set up Northwind"
description="Four steps, and the workspace is ready for the team. Each one is saved as you go."
/>
<React.Suspense fallback={<Skeleton className="h-96 w-full rounded-xl" />}>
<SetupWizard
regions={available}
sources={connectableSources()}
connected={connectedSourceNames()}
initialDraft={{
name: WORKSPACE.name,
region: available[0],
emails: pendingInvites(),
sourceIds: [],
}}
/>
</React.Suspense>
</AppShell>
)
}import { type NavConfig } from "@/lib/nav-config"
/** The route this page is installed at. AppShell matches the nav against it. */
export const ROUTE = "/setup"
/**
* This product's navigation, as plain data. AppShell resolves the icon names
* and works out which item is current from the pathname, so nothing here is a
* component and nothing here says "I am the page you are on".
*/
export const NAV: NavConfig = {
brand: { name: "Northwind", initial: "N", href: "/saas", caption: "Production" },
groups: [
{
label: "Getting started",
items: [
{ title: "Welcome", href: "/welcome", icon: "home" },
{ title: "Setup guide", href: "/setup", icon: "list" },
],
},
{
label: "Workspace",
items: [
{ title: "Overview", href: "/saas", icon: "layout-dashboard" },
{ title: "Customers", href: "/ecommerce/customers", icon: "users" },
{ title: "Reports", href: "/reports", icon: "file-text" },
],
},
],
// Pinned under the groups, the way the secondary links were.
footer: [
{ title: "Settings", href: "/settings", icon: "settings" },
{ title: "Support", href: "/support", icon: "life-buoy" },
],
}/**
* What this page reads. The regions on offer are the ones `db.services`
* actually runs in, the invite step opens with the invitations `db` still has
* pending, and the sources to connect are the integrations that are available
* rather than already wired up. The step list itself is UI vocabulary and
* lives with the component that draws it.
*/
import { getInitials } from "@/lib/format"
import { db, type Member } from "@/lib/sample-data"
/**
* The workspace's own configuration — a name and a slug rather than a row in a
* repository, so it is stated here, the way `settings-general` states its own.
*/
export const WORKSPACE = { name: "Northwind Analytics", slug: "northwind" }
/** Every region the platform already runs a service 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 invitations still waiting to be accepted — the invite step opens with them. */
export function pendingInvites(): string[] {
return db.invitations
.all()
.filter((invitation) => invitation.status === "pending")
.map((invitation) => invitation.email)
}
/** One source the workspace could connect, as the card that offers it. */
export type Source = {
id: string
name: string
description: string
category: string
}
/** The eight most useful sources that are not connected yet. */
export function connectableSources(): Source[] {
return db.integrations
.all()
.filter((row) => row.status === "available")
.slice(0, 8)
.map(({ id, name, description, category }) => ({ id, name, description, category }))
}
/** What is already wired up, named in a line under the cards. */
export function connectedSourceNames(): string[] {
return db.integrations
.all()
.filter((row) => row.status === "connected")
.map((row) => row.name)
}
function ownerRow(): Member {
return db.members.all().find((member) => member.role === "owner") ?? db.members.all()[0]
}
export function currentUser() {
const owner = ownerRow()
return { name: owner.name, email: owner.email, initials: getInitials(owner.name), avatarUrl: owner.avatarUrl }
}
export function shellNotifications() {
return db.notifications
.all()
.sort((a, b) => b.at.getTime() - a.at.getTime())
.slice(0, 6)
.map(({ id, title, description, at, read, href }) => ({ id, title, description, at, read, href }))
}"use server"
import { mockAuthAdapter } from "@/lib/auth-adapter"
import { asString, db, fields, type Result } from "@/lib/sample-data"
/**
* The two things this page changes. Both are server actions so the page can
* stay a server component, and both return `Result` so the caller reads the
* same success-or-error shape every mutation returns. `error.field` names a
* field the wizard can find: the wizard shows the message beside it, on the
* step that owns it, and anything unnamed in a form-level alert.
*/
export async function signOut(): Promise<Result<{ signedOut: true }>> {
await mockAuthAdapter.signOut()
return { ok: true, data: { signedOut: true } }
}
export type SetupInput = {
name: string
region: string
emails: string[]
sourceIds: string[]
}
/** A workspace's name, and the most invitations the wizard's invite step sends. */
const MAX_NAME = 60
const MAX_INVITES = 50
export type SetupSummary = {
name: string
region: string
invited: number
connected: number
}
/**
* Finishes setup: names the workspace, sends the invitations, and marks the
* chosen sources connected. The repository is the seam — swap it for a real
* store and the wizard above is unchanged.
*/
export async function completeSetup(sent: SetupInput): Promise<Result<SetupSummary>> {
const input = fields(sent)
const name = asString(input.name).trim()
if (!name) {
// Nothing on the step that calls this owns the name, so this one is the
// form's to announce: it can only happen to a caller that skipped ahead.
return { ok: false, error: { code: "required", message: "Give the workspace a name." } }
}
if (name.length > MAX_NAME) {
return { ok: false, error: { code: "invalid_input", message: `Keep the workspace's name under ${MAX_NAME} characters.` } }
}
const emails = input.emails ?? []
const sourceIds = input.sourceIds ?? []
if (!Array.isArray(emails) || !Array.isArray(sourceIds) || emails.length > MAX_INVITES || sourceIds.some((id) => typeof id !== "string")) {
return { ok: false, error: { code: "invalid_input", message: "Finish setup from the wizard's own steps." } }
}
if (sourceIds.length === 0) {
return {
ok: false,
error: {
code: "required",
message: "Pick at least one source to connect.",
field: "sourceIds",
},
}
}
for (const id of new Set(sourceIds)) {
const connected = await db.integrations.update(id, { status: "connected" })
if (!connected.ok) return { ok: false, error: connected.error }
}
return {
ok: true,
data: {
name,
region: asString(input.region).slice(0, 40),
invited: emails.length,
connected: new Set(sourceIds).size,
},
}
}"use client"
import * as React from "react"
import { useRouter, useSearchParams } from "next/navigation"
import { Card, CardContent } from "@/components/ui/card"
import { SectionHeader } from "@/components/ui/section-header"
import { Stepper, StepperActions, type Step } from "@/components/ui/stepper"
import { completeSetup, type SetupSummary } from "../actions"
import { type Source } from "../data"
import { ConnectStep, DoneStep, InviteStep, WorkspaceStep, type Draft } from "./setup-steps"
/** The wizard's own vocabulary: what the four steps are called, in order. */
const STEPS: Step[] = [
{ id: "workspace", title: "Workspace", description: "Name and region" },
{ id: "invite", title: "Invite", description: "Who else gets in" },
{ id: "connect", title: "Connect", description: "Where data comes from" },
{ id: "done", title: "Done", description: "Ready to use" },
]
/** The heading and the line under it, per step. */
const HEADINGS: Record<string, { title: string; description: string }> = {
workspace: {
title: "Name your workspace",
description: "Everyone you invite sees this name, and every event lands in this region.",
},
invite: {
title: "Invite your team",
description: "Invitations that are already out are listed; add anyone else who needs in.",
},
connect: {
title: "Connect a source",
description: "Pick where your product events already land. You can add more later.",
},
done: { title: "Setup complete", description: "The workspace is ready for the team." },
}
/**
* Which step owns each field a server action can pin a message to. A message
* has to land somewhere the reader can act on it, so an action that names a
* field takes them to the step holding it.
*/
const FIELD_STEP: Record<string, string> = {
name: "workspace",
emails: "invite",
sourceIds: "connect",
}
/** The step the URL names, or the first one when it names none or names nonsense. */
function stepIndexFor(id: string | null): number {
const index = STEPS.findIndex((step) => step.id === id)
return index === -1 ? 0 : index
}
export type SetupWizardProps = {
regions: string[]
sources: Source[]
connected: string[]
initialDraft: Draft
}
/**
* The wizard. The URL's `step` is where it starts and what it writes back to
* on every move, so a reload or a shared link opens the same step; the query
* is written relative to whatever route the page is framed under, so a preview
* stays on the preview. Nothing navigates on its own.
*/
export function SetupWizard({ regions, sources, connected, initialDraft }: SetupWizardProps) {
const router = useRouter()
const params = useSearchParams()
const urlStep = params.get("step")
const [index, setIndex] = React.useState(() => stepIndexFor(urlStep))
const [draft, setDraft] = React.useState<Draft>(initialDraft)
const [error, setError] = React.useState<string | undefined>(undefined)
const [formError, setFormError] = React.useState<string | undefined>(undefined)
const [summary, setSummary] = React.useState<SetupSummary | null>(null)
const [pending, setPending] = React.useState(false)
// The URL stays the source of truth: Back and Forward change it and the
// wizard follows, derived during render rather than in an effect so the step
// never paints a frame behind the address bar. `lastUrlStep` mirrors what the
// URL said and nothing else — writing the wizard's own move into it would
// make the next render read the not-yet-updated query as a move back.
const [lastUrlStep, setLastUrlStep] = React.useState(urlStep)
if (urlStep !== lastUrlStep) {
setLastUrlStep(urlStep)
setIndex(stepIndexFor(urlStep))
setError(undefined)
}
const current = STEPS[index]
const heading = HEADINGS[current.id]
function goTo(next: number) {
setIndex(next)
setError(undefined)
// Relative, so the step is written under whatever route the page is framed
// at: `/setup?step=invite` in an app, the preview's own route in a gallery.
router.replace(`?step=${STEPS[next].id}`, { scroll: false })
}
/** What each step insists on before it will let the wizard move forward. */
function problemWith(step: string): string | undefined {
if (step === "workspace" && !draft.name.trim()) return "Give the workspace a name."
// The connect step insists on a source too, but completeSetup is what says
// so: the check belongs with the write, and the wizard shows what comes
// back beside the cards it names.
return undefined
}
async function next() {
const problem = problemWith(current.id)
if (problem) {
setError(problem)
return
}
if (current.id !== "connect") {
goTo(index + 1)
return
}
setPending(true)
setFormError(undefined)
const result = await completeSetup({
name: draft.name,
region: draft.region,
emails: draft.emails,
sourceIds: draft.sourceIds,
})
setPending(false)
if (!result.ok) {
const owner = result.error.field ? FIELD_STEP[result.error.field] : undefined
const step = owner ? STEPS.findIndex((entry) => entry.id === owner) : -1
// A field nothing on this wizard holds cannot be shown beside anything,
// so it is the form's to announce rather than a message pointing at a
// control that is not there.
if (step === -1) {
setFormError(result.error.message)
return
}
setFormError(undefined)
if (step !== index) goTo(step)
// After goTo, which clears the error it is about to replace.
setError(result.error.message)
return
}
setSummary(result.data)
goTo(index + 1)
}
const change = (patch: Partial<Draft>) => setDraft((value) => ({ ...value, ...patch }))
// A wizard is a form, so it keeps a readable measure rather than stretching
// its fields the full width of a desktop shell.
return (
<section aria-label="Workspace setup" className="flex max-w-3xl flex-col gap-6">
<Stepper
steps={STEPS}
activeStep={index}
onStepClick={goTo}
clickable={summary ? "none" : "completed"}
/>
<Card>
<CardContent className="flex flex-col gap-5 py-1">
<SectionHeader as="h2" title={heading.title} description={heading.description} />
{current.id === "workspace" ? (
<WorkspaceStep draft={draft} onChange={change} error={error} regions={regions} />
) : null}
{current.id === "invite" ? (
<InviteStep draft={draft} onChange={change} error={error} />
) : null}
{current.id === "connect" ? (
<ConnectStep
draft={draft}
onChange={change}
error={error}
sources={sources}
connected={connected}
/>
) : null}
{current.id === "done" && summary ? <DoneStep summary={summary} /> : null}
{current.id === "done" && !summary ? (
<p className="text-sm text-muted-foreground">
Nothing has been saved yet — step back through the wizard to finish setup.
</p>
) : null}
{formError ? (
<p role="alert" className="text-sm text-danger">
{formError}
</p>
) : null}
{current.id === "done" ? null : (
<StepperActions
onBack={index === 0 ? undefined : () => goTo(index - 1)}
onNext={next}
isLast={current.id === "connect"}
finishText="Finish setup"
loading={pending}
/>
)}
</CardContent>
</Card>
</section>
)
}"use client"
import * as React from "react"
import { CheckIcon } from "lucide-react"
import { isEmail } from "@/lib/validation"
import { FormRow } from "@/components/ui/form-section"
import { Input } from "@/components/ui/input"
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select"
import { SelectableCardGroup } from "@/components/ui/selectable-card"
import { TagInput } from "@/components/ui/tag-input"
import { type Source } from "../data"
import { type SetupSummary } from "../actions"
/** The shape every step panel is handed: the draft, and the way to change it. */
export type Draft = {
name: string
region: string
emails: string[]
sourceIds: string[]
}
export type StepPanelProps = {
draft: Draft
onChange: (patch: Partial<Draft>) => void
/** The message for the field this step names, or undefined while it is fine. */
error?: string
}
/** Step 1: what the workspace is called and where its data lives. */
export function WorkspaceStep({
draft,
onChange,
error,
regions,
}: StepPanelProps & { regions: string[] }) {
return (
<div className="flex max-w-lg flex-col gap-4">
<FormRow label="Workspace name" htmlFor="setup-name" required error={error}>
<Input
id="setup-name"
value={draft.name}
placeholder="Northwind Analytics"
onChange={(event) => onChange({ name: event.target.value })}
/>
</FormRow>
<FormRow
label="Region"
htmlFor="setup-region"
description="Where events are stored. This cannot be changed later."
>
<NativeSelect
id="setup-region"
className="w-full"
value={draft.region}
onChange={(event) => onChange({ region: event.target.value })}
>
{regions.map((region) => (
<NativeSelectOption key={region} value={region}>
{region}
</NativeSelectOption>
))}
</NativeSelect>
</FormRow>
</div>
)
}
/** Step 2: who else gets in. Opens with the invitations db still has pending. */
export function InviteStep({ draft, onChange, error }: StepPanelProps) {
return (
// TagInput spreads its props onto the chip container rather than the field
// inside it, so a FormRow could not point a label or an error at the input;
// the field names itself and the message announces itself instead.
<div className="flex max-w-lg flex-col gap-2">
<div className="flex flex-col gap-1">
<span className="text-sm leading-none font-medium">Email addresses</span>
<p className="text-xs text-muted-foreground">
Enter or a comma adds an address. Everyone starts as a member, and an entry that is
not an email stays in the field to be fixed.
</p>
</div>
<TagInput
aria-label="Email addresses"
value={draft.emails}
onValueChange={(emails) => onChange({ emails })}
placeholder="teammate@northwind.example"
validate={(tag) => isEmail(tag)}
/>
{error ? (
<p role="alert" className="text-xs text-danger">
{error}
</p>
) : null}
</div>
)
}
/** Step 3: which of the sources that are still available to send data. */
export function ConnectStep({
draft,
onChange,
error,
sources,
connected,
}: StepPanelProps & { sources: Source[]; connected: string[] }) {
return (
<div className="flex flex-col gap-4">
<SelectableCardGroup
type="multiple"
columns={2}
aria-label="Sources to connect"
value={draft.sourceIds}
onValueChange={(sourceIds) => onChange({ sourceIds })}
options={sources.map((source) => ({
value: source.id,
title: source.name,
description: source.description,
badge: source.category.replace(/-/g, " "),
}))}
/>
{error ? (
<p role="alert" className="text-xs text-danger">
{error}
</p>
) : null}
{connected.length ? (
<p className="text-xs text-muted-foreground">
Already connected: {connected.join(", ")}.
</p>
) : null}
</div>
)
}
/**
* Step 4: what setup did. The heading takes focus when the wizard is replaced
* by it, so a reader who is not looking at the screen is told where they are.
*/
export function DoneStep({ summary }: { summary: SetupSummary }) {
const heading = React.useRef<HTMLHeadingElement>(null)
React.useEffect(() => {
heading.current?.focus()
}, [])
return (
<div className="flex flex-col items-start gap-4">
<span className="flex size-9 items-center justify-center rounded-full bg-success-muted text-success">
<CheckIcon aria-hidden="true" className="size-5" />
</span>
<h2 ref={heading} tabIndex={-1} className="text-base font-medium outline-none">
{summary.name} is ready
</h2>
<dl className="grid gap-x-6 gap-y-2 text-sm sm:grid-cols-2">
<div className="flex gap-2">
<dt className="text-muted-foreground">Region</dt>
<dd className="font-mono text-xs leading-5">{summary.region}</dd>
</div>
<div className="flex gap-2">
<dt className="text-muted-foreground">Invitations sent</dt>
<dd className="tabular-nums">{summary.invited}</dd>
</div>
<div className="flex gap-2">
<dt className="text-muted-foreground">Sources connected</dt>
<dd className="tabular-nums">{summary.connected}</dd>
</div>
</dl>
</div>
)
}