- Replace the three Attendees-tab modals (Manual Ticket / Add at Door / Invite Guest) with a single Add Ticket modal: Paid/Unpaid/Guest segmented control, shared fields, "Check in now" for all types, and a live "what happens" preview, backed by one POST /api/tickets/admin/add. - Add tickets.payment_status (paid | unpaid | comp) with a backfill migration; keep it in sync on every payment-settlement path (mark-paid, admin approval, Lightning, free bookings, hold recovery). - Show Paid/Unpaid/Comp badges in the attendee list, count only paid tickets toward revenue, let unpaid tickets be resolved via Mark Paid, and flag unpaid tickets with their balance due in the door scanner. - Replace the per-page useStatsPrivacy hook with an admin-wide PrivacyContext + SensitiveValue mask, toggled from the admin layout. - Add server-side pagination with page-size options to the users page. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
270 lines
10 KiB
TypeScript
270 lines
10 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect } from 'react';
|
|
import { useRouter } from 'next/navigation';
|
|
import Link from 'next/link';
|
|
import { usePathname } from 'next/navigation';
|
|
import { useLanguage } from '@/context/LanguageContext';
|
|
import { useAuth } from '@/context/AuthContext';
|
|
import { PrivacyProvider, usePrivacy } from '@/context/PrivacyContext';
|
|
import LanguageToggle from '@/components/LanguageToggle';
|
|
import Button from '@/components/ui/Button';
|
|
import {
|
|
HomeIcon,
|
|
CalendarIcon,
|
|
TicketIcon,
|
|
UsersIcon,
|
|
CreditCardIcon,
|
|
EnvelopeIcon,
|
|
InboxIcon,
|
|
PhotoIcon,
|
|
CameraIcon,
|
|
Cog6ToothIcon,
|
|
ArrowLeftOnRectangleIcon,
|
|
Bars3Icon,
|
|
XMarkIcon,
|
|
BanknotesIcon,
|
|
QrCodeIcon,
|
|
DocumentTextIcon,
|
|
QuestionMarkCircleIcon,
|
|
EyeIcon,
|
|
EyeSlashIcon,
|
|
} from '@heroicons/react/24/outline';
|
|
import clsx from 'clsx';
|
|
import { useState } from 'react';
|
|
|
|
export default function AdminLayout({
|
|
children,
|
|
}: {
|
|
children: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<PrivacyProvider>
|
|
<AdminLayoutInner>{children}</AdminLayoutInner>
|
|
</PrivacyProvider>
|
|
);
|
|
}
|
|
|
|
function AdminLayoutInner({
|
|
children,
|
|
}: {
|
|
children: React.ReactNode;
|
|
}) {
|
|
const router = useRouter();
|
|
const pathname = usePathname();
|
|
const { t, locale } = useLanguage();
|
|
const { user, hasAdminAccess, isLoading, logout } = useAuth();
|
|
const { privacyMode, togglePrivacyMode } = usePrivacy();
|
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
|
|
|
type Role = 'admin' | 'organizer' | 'staff' | 'marketing';
|
|
const userRole = (user?.role || 'user') as Role;
|
|
|
|
const navigationWithRoles: { name: string; href: string; icon: typeof HomeIcon; allowedRoles: Role[] }[] = [
|
|
{ name: t('admin.nav.dashboard'), href: '/admin', icon: HomeIcon, allowedRoles: ['admin', 'organizer'] },
|
|
{ name: t('admin.nav.events'), href: '/admin/events', icon: CalendarIcon, allowedRoles: ['admin', 'organizer', 'staff'] },
|
|
{ name: t('admin.nav.bookings'), href: '/admin/bookings', icon: TicketIcon, allowedRoles: ['admin', 'organizer'] },
|
|
{ name: locale === 'es' ? 'Escáner' : 'Scanner', href: '/admin/scanner', icon: QrCodeIcon, allowedRoles: ['admin', 'organizer', 'staff'] },
|
|
{ name: t('admin.nav.users'), href: '/admin/users', icon: UsersIcon, allowedRoles: ['admin'] },
|
|
{ name: t('admin.nav.payments'), href: '/admin/payments', icon: CreditCardIcon, allowedRoles: ['admin', 'organizer'] },
|
|
{ name: locale === 'es' ? 'Opciones de Pago' : 'Payment Options', href: '/admin/payment-options', icon: BanknotesIcon, allowedRoles: ['admin', 'organizer'] },
|
|
{ name: t('admin.nav.contacts'), href: '/admin/contacts', icon: EnvelopeIcon, allowedRoles: ['admin', 'organizer', 'marketing'] },
|
|
{ name: t('admin.nav.emails'), href: '/admin/emails', icon: InboxIcon, allowedRoles: ['admin', 'organizer'] },
|
|
{ name: t('admin.nav.gallery'), href: '/admin/gallery', icon: PhotoIcon, allowedRoles: ['admin', 'organizer'] },
|
|
{ name: t('admin.nav.photos'), href: '/admin/photos', icon: CameraIcon, allowedRoles: ['admin', 'organizer'] },
|
|
{ name: locale === 'es' ? 'Páginas Legales' : 'Legal Pages', href: '/admin/legal-pages', icon: DocumentTextIcon, allowedRoles: ['admin'] },
|
|
{ name: 'FAQ', href: '/admin/faq', icon: QuestionMarkCircleIcon, allowedRoles: ['admin'] },
|
|
{ name: locale === 'es' ? 'Configuración' : 'Settings', href: '/admin/settings', icon: Cog6ToothIcon, allowedRoles: ['admin'] },
|
|
];
|
|
|
|
const allowedPathsForRole = new Set(
|
|
navigationWithRoles.filter((item) => item.allowedRoles.includes(userRole)).map((item) => item.href)
|
|
);
|
|
// All known admin routes regardless of role, used only to tell "not allowed
|
|
// for this role" apart from "doesn't exist" - the latter should render the
|
|
// 404 page instead of bouncing to the default route.
|
|
const allAdminHrefs = new Set(navigationWithRoles.map((item) => item.href));
|
|
const defaultAdminRoute =
|
|
userRole === 'staff' ? '/admin/scanner' : userRole === 'marketing' ? '/admin/contacts' : '/admin';
|
|
|
|
// All hooks must be called unconditionally before any early returns
|
|
useEffect(() => {
|
|
if (!isLoading && (!user || !hasAdminAccess)) {
|
|
router.push('/login');
|
|
}
|
|
}, [user, hasAdminAccess, isLoading, router]);
|
|
|
|
useEffect(() => {
|
|
if (!user || !hasAdminAccess) return;
|
|
if (!pathname.startsWith('/admin')) return;
|
|
if (pathname === '/admin' && (userRole === 'staff' || userRole === 'marketing')) {
|
|
router.replace(defaultAdminRoute);
|
|
return;
|
|
}
|
|
const matchesHrefSet = (path: string, hrefs: Set<string>) => {
|
|
if (hrefs.has(path)) return true;
|
|
return Array.from(hrefs).some((href) => path.startsWith(href + '/'));
|
|
};
|
|
// Unknown route entirely (e.g. a typo'd URL) - let it fall through to the
|
|
// admin 404 page instead of silently redirecting away.
|
|
if (!matchesHrefSet(pathname, allAdminHrefs)) return;
|
|
if (!matchesHrefSet(pathname, allowedPathsForRole)) {
|
|
router.replace(defaultAdminRoute);
|
|
}
|
|
}, [pathname, userRole, defaultAdminRoute, router, user, hasAdminAccess]);
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="min-h-screen flex items-center justify-center">
|
|
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!user || !hasAdminAccess) {
|
|
return null;
|
|
}
|
|
|
|
const visibleNav = navigationWithRoles.filter((item) => item.allowedRoles.includes(userRole));
|
|
const navigation = visibleNav;
|
|
|
|
const handleLogout = () => {
|
|
logout();
|
|
router.push('/');
|
|
};
|
|
|
|
// Scanner page gets fullscreen layout without sidebar
|
|
const isScannerPage = pathname === '/admin/scanner';
|
|
|
|
if (isScannerPage) {
|
|
return (
|
|
<div className="min-h-screen bg-gray-950">
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="min-h-screen bg-secondary-gray">
|
|
{/* Mobile sidebar backdrop */}
|
|
{sidebarOpen && (
|
|
<div
|
|
className="fixed inset-0 bg-black/50 z-40 lg:hidden"
|
|
onClick={() => setSidebarOpen(false)}
|
|
/>
|
|
)}
|
|
|
|
{/* Sidebar */}
|
|
<aside
|
|
className={clsx(
|
|
'fixed top-0 left-0 z-50 h-full w-64 bg-white shadow-lg transform transition-transform duration-300 lg:transform-none',
|
|
{
|
|
'translate-x-0': sidebarOpen,
|
|
'-translate-x-full lg:translate-x-0': !sidebarOpen,
|
|
}
|
|
)}
|
|
>
|
|
<div className="flex flex-col h-full">
|
|
{/* Logo */}
|
|
<div className="p-6 border-b border-secondary-light-gray">
|
|
<Link href="/" className="flex items-center gap-2">
|
|
<span className="text-xl font-bold font-heading text-primary-dark">
|
|
Span<span className="text-primary-yellow">glish</span>
|
|
</span>
|
|
</Link>
|
|
<p className="text-xs text-gray-500 mt-1">{t('admin.nav.dashboard')}</p>
|
|
</div>
|
|
|
|
{/* Navigation */}
|
|
<nav className="flex-1 p-4 space-y-1 overflow-y-auto">
|
|
{navigation.map((item) => {
|
|
const isActive = pathname === item.href;
|
|
return (
|
|
<Link
|
|
key={item.href}
|
|
href={item.href}
|
|
onClick={() => setSidebarOpen(false)}
|
|
className={clsx(
|
|
'flex items-center gap-3 px-4 py-3 rounded-btn transition-colors',
|
|
{
|
|
'bg-primary-yellow text-primary-dark font-medium': isActive,
|
|
'text-gray-700 hover:bg-gray-100': !isActive,
|
|
}
|
|
)}
|
|
>
|
|
<item.icon className="w-5 h-5" />
|
|
{item.name}
|
|
</Link>
|
|
);
|
|
})}
|
|
</nav>
|
|
|
|
{/* User section */}
|
|
<div className="p-4 border-t border-secondary-light-gray">
|
|
<div className="flex items-center gap-3 mb-4">
|
|
<div className="w-10 h-10 bg-primary-yellow/20 rounded-full flex items-center justify-center">
|
|
<span className="font-semibold text-primary-dark">
|
|
{user.name.charAt(0).toUpperCase()}
|
|
</span>
|
|
</div>
|
|
<div>
|
|
<p className="font-medium text-sm">{user.name}</p>
|
|
<p className="text-xs text-gray-500 capitalize">{user.role}</p>
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={handleLogout}
|
|
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 rounded-btn transition-colors"
|
|
>
|
|
<ArrowLeftOnRectangleIcon className="w-5 h-5" />
|
|
{t('nav.logout')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
|
|
{/* Main content */}
|
|
<div className="lg:pl-64">
|
|
{/* Top bar */}
|
|
<header className="sticky top-0 z-30 bg-white shadow-sm">
|
|
<div className="flex items-center justify-between px-4 py-4">
|
|
<button
|
|
className="lg:hidden p-2 rounded-btn hover:bg-gray-100"
|
|
onClick={() => setSidebarOpen(true)}
|
|
>
|
|
<Bars3Icon className="w-6 h-6" />
|
|
</button>
|
|
|
|
<div className="flex items-center gap-4 ml-auto">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={togglePrivacyMode}
|
|
title={privacyMode ? t('admin.privacy.show') : t('admin.privacy.hide')}
|
|
>
|
|
{privacyMode ? (
|
|
<EyeIcon className="w-4 h-4 sm:mr-1.5" />
|
|
) : (
|
|
<EyeSlashIcon className="w-4 h-4 sm:mr-1.5" />
|
|
)}
|
|
<span className="hidden sm:inline">
|
|
{privacyMode ? t('admin.privacy.show') : t('admin.privacy.hide')}
|
|
</span>
|
|
</Button>
|
|
<LanguageToggle />
|
|
<Link href="/">
|
|
<Button variant="outline" size="sm">
|
|
View Site
|
|
</Button>
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
{/* Page content */}
|
|
<main className="p-6">{children}</main>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|