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>
This commit is contained in:
@@ -6,8 +6,11 @@ import { usersApi, User } from '@/lib/api';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Input from '@/components/ui/Input';
|
||||
import { TrashIcon, PencilSquareIcon } from '@heroicons/react/24/outline';
|
||||
import { MoreMenu, DropdownItem, BottomSheet, AdminMobileStyles } from '@/components/admin/MobileComponents';
|
||||
import { TrashIcon, PencilSquareIcon, FunnelIcon, XMarkIcon } from '@heroicons/react/24/outline';
|
||||
import { CheckCircleIcon } from '@heroicons/react/24/outline';
|
||||
import toast from 'react-hot-toast';
|
||||
import clsx from 'clsx';
|
||||
|
||||
export default function AdminUsersPage() {
|
||||
const { t, locale } = useLanguage();
|
||||
@@ -24,6 +27,7 @@ export default function AdminUsersPage() {
|
||||
accountStatus: '' as string,
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [mobileFilterOpen, setMobileFilterOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadUsers();
|
||||
@@ -52,7 +56,6 @@ export default function AdminUsersPage() {
|
||||
|
||||
const handleDelete = async (userId: string) => {
|
||||
if (!confirm('Are you sure you want to delete this user?')) return;
|
||||
|
||||
try {
|
||||
await usersApi.delete(userId);
|
||||
toast.success('User deleted');
|
||||
@@ -65,11 +68,8 @@ export default function AdminUsersPage() {
|
||||
const openEditModal = (user: User) => {
|
||||
setEditingUser(user);
|
||||
setEditForm({
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
phone: user.phone || '',
|
||||
role: user.role,
|
||||
languagePreference: user.languagePreference || '',
|
||||
name: user.name, email: user.email, phone: user.phone || '',
|
||||
role: user.role, languagePreference: user.languagePreference || '',
|
||||
accountStatus: user.accountStatus || 'active',
|
||||
});
|
||||
};
|
||||
@@ -77,7 +77,6 @@ export default function AdminUsersPage() {
|
||||
const handleEditSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!editingUser) return;
|
||||
|
||||
if (!editForm.name.trim() || editForm.name.trim().length < 2) {
|
||||
toast.error('Name must be at least 2 characters');
|
||||
return;
|
||||
@@ -86,14 +85,11 @@ export default function AdminUsersPage() {
|
||||
toast.error('Email is required');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
await usersApi.update(editingUser.id, {
|
||||
name: editForm.name.trim(),
|
||||
email: editForm.email.trim(),
|
||||
phone: editForm.phone.trim() || undefined,
|
||||
role: editForm.role,
|
||||
name: editForm.name.trim(), email: editForm.email.trim(),
|
||||
phone: editForm.phone.trim() || undefined, role: editForm.role,
|
||||
languagePreference: editForm.languagePreference || undefined,
|
||||
accountStatus: editForm.accountStatus || undefined,
|
||||
} as Partial<User>);
|
||||
@@ -109,20 +105,14 @@ export default function AdminUsersPage() {
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
timeZone: 'America/Asuncion',
|
||||
year: 'numeric', month: 'short', day: 'numeric', timeZone: 'America/Asuncion',
|
||||
});
|
||||
};
|
||||
|
||||
const getRoleBadge = (role: string) => {
|
||||
const styles: Record<string, string> = {
|
||||
admin: 'badge-danger',
|
||||
organizer: 'badge-info',
|
||||
staff: 'badge-warning',
|
||||
marketing: 'badge-success',
|
||||
user: 'badge-gray',
|
||||
admin: 'badge-danger', organizer: 'badge-info', staff: 'badge-warning',
|
||||
marketing: 'badge-success', user: 'badge-gray',
|
||||
};
|
||||
return <span className={`badge ${styles[role] || 'badge-gray'}`}>{t(`admin.users.roles.${role}`)}</span>;
|
||||
};
|
||||
@@ -138,19 +128,16 @@ export default function AdminUsersPage() {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold text-primary-dark">{t('admin.users.title')}</h1>
|
||||
<h1 className="text-xl md:text-2xl font-bold text-primary-dark">{t('admin.users.title')}</h1>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<Card className="p-4 mb-6">
|
||||
{/* Desktop Filters */}
|
||||
<Card className="p-4 mb-6 hidden md:block">
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">{t('admin.users.role')}</label>
|
||||
<select
|
||||
value={roleFilter}
|
||||
onChange={(e) => setRoleFilter(e.target.value)}
|
||||
className="px-4 py-2 rounded-btn border border-secondary-light-gray min-w-[150px]"
|
||||
>
|
||||
<select value={roleFilter} onChange={(e) => setRoleFilter(e.target.value)}
|
||||
className="px-4 py-2 rounded-btn border border-secondary-light-gray min-w-[150px] text-sm">
|
||||
<option value="">All Roles</option>
|
||||
<option value="admin">{t('admin.users.roles.admin')}</option>
|
||||
<option value="organizer">{t('admin.users.roles.organizer')}</option>
|
||||
@@ -162,51 +149,58 @@ export default function AdminUsersPage() {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Users Table */}
|
||||
<Card className="overflow-hidden">
|
||||
{/* Mobile Toolbar */}
|
||||
<div className="md:hidden mb-4 flex items-center gap-2">
|
||||
<button onClick={() => setMobileFilterOpen(true)}
|
||||
className={clsx(
|
||||
'flex items-center gap-1.5 px-3 py-2 rounded-btn border text-sm min-h-[44px]',
|
||||
roleFilter ? 'border-primary-yellow bg-yellow-50 text-primary-dark' : 'border-secondary-light-gray text-gray-600'
|
||||
)}>
|
||||
<FunnelIcon className="w-4 h-4" />
|
||||
{roleFilter ? t(`admin.users.roles.${roleFilter}`) : 'Filter by Role'}
|
||||
</button>
|
||||
{roleFilter && (
|
||||
<button onClick={() => setRoleFilter('')} className="text-xs text-primary-yellow min-h-[44px] flex items-center">
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
<span className="text-xs text-gray-500 ml-auto">{users.length} users</span>
|
||||
</div>
|
||||
|
||||
{/* Desktop: Table */}
|
||||
<Card className="overflow-hidden hidden md:block">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-secondary-gray">
|
||||
<tr>
|
||||
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">User</th>
|
||||
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Contact</th>
|
||||
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Role</th>
|
||||
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Joined</th>
|
||||
<th className="text-right px-6 py-3 text-sm font-medium text-gray-600">Actions</th>
|
||||
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">User</th>
|
||||
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Contact</th>
|
||||
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Role</th>
|
||||
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Joined</th>
|
||||
<th className="text-right px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-secondary-light-gray">
|
||||
{users.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-6 py-12 text-center text-gray-500">
|
||||
No users found
|
||||
</td>
|
||||
</tr>
|
||||
<tr><td colSpan={5} className="px-4 py-12 text-center text-gray-500 text-sm">No users found</td></tr>
|
||||
) : (
|
||||
users.map((user) => (
|
||||
<tr key={user.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4">
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<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 className="w-8 h-8 bg-primary-yellow/20 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<span className="font-semibold text-sm text-primary-dark">{user.name.charAt(0).toUpperCase()}</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{user.name}</p>
|
||||
<p className="text-sm text-gray-500">{user.email}</p>
|
||||
<p className="font-medium text-sm">{user.name}</p>
|
||||
<p className="text-xs text-gray-500">{user.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">
|
||||
{user.phone || '-'}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<select
|
||||
value={user.role}
|
||||
onChange={(e) => handleRoleChange(user.id, e.target.value)}
|
||||
className="px-2 py-1 rounded border border-secondary-light-gray text-sm"
|
||||
>
|
||||
<td className="px-4 py-3 text-sm text-gray-600">{user.phone || '-'}</td>
|
||||
<td className="px-4 py-3">
|
||||
<select value={user.role} onChange={(e) => handleRoleChange(user.id, e.target.value)}
|
||||
className="px-2 py-1 rounded border border-secondary-light-gray text-sm">
|
||||
<option value="user">{t('admin.users.roles.user')}</option>
|
||||
<option value="staff">{t('admin.users.roles.staff')}</option>
|
||||
<option value="marketing">{t('admin.users.roles.marketing')}</option>
|
||||
@@ -214,23 +208,15 @@ export default function AdminUsersPage() {
|
||||
<option value="admin">{t('admin.users.roles.admin')}</option>
|
||||
</select>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">
|
||||
{formatDate(user.createdAt)}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={() => openEditModal(user)}
|
||||
className="p-2 hover:bg-blue-100 text-blue-600 rounded-btn"
|
||||
title="Edit"
|
||||
>
|
||||
<td className="px-4 py-3 text-xs text-gray-500">{formatDate(user.createdAt)}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button onClick={() => openEditModal(user)}
|
||||
className="p-2 hover:bg-blue-100 text-blue-600 rounded-btn" title="Edit">
|
||||
<PencilSquareIcon className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(user.id)}
|
||||
className="p-2 hover:bg-red-100 text-red-600 rounded-btn"
|
||||
title="Delete"
|
||||
>
|
||||
<button onClick={() => handleDelete(user.id)}
|
||||
className="p-2 hover:bg-red-100 text-red-600 rounded-btn" title="Delete">
|
||||
<TrashIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -243,43 +229,90 @@ export default function AdminUsersPage() {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Mobile: Card List */}
|
||||
<div className="md:hidden space-y-2">
|
||||
{users.length === 0 ? (
|
||||
<div className="text-center py-10 text-gray-500 text-sm">No users found</div>
|
||||
) : (
|
||||
users.map((user) => (
|
||||
<Card key={user.id} className="p-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 bg-primary-yellow/20 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<span className="font-semibold text-primary-dark">{user.name.charAt(0).toUpperCase()}</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-sm truncate">{user.name}</p>
|
||||
<p className="text-xs text-gray-500 truncate">{user.email}</p>
|
||||
{user.phone && <p className="text-[10px] text-gray-400">{user.phone}</p>}
|
||||
</div>
|
||||
{getRoleBadge(user.role)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-2 pt-2 border-t border-gray-100">
|
||||
<p className="text-[10px] text-gray-400">Joined {formatDate(user.createdAt)}</p>
|
||||
<MoreMenu>
|
||||
<DropdownItem onClick={() => openEditModal(user)}>
|
||||
<PencilSquareIcon className="w-4 h-4 mr-2" /> Edit User
|
||||
</DropdownItem>
|
||||
<DropdownItem onClick={() => handleDelete(user.id)} className="text-red-600">
|
||||
<TrashIcon className="w-4 h-4 mr-2" /> Delete
|
||||
</DropdownItem>
|
||||
</MoreMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile Filter BottomSheet */}
|
||||
<BottomSheet open={mobileFilterOpen} onClose={() => setMobileFilterOpen(false)} title="Filter by Role">
|
||||
<div className="space-y-1">
|
||||
{[
|
||||
{ value: '', label: 'All Roles' },
|
||||
{ value: 'admin', label: t('admin.users.roles.admin') },
|
||||
{ value: 'organizer', label: t('admin.users.roles.organizer') },
|
||||
{ value: 'staff', label: t('admin.users.roles.staff') },
|
||||
{ value: 'marketing', label: t('admin.users.roles.marketing') },
|
||||
{ value: 'user', label: t('admin.users.roles.user') },
|
||||
].map((opt) => (
|
||||
<button key={opt.value}
|
||||
onClick={() => { setRoleFilter(opt.value); setMobileFilterOpen(false); }}
|
||||
className={clsx(
|
||||
'w-full text-left px-4 py-3 rounded-btn text-sm min-h-[44px] flex items-center justify-between',
|
||||
roleFilter === opt.value ? 'bg-yellow-50 text-primary-dark font-medium' : 'hover:bg-gray-50'
|
||||
)}>
|
||||
{opt.label}
|
||||
{roleFilter === opt.value && <CheckCircleIcon className="w-4 h-4 text-primary-yellow" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</BottomSheet>
|
||||
|
||||
{/* Edit User Modal */}
|
||||
{editingUser && (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-lg max-h-[90vh] overflow-y-auto p-6">
|
||||
<h2 className="text-xl font-bold text-primary-dark mb-6">Edit User</h2>
|
||||
|
||||
<form onSubmit={handleEditSubmit} className="space-y-4">
|
||||
<Input
|
||||
label="Name"
|
||||
value={editForm.name}
|
||||
onChange={(e) => setEditForm({ ...editForm, name: e.target.value })}
|
||||
required
|
||||
minLength={2}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Email"
|
||||
type="email"
|
||||
value={editForm.email}
|
||||
onChange={(e) => setEditForm({ ...editForm, email: e.target.value })}
|
||||
required
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Phone"
|
||||
value={editForm.phone}
|
||||
onChange={(e) => setEditForm({ ...editForm, phone: e.target.value })}
|
||||
placeholder="Optional"
|
||||
/>
|
||||
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-end md:items-center justify-center p-0 md:p-4">
|
||||
<Card className="w-full md:max-w-lg max-h-[90vh] flex flex-col overflow-hidden rounded-t-2xl md:rounded-card">
|
||||
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray flex-shrink-0">
|
||||
<h2 className="text-base font-bold">Edit User</h2>
|
||||
<button onClick={() => setEditingUser(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>
|
||||
<form onSubmit={handleEditSubmit} className="p-4 space-y-4 overflow-y-auto flex-1 min-h-0">
|
||||
<Input label="Name" value={editForm.name}
|
||||
onChange={(e) => setEditForm({ ...editForm, name: e.target.value })} required minLength={2} />
|
||||
<Input label="Email" type="email" value={editForm.email}
|
||||
onChange={(e) => setEditForm({ ...editForm, email: e.target.value })} required />
|
||||
<Input label="Phone" value={editForm.phone}
|
||||
onChange={(e) => setEditForm({ ...editForm, phone: e.target.value })} placeholder="Optional" />
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-primary-dark mb-1.5">Role</label>
|
||||
<select
|
||||
value={editForm.role}
|
||||
onChange={(e) => setEditForm({ ...editForm, role: e.target.value as User['role'] })}
|
||||
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow focus:border-transparent"
|
||||
>
|
||||
<select value={editForm.role} onChange={(e) => setEditForm({ ...editForm, role: e.target.value as User['role'] })}
|
||||
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow min-h-[44px]">
|
||||
<option value="user">{t('admin.users.roles.user')}</option>
|
||||
<option value="staff">{t('admin.users.roles.staff')}</option>
|
||||
<option value="marketing">{t('admin.users.roles.marketing')}</option>
|
||||
@@ -287,49 +320,36 @@ export default function AdminUsersPage() {
|
||||
<option value="admin">{t('admin.users.roles.admin')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-primary-dark mb-1.5">Language Preference</label>
|
||||
<select
|
||||
value={editForm.languagePreference}
|
||||
<select value={editForm.languagePreference}
|
||||
onChange={(e) => setEditForm({ ...editForm, languagePreference: e.target.value })}
|
||||
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow focus:border-transparent"
|
||||
>
|
||||
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow min-h-[44px]">
|
||||
<option value="">Not set</option>
|
||||
<option value="en">English</option>
|
||||
<option value="es">Español</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-primary-dark mb-1.5">Account Status</label>
|
||||
<select
|
||||
value={editForm.accountStatus}
|
||||
<select value={editForm.accountStatus}
|
||||
onChange={(e) => setEditForm({ ...editForm, accountStatus: e.target.value })}
|
||||
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow focus:border-transparent"
|
||||
>
|
||||
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow min-h-[44px]">
|
||||
<option value="active">Active</option>
|
||||
<option value="unclaimed">Unclaimed</option>
|
||||
<option value="suspended">Suspended</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 justify-end mt-6 pt-4 border-t border-secondary-light-gray">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setEditingUser(null)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" isLoading={saving}>
|
||||
Save Changes
|
||||
</Button>
|
||||
<div className="flex gap-3 pt-2">
|
||||
<Button type="button" variant="outline" onClick={() => setEditingUser(null)} className="flex-1 min-h-[44px]">Cancel</Button>
|
||||
<Button type="submit" isLoading={saving} className="flex-1 min-h-[44px]">Save Changes</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AdminMobileStyles />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user