
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.
blocks/faq/help-search/index.tsx
"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>
)
}
