diff --git a/.gitignore b/.gitignore index e3ca65d..ffc5a40 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,8 @@ backend/uploads/ # Tooling .turbo/ .cursor/ +.agents/ +skills-lock.json .npm-cache/ # OS diff --git a/backend/src/db/migrate.ts b/backend/src/db/migrate.ts index 675c5f5..c085ac0 100644 --- a/backend/src/db/migrate.ts +++ b/backend/src/db/migrate.ts @@ -173,6 +173,11 @@ async function migrate() { try { await (db as any).run(sql`ALTER TABLE tickets ADD COLUMN booking_id TEXT`); } catch (e) { /* column may already exist */ } + + // Migration: Add is_guest column to tickets + try { + await (db as any).run(sql`ALTER TABLE tickets ADD COLUMN is_guest INTEGER NOT NULL DEFAULT 0`); + } catch (e) { /* column may already exist */ } // Make attendee_email and attendee_phone nullable (recreate table if needed or just allow nulls for new entries) // SQLite doesn't support altering column constraints, so we'll just ensure new entries work @@ -533,8 +538,8 @@ async function migrate() { description_es TEXT, short_description VARCHAR(300), short_description_es VARCHAR(300), - start_datetime TIMESTAMP NOT NULL, - end_datetime TIMESTAMP, + start_datetime TIMESTAMPTZ NOT NULL, + end_datetime TIMESTAMPTZ, location VARCHAR(500) NOT NULL, location_url VARCHAR(500), price DECIMAL(10, 2) NOT NULL DEFAULT 0, @@ -565,6 +570,15 @@ async function migrate() { await (db as any).execute(sql`ALTER TABLE events ADD COLUMN short_description_es VARCHAR(300)`); } catch (e) { /* column may already exist */ } + // Migrate event datetime columns from TIMESTAMP to TIMESTAMPTZ for + // unambiguous UTC storage (eliminates pg driver timezone interpretation). + try { + await (db as any).execute(sql`ALTER TABLE events ALTER COLUMN start_datetime TYPE TIMESTAMPTZ USING start_datetime AT TIME ZONE 'UTC'`); + } catch (e) { /* already timestamptz or other issue */ } + try { + await (db as any).execute(sql`ALTER TABLE events ALTER COLUMN end_datetime TYPE TIMESTAMPTZ USING end_datetime AT TIME ZONE 'UTC'`); + } catch (e) { /* already timestamptz or other issue */ } + await (db as any).execute(sql` CREATE TABLE IF NOT EXISTS tickets ( id UUID PRIMARY KEY, @@ -599,6 +613,11 @@ async function migrate() { await (db as any).execute(sql`ALTER TABLE tickets ADD COLUMN booking_id UUID`); } catch (e) { /* column may already exist */ } + // Migration: Add is_guest column to tickets + try { + await (db as any).execute(sql`ALTER TABLE tickets ADD COLUMN is_guest INTEGER NOT NULL DEFAULT 0`); + } catch (e) { /* column may already exist */ } + await (db as any).execute(sql` CREATE TABLE IF NOT EXISTS payments ( id UUID PRIMARY KEY, diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index 739fd92..d2b672a 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -99,6 +99,7 @@ export const sqliteTickets = sqliteTable('tickets', { checkedInByAdminId: text('checked_in_by_admin_id').references(() => sqliteUsers.id), // Who performed the check-in qrCode: text('qr_code'), adminNote: text('admin_note'), + isGuest: integer('is_guest', { mode: 'boolean' }).notNull().default(false), createdAt: text('created_at').notNull(), }); @@ -392,8 +393,8 @@ export const pgEvents = pgTable('events', { descriptionEs: pgText('description_es'), shortDescription: varchar('short_description', { length: 300 }), shortDescriptionEs: varchar('short_description_es', { length: 300 }), - startDatetime: timestamp('start_datetime').notNull(), - endDatetime: timestamp('end_datetime'), + startDatetime: timestamp('start_datetime', { withTimezone: true }).notNull(), + endDatetime: timestamp('end_datetime', { withTimezone: true }), location: varchar('location', { length: 500 }).notNull(), locationUrl: varchar('location_url', { length: 500 }), price: decimal('price', { precision: 10, scale: 2 }).notNull().default('0'), @@ -423,6 +424,7 @@ export const pgTickets = pgTable('tickets', { checkedInByAdminId: uuid('checked_in_by_admin_id').references(() => pgUsers.id), // Who performed the check-in qrCode: varchar('qr_code', { length: 255 }), adminNote: pgText('admin_note'), + isGuest: pgInteger('is_guest').notNull().default(0), createdAt: timestamp('created_at').notNull(), }); diff --git a/backend/src/lib/utils.ts b/backend/src/lib/utils.ts index a116b91..ac4e7da 100644 --- a/backend/src/lib/utils.ts +++ b/backend/src/lib/utils.ts @@ -41,6 +41,49 @@ export function toDbDate(date: Date | string): string | Date { return getDbType() === 'postgres' ? d : d.toISOString(); } +const NAIVE_DATETIME_RE = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2}(\.\d+)?)?$/; + +/** + * Parse a datetime string that represents wall-clock time in a given timezone + * and return the corresponding UTC Date. + * + * Naive strings (no "Z" or offset) are interpreted as wall-clock time in + * `timezone`. Strings that already carry a timezone indicator are parsed + * directly so existing UTC ISO values still work. + */ +export function parseEventDatetime( + datetime: string, + timezone: string = 'America/Asuncion', +): Date { + if (!NAIVE_DATETIME_RE.test(datetime)) { + return new Date(datetime); + } + + // Treat the digits as UTC so we have a stable reference instant. + const fakeUTC = new Date(datetime + 'Z'); + + // Ask Intl what that UTC instant looks like in both UTC and the target tz. + const utcStr = fakeUTC.toLocaleString('en-US', { timeZone: 'UTC' }); + const tzStr = fakeUTC.toLocaleString('en-US', { timeZone: timezone }); + + // The gap between the two tells us the tz offset at this point in time. + const offsetMs = new Date(utcStr).getTime() - new Date(tzStr).getTime(); + + return new Date(fakeUTC.getTime() + offsetMs); +} + +/** + * Convert a datetime string to the appropriate DB format, interpreting naive + * strings as wall-clock time in `timezone` (defaults to America/Asuncion). + */ +export function toDbDateTz( + datetime: string, + timezone: string = 'America/Asuncion', +): string | Date { + const d = parseEventDatetime(datetime, timezone); + return getDbType() === 'postgres' ? d : d.toISOString(); +} + /** * Convert a boolean value to the appropriate format for the database type. * - SQLite: returns boolean (true/false) for mode: 'boolean' diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts index 911a026..70feaeb 100644 --- a/backend/src/routes/admin.ts +++ b/backend/src/routes/admin.ts @@ -1,6 +1,6 @@ import { Hono } from 'hono'; import { db, dbGet, dbAll, users, events, tickets, payments, contacts, emailSubscribers } from '../db/index.js'; -import { eq, and, gte, sql, desc, inArray } from 'drizzle-orm'; +import { eq, and, ne, gte, sql, desc, inArray } from 'drizzle-orm'; import { requireAuth } from '../lib/auth.js'; import { getNow } from '../lib/utils.js'; @@ -129,7 +129,8 @@ adminRouter.get('/analytics', requireAuth(['admin']), async (c) => { .where( and( eq((tickets as any).eventId, event.id), - eq((tickets as any).status, 'confirmed') + eq((tickets as any).status, 'confirmed'), + ne((tickets as any).isGuest, 1) ) ) ); @@ -141,7 +142,8 @@ adminRouter.get('/analytics', requireAuth(['admin']), async (c) => { .where( and( eq((tickets as any).eventId, event.id), - eq((tickets as any).status, 'checked_in') + eq((tickets as any).status, 'checked_in'), + ne((tickets as any).isGuest, 1) ) ) ); diff --git a/backend/src/routes/events.ts b/backend/src/routes/events.ts index ebc383f..bc7d228 100644 --- a/backend/src/routes/events.ts +++ b/backend/src/routes/events.ts @@ -4,7 +4,7 @@ import { z } from 'zod'; import { db, dbGet, dbAll, events, tickets, payments, eventPaymentOverrides, emailLogs, invoices, siteSettings } from '../db/index.js'; import { eq, desc, and, gte, sql } from 'drizzle-orm'; import { requireAuth, getAuthUser } from '../lib/auth.js'; -import { generateId, getNow, convertBooleansForDb, toDbDate, calculateAvailableSeats } from '../lib/utils.js'; +import { generateId, getNow, convertBooleansForDb, toDbDate, toDbDateTz, calculateAvailableSeats } from '../lib/utils.js'; import { revalidateFrontendCache } from '../lib/revalidate.js'; interface UserContext { @@ -201,6 +201,13 @@ eventsRouter.get('/:id', async (c) => { }); }); +async function getSiteTimezone(): Promise { + const settings = await dbGet( + (db as any).select().from(siteSettings).limit(1) + ); + return settings?.timezone || 'America/Asuncion'; +} + // Helper function to get ticket count for an event async function getEventTicketCount(eventId: string): Promise { const ticketCount = await dbGet( @@ -316,6 +323,7 @@ eventsRouter.post('/', requireAuth(['admin', 'organizer']), zValidator('json', c const user = c.get('user'); const now = getNow(); const id = generateId(); + const tz = await getSiteTimezone(); // Convert data for database compatibility const dbData = convertBooleansForDb(data); @@ -323,8 +331,8 @@ eventsRouter.post('/', requireAuth(['admin', 'organizer']), zValidator('json', c const newEvent = { id, ...dbData, - startDatetime: toDbDate(data.startDatetime), - endDatetime: data.endDatetime ? toDbDate(data.endDatetime) : null, + startDatetime: toDbDateTz(data.startDatetime, tz), + endDatetime: data.endDatetime ? toDbDateTz(data.endDatetime, tz) : null, createdAt: now, updatedAt: now, }; @@ -351,14 +359,15 @@ eventsRouter.put('/:id', requireAuth(['admin', 'organizer']), zValidator('json', } const now = getNow(); + const tz = await getSiteTimezone(); // Convert data for database compatibility const updateData: Record = { ...convertBooleansForDb(data), updatedAt: now }; // Convert datetime fields if present if (data.startDatetime) { - updateData.startDatetime = toDbDate(data.startDatetime); + updateData.startDatetime = toDbDateTz(data.startDatetime, tz); } if (data.endDatetime !== undefined) { - updateData.endDatetime = data.endDatetime ? toDbDate(data.endDatetime) : null; + updateData.endDatetime = data.endDatetime ? toDbDateTz(data.endDatetime, tz) : null; } await (db as any) diff --git a/backend/src/routes/tickets.ts b/backend/src/routes/tickets.ts index 59e3537..da5df76 100644 --- a/backend/src/routes/tickets.ts +++ b/backend/src/routes/tickets.ts @@ -1394,6 +1394,142 @@ ticketsRouter.post('/admin/manual', requireAuth(['admin', 'organizer', 'staff']) }, 201); }); +// Admin invite guest ticket (free, confirmed, not counted in revenue) +ticketsRouter.post('/admin/guest', requireAuth(['admin', 'organizer', 'staff']), zValidator('json', z.object({ + eventId: z.string(), + firstName: z.string().min(1), + lastName: z.string().optional().or(z.literal('')), + email: z.string().email().optional().or(z.literal('')), + phone: z.string().optional().or(z.literal('')), + preferredLanguage: z.enum(['en', 'es']).optional(), + adminNote: z.string().max(1000).optional(), +})), async (c) => { + const data = c.req.valid('json'); + + const event = await dbGet( + (db as any).select().from(events).where(eq((events as any).id, data.eventId)) + ); + if (!event) { + return c.json({ error: 'Event not found' }, 404); + } + + const now = getNow(); + const adminUser = (c as any).get('user'); + + // Find or create user (use placeholder email if none provided) + const attendeeEmail = data.email && data.email.trim() + ? data.email.trim() + : `guest-${generateId()}@guestinvite.local`; + + const fullName = data.lastName && data.lastName.trim() + ? `${data.firstName} ${data.lastName}`.trim() + : data.firstName; + + let user = await dbGet( + (db as any).select().from(users).where(eq((users as any).email, attendeeEmail)) + ); + + if (!user) { + const userId = generateId(); + user = { + id: userId, + email: attendeeEmail, + password: '', + name: fullName, + phone: data.phone || null, + role: 'user', + languagePreference: null, + createdAt: now, + updatedAt: now, + }; + await (db as any).insert(users).values(user); + } + + // Check for existing active ticket (only for real emails, not placeholder) + if (data.email && data.email.trim()) { + const existingTicket = await dbGet( + (db as any) + .select() + .from(tickets) + .where( + and( + eq((tickets as any).userId, user.id), + eq((tickets as any).eventId, data.eventId) + ) + ) + ); + if (existingTicket && existingTicket.status !== 'cancelled') { + return c.json({ error: 'This person already has a ticket for this event' }, 400); + } + } + + const ticketId = generateId(); + const qrCode = generateTicketCode(); + + const newTicket = { + id: ticketId, + userId: user.id, + eventId: data.eventId, + attendeeFirstName: data.firstName, + attendeeLastName: data.lastName && data.lastName.trim() ? data.lastName.trim() : null, + attendeeEmail: data.email && data.email.trim() ? data.email.trim() : null, + attendeePhone: data.phone && data.phone.trim() ? data.phone.trim() : null, + preferredLanguage: data.preferredLanguage || null, + status: 'confirmed', + isGuest: 1, + qrCode, + checkinAt: null, + adminNote: data.adminNote || null, + createdAt: now, + }; + + await (db as any).insert(tickets).values(newTicket); + + // Create a $0 payment record to track the invite + const paymentId = generateId(); + const newPayment = { + id: paymentId, + ticketId, + provider: 'cash', + amount: 0, + currency: event.currency, + status: 'paid', + reference: 'Guest invite', + paidAt: now, + paidByAdminId: adminUser?.id || null, + createdAt: now, + updatedAt: now, + }; + + await (db as any).insert(payments).values(newPayment); + + // Send booking confirmation email if a real email was provided + if (data.email && data.email.trim()) { + emailService.sendBookingConfirmation(ticketId).then(result => { + if (result.success) { + console.log(`[Email] Booking confirmation sent for guest ticket ${ticketId}`); + } else { + console.error(`[Email] Failed to send booking confirmation for guest ticket ${ticketId}:`, result.error); + } + }).catch(err => { + console.error('[Email] Exception sending booking confirmation for guest ticket:', err); + }); + } + + return c.json({ + ticket: { + ...newTicket, + event: { + title: event.title, + startDatetime: event.startDatetime, + location: event.location, + }, + }, + payment: newPayment, + message: 'Guest ticket created successfully', + }, 201); +}); + // Get all tickets (admin) - includes payment for each ticket ticketsRouter.get('/', requireAuth(['admin', 'organizer']), async (c) => { const eventId = c.req.query('eventId'); diff --git a/frontend/public/images/icon-192.png b/frontend/public/images/icon-192.png new file mode 100644 index 0000000..7ed7695 Binary files /dev/null and b/frontend/public/images/icon-192.png differ diff --git a/frontend/public/images/icon-512.png b/frontend/public/images/icon-512.png new file mode 100644 index 0000000..f80f7db Binary files /dev/null and b/frontend/public/images/icon-512.png differ diff --git a/frontend/src/app/(public)/book/[eventId]/page.tsx b/frontend/src/app/(public)/book/[eventId]/page.tsx index d8c8e89..959054c 100644 --- a/frontend/src/app/(public)/book/[eventId]/page.tsx +++ b/frontend/src/app/(public)/book/[eventId]/page.tsx @@ -110,6 +110,10 @@ export default function BookingPage() { const [errors, setErrors] = useState>>({}); + // Terms & Privacy agreement (not persisted across page loads) + const [agreedToTerms, setAgreedToTerms] = useState(false); + const [termsError, setTermsError] = useState(null); + const rucPattern = /^\d{6,10}$/; // Format RUC input: digits only, max 10 @@ -217,6 +221,13 @@ export default function BookingPage() { } }, [user]); + // Clear the terms error as soon as the user agrees + useEffect(() => { + if (agreedToTerms && termsError) { + setTermsError(null); + } + }, [agreedToTerms, termsError]); + const formatDate = (dateStr: string) => formatDateLong(dateStr, locale as 'en' | 'es'); const fmtTime = (dateStr: string) => formatTime(dateStr, locale as 'en' | 'es'); @@ -261,7 +272,20 @@ export default function BookingPage() { setErrors(newErrors); setAttendeeErrors(newAttendeeErrors); - return Object.keys(newErrors).length === 0 && Object.keys(newAttendeeErrors).length === 0; + + let termsOk = true; + if (!agreedToTerms) { + setTermsError(t('booking.form.errors.termsRequired')); + termsOk = false; + } else { + setTermsError(null); + } + + return ( + Object.keys(newErrors).length === 0 && + Object.keys(newAttendeeErrors).length === 0 && + termsOk + ); }; // Connect to SSE for real-time payment updates @@ -376,6 +400,10 @@ export default function BookingPage() { const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); + if (!agreedToTerms) { + setTermsError(t('booking.form.errors.termsRequired')); + return; + } if (!event || !validateForm()) return; setSubmitting(true); @@ -1323,13 +1351,58 @@ export default function BookingPage() { + {/* Terms & Privacy agreement */} + +
+ 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" + /> + +
+ {termsError && ( +

+ {termsError} +

+ )} +
+ {/* Submit Button */} - -

