Skip to contentVibraUI
Part of the Support dashboardinstalls at /support/help

Help centre

A searchable help centre: one card per shelf with its own count, a search box that looks through the whole article rather than its title, articles that open in place, and a way to reach a person when none of them answers.

Open the live page

Every article is a db.articles row, and the page hands the browser all of them whole — body included — because the search box searches the article and not only its title: a help centre that finds nothing for a word plainly written in an answer is worse than no search at all. Twenty-four short articles is a few kilobytes; a real one would search on the server, and that is the one line a consumer changes. The shelves are checkboxes rather than radios, because "Billing or Security" is a real question and a reader who picks two should get both. Nothing matching says so, with different advice depending on whether a shelf is narrowing the result. An article opens in place in an accordion rather than navigating, so the help centre is readable inside a framed preview as well as installed. The search field is controlled with a 200 ms debounce, so the clear button empties the query as well as the box while the field itself never lags behind the typist. Every stamp is formatted with timeZone: "UTC" — an article dated at midnight UTC would otherwise be stamped a day early west of Greenwich. Composes AppShell, PageHeader, SearchInput, SelectableCard, Widget, Accordion, EmptyState and Callout.

Preview

Install

npx shadcn@latest add @vibra/support-help

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

Source

app/support/help/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 { ContactCard } from "./components/contact-card"
import { HelpBrowser } from "./components/help-browser"
import {
  currentUser,
  helpArticles,
  helpCategories,
  lastUpdated,
  shellNotifications,
} from "./data"
import { NAV, ROUTES } from "@/lib/dashboards/support/nav"

/**
 * The help centre. A server component: every article is read from `db` here and
 * handed to one island whole, so the search box answers a keystroke rather than
 * a round trip, and the first frame already lists everything.
 */
export default function HelpPage() {
  return (
    <AppShell
      nav={NAV}
      activeHref={ROUTES.help}
      user={currentUser()}
      notifications={shellNotifications()}
      now={REFERENCE_DATE}
      onSignOut={signOut}
    >
      <PageHeader
        title="Help centre"
        description="Everything we have written down, and a way to reach us when it is not enough."
        meta={lastUpdated()}
      />

      <HelpBrowser articles={helpArticles()} categories={helpCategories()} />

      <ContactCard />
    </AppShell>
  )
}
app/support/help/data.ts
/**
 * What this page reads. Every article is a `db.articles` row, handed to the
 * browser whole — body included — because the search box searches the article
 * and not only its title, and a help centre that finds nothing for a word
 * plainly written in an answer is worse than no search at all. Twenty-four
 * short articles is a few kilobytes; a real one would search on the server.
 * "Now" is `REFERENCE_DATE`, and the stamp on each article is UTC.
 */
import { getInitials } from "@/lib/format"
import { ARTICLE_CATEGORIES, REFERENCE_DATE, db, type Member } from "@/lib/sample-data"

/** One article, and everything the list and the search need about it. */
export type HelpArticle = {
  id: string
  slug: string
  title: string
  category: string
  summary: string
  body: string
  updatedAt: Date
  /** The stamp under the title, formatted in UTC. */
  updatedLabel: string
  minutesToRead: number
  /** Everything the search box looks through, lowercased once. */
  haystack: string
}

/** One shelf: what it is called, what is on it, and how much. */
export type HelpCategory = { name: string; description: string; count: number }

// What each shelf is for, in the reader's words rather than the schema's. The
// six names come from the entity; only the sentences are the page's own.
const SHELF_BLURBS: Record<string, string> = {
  "Getting started": "Set the workspace up and get your team into it",
  Billing: "Invoices, plans, cards and tax details",
  Integrations: "Chat, webhooks, API keys and the warehouse sync",
  Security: "Two-factor, passkeys, single sign-on and the audit log",
  "Data & exports": "Getting data out, on demand or on a schedule",
  Troubleshooting: "When something is not behaving the way it reads",
}

// Fixed to UTC: an article dated at midnight UTC would otherwise be stamped
// with the day before wherever the reader sits west of Greenwich.
const UPDATED = new Intl.DateTimeFormat("en-US", { dateStyle: "medium", timeZone: "UTC" })

const ARTICLES: HelpArticle[] = db.articles
  .all()
  .sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime())
  .map((row) => ({
    id: row.id,
    slug: row.slug,
    title: row.title,
    category: row.category,
    summary: row.summary,
    body: row.body,
    updatedAt: row.updatedAt,
    updatedLabel: `Updated ${UPDATED.format(row.updatedAt)}`,
    minutesToRead: row.minutesToRead,
    haystack: `${row.title} ${row.summary} ${row.body} ${row.category}`.toLowerCase(),
  }))

/** Every article, newest first. */
export function helpArticles(): HelpArticle[] {
  return ARTICLES
}

const CATEGORIES: HelpCategory[] = ARTICLE_CATEGORIES.map((name) => ({
  name,
  description: SHELF_BLURBS[name] ?? "",
  count: ARTICLES.filter((article) => article.category === name).length,
})).filter((category) => category.count > 0)

/** The shelves, in the order the entity declares them, minus any that are empty. */
export function helpCategories(): HelpCategory[] {
  return CATEGORIES
}

