/welcomeWelcome
The first page of a new workspace: a setup checklist whose steps are answered from the data, three counts of how far it already is, and a card for each step still to do.
Every step on the checklist is a question asked of db — is more than one member active, is an analytics integration connected, does an active API key exist, does everyone have two-factor — so the progress bar moves when the workspace does rather than because a boolean was written out. The checklist and the What's next cards are one client island, because ticking a step off the list is what takes its card out of the cards below it; the page itself stays a server component and hands the island the answers it read. Composes AppShell, PageHeader, StatCardGroup, StatCard, OnboardingChecklist, SectionHeader and Card.
Preview
import { REFERENCE_DATE } from "@/lib/sample-data"
import { AppShell } from "@/components/ui/app-shell"
import { PageHeader } from "@/components/ui/page-header"
import { signOut } from "./actions"
import { GettingStarted } from "./components/getting-started"
import { WorkspaceTotals } from "./components/workspace-totals"
import { currentUser, setupTasks, shellNotifications, WORKSPACE } from "./data"
import { NAV, ROUTE } from "./nav"
/**
* The first page of a new workspace. The page is a server component: it asks
* `db` how far the workspace already is and hands the answer to one client
* island, which owns the ticks the reader makes on the checklist.
*/
export default function WelcomePage() {
const user = currentUser()
return (
<AppShell
nav={NAV}
activeHref={ROUTE}
user={user}
notifications={shellNotifications()}
now={REFERENCE_DATE}
onSignOut={signOut}
>
<PageHeader
title="Welcome to Northwind"
description={`You are signed in as ${user.name}. Finish the list below and the workspace is ready for everyone else.`}
meta={`${WORKSPACE.name} · ${WORKSPACE.region}`}
/>
<WorkspaceTotals />
<GettingStarted tasks={setupTasks()} />
</AppShell>
)
}Install
npx shadcn@latest add @vibra/onboarding-welcomeNeeds the @vibra registry in your components.json — set it up once.
Source
import { REFERENCE_DATE } from "@/lib/sample-data"
import { AppShell } from "@/components/ui/app-shell"
import { PageHeader } from "@/components/ui/page-header"
import { signOut } from "./actions"
import { GettingStarted } from "./components/getting-started"
import { WorkspaceTotals } from "./components/workspace-totals"
import { currentUser, setupTasks, shellNotifications, WORKSPACE } from "./data"
import { NAV, ROUTE } from "./nav"
/**
* The first page of a new workspace. The page is a server component: it asks
* `db` how far the workspace already is and hands the answer to one client
* island, which owns the ticks the reader makes on the checklist.
*/
export default function WelcomePage() {
const user = currentUser()
return (
<AppShell
nav={NAV}
activeHref={ROUTE}
user={user}
notifications={shellNotifications()}
now={REFERENCE_DATE}
onSignOut={signOut}
>
<PageHeader
title="Welcome to Northwind"
description={`You are signed in as ${user.name}. Finish the list below and the workspace is ready for everyone else.`}
meta={`${WORKSPACE.name} · ${WORKSPACE.region}`}
/>
<WorkspaceTotals />
<GettingStarted tasks={setupTasks()} />
</AppShell>
)
}import { type NavConfig } from "@/lib/nav-config"
/** The route this page is installed at. AppShell matches the nav against it. */
export const ROUTE = "/welcome"
/**
* 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. Every step on the checklist is a question asked of
* `db` — is there more than one active member, is an analytics source
* connected, does an active API key exist — so the progress bar moves when the
* workspace does rather than because a boolean was written out here.
*/
import { getInitials } from "@/lib/format"
import { db, type Member } from "@/lib/sample-data"
/** One line of the setup checklist, and one card in "What's next" while it is undone. */
export type SetupTask = {
id: string
title: string
/** What finishing it gets you — shown on the card, not in the list. */
summary: string
/** Where the workspace stands on it right now, read from db. */
status: string
/** The icon name the card draws, resolved by the component that draws it. */
icon: "building" | "users" | "plug" | "key" | "shield" | "git-branch"
href: string
actionText: string
done: boolean
}
/**
* The workspace's own configuration — a name and the region its events land
* in, 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", region: "eu-central-1" }
// Read per call, never held at module scope: a key created, an integration
// connected or a teammate deactivated since the server started is what the
// next render counts.
const activeMembers = () => db.members.all().filter((member) => member.status === "active")
const connected = () => db.integrations.all().filter((row) => row.status === "connected")
const activeKeys = () => db.apiKeys.all().filter((key) => key.status === "active")
/** How many people are in the workspace, how much is wired up, and how many keys are live. */
export function workspaceTotals(): { people: number; connected: number; apiKeys: number } {
return {
people: activeMembers().length,
connected: connected().length,
apiKeys: activeKeys().length,
}
}
/**
* The six things a new workspace does, each answered from `db`. A step is done
* because the workspace can show it, not because it was marked done.
*/
export function setupTasks(): SetupTask[] {
const active = activeMembers()
const wired = connected()
const keys = activeKeys()
const analytics = wired.filter((row) => row.category === "analytics")
const trackers = wired.filter((row) => row.category === "developer-tools")
const withTwoFactor = active.filter((member) => member.twoFactor)
return [
{
id: "workspace",
title: "Create your workspace",
summary: "Name it, pick a region, and set the environment it reports on.",
status: `${WORKSPACE.name}, on ${WORKSPACE.region}.`,
icon: "building",
href: "/settings",
actionText: "Review",
done: db.members.all().some((member) => member.role === "owner"),
},
{
id: "invite",
title: "Invite your team",
summary: "Dashboards are worth more when the people who act on them can see them.",
status: `${active.length} people are active in the workspace.`,
icon: "users",
href: "/settings/members",
actionText: "Invite",
done: active.length > 1,
},
{
id: "source",
title: "Connect a data source",
summary: "Point Northwind at where your product events already land.",
status: analytics.length
? `${analytics.map((row) => row.name).join(", ")} is sending events.`
: "No analytics source is connected yet.",
icon: "plug",
href: "/settings/integrations",
actionText: "Connect",
done: analytics.length > 0,
},
{
id: "api-key",
title: "Create an API key",
summary: "Keys authenticate every server-side call to the events API.",
status: `${keys.length} keys are active.`,
icon: "key",
href: "/settings/api-keys",
actionText: "Create",
done: keys.length > 0,
},
{
id: "issue-tracker",
title: "Connect your issue tracker",
summary: "File an issue straight from a chart, with the query attached.",
status: trackers.length
? `${trackers.map((row) => row.name).join(", ")} is connected.`
: "Nothing in developer tools is connected yet.",
icon: "git-branch",
href: "/settings/integrations",
actionText: "Connect",
done: trackers.length > 0,
},
{
id: "two-factor",
title: "Turn on two-factor for everyone",
summary: "Require a second factor before anyone can open the workspace.",
status: `${withTwoFactor.length} of ${active.length} people have it on.`,
icon: "shield",
href: "/settings/security",
actionText: "Require",
done: withTwoFactor.length === active.length,
},
]
}
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 { type Result } from "@/lib/sample-data"
/**
* The one thing this page changes. A server action so the page can stay a
* server component and still hand the shell something to call, and a `Result`
* so the caller reads the same success-or-error shape every mutation returns.
*/
export async function signOut(): Promise<Result<{ signedOut: true }>> {
await mockAuthAdapter.signOut()
return { ok: true, data: { signedOut: true } }
}"use client"
import * as React from "react"
import Link from "next/link"
import {
ArrowRightIcon,
BuildingIcon,
GitBranchIcon,
KeyRoundIcon,
PlugIcon,
ShieldCheckIcon,
UsersIcon,
type LucideIcon,
} from "lucide-react"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader } from "@/components/ui/card"
import { OnboardingChecklist } from "@/components/ui/onboarding-checklist"
import { SectionHeader } from "@/components/ui/section-header"
import { type SetupTask } from "../data"
/** Icon vocabulary, not data: the task names an icon, this file draws it. */
const ICONS: Record<SetupTask["icon"], LucideIcon> = {
building: BuildingIcon,
users: UsersIcon,
plug: PlugIcon,
key: KeyRoundIcon,
shield: ShieldCheckIcon,
"git-branch": GitBranchIcon,
}
/**
* The checklist and the cards under it are one island because they are one
* fact: ticking a step off the list is what takes its card out of "What's
* next". The seeded state comes from db; the reader's ticks stay on the page.
*/
export function GettingStarted({ tasks }: { tasks: SetupTask[] }) {
const [done, setDone] = React.useState<Record<string, boolean>>(() =>
Object.fromEntries(tasks.map((task) => [task.id, task.done]))
)
const remaining = tasks.filter((task) => !done[task.id])
return (
<>
<OnboardingChecklist
title="Set up your workspace"
steps={tasks.map((task) => ({
id: task.id,
title: task.title,
description: task.status,
done: done[task.id],
}))}
onToggle={(id, value) => setDone((current) => ({ ...current, [id]: value }))}
/>
{/* SectionHeader intersects div props, so `title` is a string and cannot
carry an id for aria-labelledby; the region names itself instead. */}
<section aria-label="What's next" className="flex flex-col gap-4">
<SectionHeader
as="h2"
title="What's next"
description={
remaining.length
? `${remaining.length} ${remaining.length === 1 ? "step" : "steps"} left before the workspace is ready for the team.`
: "Everything on the list is done. The workspace is ready for the team."
}
/>
{remaining.length ? (
<div className="grid gap-4 sm:grid-cols-2">
{remaining.map((task) => {
const Icon = ICONS[task.icon]
return (
<Card key={task.id} className="justify-between gap-3">
<CardHeader className="flex flex-col gap-2">
<span className="flex size-8 items-center justify-center rounded-md bg-surface text-muted-foreground">
<Icon aria-hidden="true" className="size-4" />
</span>
<h3 className="text-sm leading-snug font-medium">{task.title}</h3>
<CardDescription className="text-xs">{task.summary}</CardDescription>
</CardHeader>
<CardContent className="flex flex-wrap items-center justify-between gap-3">
<span className="text-xs text-muted-foreground">{task.status}</span>
<Link
href={task.href}
className={cn(buttonVariants({ variant: "outline", size: "sm" }))}
>
{task.actionText}
<ArrowRightIcon data-icon="inline-end" aria-hidden="true" />
</Link>
</CardContent>
</Card>
)
})}
</div>
) : null}
</section>
</>
)
}import { KeyRoundIcon, PlugIcon, UsersIcon } from "lucide-react"
import { StatCard } from "@/components/ui/stat-card"
import { StatCardGroup } from "@/components/ui/stat-card-group"
import { workspaceTotals } from "../data"
const TITLE_ID = "welcome-workspace-so-far"
/** Three counts that say how far the workspace already is, straight from db. */
export function WorkspaceTotals() {
const totals = workspaceTotals()
return (
<section aria-labelledby={TITLE_ID}>
<h2 id={TITLE_ID} className="sr-only">
Workspace so far
</h2>
<StatCardGroup columns={3} divided>
<StatCard
label="People"
value={totals.people}
description="active in the workspace"
icon={<UsersIcon />}
/>
<StatCard
label="Connected sources"
value={totals.connected}
description="sending data today"
icon={<PlugIcon />}
/>
<StatCard
label="API keys"
value={totals.apiKeys}
description="active, none expiring"
icon={<KeyRoundIcon />}
/>
</StatCardGroup>
</section>
)
}