Blocks

Help Center

A support landing block whose search ranks titles, keywords and summaries, whose topic cards report honest counts, and whose zero result offers a topic or a human instead of a dead end.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import {
  ArrowUpRight,
  BookOpen,
  ChevronDown,
  CircleDashed,
  Clock3,
  LifeBuoy,
  MessageCircle,
  OctagonAlert,
  RotateCw,
  Search,

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/help-center.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "HelpCenter" block with zod and
lucide-react, plus one sibling component that highlights query matches inside a
string (structural marks, never innerHTML); cn() (clsx + tailwind-merge) for
classes, no other dependency. This is the page a company points help.<domain>
at: one search field, topic cards, the articles people actually read, and a way
to reach a human. Its only job is to make sure nobody leaves with nothing.

Contract
- A zod schema (`helpCenterSchema` in a sibling contract file) is the single
  source of truth and props are z.infer of it — never a parallel interface:
  { status; title; tagline?; asOf; categories[]; articles[]; popular[];
    contact?; errorMessage? }.
- status is "loading" | "empty" | "error" | "ready" — the block's own render
  state, unrelated to whether a search matched anything.
- HelpArticle = { id; title; summary?; categoryId; href; updatedAt: string|null;
  readingMinutes?; keywords?: string[] }. `keywords` is searched and NEVER
  rendered: it carries the words people type but authors never write ("declined",
  "429", "money back"). `href` is the consumer's and is rendered as a real <a>,
  never a dead "#". updatedAt null means the source never recorded one.
- HelpCategory = { id; name; description?; articleCount: number|null }.
  articleCount is what the BACKEND holds; null means "count the ones that
  arrived". A reported number wins over the loaded count on purpose — this block
  ships a slice and the topic page behind the card holds the rest.
- HelpContact = { heading; body?; actionLabel; href: string|null;
  responseTime? }. href wins and renders an <a>; null falls back to the onContact
  handler and renders a <button>; with neither, render NO control at all — a
  dead "Contact us" button is worse than no button.
- popular is an array of ids into articles[], MOST POPULAR FIRST. That order is
  the host's ranking and is never re-sorted. Duplicates collapse; ids that
  resolve to nothing are counted and reported under the list.
- asOf is an ISO instant and is the ONLY clock. Never call Date.now(): every
  "updated 3 days ago" derives from it, so the same payload renders the same
  block and SSR matches hydration.
- Component props = the schema type plus: locale? ("en-US"), timeZone? ("UTC"),
  defaultQuery? (""), defaultCategoryId? (null), searchPlaceholder?
  ("Search help articles"), searchHotkey? ("/", null disables), resultLimit? (6,
  clamped 1-50), skeletonCards? (6, clamped 1-12), onSearchSettled?,
  onArticleSelect?, onContact?, onRetry?. forwardRef<HTMLElement>, extends
  Omit<React.HTMLAttributes<HTMLElement>, "title">, rest spread on the root
  <section>. defaultQuery / defaultCategoryId are uncontrolled initial values.
- Export the search itself: helpQueryTokens(query) and
  rankHelpArticles({ articles, categories, query, categoryId, popular }), plus
  resolvePopularArticles(articles, popular) -> { articles, missing } and
  categoryArticleCount(category, articles). A command palette, a 404 page or a
  server route must be able to rank with the same rules instead of growing a
  second opinion about which article answers "refund".

Behavior — four branches, not one plus three afterthoughts
- The hero (title, tagline, search field) renders in ALL FOUR branches. The
  field is readOnly outside "ready" — never `disabled`, because the browser
  blurs a disabled control to <body> and the caret must survive a branch flip.
- loading: skeletonCards topic cards plus four skeleton rows, aria-hidden, with
  aria-busy on the root.
- empty: one panel saying nothing is published yet.
- error: a destructive-bordered panel printing errorMessage, falling back to a
  sentence that admits the search has nothing to look through. The retry button
  exists only when onRetry is passed.
- ready: topic cards, the list, the contact strip.
- status "ready" with zero articles renders the EMPTY branch: a search field
  over nothing is a promise the block cannot keep.
- The contact strip renders in ready, empty AND error — the moment the feed is
  down is the moment somebody needs a human most.

Behavior — the search
- Tokens: lowercase the query, split on whitespace, drop duplicates. Empty means
  BROWSE, never "match nothing".
- An article survives when EVERY token matches SOMETHING (AND across tokens, OR
  across fields), which is what makes a second word narrow the list instead of
  widening it.
- Field weights decide the score: title 6 (+2 when the token opens the title),
  keyword 4, summary 2, topic name 1. So "billing" surfaces "Update your billing
  details" ahead of the twelve articles that merely live under Billing.
- Ties break by the host's popular rank, then by updatedAt descending (an
  undated article sinks below the dated ones rather than claiming to be either
  the newest or the oldest), then by id — deterministic to the last comparison,
  so two articles saved in the same second cannot swap between SSR and
  hydration.
