FAQ

Help Search

Searchable help card that filters question and answer text as you type, announces the result count, and offers clear-search and support actions when nothing matches.

Free, MIT licenseOpen in Browse
"use client"

import { useId, useRef, useState, type KeyboardEvent } from "react"
import { MagnifyingGlassIcon } from "@phosphor-icons/react"
import { cn } from "cn"

import { Faq } from "@/components/editorial/faq"
import { Button, buttonVariants } from "@/components/ui/button"
import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from "@/components/ui/card"
import {
  Empty,
  EmptyContent,
  EmptyDescription,
  EmptyHeader,
  EmptyTitle,
} from "@/components/ui/empty"
import { Field, FieldDescription, FieldLabel } from "@/components/ui/field"
import {
  InputGroup,
  InputGroupAddon,
  InputGroupInput,
} from "@/components/ui/input-group"

// The copy: change text here, not in the markup.
const COPY = {
  title: "Help",
  description:
    "Answers to what Orbcore teams ask most. Type to narrow the list.",
  label: "Search questions",
  placeholder: "Try “invoice” or “seats”",
  total: (total: number) => `${total} questions`,
  shown: (shown: number, total: number) => `${shown} of ${total} questions`,
  emptyTitle: (query: string) => `No questions match “${query}”`,
  emptyBody:
    "Try a shorter word, or ask support. A person replies within one business day.",
  clear: "Clear search",
  contact: "Email support",
  email: "support@orbcore.dev",
  subject: (query: string) => `Help search: ${query}`,
} as const

// Answers stay plain strings: search() matches the question and answer text.
const FAQS = [
  {
    id: "invite",
    question: "How do I invite a teammate?",
    answer:
      "Open Settings, then Members, and choose Invite. The email link they receive works for seven days.",
  },
  {
    id: "pending-invite",
    question: "Does a pending invite use a seat?",
    answer:
      "Yes. An invite holds its seat until it is accepted or withdrawn, so withdraw old invites to free a seat.",
  },
  {
    id: "seats",
    question: "How do I add or remove seats?",
    answer:
      "From Billing in Settings, at any time. Changes are prorated, so you pay only for the days a seat exists.",
  },
  {
    id: "invoices",
    question: "Where can I download invoices?",
    answer:
      "Every invoice since the workspace began is under Billing, as a PDF with your company details on it.",
  },
  {
    id: "refund",
    question: "Can I get a refund?",
    answer:
      "Yes, within 14 days of any payment. Write to billing@orbcore.dev and it goes back to the card you paid with.",
  },
  {
    id: "rate-limit",
    question: "What is the API rate limit?",
    answer:
      "600 requests a minute per key. Past that the API answers 429 with a Retry-After header.",
  },
  {
    id: "export",
    question: "How do I export our conversations?",
    answer:
      "Admins can download every conversation as CSV or JSON from Settings, then Data. Large exports arrive by email.",
  },
  {
    id: "hours",
    question: "When is support available?",
    answer:
      "Monday to Friday, 9:00 to 18:00 CET. A person replies within one business day.",
  },
] as const

// Lower-case, accents off, curly apostrophes straight: "what's" finds "What’s".
function normalize(text: string) {
  return text
    .normalize("NFD")
    .replace(/[\u0300-\u036f]/g, "")
    .replace(/[‘’]/g, "'")
    .toLowerCase()
}

// Every word of the query has to appear in the question or its answer.
function search(query: string) {
  const words = normalize(query).split(/\s+/).filter(Boolean)
  if (words.length === 0) return FAQS
  return FAQS.filter((faq) => {
    const text = normalize(`${faq.question} ${faq.answer}`)
    return words.every((word) => text.includes(word))
  })
}

export default function HelpSearch() {
  const id = useId()
  const inputRef = useRef<HTMLInputElement>(null)
  const [query, setQuery] = useState("")
  const term = query.trim()
  const results = search(term)

  function clear() {
    setQuery("")
    inputRef.current?.focus()
  }

  function onKeyDown(event: KeyboardEvent<HTMLInputElement>) {
    if (event.key === "Escape" && query) {
      event.preventDefault()
      setQuery("")
    }
  }

  return (
    <Card size="sm" className="mx-auto w-full max-w-xl min-w-0 wrap-anywhere">
      <CardHeader>
        <CardTitle>
          <h2 id={`${id}-title`}>{COPY.title}</h2>
        </CardTitle>
        <CardDescription>{COPY.description}</CardDescription>
      </CardHeader>
      <CardContent className="grid gap-6">
        <div role="search" aria-labelledby={`${id}-title`}>
          <Field>
            <FieldLabel htmlFor={`${id}-query`}>{COPY.label}</FieldLabel>
            <InputGroup>
              <InputGroupAddon>
                <MagnifyingGlassIcon aria-hidden="true" />
              </InputGroupAddon>
              <InputGroupInput
                ref={inputRef}
                id={`${id}-query`}
                type="search"
                value={query}
                onChange={(event) => setQuery(event.target.value)}
                onKeyDown={onKeyDown}
                placeholder={COPY.placeholder}
                autoComplete="off"
                aria-describedby={`${id}-status`}
              />
            </InputGroup>
            <FieldDescription id={`${id}-status`} role="status">
              {term
                ? COPY.shown(results.length, FAQS.length)
                : COPY.total(FAQS.length)}
            </FieldDescription>
          </Field>
        </div>
        {results.length > 0 ? (
          <Faq
            faqs={results}
            className="rounded-none border-0 border-t [&_[data-slot=accordion-content]]:px-0 [&_[data-slot=accordion-content]>div]:pr-7 [&_[data-slot=accordion-trigger]]:gap-3 [&_[data-slot=accordion-trigger]]:px-0"
          />
        ) : (
          <Empty className="min-w-0 border-t px-0 py-6 wrap-anywhere">
            <EmptyHeader>
              <EmptyTitle>{COPY.emptyTitle(term)}</EmptyTitle>
              <EmptyDescription>{COPY.emptyBody}</EmptyDescription>
            </EmptyHeader>
            <EmptyContent className="flex-row flex-wrap justify-center gap-2">
              <Button
                variant="outline"
                onClick={clear}
                className="h-auto min-h-9 max-w-full py-1.5 text-center whitespace-normal"
              >
                {COPY.clear}
              </Button>
              {/* The subject carries the search, so support sees what was tried. */}
              <a
                href={`mailto:${COPY.email}?subject=${encodeURIComponent(COPY.subject(term))}`}
                data-slot="button"
                className={cn(
                  buttonVariants(),
                  "h-auto min-h-9 max-w-full py-1.5 text-center whitespace-normal"
                )}
              >
                {COPY.contact}
              </a>
            </EmptyContent>
          </Empty>
        )}
      </CardContent>
    </Card>
  )
}

Installation

pnpm dlx shadcn add @01orbital/help-search
npx shadcn add @01orbital/help-search
yarn shadcn add @01orbital/help-search
bunx --bun shadcn add @01orbital/help-search

Free under the MIT license. Add the @01orbital registry to components.json once (install guide), then run the command.

It lands at components/blocks/faq/help-search/index.tsx, as source you own and edit.

Usage

import HelpSearch from "@/components/blocks/faq/help-search"

export default function Example() {
  return <HelpSearch />
}

Dependencies

npm: @phosphor-icons/react, cn. The CLI installs them for you.

Registry pieces it installs with it:

  • button
  • card
  • empty
  • faq
  • field
  • input-group