3 Commits
backup6 ... dev

Author SHA1 Message Date
Michilis
f8ebc3760d Fix db:export ENOBUFS by streaming pg_dump output to file
Made-with: Cursor
2026-03-12 19:18:24 +00:00
Michilis
4da26e7ef1 feat(emails): add re-send for all emails, failed tab, and resend indicators
- Add resend_attempts and last_resent_at to email_logs schema and migrations
- Add POST /api/emails/logs/:id/resend and emailService.resendFromLog
- Add resendLog API and EmailLog.resendAttempts/lastResentAt
- Add All/Failed sub-tabs, resend button for all emails, re-sent indicator in logs and detail modal

Made-with: Cursor
2026-03-12 19:13:24 +00:00
Michilis
e09ff4ed60 Admin: stats privacy toggle, clickable event rows, fix payment method display
- 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
2026-03-10 01:10:42 +00:00
12 changed files with 329 additions and 65 deletions

View File

@@ -1,5 +1,5 @@
import 'dotenv/config'; import 'dotenv/config';
import { existsSync, mkdirSync, writeFileSync } from 'fs'; import { closeSync, existsSync, mkdirSync, openSync } from 'fs';
import { dirname, resolve } from 'path'; import { dirname, resolve } from 'path';
import { spawnSync } from 'child_process'; import { spawnSync } from 'child_process';
import Database from 'better-sqlite3'; import Database from 'better-sqlite3';
@@ -43,28 +43,32 @@ function exportSqlite(outputPath: string): void {
function exportPostgres(outputPath: string): void { function exportPostgres(outputPath: string): void {
const connString = process.env.DATABASE_URL || 'postgresql://localhost:5432/spanglish'; const connString = process.env.DATABASE_URL || 'postgresql://localhost:5432/spanglish';
const result = spawnSync( const outFd = openSync(outputPath, 'w');
'pg_dump', try {
['--clean', '--if-exists', connString], const result = spawnSync(
{ 'pg_dump',
stdio: ['ignore', 'pipe', 'pipe'], ['--clean', '--if-exists', connString],
encoding: 'utf-8', {
stdio: ['ignore', outFd, 'pipe'],
encoding: 'utf-8',
}
);
if (result.error) {
console.error('pg_dump failed. Ensure pg_dump is installed and in PATH.');
console.error(result.error.message);
process.exit(1);
} }
);
if (result.error) { if (result.status !== 0) {
console.error('pg_dump failed. Ensure pg_dump is installed and in PATH.'); console.error('pg_dump failed:', result.stderr);
console.error(result.error.message); process.exit(1);
process.exit(1); }
console.log(`Exported to ${outputPath}`);
} finally {
closeSync(outFd);
} }
if (result.status !== 0) {
console.error('pg_dump failed:', result.stderr);
process.exit(1);
}
writeFileSync(outputPath, result.stdout);
console.log(`Exported to ${outputPath}`);
} }
async function main() { async function main() {

View File

@@ -368,6 +368,13 @@ async function migrate() {
) )
`); `);
try {
await (db as any).run(sql`ALTER TABLE email_logs ADD COLUMN resend_attempts INTEGER NOT NULL DEFAULT 0`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).run(sql`ALTER TABLE email_logs ADD COLUMN last_resent_at TEXT`);
} catch (e) { /* column may already exist */ }
await (db as any).run(sql` await (db as any).run(sql`
CREATE TABLE IF NOT EXISTS email_settings ( CREATE TABLE IF NOT EXISTS email_settings (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
@@ -772,6 +779,13 @@ async function migrate() {
) )
`); `);
try {
await (db as any).execute(sql`ALTER TABLE email_logs ADD COLUMN resend_attempts INTEGER NOT NULL DEFAULT 0`);
} catch (e) { /* column may already exist */ }
try {
await (db as any).execute(sql`ALTER TABLE email_logs ADD COLUMN last_resent_at TIMESTAMP`);
} catch (e) { /* column may already exist */ }
await (db as any).execute(sql` await (db as any).execute(sql`
CREATE TABLE IF NOT EXISTS email_settings ( CREATE TABLE IF NOT EXISTS email_settings (
id UUID PRIMARY KEY, id UUID PRIMARY KEY,

View File

@@ -243,6 +243,8 @@ export const sqliteEmailLogs = sqliteTable('email_logs', {
sentAt: text('sent_at'), sentAt: text('sent_at'),
sentBy: text('sent_by').references(() => sqliteUsers.id), sentBy: text('sent_by').references(() => sqliteUsers.id),
createdAt: text('created_at').notNull(), createdAt: text('created_at').notNull(),
resendAttempts: integer('resend_attempts').notNull().default(0),
lastResentAt: text('last_resent_at'),
}); });
export const sqliteEmailSettings = sqliteTable('email_settings', { export const sqliteEmailSettings = sqliteTable('email_settings', {
@@ -557,6 +559,8 @@ export const pgEmailLogs = pgTable('email_logs', {
sentAt: timestamp('sent_at'), sentAt: timestamp('sent_at'),
sentBy: uuid('sent_by').references(() => pgUsers.id), sentBy: uuid('sent_by').references(() => pgUsers.id),
createdAt: timestamp('created_at').notNull(), createdAt: timestamp('created_at').notNull(),
resendAttempts: pgInteger('resend_attempts').notNull().default(0),
lastResentAt: timestamp('last_resent_at'),
}); });
export const pgEmailSettings = pgTable('email_settings', { export const pgEmailSettings = pgTable('email_settings', {

View File

@@ -1342,6 +1342,61 @@ export const emailService = {
error: result.error error: result.error
}; };
}, },
/**
* Resend an email from an existing log entry
*/
async resendFromLog(logId: string): Promise<{ success: boolean; error?: string }> {
const log = await dbGet<any>(
(db as any).select().from(emailLogs).where(eq((emailLogs as any).id, logId))
);
if (!log) {
return { success: false, error: 'Email log not found' };
}
if (!log.bodyHtml || !log.subject || !log.recipientEmail) {
return { success: false, error: 'Email log missing required data to resend' };
}
const result = await sendEmail({
to: log.recipientEmail,
subject: log.subject,
html: log.bodyHtml,
text: undefined,
});
const now = getNow();
const currentResendAttempts = (log.resendAttempts ?? 0) + 1;
if (result.success) {
await (db as any)
.update(emailLogs)
.set({
status: 'sent',
sentAt: now,
errorMessage: null,
resendAttempts: currentResendAttempts,
lastResentAt: now,
})
.where(eq((emailLogs as any).id, logId));
} else {
await (db as any)
.update(emailLogs)
.set({
status: 'failed',
errorMessage: result.error,
resendAttempts: currentResendAttempts,
lastResentAt: now,
})
.where(eq((emailLogs as any).id, logId));
}
return {
success: result.success,
error: result.error,
};
},
}; };
// Export the main sendEmail function for direct use // Export the main sendEmail function for direct use

View File

@@ -349,6 +349,23 @@ emailsRouter.get('/logs/:id', requireAuth(['admin', 'organizer']), async (c) =>
return c.json({ log }); return c.json({ log });
}); });
// Resend email from log
emailsRouter.post('/logs/:id/resend', requireAuth(['admin', 'organizer']), async (c) => {
const { id } = c.req.param();
const result = await emailService.resendFromLog(id);
if (!result.success && result.error === 'Email log not found') {
return c.json({ error: 'Email log not found' }, 404);
}
if (!result.success && result.error === 'Email log missing required data to resend') {
return c.json({ error: result.error }, 400);
}
return c.json({ success: result.success, error: result.error });
});
// Get email stats // Get email stats
emailsRouter.get('/stats', requireAuth(['admin', 'organizer']), async (c) => { emailsRouter.get('/stats', requireAuth(['admin', 'organizer']), async (c) => {
const eventId = c.req.query('eventId'); const eventId = c.req.query('eventId');

View File

@@ -2,7 +2,7 @@ import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator'; import { zValidator } from '@hono/zod-validator';
import { z } from 'zod'; import { z } from 'zod';
import { db, dbGet, dbAll, tickets, events, users, payments, paymentOptions, siteSettings } from '../db/index.js'; 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 { requireAuth, getAuthUser } from '../lib/auth.js';
import { generateId, generateTicketCode, getNow, calculateAvailableSeats, isEventSoldOut } from '../lib/utils.js'; import { generateId, generateTicketCode, getNow, calculateAvailableSeats, isEventSoldOut } from '../lib/utils.js';
import { createInvoice, isLNbitsConfigured } from '../lib/lnbits.js'; import { createInvoice, isLNbitsConfigured } from '../lib/lnbits.js';
@@ -1394,7 +1394,7 @@ ticketsRouter.post('/admin/manual', requireAuth(['admin', 'organizer', 'staff'])
}, 201); }, 201);
}); });
// Get all tickets (admin) // Get all tickets (admin) - includes payment for each ticket
ticketsRouter.get('/', requireAuth(['admin', 'organizer']), async (c) => { ticketsRouter.get('/', requireAuth(['admin', 'organizer']), async (c) => {
const eventId = c.req.query('eventId'); const eventId = c.req.query('eventId');
const status = c.req.query('status'); const status = c.req.query('status');
@@ -1413,9 +1413,25 @@ ticketsRouter.get('/', requireAuth(['admin', 'organizer']), async (c) => {
query = query.where(and(...conditions)); query = query.where(and(...conditions));
} }
const result = await dbAll(query); const ticketsList = await dbAll(query);
const ticketIds = ticketsList.map((t: any) => t.id);
return c.json({ tickets: result });
let paymentByTicketId: Record<string, any> = {};
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; export default ticketsRouter;

View File

@@ -59,19 +59,13 @@ export default function AdminBookingsPage() {
ticketsApi.getAll(), ticketsApi.getAll(),
eventsApi.getAll(), eventsApi.getAll(),
]); ]);
const ticketsWithDetails = await Promise.all( const ticketsWithEvent = ticketsRes.tickets.map((ticket) => ({
ticketsRes.tickets.map(async (ticket) => { ...ticket,
try { event: eventsRes.events.find((e) => e.id === ticket.eventId),
const { ticket: fullTicket } = await ticketsApi.getById(ticket.id); }));
return fullTicket;
} catch { setTickets(ticketsWithEvent);
return ticket;
}
})
);
setTickets(ticketsWithDetails);
setEvents(eventsRes.events); setEvents(eventsRes.events);
} catch (error) { } catch (error) {
toast.error('Failed to load bookings'); 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<string, string> = { const labels: Record<string, string> = {
cash: locale === 'es' ? 'Efectivo en el Evento' : 'Cash at Event', cash: locale === 'es' ? 'Efectivo en el Evento' : 'Cash at Event',
bank_transfer: locale === 'es' ? 'Transferencia Bancaria' : 'Bank Transfer', bank_transfer: locale === 'es' ? 'Transferencia Bancaria' : 'Bank Transfer',
@@ -164,13 +159,13 @@ export default function AdminBookingsPage() {
return labels[provider] || provider; return labels[provider] || provider;
}; };
const getDisplayProvider = (ticket: TicketWithDetails) => { const getDisplayProvider = (ticket: TicketWithDetails): string | null => {
if (ticket.payment?.provider) return ticket.payment.provider; if (ticket.payment?.provider) return ticket.payment.provider;
if (ticket.bookingId) { if (ticket.bookingId) {
const sibling = tickets.find(t => t.bookingId === ticket.bookingId && t.payment?.provider); const sibling = tickets.find((t) => t.bookingId === ticket.bookingId && t.payment?.provider);
return sibling?.payment?.provider ?? 'cash'; return sibling?.payment?.provider ?? null;
} }
return 'cash'; return null;
}; };
const filteredTickets = tickets.filter((ticket) => { const filteredTickets = tickets.filter((ticket) => {

View File

@@ -20,6 +20,7 @@ import {
ChevronLeftIcon, ChevronLeftIcon,
ChevronRightIcon, ChevronRightIcon,
XMarkIcon, XMarkIcon,
ArrowPathIcon,
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import clsx from 'clsx'; import clsx from 'clsx';
@@ -52,6 +53,8 @@ export default function AdminEmailsPage() {
const [logs, setLogs] = useState<EmailLog[]>([]); const [logs, setLogs] = useState<EmailLog[]>([]);
const [logsOffset, setLogsOffset] = useState(0); const [logsOffset, setLogsOffset] = useState(0);
const [logsTotal, setLogsTotal] = useState(0); const [logsTotal, setLogsTotal] = useState(0);
const [logsSubTab, setLogsSubTab] = useState<'all' | 'failed'>('all');
const [resendingLogId, setResendingLogId] = useState<string | null>(null);
const [selectedLog, setSelectedLog] = useState<EmailLog | null>(null); const [selectedLog, setSelectedLog] = useState<EmailLog | null>(null);
// Stats state // Stats state
@@ -214,7 +217,7 @@ export default function AdminEmailsPage() {
if (activeTab === 'logs') { if (activeTab === 'logs') {
loadLogs(); loadLogs();
} }
}, [activeTab, logsOffset]); }, [activeTab, logsOffset, logsSubTab]);
const loadData = async () => { const loadData = async () => {
try { try {
@@ -233,7 +236,11 @@ export default function AdminEmailsPage() {
const loadLogs = async () => { const loadLogs = async () => {
try { try {
const res = await emailsApi.getLogs({ limit: 20, offset: logsOffset }); const res = await emailsApi.getLogs({
limit: 20,
offset: logsOffset,
...(logsSubTab === 'failed' ? { status: 'failed' } : {}),
});
setLogs(res.logs); setLogs(res.logs);
setLogsTotal(res.pagination.total); setLogsTotal(res.pagination.total);
} catch (error) { } catch (error) {
@@ -241,6 +248,27 @@ export default function AdminEmailsPage() {
} }
}; };
const handleResend = async (log: EmailLog) => {
setResendingLogId(log.id);
try {
const res = await emailsApi.resendLog(log.id);
if (res.success) {
toast.success('Email re-sent successfully');
} else {
toast.error(res.error || 'Failed to re-send email');
}
await loadLogs();
if (selectedLog?.id === log.id) {
const { log: updatedLog } = await emailsApi.getLog(log.id);
setSelectedLog(updatedLog);
}
} catch (error: any) {
toast.error(error.message || 'Failed to re-send email');
} finally {
setResendingLogId(null);
}
};
const resetTemplateForm = () => { const resetTemplateForm = () => {
setTemplateForm({ setTemplateForm({
name: '', name: '',
@@ -699,6 +727,35 @@ export default function AdminEmailsPage() {
{/* Logs Tab */} {/* Logs Tab */}
{activeTab === 'logs' && ( {activeTab === 'logs' && (
<div> <div>
{/* Sub-tabs: All | Failed */}
<div className="border-b border-secondary-light-gray mb-4">
<nav className="flex gap-4">
<button
onClick={() => { setLogsSubTab('all'); setLogsOffset(0); }}
className={clsx(
'py-2 px-1 border-b-2 font-medium text-sm transition-colors',
logsSubTab === 'all' ? 'border-primary-yellow text-primary-dark' : 'border-transparent text-gray-500 hover:text-gray-700'
)}
>
All
</button>
<button
onClick={() => { setLogsSubTab('failed'); setLogsOffset(0); }}
className={clsx(
'py-2 px-1 border-b-2 font-medium text-sm transition-colors',
logsSubTab === 'failed' ? 'border-primary-yellow text-primary-dark' : 'border-transparent text-gray-500 hover:text-gray-700'
)}
>
Failed
{stats && stats.failed > 0 && (
<span className="ml-1.5 inline-flex items-center justify-center px-1.5 py-0.5 text-xs font-medium rounded-full bg-red-100 text-red-700">
{stats.failed}
</span>
)}
</button>
</nav>
</div>
{/* Desktop: Table */} {/* Desktop: Table */}
<Card className="overflow-hidden hidden md:block"> <Card className="overflow-hidden hidden md:block">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
@@ -714,12 +771,17 @@ export default function AdminEmailsPage() {
</thead> </thead>
<tbody className="divide-y divide-secondary-light-gray"> <tbody className="divide-y divide-secondary-light-gray">
{logs.length === 0 ? ( {logs.length === 0 ? (
<tr><td colSpan={5} className="px-4 py-12 text-center text-gray-500 text-sm">No emails sent yet</td></tr> <tr><td colSpan={5} className="px-4 py-12 text-center text-gray-500 text-sm">{logsSubTab === 'failed' ? 'No failed emails' : 'No emails sent yet'}</td></tr>
) : ( ) : (
logs.map((log) => ( logs.map((log) => (
<tr key={log.id} className="hover:bg-gray-50"> <tr key={log.id} className="hover:bg-gray-50">
<td className="px-4 py-3"> <td className="px-4 py-3">
<div className="flex items-center gap-2">{getStatusIcon(log.status)}<span className="capitalize text-sm">{log.status}</span></div> <div className="flex flex-col gap-1">
<div className="flex items-center gap-2">{getStatusIcon(log.status)}<span className="capitalize text-sm">{log.status}</span></div>
{(log.resendAttempts ?? 0) > 0 && (
<span className="text-xs text-gray-500">Re-sent {log.resendAttempts} time{(log.resendAttempts ?? 0) !== 1 ? 's' : ''}</span>
)}
</div>
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<p className="font-medium text-sm">{log.recipientName || 'Unknown'}</p> <p className="font-medium text-sm">{log.recipientName || 'Unknown'}</p>
@@ -728,7 +790,15 @@ export default function AdminEmailsPage() {
<td className="px-4 py-3 max-w-xs"><p className="text-sm truncate">{log.subject}</p></td> <td className="px-4 py-3 max-w-xs"><p className="text-sm truncate">{log.subject}</p></td>
<td className="px-4 py-3 text-xs text-gray-500">{formatDate(log.sentAt || log.createdAt)}</td> <td className="px-4 py-3 text-xs text-gray-500">{formatDate(log.sentAt || log.createdAt)}</td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<div className="flex items-center justify-end"> <div className="flex items-center justify-end gap-1">
<button
onClick={() => handleResend(log)}
disabled={resendingLogId === log.id}
className="p-2 hover:bg-gray-100 rounded-btn disabled:opacity-50"
title="Re-send"
>
<ArrowPathIcon className={clsx('w-4 h-4', resendingLogId === log.id && 'animate-spin')} />
</button>
<button onClick={() => setSelectedLog(log)} className="p-2 hover:bg-gray-100 rounded-btn" title="View"> <button onClick={() => setSelectedLog(log)} className="p-2 hover:bg-gray-100 rounded-btn" title="View">
<EyeIcon className="w-4 h-4" /> <EyeIcon className="w-4 h-4" />
</button> </button>
@@ -758,7 +828,7 @@ export default function AdminEmailsPage() {
{/* Mobile: Card List */} {/* Mobile: Card List */}
<div className="md:hidden space-y-2"> <div className="md:hidden space-y-2">
{logs.length === 0 ? ( {logs.length === 0 ? (
<div className="text-center py-10 text-gray-500 text-sm">No emails sent yet</div> <div className="text-center py-10 text-gray-500 text-sm">{logsSubTab === 'failed' ? 'No failed emails' : 'No emails sent yet'}</div>
) : ( ) : (
logs.map((log) => ( logs.map((log) => (
<Card key={log.id} className="p-3" onClick={() => setSelectedLog(log)}> <Card key={log.id} className="p-3" onClick={() => setSelectedLog(log)}>
@@ -768,7 +838,18 @@ export default function AdminEmailsPage() {
<p className="font-medium text-sm truncate">{log.subject}</p> <p className="font-medium text-sm truncate">{log.subject}</p>
<p className="text-xs text-gray-500 truncate">{log.recipientName || 'Unknown'} &lt;{log.recipientEmail}&gt;</p> <p className="text-xs text-gray-500 truncate">{log.recipientName || 'Unknown'} &lt;{log.recipientEmail}&gt;</p>
<p className="text-[10px] text-gray-400 mt-1">{formatDate(log.sentAt || log.createdAt)}</p> <p className="text-[10px] text-gray-400 mt-1">{formatDate(log.sentAt || log.createdAt)}</p>
{(log.resendAttempts ?? 0) > 0 && (
<p className="text-[10px] text-gray-500 mt-0.5">Re-sent {log.resendAttempts} time{(log.resendAttempts ?? 0) !== 1 ? 's' : ''}</p>
)}
</div> </div>
<button
onClick={(e) => { e.stopPropagation(); handleResend(log); }}
disabled={resendingLogId === log.id}
className="p-2 hover:bg-gray-100 rounded-btn flex-shrink-0 disabled:opacity-50"
title="Re-send"
>
<ArrowPathIcon className={clsx('w-4 h-4', resendingLogId === log.id && 'animate-spin')} />
</button>
</div> </div>
</Card> </Card>
)) ))
@@ -938,12 +1019,26 @@ export default function AdminEmailsPage() {
{selectedLog.errorMessage && ( {selectedLog.errorMessage && (
<span className="text-xs text-red-500">- {selectedLog.errorMessage}</span> <span className="text-xs text-red-500">- {selectedLog.errorMessage}</span>
)} )}
{(selectedLog.resendAttempts ?? 0) > 0 && (
<span className="text-xs text-gray-500">Re-sent {selectedLog.resendAttempts} time{(selectedLog.resendAttempts ?? 0) !== 1 ? 's' : ''}{selectedLog.lastResentAt ? ` (${formatDate(selectedLog.lastResentAt)})` : ''}</span>
)}
</div> </div>
</div> </div>
<button onClick={() => setSelectedLog(null)} <div className="flex items-center gap-1 flex-shrink-0">
className="p-2 hover:bg-gray-100 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center flex-shrink-0"> <button
<XMarkIcon className="w-5 h-5" /> onClick={() => handleResend(selectedLog)}
</button> disabled={resendingLogId === selectedLog.id}
className="p-2 hover:bg-gray-100 rounded-btn disabled:opacity-50 flex items-center gap-1.5 text-sm"
title="Re-send"
>
<ArrowPathIcon className={clsx('w-4 h-4', resendingLogId === selectedLog.id && 'animate-spin')} />
Re-send
</button>
<button onClick={() => setSelectedLog(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> </div>
<div className="p-4 space-y-2 border-b border-secondary-light-gray bg-gray-50"> <div className="p-4 space-y-2 border-b border-secondary-light-gray bg-gray-50">
<p><strong>To:</strong> {selectedLog.recipientName} &lt;{selectedLog.recipientEmail}&gt;</p> <p><strong>To:</strong> {selectedLog.recipientName} &lt;{selectedLog.recipientEmail}&gt;</p>

View File

@@ -41,6 +41,7 @@ import {
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import clsx from 'clsx'; import clsx from 'clsx';
import { useStatsPrivacy } from '@/hooks/useStatsPrivacy';
type TabType = 'overview' | 'attendees' | 'tickets' | 'email' | 'payments'; 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 [statusFilter, setStatusFilter] = useState<'all' | 'pending' | 'confirmed' | 'checked_in' | 'cancelled'>('all');
const [showAddAtDoorModal, setShowAddAtDoorModal] = useState(false); const [showAddAtDoorModal, setShowAddAtDoorModal] = useState(false);
const [showManualTicketModal, setShowManualTicketModal] = useState(false); const [showManualTicketModal, setShowManualTicketModal] = useState(false);
const [showStats, setShowStats] = useState(true); const [showStats, setShowStats, toggleStats] = useStatsPrivacy();
const [showNoteModal, setShowNoteModal] = useState(false); const [showNoteModal, setShowNoteModal] = useState(false);
const [selectedTicket, setSelectedTicket] = useState<Ticket | null>(null); const [selectedTicket, setSelectedTicket] = useState<Ticket | null>(null);
const [noteText, setNoteText] = useState(''); const [noteText, setNoteText] = useState('');
@@ -576,6 +577,10 @@ export default function AdminEventDetailPage() {
</div> </div>
{/* Desktop header actions */} {/* Desktop header actions */}
<div className="hidden md:flex items-center gap-2 flex-shrink-0"> <div className="hidden md:flex items-center gap-2 flex-shrink-0">
<Button variant="outline" size="sm" onClick={toggleStats} title={showStats ? 'Hide stats' : 'Show stats'}>
{showStats ? <EyeSlashIcon className="w-4 h-4 mr-1.5" /> : <EyeIcon className="w-4 h-4 mr-1.5" />}
{showStats ? 'Hide Stats' : 'Show Stats'}
</Button>
<Link href={`/events/${event.id}`} target="_blank"> <Link href={`/events/${event.id}`} target="_blank">
<Button variant="outline" size="sm"> <Button variant="outline" size="sm">
<EyeIcon className="w-4 h-4 mr-1.5" /> <EyeIcon className="w-4 h-4 mr-1.5" />
@@ -606,7 +611,7 @@ export default function AdminEventDetailPage() {
<DropdownItem onClick={() => { router.push(`/admin/events?edit=${event.id}`); setMobileHeaderMenuOpen(false); }}> <DropdownItem onClick={() => { router.push(`/admin/events?edit=${event.id}`); setMobileHeaderMenuOpen(false); }}>
<PencilIcon className="w-4 h-4 mr-2" /> Edit Event <PencilIcon className="w-4 h-4 mr-2" /> Edit Event
</DropdownItem> </DropdownItem>
<DropdownItem onClick={() => { setShowStats(v => !v); setMobileHeaderMenuOpen(false); }}> <DropdownItem onClick={() => { toggleStats(); setMobileHeaderMenuOpen(false); }}>
{showStats ? <EyeSlashIcon className="w-4 h-4 mr-2" /> : <EyeIcon className="w-4 h-4 mr-2" />} {showStats ? <EyeSlashIcon className="w-4 h-4 mr-2" /> : <EyeIcon className="w-4 h-4 mr-2" />}
{showStats ? 'Hide Stats' : 'Show Stats'} {showStats ? 'Hide Stats' : 'Show Stats'}
</DropdownItem> </DropdownItem>
@@ -628,10 +633,12 @@ export default function AdminEventDetailPage() {
<CurrencyDollarIcon className="w-3.5 h-3.5" /> <CurrencyDollarIcon className="w-3.5 h-3.5" />
{event.price === 0 ? 'Free' : formatCurrency(event.price, event.currency)} {event.price === 0 ? 'Free' : formatCurrency(event.price, event.currency)}
</span> </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"> {showStats && (
<UsersIcon className="w-3.5 h-3.5" /> <span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-gray-100 rounded-full text-xs text-gray-700">
{confirmedCount + checkedInCount}/{event.capacity} <UsersIcon className="w-3.5 h-3.5" />
</span> {confirmedCount + checkedInCount}/{event.capacity}
</span>
)}
</div> </div>
{/* ============= STATS ROW ============= */} {/* ============= STATS ROW ============= */}

View File

@@ -2,7 +2,7 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import { useLanguage } from '@/context/LanguageContext'; import { useLanguage } from '@/context/LanguageContext';
import { eventsApi, siteSettingsApi, Event } from '@/lib/api'; import { eventsApi, siteSettingsApi, Event } from '@/lib/api';
import Card from '@/components/ui/Card'; import Card from '@/components/ui/Card';
@@ -16,6 +16,7 @@ import toast from 'react-hot-toast';
import clsx from 'clsx'; import clsx from 'clsx';
export default function AdminEventsPage() { export default function AdminEventsPage() {
const router = useRouter();
const { t, locale } = useLanguage(); const { t, locale } = useLanguage();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const [events, setEvents] = useState<Event[]>([]); const [events, setEvents] = useState<Event[]>([]);
@@ -458,7 +459,11 @@ export default function AdminEventsPage() {
</tr> </tr>
) : ( ) : (
events.map((event) => ( events.map((event) => (
<tr key={event.id} className={clsx("hover:bg-gray-50", featuredEventId === event.id && "bg-amber-50")}> <tr
key={event.id}
onClick={() => router.push(`/admin/events/${event.id}`)}
className={clsx("hover:bg-gray-50 cursor-pointer", featuredEventId === event.id && "bg-amber-50")}
>
<td className="px-4 py-3"> <td className="px-4 py-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{event.bannerUrl ? ( {event.bannerUrl ? (
@@ -492,7 +497,7 @@ export default function AdminEventsPage() {
)} )}
</div> </div>
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-end gap-1"> <div className="flex items-center justify-end gap-1">
{event.status === 'draft' && ( {event.status === 'draft' && (
<Button size="sm" variant="ghost" onClick={() => handleStatusChange(event, 'published')}> <Button size="sm" variant="ghost" onClick={() => handleStatusChange(event, 'published')}>
@@ -561,7 +566,11 @@ export default function AdminEventsPage() {
</div> </div>
) : ( ) : (
events.map((event) => ( events.map((event) => (
<Card key={event.id} className={clsx("p-3", featuredEventId === event.id && "ring-2 ring-amber-300")}> <Card
key={event.id}
className={clsx("p-3 cursor-pointer", featuredEventId === event.id && "ring-2 ring-amber-300")}
onClick={() => router.push(`/admin/events/${event.id}`)}
>
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
{event.bannerUrl ? ( {event.bannerUrl ? (
<img src={event.bannerUrl} alt={event.title} <img src={event.bannerUrl} alt={event.title}
@@ -590,7 +599,7 @@ export default function AdminEventsPage() {
</div> </div>
<div className="flex items-center justify-between mt-2 pt-2 border-t border-gray-100"> <div className="flex items-center justify-between mt-2 pt-2 border-t border-gray-100">
<p className="text-xs text-gray-500">{event.bookedCount || 0} / {event.capacity} spots</p> <p className="text-xs text-gray-500">{event.bookedCount || 0} / {event.capacity} spots</p>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
<Link href={`/admin/events/${event.id}`} <Link href={`/admin/events/${event.id}`}
className="p-2 hover:bg-primary-yellow/20 text-primary-dark rounded-btn min-h-[36px] min-w-[36px] flex items-center justify-center"> className="p-2 hover:bg-primary-yellow/20 text-primary-dark rounded-btn min-h-[36px] min-w-[36px] flex items-center justify-center">
<EyeIcon className="w-4 h-4" /> <EyeIcon className="w-4 h-4" />

View File

@@ -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;
}

View File

@@ -492,7 +492,12 @@ export const emailsApi = {
}, },
getLog: (id: string) => fetchApi<{ log: EmailLog }>(`/api/emails/logs/${id}`), getLog: (id: string) => fetchApi<{ log: EmailLog }>(`/api/emails/logs/${id}`),
resendLog: (id: string) =>
fetchApi<{ success: boolean; error?: string }>(`/api/emails/logs/${id}/resend`, {
method: 'POST',
}),
getStats: (eventId?: string) => { getStats: (eventId?: string) => {
const query = eventId ? `?eventId=${eventId}` : ''; const query = eventId ? `?eventId=${eventId}` : '';
return fetchApi<{ stats: EmailStats }>(`/api/emails/stats${query}`); return fetchApi<{ stats: EmailStats }>(`/api/emails/stats${query}`);
@@ -792,6 +797,8 @@ export interface EmailLog {
sentAt?: string; sentAt?: string;
sentBy?: string; sentBy?: string;
createdAt: string; createdAt: string;
resendAttempts?: number;
lastResentAt?: string;
} }
export interface EmailStats { export interface EmailStats {