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:
Michilis
2026-02-12 04:49:16 +00:00
parent 5885044369
commit 07ba357194
15 changed files with 1137 additions and 149 deletions

View 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>
);
}

View File

@@ -1,44 +1,21 @@
import type { Metadata } from 'next';
// FAQ Page structured data
const faqSchema = {
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: [
{
'@type': 'Question',
name: 'What is Spanglish?',
acceptedAnswer: {
'@type': 'Answer',
text: 'Spanglish is a language exchange community in Asunción, Paraguay. We organize monthly events where Spanish and English speakers come together to practice languages, meet new people, and have fun in a relaxed social environment.',
},
},
{
'@type': 'Question',
name: 'Who can attend Spanglish events?',
acceptedAnswer: {
'@type': 'Answer',
text: 'Anyone interested in practicing English or Spanish is welcome! We accept all levels - from complete beginners to native speakers. Our events are designed to be inclusive and welcoming to everyone.',
},
},
{
'@type': 'Question',
name: 'How do language exchange events work?',
acceptedAnswer: {
'@type': 'Answer',
text: 'Our events typically last 2-3 hours. You will be paired with people who speak the language you want to practice. We rotate partners throughout the evening so you can meet multiple people. There are also group activities and free conversation time.',
},
},
{
'@type': 'Question',
name: 'Do I need to speak the language already?',
acceptedAnswer: {
'@type': 'Answer',
text: 'Not at all! We welcome complete beginners. Our events are structured to support all levels. Native speakers are patient and happy to help beginners practice.',
},
},
],
};
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
async function getFaqForSchema(): Promise<{ question: string; answer: string }[]> {
try {
const res = await fetch(`${apiUrl}/api/faq`, { next: { revalidate: 60 } });
if (!res.ok) return [];
const data = await res.json();
const faqs = data.faqs || [];
return faqs.map((f: { question: string; questionEs?: string | null; answer: string; answerEs?: string | null }) => ({
question: f.question,
answer: f.answer || '',
}));
} catch {
return [];
}
}
export const metadata: Metadata = {
title: 'Frequently Asked Questions',
@@ -49,11 +26,25 @@ export const metadata: Metadata = {
},
};
export default function FAQLayout({
export default async function FAQLayout({
children,
}: {
children: React.ReactNode;
}) {
const faqList = await getFaqForSchema();
const faqSchema = {
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: faqList.map(({ question, answer }) => ({
'@type': 'Question',
name: question,
acceptedAnswer: {
'@type': 'Answer',
text: answer,
},
})),
};
return (
<>
<script

View File

@@ -1,89 +1,44 @@
'use client';
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { useLanguage } from '@/context/LanguageContext';
import { faqApi, FaqItem } from '@/lib/api';
import Card from '@/components/ui/Card';
import { ChevronDownIcon } from '@heroicons/react/24/outline';
import clsx from 'clsx';
interface FAQItem {
question: string;
questionEs: string;
answer: string;
answerEs: string;
}
const faqs: FAQItem[] = [
{
question: "What is Spanglish?",
questionEs: "¿Qué es Spanglish?",
answer: "Spanglish is a language exchange community in Asunción, Paraguay. We organize monthly events where Spanish and English speakers come together to practice languages, meet new people, and have fun in a relaxed social environment.",
answerEs: "Spanglish es una comunidad de intercambio de idiomas en Asunción, Paraguay. Organizamos eventos mensuales donde hablantes de español e inglés se reúnen para practicar idiomas, conocer gente nueva y divertirse en un ambiente social relajado."
},
{
question: "Who can attend Spanglish events?",
questionEs: "¿Quién puede asistir a los eventos de Spanglish?",
answer: "Anyone interested in practicing English or Spanish is welcome! We accept all levels - from complete beginners to native speakers. Our events are designed to be inclusive and welcoming to everyone.",
answerEs: "¡Cualquier persona interesada en practicar inglés o español es bienvenida! Aceptamos todos los niveles - desde principiantes hasta hablantes nativos. Nuestros eventos están diseñados para ser inclusivos y acogedores para todos."
},
{
question: "How do events work?",
questionEs: "¿Cómo funcionan los eventos?",
answer: "Our events typically last 2-3 hours. You'll be paired with people who speak the language you want to practice. We rotate partners throughout the evening so you can meet multiple people. There are also group activities and free conversation time.",
answerEs: "Nuestros eventos suelen durar 2-3 horas. Serás emparejado con personas que hablan el idioma que quieres practicar. Rotamos parejas durante la noche para que puedas conocer a varias personas. También hay actividades grupales y tiempo de conversación libre."
},
{
question: "How much does it cost to attend?",
questionEs: "¿Cuánto cuesta asistir?",
answer: "Event prices vary but are always kept affordable. The price covers venue costs and event organization. Check each event page for specific pricing. Some special events may be free!",
answerEs: "Los precios de los eventos varían pero siempre se mantienen accesibles. El precio cubre los costos del local y la organización del evento. Consulta la página de cada evento para precios específicos. ¡Algunos eventos especiales pueden ser gratis!"
},
{
question: "What payment methods do you accept?",
questionEs: "¿Qué métodos de pago aceptan?",
answer: "We accept multiple payment methods: credit/debit cards through Bancard, Bitcoin Lightning for crypto enthusiasts, and cash payment at the event. You can choose your preferred method when booking.",
answerEs: "Aceptamos múltiples métodos de pago: tarjetas de crédito/débito a través de Bancard, Bitcoin Lightning para entusiastas de cripto, y pago en efectivo en el evento. Puedes elegir tu método preferido al reservar."
},
{
question: "Do I need to speak the language already?",
questionEs: "¿Necesito ya hablar el idioma?",
answer: "Not at all! We welcome complete beginners. Our events are structured to support all levels. Native speakers are patient and happy to help beginners practice. It's a judgment-free zone for learning.",
answerEs: "¡Para nada! Damos la bienvenida a principiantes absolutos. Nuestros eventos están estructurados para apoyar todos los niveles. Los hablantes nativos son pacientes y felices de ayudar a los principiantes a practicar. Es una zona libre de juicios para aprender."
},
{
question: "Can I come alone?",
questionEs: "¿Puedo ir solo/a?",
answer: "Absolutely! Most people come alone and that's totally fine. In fact, it's a great way to meet new people. Our events are designed to be social, so you'll quickly find conversation partners.",
answerEs: "¡Absolutamente! La mayoría de las personas vienen solas y eso está totalmente bien. De hecho, es una excelente manera de conocer gente nueva. Nuestros eventos están diseñados para ser sociales, así que encontrarás compañeros de conversación rápidamente."
},
{
question: "What if I can't make it after booking?",
questionEs: "¿Qué pasa si no puedo asistir después de reservar?",
answer: "If you can't attend, please let us know as soon as possible so we can offer your spot to someone on the waitlist. Contact us through the website or WhatsApp group to cancel your booking.",
answerEs: "Si no puedes asistir, por favor avísanos lo antes posible para poder ofrecer tu lugar a alguien en la lista de espera. Contáctanos a través del sitio web o el grupo de WhatsApp para cancelar tu reserva."
},
{
question: "How can I stay updated about events?",
questionEs: "¿Cómo puedo mantenerme actualizado sobre los eventos?",
answer: "Join our WhatsApp group for instant updates, follow us on Instagram for announcements and photos, or subscribe to our newsletter on the website. We typically announce events 2-3 weeks in advance.",
answerEs: "Únete a nuestro grupo de WhatsApp para actualizaciones instantáneas, síguenos en Instagram para anuncios y fotos, o suscríbete a nuestro boletín en el sitio web. Normalmente anunciamos eventos con 2-3 semanas de anticipación."
},
{
question: "Can I volunteer or help organize events?",
questionEs: "¿Puedo ser voluntario o ayudar a organizar eventos?",
answer: "Yes! We're always looking for enthusiastic volunteers. Volunteers help with setup, greeting newcomers, facilitating activities, and more. Contact us through the website if you're interested in getting involved.",
answerEs: "¡Sí! Siempre estamos buscando voluntarios entusiastas. Los voluntarios ayudan con la preparación, saludar a los recién llegados, facilitar actividades y más. Contáctanos a través del sitio web si estás interesado en participar."
}
];
export default function FAQPage() {
const { t, locale } = useLanguage();
const { 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().then((res) => {
if (!cancelled) {
setFaqs(res.faqs);
}
}).finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, []);
const toggleFAQ = (index: number) => {
setOpenIndex(openIndex === index ? null : index);
};
if (loading) {
return (
<div className="section-padding">
<div className="container-page max-w-3xl flex justify-center py-20">
<div className="animate-spin w-10 h-10 border-4 border-primary-yellow border-t-transparent rounded-full" />
</div>
</div>
);
}
return (
<div className="section-padding">
<div className="container-page max-w-3xl">
@@ -92,53 +47,63 @@ export default function FAQPage() {
{locale === 'es' ? 'Preguntas Frecuentes' : 'Frequently Asked Questions'}
</h1>
<p className="text-gray-600">
{locale === 'es'
? 'Encuentra respuestas a las preguntas más comunes sobre Spanglish'
{locale === 'es'
? 'Encuentra respuestas a las preguntas más comunes sobre Spanglish'
: 'Find answers to the most common questions about Spanglish'}
</p>
</div>
<div className="space-y-4">
{faqs.map((faq, index) => (
<Card key={index} className="overflow-hidden">
<button
onClick={() => toggleFAQ(index)}
className="w-full px-6 py-4 flex items-center justify-between text-left hover:bg-gray-50 transition-colors"
>
<span className="font-semibold text-primary-dark pr-4">
{locale === 'es' ? faq.questionEs : faq.question}
</span>
<ChevronDownIcon
{faqs.length === 0 ? (
<Card className="p-8 text-center">
<p className="text-gray-600">
{locale === 'es'
? 'No hay preguntas frecuentes publicadas en este momento.'
: 'No FAQ questions are published at the moment.'}
</p>
</Card>
) : (
<div className="space-y-4">
{faqs.map((faq, index) => (
<Card key={faq.id} className="overflow-hidden">
<button
onClick={() => toggleFAQ(index)}
className="w-full px-6 py-4 flex items-center justify-between text-left hover:bg-gray-50 transition-colors"
>
<span className="font-semibold text-primary-dark pr-4">
{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(
'w-5 h-5 text-gray-500 flex-shrink-0 transition-transform duration-200',
openIndex === index && 'transform rotate-180'
'overflow-hidden transition-all duration-200',
openIndex === index ? 'max-h-96' : 'max-h-0'
)}
/>
</button>
<div
className={clsx(
'overflow-hidden transition-all duration-200',
openIndex === index ? 'max-h-96' : 'max-h-0'
)}
>
<div className="px-6 pb-4 text-gray-600">
{locale === 'es' ? faq.answerEs : faq.answer}
>
<div className="px-6 pb-4 text-gray-600">
{locale === 'es' && faq.answerEs ? faq.answerEs : faq.answer}
</div>
</div>
</div>
</Card>
))}
</div>
</Card>
))}
</div>
)}
<Card className="mt-12 p-8 text-center bg-primary-yellow/10">
<h2 className="text-xl font-semibold text-primary-dark mb-2">
{locale === 'es' ? '¿Todavía tienes preguntas?' : 'Still have questions?'}
</h2>
<p className="text-gray-600 mb-4">
{locale === 'es'
{locale === 'es'
? 'No dudes en contactarnos. ¡Estamos aquí para ayudarte!'
: "Don't hesitate to reach out. We're here to help!"}
</p>
<a
<a
href="/contact"
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"
>

View File

@@ -4,6 +4,7 @@ import NextEventSectionWrapper from './components/NextEventSectionWrapper';
import AboutSection from './components/AboutSection';
import MediaCarouselSection from './components/MediaCarouselSection';
import NewsletterSection from './components/NewsletterSection';
import HomepageFaqSection from './components/HomepageFaqSection';
import { getCarouselImages } from '@/lib/carouselImages';
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://spanglish.com.py';
@@ -160,6 +161,7 @@ export default async function HomePage() {
<AboutSection />
<MediaCarouselSection images={carouselImages} />
<NewsletterSection />
<HomepageFaqSection />
</>
);
}

View File

@@ -0,0 +1,395 @@
'use client';
import { useState, useEffect } from 'react';
import { useLanguage } from '@/context/LanguageContext';
import { faqApi, FaqItemAdmin } from '@/lib/api';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input';
import toast from 'react-hot-toast';
import clsx from 'clsx';
import {
PlusIcon,
PencilSquareIcon,
TrashIcon,
Bars3Icon,
XMarkIcon,
CheckIcon,
ArrowLeftIcon,
} from '@heroicons/react/24/outline';
type FormState = { id: string | null; question: string; questionEs: string; answer: string; answerEs: string; enabled: boolean; showOnHomepage: boolean };
const emptyForm: FormState = {
id: null,
question: '',
questionEs: '',
answer: '',
answerEs: '',
enabled: true,
showOnHomepage: false,
};
export default function AdminFaqPage() {
const { locale } = useLanguage();
const [faqs, setFaqs] = useState<FaqItemAdmin[]>([]);
const [loading, setLoading] = useState(true);
const [form, setForm] = useState<FormState>(emptyForm);
const [showForm, setShowForm] = useState(false);
const [saving, setSaving] = useState(false);
const [draggedId, setDraggedId] = useState<string | null>(null);
const [dragOverId, setDragOverId] = useState<string | null>(null);
useEffect(() => {
loadFaqs();
}, []);
const loadFaqs = async () => {
try {
setLoading(true);
const res = await faqApi.getAdminList();
setFaqs(res.faqs);
} catch (e) {
console.error(e);
toast.error(locale === 'es' ? 'Error al cargar FAQs' : 'Failed to load FAQs');
} finally {
setLoading(false);
}
};
const handleCreate = () => {
setForm(emptyForm);
setShowForm(true);
};
const handleEdit = (faq: FaqItemAdmin) => {
setForm({
id: faq.id,
question: faq.question,
questionEs: faq.questionEs ?? '',
answer: faq.answer,
answerEs: faq.answerEs ?? '',
enabled: faq.enabled,
showOnHomepage: faq.showOnHomepage,
});
setShowForm(true);
};
const handleSave = async () => {
if (!form.question.trim() || !form.answer.trim()) {
toast.error(locale === 'es' ? 'Pregunta y respuesta (EN) son obligatorios' : 'Question and answer (EN) are required');
return;
}
try {
setSaving(true);
if (form.id) {
await faqApi.update(form.id, {
question: form.question.trim(),
questionEs: form.questionEs.trim() || null,
answer: form.answer.trim(),
answerEs: form.answerEs.trim() || null,
enabled: form.enabled,
showOnHomepage: form.showOnHomepage,
});
toast.success(locale === 'es' ? 'FAQ actualizado' : 'FAQ updated');
} else {
await faqApi.create({
question: form.question.trim(),
questionEs: form.questionEs.trim() || undefined,
answer: form.answer.trim(),
answerEs: form.answerEs.trim() || undefined,
enabled: form.enabled,
showOnHomepage: form.showOnHomepage,
});
toast.success(locale === 'es' ? 'FAQ creado' : 'FAQ created');
}
setForm(emptyForm);
setShowForm(false);
await loadFaqs();
} catch (e: any) {
toast.error(e.message || (locale === 'es' ? 'Error al guardar' : 'Failed to save'));
} finally {
setSaving(false);
}
};
const handleDelete = async (id: string) => {
if (!confirm(locale === 'es' ? '¿Eliminar esta pregunta?' : 'Delete this question?')) return;
try {
await faqApi.delete(id);
toast.success(locale === 'es' ? 'FAQ eliminado' : 'FAQ deleted');
if (form.id === id) { setForm(emptyForm); setShowForm(false); }
await loadFaqs();
} catch (e: any) {
toast.error(e.message || (locale === 'es' ? 'Error al eliminar' : 'Failed to delete'));
}
};
const handleToggleEnabled = async (faq: FaqItemAdmin) => {
try {
await faqApi.update(faq.id, { enabled: !faq.enabled });
setFaqs(prev => prev.map(f => f.id === faq.id ? { ...f, enabled: !f.enabled } : f));
} catch (e: any) {
toast.error(e.message || (locale === 'es' ? 'Error al actualizar' : 'Failed to update'));
}
};
const handleToggleShowOnHomepage = async (faq: FaqItemAdmin) => {
try {
await faqApi.update(faq.id, { showOnHomepage: !faq.showOnHomepage });
setFaqs(prev => prev.map(f => f.id === faq.id ? { ...f, showOnHomepage: !f.showOnHomepage } : f));
} catch (e: any) {
toast.error(e.message || (locale === 'es' ? 'Error al actualizar' : 'Failed to update'));
}
};
const handleDragStart = (e: React.DragEvent, id: string) => {
setDraggedId(id);
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', id);
};
const handleDragOver = (e: React.DragEvent, id: string) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
setDragOverId(id);
};
const handleDragLeave = () => {
setDragOverId(null);
};
const handleDrop = async (e: React.DragEvent, targetId: string) => {
e.preventDefault();
setDragOverId(null);
setDraggedId(null);
const sourceId = e.dataTransfer.getData('text/plain');
if (!sourceId || sourceId === targetId) return;
const idx = faqs.findIndex(f => f.id === sourceId);
const targetIdx = faqs.findIndex(f => f.id === targetId);
if (idx === -1 || targetIdx === -1) return;
const newOrder = [...faqs];
const [removed] = newOrder.splice(idx, 1);
newOrder.splice(targetIdx, 0, removed);
const ids = newOrder.map(f => f.id);
try {
const res = await faqApi.reorder(ids);
setFaqs(res.faqs);
toast.success(locale === 'es' ? 'Orden actualizado' : 'Order updated');
} catch (err: any) {
toast.error(err.message || (locale === 'es' ? 'Error al reordenar' : 'Failed to reorder'));
}
};
const handleDragEnd = () => {
setDraggedId(null);
setDragOverId(null);
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full" />
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between flex-wrap gap-4">
<div>
<h1 className="text-2xl font-bold font-heading">
{locale === 'es' ? 'FAQ' : 'FAQ'}
</h1>
<p className="text-gray-500 text-sm mt-1">
{locale === 'es'
? 'Crear y editar preguntas frecuentes. Arrastra para cambiar el orden.'
: 'Create and edit FAQ questions. Drag to change order.'}
</p>
</div>
<Button onClick={handleCreate}>
<PlusIcon className="w-4 h-4 mr-2" />
{locale === 'es' ? 'Nueva pregunta' : 'Add question'}
</Button>
</div>
{showForm && (
<Card>
<div className="p-6 space-y-4">
<div className="flex justify-between items-center">
<h2 className="text-lg font-semibold">
{form.id ? (locale === 'es' ? 'Editar pregunta' : 'Edit question') : (locale === 'es' ? 'Nueva pregunta' : 'New question')}
</h2>
<button
onClick={() => { setForm(emptyForm); setShowForm(false); }}
className="p-2 hover:bg-gray-100 rounded-full"
>
<XMarkIcon className="w-5 h-5" />
</button>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="block text-sm font-medium mb-1">Question (EN) *</label>
<Input
value={form.question}
onChange={e => setForm(f => ({ ...f, question: e.target.value }))}
placeholder="Question in English"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Pregunta (ES)</label>
<Input
value={form.questionEs}
onChange={e => setForm(f => ({ ...f, questionEs: e.target.value }))}
placeholder="Pregunta en español"
/>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="block text-sm font-medium mb-1">Answer (EN) *</label>
<textarea
className="w-full border border-gray-300 rounded-btn px-3 py-2 min-h-[100px]"
value={form.answer}
onChange={e => setForm(f => ({ ...f, answer: e.target.value }))}
placeholder="Answer in English"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Respuesta (ES)</label>
<textarea
className="w-full border border-gray-300 rounded-btn px-3 py-2 min-h-[100px]"
value={form.answerEs}
onChange={e => setForm(f => ({ ...f, answerEs: e.target.value }))}
placeholder="Respuesta en español"
/>
</div>
</div>
<div className="flex flex-wrap gap-6">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={form.enabled}
onChange={e => setForm(f => ({ ...f, enabled: e.target.checked }))}
className="rounded border-gray-300"
/>
<span className="text-sm">{locale === 'es' ? 'Mostrar en el sitio' : 'Show on site'}</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={form.showOnHomepage}
onChange={e => setForm(f => ({ ...f, showOnHomepage: e.target.checked }))}
className="rounded border-gray-300"
/>
<span className="text-sm">{locale === 'es' ? 'Mostrar en inicio' : 'Show on homepage'}</span>
</label>
</div>
<div className="flex gap-2">
<Button onClick={handleSave} isLoading={saving}>
<CheckIcon className="w-4 h-4 mr-1" />
{locale === 'es' ? 'Guardar' : 'Save'}
</Button>
<Button variant="outline" onClick={() => { setForm(emptyForm); setShowForm(false); }} disabled={saving}>
{locale === 'es' ? 'Cancelar' : 'Cancel'}
</Button>
</div>
</div>
</Card>
)}
<Card>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="w-10 px-4 py-3" />
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-600 uppercase">
{locale === 'es' ? 'Pregunta' : 'Question'}
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-600 uppercase w-24">
{locale === 'es' ? 'En sitio' : 'On site'}
</th>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-600 uppercase w-28">
{locale === 'es' ? 'En inicio' : 'Homepage'}
</th>
<th className="px-4 py-3 text-right text-xs font-semibold text-gray-600 uppercase w-32">
{locale === 'es' ? 'Acciones' : 'Actions'}
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{faqs.length === 0 ? (
<tr>
<td colSpan={5} className="px-6 py-12 text-center text-gray-500">
{locale === 'es' ? 'No hay preguntas. Añade la primera.' : 'No questions yet. Add the first one.'}
</td>
</tr>
) : (
faqs.map((faq) => (
<tr
key={faq.id}
draggable
onDragStart={e => handleDragStart(e, faq.id)}
onDragOver={e => handleDragOver(e, faq.id)}
onDragLeave={handleDragLeave}
onDrop={e => handleDrop(e, faq.id)}
onDragEnd={handleDragEnd}
className={clsx(
'hover:bg-gray-50',
draggedId === faq.id && 'opacity-50',
dragOverId === faq.id && 'bg-primary-yellow/10'
)}
>
<td className="px-4 py-3">
<span className="cursor-grab active:cursor-grabbing text-gray-400 hover:text-gray-600" title={locale === 'es' ? 'Arrastrar para reordenar' : 'Drag to reorder'}>
<Bars3Icon className="w-5 h-5" />
</span>
</td>
<td className="px-4 py-3">
<p className="font-medium text-primary-dark line-clamp-1">
{locale === 'es' && faq.questionEs ? faq.questionEs : faq.question}
</p>
</td>
<td className="px-4 py-3">
<button
onClick={() => handleToggleEnabled(faq)}
className={clsx(
'inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium',
faq.enabled ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-500'
)}
>
{faq.enabled ? (locale === 'es' ? 'Sí' : 'Yes') : (locale === 'es' ? 'No' : 'No')}
</button>
</td>
<td className="px-4 py-3">
<button
onClick={() => handleToggleShowOnHomepage(faq)}
className={clsx(
'inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium',
faq.showOnHomepage ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-500'
)}
>
{faq.showOnHomepage ? (locale === 'es' ? 'Sí' : 'Yes') : (locale === 'es' ? 'No' : 'No')}
</button>
</td>
<td className="px-4 py-3 text-right">
<div className="flex justify-end gap-1">
<Button size="sm" variant="outline" onClick={() => handleEdit(faq)}>
<PencilSquareIcon className="w-4 h-4" />
</Button>
<Button size="sm" variant="outline" onClick={() => handleDelete(faq.id)} className="text-red-600 hover:bg-red-50">
<TrashIcon className="w-4 h-4" />
</Button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</Card>
</div>
);
}

View File

@@ -24,6 +24,7 @@ import {
BanknotesIcon,
QrCodeIcon,
DocumentTextIcon,
QuestionMarkCircleIcon,
} from '@heroicons/react/24/outline';
import clsx from 'clsx';
import { useState } from 'react';
@@ -69,6 +70,7 @@ export default function AdminLayout({
{ name: t('admin.nav.emails'), href: '/admin/emails', icon: InboxIcon },
{ name: t('admin.nav.gallery'), href: '/admin/gallery', icon: PhotoIcon },
{ name: locale === 'es' ? 'Páginas Legales' : 'Legal Pages', href: '/admin/legal-pages', icon: DocumentTextIcon },
{ name: 'FAQ', href: '/admin/faq', icon: QuestionMarkCircleIcon },
{ name: locale === 'es' ? 'Configuración' : 'Settings', href: '/admin/settings', icon: Cog6ToothIcon },
];

View File

@@ -3,6 +3,11 @@ import { NextResponse } from 'next/server';
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://spanglish.com.py';
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
interface LlmsFaq {
question: string;
answer: string;
}
interface LlmsEvent {
id: string;
title: string;
@@ -68,10 +73,27 @@ function formatPrice(price: number, currency: string): string {
return `${price.toLocaleString()} ${currency}`;
}
async function getHomepageFaqs(): Promise<LlmsFaq[]> {
try {
const response = await fetch(`${apiUrl}/api/faq?homepage=true`, {
next: { revalidate: 3600 },
});
if (!response.ok) return [];
const data = await response.json();
return (data.faqs || []).map((f: any) => ({
question: f.question,
answer: f.answer,
}));
} catch {
return [];
}
}
export async function GET() {
const [nextEvent, upcomingEvents] = await Promise.all([
const [nextEvent, upcomingEvents, faqs] = await Promise.all([
getNextUpcomingEvent(),
getUpcomingEvents(),
getHomepageFaqs(),
]);
const lines: string[] = [];
@@ -145,11 +167,16 @@ export async function GET() {
lines.push('');
lines.push('## Frequently Asked Questions');
lines.push('');
lines.push('- **What is Spanglish?** A language exchange community that hosts social events to practice English and Spanish.');
lines.push('- **Where are events held?** In Asunción, Paraguay. Specific venues are listed on each event page.');
lines.push('- **How do I attend an event?** Visit the events page to see upcoming events and book tickets.');
lines.push('- **How much do events cost?** Prices vary by event. Some are free, others have a small cover charge.');
lines.push(`- **How do I stay updated?** Follow us on Instagram${instagram ? ` (@${instagram})` : ''}, join our Telegram${telegram ? ` (@${telegram})` : ''}, or check ${siteUrl}/events regularly.`);
if (faqs.length > 0) {
for (const faq of faqs) {
lines.push(`- **${faq.question}** ${faq.answer}`);
}
} else {
lines.push('- **What is Spanglish?** A language exchange community that hosts social events to practice English and Spanish.');
lines.push('- **Where are events held?** In Asunción, Paraguay. Specific venues are listed on each event page.');
lines.push('- **How do I attend an event?** Visit the events page to see upcoming events and book tickets.');
}
lines.push(`- More FAQ: ${siteUrl}/faq`);
lines.push('');
const content = lines.join('\n');

View File

@@ -342,6 +342,7 @@ export default function RichTextEditor({
const lastContentRef = useRef(content);
const editor = useEditor({
immediatelyRender: false,
extensions: [
StarterKit.configure({
heading: {
@@ -393,6 +394,7 @@ export function RichTextPreview({
className?: string;
}) {
const editor = useEditor({
immediatelyRender: false,
extensions: [StarterKit],
content: markdownToHtml(content),
editable: false,

View File

@@ -67,6 +67,10 @@
"button": "Subscribe",
"success": "Thanks for subscribing!",
"error": "Subscription failed. Please try again."
},
"faq": {
"title": "Frequently Asked Questions",
"seeFull": "See full FAQ"
}
},
"events": {

View File

@@ -67,6 +67,10 @@
"button": "Suscribirse",
"success": "¡Gracias por suscribirte!",
"error": "Error al suscribirse. Por favor intenta de nuevo."
},
"faq": {
"title": "Preguntas Frecuentes",
"seeFull": "Ver FAQ completo"
}
},
"events": {

View File

@@ -1078,3 +1078,71 @@ export const legalPagesApi = {
method: 'POST',
}),
};
// ==================== FAQ Types ====================
export interface FaqItem {
id: string;
question: string;
questionEs?: string | null;
answer: string;
answerEs?: string | null;
rank?: number;
}
export interface FaqItemAdmin extends FaqItem {
enabled: boolean;
showOnHomepage: boolean;
createdAt: string;
updatedAt: string;
}
// ==================== FAQ API ====================
export const faqApi = {
// Public
getList: (homepageOnly?: boolean) =>
fetchApi<{ faqs: FaqItem[] }>(`/api/faq${homepageOnly ? '?homepage=true' : ''}`),
// Admin
getAdminList: () =>
fetchApi<{ faqs: FaqItemAdmin[] }>('/api/faq/admin/list'),
getById: (id: string) =>
fetchApi<{ faq: FaqItemAdmin }>(`/api/faq/admin/${id}`),
create: (data: {
question: string;
questionEs?: string;
answer: string;
answerEs?: string;
enabled?: boolean;
showOnHomepage?: boolean;
}) =>
fetchApi<{ faq: FaqItemAdmin }>('/api/faq/admin', {
method: 'POST',
body: JSON.stringify(data),
}),
update: (id: string, data: {
question?: string;
questionEs?: string | null;
answer?: string;
answerEs?: string | null;
enabled?: boolean;
showOnHomepage?: boolean;
}) =>
fetchApi<{ faq: FaqItemAdmin }>(`/api/faq/admin/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
}),
delete: (id: string) =>
fetchApi<{ message: string }>(`/api/faq/admin/${id}`, { method: 'DELETE' }),
reorder: (ids: string[]) =>
fetchApi<{ faqs: FaqItemAdmin[] }>('/api/faq/admin/reorder', {
method: 'POST',
body: JSON.stringify({ ids }),
}),
};