Skip to content

Commit deeb05f

Browse files
committed
Add structured FAQ page with search, categories, and deep links
- Implemented `parseFaq` to transform markdown into structured data for improved rendering and SEO. - Introduced `react-markdown` with custom styling for better alignment with the application design. - Added search functionality with query matching and automatic expansion of relevant answers. - Designed a sticky navigation rail for easier category navigation. - Enabled deep linking to questions and categories for external reference. - Created reusable components for FAQ items and markdown rendering. - Updated markdown file handling to exclude FAQ from generic loader (`MD_PATHS`).
1 parent 3f73afb commit deeb05f

12 files changed

Lines changed: 1101 additions & 219 deletions

File tree

backend/api/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@compass/api",
3-
"version": "1.50.0",
3+
"version": "1.51.0",
44
"private": true,
55
"description": "Backend API endpoints",
66
"main": "src/serve.ts",

common/messages/de.json

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1679,5 +1679,25 @@
16791679
"share_profile.x_share_profile": "Ich bin auf einer Plattform, die Menschen nach Werten, nicht nach Fotos zusammenbringt.\nKeine Werbung. Keine Algorithmen. Kein Wischen. Nur Tiefe.",
16801680
"email.new_message.daily_limit": "Um deine Inbox nicht zu überladen, erhältst du höchstens eine E-Mail pro Tag pro Konversation. Wenn {creatorName} heute weitere Nachrichten sendet, wirst du erst morgen wieder per E-Mail benachrichtigt.",
16811681
"email.new_message.app_prompt": "Willst du Echtzeit-Benachrichtigungen? Lade die Compass App für Android herunter:",
1682-
"email.new_message.app_link": "App herunterladen"
1682+
"email.new_message.app_link": "App herunterladen",
1683+
"faq.empty.cta": "Frage stellen →",
1684+
"faq.empty.text": "Versuche ein allgemeineres Wort — oder frag uns direkt, wir beantworten jede Nachricht.",
1685+
"faq.empty.title": "Dazu passt hier nichts.",
1686+
"faq.eyebrow": "Hilfebereich",
1687+
"faq.nav.label": "FAQ-Kategorien",
1688+
"faq.nav.title": "Themen",
1689+
"faq.search.clear": "Suche zurücksetzen",
1690+
"faq.search.count": "{count} passende Fragen",
1691+
"faq.search.label": "FAQ durchsuchen",
1692+
"faq.search.none": "Keine Frage passt — versuche ein anderes Wort.",
1693+
"faq.search.placeholder": "FAQ durchsuchen…",
1694+
"faq.seo.description": "Häufige Fragen zu Compass",
1695+
"faq.seo.title": "FAQ",
1696+
"faq.stuck.contact": "Kontakt aufnehmen",
1697+
"faq.stuck.discord": "Auf Discord fragen",
1698+
"faq.stuck.label": "Noch offen",
1699+
"faq.stuck.text": "Compass wird von den Menschen gebaut, die es nutzen — frag alles, und die Antwort landet meist auf dieser Seite für die nächste Person.",
1700+
"faq.stuck.title": "Antwort nicht gefunden?",
1701+
"faq.title": "Häufig gestellte Fragen",
1702+
"faq.search.count_one": "1 passende Frage"
16831703
}

common/messages/fr.json

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1678,5 +1678,25 @@
16781678
"share_profile.x_share_profile": "Je suis sur une plateforme qui met les gens en relation selon leurs valeurs, pas leurs photos.\nPas de pubs. Pas d'algorithmes. Pas de swipe. Juste de la profondeur.",
16791679
"email.new_message.daily_limit": "Pour éviter de surcharger votre boîte de réception, vous recevrez au plus un e-mail par jour par conversation. Si {creatorName} envoie d'autres messages aujourd'hui, vous ne serez pas notifié par e-mail.",
16801680
"email.new_message.app_prompt": "Vous voulez des notifications en temps réel ? Téléchargez l'application Compass pour Android :",
1681-
"email.new_message.app_link": "Obtenir l'app"
1681+
"email.new_message.app_link": "Obtenir l'app",
1682+
"faq.empty.cta": "Poser une question →",
1683+
"faq.empty.text": "Essayez un mot plus général — ou posez-nous directement la question, nous répondons à chaque message.",
1684+
"faq.empty.title": "Aucun résultat pour cette recherche.",
1685+
"faq.eyebrow": "Centre d’aide",
1686+
"faq.nav.label": "Catégories de la FAQ",
1687+
"faq.nav.title": "Thèmes",
1688+
"faq.search.clear": "Effacer la recherche",
1689+
"faq.search.count": "{count} questions correspondantes",
1690+
"faq.search.label": "Rechercher dans la FAQ",
1691+
"faq.search.none": "Aucune question ne correspond — essayez un autre mot.",
1692+
"faq.search.placeholder": "Rechercher dans la FAQ…",
1693+
"faq.seo.description": "Questions fréquentes sur Compass",
1694+
"faq.seo.title": "FAQ",
1695+
"faq.stuck.contact": "Nous contacter",
1696+
"faq.stuck.discord": "Demander sur Discord",
1697+
"faq.stuck.label": "Toujours bloqué",
1698+
"faq.stuck.text": "Compass est construit par celles et ceux qui l’utilisent — posez n’importe quelle question, la réponse finit généralement sur cette page pour la personne suivante.",
1699+
"faq.stuck.title": "Vous n’avez pas trouvé votre réponse ?",
1700+
"faq.title": "Questions fréquentes",
1701+
"faq.search.count_one": "1 question correspondante"
16821702
}

