Security recovery: hold sweep, dashboard updates, and admin fixes.
This commit is contained in:
@@ -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}`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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.',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user