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
@@ -10,6 +10,10 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@heroicons/react": "^2.1.4",
|
||||
"@tiptap/extension-placeholder": "^3.18.0",
|
||||
"@tiptap/pm": "^3.18.0",
|
||||
"@tiptap/react": "^3.18.0",
|
||||
"@tiptap/starter-kit": "^3.18.0",
|
||||
"clsx": "^2.1.1",
|
||||
"html5-qrcode": "^2.3.8",
|
||||
"next": "^14.2.4",
|
||||
|
||||
|
Before Width: | Height: | Size: 168 KiB |
|
Before Width: | Height: | Size: 229 KiB |
|
Before Width: | Height: | Size: 207 KiB |
|
Before Width: | Height: | Size: 141 KiB |
|
Before Width: | Height: | Size: 131 KiB |
|
Before Width: | Height: | Size: 146 KiB |
|
Before Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 143 KiB |
|
Before Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 116 KiB |
BIN
frontend/public/images/carrousel/2026-02-02 00.33.56.jpg
Normal file
|
After Width: | Height: | Size: 124 KiB |
BIN
frontend/public/images/carrousel/2026-02-02 00.34.12.jpg
Normal file
|
After Width: | Height: | Size: 233 KiB |
BIN
frontend/public/images/carrousel/2026-02-02 00.34.15.jpg
Normal file
|
After Width: | Height: | Size: 209 KiB |
BIN
frontend/public/images/carrousel/2026-02-02 00.34.18.jpg
Normal file
|
After Width: | Height: | Size: 171 KiB |
BIN
frontend/public/images/carrousel/2026-02-02 00.34.21.jpg
Normal file
|
After Width: | Height: | Size: 124 KiB |
BIN
frontend/public/images/carrousel/2026-02-02 00.34.23.jpg
Normal file
|
After Width: | Height: | Size: 168 KiB |
BIN
frontend/public/images/carrousel/2026-02-02 00.34.26.jpg
Normal file
|
After Width: | Height: | Size: 116 KiB |
BIN
frontend/public/images/carrousel/2026-02-02 00.34.28.jpg
Normal file
|
After Width: | Height: | Size: 186 KiB |
BIN
frontend/public/images/carrousel/2026-02-02 00.34.31.jpg
Normal file
|
After Width: | Height: | Size: 144 KiB |
BIN
frontend/public/images/carrousel/2026-02-02 00.34.33.jpg
Normal file
|
After Width: | Height: | Size: 154 KiB |
BIN
frontend/public/images/carrousel/2026-02-02 00.34.38.jpg
Normal file
|
After Width: | Height: | Size: 110 KiB |
BIN
frontend/public/images/carrousel/2026-02-02 00.34.40.jpg
Normal file
|
After Width: | Height: | Size: 121 KiB |
@@ -6,6 +6,7 @@ import Link from 'next/link';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { eventsApi, ticketsApi, paymentOptionsApi, Event, PaymentOptionsConfig } from '@/lib/api';
|
||||
import { formatPrice } from '@/lib/utils';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Input from '@/components/ui/Input';
|
||||
@@ -620,7 +621,7 @@ export default function BookingPage() {
|
||||
{locale === 'es' ? 'Monto a pagar' : 'Amount to pay'}
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-primary-dark">
|
||||
{event?.price?.toLocaleString()} {event?.currency}
|
||||
{event?.price !== undefined ? formatPrice(event.price, event.currency) : ''}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -924,7 +925,7 @@ export default function BookingPage() {
|
||||
<span className="font-bold text-lg">
|
||||
{event.price === 0
|
||||
? t('events.details.free')
|
||||
: `${event.price.toLocaleString()} ${event.currency}`}
|
||||
: formatPrice(event.price, event.currency)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useParams, useSearchParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { ticketsApi, paymentOptionsApi, Ticket, PaymentOptionsConfig } from '@/lib/api';
|
||||
import { formatPrice } from '@/lib/utils';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import {
|
||||
@@ -341,7 +342,7 @@ export default function BookingPaymentPage() {
|
||||
<div className="flex items-center gap-3">
|
||||
<CurrencyDollarIcon className="w-5 h-5 text-primary-yellow" />
|
||||
<span className="font-bold text-lg">
|
||||
{ticket.event.price?.toLocaleString()} {ticket.event.currency}
|
||||
{ticket.event.price !== undefined ? formatPrice(ticket.event.price, ticket.event.currency) : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -374,7 +375,7 @@ export default function BookingPaymentPage() {
|
||||
{locale === 'es' ? 'Monto a pagar' : 'Amount to pay'}
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-primary-dark">
|
||||
{ticket.event?.price?.toLocaleString()} {ticket.event?.currency}
|
||||
{ticket.event?.price !== undefined ? formatPrice(ticket.event.price, ticket.event.currency) : ''}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { eventsApi, Event } from '@/lib/api';
|
||||
import { formatPrice } from '@/lib/utils';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Card from '@/components/ui/Card';
|
||||
import { CalendarIcon, MapPinIcon } from '@heroicons/react/24/outline';
|
||||
@@ -91,7 +92,7 @@ export default function NextEventSection() {
|
||||
<span className="text-3xl font-bold text-primary-dark">
|
||||
{nextEvent.price === 0
|
||||
? t('events.details.free')
|
||||
: `${nextEvent.price.toLocaleString()} ${nextEvent.currency}`}
|
||||
: formatPrice(nextEvent.price, nextEvent.currency)}
|
||||
</span>
|
||||
{!nextEvent.externalBookingEnabled && (
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
|
||||
@@ -5,6 +5,7 @@ import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { eventsApi, Event } from '@/lib/api';
|
||||
import { formatPrice } from '@/lib/utils';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import ShareButtons from '@/components/ShareButtons';
|
||||
@@ -186,7 +187,7 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail
|
||||
<p className="text-4xl font-bold text-primary-dark">
|
||||
{event.price === 0
|
||||
? t('events.details.free')
|
||||
: `${event.price.toLocaleString()} ${event.currency}`}
|
||||
: formatPrice(event.price, event.currency)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { eventsApi, Event } from '@/lib/api';
|
||||
import { formatPrice } from '@/lib/utils';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import { CalendarIcon, MapPinIcon, UserGroupIcon } from '@heroicons/react/24/outline';
|
||||
@@ -149,7 +150,7 @@ export default function EventsPage() {
|
||||
<span className="font-bold text-xl text-primary-dark">
|
||||
{event.price === 0
|
||||
? t('events.details.free')
|
||||
: `${event.price.toLocaleString()} ${event.currency}`}
|
||||
: formatPrice(event.price, event.currency)}
|
||||
</span>
|
||||
<Button size="sm">
|
||||
{t('common.moreInfo')}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import { Metadata } from 'next';
|
||||
import { getLegalPage, getAllLegalSlugs } from '@/lib/legal';
|
||||
import { getLegalPageAsync, getAllLegalSlugs } from '@/lib/legal';
|
||||
import LegalPageLayout from '@/components/layout/LegalPageLayout';
|
||||
|
||||
interface PageProps {
|
||||
params: { slug: string };
|
||||
params: Promise<{ slug: string }>;
|
||||
searchParams: Promise<{ locale?: string }>;
|
||||
}
|
||||
|
||||
// Generate static params for all legal pages
|
||||
@@ -13,11 +14,24 @@ export async function generateStaticParams() {
|
||||
return slugs.map((slug) => ({ slug }));
|
||||
}
|
||||
|
||||
// Enable dynamic rendering to always fetch fresh content from DB
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const revalidate = 60; // Revalidate every 60 seconds
|
||||
|
||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://spanglish.com.py';
|
||||
|
||||
// Validate and normalize locale
|
||||
function getValidLocale(locale?: string): 'en' | 'es' {
|
||||
if (locale === 'es') return 'es';
|
||||
return 'en'; // Default to English
|
||||
}
|
||||
|
||||
// Generate metadata for SEO
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const legalPage = getLegalPage(params.slug);
|
||||
export async function generateMetadata({ params, searchParams }: PageProps): Promise<Metadata> {
|
||||
const resolvedParams = await params;
|
||||
const resolvedSearchParams = await searchParams;
|
||||
const locale = getValidLocale(resolvedSearchParams.locale);
|
||||
const legalPage = await getLegalPageAsync(resolvedParams.slug, locale);
|
||||
|
||||
if (!legalPage) {
|
||||
return {
|
||||
@@ -33,13 +47,20 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
|
||||
follow: true,
|
||||
},
|
||||
alternates: {
|
||||
canonical: `${siteUrl}/legal/${params.slug}`,
|
||||
canonical: `${siteUrl}/legal/${resolvedParams.slug}`,
|
||||
languages: {
|
||||
'en': `${siteUrl}/legal/${resolvedParams.slug}`,
|
||||
'es': `${siteUrl}/legal/${resolvedParams.slug}?locale=es`,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default function LegalPage({ params }: PageProps) {
|
||||
const legalPage = getLegalPage(params.slug);
|
||||
export default async function LegalPage({ params, searchParams }: PageProps) {
|
||||
const resolvedParams = await params;
|
||||
const resolvedSearchParams = await searchParams;
|
||||
const locale = getValidLocale(resolvedSearchParams.locale);
|
||||
const legalPage = await getLegalPageAsync(resolvedParams.slug, locale);
|
||||
|
||||
if (!legalPage) {
|
||||
notFound();
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -99,3 +99,57 @@
|
||||
text-wrap: balance;
|
||||
}
|
||||
}
|
||||
|
||||
/* TipTap Rich Text Editor Styles */
|
||||
.ProseMirror {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.ProseMirror p.is-editor-empty:first-child::before {
|
||||
@apply text-gray-400 pointer-events-none float-left h-0;
|
||||
content: attr(data-placeholder);
|
||||
}
|
||||
|
||||
.ProseMirror > * + * {
|
||||
margin-top: 0.75em;
|
||||
}
|
||||
|
||||
.ProseMirror h1 {
|
||||
@apply text-2xl font-bold mt-6 mb-3;
|
||||
}
|
||||
|
||||
.ProseMirror h2 {
|
||||
@apply text-xl font-bold mt-5 mb-2;
|
||||
}
|
||||
|
||||
.ProseMirror h3 {
|
||||
@apply text-lg font-semibold mt-4 mb-2;
|
||||
}
|
||||
|
||||
.ProseMirror ul {
|
||||
@apply list-disc list-inside my-3;
|
||||
}
|
||||
|
||||
.ProseMirror ol {
|
||||
@apply list-decimal list-inside my-3;
|
||||
}
|
||||
|
||||
.ProseMirror li {
|
||||
@apply my-1;
|
||||
}
|
||||
|
||||
.ProseMirror blockquote {
|
||||
@apply border-l-4 border-gray-300 pl-4 my-4 italic text-gray-600;
|
||||
}
|
||||
|
||||
.ProseMirror hr {
|
||||
@apply border-t border-gray-300 my-6;
|
||||
}
|
||||
|
||||
.ProseMirror strong {
|
||||
@apply font-bold;
|
||||
}
|
||||
|
||||
.ProseMirror em {
|
||||
@apply italic;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { eventsApi, Event } from '@/lib/api';
|
||||
import { formatPrice } from '@/lib/utils';
|
||||
import {
|
||||
CalendarIcon,
|
||||
MapPinIcon,
|
||||
@@ -100,7 +101,7 @@ export default function LinktreePage() {
|
||||
<span className="font-bold text-primary-yellow">
|
||||
{nextEvent.price === 0
|
||||
? t('events.details.free')
|
||||
: `${nextEvent.price.toLocaleString()} ${nextEvent.currency}`}
|
||||
: formatPrice(nextEvent.price, nextEvent.currency)}
|
||||
</span>
|
||||
{!nextEvent.externalBookingEnabled && (
|
||||
<span className="text-sm text-gray-400">
|
||||
|
||||
417
frontend/src/components/ui/RichTextEditor.tsx
Normal file
@@ -0,0 +1,417 @@
|
||||
'use client';
|
||||
|
||||
import { useEditor, EditorContent, Editor } from '@tiptap/react';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import Placeholder from '@tiptap/extension-placeholder';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface RichTextEditorProps {
|
||||
content: string; // Markdown content
|
||||
onChange: (content: string) => void; // Returns markdown
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
editable?: boolean;
|
||||
}
|
||||
|
||||
// Convert markdown to HTML for TipTap
|
||||
function markdownToHtml(markdown: string): string {
|
||||
if (!markdown) return '<p></p>';
|
||||
|
||||
let html = markdown;
|
||||
|
||||
// Convert horizontal rules first (before other processing)
|
||||
html = html.replace(/^---+$/gm, '<hr>');
|
||||
|
||||
// Convert headings (must be done before other inline formatting)
|
||||
html = html.replace(/^### (.+)$/gm, '<h3>$1</h3>');
|
||||
html = html.replace(/^## (.+)$/gm, '<h2>$1</h2>');
|
||||
html = html.replace(/^# (.+)$/gm, '<h1>$1</h1>');
|
||||
|
||||
// Convert bold and italic
|
||||
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
||||
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
|
||||
html = html.replace(/__(.+?)__/g, '<strong>$1</strong>');
|
||||
html = html.replace(/_(.+?)_/g, '<em>$1</em>');
|
||||
|
||||
// Convert unordered lists
|
||||
const lines = html.split('\n');
|
||||
let inList = false;
|
||||
let listType = '';
|
||||
const processedLines: string[] = [];
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const bulletMatch = line.match(/^[\*\-\+]\s+(.+)$/);
|
||||
const numberedMatch = line.match(/^\d+\.\s+(.+)$/);
|
||||
|
||||
if (bulletMatch) {
|
||||
if (!inList || listType !== 'ul') {
|
||||
if (inList) processedLines.push(listType === 'ul' ? '</ul>' : '</ol>');
|
||||
processedLines.push('<ul>');
|
||||
inList = true;
|
||||
listType = 'ul';
|
||||
}
|
||||
processedLines.push(`<li>${bulletMatch[1]}</li>`);
|
||||
} else if (numberedMatch) {
|
||||
if (!inList || listType !== 'ol') {
|
||||
if (inList) processedLines.push(listType === 'ul' ? '</ul>' : '</ol>');
|
||||
processedLines.push('<ol>');
|
||||
inList = true;
|
||||
listType = 'ol';
|
||||
}
|
||||
processedLines.push(`<li>${numberedMatch[1]}</li>`);
|
||||
} else {
|
||||
if (inList) {
|
||||
processedLines.push(listType === 'ul' ? '</ul>' : '</ol>');
|
||||
inList = false;
|
||||
listType = '';
|
||||
}
|
||||
processedLines.push(line);
|
||||
}
|
||||
}
|
||||
if (inList) {
|
||||
processedLines.push(listType === 'ul' ? '</ul>' : '</ol>');
|
||||
}
|
||||
|
||||
html = processedLines.join('\n');
|
||||
|
||||
// Convert blockquotes
|
||||
html = html.replace(/^>\s*(.+)$/gm, '<blockquote><p>$1</p></blockquote>');
|
||||
|
||||
// Convert paragraphs (lines that aren't already HTML tags)
|
||||
const finalLines = html.split('\n');
|
||||
const result: string[] = [];
|
||||
let paragraph: string[] = [];
|
||||
|
||||
for (const line of finalLines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
// Empty line - close paragraph if open
|
||||
if (paragraph.length > 0) {
|
||||
result.push(`<p>${paragraph.join('<br>')}</p>`);
|
||||
paragraph = [];
|
||||
}
|
||||
} else if (trimmed.startsWith('<h') || trimmed.startsWith('<ul') || trimmed.startsWith('<ol') ||
|
||||
trimmed.startsWith('<li') || trimmed.startsWith('</ul') || trimmed.startsWith('</ol') ||
|
||||
trimmed.startsWith('<hr') || trimmed.startsWith('<blockquote')) {
|
||||
// HTML tag - close paragraph and add tag
|
||||
if (paragraph.length > 0) {
|
||||
result.push(`<p>${paragraph.join('<br>')}</p>`);
|
||||
paragraph = [];
|
||||
}
|
||||
result.push(trimmed);
|
||||
} else {
|
||||
// Regular text - add to paragraph
|
||||
paragraph.push(trimmed);
|
||||
}
|
||||
}
|
||||
if (paragraph.length > 0) {
|
||||
result.push(`<p>${paragraph.join('<br>')}</p>`);
|
||||
}
|
||||
|
||||
return result.join('') || '<p></p>';
|
||||
}
|
||||
|
||||
// Convert HTML from TipTap back to markdown
|
||||
function htmlToMarkdown(html: string): string {
|
||||
if (!html) return '';
|
||||
|
||||
let md = html;
|
||||
|
||||
// Convert headings
|
||||
md = md.replace(/<h1[^>]*>(.*?)<\/h1>/gi, '# $1\n\n');
|
||||
md = md.replace(/<h2[^>]*>(.*?)<\/h2>/gi, '## $1\n\n');
|
||||
md = md.replace(/<h3[^>]*>(.*?)<\/h3>/gi, '### $1\n\n');
|
||||
|
||||
// Convert bold and italic
|
||||
md = md.replace(/<strong[^>]*>(.*?)<\/strong>/gi, '**$1**');
|
||||
md = md.replace(/<b[^>]*>(.*?)<\/b>/gi, '**$1**');
|
||||
md = md.replace(/<em[^>]*>(.*?)<\/em>/gi, '*$1*');
|
||||
md = md.replace(/<i[^>]*>(.*?)<\/i>/gi, '*$1*');
|
||||
|
||||
// Convert lists
|
||||
md = md.replace(/<ul[^>]*>/gi, '\n');
|
||||
md = md.replace(/<\/ul>/gi, '\n');
|
||||
md = md.replace(/<ol[^>]*>/gi, '\n');
|
||||
md = md.replace(/<\/ol>/gi, '\n');
|
||||
md = md.replace(/<li[^>]*>(.*?)<\/li>/gi, '* $1\n');
|
||||
|
||||
// Convert blockquotes
|
||||
md = md.replace(/<blockquote[^>]*><p[^>]*>(.*?)<\/p><\/blockquote>/gi, '> $1\n\n');
|
||||
md = md.replace(/<blockquote[^>]*>(.*?)<\/blockquote>/gi, '> $1\n\n');
|
||||
|
||||
// Convert horizontal rules
|
||||
md = md.replace(/<hr[^>]*\/?>/gi, '\n---\n\n');
|
||||
|
||||
// Convert paragraphs
|
||||
md = md.replace(/<p[^>]*>(.*?)<\/p>/gi, '$1\n\n');
|
||||
|
||||
// Convert line breaks
|
||||
md = md.replace(/<br[^>]*\/?>/gi, '\n');
|
||||
|
||||
// Remove any remaining HTML tags
|
||||
md = md.replace(/<[^>]+>/g, '');
|
||||
|
||||
// Decode HTML entities
|
||||
md = md.replace(/&/g, '&');
|
||||
md = md.replace(/</g, '<');
|
||||
md = md.replace(/>/g, '>');
|
||||
md = md.replace(/"/g, '"');
|
||||
md = md.replace(/'/g, "'");
|
||||
md = md.replace(/ /g, ' ');
|
||||
|
||||
// Clean up extra newlines
|
||||
md = md.replace(/\n{3,}/g, '\n\n');
|
||||
|
||||
return md.trim();
|
||||
}
|
||||
|
||||
// Toolbar button component
|
||||
function ToolbarButton({
|
||||
onClick,
|
||||
isActive = false,
|
||||
disabled = false,
|
||||
children,
|
||||
title,
|
||||
}: {
|
||||
onClick: () => void;
|
||||
isActive?: boolean;
|
||||
disabled?: boolean;
|
||||
children: React.ReactNode;
|
||||
title?: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
title={title}
|
||||
className={clsx(
|
||||
'p-2 rounded transition-colors',
|
||||
isActive
|
||||
? 'bg-primary-yellow text-primary-dark'
|
||||
: 'text-gray-600 hover:bg-gray-100',
|
||||
disabled && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// Toolbar component
|
||||
function Toolbar({ editor }: { editor: Editor | null }) {
|
||||
if (!editor) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1 p-2 border-b border-gray-200 bg-gray-50">
|
||||
{/* Text formatting */}
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||
isActive={editor.isActive('bold')}
|
||||
title="Bold (Ctrl+B)"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 4h8a4 4 0 014 4 4 4 0 01-4 4H6z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 12h9a4 4 0 014 4 4 4 0 01-4 4H6z" />
|
||||
</svg>
|
||||
</ToolbarButton>
|
||||
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||
isActive={editor.isActive('italic')}
|
||||
title="Italic (Ctrl+I)"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 4h4m-2 0v16m-4 0h8" transform="skewX(-10)" />
|
||||
</svg>
|
||||
</ToolbarButton>
|
||||
|
||||
<div className="w-px h-6 bg-gray-300 mx-1 self-center" />
|
||||
|
||||
{/* Headings */}
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
|
||||
isActive={editor.isActive('heading', { level: 1 })}
|
||||
title="Heading 1"
|
||||
>
|
||||
<span className="text-sm font-bold">H1</span>
|
||||
</ToolbarButton>
|
||||
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
||||
isActive={editor.isActive('heading', { level: 2 })}
|
||||
title="Heading 2"
|
||||
>
|
||||
<span className="text-sm font-bold">H2</span>
|
||||
</ToolbarButton>
|
||||
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
|
||||
isActive={editor.isActive('heading', { level: 3 })}
|
||||
title="Heading 3"
|
||||
>
|
||||
<span className="text-sm font-bold">H3</span>
|
||||
</ToolbarButton>
|
||||
|
||||
<div className="w-px h-6 bg-gray-300 mx-1 self-center" />
|
||||
|
||||
{/* Lists */}
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||
isActive={editor.isActive('bulletList')}
|
||||
title="Bullet List"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
<circle cx="2" cy="6" r="1" fill="currentColor" />
|
||||
<circle cx="2" cy="12" r="1" fill="currentColor" />
|
||||
<circle cx="2" cy="18" r="1" fill="currentColor" />
|
||||
</svg>
|
||||
</ToolbarButton>
|
||||
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||
isActive={editor.isActive('orderedList')}
|
||||
title="Numbered List"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 6h13M7 12h13M7 18h13" />
|
||||
<text x="1" y="8" fontSize="6" fill="currentColor">1</text>
|
||||
<text x="1" y="14" fontSize="6" fill="currentColor">2</text>
|
||||
<text x="1" y="20" fontSize="6" fill="currentColor">3</text>
|
||||
</svg>
|
||||
</ToolbarButton>
|
||||
|
||||
<div className="w-px h-6 bg-gray-300 mx-1 self-center" />
|
||||
|
||||
{/* Block elements */}
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
||||
isActive={editor.isActive('blockquote')}
|
||||
title="Quote"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||
</svg>
|
||||
</ToolbarButton>
|
||||
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().setHorizontalRule().run()}
|
||||
title="Horizontal Rule"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14" />
|
||||
</svg>
|
||||
</ToolbarButton>
|
||||
|
||||
<div className="w-px h-6 bg-gray-300 mx-1 self-center" />
|
||||
|
||||
{/* Undo/Redo */}
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().undo().run()}
|
||||
disabled={!editor.can().undo()}
|
||||
title="Undo (Ctrl+Z)"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
|
||||
</svg>
|
||||
</ToolbarButton>
|
||||
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().redo().run()}
|
||||
disabled={!editor.can().redo()}
|
||||
title="Redo (Ctrl+Shift+Z)"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 10h-10a8 8 0 00-8 8v2M21 10l-6 6m6-6l-6-6" />
|
||||
</svg>
|
||||
</ToolbarButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RichTextEditor({
|
||||
content,
|
||||
onChange,
|
||||
placeholder = 'Start writing...',
|
||||
className = '',
|
||||
editable = true,
|
||||
}: RichTextEditorProps) {
|
||||
const lastContentRef = useRef(content);
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
heading: {
|
||||
levels: [1, 2, 3],
|
||||
},
|
||||
}),
|
||||
Placeholder.configure({
|
||||
placeholder,
|
||||
}),
|
||||
],
|
||||
content: markdownToHtml(content),
|
||||
editable,
|
||||
onUpdate: ({ editor }) => {
|
||||
// Convert HTML back to markdown
|
||||
const markdown = htmlToMarkdown(editor.getHTML());
|
||||
lastContentRef.current = markdown;
|
||||
onChange(markdown);
|
||||
},
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: 'prose prose-sm max-w-none focus:outline-none min-h-[400px] p-4',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Update content when prop changes (e.g., switching languages)
|
||||
useEffect(() => {
|
||||
if (editor && content !== lastContentRef.current) {
|
||||
lastContentRef.current = content;
|
||||
const html = markdownToHtml(content);
|
||||
editor.commands.setContent(html);
|
||||
}
|
||||
}, [content, editor]);
|
||||
|
||||
return (
|
||||
<div className={clsx('border border-secondary-light-gray rounded-btn overflow-hidden bg-white', className)}>
|
||||
{editable && <Toolbar editor={editor} />}
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Read-only preview component
|
||||
export function RichTextPreview({
|
||||
content,
|
||||
className = '',
|
||||
}: {
|
||||
content: string; // Markdown content
|
||||
className?: string;
|
||||
}) {
|
||||
const editor = useEditor({
|
||||
extensions: [StarterKit],
|
||||
content: markdownToHtml(content),
|
||||
editable: false,
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: 'prose prose-sm max-w-none p-4',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (editor) {
|
||||
editor.commands.setContent(markdownToHtml(content));
|
||||
}
|
||||
}, [content, editor]);
|
||||
|
||||
return (
|
||||
<div className={clsx('border border-secondary-light-gray rounded-btn bg-gray-50', className)}>
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -967,3 +967,74 @@ export const siteSettingsApi = {
|
||||
getTimezones: () =>
|
||||
fetchApi<{ timezones: TimezoneOption[] }>('/api/site-settings/timezones'),
|
||||
};
|
||||
|
||||
// ==================== Legal Pages Types ====================
|
||||
|
||||
export interface LegalPage {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
titleEs?: string | null;
|
||||
contentText: string;
|
||||
contentTextEs?: string | null;
|
||||
contentMarkdown: string;
|
||||
contentMarkdownEs?: string | null;
|
||||
updatedAt: string;
|
||||
updatedBy?: string | null;
|
||||
createdAt: string;
|
||||
source?: 'database' | 'filesystem';
|
||||
hasEnglish?: boolean;
|
||||
hasSpanish?: boolean;
|
||||
}
|
||||
|
||||
export interface LegalPagePublic {
|
||||
id?: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
contentMarkdown: string;
|
||||
updatedAt?: string;
|
||||
source?: 'database' | 'filesystem';
|
||||
}
|
||||
|
||||
export interface LegalPageListItem {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
updatedAt: string;
|
||||
hasEnglish?: boolean;
|
||||
hasSpanish?: boolean;
|
||||
}
|
||||
|
||||
// ==================== Legal Pages API ====================
|
||||
|
||||
export const legalPagesApi = {
|
||||
// Public endpoints
|
||||
getAll: (locale?: string) =>
|
||||
fetchApi<{ pages: LegalPageListItem[] }>(`/api/legal-pages${locale ? `?locale=${locale}` : ''}`),
|
||||
|
||||
getBySlug: (slug: string, locale?: string) =>
|
||||
fetchApi<{ page: LegalPagePublic }>(`/api/legal-pages/${slug}${locale ? `?locale=${locale}` : ''}`),
|
||||
|
||||
// Admin endpoints
|
||||
getAdminList: () =>
|
||||
fetchApi<{ pages: LegalPage[] }>('/api/legal-pages/admin/list'),
|
||||
|
||||
getAdminPage: (slug: string) =>
|
||||
fetchApi<{ page: LegalPage }>(`/api/legal-pages/admin/${slug}`),
|
||||
|
||||
update: (slug: string, data: {
|
||||
contentMarkdown?: string;
|
||||
contentMarkdownEs?: string;
|
||||
title?: string;
|
||||
titleEs?: string;
|
||||
}) =>
|
||||
fetchApi<{ page: LegalPage; message: string }>(`/api/legal-pages/admin/${slug}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
|
||||
seed: () =>
|
||||
fetchApi<{ message: string; seeded: number; pages?: string[] }>('/api/legal-pages/admin/seed', {
|
||||
method: 'POST',
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -16,8 +16,11 @@ export interface LegalPageMeta {
|
||||
// Map file names to display titles
|
||||
const titleMap: Record<string, { en: string; es: string }> = {
|
||||
'privacy_policy': { en: 'Privacy Policy', es: 'Política de Privacidad' },
|
||||
'privacy-policy': { en: 'Privacy Policy', es: 'Política de Privacidad' },
|
||||
'terms_policy': { en: 'Terms & Conditions', es: 'Términos y Condiciones' },
|
||||
'terms-policy': { en: 'Terms & Conditions', es: 'Términos y Condiciones' },
|
||||
'refund_cancelation_policy': { en: 'Refund & Cancellation Policy', es: 'Política de Reembolso y Cancelación' },
|
||||
'refund-cancelation-policy': { en: 'Refund & Cancellation Policy', es: 'Política de Reembolso y Cancelación' },
|
||||
};
|
||||
|
||||
// Convert file name to URL-friendly slug
|
||||
@@ -70,8 +73,8 @@ export function getAllLegalPagesMeta(locale: string = 'en'): LegalPageMeta[] {
|
||||
});
|
||||
}
|
||||
|
||||
// Get a specific legal page content
|
||||
export function getLegalPage(slug: string, locale: string = 'en'): LegalPage | null {
|
||||
// Get a specific legal page content from filesystem (fallback)
|
||||
export function getLegalPageFromFilesystem(slug: string, locale: string = 'en'): LegalPage | null {
|
||||
const legalDir = getLegalDir();
|
||||
const fileName = slugToFileName(slug);
|
||||
const filePath = path.join(legalDir, fileName);
|
||||
@@ -82,7 +85,7 @@ export function getLegalPage(slug: string, locale: string = 'en'): LegalPage | n
|
||||
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const baseFileName = fileName.replace('.md', '');
|
||||
const titles = titleMap[baseFileName];
|
||||
const titles = titleMap[baseFileName] || titleMap[slug];
|
||||
const title = titles ? titles[locale as 'en' | 'es'] || titles.en : baseFileName.replace(/_/g, ' ');
|
||||
|
||||
// Try to extract last updated date from content
|
||||
@@ -96,3 +99,43 @@ export function getLegalPage(slug: string, locale: string = 'en'): LegalPage | n
|
||||
lastUpdated,
|
||||
};
|
||||
}
|
||||
|
||||
// Get a specific legal page content - tries API first, falls back to filesystem
|
||||
export async function getLegalPageAsync(slug: string, locale: string = 'en'): Promise<LegalPage | null> {
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL || '';
|
||||
|
||||
// Try to fetch from API with locale parameter
|
||||
try {
|
||||
const response = await fetch(`${apiUrl}/api/legal-pages/${slug}?locale=${locale}`, {
|
||||
next: { revalidate: 60 }, // Cache for 60 seconds
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const page = data.page;
|
||||
|
||||
if (page) {
|
||||
// Extract last updated from content or use updatedAt
|
||||
const lastUpdatedMatch = page.contentMarkdown?.match(/Last updated:\s*(.+)/i);
|
||||
const lastUpdated = lastUpdatedMatch ? lastUpdatedMatch[1].trim() : page.updatedAt;
|
||||
|
||||
return {
|
||||
slug: page.slug,
|
||||
title: page.title, // API already returns localized title with fallback
|
||||
content: page.contentMarkdown, // API already returns localized content with fallback
|
||||
lastUpdated,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to fetch legal page from API, falling back to filesystem:', error);
|
||||
}
|
||||
|
||||
// Fallback to filesystem
|
||||
return getLegalPageFromFilesystem(slug, locale);
|
||||
}
|
||||
|
||||
// Legacy sync function for backwards compatibility
|
||||
export function getLegalPage(slug: string, locale: string = 'en'): LegalPage | null {
|
||||
return getLegalPageFromFilesystem(slug, locale);
|
||||
}
|
||||
|
||||
36
frontend/src/lib/utils.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Format price - shows decimals only if needed
|
||||
* Uses space as thousands separator (common in Paraguay)
|
||||
* Examples:
|
||||
* 45000 PYG -> "45 000 PYG" (no decimals)
|
||||
* 41.44 PYG -> "41,44 PYG" (with decimals)
|
||||
*/
|
||||
export function formatPrice(price: number, currency: string = 'PYG'): string {
|
||||
const hasDecimals = price % 1 !== 0;
|
||||
|
||||
// Format the integer and decimal parts separately
|
||||
const intPart = Math.floor(Math.abs(price));
|
||||
const decPart = Math.abs(price) - intPart;
|
||||
|
||||
// Format integer part with space as thousands separator
|
||||
const intFormatted = intPart.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ' ');
|
||||
|
||||
// Build final string
|
||||
let result = price < 0 ? '-' : '';
|
||||
result += intFormatted;
|
||||
|
||||
// Add decimals only if present
|
||||
if (hasDecimals) {
|
||||
const decStr = decPart.toFixed(2).substring(2); // Get just the decimal digits
|
||||
result += ',' + decStr;
|
||||
}
|
||||
|
||||
return `${result} ${currency}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format currency amount (alias for formatPrice for backward compatibility)
|
||||
*/
|
||||
export function formatCurrency(amount: number, currency: string = 'PYG'): string {
|
||||
return formatPrice(amount, currency);
|
||||
}
|
||||