Compare commits
25 Commits
622bb5171c
...
backup9
| Author | SHA1 | Date | |
|---|---|---|---|
| e0f0700398 | |||
|
|
69768077e5 | ||
|
|
ecd2a7d009 | ||
|
|
0f7573c934 | ||
|
|
a8b72b47b1 | ||
| defd9685e0 | |||
|
|
22e9254f42 | ||
|
|
2cabd8c92f | ||
| 1ed62b0d3f | |||
| 91de6df04d | |||
| a5d97d65e1 | |||
| f0128f66b0 | |||
| b33c68feb0 | |||
| 15655e3987 | |||
| d8b3864411 | |||
| 194cbd6ca8 | |||
| d5445c2282 | |||
| dcfefc8371 | |||
| b5f14335c4 | |||
| d44ac949b5 | |||
| a5e939221d | |||
| 833e3e5a9c | |||
| ba1975dd6d | |||
| 3025ef3d21 | |||
| 8564f8af83 |
@@ -1,6 +1,6 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import { db, dbGet, dbAll, emailTemplates, emailLogs, events, tickets } from '../db/index.js';
|
import { db, dbGet, dbAll, emailTemplates, emailLogs, events, tickets } from '../db/index.js';
|
||||||
import { eq, desc, and, sql } from 'drizzle-orm';
|
import { eq, desc, and, or, sql } from 'drizzle-orm';
|
||||||
import { requireAuth } from '../lib/auth.js';
|
import { requireAuth } from '../lib/auth.js';
|
||||||
import { getNow, generateId } from '../lib/utils.js';
|
import { getNow, generateId } from '../lib/utils.js';
|
||||||
import emailService from '../lib/email.js';
|
import emailService from '../lib/email.js';
|
||||||
@@ -287,6 +287,7 @@ emailsRouter.post('/preview', requireAuth(['admin', 'organizer']), async (c) =>
|
|||||||
emailsRouter.get('/logs', requireAuth(['admin', 'organizer']), async (c) => {
|
emailsRouter.get('/logs', requireAuth(['admin', 'organizer']), async (c) => {
|
||||||
const eventId = c.req.query('eventId');
|
const eventId = c.req.query('eventId');
|
||||||
const status = c.req.query('status');
|
const status = c.req.query('status');
|
||||||
|
const search = c.req.query('search');
|
||||||
const limit = parseInt(c.req.query('limit') || '50');
|
const limit = parseInt(c.req.query('limit') || '50');
|
||||||
const offset = parseInt(c.req.query('offset') || '0');
|
const offset = parseInt(c.req.query('offset') || '0');
|
||||||
|
|
||||||
@@ -299,6 +300,14 @@ emailsRouter.get('/logs', requireAuth(['admin', 'organizer']), async (c) => {
|
|||||||
if (status) {
|
if (status) {
|
||||||
conditions.push(eq((emailLogs as any).status, status));
|
conditions.push(eq((emailLogs as any).status, status));
|
||||||
}
|
}
|
||||||
|
if (search && search.trim()) {
|
||||||
|
const term = `%${search.trim().toLowerCase()}%`;
|
||||||
|
conditions.push(or(
|
||||||
|
sql`LOWER(${(emailLogs as any).recipientEmail}) LIKE ${term}`,
|
||||||
|
sql`LOWER(COALESCE(${(emailLogs as any).recipientName}, '')) LIKE ${term}`,
|
||||||
|
sql`LOWER(${(emailLogs as any).subject}) LIKE ${term}`,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
if (conditions.length > 0) {
|
if (conditions.length > 0) {
|
||||||
query = query.where(and(...conditions));
|
query = query.where(and(...conditions));
|
||||||
|
|||||||
@@ -1476,7 +1476,7 @@ ticketsRouter.post('/admin/guest', requireAuth(['admin', 'organizer', 'staff']),
|
|||||||
attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null,
|
attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null,
|
||||||
preferredLanguage: data.preferredLanguage || null,
|
preferredLanguage: data.preferredLanguage || null,
|
||||||
status: 'confirmed',
|
status: 'confirmed',
|
||||||
isGuest: true,
|
isGuest: 1,
|
||||||
qrCode,
|
qrCode,
|
||||||
checkinAt: null,
|
checkinAt: null,
|
||||||
adminNote: data.adminNote || null,
|
adminNote: data.adminNote || null,
|
||||||
|
|||||||
@@ -1370,7 +1370,7 @@ export default function BookingPage() {
|
|||||||
>
|
>
|
||||||
{t('booking.form.termsAgreePart1')}
|
{t('booking.form.termsAgreePart1')}
|
||||||
<Link
|
<Link
|
||||||
href="/legal/terms-policy"
|
href={`/legal/terms-policy${locale === 'es' ? '?locale=es' : ''}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-secondary-blue hover:text-brand-navy underline"
|
className="text-secondary-blue hover:text-brand-navy underline"
|
||||||
@@ -1379,7 +1379,7 @@ export default function BookingPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
{t('booking.form.termsAgreePart2')}
|
{t('booking.form.termsAgreePart2')}
|
||||||
<Link
|
<Link
|
||||||
href="/legal/privacy-policy"
|
href={`/legal/privacy-policy${locale === 'es' ? '?locale=es' : ''}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-secondary-blue hover:text-brand-navy underline"
|
className="text-secondary-blue hover:text-brand-navy underline"
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ interface EventDetailClientProps {
|
|||||||
initialEvent: Event;
|
initialEvent: Event;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MAX_TICKETS_PER_PERSON = 5;
|
||||||
|
|
||||||
export default function EventDetailClient({ eventId, initialEvent }: EventDetailClientProps) {
|
export default function EventDetailClient({ eventId, initialEvent }: EventDetailClientProps) {
|
||||||
const { t, locale } = useLanguage();
|
const { t, locale } = useLanguage();
|
||||||
const [event, setEvent] = useState<Event>(initialEvent);
|
const [event, setEvent] = useState<Event>(initialEvent);
|
||||||
@@ -44,7 +46,13 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail
|
|||||||
// Spots left: never negative; sold out when confirmed >= capacity
|
// Spots left: never negative; sold out when confirmed >= capacity
|
||||||
const spotsLeft = Math.max(0, event.capacity - (event.bookedCount ?? 0));
|
const spotsLeft = Math.max(0, event.capacity - (event.bookedCount ?? 0));
|
||||||
const isSoldOut = (event.bookedCount ?? 0) >= event.capacity;
|
const isSoldOut = (event.bookedCount ?? 0) >= event.capacity;
|
||||||
const maxTickets = isSoldOut ? 0 : Math.max(1, spotsLeft);
|
const maxTickets = isSoldOut ? 0 : Math.min(MAX_TICKETS_PER_PERSON, Math.max(1, spotsLeft));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (maxTickets > 0) {
|
||||||
|
setTicketQuantity((q) => Math.min(q, maxTickets));
|
||||||
|
}
|
||||||
|
}, [maxTickets]);
|
||||||
|
|
||||||
const decreaseQuantity = () => {
|
const decreaseQuantity = () => {
|
||||||
setTicketQuantity(prev => Math.max(1, prev - 1));
|
setTicketQuantity(prev => Math.max(1, prev - 1));
|
||||||
|
|||||||
@@ -68,6 +68,8 @@ export default async function LegalPage({ params, searchParams }: PageProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<LegalPageLayout
|
<LegalPageLayout
|
||||||
|
slug={resolvedParams.slug}
|
||||||
|
initialLocale={locale}
|
||||||
title={legalPage.title}
|
title={legalPage.title}
|
||||||
content={legalPage.content}
|
content={legalPage.content}
|
||||||
lastUpdated={legalPage.lastUpdated}
|
lastUpdated={legalPage.lastUpdated}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
ChevronRightIcon,
|
ChevronRightIcon,
|
||||||
XMarkIcon,
|
XMarkIcon,
|
||||||
ArrowPathIcon,
|
ArrowPathIcon,
|
||||||
|
MagnifyingGlassIcon,
|
||||||
} from '@heroicons/react/24/outline';
|
} from '@heroicons/react/24/outline';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
@@ -55,6 +56,9 @@ export default function AdminEmailsPage() {
|
|||||||
const [logsOffset, setLogsOffset] = useState(0);
|
const [logsOffset, setLogsOffset] = useState(0);
|
||||||
const [logsTotal, setLogsTotal] = useState(0);
|
const [logsTotal, setLogsTotal] = useState(0);
|
||||||
const [logsSubTab, setLogsSubTab] = useState<'all' | 'failed'>('all');
|
const [logsSubTab, setLogsSubTab] = useState<'all' | 'failed'>('all');
|
||||||
|
const [logsSearch, setLogsSearch] = useState('');
|
||||||
|
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||||
|
const [logsEventFilter, setLogsEventFilter] = useState('');
|
||||||
const [resendingLogId, setResendingLogId] = useState<string | null>(null);
|
const [resendingLogId, setResendingLogId] = useState<string | null>(null);
|
||||||
const [selectedLog, setSelectedLog] = useState<EmailLog | null>(null);
|
const [selectedLog, setSelectedLog] = useState<EmailLog | null>(null);
|
||||||
|
|
||||||
@@ -214,11 +218,20 @@ export default function AdminEmailsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handle = setTimeout(() => setDebouncedSearch(logsSearch), 300);
|
||||||
|
return () => clearTimeout(handle);
|
||||||
|
}, [logsSearch]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLogsOffset(0);
|
||||||
|
}, [debouncedSearch, logsEventFilter]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeTab === 'logs') {
|
if (activeTab === 'logs') {
|
||||||
loadLogs();
|
loadLogs();
|
||||||
}
|
}
|
||||||
}, [activeTab, logsOffset, logsSubTab]);
|
}, [activeTab, logsOffset, logsSubTab, debouncedSearch, logsEventFilter]);
|
||||||
|
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -241,6 +254,8 @@ export default function AdminEmailsPage() {
|
|||||||
limit: 20,
|
limit: 20,
|
||||||
offset: logsOffset,
|
offset: logsOffset,
|
||||||
...(logsSubTab === 'failed' ? { status: 'failed' } : {}),
|
...(logsSubTab === 'failed' ? { status: 'failed' } : {}),
|
||||||
|
...(debouncedSearch.trim() ? { search: debouncedSearch.trim() } : {}),
|
||||||
|
...(logsEventFilter ? { eventId: logsEventFilter } : {}),
|
||||||
});
|
});
|
||||||
setLogs(res.logs);
|
setLogs(res.logs);
|
||||||
setLogsTotal(res.pagination.total);
|
setLogsTotal(res.pagination.total);
|
||||||
@@ -757,6 +772,41 @@ export default function AdminEmailsPage() {
|
|||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Filters: search + event */}
|
||||||
|
<div className="flex flex-col md:flex-row gap-3 mb-4">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<MagnifyingGlassIcon className="w-5 h-5 text-gray-400 absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={logsSearch}
|
||||||
|
onChange={(e) => setLogsSearch(e.target.value)}
|
||||||
|
placeholder="Search by recipient or subject..."
|
||||||
|
className="w-full pl-10 pr-10 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||||
|
/>
|
||||||
|
{logsSearch && (
|
||||||
|
<button
|
||||||
|
onClick={() => setLogsSearch('')}
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 p-1.5 hover:bg-gray-100 rounded-btn"
|
||||||
|
title="Clear search"
|
||||||
|
>
|
||||||
|
<XMarkIcon className="w-4 h-4 text-gray-400" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
value={logsEventFilter}
|
||||||
|
onChange={(e) => setLogsEventFilter(e.target.value)}
|
||||||
|
className="w-full md:w-64 px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||||
|
>
|
||||||
|
<option value="">All events</option>
|
||||||
|
{events.map((event) => (
|
||||||
|
<option key={event.id} value={event.id}>
|
||||||
|
{event.title}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Desktop: Table */}
|
{/* Desktop: Table */}
|
||||||
<Card className="overflow-hidden hidden md:block">
|
<Card className="overflow-hidden hidden md:block">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
@@ -772,7 +822,7 @@ export default function AdminEmailsPage() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-secondary-light-gray">
|
<tbody className="divide-y divide-secondary-light-gray">
|
||||||
{logs.length === 0 ? (
|
{logs.length === 0 ? (
|
||||||
<tr><td colSpan={5} className="px-4 py-12 text-center text-gray-500 text-sm">{logsSubTab === 'failed' ? 'No failed emails' : 'No emails sent yet'}</td></tr>
|
<tr><td colSpan={5} className="px-4 py-12 text-center text-gray-500 text-sm">{(debouncedSearch.trim() || logsEventFilter) ? 'No emails match your filters' : logsSubTab === 'failed' ? 'No failed emails' : 'No emails sent yet'}</td></tr>
|
||||||
) : (
|
) : (
|
||||||
logs.map((log) => (
|
logs.map((log) => (
|
||||||
<tr key={log.id} className="hover:bg-gray-50">
|
<tr key={log.id} className="hover:bg-gray-50">
|
||||||
@@ -829,7 +879,7 @@ export default function AdminEmailsPage() {
|
|||||||
{/* Mobile: Card List */}
|
{/* Mobile: Card List */}
|
||||||
<div className="md:hidden space-y-2">
|
<div className="md:hidden space-y-2">
|
||||||
{logs.length === 0 ? (
|
{logs.length === 0 ? (
|
||||||
<div className="text-center py-10 text-gray-500 text-sm">{logsSubTab === 'failed' ? 'No failed emails' : 'No emails sent yet'}</div>
|
<div className="text-center py-10 text-gray-500 text-sm">{(debouncedSearch.trim() || logsEventFilter) ? 'No emails match your filters' : logsSubTab === 'failed' ? 'No failed emails' : 'No emails sent yet'}</div>
|
||||||
) : (
|
) : (
|
||||||
logs.map((log) => (
|
logs.map((log) => (
|
||||||
<Card key={log.id} className="p-3" onClick={() => setSelectedLog(log)}>
|
<Card key={log.id} className="p-3" onClick={() => setSelectedLog(log)}>
|
||||||
|
|||||||
@@ -1024,7 +1024,7 @@ export default function AdminEventDetailPage() {
|
|||||||
<td className="px-4 py-2.5">
|
<td className="px-4 py-2.5">
|
||||||
<div className="flex items-center gap-1 flex-wrap">
|
<div className="flex items-center gap-1 flex-wrap">
|
||||||
{getStatusBadge(ticket.status, true)}
|
{getStatusBadge(ticket.status, true)}
|
||||||
{ticket.isGuest && (
|
{!!ticket.isGuest && (
|
||||||
<span className="px-1.5 py-0.5 text-[10px] rounded-full bg-amber-100 text-amber-700 font-medium">Guest</span>
|
<span className="px-1.5 py-0.5 text-[10px] rounded-full bg-amber-100 text-amber-700 font-medium">Guest</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1089,7 +1089,7 @@ export default function AdminEventDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 flex-shrink-0 flex-wrap justify-end">
|
<div className="flex items-center gap-1.5 flex-shrink-0 flex-wrap justify-end">
|
||||||
{getStatusBadge(ticket.status, true)}
|
{getStatusBadge(ticket.status, true)}
|
||||||
{ticket.isGuest && (
|
{!!ticket.isGuest && (
|
||||||
<span className="px-1.5 py-0.5 text-[10px] rounded-full bg-amber-100 text-amber-700 font-medium">Guest</span>
|
<span className="px-1.5 py-0.5 text-[10px] rounded-full bg-amber-100 text-amber-700 font-medium">Guest</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -127,11 +127,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ProseMirror ul {
|
.ProseMirror ul {
|
||||||
@apply list-disc list-inside my-3;
|
@apply list-disc list-outside pl-6 my-3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ProseMirror ol {
|
.ProseMirror ol {
|
||||||
@apply list-decimal list-inside my-3;
|
@apply list-decimal list-outside pl-6 my-3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ProseMirror li {
|
.ProseMirror li {
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ export default function Footer() {
|
|||||||
{legalLinks.map((link) => (
|
{legalLinks.map((link) => (
|
||||||
<Link
|
<Link
|
||||||
key={link.slug}
|
key={link.slug}
|
||||||
href={`/legal/${link.slug}`}
|
href={`/legal/${link.slug}${locale === 'es' ? '?locale=es' : ''}`}
|
||||||
className="hover:opacity-70 transition-colors text-sm"
|
className="hover:opacity-70 transition-colors text-sm"
|
||||||
style={{ color: '#002F44' }}
|
style={{ color: '#002F44' }}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,17 +1,74 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { ArrowLeftIcon } from '@heroicons/react/24/outline';
|
import { ArrowLeftIcon } from '@heroicons/react/24/outline';
|
||||||
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
|
import { legalPagesApi } from '@/lib/api';
|
||||||
|
|
||||||
interface LegalPageLayoutProps {
|
interface LegalPageLayoutProps {
|
||||||
|
slug: string;
|
||||||
|
initialLocale: 'en' | 'es';
|
||||||
title: string;
|
title: string;
|
||||||
content: string;
|
content: string;
|
||||||
lastUpdated?: string;
|
lastUpdated?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function LegalPageLayout({ title, content, lastUpdated }: LegalPageLayoutProps) {
|
function extractLastUpdated(contentMarkdown: string, updatedAt?: string): string | undefined {
|
||||||
|
const match = contentMarkdown?.match(/Last updated:\s*(.+)/i);
|
||||||
|
return match ? match[1].trim() : updatedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LegalPageLayout({
|
||||||
|
slug,
|
||||||
|
initialLocale,
|
||||||
|
title: initialTitle,
|
||||||
|
content: initialContent,
|
||||||
|
lastUpdated: initialLastUpdated,
|
||||||
|
}: LegalPageLayoutProps) {
|
||||||
|
const { locale, t } = useLanguage();
|
||||||
|
const [title, setTitle] = useState(initialTitle);
|
||||||
|
const [content, setContent] = useState(initialContent);
|
||||||
|
const [lastUpdated, setLastUpdated] = useState(initialLastUpdated);
|
||||||
|
const [loadedLocale, setLoadedLocale] = useState(initialLocale);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (locale === loadedLocale) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returning to the server-rendered language: restore SSR content without a fetch
|
||||||
|
if (locale === initialLocale) {
|
||||||
|
setTitle(initialTitle);
|
||||||
|
setContent(initialContent);
|
||||||
|
setLastUpdated(initialLastUpdated);
|
||||||
|
setLoadedLocale(initialLocale);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
legalPagesApi
|
||||||
|
.getBySlug(slug, locale)
|
||||||
|
.then(({ page }) => {
|
||||||
|
if (cancelled || !page) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTitle(page.title);
|
||||||
|
setContent(page.contentMarkdown);
|
||||||
|
setLastUpdated(extractLastUpdated(page.contentMarkdown, page.updatedAt));
|
||||||
|
setLoadedLocale(locale);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Keep the server-rendered content if the re-fetch fails
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [locale, loadedLocale, initialLocale, slug, initialTitle, initialContent, initialLastUpdated]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="section-padding">
|
<div className="section-padding">
|
||||||
<div className="container-page max-w-4xl">
|
<div className="container-page max-w-4xl">
|
||||||
@@ -21,7 +78,7 @@ export default function LegalPageLayout({ title, content, lastUpdated }: LegalPa
|
|||||||
className="inline-flex items-center text-gray-600 hover:text-primary-dark transition-colors mb-8"
|
className="inline-flex items-center text-gray-600 hover:text-primary-dark transition-colors mb-8"
|
||||||
>
|
>
|
||||||
<ArrowLeftIcon className="w-4 h-4 mr-2" />
|
<ArrowLeftIcon className="w-4 h-4 mr-2" />
|
||||||
Back to Home
|
{t('legalPage.backToHome')}
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* Title */}
|
{/* Title */}
|
||||||
@@ -31,7 +88,7 @@ export default function LegalPageLayout({ title, content, lastUpdated }: LegalPa
|
|||||||
</h1>
|
</h1>
|
||||||
{lastUpdated && lastUpdated !== '[Insert Date]' && (
|
{lastUpdated && lastUpdated !== '[Insert Date]' && (
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-gray-500">
|
||||||
Last updated: {lastUpdated}
|
{t('legalPage.lastUpdated', { date: lastUpdated })}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -70,12 +127,12 @@ export default function LegalPageLayout({ title, content, lastUpdated }: LegalPa
|
|||||||
),
|
),
|
||||||
// Style lists
|
// Style lists
|
||||||
ul: ({ children }) => (
|
ul: ({ children }) => (
|
||||||
<ul className="list-disc list-inside space-y-2 mb-4 text-gray-700 ml-4">
|
<ul className="list-disc list-outside space-y-2 mb-4 text-gray-700 pl-6">
|
||||||
{children}
|
{children}
|
||||||
</ul>
|
</ul>
|
||||||
),
|
),
|
||||||
ol: ({ children }) => (
|
ol: ({ children }) => (
|
||||||
<ol className="list-decimal list-inside space-y-2 mb-4 text-gray-700 ml-4">
|
<ol className="list-decimal list-outside space-y-2 mb-4 text-gray-700 pl-6">
|
||||||
{children}
|
{children}
|
||||||
</ol>
|
</ol>
|
||||||
),
|
),
|
||||||
@@ -182,7 +239,7 @@ export default function LegalPageLayout({ title, content, lastUpdated }: LegalPa
|
|||||||
onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
|
onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
|
||||||
className="text-gray-500 hover:text-primary-dark transition-colors text-sm"
|
className="text-gray-500 hover:text-primary-dark transition-colors text-sm"
|
||||||
>
|
>
|
||||||
Back to top
|
{t('legalPage.backToTop')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -322,6 +322,11 @@
|
|||||||
"refund": "Refund Policy"
|
"refund": "Refund Policy"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"legalPage": {
|
||||||
|
"backToHome": "Back to Home",
|
||||||
|
"lastUpdated": "Last updated: {date}",
|
||||||
|
"backToTop": "Back to top"
|
||||||
|
},
|
||||||
"linktree": {
|
"linktree": {
|
||||||
"tagline": "Language Exchange Community",
|
"tagline": "Language Exchange Community",
|
||||||
"nextEvent": "Next Event",
|
"nextEvent": "Next Event",
|
||||||
|
|||||||
@@ -322,6 +322,11 @@
|
|||||||
"refund": "Política de Reembolso"
|
"refund": "Política de Reembolso"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"legalPage": {
|
||||||
|
"backToHome": "Volver al inicio",
|
||||||
|
"lastUpdated": "Última actualización: {date}",
|
||||||
|
"backToTop": "Volver arriba"
|
||||||
|
},
|
||||||
"linktree": {
|
"linktree": {
|
||||||
"tagline": "Comunidad de Intercambio de Idiomas",
|
"tagline": "Comunidad de Intercambio de Idiomas",
|
||||||
"nextEvent": "Próximo Evento",
|
"nextEvent": "Próximo Evento",
|
||||||
|
|||||||
@@ -496,10 +496,11 @@ export const emailsApi = {
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
// Logs
|
// Logs
|
||||||
getLogs: (params?: { eventId?: string; status?: string; limit?: number; offset?: number }) => {
|
getLogs: (params?: { eventId?: string; status?: string; search?: string; limit?: number; offset?: number }) => {
|
||||||
const query = new URLSearchParams();
|
const query = new URLSearchParams();
|
||||||
if (params?.eventId) query.set('eventId', params.eventId);
|
if (params?.eventId) query.set('eventId', params.eventId);
|
||||||
if (params?.status) query.set('status', params.status);
|
if (params?.status) query.set('status', params.status);
|
||||||
|
if (params?.search) query.set('search', params.search);
|
||||||
if (params?.limit) query.set('limit', params.limit.toString());
|
if (params?.limit) query.set('limit', params.limit.toString());
|
||||||
if (params?.offset) query.set('offset', params.offset.toString());
|
if (params?.offset) query.set('offset', params.offset.toString());
|
||||||
return fetchApi<{ logs: EmailLog[]; pagination: Pagination }>(`/api/emails/logs?${query}`);
|
return fetchApi<{ logs: EmailLog[]; pagination: Pagination }>(`/api/emails/logs?${query}`);
|
||||||
|
|||||||
Reference in New Issue
Block a user