From e09ff4ed60e4f7b9f2adb34322b9136c1e46a881 Mon Sep 17 00:00:00 2001 From: Michilis Date: Tue, 10 Mar 2026 01:10:42 +0000 Subject: [PATCH] Admin: stats privacy toggle, clickable event rows, fix payment method display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add useStatsPrivacy hook with localStorage persistence for stats visibility - Single event page: desktop privacy button, hide capacity chip when stats hidden - Events list: row/card click navigates to event detail; stopPropagation on actions - Backend GET /tickets: include payment for each ticket (removes N+1) - Bookings page: use list payment data, show — when payment method unknown Made-with: Cursor --- backend/src/routes/tickets.ts | 26 ++++++++++--- frontend/src/app/admin/bookings/page.tsx | 31 +++++++--------- frontend/src/app/admin/events/[id]/page.tsx | 19 +++++++--- frontend/src/app/admin/events/page.tsx | 19 +++++++--- frontend/src/hooks/useStatsPrivacy.ts | 41 +++++++++++++++++++++ 5 files changed, 102 insertions(+), 34 deletions(-) create mode 100644 frontend/src/hooks/useStatsPrivacy.ts diff --git a/backend/src/routes/tickets.ts b/backend/src/routes/tickets.ts index 7456779..59e3537 100644 --- a/backend/src/routes/tickets.ts +++ b/backend/src/routes/tickets.ts @@ -2,7 +2,7 @@ import { Hono } from 'hono'; import { zValidator } from '@hono/zod-validator'; import { z } from 'zod'; import { db, dbGet, dbAll, tickets, events, users, payments, paymentOptions, siteSettings } from '../db/index.js'; -import { eq, and, or, sql } from 'drizzle-orm'; +import { eq, and, or, sql, inArray } from 'drizzle-orm'; import { requireAuth, getAuthUser } from '../lib/auth.js'; import { generateId, generateTicketCode, getNow, calculateAvailableSeats, isEventSoldOut } from '../lib/utils.js'; import { createInvoice, isLNbitsConfigured } from '../lib/lnbits.js'; @@ -1394,7 +1394,7 @@ ticketsRouter.post('/admin/manual', requireAuth(['admin', 'organizer', 'staff']) }, 201); }); -// Get all tickets (admin) +// Get all tickets (admin) - includes payment for each ticket ticketsRouter.get('/', requireAuth(['admin', 'organizer']), async (c) => { const eventId = c.req.query('eventId'); const status = c.req.query('status'); @@ -1413,9 +1413,25 @@ ticketsRouter.get('/', requireAuth(['admin', 'organizer']), async (c) => { query = query.where(and(...conditions)); } - const result = await dbAll(query); - - return c.json({ tickets: result }); + const ticketsList = await dbAll(query); + const ticketIds = ticketsList.map((t: any) => t.id); + + let paymentByTicketId: Record = {}; + if (ticketIds.length > 0) { + const paymentsList = await dbAll( + (db as any).select().from(payments).where(inArray((payments as any).ticketId, ticketIds)) + ); + for (const p of paymentsList as any[]) { + paymentByTicketId[p.ticketId] = p; + } + } + + const ticketsWithPayment = ticketsList.map((t: any) => ({ + ...t, + payment: paymentByTicketId[t.id] || null, + })); + + return c.json({ tickets: ticketsWithPayment }); }); export default ticketsRouter; diff --git a/frontend/src/app/admin/bookings/page.tsx b/frontend/src/app/admin/bookings/page.tsx index cb5fdc7..7ed6141 100644 --- a/frontend/src/app/admin/bookings/page.tsx +++ b/frontend/src/app/admin/bookings/page.tsx @@ -59,19 +59,13 @@ export default function AdminBookingsPage() { ticketsApi.getAll(), eventsApi.getAll(), ]); - - const ticketsWithDetails = await Promise.all( - ticketsRes.tickets.map(async (ticket) => { - try { - const { ticket: fullTicket } = await ticketsApi.getById(ticket.id); - return fullTicket; - } catch { - return ticket; - } - }) - ); - - setTickets(ticketsWithDetails); + + const ticketsWithEvent = ticketsRes.tickets.map((ticket) => ({ + ...ticket, + event: eventsRes.events.find((e) => e.id === ticket.eventId), + })); + + setTickets(ticketsWithEvent); setEvents(eventsRes.events); } catch (error) { toast.error('Failed to load bookings'); @@ -153,7 +147,8 @@ export default function AdminBookingsPage() { } }; - const getPaymentMethodLabel = (provider: string) => { + const getPaymentMethodLabel = (provider: string | null) => { + if (provider == null) return '—'; const labels: Record = { cash: locale === 'es' ? 'Efectivo en el Evento' : 'Cash at Event', bank_transfer: locale === 'es' ? 'Transferencia Bancaria' : 'Bank Transfer', @@ -164,13 +159,13 @@ export default function AdminBookingsPage() { return labels[provider] || provider; }; - const getDisplayProvider = (ticket: TicketWithDetails) => { + const getDisplayProvider = (ticket: TicketWithDetails): string | null => { if (ticket.payment?.provider) return ticket.payment.provider; if (ticket.bookingId) { - const sibling = tickets.find(t => t.bookingId === ticket.bookingId && t.payment?.provider); - return sibling?.payment?.provider ?? 'cash'; + const sibling = tickets.find((t) => t.bookingId === ticket.bookingId && t.payment?.provider); + return sibling?.payment?.provider ?? null; } - return 'cash'; + return null; }; const filteredTickets = tickets.filter((ticket) => { diff --git a/frontend/src/app/admin/events/[id]/page.tsx b/frontend/src/app/admin/events/[id]/page.tsx index 11675d2..1d1c783 100644 --- a/frontend/src/app/admin/events/[id]/page.tsx +++ b/frontend/src/app/admin/events/[id]/page.tsx @@ -41,6 +41,7 @@ import { } from '@heroicons/react/24/outline'; import toast from 'react-hot-toast'; import clsx from 'clsx'; +import { useStatsPrivacy } from '@/hooks/useStatsPrivacy'; type TabType = 'overview' | 'attendees' | 'tickets' | 'email' | 'payments'; @@ -68,7 +69,7 @@ export default function AdminEventDetailPage() { const [statusFilter, setStatusFilter] = useState<'all' | 'pending' | 'confirmed' | 'checked_in' | 'cancelled'>('all'); const [showAddAtDoorModal, setShowAddAtDoorModal] = useState(false); const [showManualTicketModal, setShowManualTicketModal] = useState(false); - const [showStats, setShowStats] = useState(true); + const [showStats, setShowStats, toggleStats] = useStatsPrivacy(); const [showNoteModal, setShowNoteModal] = useState(false); const [selectedTicket, setSelectedTicket] = useState(null); const [noteText, setNoteText] = useState(''); @@ -576,6 +577,10 @@ export default function AdminEventDetailPage() { {/* Desktop header actions */}
+
{/* ============= STATS ROW ============= */} diff --git a/frontend/src/app/admin/events/page.tsx b/frontend/src/app/admin/events/page.tsx index c803230..a4a6c57 100644 --- a/frontend/src/app/admin/events/page.tsx +++ b/frontend/src/app/admin/events/page.tsx @@ -2,7 +2,7 @@ import { useState, useEffect } from 'react'; import Link from 'next/link'; -import { useSearchParams } from 'next/navigation'; +import { useRouter, useSearchParams } from 'next/navigation'; import { useLanguage } from '@/context/LanguageContext'; import { eventsApi, siteSettingsApi, Event } from '@/lib/api'; import Card from '@/components/ui/Card'; @@ -16,6 +16,7 @@ import toast from 'react-hot-toast'; import clsx from 'clsx'; export default function AdminEventsPage() { + const router = useRouter(); const { t, locale } = useLanguage(); const searchParams = useSearchParams(); const [events, setEvents] = useState([]); @@ -458,7 +459,11 @@ export default function AdminEventsPage() { ) : ( events.map((event) => ( - + router.push(`/admin/events/${event.id}`)} + className={clsx("hover:bg-gray-50 cursor-pointer", featuredEventId === event.id && "bg-amber-50")} + >
{event.bannerUrl ? ( @@ -492,7 +497,7 @@ export default function AdminEventsPage() { )}
- + e.stopPropagation()}>
{event.status === 'draft' && (
) : ( events.map((event) => ( - + router.push(`/admin/events/${event.id}`)} + >
{event.bannerUrl ? ( {event.title}

{event.bookedCount || 0} / {event.capacity} spots

-
+
e.stopPropagation()}> diff --git a/frontend/src/hooks/useStatsPrivacy.ts b/frontend/src/hooks/useStatsPrivacy.ts new file mode 100644 index 0000000..3516f71 --- /dev/null +++ b/frontend/src/hooks/useStatsPrivacy.ts @@ -0,0 +1,41 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; + +const STORAGE_KEY = 'spanglish-admin-stats-hidden'; + +export function useStatsPrivacy() { + const [showStats, setShowStatsState] = useState(true); + + useEffect(() => { + if (typeof window === 'undefined') return; + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored !== null) { + setShowStatsState(stored !== 'true'); + } + } catch { + // ignore + } + }, []); + + const setShowStats = useCallback((value: boolean | ((prev: boolean) => boolean)) => { + setShowStatsState((prev) => { + const next = typeof value === 'function' ? value(prev) : value; + try { + if (typeof window !== 'undefined') { + localStorage.setItem(STORAGE_KEY, String(!next)); + } + } catch { + // ignore + } + return next; + }); + }, []); + + const toggleStats = useCallback(() => { + setShowStats((prev) => !prev); + }, [setShowStats]); + + return [showStats, setShowStats, toggleStats] as const; +} -- 2.49.1