Files
eranos/src/components/HelpFAQSection.tsx
T
mkfain e63a08c2e2 i18n: route FAQ strings through the locale namespace
Splits the FAQ definition in `helpContent.ts` into two layers:

  * a structural template (category order, item IDs, hidden flag) that
    stays in TS, and
  * a flat `faq.*` namespace in `locales/*.json` that holds every
    user-visible string (category labels, questions, answer paragraph
    arrays).

`getFAQCategories` / `getFAQItems` / `getFAQItem` keep the same
`(appName)` signature and `FAQCategory` / `FAQItem` shape — internally
they resolve strings through `i18n.t()`, with `{appName}` literals
rewritten to `{{appName}}` for i18next interpolation. Answer arrays use
`returnObjects: true` so a missing locale entry falls back to English
without breaking the renderer.

`HelpFAQSection` reads `i18n.language` to re-resolve on language switch;
`HelpTip` calls `useTranslation()` for the same reason. The Donor /
Activist guide blocks below are still keyed off the original
single-brace `{appName}` literal — that's a separate i18n pass.
2026-05-23 21:05:46 -05:00

287 lines
11 KiB
TypeScript

import { useMemo, useState, Fragment } from 'react';
import { useTranslation } from 'react-i18next';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/accordion';
import { useAppContext } from '@/hooks/useAppContext';
import { getFAQCategories, type FAQCategory, type FAQItem } from '@/lib/helpContent';
import { renderInlineMarkup } from '@/lib/helpMarkup';
import { cn } from '@/lib/utils';
// ── Component ─────────────────────────────────────────────────────────────────
interface HelpFAQSectionProps {
/** Show only these category IDs. Omit to show all. */
categories?: string[];
/** Show only these item IDs (across all categories). Omit to show all. */
items?: string[];
/** Hide category headings (useful when showing a single category or filtered items). */
hideHeadings?: boolean;
/** Additional class names for the wrapper. */
className?: string;
/**
* Rendering variant.
*
* - `'list'` (default): flat accordion list grouped by category. Used
* inside contextual settings pages where the FAQ is a small inline
* reference.
* - `'cards'`: each FAQ item renders as its own rounded card with a
* single-item accordion inside. Used by the About page so the FAQ
* reads as a designed section rather than an undifferentiated dump.
*/
variant?: 'list' | 'cards';
/**
* When `variant='cards'`, render a row of category-tab pills above the
* cards. Only one category is shown at a time. No effect on `'list'`.
*/
tabs?: boolean;
/**
* When `variant='list'`, choose between the default pill-tinted category
* heading (used by inline contextual FAQs in settings pages) and a
* quieter 'reference' tone used by the About page's `Need help?`
* section where the FAQ is the main content rather than a sidebar.
*/
listTone?: 'default' | 'reference';
}
/**
* Reusable FAQ accordion section.
*
* Renders FAQ items from `helpContent.ts` in collapsible accordions grouped by
* category. Accepts filter props so it can be dropped into any page to show a
* relevant subset of questions.
*
* @example
* // Full FAQ (legacy list layout)
* <HelpFAQSection />
*
* // About-page integrated FAQ (cards + category tabs)
* <HelpFAQSection variant="cards" tabs />
*
* // Only payments questions (wallet settings page)
* <HelpFAQSection categories={['payments']} hideHeadings />
*
* // Specific questions (onboarding)
* <HelpFAQSection items={['what-are-relays', 'what-are-blossom']} hideHeadings />
*/
export function HelpFAQSection({
categories,
items,
hideHeadings,
className,
variant = 'list',
tabs = false,
listTone = 'default',
}: HelpFAQSectionProps) {
const { config } = useAppContext();
const { i18n } = useTranslation();
// `getFAQCategories` resolves strings against `i18n.language` at call
// time. Capturing the language in a local lets us include it as a memo
// dep so a language switch re-runs the resolver.
const lng = i18n.language;
const filteredCategories = useMemo(() => {
let cats: FAQCategory[] = getFAQCategories(config.appName);
// Drop hidden categories from the default render. They still exist in
// the underlying template so `HelpTip` can look up individual items by
// ID, but they don't show up in the FAQ accordion.
if (!categories && !items) {
cats = cats.filter((c) => !c.hidden);
}
// Filter to specific categories
if (categories) {
cats = cats.filter((c) => categories.includes(c.id));
}
// Filter to specific items
if (items) {
cats = cats
.map((c) => ({
...c,
items: c.items.filter((i) => items.includes(i.id)),
}))
.filter((c) => c.items.length > 0);
}
return cats;
// `lng` is in the dep list to force re-resolution on a language switch.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [categories, items, config.appName, lng]);
// Tab state: first category is selected by default.
const [activeTab, setActiveTab] = useState<string | null>(
filteredCategories[0]?.id ?? null,
);
if (filteredCategories.length === 0) return null;
// ── Card variant ─────────────────────────────────────────────────────────
if (variant === 'cards') {
const showTabs = tabs && filteredCategories.length > 1;
const visibleCategories = showTabs
? filteredCategories.filter((c) => c.id === activeTab)
: filteredCategories;
return (
<div className={className}>
{/* Category tab pills */}
{showTabs && (
<div className="flex flex-wrap justify-center gap-2 mb-10">
{filteredCategories.map((category) => {
const active = category.id === activeTab;
return (
<button
key={category.id}
type="button"
onClick={() => setActiveTab(category.id)}
className={cn(
'rounded-full px-4 py-2 text-sm font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50',
active
? 'bg-primary text-primary-foreground shadow-sm'
: 'bg-white dark:bg-[#1c2230] text-gray-700 dark:text-gray-300 border border-gray-200 dark:border-white/10 hover:border-gray-300 dark:hover:border-white/20 hover:bg-gray-50 dark:hover:bg-[#252b3a]',
)}
aria-pressed={active}
>
{category.label}
</button>
);
})}
</div>
)}
{visibleCategories.map((category) => (
<Fragment key={category.id}>
{!hideHeadings && !showTabs && (
<h3 className="text-xs font-bold uppercase tracking-widest text-primary mt-10 first:mt-0 mb-5">
{category.label}
</h3>
)}
{/* Masonry-style two-column grid on md+ */}
<div className="md:columns-2 md:gap-6 [column-fill:_balance]">
{category.items.map((item) => (
<FAQCard key={item.id} item={item} />
))}
</div>
</Fragment>
))}
</div>
);
}
// ── List variant (default, unchanged behavior) ───────────────────────────
const reference = listTone === 'reference';
return (
<div className={className}>
{filteredCategories.map((category, catIndex) => (
<Fragment key={category.id}>
{/* Category heading */}
{!hideHeadings && (
reference ? (
<div className={catIndex === 0 ? 'pt-2 pb-3' : 'pt-10 pb-3'}>
<h3 className="text-xs font-bold uppercase tracking-widest text-primary">
{category.label}
</h3>
</div>
) : (
<div className={catIndex === 0 ? 'pt-2 pb-2' : 'pt-6 pb-2'}>
<h3 className="text-sm font-bold uppercase tracking-wider text-primary bg-primary/10 rounded-full px-3.5 py-1.5 inline-block">
{category.label}
</h3>
</div>
)
)}
<Accordion
type="single"
collapsible
className={cn('w-full', reference ? '' : 'pl-3')}
>
{category.items.map((item) => (
<FAQAccordionItem key={item.id} item={item} reference={reference} />
))}
</Accordion>
</Fragment>
))}
</div>
);
}
function FAQAccordionItem({
item,
reference,
}: {
item: FAQItem;
reference?: boolean;
}) {
if (reference) {
// 'reference' mode: each Q&A reads as a substantial card-row with
// a left orange-accent rule that lights up on hover and open. Used
// by the About page where the FAQ is a first-class chapter rather
// than a sidebar.
return (
<AccordionItem
value={item.id}
className="group relative border-b-0 rounded-lg bg-white dark:bg-[#1c2230] border border-gray-200 dark:border-white/10 shadow-sm mb-3 overflow-hidden transition-colors hover:border-primary/40 data-[state=open]:border-primary/50 data-[state=open]:shadow-md"
>
{/* Left accent rule: orange when open, transparent otherwise */}
<span
aria-hidden
className="absolute left-0 top-0 bottom-0 w-1 bg-transparent group-hover:bg-primary/30 group-data-[state=open]:bg-primary transition-colors"
/>
<AccordionTrigger className="text-left text-base sm:text-lg font-bold tracking-tight leading-snug hover:no-underline gap-3 px-5 sm:px-6 py-5 text-gray-900 dark:text-white">
{item.question}
</AccordionTrigger>
<AccordionContent className="text-[15px] leading-relaxed text-gray-600 dark:text-gray-300 space-y-3 px-5 sm:px-6 pb-5 -mt-1">
{item.answer.map((paragraph, i) => (
<p key={i}>{renderInlineMarkup(paragraph)}</p>
))}
</AccordionContent>
</AccordionItem>
);
}
return (
<AccordionItem value={item.id}>
<AccordionTrigger className="text-left text-base font-semibold leading-snug hover:no-underline gap-3">
{item.question}
</AccordionTrigger>
<AccordionContent className="text-[14px] leading-relaxed text-foreground/80 space-y-3">
{item.answer.map((paragraph, i) => (
<p key={i}>{renderInlineMarkup(paragraph)}</p>
))}
</AccordionContent>
</AccordionItem>
);
}
/**
* A single FAQ entry rendered as its own rounded card. Wraps a one-item
* Radix Accordion so the click-to-expand UX is preserved. Used by the
* About page's card-variant FAQ section.
*/
function FAQCard({ item }: { item: FAQItem }) {
return (
<div className="mb-6 break-inside-avoid rounded-2xl border border-gray-200 dark:border-white/10 bg-white dark:bg-[#1c2230] shadow-sm">
<Accordion type="single" collapsible>
<AccordionItem value={item.id} className="border-b-0">
<AccordionTrigger className="text-left font-display font-semibold text-gray-900 dark:text-white text-lg leading-snug hover:no-underline px-5 py-4 gap-3">
{item.question}
</AccordionTrigger>
<AccordionContent className="text-[15px] leading-relaxed text-gray-600 dark:text-gray-300 space-y-3 px-5 pb-5">
{item.answer.map((paragraph, i) => (
<p key={i}>{renderInlineMarkup(paragraph)}</p>
))}
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
);
}