FAQs
The questions a buyer asks before signing up, answered in place.
Two-column FAQ
faqs-01Eight questions under a centred claim, in two columns so the whole list is in view at once, numbered straight through so a phone reads them in the same order.
import * as React from "react"
import Link from "next/link"
import { ArrowRightIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
import { buttonVariants } from "@/components/ui/button"
import { COLUMNS, EYEBROW, HEADLINE, HELP, LEDE } from "./faqs-01.data"
/** "01": a question's place in the whole list, which runs on from the first column into the second. */
function place(index: number): string {
return String(index + 1).padStart(2, "0")
}
/**
* The general FAQ: a centred claim, then eight questions in two columns so
* the whole list is in view at once on a laptop, numbered straight through so
* a phone — which stacks the columns — reads them in the same order. Each
* question is the kit's Accordion: a heading holding a real button that
* says whether its answer is open. The first answer starts open, to show
* what one looks like.
*
* The answers stay in the page while closed (`hiddenUntilFound`), so the
* browser's own find opens the question that holds a match, and a crawler
* reads every answer. Each column is its own accordion, so opening an answer
* never moves a question into the other column. A server component: the
* accordion keeps its own state, and every word is in `./faqs-01.data`.
*/
export function Faqs01() {
const titleId = React.useId()
return (
<section
data-section="faqs-01"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background px-4 py-16 text-foreground md:px-6 md:py-24"
>
<div className="mx-auto max-w-7xl">
<div className="mx-auto flex max-w-3xl flex-col items-center text-center">
<p className="type-eyebrow">{EYEBROW}</p>
<h2 id={titleId} className="type-display mt-4 text-3xl text-balance md:text-4xl">
{HEADLINE}
</h2>
<p className="mt-5 max-w-2xl text-base/7 text-pretty text-muted-foreground md:text-lg/8">{LEDE}</p>
</div>
<div className="mx-auto mt-12 grid max-w-5xl gap-x-12 md:mt-16 md:grid-cols-2">
{COLUMNS.map((column, columnIndex) => {
const offset = COLUMNS.slice(0, columnIndex).reduce((count, earlier) => count + earlier.length, 0)
return (
<Accordion
key={column[0]?.id ?? columnIndex}
multiple
hiddenUntilFound
defaultValue={columnIndex === 0 && column[0] ? [column[0].id] : []}
// A rule over each column; on a phone the second column's
// would double the first one's last hairline, so it waits for `md`.
className={cn("border-border", columnIndex === 0 ? "border-t" : "md:border-t")}
>
{column.map((faq, index) => (
<AccordionItem key={faq.id} value={faq.id} className="border-b">
<AccordionTrigger className="py-4 text-base hover:no-underline">
<span className="flex items-baseline gap-3">
<span aria-hidden="true" className="w-6 shrink-0 text-sm font-normal text-muted-foreground tabular-nums">
{place(offset + index)}
</span>
<span className="text-pretty underline-offset-4 group-hover/accordion-trigger:underline">{faq.question}</span>
</span>
</AccordionTrigger>
<AccordionContent className="ps-9 pe-7 pb-5 text-base/7 text-pretty text-muted-foreground">
{faq.answer}
</AccordionContent>
</AccordionItem>
))}
</Accordion>
)
})}
</div>
<p className="mt-10 flex flex-wrap items-center justify-center gap-x-1.5 text-sm text-muted-foreground">
{HELP.lead}
<Link href={HELP.link.href} className={cn(buttonVariants({ variant: "link" }), "group/help h-auto gap-1 px-0")}>
{HELP.link.label}
<ArrowRightIcon
aria-hidden="true"
data-icon="inline-end"
className="transition-transform duration-(--duration-fast) ease-(--ease-standard) group-hover/help:translate-x-0.5"
/>
</Link>
</p>
</div>
</section>
)
}
export default Faqs01/**
* Every word `faqs-01` sets: the eight questions a team asks before its first
* review in Northwind, each answered in a sentence or two, split into the two
* columns the section lays them in. Replace them with your own; keep each
* answer to what a reader needs in order to decide, and send the rest to the
* help centre the last line links to.
*/
export type Faq = {
/** Stable, so an open question survives a reordering and a test can name it. */
id: string
question: string
answer: string
}
export type FaqLink = { label: string; href: string }
export const EYEBROW = "Questions"
export const HEADLINE = "What teams ask before their first review"
export const LEDE =
"The trial, your data and the bill, answered in a sentence or two. Everything else is in the help centre."
/**
* The two columns, left then right. The questions are numbered straight
* through, so a phone, which stacks the columns, reads them in the same order.
*/
export const COLUMNS: readonly (readonly Faq[])[] = [
[
{
id: "trial",
question: "What does the 14-day trial include?",
answer:
"Every Business feature for up to 25 people, with no card. On day 15 the workspace pauses until someone picks a plan, and nothing in it is deleted for 90 days.",
},
{
id: "sources",
question: "Which tools can Northwind read from?",
answer:
"Billing, your CRM, product events and your support desk, each through a read-only connection. Most teams connect the first two in under ten minutes.",
},
{
id: "freshness",
question: "How fresh are the numbers?",
answer:
"Starter refreshes once a day, Team every hour and Business every five minutes. Every chart says when its sources last synced.",
},
{
id: "definitions",
question: "Can we keep our own metric definitions?",
answer:
"Yes. MRR, churn and every other figure is a definition your team can read and change, and each change is versioned, so last quarter's report still adds up.",
},
],
[
{
id: "access",
question: "Who can see which numbers?",
answer:
"Owners share each dashboard with the people who need it. On Business, rules go down to the row, so a regional team or a partner sees only its own accounts.",
},
{
id: "region",
question: "Where is our data stored?",
answer:
"In the US or the EU, chosen when the workspace is created. Storage, processing and backups all stay in that region.",
},
{
id: "pricing",
question: "How is Northwind priced?",
answer:
"Per workspace, never per row: $39, $119 or $399 a month on the three plans, and about a sixth less billed yearly.",
},
{
id: "leaving",
question: "Can we take our data with us if we leave?",
answer:
"Every table and chart exports to CSV at any time. Closing the workspace deletes its data within 30 days, and we send a letter saying so if you ask.",
},
],
]
/** The last line: where a question that is not here gets answered. */
export const HELP: { lead: string; link: FaqLink } = {
lead: "Not on the list?",
link: { label: "Search the help centre", href: "/help" },
}npx shadcn@latest add @vibra/faqs-01FAQ by topic
faqs-02A help centre's questions filed under five topics, chosen from a rail that says how many each holds; each topic ends on the address of the team that answers it.
import * as React from "react"
import { MailIcon } from "lucide-react"
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { COUNT_LABEL, EYEBROW, HEADLINE, LEDE, TOPICS, TOPICS_LABEL } from "./faqs-02.data"
/** "4 questions": a tab's count in words, for a screen reader. */
function countLabel(count: number): string {
return (count === 1 ? COUNT_LABEL.one : COUNT_LABEL.other).replace("{count}", String(count))
}
/**
* The help centre's FAQ: the questions filed under five topics, chosen from a
* rail beside them. The rail is the kit's Tabs, because one choice drives one
* list: Up and Down move between topics and bring each one's questions up as
* they land, Home and End jump to the ends, and each tab says how many
* questions it holds — a figure to the eye, words to a screen reader. The
* chosen topic wears the accent's tint, a rail and the 500 weight.
*
* Each topic's questions are the kit's Accordion, and the list ends on the
* address of the team that answers what it does not. From `lg` the rail stands
* left of the list; on a phone it sits above it. The answers stay in the page
* while closed, so the browser's find reaches them. A server component: the
* tabs and the accordion keep their own state, and every word is in
* `./faqs-02.data`.
*/
export function Faqs02() {
const titleId = React.useId()
return (
<section
data-section="faqs-02"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background px-4 py-16 text-foreground md:px-6 md:py-24"
>
<div className="mx-auto max-w-7xl">
<div className="max-w-3xl">
<p className="type-eyebrow">{EYEBROW}</p>
<h2 id={titleId} className="type-display mt-4 text-3xl text-balance md:text-4xl">
{HEADLINE}
</h2>
<p className="mt-5 text-base/7 text-pretty text-muted-foreground md:text-lg/8">{LEDE}</p>
</div>
<Tabs orientation="vertical" defaultValue={TOPICS[0]?.id} className="mt-12 gap-8 max-lg:flex-col md:mt-16 lg:gap-16">
<TabsList variant="line" aria-label={TOPICS_LABEL} activateOnFocus className="w-full shrink-0 items-stretch gap-1 lg:w-64">
{TOPICS.map((topic) => (
<TabsTrigger
key={topic.id}
value={topic.id}
className="h-10 flex-none justify-start gap-3 rounded-lg pr-3 pl-4 text-left font-normal group-data-vertical/tabs:after:inset-y-2.5 group-data-vertical/tabs:after:right-auto group-data-vertical/tabs:after:left-0 group-data-vertical/tabs:after:rounded-full hover:bg-muted/50 data-active:bg-brand-muted data-active:font-medium data-active:hover:bg-brand-muted"
>
{topic.title}
<span aria-hidden="true" className="ms-auto text-muted-foreground tabular-nums">
{topic.faqs.length}
</span>
<span className="sr-only">, {countLabel(topic.faqs.length)}</span>
</TabsTrigger>
))}
</TabsList>
{TOPICS.map((topic) => (
<TabsContent key={topic.id} value={topic.id} className="max-w-3xl min-w-0 rounded-xl focus-ring">
<Accordion hiddenUntilFound defaultValue={topic.faqs[0] ? [topic.faqs[0].id] : []} className="border-t border-border">
{topic.faqs.map((faq) => (
<AccordionItem key={faq.id} value={faq.id} className="border-b">
<AccordionTrigger className="py-4 text-base text-pretty">{faq.question}</AccordionTrigger>
<AccordionContent className="pe-7 pb-5 text-base/7 text-pretty text-muted-foreground">{faq.answer}</AccordionContent>
</AccordionItem>
))}
</Accordion>
<p className="mt-6 flex flex-wrap items-center gap-x-1.5 gap-y-1 text-sm text-muted-foreground">
<MailIcon aria-hidden="true" className="size-4 shrink-0" />
{topic.contact.lead}
<a href={topic.contact.href} className="rounded-sm font-medium text-foreground underline underline-offset-4 focus-ring hover:text-brand">
{topic.contact.label}
</a>
</p>
</TabsContent>
))}
</Tabs>
</div>
</section>
)
}
export default Faqs02/**
* Every word `faqs-02` sets: Northwind's help centre in five topics, each
* with its questions and the address that answers the ones not listed. The
* rail shows how many questions a topic holds by counting them here, so the
* count can never disagree with the list. Replace the topics with your own.
*/
export type Faq = { id: string; question: string; answer: string }
export type Topic = {
/** Stable: the tab's value, and what a test or a link names the topic by. */
id: string
title: string
faqs: readonly Faq[]
/** Who answers what the list does not: "Billing questions go to billing@northwind.example". */
contact: { lead: string; label: string; href: string }
}
export const EYEBROW = "Help centre"
export const HEADLINE = "Answers, by topic"
export const LEDE =
"Pick a topic to see its questions. Each one ends with the address of the team that answers what is not here."
/** The rail's name, for a screen reader. */
export const TOPICS_LABEL = "Topics"
/** How a tab says its count in words: `{count}` is the number. */
export const COUNT_LABEL = { one: "{count} question", other: "{count} questions" }
export const TOPICS: readonly Topic[] = [
{
id: "start",
title: "Getting started",
faqs: [
{
id: "setup-time",
question: "How long does setup take?",
answer:
"Most teams connect billing and their CRM in the first ten minutes and open their first dashboard within the hour. The other sources can follow.",
},
{
id: "engineer",
question: "Do we need an engineer to connect our tools?",
answer:
"No. Each connection is a sign-in to the tool itself, with read-only access. An engineer only helps if you send product events from your own code.",
},
{
id: "spreadsheets",
question: "Can we bring the spreadsheets we use today?",
answer:
"Yes. Upload a CSV and it becomes a source you refresh by uploading again, so the old report and the new one sit side by side while you switch.",
},
{
id: "onboarding",
question: "Is there a person who can help us set up?",
answer:
"Every trial has a named onboarding lead who answers in the chat, and Business includes a 45-minute setup call.",
},
],
contact: { lead: "Setup questions go to", label: "onboarding@northwind.example", href: "mailto:onboarding@northwind.example" },
},
{
id: "billing",
title: "Billing",
faqs: [
{
id: "charged",
question: "When are we charged?",
answer:
"On the day the plan starts, then on the same date each month or year. Invoices go to the billing contact and stay under Settings, Billing.",
},
{
id: "invoice",
question: "Can we pay by invoice?",
answer: "Yes, on yearly plans: a bank transfer against a PDF invoice, on 30-day terms.",
},
{
id: "members",
question: "What happens when we add people mid-month?",
answer:
"Nothing, until you pass your plan's member limit: plans are priced per workspace, not per person. Then we ask before moving you up.",
},
{
id: "discounts",
question: "Do you offer discounts?",
answer:
"Yearly billing costs about a sixth less. Charities and schools get 40% off any plan; write to us from your organisation's address.",
},
],
contact: { lead: "Billing questions go to", label: "billing@northwind.example", href: "mailto:billing@northwind.example" },
},
{
id: "data",
title: "Data and sources",
faqs: [
{
id: "writes",
question: "Does Northwind ever change our data?",
answer:
"No. Every connection is read-only, so Northwind cannot write to billing or your CRM. Credentials are encrypted, and you can rotate them at any time.",
},
{
id: "sync-stopped",
question: "What happens when a source stops syncing?",
answer:
"Every chart that reads it says so, with the time it last synced, and the workspace owner gets an email with the reason and the fix.",
},
{
id: "history",
question: "How much history do you import?",
answer: "Everything the source keeps. Starter shows the last 12 months; Team and Business show all of it.",
},
{
id: "matching",
question: "Two tools describe the same customer. Which one wins?",
answer:
"Neither: Northwind matches billing, CRM and support records on the customer's domain into one account, and you can correct a match by hand.",
},
{
id: "events",
question: "Can we send product events from our own code?",
answer:
"Yes, over one HTTPS endpoint with a workspace key. Events appear in charts within a minute of arriving.",
},
],
contact: { lead: "Questions about a source go to", label: "support@northwind.example", href: "mailto:support@northwind.example" },
},
{
id: "security",
title: "Security",
faqs: [
{
id: "soc2",
question: "Do you have a SOC 2 report?",
answer:
"Yes: SOC 2 Type II, audited each year by an independent firm. We share the report and our answers to your questionnaire under NDA.",
},
{
id: "sso",
question: "Can we sign in through our identity provider?",
answer:
"On Business: SAML single sign-on with any provider, and SCIM adds and removes people as your directory changes.",
},
{
id: "region",
question: "Where is our data stored?",
answer: "In the US or the EU, chosen when the workspace is created. Storage, processing and backups stay in that region.",
},
{
id: "staff",
question: "Can anyone at Northwind see our numbers?",
answer:
"Only when an owner grants support access, and only for the window they choose. Every grant and every view lands in your audit log.",
},
],
contact: { lead: "Security reviews go to", label: "security@northwind.example", href: "mailto:security@northwind.example" },
},
{
id: "account",
title: "Your account",
faqs: [
{
id: "workspaces",
question: "Can we have more than one workspace?",
answer:
"Yes. Each workspace is on its own plan, and one person can belong to several with the same sign-in.",
},
{
id: "remove",
question: "How do we remove someone?",
answer:
"An owner removes them under Settings, Members, and their access ends at once. With SCIM, it ends when your directory removes them.",
},
{
id: "owner",
question: "Can we change the workspace owner?",
answer:
"Yes: the current owner hands it over under Settings, Members. If they have left the company, write to support from an admin's address.",
},
{
id: "close",
question: "How do we close the workspace?",
answer: "An owner closes it under Settings, Workspace. Exports stay available for 30 days, then everything is deleted.",
},
],
contact: { lead: "Account questions go to", label: "support@northwind.example", href: "mailto:support@northwind.example" },
},
]npx shadcn@latest add @vibra/faqs-02Searchable FAQ
faqs-03A search field that narrows sixteen answers as the reader types, says how many match, opens each match with the words marked, and offers a way out when nothing does.
import * as React from "react"
import { FaqSearch } from "./faqs-03.client"
import { EYEBROW, HEADLINE, LEDE, SEARCH, TOPICS } from "./faqs-03.data"
/**
* The searchable FAQ: a centred claim over one field that narrows sixteen
* support answers as the reader types. The count under the field says how
* many match in words a screen reader hears; every answer that matches opens
* with the match marked; three common searches fill the field in one press;
* and a search with no match offers a shorter word, the address that answers
* and a way back to the whole list. The answers are grouped by topic, each
* group named by its label.
*
* One narrow column at every width, because a search result is read down, not
* across. A server component; the search is the one island, and every
* answer and word it says is in `./faqs-03.data`.
*/
export function Faqs03() {
const titleId = React.useId()
return (
<section
data-section="faqs-03"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background px-4 py-16 text-foreground md:px-6 md:py-24"
>
<div className="mx-auto max-w-7xl">
<div className="mx-auto flex max-w-3xl flex-col items-center text-center">
<p className="type-eyebrow">{EYEBROW}</p>
<h2 id={titleId} className="type-display mt-4 text-3xl text-balance md:text-4xl">
{HEADLINE}
</h2>
<p className="mt-5 max-w-2xl text-base/7 text-pretty text-muted-foreground md:text-lg/8">{LEDE}</p>
</div>
<div className="mx-auto mt-10 max-w-3xl md:mt-12">
<FaqSearch topics={TOPICS} copy={SEARCH} />
</div>
</div>
</section>
)
}
export default Faqs03"use client"
import * as React from "react"
import { SearchXIcon } from "lucide-react"
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
import { Button } from "@/components/ui/button"
import { EmptyState } from "@/components/ui/empty-state"
import { SearchInput } from "@/components/ui/search-input"
import type { Faq, SearchCopy, Topic } from "./faqs-03.data"
/** The words of a query, lower-cased; a query of spaces has none. */
function wordsOf(query: string): string[] {
return query.toLowerCase().split(/\s+/).filter(Boolean)
}
/** Every word of the query appears somewhere in the question or its answer. */
function matches(faq: Faq, words: readonly string[]): boolean {
const text = `${faq.question} ${faq.answer}`.toLowerCase()
return words.every((word) => text.includes(word))
}
const escape = (word: string) => word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
/**
* A line of copy with every word of the query marked where it appears. The
* split keeps the matches at the odd places, because the pattern captures
* them; the tint is the accent's, the one a chosen row wears.
*/
function Marked({ text, words }: { text: string; words: readonly string[] }) {
if (words.length === 0) return text
const parts = text.split(new RegExp(`(${words.map(escape).join("|")})`, "gi"))
return parts.map((part, index) =>
index % 2 === 1 ? (
<mark key={index} className="rounded-xs bg-brand-muted text-foreground">
{part}
</mark>
) : (
part
)
)
}
const fill = (template: string, values: Record<string, string | number>) =>
template.replace(/\{(\w+)\}/g, (_, key: string) => String(values[key] ?? ""))
/**
* The search and the answers it narrows. Every keystroke filters the list —
* every word of the query has to appear in a question or its answer — and
* opens each answer that matches with the words marked, so a reader sees why
* it matched; the count line under the field says how many did, in a status
* region that is always in the page. Nothing matching leaves a way out: a
* shorter word, the address that answers, and a button that clears the search
* and hands focus back to the field. The common searches fill the field in
* one press.
*
* Every question and word arrives as plain props from the section.
*/
export function FaqSearch({ topics, copy }: { topics: readonly Topic[]; copy: SearchCopy }) {
const [query, setQuery] = React.useState("")
const [open, setOpen] = React.useState<string[]>([])
const baseId = React.useId()
const field = React.useRef<HTMLDivElement>(null)
const all = topics.flatMap((topic) => topic.faqs)
const words = wordsOf(query)
const results = topics
.map((topic) => ({ ...topic, faqs: topic.faqs.filter((faq) => matches(faq, words)) }))
.filter((topic) => topic.faqs.length > 0)
const count = results.reduce((sum, topic) => sum + topic.faqs.length, 0)
// Opening follows the query: a new search opens every answer it matched, and
// an empty one closes them. Decided here rather than in an effect, so the
// list and the open answers change in the same render.
function search(next: string) {
setQuery(next)
const nextWords = wordsOf(next)
setOpen(nextWords.length > 0 ? all.filter((faq) => matches(faq, nextWords)).map((faq) => faq.id) : [])
}
function clear() {
search("")
field.current?.querySelector("input")?.focus()
}
const status =
words.length === 0
? fill(copy.all, { total: all.length, topics: topics.length })
: count === 0
? fill(copy.none, { query: query.trim() })
: fill(count === 1 ? copy.some.one : copy.some.other, { count, query: query.trim() })
return (
<div className="flex flex-col">
<div ref={field}>
<SearchInput
value={query}
onValueChange={search}
aria-label={copy.label}
placeholder={copy.placeholder}
className="[&_input]:h-11 [&_input]:rounded-lg [&_input]:pl-9 [&_input]:text-base"
/>
</div>
<div className="mt-3 flex flex-wrap items-center justify-between gap-x-6 gap-y-2">
<p role="status" aria-live="polite" className="text-sm text-muted-foreground tabular-nums">
{status}
</p>
<div role="group" aria-label={copy.suggestionsLabel} className="flex flex-wrap items-center gap-1.5">
<span aria-hidden="true" className="text-xs text-muted-foreground">
{copy.suggestionsLabel}
</span>
{copy.suggestions.map((suggestion) => {
const active = query.trim().toLowerCase() === suggestion
return (
<Button
key={suggestion}
type="button"
variant="outline"
size="xs"
aria-pressed={active}
onClick={() => search(active ? "" : suggestion)}
className="rounded-full px-2.5 font-normal aria-pressed:border-transparent aria-pressed:bg-brand-muted aria-pressed:font-medium"
>
{suggestion}
</Button>
)
})}
</div>
</div>
{count === 0 ? (
<EmptyState
variant="dashed"
className="mt-8"
icon={<SearchXIcon />}
title={copy.empty.title}
description={
<>
{copy.empty.lead}{" "}
<a href={copy.empty.contact.href} className="rounded-sm font-medium text-foreground underline underline-offset-4 focus-ring">
{copy.empty.contact.label}
</a>
</>
}
action={
<Button type="button" variant="outline" onClick={clear}>
{copy.empty.clear}
</Button>
}
/>
) : (
<div className="mt-8 flex flex-col gap-10">
{results.map((topic) => {
const labelId = `${baseId}-${topic.id}`
const ids = new Set(topic.faqs.map((faq) => faq.id))
return (
<div key={topic.id} role="group" aria-labelledby={labelId}>
<p id={labelId} className="type-eyebrow">
{topic.title}
</p>
<Accordion
multiple
hiddenUntilFound
value={open.filter((id) => ids.has(id))}
onValueChange={(next) => setOpen((current) => [...current.filter((id) => !ids.has(id)), ...next])}
className="mt-3 border-t border-border"
>
{topic.faqs.map((faq) => (
<AccordionItem key={faq.id} value={faq.id} className="border-b">
<AccordionTrigger className="py-4 text-base">
<span className="text-pretty">
<Marked text={faq.question} words={words} />
</span>
</AccordionTrigger>
<AccordionContent className="pe-7 pb-5 text-base/7 text-pretty text-muted-foreground">
<Marked text={faq.answer} words={words} />
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</div>
)
})}
</div>
)}
</div>
)
}/**
* Every word `faqs-03` sets: the support answers a Northwind admin searches,
* in four topics, and everything the search says — its label, its
* suggestions, the line that counts the matches and what it offers when
* nothing matches. `{count}`, `{total}`, `{topics}` and `{query}` are filled
* in by the section. Replace the answers with yours; the search reads
* whatever is here.
*/
export type Faq = { id: string; question: string; answer: string }
export type Topic = { id: string; title: string; faqs: readonly Faq[] }
export const EYEBROW = "Support"
export const HEADLINE = "Search the answers"
export const LEDE =
"Type a word from your question. The list narrows as you type, and every answer that matches opens with the match marked."
export const SEARCH = {
label: "Search the questions and answers",
placeholder: "Try invoice, export or reconnect",
suggestionsLabel: "Common searches",
suggestions: ["invoice", "export", "reconnect"],
/** The count line, always in the page and read out when it changes. */
all: "{total} questions in {topics} topics",
some: { one: "1 question matches “{query}”", other: "{count} questions match “{query}”" },
none: "No question matches “{query}”",
/** What the empty result offers instead. */
empty: {
title: "Try a shorter word or another spelling",
lead: "Or ask the people who wrote these answers:",
contact: { label: "support@northwind.example", href: "mailto:support@northwind.example" },
clear: "Clear the search",
},
}
export type SearchCopy = typeof SEARCH
export const TOPICS: readonly Topic[] = [
{
id: "dashboards",
title: "Dashboards",
faqs: [
{
id: "add-chart",
question: "How do I add a chart to a dashboard?",
answer: "Open the dashboard, choose Add, then pick a saved chart or start a new one from any metric. Drag its corner to resize it.",
},
{
id: "default-range",
question: "Can a dashboard open on its own date range?",
answer: "Yes. Set the range in the dashboard's settings; everyone who opens it starts there, and can still change it for themselves.",
},
{
id: "compare-year",
question: "How do I compare this month with the same month last year?",
answer: "Turn on Compare in the date picker and choose Previous year. Every chart draws the earlier period as a dashed line behind the current one.",
},
{
id: "duplicate",
question: "Can I copy a dashboard for another team?",
answer: "Choose Duplicate from the dashboard's menu. The copy keeps every chart and filter, and nobody else sees it until you share it.",
},
],
},
{
id: "sources",
title: "Sources",
faqs: [
{
id: "reconnect",
question: "How do I reconnect a source?",
answer: "Go to Settings, Sources, choose the source and press Reconnect. Sign in to the tool again; the history already imported stays.",
},
{
id: "differs",
question: "Why does a figure differ from the one in our billing tool?",
answer: "Usually a definition: Northwind counts MRR after discounts and without tax by default. Open the metric to read its definition and change it.",
},
{
id: "test-accounts",
question: "How do I leave test accounts out of every chart?",
answer: "Add a workspace filter under Settings, Data, for example every account whose email ends in your own domain. It applies everywhere at once.",
},
{
id: "csv",
question: "Can I upload a CSV as a source?",
answer: "Yes. Choose Add source, then CSV, and upload the file. Upload a newer file with the same columns to refresh it.",
},
],
},
{
id: "sharing",
title: "Sharing",
faqs: [
{
id: "outside",
question: "How do I share a dashboard with someone outside the company?",
answer: "Choose Share, then Invite a guest. Guests see only what you shared with them, and every view they make is in the audit log.",
},
{
id: "schedule",
question: "Can a report arrive by email on a schedule?",
answer: "Yes. From any dashboard choose Schedule, pick the day and the time, and the people to send it to. It arrives as a PDF with a link back.",
},
{
id: "embed",
question: "How do I embed a chart in our internal wiki?",
answer: "Choose Embed from the chart's menu and paste the snippet into the page. Only people signed in to the workspace can see it.",
},
{
id: "viewer-export",
question: "Can a viewer export rows?",
answer: "Only if an owner allows it under Settings, Permissions. Each export is logged with the rows it held.",
},
],
},
{
id: "billing",
title: "Billing",
faqs: [
{
id: "invoice",
question: "Where do I download an invoice?",
answer: "Under Settings, Billing, Invoices. Each invoice is a PDF, and the billing contact also receives it by email on the day it is issued.",
},
{
id: "billing-contact",
question: "How do I change who receives the invoices?",
answer: "An owner changes the billing contact under Settings, Billing. The next invoice goes to the new address.",
},
{
id: "yearly",
question: "How do I switch to yearly billing?",
answer: "Choose Switch to yearly under Settings, Billing. You pay the year from today, less what is left of the current month.",
},
{
id: "downgrade",
question: "What happens to our dashboards if we move to a smaller plan?",
answer: "Nothing is deleted. Charts that read a source beyond the new plan's limit pause until you remove a source or move back up.",
},
],
},
]npx shadcn@latest add @vibra/faqs-03FAQ with a contact card
faqs-04The questions a buyer asks before signing, as separate panels, beside a card that names the person who answers the rest: her face, a call to book and how soon she replies.
import * as React from "react"
import Link from "next/link"
import { ClockIcon, MailIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { avatarFor } from "@/lib/avatars"
import { getInitials } from "@/lib/format"
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { buttonVariants } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { CONTACT, EYEBROW, FAQS, HEADLINE, LEDE } from "./faqs-04.data"
/**
* The buyer's FAQ, with a person beside it: the claim, and under it a card
* that names who answers what the list does not — her face, her name and
* role, what a call covers, the two ways to reach her and how soon she
* replies. The questions stand on the right as separate panels; the open one
* rises to the card plane, told by the plane rather than by an outline.
*
* From `lg` the claim and the card hold still while the questions scroll past
* them; on a phone the card comes before the questions, so the way to a person
* is never below the fold of a long answer. The face is decoration — her name
* is beside it — and is `avatarFor(name)` from the `avatars` item. A server
* component: the accordion keeps its own state, and every word is in
* `./faqs-04.data`.
*/
export function Faqs04() {
const titleId = React.useId()
return (
<section
data-section="faqs-04"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background px-4 py-16 text-foreground md:px-6 md:py-24"
>
<div className="mx-auto grid max-w-7xl gap-x-16 gap-y-12 lg:grid-cols-12">
<div className="lg:sticky lg:top-24 lg:col-span-5 lg:self-start">
<p className="type-eyebrow">{EYEBROW}</p>
<h2 id={titleId} className="type-display mt-4 text-3xl text-balance md:text-4xl">
{HEADLINE}
</h2>
<p className="mt-5 text-base/7 text-pretty text-muted-foreground md:text-lg/8">{LEDE}</p>
<Card className="mt-10">
<CardHeader>
<CardTitle>
<h3>{CONTACT.title}</h3>
</CardTitle>
<CardDescription className="text-pretty">{CONTACT.body}</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-5">
<div className="flex items-center gap-3">
<Avatar size="lg" className="after:hidden">
<AvatarImage src={avatarFor(CONTACT.name)} alt="" />
<AvatarFallback>{getInitials(CONTACT.name)}</AvatarFallback>
</Avatar>
<div className="flex min-w-0 flex-col">
<p className="text-sm font-medium">{CONTACT.name}</p>
<p className="text-sm text-muted-foreground">{CONTACT.role}</p>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Link href={CONTACT.call.href} className={buttonVariants()}>
{CONTACT.call.label}
</Link>
<a href={CONTACT.email.href} className={cn(buttonVariants({ variant: "outline" }), "min-w-0")}>
<MailIcon aria-hidden="true" data-icon="inline-start" />
<span className="truncate">{CONTACT.email.label}</span>
</a>
</div>
<ul className="flex flex-col gap-1.5 border-t border-border pt-4 text-sm text-muted-foreground">
{CONTACT.hours.map((line) => (
<li key={line} className="flex items-start gap-2">
<ClockIcon aria-hidden="true" className="mt-0.5 size-4 shrink-0" />
{line}
</li>
))}
</ul>
</CardContent>
</Card>
</div>
<Accordion hiddenUntilFound defaultValue={FAQS[0] ? [FAQS[0].id] : []} className="gap-2 lg:col-span-7">
{FAQS.map((faq) => (
<AccordionItem key={faq.id} value={faq.id} className="frame px-5 not-last:border-b-0 data-open:bg-card">
<AccordionTrigger className="py-4 text-base text-pretty">{faq.question}</AccordionTrigger>
<AccordionContent className="pe-7 pb-5 text-base/7 text-pretty text-muted-foreground">{faq.answer}</AccordionContent>
</AccordionItem>
))}
</Accordion>
</div>
</section>
)
}
export default Faqs04/**
* Every word `faqs-04` sets: the questions a buyer asks before signing — a
* pilot, the security review, paying by invoice, the contract — and the
* person who answers the ones that are not here, with the two ways to reach
* her and how soon she replies. Her face is the kit's `avatarFor(name)`, drawn
* from the name, so change the name and the face follows.
*/
export type Faq = { id: string; question: string; answer: string }
export type ContactLink = { label: string; href: string }
export const EYEBROW = "Before you buy"
export const HEADLINE = "Questions from the people who sign the contract"
export const LEDE =
"Procurement, security and finance ask these in every deal. If yours is not here, put it to the person who answers them."
export const CONTACT = {
name: "Maya Lindqvist",
role: "Solutions lead, Northwind",
title: "Talk it through with Maya",
body: "Twenty minutes on your sources, your security review or your contract, with someone who has read your questions first.",
call: { label: "Book a call", href: "/contact?topic=sales" } satisfies ContactLink,
email: { label: "sales@northwind.example", href: "mailto:sales@northwind.example" } satisfies ContactLink,
/** How soon a message is answered, and when calls happen. */
hours: ["Replies within one working day", "Calls Monday to Friday, 9:00 to 17:00 CET"],
}
export const FAQS: readonly Faq[] = [
{
id: "pilot",
question: "Can we run a pilot with one team first?",
answer:
"Yes. A pilot runs for 30 days on Business with one team and up to three sources, and the dashboards it builds carry over when the rest of the company joins.",
},
{
id: "questionnaire",
question: "Will you fill in our security questionnaire?",
answer:
"Yes, usually within five working days. Most questions are already answered in our SOC 2 Type II report, which we send under NDA with the questionnaire.",
},
{
id: "invoice",
question: "Can we pay by invoice or purchase order?",
answer:
"On yearly plans, yes: we invoice against your purchase order number on 30-day terms, in US dollars, euros or pounds.",
},
{
id: "dpa",
question: "Do you sign a data processing agreement?",
answer:
"Our standard DPA is signed by default and covers the EU and UK. If your legal team has its own, send it over and we will mark up the differences.",
},
{
id: "migration",
question: "How long does it take to move off our spreadsheets?",
answer:
"Most teams run their weekly review in Northwind by the third week. We rebuild your two most-used reports with you during onboarding.",
},
{
id: "enterprise",
question: "What does Enterprise add to Business?",
answer:
"More than one workspace under one contract, a named success manager, a 99.95% uptime commitment in writing and a one-hour response for urgent issues.",
},
]npx shadcn@latest add @vibra/faqs-04Compact FAQ
faqs-05Six pricing questions with their answers already in view, in three ruled columns under a title and a link to the rest — for the foot of a pricing page.
import * as React from "react"
import Link from "next/link"
import { ArrowRightIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
import { ALL_QUESTIONS, FAQS, HEADLINE } from "./faqs-05.data"
/**
* The compact FAQ: six pricing questions with their answers already in view,
* for the foot of a pricing page, where a reader comparing plans wants the
* fine print without opening anything. The questions are a description list —
* each question its term, each answer its detail — in three ruled columns
* from `lg`, two from `sm` and one on a phone, under a title and a link to
* the rest.
*
* Nothing to open, so nothing to hide: a screen reader hears every question
* with its answer, and the browser's find reaches all of them. A server
* component with no state; every word is in `./faqs-05.data`.
*/
export function Faqs05() {
const titleId = React.useId()
return (
<section
data-section="faqs-05"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background px-4 py-16 text-foreground md:px-6 md:py-24"
>
<div className="mx-auto max-w-7xl">
<div className="flex flex-wrap items-end justify-between gap-x-6 gap-y-3">
<h2 id={titleId} className="type-display text-2xl text-balance md:text-3xl">
{HEADLINE}
</h2>
<Link href={ALL_QUESTIONS.href} className={cn(buttonVariants({ variant: "link" }), "group/all h-auto gap-1 px-0")}>
{ALL_QUESTIONS.label}
<ArrowRightIcon
aria-hidden="true"
data-icon="inline-end"
className="transition-transform duration-(--duration-fast) ease-(--ease-standard) group-hover/all:translate-x-0.5"
/>
</Link>
</div>
<dl className="mt-10 grid gap-x-10 gap-y-8 sm:grid-cols-2 lg:grid-cols-3">
{FAQS.map((faq) => (
<div key={faq.id} className="border-t border-rule pt-5">
<dt className="text-base font-semibold text-pretty">{faq.question}</dt>
<dd className="mt-2 text-sm/6 text-pretty text-muted-foreground">{faq.answer}</dd>
</div>
))}
</dl>
</div>
</section>
)
}
export default Faqs05/**
* Every word `faqs-05` sets: six pricing questions, answered in full on the
* page with nothing to open, for the foot of a pricing page where a reader
* compares plans and wants the fine print in view. Keep the answers short
* enough to read in a glance; the link at the top leads to the rest.
*/
export type Faq = { id: string; question: string; answer: string }
export const HEADLINE = "Pricing questions"
export const ALL_QUESTIONS = { label: "Every question", href: "/help" }
export const FAQS: readonly Faq[] = [
{
id: "per-workspace",
question: "What does a plan cover?",
answer: "One workspace: its members, sources and dashboards. Prices never depend on rows, charts or viewers.",
},
{
id: "trial",
question: "Do we need a card for the trial?",
answer: "No. The 14 days run on Business, and on day 15 the workspace waits for a plan without deleting anything.",
},
{
id: "yearly",
question: "How much does yearly billing save?",
answer: "About a sixth: Team is $99 a month billed yearly instead of $119, and Business $329 instead of $399.",
},
{
id: "change-plan",
question: "Can we change plans later?",
answer: "Any time. Moving up takes effect at once and is charged for the days left; moving down applies from the next bill.",
},
{
id: "tax",
question: "Are prices before tax?",
answer: "Yes. Sales tax or VAT is added where it applies, and a valid VAT number removes it for EU businesses.",
},
{
id: "cancel",
question: "What if we cancel?",
answer: "The plan runs to the end of the period you paid for, and every table stays exportable for 30 days after.",
},
]npx shadcn@latest add @vibra/faqs-05FAQ with support channels
faqs-06The five problems customers write in about most, each with its fix, then a panel of four places to take it next — guides, status, community and a person — each saying how soon it answers.
import * as React from "react"
import Link from "next/link"
import { ActivityIcon, ArrowUpRightIcon, BookOpenIcon, MailIcon, UsersIcon, type LucideIcon } from "lucide-react"
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
import { CHANNELS, EYEBROW, FAQS, HEADLINE, LEDE, STILL_STUCK, type Channel, type ChannelIcon } from "./faqs-06.data"
/** The picture each channel is drawn with: vocabulary of the layout, not copy. */
const ICONS: Record<ChannelIcon, LucideIcon> = {
guides: BookOpenIcon,
status: ActivityIcon,
community: UsersIcon,
email: MailIcon,
}
/**
* One way to get help: its icon, its name as the link, what it is for and how
* soon it answers. The link is the name, stretched over the tile, so a
* keyboard stops once per channel and a screen reader hears "Guides" rather
* than the whole tile read as one link; the lines under it stay text.
*/
function ChannelTile({ channel }: { channel: Channel }) {
const Icon = ICONS[channel.icon]
const stretched =
"rounded-sm font-medium after:absolute after:inset-0 after:rounded-lg focus-ring"
return (
<li className="group/channel relative flex flex-col gap-3 rounded-lg bg-card p-4 ring-1 ring-border transition-colors duration-(--duration-fast) ease-(--ease-standard) has-[a:hover]:bg-muted/40">
<div className="flex items-center justify-between gap-3">
<span className="flex size-9 items-center justify-center rounded-lg bg-surface ring-1 ring-border">
<Icon aria-hidden="true" className="size-4" />
</span>
<ArrowUpRightIcon
aria-hidden="true"
className="size-4 text-muted-foreground transition-transform duration-(--duration-fast) ease-(--ease-standard) group-has-[a:hover]/channel:translate-x-0.5 group-has-[a:hover]/channel:-translate-y-0.5"
/>
</div>
<h4 className="text-sm">
{channel.href.startsWith("mailto:") ? (
<a href={channel.href} className={stretched}>
{channel.title}
</a>
) : (
<Link href={channel.href} className={stretched}>
{channel.title}
</Link>
)}
</h4>
<p className="text-sm text-pretty text-muted-foreground">{channel.body}</p>
<p className="mt-auto text-xs text-muted-foreground">{channel.meta}</p>
</li>
)
}
/**
* The support FAQ: the five problems customers write in about most, each with
* the fix they can make themselves, then — for when a fix is not enough —
* four places to take it, from the guides to a person, each saying how soon
* it answers. The fixes read down one column at a comfortable measure; the
* channels sit in a panel on the surface plane under them, four across from
* `lg`, two from `sm` and stacked on a phone.
*
* The answers stay in the page while closed, so the browser's find reaches
* them. A server component: the accordion keeps its own state, and every word
* is in `./faqs-06.data`.
*/
export function Faqs06() {
const titleId = React.useId()
const stuckId = React.useId()
return (
<section
data-section="faqs-06"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background px-4 py-16 text-foreground md:px-6 md:py-24"
>
<div className="mx-auto max-w-7xl">
<div className="grid gap-x-16 gap-y-10 lg:grid-cols-12">
<div className="lg:col-span-4">
<p className="type-eyebrow">{EYEBROW}</p>
<h2 id={titleId} className="type-display mt-4 text-3xl text-balance md:text-4xl">
{HEADLINE}
</h2>
<p className="mt-5 text-base/7 text-pretty text-muted-foreground">{LEDE}</p>
</div>
<Accordion hiddenUntilFound className="border-t border-border lg:col-span-8">
{FAQS.map((faq) => (
<AccordionItem key={faq.id} value={faq.id} className="border-b">
<AccordionTrigger className="py-4 text-base text-pretty">{faq.question}</AccordionTrigger>
<AccordionContent className="pe-7 pb-5 text-base/7 text-pretty text-muted-foreground">{faq.answer}</AccordionContent>
</AccordionItem>
))}
</Accordion>
</div>
<div className="mt-16 frame p-5 md:mt-20 md:p-8">
<div className="flex flex-col gap-1.5">
<h3 id={stuckId} className="text-lg font-semibold">
{STILL_STUCK.title}
</h3>
<p className="text-sm text-pretty text-muted-foreground">{STILL_STUCK.lead}</p>
</div>
<ul aria-labelledby={stuckId} className="mt-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{CHANNELS.map((channel) => (
<ChannelTile key={channel.title} channel={channel} />
))}
</ul>
</div>
</div>
</section>
)
}
export default Faqs06/**
* Every word `faqs-06` sets: the five problems Northwind's support team hears
* most, each with its fix, and where to turn when a fix is not enough — the
* guides, the status page, the community and a person, each with what it is
* for and how soon it answers. Replace them with your own channels; keep the
* reply times true.
*/
export type Faq = { id: string; question: string; answer: string }
export type ChannelIcon = "guides" | "status" | "community" | "email"
export type Channel = {
icon: ChannelIcon
title: string
/** What the channel is for, in one line. */
body: string
/** How soon it answers, or how current it is. */
meta: string
href: string
}
export const EYEBROW = "Support"
export const HEADLINE = "Fixes for what we hear most"
export const LEDE =
"Five problems account for most of the messages we receive. Each one has a fix you can make yourself in a minute or two."
export const FAQS: readonly Faq[] = [
{
id: "sync-stopped",
question: "A source says it stopped syncing",
answer:
"Its sign-in has usually expired. Open Settings, Sources, choose the source and press Reconnect; the history already imported stays, and the charts catch up within minutes.",
},
{
id: "mrr-differs",
question: "MRR differs from the figure in our billing tool",
answer:
"Check the definition first: Northwind counts MRR after discounts and without tax. Open the metric to compare the two rules line by line, and change ours if yours is the one your board reads.",
},
{
id: "export-missing",
question: "An export never reached my inbox",
answer:
"Exports over 50,000 rows arrive as a download link rather than an attachment, and some mail filters hold the link. Every export of the last 30 days is also under Settings, Exports.",
},
{
id: "invite-missing",
question: "A teammate never received their invitation",
answer:
"Invitations expire after seven days. Resend it from Settings, Members; if your company signs in through an identity provider, add them there instead and SCIM brings them in.",
},
{
id: "lost-phone",
question: "I lost the phone with my sign-in codes",
answer:
"Use one of the recovery codes you saved when you turned the codes on. If you have none, a workspace owner can reset your second step from Settings, Members.",
},
]
export const STILL_STUCK = {
title: "Still stuck?",
lead: "Four places to take it next, from the quickest to a person.",
}
export const CHANNELS: readonly Channel[] = [
{
icon: "guides",
title: "Guides",
body: "Step-by-step setup for every source, chart and permission.",
meta: "142 guides, searchable",
href: "/docs",
},
{
icon: "status",
title: "Status",
body: "Whether every source is syncing right now, and past incidents.",
meta: "Updated every minute",
href: "/status",
},
{
icon: "community",
title: "Community",
body: "Questions answered by other Northwind admins and by our team.",
meta: "Most threads answered the same day",
href: "/community",
},
{
icon: "email",
title: "Email support",
body: "A person on the support team, with your workspace in front of them.",
meta: "Within 4 hours on Business",
href: "mailto:support@northwind.example",
},
]npx shadcn@latest add @vibra/faqs-06