Files
Spanglish/frontend/src/components/admin/MobileComponents.tsx
T
MichilisandCursor 958181e049 Mobile-friendly admin pages, redesigned homepage Next Event card
- 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>
2026-02-18 03:27:49 +00:00

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