- 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>
396 lines
15 KiB
TypeScript
396 lines
15 KiB
TypeScript
'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>
|
|
);
|
|
}
|