feat: FAQ management from admin, public /faq, homepage section, llms.txt
- Backend: faq_questions table (schema + migration), CRUD + reorder API, Swagger docs - Admin: FAQ page with create/edit, enable/disable, show on homepage, drag reorder - Public /faq page fetches enabled FAQs from API; layout builds dynamic JSON-LD - Homepage: FAQ section under Stay updated (homepage-enabled only) with See full FAQ link - llms.txt: FAQ section uses homepage FAQs from API - i18n: home.faq title/seeFull, admin FAQ nav Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
82
frontend/src/app/(public)/components/HomepageFaqSection.tsx
Normal file
82
frontend/src/app/(public)/components/HomepageFaqSection.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { faqApi, FaqItem } from '@/lib/api';
|
||||
import { ChevronDownIcon } from '@heroicons/react/24/outline';
|
||||
import Link from 'next/link';
|
||||
import clsx from 'clsx';
|
||||
|
||||
export default function HomepageFaqSection() {
|
||||
const { t, locale } = useLanguage();
|
||||
const [faqs, setFaqs] = useState<FaqItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [openIndex, setOpenIndex] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
faqApi.getList(true).then((res) => {
|
||||
if (!cancelled) setFaqs(res.faqs);
|
||||
}).finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
if (loading || faqs.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="section-padding bg-secondary-gray" aria-labelledby="homepage-faq-title">
|
||||
<div className="container-page">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<h2 id="homepage-faq-title" className="text-2xl md:text-3xl font-bold text-primary-dark text-center mb-8">
|
||||
{t('home.faq.title')}
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
{faqs.map((faq, index) => (
|
||||
<div
|
||||
key={faq.id}
|
||||
className="bg-white rounded-btn border border-gray-200 overflow-hidden"
|
||||
>
|
||||
<button
|
||||
onClick={() => setOpenIndex(openIndex === index ? null : index)}
|
||||
className="w-full px-5 py-4 flex items-center justify-between text-left hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<span className="font-semibold text-primary-dark pr-4 text-sm md:text-base">
|
||||
{locale === 'es' && faq.questionEs ? faq.questionEs : faq.question}
|
||||
</span>
|
||||
<ChevronDownIcon
|
||||
className={clsx(
|
||||
'w-5 h-5 text-gray-500 flex-shrink-0 transition-transform duration-200',
|
||||
openIndex === index && 'transform rotate-180'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
className={clsx(
|
||||
'overflow-hidden transition-all duration-200',
|
||||
openIndex === index ? 'max-h-80' : 'max-h-0'
|
||||
)}
|
||||
>
|
||||
<div className="px-5 pb-4 text-gray-600 text-sm md:text-base">
|
||||
{locale === 'es' && faq.answerEs ? faq.answerEs : faq.answer}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-8 text-center">
|
||||
<Link
|
||||
href="/faq"
|
||||
className="inline-flex items-center justify-center px-6 py-3 bg-primary-yellow text-primary-dark font-semibold rounded-btn hover:bg-primary-yellow/90 transition-colors"
|
||||
>
|
||||
{t('home.faq.seeFull')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user