PVR Tech Studio

FAQ Page

A polished help center at /faq — a searchable hero, browse-by-topic cards, a filterable Q&A accordion with helpful-voting, and a sticky support aside, all driven by i18n keys.

8 min read
Updated July 15, 2026

A polished help-center at /faq: a tinted hero band with live search, browse-by-topic category cards, a filterable single-open Q&A accordion with "was this helpful?" voting, and a sticky aside (popular questions + support CTA). All questions and answers are i18n keys — the content model stores no literal copy.

Overview

The page opens with a hero band — a light primary/info-tinted gradient card (dark text, not a saturated CTA band) textured with a masked DotGrid and two parallaxing soft glows — carrying an eyebrow chip, the headline, and a large pill search input. Below it, five category cards (License & usage / Customization & theming / Tech stack / Updates & versioning / Support) act as filter toggles, each showing its icon and a live "N articles" count.

The main area is a two-column grid: the Q&A accordion (single-open, controlled, first question open by default; each answer ends in a HelpfulVote thumbs row) and a sticky aside with a "Popular questions" list (click → clears filters, opens that item, and smooth-scrolls it to center) and a support CTA card (Email / Live chat / Contact buttons + a typical-reply-time note).

Search matches against the translated question and answer text, so filtering works in the active language. Search and category compose; a ghost Clear filters button appears only while filtering, and zero matches render a token-drawn FaqEmpty illustration. All state lives in the useFaq view-model hook (the feature view-model convention); the page component is render-only wiring. Votes are local component state — nothing persists.

Architecture & files

FileResponsibility
src/pages/FaqPage.tsxThe page: PageHeader, the hero band (search), the category-card grid, the accordion + aside layout, and the support CTA. Owns only presentation helpers (selectCategory, scroll-to-list); everything stateful comes from useFaq.
src/components/faq/useFaq.tsThe view-model: search query, category filter (with the FAQ_ALL sentinel), the controlled single-open accordion value, local votes (+ thanks toast), the popular list, filtered results, isFiltering, clear, and jumpTo.
src/components/faq/HelpfulVote.tsx"Was this helpful?" thumbs up/down row appended to each answer. Controlled by the parent; shows the thanks string once voted.
src/components/faq/FaqEmpty.tsx"No matching questions" empty state — an inline SVG drawn with raw token vars so it re-skins/dark-modes automatically.
src/components/faq/index.tsBarrel: FaqEmpty, HelpfulVote (+ Vote type), useFaq, FAQ_ALL.
src/data/faq.tsThe content model: FaqCategory / FaqItem types, FAQ_CATEGORIES (5), and FAQ_ITEMS (19) — i18n keys only, no literal copy.
src/locales/en/faq.jsonAll page chrome and every question/answer (q*/a* keys) in the faq namespace.
src/routes.tsxThe route entry: {path: '/faq', element: <FaqPage />} (standard shell page, not full-bleed).
src/data/menu.tsThe sidebar leaf: Pages → FAQ Page (nav:faq).

Data model

export interface FaqCategory {
    id: string
    /** i18n key (faq ns) for the category label. */
    labelKey: string
    icon: LucideIcon
}
 
export interface FaqItem {
    id: string
    categoryId: string
    /** i18n keys (faq ns) for the question / answer. */
    qKey: string
    aKey: string
    /** Surfaced in the "Popular questions" strip. */
    popular?: boolean
}

Like data/pricing.ts, the model stores keys, not copyqKey/aKey resolve at render via the faq namespace, so the entire help center translates by editing locale JSON. Five items are flagged popular in the shipped seed and feed the aside strip.

No persistence

Unlike the scrumboard/calendar/contacts apps, the FAQ keeps no localStorage state: filters, the open item, and votes reset on reload. There is deliberately no STORAGE_KEY and nothing to register in src/lib/appStorage.ts.

Usage

The page is already wired — navigate to /faq (Pages → FAQ Page in the sidebar). It's a standard shell page (renders <PageHeader title={t('nav:faq')} />, so the breadcrumb auto-derives and the tab title is set automatically), registered in src/routes.tsx:

{path: '/faq', element: <FaqPage />},

To reuse just the state logic (e.g. an embedded FAQ widget), call the hook:

import {useFaq, FAQ_ALL} from '@/components/faq'
 
function MiniFaq() {
    const faq = useFaq()
    // faq.filtered is ready to render; faq.setQuery / faq.setCategory drive it
}

API / Props

useFaq()

Returns the FAQ view-model:

KeyTypeDescription
query / setQuerystring / (v) => voidThe search text. Matching is case-insensitive against the translated question and answer.
category / setCategorystring / (v) => voidThe active category id, or the FAQ_ALL sentinel ('all').
open / setOpenstring / (v) => voidThe single open accordion item id (controlled). Defaults to the first seed question.
votesRecord<string, Vote>Per-question local votes ('up' | 'down').
vote(id, dir) => voidRecord a vote and fire the faq:helpfulThanks success toast.
filteredFaqItem[]Items passing the category + search filters.
popularFaqItem[]Items flagged popular in the seed.
isFilteringbooleanTrue when a query is set or a category is active — drives the Clear-filters button.
clear() => voidReset query + category.
jumpTo(item: FaqItem) => voidReset filters, open the item, and smooth-scroll it to center (double-rAF so the list re-renders first).

HelpfulVoteProps (HelpfulVote.tsx)

