Add PostgreSQL support with SQLite/Postgres database compatibility layer

- Add dbGet/dbAll helper functions for database-agnostic queries
- Add toDbBool/convertBooleansForDb for boolean type conversion
- Add toDbDate/getNow for timestamp type handling
- Add generateId that returns UUID for Postgres, nanoid for SQLite
- Update all routes to use compatibility helpers
- Add normalizeEvent to return clean number types from Postgres decimal
- Add formatPrice utility for consistent price display
- Add legal pages admin interface with RichTextEditor
- Update carousel images
- Add drizzle migration files for PostgreSQL
This commit is contained in:
Michilis
2026-02-02 03:46:35 +00:00
parent 9410e83b89
commit bafd1425c4
61 changed files with 5015 additions and 881 deletions

View File

@@ -23,6 +23,7 @@ import {
XMarkIcon,
BanknotesIcon,
QrCodeIcon,
DocumentTextIcon,
} from '@heroicons/react/24/outline';
import clsx from 'clsx';
import { useState } from 'react';
@@ -67,6 +68,7 @@ export default function AdminLayout({
{ name: t('admin.nav.contacts'), href: '/admin/contacts', icon: EnvelopeIcon },
{ 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: locale === 'es' ? 'Configuración' : 'Settings', href: '/admin/settings', icon: Cog6ToothIcon },
];

View File

@@ -0,0 +1,556 @@
'use client';
import { useState, useEffect } from 'react';
import dynamic from 'next/dynamic';
import { useLanguage } from '@/context/LanguageContext';
import { legalPagesApi, LegalPage } 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 {
DocumentTextIcon,
PencilSquareIcon,
ArrowPathIcon,
XMarkIcon,
CheckIcon,
ArrowLeftIcon,
CheckCircleIcon,
ExclamationCircleIcon,
EyeIcon,
PencilIcon,
} from '@heroicons/react/24/outline';
// Dynamically import rich text editor to avoid SSR issues
const RichTextEditor = dynamic(
() => import('@/components/ui/RichTextEditor'),
{
ssr: false,
loading: () => (
<div className="border border-secondary-light-gray rounded-btn bg-gray-50 h-[400px] flex items-center justify-center">
<div className="animate-spin w-6 h-6 border-2 border-primary-yellow border-t-transparent rounded-full" />
</div>
),
}
);
const RichTextPreview = dynamic(
() => import('@/components/ui/RichTextEditor').then(mod => ({ default: mod.RichTextPreview })),
{
ssr: false,
loading: () => (
<div className="border border-secondary-light-gray rounded-btn bg-gray-50 h-[400px] flex items-center justify-center">
<div className="animate-spin w-6 h-6 border-2 border-primary-yellow border-t-transparent rounded-full" />
</div>
),
}
);
type EditLanguage = 'en' | 'es';
type ViewMode = 'edit' | 'preview' | 'split';
export default function AdminLegalPagesPage() {
const { locale } = useLanguage();
const [pages, setPages] = useState<LegalPage[]>([]);
const [loading, setLoading] = useState(true);
const [seeding, setSeeding] = useState(false);
// Editor state
const [editingPage, setEditingPage] = useState<LegalPage | null>(null);
const [editLanguage, setEditLanguage] = useState<EditLanguage>('en');
const [editContentEn, setEditContentEn] = useState('');
const [editContentEs, setEditContentEs] = useState('');
const [editTitleEn, setEditTitleEn] = useState('');
const [editTitleEs, setEditTitleEs] = useState('');
const [saving, setSaving] = useState(false);
const [viewMode, setViewMode] = useState<ViewMode>('edit');
useEffect(() => {
loadPages();
}, []);
const loadPages = async () => {
try {
setLoading(true);
const response = await legalPagesApi.getAdminList();
setPages(response.pages);
} catch (error) {
console.error('Failed to load legal pages:', error);
toast.error(locale === 'es' ? 'Error al cargar páginas legales' : 'Failed to load legal pages');
} finally {
setLoading(false);
}
};
const handleSeed = async () => {
try {
setSeeding(true);
const response = await legalPagesApi.seed();
toast.success(response.message);
if (response.seeded > 0) {
await loadPages();
}
} catch (error: any) {
console.error('Failed to seed legal pages:', error);
toast.error(error.message || (locale === 'es' ? 'Error al importar páginas' : 'Failed to import pages'));
} finally {
setSeeding(false);
}
};
const handleEdit = (page: LegalPage) => {
setEditingPage(page);
// Load from contentMarkdown to preserve formatting (fallback to contentText)
setEditContentEn(page.contentMarkdown || page.contentText || '');
setEditContentEs(page.contentMarkdownEs || page.contentTextEs || '');
setEditTitleEn(page.title || '');
setEditTitleEs(page.titleEs || '');
// Default to English tab, or Spanish if only Spanish exists
setEditLanguage(page.hasEnglish || !page.hasSpanish ? 'en' : 'es');
};
const handleCancelEdit = () => {
setEditingPage(null);
setEditContentEn('');
setEditContentEs('');
setEditTitleEn('');
setEditTitleEs('');
setEditLanguage('en');
};
const handleSave = async () => {
if (!editingPage) return;
// Validate - at least one language must have content
if (!editContentEn.trim() && !editContentEs.trim()) {
toast.error(locale === 'es'
? 'Al menos una versión de idioma debe tener contenido'
: 'At least one language version must have content'
);
return;
}
try {
setSaving(true);
const response = await legalPagesApi.update(editingPage.slug, {
contentMarkdown: editContentEn.trim() || undefined,
contentMarkdownEs: editContentEs.trim() || undefined,
title: editTitleEn.trim() || undefined,
titleEs: editTitleEs.trim() || undefined,
});
toast.success(response.message || (locale === 'es' ? 'Página actualizada' : 'Page updated successfully'));
// Update local state
setPages(prev => prev.map(p =>
p.slug === editingPage.slug ? response.page : p
));
handleCancelEdit();
} catch (error: any) {
console.error('Failed to save legal page:', error);
toast.error(error.message || (locale === 'es' ? 'Error al guardar' : 'Failed to save'));
} finally {
setSaving(false);
}
};
const formatDate = (dateStr: string) => {
try {
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-PY' : 'en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
} catch {
return dateStr;
}
};
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>
);
}
// Editor view
if (editingPage) {
const currentContent = editLanguage === 'en' ? editContentEn : editContentEs;
const setCurrentContent = editLanguage === 'en' ? setEditContentEn : setEditContentEs;
const currentTitle = editLanguage === 'en' ? editTitleEn : editTitleEs;
const setCurrentTitle = editLanguage === 'en' ? setEditTitleEn : setEditTitleEs;
return (
<div className="space-y-6">
<div className="flex items-center justify-between flex-wrap gap-4">
<div className="flex items-center gap-4">
<button
onClick={handleCancelEdit}
className="p-2 hover:bg-gray-100 rounded-full transition-colors"
>
<ArrowLeftIcon className="w-5 h-5" />
</button>
<div>
<h1 className="text-2xl font-bold font-heading">
{locale === 'es' ? 'Editar Página Legal' : 'Edit Legal Page'}
</h1>
<p className="text-gray-500 text-sm mt-1">
{editingPage.slug}
</p>
</div>
</div>
<div className="flex gap-2">
<Button
variant="outline"
onClick={handleCancelEdit}
disabled={saving}
>
<XMarkIcon className="w-4 h-4 mr-1" />
{locale === 'es' ? 'Cancelar' : 'Cancel'}
</Button>
<Button
onClick={handleSave}
isLoading={saving}
>
<CheckIcon className="w-4 h-4 mr-1" />
{locale === 'es' ? 'Guardar Todo' : 'Save All'}
</Button>
</div>
</div>
<Card>
<div className="p-6 space-y-6">
{/* Language tabs and View mode toggle */}
<div className="flex justify-between items-center border-b border-gray-200 pb-3">
{/* Language tabs */}
<div className="flex gap-4">
<button
onClick={() => setEditLanguage('en')}
className={clsx(
'pb-3 px-1 text-sm font-medium border-b-2 transition-colors flex items-center gap-2 -mb-3',
editLanguage === 'en'
? 'border-primary-yellow text-primary-dark'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
)}
>
English
{editContentEn.trim() ? (
<CheckCircleIcon className="w-4 h-4 text-green-500" />
) : (
<ExclamationCircleIcon className="w-4 h-4 text-amber-500" />
)}
</button>
<button
onClick={() => setEditLanguage('es')}
className={clsx(
'pb-3 px-1 text-sm font-medium border-b-2 transition-colors flex items-center gap-2 -mb-3',
editLanguage === 'es'
? 'border-primary-yellow text-primary-dark'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
)}
>
Español (Paraguay)
{editContentEs.trim() ? (
<CheckCircleIcon className="w-4 h-4 text-green-500" />
) : (
<ExclamationCircleIcon className="w-4 h-4 text-amber-500" />
)}
</button>
</div>
{/* View mode toggle */}
<div className="flex bg-gray-100 rounded-lg p-1">
<button
onClick={() => setViewMode('edit')}
className={clsx(
'px-3 py-1.5 text-sm rounded-md transition-colors flex items-center gap-1.5',
viewMode === 'edit'
? 'bg-white shadow-sm text-primary-dark'
: 'text-gray-600 hover:text-gray-900'
)}
>
<PencilIcon className="w-4 h-4" />
{locale === 'es' ? 'Editar' : 'Edit'}
</button>
<button
onClick={() => setViewMode('split')}
className={clsx(
'px-3 py-1.5 text-sm rounded-md transition-colors hidden lg:flex items-center gap-1.5',
viewMode === 'split'
? 'bg-white shadow-sm text-primary-dark'
: 'text-gray-600 hover:text-gray-900'
)}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7" />
</svg>
Split
</button>
<button
onClick={() => setViewMode('preview')}
className={clsx(
'px-3 py-1.5 text-sm rounded-md transition-colors flex items-center gap-1.5',
viewMode === 'preview'
? 'bg-white shadow-sm text-primary-dark'
: 'text-gray-600 hover:text-gray-900'
)}
>
<EyeIcon className="w-4 h-4" />
{locale === 'es' ? 'Vista previa' : 'Preview'}
</button>
</div>
</div>
{/* Title for current language */}
<div>
<label className="block text-sm font-medium mb-2">
{editLanguage === 'en'
? (locale === 'es' ? 'Título (Inglés)' : 'Title (English)')
: (locale === 'es' ? 'Título (Español)' : 'Title (Spanish)')
}
</label>
<Input
value={currentTitle}
onChange={(e) => setCurrentTitle(e.target.value)}
placeholder={editLanguage === 'en' ? 'Title' : 'Título'}
/>
</div>
{/* Content editor and preview */}
<div>
<label className="block text-sm font-medium mb-2">
{editLanguage === 'en'
? (locale === 'es' ? 'Contenido (Inglés)' : 'Content (English)')
: (locale === 'es' ? 'Contenido (Español)' : 'Content (Spanish)')
}
</label>
{viewMode === 'edit' && (
<>
<p className="text-xs text-gray-500 mb-2">
{locale === 'es'
? 'Usa la barra de herramientas para dar formato. Los cambios se guardan como texto plano.'
: 'Use the toolbar to format text. Changes are saved as plain text.'
}
</p>
<RichTextEditor
content={currentContent}
onChange={setCurrentContent}
placeholder={editLanguage === 'en'
? 'Write content here...'
: 'Escribe el contenido aquí...'
}
/>
</>
)}
{viewMode === 'preview' && (
<>
<p className="text-xs text-gray-500 mb-2">
{locale === 'es'
? 'Así se verá el contenido en la página pública.'
: 'This is how the content will look on the public page.'
}
</p>
<div className="border border-secondary-light-gray rounded-btn bg-white">
<div className="bg-gray-50 px-4 py-2 border-b border-gray-200 text-sm text-gray-500">
{locale === 'es' ? 'Vista previa' : 'Preview'}
</div>
<RichTextPreview content={currentContent} />
</div>
</>
)}
{viewMode === 'split' && (
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-xs text-gray-500 mb-2">
{locale === 'es' ? 'Editor' : 'Editor'}
</p>
<RichTextEditor
content={currentContent}
onChange={setCurrentContent}
placeholder={editLanguage === 'en'
? 'Write content here...'
: 'Escribe el contenido aquí...'
}
/>
</div>
<div>
<p className="text-xs text-gray-500 mb-2">
{locale === 'es' ? 'Vista previa' : 'Preview'}
</p>
<div className="border border-secondary-light-gray rounded-btn bg-white h-full">
<RichTextPreview content={currentContent} className="h-full" />
</div>
</div>
</div>
)}
</div>
{/* Info */}
<div className="bg-gray-50 rounded-lg p-4 text-sm text-gray-600">
<p className="font-medium mb-2">
{locale === 'es' ? 'Nota:' : 'Note:'}
</p>
<ul className="list-disc list-inside space-y-1">
<li>
{locale === 'es'
? 'El slug (URL) no se puede cambiar: '
: 'The slug (URL) cannot be changed: '
}
<code className="bg-gray-200 px-1 rounded">/legal/{editingPage.slug}</code>
</li>
<li>
{locale === 'es'
? 'Usa la barra de herramientas para encabezados, listas, negritas y cursivas.'
: 'Use the toolbar for headings, lists, bold, and italics.'
}
</li>
<li>
{locale === 'es'
? 'Si falta una versión de idioma, se mostrará la otra versión disponible.'
: 'If a language version is missing, the other available version will be shown.'
}
</li>
</ul>
</div>
</div>
</Card>
</div>
);
}
// List view
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold font-heading">
{locale === 'es' ? 'Páginas Legales' : 'Legal Pages'}
</h1>
<p className="text-gray-500 text-sm mt-1">
{locale === 'es'
? 'Administra el contenido de las páginas legales del sitio.'
: 'Manage the content of the site\'s legal pages.'
}
</p>
</div>
{pages.length === 0 && (
<Button
onClick={handleSeed}
isLoading={seeding}
variant="outline"
>
<ArrowPathIcon className="w-4 h-4 mr-2" />
{locale === 'es' ? 'Importar desde archivos' : 'Import from files'}
</Button>
)}
</div>
{pages.length === 0 ? (
<Card>
<div className="p-12 text-center">
<DocumentTextIcon className="w-12 h-12 mx-auto text-gray-400 mb-4" />
<h3 className="text-lg font-medium mb-2">
{locale === 'es' ? 'No hay páginas legales' : 'No legal pages found'}
</h3>
<p className="text-gray-500 mb-4">
{locale === 'es'
? 'Haz clic en "Importar desde archivos" para cargar las páginas legales existentes.'
: 'Click "Import from files" to load existing legal pages.'
}
</p>
<Button onClick={handleSeed} isLoading={seeding}>
<ArrowPathIcon className="w-4 h-4 mr-2" />
{locale === 'es' ? 'Importar Páginas' : 'Import Pages'}
</Button>
</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="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
{locale === 'es' ? 'Página' : 'Page'}
</th>
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
Slug
</th>
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
{locale === 'es' ? 'Idiomas' : 'Languages'}
</th>
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">
{locale === 'es' ? 'Última actualización' : 'Last Updated'}
</th>
<th className="px-6 py-3 text-right text-xs font-semibold text-gray-600 uppercase tracking-wider">
{locale === 'es' ? 'Acciones' : 'Actions'}
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{pages.map((page) => (
<tr key={page.id} className="hover:bg-gray-50">
<td className="px-6 py-4">
<div>
<p className="font-medium">{page.title}</p>
{page.titleEs && (
<p className="text-sm text-gray-500">{page.titleEs}</p>
)}
</div>
</td>
<td className="px-6 py-4">
<code className="bg-gray-100 px-2 py-1 rounded text-sm">
{page.slug}
</code>
</td>
<td className="px-6 py-4">
<div className="flex gap-2">
<span className={clsx(
'inline-flex items-center px-2 py-0.5 rounded text-xs font-medium',
page.hasEnglish
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-500'
)}>
EN {page.hasEnglish ? '✓' : '—'}
</span>
<span className={clsx(
'inline-flex items-center px-2 py-0.5 rounded text-xs font-medium',
page.hasSpanish
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-500'
)}>
ES {page.hasSpanish ? '✓' : '—'}
</span>
</div>
</td>
<td className="px-6 py-4 text-sm text-gray-500">
{formatDate(page.updatedAt)}
</td>
<td className="px-6 py-4 text-right">
<Button
size="sm"
variant="outline"
onClick={() => handleEdit(page)}
>
<PencilSquareIcon className="w-4 h-4 mr-1" />
{locale === 'es' ? 'Editar' : 'Edit'}
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
)}
</div>
);
}