Security recovery: hold sweep, dashboard updates, and admin fixes.

This commit is contained in:
Michilis
2026-07-01 05:51:38 +00:00
parent 38526f17b5
commit cacc52ec24
45 changed files with 1452 additions and 474 deletions
@@ -3,6 +3,9 @@ import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Join Our Language Exchange Community',
description: 'Connect with English and Spanish speakers in Asunción. Join our WhatsApp group, follow us on Instagram, and be part of the Spanglish community.',
alternates: {
canonical: '/community',
},
openGraph: {
title: 'Join Our Language Exchange Community Spanglish',
description: 'Connect with English and Spanish speakers in Asunción. Join our WhatsApp group, follow us on Instagram, and be part of the Spanglish community.',
@@ -3,6 +3,9 @@ import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Contact Us',
description: 'Get in touch with Spanglish. Questions about language exchange events in Asunción? We are here to help.',
alternates: {
canonical: '/contact',
},
openGraph: {
title: 'Contact Us Spanglish',
description: 'Get in touch with Spanglish. Questions about language exchange events in Asunción? We are here to help.',
@@ -22,6 +22,7 @@ import {
groupByBooking,
isUnpaid,
isAwaitingApproval,
isOnHold,
ticketAmount,
shareTicket,
isToday,
@@ -57,11 +58,12 @@ export default function OverviewTab({
// awaiting approval), ordered by soonest event.
const attentionTicket = useMemo(() => {
const candidates = activeTickets.filter(
(t) => isUnpaid(t) || isAwaitingApproval(t)
(t) => isUnpaid(t) || isOnHold(t) || isAwaitingApproval(t)
);
const priority = (t: UserTicket) => (isUnpaid(t) ? 0 : isOnHold(t) ? 1 : 2);
candidates.sort((a, b) => {
const aUnpaid = isUnpaid(a) ? 0 : 1;
const bUnpaid = isUnpaid(b) ? 0 : 1;
const aUnpaid = priority(a);
const bUnpaid = priority(b);
if (aUnpaid !== bUnpaid) return aUnpaid - bUnpaid;
const aStart = a.event?.startDatetime
? parseDate(a.event.startDatetime).getTime()
@@ -190,7 +192,19 @@ export default function OverviewTab({
</div>
<div className="flex items-center gap-3">
<StatusPill status={status} locale={locale} />
{isUnpaid(t) ? (
{isOnHold(t) ? (
<PayActions
ticketId={t.id}
amount={amount}
currency={currency}
destination={title}
locale={locale}
onPaid={onChange}
layout="inline"
size="sm"
onHold
/>
) : isUnpaid(t) ? (
<PayActions
ticketId={t.id}
amount={amount}
@@ -346,7 +360,19 @@ function HeroCard({
)}
{/* Smart primary action. */}
{isUnpaid(ticket) ? (
{isOnHold(ticket) ? (
<PayActions
ticketId={ticket.id}
amount={amount}
currency={currency}
destination={title}
locale={locale}
onPaid={onChange}
layout="inline"
size="md"
onHold
/>
) : isUnpaid(ticket) ? (
<PayActions
ticketId={ticket.id}
amount={amount}
@@ -106,6 +106,7 @@ export default function PaymentsTab({ payments, language: locale, onChange }: Pa
: payment.event?.title) || (locale === 'es' ? 'Evento' : 'Event');
const canMarkPaid =
payment.status === 'pending' && isManualProvider(payment.provider);
const canRebook = payment.status === 'on_hold';
return (
<Card key={payment.id} className="p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
@@ -131,7 +132,7 @@ export default function PaymentsTab({ payments, language: locale, onChange }: Pa
</div>
<div className="flex flex-shrink-0 flex-col items-stretch gap-2 sm:items-end">
{canMarkPaid && (
{canRebook ? (
<PayActions
ticketId={payment.ticketId}
amount={Number(payment.amount)}
@@ -141,7 +142,21 @@ export default function PaymentsTab({ payments, language: locale, onChange }: Pa
onPaid={onChange}
layout="inline"
size="sm"
onHold
/>
) : (
canMarkPaid && (
<PayActions
ticketId={payment.ticketId}
amount={Number(payment.amount)}
currency={payment.currency}
destination={eventTitle}
locale={locale}
onPaid={onChange}
layout="inline"
size="sm"
/>
)
)}
{payment.invoice && (
<a
@@ -16,9 +16,11 @@ import { StatusPill, deriveTicketStatus } from './_shared/status';
import {
groupByBooking,
isUnpaid,
isOnHold,
ticketAmount,
ticketPdfUrl,
pyg,
HOLD_THRESHOLD_HOURS,
type BookingGroup,
} from './_shared/helpers';
import PayActions from './_shared/PayActions';
@@ -176,12 +178,31 @@ function BookingCard({
<p className="text-gray-500">
{pyg(amount, currency)}
</p>
{isOnHold(ticket) && (
<p className="text-slate-600">
{locale === 'es'
? `Tu lugar fue liberado porque el pago no se confirmó dentro de ${HOLD_THRESHOLD_HOURS} horas.`
: `Your spot has been released because payment was not confirmed within ${HOLD_THRESHOLD_HOURS} hours.`}
</p>
)}
</div>
</div>
{/* Actions */}
<div className="flex flex-col gap-2 sm:w-44">
{isUnpaid(ticket) ? (
{isOnHold(ticket) ? (
<PayActions
ticketId={ticket.id}
amount={amount}
currency={currency}
destination={title}
locale={locale}
onPaid={onChange}
layout="stack"
size="sm"
onHold
/>
) : isUnpaid(ticket) ? (
<PayActions
ticketId={ticket.id}
amount={amount}
@@ -13,6 +13,8 @@ import {
ticketAmount,
pyg,
isAwaitingApproval,
isOnHold,
HOLD_THRESHOLD_HOURS,
} from './helpers';
/**
@@ -38,6 +40,41 @@ export default function AttentionBanner({
: ticket.event?.title) || (locale === 'es' ? 'tu evento' : 'your event');
const { amount, currency } = ticketAmount(ticket);
if (isOnHold(ticket)) {
return (
<div className="rounded-card border border-slate-200 bg-slate-50 p-4">
<div className="flex items-start gap-3">
<ExclamationTriangleIcon className="mt-0.5 h-6 w-6 flex-shrink-0 text-slate-500" />
<div className="min-w-0 flex-1">
<p className="font-semibold text-slate-800">
{locale === 'es'
? `Tu lugar para ${eventTitle} fue liberado`
: `Your spot for ${eventTitle} was released`}
</p>
<p className="mt-0.5 text-sm text-slate-600">
{locale === 'es'
? `El pago no se confirmó dentro de ${HOLD_THRESHOLD_HOURS} horas.`
: `Payment was not confirmed within ${HOLD_THRESHOLD_HOURS} hours.`}
</p>
</div>
</div>
<div className="mt-3 sm:pl-9">
<PayActions
ticketId={ticket.id}
amount={amount}
currency={currency}
destination={eventTitle}
locale={locale}
onPaid={onChange}
layout="inline"
size="sm"
onHold
/>
</div>
</div>
);
}
if (isAwaitingApproval(ticket)) {
return (
<div className="flex items-start gap-3 rounded-card border border-amber-200 bg-amber-50 p-4">
@@ -23,6 +23,11 @@ interface PayActionsProps {
/** Stack the two buttons full-width (cards) vs inline (rows). */
layout?: 'stack' | 'inline';
className?: string;
/**
* The booking's spot was released after the hold threshold passed. Hides the
* "Pay now" link (money was already sent) and labels the retry "Rebook".
*/
onHold?: boolean;
}
/**
@@ -41,6 +46,7 @@ export default function PayActions({
size = 'sm',
layout = 'stack',
className,
onHold = false,
}: PayActionsProps) {
const { t } = useLanguage();
const [confirming, setConfirming] = useState(false);
@@ -76,18 +82,22 @@ export default function PayActions({
return (
<>
<div className={`${containerClass} ${className || ''}`}>
<Link href={`/booking/${ticketId}`} className={btnWidth}>
<Button size={size} className={btnWidth}>
{locale === 'es' ? 'Pagar ahora' : 'Pay now'}
</Button>
</Link>
{!onHold && (
<Link href={`/booking/${ticketId}`} className={btnWidth}>
<Button size={size} className={btnWidth}>
{locale === 'es' ? 'Pagar ahora' : 'Pay now'}
</Button>
</Link>
)}
<Button
variant="outline"
variant={onHold ? 'primary' : 'outline'}
size={size}
className={btnWidth}
onClick={() => setConfirming(true)}
>
{locale === 'es' ? 'Ya pagué' : "I've paid"}
{onHold
? (locale === 'es' ? 'Reservar de nuevo' : 'Rebook')
: (locale === 'es' ? 'Ya pagué' : "I've paid")}
</Button>
</div>
@@ -108,16 +118,24 @@ export default function PayActions({
</div>
</div>
<h3 className="mb-2 text-center text-lg font-semibold text-primary-dark">
{locale === 'es' ? '¿Confirmar pago?' : 'Confirm payment?'}
{onHold
? (locale === 'es' ? '¿Reservar de nuevo?' : 'Rebook your spot?')
: (locale === 'es' ? '¿Confirmar pago?' : 'Confirm payment?')}
</h3>
<p className="mb-6 text-center text-sm text-gray-600">
{locale === 'es'
? `¿Ya enviaste los ${pyg(amount, currency)} para ${destination}?`
: `Did you already send the ${pyg(amount, currency)} for ${destination}?`}
{onHold
? (locale === 'es'
? `Intentaremos reservar tu lugar de nuevo para ${destination}.`
: `We'll try to re-reserve your spot for ${destination}.`)
: (locale === 'es'
? `¿Ya enviaste los ${pyg(amount, currency)} para ${destination}?`
: `Did you already send the ${pyg(amount, currency)} for ${destination}?`)}
</p>
<div className="flex flex-col gap-2">
<Button isLoading={marking} className="w-full" onClick={handleConfirm}>
{locale === 'es' ? 'Sí, ya pagué' : "Yes, I've paid"}
{onHold
? (locale === 'es' ? 'Sí, reservar de nuevo' : 'Yes, rebook')
: (locale === 'es' ? 'Sí, ya pagué' : "Yes, I've paid")}
</Button>
<Button
variant="ghost"
@@ -6,6 +6,16 @@ import { formatPrice, parseDate, EVENT_TIMEZONE } from '@/lib/utils';
// ticket was created, capped at the event start time.
export const PAYMENT_HOLD_HOURS = 24;
// Hours a pending-approval booking (payment already marked as sent) can wait
// for admin review before the auto-hold sweep releases the spot. Mirrors the
// backend's HOLD_THRESHOLD_HOURS env default - keep these in sync.
export const HOLD_THRESHOLD_HOURS = 72;
/** True once a booking has been auto-released after the approval hold window. */
export function isOnHold(ticket: Pick<UserTicket, 'status'> & { payment?: { status?: string } | null }): boolean {
return ticket.status === 'on_hold' || ticket.payment?.status === 'on_hold';
}
/** Currency is Guarani with no decimals, e.g. "21 PYG". */
export function pyg(amount: number, currency: string = 'PYG'): string {
return formatPrice(Number(amount) || 0, currency || 'PYG');
@@ -9,12 +9,14 @@ import type { UserTicket, Payment } from '@/lib/api';
// unpaid -> pale red
// attended -> pale blue (replaces the raw "checked_in" value)
// cancelled -> pale gray
// onHold -> pale slate (spot released after the payment deadline passed)
export type DashStatus =
| 'confirmed'
| 'awaiting'
| 'unpaid'
| 'attended'
| 'cancelled';
| 'cancelled'
| 'onHold';
/**
* Collapse a ticket status + payment status into a single user-facing status.
@@ -26,6 +28,7 @@ export function deriveTicketStatus(
): DashStatus {
if (ticketStatus === 'checked_in') return 'attended';
if (ticketStatus === 'cancelled') return 'cancelled';
if (ticketStatus === 'on_hold' || paymentStatus === 'on_hold') return 'onHold';
if (paymentStatus === 'paid' || ticketStatus === 'confirmed') return 'confirmed';
if (paymentStatus === 'pending_approval') return 'awaiting';
return 'unpaid';
@@ -35,6 +38,7 @@ export function deriveTicketStatus(
export function derivePaymentStatus(paymentStatus?: string): DashStatus {
if (paymentStatus === 'paid') return 'confirmed';
if (paymentStatus === 'pending_approval') return 'awaiting';
if (paymentStatus === 'on_hold') return 'onHold';
if (paymentStatus === 'refunded') return 'cancelled';
return 'unpaid';
}
@@ -46,6 +50,7 @@ export function statusLabel(status: DashStatus, locale: string): string {
unpaid: { en: 'Unpaid', es: 'No pagado' },
attended: { en: 'Attended', es: 'Asistió' },
cancelled: { en: 'Cancelled', es: 'Cancelado' },
onHold: { en: 'On Hold', es: 'En Espera' },
};
return locale === 'es' ? labels[status].es : labels[status].en;
}
@@ -56,6 +61,7 @@ const PILL_STYLES: Record<DashStatus, string> = {
unpaid: 'bg-red-100 text-red-700',
attended: 'bg-blue-100 text-blue-800',
cancelled: 'bg-gray-100 text-gray-600',
onHold: 'bg-slate-100 text-slate-600',
};
export function StatusPill({
@@ -0,0 +1,147 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { useLanguage } from '@/context/LanguageContext';
import { Event } from '@/lib/api';
import { formatPrice, formatDateShort, formatTime } from '@/lib/utils';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import { CalendarIcon, MapPinIcon, UserGroupIcon } from '@heroicons/react/24/outline';
import clsx from 'clsx';
// Receives the event list already fetched on the server so event titles, dates,
// and locations are present in the initial HTML. The upcoming/past filter below
// is client-side interactivity layered on top of the server-rendered data.
export default function EventsClient({ initialEvents }: { initialEvents: Event[] }) {
const { t, locale } = useLanguage();
const [filter, setFilter] = useState<'upcoming' | 'past'>('upcoming');
const now = new Date();
const upcomingEvents = initialEvents.filter(e =>
e.status === 'published' && new Date(e.startDatetime) >= now
);
const pastEvents = initialEvents.filter(e =>
e.status === 'completed' || (e.status === 'published' && new Date(e.startDatetime) < now)
);
const displayedEvents = filter === 'upcoming' ? upcomingEvents : pastEvents;
const formatDate = (dateStr: string) => formatDateShort(dateStr, locale as 'en' | 'es');
const fmtTime = (dateStr: string) => formatTime(dateStr, locale as 'en' | 'es');
const getStatusBadge = (event: Event) => {
if (event.status === 'cancelled') {
return <span className="badge badge-danger">{t('events.details.cancelled')}</span>;
}
if (event.availableSeats === 0) {
return <span className="badge badge-warning">{t('events.details.soldOut')}</span>;
}
return null;
};
return (
<div className="section-padding">
<div className="container-page">
<h1 className="section-title">{t('events.title')}</h1>
{/* Filter tabs */}
<div className="mt-8 flex gap-2">
<button
onClick={() => setFilter('upcoming')}
className={clsx(
'px-4 py-2 rounded-btn font-medium transition-colors',
filter === 'upcoming'
? 'bg-primary-yellow text-primary-dark'
: 'bg-secondary-gray text-gray-600 hover:bg-gray-200'
)}
>
{t('events.upcoming')} ({upcomingEvents.length})
</button>
<button
onClick={() => setFilter('past')}
className={clsx(
'px-4 py-2 rounded-btn font-medium transition-colors',
filter === 'past'
? 'bg-primary-yellow text-primary-dark'
: 'bg-secondary-gray text-gray-600 hover:bg-gray-200'
)}
>
{t('events.past')} ({pastEvents.length})
</button>
</div>
{/* Events grid */}
<div className="mt-8">
{displayedEvents.length === 0 ? (
<div className="text-center py-16 text-gray-500">
<CalendarIcon className="w-16 h-16 mx-auto mb-4 text-gray-300" />
<p className="text-lg">{t('events.noEvents')}</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{displayedEvents.map((event) => (
<Link key={event.id} href={`/events/${event.slug}`} className="block">
<Card variant="elevated" className="card-hover overflow-hidden cursor-pointer h-full">
{/* Event banner */}
{event.bannerUrl ? (
<img
src={event.bannerUrl}
alt={`${event.title} - Spanglish language exchange event in Asunción`}
className="h-40 w-full object-cover"
loading="lazy"
/>
) : (
<div className="h-40 bg-gradient-to-br from-primary-yellow/30 to-secondary-blue/20 flex items-center justify-center">
<CalendarIcon className="w-16 h-16 text-primary-dark/30" />
</div>
)}
<div className="p-6">
<div className="flex items-start justify-between gap-2">
<h3 className="font-semibold text-lg text-primary-dark">
{locale === 'es' && event.titleEs ? event.titleEs : event.title}
</h3>
{getStatusBadge(event)}
</div>
<div className="mt-4 space-y-2 text-sm text-gray-600">
<div className="flex items-center gap-2">
<CalendarIcon className="w-4 h-4" />
<span>{formatDate(event.startDatetime)} - {fmtTime(event.startDatetime)}</span>
</div>
<div className="flex items-center gap-2">
<MapPinIcon className="w-4 h-4" />
<span className="truncate">{event.location}</span>
</div>
{!event.externalBookingEnabled && (
<div className="flex items-center gap-2">
<UserGroupIcon className="w-4 h-4" />
<span>
{Math.max(0, event.capacity - (event.bookedCount ?? 0))} / {event.capacity} {t('events.details.spotsLeft')}
</span>
</div>
)}
</div>
<div className="mt-6 flex items-center justify-between">
<span className="font-bold text-xl text-primary-dark">
{event.price === 0
? t('events.details.free')
: formatPrice(event.price, event.currency)}
</span>
<Button size="sm">
{t('common.moreInfo')}
</Button>
</div>
</div>
</Card>
</Link>
))}
</div>
)}
</div>
</div>
</div>
);
}
@@ -123,7 +123,9 @@ function generateEventJsonLd(event: Event) {
url: `${siteUrl}/events/${event.slug}`,
validFrom: new Date().toISOString(),
},
image: event.bannerUrl || `${siteUrl}/images/og-image.jpg`,
image: event.bannerUrl
? (event.bannerUrl.startsWith('http') ? event.bannerUrl : `${siteUrl}${event.bannerUrl}`)
: `${siteUrl}/images/og-image.jpg`,
url: `${siteUrl}/events/${event.slug}`,
};
}
+6 -1
View File
@@ -1,8 +1,13 @@
import type { Metadata } from 'next';
// Note: the page title for the listing lives on events/page.tsx, not here. A
// plain-string title in this layout would reset the root title template for the
// child /events/[id] route, stripping the brand suffix from event detail titles.
export const metadata: Metadata = {
title: 'Upcoming Language Exchange Events in Asunción',
description: 'Discover upcoming English and Spanish language exchange events in Asunción. Social, friendly, and open to everyone.',
alternates: {
canonical: '/events',
},
openGraph: {
title: 'Upcoming Language Exchange Events in Asunción Spanglish',
description: 'Discover upcoming English and Spanish language exchange events in Asunción. Social, friendly, and open to everyone.',
+24 -152
View File
@@ -1,157 +1,29 @@
'use client';
import type { Metadata } from 'next';
import { Event } from '@/lib/api';
import EventsClient from './EventsClient';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { useLanguage } from '@/context/LanguageContext';
import { eventsApi, Event } from '@/lib/api';
import { formatPrice, formatDateShort, formatTime } from '@/lib/utils';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import { CalendarIcon, MapPinIcon, UserGroupIcon } from '@heroicons/react/24/outline';
import clsx from 'clsx';
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
export default function EventsPage() {
const { t, locale } = useLanguage();
const [events, setEvents] = useState<Event[]>([]);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState<'upcoming' | 'past'>('upcoming');
// Listing title lives here (not in the layout) so the root title template still
// applies to the sibling /events/[id] detail route. Picks up "%s Spanglish".
export const metadata: Metadata = {
title: 'Upcoming Language Exchange Events in Asunción',
};
useEffect(() => {
eventsApi.getAll()
.then(({ events }) => setEvents(events))
.catch(console.error)
.finally(() => setLoading(false));
}, []);
// Fetch the public (published) event list on the server so event titles, dates,
// and locations appear in the initial HTML rather than only after JS runs.
async function getEvents(): Promise<Event[]> {
try {
const res = await fetch(`${apiUrl}/api/events`, { next: { revalidate: 60 } });
if (!res.ok) return [];
const data = await res.json();
return data.events || [];
} catch {
return [];
}
}
const now = new Date();
const upcomingEvents = events.filter(e =>
e.status === 'published' && new Date(e.startDatetime) >= now
);
const pastEvents = events.filter(e =>
e.status === 'completed' || (e.status === 'published' && new Date(e.startDatetime) < now)
);
const displayedEvents = filter === 'upcoming' ? upcomingEvents : pastEvents;
const formatDate = (dateStr: string) => formatDateShort(dateStr, locale as 'en' | 'es');
const fmtTime = (dateStr: string) => formatTime(dateStr, locale as 'en' | 'es');
const getStatusBadge = (event: Event) => {
if (event.status === 'cancelled') {
return <span className="badge badge-danger">{t('events.details.cancelled')}</span>;
}
if (event.availableSeats === 0) {
return <span className="badge badge-warning">{t('events.details.soldOut')}</span>;
}
return null;
};
return (
<div className="section-padding">
<div className="container-page">
<h1 className="section-title">{t('events.title')}</h1>
{/* Filter tabs */}
<div className="mt-8 flex gap-2">
<button
onClick={() => setFilter('upcoming')}
className={clsx(
'px-4 py-2 rounded-btn font-medium transition-colors',
filter === 'upcoming'
? 'bg-primary-yellow text-primary-dark'
: 'bg-secondary-gray text-gray-600 hover:bg-gray-200'
)}
>
{t('events.upcoming')} ({upcomingEvents.length})
</button>
<button
onClick={() => setFilter('past')}
className={clsx(
'px-4 py-2 rounded-btn font-medium transition-colors',
filter === 'past'
? 'bg-primary-yellow text-primary-dark'
: 'bg-secondary-gray text-gray-600 hover:bg-gray-200'
)}
>
{t('events.past')} ({pastEvents.length})
</button>
</div>
{/* Events grid */}
<div className="mt-8">
{loading ? (
<div className="text-center py-12">
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full mx-auto" />
</div>
) : displayedEvents.length === 0 ? (
<div className="text-center py-16 text-gray-500">
<CalendarIcon className="w-16 h-16 mx-auto mb-4 text-gray-300" />
<p className="text-lg">{t('events.noEvents')}</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{displayedEvents.map((event) => (
<Link key={event.id} href={`/events/${event.slug}`} className="block">
<Card variant="elevated" className="card-hover overflow-hidden cursor-pointer h-full">
{/* Event banner */}
{event.bannerUrl ? (
<img
src={event.bannerUrl}
alt={`${event.title} - Spanglish language exchange event in Asunción`}
className="h-40 w-full object-cover"
loading="lazy"
/>
) : (
<div className="h-40 bg-gradient-to-br from-primary-yellow/30 to-secondary-blue/20 flex items-center justify-center">
<CalendarIcon className="w-16 h-16 text-primary-dark/30" />
</div>
)}
<div className="p-6">
<div className="flex items-start justify-between gap-2">
<h3 className="font-semibold text-lg text-primary-dark">
{locale === 'es' && event.titleEs ? event.titleEs : event.title}
</h3>
{getStatusBadge(event)}
</div>
<div className="mt-4 space-y-2 text-sm text-gray-600">
<div className="flex items-center gap-2">
<CalendarIcon className="w-4 h-4" />
<span>{formatDate(event.startDatetime)} - {fmtTime(event.startDatetime)}</span>
</div>
<div className="flex items-center gap-2">
<MapPinIcon className="w-4 h-4" />
<span className="truncate">{event.location}</span>
</div>
{!event.externalBookingEnabled && (
<div className="flex items-center gap-2">
<UserGroupIcon className="w-4 h-4" />
<span>
{Math.max(0, event.capacity - (event.bookedCount ?? 0))} / {event.capacity} {t('events.details.spotsLeft')}
</span>
</div>
)}
</div>
<div className="mt-6 flex items-center justify-between">
<span className="font-bold text-xl text-primary-dark">
{event.price === 0
? t('events.details.free')
: formatPrice(event.price, event.currency)}
</span>
<Button size="sm">
{t('common.moreInfo')}
</Button>
</div>
</div>
</Card>
</Link>
))}
</div>
)}
</div>
</div>
</div>
);
export default async function EventsPage() {
const events = await getEvents();
return <EventsClient initialEvents={events} />;
}
@@ -0,0 +1,95 @@
'use client';
import { useState } from 'react';
import { useLanguage } from '@/context/LanguageContext';
import { FaqItem } from '@/lib/api';
import Card from '@/components/ui/Card';
import { ChevronDownIcon } from '@heroicons/react/24/outline';
import clsx from 'clsx';
// Receives the FAQ list already fetched on the server so the questions and
// answers are present in the initial HTML for crawlers. The accordion below is
// purely a visual toggle; the answer text stays in the DOM either way.
export default function FaqClient({ initialFaqs }: { initialFaqs: FaqItem[] }) {
const { locale } = useLanguage();
const [openIndex, setOpenIndex] = useState<number | null>(null);
const toggleFAQ = (index: number) => {
setOpenIndex(openIndex === index ? null : index);
};
return (
<div className="section-padding">
<div className="container-page max-w-3xl">
<div className="text-center mb-12">
<h1 className="text-4xl font-bold text-primary-dark mb-4">
{locale === 'es' ? 'Preguntas Frecuentes' : 'Frequently Asked Questions'}
</h1>
<p className="text-gray-600">
{locale === 'es'
? 'Encuentra respuestas a las preguntas más comunes sobre Spanglish'
: 'Find answers to the most common questions about Spanglish'}
</p>
</div>
{initialFaqs.length === 0 ? (
<Card className="p-8 text-center">
<p className="text-gray-600">
{locale === 'es'
? 'No hay preguntas frecuentes publicadas en este momento.'
: 'No FAQ questions are published at the moment.'}
</p>
</Card>
) : (
<div className="space-y-4">
{initialFaqs.map((faq, index) => (
<Card key={faq.id} className="overflow-hidden">
<button
onClick={() => toggleFAQ(index)}
className="w-full px-6 py-4 flex items-center justify-between text-left hover:bg-gray-50 transition-colors"
>
<span className="font-semibold text-primary-dark pr-4">
{locale === 'es' && faq.questionEs ? faq.questionEs : faq.question}
</span>
<ChevronDownIcon
className={clsx(
'w-5 h-5 text-gray-500 flex-shrink-0 transition-transform duration-200',
openIndex === index && 'transform rotate-180'
)}
/>
</button>
<div
className={clsx(
'overflow-hidden transition-all duration-200',
openIndex === index ? 'max-h-96' : 'max-h-0'
)}
>
<div className="px-6 pb-4 text-gray-600">
{locale === 'es' && faq.answerEs ? faq.answerEs : faq.answer}
</div>
</div>
</Card>
))}
</div>
)}
<Card className="mt-12 p-8 text-center bg-primary-yellow/10">
<h2 className="text-xl font-semibold text-primary-dark mb-2">
{locale === 'es' ? '¿Todavía tienes preguntas?' : 'Still have questions?'}
</h2>
<p className="text-gray-600 mb-4">
{locale === 'es'
? 'No dudes en contactarnos. ¡Estamos aquí para ayudarte!'
: "Don't hesitate to reach out. We're here to help!"}
</p>
<a
href="/contact"
className="inline-flex items-center justify-center px-6 py-3 bg-primary-yellow text-primary-dark font-semibold rounded-btn hover:bg-primary-yellow/90 transition-colors"
>
{locale === 'es' ? 'Contáctanos' : 'Contact Us'}
</a>
</Card>
</div>
</div>
);
}
+3
View File
@@ -20,6 +20,9 @@ async function getFaqForSchema(): Promise<{ question: string; answer: string }[]
export const metadata: Metadata = {
title: 'Frequently Asked Questions',
description: 'Find answers to common questions about Spanglish language exchange events in Asunción. Learn about how events work, who can attend, and more.',
alternates: {
canonical: '/faq',
},
openGraph: {
title: 'Frequently Asked Questions Spanglish',
description: 'Find answers to common questions about Spanglish language exchange events in Asunción.',
+17 -111
View File
@@ -1,116 +1,22 @@
'use client';
import { FaqItem } from '@/lib/api';
import FaqClient from './FaqClient';
import { useState, useEffect } from 'react';
import { useLanguage } from '@/context/LanguageContext';
import { faqApi, FaqItem } from '@/lib/api';
import Card from '@/components/ui/Card';
import { ChevronDownIcon } from '@heroicons/react/24/outline';
import clsx from 'clsx';
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
export default function FAQPage() {
const { locale } = useLanguage();
const [faqs, setFaqs] = useState<FaqItem[]>([]);
const [loading, setLoading] = useState(true);
const [openIndex, setOpenIndex] = useState<number | null>(null);
useEffect(() => {
let cancelled = false;
faqApi.getList().then((res) => {
if (!cancelled) {
setFaqs(res.faqs);
}
}).finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, []);
const toggleFAQ = (index: number) => {
setOpenIndex(openIndex === index ? null : index);
};
if (loading) {
return (
<div className="section-padding">
<div className="container-page max-w-3xl flex justify-center py-20">
<div className="animate-spin w-10 h-10 border-4 border-primary-yellow border-t-transparent rounded-full" />
</div>
</div>
);
// Fetch the published FAQ list on the server so the questions and answers are
// rendered into the initial HTML (crawlers see the content without running JS).
async function getFaqs(): Promise<FaqItem[]> {
try {
const res = await fetch(`${apiUrl}/api/faq`, { next: { revalidate: 60 } });
if (!res.ok) return [];
const data = await res.json();
return data.faqs || [];
} catch {
return [];
}
}
return (
<div className="section-padding">
<div className="container-page max-w-3xl">
<div className="text-center mb-12">
<h1 className="text-4xl font-bold text-primary-dark mb-4">
{locale === 'es' ? 'Preguntas Frecuentes' : 'Frequently Asked Questions'}
</h1>
<p className="text-gray-600">
{locale === 'es'
? 'Encuentra respuestas a las preguntas más comunes sobre Spanglish'
: 'Find answers to the most common questions about Spanglish'}
</p>
</div>
{faqs.length === 0 ? (
<Card className="p-8 text-center">
<p className="text-gray-600">
{locale === 'es'
? 'No hay preguntas frecuentes publicadas en este momento.'
: 'No FAQ questions are published at the moment.'}
</p>
</Card>
) : (
<div className="space-y-4">
{faqs.map((faq, index) => (
<Card key={faq.id} className="overflow-hidden">
<button
onClick={() => toggleFAQ(index)}
className="w-full px-6 py-4 flex items-center justify-between text-left hover:bg-gray-50 transition-colors"
>
<span className="font-semibold text-primary-dark pr-4">
{locale === 'es' && faq.questionEs ? faq.questionEs : faq.question}
</span>
<ChevronDownIcon
className={clsx(
'w-5 h-5 text-gray-500 flex-shrink-0 transition-transform duration-200',
openIndex === index && 'transform rotate-180'
)}
/>
</button>
<div
className={clsx(
'overflow-hidden transition-all duration-200',
openIndex === index ? 'max-h-96' : 'max-h-0'
)}
>
<div className="px-6 pb-4 text-gray-600">
{locale === 'es' && faq.answerEs ? faq.answerEs : faq.answer}
</div>
</div>
</Card>
))}
</div>
)}
<Card className="mt-12 p-8 text-center bg-primary-yellow/10">
<h2 className="text-xl font-semibold text-primary-dark mb-2">
{locale === 'es' ? '¿Todavía tienes preguntas?' : 'Still have questions?'}
</h2>
<p className="text-gray-600 mb-4">
{locale === 'es'
? 'No dudes en contactarnos. ¡Estamos aquí para ayudarte!'
: "Don't hesitate to reach out. We're here to help!"}
</p>
<a
href="/contact"
className="inline-flex items-center justify-center px-6 py-3 bg-primary-yellow text-primary-dark font-semibold rounded-btn hover:bg-primary-yellow/90 transition-colors"
>
{locale === 'es' ? 'Contáctanos' : 'Contact Us'}
</a>
</Card>
</div>
</div>
);
export default async function FAQPage() {
const faqs = await getFaqs();
return <FaqClient initialFaqs={faqs} />;
}
@@ -40,7 +40,8 @@ export async function generateMetadata({ params, searchParams }: PageProps): Pro
}
return {
title: `${legalPage.title} Spanglish`,
// The root layout's title template appends " Spanglish"; do not repeat it here.
title: legalPage.title,
description: `${legalPage.title} for Spanglish language exchange events in Asunción, Paraguay.`,
robots: {
index: true,
+9 -3
View File
@@ -59,7 +59,9 @@ export async function generateMetadata(): Promise<Metadata> {
if (!event) {
return {
title: 'Spanglish Language Exchange Events in Asunción',
// Title already carries the brand, so bypass the "%s Spanglish"
// template to avoid doubling it.
title: { absolute: 'Spanglish Language Exchange Events in Asunción' },
description:
'Practice English and Spanish at relaxed social events in Asunción. Meet locals and internationals. Join the next Spanglish meetup.',
};
@@ -76,7 +78,9 @@ export async function generateMetadata(): Promise<Metadata> {
const description = `Next event: ${eventDate} ${event.title}. Practice English and Spanish at relaxed social events in Asunción. Meet locals and internationals.`;
return {
title: 'Spanglish Language Exchange Events in Asunción',
// Title already carries the brand, so bypass the "%s Spanglish"
// template to avoid doubling it.
title: { absolute: 'Spanglish Language Exchange Events in Asunción' },
description,
openGraph: {
title: 'Spanglish Language Exchange Events in Asunción',
@@ -142,7 +146,9 @@ function generateNextEventJsonLd(event: NextEvent) {
: 'https://schema.org/SoldOut',
url: `${siteUrl}/events/${event.slug}`,
},
image: event.bannerUrl || `${siteUrl}/images/og-image.jpg`,
image: event.bannerUrl
? (event.bannerUrl.startsWith('http') ? event.bannerUrl : `${siteUrl}${event.bannerUrl}`)
: `${siteUrl}/images/og-image.jpg`,
url: `${siteUrl}/events/${event.slug}`,
};
}