18 Commits
Author SHA1 Message Date
Michilis defd9685e0 Merge pull request 'dev' (#18) from dev into main
Reviewed-on: #18
2026-04-27 20:42:36 +00:00
Michilis 1ed62b0d3f Merge pull request 'Fix db:export ENOBUFS by streaming pg_dump output to file' (#17) from dev into main
Reviewed-on: #17
2026-03-12 19:18:57 +00:00
Michilis 91de6df04d Merge pull request 'feat(emails): add re-send for all emails, failed tab, and resend indicators' (#16) from dev into main
Reviewed-on: #16
2026-03-12 19:14:36 +00:00
Michilis a5d97d65e1 Merge pull request 'Admin: stats privacy toggle, clickable event rows, fix payment method display' (#15) from dev into main
Reviewed-on: #15
2026-03-10 01:14:36 +00:00
Michilis f0128f66b0 Merge pull request 'Bug fixes and improvements' (#14) from dev into main
Reviewed-on: #14
2026-03-07 22:53:13 +00:00
Michilis b33c68feb0 Merge pull request 'dev' (#13) from dev into main
Reviewed-on: #13
2026-02-19 02:23:19 +00:00
Michilis 15655e3987 Merge pull request 'dev' (#12) from dev into main
Reviewed-on: #12
2026-02-16 23:11:52 +00:00
Michilis d8b3864411 Merge pull request 'Fix stale featured event on homepage: revalidate cache when featured event changes' (#11) from dev into main
Reviewed-on: #11
2026-02-16 22:44:19 +00:00
Michilis 194cbd6ca8 Merge pull request 'Scanner: close button on valid ticket, camera lifecycle fix' (#10) from dev into main
Reviewed-on: #10
2026-02-14 19:04:42 +00:00
Michilis d5445c2282 Merge pull request 'Admin event page: redesign UI, export endpoints, mobile fixes' (#9) from dev into main
Reviewed-on: #9
2026-02-14 18:38:57 +00:00
Michilis dcfefc8371 Merge pull request 'feat(admin): add event attendees export (CSV) with status filters' (#8) from dev into main
Reviewed-on: #8
2026-02-14 05:28:24 +00:00
Michilis b5f14335c4 Merge pull request 'Mobile scanner redesign + backend live search' (#7) from dev into main
Reviewed-on: #7
2026-02-14 04:28:44 +00:00
Michilis d44ac949b5 Merge pull request 'Email queue + async sending; legal settings and placeholders' (#6) from dev into main
Reviewed-on: #6
2026-02-12 21:04:58 +00:00
Michilis a5e939221d Merge pull request 'dev' (#5) from dev into main
Reviewed-on: #5
2026-02-12 07:56:37 +00:00
Michilis 833e3e5a9c Merge pull request 'Fix llms.txt event times: format in America/Asuncion timezone' (#4) from dev into main
Reviewed-on: #4
2026-02-12 06:28:51 +00:00
Michilis ba1975dd6d Merge pull request 'dev' (#3) from dev into main
Reviewed-on: #3
2026-02-12 04:55:39 +00:00
Michilis 3025ef3d21 Merge pull request 'dev' (#2) from dev into main
Reviewed-on: #2
2026-02-12 03:19:06 +00:00
Michilis 8564f8af83 Merge pull request 'dev' (#1) from dev into main
Reviewed-on: #1
2026-02-12 02:18:08 +00:00
7 changed files with 8 additions and 85 deletions
@@ -1370,7 +1370,7 @@ export default function BookingPage() {
> >
{t('booking.form.termsAgreePart1')} {t('booking.form.termsAgreePart1')}
<Link <Link
href={`/legal/terms-policy${locale === 'es' ? '?locale=es' : ''}`} href="/legal/terms-policy"
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${locale === 'es' ? '?locale=es' : ''}`} href="/legal/privacy-policy"
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,8 +23,6 @@ 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);
@@ -46,13 +44,7 @@ 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.min(MAX_TICKETS_PER_PERSON, Math.max(1, spotsLeft)); const maxTickets = isSoldOut ? 0 : 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,8 +68,6 @@ 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}
+1 -1
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}${locale === 'es' ? '?locale=es' : ''}`} href={`/legal/${link.slug}`}
className="hover:opacity-70 transition-colors text-sm" className="hover:opacity-70 transition-colors text-sm"
style={{ color: '#002F44' }} style={{ color: '#002F44' }}
> >
@@ -1,74 +1,17 @@
'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;
} }
function extractLastUpdated(contentMarkdown: string, updatedAt?: string): string | undefined { export default function LegalPageLayout({ title, content, lastUpdated }: LegalPageLayoutProps) {
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">
@@ -78,7 +21,7 @@ export default function LegalPageLayout({
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" />
{t('legalPage.backToHome')} Back to Home
</Link> </Link>
{/* Title */} {/* Title */}
@@ -88,7 +31,7 @@ export default function LegalPageLayout({
</h1> </h1>
{lastUpdated && lastUpdated !== '[Insert Date]' && ( {lastUpdated && lastUpdated !== '[Insert Date]' && (
<p className="text-sm text-gray-500"> <p className="text-sm text-gray-500">
{t('legalPage.lastUpdated', { date: lastUpdated })} Last updated: {lastUpdated}
</p> </p>
)} )}
</div> </div>
@@ -239,7 +182,7 @@ export default function LegalPageLayout({
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"
> >
{t('legalPage.backToTop')} Back to top
</button> </button>
</div> </div>
</div> </div>
-5
View File
@@ -322,11 +322,6 @@
"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",
-5
View File
@@ -322,11 +322,6 @@
"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",