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:
@@ -1,13 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { eventsApi, ticketsApi, emailsApi, paymentOptionsApi, adminApi, Event, Ticket, EmailTemplate, PaymentOptionsConfig } from '@/lib/api';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import { Dropdown, DropdownItem, BottomSheet, MoreMenu, AdminMobileStyles } from '@/components/admin/MobileComponents';
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
CalendarIcon,
|
||||
@@ -44,160 +44,6 @@ import clsx from 'clsx';
|
||||
|
||||
type TabType = 'overview' | 'attendees' | 'tickets' | 'email' | 'payments';
|
||||
|
||||
// ----- Skeleton loaders -----
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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) -----
|
||||
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);
|
||||
|
||||
// Recalculate position when opened
|
||||
useEffect(() => {
|
||||
if (open && triggerRef.current) {
|
||||
const rect = triggerRef.current.getBoundingClientRect();
|
||||
const menuWidth = 192; // w-48 = 12rem = 192px
|
||||
let left = align === 'right' ? rect.right - menuWidth : rect.left;
|
||||
// Clamp so menu doesn't overflow viewport
|
||||
left = Math.max(8, Math.min(left, window.innerWidth - menuWidth - 8));
|
||||
setPos({ top: rect.bottom + 4, left });
|
||||
}
|
||||
}, [open, align]);
|
||||
|
||||
// Close on outside click
|
||||
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]);
|
||||
|
||||
// Close on scroll (the menu position would be stale)
|
||||
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
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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) -----
|
||||
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) -----
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminEventDetailPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
@@ -2239,23 +2085,7 @@ export default function AdminEventDetailPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CSS for animations */}
|
||||
<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>
|
||||
<AdminMobileStyles />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,19 +2,22 @@
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { eventsApi, siteSettingsApi, Event } from '@/lib/api';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Input from '@/components/ui/Input';
|
||||
import MediaPicker from '@/components/MediaPicker';
|
||||
import { PlusIcon, PencilIcon, TrashIcon, EyeIcon, PhotoIcon, DocumentDuplicateIcon, ArchiveBoxIcon, StarIcon } from '@heroicons/react/24/outline';
|
||||
import { MoreMenu, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
|
||||
import { PlusIcon, PencilIcon, TrashIcon, EyeIcon, PhotoIcon, DocumentDuplicateIcon, ArchiveBoxIcon, StarIcon, XMarkIcon } from '@heroicons/react/24/outline';
|
||||
import { StarIcon as StarIconSolid } from '@heroicons/react/24/solid';
|
||||
import toast from 'react-hot-toast';
|
||||
import clsx from 'clsx';
|
||||
|
||||
export default function AdminEventsPage() {
|
||||
const { t, locale } = useLanguage();
|
||||
const searchParams = useSearchParams();
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
@@ -66,6 +69,14 @@ export default function AdminEventsPage() {
|
||||
loadFeaturedEvent();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const editId = searchParams.get('edit');
|
||||
if (editId && events.length > 0) {
|
||||
const event = events.find(e => e.id === editId);
|
||||
if (event) handleEdit(event);
|
||||
}
|
||||
}, [searchParams, events]);
|
||||
|
||||
const loadEvents = async () => {
|
||||
try {
|
||||
const { events } = await eventsApi.getAll();
|
||||
@@ -82,7 +93,7 @@ export default function AdminEventsPage() {
|
||||
const { settings } = await siteSettingsApi.get();
|
||||
setFeaturedEventId(settings.featuredEventId || null);
|
||||
} catch (error) {
|
||||
// Ignore error - settings may not exist yet
|
||||
// Ignore - settings may not exist yet
|
||||
}
|
||||
};
|
||||
|
||||
@@ -101,28 +112,15 @@ export default function AdminEventsPage() {
|
||||
|
||||
const resetForm = () => {
|
||||
setFormData({
|
||||
title: '',
|
||||
titleEs: '',
|
||||
description: '',
|
||||
descriptionEs: '',
|
||||
shortDescription: '',
|
||||
shortDescriptionEs: '',
|
||||
startDatetime: '',
|
||||
endDatetime: '',
|
||||
location: '',
|
||||
locationUrl: '',
|
||||
price: 0,
|
||||
currency: 'PYG',
|
||||
capacity: 50,
|
||||
status: 'draft' as const,
|
||||
bannerUrl: '',
|
||||
externalBookingEnabled: false,
|
||||
externalBookingUrl: '',
|
||||
title: '', titleEs: '', description: '', descriptionEs: '',
|
||||
shortDescription: '', shortDescriptionEs: '',
|
||||
startDatetime: '', endDatetime: '', location: '', locationUrl: '',
|
||||
price: 0, currency: 'PYG', capacity: 50, status: 'draft' as const,
|
||||
bannerUrl: '', externalBookingEnabled: false, externalBookingUrl: '',
|
||||
});
|
||||
setEditingEvent(null);
|
||||
};
|
||||
|
||||
// Convert ISO UTC string to local datetime-local format (YYYY-MM-DDTHH:MM)
|
||||
const isoToLocalDatetime = (isoString: string): string => {
|
||||
const date = new Date(isoString);
|
||||
const year = date.getFullYear();
|
||||
@@ -135,21 +133,14 @@ export default function AdminEventsPage() {
|
||||
|
||||
const handleEdit = (event: Event) => {
|
||||
setFormData({
|
||||
title: event.title,
|
||||
titleEs: event.titleEs || '',
|
||||
description: event.description,
|
||||
descriptionEs: event.descriptionEs || '',
|
||||
shortDescription: event.shortDescription || '',
|
||||
shortDescriptionEs: event.shortDescriptionEs || '',
|
||||
title: event.title, titleEs: event.titleEs || '',
|
||||
description: event.description, descriptionEs: event.descriptionEs || '',
|
||||
shortDescription: event.shortDescription || '', shortDescriptionEs: event.shortDescriptionEs || '',
|
||||
startDatetime: isoToLocalDatetime(event.startDatetime),
|
||||
endDatetime: event.endDatetime ? isoToLocalDatetime(event.endDatetime) : '',
|
||||
location: event.location,
|
||||
locationUrl: event.locationUrl || '',
|
||||
price: event.price,
|
||||
currency: event.currency,
|
||||
capacity: event.capacity,
|
||||
status: event.status,
|
||||
bannerUrl: event.bannerUrl || '',
|
||||
location: event.location, locationUrl: event.locationUrl || '',
|
||||
price: event.price, currency: event.currency, capacity: event.capacity,
|
||||
status: event.status, bannerUrl: event.bannerUrl || '',
|
||||
externalBookingEnabled: event.externalBookingEnabled || false,
|
||||
externalBookingUrl: event.externalBookingUrl || '',
|
||||
});
|
||||
@@ -160,9 +151,7 @@ export default function AdminEventsPage() {
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
// Validate external booking URL if enabled
|
||||
if (formData.externalBookingEnabled && !formData.externalBookingUrl) {
|
||||
toast.error('External booking URL is required when external booking is enabled');
|
||||
setSaving(false);
|
||||
@@ -173,27 +162,18 @@ export default function AdminEventsPage() {
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const eventData = {
|
||||
title: formData.title,
|
||||
titleEs: formData.titleEs || undefined,
|
||||
description: formData.description,
|
||||
descriptionEs: formData.descriptionEs || undefined,
|
||||
shortDescription: formData.shortDescription || undefined,
|
||||
shortDescriptionEs: formData.shortDescriptionEs || undefined,
|
||||
title: formData.title, titleEs: formData.titleEs || undefined,
|
||||
description: formData.description, descriptionEs: formData.descriptionEs || undefined,
|
||||
shortDescription: formData.shortDescription || undefined, shortDescriptionEs: formData.shortDescriptionEs || undefined,
|
||||
startDatetime: new Date(formData.startDatetime).toISOString(),
|
||||
endDatetime: formData.endDatetime ? new Date(formData.endDatetime).toISOString() : undefined,
|
||||
location: formData.location,
|
||||
locationUrl: formData.locationUrl || undefined,
|
||||
price: formData.price,
|
||||
currency: formData.currency,
|
||||
capacity: formData.capacity,
|
||||
status: formData.status,
|
||||
bannerUrl: formData.bannerUrl || undefined,
|
||||
location: formData.location, locationUrl: formData.locationUrl || undefined,
|
||||
price: formData.price, currency: formData.currency, capacity: formData.capacity,
|
||||
status: formData.status, bannerUrl: formData.bannerUrl || undefined,
|
||||
externalBookingEnabled: formData.externalBookingEnabled,
|
||||
externalBookingUrl: formData.externalBookingEnabled ? formData.externalBookingUrl : undefined,
|
||||
};
|
||||
|
||||
if (editingEvent) {
|
||||
await eventsApi.update(editingEvent.id, eventData);
|
||||
toast.success('Event updated');
|
||||
@@ -201,7 +181,6 @@ export default function AdminEventsPage() {
|
||||
await eventsApi.create(eventData);
|
||||
toast.success('Event created');
|
||||
}
|
||||
|
||||
setShowForm(false);
|
||||
resetForm();
|
||||
loadEvents();
|
||||
@@ -214,7 +193,6 @@ export default function AdminEventsPage() {
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('Are you sure you want to delete this event?')) return;
|
||||
|
||||
try {
|
||||
await eventsApi.delete(id);
|
||||
toast.success('Event deleted');
|
||||
@@ -234,23 +212,21 @@ export default function AdminEventsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
timeZone: 'America/Asuncion',
|
||||
month: 'short', day: 'numeric', year: 'numeric', timeZone: 'America/Asuncion',
|
||||
});
|
||||
};
|
||||
|
||||
const isEventOver = (event: Event) => {
|
||||
const refDate = event.endDatetime || event.startDatetime;
|
||||
return new Date(refDate) < new Date();
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const styles: Record<string, string> = {
|
||||
draft: 'badge-gray',
|
||||
published: 'badge-success',
|
||||
cancelled: 'badge-danger',
|
||||
completed: 'badge-info',
|
||||
archived: 'badge-gray',
|
||||
draft: 'badge-gray', published: 'badge-success', cancelled: 'badge-danger',
|
||||
completed: 'badge-info', archived: 'badge-gray',
|
||||
};
|
||||
return <span className={`badge ${styles[status] || 'badge-gray'}`}>{status}</span>;
|
||||
};
|
||||
@@ -286,8 +262,8 @@ export default function AdminEventsPage() {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold text-primary-dark">{t('admin.events.title')}</h1>
|
||||
<Button onClick={() => { resetForm(); setShowForm(true); }}>
|
||||
<h1 className="text-xl md:text-2xl font-bold text-primary-dark">{t('admin.events.title')}</h1>
|
||||
<Button onClick={() => { resetForm(); setShowForm(true); }} className="hidden md:flex">
|
||||
<PlusIcon className="w-5 h-5 mr-2" />
|
||||
{t('admin.events.create')}
|
||||
</Button>
|
||||
@@ -295,140 +271,92 @@ export default function AdminEventsPage() {
|
||||
|
||||
{/* Event Form Modal */}
|
||||
{showForm && (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-2xl max-h-[90vh] overflow-y-auto p-6">
|
||||
<h2 className="text-xl font-bold mb-6">
|
||||
{editingEvent ? t('admin.events.edit') : t('admin.events.create')}
|
||||
</h2>
|
||||
<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-2xl max-h-[90vh] flex flex-col overflow-hidden rounded-t-2xl md:rounded-card">
|
||||
<div className="flex items-center justify-between p-4 md:p-6 border-b border-secondary-light-gray flex-shrink-0">
|
||||
<h2 className="text-lg md:text-xl font-bold">
|
||||
{editingEvent ? t('admin.events.edit') : t('admin.events.create')}
|
||||
</h2>
|
||||
<button onClick={() => { setShowForm(false); resetForm(); }}
|
||||
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={handleSubmit} className="space-y-4">
|
||||
<form onSubmit={handleSubmit} className="p-4 md:p-6 space-y-4 overflow-y-auto flex-1 min-h-0">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Title (English)"
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Title (Spanish)"
|
||||
value={formData.titleEs}
|
||||
onChange={(e) => setFormData({ ...formData, titleEs: e.target.value })}
|
||||
/>
|
||||
<Input label="Title (English)" value={formData.title}
|
||||
onChange={(e) => setFormData({ ...formData, title: e.target.value })} required />
|
||||
<Input label="Title (Spanish)" value={formData.titleEs}
|
||||
onChange={(e) => setFormData({ ...formData, titleEs: e.target.value })} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Description (English)</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
<textarea value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: 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"
|
||||
rows={3}
|
||||
required
|
||||
/>
|
||||
rows={3} required />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Description (Spanish)</label>
|
||||
<textarea
|
||||
value={formData.descriptionEs}
|
||||
<textarea value={formData.descriptionEs}
|
||||
onChange={(e) => setFormData({ ...formData, descriptionEs: 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"
|
||||
rows={3}
|
||||
/>
|
||||
rows={3} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Short Description (English)</label>
|
||||
<textarea
|
||||
value={formData.shortDescription}
|
||||
<textarea value={formData.shortDescription}
|
||||
onChange={(e) => setFormData({ ...formData, shortDescription: e.target.value.slice(0, 300) })}
|
||||
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
rows={2}
|
||||
maxLength={300}
|
||||
placeholder="Brief summary for SEO and cards (max 300 chars)"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">{formData.shortDescription.length}/300 characters</p>
|
||||
rows={2} maxLength={300} placeholder="Brief summary for SEO and cards (max 300 chars)" />
|
||||
<p className="text-xs text-gray-500 mt-1">{formData.shortDescription.length}/300</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Short Description (Spanish)</label>
|
||||
<textarea
|
||||
value={formData.shortDescriptionEs}
|
||||
<textarea value={formData.shortDescriptionEs}
|
||||
onChange={(e) => setFormData({ ...formData, shortDescriptionEs: e.target.value.slice(0, 300) })}
|
||||
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
rows={2}
|
||||
maxLength={300}
|
||||
placeholder="Resumen breve para SEO y tarjetas (máx 300 caracteres)"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">{formData.shortDescriptionEs.length}/300 characters</p>
|
||||
rows={2} maxLength={300} placeholder="Resumen breve (máx 300 caracteres)" />
|
||||
<p className="text-xs text-gray-500 mt-1">{formData.shortDescriptionEs.length}/300</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Start Date & Time"
|
||||
type="datetime-local"
|
||||
value={formData.startDatetime}
|
||||
onChange={(e) => setFormData({ ...formData, startDatetime: e.target.value })}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="End Date & Time"
|
||||
type="datetime-local"
|
||||
value={formData.endDatetime}
|
||||
onChange={(e) => setFormData({ ...formData, endDatetime: e.target.value })}
|
||||
/>
|
||||
<Input label="Start Date & Time" type="datetime-local" value={formData.startDatetime}
|
||||
onChange={(e) => setFormData({ ...formData, startDatetime: e.target.value })} required />
|
||||
<Input label="End Date & Time" type="datetime-local" value={formData.endDatetime}
|
||||
onChange={(e) => setFormData({ ...formData, endDatetime: e.target.value })} />
|
||||
</div>
|
||||
|
||||
<Input
|
||||
label="Location"
|
||||
value={formData.location}
|
||||
onChange={(e) => setFormData({ ...formData, location: e.target.value })}
|
||||
required
|
||||
/>
|
||||
<Input label="Location" value={formData.location}
|
||||
onChange={(e) => setFormData({ ...formData, location: e.target.value })} required />
|
||||
<Input label="Location URL (Google Maps)" type="url" value={formData.locationUrl}
|
||||
onChange={(e) => setFormData({ ...formData, locationUrl: e.target.value })} />
|
||||
|
||||
<Input
|
||||
label="Location URL (Google Maps)"
|
||||
type="url"
|
||||
value={formData.locationUrl}
|
||||
onChange={(e) => setFormData({ ...formData, locationUrl: e.target.value })}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Input
|
||||
label="Price"
|
||||
type="number"
|
||||
min="0"
|
||||
value={formData.price}
|
||||
onChange={(e) => setFormData({ ...formData, price: Number(e.target.value) })}
|
||||
/>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<Input label="Price" type="number" min="0" value={formData.price}
|
||||
onChange={(e) => setFormData({ ...formData, price: Number(e.target.value) })} />
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Currency</label>
|
||||
<select
|
||||
value={formData.currency}
|
||||
onChange={(e) => setFormData({ ...formData, currency: e.target.value })}
|
||||
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray"
|
||||
>
|
||||
<select value={formData.currency} onChange={(e) => setFormData({ ...formData, currency: e.target.value })}
|
||||
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray">
|
||||
<option value="PYG">PYG</option>
|
||||
<option value="USD">USD</option>
|
||||
</select>
|
||||
</div>
|
||||
<Input
|
||||
label="Capacity"
|
||||
type="number"
|
||||
min="1"
|
||||
value={formData.capacity}
|
||||
onChange={(e) => setFormData({ ...formData, capacity: Number(e.target.value) })}
|
||||
/>
|
||||
<Input label="Capacity" type="number" min="1" value={formData.capacity}
|
||||
onChange={(e) => setFormData({ ...formData, capacity: Number(e.target.value) })} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Status</label>
|
||||
<select
|
||||
value={formData.status}
|
||||
onChange={(e) => setFormData({ ...formData, status: e.target.value as any })}
|
||||
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray"
|
||||
>
|
||||
<select value={formData.status} onChange={(e) => setFormData({ ...formData, status: e.target.value as any })}
|
||||
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray">
|
||||
<option value="draft">Draft</option>
|
||||
<option value="published">Published</option>
|
||||
<option value="cancelled">Cancelled</option>
|
||||
@@ -437,79 +365,53 @@ export default function AdminEventsPage() {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* External Booking Section */}
|
||||
<div className="border border-secondary-light-gray rounded-lg p-4 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">External Booking</label>
|
||||
<p className="text-xs text-gray-500">Redirect users to an external booking platform</p>
|
||||
<p className="text-xs text-gray-500">Redirect users to an external platform</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
<button type="button"
|
||||
onClick={() => setFormData({ ...formData, externalBookingEnabled: !formData.externalBookingEnabled })}
|
||||
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-yellow focus:ring-offset-2 ${
|
||||
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors ${
|
||||
formData.externalBookingEnabled ? 'bg-primary-yellow' : 'bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
|
||||
formData.externalBookingEnabled ? 'translate-x-5' : 'translate-x-0'
|
||||
}`}
|
||||
/>
|
||||
}`}>
|
||||
<span className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition ${
|
||||
formData.externalBookingEnabled ? 'translate-x-5' : 'translate-x-0'
|
||||
}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{formData.externalBookingEnabled && (
|
||||
<div>
|
||||
<Input
|
||||
label="External Booking URL"
|
||||
type="url"
|
||||
value={formData.externalBookingUrl}
|
||||
<Input label="External Booking URL" type="url" value={formData.externalBookingUrl}
|
||||
onChange={(e) => setFormData({ ...formData, externalBookingUrl: e.target.value })}
|
||||
placeholder="https://example.com/book"
|
||||
required
|
||||
/>
|
||||
placeholder="https://example.com/book" required />
|
||||
<p className="text-xs text-gray-500 mt-1">Must be a valid HTTPS URL</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Image Upload / Media Picker */}
|
||||
<MediaPicker
|
||||
value={formData.bannerUrl}
|
||||
<MediaPicker value={formData.bannerUrl}
|
||||
onChange={(url) => setFormData({ ...formData, bannerUrl: url })}
|
||||
relatedId={editingEvent?.id}
|
||||
relatedType="event"
|
||||
/>
|
||||
relatedId={editingEvent?.id} relatedType="event" />
|
||||
|
||||
{/* Featured Event Section - Only show for published events when editing */}
|
||||
{editingEvent && editingEvent.status === 'published' && (
|
||||
<div className="border border-secondary-light-gray rounded-lg p-4 space-y-4 bg-amber-50">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 flex items-center gap-2">
|
||||
<StarIcon className="w-5 h-5 text-amber-500" />
|
||||
Featured Event
|
||||
<StarIcon className="w-5 h-5 text-amber-500" /> Featured Event
|
||||
</label>
|
||||
<p className="text-xs text-gray-500">
|
||||
Featured events are prominently displayed on the homepage and linktree
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">Prominently displayed on homepage</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={settingFeatured !== null}
|
||||
onClick={() => handleSetFeatured(
|
||||
featuredEventId === editingEvent.id ? null : editingEvent.id
|
||||
)}
|
||||
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-amber-500 focus:ring-offset-2 disabled:opacity-50 ${
|
||||
<button type="button" disabled={settingFeatured !== null}
|
||||
onClick={() => handleSetFeatured(featuredEventId === editingEvent.id ? null : editingEvent.id)}
|
||||
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors disabled:opacity-50 ${
|
||||
featuredEventId === editingEvent.id ? 'bg-amber-500' : 'bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
|
||||
featuredEventId === editingEvent.id ? 'translate-x-5' : 'translate-x-0'
|
||||
}`}
|
||||
/>
|
||||
}`}>
|
||||
<span className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition ${
|
||||
featuredEventId === editingEvent.id ? 'translate-x-5' : 'translate-x-0'
|
||||
}`} />
|
||||
</button>
|
||||
</div>
|
||||
{featuredEventId && featuredEventId !== editingEvent.id && (
|
||||
@@ -521,14 +423,10 @@ export default function AdminEventsPage() {
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 pt-4">
|
||||
<Button type="submit" isLoading={saving}>
|
||||
<Button type="submit" isLoading={saving} className="flex-1 min-h-[44px]">
|
||||
{editingEvent ? 'Update Event' : 'Create Event'}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => { setShowForm(false); resetForm(); }}
|
||||
>
|
||||
<Button type="button" variant="outline" onClick={() => { setShowForm(false); resetForm(); }} className="flex-1 min-h-[44px]">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
@@ -537,17 +435,17 @@ export default function AdminEventsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Events Table */}
|
||||
<Card className="overflow-hidden">
|
||||
{/* 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">Event</th>
|
||||
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Date</th>
|
||||
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Capacity</th>
|
||||
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Status</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">Event</th>
|
||||
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th>
|
||||
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Capacity</th>
|
||||
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Status</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">
|
||||
@@ -560,109 +458,75 @@ export default function AdminEventsPage() {
|
||||
) : (
|
||||
events.map((event) => (
|
||||
<tr key={event.id} className={clsx("hover:bg-gray-50", featuredEventId === event.id && "bg-amber-50")}>
|
||||
<td className="px-6 py-4">
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
{event.bannerUrl ? (
|
||||
<img
|
||||
src={event.bannerUrl}
|
||||
alt={event.title}
|
||||
className="w-12 h-12 rounded-lg object-cover flex-shrink-0"
|
||||
/>
|
||||
<img src={event.bannerUrl} alt={event.title}
|
||||
className="w-10 h-10 rounded-lg object-cover flex-shrink-0" />
|
||||
) : (
|
||||
<div className="w-12 h-12 rounded-lg bg-secondary-gray flex items-center justify-center flex-shrink-0">
|
||||
<PhotoIcon className="w-6 h-6 text-gray-400" />
|
||||
<div className="w-10 h-10 rounded-lg bg-secondary-gray flex items-center justify-center flex-shrink-0">
|
||||
<PhotoIcon className="w-5 h-5 text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-medium">{event.title}</p>
|
||||
<p className="font-medium text-sm">{event.title}</p>
|
||||
{featuredEventId === event.id && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800">
|
||||
<StarIconSolid className="w-3 h-3" />
|
||||
Featured
|
||||
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full text-[10px] font-medium bg-amber-100 text-amber-800">
|
||||
<StarIconSolid className="w-2.5 h-2.5" /> Featured
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 truncate max-w-xs">{event.location}</p>
|
||||
<p className="text-xs text-gray-500 truncate max-w-xs">{event.location}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">
|
||||
{formatDate(event.startDatetime)}
|
||||
<td className="px-4 py-3 text-sm text-gray-600">{formatDate(event.startDatetime)}</td>
|
||||
<td className="px-4 py-3 text-sm">{event.bookedCount || 0} / {event.capacity}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{getStatusBadge(event.status)}
|
||||
{isEventOver(event) && event.status !== 'completed' && event.status !== 'cancelled' && event.status !== 'archived' && (
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded-full text-[10px] font-medium bg-gray-800 text-white">Over</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm">
|
||||
{event.bookedCount || 0} / {event.capacity}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{getStatusBadge(event.status)}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{event.status === 'draft' && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleStatusChange(event, 'published')}
|
||||
>
|
||||
<Button size="sm" variant="ghost" onClick={() => handleStatusChange(event, 'published')}>
|
||||
Publish
|
||||
</Button>
|
||||
)}
|
||||
{event.status === 'published' && (
|
||||
<button
|
||||
onClick={() => handleSetFeatured(featuredEventId === event.id ? null : event.id)}
|
||||
<button onClick={() => handleSetFeatured(featuredEventId === event.id ? null : event.id)}
|
||||
disabled={settingFeatured !== null}
|
||||
className={clsx(
|
||||
"p-2 rounded-btn disabled:opacity-50",
|
||||
featuredEventId === event.id
|
||||
? "bg-amber-100 text-amber-600 hover:bg-amber-200"
|
||||
: "hover:bg-amber-100 text-gray-400 hover:text-amber-600"
|
||||
)}
|
||||
title={featuredEventId === event.id ? "Remove from featured" : "Set as featured"}
|
||||
>
|
||||
{featuredEventId === event.id ? (
|
||||
<StarIconSolid className="w-4 h-4" />
|
||||
) : (
|
||||
<StarIcon className="w-4 h-4" />
|
||||
)}
|
||||
className={clsx("p-2 rounded-btn disabled:opacity-50",
|
||||
featuredEventId === event.id ? "bg-amber-100 text-amber-600 hover:bg-amber-200" : "hover:bg-amber-100 text-gray-400 hover:text-amber-600")}
|
||||
title={featuredEventId === event.id ? "Remove from featured" : "Set as featured"}>
|
||||
{featuredEventId === event.id ? <StarIconSolid className="w-4 h-4" /> : <StarIcon className="w-4 h-4" />}
|
||||
</button>
|
||||
)}
|
||||
<Link
|
||||
href={`/admin/events/${event.id}`}
|
||||
className="p-2 hover:bg-primary-yellow/20 text-primary-dark rounded-btn"
|
||||
title="Manage Event"
|
||||
>
|
||||
<Link href={`/admin/events/${event.id}`}
|
||||
className="p-2 hover:bg-primary-yellow/20 text-primary-dark rounded-btn" title="Manage">
|
||||
<EyeIcon className="w-4 h-4" />
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => handleEdit(event)}
|
||||
className="p-2 hover:bg-gray-100 rounded-btn"
|
||||
title="Edit"
|
||||
>
|
||||
<button onClick={() => handleEdit(event)} className="p-2 hover:bg-gray-100 rounded-btn" title="Edit">
|
||||
<PencilIcon className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDuplicate(event)}
|
||||
className="p-2 hover:bg-blue-100 text-blue-600 rounded-btn"
|
||||
title="Duplicate"
|
||||
>
|
||||
<DocumentDuplicateIcon className="w-4 h-4" />
|
||||
</button>
|
||||
{event.status !== 'archived' && (
|
||||
<button
|
||||
onClick={() => handleArchive(event)}
|
||||
className="p-2 hover:bg-gray-100 text-gray-600 rounded-btn"
|
||||
title="Archive"
|
||||
>
|
||||
<ArchiveBoxIcon className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleDelete(event.id)}
|
||||
className="p-2 hover:bg-red-100 text-red-600 rounded-btn"
|
||||
title="Delete"
|
||||
>
|
||||
<TrashIcon className="w-4 h-4" />
|
||||
</button>
|
||||
<MoreMenu>
|
||||
<DropdownItem onClick={() => handleDuplicate(event)}>
|
||||
<DocumentDuplicateIcon className="w-4 h-4 mr-2" /> Duplicate
|
||||
</DropdownItem>
|
||||
{event.status !== 'archived' && (
|
||||
<DropdownItem onClick={() => handleArchive(event)}>
|
||||
<ArchiveBoxIcon className="w-4 h-4 mr-2" /> Archive
|
||||
</DropdownItem>
|
||||
)}
|
||||
<DropdownItem onClick={() => handleDelete(event.id)} className="text-red-600">
|
||||
<TrashIcon className="w-4 h-4 mr-2" /> Delete
|
||||
</DropdownItem>
|
||||
</MoreMenu>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -672,6 +536,94 @@ export default function AdminEventsPage() {
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Mobile: Card List */}
|
||||
<div className="md:hidden space-y-2">
|
||||
{events.length === 0 ? (
|
||||
<div className="text-center py-10 text-gray-500 text-sm">
|
||||
No events found. Create your first event!
|
||||
</div>
|
||||
) : (
|
||||
events.map((event) => (
|
||||
<Card key={event.id} className={clsx("p-3", featuredEventId === event.id && "ring-2 ring-amber-300")}>
|
||||
<div className="flex items-start gap-3">
|
||||
{event.bannerUrl ? (
|
||||
<img src={event.bannerUrl} alt={event.title}
|
||||
className="w-14 h-14 rounded-lg object-cover flex-shrink-0" />
|
||||
) : (
|
||||
<div className="w-14 h-14 rounded-lg bg-secondary-gray flex items-center justify-center flex-shrink-0">
|
||||
<PhotoIcon className="w-6 h-6 text-gray-400" />
|
||||
</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">{event.title}</p>
|
||||
<p className="text-xs text-gray-500">{formatDate(event.startDatetime)}</p>
|
||||
<p className="text-xs text-gray-400 truncate">{event.location}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 flex-shrink-0 flex-wrap justify-end">
|
||||
{getStatusBadge(event.status)}
|
||||
{isEventOver(event) && event.status !== 'completed' && event.status !== 'cancelled' && event.status !== 'archived' && (
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded-full text-[10px] font-medium bg-gray-800 text-white">Over</span>
|
||||
)}
|
||||
{featuredEventId === event.id && (
|
||||
<StarIconSolid className="w-4 h-4 text-amber-500" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-2 pt-2 border-t border-gray-100">
|
||||
<p className="text-xs text-gray-500">{event.bookedCount || 0} / {event.capacity} spots</p>
|
||||
<div className="flex items-center gap-1">
|
||||
<Link href={`/admin/events/${event.id}`}
|
||||
className="p-2 hover:bg-primary-yellow/20 text-primary-dark rounded-btn min-h-[36px] min-w-[36px] flex items-center justify-center">
|
||||
<EyeIcon className="w-4 h-4" />
|
||||
</Link>
|
||||
<MoreMenu>
|
||||
<DropdownItem onClick={() => handleEdit(event)}>
|
||||
<PencilIcon className="w-4 h-4 mr-2" /> Edit
|
||||
</DropdownItem>
|
||||
{event.status === 'draft' && (
|
||||
<DropdownItem onClick={() => handleStatusChange(event, 'published')}>
|
||||
Publish
|
||||
</DropdownItem>
|
||||
)}
|
||||
{event.status === 'published' && (
|
||||
<DropdownItem onClick={() => handleSetFeatured(featuredEventId === event.id ? null : event.id)}>
|
||||
<StarIcon className="w-4 h-4 mr-2" />
|
||||
{featuredEventId === event.id ? 'Unfeature' : 'Set Featured'}
|
||||
</DropdownItem>
|
||||
)}
|
||||
<DropdownItem onClick={() => handleDuplicate(event)}>
|
||||
<DocumentDuplicateIcon className="w-4 h-4 mr-2" /> Duplicate
|
||||
</DropdownItem>
|
||||
{event.status !== 'archived' && (
|
||||
<DropdownItem onClick={() => handleArchive(event)}>
|
||||
<ArchiveBoxIcon className="w-4 h-4 mr-2" /> Archive
|
||||
</DropdownItem>
|
||||
)}
|
||||
<DropdownItem onClick={() => handleDelete(event.id)} className="text-red-600">
|
||||
<TrashIcon className="w-4 h-4 mr-2" /> Delete
|
||||
</DropdownItem>
|
||||
</MoreMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile FAB */}
|
||||
<div className="md:hidden fixed bottom-6 right-6 z-40">
|
||||
<button onClick={() => { resetForm(); setShowForm(true); }}
|
||||
className="w-14 h-14 bg-primary-yellow text-primary-dark rounded-full shadow-lg flex items-center justify-center hover:bg-yellow-400 active:scale-95 transition-transform">
|
||||
<PlusIcon className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AdminMobileStyles />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user