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:
@@ -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 },
|
||||
];
|
||||
|
||||
|
||||
556
frontend/src/app/admin/legal-pages/page.tsx
Normal file
556
frontend/src/app/admin/legal-pages/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user