- {t('booking.form.termsNote')} -

)} diff --git a/frontend/src/app/(public)/components/NextEventSection.tsx b/frontend/src/app/(public)/components/NextEventSection.tsx index 7404344..5a50712 100644 --- a/frontend/src/app/(public)/components/NextEventSection.tsx +++ b/frontend/src/app/(public)/components/NextEventSection.tsx @@ -102,11 +102,11 @@ export default function NextEventSection({ initialEvent }: NextEventSectionProps
- {formatDate(nextEvent.startDatetime)} + {formatDate(nextEvent.startDatetime)}
- {fmtTime(nextEvent.startDatetime)} + {fmtTime(nextEvent.startDatetime)}
diff --git a/frontend/src/app/(public)/dashboard/components/PaymentsTab.tsx b/frontend/src/app/(public)/dashboard/components/PaymentsTab.tsx index ea9fd21..4a2e5ee 100644 --- a/frontend/src/app/(public)/dashboard/components/PaymentsTab.tsx +++ b/frontend/src/app/(public)/dashboard/components/PaymentsTab.tsx @@ -4,6 +4,7 @@ import { useState } from 'react'; import Card from '@/components/ui/Card'; import Button from '@/components/ui/Button'; import { UserPayment } from '@/lib/api'; +import { parseDate } from '@/lib/utils'; interface PaymentsTabProps { payments: UserPayment[]; @@ -21,7 +22,7 @@ export default function PaymentsTab({ payments, language }: PaymentsTabProps) { }); const formatDate = (dateStr: string) => { - return new Date(dateStr).toLocaleDateString(language === 'es' ? 'es-ES' : 'en-US', { + return parseDate(dateStr).toLocaleDateString(language === 'es' ? 'es-ES' : 'en-US', { year: 'numeric', month: 'short', day: 'numeric', diff --git a/frontend/src/app/(public)/dashboard/components/ProfileTab.tsx b/frontend/src/app/(public)/dashboard/components/ProfileTab.tsx index bff2f5f..3894eb8 100644 --- a/frontend/src/app/(public)/dashboard/components/ProfileTab.tsx +++ b/frontend/src/app/(public)/dashboard/components/ProfileTab.tsx @@ -7,6 +7,7 @@ import Card from '@/components/ui/Card'; import Button from '@/components/ui/Button'; import Input from '@/components/ui/Input'; import { dashboardApi, UserProfile } from '@/lib/api'; +import { parseDate } from '@/lib/utils'; import toast from 'react-hot-toast'; interface ProfileTabProps { @@ -116,7 +117,7 @@ export default function ProfileTab({ onUpdate }: ProfileTabProps) { {profile?.memberSince - ? new Date(profile.memberSince).toLocaleDateString( + ? parseDate(profile.memberSince).toLocaleDateString( language === 'es' ? 'es-ES' : 'en-US', { year: 'numeric', month: 'long', day: 'numeric', timeZone: 'America/Asuncion' } ) diff --git a/frontend/src/app/(public)/dashboard/components/SecurityTab.tsx b/frontend/src/app/(public)/dashboard/components/SecurityTab.tsx index fd43bbe..380ed31 100644 --- a/frontend/src/app/(public)/dashboard/components/SecurityTab.tsx +++ b/frontend/src/app/(public)/dashboard/components/SecurityTab.tsx @@ -7,6 +7,7 @@ import Card from '@/components/ui/Card'; import Button from '@/components/ui/Button'; import Input from '@/components/ui/Input'; import { dashboardApi, authApi, UserProfile, UserSession } from '@/lib/api'; +import { parseDate } from '@/lib/utils'; import toast from 'react-hot-toast'; export default function SecurityTab() { @@ -147,7 +148,7 @@ export default function SecurityTab() { }; const formatDate = (dateStr: string) => { - return new Date(dateStr).toLocaleString(language === 'es' ? 'es-ES' : 'en-US', { + return parseDate(dateStr).toLocaleString(language === 'es' ? 'es-ES' : 'en-US', { year: 'numeric', month: 'short', day: 'numeric', diff --git a/frontend/src/app/(public)/dashboard/components/TicketsTab.tsx b/frontend/src/app/(public)/dashboard/components/TicketsTab.tsx index 69335f7..8e7dee2 100644 --- a/frontend/src/app/(public)/dashboard/components/TicketsTab.tsx +++ b/frontend/src/app/(public)/dashboard/components/TicketsTab.tsx @@ -5,6 +5,7 @@ import Link from 'next/link'; import Card from '@/components/ui/Card'; import Button from '@/components/ui/Button'; import { UserTicket } from '@/lib/api'; +import { parseDate } from '@/lib/utils'; interface TicketsTabProps { tickets: UserTicket[]; @@ -26,7 +27,7 @@ export default function TicketsTab({ tickets, language }: TicketsTabProps) { }); const formatDate = (dateStr: string) => { - return new Date(dateStr).toLocaleDateString(language === 'es' ? 'es-ES' : 'en-US', { + return parseDate(dateStr).toLocaleDateString(language === 'es' ? 'es-ES' : 'en-US', { year: 'numeric', month: 'short', day: 'numeric', diff --git a/frontend/src/app/(public)/page.tsx b/frontend/src/app/(public)/page.tsx index ed4e23e..c9dd594 100644 --- a/frontend/src/app/(public)/page.tsx +++ b/frontend/src/app/(public)/page.tsx @@ -3,6 +3,7 @@ import HeroSection from './components/HeroSection'; import NextEventSectionWrapper from './components/NextEventSectionWrapper'; import AboutSection from './components/AboutSection'; import MediaCarouselSection from './components/MediaCarouselSection'; +import { parseDate } from '@/lib/utils'; import NewsletterSection from './components/NewsletterSection'; import HomepageFaqSection from './components/HomepageFaqSection'; import { getCarouselImages } from '@/lib/carouselImages'; @@ -63,7 +64,7 @@ export async function generateMetadata(): Promise { }; } - const eventDate = new Date(event.startDatetime).toLocaleDateString('en-US', { + const eventDate = parseDate(event.startDatetime).toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', diff --git a/frontend/src/app/admin/bookings/page.tsx b/frontend/src/app/admin/bookings/page.tsx index 7ed6141..b6f1c8a 100644 --- a/frontend/src/app/admin/bookings/page.tsx +++ b/frontend/src/app/admin/bookings/page.tsx @@ -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', diff --git a/frontend/src/app/admin/contacts/page.tsx b/frontend/src/app/admin/contacts/page.tsx index 8953ed9..30bf0c7 100644 --- a/frontend/src/app/admin/contacts/page.tsx +++ b/frontend/src/app/admin/contacts/page.tsx @@ -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', diff --git a/frontend/src/app/admin/emails/page.tsx b/frontend/src/app/admin/emails/page.tsx index 426d928..3aec4ac 100644 --- a/frontend/src/app/admin/emails/page.tsx +++ b/frontend/src/app/admin/emails/page.tsx @@ -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() {
{hasDraft && ( - 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' }) : ''} )} +
@@ -2046,6 +2080,86 @@ export default function AdminEventDetailPage() {
)} + {/* Invite Guest Modal */} + {showInviteGuestModal && ( +
setShowInviteGuestModal(false)} + role="presentation" + > + e.stopPropagation()} + > +
+
+

Invite Guest

+

Free ticket — not counted in revenue

+
+ +
+
+
+
+ + 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" /> +
+
+ + 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" /> +
+
+
+ + 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)" /> +

If provided, a confirmation email will be sent

+
+
+ + 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" /> +
+
+ +