/** The bell's contents: the newest notifications, unread first in the panel. */
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 }))
}

function ownerRow(): Member {
  return db.members.all().find((member) => member.role === "owner") ?? db.members.all()[0]
}

/** The person looking at the page: whoever owns this workspace. */
export function currentUser() {
  const owner = ownerRow()
  return { name: owner.name, email: owner.email, initials: getInitials(owner.name), avatarUrl: owner.avatarUrl }
}

/** How many articles there are, and when the newest of them was touched. */
export function lastUpdated(): string {
  const newest = ARTICLES[0]?.updatedAt ?? REFERENCE_DATE
  return `${ARTICLES.length} articles · newest ${UPDATED.format(newest)} UTC`
}
app/support/help/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/support/help/components/contact-card.tsx
import Link from "next/link"

import { Callout } from "@/components/ui/callout"
import { Widget } from "@/components/ui/widget"

/** Where a reader goes when nothing on the shelf answers the question. */
export function ContactCard() {
  return (
    <Widget
      data-widget="widget-support-help-contact-card"
      title="Still stuck?"
      description="Two ways to reach a person"
      footer="Support answers between 08:00 and 20:00 UTC on working days."
    >
      <Callout title="Open a ticket and we will pick it up">
        <p>
          A ticket keeps the whole conversation, the workspace it is about, and anything you
          attach, in one place.{" "}
          <Link href="/support" className="text-foreground underline underline-offset-4">
            Open a ticket
          </Link>{" "}
          or write to{" "}
          <a href="mailto:support@northwind.example" className="text-foreground underline underline-offset-4">
            support@northwind.example
          </a>
          .
        </p>
      </Callout>
    </Widget>
  )
}
app/support/help/components/help-browser.tsx
"use client"

import * as React from "react"

import {
  Accordion,
  AccordionContent,
  AccordionItem,
  AccordionTrigger,
} from "@/components/ui/accordion"
import { EmptyState } from "@/components/ui/empty-state"
import { SearchInput } from "@/components/ui/search-input"
import { SelectableCard } from "@/components/ui/selectable-card"
import { Widget } from "@/components/ui/widget"

import { type HelpArticle, type HelpCategory } from "../data"

/**
 * The help centre's one interactive part: a search box, a shelf per category,
 * and the articles that survive both.
 *
 * Every article arrives from the server whole, so the search is a filter over
 * an array rather than a request — which is what makes it answer a keystroke,
 * and what lets it look through the body as well as the title. The shelves are
 * checkboxes rather than radios: "Billing or Security" is a real question, and
 * a reader who picks two should get both rather than losing the first.
 */
export function HelpBrowser({
  articles,
  categories,
}: {
  articles: HelpArticle[]
  categories: HelpCategory[]
}) {
  const [query, setQuery] = React.useState("")
  const [shelves, setShelves] = React.useState<string[]>([])

  const needle = query.trim().toLowerCase()
  const visible = articles.filter(
    (article) =>
      (shelves.length === 0 || shelves.includes(article.category)) &&
      (needle === "" || article.haystack.includes(needle))
  )

  function toggle(name: string) {
    setShelves((current) =>
      current.includes(name) ? current.filter((entry) => entry !== name) : [...current, name]
    )
  }

  return (
    <div className="flex flex-col gap-4">
      <SearchInput
        aria-label="Search the help centre"
        placeholder="Search every article"
        // Controlled, so the clear button empties the query as well as the box;
        // with a debounce the field keeps its own draft while a word is being
        // typed, and the list is filtered once the typing stops.
        value={query}
        onValueChange={setQuery}
        debounce={200}
        clearable
      />

      <div
        role="group"
        aria-label="Categories"
        className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"
      >
        {categories.map((category) => (
          <SelectableCard
            key={category.name}
            selected={shelves.includes(category.name)}
            onSelectedChange={() => toggle(category.name)}
            title={category.name}
            description={category.description}
            badge={`${category.count}`}
          />
        ))}
      </div>

      <Widget
        title="Articles"
        description={
          shelves.length === 0
            ? `${visible.length} of ${articles.length} articles`
            : `${visible.length} on ${shelves.join(", ")}`
        }
        footer="Search looks through the whole article, not only its title, so a word written in an answer will find it."
      >
        {visible.length === 0 ? (
          <EmptyState
            title="No article matches that"
            description={
              shelves.length > 0
                ? "Try the same words with the shelves cleared."
                : "Try fewer words, or open a ticket below."
            }
            size="sm"
          />
        ) : (
          <Accordion>
            {visible.map((article) => (
              <AccordionItem key={article.id} value={article.id}>
                <AccordionTrigger headingLevel={2}>
                  <span className="flex min-w-0 flex-col gap-0.5 pr-4">
                    <span className="truncate">{article.title}</span>
                    <span className="text-xs font-normal text-muted-foreground">
                      {article.category} · {article.minutesToRead} min · {article.updatedLabel}
                    </span>
                  </span>
                </AccordionTrigger>
                <AccordionContent>
                  <p className="max-w-prose text-muted-foreground">{article.body}</p>
                </AccordionContent>
              </AccordionItem>
            ))}
          </Accordion>
        )}
      </Widget>
    </div>
  )
}