A support site with a search that suggests articles as you type, topic cards, readable articles with a table of contents that follows you, and a feedback form that asks what went wrong.
registry/devl/help-centre.tsx
"use client";
import {
ArrowLeftRightIcon,
ArrowRightIcon,
CheckIcon,
ChevronLeftIcon,
ChevronRightIcon,
CircleAlertIcon,
ClockIcon,
CornerDownLeftIcon,
CreditCardIcon,
FileTextIcon,
InfoIcon,
LifeBuoyIcon,
MailIcon,
MessageCircleIcon,
PlugIcon,
RocketIcon,
SearchIcon,
SearchXIcon,
ShieldCheckIcon,
ThumbsDownIcon,
ThumbsUpIcon,
XIcon,
} from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { type ReactNode, useEffect, useId, useRef, useState } from "react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Kbd } from "@/components/ui/kbd";
import { Textarea } from "@/components/ui/textarea";
function useReducedMotionAfterMount() {
const prefers = useReducedMotion();
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
return mounted && Boolean(prefers);
}
export type HelpTone = "sky" | "emerald" | "amber" | "violet" | "rose" | "teal";
export type HelpIcon = "rocket" | "payments" | "invoices" | "billing" | "security" | "integrations";
export interface HelpCategory {
id: string;
name: string;
description: string;
icon: HelpIcon;
tone: HelpTone;
}
export type HelpBlock =
| { type: "p"; text: string }
| { type: "h2"; text: string }
| { type: "steps"; items: string[] }
| { type: "list"; items: string[] }
| { type: "callout"; tone: "info" | "warning"; text: string };
export interface HelpArticle {
id: string;
title: string;
categoryId: string;
summary: string;
updated: string;
minutes: number;
popular?: boolean;
body: HelpBlock[];
}
export interface HelpCentreProps {
className?: string;
productName?: string;
categories?: HelpCategory[];
articles?: HelpArticle[];
popularSearches?: string[];
replyTime?: string;
}
const easeOut = [0.23, 1, 0.32, 1] as const;
const spring = { type: "spring", stiffness: 520, damping: 46, mass: 1 } as const;
const press = "transition-[color,background-color,box-shadow,scale,border-color] duration-150 ease-out active:scale-[0.97]";
const ring = "outline-none focus-visible:ring-2 focus-visible:ring-ring";
const icons: Record<HelpIcon, ReactNode> = {
rocket: <RocketIcon />,
payments: <ArrowLeftRightIcon />,
invoices: <FileTextIcon />,
billing: <CreditCardIcon />,
security: <ShieldCheckIcon />,
integrations: <PlugIcon />,
};
const toneTile: Record<HelpTone, string> = {
sky: "border bg-background text-sky-600 dark:bg-input/32 dark:text-sky-400",
emerald: "border bg-background text-emerald-600 dark:bg-input/32 dark:text-emerald-400",
amber: "border bg-background text-amber-600 dark:bg-input/32 dark:text-amber-400",
violet: "border bg-background text-violet-600 dark:bg-input/32 dark:text-violet-400",
rose: "border bg-background text-rose-600 dark:bg-input/32 dark:text-rose-400",
teal: "border bg-background text-teal-600 dark:bg-input/32 dark:text-teal-400",
};
const defaultCategories: HelpCategory[] = [
{ id: "start", name: "Getting started", description: "Set up your workspace and send your first invoice.", icon: "rocket", tone: "sky" },
{ id: "payments", name: "Payments & payouts", description: "Get paid by card or bank transfer, and know when money lands.", icon: "payments", tone: "emerald" },
{ id: "invoices", name: "Invoices & quotes", description: "Templates, reminders, taxes and credit notes.", icon: "invoices", tone: "amber" },
{ id: "billing", name: "Plans & billing", description: "Change plan, update your card, download receipts.", icon: "billing", tone: "violet" },
{ id: "security", name: "Account & security", description: "Two-factor, passkeys, sessions and data exports.", icon: "security", tone: "rose" },
{ id: "integrations", name: "Integrations", description: "Connect accounting tools and bank feeds.", icon: "integrations", tone: "teal" },
];
const brief = (intro: string, heading: string, steps: string[], tail?: string): HelpBlock[] => [
{ type: "p", text: intro },
{ type: "h2", text: heading },
{ type: "steps", items: steps },
...(tail ? [{ type: "p", text: tail } as HelpBlock] : []),
];
const defaultArticles: HelpArticle[] = [
{
id: "payout-timing",
title: "How long do payouts take?",
categoryId: "payments",
summary: "Most payouts reach your bank in 2 business days. Timing depends on the payment method and your bank.",
updated: "Oct 12, 2026",
minutes: 3,
popular: true,
body: [
{ type: "p", text: "Most payouts reach your bank in 2 business days. The exact timing depends on how your customer paid and where your bank is." },
{ type: "h2", text: "Payout timing by method" },
{
type: "list",
items: [
"Card payments: 2 business days after the payment clears.",
"Bank transfers (ACH or SEPA): 3 to 4 business days.",
"Instant payouts: within 30 minutes, for a 1% fee.",
],
},
{ type: "callout", tone: "info", text: "Payouts don't move on weekends or bank holidays. A payment made on Friday evening usually lands on Tuesday." },
{ type: "h2", text: "Check when a payout will arrive" },
{
type: "steps",
items: [
"Open Payments in the sidebar.",
"Select the Payouts tab.",
"Find the payout and check the Expected column. Hover the date to see which payments it includes.",
],
},
{ type: "h2", text: "If a payout is late" },
{
type: "p",
text: "If a payout hasn't arrived 2 business days after its expected date, check your bank details under Settings → Payouts, then contact us with the payout ID. It starts with po_.",
},
],
},
{
id: "two-factor",
title: "Set up two-factor authentication",
categoryId: "security",
summary: "Add a second step at sign-in so a stolen password alone can't open your account.",
updated: "Sep 28, 2026",
minutes: 2,
popular: true,
body: [
{ type: "p", text: "Two-factor authentication (2FA) asks for a second step when you sign in, so a stolen password alone can't get into your account." },
{ type: "h2", text: "Turn on 2FA" },
{
type: "steps",
items: [
"Go to Settings → Security.",
"Under Two-factor authentication, choose Authenticator app.",
"Scan the QR code with your app and enter the 6-digit code it shows.",
"Save your backup codes somewhere safe. Each one works once.",
],
},
{ type: "callout", tone: "warning", text: "If you lose your phone and your backup codes, we have to verify your identity before restoring access. That can take up to 3 business days." },
{ type: "h2", text: "Require 2FA for your team" },
{ type: "p", text: "Owners and admins can make 2FA mandatory under Settings → Team → Security policy. Teammates without it are asked to set it up the next time they sign in." },
],
},
{
id: "reminders",
title: "Send automatic payment reminders",
categoryId: "invoices",
summary: "Choose when reminders go out before and after the due date, and what they say.",
updated: "Oct 3, 2026",
minutes: 2,
popular: true,
body: [
{ type: "p", text: "Reminders go out on a schedule you choose, so you don't have to chase late invoices yourself." },
{ type: "h2", text: "Set a reminder schedule" },
{
type: "steps",
items: [
"Open Invoices → Settings → Reminders.",
"Pick when reminders go out: before the due date, on it, and after it.",
"Edit the message for each reminder, or keep the default wording.",
"Click Save. The schedule applies to every new invoice.",
],
},
{ type: "h2", text: "Turn reminders off for one customer" },
{ type: "p", text: "Open the customer, choose Edit, and switch off Send payment reminders. Invoices already sent keep their schedule until you pause them." },
{ type: "callout", tone: "info", text: "Reminders stop automatically as soon as an invoice is paid in full." },
],
},
{
id: "declined",
title: "Why was a payment declined?",
categoryId: "payments",
summary: "The customer's bank made the call. Here's what each decline reason means and how to retry.",
updated: "Oct 18, 2026",
minutes: 3,
popular: true,
body: [
{ type: "p", text: "When a card payment is declined, the customer's bank made the call. We show the reason the bank gave on the payment's timeline." },
{ type: "h2", text: "Common reasons" },
{
type: "list",
items: [
"Insufficient funds: the customer should try another card or pay by bank transfer.",
"Do not honour: a generic refusal. The customer needs to call their bank.",
"Suspected fraud: the bank flagged it. Ask the customer to approve the charge in their banking app, then retry.",
],
},
{ type: "h2", text: "Retry a declined payment" },
{
type: "steps",
items: [
"Open the invoice.",
"Click Send payment link to email the customer a fresh link.",
"Or, if they asked you to, choose Retry card to charge the saved card again.",
],
},
{ type: "callout", tone: "warning", text: "Retrying the same card more than 3 times in 24 hours can get it blocked. Ask the customer first." },
],
},
{
id: "change-plan",
title: "Change or cancel your plan",
categoryId: "billing",
summary: "Upgrades apply right away. Downgrades and cancellations take effect at the end of your billing period.",
updated: "Aug 30, 2026",
minutes: 2,
popular: true,
body: brief(
"Upgrades apply right away and you're charged the difference for the rest of the period. Downgrades and cancellations take effect when the current period ends.",
"Change your plan",
["Go to Settings → Plan & billing.", "Click Change plan and pick the new plan.", "Review the price change, then confirm."],
"Cancelling keeps your data for 90 days, so you can come back without losing invoices or customers.",
),
},
{
id: "invite-accountant",
title: "Invite your accountant",
categoryId: "start",
summary: "Give your accountant read-only access to invoices, payouts and reports. It doesn't use a paid seat.",
updated: "Sep 9, 2026",
minutes: 1,
popular: true,
body: brief(
"Accountants get read-only access to invoices, payouts and reports. The Accountant role is free and doesn't count towards your seats.",
"Send the invite",
["Open Settings → Team.", "Click Invite and enter their email.", "Choose the Accountant role and send."],
),
},
{
id: "first-invoice",
title: "Create your first invoice",
categoryId: "start",
summary: "Add a customer, line items and a due date, then send it by email or share a link.",
updated: "Jul 22, 2026",
minutes: 3,
body: brief(
"An invoice needs a customer, at least one line item and a due date. Everything else is optional.",
"Create and send",
["Click New → Invoice.", "Pick a customer or add a new one.", "Add line items, then set the due date.", "Click Send, or Copy link to share it yourself."],
),
},
{
id: "vat",
title: "Add VAT or sales tax to invoices",
categoryId: "invoices",
summary: "Save tax rates once and apply them per line item. Totals and tax summaries update automatically.",
updated: "Oct 1, 2026",
minutes: 2,
body: brief(
"Save each tax rate once, then apply it to any line item. Invoice totals and the tax summary update as you go.",
"Add a tax rate",
["Go to Invoices → Settings → Taxes.", "Click Add rate, name it (for example “VAT 20%”) and enter the percentage.", "On any invoice, pick the rate from the Tax column."],
),
},
{
id: "bank-feed",
title: "Connect a bank feed",
categoryId: "integrations",
summary: "Match incoming payments to invoices automatically by connecting your business bank account.",
updated: "Sep 17, 2026",
minutes: 2,
body: brief(
"A bank feed brings in transactions every few hours so we can match them to open invoices for you.",
"Connect your bank",
["Open Integrations → Bank feeds.", "Search for your bank and sign in through its secure page.", "Pick the accounts to sync. The first import covers 90 days."],
),
},
{
id: "receipts",
title: "Download receipts and tax invoices",
categoryId: "billing",
summary: "Every charge has a PDF receipt. You can also add your tax ID so it appears on them.",
updated: "Aug 4, 2026",
minutes: 1,
body: brief(
"Every charge for your plan has a receipt you can download as a PDF.",
"Find a receipt",
["Go to Settings → Plan & billing.", "Scroll to Billing history.", "Click Download next to the charge."],
"To show your tax ID on receipts, add it under Billing details. It applies to future receipts only.",
),
},
{
id: "passkey",
title: "Sign in with a passkey",
categoryId: "security",
summary: "Use your fingerprint, face or device PIN instead of a password.",
updated: "Oct 20, 2026",
minutes: 1,
body: brief(
"Passkeys let you sign in with your fingerprint, face or device PIN. They can't be phished or reused.",
"Add a passkey",
["Go to Settings → Security.", "Under Passkeys, click Add passkey.", "Follow your device's prompt to confirm."],
),
},
{
id: "accounting-sync",
title: "Sync invoices to your accounting software",
categoryId: "integrations",
summary: "Invoices, payments and fees sync both ways, every 15 minutes.",
updated: "Sep 25, 2026",
minutes: 3,
body: brief(
"Once connected, invoices, payments and fees sync both ways every 15 minutes.",
"Turn on sync",
["Open Integrations and choose your accounting tool.", "Sign in and approve access.", "Map your income and fee accounts, then click Start syncing."],
),
},
{
id: "refund",
title: "Issue a refund or credit note",
categoryId: "invoices",
summary: "Refund all or part of a payment. A credit note is created for your records automatically.",
updated: "Oct 9, 2026",
minutes: 2,
body: brief(
"You can refund all or part of a card payment. We create a matching credit note so your books stay balanced.",
"Refund a payment",
["Open the paid invoice.", "Click Refund and enter the amount.", "Add a reason (the customer sees it) and confirm."],
"Refunds take 5 to 10 business days to reach the customer's card.",
),
},
{
id: "payout-account",
title: "Update your payout bank account",
categoryId: "payments",
summary: "Change where payouts go. Pending payouts still go to the old account.",
updated: "Sep 2, 2026",
minutes: 1,
body: brief(
"You can change the bank account payouts go to at any time. Payouts already on their way still go to the old account.",
"Change the account",
["Go to Settings → Payouts.", "Click Change next to your bank account.", "Enter the new details and confirm with your 2FA code."],
),
},
];
function slug(s: string) {
return s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "");
}
function Mark({ text, query }: { text: string; query: string }) {
const words = query.trim().toLowerCase().split(/\s+/).filter((w) => w.length > 1);
if (!words.length) return <>{text}</>;
const re = new RegExp(`(${words.map((w) => w.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})`, "gi");
return (
<>
{text.split(re).map((part, i) =>
i % 2 ? (
<mark className="rounded-[3px] bg-warning/25 text-foreground" key={i}>
{part}
</mark>
) : (
part
),
)}
</>
);
}
function score(a: HelpArticle, query: string) {
const words = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
if (!words.length) return 0;
const title = a.title.toLowerCase();
const text = `${a.summary} ${a.body.map((b) => ("text" in b ? b.text : b.items.join(" "))).join(" ")}`.toLowerCase();
let s = 0;
for (const w of words) {
if (title.includes(w)) s += 3;
else if (text.includes(w)) s += 1;
else return 0;
}
return s;
}
function search(articles: HelpArticle[], query: string) {
return articles
.map((a) => ({ a, s: score(a, query) }))
.filter((x) => x.s > 0)
.sort((x, y) => y.s - x.s)
.map((x) => x.a);
}
type View =
| { name: "home" }
| { name: "category"; id: string }
| { name: "results"; query: string }
| { name: "article"; id: string };
function SearchBox({
articles,
categories,
onOpen,
onSearch,
size,
initial = "",
}: {
articles: HelpArticle[];
categories: HelpCategory[];
onOpen: (id: string) => void;
onSearch: (q: string) => void;
size: "hero" | "compact";
initial?: string;
}) {
const [query, setQuery] = useState(initial);
const [open, setOpen] = useState(false);
const [active, setActive] = useState(-1);
const listId = useId();
const reduce = useReducedMotionAfterMount();
const results = search(articles, query).slice(0, 5);
const showList = open && query.trim().length > 0;
useEffect(() => setActive(-1), [query]);
const commit = () => {
if (active >= 0 && results[active]) onOpen(results[active].id);
else if (query.trim()) onSearch(query.trim());
setOpen(false);
};
const hero = size === "hero";
return (
<div
className="relative w-full"
onBlur={(e) => {
if (!e.currentTarget.contains(e.relatedTarget as Node)) setOpen(false);
}}
>
<div
className={cn(
"relative flex items-center rounded-xl border border-input bg-background text-foreground shadow-xs/5 transition-shadow has-focus-visible:border-ring has-focus-visible:ring-[3px] has-focus-visible:ring-ring/24 dark:bg-input/32",
hero ? "h-12 @lg:h-13" : "h-9",
)}
>
<SearchIcon className={cn("pointer-events-none absolute text-muted-foreground", hero ? "left-4 size-4.5" : "left-3 size-4")} />
<input
aria-activedescendant={active >= 0 ? `${listId}-${active}` : undefined}
aria-autocomplete="list"
aria-controls={listId}
aria-expanded={showList}
aria-label="Search help articles"
className={cn(
"size-full min-w-0 bg-transparent outline-none placeholder:text-muted-foreground/72 [&::-webkit-search-cancel-button]:hidden",
hero ? "ps-11 pe-11 text-base" : "ps-9 pe-8 text-sm",
)}
onChange={(e) => {
setQuery(e.target.value);
setOpen(true);
}}
onFocus={() => setOpen(true)}
onKeyDown={(e) => {
if (e.key === "ArrowDown") {
e.preventDefault();
setOpen(true);
setActive((a) => Math.min(results.length - 1, a + 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setActive((a) => Math.max(-1, a - 1));
} else if (e.key === "Enter") {
e.preventDefault();
commit();
} else if (e.key === "Escape") {
if (showList) setOpen(false);
else setQuery("");
}
}}
placeholder={hero ? "Search for answers, e.g. “payout”" : "Search help"}
role="combobox"
type="search"
value={query}
/>
{query ? (
<button
aria-label="Clear search"
className={cn("absolute flex size-7 cursor-pointer items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground", ring, hero ? "right-3" : "right-1")}
onClick={() => setQuery("")}
type="button"
>
<XIcon className="size-4" />
</button>
) : (
hero && <Kbd className="absolute right-4 hidden @lg:inline-flex">/</Kbd>
)}
</div>
<AnimatePresence>
{showList && (
<motion.div
animate={{ opacity: 1, y: 0, scale: 1 }}
className="absolute inset-x-0 top-full z-30 mt-2 origin-top overflow-hidden rounded-xl border bg-popover text-left text-popover-foreground shadow-lg/5"
exit={{ opacity: 0, scale: 0.98, transition: { duration: 0.1 } }}
initial={reduce ? { opacity: 0 } : { opacity: 0, y: -4, scale: 0.98 }}
transition={{ duration: 0.16, ease: easeOut }}
>
{results.length ? (
<ul className="p-1.5" id={listId} role="listbox">
{results.map((a, i) => {
const cat = categories.find((c) => c.id === a.categoryId);
return (
<li aria-selected={active === i} id={`${listId}-${i}`} key={a.id} role="option">
<button
className={cn("flex w-full cursor-pointer items-center gap-3 rounded-lg px-2.5 py-2 text-left", active === i ? "bg-accent" : "hover:bg-accent/60")}
onClick={() => {
onOpen(a.id);
setOpen(false);
}}
onMouseEnter={() => setActive(i)}
tabIndex={-1}
type="button"
>
<span className={cn("flex size-8 shrink-0 items-center justify-center rounded-lg [&_svg]:size-4", cat && toneTile[cat.tone])}>
{cat && icons[cat.icon]}
</span>
<span className="flex min-w-0 flex-1 flex-col">
<span className="truncate font-medium text-sm">
<Mark query={query} text={a.title} />
</span>
<span className="truncate text-muted-foreground text-xs">{cat?.name}</span>
</span>
{active === i && <CornerDownLeftIcon className="size-3.5 shrink-0 text-muted-foreground" />}
</button>
</li>
);
})}
</ul>
) : (
<div className="flex items-center gap-2.5 px-4 py-3.5 text-muted-foreground text-sm" id={listId} role="listbox">
<SearchXIcon className="size-4" /> No articles match “{query.trim()}”
</div>
)}
<button
className="flex w-full cursor-pointer items-center justify-between gap-2 border-t px-4 py-2.5 text-muted-foreground text-xs hover:bg-accent/60 hover:text-foreground"
onClick={() => {
onSearch(query.trim());
setOpen(false);
}}
tabIndex={-1}
type="button"
>
<span className="truncate">
See all results for “<span className="font-medium text-foreground">{query.trim()}</span>”
</span>
<Kbd>↵</Kbd>
</button>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
function ArticleRow({ article, category, query = "", onOpen }: { article: HelpArticle; category?: HelpCategory; query?: string; onOpen: () => void }) {
return (
<li>
<button
className={cn("group/row flex w-full cursor-pointer items-start gap-3 rounded-xl px-3 py-3 text-left hover:bg-accent/60", ring, press, "active:scale-[0.99]")}
onClick={onOpen}
type="button"
>
<FileTextIcon className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="font-medium">
<Mark query={query} text={article.title} />
</span>
<span className="line-clamp-2 text-pretty text-muted-foreground text-xs leading-5">
<Mark query={query} text={article.summary} />
</span>
{category && <span className="mt-1 text-muted-foreground text-xs">{category.name}</span>}
</span>
<ChevronRightIcon className="mt-0.5 size-4 shrink-0 text-muted-foreground/60 transition-transform duration-150 ease-out [@media(hover:hover)]:group-hover/row:translate-x-0.5" />
</button>
</li>
);
}
const reasons = ["Out of date", "Hard to follow", "Didn't fix my problem", "Something else"];
export function HelpCentre({
className,
productName = "Kite",
categories = defaultCategories,
articles = defaultArticles,
popularSearches = ["payouts", "2FA", "refund", "VAT", "cancel plan"],
replyTime = "4 min",
}: HelpCentreProps) {
const reduce = useReducedMotionAfterMount();
const [view, setView] = useState<View>({ name: "home" });
const [feedback, setFeedback] = useState<Record<string, { vote: "up" | "down"; reason?: string; note?: string; sent?: boolean }>>({});
const [activeHeading, setActiveHeading] = useState<string | null>(null);
const [contact, setContact] = useState<null | "form" | "sent">(null);
const [contactTopic, setContactTopic] = useState("Payments & payouts");
const [contactMsg, setContactMsg] = useState("");
const [contactError, setContactError] = useState(false);
const [heroKey, setHeroKey] = useState(0);
const scrollRef = useRef<HTMLDivElement>(null);
const articleRef = useRef<HTMLElement>(null);
const msgRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
if (contact === "form") msgRef.current?.focus({ preventScroll: true });
}, [contact]);
const go = (v: View) => {
setView(v);
scrollRef.current?.scrollTo({ top: 0 });
};
const cat = (id: string) => categories.find((c) => c.id === id);
const count = (id: string) => articles.filter((a) => a.categoryId === id).length;
const article = view.name === "article" ? articles.find((a) => a.id === view.id) : undefined;
const headings = article ? article.body.filter((b) => b.type === "h2").map((b) => (b as { text: string }).text) : [];
useEffect(() => {
const box = scrollRef.current;
if (!box || !article) return;
const onScroll = () => {
const els = Array.from(box.querySelectorAll<HTMLElement>("[data-heading]"));
const top = box.getBoundingClientRect().top + 96;
let current: string | null = els[0]?.dataset.heading ?? null;
for (const el of els) if (el.getBoundingClientRect().top <= top) current = el.dataset.heading ?? current;
if (box.scrollTop + box.clientHeight >= box.scrollHeight - 4 && els.length) current = els[els.length - 1].dataset.heading ?? current;
setActiveHeading(current);
};
onScroll();
box.addEventListener("scroll", onScroll, { passive: true });
return () => box.removeEventListener("scroll", onScroll);
}, [article]);
const jumpTo = (h: string) => {
const box = scrollRef.current;
const el = box?.querySelector<HTMLElement>(`[data-heading="${slug(h)}"]`);
if (!box || !el) return;
box.scrollTo({ top: el.offsetTop - 24, behavior: reduce ? "auto" : "smooth" });
};
const openContact = () => {
setContact("form");
setContactError(false);
if (article) setContactTopic(cat(article.categoryId)?.name ?? contactTopic);
};
const crumbs = (items: { label: string; onClick?: () => void }[]) => {
const parent = [...items].reverse().find((c) => c.onClick && c !== items[items.length - 1]);
return (
<>
{parent && (
<button
className={cn("-ms-1 flex w-fit cursor-pointer items-center gap-0.5 rounded text-muted-foreground text-xs hover:text-foreground @lg:hidden", ring)}
onClick={parent.onClick}
type="button"
>
<ChevronLeftIcon className="size-4" /> {parent.label}
</button>
)}
<nav aria-label="Breadcrumb" className="hidden min-w-0 items-center gap-1 text-muted-foreground text-xs @lg:flex">
{items.map((c, i) => (
<span className="flex min-w-0 items-center gap-1" key={c.label}>
{i > 0 && <ChevronRightIcon className="size-3.5 shrink-0 opacity-60" />}
{c.onClick ? (
<button className={cn("cursor-pointer truncate rounded hover:text-foreground", ring)} onClick={c.onClick} type="button">
{c.label}
</button>
) : (
<span aria-current="page" className="truncate text-foreground">
{c.label}
</span>
)}
</span>
))}
</nav>
</>
);
};
const rail = (activeCat?: string, activeArticle?: string) => (
<nav aria-label="Topics" className="sticky top-0 hidden w-52 shrink-0 self-start pt-11 @4xl:block">
<p className="mb-2 px-2 font-medium text-xs">Topics</p>
<ul className="flex flex-col gap-px">
{categories.map((tc) => {
const on = tc.id === activeCat;
const inCat = on && activeArticle ? articles.filter((a) => a.categoryId === tc.id) : [];
return (
<li key={tc.id}>
<button
aria-current={on && !activeArticle ? "page" : undefined}
className={cn(
"flex h-8 w-full cursor-pointer items-center gap-2.5 rounded-md px-2 text-left [&_svg]:size-4",
ring,
on ? "bg-accent font-medium text-foreground" : "text-muted-foreground hover:bg-accent/60 hover:text-foreground",
)}
onClick={() => go({ name: "category", id: tc.id })}
type="button"
>
<span className={cn("shrink-0", on ? "text-foreground" : "opacity-80")}>{icons[tc.icon]}</span>
<span className="truncate">{tc.name}</span>
</button>
{inCat.length > 0 && (
<ul className="my-1 ms-4 flex flex-col border-l ps-2">
{inCat.map((a) => (
<li key={a.id}>
<button
aria-current={a.id === activeArticle ? "page" : undefined}
className={cn(
"w-full cursor-pointer rounded-md px-2 py-1.5 text-left text-xs leading-5",
ring,
a.id === activeArticle ? "font-medium text-foreground" : "text-muted-foreground hover:text-foreground",
)}
onClick={() => go({ name: "article", id: a.id })}
type="button"
>
{a.title}
</button>
</li>
))}
</ul>
)}
</li>
);
})}
</ul>
</nav>
);
const contactCard = (
<section className="flex flex-col gap-4 rounded-xl border bg-card p-5 @2xl:flex-row @2xl:items-center">
<span className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-muted text-foreground">
<LifeBuoyIcon className="size-5" />
</span>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<h2 className="font-heading font-semibold">Still stuck?</h2>
<p className="text-pretty text-muted-foreground">
Our support team replies in about <span className="font-medium text-foreground tabular-nums">{replyTime}</span> on weekdays.
</p>
</div>
<div className="flex gap-2">
<Button className={press} onClick={openContact}>
<MessageCircleIcon /> Contact support
</Button>
</div>
</section>
);
let body: ReactNode;
if (view.name === "home") {
const popular = articles.filter((a) => a.popular);
body = (
<>
<section className="border-b bg-muted/30 px-4 pt-12 pb-10 @2xl:pt-16 @2xl:pb-14">
<div className="mx-auto flex max-w-xl flex-col items-center gap-5 text-center">
<div className="flex flex-col gap-1.5">
<h1 className="text-balance font-heading font-semibold text-2xl tracking-tight @2xl:text-3xl">How can we help?</h1>
<p className="text-pretty text-muted-foreground">
Search {articles.length} guides from the {productName} team, or browse by topic.
</p>
</div>
<SearchBox articles={articles} categories={categories} key={heroKey} onOpen={(id) => go({ name: "article", id })} onSearch={(q) => go({ name: "results", query: q })} size="hero" />
<div className="flex flex-wrap items-center justify-center gap-1.5 text-xs">
<span className="text-muted-foreground">Popular:</span>
{popularSearches.map((p) => (
<button
className={cn("h-7 cursor-pointer rounded-full border bg-background px-2.5 text-muted-foreground hover:border-foreground/20 hover:text-foreground dark:bg-input/32", ring, press)}
key={p}
onClick={() => go({ name: "results", query: p })}
type="button"
>
{p}
</button>
))}
</div>
</div>
</section>
<div className="mx-auto flex w-full max-w-5xl flex-col gap-10 px-4 py-10 @2xl:px-8">
<section aria-labelledby="hc-cats" className="flex flex-col gap-3">
<h2 className="font-medium" id="hc-cats">
Browse by topic
</h2>
<ul className="grid grid-cols-1 gap-3 @lg:grid-cols-2 @4xl:grid-cols-3">
{categories.map((c, i) => (
<motion.li
animate={{ opacity: 1, y: 0 }}
initial={reduce ? false : { opacity: 0, y: 6 }}
key={c.id}
transition={{ duration: 0.25, ease: easeOut, delay: Math.min(i, 6) * 0.03 }}
>
<button
className={cn(
"group/cat flex h-full w-full cursor-pointer items-start gap-3.5 rounded-xl border bg-card p-4 text-left hover:border-foreground/16 hover:bg-accent/32",
ring,
press,
"active:scale-[0.98]",
)}
onClick={() => go({ name: "category", id: c.id })}
type="button"
>
<span className={cn("flex size-9 shrink-0 items-center justify-center rounded-lg shadow-xs/5 [&_svg]:size-4", toneTile[c.tone])}>{icons[c.icon]}</span>
<span className="flex min-w-0 flex-1 flex-col gap-1">
<span className="flex items-center justify-between gap-2 font-medium">
{c.name}
<ArrowRightIcon className="size-4 shrink-0 text-muted-foreground/0 transition-[color,translate] duration-150 ease-out [@media(hover:hover)]:group-hover/cat:translate-x-0.5 [@media(hover:hover)]:group-hover/cat:text-muted-foreground" />
</span>
<span className="text-muted-foreground text-xs leading-5">{c.description}</span>
<span className="mt-1 text-muted-foreground text-xs tabular-nums">{count(c.id)} articles</span>
</span>
</button>
</motion.li>
))}
</ul>
</section>
<section aria-labelledby="hc-popular" className="flex flex-col gap-3">
<h2 className="font-medium" id="hc-popular">
Popular articles
</h2>
<ul className="grid grid-cols-1 divide-y overflow-hidden rounded-xl border bg-card [&_button]:rounded-none [&_button]:px-4 @3xl:grid-cols-2 @3xl:divide-y-0 @3xl:[&>li:nth-child(n+3)]:border-t @3xl:[&>li:nth-child(even)]:border-s">
{popular.map((a) => (
<ArticleRow article={a} category={cat(a.categoryId)} key={a.id} onOpen={() => go({ name: "article", id: a.id })} />
))}
</ul>
</section>
{contactCard}
</div>
</>
);
} else if (view.name === "category" || view.name === "results") {
const c = view.name === "category" ? cat(view.id) : undefined;
const list = view.name === "category" ? articles.filter((a) => a.categoryId === view.id) : search(articles, view.query);
body = (
<div className="mx-auto flex w-full max-w-6xl gap-10 px-4 py-8 @2xl:px-8 @2xl:py-10">
{rail(c?.id)}
<div className="flex min-w-0 max-w-2xl flex-1 flex-col">
{crumbs([{ label: "Help Centre", onClick: () => go({ name: "home" }) }, { label: c ? c.name : "Search" }])}
<div className="mt-5 flex items-start gap-4 border-b pb-6">
{c && <span className={cn("flex size-10 shrink-0 items-center justify-center rounded-lg shadow-xs/5 [&_svg]:size-5", toneTile[c.tone])}>{icons[c.icon]}</span>}
<div className="flex min-w-0 flex-col gap-1">
<h1 className="text-balance font-heading font-semibold text-2xl tracking-tight">
{c ? c.name : <>Results for “{view.name === "results" ? view.query : ""}”</>}
</h1>
<p className="text-pretty text-muted-foreground">{c ? c.description : `${list.length} ${list.length === 1 ? "article" : "articles"} found`}</p>
{c && <p className="mt-1 text-muted-foreground text-xs tabular-nums">{list.length} {list.length === 1 ? "article" : "articles"} · updated {list[0]?.updated ?? "recently"}</p>}
</div>
</div>
{list.length ? (
<ul className="mt-6 divide-y overflow-hidden rounded-xl border bg-card [&_button]:rounded-none [&_button]:px-4">
{list.map((a) => (
<ArticleRow
article={a}
category={view.name === "results" ? cat(a.categoryId) : undefined}
key={a.id}
onOpen={() => go({ name: "article", id: a.id })}
query={view.name === "results" ? view.query : ""}
/>
))}
</ul>
) : (
<div className="mt-6 flex flex-col items-center gap-4 rounded-xl border border-dashed px-6 py-12 text-center">
<span className="flex size-10 items-center justify-center rounded-lg border bg-background text-muted-foreground shadow-xs/5 dark:bg-input/32">
<SearchXIcon className="size-4.5" />
</span>
<div className="flex max-w-xs flex-col gap-1">
<span className="font-medium">Nothing matched that</span>
<span className="text-pretty text-muted-foreground text-xs">Try fewer or different words, or ask us directly. A person reads every message.</span>
</div>
<div className="flex gap-2">
<Button className={press} onClick={() => go({ name: "home" })} size="sm" variant="outline">
Browse topics
</Button>
<Button className={press} onClick={openContact} size="sm">
Contact support
</Button>
</div>
</div>
)}
<div className="mt-10">{contactCard}</div>
</div>
</div>
);
} else if (article) {
const c = cat(article.categoryId);
const fb = feedback[article.id];
const related = articles.filter((a) => a.categoryId === article.categoryId && a.id !== article.id).slice(0, 3);
let step = 0;
body = (
<div className="mx-auto flex w-full max-w-6xl gap-10 px-4 py-8 @2xl:px-8 @2xl:py-10">
{rail(article.categoryId, article.id)}
<article className="flex min-w-0 max-w-2xl flex-1 flex-col" ref={articleRef}>
{crumbs([
{ label: "Help Centre", onClick: () => go({ name: "home" }) },
{ label: c?.name ?? "", onClick: () => go({ name: "category", id: article.categoryId }) },
{ label: article.title },
])}
<header className="mt-5 border-b pb-6">
<h1 className="text-balance font-heading font-semibold text-2xl tracking-tight @2xl:text-3xl">{article.title}</h1>
<div className="mt-4 flex flex-wrap items-center gap-x-3 gap-y-2 text-muted-foreground text-xs">
<span className="flex items-center gap-2">
<span className="flex size-6 items-center justify-center rounded-full bg-foreground font-medium text-[10px] text-background">
{productName.slice(0, 1)}
</span>
<span>
Written by <span className="font-medium text-foreground">{productName} Support</span>
</span>
</span>
<span aria-hidden className="text-border">
|
</span>
<span>Updated {article.updated}</span>
<span className="flex items-center gap-1 tabular-nums">
<ClockIcon className="size-3.5" /> {article.minutes} min read
</span>
</div>
</header>
<div className="mt-6 flex flex-col gap-4 text-[15px] text-foreground/90 leading-7">
{article.body.map((b, i) => {
if (b.type === "p") return <p className="text-pretty" key={i}>{b.text}</p>;
if (b.type === "h2")
return (
<h2
className="mt-6 scroll-mt-6 font-heading font-semibold text-foreground text-lg tracking-tight first:mt-0"
data-heading={slug(b.text)}
id={`hc-${slug(b.text)}`}
key={i}
>
{b.text}
</h2>
);
if (b.type === "list")
return (
<ul className="flex flex-col gap-2" key={i}>
{b.items.map((it) => {
const [lead, ...rest] = it.split(": ");
return (
<li className="flex gap-3" key={it}>
<span aria-hidden className="mt-[0.7rem] size-1.5 shrink-0 rounded-full bg-foreground/40" />
<span className="text-pretty">
{rest.length ? (
<>
<span className="font-medium text-foreground">{lead}:</span> {rest.join(": ")}
</>
) : (
it
)}
</span>
</li>
);
})}
</ul>
);
if (b.type === "steps") {
step = 0;
return (
<ol className="flex flex-col gap-3.5" key={i}>
{b.items.map((it) => {
step += 1;
return (
<li className="relative flex gap-3.5" key={it}>
<span className="flex size-6 shrink-0 items-center justify-center rounded-full border bg-background font-medium text-xs tabular-nums shadow-xs/5">
{step}
</span>
<span className="text-pretty pt-px leading-6">{it}</span>
</li>
);
})}
</ol>
);
}
return (
<div
className={cn(
"flex gap-3 rounded-lg border-s-2 px-4 py-3 text-sm leading-6",
b.tone === "info" ? "border-info bg-info/6 dark:bg-info/10" : "border-warning bg-warning/8 dark:bg-warning/10",
)}
key={i}
role="note"
>
{b.tone === "info" ? (
<InfoIcon className="mt-1 size-4 shrink-0 text-info-foreground" />
) : (
<CircleAlertIcon className="mt-1 size-4 shrink-0 text-warning-foreground" />
)}
<p className="text-pretty">{b.text}</p>
</div>
);
})}
</div>
<section aria-label="Article feedback" className="mt-12 border-t pt-6">
<AnimatePresence initial={false} mode="popLayout">
{fb?.sent || fb?.vote === "up" ? (
<motion.div
animate={{ opacity: 1, scale: 1 }}
className="flex items-center gap-3"
initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.98 }}
key="thanks"
transition={{ duration: 0.2, ease: easeOut }}
>
<span className="flex size-8 shrink-0 items-center justify-center rounded-full bg-success/12 text-success-foreground">
<CheckIcon className="size-4" />
</span>
<div className="flex flex-col">
<span className="font-medium">{fb.vote === "up" ? "Glad it helped." : "Thanks, we'll use this to fix the article."}</span>
<span className="text-muted-foreground text-xs">
{fb.vote === "up" ? "Your vote helps others find the right answer." : "The docs team reviews every note within a week."}
</span>
</div>
</motion.div>
) : (
<motion.div
className="flex flex-col gap-4"
exit={{ opacity: 0, scale: 0.98, transition: { duration: 0.12 } }}
key="ask"
layout={reduce ? false : "size"}
transition={spring}
>
<div className="flex flex-wrap items-center justify-between gap-3">
<span className="font-medium">Did this answer your question?</span>
<div className="flex gap-2">
<Button
aria-pressed={false}
className={press}
onClick={() => setFeedback((f) => ({ ...f, [article.id]: { vote: "up" } }))}
size="sm"
variant="outline"
>
<ThumbsUpIcon /> Yes
</Button>
<Button
aria-pressed={fb?.vote === "down"}
className={cn(press, fb?.vote === "down" && "border-foreground/30 bg-accent")}
onClick={() => setFeedback((f) => ({ ...f, [article.id]: { vote: "down" } }))}
size="sm"
variant="outline"
>
<ThumbsDownIcon /> No
</Button>
</div>
</div>
{fb?.vote === "down" && (
<motion.form
animate={{ opacity: 1, y: 0 }}
className="flex flex-col gap-3"
initial={reduce ? { opacity: 0 } : { opacity: 0, y: -4 }}
onSubmit={(e) => {
e.preventDefault();
setFeedback((f) => ({ ...f, [article.id]: { ...f[article.id], sent: true } }));
}}
transition={{ duration: 0.2, ease: easeOut }}
>
<fieldset className="flex flex-col gap-2">
<legend className="mb-2 text-muted-foreground text-xs">What went wrong?</legend>
<div className="flex flex-wrap gap-1.5">
{reasons.map((r) => {
const on = fb.reason === r;
return (
<button
aria-pressed={on}
className={cn(
"h-8 cursor-pointer rounded-full border px-3 text-xs",
ring,
press,
on ? "border-primary bg-primary text-primary-foreground" : "bg-background text-foreground hover:bg-accent dark:bg-input/32",
)}
key={r}
onClick={() => setFeedback((f) => ({ ...f, [article.id]: { ...f[article.id], reason: r } }))}
type="button"
>
{r}
</button>
);
})}
</div>
</fieldset>
<Textarea
aria-label="Tell us more (optional)"
onChange={(e) => setFeedback((f) => ({ ...f, [article.id]: { ...f[article.id], note: e.target.value } }))}
placeholder="Tell us more (optional)"
size="sm"
value={fb.note ?? ""}
/>
<div className="flex items-center justify-end gap-2">
<Button className={press} onClick={() => setFeedback((f) => {
const { [article.id]: _, ...rest } = f;
return rest;
})} size="sm" type="button" variant="ghost">
Cancel
</Button>
<Button className={press} disabled={!fb.reason} size="sm" type="submit">
Send feedback
</Button>
</div>
</motion.form>
)}
</motion.div>
)}
</AnimatePresence>
</section>
{related.length > 0 && (
<section aria-labelledby="hc-related" className="mt-10 flex flex-col gap-3">
<h2 className="font-medium" id="hc-related">
Related articles
</h2>
<ul className="divide-y overflow-hidden rounded-xl border bg-card [&_button]:rounded-none [&_button]:px-4">
{related.map((a) => (
<ArticleRow article={a} key={a.id} onOpen={() => go({ name: "article", id: a.id })} />
))}
</ul>
</section>
)}
<div className="mt-10">{contactCard}</div>
</article>
{headings.length > 1 && (
<aside className="sticky top-0 hidden w-44 shrink-0 self-start pt-11 @6xl:block">
<h2 className="mb-2 font-medium text-xs">On this page</h2>
<ul className="relative flex flex-col border-l">
{headings.map((h) => {
const on = activeHeading === slug(h);
return (
<li className="relative" key={h}>
{on && (
<motion.span
className="absolute inset-y-1 -left-px w-0.5 rounded-full bg-foreground"
layoutId="hc-toc"
transition={reduce ? { duration: 0 } : spring}
/>
)}
<button
className={cn(
"w-full cursor-pointer rounded-r-md py-1.5 ps-3 text-left text-xs leading-5 transition-colors",
ring,
on ? "font-medium text-foreground" : "text-muted-foreground hover:text-foreground",
)}
onClick={() => jumpTo(h)}
type="button"
>
{h}
</button>
</li>
);
})}
</ul>
</aside>
)}
</div>
);
}
const viewKey = view.name === "article" ? `a-${view.id}` : view.name === "category" ? `c-${view.id}` : view.name === "results" ? `r-${view.query}` : "home";
return (
<div
className={cn("@container relative isolate flex h-[760px] w-full flex-col overflow-hidden bg-background text-foreground text-sm", className)}
onKeyDown={(e) => {
if (e.key === "Escape" && contact) setContact(null);
const t = e.target as HTMLElement;
if (e.key === "/" && !t.closest("input, textarea")) {
e.preventDefault();
(e.currentTarget.querySelector('input[role="combobox"]') as HTMLInputElement | null)?.focus();
}
}}
>
<header className="flex h-14 shrink-0 items-center gap-3 border-b px-4 @2xl:px-6">
<button
className={cn("flex shrink-0 cursor-pointer items-center gap-2.5 rounded-lg", ring)}
onClick={() => {
go({ name: "home" });
setHeroKey((k) => k + 1);
}}
type="button"
>
<span className="flex size-8 items-center justify-center rounded-lg bg-primary text-primary-foreground">
<svg aria-hidden className="size-4" fill="none" viewBox="0 0 16 16">
<path d="M8 1.5 14 8l-6 6.5L2 8z" fill="currentColor" opacity=".9" />
<path d="M8 1.5V14.5" stroke="var(--primary)" strokeWidth="1.2" />
</svg>
</span>
<span className="font-heading font-semibold">{productName}</span>
<span className="hidden text-muted-foreground @md:inline">Help Centre</span>
</button>
{view.name !== "home" && (
<div className="ms-2 hidden w-full max-w-xs @3xl:block">
<SearchBox
articles={articles}
categories={categories}
initial={view.name === "results" ? view.query : ""}
key={viewKey}
onOpen={(id) => go({ name: "article", id })}
onSearch={(q) => go({ name: "results", query: q })}
size="compact"
/>
</div>
)}
<div className="ms-auto flex items-center gap-2">
<span className="hidden items-center gap-1.5 rounded-full border px-2.5 py-1 text-muted-foreground text-xs @2xl:flex">
<span className="size-1.5 rounded-full bg-success" /> All systems normal
</span>
{view.name !== "home" && (
<Button aria-label="Search" className="@3xl:hidden" onClick={() => go({ name: "home" })} size="icon-sm" variant="ghost">
<SearchIcon />
</Button>
)}
<Button className={press} onClick={openContact} size="sm" variant="outline">
<MailIcon /> <span className="hidden @md:inline">Contact us</span>
<span className="@md:hidden">Contact</span>
</Button>
</div>
</header>
<div className="min-h-0 flex-1 overflow-y-auto" ref={scrollRef}>
<motion.div
animate={{ opacity: 1, y: 0 }}
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 6 }}
key={viewKey}
transition={{ duration: 0.2, ease: easeOut }}
>
{body}
</motion.div>
</div>
<AnimatePresence>
{contact && (
<>
<motion.div
animate={{ opacity: 1 }}
aria-hidden
className="absolute inset-0 z-40 bg-black/32"
exit={{ opacity: 0 }}
initial={{ opacity: 0 }}
onClick={() => setContact(null)}
transition={{ duration: 0.2, ease: easeOut }}
/>
<motion.div
animate={{ opacity: 1, y: 0, scale: 1 }}
aria-labelledby="hc-contact-title"
aria-modal
className="absolute inset-x-3 bottom-3 z-50 mx-auto flex max-w-md flex-col gap-4 rounded-2xl border bg-popover p-5 text-popover-foreground shadow-lg/10 @2xl:top-24 @2xl:bottom-auto"
exit={{ opacity: 0, y: 8, scale: 0.98, transition: { duration: 0.15, ease: easeOut } }}
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 12, scale: 0.98 }}
role="dialog"
transition={{ duration: 0.22, ease: easeOut }}
>
<div className="flex items-start gap-3">
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<h2 className="font-heading font-semibold text-base" id="hc-contact-title">
{contact === "sent" ? "Message sent" : "Contact support"}
</h2>
<p className="text-pretty text-muted-foreground text-xs">
{contact === "sent" ? "We've emailed you a copy. Replies land in your inbox." : `A real person replies, usually within ${replyTime}.`}
</p>
</div>
<Button aria-label="Close" className="-me-2 -mt-1" onClick={() => setContact(null)} size="icon-sm" variant="ghost">
<XIcon />
</Button>
</div>
{contact === "sent" ? (
<motion.div
animate={{ opacity: 1, scale: 1 }}
className="flex flex-col items-center gap-3 rounded-xl bg-muted/60 px-4 py-6 text-center"
initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.97 }}
transition={{ duration: 0.22, ease: easeOut }}
>
<span className="flex size-10 items-center justify-center rounded-full bg-success/12 text-success-foreground">
<CheckIcon className="size-5" />
</span>
<span className="font-medium">
Ticket <span className="font-mono">#48213</span> is open
</span>
<Button className={press} onClick={() => setContact(null)} size="sm" variant="outline">
Done
</Button>
</motion.div>
) : (
<form
className="flex flex-col gap-4"
noValidate
onSubmit={(e) => {
e.preventDefault();
if (contactMsg.trim().length < 10) {
setContactError(true);
msgRef.current?.focus();
return;
}
setContact("sent");
setContactMsg("");
}}
>
<fieldset className="flex flex-col gap-2">
<legend className="mb-2 font-medium text-xs">Topic</legend>
<div className="flex flex-wrap gap-1.5">
{categories.map((c) => {
const on = contactTopic === c.name;
return (
<button
aria-pressed={on}
className={cn(
"h-8 cursor-pointer rounded-full border px-3 text-xs",
ring,
press,
on ? "border-primary bg-primary text-primary-foreground" : "bg-background hover:bg-accent dark:bg-input/32",
)}
key={c.id}
onClick={() => setContactTopic(c.name)}
type="button"
>
{c.name}
</button>
);
})}
</div>
</fieldset>
<label className="flex flex-col gap-2">
<span className="font-medium text-xs">How can we help?</span>
<Textarea
aria-describedby={contactError ? "hc-contact-err" : undefined}
aria-invalid={contactError || undefined}
onChange={(e) => {
setContactMsg(e.target.value);
if (contactError && e.target.value.trim().length >= 10) setContactError(false);
}}
placeholder="Describe what happened and what you expected. Include IDs like po_ or inv_ if you have them."
ref={msgRef}
value={contactMsg}
/>
<AnimatePresence initial={false}>
{contactError && (
<motion.span
animate={{ opacity: 1, height: "auto" }}
className="overflow-hidden text-destructive-foreground text-xs"
exit={{ opacity: 0, height: 0 }}
id="hc-contact-err"
initial={{ opacity: 0, height: 0 }}
transition={{ duration: 0.18, ease: easeOut }}
>
Add a few more words so we can help on the first reply.
</motion.span>
)}
</AnimatePresence>
</label>
<div className="flex items-center gap-2">
<Input aria-label="Your email" className="flex-1" defaultValue="maya@larkfield.studio" type="email" />
<Button className={press} type="submit">
Send
</Button>
</div>
</form>
)}
</motion.div>
</>
)}
</AnimatePresence>
</div>
);
}