Refactor monolithic modules and harden booking, email, and auth infrastructure.
Split oversized frontend API client, email service, and admin/booking pages into focused modules while preserving import surfaces, and add Redis-backed queues, stale booking cleanup, stronger auth, and scale deployment configs. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
import { useEffect } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { ticketsApi } from '@/lib/api';
|
||||
import type { BookingStep } from '../_types';
|
||||
|
||||
/**
|
||||
* Watch for Lightning payment confirmation while on the paying step.
|
||||
* SSE gives instant updates; a 3s poll runs in parallel as a safety net so a
|
||||
* buffered/stuck stream (e.g. a proxy that doesn't flush SSE) can't strand the UI.
|
||||
*/
|
||||
export function useLightningWatcher(
|
||||
step: BookingStep,
|
||||
ticketId: string | undefined,
|
||||
locale: string,
|
||||
setPaymentPending: (value: boolean) => void,
|
||||
setStep: (value: BookingStep) => void
|
||||
) {
|
||||
useEffect(() => {
|
||||
if (step !== 'paying' || !ticketId) return;
|
||||
|
||||
let settled = false;
|
||||
let pollTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const confirmPaid = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
toast.success(locale === 'es' ? '¡Pago confirmado!' : 'Payment confirmed!');
|
||||
setPaymentPending(false);
|
||||
setStep('success');
|
||||
};
|
||||
|
||||
const expire = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
toast.error(locale === 'es' ? 'La factura ha expirado' : 'Invoice has expired');
|
||||
setPaymentPending(false);
|
||||
};
|
||||
|
||||
// Always same-origin so the streaming proxy route handler is used (it
|
||||
// bypasses the rewrite, which buffers SSE).
|
||||
const eventSource = new EventSource(`/api/lnbits/stream/${ticketId}`);
|
||||
|
||||
eventSource.addEventListener('payment', (event) => {
|
||||
try {
|
||||
const data = JSON.parse((event as MessageEvent).data);
|
||||
if (data.type === 'paid' || data.type === 'already_paid') {
|
||||
confirmPaid();
|
||||
} else if (data.type === 'expired') {
|
||||
expire();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error parsing payment event:', e);
|
||||
}
|
||||
});
|
||||
|
||||
eventSource.onerror = () => {
|
||||
// SSE failed or was closed; the poll below remains the source of truth.
|
||||
eventSource.close();
|
||||
};
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const status = await ticketsApi.checkPaymentStatus(ticketId);
|
||||
if (status.isPaid) {
|
||||
confirmPaid();
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking payment status:', error);
|
||||
}
|
||||
if (!settled) {
|
||||
pollTimer = setTimeout(poll, 3000);
|
||||
}
|
||||
};
|
||||
pollTimer = setTimeout(poll, 3000);
|
||||
|
||||
return () => {
|
||||
settled = true;
|
||||
eventSource.close();
|
||||
if (pollTimer) clearTimeout(pollTimer);
|
||||
};
|
||||
}, [step, ticketId, locale]);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import toast from 'react-hot-toast';
|
||||
import { PaymentOptionsConfig } from '@/lib/api';
|
||||
import {
|
||||
CreditCardIcon,
|
||||
BanknotesIcon,
|
||||
BoltIcon,
|
||||
BuildingLibraryIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import type { PaymentMethod, BookingResult } from '../_types';
|
||||
|
||||
export const rucPattern = /^\d{6,10}$/;
|
||||
|
||||
/** Format RUC input: digits only, max 10. */
|
||||
export function formatRuc(value: string): string {
|
||||
return value.replace(/\D/g, '').slice(0, 10);
|
||||
}
|
||||
|
||||
/** Truncate a long invoice string for display. */
|
||||
export function truncateInvoice(invoice: string, chars: number = 20): string {
|
||||
if (invoice.length <= chars * 2) return invoice;
|
||||
return `${invoice.slice(0, chars)}...${invoice.slice(-chars)}`;
|
||||
}
|
||||
|
||||
/** Copy a Lightning invoice to the clipboard with localized feedback. */
|
||||
export function copyInvoiceToClipboard(invoice: string, locale: string): void {
|
||||
navigator.clipboard.writeText(invoice).then(() => {
|
||||
toast.success(locale === 'es' ? '¡Copiado!' : 'Copied!');
|
||||
}).catch(() => {
|
||||
toast.error(locale === 'es' ? 'Error al copiar' : 'Failed to copy');
|
||||
});
|
||||
}
|
||||
|
||||
export interface PaymentMethodOption {
|
||||
id: PaymentMethod;
|
||||
icon: typeof CreditCardIcon;
|
||||
label: string;
|
||||
description: string;
|
||||
badge?: string;
|
||||
}
|
||||
|
||||
/** Build the list of selectable payment methods from the event config. */
|
||||
export function buildPaymentMethods(
|
||||
paymentConfig: PaymentOptionsConfig | null,
|
||||
locale: string
|
||||
): PaymentMethodOption[] {
|
||||
const paymentMethods: PaymentMethodOption[] = [];
|
||||
|
||||
if (paymentConfig?.lightningEnabled) {
|
||||
paymentMethods.push({
|
||||
id: 'lightning',
|
||||
icon: BoltIcon,
|
||||
label: 'Bitcoin Lightning',
|
||||
description: locale === 'es' ? 'Pago instantáneo con Bitcoin' : 'Instant payment with Bitcoin',
|
||||
badge: locale === 'es' ? 'Instantáneo' : 'Instant',
|
||||
});
|
||||
}
|
||||
|
||||
if (paymentConfig?.tpagoEnabled) {
|
||||
paymentMethods.push({
|
||||
id: 'tpago',
|
||||
icon: CreditCardIcon,
|
||||
label: locale === 'es' ? 'TPago / Tarjetas de Crédito' : 'TPago / Credit Cards',
|
||||
description: locale === 'es' ? 'Pagá con tarjetas de crédito locales o internacionales' : 'Pay with local or international credit cards',
|
||||
badge: locale === 'es' ? 'Manual' : 'Manual',
|
||||
});
|
||||
}
|
||||
|
||||
if (paymentConfig?.bankTransferEnabled) {
|
||||
paymentMethods.push({
|
||||
id: 'bank_transfer',
|
||||
icon: BuildingLibraryIcon,
|
||||
label: locale === 'es' ? 'Transferencia Bancaria Local' : 'Local Bank Transfer',
|
||||
description: locale === 'es' ? 'Pago por transferencia bancaria en Paraguay' : 'Pay via Paraguayan bank transfer',
|
||||
badge: locale === 'es' ? 'Manual' : 'Manual',
|
||||
});
|
||||
}
|
||||
|
||||
if (paymentConfig?.cashEnabled) {
|
||||
paymentMethods.push({
|
||||
id: 'cash',
|
||||
icon: BanknotesIcon,
|
||||
label: locale === 'es' ? 'Efectivo en el Evento' : 'Cash at Event',
|
||||
description: locale === 'es' ? 'Paga cuando llegues al evento' : 'Pay when you arrive at the event',
|
||||
badge: locale === 'es' ? 'Manual' : 'Manual',
|
||||
});
|
||||
}
|
||||
|
||||
return paymentMethods;
|
||||
}
|
||||
|
||||
export interface SuccessContent {
|
||||
title: string;
|
||||
description: string;
|
||||
iconColor: string;
|
||||
iconTextColor: string;
|
||||
}
|
||||
|
||||
/** Resolve the success-screen copy based on the payment method used. */
|
||||
export function getSuccessContent(
|
||||
bookingResult: BookingResult | null,
|
||||
locale: string,
|
||||
t: (key: string) => string
|
||||
): SuccessContent {
|
||||
if (bookingResult?.paymentMethod === 'cash') {
|
||||
return {
|
||||
title: locale === 'es' ? '¡Reserva Recibida!' : 'Reservation Received!',
|
||||
description: locale === 'es'
|
||||
? 'Tu lugar está reservado. El pago se realizará en el evento.'
|
||||
: 'Your spot is reserved. Payment will be collected at the event.',
|
||||
iconColor: 'bg-yellow-100',
|
||||
iconTextColor: 'text-yellow-600',
|
||||
};
|
||||
}
|
||||
if (bookingResult?.paymentMethod === 'lightning') {
|
||||
// For Lightning, if we're on success step, payment was confirmed
|
||||
return {
|
||||
title: locale === 'es' ? '¡Pago Confirmado!' : 'Payment Confirmed!',
|
||||
description: locale === 'es'
|
||||
? '¡Tu reserva está confirmada! Te esperamos en el evento.'
|
||||
: 'Your booking is confirmed! See you at the event.',
|
||||
iconColor: 'bg-green-100',
|
||||
iconTextColor: 'text-green-600',
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: t('booking.success.title'),
|
||||
description: t('booking.success.description'),
|
||||
iconColor: 'bg-green-100',
|
||||
iconTextColor: 'text-green-600',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
import Link from 'next/link';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Input from '@/components/ui/Input';
|
||||
import {
|
||||
CalendarIcon,
|
||||
MapPinIcon,
|
||||
UserGroupIcon,
|
||||
CurrencyDollarIcon,
|
||||
ArrowLeftIcon,
|
||||
CheckCircleIcon,
|
||||
UserIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { Event } from '@/lib/api';
|
||||
import { formatPrice } from '@/lib/utils';
|
||||
import type { AttendeeInfo, BookingFormData } from '../_types';
|
||||
import type { PaymentMethodOption } from '../_logic/booking';
|
||||
|
||||
interface BookingFormStepProps {
|
||||
event: Event;
|
||||
locale: string;
|
||||
t: (key: string) => string;
|
||||
spotsLeft: number;
|
||||
isSoldOut: boolean;
|
||||
ticketQuantity: number;
|
||||
formData: BookingFormData;
|
||||
setFormData: React.Dispatch<React.SetStateAction<BookingFormData>>;
|
||||
errors: Partial<Record<keyof BookingFormData, string>>;
|
||||
attendees: AttendeeInfo[];
|
||||
setAttendees: React.Dispatch<React.SetStateAction<AttendeeInfo[]>>;
|
||||
attendeeErrors: { [key: number]: string };
|
||||
setAttendeeErrors: React.Dispatch<React.SetStateAction<{ [key: number]: string }>>;
|
||||
handleRucChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
handleRucBlur: () => void;
|
||||
paymentMethods: PaymentMethodOption[];
|
||||
agreedToTerms: boolean;
|
||||
setAgreedToTerms: (value: boolean) => void;
|
||||
termsError: string | null;
|
||||
submitting: boolean;
|
||||
onSubmit: (e: React.FormEvent) => void;
|
||||
formatDate: (dateStr: string) => string;
|
||||
fmtTime: (dateStr: string) => string;
|
||||
}
|
||||
|
||||
export function BookingFormStep({
|
||||
event,
|
||||
locale,
|
||||
t,
|
||||
spotsLeft,
|
||||
isSoldOut,
|
||||
ticketQuantity,
|
||||
formData,
|
||||
setFormData,
|
||||
errors,
|
||||
attendees,
|
||||
setAttendees,
|
||||
attendeeErrors,
|
||||
setAttendeeErrors,
|
||||
handleRucChange,
|
||||
handleRucBlur,
|
||||
paymentMethods,
|
||||
agreedToTerms,
|
||||
setAgreedToTerms,
|
||||
termsError,
|
||||
submitting,
|
||||
onSubmit,
|
||||
formatDate,
|
||||
fmtTime,
|
||||
}: BookingFormStepProps) {
|
||||
return (
|
||||
<div className="section-padding bg-secondary-gray min-h-screen">
|
||||
<div className="container-page max-w-2xl">
|
||||
<Link
|
||||
href={`/events/${event.slug}`}
|
||||
className="inline-flex items-center gap-2 text-gray-600 hover:text-primary-dark mb-6"
|
||||
>
|
||||
<ArrowLeftIcon className="w-4 h-4" />
|
||||
{t('common.back')}
|
||||
</Link>
|
||||
|
||||
{/* Event Summary - Always Visible */}
|
||||
<Card className="mb-6 overflow-hidden">
|
||||
<div className="bg-primary-yellow/20 p-4 border-b border-primary-yellow/30">
|
||||
<h2 className="font-bold text-lg text-primary-dark">
|
||||
{locale === 'es' && event.titleEs ? event.titleEs : event.title}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="p-4 space-y-2 text-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<CalendarIcon className="w-5 h-5 text-primary-yellow" />
|
||||
<span>{formatDate(event.startDatetime)} • {fmtTime(event.startDatetime)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<MapPinIcon className="w-5 h-5 text-primary-yellow" />
|
||||
<span>{event.location}</span>
|
||||
</div>
|
||||
{!event.externalBookingEnabled && (
|
||||
<div className="flex items-center gap-3">
|
||||
<UserGroupIcon className="w-5 h-5 text-primary-yellow" />
|
||||
<span>{spotsLeft} / {event.capacity} {t('events.details.spotsLeft')}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-3">
|
||||
<CurrencyDollarIcon className="w-5 h-5 text-primary-yellow" />
|
||||
<span className="font-bold text-lg">
|
||||
{event.price === 0
|
||||
? t('events.details.free')
|
||||
: formatPrice(event.price, event.currency)}
|
||||
</span>
|
||||
{event.price > 0 && (
|
||||
<span className="text-gray-400 text-sm">
|
||||
{locale === 'es' ? 'por persona' : 'per person'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Ticket quantity and total */}
|
||||
{ticketQuantity > 1 && (
|
||||
<div className="mt-3 pt-3 border-t border-secondary-light-gray">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-600">
|
||||
{locale === 'es' ? 'Tickets' : 'Tickets'}: <span className="font-semibold">{ticketQuantity}</span>
|
||||
</span>
|
||||
<span className="font-bold text-lg text-primary-dark">
|
||||
{locale === 'es' ? 'Total' : 'Total'}: {formatPrice(event.price * ticketQuantity, event.currency)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{isSoldOut ? (
|
||||
<Card className="p-8 text-center">
|
||||
<UserGroupIcon className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||||
<h2 className="text-xl font-bold text-gray-700">{t('events.details.soldOut')}</h2>
|
||||
<p className="text-gray-500 mt-2">{t('booking.form.soldOutMessage')}</p>
|
||||
</Card>
|
||||
) : (
|
||||
<form onSubmit={onSubmit}>
|
||||
{/* User Information Section */}
|
||||
<Card className="mb-6 p-6">
|
||||
<h3 className="font-bold text-lg mb-4 text-primary-dark flex items-center gap-2">
|
||||
{attendees.length > 0 && (
|
||||
<span className="w-6 h-6 rounded-full bg-primary-yellow text-primary-dark text-sm font-bold flex items-center justify-center">
|
||||
1
|
||||
</span>
|
||||
)}
|
||||
{t('booking.form.personalInfo')}
|
||||
{attendees.length > 0 && (
|
||||
<span className="text-sm font-normal text-gray-500">
|
||||
({locale === 'es' ? 'Asistente principal' : 'Primary attendee'})
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label={t('booking.form.firstName')}
|
||||
value={formData.firstName}
|
||||
onChange={(e) => setFormData({ ...formData, firstName: e.target.value })}
|
||||
placeholder={t('booking.form.firstNamePlaceholder')}
|
||||
error={errors.firstName}
|
||||
required
|
||||
/>
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
{t('booking.form.lastName')}
|
||||
</label>
|
||||
<span className="text-xs text-gray-400">
|
||||
({locale === 'es' ? 'Opcional' : 'Optional'})
|
||||
</span>
|
||||
</div>
|
||||
<Input
|
||||
value={formData.lastName}
|
||||
onChange={(e) => setFormData({ ...formData, lastName: e.target.value })}
|
||||
placeholder={t('booking.form.lastNamePlaceholder')}
|
||||
error={errors.lastName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Input
|
||||
label={t('booking.form.email')}
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
placeholder={t('booking.form.emailPlaceholder')}
|
||||
error={errors.email}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
{t('booking.form.phone')}
|
||||
</label>
|
||||
<span className="text-xs text-gray-400">
|
||||
({locale === 'es' ? 'Opcional' : 'Optional'})
|
||||
</span>
|
||||
</div>
|
||||
<Input
|
||||
type="tel"
|
||||
value={formData.phone}
|
||||
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
|
||||
placeholder={t('booking.form.phonePlaceholder')}
|
||||
error={errors.phone}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
{t('booking.form.ruc')}
|
||||
</label>
|
||||
<span className="text-xs text-gray-400">
|
||||
{t('booking.form.rucOptional')}
|
||||
</span>
|
||||
</div>
|
||||
<Input
|
||||
value={formData.ruc}
|
||||
onChange={handleRucChange}
|
||||
onBlur={handleRucBlur}
|
||||
placeholder={t('booking.form.rucPlaceholder')}
|
||||
error={errors.ruc}
|
||||
inputMode="numeric"
|
||||
maxLength={10}
|
||||
aria-label={t('booking.form.ruc')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{t('booking.form.preferredLanguage')}
|
||||
</label>
|
||||
<select
|
||||
value={formData.preferredLanguage}
|
||||
onChange={(e) => setFormData({ ...formData, preferredLanguage: e.target.value as 'en' | 'es' })}
|
||||
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
>
|
||||
<option value="en">English</option>
|
||||
<option value="es">Español</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Additional Attendees Section (for multi-ticket bookings) */}
|
||||
{attendees.length > 0 && (
|
||||
<Card className="mb-6 p-6">
|
||||
<h3 className="font-bold text-lg mb-4 text-primary-dark flex items-center gap-2">
|
||||
<UserIcon className="w-5 h-5 text-primary-yellow" />
|
||||
{locale === 'es' ? 'Información de los Otros Asistentes' : 'Other Attendees Information'}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
{locale === 'es'
|
||||
? 'Ingresa el nombre de cada asistente adicional. Cada persona recibirá su propio ticket.'
|
||||
: 'Enter the name for each additional attendee. Each person will receive their own ticket.'}
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{attendees.map((attendee, index) => (
|
||||
<div key={index} className="p-4 bg-gray-50 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="w-6 h-6 rounded-full bg-primary-yellow text-primary-dark text-sm font-bold flex items-center justify-center">
|
||||
{index + 2}
|
||||
</span>
|
||||
<span className="font-medium text-gray-700">
|
||||
{locale === 'es' ? `Asistente ${index + 2}` : `Attendee ${index + 2}`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label={t('booking.form.firstName')}
|
||||
value={attendee.firstName}
|
||||
onChange={(e) => {
|
||||
const newAttendees = [...attendees];
|
||||
newAttendees[index].firstName = e.target.value;
|
||||
setAttendees(newAttendees);
|
||||
if (attendeeErrors[index]) {
|
||||
const newErrors = { ...attendeeErrors };
|
||||
delete newErrors[index];
|
||||
setAttendeeErrors(newErrors);
|
||||
}
|
||||
}}
|
||||
placeholder={t('booking.form.firstNamePlaceholder')}
|
||||
error={attendeeErrors[index]}
|
||||
required
|
||||
/>
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
{t('booking.form.lastName')}
|
||||
</label>
|
||||
<span className="text-xs text-gray-400">
|
||||
({locale === 'es' ? 'Opcional' : 'Optional'})
|
||||
</span>
|
||||
</div>
|
||||
<Input
|
||||
value={attendee.lastName}
|
||||
onChange={(e) => {
|
||||
const newAttendees = [...attendees];
|
||||
newAttendees[index].lastName = e.target.value;
|
||||
setAttendees(newAttendees);
|
||||
}}
|
||||
placeholder={t('booking.form.lastNamePlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Payment Selection Section */}
|
||||
<Card className="mb-6 p-6">
|
||||
<h3 className="font-bold text-lg mb-4 text-primary-dark">
|
||||
{t('booking.form.paymentMethod')}
|
||||
</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
{paymentMethods.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
{locale === 'es'
|
||||
? 'No hay métodos de pago disponibles para este evento.'
|
||||
: 'No payment methods available for this event.'}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{paymentMethods.map((method) => (
|
||||
<button
|
||||
key={method.id}
|
||||
type="button"
|
||||
onClick={() => setFormData({ ...formData, paymentMethod: method.id })}
|
||||
className={`w-full p-4 rounded-lg border-2 transition-all text-left flex items-start gap-4 ${
|
||||
formData.paymentMethod === method.id
|
||||
? 'border-primary-yellow bg-primary-yellow/10'
|
||||
: 'border-secondary-light-gray hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<div className={`w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 ${
|
||||
formData.paymentMethod === method.id
|
||||
? 'bg-primary-yellow'
|
||||
: 'bg-gray-100'
|
||||
}`}>
|
||||
<method.icon className={`w-5 h-5 ${
|
||||
formData.paymentMethod === method.id
|
||||
? 'text-primary-dark'
|
||||
: 'text-gray-500'
|
||||
}`} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-medium text-primary-dark">{method.label}</p>
|
||||
{method.badge && (
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${
|
||||
method.badge === 'Instant' || method.badge === 'Instantáneo'
|
||||
? 'bg-green-100 text-green-700'
|
||||
: 'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
{method.badge}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500">{method.description}</p>
|
||||
</div>
|
||||
{formData.paymentMethod === method.id && (
|
||||
<CheckCircleIcon className="w-6 h-6 text-primary-yellow ml-auto flex-shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Terms & Privacy agreement */}
|
||||
<Card className="mb-6 p-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<input
|
||||
id="booking-terms-agree"
|
||||
type="checkbox"
|
||||
checked={agreedToTerms}
|
||||
onChange={(e) => setAgreedToTerms(e.target.checked)}
|
||||
aria-required="true"
|
||||
aria-invalid={termsError ? true : undefined}
|
||||
aria-describedby={termsError ? 'booking-terms-error' : undefined}
|
||||
className="h-5 w-5 mt-0.5 flex-shrink-0 accent-primary-yellow rounded focus:outline-none focus:ring-2 focus:ring-primary-yellow focus:ring-offset-2 cursor-pointer"
|
||||
/>
|
||||
<label
|
||||
htmlFor="booking-terms-agree"
|
||||
className="text-sm text-gray-500 leading-relaxed cursor-pointer select-none"
|
||||
>
|
||||
{t('booking.form.termsAgreePart1')}
|
||||
<Link
|
||||
href={`/legal/terms-policy${locale === 'es' ? '?locale=es' : ''}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-secondary-blue hover:text-brand-navy underline"
|
||||
>
|
||||
{t('booking.form.termsOfService')}
|
||||
</Link>
|
||||
{t('booking.form.termsAgreePart2')}
|
||||
<Link
|
||||
href={`/legal/privacy-policy${locale === 'es' ? '?locale=es' : ''}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-secondary-blue hover:text-brand-navy underline"
|
||||
>
|
||||
{t('booking.form.privacyPolicy')}
|
||||
</Link>
|
||||
{t('booking.form.termsAgreePart3')}
|
||||
</label>
|
||||
</div>
|
||||
{termsError && (
|
||||
<p id="booking-terms-error" className="mt-1.5 text-sm text-red-600">
|
||||
{termsError}
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Submit Button */}
|
||||
<Button
|
||||
type="submit"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
isLoading={submitting}
|
||||
disabled={paymentMethods.length === 0 || !agreedToTerms}
|
||||
>
|
||||
{formData.paymentMethod === 'cash'
|
||||
? t('booking.form.reserveSpot')
|
||||
: formData.paymentMethod === 'lightning'
|
||||
? t('booking.form.proceedPayment')
|
||||
: locale === 'es' ? 'Continuar al Pago' : 'Continue to Payment'
|
||||
}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Input from '@/components/ui/Input';
|
||||
import {
|
||||
CreditCardIcon,
|
||||
BuildingLibraryIcon,
|
||||
CheckCircleIcon,
|
||||
ArrowTopRightOnSquareIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { Event, PaymentOptionsConfig } from '@/lib/api';
|
||||
import { formatPrice, getTpagoLink } from '@/lib/utils';
|
||||
import type { BookingResult } from '../_types';
|
||||
|
||||
interface ManualPaymentStepProps {
|
||||
bookingResult: BookingResult;
|
||||
event: Event;
|
||||
paymentConfig: PaymentOptionsConfig;
|
||||
locale: string;
|
||||
paidUnderDifferentName: boolean;
|
||||
setPaidUnderDifferentName: (value: boolean) => void;
|
||||
payerName: string;
|
||||
setPayerName: (value: string) => void;
|
||||
markingPaid: boolean;
|
||||
onMarkPaymentSent: () => void;
|
||||
}
|
||||
|
||||
export function ManualPaymentStep({
|
||||
bookingResult,
|
||||
event,
|
||||
paymentConfig,
|
||||
locale,
|
||||
paidUnderDifferentName,
|
||||
setPaidUnderDifferentName,
|
||||
payerName,
|
||||
setPayerName,
|
||||
markingPaid,
|
||||
onMarkPaymentSent,
|
||||
}: ManualPaymentStepProps) {
|
||||
const isBankTransfer = bookingResult.paymentMethod === 'bank_transfer';
|
||||
const isTpago = bookingResult.paymentMethod === 'tpago';
|
||||
const ticketCount = bookingResult.ticketCount || 1;
|
||||
const totalAmount = (event?.price || 0) * ticketCount;
|
||||
const tpagoLink = getTpagoLink(paymentConfig, ticketCount);
|
||||
|
||||
return (
|
||||
<div className="section-padding">
|
||||
<div className="container-page max-w-xl">
|
||||
<Card className="p-6">
|
||||
<div className="text-center mb-6">
|
||||
<div className={`w-16 h-16 rounded-full ${isBankTransfer ? 'bg-green-100' : 'bg-blue-100'} flex items-center justify-center mx-auto mb-4`}>
|
||||
{isBankTransfer ? (
|
||||
<BuildingLibraryIcon className="w-8 h-8 text-green-600" />
|
||||
) : (
|
||||
<CreditCardIcon className="w-8 h-8 text-blue-600" />
|
||||
)}
|
||||
</div>
|
||||
<h1 className="text-xl font-bold text-primary-dark mb-2">
|
||||
{locale === 'es' ? 'Completa tu Pago' : 'Complete Your Payment'}
|
||||
</h1>
|
||||
<p className="text-gray-600">
|
||||
{locale === 'es'
|
||||
? 'Sigue las instrucciones para completar tu pago'
|
||||
: 'Follow the instructions to complete your payment'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Amount to pay */}
|
||||
<div className="bg-gray-50 rounded-lg p-4 mb-6 text-center">
|
||||
<p className="text-sm text-gray-500 mb-1">
|
||||
{locale === 'es' ? 'Monto a pagar' : 'Amount to pay'}
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-primary-dark">
|
||||
{event?.price !== undefined ? formatPrice(totalAmount, event.currency) : ''}
|
||||
</p>
|
||||
{ticketCount > 1 && (
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{ticketCount} tickets × {formatPrice(event?.price || 0, event?.currency || 'PYG')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bank Transfer Details */}
|
||||
{isBankTransfer && (
|
||||
<div className="space-y-4 mb-6">
|
||||
<h3 className="font-semibold text-gray-900">
|
||||
{locale === 'es' ? 'Datos Bancarios' : 'Bank Details'}
|
||||
</h3>
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4 space-y-3">
|
||||
{paymentConfig.bankName && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">{locale === 'es' ? 'Banco' : 'Bank'}:</span>
|
||||
<span className="font-medium">{paymentConfig.bankName}</span>
|
||||
</div>
|
||||
)}
|
||||
{paymentConfig.bankAccountHolder && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">{locale === 'es' ? 'Titular' : 'Account Holder'}:</span>
|
||||
<span className="font-medium">{paymentConfig.bankAccountHolder}</span>
|
||||
</div>
|
||||
)}
|
||||
{paymentConfig.bankAccountNumber && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">{locale === 'es' ? 'Nro. Cuenta' : 'Account Number'}:</span>
|
||||
<span className="font-medium font-mono">{paymentConfig.bankAccountNumber}</span>
|
||||
</div>
|
||||
)}
|
||||
{paymentConfig.bankAlias && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Alias:</span>
|
||||
<span className="font-medium">{paymentConfig.bankAlias}</span>
|
||||
</div>
|
||||
)}
|
||||
{paymentConfig.bankPhone && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">{locale === 'es' ? 'Teléfono' : 'Phone'}:</span>
|
||||
<span className="font-medium">{paymentConfig.bankPhone}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{(locale === 'es' ? paymentConfig.bankNotesEs : paymentConfig.bankNotes) && (
|
||||
<p className="text-sm text-gray-600">
|
||||
{locale === 'es' ? paymentConfig.bankNotesEs : paymentConfig.bankNotes}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TPago Link */}
|
||||
{isTpago && (
|
||||
<div className="space-y-4 mb-6">
|
||||
<h3 className="font-semibold text-gray-900">
|
||||
{locale === 'es' ? 'Pago con Tarjeta' : 'Card Payment'}
|
||||
</h3>
|
||||
{tpagoLink && (
|
||||
<a
|
||||
href={tpagoLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center gap-2 w-full px-6 py-4 bg-blue-600 text-white rounded-btn hover:bg-blue-700 transition-colors font-medium"
|
||||
>
|
||||
<ArrowTopRightOnSquareIcon className="w-5 h-5" />
|
||||
{locale === 'es' ? 'Abrir TPago para Pagar' : 'Open TPago to Pay'}
|
||||
</a>
|
||||
)}
|
||||
{(locale === 'es' ? paymentConfig.tpagoInstructionsEs : paymentConfig.tpagoInstructions) && (
|
||||
<p className="text-sm text-gray-600">
|
||||
{locale === 'es' ? paymentConfig.tpagoInstructionsEs : paymentConfig.tpagoInstructions}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reference */}
|
||||
<div className="bg-gray-100 rounded-lg p-3 mb-6">
|
||||
<p className="text-xs text-gray-500 mb-1">
|
||||
{locale === 'es' ? 'Referencia de tu reserva' : 'Your booking reference'}
|
||||
</p>
|
||||
<p className="font-mono font-bold text-lg">{bookingResult.qrCode}</p>
|
||||
</div>
|
||||
|
||||
{/* Manual verification notice */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-4">
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="w-5 h-5 text-blue-600 mt-0.5" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="text-sm text-blue-800">
|
||||
<p className="font-medium mb-1">
|
||||
{locale === 'es' ? 'Verificación manual' : 'Manual verification'}
|
||||
</p>
|
||||
<p className="text-blue-700">
|
||||
{locale === 'es'
|
||||
? 'El equipo de Spanglish revisará el pago manualmente. Tu reserva solo será confirmada después de recibir un email de confirmación de nuestra parte.'
|
||||
: 'The Spanglish team will review the payment manually. Your booking is only confirmed after you receive a confirmation email from us.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Paid under different name option */}
|
||||
<div className="bg-gray-50 rounded-lg p-4 mb-4">
|
||||
<label className="flex items-start gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={paidUnderDifferentName}
|
||||
onChange={(e) => {
|
||||
setPaidUnderDifferentName(e.target.checked);
|
||||
if (!e.target.checked) setPayerName('');
|
||||
}}
|
||||
className="mt-1 w-4 h-4 text-primary-yellow border-gray-300 rounded focus:ring-primary-yellow"
|
||||
/>
|
||||
<div>
|
||||
<span className="font-medium text-gray-700">
|
||||
{locale === 'es'
|
||||
? 'El pago está a nombre de otra persona'
|
||||
: 'The payment is under another person\'s name'}
|
||||
</span>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{locale === 'es'
|
||||
? 'Marcá esta opción si el pago fue realizado por un familiar o tercero.'
|
||||
: 'Check this option if the payment was made by a family member or a third party.'}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{paidUnderDifferentName && (
|
||||
<div className="mt-3 pl-7">
|
||||
<Input
|
||||
label={locale === 'es' ? 'Nombre del pagador' : 'Payer name'}
|
||||
value={payerName}
|
||||
onChange={(e) => setPayerName(e.target.value)}
|
||||
placeholder={locale === 'es' ? 'Nombre completo del titular de la cuenta' : 'Full name of account holder'}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Warning before I Have Paid button */}
|
||||
<p className="text-sm text-center text-amber-700 font-medium mb-3">
|
||||
{locale === 'es'
|
||||
? 'Solo haz clic aquí después de haber completado el pago.'
|
||||
: 'Only click this after you have actually completed the payment.'}
|
||||
</p>
|
||||
|
||||
{/* I Have Paid Button */}
|
||||
<Button
|
||||
onClick={onMarkPaymentSent}
|
||||
isLoading={markingPaid}
|
||||
size="lg"
|
||||
className="w-full"
|
||||
disabled={paidUnderDifferentName && !payerName.trim()}
|
||||
>
|
||||
<CheckCircleIcon className="w-5 h-5 mr-2" />
|
||||
{locale === 'es' ? 'Ya Realicé el Pago' : 'I Have Paid'}
|
||||
</Button>
|
||||
|
||||
<p className="text-xs text-center text-gray-500 mt-4">
|
||||
{locale === 'es'
|
||||
? 'Tu reserva será confirmada una vez que verifiquemos el pago'
|
||||
: 'Your booking will be confirmed once we verify the payment'}
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import Card from '@/components/ui/Card';
|
||||
import { BoltIcon, ClipboardDocumentIcon } from '@heroicons/react/24/outline';
|
||||
import { copyInvoiceToClipboard, truncateInvoice } from '../_logic/booking';
|
||||
import type { LightningInvoice } from '../_types';
|
||||
|
||||
interface PayingStepProps {
|
||||
invoice: LightningInvoice;
|
||||
qrCode: string;
|
||||
locale: string;
|
||||
}
|
||||
|
||||
export function PayingStep({ invoice, qrCode, locale }: PayingStepProps) {
|
||||
return (
|
||||
<div className="section-padding">
|
||||
<div className="container-page max-w-md">
|
||||
<Card className="p-6 text-center">
|
||||
{/* Amount - prominent at top */}
|
||||
<div className="mb-4">
|
||||
{invoice.fiatAmount && invoice.fiatCurrency && (
|
||||
<p className="text-2xl font-bold text-primary-dark">
|
||||
{invoice.fiatAmount.toLocaleString()} {invoice.fiatCurrency}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-orange-600 font-medium">
|
||||
≈ {invoice.amount.toLocaleString()} sats
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* QR Code - clickable to copy */}
|
||||
<div
|
||||
className="bg-white p-4 rounded-lg shadow-inner inline-block mb-4 cursor-pointer hover:shadow-md transition-shadow"
|
||||
onClick={() => copyInvoiceToClipboard(invoice.paymentRequest, locale)}
|
||||
title={locale === 'es' ? 'Clic para copiar' : 'Click to copy'}
|
||||
>
|
||||
<QRCodeSVG
|
||||
value={invoice.paymentRequest.toUpperCase()}
|
||||
size={200}
|
||||
level="M"
|
||||
includeMargin={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Invoice string - truncated, clickable */}
|
||||
<div
|
||||
className="bg-secondary-gray rounded-lg p-3 mb-4 cursor-pointer hover:bg-gray-200 transition-colors"
|
||||
onClick={() => copyInvoiceToClipboard(invoice.paymentRequest, locale)}
|
||||
>
|
||||
<p className="font-mono text-xs text-gray-600 flex items-center justify-center gap-2">
|
||||
<ClipboardDocumentIcon className="w-4 h-4 flex-shrink-0" />
|
||||
<span className="truncate">{truncateInvoice(invoice.paymentRequest, 16)}</span>
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
{locale === 'es' ? 'Toca para copiar' : 'Tap to copy'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Open in Wallet - primary action */}
|
||||
<a
|
||||
href={`lightning:${invoice.paymentRequest}`}
|
||||
className="inline-flex items-center justify-center gap-2 w-full px-6 py-3 bg-orange-500 text-white rounded-btn hover:bg-orange-600 transition-colors font-medium mb-4"
|
||||
>
|
||||
<BoltIcon className="w-5 h-5" />
|
||||
{locale === 'es' ? 'Abrir en Billetera' : 'Open in Wallet'}
|
||||
</a>
|
||||
|
||||
{/* Status indicator */}
|
||||
<div className="flex items-center justify-center gap-2 text-gray-500 text-sm">
|
||||
<div className="animate-spin w-3 h-3 border-2 border-orange-400 border-t-transparent rounded-full" />
|
||||
<span>{locale === 'es' ? 'Esperando pago...' : 'Waiting for payment...'}</span>
|
||||
</div>
|
||||
|
||||
{/* Ticket reference - small */}
|
||||
<p className="text-xs text-gray-400 mt-3">
|
||||
{locale === 'es' ? 'Ref' : 'Ref'}: {qrCode}
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import Link from 'next/link';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import { ClockIcon, TicketIcon } from '@heroicons/react/24/outline';
|
||||
import { Event } from '@/lib/api';
|
||||
import type { BookingResult } from '../_types';
|
||||
|
||||
interface PendingApprovalStepProps {
|
||||
bookingResult: BookingResult;
|
||||
event: Event | null;
|
||||
locale: string;
|
||||
t: (key: string) => string;
|
||||
formatDate: (dateStr: string) => string;
|
||||
fmtTime: (dateStr: string) => string;
|
||||
}
|
||||
|
||||
export function PendingApprovalStep({
|
||||
bookingResult,
|
||||
event,
|
||||
locale,
|
||||
t,
|
||||
formatDate,
|
||||
fmtTime,
|
||||
}: PendingApprovalStepProps) {
|
||||
return (
|
||||
<div className="section-padding">
|
||||
<div className="container-page max-w-xl">
|
||||
<Card className="p-8 text-center">
|
||||
<div className="w-16 h-16 rounded-full bg-yellow-100 flex items-center justify-center mx-auto mb-6">
|
||||
<ClockIcon className="w-10 h-10 text-yellow-600" />
|
||||
</div>
|
||||
|
||||
<h1 className="text-2xl font-bold text-primary-dark mb-2">
|
||||
{locale === 'es' ? '¡Pago en Verificación!' : 'Payment Being Verified!'}
|
||||
</h1>
|
||||
<p className="text-gray-600 mb-6">
|
||||
{locale === 'es'
|
||||
? 'Estamos verificando tu pago. Recibirás un email de confirmación una vez aprobado.'
|
||||
: 'We are verifying your payment. You will receive a confirmation email once approved.'}
|
||||
</p>
|
||||
|
||||
<div className="bg-secondary-gray rounded-lg p-6 mb-6">
|
||||
<div className="flex items-center justify-center gap-2 mb-4">
|
||||
<TicketIcon className="w-6 h-6 text-primary-yellow" />
|
||||
<span className="font-mono text-lg font-bold">{bookingResult.qrCode}</span>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-gray-600 space-y-2">
|
||||
<p><strong>{t('booking.success.event')}:</strong> {event?.title}</p>
|
||||
<p><strong>{t('booking.success.date')}:</strong> {event && formatDate(event.startDatetime)}</p>
|
||||
<p><strong>{t('booking.success.time')}:</strong> {event && fmtTime(event.startDatetime)}</p>
|
||||
<p><strong>{t('booking.success.location')}:</strong> {event?.location}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-6">
|
||||
<p className="text-yellow-800 text-sm">
|
||||
{locale === 'es'
|
||||
? 'La verificación del pago puede tomar hasta 24 horas hábiles. Por favor revisa tu email regularmente.'
|
||||
: 'Payment verification may take up to 24 business hours. Please check your email regularly.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-3 justify-center">
|
||||
<Link href="/events">
|
||||
<Button variant="outline">{t('booking.success.browseEvents')}</Button>
|
||||
</Link>
|
||||
<Link href="/">
|
||||
<Button>{t('booking.success.backHome')}</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import Link from 'next/link';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
TicketIcon,
|
||||
ArrowDownTrayIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { Event } from '@/lib/api';
|
||||
import { getSuccessContent } from '../_logic/booking';
|
||||
import type { BookingResult } from '../_types';
|
||||
|
||||
interface SuccessStepProps {
|
||||
bookingResult: BookingResult;
|
||||
event: Event;
|
||||
locale: string;
|
||||
t: (key: string) => string;
|
||||
formatDate: (dateStr: string) => string;
|
||||
fmtTime: (dateStr: string) => string;
|
||||
}
|
||||
|
||||
export function SuccessStep({
|
||||
bookingResult,
|
||||
event,
|
||||
locale,
|
||||
t,
|
||||
formatDate,
|
||||
fmtTime,
|
||||
}: SuccessStepProps) {
|
||||
const successContent = getSuccessContent(bookingResult, locale, t);
|
||||
|
||||
return (
|
||||
<div className="section-padding">
|
||||
<div className="container-page max-w-2xl">
|
||||
<Card className="p-8 text-center">
|
||||
<div className={`w-16 h-16 rounded-full ${successContent.iconColor} flex items-center justify-center mx-auto mb-6`}>
|
||||
<CheckCircleIcon className={`w-10 h-10 ${successContent.iconTextColor}`} />
|
||||
</div>
|
||||
|
||||
<h1 className="text-2xl font-bold text-primary-dark mb-2">
|
||||
{successContent.title}
|
||||
</h1>
|
||||
<p className="text-gray-600 mb-6">
|
||||
{successContent.description}
|
||||
</p>
|
||||
|
||||
<div className="bg-secondary-gray rounded-lg p-6 mb-6">
|
||||
{/* Multi-ticket indicator */}
|
||||
{bookingResult.ticketCount && bookingResult.ticketCount > 1 && (
|
||||
<div className="mb-4 pb-4 border-b border-gray-300">
|
||||
<p className="text-lg font-semibold text-primary-dark">
|
||||
{locale === 'es'
|
||||
? `${bookingResult.ticketCount} tickets reservados`
|
||||
: `${bookingResult.ticketCount} tickets booked`}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{locale === 'es'
|
||||
? 'Cada asistente recibirá su propio código QR'
|
||||
: 'Each attendee will receive their own QR code'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-center gap-2 mb-4">
|
||||
<TicketIcon className="w-6 h-6 text-primary-yellow" />
|
||||
<span className="font-mono text-lg font-bold">{bookingResult.qrCode}</span>
|
||||
{bookingResult.ticketCount && bookingResult.ticketCount > 1 && (
|
||||
<span className="text-xs bg-purple-100 text-purple-700 px-2 py-1 rounded-full">
|
||||
+{bookingResult.ticketCount - 1} {locale === 'es' ? 'más' : 'more'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-gray-600 space-y-2">
|
||||
<p><strong>{t('booking.success.event')}:</strong> {event.title}</p>
|
||||
<p><strong>{t('booking.success.date')}:</strong> {formatDate(event.startDatetime)}</p>
|
||||
<p><strong>{t('booking.success.time')}:</strong> {fmtTime(event.startDatetime)}</p>
|
||||
<p><strong>{t('booking.success.location')}:</strong> {event.location}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{bookingResult.paymentMethod === 'cash' && (
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-6">
|
||||
<p className="text-yellow-800 text-sm">
|
||||
<strong>{t('booking.success.cashNote')}:</strong> {t('booking.success.cashDescription')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{bookingResult.paymentMethod === 'bancard' && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
|
||||
<p className="text-blue-800 text-sm">
|
||||
{t('booking.success.cardNote')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{bookingResult.paymentMethod === 'lightning' && (
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4 mb-6">
|
||||
<p className="text-green-800 text-sm flex items-center gap-2">
|
||||
<CheckCircleIcon className="w-5 h-5" />
|
||||
{locale === 'es'
|
||||
? '¡Pago con Bitcoin Lightning recibido exitosamente!'
|
||||
: 'Bitcoin Lightning payment received successfully!'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-sm text-gray-500 mb-6">
|
||||
{t('booking.success.emailSent')}
|
||||
</p>
|
||||
|
||||
{/* Download Ticket Button - only for instant confirmation (Lightning) */}
|
||||
{bookingResult.paymentMethod === 'lightning' && (
|
||||
<div className="mb-6">
|
||||
<a
|
||||
href={bookingResult.bookingId
|
||||
? `/api/tickets/booking/${bookingResult.bookingId}/pdf`
|
||||
: `/api/tickets/${bookingResult.ticketId}/pdf`
|
||||
}
|
||||
download
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-primary-yellow text-primary-dark font-medium rounded-btn hover:bg-primary-yellow/90 transition-colors"
|
||||
>
|
||||
<ArrowDownTrayIcon className="w-5 h-5" />
|
||||
{locale === 'es'
|
||||
? (bookingResult.ticketCount && bookingResult.ticketCount > 1 ? 'Descargar Tickets' : 'Descargar Ticket')
|
||||
: (bookingResult.ticketCount && bookingResult.ticketCount > 1 ? 'Download Tickets' : 'Download Ticket')}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-3 justify-center">
|
||||
<Link href="/events">
|
||||
<Button variant="outline">{t('booking.success.browseEvents')}</Button>
|
||||
</Link>
|
||||
<Link href="/">
|
||||
<Button>{t('booking.success.backHome')}</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// Shared types for the booking flow.
|
||||
|
||||
export interface AttendeeInfo {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
}
|
||||
|
||||
export type PaymentMethod = 'bancard' | 'lightning' | 'cash' | 'bank_transfer' | 'tpago';
|
||||
|
||||
export interface BookingFormData {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
preferredLanguage: 'en' | 'es';
|
||||
paymentMethod: PaymentMethod;
|
||||
ruc: string;
|
||||
}
|
||||
|
||||
export interface LightningInvoice {
|
||||
paymentHash: string;
|
||||
paymentRequest: string; // BOLT11 invoice
|
||||
amount: number; // Amount in satoshis
|
||||
fiatAmount?: number; // Original fiat amount
|
||||
fiatCurrency?: string; // Original fiat currency
|
||||
expiry?: string;
|
||||
}
|
||||
|
||||
export interface BookingResult {
|
||||
ticketId: string;
|
||||
ticketIds?: string[]; // For multi-ticket bookings
|
||||
bookingId?: string;
|
||||
qrCode: string;
|
||||
qrCodes?: string[]; // For multi-ticket bookings
|
||||
paymentMethod: PaymentMethod;
|
||||
lightningInvoice?: LightningInvoice;
|
||||
ticketCount?: number;
|
||||
}
|
||||
|
||||
export type BookingStep = 'form' | 'paying' | 'manual_payment' | 'pending_approval' | 'success';
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
||||
import clsx from 'clsx';
|
||||
|
||||
export function StatusBadge({ status, compact = false }: { status: string; compact?: boolean }) {
|
||||
const styles: Record<string, string> = {
|
||||
pending: 'bg-yellow-100 text-yellow-800',
|
||||
confirmed: 'bg-green-100 text-green-800',
|
||||
cancelled: 'bg-red-100 text-red-800',
|
||||
checked_in: 'bg-blue-100 text-blue-800',
|
||||
};
|
||||
return (
|
||||
<span className={clsx(
|
||||
'inline-flex items-center rounded-full font-medium',
|
||||
compact ? 'px-1.5 py-0.5 text-[10px]' : 'px-2 py-0.5 text-xs',
|
||||
styles[status] || 'bg-gray-100 text-gray-800'
|
||||
)}>
|
||||
{status.replace('_', ' ')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { eventsApi, ticketsApi, emailsApi, Event, Ticket, EmailTemplate } from '@/lib/api';
|
||||
|
||||
/**
|
||||
* Loads the core data for the admin event detail page (event, tickets, active
|
||||
* email templates) and exposes a reload function used after mutations.
|
||||
*/
|
||||
export function useEventDetailData(eventId: string) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [event, setEvent] = useState<Event | null>(null);
|
||||
const [tickets, setTickets] = useState<Ticket[]>([]);
|
||||
const [templates, setTemplates] = useState<EmailTemplate[]>([]);
|
||||
|
||||
const loadEventData = async () => {
|
||||
try {
|
||||
const [eventRes, ticketsRes, templatesRes] = await Promise.all([
|
||||
eventsApi.getById(eventId),
|
||||
ticketsApi.getAll({ eventId }),
|
||||
emailsApi.getTemplates(),
|
||||
]);
|
||||
setEvent(eventRes.event);
|
||||
setTickets(ticketsRes.tickets);
|
||||
setTemplates(templatesRes.templates.filter(t => t.isActive));
|
||||
} catch (error) {
|
||||
toast.error('Failed to load event data');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadEventData();
|
||||
}, [eventId]);
|
||||
|
||||
return { loading, event, tickets, templates, loadEventData };
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { paymentOptionsApi, PaymentOptionsConfig } from '@/lib/api';
|
||||
|
||||
/**
|
||||
* Manages the event-level payment override editor state: loading global +
|
||||
* override config, computing effective values, editing, saving and resetting.
|
||||
*/
|
||||
export function usePaymentOverrides(eventId: string, locale: string) {
|
||||
const [globalPaymentOptions, setGlobalPaymentOptions] = useState<PaymentOptionsConfig | null>(null);
|
||||
const [paymentOverrides, setPaymentOverrides] = useState<Partial<PaymentOptionsConfig>>({});
|
||||
const [hasPaymentOverrides, setHasPaymentOverrides] = useState(false);
|
||||
const [savingPayments, setSavingPayments] = useState(false);
|
||||
const [loadingPayments, setLoadingPayments] = useState(false);
|
||||
|
||||
const loadPaymentOptions = async () => {
|
||||
if (globalPaymentOptions) return;
|
||||
setLoadingPayments(true);
|
||||
try {
|
||||
const [globalRes, overridesRes] = await Promise.all([
|
||||
paymentOptionsApi.getGlobal(),
|
||||
paymentOptionsApi.getEventOverrides(eventId),
|
||||
]);
|
||||
setGlobalPaymentOptions(globalRes.paymentOptions);
|
||||
if (overridesRes.overrides) {
|
||||
setPaymentOverrides(overridesRes.overrides);
|
||||
setHasPaymentOverrides(true);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to load payment options');
|
||||
} finally {
|
||||
setLoadingPayments(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getEffectivePaymentOption = <K extends keyof PaymentOptionsConfig>(key: K): PaymentOptionsConfig[K] => {
|
||||
if (paymentOverrides[key] !== undefined && paymentOverrides[key] !== null) {
|
||||
return paymentOverrides[key] as PaymentOptionsConfig[K];
|
||||
}
|
||||
return globalPaymentOptions?.[key] as PaymentOptionsConfig[K];
|
||||
};
|
||||
|
||||
const updatePaymentOverride = <K extends keyof PaymentOptionsConfig>(
|
||||
key: K,
|
||||
value: PaymentOptionsConfig[K] | null
|
||||
) => {
|
||||
setPaymentOverrides((prev) => ({ ...prev, [key]: value }));
|
||||
setHasPaymentOverrides(true);
|
||||
};
|
||||
|
||||
const handleSavePaymentOptions = async () => {
|
||||
setSavingPayments(true);
|
||||
try {
|
||||
await paymentOptionsApi.updateEventOverrides(eventId, paymentOverrides);
|
||||
toast.success(locale === 'es' ? 'Opciones de pago guardadas' : 'Payment options saved');
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Failed to save payment options');
|
||||
} finally {
|
||||
setSavingPayments(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetToGlobal = async () => {
|
||||
if (!confirm(locale === 'es'
|
||||
? '¿Resetear a la configuración global? Se eliminarán todas las personalizaciones de este evento.'
|
||||
: 'Reset to global settings? This will remove all customizations for this event.')) {
|
||||
return;
|
||||
}
|
||||
setSavingPayments(true);
|
||||
try {
|
||||
await paymentOptionsApi.deleteEventOverrides(eventId);
|
||||
setPaymentOverrides({});
|
||||
setHasPaymentOverrides(false);
|
||||
toast.success(locale === 'es' ? 'Restablecido a configuración global' : 'Reset to global settings');
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Failed to reset payment options');
|
||||
} finally {
|
||||
setSavingPayments(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
globalPaymentOptions,
|
||||
paymentOverrides,
|
||||
hasPaymentOverrides,
|
||||
savingPayments,
|
||||
loadingPayments,
|
||||
loadPaymentOptions,
|
||||
getEffectivePaymentOption,
|
||||
updatePaymentOverride,
|
||||
handleSavePaymentOptions,
|
||||
handleResetToGlobal,
|
||||
};
|
||||
}
|
||||
|
||||
export type PaymentOverridesController = ReturnType<typeof usePaymentOverrides>;
|
||||
@@ -0,0 +1,521 @@
|
||||
import type { Dispatch, FormEvent, SetStateAction } from 'react';
|
||||
import { Ticket } from '@/lib/api';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import { BottomSheet } from '@/components/admin/MobileComponents';
|
||||
import clsx from 'clsx';
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
EnvelopeIcon,
|
||||
PlusIcon,
|
||||
StarIcon,
|
||||
XMarkIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import type { AttendeeStatusFilter, AttendeeFormState, AddAtDoorFormState } from '../_types';
|
||||
|
||||
interface EventModalsProps {
|
||||
// counts + filter
|
||||
ticketsCount: number;
|
||||
pendingCount: number;
|
||||
confirmedCount: number;
|
||||
checkedInCount: number;
|
||||
cancelledCount: number;
|
||||
statusFilter: AttendeeStatusFilter;
|
||||
setStatusFilter: (value: AttendeeStatusFilter) => void;
|
||||
// mobile filter sheet
|
||||
mobileFilterOpen: boolean;
|
||||
setMobileFilterOpen: (value: boolean) => void;
|
||||
// add ticket sheet
|
||||
showAddTicketSheet: boolean;
|
||||
setShowAddTicketSheet: (value: boolean) => void;
|
||||
// export sheets
|
||||
showExportSheet: boolean;
|
||||
setShowExportSheet: (value: boolean) => void;
|
||||
handleExportAttendees: (status: 'confirmed' | 'checked_in' | 'confirmed_pending' | 'all') => void;
|
||||
showTicketExportSheet: boolean;
|
||||
setShowTicketExportSheet: (value: boolean) => void;
|
||||
handleExportTickets: (status: 'confirmed' | 'checked_in' | 'all') => void;
|
||||
// add at door
|
||||
showAddAtDoorModal: boolean;
|
||||
setShowAddAtDoorModal: (value: boolean) => void;
|
||||
addAtDoorForm: AddAtDoorFormState;
|
||||
setAddAtDoorForm: Dispatch<SetStateAction<AddAtDoorFormState>>;
|
||||
handleAddAtDoor: (e: FormEvent) => void;
|
||||
// manual ticket
|
||||
showManualTicketModal: boolean;
|
||||
setShowManualTicketModal: (value: boolean) => void;
|
||||
manualTicketForm: AttendeeFormState;
|
||||
setManualTicketForm: Dispatch<SetStateAction<AttendeeFormState>>;
|
||||
handleManualTicket: (e: FormEvent) => void;
|
||||
// invite guest
|
||||
showInviteGuestModal: boolean;
|
||||
setShowInviteGuestModal: (value: boolean) => void;
|
||||
inviteGuestForm: AttendeeFormState;
|
||||
setInviteGuestForm: Dispatch<SetStateAction<AttendeeFormState>>;
|
||||
handleInviteGuest: (e: FormEvent) => void;
|
||||
// shared submit flag
|
||||
submitting: boolean;
|
||||
// note modal
|
||||
showNoteModal: boolean;
|
||||
setShowNoteModal: (value: boolean) => void;
|
||||
selectedTicket: Ticket | null;
|
||||
setSelectedTicket: (value: Ticket | null) => void;
|
||||
noteText: string;
|
||||
setNoteText: (value: string) => void;
|
||||
handleSaveNote: () => void;
|
||||
// preview modal
|
||||
previewHtml: string | null;
|
||||
setPreviewHtml: (value: string | null) => void;
|
||||
}
|
||||
|
||||
export function EventModals(props: EventModalsProps) {
|
||||
const {
|
||||
ticketsCount,
|
||||
pendingCount,
|
||||
confirmedCount,
|
||||
checkedInCount,
|
||||
cancelledCount,
|
||||
statusFilter,
|
||||
setStatusFilter,
|
||||
mobileFilterOpen,
|
||||
setMobileFilterOpen,
|
||||
showAddTicketSheet,
|
||||
setShowAddTicketSheet,
|
||||
showExportSheet,
|
||||
setShowExportSheet,
|
||||
handleExportAttendees,
|
||||
showTicketExportSheet,
|
||||
setShowTicketExportSheet,
|
||||
handleExportTickets,
|
||||
showAddAtDoorModal,
|
||||
setShowAddAtDoorModal,
|
||||
addAtDoorForm,
|
||||
setAddAtDoorForm,
|
||||
handleAddAtDoor,
|
||||
showManualTicketModal,
|
||||
setShowManualTicketModal,
|
||||
manualTicketForm,
|
||||
setManualTicketForm,
|
||||
handleManualTicket,
|
||||
showInviteGuestModal,
|
||||
setShowInviteGuestModal,
|
||||
inviteGuestForm,
|
||||
setInviteGuestForm,
|
||||
handleInviteGuest,
|
||||
submitting,
|
||||
showNoteModal,
|
||||
setShowNoteModal,
|
||||
selectedTicket,
|
||||
setSelectedTicket,
|
||||
noteText,
|
||||
setNoteText,
|
||||
handleSaveNote,
|
||||
previewHtml,
|
||||
setPreviewHtml,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile filter bottom sheet */}
|
||||
<BottomSheet
|
||||
open={mobileFilterOpen}
|
||||
onClose={() => setMobileFilterOpen(false)}
|
||||
title="Filter by Status"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
{[
|
||||
{ value: 'all', label: `All (${ticketsCount})` },
|
||||
{ value: 'pending', label: `Pending (${pendingCount})` },
|
||||
{ value: 'confirmed', label: `Confirmed (${confirmedCount})` },
|
||||
{ value: 'checked_in', label: `Checked In (${checkedInCount})` },
|
||||
{ value: 'cancelled', label: `Cancelled (${cancelledCount})` },
|
||||
].map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
onClick={() => { setStatusFilter(option.value as AttendeeStatusFilter); setMobileFilterOpen(false); }}
|
||||
className={clsx(
|
||||
'w-full text-left px-4 py-3 rounded-btn text-sm min-h-[44px] flex items-center justify-between',
|
||||
statusFilter === option.value ? 'bg-yellow-50 text-primary-dark font-medium' : 'hover:bg-gray-50'
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
{statusFilter === option.value && <CheckCircleIcon className="w-4 h-4 text-primary-yellow" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</BottomSheet>
|
||||
|
||||
{/* Mobile FAB bottom sheet */}
|
||||
<BottomSheet
|
||||
open={showAddTicketSheet}
|
||||
onClose={() => setShowAddTicketSheet(false)}
|
||||
title="Add Ticket"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<button
|
||||
onClick={() => { setShowManualTicketModal(true); setShowAddTicketSheet(false); }}
|
||||
className="w-full text-left px-4 py-3 rounded-btn text-sm hover:bg-gray-50 min-h-[44px] flex items-center gap-3"
|
||||
>
|
||||
<EnvelopeIcon className="w-5 h-5 text-gray-500" />
|
||||
<div>
|
||||
<p className="font-medium">Manual Ticket</p>
|
||||
<p className="text-xs text-gray-500">Send confirmation email with ticket</p>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setShowAddAtDoorModal(true); setShowAddTicketSheet(false); }}
|
||||
className="w-full text-left px-4 py-3 rounded-btn text-sm hover:bg-gray-50 min-h-[44px] flex items-center gap-3"
|
||||
>
|
||||
<PlusIcon className="w-5 h-5 text-gray-500" />
|
||||
<div>
|
||||
<p className="font-medium">Add at Door</p>
|
||||
<p className="text-xs text-gray-500">Quick add with optional auto check-in</p>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setShowInviteGuestModal(true); setShowAddTicketSheet(false); }}
|
||||
className="w-full text-left px-4 py-3 rounded-btn text-sm hover:bg-gray-50 min-h-[44px] flex items-center gap-3"
|
||||
>
|
||||
<StarIcon className="w-5 h-5 text-gray-500" />
|
||||
<div>
|
||||
<p className="font-medium">Invite Guest</p>
|
||||
<p className="text-xs text-gray-500">Free ticket, not counted in revenue</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</BottomSheet>
|
||||
|
||||
{/* Mobile export bottom sheet (attendees) */}
|
||||
<BottomSheet
|
||||
open={showExportSheet}
|
||||
onClose={() => setShowExportSheet(false)}
|
||||
title="Export Attendees"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
{[
|
||||
{ status: 'all' as const, label: 'Export All' },
|
||||
{ status: 'confirmed' as const, label: 'Export Confirmed' },
|
||||
{ status: 'checked_in' as const, label: 'Export Checked-in' },
|
||||
{ status: 'confirmed_pending' as const, label: 'Confirmed & Pending' },
|
||||
].map((opt) => (
|
||||
<button
|
||||
key={opt.status}
|
||||
onClick={() => { handleExportAttendees(opt.status); setShowExportSheet(false); }}
|
||||
className="w-full text-left px-4 py-3 rounded-btn text-sm hover:bg-gray-50 min-h-[44px]"
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
<p className="text-[10px] text-gray-400 px-4 pt-2">Format: CSV</p>
|
||||
</div>
|
||||
</BottomSheet>
|
||||
|
||||
{/* Mobile export bottom sheet (tickets) */}
|
||||
<BottomSheet
|
||||
open={showTicketExportSheet}
|
||||
onClose={() => setShowTicketExportSheet(false)}
|
||||
title="Export Tickets"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
{[
|
||||
{ status: 'all' as const, label: 'Export All' },
|
||||
{ status: 'confirmed' as const, label: 'Export Valid' },
|
||||
{ status: 'checked_in' as const, label: 'Export Checked-in' },
|
||||
].map((opt) => (
|
||||
<button
|
||||
key={opt.status}
|
||||
onClick={() => { handleExportTickets(opt.status); setShowTicketExportSheet(false); }}
|
||||
className="w-full text-left px-4 py-3 rounded-btn text-sm hover:bg-gray-50 min-h-[44px]"
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
<p className="text-[10px] text-gray-400 px-4 pt-2">Format: CSV</p>
|
||||
</div>
|
||||
</BottomSheet>
|
||||
|
||||
{/* Add at Door Modal */}
|
||||
{showAddAtDoorModal && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-50 flex items-end md:items-center justify-center p-0 md:p-4"
|
||||
onClick={() => setShowAddAtDoorModal(false)}
|
||||
role="presentation"
|
||||
>
|
||||
<Card
|
||||
className="w-full md:max-w-md max-h-[90vh] flex flex-col overflow-hidden rounded-t-2xl md:rounded-card"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray flex-shrink-0">
|
||||
<h2 className="text-base font-bold">Add Attendee at Door</h2>
|
||||
<button
|
||||
onClick={() => setShowAddAtDoorModal(false)}
|
||||
className="p-2 hover:bg-gray-100 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center"
|
||||
>
|
||||
<XMarkIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={handleAddAtDoor} className="p-4 space-y-3 overflow-y-auto flex-1 min-h-0">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1">First Name *</label>
|
||||
<input type="text" required value={addAtDoorForm.firstName}
|
||||
onChange={(e) => setAddAtDoorForm({ ...addAtDoorForm, firstName: e.target.value })}
|
||||
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
placeholder="First name" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1">Last Name</label>
|
||||
<input type="text" value={addAtDoorForm.lastName}
|
||||
onChange={(e) => setAddAtDoorForm({ ...addAtDoorForm, lastName: e.target.value })}
|
||||
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
placeholder="Last name" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1">Email</label>
|
||||
<input type="email" value={addAtDoorForm.email}
|
||||
onChange={(e) => setAddAtDoorForm({ ...addAtDoorForm, email: e.target.value })}
|
||||
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
placeholder="email@example.com" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1">Phone</label>
|
||||
<input type="tel" value={addAtDoorForm.phone}
|
||||
onChange={(e) => setAddAtDoorForm({ ...addAtDoorForm, phone: e.target.value })}
|
||||
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
placeholder="+595 981 123456" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1">Admin Note</label>
|
||||
<textarea value={addAtDoorForm.adminNote}
|
||||
onChange={(e) => setAddAtDoorForm({ ...addAtDoorForm, adminNote: e.target.value })}
|
||||
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
rows={2} placeholder="Internal note..." />
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input type="checkbox" id="autoCheckin" checked={addAtDoorForm.autoCheckin}
|
||||
onChange={(e) => setAddAtDoorForm({ ...addAtDoorForm, autoCheckin: e.target.checked })}
|
||||
className="w-4 h-4 rounded border-secondary-light-gray text-primary-yellow focus:ring-primary-yellow" />
|
||||
<label htmlFor="autoCheckin" className="text-sm font-medium">Auto check-in immediately</label>
|
||||
</div>
|
||||
<div className="flex gap-3 pt-2">
|
||||
<Button type="button" variant="outline" onClick={() => setShowAddAtDoorModal(false)} className="flex-1 min-h-[44px]">Cancel</Button>
|
||||
<Button type="submit" isLoading={submitting} className="flex-1 min-h-[44px]">Add Attendee</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Manual Ticket Modal */}
|
||||
{showManualTicketModal && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-50 flex items-end md:items-center justify-center p-0 md:p-4"
|
||||
onClick={() => setShowManualTicketModal(false)}
|
||||
role="presentation"
|
||||
>
|
||||
<Card
|
||||
className="w-full md:max-w-md max-h-[90vh] flex flex-col overflow-hidden rounded-t-2xl md:rounded-card"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray flex-shrink-0">
|
||||
<div>
|
||||
<h2 className="text-base font-bold">Create Manual Ticket</h2>
|
||||
<p className="text-xs text-gray-500">Confirmation email will be sent</p>
|
||||
</div>
|
||||
<button onClick={() => setShowManualTicketModal(false)}
|
||||
className="p-2 hover:bg-gray-100 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center">
|
||||
<XMarkIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={handleManualTicket} className="p-4 space-y-3 overflow-y-auto flex-1 min-h-0">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1">First Name *</label>
|
||||
<input type="text" required value={manualTicketForm.firstName}
|
||||
onChange={(e) => setManualTicketForm({ ...manualTicketForm, firstName: e.target.value })}
|
||||
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
placeholder="First name" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1">Last Name</label>
|
||||
<input type="text" value={manualTicketForm.lastName}
|
||||
onChange={(e) => setManualTicketForm({ ...manualTicketForm, lastName: e.target.value })}
|
||||
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
placeholder="Last name" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1">Email *</label>
|
||||
<input type="email" required value={manualTicketForm.email}
|
||||
onChange={(e) => setManualTicketForm({ ...manualTicketForm, email: e.target.value })}
|
||||
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
placeholder="email@example.com" />
|
||||
<p className="text-[10px] text-gray-500 mt-1">Ticket will be sent to this email</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1">Phone</label>
|
||||
<input type="tel" value={manualTicketForm.phone}
|
||||
onChange={(e) => setManualTicketForm({ ...manualTicketForm, phone: e.target.value })}
|
||||
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
placeholder="+595 981 123456" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1">Admin Note</label>
|
||||
<textarea value={manualTicketForm.adminNote}
|
||||
onChange={(e) => setManualTicketForm({ ...manualTicketForm, adminNote: e.target.value })}
|
||||
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
rows={2} placeholder="Internal note..." />
|
||||
</div>
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<EnvelopeIcon className="w-4 h-4 text-blue-500 mt-0.5 flex-shrink-0" />
|
||||
<div className="text-xs text-blue-800">
|
||||
<p className="font-medium">This will send:</p>
|
||||
<ul className="list-disc ml-4 mt-0.5 space-y-0.5">
|
||||
<li>Booking confirmation email</li>
|
||||
<li>Ticket with QR code</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3 pt-2">
|
||||
<Button type="button" variant="outline" onClick={() => setShowManualTicketModal(false)} className="flex-1 min-h-[44px]">Cancel</Button>
|
||||
<Button type="submit" isLoading={submitting} className="flex-1 min-h-[44px]">
|
||||
<EnvelopeIcon className="w-4 h-4 mr-1.5" />
|
||||
Create & Send
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Invite Guest Modal */}
|
||||
{showInviteGuestModal && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-50 flex items-end md:items-center justify-center p-0 md:p-4"
|
||||
onClick={() => setShowInviteGuestModal(false)}
|
||||
role="presentation"
|
||||
>
|
||||
<Card
|
||||
className="w-full md:max-w-md max-h-[90vh] flex flex-col overflow-hidden rounded-t-2xl md:rounded-card"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray flex-shrink-0">
|
||||
<div>
|
||||
<h2 className="text-base font-bold">Invite Guest</h2>
|
||||
<p className="text-xs text-gray-500">Free ticket — not counted in revenue</p>
|
||||
</div>
|
||||
<button onClick={() => setShowInviteGuestModal(false)}
|
||||
className="p-2 hover:bg-gray-100 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center">
|
||||
<XMarkIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={handleInviteGuest} className="p-4 space-y-3 overflow-y-auto flex-1 min-h-0">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1">First Name *</label>
|
||||
<input type="text" required value={inviteGuestForm.firstName}
|
||||
onChange={(e) => setInviteGuestForm({ ...inviteGuestForm, firstName: e.target.value })}
|
||||
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
placeholder="First name" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1">Last Name</label>
|
||||
<input type="text" value={inviteGuestForm.lastName}
|
||||
onChange={(e) => setInviteGuestForm({ ...inviteGuestForm, lastName: e.target.value })}
|
||||
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
placeholder="Last name" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1">Email</label>
|
||||
<input type="email" value={inviteGuestForm.email}
|
||||
onChange={(e) => setInviteGuestForm({ ...inviteGuestForm, email: e.target.value })}
|
||||
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
placeholder="email@example.com (optional)" />
|
||||
<p className="text-[10px] text-gray-500 mt-1">If provided, a confirmation email will be sent</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1">Phone</label>
|
||||
<input type="tel" value={inviteGuestForm.phone}
|
||||
onChange={(e) => setInviteGuestForm({ ...inviteGuestForm, phone: e.target.value })}
|
||||
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
placeholder="+595 981 123456" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1">Admin Note</label>
|
||||
<textarea value={inviteGuestForm.adminNote}
|
||||
onChange={(e) => setInviteGuestForm({ ...inviteGuestForm, adminNote: e.target.value })}
|
||||
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
rows={2} placeholder="Internal note..." />
|
||||
</div>
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<StarIcon className="w-4 h-4 text-amber-500 mt-0.5 flex-shrink-0" />
|
||||
<p className="text-xs text-amber-800">
|
||||
Guest tickets are <strong>free</strong> and are automatically confirmed. They are not counted toward revenue or paid ticket totals.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3 pt-2">
|
||||
<Button type="button" variant="outline" onClick={() => setShowInviteGuestModal(false)} className="flex-1 min-h-[44px]">Cancel</Button>
|
||||
<Button type="submit" isLoading={submitting} className="flex-1 min-h-[44px]">
|
||||
<StarIcon className="w-4 h-4 mr-1.5" />
|
||||
Invite Guest
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Note Modal */}
|
||||
{showNoteModal && selectedTicket && (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-end md:items-center justify-center p-0 md:p-4">
|
||||
<Card className="w-full md:max-w-md rounded-t-2xl md:rounded-card">
|
||||
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray">
|
||||
<div>
|
||||
<h2 className="text-base font-bold">Admin Note</h2>
|
||||
<p className="text-xs text-gray-500">{selectedTicket.attendeeFirstName} {selectedTicket.attendeeLastName || ''}</p>
|
||||
</div>
|
||||
<button onClick={() => { setShowNoteModal(false); setSelectedTicket(null); }}
|
||||
className="p-2 hover:bg-gray-100 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center">
|
||||
<XMarkIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-4 space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium mb-1">Note</label>
|
||||
<textarea value={noteText} onChange={(e) => setNoteText(e.target.value)}
|
||||
className="w-full px-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
rows={4} placeholder="Add a private note..." maxLength={1000} />
|
||||
<p className="text-[10px] text-gray-400 mt-1 text-right">{noteText.length}/1000</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Button variant="outline" onClick={() => { setShowNoteModal(false); setSelectedTicket(null); }} className="flex-1 min-h-[44px]">Cancel</Button>
|
||||
<Button onClick={handleSaveNote} isLoading={submitting} className="flex-1 min-h-[44px]">Save Note</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Preview Modal */}
|
||||
{previewHtml && (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-3xl max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray">
|
||||
<h2 className="text-base font-bold">Email Preview</h2>
|
||||
<Button variant="outline" size="sm" onClick={() => setPreviewHtml(null)} className="min-h-[44px] md:min-h-0">Close</Button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<iframe srcDoc={previewHtml} sandbox="" className="w-full h-full min-h-[500px]" title="Email Preview" />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
import { Ticket } from '@/lib/api';
|
||||
import { parseDate, EVENT_TIMEZONE } from '@/lib/utils';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import { Dropdown, DropdownItem, MoreMenu } from '@/components/admin/MobileComponents';
|
||||
import clsx from 'clsx';
|
||||
import {
|
||||
MagnifyingGlassIcon,
|
||||
ChevronDownIcon,
|
||||
ArrowDownTrayIcon,
|
||||
PlusIcon,
|
||||
EnvelopeIcon,
|
||||
StarIcon,
|
||||
FunnelIcon,
|
||||
ChatBubbleLeftIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { StatusBadge } from '../_components/StatusBadge';
|
||||
import type { AttendeeStatusFilter, PrimaryAction } from '../_types';
|
||||
|
||||
interface AttendeesTabProps {
|
||||
locale: string;
|
||||
tickets: Ticket[];
|
||||
filteredTickets: Ticket[];
|
||||
searchQuery: string;
|
||||
setSearchQuery: (value: string) => void;
|
||||
statusFilter: AttendeeStatusFilter;
|
||||
setStatusFilter: (value: AttendeeStatusFilter) => void;
|
||||
pendingCount: number;
|
||||
confirmedCount: number;
|
||||
checkedInCount: number;
|
||||
cancelledCount: number;
|
||||
exporting: boolean;
|
||||
showExportDropdown: boolean;
|
||||
setShowExportDropdown: (value: boolean) => void;
|
||||
showAddTicketDropdown: boolean;
|
||||
setShowAddTicketDropdown: (value: boolean) => void;
|
||||
handleExportAttendees: (status: 'confirmed' | 'checked_in' | 'confirmed_pending' | 'all') => void;
|
||||
setShowManualTicketModal: (value: boolean) => void;
|
||||
setShowAddAtDoorModal: (value: boolean) => void;
|
||||
setShowInviteGuestModal: (value: boolean) => void;
|
||||
setMobileFilterOpen: (value: boolean) => void;
|
||||
setShowExportSheet: (value: boolean) => void;
|
||||
setShowAddTicketSheet: (value: boolean) => void;
|
||||
getPrimaryAction: (ticket: Ticket) => PrimaryAction | null;
|
||||
handleOpenNoteModal: (ticket: Ticket) => void;
|
||||
}
|
||||
|
||||
export function AttendeesTab({
|
||||
locale,
|
||||
tickets,
|
||||
filteredTickets,
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
statusFilter,
|
||||
setStatusFilter,
|
||||
pendingCount,
|
||||
confirmedCount,
|
||||
checkedInCount,
|
||||
cancelledCount,
|
||||
exporting,
|
||||
showExportDropdown,
|
||||
setShowExportDropdown,
|
||||
showAddTicketDropdown,
|
||||
setShowAddTicketDropdown,
|
||||
handleExportAttendees,
|
||||
setShowManualTicketModal,
|
||||
setShowAddAtDoorModal,
|
||||
setShowInviteGuestModal,
|
||||
setMobileFilterOpen,
|
||||
setShowExportSheet,
|
||||
setShowAddTicketSheet,
|
||||
getPrimaryAction,
|
||||
handleOpenNoteModal,
|
||||
}: AttendeesTabProps) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Desktop toolbar */}
|
||||
<Card className="p-3 hidden md:block">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Left: Search + Status */}
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<MagnifyingGlassIcon className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search name, email, phone..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-9 pr-3 py-1.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value as AttendeeStatusFilter)}
|
||||
className="px-3 py-1.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
>
|
||||
<option value="all">All ({tickets.length})</option>
|
||||
<option value="pending">Pending ({pendingCount})</option>
|
||||
<option value="confirmed">Confirmed ({confirmedCount})</option>
|
||||
<option value="checked_in">Checked In ({checkedInCount})</option>
|
||||
<option value="cancelled">Cancelled ({cancelledCount})</option>
|
||||
</select>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Right: Export + Add Ticket dropdown */}
|
||||
<Dropdown
|
||||
open={showExportDropdown}
|
||||
onOpenChange={setShowExportDropdown}
|
||||
trigger={
|
||||
<Button variant="outline" size="sm" disabled={exporting}>
|
||||
{exporting ? (
|
||||
<div className="w-3.5 h-3.5 mr-1.5 border-2 border-gray-400 border-t-transparent rounded-full animate-spin" />
|
||||
) : (
|
||||
<ArrowDownTrayIcon className="w-3.5 h-3.5 mr-1.5" />
|
||||
)}
|
||||
Export
|
||||
<ChevronDownIcon className="w-3 h-3 ml-1" />
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<DropdownItem onClick={() => handleExportAttendees('all')}>Export All</DropdownItem>
|
||||
<DropdownItem onClick={() => handleExportAttendees('confirmed')}>Export Confirmed</DropdownItem>
|
||||
<DropdownItem onClick={() => handleExportAttendees('checked_in')}>Export Checked-in</DropdownItem>
|
||||
<DropdownItem onClick={() => handleExportAttendees('confirmed_pending')}>Confirmed & Pending</DropdownItem>
|
||||
<div className="border-t border-gray-100 mx-2" />
|
||||
<div className="px-4 py-1.5 text-[10px] text-gray-400">Format: CSV</div>
|
||||
</Dropdown>
|
||||
|
||||
<Dropdown
|
||||
open={showAddTicketDropdown}
|
||||
onOpenChange={setShowAddTicketDropdown}
|
||||
trigger={
|
||||
<Button size="sm">
|
||||
<PlusIcon className="w-3.5 h-3.5 mr-1.5" />
|
||||
Add Ticket
|
||||
<ChevronDownIcon className="w-3 h-3 ml-1" />
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<DropdownItem onClick={() => { setShowManualTicketModal(true); setShowAddTicketDropdown(false); }}>
|
||||
<EnvelopeIcon className="w-4 h-4 mr-2" /> Manual Ticket
|
||||
</DropdownItem>
|
||||
<DropdownItem onClick={() => { setShowAddAtDoorModal(true); setShowAddTicketDropdown(false); }}>
|
||||
<PlusIcon className="w-4 h-4 mr-2" /> Add at Door
|
||||
</DropdownItem>
|
||||
<DropdownItem onClick={() => { setShowInviteGuestModal(true); setShowAddTicketDropdown(false); }}>
|
||||
<StarIcon className="w-4 h-4 mr-2" /> Invite Guest
|
||||
</DropdownItem>
|
||||
</Dropdown>
|
||||
</div>
|
||||
{(searchQuery || statusFilter !== 'all') && (
|
||||
<div className="mt-2 text-xs text-gray-500 flex items-center gap-2">
|
||||
<span>Showing {filteredTickets.length} of {tickets.length}</span>
|
||||
<button onClick={() => { setSearchQuery(''); setStatusFilter('all'); }} className="text-primary-yellow hover:underline">
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Mobile toolbar */}
|
||||
<div className="md:hidden space-y-2">
|
||||
<div className="relative">
|
||||
<MagnifyingGlassIcon className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search name, email, phone..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-9 pr-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setMobileFilterOpen(true)}
|
||||
className={clsx(
|
||||
'flex items-center gap-1.5 px-3 py-2 rounded-btn border text-sm min-h-[44px]',
|
||||
statusFilter !== 'all'
|
||||
? 'border-primary-yellow bg-yellow-50 text-primary-dark'
|
||||
: 'border-secondary-light-gray text-gray-600'
|
||||
)}
|
||||
>
|
||||
<FunnelIcon className="w-4 h-4" />
|
||||
{statusFilter === 'all' ? 'Filter' : statusFilter.replace('_', ' ')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowExportSheet(true)}
|
||||
disabled={exporting}
|
||||
className="flex items-center gap-1.5 px-3 py-2 rounded-btn border border-secondary-light-gray text-sm text-gray-600 min-h-[44px]"
|
||||
>
|
||||
<ArrowDownTrayIcon className="w-4 h-4" />
|
||||
Export
|
||||
</button>
|
||||
{(searchQuery || statusFilter !== 'all') && (
|
||||
<button
|
||||
onClick={() => { setSearchQuery(''); setStatusFilter('all'); }}
|
||||
className="text-xs text-primary-yellow ml-auto min-h-[44px] flex items-center"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{(searchQuery || statusFilter !== 'all') && (
|
||||
<p className="text-xs text-gray-500">Showing {filteredTickets.length} of {tickets.length}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Desktop: Dense table */}
|
||||
<Card className="overflow-hidden hidden md:block">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Attendee</th>
|
||||
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Contact</th>
|
||||
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
||||
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Booked</th>
|
||||
<th className="text-right px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{filteredTickets.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-4 py-10 text-center text-gray-500 text-sm">
|
||||
{tickets.length === 0 ? 'No attendees yet' : 'No attendees match the current filters'}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredTickets.map((ticket) => {
|
||||
const primary = getPrimaryAction(ticket);
|
||||
return (
|
||||
<tr key={ticket.id} className="hover:bg-gray-50/50">
|
||||
<td className="px-4 py-2.5">
|
||||
<p className="font-medium text-sm">{ticket.attendeeFirstName} {ticket.attendeeLastName || ''}</p>
|
||||
{ticket.bookingId && (
|
||||
<span className="text-[10px] text-purple-600" title={`Booking: ${ticket.bookingId}`}>
|
||||
Group booking
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<p className="text-sm text-gray-600 truncate max-w-[200px]">{ticket.attendeeEmail}</p>
|
||||
{ticket.attendeePhone && <p className="text-xs text-gray-400">{ticket.attendeePhone}</p>}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
<StatusBadge status={ticket.status} compact />
|
||||
{!!ticket.isGuest && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] rounded-full bg-amber-100 text-amber-700 font-medium">Guest</span>
|
||||
)}
|
||||
</div>
|
||||
{ticket.checkinAt && (
|
||||
<p className="text-[10px] text-gray-400 mt-0.5">
|
||||
{parseDate(ticket.checkinAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', timeZone: EVENT_TIMEZONE })}
|
||||
</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-xs text-gray-500">
|
||||
{parseDate(ticket.createdAt).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { timeZone: EVENT_TIMEZONE })}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{primary && (
|
||||
<Button size="sm" variant={primary.variant} onClick={primary.onClick} className="text-xs px-2 py-1">
|
||||
{primary.icon && <primary.icon className="w-3 h-3 mr-1" />}
|
||||
{primary.label}
|
||||
</Button>
|
||||
)}
|
||||
<MoreMenu>
|
||||
<DropdownItem onClick={() => handleOpenNoteModal(ticket)}>
|
||||
<ChatBubbleLeftIcon className="w-4 h-4 mr-2" />
|
||||
{ticket.adminNote ? 'Edit Note' : 'Add Note'}
|
||||
</DropdownItem>
|
||||
{ticket.adminNote && (
|
||||
<div className="px-4 py-1.5 text-[10px] text-gray-400 truncate max-w-[180px]">
|
||||
Note: {ticket.adminNote}
|
||||
</div>
|
||||
)}
|
||||
<div className="px-4 py-1.5 text-[10px] text-gray-400 font-mono" title={ticket.id}>
|
||||
ID: {ticket.id.slice(0, 8)}...
|
||||
</div>
|
||||
</MoreMenu>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Mobile: Card layout */}
|
||||
<div className="md:hidden space-y-2">
|
||||
{filteredTickets.length === 0 ? (
|
||||
<div className="text-center py-10 text-gray-500 text-sm">
|
||||
{tickets.length === 0 ? 'No attendees yet' : 'No attendees match the current filters'}
|
||||
</div>
|
||||
) : (
|
||||
filteredTickets.map((ticket) => {
|
||||
const primary = getPrimaryAction(ticket);
|
||||
return (
|
||||
<Card key={ticket.id} className="p-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium text-sm truncate">{ticket.attendeeFirstName} {ticket.attendeeLastName || ''}</p>
|
||||
<p className="text-xs text-gray-500 truncate">{ticket.attendeeEmail}</p>
|
||||
{ticket.attendeePhone && <p className="text-[10px] text-gray-400">{ticket.attendeePhone}</p>}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0 flex-wrap justify-end">
|
||||
<StatusBadge status={ticket.status} compact />
|
||||
{!!ticket.isGuest && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] rounded-full bg-amber-100 text-amber-700 font-medium">Guest</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-2 pt-2 border-t border-gray-100">
|
||||
<p className="text-[10px] text-gray-400">
|
||||
{parseDate(ticket.createdAt).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { timeZone: EVENT_TIMEZONE })}
|
||||
{ticket.checkinAt && ` · Checked in ${parseDate(ticket.checkinAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', timeZone: EVENT_TIMEZONE })}`}
|
||||
</p>
|
||||
<div className="flex items-center gap-1">
|
||||
{primary && (
|
||||
<Button size="sm" variant={primary.variant} onClick={primary.onClick} className="text-xs px-2.5 py-1.5 min-h-[36px]">
|
||||
{primary.icon && <primary.icon className="w-3 h-3 mr-1" />}
|
||||
{primary.label}
|
||||
</Button>
|
||||
)}
|
||||
<MoreMenu>
|
||||
<DropdownItem onClick={() => handleOpenNoteModal(ticket)}>
|
||||
<ChatBubbleLeftIcon className="w-4 h-4 mr-2" />
|
||||
{ticket.adminNote ? 'Edit Note' : 'Add Note'}
|
||||
</DropdownItem>
|
||||
</MoreMenu>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile FAB */}
|
||||
<div className="md:hidden fixed bottom-6 right-6 z-40">
|
||||
<button
|
||||
onClick={() => setShowAddTicketSheet(true)}
|
||||
className="w-14 h-14 bg-primary-yellow text-primary-dark rounded-full shadow-lg flex items-center justify-center hover:bg-yellow-400 active:scale-95 transition-transform"
|
||||
>
|
||||
<PlusIcon className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { EmailTemplate } from '@/lib/api';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import clsx from 'clsx';
|
||||
import {
|
||||
EyeIcon,
|
||||
PaperAirplaneIcon,
|
||||
CheckCircleIcon,
|
||||
ClockIcon,
|
||||
TicketIcon,
|
||||
XCircleIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
|
||||
type RecipientFilter = 'all' | 'confirmed' | 'pending' | 'checked_in';
|
||||
|
||||
interface EmailTabProps {
|
||||
templates: EmailTemplate[];
|
||||
selectedTemplate: string;
|
||||
setSelectedTemplate: (value: string) => void;
|
||||
recipientFilter: RecipientFilter;
|
||||
setRecipientFilter: (value: RecipientFilter) => void;
|
||||
customMessage: string;
|
||||
setCustomMessage: (value: string) => void;
|
||||
sending: boolean;
|
||||
handlePreviewEmail: () => void;
|
||||
handleSendEmail: () => void;
|
||||
getFilteredRecipientCount: () => number;
|
||||
ticketsCount: number;
|
||||
confirmedCount: number;
|
||||
pendingCount: number;
|
||||
checkedInCount: number;
|
||||
cancelledCount: number;
|
||||
}
|
||||
|
||||
export function EmailTab({
|
||||
templates,
|
||||
selectedTemplate,
|
||||
setSelectedTemplate,
|
||||
recipientFilter,
|
||||
setRecipientFilter,
|
||||
customMessage,
|
||||
setCustomMessage,
|
||||
sending,
|
||||
handlePreviewEmail,
|
||||
handleSendEmail,
|
||||
getFilteredRecipientCount,
|
||||
ticketsCount,
|
||||
confirmedCount,
|
||||
pendingCount,
|
||||
checkedInCount,
|
||||
cancelledCount,
|
||||
}: EmailTabProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<Card className="p-5">
|
||||
<h3 className="font-semibold text-base mb-3">Send Email to Attendees</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Email Template</label>
|
||||
<select
|
||||
value={selectedTemplate}
|
||||
onChange={(e) => setSelectedTemplate(e.target.value)}
|
||||
className="w-full px-3 py-2.5 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow text-sm"
|
||||
>
|
||||
<option value="">Select a template...</option>
|
||||
{templates.map((template) => (
|
||||
<option key={template.id} value={template.slug}>
|
||||
{template.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Recipients</label>
|
||||
<select
|
||||
value={recipientFilter}
|
||||
onChange={(e) => setRecipientFilter(e.target.value as RecipientFilter)}
|
||||
className="w-full px-3 py-2.5 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow text-sm"
|
||||
>
|
||||
<option value="all">All Attendees ({ticketsCount})</option>
|
||||
<option value="confirmed">Confirmed Only ({confirmedCount})</option>
|
||||
<option value="pending">Pending Only ({pendingCount})</option>
|
||||
<option value="checked_in">Checked In Only ({checkedInCount})</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Custom Message (optional)</label>
|
||||
<textarea
|
||||
value={customMessage}
|
||||
onChange={(e) => setCustomMessage(e.target.value)}
|
||||
className="w-full px-3 py-2.5 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow text-sm"
|
||||
rows={3}
|
||||
placeholder="Add a custom message that will be included in the email..."
|
||||
/>
|
||||
<p className="text-[10px] text-gray-500 mt-1">
|
||||
This message will replace the {`{{customMessage}}`} variable in the template.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 pt-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handlePreviewEmail}
|
||||
disabled={!selectedTemplate}
|
||||
className="min-h-[44px] md:min-h-0"
|
||||
>
|
||||
<EyeIcon className="w-4 h-4 mr-1.5" />
|
||||
Preview
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSendEmail}
|
||||
disabled={!selectedTemplate || getFilteredRecipientCount() === 0}
|
||||
isLoading={sending}
|
||||
className="min-h-[44px] md:min-h-0"
|
||||
>
|
||||
<PaperAirplaneIcon className="w-4 h-4 mr-1.5" />
|
||||
Send to {getFilteredRecipientCount()} {getFilteredRecipientCount() === 1 ? 'person' : 'people'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-5">
|
||||
<h3 className="font-semibold text-base mb-3">Recipient Summary</h3>
|
||||
<div className="space-y-2">
|
||||
{[
|
||||
{ label: 'Confirmed', count: confirmedCount, icon: CheckCircleIcon, color: 'text-green-500' },
|
||||
{ label: 'Pending Payment', count: pendingCount, icon: ClockIcon, color: 'text-yellow-500' },
|
||||
{ label: 'Checked In', count: checkedInCount, icon: TicketIcon, color: 'text-blue-500' },
|
||||
{ label: 'Cancelled', count: cancelledCount, icon: XCircleIcon, color: 'text-red-500' },
|
||||
].map((item) => (
|
||||
<div key={item.label} className="flex items-center justify-between p-2.5 bg-gray-50 rounded-btn">
|
||||
<div className="flex items-center gap-2">
|
||||
<item.icon className={clsx('w-4 h-4', item.color)} />
|
||||
<span className="text-sm">{item.label}</span>
|
||||
</div>
|
||||
<span className="font-semibold text-sm">{item.count}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="border-t pt-2 mt-2">
|
||||
<div className="flex items-center justify-between font-semibold text-sm">
|
||||
<span>Total Bookings</span>
|
||||
<span>{ticketsCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Event } from '@/lib/api';
|
||||
import Card from '@/components/ui/Card';
|
||||
import { CalendarIcon, MapPinIcon, CurrencyDollarIcon, UsersIcon } from '@heroicons/react/24/outline';
|
||||
|
||||
interface OverviewTabProps {
|
||||
event: Event;
|
||||
formatDate: (dateStr: string) => string;
|
||||
fmtTime: (dateStr: string) => string;
|
||||
formatCurrency: (amount: number, currency: string) => string;
|
||||
confirmedCount: number;
|
||||
checkedInCount: number;
|
||||
}
|
||||
|
||||
export function OverviewTab({ event, formatDate, fmtTime, formatCurrency, confirmedCount, checkedInCount }: OverviewTabProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<Card className="p-5">
|
||||
<h3 className="font-semibold text-base mb-3">Event Information</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<CalendarIcon className="w-5 h-5 text-gray-400 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium text-sm">Date & Time</p>
|
||||
<p className="text-sm text-gray-600">{formatDate(event.startDatetime)}</p>
|
||||
<p className="text-sm text-gray-600">{fmtTime(event.startDatetime)}{event.endDatetime && ` - ${fmtTime(event.endDatetime)}`}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<MapPinIcon className="w-5 h-5 text-gray-400 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium text-sm">Location</p>
|
||||
<p className="text-sm text-gray-600">{event.location}</p>
|
||||
{event.locationUrl && (
|
||||
<a href={event.locationUrl} target="_blank" rel="noopener" className="text-blue-600 text-xs hover:underline">
|
||||
View on Map
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<CurrencyDollarIcon className="w-5 h-5 text-gray-400 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium text-sm">Price</p>
|
||||
<p className="text-sm text-gray-600">{event.price === 0 ? 'Free' : formatCurrency(event.price, event.currency)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<UsersIcon className="w-5 h-5 text-gray-400 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium text-sm">Capacity</p>
|
||||
<p className="text-sm text-gray-600">{confirmedCount + checkedInCount} / {event.capacity} spots filled</p>
|
||||
<p className="text-xs text-gray-500">{Math.max(0, event.capacity - confirmedCount - checkedInCount)} spots remaining</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-5">
|
||||
<h3 className="font-semibold text-base mb-3">Description</h3>
|
||||
<div className="prose prose-sm max-w-none">
|
||||
<p className="text-sm text-gray-600 whitespace-pre-wrap">{event.description}</p>
|
||||
{event.descriptionEs && (
|
||||
<>
|
||||
<p className="font-medium text-sm mt-3">Spanish:</p>
|
||||
<p className="text-sm text-gray-600 whitespace-pre-wrap">{event.descriptionEs}</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{event.bannerUrl && (
|
||||
<Card className="p-5 lg:col-span-2">
|
||||
<h3 className="font-semibold text-base mb-3">Event Banner</h3>
|
||||
<img
|
||||
src={event.bannerUrl}
|
||||
alt={event.title}
|
||||
className="w-full max-h-64 object-cover rounded-lg"
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
import { PaymentOptionsConfig } from '@/lib/api';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import clsx from 'clsx';
|
||||
import {
|
||||
CreditCardIcon,
|
||||
BuildingLibraryIcon,
|
||||
BoltIcon,
|
||||
BanknotesIcon,
|
||||
ArrowPathIcon,
|
||||
CheckCircleIcon,
|
||||
XCircleIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import type { PaymentOverridesController } from '../_hooks/usePaymentOverrides';
|
||||
|
||||
interface PaymentsTabProps {
|
||||
locale: string;
|
||||
payments: PaymentOverridesController;
|
||||
}
|
||||
|
||||
export function PaymentsTab({ locale, payments }: PaymentsTabProps) {
|
||||
const {
|
||||
loadingPayments,
|
||||
hasPaymentOverrides,
|
||||
savingPayments,
|
||||
globalPaymentOptions,
|
||||
paymentOverrides,
|
||||
getEffectivePaymentOption,
|
||||
updatePaymentOverride,
|
||||
handleResetToGlobal,
|
||||
handleSavePaymentOptions,
|
||||
} = payments;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{loadingPayments ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
|
||||
<div>
|
||||
<h3 className="font-semibold text-base">
|
||||
{locale === 'es' ? 'Métodos de Pago del Evento' : 'Event Payment Methods'}
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500">
|
||||
{hasPaymentOverrides
|
||||
? (locale === 'es' ? 'Configuración personalizada' : 'Custom settings')
|
||||
: (locale === 'es' ? 'Usando configuración global' : 'Using global settings')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{hasPaymentOverrides && (
|
||||
<Button variant="outline" size="sm" onClick={handleResetToGlobal} disabled={savingPayments} className="min-h-[44px] md:min-h-0">
|
||||
<ArrowPathIcon className="w-4 h-4 mr-1.5" />
|
||||
{locale === 'es' ? 'Resetear' : 'Reset'}
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" onClick={handleSavePaymentOptions} isLoading={savingPayments} className="min-h-[44px] md:min-h-0">
|
||||
{locale === 'es' ? 'Guardar' : 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* TPago */}
|
||||
<Card>
|
||||
<div className="p-4 md:p-5">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-8 h-8 bg-blue-100 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<CreditCardIcon className="w-4 h-4 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-sm">
|
||||
{locale === 'es' ? 'TPago / Tarjeta' : 'TPago / Card'}
|
||||
</h4>
|
||||
<p className="text-[10px] text-gray-500">
|
||||
{locale === 'es' ? 'Requiere aprobación' : 'Requires approval'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{globalPaymentOptions && !globalPaymentOptions.tpagoEnabled && (
|
||||
<span className="text-[10px] text-gray-400 hidden sm:inline">
|
||||
{locale === 'es' ? '(Deshabilitado global)' : '(Disabled globally)'}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => updatePaymentOverride('tpagoEnabled', !getEffectivePaymentOption('tpagoEnabled'))}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
getEffectivePaymentOption('tpagoEnabled') ? 'bg-primary-yellow' : 'bg-gray-300'
|
||||
}`}
|
||||
>
|
||||
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
getEffectivePaymentOption('tpagoEnabled') ? 'translate-x-6' : 'translate-x-1'
|
||||
}`} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{getEffectivePaymentOption('tpagoEnabled') && (
|
||||
<div className="space-y-3 pt-3 border-t">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||
{locale === 'es' ? 'Enlaces de Pago TPago (por cantidad de tickets)' : 'TPago Payment Links (per ticket quantity)'}
|
||||
</label>
|
||||
<p className="text-[10px] text-gray-500 mb-2">
|
||||
{locale === 'es'
|
||||
? 'Cada enlace tiene un monto fijo. Un enlace distinto por cantidad de tickets.'
|
||||
: 'Each link has a fixed amount. One link per ticket quantity.'}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{([1, 2, 3, 4, 5] as const).map((qty) => {
|
||||
const key = (qty === 1 ? 'tpagoLink' : `tpagoLink${qty}`) as keyof PaymentOptionsConfig;
|
||||
return (
|
||||
<div key={qty} className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium text-gray-600 w-20 flex-shrink-0">
|
||||
{qty} {qty === 1 ? 'ticket' : 'tickets'}
|
||||
</span>
|
||||
<input
|
||||
type="url"
|
||||
value={(paymentOverrides[key] as string | null) ?? ''}
|
||||
onChange={(e) => updatePaymentOverride(key, (e.target.value || null) as any)}
|
||||
placeholder={(globalPaymentOptions?.[key] as string | null) || 'https://www.tpago.com.py/links?alias=...'}
|
||||
className="flex-1 px-3 py-2 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">Instructions (EN)</label>
|
||||
<textarea
|
||||
value={paymentOverrides.tpagoInstructions ?? ''}
|
||||
onChange={(e) => updatePaymentOverride('tpagoInstructions', e.target.value || null)}
|
||||
rows={2}
|
||||
placeholder={globalPaymentOptions?.tpagoInstructions || 'Instructions for users...'}
|
||||
className="w-full px-3 py-2 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">Instrucciones (ES)</label>
|
||||
<textarea
|
||||
value={paymentOverrides.tpagoInstructionsEs ?? ''}
|
||||
onChange={(e) => updatePaymentOverride('tpagoInstructionsEs', e.target.value || null)}
|
||||
rows={2}
|
||||
placeholder={globalPaymentOptions?.tpagoInstructionsEs || 'Instrucciones para usuarios...'}
|
||||
className="w-full px-3 py-2 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[10px] text-gray-400">
|
||||
{locale === 'es' ? 'Vacío = configuración global' : 'Empty = global settings'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Bank Transfer */}
|
||||
<Card>
|
||||
<div className="p-4 md:p-5">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-8 h-8 bg-green-100 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<BuildingLibraryIcon className="w-4 h-4 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-sm">
|
||||
{locale === 'es' ? 'Transferencia Bancaria' : 'Bank Transfer'}
|
||||
</h4>
|
||||
<p className="text-[10px] text-gray-500">
|
||||
{locale === 'es' ? 'Requiere aprobación' : 'Requires approval'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{globalPaymentOptions && !globalPaymentOptions.bankTransferEnabled && (
|
||||
<span className="text-[10px] text-gray-400 hidden sm:inline">
|
||||
{locale === 'es' ? '(Deshabilitado global)' : '(Disabled globally)'}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => updatePaymentOverride('bankTransferEnabled', !getEffectivePaymentOption('bankTransferEnabled'))}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
getEffectivePaymentOption('bankTransferEnabled') ? 'bg-primary-yellow' : 'bg-gray-300'
|
||||
}`}
|
||||
>
|
||||
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
getEffectivePaymentOption('bankTransferEnabled') ? 'translate-x-6' : 'translate-x-1'
|
||||
}`} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{getEffectivePaymentOption('bankTransferEnabled') && (
|
||||
<div className="space-y-3 pt-3 border-t">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{[
|
||||
{ label: locale === 'es' ? 'Banco' : 'Bank Name', key: 'bankName' as const, placeholder: 'e.g., Banco Itaú' },
|
||||
{ label: locale === 'es' ? 'Titular' : 'Account Holder', key: 'bankAccountHolder' as const, placeholder: 'e.g., Juan Pérez' },
|
||||
{ label: locale === 'es' ? 'N° Cuenta' : 'Account Number', key: 'bankAccountNumber' as const, placeholder: 'e.g., 1234567890' },
|
||||
{ label: 'Alias', key: 'bankAlias' as const, placeholder: 'e.g., spanglish.pagos' },
|
||||
{ label: locale === 'es' ? 'Teléfono' : 'Phone', key: 'bankPhone' as const, placeholder: '+595 981 123456' },
|
||||
].map((field) => (
|
||||
<div key={field.key}>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">{field.label}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={(paymentOverrides as any)[field.key] ?? ''}
|
||||
onChange={(e) => updatePaymentOverride(field.key, e.target.value || null)}
|
||||
placeholder={(globalPaymentOptions as any)?.[field.key] || field.placeholder}
|
||||
className="w-full px-3 py-2 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">Notes (EN)</label>
|
||||
<textarea
|
||||
value={paymentOverrides.bankNotes ?? ''}
|
||||
onChange={(e) => updatePaymentOverride('bankNotes', e.target.value || null)}
|
||||
rows={2}
|
||||
placeholder={globalPaymentOptions?.bankNotes || 'Additional notes...'}
|
||||
className="w-full px-3 py-2 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">Notas (ES)</label>
|
||||
<textarea
|
||||
value={paymentOverrides.bankNotesEs ?? ''}
|
||||
onChange={(e) => updatePaymentOverride('bankNotesEs', e.target.value || null)}
|
||||
rows={2}
|
||||
placeholder={globalPaymentOptions?.bankNotesEs || 'Notas adicionales...'}
|
||||
className="w-full px-3 py-2 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[10px] text-gray-400">
|
||||
{locale === 'es' ? 'Vacío = configuración global' : 'Empty = global settings'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Bitcoin Lightning */}
|
||||
<Card>
|
||||
<div className="p-4 md:p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-8 h-8 bg-orange-100 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<BoltIcon className="w-4 h-4 text-orange-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-sm">Bitcoin Lightning</h4>
|
||||
<p className="text-[10px] text-gray-500">
|
||||
{locale === 'es' ? 'Confirmación automática' : 'Auto confirmation'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{globalPaymentOptions && !globalPaymentOptions.lightningEnabled && (
|
||||
<span className="text-[10px] text-gray-400 hidden sm:inline">
|
||||
{locale === 'es' ? '(Deshabilitado global)' : '(Disabled globally)'}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => updatePaymentOverride('lightningEnabled', !getEffectivePaymentOption('lightningEnabled'))}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
getEffectivePaymentOption('lightningEnabled') ? 'bg-primary-yellow' : 'bg-gray-300'
|
||||
}`}
|
||||
>
|
||||
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
getEffectivePaymentOption('lightningEnabled') ? 'translate-x-6' : 'translate-x-1'
|
||||
}`} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{getEffectivePaymentOption('lightningEnabled') && (
|
||||
<div className="pt-3 border-t mt-3">
|
||||
<div className="bg-orange-50 border border-orange-200 rounded-lg p-3">
|
||||
<p className="text-xs text-orange-800">
|
||||
{locale === 'es'
|
||||
? 'Lightning configurado vía LNbits. No personalizable por evento.'
|
||||
: 'Lightning is configured via LNbits. Cannot be customized per event.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Cash at Door */}
|
||||
<Card>
|
||||
<div className="p-4 md:p-5">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-8 h-8 bg-yellow-100 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<BanknotesIcon className="w-4 h-4 text-yellow-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-sm">
|
||||
{locale === 'es' ? 'Efectivo' : 'Cash at Door'}
|
||||
</h4>
|
||||
<p className="text-[10px] text-gray-500">
|
||||
{locale === 'es' ? 'Requiere aprobación' : 'Requires approval'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{globalPaymentOptions && !globalPaymentOptions.cashEnabled && (
|
||||
<span className="text-[10px] text-gray-400 hidden sm:inline">
|
||||
{locale === 'es' ? '(Deshabilitado global)' : '(Disabled globally)'}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => updatePaymentOverride('cashEnabled', !getEffectivePaymentOption('cashEnabled'))}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
getEffectivePaymentOption('cashEnabled') ? 'bg-primary-yellow' : 'bg-gray-300'
|
||||
}`}
|
||||
>
|
||||
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
getEffectivePaymentOption('cashEnabled') ? 'translate-x-6' : 'translate-x-1'
|
||||
}`} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{getEffectivePaymentOption('cashEnabled') && (
|
||||
<div className="space-y-3 pt-3 border-t">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">Instructions (EN)</label>
|
||||
<textarea
|
||||
value={paymentOverrides.cashInstructions ?? ''}
|
||||
onChange={(e) => updatePaymentOverride('cashInstructions', e.target.value || null)}
|
||||
rows={2}
|
||||
placeholder={globalPaymentOptions?.cashInstructions || 'Cash payment instructions...'}
|
||||
className="w-full px-3 py-2 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">Instrucciones (ES)</label>
|
||||
<textarea
|
||||
value={paymentOverrides.cashInstructionsEs ?? ''}
|
||||
onChange={(e) => updatePaymentOverride('cashInstructionsEs', e.target.value || null)}
|
||||
rows={2}
|
||||
placeholder={globalPaymentOptions?.cashInstructionsEs || 'Instrucciones de pago en efectivo...'}
|
||||
className="w-full px-3 py-2 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[10px] text-gray-400">
|
||||
{locale === 'es' ? 'Vacío = configuración global' : 'Empty = global settings'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Summary */}
|
||||
<Card>
|
||||
<div className="p-4 md:p-5">
|
||||
<h4 className="font-semibold text-sm mb-3">
|
||||
{locale === 'es' ? 'Resumen' : 'Active Methods'}
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
{[
|
||||
{ label: 'TPago', enabled: getEffectivePaymentOption('tpagoEnabled') },
|
||||
{ label: locale === 'es' ? 'Transferencia' : 'Bank Transfer', enabled: getEffectivePaymentOption('bankTransferEnabled') },
|
||||
{ label: 'Lightning', enabled: getEffectivePaymentOption('lightningEnabled') },
|
||||
{ label: locale === 'es' ? 'Efectivo' : 'Cash', enabled: getEffectivePaymentOption('cashEnabled') },
|
||||
].map((method) => (
|
||||
<div key={method.label} className="flex items-center gap-1.5">
|
||||
{method.enabled ? (
|
||||
<CheckCircleIcon className="w-4 h-4 text-green-500" />
|
||||
) : (
|
||||
<XCircleIcon className="w-4 h-4 text-gray-300" />
|
||||
)}
|
||||
<span className={clsx('text-sm', method.enabled ? 'text-gray-900' : 'text-gray-400')}>
|
||||
{method.label}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{hasPaymentOverrides && (
|
||||
<p className="text-[10px] text-gray-500 mt-3 flex items-center gap-1">
|
||||
<span className="inline-block w-1.5 h-1.5 bg-primary-yellow rounded-full" />
|
||||
{locale === 'es'
|
||||
? 'Configuración personalizada activa'
|
||||
: 'Custom settings override global defaults'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
import { Ticket } from '@/lib/api';
|
||||
import { parseDate, EVENT_TIMEZONE } from '@/lib/utils';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import { Dropdown, DropdownItem, MoreMenu } from '@/components/admin/MobileComponents';
|
||||
import {
|
||||
MagnifyingGlassIcon,
|
||||
ChevronDownIcon,
|
||||
ArrowDownTrayIcon,
|
||||
ArrowUturnLeftIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import type { TicketStatusFilter } from '../_types';
|
||||
|
||||
interface TicketsTabProps {
|
||||
locale: string;
|
||||
confirmedTickets: Ticket[];
|
||||
filteredConfirmedTickets: Ticket[];
|
||||
ticketSearchQuery: string;
|
||||
setTicketSearchQuery: (value: string) => void;
|
||||
ticketStatusFilter: TicketStatusFilter;
|
||||
setTicketStatusFilter: (value: TicketStatusFilter) => void;
|
||||
confirmedCount: number;
|
||||
checkedInCount: number;
|
||||
exporting: boolean;
|
||||
showTicketExportDropdown: boolean;
|
||||
setShowTicketExportDropdown: (value: boolean) => void;
|
||||
handleExportTickets: (status: 'confirmed' | 'checked_in' | 'all') => void;
|
||||
handleCheckin: (ticketId: string) => void;
|
||||
handleRemoveCheckin: (ticketId: string) => void;
|
||||
setShowTicketExportSheet: (value: boolean) => void;
|
||||
}
|
||||
|
||||
export function TicketsTab({
|
||||
locale,
|
||||
confirmedTickets,
|
||||
filteredConfirmedTickets,
|
||||
ticketSearchQuery,
|
||||
setTicketSearchQuery,
|
||||
ticketStatusFilter,
|
||||
setTicketStatusFilter,
|
||||
confirmedCount,
|
||||
checkedInCount,
|
||||
exporting,
|
||||
showTicketExportDropdown,
|
||||
setShowTicketExportDropdown,
|
||||
handleExportTickets,
|
||||
handleCheckin,
|
||||
handleRemoveCheckin,
|
||||
setShowTicketExportSheet,
|
||||
}: TicketsTabProps) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Desktop toolbar */}
|
||||
<Card className="p-3 hidden md:block">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<MagnifyingGlassIcon className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by name or ticket ID..."
|
||||
value={ticketSearchQuery}
|
||||
onChange={(e) => setTicketSearchQuery(e.target.value)}
|
||||
className="w-full pl-9 pr-3 py-1.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={ticketStatusFilter}
|
||||
onChange={(e) => setTicketStatusFilter(e.target.value as TicketStatusFilter)}
|
||||
className="px-3 py-1.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
>
|
||||
<option value="all">All ({confirmedTickets.length})</option>
|
||||
<option value="confirmed">Valid ({confirmedCount})</option>
|
||||
<option value="checked_in">Checked In ({checkedInCount})</option>
|
||||
</select>
|
||||
<div className="flex-1" />
|
||||
<Dropdown
|
||||
open={showTicketExportDropdown}
|
||||
onOpenChange={setShowTicketExportDropdown}
|
||||
trigger={
|
||||
<Button variant="outline" size="sm" disabled={exporting}>
|
||||
<ArrowDownTrayIcon className="w-3.5 h-3.5 mr-1.5" />
|
||||
Export
|
||||
<ChevronDownIcon className="w-3 h-3 ml-1" />
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<DropdownItem onClick={() => handleExportTickets('all')}>Export All</DropdownItem>
|
||||
<DropdownItem onClick={() => handleExportTickets('confirmed')}>Export Valid</DropdownItem>
|
||||
<DropdownItem onClick={() => handleExportTickets('checked_in')}>Export Checked-in</DropdownItem>
|
||||
<div className="border-t border-gray-100 mx-2" />
|
||||
<div className="px-4 py-1.5 text-[10px] text-gray-400">Format: CSV</div>
|
||||
</Dropdown>
|
||||
</div>
|
||||
{(ticketSearchQuery || ticketStatusFilter !== 'all') && (
|
||||
<div className="mt-2 text-xs text-gray-500 flex items-center gap-2">
|
||||
<span>Showing {filteredConfirmedTickets.length} of {confirmedTickets.length}</span>
|
||||
<button onClick={() => { setTicketSearchQuery(''); setTicketStatusFilter('all'); }} className="text-primary-yellow hover:underline">Clear</button>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Mobile toolbar */}
|
||||
<div className="md:hidden space-y-2">
|
||||
<div className="relative">
|
||||
<MagnifyingGlassIcon className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by name or ticket ID..."
|
||||
value={ticketSearchQuery}
|
||||
onChange={(e) => setTicketSearchQuery(e.target.value)}
|
||||
className="w-full pl-9 pr-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={ticketStatusFilter}
|
||||
onChange={(e) => setTicketStatusFilter(e.target.value as TicketStatusFilter)}
|
||||
className="px-3 py-2 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow min-h-[44px]"
|
||||
>
|
||||
<option value="all">All ({confirmedTickets.length})</option>
|
||||
<option value="confirmed">Valid ({confirmedCount})</option>
|
||||
<option value="checked_in">Checked In ({checkedInCount})</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={() => setShowTicketExportSheet(true)}
|
||||
disabled={exporting}
|
||||
className="flex items-center gap-1.5 px-3 py-2 rounded-btn border border-secondary-light-gray text-sm text-gray-600 min-h-[44px]"
|
||||
>
|
||||
<ArrowDownTrayIcon className="w-4 h-4" />
|
||||
Export
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop: Dense table */}
|
||||
<Card className="overflow-hidden hidden md:block">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Attendee</th>
|
||||
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
||||
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Check-in</th>
|
||||
<th className="text-right px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{filteredConfirmedTickets.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-4 py-10 text-center text-gray-500 text-sm">
|
||||
{confirmedTickets.length === 0 ? 'No confirmed tickets yet' : 'No tickets match the current filters'}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredConfirmedTickets.map((ticket) => (
|
||||
<tr key={ticket.id} className="hover:bg-gray-50/50">
|
||||
<td className="px-4 py-2.5">
|
||||
<p className="font-medium text-sm">{ticket.attendeeFirstName} {ticket.attendeeLastName || ''}</p>
|
||||
{ticket.bookingId && (
|
||||
<span className="text-[10px] text-purple-600">Group booking</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
{ticket.status === 'confirmed' ? (
|
||||
<span className="px-1.5 py-0.5 text-[10px] rounded-full bg-green-100 text-green-800 font-medium">Valid</span>
|
||||
) : (
|
||||
<span className="px-1.5 py-0.5 text-[10px] rounded-full bg-blue-100 text-blue-800 font-medium">Checked In</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-xs text-gray-500">
|
||||
{ticket.checkinAt ? (
|
||||
parseDate(ticket.checkinAt).toLocaleString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', timeZone: EVENT_TIMEZONE,
|
||||
})
|
||||
) : (
|
||||
<span className="text-gray-300">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{ticket.status === 'confirmed' && (
|
||||
<Button size="sm" onClick={() => handleCheckin(ticket.id)} className="text-xs px-2 py-1">
|
||||
Check In
|
||||
</Button>
|
||||
)}
|
||||
{ticket.status === 'checked_in' && (
|
||||
<Button size="sm" variant="outline" onClick={() => handleRemoveCheckin(ticket.id)} className="text-xs px-2 py-1">
|
||||
<ArrowUturnLeftIcon className="w-3 h-3 mr-1" />
|
||||
Undo
|
||||
</Button>
|
||||
)}
|
||||
<MoreMenu>
|
||||
<div className="px-4 py-1.5 text-[10px] text-gray-400 font-mono" title={ticket.id}>
|
||||
ID: {ticket.id.slice(0, 8)}...
|
||||
</div>
|
||||
{ticket.bookingId && (
|
||||
<div className="px-4 py-1.5 text-[10px] text-purple-500 font-mono" title={ticket.bookingId}>
|
||||
Booking: {ticket.bookingId.slice(0, 8)}...
|
||||
</div>
|
||||
)}
|
||||
</MoreMenu>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Mobile: Card layout */}
|
||||
<div className="md:hidden space-y-2">
|
||||
{filteredConfirmedTickets.length === 0 ? (
|
||||
<div className="text-center py-10 text-gray-500 text-sm">
|
||||
{confirmedTickets.length === 0 ? 'No confirmed tickets yet' : 'No tickets match the current filters'}
|
||||
</div>
|
||||
) : (
|
||||
filteredConfirmedTickets.map((ticket) => (
|
||||
<Card key={ticket.id} className="p-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium text-sm truncate">{ticket.attendeeFirstName} {ticket.attendeeLastName || ''}</p>
|
||||
{ticket.bookingId && <p className="text-[10px] text-purple-600">Group booking</p>}
|
||||
</div>
|
||||
{ticket.status === 'confirmed' ? (
|
||||
<span className="px-1.5 py-0.5 text-[10px] rounded-full bg-green-100 text-green-800 font-medium flex-shrink-0">Valid</span>
|
||||
) : (
|
||||
<span className="px-1.5 py-0.5 text-[10px] rounded-full bg-blue-100 text-blue-800 font-medium flex-shrink-0">Checked In</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-2 pt-2 border-t border-gray-100">
|
||||
<p className="text-[10px] text-gray-400">
|
||||
{ticket.checkinAt
|
||||
? `Checked in ${parseDate(ticket.checkinAt).toLocaleString(locale === 'es' ? 'es-ES' : 'en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', timeZone: EVENT_TIMEZONE })}`
|
||||
: 'Not checked in'}
|
||||
</p>
|
||||
<div className="flex items-center gap-1">
|
||||
{ticket.status === 'confirmed' && (
|
||||
<Button size="sm" onClick={() => handleCheckin(ticket.id)} className="text-xs px-2.5 py-1.5 min-h-[36px]">
|
||||
Check In
|
||||
</Button>
|
||||
)}
|
||||
{ticket.status === 'checked_in' && (
|
||||
<Button size="sm" variant="outline" onClick={() => handleRemoveCheckin(ticket.id)} className="text-xs px-2.5 py-1.5 min-h-[36px]">
|
||||
<ArrowUturnLeftIcon className="w-3 h-3 mr-1" />
|
||||
Undo
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { ComponentType } from 'react';
|
||||
|
||||
export type TabType = 'overview' | 'attendees' | 'tickets' | 'email' | 'payments';
|
||||
|
||||
export type AttendeeStatusFilter = 'all' | 'pending' | 'confirmed' | 'checked_in' | 'cancelled';
|
||||
export type TicketStatusFilter = 'all' | 'confirmed' | 'checked_in';
|
||||
export type RecipientFilter = 'all' | 'confirmed' | 'pending' | 'checked_in';
|
||||
|
||||
export interface PrimaryAction {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
variant: 'outline' | 'primary';
|
||||
icon?: ComponentType<{ className?: string }>;
|
||||
}
|
||||
|
||||
export interface AttendeeFormState {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
adminNote: string;
|
||||
}
|
||||
|
||||
export interface AddAtDoorFormState extends AttendeeFormState {
|
||||
autoCheckin: boolean;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// Pure formatting/IO helpers for the admin event detail page.
|
||||
|
||||
export function formatCurrency(amount: number, currency: string): string {
|
||||
if (currency === 'PYG') {
|
||||
return `${amount.toLocaleString('es-PY')} PYG`;
|
||||
}
|
||||
return `$${amount.toFixed(2)} ${currency}`;
|
||||
}
|
||||
|
||||
/** Trigger a browser download for a blob with the given filename. */
|
||||
export function downloadBlob(blob: Blob, filename: string): void {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user