Booking flow: required terms and privacy checkbox with i18n
Also includes admin, dashboard, and API updates; PWA icon assets; and assorted layout and utility changes on dev.
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { ticketsApi, eventsApi, Ticket, Event } from '@/lib/api';
|
||||
import { parseDate } from '@/lib/utils';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import { BottomSheet, MoreMenu, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
|
||||
@@ -116,7 +117,7 @@ export default function AdminBookingsPage() {
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||
return parseDate(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
|
||||
@@ -7,6 +7,7 @@ import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import { EnvelopeIcon, EnvelopeOpenIcon, CheckIcon } from '@heroicons/react/24/outline';
|
||||
import toast from 'react-hot-toast';
|
||||
import { parseDate } from '@/lib/utils';
|
||||
|
||||
export default function AdminContactsPage() {
|
||||
const { t, locale } = useLanguage();
|
||||
@@ -44,7 +45,7 @@ export default function AdminContactsPage() {
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||
return parseDate(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { emailsApi, EmailTemplate, EmailLog, EmailStats } from '@/lib/api';
|
||||
import { parseDate } from '@/lib/utils';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Input from '@/components/ui/Input';
|
||||
@@ -391,7 +392,7 @@ export default function AdminEmailsPage() {
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||
return parseDate(dateStr).toLocaleString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
@@ -572,7 +573,7 @@ export default function AdminEmailsPage() {
|
||||
<div className="flex items-center gap-2">
|
||||
{hasDraft && (
|
||||
<span className="text-xs text-gray-500">
|
||||
Draft saved {composeForm.savedAt ? new Date(composeForm.savedAt).toLocaleString(locale === 'es' ? 'es-ES' : 'en-US', { timeZone: 'America/Asuncion' }) : ''}
|
||||
Draft saved {composeForm.savedAt ? parseDate(composeForm.savedAt).toLocaleString(locale === 'es' ? 'es-ES' : 'en-US', { timeZone: 'America/Asuncion' }) : ''}
|
||||
</span>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={saveDraft}>
|
||||
@@ -598,7 +599,7 @@ export default function AdminEmailsPage() {
|
||||
<option value="">Choose an event</option>
|
||||
{events.filter(e => e.status === 'published' || e.status === 'unlisted').map((event) => (
|
||||
<option key={event.id} value={event.id}>
|
||||
{event.title} - {new Date(event.startDatetime).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { timeZone: 'America/Asuncion' })}
|
||||
{event.title} - {parseDate(event.startDatetime).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { timeZone: 'America/Asuncion' })}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useParams, useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { eventsApi, ticketsApi, emailsApi, paymentOptionsApi, adminApi, Event, Ticket, EmailTemplate, PaymentOptionsConfig } from '@/lib/api';
|
||||
import { formatDateLong, formatDateCompact, formatTime, parseDate, EVENT_TIMEZONE } from '@/lib/utils';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import { Dropdown, DropdownItem, BottomSheet, MoreMenu, AdminMobileStyles } from '@/components/admin/MobileComponents';
|
||||
@@ -38,6 +39,7 @@ import {
|
||||
ArrowDownTrayIcon,
|
||||
ChevronDownIcon,
|
||||
EllipsisVerticalIcon,
|
||||
StarIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import toast from 'react-hot-toast';
|
||||
import clsx from 'clsx';
|
||||
@@ -88,6 +90,14 @@ export default function AdminEventDetailPage() {
|
||||
phone: '',
|
||||
adminNote: '',
|
||||
});
|
||||
const [showInviteGuestModal, setShowInviteGuestModal] = useState(false);
|
||||
const [inviteGuestForm, setInviteGuestForm] = useState({
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
adminNote: '',
|
||||
});
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// Export state — separate desktop (Dropdown portal) vs mobile (BottomSheet)
|
||||
@@ -212,32 +222,9 @@ export default function AdminEventDetailPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
timeZone: 'America/Asuncion',
|
||||
});
|
||||
};
|
||||
|
||||
const formatDateShort = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
timeZone: 'America/Asuncion',
|
||||
});
|
||||
};
|
||||
|
||||
const formatTime = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleTimeString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
timeZone: 'America/Asuncion',
|
||||
});
|
||||
};
|
||||
const formatDate = (dateStr: string) => formatDateLong(dateStr, locale as 'en' | 'es');
|
||||
const formatDateShort = (dateStr: string) => formatDateCompact(dateStr, locale as 'en' | 'es');
|
||||
const fmtTime = (dateStr: string) => formatTime(dateStr, locale as 'en' | 'es');
|
||||
|
||||
const formatCurrency = (amount: number, currency: string) => {
|
||||
if (currency === 'PYG') {
|
||||
@@ -376,6 +363,30 @@ export default function AdminEventDetailPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleInviteGuest = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!event) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await ticketsApi.guestCreate({
|
||||
eventId: event.id,
|
||||
firstName: inviteGuestForm.firstName,
|
||||
lastName: inviteGuestForm.lastName || undefined,
|
||||
email: inviteGuestForm.email || undefined,
|
||||
phone: inviteGuestForm.phone || undefined,
|
||||
adminNote: inviteGuestForm.adminNote || undefined,
|
||||
});
|
||||
toast.success('Guest invited successfully');
|
||||
setShowInviteGuestModal(false);
|
||||
setInviteGuestForm({ firstName: '', lastName: '', email: '', phone: '', adminNote: '' });
|
||||
loadEventData();
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Failed to invite guest');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportAttendees = async (status: 'confirmed' | 'checked_in' | 'confirmed_pending' | 'all') => {
|
||||
if (!event) return;
|
||||
setExporting(true);
|
||||
@@ -466,7 +477,7 @@ export default function AdminEventDetailPage() {
|
||||
ticketId: 'TKT-PREVIEW',
|
||||
eventTitle: event?.title || '',
|
||||
eventDate: event ? formatDate(event.startDatetime) : '',
|
||||
eventTime: event ? formatTime(event.startDatetime) : '',
|
||||
eventTime: event ? fmtTime(event.startDatetime) : '',
|
||||
eventLocation: event?.location || '',
|
||||
eventLocationUrl: event?.locationUrl || '',
|
||||
eventPrice: event ? formatCurrency(event.price, event.currency) : '',
|
||||
@@ -538,7 +549,9 @@ export default function AdminEventDetailPage() {
|
||||
const pendingCount = getTicketsByStatus('pending').length;
|
||||
const checkedInCount = getTicketsByStatus('checked_in').length;
|
||||
const cancelledCount = getTicketsByStatus('cancelled').length;
|
||||
const revenue = (confirmedCount + checkedInCount) * event.price;
|
||||
const paidConfirmedCount = getTicketsByStatus('confirmed').filter(t => !t.isGuest).length;
|
||||
const paidCheckedInCount = getTicketsByStatus('checked_in').filter(t => !t.isGuest).length;
|
||||
const revenue = (paidConfirmedCount + paidCheckedInCount) * event.price;
|
||||
|
||||
const tabs: { key: TabType; label: string; icon: typeof CalendarIcon; count?: number }[] = [
|
||||
{ key: 'overview', label: 'Overview', icon: CalendarIcon },
|
||||
@@ -573,7 +586,7 @@ export default function AdminEventDetailPage() {
|
||||
</Link>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="text-xl md:text-2xl font-bold text-primary-dark truncate">{event.title}</h1>
|
||||
<p className="text-sm text-gray-500">{formatDateShort(event.startDatetime)} · {formatTime(event.startDatetime)}</p>
|
||||
<p className="text-sm text-gray-500">{formatDateShort(event.startDatetime)} · {fmtTime(event.startDatetime)}</p>
|
||||
</div>
|
||||
{/* Desktop header actions */}
|
||||
<div className="hidden md:flex items-center gap-2 flex-shrink-0">
|
||||
@@ -623,7 +636,7 @@ export default function AdminEventDetailPage() {
|
||||
<div className="hidden md:flex flex-wrap items-center gap-2 mb-4 ml-[52px]">
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-gray-100 rounded-full text-xs text-gray-700">
|
||||
<CalendarIcon className="w-3.5 h-3.5" />
|
||||
{formatDateShort(event.startDatetime)} {formatTime(event.startDatetime)}{event.endDatetime && ` – ${formatTime(event.endDatetime)}`}
|
||||
{formatDateShort(event.startDatetime)} {fmtTime(event.startDatetime)}{event.endDatetime && ` – ${fmtTime(event.endDatetime)}`}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-gray-100 rounded-full text-xs text-gray-700">
|
||||
<MapPinIcon className="w-3.5 h-3.5" />
|
||||
@@ -778,7 +791,7 @@ export default function AdminEventDetailPage() {
|
||||
<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">{formatTime(event.startDatetime)}{event.endDatetime && ` - ${formatTime(event.endDatetime)}`}</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">
|
||||
@@ -909,6 +922,9 @@ export default function AdminEventDetailPage() {
|
||||
<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') && (
|
||||
@@ -1006,15 +1022,20 @@ export default function AdminEventDetailPage() {
|
||||
{ticket.attendeePhone && <p className="text-xs text-gray-400">{ticket.attendeePhone}</p>}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
{getStatusBadge(ticket.status, true)}
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
{getStatusBadge(ticket.status, true)}
|
||||
{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">
|
||||
{new Date(ticket.checkinAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', timeZone: 'America/Asuncion' })}
|
||||
{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">
|
||||
{new Date(ticket.createdAt).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { timeZone: 'America/Asuncion' })}
|
||||
{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">
|
||||
@@ -1066,14 +1087,17 @@ export default function AdminEventDetailPage() {
|
||||
<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">
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0 flex-wrap justify-end">
|
||||
{getStatusBadge(ticket.status, true)}
|
||||
{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">
|
||||
{new Date(ticket.createdAt).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { timeZone: 'America/Asuncion' })}
|
||||
{ticket.checkinAt && ` · Checked in ${new Date(ticket.checkinAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', timeZone: 'America/Asuncion' })}`}
|
||||
{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 && (
|
||||
@@ -1230,8 +1254,8 @@ export default function AdminEventDetailPage() {
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-xs text-gray-500">
|
||||
{ticket.checkinAt ? (
|
||||
new Date(ticket.checkinAt).toLocaleString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', timeZone: 'America/Asuncion',
|
||||
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>
|
||||
@@ -1293,7 +1317,7 @@ export default function AdminEventDetailPage() {
|
||||
<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 ${new Date(ticket.checkinAt).toLocaleString(locale === 'es' ? 'es-ES' : 'en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', timeZone: 'America/Asuncion' })}`
|
||||
? `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">
|
||||
@@ -1837,6 +1861,16 @@ export default function AdminEventDetailPage() {
|
||||
<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>
|
||||
|
||||
@@ -2046,6 +2080,86 @@ export default function AdminEventDetailPage() {
|
||||
</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">
|
||||
|
||||
@@ -14,6 +14,7 @@ import { PlusIcon, PencilIcon, TrashIcon, EyeIcon, PhotoIcon, DocumentDuplicateI
|
||||
import { StarIcon as StarIconSolid } from '@heroicons/react/24/solid';
|
||||
import toast from 'react-hot-toast';
|
||||
import clsx from 'clsx';
|
||||
import { parseDate, EVENT_TIMEZONE } from '@/lib/utils';
|
||||
|
||||
export default function AdminEventsPage() {
|
||||
const router = useRouter();
|
||||
@@ -123,13 +124,19 @@ export default function AdminEventsPage() {
|
||||
};
|
||||
|
||||
const isoToLocalDatetime = (isoString: string): string => {
|
||||
const date = new Date(isoString);
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
||||
const date = parseDate(isoString);
|
||||
const parts = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: EVENT_TIMEZONE,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
}).formatToParts(date);
|
||||
const get = (type: string) => parts.find(p => p.type === type)!.value;
|
||||
const h = get('hour') === '24' ? '00' : get('hour');
|
||||
return `${get('year')}-${get('month')}-${get('day')}T${h}:${get('minute')}`;
|
||||
};
|
||||
|
||||
const handleEdit = (event: Event) => {
|
||||
@@ -167,8 +174,8 @@ export default function AdminEventsPage() {
|
||||
title: formData.title, titleEs: formData.titleEs || undefined,
|
||||
description: formData.description, descriptionEs: formData.descriptionEs || undefined,
|
||||
shortDescription: formData.shortDescription || undefined, shortDescriptionEs: formData.shortDescriptionEs || undefined,
|
||||
startDatetime: new Date(formData.startDatetime).toISOString(),
|
||||
endDatetime: formData.endDatetime ? new Date(formData.endDatetime).toISOString() : undefined,
|
||||
startDatetime: formData.startDatetime,
|
||||
endDatetime: formData.endDatetime || undefined,
|
||||
location: formData.location, locationUrl: formData.locationUrl || undefined,
|
||||
price: formData.price, currency: formData.currency, capacity: formData.capacity,
|
||||
status: formData.status, bannerUrl: formData.bannerUrl || undefined,
|
||||
@@ -214,7 +221,7 @@ export default function AdminEventsPage() {
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||
return parseDate(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||
month: 'short', day: 'numeric', year: 'numeric', timeZone: 'America/Asuncion',
|
||||
});
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { mediaApi, Media } from '@/lib/api';
|
||||
import { parseDate } from '@/lib/utils';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import {
|
||||
@@ -108,7 +109,7 @@ export default function AdminGalleryPage() {
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||
return parseDate(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState, useEffect } from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { legalPagesApi, LegalPage } from '@/lib/api';
|
||||
import { parseDate } from '@/lib/utils';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Input from '@/components/ui/Input';
|
||||
@@ -158,7 +159,7 @@ export default function AdminLegalPagesPage() {
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
try {
|
||||
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-PY' : 'en-US', {
|
||||
return parseDate(dateStr).toLocaleDateString(locale === 'es' ? 'es-PY' : 'en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
UserGroupIcon,
|
||||
ExclamationTriangleIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { parseDate } from '@/lib/utils';
|
||||
|
||||
export default function AdminDashboardPage() {
|
||||
const { t, locale } = useLanguage();
|
||||
@@ -30,7 +31,7 @@ export default function AdminDashboardPage() {
|
||||
}, []);
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||
return parseDate(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { paymentsApi, adminApi, eventsApi, PaymentWithDetails, Event, ExportedPayment, FinancialSummary } from '@/lib/api';
|
||||
import { parseDate } from '@/lib/utils';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Input from '@/components/ui/Input';
|
||||
@@ -203,7 +204,7 @@ export default function AdminPaymentsPage() {
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||
return parseDate(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
VideoCameraIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import toast from 'react-hot-toast';
|
||||
import { parseDate, EVENT_TIMEZONE } from '@/lib/utils';
|
||||
import clsx from 'clsx';
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────
|
||||
@@ -324,7 +325,7 @@ function InvalidTicketScreen({
|
||||
|
||||
const reasonDetail: Record<InvalidReason, string> = {
|
||||
already_checked_in: validation?.ticket?.checkinAt
|
||||
? `Checked in at ${new Date(validation.ticket.checkinAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}${validation.ticket.checkedInBy ? ` by ${validation.ticket.checkedInBy}` : ''}`
|
||||
? `Checked in at ${parseDate(validation.ticket.checkinAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', timeZone: EVENT_TIMEZONE })}${validation.ticket.checkedInBy ? ` by ${validation.ticket.checkedInBy}` : ''}`
|
||||
: 'This ticket was already used',
|
||||
cancelled: 'This ticket has been cancelled and is no longer valid.',
|
||||
not_found: error || 'No ticket matching this code was found.',
|
||||
@@ -765,7 +766,7 @@ export default function AdminScannerPage() {
|
||||
setRecentCheckins((prev) => [
|
||||
{
|
||||
name: result.ticket.attendeeName || 'Guest',
|
||||
time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
|
||||
time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', timeZone: EVENT_TIMEZONE }),
|
||||
ticketId: scanResult.validation!.ticket!.id,
|
||||
},
|
||||
...prev.slice(0, 19),
|
||||
@@ -796,7 +797,7 @@ export default function AdminScannerPage() {
|
||||
setRecentCheckins((prev) => [
|
||||
{
|
||||
name: result.ticket.attendeeName || 'Guest',
|
||||
time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
|
||||
time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', timeZone: EVENT_TIMEZONE }),
|
||||
ticketId: searchDetailValidation!.ticket!.id,
|
||||
},
|
||||
...prev.slice(0, 19),
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { siteSettingsApi, eventsApi, legalSettingsApi, SiteSettings, TimezoneOption, Event, LegalSettingsData } from '@/lib/api';
|
||||
import { parseDate } from '@/lib/utils';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Input from '@/components/ui/Input';
|
||||
@@ -319,7 +320,7 @@ export default function AdminSettingsPage() {
|
||||
<div>
|
||||
<p className="font-medium text-amber-900">{featuredEvent.title}</p>
|
||||
<p className="text-sm text-amber-700">
|
||||
{new Date(featuredEvent.startDatetime).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||
{parseDate(featuredEvent.startDatetime).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { ticketsApi, eventsApi, Ticket, Event } from '@/lib/api';
|
||||
import { parseDate } from '@/lib/utils';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Input from '@/components/ui/Input';
|
||||
@@ -107,7 +108,7 @@ export default function AdminTicketsPage() {
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||
return parseDate(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', timeZone: 'America/Asuncion',
|
||||
});
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { usersApi, User } from '@/lib/api';
|
||||
import { parseDate } from '@/lib/utils';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Input from '@/components/ui/Input';
|
||||
@@ -104,7 +105,7 @@ export default function AdminUsersPage() {
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||
return parseDate(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||
year: 'numeric', month: 'short', day: 'numeric', timeZone: 'America/Asuncion',
|
||||
});
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user