web/components/faq/faq-content.tsx

Lines changed: 367 additions & 0 deletions
Large diffs are not rendered by default.

web/components/faq/faq-item.tsx

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import {ChevronDownIcon} from '@heroicons/react/24/outline'
2+
import clsx from 'clsx'
3+
import {surface, surfaceHover} from 'web/components/widgets/surface'
4+
import {FaqQuestion} from 'web/lib/faq'
5+
6+
import {FaqMarkdown} from './faq-markdown'
7+
8+
/**
9+
* One question, as a disclosure card.
10+
*
11+
* **The answer stays in the DOM when collapsed.** It is hidden by a `grid-template-rows` collapse, not
12+
* by unmounting — which is what keeps 22 answers' worth of text in the served HTML for crawlers, and
13+
* what lets a `#hash` deep link scroll to a question that has not been opened yet. Conditionally
14+
* rendering the body would have quietly traded away the SEO the build-time parse was for.
15+
*
16+
* The `0fr → 1fr` grid trick rather than a `max-height` guess: answers here range from two lines to
17+
* roughly thirty, so any single `max-height` is either a clipped answer or a long dead pause on the
18+
* short ones. Grid animates to the content's real height with no measurement.
19+
*/
20+
export function FaqItem({
21+
item,
22+
open,
23+
onToggle,
24+
}: {
25+
item: FaqQuestion
26+
open: boolean
27+
onToggle: () => void
28+
}) {
29+
return (
30+
<div
31+
id={item.id}
32+
className={clsx(
33+
surface,
34+
!open && surfaceHover,
35+
// `scroll-mt` clears the sticky header when a deep link or a nav click scrolls here — without
36+
// it the question lands underneath the chrome.
37+
'scroll-mt-28 overflow-hidden',
38+
open && 'ring-primary-500/30',
39+
)}
40+
>
41+
{/* `m-0` because globals.css gives every heading `margin: 1.5rem 0 0.5rem` — inside a card whose
42+
padding already sets the spacing, that is 32px of dead height per question, times 22. */}
43+
<h3 className="m-0 text-lg">
44+
<button
45+
type="button"
46+
onClick={onToggle}
47+
aria-expanded={open}
48+
aria-controls={`${item.id}-panel`}
49+
className="flex w-full items-start gap-4 px-5 py-4 text-left sm:px-6 sm:py-5"
50+
>
51+
<span
52+
className={clsx(
53+
'flex-1 font-semibold leading-snug transition-colors',
54+
open ? 'text-primary-800' : 'text-ink-900',
55+
)}
56+
>
57+
{item.question}
58+
</span>
59+
<span
60+
aria-hidden
61+
className={clsx(
62+
'mt-0.5 flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-full transition-all duration-200 ease-out',
63+
open ? 'rotate-180 bg-primary-100 text-primary-700' : 'bg-canvas-100 text-ink-500',
64+
)}
65+
>
66+
<ChevronDownIcon className="h-4 w-4" strokeWidth={2.2} />
67+
</span>
68+
</button>
69+
</h3>
70+
71+
<div
72+
id={`${item.id}-panel`}
73+
role="region"
74+
className={clsx(
75+
'grid transition-[grid-template-rows] duration-300 ease-out motion-reduce:transition-none',
76+
open ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]',
77+
)}
78+
>
79+
{/* The clipper. The row collapses to zero height, so this has to hide the overflow or the
80+
answer stays painted over the card below it. */}
81+
<div className="overflow-hidden">
82+
{/* `inert` rather than `hidden` or unmounting: a zero-height `overflow-hidden` box still
83+
holds focusable links, so without this, tabbing through the page walks into every
84+
collapsed answer. `inert` takes the subtree out of the tab order and the accessibility
85+
tree while leaving it in the DOM, which is exactly the split this needs — invisible to
86+
a keyboard, still present for crawlers. */}
87+
<div className="px-5 pb-5 sm:px-6 sm:pb-6" inert={!open}>
88+
<div className="border-t border-canvas-200/70 pt-4">
89+
<FaqMarkdown>{item.answer}</FaqMarkdown>
90+
</div>
91+
</div>
92+
</div>
93+
</div>
94+
</div>
95+
)
96+
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import clsx from 'clsx'
2+
import ReactMarkdown from 'react-markdown'
3+
import {CustomLink} from 'web/components/links'
4+
5+
/**
6+
* The answer body renderer.
7+
*
8+
* The old FAQ handed the entire file to `react-markdown` inside one `prose prose-neutral` div, which
9+
* is why it read as a document rather than as part of the product: `prose` is a *reset for unstyled
10+
* HTML*, not a design, so none of the tokens `/about` and `/home` are built on (`text-ink-600`,
11+
* `primary-*`, the leading scale) ever reached it. This maps the handful of node types an FAQ answer
12+
* actually uses onto those tokens instead.
13+
*
14+
* `a` goes through `CustomLink` — the FAQ links to `/news`, `/vote`, `/support`, `/stats` and a dozen
15+
* other internal routes, and `CustomLink` is what keeps those as client-side navigations while still
16+
* opening external links in a new tab.
17+
*/
18+
export function FaqMarkdown({children, className}: {children: string; className?: string}) {
19+
return (
20+
<div className={clsx('text-[15px] leading-relaxed text-ink-600', className)}>
21+
<ReactMarkdown
22+
components={{
23+
p: ({node: _node, children, ...props}) => (
24+
<p className="mb-4 last:mb-0" {...props}>
25+
{children}
26+
</p>
27+
),
28+
a: ({node: _node, children, ...props}) => (
29+
<CustomLink
30+
className="font-medium text-primary-700 underline decoration-primary-500/35 underline-offset-2 transition-colors hover:decoration-primary-500"
31+
{...props}
32+
>
33+
{children}
34+
</CustomLink>
35+
),
36+
strong: ({node: _node, children, ...props}) => (
37+
<strong className="font-semibold text-ink-900" {...props}>
38+
{children}
39+
</strong>
40+
),
41+
// A drawn marker rather than a native bullet: the bulleted answers are the page's densest
42+
// content, and a small primary dot pinned to the first line's optical centre is both calmer
43+
// than a default disc and consistent with the flow steps on /about. Done as a `before:` on
44+
// the children so the `li` renderer stays shared with ordered lists, which keep their real
45+
// numbers.
46+
//
47+
// `list-none pl-0 mt-0` undoes globals.css's blanket `ul { list-style: disc; padding-left:
48+
// 1.25rem; margin-top: 0.5rem }` — without it every bullet is drawn twice, once by the
49+
// browser and once by us, at two different indents.
50+
ul: ({node: _node, children, ...props}) => (
51+
<ul
52+
className={clsx(
53+
'mb-4 mt-0 list-none space-y-2.5 pl-0 last:mb-0',
54+
"[&>li]:relative [&>li]:pl-5 [&>li]:before:absolute [&>li]:before:left-0 [&>li]:before:top-[0.6em] [&>li]:before:h-1.5 [&>li]:before:w-1.5 [&>li]:before:rounded-full [&>li]:before:bg-primary-500/70 [&>li]:before:content-['']",
55+
)}
56+
{...props}
57+
>
58+
{children}
59+
</ul>
60+
),
61+
// `list-outside` because the global `ol` rule sets `list-style-position: inside`, which puts
62+
// the number in the text flow and kills the hanging indent on wrapped lines.
63+
ol: ({node: _node, children, ...props}) => (
64+
<ol
65+
className="mb-4 mt-0 list-decimal list-outside space-y-2.5 pl-5 last:mb-0 marker:text-ink-400"
66+
{...props}
67+
>
68+
{children}
69+
</ol>
70+
),
71+
code: ({node: _node, children, ...props}) => (
72+
<code
73+
className="rounded bg-canvas-100 px-1.5 py-0.5 font-mono text-[0.9em] text-ink-800"
74+
{...props}
75+
>
76+
{children}
77+
</code>
78+
),
79+
blockquote: ({node: _node, children, ...props}) => (
80+
<blockquote
81+
className="mb-4 border-l-2 border-primary-300 pl-4 italic last:mb-0"
82+
{...props}
83+
>
84+
{children}
85+
</blockquote>
86+
),
87+
}}
88+
>
89+
{children}
90+
</ReactMarkdown>
91+
</div>
92+
)
93+
}

web/components/markdown.tsx

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,10 @@ import {CustomLink} from 'web/components/links'
77
import {PageBase} from 'web/components/page-base'
88
import {SEO} from 'web/components/SEO'
99

10-
export const MD_PATHS = [
11-
'constitution',
12-
'faq',
13-
'financials',
14-
'members',
15-
'support',
16-
'tips-bio',
17-
] as const
10+
// `faq` is deliberately absent: /faq no longer renders through this generic loader. It parses its
11+
// markdown at build time (see web/lib/faq.ts) so the questions reach the served HTML and the page can
12+
// group, search, and deep-link them — none of which is possible with the file as one opaque blob.
13+
export const MD_PATHS = ['constitution', 'financials', 'members', 'support', 'tips-bio'] as const
1814

1915
type Props = {
2016
content: string

0 commit comments

Comments
 (0)