diff --git a/backend/src/routes/emails.ts b/backend/src/routes/emails.ts index 91f8406..281e900 100644 --- a/backend/src/routes/emails.ts +++ b/backend/src/routes/emails.ts @@ -1,6 +1,6 @@ import { Hono } from 'hono'; 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 { getNow, generateId } from '../lib/utils.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) => { const eventId = c.req.query('eventId'); const status = c.req.query('status'); + const search = c.req.query('search'); const limit = parseInt(c.req.query('limit') || '50'); const offset = parseInt(c.req.query('offset') || '0'); @@ -299,6 +300,14 @@ emailsRouter.get('/logs', requireAuth(['admin', 'organizer']), async (c) => { if (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) { query = query.where(and(...conditions)); diff --git a/frontend/src/app/(public)/book/[eventId]/page.tsx b/frontend/src/app/(public)/book/[eventId]/page.tsx index 959054c..9204ea8 100644 --- a/frontend/src/app/(public)/book/[eventId]/page.tsx +++ b/frontend/src/app/(public)/book/[eventId]/page.tsx @@ -1370,7 +1370,7 @@ export default function BookingPage() { > {t('booking.form.termsAgreePart1')} {t('booking.form.termsAgreePart2')} (initialEvent); @@ -44,7 +46,13 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail // Spots left: never negative; sold out when confirmed >= capacity const spotsLeft = Math.max(0, event.capacity - (event.bookedCount ?? 0)); 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 = () => { setTicketQuantity(prev => Math.max(1, prev - 1)); diff --git a/frontend/src/app/(public)/legal/[slug]/page.tsx b/frontend/src/app/(public)/legal/[slug]/page.tsx index d6784c7..e0e3d0c 100644 --- a/frontend/src/app/(public)/legal/[slug]/page.tsx +++ b/frontend/src/app/(public)/legal/[slug]/page.tsx @@ -68,6 +68,8 @@ export default async function LegalPage({ params, searchParams }: PageProps) { return ( ('all'); + const [logsSearch, setLogsSearch] = useState(''); + const [debouncedSearch, setDebouncedSearch] = useState(''); + const [logsEventFilter, setLogsEventFilter] = useState(''); const [resendingLogId, setResendingLogId] = useState(null); const [selectedLog, setSelectedLog] = useState(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(() => { if (activeTab === 'logs') { loadLogs(); } - }, [activeTab, logsOffset, logsSubTab]); + }, [activeTab, logsOffset, logsSubTab, debouncedSearch, logsEventFilter]); const loadData = async () => { try { @@ -241,6 +254,8 @@ export default function AdminEmailsPage() { limit: 20, offset: logsOffset, ...(logsSubTab === 'failed' ? { status: 'failed' } : {}), + ...(debouncedSearch.trim() ? { search: debouncedSearch.trim() } : {}), + ...(logsEventFilter ? { eventId: logsEventFilter } : {}), }); setLogs(res.logs); setLogsTotal(res.pagination.total); @@ -757,6 +772,41 @@ export default function AdminEmailsPage() { + {/* Filters: search + event */} +
+
+ + 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 && ( + + )} +
+ +
+ {/* Desktop: Table */}
@@ -772,7 +822,7 @@ export default function AdminEmailsPage() { {logs.length === 0 ? ( - {logsSubTab === 'failed' ? 'No failed emails' : 'No emails sent yet'} + {(debouncedSearch.trim() || logsEventFilter) ? 'No emails match your filters' : logsSubTab === 'failed' ? 'No failed emails' : 'No emails sent yet'} ) : ( logs.map((log) => ( @@ -829,7 +879,7 @@ export default function AdminEmailsPage() { {/* Mobile: Card List */}
{logs.length === 0 ? ( -
{logsSubTab === 'failed' ? 'No failed emails' : 'No emails sent yet'}
+
{(debouncedSearch.trim() || logsEventFilter) ? 'No emails match your filters' : logsSubTab === 'failed' ? 'No failed emails' : 'No emails sent yet'}
) : ( logs.map((log) => ( setSelectedLog(log)}> diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index ad88586..067456f 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -127,11 +127,11 @@ } .ProseMirror ul { - @apply list-disc list-inside my-3; + @apply list-disc list-outside pl-6 my-3; } .ProseMirror ol { - @apply list-decimal list-inside my-3; + @apply list-decimal list-outside pl-6 my-3; } .ProseMirror li { diff --git a/frontend/src/components/layout/Footer.tsx b/frontend/src/components/layout/Footer.tsx index bece324..ffd7e01 100644 --- a/frontend/src/components/layout/Footer.tsx +++ b/frontend/src/components/layout/Footer.tsx @@ -108,7 +108,7 @@ export default function Footer() { {legalLinks.map((link) => ( diff --git a/frontend/src/components/layout/LegalPageLayout.tsx b/frontend/src/components/layout/LegalPageLayout.tsx index 8d3de09..5e05400 100644 --- a/frontend/src/components/layout/LegalPageLayout.tsx +++ b/frontend/src/components/layout/LegalPageLayout.tsx @@ -1,17 +1,74 @@ 'use client'; +import { useEffect, useState } from 'react'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import Link from 'next/link'; import { ArrowLeftIcon } from '@heroicons/react/24/outline'; +import { useLanguage } from '@/context/LanguageContext'; +import { legalPagesApi } from '@/lib/api'; interface LegalPageLayoutProps { + slug: string; + initialLocale: 'en' | 'es'; title: string; content: 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 (
@@ -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" > - Back to Home + {t('legalPage.backToHome')} {/* Title */} @@ -31,7 +88,7 @@ export default function LegalPageLayout({ title, content, lastUpdated }: LegalPa {lastUpdated && lastUpdated !== '[Insert Date]' && (

- Last updated: {lastUpdated} + {t('legalPage.lastUpdated', { date: lastUpdated })}

)}
@@ -70,12 +127,12 @@ export default function LegalPageLayout({ title, content, lastUpdated }: LegalPa ), // Style lists ul: ({ children }) => ( -
    +
      {children}
    ), ol: ({ children }) => ( -
      +
        {children}
      ), @@ -182,7 +239,7 @@ export default function LegalPageLayout({ title, content, lastUpdated }: LegalPa onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })} className="text-gray-500 hover:text-primary-dark transition-colors text-sm" > - Back to top + {t('legalPage.backToTop')}
diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 6cd1c1e..d728c65 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -322,6 +322,11 @@ "refund": "Refund Policy" } }, + "legalPage": { + "backToHome": "Back to Home", + "lastUpdated": "Last updated: {date}", + "backToTop": "Back to top" + }, "linktree": { "tagline": "Language Exchange Community", "nextEvent": "Next Event", diff --git a/frontend/src/i18n/locales/es.json b/frontend/src/i18n/locales/es.json index 384c080..c85409a 100644 --- a/frontend/src/i18n/locales/es.json +++ b/frontend/src/i18n/locales/es.json @@ -322,6 +322,11 @@ "refund": "Política de Reembolso" } }, + "legalPage": { + "backToHome": "Volver al inicio", + "lastUpdated": "Última actualización: {date}", + "backToTop": "Volver arriba" + }, "linktree": { "tagline": "Comunidad de Intercambio de Idiomas", "nextEvent": "Próximo Evento", diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index e903bc5..9a1b790 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -496,10 +496,11 @@ export const emailsApi = { }), // 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(); if (params?.eventId) query.set('eventId', params.eventId); 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?.offset) query.set('offset', params.offset.toString()); return fetchApi<{ logs: EmailLog[]; pagination: Pagination }>(`/api/emails/logs?${query}`);