5 Commits

Author SHA1 Message Date
Michilis
d09c87a5a5 Fix booking flow scroll position on mobile step changes.
Scroll to the top whenever the booking step changes so users are not left at the bottom of the page after submitting the form.
2026-06-05 02:52:14 +00:00
Michilis
69768077e5 Add search and event filters to admin email logs.
Let admins find logs by recipient or subject and narrow results by event on the Email Logs tab.
2026-06-04 23:35:53 +00:00
Michilis
ecd2a7d009 fix(legal): align bullet list markers with list text
Use list-outside and left padding so ReactMarkdown and TipTap list items render markers beside text instead of on separate lines.
2026-06-04 22:58:31 +00:00
Michilis
0f7573c934 Cap event page ticket quantity at 5 per booking.
Limit the quantity stepper to five tickets or remaining spots, whichever is lower.
2026-06-04 22:53:39 +00:00
Michilis
a8b72b47b1 fix(legal): show legal pages in site language with bidirectional toggle
Wire legal pages to LanguageContext, pass locale on footer/booking links,
translate layout chrome, and restore SSR content when switching back to English.
2026-06-04 22:51:32 +00:00
11 changed files with 160 additions and 17 deletions

View File

@@ -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));

View File

@@ -228,6 +228,12 @@ export default function BookingPage() {
} }
}, [agreedToTerms, termsError]); }, [agreedToTerms, termsError]);
// Scroll to top when moving between booking steps (esp. mobile, where
// the submit button sits at the bottom of a long form)
useEffect(() => {
window.scrollTo({ top: 0, behavior: 'smooth' });
}, [step]);
const formatDate = (dateStr: string) => formatDateLong(dateStr, locale as 'en' | 'es'); const formatDate = (dateStr: string) => formatDateLong(dateStr, locale as 'en' | 'es');
const fmtTime = (dateStr: string) => formatTime(dateStr, locale as 'en' | 'es'); const fmtTime = (dateStr: string) => formatTime(dateStr, locale as 'en' | 'es');
@@ -1370,7 +1376,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 +1385,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"

View File

@@ -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));

View File

@@ -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}

View File

@@ -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)}>

View File

@@ -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 {

View File

@@ -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' }}
> >

View File

@@ -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>

View File

@@ -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",

View File

@@ -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",

View File

@@ -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}`);