11'use client'
22
3- import { useState } from 'react'
4- import { AnimatePresence , domAnimation , LazyMotion , m } from 'framer-motion'
3+ import { useId , useState } from 'react'
4+ import { domAnimation , LazyMotion , m } from 'framer-motion'
55import { ChevronDown } from '@/components/emcn'
66import { cn } from '@/lib/core/utils/cn'
77
@@ -14,7 +14,19 @@ interface LandingFAQProps {
1414 faqs : LandingFAQItem [ ]
1515}
1616
17+ /**
18+ * Accordion FAQ for landing pages.
19+ *
20+ * Every answer stays mounted and is collapsed via animated height rather than
21+ * being rendered on demand: AI crawlers (GPTBot, ClaudeBot, PerplexityBot) and
22+ * non-rendering search crawlers only read the served HTML, so the full Q&A
23+ * text must exist in the initial document to be indexed or cited. Keeping the
24+ * answers in the DOM also keeps the FAQPage JSON-LD emitted by consuming pages
25+ * aligned with visible content, as Google's structured-data policy requires.
26+ * Questions render as h3 headings so each Q&A forms an extractable section.
27+ */
1728export function LandingFAQ ( { faqs } : LandingFAQProps ) {
29+ const baseId = useId ( )
1830 const [ openIndex , setOpenIndex ] = useState < number | null > ( 0 )
1931 const [ hoveredIndex , setHoveredIndex ] = useState < number | null > ( null )
2032
@@ -23,8 +35,8 @@ export function LandingFAQ({ faqs }: LandingFAQProps) {
2335 < div >
2436 { faqs . map ( ( { question, answer } , index ) => {
2537 const isOpen = openIndex === index
26- const isHovered = hoveredIndex === index
2738 const showDivider = index > 0 && hoveredIndex !== index && hoveredIndex !== index - 1
39+ const panelId = `${ baseId } -faq-panel-${ index } `
2840
2941 return (
3042 < div key = { question } >
@@ -34,50 +46,50 @@ export function LandingFAQ({ faqs }: LandingFAQProps) {
3446 index === 0 || ! showDivider ? 'invisible' : 'visible'
3547 ) }
3648 />
37- < button
38- type = 'button'
39- onClick = { ( ) => setOpenIndex ( isOpen ? null : index ) }
40- onMouseEnter = { ( ) => setHoveredIndex ( index ) }
41- onMouseLeave = { ( ) => setHoveredIndex ( null ) }
42- className = '-mx-6 flex w-[calc(100%+3rem)] items-center justify-between gap-4 px-6 py-4 text-left transition-colors hover:bg-[var(--landing-bg-elevated)]'
43- aria-expanded = { isOpen }
44- >
45- < span
46- className = { cn (
47- 'text-[15px] leading-snug tracking-[-0.02em] transition-colors' ,
48- isOpen
49- ? 'text-[var(--landing-text)]'
50- : 'text-[var(--landing-text-body)] hover:text-[var(--landing-text)]'
51- ) }
49+ < h3 >
50+ < button
51+ type = 'button'
52+ onClick = { ( ) => setOpenIndex ( isOpen ? null : index ) }
53+ onMouseEnter = { ( ) => setHoveredIndex ( index ) }
54+ onMouseLeave = { ( ) => setHoveredIndex ( null ) }
55+ className = '-mx-6 flex w-[calc(100%+3rem)] items-center justify-between gap-4 px-6 py-4 text-left transition-colors hover:bg-[var(--landing-bg-elevated)]'
56+ aria-expanded = { isOpen }
57+ aria-controls = { panelId }
5258 >
53- { question }
54- </ span >
55- < ChevronDown
56- className = { cn (
57- 'h-3 w-3 shrink-0 text-[var(--landing-text-subtle)] transition-transform duration-200' ,
58- isOpen ? 'rotate-180' : 'rotate-0'
59- ) }
60- aria-hidden = 'true'
61- />
62- </ button >
63-
64- < AnimatePresence initial = { false } >
65- { isOpen && (
66- < m . div
67- initial = { { height : 0 , opacity : 0 } }
68- animate = { { height : 'auto' , opacity : 1 } }
69- exit = { { height : 0 , opacity : 0 } }
70- transition = { { duration : 0.25 , ease : [ 0.4 , 0 , 0.2 , 1 ] } }
71- className = 'overflow-hidden'
59+ < span
60+ className = { cn (
61+ 'text-[15px] leading-snug tracking-[-0.02em] transition-colors' ,
62+ isOpen
63+ ? 'text-[var(--landing-text)]'
64+ : 'text-[var(--landing-text-body)] hover:text-[var(--landing-text)]'
65+ ) }
7266 >
73- < div className = 'pt-2 pb-4' >
74- < p className = 'text-[14px] text-[var(--landing-text-body)] leading-[1.75]' >
75- { answer }
76- </ p >
77- </ div >
78- </ m . div >
79- ) }
80- </ AnimatePresence >
67+ { question }
68+ </ span >
69+ < ChevronDown
70+ className = { cn (
71+ 'h-3 w-3 shrink-0 text-[var(--landing-text-subtle)] transition-transform duration-200' ,
72+ isOpen ? 'rotate-180' : 'rotate-0'
73+ ) }
74+ aria-hidden = 'true'
75+ />
76+ </ button >
77+ </ h3 >
78+
79+ < m . div
80+ id = { panelId }
81+ initial = { false }
82+ animate = { { height : isOpen ? 'auto' : 0 , opacity : isOpen ? 1 : 0 } }
83+ transition = { { duration : 0.25 , ease : [ 0.4 , 0 , 0.2 , 1 ] } }
84+ className = 'overflow-hidden'
85+ aria-hidden = { ! isOpen }
86+ >
87+ < div className = 'pt-2 pb-4' >
88+ < p className = 'text-[14px] text-[var(--landing-text-body)] leading-[1.75]' >
89+ { answer }
90+ </ p >
91+ </ div >
92+ </ m . div >
8193 </ div >
8294 )
8395 } ) }
0 commit comments