"use client";
import * as React from "react";
import { AnimatePresence, LayoutGroup, motion, useReducedMotion } from "motion/react";
import { ChevronDown, Search, SearchX, X } from "lucide-react";
import { cn } from "@/lib/utils";
export type SearchFaqItem = { question: string; answer: string; category: string };
export interface FaqSearchProps {
eyebrow?: string;
title?: string;
subtitle?: string;
items?: SearchFaqItem[];
/** Category order for the chips. Defaults to the order they appear in `items`. */
categories?: string[];
allLabel?: string;
placeholder?: string;
className?: string;
}
const DEFAULT_ITEMS: SearchFaqItem[] = [
{ category: "Billing", question: "Which payment methods do you accept?", answer: "All major credit and debit cards, SEPA direct debit and bank transfer on annual plans. Invoices are issued automatically every billing cycle." },
{ category: "Billing", question: "Can I get a refund?", answer: "Annual plans can be refunded in full within 30 days of purchase. Monthly plans can be cancelled at any time and won't renew." },
{ category: "Billing", question: "Do prices include VAT?", answer: "Prices are shown excluding VAT. Tax is calculated at checkout based on your billing address, and reverse charge applies for valid EU VAT IDs." },
{ category: "Account", question: "How do I invite teammates?", answer: "Open Settings → Members and send invites by email. Teammates can join with a magic link or single sign-on if your workspace has it enabled." },
{ category: "Account", question: "Can I transfer workspace ownership?", answer: "Yes. The current owner can transfer ownership to any admin from Settings → Workspace. The new owner receives an email to confirm." },
{ category: "Account", question: "How do I delete my account?", answer: "Go to Settings → Profile → Delete account. All personal data is removed within 30 days, and you can export your data before deleting." },
{ category: "Product", question: "Does it work offline?", answer: "The desktop and mobile apps cache your recent projects, so you can keep working offline. Changes sync automatically when you reconnect." },
{ category: "Product", question: "Which integrations are available?", answer: "Over 80 native integrations including calendars, chat, storage and CRM tools, plus webhooks and a public API for anything custom." },
{ category: "Product", question: "Is there a limit on file uploads?", answer: "Free workspaces can upload files up to 25 MB. Paid plans raise the limit to 2 GB per file with unlimited total storage." },
{ category: "Security", question: "Is my data encrypted?", answer: "Data is encrypted in transit with TLS 1.3 and at rest with AES-256. Encryption keys are rotated automatically every 90 days." },
{ category: "Security", question: "Do you support single sign-on?", answer: "SAML and OIDC single sign-on are available on the Business plan, along with SCIM provisioning and enforced two-factor authentication." },
{ category: "Security", question: "Where are your servers located?", answer: "You can choose EU (Frankfurt) or US (Virginia) data residency when you create a workspace. Backups stay in the same region." },
];
function escapeRegExp(s: string) {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function Highlight({ text, query }: { text: string; query: string }) {
if (!query) return <>{text}</>;
const parts = text.split(new RegExp(`(${escapeRegExp(query)})`, "gi"));
return (
<>
{parts.map((p, i) =>
p.toLowerCase() === query.toLowerCase() ? (
<mark key={i} className="rounded-[4px] bg-amber-300/70 px-0.5 text-foreground dark:bg-amber-400/35">
{p}
</mark>
) : (
<React.Fragment key={i}>{p}</React.Fragment>
),
)}
</>
);
}
export function FaqSearch({
eyebrow = "Help center",
title = "How can we help?",
subtitle = "Search our most common questions or browse by topic.",
items = DEFAULT_ITEMS,
categories,
allLabel = "All",
placeholder = "Search questions…",
className,
}: FaqSearchProps) {
const reduce = useReducedMotion();
const [query, setQuery] = React.useState("");
const [category, setCategory] = React.useState<string>(allLabel);
const [open, setOpen] = React.useState<Set<string>>(() => new Set());
const inputRef = React.useRef<HTMLInputElement>(null);
const baseId = React.useId();
const cats = React.useMemo(() => [allLabel, ...(categories ?? Array.from(new Set(items.map((i) => i.category))))], [allLabel, categories, items]);
const q = query.trim();
const inCategory = (i: SearchFaqItem) => category === allLabel || i.category === category;
const matches = (i: SearchFaqItem) => !q || `${i.question} ${i.answer}`.toLowerCase().includes(q.toLowerCase());
// Question matches rank above answer-only matches so the list visibly reorders while typing.
const results = items
.filter((i) => inCategory(i) && matches(i))
.map((i) => ({ item: i, score: q && i.question.toLowerCase().includes(q.toLowerCase()) ? 0 : 1 }))
.sort((a, b) => a.score - b.score)
.map((r) => r.item);
const countFor = (c: string) => items.filter((i) => (c === allLabel || i.category === c) && matches(i)).length;
// "/" focuses the search box, like most docs sites.
React.useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const target = e.target as HTMLElement | null;
if (e.key === "/" && target && !["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName)) {
e.preventDefault();
inputRef.current?.focus();
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
const toggle = (key: string) =>
setOpen((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
const spring = reduce ? { duration: 0 } : { type: "spring" as const, stiffness: 420, damping: 38 };
return (
<section className={cn("relative w-full overflow-hidden bg-background px-4 py-16 sm:px-6 sm:py-20", className)}>
<div aria-hidden className="pointer-events-none absolute inset-x-0 top-0 h-80 bg-[radial-gradient(55%_100%_at_50%_0%,color-mix(in_oklch,var(--primary)_13%,transparent),transparent)]" />
<div className="relative mx-auto max-w-3xl">
<header className="text-center">
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-primary">{eyebrow}</p>
<h2 className="mt-3 text-balance text-3xl font-semibold tracking-tight text-foreground sm:text-5xl">{title}</h2>
<p className="mt-4 text-pretty text-base text-muted-foreground sm:text-lg">{subtitle}</p>
</header>
<div role="search" className="mt-10">
<label htmlFor={`${baseId}-q`} className="sr-only">
Search questions
</label>
<div className="group relative">
<Search aria-hidden className="pointer-events-none absolute left-5 top-1/2 size-5 -translate-y-1/2 text-muted-foreground transition-colors group-focus-within:text-primary" />
<input
ref={inputRef}
id={`${baseId}-q`}
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => e.key === "Escape" && setQuery("")}
placeholder={placeholder}
autoComplete="off"
aria-describedby={`${baseId}-count`}
className="h-14 w-full rounded-2xl border bg-card pl-14 pr-24 text-base text-foreground shadow-sm outline-none transition placeholder:text-muted-foreground/80 focus:border-primary/50 focus:shadow-lg focus:shadow-primary/10 focus:ring-4 focus:ring-primary/15 [&::-webkit-search-cancel-button]:appearance-none"
/>
<div className="absolute right-3 top-1/2 flex -translate-y-1/2 items-center gap-2">
<AnimatePresence initial={false}>
{query ? (
<motion.button
key="clear"
type="button"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
onClick={() => {
setQuery("");
inputRef.current?.focus();
}}
aria-label="Clear search"
className="grid size-8 place-items-center rounded-full bg-muted text-muted-foreground transition hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<X className="size-4" />
</motion.button>
) : (
<motion.kbd
key="kbd"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="hidden rounded-md border bg-muted px-2 py-0.5 font-mono text-xs text-muted-foreground sm:block"
>
/
</motion.kbd>
)}
</AnimatePresence>
</div>
</div>
</div>
<LayoutGroup id={baseId}>
<div className="mt-5 flex flex-wrap justify-center gap-2" role="group" aria-label="Filter by topic">
{cats.map((c) => {
const active = c === category;
const n = countFor(c);
return (
<button
key={c}
type="button"
aria-pressed={active}
onClick={() => setCategory(c)}
className={cn(
"relative inline-flex h-9 items-center gap-2 rounded-full px-4 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
active ? "text-primary-foreground" : "border bg-card text-muted-foreground hover:text-foreground",
)}
>
{active && <motion.span layoutId="faq-search-chip" className="absolute inset-0 rounded-full bg-primary shadow-md shadow-primary/25" transition={spring} />}
<span className="relative">{c}</span>
<span className={cn("relative rounded-full px-1.5 text-[11px] tabular-nums", active ? "bg-primary-foreground/20" : "bg-muted")}>{n}</span>
</button>
);
})}
</div>
<p id={`${baseId}-count`} className="sr-only" aria-live="polite">
{results.length} {results.length === 1 ? "question" : "questions"} found
</p>
<motion.ul layout={!reduce} className="relative mt-8 space-y-3">
<AnimatePresence mode="popLayout" initial={false}>
{results.map((item) => {
const key = item.question;
const answerHit = !!q && item.answer.toLowerCase().includes(q.toLowerCase());
const isOpen = open.has(key) || answerHit;
const panelId = `${baseId}-${items.indexOf(item)}`;
return (
<motion.li
key={key}
layout={!reduce}
initial={{ opacity: 0, scale: 0.97, y: 8 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.97, transition: { duration: 0.15 } }}
transition={spring}
className={cn("overflow-hidden rounded-2xl border bg-card transition-colors", isOpen && "border-primary/30 shadow-sm")}
>
<motion.button
layout="position"
type="button"
aria-expanded={isOpen}
aria-controls={panelId}
onClick={() => toggle(key)}
className="flex w-full items-center gap-4 px-5 py-4 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring sm:px-6"
>
<span aria-hidden className="hidden w-20 shrink-0 rounded-md bg-muted py-0.5 text-center text-[11px] font-medium uppercase tracking-wide text-muted-foreground sm:inline">{item.category}</span>
<span className="flex-1 font-medium text-foreground">
<Highlight text={item.question} query={q} />
</span>
<ChevronDown aria-hidden className={cn("size-4 shrink-0 text-muted-foreground transition-transform duration-300", isOpen && "rotate-180")} />
</motion.button>
<AnimatePresence initial={false}>
{isOpen && (
<motion.div
id={panelId}
key="a"
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={reduce ? { duration: 0 } : { duration: 0.3, ease: [0.22, 1, 0.36, 1] }}
>
<p className="px-5 pb-5 text-sm leading-relaxed text-muted-foreground sm:px-6">
<Highlight text={item.answer} query={q} />
</p>
</motion.div>
)}
</AnimatePresence>
</motion.li>
);
})}
</AnimatePresence>
</motion.ul>
</LayoutGroup>
<AnimatePresence>
{results.length === 0 && (
<motion.div
key="empty"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0, transition: { delay: 0.1 } }}
exit={{ opacity: 0 }}
className="mt-2 flex flex-col items-center rounded-3xl border border-dashed px-6 py-14 text-center"
>
<span className="grid size-14 place-items-center rounded-2xl bg-muted text-muted-foreground">
<SearchX aria-hidden className="size-6" />
</span>
<p className="mt-4 font-semibold text-foreground">
No results for “{q}”{category !== allLabel && <> in {category}</>}
</p>
<p className="mt-1 max-w-sm text-sm text-muted-foreground">Try a different keyword, pick another topic, or ask our team directly.</p>
<button
type="button"
onClick={() => {
setQuery("");
setCategory(allLabel);
}}
className="mt-5 rounded-full bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground transition hover:brightness-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
Clear filters
</button>
</motion.div>
)}
</AnimatePresence>
</div>
</section>
);
}