"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 = { rocket: , payments: , invoices: , billing: , security: , integrations: , }; const toneTile: Record = { 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 ? ( {part} ) : ( 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 (
{ if (!e.currentTarget.contains(e.relatedTarget as Node)) setOpen(false); }} >
= 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 ? ( ) : ( hero && / )}
{showList && ( {results.length ? (
    {results.map((a, i) => { const cat = categories.find((c) => c.id === a.categoryId); return (
  • ); })}
) : (
No articles match “{query.trim()}”
)}
)}
); } function ArticleRow({ article, category, query = "", onOpen }: { article: HelpArticle; category?: HelpCategory; query?: string; onOpen: () => void }) { return (
  • ); } 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({ name: "home" }); const [feedback, setFeedback] = useState>({}); const [activeHeading, setActiveHeading] = useState(null); const [contact, setContact] = useState(null); const [contactTopic, setContactTopic] = useState("Payments & payouts"); const [contactMsg, setContactMsg] = useState(""); const [contactError, setContactError] = useState(false); const [heroKey, setHeroKey] = useState(0); const scrollRef = useRef(null); const articleRef = useRef(null); const msgRef = useRef(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("[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(`[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 && ( )} ); }; const rail = (activeCat?: string, activeArticle?: string) => ( ); const contactCard = (

    Still stuck?

    Our support team replies in about {replyTime} on weekdays.

    ); let body: ReactNode; if (view.name === "home") { const popular = articles.filter((a) => a.popular); body = ( <>

    How can we help?

    Search {articles.length} guides from the {productName} team, or browse by topic.

    go({ name: "article", id })} onSearch={(q) => go({ name: "results", query: q })} size="hero" />
    Popular: {popularSearches.map((p) => ( ))}

    Browse by topic

      {categories.map((c, i) => ( ))}
      {popular.map((a) => ( go({ name: "article", id: a.id })} /> ))}
    {contactCard}
    ); } 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 = (
    {rail(c?.id)}
    {crumbs([{ label: "Help Centre", onClick: () => go({ name: "home" }) }, { label: c ? c.name : "Search" }])}
    {c && {icons[c.icon]}}

    {c ? c.name : <>Results for “{view.name === "results" ? view.query : ""}”}

    {c ? c.description : `${list.length} ${list.length === 1 ? "article" : "articles"} found`}

    {c &&

    {list.length} {list.length === 1 ? "article" : "articles"} · updated {list[0]?.updated ?? "recently"}

    }
    {list.length ? (
      {list.map((a) => ( go({ name: "article", id: a.id })} query={view.name === "results" ? view.query : ""} /> ))}
    ) : (
    Nothing matched that Try fewer or different words, or ask us directly. A person reads every message.
    )}
    {contactCard}
    ); } 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 = (
    {rail(article.categoryId, article.id)}
    {crumbs([ { label: "Help Centre", onClick: () => go({ name: "home" }) }, { label: c?.name ?? "", onClick: () => go({ name: "category", id: article.categoryId }) }, { label: article.title }, ])}

    {article.title}

    {productName.slice(0, 1)} Written by {productName} Support | Updated {article.updated} {article.minutes} min read
    {article.body.map((b, i) => { if (b.type === "p") return

    {b.text}

    ; if (b.type === "h2") return (

    {b.text}

    ); if (b.type === "list") return (
      {b.items.map((it) => { const [lead, ...rest] = it.split(": "); return (
    • {rest.length ? ( <> {lead}: {rest.join(": ")} ) : ( it )}
    • ); })}
    ); if (b.type === "steps") { step = 0; return (
      {b.items.map((it) => { step += 1; return (
    1. {step} {it}
    2. ); })}
    ); } return (
    {b.tone === "info" ? ( ) : ( )}

    {b.text}

    ); })}
    {fb?.sent || fb?.vote === "up" ? (
    {fb.vote === "up" ? "Glad it helped." : "Thanks, we'll use this to fix the article."} {fb.vote === "up" ? "Your vote helps others find the right answer." : "The docs team reviews every note within a week."}
    ) : (
    Did this answer your question?
    {fb?.vote === "down" && ( { e.preventDefault(); setFeedback((f) => ({ ...f, [article.id]: { ...f[article.id], sent: true } })); }} transition={{ duration: 0.2, ease: easeOut }} >
    What went wrong?
    {reasons.map((r) => { const on = fb.reason === r; return ( ); })}