- A topic card is a toggle (aria-pressed), not a link: pressing it filters,
  pressing it again releases. The card stays mounted, so focus never moves.
- Highlighting is structural — text nodes plus <mark> — never
  dangerouslySetInnerHTML, and the query is escaped before it reaches a RegExp:
  typing "(429)" must highlight those characters, not throw.
- The list caps at resultLimit with a "Show N more articles" disclosure
  (aria-expanded + aria-controls). Any change to the query or the topic collapses
  it again, because a new list must not grow the page under the person typing.
- With no query and no topic the list is the POPULAR one, numbered in the host's
  order. Otherwise it is the ranked results.

Behavior — the zero states (this is what makes it a help centre)
- Two different empties, two different sentences: "nothing matches <query>" is
  not "nothing is published under <topic> yet". Say which one happened.
- Both offer a way onward — the escape hatch ("Clear search" / "Show all
  articles") AND the contact action, so a miss routes to a human instead of
  ending the journey.
- The escape hatch unmounts itself with the panel it lives in, so the handler
  hands focus back to the search field on purpose. Focus must never land on
  <body>.

Behavior — degenerate data (branches, not crashes)
- A categoryId matching no category prints the raw id as the article's topic and
  the article stays searchable under that literal text: a broken association is
  information, not a reason to hide somebody's article.
- articleCount below zero clamps to zero; a count of 0 reads "No articles yet",
  and selecting that topic lands on the "nothing published here yet" branch.
- updatedAt that will not parse is printed VERBATIM; null prints nothing. A
  timestamp in the future (a skewed publishing box) reads "today", never
  "in -2 days".
- readingMinutes of 0, a negative or a non-finite number prints nothing rather
  than promising a "0 min read".
- Relative under 30 days ("today", "yesterday", "N days ago"), absolute beyond
  it — "47 days ago" is arithmetic, not information.
- An unknown IANA zone or a malformed locale makes Intl.DateTimeFormat throw a
  RangeError at construction; catch it and retry without timeZone, then with
  "en-US", so one bad config string cannot blank the page.

Behavior — keyboard, ARIA and narration
- Root is <section aria-labelledby> pointing at the h2 title, aria-busy while
  loading. The field sits in a <form role="search"> whose onSubmit is
  preventDefault (Enter must not reload the page), with an sr-only <label> and a
  visible hint wired through aria-describedby.
- searchHotkey (default "/") focuses and selects the field from anywhere: one
  document keydown listener that ignores modifier chords and any event whose
  target is an input, textarea, select or contenteditable — never steal a
  keystroke from something the user is already typing into. Remove the listener
  on unmount and whenever the key or the branch changes.
- Escape inside the field clears it and calls stopPropagation, but ONLY when it
  actually cleared something: an empty field leaves the key to a surrounding
  dialog.
- Tab order is a plain list of controls (field, clear, each topic card, each
  row, the disclosure, the contact action) — no roving tabindex, because this is
  not a composite widget.
- One persistent live region, <span role="status" aria-atomic class="sr-only">,
  fed from two places: the branch changing phase (announced immediately, with
  the first paint as a silent baseline), and the result count after a 320ms
  debounce. Keep the sentence and the count in a REF, not in the debounce
  effect's deps — putting them there re-runs the effect on every keystroke and
  the cleanup cancels the pending timer before it can fire.
- onSearchSettled fires on that same debounce with { query, categoryId,
  resultCount }, which is where a server search or an analytics event belongs.
- Retry is one-shot per burst: the guard is a ref read AND written synchronously
  inside the handler, because a state flag is only visible after a re-render and
  the second click of a double click lands before that. Paint the pressed state
  from separate state, re-arm on a 1200ms timer, and use aria-disabled plus a
  handler guard while locked — never the native disabled attribute.
- Cleanup: clear the debounce timer and the retry timer on unmount, and detach
  the hotkey listener on unmount and on dependency change.

Rendering & styling
- Semantic tokens only, no hex / rgb / oklch anywhere: bg-card + border +
  rounded-xl cards, bg-muted/30 hero, bg-muted/40 contact strip, bg-primary/5 +
  border-primary for the pressed topic card, text-muted-foreground for secondary
  text, text-destructive + border-destructive/40 for the error panel, bg-primary
  + text-primary-foreground for the contact action, ring-ring focus-visible
  rings with ring-offset-background on every control including the rows.
- The <mark> element's user-agent yellow ignores the colour scheme, so the
  highlighter must override it with tokens (bg-primary/30 + text-foreground).
- Topic cards are a grid (1 / 2 / 3 columns); the rows are a divide-y list. Long
  titles, topic names and raw ids get wrap-anywhere so a 58-character hostname
  wraps instead of overflowing.
- Reduced motion: motion-reduce:animate-none on the skeleton pulse and the retry
  spinner, motion-reduce:transition-none on the row hover, the card hover and the
  chevron rotation. Nothing functional depends on any of it.
- The row's trailing arrow appears on group-hover AND group-focus-within, so it
  is not a mouse-only affordance.

Customization levers
- Sub-blocks: pass categories: [] and the topic grid disappears; pass popular:
  [] and the browse list becomes the "nothing ranked yet" panel; omit `contact`
  and the block never offers a human (the zero states then only offer the escape
  hatch). Drop the hero copy by passing an empty tagline.
- Ranking: FIELD_SCORE { title 6, keyword 4, summary 2, category 1 } and
  TITLE_PREFIX_BONUS (2) are the entire relevance policy — raise the keyword
  weight for a support desk whose articles are titled badly, drop the category
  weight to 0 if topic names should never match.
- Density: resultLimit (6) is how many rows show before the disclosure; set it
  above your article count for a flat list. skeletonCards (6) should match the
  number of topics you expect.
- Freshness: RELATIVE_DAYS (30) is where "N days ago" turns into a date, and
  locale / timeZone drive the absolute format.
- Narration: SETTLE_MS (320) is the debounce for both the live region and
  onSearchSettled — raise it for a server-side search, lower it for a local one.
- Shortcut: searchHotkey takes any single key ("k" for a ⌘K-adjacent feel) or
  null to leave the document's keystrokes alone.
- Palette: the block is monochrome by default (primary + muted + destructive).
  Giving each topic card its own accent is one map from category id to
  var(--chart-1..5) applied to the card border and icon.
- Actions: onArticleSelect receives the click event, so a client router can
  preventDefault() and push instead of reloading; wire onContact to your ticket
  dialog, or set contact.href to a mailto:/support URL and skip the handler.

Concepts

  • A miss is a route, not a dead end — zero results is the state this block exists for. It never ends in an empty list: the panel names what happened, offers the escape hatch, and offers a human, which is the difference between a help centre and a list with a filter on top.
  • Two distinct zero states — "nothing matches your words" and "this topic is empty" are different facts and get different sentences and different escape hatches. Collapsing them into one "No results" is how people conclude the answer does not exist.
  • Deliberate focus successor — every control that removes itself (the clear-search X, "Clear filters", "Show all articles") hands focus back to the search field inside the handler. A topic card, by contrast, only flips aria-pressed and stays mounted, so pressing it never moves focus at all.
  • Reported counts beat loaded counts — a topic card prints the number the backend claims, not the number that happened to ship in this payload; null is the explicit way to say "count what arrived". A landing block is a slice of the library and must not understate it.
  • AND across tokens, OR across fields — every word you type has to match somewhere (title, keyword, summary or topic name), so a second word narrows instead of widening, and the field it matched in decides the rank. Keywords are searched and never rendered: that is how "429" and "money back" find articles nobody titled that way.
  • Phase plus debounce, one live region — the region speaks when the branch changes phase, and again 320ms after the query settles with the result count. The first paint is a silent baseline, so a screen reader is not read the whole block on load and not read a number for every keystroke.

On This Page