/support/helpHelp 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.
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
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>
)
}Install
npx shadcn@latest add @vibra/support-helpNeeds 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 { 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>
)
}/**
* 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`
}"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 } }
}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>
)
}"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>
)
}