Snapshot of uncommitted work that had accumulated in the tree alongside the Turnstile changes: - marketing pages, SEO helpers (lib/seo.ts, lib/marketing/) and structured data - admin billing actions and a per-user portfolio view, plus an admin error boundary - rate limiting (lib/rate-limit.ts) applied across the /api/v1 surface - CSP and proxy adjustments, accounting/webhook lib updates - Playwright config and an e2e/unit test suite - next bumped to ^16.3.4 with the lockfile regenerated - generated AGENTS.md / CLAUDE.md Authored by other sessions working in this tree; committed here so the Turnstile work could be pushed without leaving the tree dirty. Typecheck passes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
87 lines
3.4 KiB
TypeScript
87 lines
3.4 KiB
TypeScript
"use client"
|
|
|
|
import { useState } from "react"
|
|
import { motion } from "framer-motion"
|
|
import { Plus, Minus } from "lucide-react"
|
|
import { FAQS } from "@/lib/marketing/faqs"
|
|
|
|
export function FAQ() {
|
|
const [open, setOpen] = useState<number | null>(null)
|
|
|
|
return (
|
|
<section id="faq" className="mx-auto max-w-3xl px-4 sm:px-6 py-16 sm:py-24">
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 20 }}
|
|
whileInView={{ opacity: 1, y: 0 }}
|
|
viewport={{ once: true }}
|
|
className="text-center mb-12"
|
|
>
|
|
<p className="text-xs font-semibold uppercase tracking-widest text-indigo-400 mb-3">FAQ</p>
|
|
<h2 className="text-3xl font-bold text-white">Common questions</h2>
|
|
<p className="mt-3 text-white/50">Everything you need to know before signing up.</p>
|
|
</motion.div>
|
|
|
|
<div className="space-y-3">
|
|
{FAQS.map((faq, i) => {
|
|
const isOpen = open === i
|
|
return (
|
|
<motion.div
|
|
key={i}
|
|
initial={{ opacity: 0, y: 12 }}
|
|
whileInView={{ opacity: 1, y: 0 }}
|
|
viewport={{ once: true }}
|
|
transition={{ delay: i * 0.05 }}
|
|
className="rounded-xl border border-white/[0.06] bg-[#16161f] overflow-hidden"
|
|
>
|
|
<h3>
|
|
<button
|
|
type="button"
|
|
onClick={() => setOpen(isOpen ? null : i)}
|
|
aria-expanded={isOpen}
|
|
aria-controls={`faq-answer-${i}`}
|
|
id={`faq-question-${i}`}
|
|
className="flex w-full items-center justify-between px-5 py-4 text-left"
|
|
>
|
|
<span className="text-sm font-medium text-white pr-4">{faq.q}</span>
|
|
<div
|
|
className={`shrink-0 flex h-6 w-6 items-center justify-center rounded-full border transition-colors ${
|
|
isOpen ? "border-indigo-500/40 bg-indigo-500/10" : "border-white/10"
|
|
}`}
|
|
>
|
|
{isOpen ? (
|
|
<Minus className="h-3 w-3 text-indigo-400" />
|
|
) : (
|
|
<Plus className="h-3 w-3 text-white/50" />
|
|
)}
|
|
</div>
|
|
</button>
|
|
</h3>
|
|
{/*
|
|
The answer stays mounted and is collapsed by animating its height
|
|
rather than being conditionally rendered. Google requires the
|
|
answer text behind an FAQ accordion to be present in the served
|
|
HTML — unmounting it when closed would leave the FAQPage JSON-LD
|
|
in components/marketing/structured-data.tsx describing content no
|
|
crawler can see.
|
|
*/}
|
|
<motion.div
|
|
id={`faq-answer-${i}`}
|
|
role="region"
|
|
aria-labelledby={`faq-question-${i}`}
|
|
initial={false}
|
|
animate={{ height: isOpen ? "auto" : 0, opacity: isOpen ? 1 : 0 }}
|
|
transition={{ duration: 0.25, ease: "easeInOut" }}
|
|
className="overflow-hidden"
|
|
>
|
|
<p className="px-5 pb-5 text-sm text-white/50 leading-relaxed border-t border-white/[0.04] pt-3">
|
|
{faq.a}
|
|
</p>
|
|
</motion.div>
|
|
</motion.div>
|
|
)
|
|
})}
|
|
</div>
|
|
</section>
|
|
)
|
|
}
|