Compare commits
14 Commits
dev
...
f0128f66b0
| Author | SHA1 | Date | |
|---|---|---|---|
| f0128f66b0 | |||
| b33c68feb0 | |||
| 15655e3987 | |||
| d8b3864411 | |||
| 194cbd6ca8 | |||
| d5445c2282 | |||
| dcfefc8371 | |||
| b5f14335c4 | |||
| d44ac949b5 | |||
| a5e939221d | |||
| 833e3e5a9c | |||
| ba1975dd6d | |||
| 3025ef3d21 | |||
| 8564f8af83 |
@@ -1,5 +1,5 @@
|
||||
import 'dotenv/config';
|
||||
import { closeSync, existsSync, mkdirSync, openSync } from 'fs';
|
||||
import { existsSync, mkdirSync, writeFileSync } from 'fs';
|
||||
import { dirname, resolve } from 'path';
|
||||
import { spawnSync } from 'child_process';
|
||||
import Database from 'better-sqlite3';
|
||||
@@ -43,32 +43,28 @@ function exportSqlite(outputPath: string): void {
|
||||
|
||||
function exportPostgres(outputPath: string): void {
|
||||
const connString = process.env.DATABASE_URL || 'postgresql://localhost:5432/spanglish';
|
||||
const outFd = openSync(outputPath, 'w');
|
||||
try {
|
||||
const result = spawnSync(
|
||||
'pg_dump',
|
||||
['--clean', '--if-exists', connString],
|
||||
{
|
||||
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);
|
||||
const result = spawnSync(
|
||||
'pg_dump',
|
||||
['--clean', '--if-exists', connString],
|
||||
{
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
encoding: 'utf-8',
|
||||
}
|
||||
);
|
||||
|
||||
if (result.status !== 0) {
|
||||
console.error('pg_dump failed:', result.stderr);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`Exported to ${outputPath}`);
|
||||
} finally {
|
||||
closeSync(outFd);
|
||||
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.status !== 0) {
|
||||
console.error('pg_dump failed:', result.stderr);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
writeFileSync(outputPath, result.stdout);
|
||||
console.log(`Exported to ${outputPath}`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
|
||||
@@ -368,13 +368,6 @@ 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`
|
||||
CREATE TABLE IF NOT EXISTS email_settings (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -779,13 +772,6 @@ 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`
|
||||
CREATE TABLE IF NOT EXISTS email_settings (
|
||||
id UUID PRIMARY KEY,
|
||||
|
||||
@@ -243,8 +243,6 @@ export const sqliteEmailLogs = sqliteTable('email_logs', {
|
||||
sentAt: text('sent_at'),
|
||||
sentBy: text('sent_by').references(() => sqliteUsers.id),
|
||||
createdAt: text('created_at').notNull(),
|
||||
resendAttempts: integer('resend_attempts').notNull().default(0),
|
||||
lastResentAt: text('last_resent_at'),
|
||||
});
|
||||
|
||||
export const sqliteEmailSettings = sqliteTable('email_settings', {
|
||||
@@ -559,8 +557,6 @@ export const pgEmailLogs = pgTable('email_logs', {
|
||||
sentAt: timestamp('sent_at'),
|
||||
sentBy: uuid('sent_by').references(() => pgUsers.id),
|
||||
createdAt: timestamp('created_at').notNull(),
|
||||
resendAttempts: pgInteger('resend_attempts').notNull().default(0),
|
||||
lastResentAt: timestamp('last_resent_at'),
|
||||
});
|
||||
|
||||
export const pgEmailSettings = pgTable('email_settings', {
|
||||
|
||||
@@ -1342,61 +1342,6 @@ export const emailService = {
|
||||
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
|
||||
|
||||
@@ -349,23 +349,6 @@ emailsRouter.get('/logs/:id', requireAuth(['admin', 'organizer']), async (c) =>
|
||||
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
|
||||
emailsRouter.get('/stats', requireAuth(['admin', 'organizer']), async (c) => {
|
||||
const eventId = c.req.query('eventId');
|
||||
|
||||
@@ -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, inArray } from 'drizzle-orm';
|
||||
import { eq, and, or, sql } 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) - includes payment for each ticket
|
||||
// Get all tickets (admin)
|
||||
ticketsRouter.get('/', requireAuth(['admin', 'organizer']), async (c) => {
|
||||
const eventId = c.req.query('eventId');
|
||||
const status = c.req.query('status');
|
||||
@@ -1413,25 +1413,9 @@ ticketsRouter.get('/', requireAuth(['admin', 'organizer']), async (c) => {
|
||||
query = query.where(and(...conditions));
|
||||
}
|
||||
|
||||
const ticketsList = await dbAll(query);
|
||||
const ticketIds = ticketsList.map((t: any) => t.id);
|
||||
|
||||
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 });
|
||||
const result = await dbAll(query);
|
||||
|
||||
return c.json({ tickets: result });
|
||||
});
|
||||
|
||||
export default ticketsRouter;
|
||||
|
||||
@@ -59,13 +59,19 @@ export default function AdminBookingsPage() {
|
||||
ticketsApi.getAll(),
|
||||
eventsApi.getAll(),
|
||||
]);
|
||||
|
||||
const ticketsWithEvent = ticketsRes.tickets.map((ticket) => ({
|
||||
...ticket,
|
||||
event: eventsRes.events.find((e) => e.id === ticket.eventId),
|
||||
}));
|
||||
|
||||
setTickets(ticketsWithEvent);
|
||||
|
||||
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);
|
||||
setEvents(eventsRes.events);
|
||||
} catch (error) {
|
||||
toast.error('Failed to load bookings');
|
||||
@@ -147,8 +153,7 @@ export default function AdminBookingsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const getPaymentMethodLabel = (provider: string | null) => {
|
||||
if (provider == null) return '—';
|
||||
const getPaymentMethodLabel = (provider: string) => {
|
||||
const labels: Record<string, string> = {
|
||||
cash: locale === 'es' ? 'Efectivo en el Evento' : 'Cash at Event',
|
||||
bank_transfer: locale === 'es' ? 'Transferencia Bancaria' : 'Bank Transfer',
|
||||
@@ -159,13 +164,13 @@ export default function AdminBookingsPage() {
|
||||
return labels[provider] || provider;
|
||||
};
|
||||
|
||||
const getDisplayProvider = (ticket: TicketWithDetails): string | null => {
|
||||
const getDisplayProvider = (ticket: TicketWithDetails) => {
|
||||
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 ?? null;
|
||||
const sibling = tickets.find(t => t.bookingId === ticket.bookingId && t.payment?.provider);
|
||||
return sibling?.payment?.provider ?? 'cash';
|
||||
}
|
||||
return null;
|
||||
return 'cash';
|
||||
};
|
||||
|
||||
const filteredTickets = tickets.filter((ticket) => {
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
XMarkIcon,
|
||||
ArrowPathIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import toast from 'react-hot-toast';
|
||||
import clsx from 'clsx';
|
||||
@@ -53,8 +52,6 @@ export default function AdminEmailsPage() {
|
||||
const [logs, setLogs] = useState<EmailLog[]>([]);
|
||||
const [logsOffset, setLogsOffset] = 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);
|
||||
|
||||
// Stats state
|
||||
@@ -217,7 +214,7 @@ export default function AdminEmailsPage() {
|
||||
if (activeTab === 'logs') {
|
||||
loadLogs();
|
||||
}
|
||||
}, [activeTab, logsOffset, logsSubTab]);
|
||||
}, [activeTab, logsOffset]);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
@@ -236,11 +233,7 @@ export default function AdminEmailsPage() {
|
||||
|
||||
const loadLogs = async () => {
|
||||
try {
|
||||
const res = await emailsApi.getLogs({
|
||||
limit: 20,
|
||||
offset: logsOffset,
|
||||
...(logsSubTab === 'failed' ? { status: 'failed' } : {}),
|
||||
});
|
||||
const res = await emailsApi.getLogs({ limit: 20, offset: logsOffset });
|
||||
setLogs(res.logs);
|
||||
setLogsTotal(res.pagination.total);
|
||||
} catch (error) {
|
||||
@@ -248,27 +241,6 @@ 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 = () => {
|
||||
setTemplateForm({
|
||||
name: '',
|
||||
@@ -727,35 +699,6 @@ export default function AdminEmailsPage() {
|
||||
{/* Logs Tab */}
|
||||
{activeTab === 'logs' && (
|
||||
<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 */}
|
||||
<Card className="overflow-hidden hidden md:block">
|
||||
<div className="overflow-x-auto">
|
||||
@@ -771,17 +714,12 @@ export default function AdminEmailsPage() {
|
||||
</thead>
|
||||
<tbody className="divide-y divide-secondary-light-gray">
|
||||
{logs.length === 0 ? (
|
||||
<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>
|
||||
<tr><td colSpan={5} className="px-4 py-12 text-center text-gray-500 text-sm">No emails sent yet</td></tr>
|
||||
) : (
|
||||
logs.map((log) => (
|
||||
<tr key={log.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3">
|
||||
<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>
|
||||
<div className="flex items-center gap-2">{getStatusIcon(log.status)}<span className="capitalize text-sm">{log.status}</span></div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<p className="font-medium text-sm">{log.recipientName || 'Unknown'}</p>
|
||||
@@ -790,15 +728,7 @@ 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 text-xs text-gray-500">{formatDate(log.sentAt || log.createdAt)}</td>
|
||||
<td className="px-4 py-3">
|
||||
<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>
|
||||
<div className="flex items-center justify-end">
|
||||
<button onClick={() => setSelectedLog(log)} className="p-2 hover:bg-gray-100 rounded-btn" title="View">
|
||||
<EyeIcon className="w-4 h-4" />
|
||||
</button>
|
||||
@@ -828,7 +758,7 @@ export default function AdminEmailsPage() {
|
||||
{/* Mobile: Card List */}
|
||||
<div className="md:hidden space-y-2">
|
||||
{logs.length === 0 ? (
|
||||
<div className="text-center py-10 text-gray-500 text-sm">{logsSubTab === 'failed' ? 'No failed emails' : 'No emails sent yet'}</div>
|
||||
<div className="text-center py-10 text-gray-500 text-sm">No emails sent yet</div>
|
||||
) : (
|
||||
logs.map((log) => (
|
||||
<Card key={log.id} className="p-3" onClick={() => setSelectedLog(log)}>
|
||||
@@ -838,18 +768,7 @@ export default function AdminEmailsPage() {
|
||||
<p className="font-medium text-sm truncate">{log.subject}</p>
|
||||
<p className="text-xs text-gray-500 truncate">{log.recipientName || 'Unknown'} <{log.recipientEmail}></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>
|
||||
<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>
|
||||
</Card>
|
||||
))
|
||||
@@ -1019,26 +938,12 @@ export default function AdminEmailsPage() {
|
||||
{selectedLog.errorMessage && (
|
||||
<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 className="flex items-center gap-1 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => handleResend(selectedLog)}
|
||||
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>
|
||||
<button onClick={() => setSelectedLog(null)}
|
||||
className="p-2 hover:bg-gray-100 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center flex-shrink-0">
|
||||
<XMarkIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-4 space-y-2 border-b border-secondary-light-gray bg-gray-50">
|
||||
<p><strong>To:</strong> {selectedLog.recipientName} <{selectedLog.recipientEmail}></p>
|
||||
|
||||
@@ -41,7 +41,6 @@ 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';
|
||||
|
||||
@@ -69,7 +68,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, toggleStats] = useStatsPrivacy();
|
||||
const [showStats, setShowStats] = useState(true);
|
||||
const [showNoteModal, setShowNoteModal] = useState(false);
|
||||
const [selectedTicket, setSelectedTicket] = useState<Ticket | null>(null);
|
||||
const [noteText, setNoteText] = useState('');
|
||||
@@ -577,10 +576,6 @@ export default function AdminEventDetailPage() {
|
||||
</div>
|
||||
{/* Desktop header actions */}
|
||||
<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">
|
||||
<Button variant="outline" size="sm">
|
||||
<EyeIcon className="w-4 h-4 mr-1.5" />
|
||||
@@ -611,7 +606,7 @@ export default function AdminEventDetailPage() {
|
||||
<DropdownItem onClick={() => { router.push(`/admin/events?edit=${event.id}`); setMobileHeaderMenuOpen(false); }}>
|
||||
<PencilIcon className="w-4 h-4 mr-2" /> Edit Event
|
||||
</DropdownItem>
|
||||
<DropdownItem onClick={() => { toggleStats(); setMobileHeaderMenuOpen(false); }}>
|
||||
<DropdownItem onClick={() => { setShowStats(v => !v); setMobileHeaderMenuOpen(false); }}>
|
||||
{showStats ? <EyeSlashIcon className="w-4 h-4 mr-2" /> : <EyeIcon className="w-4 h-4 mr-2" />}
|
||||
{showStats ? 'Hide Stats' : 'Show Stats'}
|
||||
</DropdownItem>
|
||||
@@ -633,12 +628,10 @@ export default function AdminEventDetailPage() {
|
||||
<CurrencyDollarIcon className="w-3.5 h-3.5" />
|
||||
{event.price === 0 ? 'Free' : formatCurrency(event.price, event.currency)}
|
||||
</span>
|
||||
{showStats && (
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-gray-100 rounded-full text-xs text-gray-700">
|
||||
<UsersIcon className="w-3.5 h-3.5" />
|
||||
{confirmedCount + checkedInCount}/{event.capacity}
|
||||
</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">
|
||||
<UsersIcon className="w-3.5 h-3.5" />
|
||||
{confirmedCount + checkedInCount}/{event.capacity}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* ============= STATS ROW ============= */}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { eventsApi, siteSettingsApi, Event } from '@/lib/api';
|
||||
import Card from '@/components/ui/Card';
|
||||
@@ -16,7 +16,6 @@ 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<Event[]>([]);
|
||||
@@ -459,11 +458,7 @@ export default function AdminEventsPage() {
|
||||
</tr>
|
||||
) : (
|
||||
events.map((event) => (
|
||||
<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")}
|
||||
>
|
||||
<tr key={event.id} className={clsx("hover:bg-gray-50", featuredEventId === event.id && "bg-amber-50")}>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
{event.bannerUrl ? (
|
||||
@@ -497,7 +492,7 @@ export default function AdminEventsPage() {
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3" onClick={(e) => e.stopPropagation()}>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{event.status === 'draft' && (
|
||||
<Button size="sm" variant="ghost" onClick={() => handleStatusChange(event, 'published')}>
|
||||
@@ -566,11 +561,7 @@ export default function AdminEventsPage() {
|
||||
</div>
|
||||
) : (
|
||||
events.map((event) => (
|
||||
<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}`)}
|
||||
>
|
||||
<Card key={event.id} className={clsx("p-3", featuredEventId === event.id && "ring-2 ring-amber-300")}>
|
||||
<div className="flex items-start gap-3">
|
||||
{event.bannerUrl ? (
|
||||
<img src={event.bannerUrl} alt={event.title}
|
||||
@@ -599,7 +590,7 @@ export default function AdminEventsPage() {
|
||||
</div>
|
||||
<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>
|
||||
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center gap-1">
|
||||
<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">
|
||||
<EyeIcon className="w-4 h-4" />
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
'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;
|
||||
}
|
||||
@@ -492,12 +492,7 @@ export const emailsApi = {
|
||||
},
|
||||
|
||||
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) => {
|
||||
const query = eventId ? `?eventId=${eventId}` : '';
|
||||
return fetchApi<{ stats: EmailStats }>(`/api/emails/stats${query}`);
|
||||
@@ -797,8 +792,6 @@ export interface EmailLog {
|
||||
sentAt?: string;
|
||||
sentBy?: string;
|
||||
createdAt: string;
|
||||
resendAttempts?: number;
|
||||
lastResentAt?: string;
|
||||
}
|
||||
|
||||
export interface EmailStats {
|
||||
|
||||
Reference in New Issue
Block a user