Skip to contentVibraUI
Shared pagesinstalls at /welcome

Welcome

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.

Open the live page

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

Install

npx shadcn@latest add @vibra/onboarding-welcome

Needs the @vibra registry in your components.json — set it up once.

Source

app/welcome/page.tsx
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>
  )
}
app/welcome/nav.ts
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" },
  ],
}
app/welcome/data.ts
/**
 * 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 }))
}
app/welcome/actions.ts
"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 } }
}
app/welcome/components/getting-started.tsx
"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>
    </>
  )
}
app/welcome/components/workspace-totals.tsx
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>
  )
}