Add .npm-cache to gitignore; users route and mobile header updates
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -37,6 +37,7 @@ backend/uploads/
|
|||||||
# Tooling
|
# Tooling
|
||||||
.turbo/
|
.turbo/
|
||||||
.cursor/
|
.cursor/
|
||||||
|
.npm-cache/
|
||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Hono } from 'hono';
|
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, users, tickets, events, payments } from '../db/index.js';
|
import { db, dbGet, dbAll, users, tickets, events, payments, magicLinkTokens, userSessions, invoices, auditLogs, emailLogs, paymentOptions, legalPages, siteSettings } from '../db/index.js';
|
||||||
import { eq, desc, sql } from 'drizzle-orm';
|
import { eq, desc, sql } from 'drizzle-orm';
|
||||||
import { requireAuth } from '../lib/auth.js';
|
import { requireAuth } from '../lib/auth.js';
|
||||||
import { getNow } from '../lib/utils.js';
|
import { getNow } from '../lib/utils.js';
|
||||||
@@ -191,14 +191,73 @@ usersRouter.delete('/:id', requireAuth(['admin']), async (c) => {
|
|||||||
.where(eq((tickets as any).userId, id))
|
.where(eq((tickets as any).userId, id))
|
||||||
);
|
);
|
||||||
|
|
||||||
// Delete payments associated with user's tickets
|
// Delete invoices associated with user's tickets (invoices reference payments which reference tickets)
|
||||||
for (const ticket of userTickets) {
|
for (const ticket of userTickets) {
|
||||||
|
// Get payments for this ticket
|
||||||
|
const ticketPayments = await dbAll<any>(
|
||||||
|
(db as any)
|
||||||
|
.select()
|
||||||
|
.from(payments)
|
||||||
|
.where(eq((payments as any).ticketId, ticket.id))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Delete invoices for each payment
|
||||||
|
for (const payment of ticketPayments) {
|
||||||
|
await (db as any).delete(invoices).where(eq((invoices as any).paymentId, payment.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete payments for this ticket
|
||||||
await (db as any).delete(payments).where(eq((payments as any).ticketId, ticket.id));
|
await (db as any).delete(payments).where(eq((payments as any).ticketId, ticket.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Delete invoices directly associated with the user (if any)
|
||||||
|
await (db as any).delete(invoices).where(eq((invoices as any).userId, id));
|
||||||
|
|
||||||
// Delete user's tickets
|
// Delete user's tickets
|
||||||
await (db as any).delete(tickets).where(eq((tickets as any).userId, id));
|
await (db as any).delete(tickets).where(eq((tickets as any).userId, id));
|
||||||
|
|
||||||
|
// Delete magic link tokens for the user
|
||||||
|
await (db as any).delete(magicLinkTokens).where(eq((magicLinkTokens as any).userId, id));
|
||||||
|
|
||||||
|
// Delete user sessions
|
||||||
|
await (db as any).delete(userSessions).where(eq((userSessions as any).userId, id));
|
||||||
|
|
||||||
|
// Set userId to null in audit_logs (nullable reference)
|
||||||
|
await (db as any)
|
||||||
|
.update(auditLogs)
|
||||||
|
.set({ userId: null })
|
||||||
|
.where(eq((auditLogs as any).userId, id));
|
||||||
|
|
||||||
|
// Set sentBy to null in email_logs (nullable reference)
|
||||||
|
await (db as any)
|
||||||
|
.update(emailLogs)
|
||||||
|
.set({ sentBy: null })
|
||||||
|
.where(eq((emailLogs as any).sentBy, id));
|
||||||
|
|
||||||
|
// Set updatedBy to null in payment_options (nullable reference)
|
||||||
|
await (db as any)
|
||||||
|
.update(paymentOptions)
|
||||||
|
.set({ updatedBy: null })
|
||||||
|
.where(eq((paymentOptions as any).updatedBy, id));
|
||||||
|
|
||||||
|
// Set updatedBy to null in legal_pages (nullable reference)
|
||||||
|
await (db as any)
|
||||||
|
.update(legalPages)
|
||||||
|
.set({ updatedBy: null })
|
||||||
|
.where(eq((legalPages as any).updatedBy, id));
|
||||||
|
|
||||||
|
// Set updatedBy to null in site_settings (nullable reference)
|
||||||
|
await (db as any)
|
||||||
|
.update(siteSettings)
|
||||||
|
.set({ updatedBy: null })
|
||||||
|
.where(eq((siteSettings as any).updatedBy, id));
|
||||||
|
|
||||||
|
// Clear checkedInByAdminId references in tickets
|
||||||
|
await (db as any)
|
||||||
|
.update(tickets)
|
||||||
|
.set({ checkedInByAdminId: null })
|
||||||
|
.where(eq((tickets as any).checkedInByAdminId, id));
|
||||||
|
|
||||||
// Delete the user
|
// Delete the user
|
||||||
await (db as any).delete(users).where(eq((users as any).id, id));
|
await (db as any).delete(users).where(eq((users as any).id, id));
|
||||||
|
|
||||||
|
|||||||
@@ -2,14 +2,13 @@
|
|||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { useState } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import { useLanguage } from '@/context/LanguageContext';
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
import { useAuth } from '@/context/AuthContext';
|
import { useAuth } from '@/context/AuthContext';
|
||||||
import LanguageToggle from '@/components/LanguageToggle';
|
import LanguageToggle from '@/components/LanguageToggle';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import { Bars3Icon, XMarkIcon } from '@heroicons/react/24/outline';
|
import { Bars3Icon, XMarkIcon } from '@heroicons/react/24/outline';
|
||||||
import clsx from 'clsx';
|
|
||||||
|
|
||||||
function NavLink({ href, children }: { href: string; children: React.ReactNode }) {
|
function NavLink({ href, children }: { href: string; children: React.ReactNode }) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
@@ -33,7 +32,7 @@ function MobileNavLink({ href, children, onClick }: { href: string; children: Re
|
|||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={href}
|
href={href}
|
||||||
className="px-4 py-2 hover:bg-gray-50 rounded-lg font-medium"
|
className="block px-6 py-3 text-lg font-medium transition-colors hover:bg-gray-50"
|
||||||
style={{ color: isActive ? '#FBB82B' : '#002F44' }}
|
style={{ color: isActive ? '#FBB82B' : '#002F44' }}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
@@ -46,6 +45,65 @@ export default function Header() {
|
|||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const { user, isAdmin, logout } = useAuth();
|
const { user, isAdmin, logout } = useAuth();
|
||||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
|
const touchStartX = useRef<number>(0);
|
||||||
|
const touchCurrentX = useRef<number>(0);
|
||||||
|
const isDragging = useRef<boolean>(false);
|
||||||
|
|
||||||
|
// Close menu on route change
|
||||||
|
const pathname = usePathname();
|
||||||
|
useEffect(() => {
|
||||||
|
setMobileMenuOpen(false);
|
||||||
|
}, [pathname]);
|
||||||
|
|
||||||
|
// Prevent body scroll when menu is open
|
||||||
|
useEffect(() => {
|
||||||
|
if (mobileMenuOpen) {
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
} else {
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
};
|
||||||
|
}, [mobileMenuOpen]);
|
||||||
|
|
||||||
|
// Handle swipe to close
|
||||||
|
const handleTouchStart = useCallback((e: React.TouchEvent) => {
|
||||||
|
touchStartX.current = e.touches[0].clientX;
|
||||||
|
touchCurrentX.current = e.touches[0].clientX;
|
||||||
|
isDragging.current = true;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleTouchMove = useCallback((e: React.TouchEvent) => {
|
||||||
|
if (!isDragging.current) return;
|
||||||
|
touchCurrentX.current = e.touches[0].clientX;
|
||||||
|
|
||||||
|
const deltaX = touchCurrentX.current - touchStartX.current;
|
||||||
|
// Only allow dragging to the right (to close)
|
||||||
|
if (deltaX > 0 && menuRef.current) {
|
||||||
|
menuRef.current.style.transform = `translateX(${deltaX}px)`;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleTouchEnd = useCallback(() => {
|
||||||
|
if (!isDragging.current) return;
|
||||||
|
isDragging.current = false;
|
||||||
|
|
||||||
|
const deltaX = touchCurrentX.current - touchStartX.current;
|
||||||
|
const threshold = 100; // Minimum swipe distance to close
|
||||||
|
|
||||||
|
if (menuRef.current) {
|
||||||
|
menuRef.current.style.transform = '';
|
||||||
|
if (deltaX > threshold) {
|
||||||
|
setMobileMenuOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const closeMenu = useCallback(() => {
|
||||||
|
setMobileMenuOpen(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const navLinks = [
|
const navLinks = [
|
||||||
{ href: '/', label: t('nav.home') },
|
{ href: '/', label: t('nav.home') },
|
||||||
@@ -118,81 +176,142 @@ export default function Header() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile menu button */}
|
{/* Mobile menu button (hamburger) */}
|
||||||
<button
|
<button
|
||||||
className="md:hidden p-2 rounded-lg hover:bg-gray-100"
|
className="md:hidden p-2 rounded-lg hover:bg-gray-100 transition-colors"
|
||||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
onClick={() => setMobileMenuOpen(true)}
|
||||||
|
aria-label="Open menu"
|
||||||
>
|
>
|
||||||
{mobileMenuOpen ? (
|
<Bars3Icon className="w-6 h-6" style={{ color: '#002F44' }} />
|
||||||
<XMarkIcon className="w-6 h-6" />
|
</button>
|
||||||
) : (
|
</div>
|
||||||
<Bars3Icon className="w-6 h-6" />
|
</nav>
|
||||||
)}
|
|
||||||
|
{/* Mobile Slide-in Menu */}
|
||||||
|
{/* Overlay */}
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
fixed inset-0 bg-black/50 z-40 md:hidden
|
||||||
|
transition-opacity duration-300 ease-in-out
|
||||||
|
${mobileMenuOpen ? 'opacity-100 pointer-events-auto' : 'opacity-0 pointer-events-none'}
|
||||||
|
`}
|
||||||
|
onClick={closeMenu}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Slide-in Panel */}
|
||||||
|
<div
|
||||||
|
ref={menuRef}
|
||||||
|
className={`
|
||||||
|
fixed top-0 right-0 h-full w-[280px] max-w-[85vw] bg-white z-50 md:hidden
|
||||||
|
shadow-xl transform transition-transform duration-300 ease-in-out
|
||||||
|
${mobileMenuOpen ? 'translate-x-0' : 'translate-x-full'}
|
||||||
|
`}
|
||||||
|
onTouchStart={handleTouchStart}
|
||||||
|
onTouchMove={handleTouchMove}
|
||||||
|
onTouchEnd={handleTouchEnd}
|
||||||
|
>
|
||||||
|
{/* Menu Header */}
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-gray-100">
|
||||||
|
<Image
|
||||||
|
src="/images/logo-spanglish.png"
|
||||||
|
alt="Spanglish"
|
||||||
|
width={100}
|
||||||
|
height={28}
|
||||||
|
className="h-7 w-auto"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="p-2 rounded-lg hover:bg-gray-100 transition-colors"
|
||||||
|
onClick={closeMenu}
|
||||||
|
aria-label="Close menu"
|
||||||
|
>
|
||||||
|
<XMarkIcon className="w-6 h-6" style={{ color: '#002F44' }} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile Navigation */}
|
{/* Menu Content */}
|
||||||
<div
|
<div className="flex flex-col h-[calc(100%-65px)] overflow-y-auto">
|
||||||
className={clsx(
|
{/* Navigation Links */}
|
||||||
'md:hidden overflow-hidden transition-all duration-300',
|
<nav className="py-4">
|
||||||
{
|
|
||||||
'max-h-0': !mobileMenuOpen,
|
|
||||||
'max-h-96 pb-4': mobileMenuOpen,
|
|
||||||
}
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col gap-2 pt-4">
|
|
||||||
{navLinks.map((link) => (
|
{navLinks.map((link) => (
|
||||||
<MobileNavLink
|
<MobileNavLink
|
||||||
key={link.href}
|
key={link.href}
|
||||||
href={link.href}
|
href={link.href}
|
||||||
onClick={() => setMobileMenuOpen(false)}
|
onClick={closeMenu}
|
||||||
>
|
>
|
||||||
{link.label}
|
{link.label}
|
||||||
</MobileNavLink>
|
</MobileNavLink>
|
||||||
))}
|
))}
|
||||||
|
</nav>
|
||||||
<div className="border-t border-gray-100 mt-2 pt-4 px-4">
|
|
||||||
<LanguageToggle variant="buttons" />
|
{/* Divider */}
|
||||||
</div>
|
<div className="border-t border-gray-100 mx-6" />
|
||||||
|
|
||||||
<div className="px-4 pt-2 flex flex-col gap-2">
|
{/* Language Toggle */}
|
||||||
{user ? (
|
<div className="px-6 py-4">
|
||||||
<>
|
<LanguageToggle variant="buttons" />
|
||||||
<Link href="/dashboard" onClick={() => setMobileMenuOpen(false)}>
|
</div>
|
||||||
<Button variant="outline" className="w-full">
|
|
||||||
{t('nav.dashboard')}
|
{/* Divider */}
|
||||||
</Button>
|
<div className="border-t border-gray-100 mx-6" />
|
||||||
</Link>
|
|
||||||
{isAdmin && (
|
{/* Auth Actions */}
|
||||||
<Link href="/admin" onClick={() => setMobileMenuOpen(false)}>
|
<div className="px-6 py-4 flex flex-col gap-3 mt-auto">
|
||||||
<Button variant="outline" className="w-full">
|
{user ? (
|
||||||
{t('nav.admin')}
|
<>
|
||||||
</Button>
|
<div className="text-sm text-gray-500 mb-2 flex items-center gap-2">
|
||||||
</Link>
|
<div className="w-8 h-8 rounded-full bg-[#002F44] flex items-center justify-center text-white text-sm font-medium">
|
||||||
)}
|
{user.name?.charAt(0).toUpperCase()}
|
||||||
<Button variant="secondary" onClick={logout} className="w-full">
|
</div>
|
||||||
{t('nav.logout')}
|
<span className="font-medium text-[#002F44]">{user.name}</span>
|
||||||
|
</div>
|
||||||
|
<Link href="/dashboard" onClick={closeMenu}>
|
||||||
|
<Button variant="outline" className="w-full justify-center">
|
||||||
|
{t('nav.dashboard')}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</Link>
|
||||||
) : (
|
{isAdmin && (
|
||||||
<>
|
<Link href="/admin" onClick={closeMenu}>
|
||||||
<Link href="/login" onClick={() => setMobileMenuOpen(false)}>
|
<Button variant="outline" className="w-full justify-center">
|
||||||
<Button variant="outline" className="w-full">
|
{t('nav.admin')}
|
||||||
{t('nav.login')}
|
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/events" onClick={() => setMobileMenuOpen(false)}>
|
)}
|
||||||
<Button className="w-full">
|
<Button
|
||||||
{t('nav.joinEvent')}
|
variant="secondary"
|
||||||
</Button>
|
onClick={() => {
|
||||||
</Link>
|
logout();
|
||||||
</>
|
closeMenu();
|
||||||
)}
|
}}
|
||||||
</div>
|
className="w-full justify-center"
|
||||||
|
>
|
||||||
|
{t('nav.logout')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Link href="/login" onClick={closeMenu}>
|
||||||
|
<Button variant="outline" className="w-full justify-center">
|
||||||
|
{t('nav.login')}
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
<Link href="/events" onClick={closeMenu}>
|
||||||
|
<Button className="w-full justify-center">
|
||||||
|
{t('nav.joinEvent')}
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Swipe hint */}
|
||||||
|
<div className="px-6 pb-6 pt-2">
|
||||||
|
<p className="text-xs text-gray-400 text-center">
|
||||||
|
Swipe right to close
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user