- Extract shared mobile components (BottomSheet, MoreMenu, Dropdown, etc.) into MobileComponents.tsx - Make admin pages mobile-friendly: bookings, emails, events, faq, payments, tickets, users - Redesign homepage Next Event card with banner image, responsive layout, and updated styling - Fix past events showing on homepage/linktree: use proper Date comparison, auto-unfeature expired events - Add "Over" tag to admin events list for past events - Fix backend FRONTEND_URL for cache revalidation Co-authored-by: Cursor <cursoragent@cursor.com>
184 lines
5.8 KiB
TypeScript
184 lines
5.8 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect, useRef } from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
import { XMarkIcon, EllipsisVerticalIcon } from '@heroicons/react/24/outline';
|
|
import clsx from 'clsx';
|
|
|
|
// ----- Skeleton loaders -----
|
|
|
|
export function TableSkeleton({ rows = 5 }: { rows?: number }) {
|
|
return (
|
|
<div className="animate-pulse">
|
|
{Array.from({ length: rows }).map((_, i) => (
|
|
<div key={i} className="flex items-center gap-4 px-4 py-3 border-b border-gray-100">
|
|
<div className="h-4 bg-gray-200 rounded w-1/4" />
|
|
<div className="h-4 bg-gray-200 rounded w-1/5" />
|
|
<div className="h-4 bg-gray-200 rounded w-16" />
|
|
<div className="h-4 bg-gray-200 rounded w-20" />
|
|
<div className="h-4 bg-gray-200 rounded w-16 ml-auto" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function CardSkeleton({ count = 3 }: { count?: number }) {
|
|
return (
|
|
<div className="space-y-3 animate-pulse">
|
|
{Array.from({ length: count }).map((_, i) => (
|
|
<div key={i} className="bg-white rounded-card shadow-card p-4">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<div className="h-4 bg-gray-200 rounded w-1/3" />
|
|
<div className="h-5 bg-gray-200 rounded-full w-16" />
|
|
</div>
|
|
<div className="h-3 bg-gray-200 rounded w-1/2 mb-2" />
|
|
<div className="h-3 bg-gray-200 rounded w-1/4" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ----- Dropdown component (portal-based to escape overflow:hidden) -----
|
|
|
|
export function Dropdown({ trigger, children, open, onOpenChange, align = 'right' }: {
|
|
trigger: React.ReactNode;
|
|
children: React.ReactNode;
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
align?: 'left' | 'right';
|
|
}) {
|
|
const triggerRef = useRef<HTMLDivElement>(null);
|
|
const menuRef = useRef<HTMLDivElement>(null);
|
|
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (open && triggerRef.current) {
|
|
const rect = triggerRef.current.getBoundingClientRect();
|
|
const menuWidth = 192;
|
|
let left = align === 'right' ? rect.right - menuWidth : rect.left;
|
|
left = Math.max(8, Math.min(left, window.innerWidth - menuWidth - 8));
|
|
setPos({ top: rect.bottom + 4, left });
|
|
}
|
|
}, [open, align]);
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
const handler = (e: MouseEvent) => {
|
|
const target = e.target as Node;
|
|
if (
|
|
triggerRef.current && !triggerRef.current.contains(target) &&
|
|
menuRef.current && !menuRef.current.contains(target)
|
|
) {
|
|
onOpenChange(false);
|
|
}
|
|
};
|
|
document.addEventListener('mousedown', handler);
|
|
return () => document.removeEventListener('mousedown', handler);
|
|
}, [open, onOpenChange]);
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
const handler = () => onOpenChange(false);
|
|
window.addEventListener('scroll', handler, true);
|
|
return () => window.removeEventListener('scroll', handler, true);
|
|
}, [open, onOpenChange]);
|
|
|
|
return (
|
|
<>
|
|
<div ref={triggerRef} className="inline-block">
|
|
<div onClick={() => onOpenChange(!open)}>{trigger}</div>
|
|
</div>
|
|
{open && pos && createPortal(
|
|
<div
|
|
ref={menuRef}
|
|
className="fixed z-[9999] w-48 bg-white border border-secondary-light-gray rounded-btn shadow-lg py-1"
|
|
style={{ top: pos.top, left: pos.left }}
|
|
>
|
|
{children}
|
|
</div>,
|
|
document.body
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
export function DropdownItem({ onClick, children, className }: { onClick: () => void; children: React.ReactNode; className?: string }) {
|
|
return (
|
|
<button onClick={onClick} className={clsx('w-full text-left px-4 py-2 text-sm hover:bg-gray-50 min-h-[44px] flex items-center', className)}>
|
|
{children}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
// ----- Bottom Sheet (mobile) -----
|
|
|
|
export function BottomSheet({ open, onClose, title, children }: {
|
|
open: boolean;
|
|
onClose: () => void;
|
|
title: string;
|
|
children: React.ReactNode;
|
|
}) {
|
|
if (!open) return null;
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-end justify-center md:hidden" onClick={onClose}>
|
|
<div className="fixed inset-0 bg-black/50" />
|
|
<div
|
|
className="relative w-full bg-white rounded-t-2xl max-h-[80vh] overflow-auto animate-slide-up"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<div className="flex items-center justify-between p-4 border-b border-gray-100 sticky top-0 bg-white z-10">
|
|
<h3 className="font-semibold text-base">{title}</h3>
|
|
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-full min-h-[44px] min-w-[44px] flex items-center justify-center">
|
|
<XMarkIcon className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
<div className="p-4">{children}</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ----- More Menu (per-row) -----
|
|
|
|
export function MoreMenu({ children }: { children: React.ReactNode }) {
|
|
const [open, setOpen] = useState(false);
|
|
return (
|
|
<Dropdown
|
|
open={open}
|
|
onOpenChange={setOpen}
|
|
trigger={
|
|
<button className="p-2 hover:bg-gray-100 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center">
|
|
<EllipsisVerticalIcon className="w-5 h-5 text-gray-500" />
|
|
</button>
|
|
}
|
|
>
|
|
{children}
|
|
</Dropdown>
|
|
);
|
|
}
|
|
|
|
// ----- Global CSS for animations -----
|
|
|
|
export function AdminMobileStyles() {
|
|
return (
|
|
<style jsx global>{`
|
|
@keyframes slide-up {
|
|
from { transform: translateY(100%); }
|
|
to { transform: translateY(0); }
|
|
}
|
|
.animate-slide-up {
|
|
animation: slide-up 0.25s ease-out;
|
|
}
|
|
.scrollbar-hide {
|
|
-ms-overflow-style: none;
|
|
scrollbar-width: none;
|
|
}
|
|
.scrollbar-hide::-webkit-scrollbar {
|
|
display: none;
|
|
}
|
|
`}</style>
|
|
);
|
|
}
|