PropTypeDescription
vote?'up' | 'down'The current vote for this question (from the page's vote map), if any.
onVote(dir: Vote) => voidVote callback. Buttons carry aria-pressed; the selected one tints success (up) / danger (down).

FaqEmpty (FaqEmpty.tsx)

{title: string; hint: string} — pass already-translated strings (faq:emptyTitle / faq:emptyHint on the page).

Accordion wiring

The list is the standard Accordion primitive in controlled single-open mode — value={faq.open} with onValueChange={(open) => faq.setOpen(open[0] ?? '')} (the callback always receives the next open-item array). Each AccordionItem gets its category's icon and a title <span> whose id is faq-item-<id> with a scroll-mt-24 class — that id + scroll margin is what jumpTo targets under the sticky header.

Configuration & customization

Add or edit a question

Add a FaqItem to FAQ_ITEMS in src/data/faq.ts and its two keys to the locale files:

// src/data/faq.ts
{id: 'stackDeploy', categoryId: 'stack', qKey: 'faq:qStackDeploy', aKey: 'faq:aStackDeploy'},
// src/locales/en/faq.json
{
    "qStackDeploy": "How do I deploy the build?",
    "aStackDeploy": "Run npm run build and serve the dist/ folder from any static host."
}

Category counts, search, and the accordion pick it up automatically. Flag it popular: true to surface it in the aside strip.

Add a category

Add a FaqCategory (unique id, a faq: label key, a lucide icon) to FAQ_CATEGORIES plus the label in the locale files. The card grid is lg:grid-cols-5 — adjust the grid classes in FaqPage.tsx if you go beyond five. Keep at least ~3 questions per category: the "N articles" count renders a plural label (faq:articles) unconditionally.

Change the support contacts

The CTA card's email is the module-level SUPPORT_EMAIL constant at the top of FaqPage.tsx (a demo contact — swap for your own; the button opens mailto:). Live chat navigates to /apps/chat and Contact to /apps/contacts; repoint those navigate calls at your channels.

Hero band

The hero is a FadeIn card with bg-gradient-to-br from-primary/10 via-surface to-info/10, a <DotGrid masked> texture (faded toward the edges), and a <Parallax distance={36}> layer of two blurred token-tinted glows. It's the light variant deliberately — dark foreground text on a tinted surface rather than a saturated primary CTA band. To restyle it, adjust the token-opacity utilities; don't introduce hex.

Examples

import {useFaq} from '@/components/faq'
import {FAQ_ITEMS} from '@/data/faq'
 
const faq = useFaq()
const target = FAQ_ITEMS.find((i) => i.id === 'customTheme')!
faq.jumpTo(target) // clears filters, opens it, scrolls it to center

Filter to one category programmatically

faq.setCategory('license') // only License & usage questions
faq.setQuery('extended')   // …matching "extended" in the active language

Best practices

  • Keys, not copy. Every question/answer is a faq: key resolved at render — add content by editing data/faq.ts + the locale JSON, never by inlining strings in components.
  • Keep logic in useFaq. The page stays render-only per the feature-view-model convention; extend filtering/voting in the hook.
  • Search the translation, not the key. The filter compares t(item.qKey)/t(item.aKey) — keep it that way so search works in every language.
  • Tokens for every tint. Category cards, vote states (success/danger tints), the hero, and the empty-state SVG are all semantic-token based.
  • Persist votes deliberately. If you make votes survive reloads, add the new localStorage key to APP_LOCAL_KEYS in src/lib/appStorage.ts so "Reset to defaults" clears it.

Troubleshooting

SymptomCause / fix
A popular-question click doesn't scroll to the itemjumpTo waits two animation frames before scrollIntoView (the list must re-render unfiltered first) and targets #faq-item-<id> with scroll-mt-24. Keep both if you restructure.
Clicking an open question's category card shows everythingExpected toggle: selectCategory flips an already-active category back to FAQ_ALL.
Search misses a question you can seeThe match runs on the translated text in the current language — check the active locale's faq.json, not the English source.
Votes disappear after reloadBy design — votes are local useState, no persistence.
Two questions open at onceThe accordion is type="single" and controlled via faq.open; keep the value/onValueChange pair rather than switching to uncontrolled defaultValue.
"N articles" reads wrong for a 1-question categoryThe label is unconditionally plural (faq:articles); all shipped categories have ≥3 questions. Add a plural-aware key if you need singleton categories.

FAQ

Is the FAQ content translated? The chrome and all Q&A ship in the faq namespace with en+ja parity; other languages fill via npm run i18n:translate (DeepL). Answers referencing file paths (e.g. src/styles/index.css) keep those literal.

Do votes go anywhere? No — they're presentational local state plus a thanks toast. Wire useFaq's vote() to your API (src/lib/api.ts) to record real feedback.

Can I make questions rich text? The answer renders as a plain <p>{t(item.aKey)}</p>. For markup, render a <Trans> component or a custom body per item instead of the flat aKey string.

Why does one question start open? useFaq seeds open with the first seed item (licenseWhich) so the page never looks collapsed-empty on first paint.

Where is the empty state from? FaqEmpty — an inline SVG using raw token vars (var(--surface), var(--border), var(--muted-foreground)), the same technique as the contacts/scrumboard/chat illustrations.

Notes for designers & content editors

  • All copy lives in src/locales/<lng>/faq.json — hero, buttons, categories, and every Q&A. The shipped answers are real template FAQ content (licensing, theming, stack, updates, support); rewrite them for your product without touching code.
  • Category icons are lucide icons chosen in src/data/faq.ts — swap per category there.
  • The popular flags curate the aside strip; keep it to a handful so the sticky card stays scannable.
  • Colors are tokens throughout — the hero tint, active card state, vote tints, and the empty-state SVG all re-skin and dark-mode automatically.
  • The support email (SUPPORT_EMAIL in FaqPage.tsx) is a demo address — replace before ship.

Was this page helpful?