From 958181e049533f433c3d8471e198931419c46175 Mon Sep 17 00:00:00 2001 From: Michilis Date: Wed, 18 Feb 2026 03:27:49 +0000 Subject: [PATCH 1/2] Mobile-friendly admin pages, redesigned homepage Next Event card - Extract shared mobile components (BottomSheet, MoreMenu, Dropdown, etc.) into MobileComponents.tsx - Make admin pages mobile-friendly: bookings, emails, events, faq, payments, tickets, users - Redesign homepage Next Event card with banner image, responsive layout, and updated styling - Fix past events showing on homepage/linktree: use proper Date comparison, auto-unfeature expired events - Add "Over" tag to admin events list for past events - Fix backend FRONTEND_URL for cache revalidation Co-authored-by: Cursor --- backend/src/routes/events.ts | 31 +- .../(public)/components/NextEventSection.tsx | 136 +++-- .../components/NextEventSectionWrapper.tsx | 2 +- frontend/src/app/admin/bookings/page.tsx | 546 +++++++++++------- frontend/src/app/admin/emails/page.tsx | 255 ++++---- frontend/src/app/admin/events/[id]/page.tsx | 174 +----- frontend/src/app/admin/events/page.tsx | 532 ++++++++--------- frontend/src/app/admin/faq/page.tsx | 348 ++++++----- frontend/src/app/admin/payments/page.tsx | 337 ++++++----- frontend/src/app/admin/tickets/page.tsx | 400 ++++++------- frontend/src/app/admin/users/page.tsx | 276 +++++---- frontend/src/app/linktree/page.tsx | 11 +- .../src/components/admin/MobileComponents.tsx | 183 ++++++ 13 files changed, 1724 insertions(+), 1507 deletions(-) create mode 100644 frontend/src/components/admin/MobileComponents.tsx diff --git a/backend/src/routes/events.ts b/backend/src/routes/events.ts index 4a1c1a5..b4f2239 100644 --- a/backend/src/routes/events.ts +++ b/backend/src/routes/events.ts @@ -220,6 +220,7 @@ async function getEventTicketCount(eventId: string): Promise { // Get next upcoming event (public) - returns featured event if valid, otherwise next upcoming eventsRouter.get('/next/upcoming', async (c) => { const now = getNow(); + const nowMs = Date.now(); // First, check if there's a featured event in site settings const settings = await dbGet( @@ -230,7 +231,6 @@ eventsRouter.get('/next/upcoming', async (c) => { let shouldUnsetFeatured = false; if (settings?.featuredEventId) { - // Get the featured event featuredEvent = await dbGet( (db as any) .select() @@ -239,37 +239,30 @@ eventsRouter.get('/next/upcoming', async (c) => { ); if (featuredEvent) { - // Check if featured event is still valid: - // 1. Must be published - // 2. Must not have ended (endDatetime >= now, or startDatetime >= now if no endDatetime) const eventEndTime = featuredEvent.endDatetime || featuredEvent.startDatetime; const isPublished = featuredEvent.status === 'published'; - const hasNotEnded = eventEndTime >= now; + const hasNotEnded = new Date(eventEndTime).getTime() > nowMs; if (!isPublished || !hasNotEnded) { - // Featured event is no longer valid - mark for unsetting shouldUnsetFeatured = true; featuredEvent = null; } } else { - // Featured event no longer exists shouldUnsetFeatured = true; } } - // If we need to unset the featured event, do it asynchronously if (shouldUnsetFeatured && settings) { - // Unset featured event in background (don't await to avoid blocking response) - (db as any) - .update(siteSettings) - .set({ featuredEventId: null, updatedAt: now }) - .where(eq((siteSettings as any).id, settings.id)) - .then(() => { - console.log('Featured event auto-cleared (event ended or unpublished)'); - }) - .catch((err: any) => { - console.error('Failed to clear featured event:', err); - }); + try { + await (db as any) + .update(siteSettings) + .set({ featuredEventId: null, updatedAt: now }) + .where(eq((siteSettings as any).id, settings.id)); + console.log('Featured event auto-cleared (event ended or unpublished)'); + revalidateFrontendCache(); + } catch (err: any) { + console.error('Failed to clear featured event:', err); + } } // If we have a valid featured event, return it diff --git a/frontend/src/app/(public)/components/NextEventSection.tsx b/frontend/src/app/(public)/components/NextEventSection.tsx index 2353af0..7404344 100644 --- a/frontend/src/app/(public)/components/NextEventSection.tsx +++ b/frontend/src/app/(public)/components/NextEventSection.tsx @@ -5,9 +5,7 @@ import Link from 'next/link'; import { useLanguage } from '@/context/LanguageContext'; import { eventsApi, Event } from '@/lib/api'; import { formatPrice, formatDateLong, formatTime } from '@/lib/utils'; -import Button from '@/components/ui/Button'; -import Card from '@/components/ui/Card'; -import { CalendarIcon, MapPinIcon } from '@heroicons/react/24/outline'; +import { CalendarIcon, MapPinIcon, ClockIcon } from '@heroicons/react/24/outline'; interface NextEventSectionProps { initialEvent?: Event | null; @@ -16,11 +14,24 @@ interface NextEventSectionProps { export default function NextEventSection({ initialEvent }: NextEventSectionProps) { const { t, locale } = useLanguage(); const [nextEvent, setNextEvent] = useState(initialEvent ?? null); - const [loading, setLoading] = useState(!initialEvent); + const [loading, setLoading] = useState(initialEvent === undefined); useEffect(() => { - // Skip fetch if we already have server-provided data - if (initialEvent !== undefined) return; + if (initialEvent !== undefined) { + if (initialEvent) { + const endTime = initialEvent.endDatetime || initialEvent.startDatetime; + if (new Date(endTime).getTime() <= Date.now()) { + setNextEvent(null); + setLoading(true); + eventsApi.getNextUpcoming() + .then(({ event }) => setNextEvent(event)) + .catch(console.error) + .finally(() => setLoading(false)); + return; + } + } + return; + } eventsApi.getNextUpcoming() .then(({ event }) => setNextEvent(event)) .catch(console.error) @@ -30,6 +41,15 @@ export default function NextEventSection({ initialEvent }: NextEventSectionProps const formatDate = (dateStr: string) => formatDateLong(dateStr, locale as 'en' | 'es'); const fmtTime = (dateStr: string) => formatTime(dateStr, locale as 'en' | 'es'); + const title = nextEvent + ? (locale === 'es' && nextEvent.titleEs ? nextEvent.titleEs : nextEvent.title) + : ''; + const description = nextEvent + ? (locale === 'es' + ? (nextEvent.shortDescriptionEs || nextEvent.descriptionEs || nextEvent.shortDescription || nextEvent.description) + : (nextEvent.shortDescription || nextEvent.description)) + : ''; + if (loading) { return (
@@ -49,56 +69,72 @@ export default function NextEventSection({ initialEvent }: NextEventSectionProps } return ( - - -
-
-

- {locale === 'es' && nextEvent.titleEs ? nextEvent.titleEs : nextEvent.title} -

-

- {locale === 'es' - ? (nextEvent.shortDescriptionEs || nextEvent.descriptionEs || nextEvent.shortDescription || nextEvent.description) - : (nextEvent.shortDescription || nextEvent.description)} -

- -
-
- - {formatDate(nextEvent.startDatetime)} -
-
- - ⏰ - - {fmtTime(nextEvent.startDatetime)} -
-
- - {nextEvent.location} -
+ +
+
+ {/* Banner */} + {nextEvent.bannerUrl ? ( +
+ {title}
-
- -
-
- - {nextEvent.price === 0 - ? t('events.details.free') - : formatPrice(nextEvent.price, nextEvent.currency)} - - {!nextEvent.externalBookingEnabled && ( -

- {nextEvent.availableSeats} {t('events.details.spotsLeft')} + ) : ( +

+ +
+ )} + + {/* Info */} +
+
+

+ {title} +

+ {description && ( +

+ {description}

)} + +
+
+ + {formatDate(nextEvent.startDatetime)} +
+
+ + {fmtTime(nextEvent.startDatetime)} +
+
+ + {nextEvent.location} +
+
+
+ +
+
+ + {nextEvent.price === 0 + ? t('events.details.free') + : formatPrice(nextEvent.price, nextEvent.currency)} + + {!nextEvent.externalBookingEnabled && nextEvent.availableSeats != null && ( +

+ {nextEvent.availableSeats} {t('events.details.spotsLeft')} +

+ )} +
+ + {t('common.moreInfo')} +
-
- +
); } diff --git a/frontend/src/app/(public)/components/NextEventSectionWrapper.tsx b/frontend/src/app/(public)/components/NextEventSectionWrapper.tsx index 9bd78d7..e0ab2ca 100644 --- a/frontend/src/app/(public)/components/NextEventSectionWrapper.tsx +++ b/frontend/src/app/(public)/components/NextEventSectionWrapper.tsx @@ -17,7 +17,7 @@ export default function NextEventSectionWrapper({ initialEvent }: NextEventSecti

{t('home.nextEvent.title')}

-
+
diff --git a/frontend/src/app/admin/bookings/page.tsx b/frontend/src/app/admin/bookings/page.tsx index 97a5b22..161727c 100644 --- a/frontend/src/app/admin/bookings/page.tsx +++ b/frontend/src/app/admin/bookings/page.tsx @@ -5,6 +5,7 @@ import { useLanguage } from '@/context/LanguageContext'; import { ticketsApi, eventsApi, Ticket, Event } from '@/lib/api'; import Card from '@/components/ui/Card'; import Button from '@/components/ui/Button'; +import { BottomSheet, MoreMenu, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents'; import { TicketIcon, CheckCircleIcon, @@ -14,8 +15,10 @@ import { EnvelopeIcon, PhoneIcon, FunnelIcon, + MagnifyingGlassIcon, } from '@heroicons/react/24/outline'; import toast from 'react-hot-toast'; +import clsx from 'clsx'; interface TicketWithDetails extends Omit { bookingId?: string; @@ -40,10 +43,11 @@ export default function AdminBookingsPage() { const [loading, setLoading] = useState(true); const [processing, setProcessing] = useState(null); - // Filters const [selectedEvent, setSelectedEvent] = useState(''); const [selectedStatus, setSelectedStatus] = useState(''); const [selectedPaymentStatus, setSelectedPaymentStatus] = useState(''); + const [searchQuery, setSearchQuery] = useState(''); + const [mobileFilterOpen, setMobileFilterOpen] = useState(false); useEffect(() => { loadData(); @@ -56,7 +60,6 @@ export default function AdminBookingsPage() { eventsApi.getAll(), ]); - // Fetch full ticket details with payment info const ticketsWithDetails = await Promise.all( ticketsRes.tickets.map(async (ticket) => { try { @@ -131,62 +134,50 @@ export default function AdminBookingsPage() { const getStatusColor = (status: string) => { switch (status) { - case 'confirmed': - return 'bg-green-100 text-green-800'; - case 'pending': - return 'bg-yellow-100 text-yellow-800'; - case 'cancelled': - return 'bg-red-100 text-red-800'; - case 'checked_in': - return 'bg-blue-100 text-blue-800'; - default: - return 'bg-gray-100 text-gray-800'; + case 'confirmed': return 'bg-green-100 text-green-800'; + case 'pending': return 'bg-yellow-100 text-yellow-800'; + case 'cancelled': return 'bg-red-100 text-red-800'; + case 'checked_in': return 'bg-blue-100 text-blue-800'; + default: return 'bg-gray-100 text-gray-800'; } }; const getPaymentStatusColor = (status: string) => { switch (status) { - case 'paid': - return 'bg-green-100 text-green-800'; - case 'pending': - return 'bg-yellow-100 text-yellow-800'; + case 'paid': return 'bg-green-100 text-green-800'; + case 'pending': return 'bg-yellow-100 text-yellow-800'; case 'failed': - case 'cancelled': - return 'bg-red-100 text-red-800'; - case 'refunded': - return 'bg-purple-100 text-purple-800'; - default: - return 'bg-gray-100 text-gray-800'; + case 'cancelled': return 'bg-red-100 text-red-800'; + case 'refunded': return 'bg-purple-100 text-purple-800'; + default: return 'bg-gray-100 text-gray-800'; } }; const getPaymentMethodLabel = (provider: string) => { switch (provider) { - case 'bancard': - return 'TPago / Card'; - case 'lightning': - return 'Bitcoin Lightning'; - case 'cash': - return 'Cash at Event'; - default: - return provider; + case 'bancard': return 'TPago / Card'; + case 'lightning': return 'Bitcoin Lightning'; + case 'cash': return 'Cash at Event'; + default: return provider; } }; - // Filter tickets const filteredTickets = tickets.filter((ticket) => { if (selectedEvent && ticket.eventId !== selectedEvent) return false; if (selectedStatus && ticket.status !== selectedStatus) return false; if (selectedPaymentStatus && ticket.payment?.status !== selectedPaymentStatus) return false; + if (searchQuery) { + const q = searchQuery.toLowerCase(); + const name = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.toLowerCase(); + return name.includes(q) || (ticket.attendeeEmail?.toLowerCase().includes(q) || false); + } return true; }); - // Sort by created date (newest first) const sortedTickets = [...filteredTickets].sort( (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() ); - // Stats const stats = { total: tickets.length, pending: tickets.filter(t => t.status === 'pending').length, @@ -196,23 +187,36 @@ export default function AdminBookingsPage() { pendingPayment: tickets.filter(t => t.payment?.status === 'pending').length, }; - // Helper to get booking info for a ticket (ticket count and total) const getBookingInfo = (ticket: TicketWithDetails) => { if (!ticket.bookingId) { return { ticketCount: 1, bookingTotal: Number(ticket.payment?.amount || 0) }; } - - // Count all tickets with the same bookingId - const bookingTickets = tickets.filter( - t => t.bookingId === ticket.bookingId - ); - + const bookingTickets = tickets.filter(t => t.bookingId === ticket.bookingId); return { ticketCount: bookingTickets.length, bookingTotal: bookingTickets.reduce((sum, t) => sum + Number(t.payment?.amount || 0), 0), }; }; + const hasActiveFilters = selectedEvent || selectedStatus || selectedPaymentStatus || searchQuery; + + const clearFilters = () => { + setSelectedEvent(''); + setSelectedStatus(''); + setSelectedPaymentStatus(''); + setSearchQuery(''); + }; + + const getPrimaryAction = (ticket: TicketWithDetails) => { + if (ticket.status === 'pending' && ticket.payment?.status === 'pending') { + return { label: 'Mark Paid', onClick: () => handleMarkPaid(ticket.id), color: 'text-green-600' }; + } + if (ticket.status === 'confirmed') { + return { label: 'Check In', onClick: () => handleCheckin(ticket.id), color: 'text-blue-600' }; + } + return null; + }; + if (loading) { return (
@@ -224,51 +228,61 @@ export default function AdminBookingsPage() { return (
-

Manage Bookings

+

Manage Bookings

{/* Stats Cards */} -
- -

{stats.total}

-

Total

+
+ +

{stats.total}

+

Total

- -

{stats.pending}

-

Pending

+ +

{stats.pending}

+

Pending

- -

{stats.confirmed}

-

Confirmed

+ +

{stats.confirmed}

+

Confirmed

- -

{stats.checkedIn}

-

Checked In

+ +

{stats.checkedIn}

+

Checked In

- -

{stats.cancelled}

-

Cancelled

+ +

{stats.cancelled}

+

Cancelled

- -

{stats.pendingPayment}

-

Pending Payment

+ +

{stats.pendingPayment}

+

Pending Pay

- {/* Filters */} - + {/* Desktop Filters */} +
Filters
-
+
+
+ +
+ + setSearchQuery(e.target.value)} + className="w-full pl-9 pr-3 py-2 rounded-btn border border-secondary-light-gray text-sm focus:outline-none focus:ring-2 focus:ring-primary-yellow" + /> +
+
- setSelectedEvent(e.target.value)} + className="w-full px-3 py-2 rounded-btn border border-secondary-light-gray text-sm"> {events.map((event) => ( @@ -277,11 +291,8 @@ export default function AdminBookingsPage() {
- setSelectedStatus(e.target.value)} + className="w-full px-3 py-2 rounded-btn border border-secondary-light-gray text-sm"> @@ -291,12 +302,9 @@ export default function AdminBookingsPage() {
- setSelectedPaymentStatus(e.target.value)} + className="w-full px-3 py-2 rounded-btn border border-secondary-light-gray text-sm"> + @@ -304,26 +312,66 @@ export default function AdminBookingsPage() {
+ {hasActiveFilters && ( +
+ Showing {sortedTickets.length} of {tickets.length} + +
+ )} - {/* Bookings List */} - + {/* Mobile Toolbar */} +
+
+ + 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" + /> +
+
+ + {hasActiveFilters && ( + + )} +
+
+ + {/* Desktop: Table */} +
- - - - - - + + + + + + {sortedTickets.length === 0 ? ( - @@ -331,123 +379,69 @@ export default function AdminBookingsPage() { sortedTickets.map((ticket) => { const bookingInfo = getBookingInfo(ticket); return ( - - - - + + + - - - + - + + + + ); }) )} @@ -455,6 +449,158 @@ export default function AdminBookingsPage() {
AttendeeEventPaymentStatusBookedActionsAttendeeEventPaymentStatusBookedActions
+ No bookings found.
-
-
- - {ticket.attendeeFirstName} {ticket.attendeeLastName || ''} -
-
- - {ticket.attendeeEmail || 'N/A'} -
-
- - {ticket.attendeePhone || 'N/A'} -
-
-
- - {ticket.event?.title || events.find(e => e.id === ticket.eventId)?.title || 'Unknown'} - - -
- +
+

{ticket.attendeeFirstName} {ticket.attendeeLastName || ''}

+

{ticket.attendeeEmail || 'N/A'}

+ {ticket.attendeePhone &&

{ticket.attendeePhone}

} +
+ + {ticket.event?.title || events.find(e => e.id === ticket.eventId)?.title || 'Unknown'} + + + {ticket.payment?.status || 'pending'} -

- {getPaymentMethodLabel(ticket.payment?.provider || 'cash')} -

+

{getPaymentMethodLabel(ticket.payment?.provider || 'cash')}

{ticket.payment && ( -
-

- {bookingInfo.bookingTotal.toLocaleString()} {ticket.payment.currency} -

- {bookingInfo.ticketCount > 1 && ( -

- 📦 {bookingInfo.ticketCount} × {Number(ticket.payment.amount).toLocaleString()} {ticket.payment.currency} -

- )} -
+

{bookingInfo.bookingTotal.toLocaleString()} {ticket.payment.currency}

)} - -
- - {ticket.status} - - {ticket.qrCode && ( -

{ticket.qrCode}

- )} - {ticket.bookingId && ( -

- 📦 Group Booking -

- )} -
- {formatDate(ticket.createdAt)} - -
- {/* Mark as Paid (for pending payments) */} - {ticket.status === 'pending' && ticket.payment?.status === 'pending' && ( - +
+ + {ticket.status.replace('_', ' ')} + + {ticket.bookingId && ( +

Group Booking

)} - - {/* Check-in (for confirmed tickets) */} - {ticket.status === 'confirmed' && ( - - )} - - {/* Cancel (for pending/confirmed) */} - {(ticket.status === 'pending' || ticket.status === 'confirmed') && ( - - )} - - {ticket.status === 'checked_in' && ( - - - Attended - - )} - - {ticket.status === 'cancelled' && ( - Cancelled - )} - -
+ {formatDate(ticket.createdAt)} + +
+ {ticket.status === 'pending' && ticket.payment?.status === 'pending' && ( + + )} + {ticket.status === 'confirmed' && ( + + )} + {(ticket.status === 'pending' || ticket.status === 'confirmed') && ( + + handleCancel(ticket.id)} className="text-red-600"> + Cancel + + + )} + {ticket.status === 'checked_in' && ( + + Attended + + )} + {ticket.status === 'cancelled' && ( + Cancelled + )} +
+
+ + {/* Mobile: Card List */} +
+ {sortedTickets.length === 0 ? ( +
+ No bookings found. +
+ ) : ( + sortedTickets.map((ticket) => { + const bookingInfo = getBookingInfo(ticket); + const primary = getPrimaryAction(ticket); + const eventTitle = ticket.event?.title || events.find(e => e.id === ticket.eventId)?.title || 'Unknown'; + return ( + +
+
+

{ticket.attendeeFirstName} {ticket.attendeeLastName || ''}

+

{ticket.attendeeEmail || 'N/A'}

+
+
+ + {ticket.status.replace('_', ' ')} + +
+
+
+ {eventTitle} + | + + {ticket.payment?.status || 'pending'} + + {ticket.payment && ( + <> + | + {bookingInfo.bookingTotal.toLocaleString()} {ticket.payment.currency} + + )} +
+ {ticket.bookingId && ( +

{bookingInfo.ticketCount} tickets - Group Booking

+ )} +
+

{formatDate(ticket.createdAt)}

+
+ {primary && ( + + )} + {(ticket.status === 'pending' || ticket.status === 'confirmed') && ( + + {ticket.status === 'pending' && ticket.payment?.status === 'pending' && !primary && ( + handleMarkPaid(ticket.id)}> + Mark Paid + + )} + handleCancel(ticket.id)} className="text-red-600"> + Cancel Booking + + + )} + {ticket.status === 'checked_in' && ( + + Attended + + )} + {ticket.status === 'cancelled' && ( + Cancelled + )} +
+
+
+ ); + }) + )} +
+ + {/* Mobile Filter BottomSheet */} + setMobileFilterOpen(false)} title="Filters"> +
+
+ + +
+
+ +
+ {[ + { value: '', label: 'All Statuses' }, + { value: 'pending', label: `Pending (${stats.pending})` }, + { value: 'confirmed', label: `Confirmed (${stats.confirmed})` }, + { value: 'checked_in', label: `Checked In (${stats.checkedIn})` }, + { value: 'cancelled', label: `Cancelled (${stats.cancelled})` }, + ].map((opt) => ( + + ))} +
+
+
+ +
+ {[ + { value: '', label: 'All Payments' }, + { value: 'pending', label: 'Pending' }, + { value: 'paid', label: 'Paid' }, + { value: 'refunded', label: 'Refunded' }, + { value: 'failed', label: 'Failed' }, + ].map((opt) => ( + + ))} +
+
+
+ + +
+
+
+ +
); } diff --git a/frontend/src/app/admin/emails/page.tsx b/frontend/src/app/admin/emails/page.tsx index 6374ad2..931601f 100644 --- a/frontend/src/app/admin/emails/page.tsx +++ b/frontend/src/app/admin/emails/page.tsx @@ -6,6 +6,7 @@ import { emailsApi, EmailTemplate, EmailLog, EmailStats } from '@/lib/api'; import Card from '@/components/ui/Card'; import Button from '@/components/ui/Button'; import Input from '@/components/ui/Input'; +import { MoreMenu, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents'; import { EnvelopeIcon, PencilIcon, @@ -18,6 +19,7 @@ import { ExclamationTriangleIcon, ChevronLeftIcon, ChevronRightIcon, + XMarkIcon, } from '@heroicons/react/24/outline'; import toast from 'react-hot-toast'; import clsx from 'clsx'; @@ -382,7 +384,7 @@ export default function AdminEmailsPage() { return (
-

Email Center

+

Email Center

{/* Stats Cards */} @@ -436,18 +438,15 @@ export default function AdminEmailsPage() { )} {/* Tabs */} -
-
-
- - - {!template.isSystem && ( - - )} +
+ {!template.isSystem && ( + + )} +
+
+ + handleEditTemplate(template)}> + Edit + + {!template.isSystem && ( + handleDeleteTemplate(template.id)} className="text-red-600"> + Delete + + )} + +
@@ -635,13 +639,17 @@ export default function AdminEmailsPage() { {/* Recipient Preview Modal */} {showRecipientPreview && ( -
- -
-

Recipient Preview

-

- {previewRecipients.length} recipient(s) will receive this email -

+
+ +
+
+

Recipient Preview

+

{previewRecipients.length} recipient(s)

+
+
@@ -675,14 +683,10 @@ export default function AdminEmailsPage() {
- -
@@ -695,51 +699,37 @@ export default function AdminEmailsPage() { {/* Logs Tab */} {activeTab === 'logs' && (
- + {/* Desktop: Table */} +
- - - - - + + + + + {logs.length === 0 ? ( - - - + ) : ( logs.map((log) => ( - - - - - + +
StatusRecipientSubjectSentActionsStatusRecipientSubjectSentActions
- No emails sent yet -
No emails sent yet
-
- {getStatusIcon(log.status)} - {log.status} -
+
+
{getStatusIcon(log.status)}{log.status}
+

{log.recipientName || 'Unknown'}

-

{log.recipientEmail}

+

{log.recipientEmail}

-

{log.subject}

-
- {formatDate(log.sentAt || log.createdAt)} - -
-

{log.subject}

{formatDate(log.sentAt || log.createdAt)} +
+
@@ -750,46 +740,69 @@ export default function AdminEmailsPage() {
- - {/* Pagination */} {logsTotal > 20 && ( -
-

- Showing {logsOffset + 1}-{Math.min(logsOffset + 20, logsTotal)} of {logsTotal} -

+
+

Showing {logsOffset + 1}-{Math.min(logsOffset + 20, logsTotal)} of {logsTotal}

- -
)} + + {/* Mobile: Card List */} +
+ {logs.length === 0 ? ( +
No emails sent yet
+ ) : ( + logs.map((log) => ( + setSelectedLog(log)}> +
+
{getStatusIcon(log.status)}
+
+

{log.subject}

+

{log.recipientName || 'Unknown'} <{log.recipientEmail}>

+

{formatDate(log.sentAt || log.createdAt)}

+
+
+
+ )) + )} + {logsTotal > 20 && ( +
+

{logsOffset + 1}-{Math.min(logsOffset + 20, logsTotal)} of {logsTotal}

+
+ + +
+
+ )} +
)} {/* Template Form Modal */} {showTemplateForm && ( -
- -

- {editingTemplate ? 'Edit Template' : 'Create Template'} -

+
+ +
+

{editingTemplate ? 'Edit Template' : 'Create Template'}

+ +
-
+
- -
@@ -891,16 +900,17 @@ export default function AdminEmailsPage() { {/* Preview Modal */} {previewHtml && ( -
- +
+
-
-

Email Preview

-

Subject: {previewSubject}

+
+

Email Preview

+

Subject: {previewSubject}

- +