Compare commits
3 Commits
backup5
...
b33c68feb0
| Author | SHA1 | Date | |
|---|---|---|---|
| b33c68feb0 | |||
|
|
bbfaa1172a | ||
|
|
958181e049 |
@@ -75,7 +75,7 @@ export const sqliteEvents = sqliteTable('events', {
|
|||||||
price: real('price').notNull().default(0),
|
price: real('price').notNull().default(0),
|
||||||
currency: text('currency').notNull().default('PYG'),
|
currency: text('currency').notNull().default('PYG'),
|
||||||
capacity: integer('capacity').notNull().default(50),
|
capacity: integer('capacity').notNull().default(50),
|
||||||
status: text('status', { enum: ['draft', 'published', 'cancelled', 'completed', 'archived'] }).notNull().default('draft'),
|
status: text('status', { enum: ['draft', 'published', 'unlisted', 'cancelled', 'completed', 'archived'] }).notNull().default('draft'),
|
||||||
bannerUrl: text('banner_url'),
|
bannerUrl: text('banner_url'),
|
||||||
externalBookingEnabled: integer('external_booking_enabled', { mode: 'boolean' }).notNull().default(false),
|
externalBookingEnabled: integer('external_booking_enabled', { mode: 'boolean' }).notNull().default(false),
|
||||||
externalBookingUrl: text('external_booking_url'),
|
externalBookingUrl: text('external_booking_url'),
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ const baseEventSchema = z.object({
|
|||||||
price: z.union([z.number(), z.string()]).transform(parsePrice).pipe(z.number().min(0)).default(0),
|
price: z.union([z.number(), z.string()]).transform(parsePrice).pipe(z.number().min(0)).default(0),
|
||||||
currency: z.string().default('PYG'),
|
currency: z.string().default('PYG'),
|
||||||
capacity: z.union([z.number(), z.string()]).transform((val) => typeof val === 'string' ? parseInt(val, 10) || 50 : val).pipe(z.number().min(1)).default(50),
|
capacity: z.union([z.number(), z.string()]).transform((val) => typeof val === 'string' ? parseInt(val, 10) || 50 : val).pipe(z.number().min(1)).default(50),
|
||||||
status: z.enum(['draft', 'published', 'cancelled', 'completed', 'archived']).default('draft'),
|
status: z.enum(['draft', 'published', 'unlisted', 'cancelled', 'completed', 'archived']).default('draft'),
|
||||||
// Accept relative paths (/uploads/...) or full URLs
|
// Accept relative paths (/uploads/...) or full URLs
|
||||||
bannerUrl: z.string().optional().nullable().or(z.literal('')),
|
bannerUrl: z.string().optional().nullable().or(z.literal('')),
|
||||||
// External booking support - accept boolean or number (0/1 from DB)
|
// External booking support - accept boolean or number (0/1 from DB)
|
||||||
@@ -220,6 +220,7 @@ async function getEventTicketCount(eventId: string): Promise<number> {
|
|||||||
// Get next upcoming event (public) - returns featured event if valid, otherwise next upcoming
|
// Get next upcoming event (public) - returns featured event if valid, otherwise next upcoming
|
||||||
eventsRouter.get('/next/upcoming', async (c) => {
|
eventsRouter.get('/next/upcoming', async (c) => {
|
||||||
const now = getNow();
|
const now = getNow();
|
||||||
|
const nowMs = Date.now();
|
||||||
|
|
||||||
// First, check if there's a featured event in site settings
|
// First, check if there's a featured event in site settings
|
||||||
const settings = await dbGet<any>(
|
const settings = await dbGet<any>(
|
||||||
@@ -230,7 +231,6 @@ eventsRouter.get('/next/upcoming', async (c) => {
|
|||||||
let shouldUnsetFeatured = false;
|
let shouldUnsetFeatured = false;
|
||||||
|
|
||||||
if (settings?.featuredEventId) {
|
if (settings?.featuredEventId) {
|
||||||
// Get the featured event
|
|
||||||
featuredEvent = await dbGet<any>(
|
featuredEvent = await dbGet<any>(
|
||||||
(db as any)
|
(db as any)
|
||||||
.select()
|
.select()
|
||||||
@@ -239,37 +239,30 @@ eventsRouter.get('/next/upcoming', async (c) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (featuredEvent) {
|
if (featuredEvent) {
|
||||||
// Check if featured event is still valid:
|
|
||||||
// 1. Must be published
|
|
||||||
// 2. Must not have ended (endDatetime >= now, or startDatetime >= now if no endDatetime)
|
|
||||||
const eventEndTime = featuredEvent.endDatetime || featuredEvent.startDatetime;
|
const eventEndTime = featuredEvent.endDatetime || featuredEvent.startDatetime;
|
||||||
const isPublished = featuredEvent.status === 'published';
|
const isPublished = featuredEvent.status === 'published';
|
||||||
const hasNotEnded = eventEndTime >= now;
|
const hasNotEnded = new Date(eventEndTime).getTime() > nowMs;
|
||||||
|
|
||||||
if (!isPublished || !hasNotEnded) {
|
if (!isPublished || !hasNotEnded) {
|
||||||
// Featured event is no longer valid - mark for unsetting
|
|
||||||
shouldUnsetFeatured = true;
|
shouldUnsetFeatured = true;
|
||||||
featuredEvent = null;
|
featuredEvent = null;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Featured event no longer exists
|
|
||||||
shouldUnsetFeatured = true;
|
shouldUnsetFeatured = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we need to unset the featured event, do it asynchronously
|
|
||||||
if (shouldUnsetFeatured && settings) {
|
if (shouldUnsetFeatured && settings) {
|
||||||
// Unset featured event in background (don't await to avoid blocking response)
|
try {
|
||||||
(db as any)
|
await (db as any)
|
||||||
.update(siteSettings)
|
.update(siteSettings)
|
||||||
.set({ featuredEventId: null, updatedAt: now })
|
.set({ featuredEventId: null, updatedAt: now })
|
||||||
.where(eq((siteSettings as any).id, settings.id))
|
.where(eq((siteSettings as any).id, settings.id));
|
||||||
.then(() => {
|
|
||||||
console.log('Featured event auto-cleared (event ended or unpublished)');
|
console.log('Featured event auto-cleared (event ended or unpublished)');
|
||||||
})
|
revalidateFrontendCache();
|
||||||
.catch((err: any) => {
|
} catch (err: any) {
|
||||||
console.error('Failed to clear featured event:', err);
|
console.error('Failed to clear featured event:', err);
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we have a valid featured event, return it
|
// If we have a valid featured event, return it
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ ticketsRouter.post('/', zValidator('json', createTicketSchema), async (c) => {
|
|||||||
return c.json({ error: 'Event not found' }, 404);
|
return c.json({ error: 'Event not found' }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.status !== 'published') {
|
if (!['published', 'unlisted'].includes(event.status)) {
|
||||||
return c.json({ error: 'Event is not available for booking' }, 400);
|
return c.json({ error: 'Event is not available for booking' }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ export default function BookingPage() {
|
|||||||
paymentOptionsApi.getForEvent(params.eventId as string),
|
paymentOptionsApi.getForEvent(params.eventId as string),
|
||||||
])
|
])
|
||||||
.then(([eventRes, paymentRes]) => {
|
.then(([eventRes, paymentRes]) => {
|
||||||
if (!eventRes.event || eventRes.event.status !== 'published') {
|
if (!eventRes.event || !['published', 'unlisted'].includes(eventRes.event.status)) {
|
||||||
toast.error('Event is not available for booking');
|
toast.error('Event is not available for booking');
|
||||||
router.push('/events');
|
router.push('/events');
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -5,9 +5,7 @@ import Link from 'next/link';
|
|||||||
import { useLanguage } from '@/context/LanguageContext';
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
import { eventsApi, Event } from '@/lib/api';
|
import { eventsApi, Event } from '@/lib/api';
|
||||||
import { formatPrice, formatDateLong, formatTime } from '@/lib/utils';
|
import { formatPrice, formatDateLong, formatTime } from '@/lib/utils';
|
||||||
import Button from '@/components/ui/Button';
|
import { CalendarIcon, MapPinIcon, ClockIcon } from '@heroicons/react/24/outline';
|
||||||
import Card from '@/components/ui/Card';
|
|
||||||
import { CalendarIcon, MapPinIcon } from '@heroicons/react/24/outline';
|
|
||||||
|
|
||||||
interface NextEventSectionProps {
|
interface NextEventSectionProps {
|
||||||
initialEvent?: Event | null;
|
initialEvent?: Event | null;
|
||||||
@@ -16,11 +14,24 @@ interface NextEventSectionProps {
|
|||||||
export default function NextEventSection({ initialEvent }: NextEventSectionProps) {
|
export default function NextEventSection({ initialEvent }: NextEventSectionProps) {
|
||||||
const { t, locale } = useLanguage();
|
const { t, locale } = useLanguage();
|
||||||
const [nextEvent, setNextEvent] = useState<Event | null>(initialEvent ?? null);
|
const [nextEvent, setNextEvent] = useState<Event | null>(initialEvent ?? null);
|
||||||
const [loading, setLoading] = useState(!initialEvent);
|
const [loading, setLoading] = useState(initialEvent === undefined);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Skip fetch if we already have server-provided data
|
if (initialEvent !== undefined) {
|
||||||
if (initialEvent !== undefined) return;
|
if (initialEvent) {
|
||||||
|
const endTime = initialEvent.endDatetime || initialEvent.startDatetime;
|
||||||
|
if (new Date(endTime).getTime() <= Date.now()) {
|
||||||
|
setNextEvent(null);
|
||||||
|
setLoading(true);
|
||||||
|
eventsApi.getNextUpcoming()
|
||||||
|
.then(({ event }) => setNextEvent(event))
|
||||||
|
.catch(console.error)
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
eventsApi.getNextUpcoming()
|
eventsApi.getNextUpcoming()
|
||||||
.then(({ event }) => setNextEvent(event))
|
.then(({ event }) => setNextEvent(event))
|
||||||
.catch(console.error)
|
.catch(console.error)
|
||||||
@@ -30,6 +41,15 @@ export default function NextEventSection({ initialEvent }: NextEventSectionProps
|
|||||||
const formatDate = (dateStr: string) => formatDateLong(dateStr, locale as 'en' | 'es');
|
const formatDate = (dateStr: string) => formatDateLong(dateStr, locale as 'en' | 'es');
|
||||||
const fmtTime = (dateStr: string) => formatTime(dateStr, locale as 'en' | 'es');
|
const fmtTime = (dateStr: string) => formatTime(dateStr, locale as 'en' | 'es');
|
||||||
|
|
||||||
|
const title = nextEvent
|
||||||
|
? (locale === 'es' && nextEvent.titleEs ? nextEvent.titleEs : nextEvent.title)
|
||||||
|
: '';
|
||||||
|
const description = nextEvent
|
||||||
|
? (locale === 'es'
|
||||||
|
? (nextEvent.shortDescriptionEs || nextEvent.descriptionEs || nextEvent.shortDescription || nextEvent.description)
|
||||||
|
: (nextEvent.shortDescription || nextEvent.description))
|
||||||
|
: '';
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
@@ -49,56 +69,72 @@ export default function NextEventSection({ initialEvent }: NextEventSectionProps
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link href={`/events/${nextEvent.id}`} className="block">
|
<Link href={`/events/${nextEvent.id}`} className="block group">
|
||||||
<Card variant="elevated" className="p-8 cursor-pointer hover:shadow-lg transition-shadow">
|
<div className="bg-gray-50 border border-gray-200 rounded-2xl overflow-hidden shadow-lg transition-all duration-300 hover:shadow-2xl hover:scale-[1.01]">
|
||||||
<div className="flex flex-col md:flex-row gap-8">
|
<div className="flex flex-col md:flex-row">
|
||||||
<div className="flex-1">
|
{/* Banner */}
|
||||||
<h3 className="text-2xl font-bold text-primary-dark">
|
{nextEvent.bannerUrl ? (
|
||||||
{locale === 'es' && nextEvent.titleEs ? nextEvent.titleEs : nextEvent.title}
|
<div className="relative w-full md:w-2/5 flex-shrink-0">
|
||||||
</h3>
|
<img
|
||||||
<p className="mt-3 text-gray-600 whitespace-pre-line">
|
src={nextEvent.bannerUrl}
|
||||||
{locale === 'es'
|
alt={title}
|
||||||
? (nextEvent.shortDescriptionEs || nextEvent.descriptionEs || nextEvent.shortDescription || nextEvent.description)
|
className="w-full h-48 md:h-full object-cover"
|
||||||
: (nextEvent.shortDescription || nextEvent.description)}
|
/>
|
||||||
</p>
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="w-full md:w-2/5 flex-shrink-0 h-48 md:h-auto bg-gradient-to-br from-primary-yellow/20 to-secondary-gray flex items-center justify-center">
|
||||||
|
<CalendarIcon className="w-16 h-16 text-gray-300" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="mt-6 space-y-3">
|
{/* Info */}
|
||||||
<div className="flex items-center gap-3 text-gray-700">
|
<div className="flex-1 p-5 md:p-8 flex flex-col justify-between">
|
||||||
<CalendarIcon className="w-5 h-5 text-primary-yellow" />
|
<div>
|
||||||
|
<h3 className="text-xl md:text-2xl font-bold text-primary-dark group-hover:text-brand-navy transition-colors">
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
{description && (
|
||||||
|
<p className="mt-2 text-sm md:text-base text-gray-600 line-clamp-2">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-4 md:mt-5 space-y-2">
|
||||||
|
<div className="flex items-center gap-2.5 text-gray-700 text-sm">
|
||||||
|
<CalendarIcon className="w-4 h-4 text-primary-yellow flex-shrink-0" />
|
||||||
<span>{formatDate(nextEvent.startDatetime)}</span>
|
<span>{formatDate(nextEvent.startDatetime)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3 text-gray-700">
|
<div className="flex items-center gap-2.5 text-gray-700 text-sm">
|
||||||
<span className="w-5 h-5 flex items-center justify-center text-primary-yellow font-bold">
|
<ClockIcon className="w-4 h-4 text-primary-yellow flex-shrink-0" />
|
||||||
⏰
|
|
||||||
</span>
|
|
||||||
<span>{fmtTime(nextEvent.startDatetime)}</span>
|
<span>{fmtTime(nextEvent.startDatetime)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3 text-gray-700">
|
<div className="flex items-center gap-2.5 text-gray-700 text-sm">
|
||||||
<MapPinIcon className="w-5 h-5 text-primary-yellow" />
|
<MapPinIcon className="w-4 h-4 text-primary-yellow flex-shrink-0" />
|
||||||
<span>{nextEvent.location}</span>
|
<span>{nextEvent.location}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col justify-between items-start md:items-end">
|
<div className="mt-5 md:mt-6 flex items-center justify-between gap-4">
|
||||||
<div className="text-right">
|
<div>
|
||||||
<span className="text-3xl font-bold text-primary-dark">
|
<span className="text-2xl md:text-3xl font-bold text-primary-dark">
|
||||||
{nextEvent.price === 0
|
{nextEvent.price === 0
|
||||||
? t('events.details.free')
|
? t('events.details.free')
|
||||||
: formatPrice(nextEvent.price, nextEvent.currency)}
|
: formatPrice(nextEvent.price, nextEvent.currency)}
|
||||||
</span>
|
</span>
|
||||||
{!nextEvent.externalBookingEnabled && (
|
{!nextEvent.externalBookingEnabled && nextEvent.availableSeats != null && (
|
||||||
<p className="text-sm text-gray-500 mt-1">
|
<p className="text-xs text-gray-500 mt-0.5">
|
||||||
{nextEvent.availableSeats} {t('events.details.spotsLeft')}
|
{nextEvent.availableSeats} {t('events.details.spotsLeft')}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Button size="lg" className="mt-6">
|
<span className="inline-flex items-center bg-primary-yellow text-primary-dark font-semibold py-2.5 px-5 rounded-xl text-sm transition-all duration-200 group-hover:bg-yellow-400 flex-shrink-0">
|
||||||
{t('common.moreInfo')}
|
{t('common.moreInfo')}
|
||||||
</Button>
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export default function NextEventSectionWrapper({ initialEvent }: NextEventSecti
|
|||||||
<h2 className="section-title text-center">
|
<h2 className="section-title text-center">
|
||||||
{t('home.nextEvent.title')}
|
{t('home.nextEvent.title')}
|
||||||
</h2>
|
</h2>
|
||||||
<div className="mt-12 max-w-3xl mx-auto">
|
<div className="mt-12 max-w-4xl mx-auto">
|
||||||
<NextEventSection initialEvent={initialEvent} />
|
<NextEventSection initialEvent={initialEvent} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export default function EventDetailClient({ eventId, initialEvent }: EventDetail
|
|||||||
const isCancelled = event.status === 'cancelled';
|
const isCancelled = event.status === 'cancelled';
|
||||||
// Only calculate isPastEvent after mount to avoid hydration mismatch
|
// Only calculate isPastEvent after mount to avoid hydration mismatch
|
||||||
const isPastEvent = mounted ? new Date(event.startDatetime) < new Date() : false;
|
const isPastEvent = mounted ? new Date(event.startDatetime) < new Date() : false;
|
||||||
const canBook = !isSoldOut && !isCancelled && !isPastEvent && event.status === 'published';
|
const canBook = !isSoldOut && !isCancelled && !isPastEvent && (event.status === 'published' || event.status === 'unlisted');
|
||||||
|
|
||||||
// Booking card content - reused for mobile and desktop positions
|
// Booking card content - reused for mobile and desktop positions
|
||||||
const BookingCardContent = () => (
|
const BookingCardContent = () => (
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ interface Event {
|
|||||||
price: number;
|
price: number;
|
||||||
currency: string;
|
currency: string;
|
||||||
capacity: number;
|
capacity: number;
|
||||||
status: 'draft' | 'published' | 'cancelled' | 'completed' | 'archived';
|
status: 'draft' | 'published' | 'unlisted' | 'cancelled' | 'completed' | 'archived';
|
||||||
bannerUrl?: string;
|
bannerUrl?: string;
|
||||||
availableSeats?: number;
|
availableSeats?: number;
|
||||||
bookedCount?: number;
|
bookedCount?: number;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useLanguage } from '@/context/LanguageContext';
|
|||||||
import { ticketsApi, eventsApi, Ticket, Event } from '@/lib/api';
|
import { ticketsApi, eventsApi, Ticket, Event } from '@/lib/api';
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
|
import { BottomSheet, MoreMenu, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
|
||||||
import {
|
import {
|
||||||
TicketIcon,
|
TicketIcon,
|
||||||
CheckCircleIcon,
|
CheckCircleIcon,
|
||||||
@@ -14,8 +15,10 @@ import {
|
|||||||
EnvelopeIcon,
|
EnvelopeIcon,
|
||||||
PhoneIcon,
|
PhoneIcon,
|
||||||
FunnelIcon,
|
FunnelIcon,
|
||||||
|
MagnifyingGlassIcon,
|
||||||
} from '@heroicons/react/24/outline';
|
} from '@heroicons/react/24/outline';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
interface TicketWithDetails extends Omit<Ticket, 'payment'> {
|
interface TicketWithDetails extends Omit<Ticket, 'payment'> {
|
||||||
bookingId?: string;
|
bookingId?: string;
|
||||||
@@ -40,10 +43,11 @@ export default function AdminBookingsPage() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [processing, setProcessing] = useState<string | null>(null);
|
const [processing, setProcessing] = useState<string | null>(null);
|
||||||
|
|
||||||
// Filters
|
|
||||||
const [selectedEvent, setSelectedEvent] = useState<string>('');
|
const [selectedEvent, setSelectedEvent] = useState<string>('');
|
||||||
const [selectedStatus, setSelectedStatus] = useState<string>('');
|
const [selectedStatus, setSelectedStatus] = useState<string>('');
|
||||||
const [selectedPaymentStatus, setSelectedPaymentStatus] = useState<string>('');
|
const [selectedPaymentStatus, setSelectedPaymentStatus] = useState<string>('');
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [mobileFilterOpen, setMobileFilterOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadData();
|
loadData();
|
||||||
@@ -56,7 +60,6 @@ export default function AdminBookingsPage() {
|
|||||||
eventsApi.getAll(),
|
eventsApi.getAll(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Fetch full ticket details with payment info
|
|
||||||
const ticketsWithDetails = await Promise.all(
|
const ticketsWithDetails = await Promise.all(
|
||||||
ticketsRes.tickets.map(async (ticket) => {
|
ticketsRes.tickets.map(async (ticket) => {
|
||||||
try {
|
try {
|
||||||
@@ -131,62 +134,50 @@ export default function AdminBookingsPage() {
|
|||||||
|
|
||||||
const getStatusColor = (status: string) => {
|
const getStatusColor = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'confirmed':
|
case 'confirmed': return 'bg-green-100 text-green-800';
|
||||||
return 'bg-green-100 text-green-800';
|
case 'pending': return 'bg-yellow-100 text-yellow-800';
|
||||||
case 'pending':
|
case 'cancelled': return 'bg-red-100 text-red-800';
|
||||||
return 'bg-yellow-100 text-yellow-800';
|
case 'checked_in': return 'bg-blue-100 text-blue-800';
|
||||||
case 'cancelled':
|
default: return 'bg-gray-100 text-gray-800';
|
||||||
return 'bg-red-100 text-red-800';
|
|
||||||
case 'checked_in':
|
|
||||||
return 'bg-blue-100 text-blue-800';
|
|
||||||
default:
|
|
||||||
return 'bg-gray-100 text-gray-800';
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getPaymentStatusColor = (status: string) => {
|
const getPaymentStatusColor = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'paid':
|
case 'paid': return 'bg-green-100 text-green-800';
|
||||||
return 'bg-green-100 text-green-800';
|
case 'pending': return 'bg-yellow-100 text-yellow-800';
|
||||||
case 'pending':
|
|
||||||
return 'bg-yellow-100 text-yellow-800';
|
|
||||||
case 'failed':
|
case 'failed':
|
||||||
case 'cancelled':
|
case 'cancelled': return 'bg-red-100 text-red-800';
|
||||||
return 'bg-red-100 text-red-800';
|
case 'refunded': return 'bg-purple-100 text-purple-800';
|
||||||
case 'refunded':
|
default: return 'bg-gray-100 text-gray-800';
|
||||||
return 'bg-purple-100 text-purple-800';
|
|
||||||
default:
|
|
||||||
return 'bg-gray-100 text-gray-800';
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getPaymentMethodLabel = (provider: string) => {
|
const getPaymentMethodLabel = (provider: string) => {
|
||||||
switch (provider) {
|
switch (provider) {
|
||||||
case 'bancard':
|
case 'bancard': return 'TPago / Card';
|
||||||
return 'TPago / Card';
|
case 'lightning': return 'Bitcoin Lightning';
|
||||||
case 'lightning':
|
case 'cash': return 'Cash at Event';
|
||||||
return 'Bitcoin Lightning';
|
default: return provider;
|
||||||
case 'cash':
|
|
||||||
return 'Cash at Event';
|
|
||||||
default:
|
|
||||||
return provider;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Filter tickets
|
|
||||||
const filteredTickets = tickets.filter((ticket) => {
|
const filteredTickets = tickets.filter((ticket) => {
|
||||||
if (selectedEvent && ticket.eventId !== selectedEvent) return false;
|
if (selectedEvent && ticket.eventId !== selectedEvent) return false;
|
||||||
if (selectedStatus && ticket.status !== selectedStatus) return false;
|
if (selectedStatus && ticket.status !== selectedStatus) return false;
|
||||||
if (selectedPaymentStatus && ticket.payment?.status !== selectedPaymentStatus) return false;
|
if (selectedPaymentStatus && ticket.payment?.status !== selectedPaymentStatus) return false;
|
||||||
|
if (searchQuery) {
|
||||||
|
const q = searchQuery.toLowerCase();
|
||||||
|
const name = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.toLowerCase();
|
||||||
|
return name.includes(q) || (ticket.attendeeEmail?.toLowerCase().includes(q) || false);
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sort by created date (newest first)
|
|
||||||
const sortedTickets = [...filteredTickets].sort(
|
const sortedTickets = [...filteredTickets].sort(
|
||||||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||||
);
|
);
|
||||||
|
|
||||||
// Stats
|
|
||||||
const stats = {
|
const stats = {
|
||||||
total: tickets.length,
|
total: tickets.length,
|
||||||
pending: tickets.filter(t => t.status === 'pending').length,
|
pending: tickets.filter(t => t.status === 'pending').length,
|
||||||
@@ -196,23 +187,36 @@ export default function AdminBookingsPage() {
|
|||||||
pendingPayment: tickets.filter(t => t.payment?.status === 'pending').length,
|
pendingPayment: tickets.filter(t => t.payment?.status === 'pending').length,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper to get booking info for a ticket (ticket count and total)
|
|
||||||
const getBookingInfo = (ticket: TicketWithDetails) => {
|
const getBookingInfo = (ticket: TicketWithDetails) => {
|
||||||
if (!ticket.bookingId) {
|
if (!ticket.bookingId) {
|
||||||
return { ticketCount: 1, bookingTotal: Number(ticket.payment?.amount || 0) };
|
return { ticketCount: 1, bookingTotal: Number(ticket.payment?.amount || 0) };
|
||||||
}
|
}
|
||||||
|
const bookingTickets = tickets.filter(t => t.bookingId === ticket.bookingId);
|
||||||
// Count all tickets with the same bookingId
|
|
||||||
const bookingTickets = tickets.filter(
|
|
||||||
t => t.bookingId === ticket.bookingId
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ticketCount: bookingTickets.length,
|
ticketCount: bookingTickets.length,
|
||||||
bookingTotal: bookingTickets.reduce((sum, t) => sum + Number(t.payment?.amount || 0), 0),
|
bookingTotal: bookingTickets.reduce((sum, t) => sum + Number(t.payment?.amount || 0), 0),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const hasActiveFilters = selectedEvent || selectedStatus || selectedPaymentStatus || searchQuery;
|
||||||
|
|
||||||
|
const clearFilters = () => {
|
||||||
|
setSelectedEvent('');
|
||||||
|
setSelectedStatus('');
|
||||||
|
setSelectedPaymentStatus('');
|
||||||
|
setSearchQuery('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPrimaryAction = (ticket: TicketWithDetails) => {
|
||||||
|
if (ticket.status === 'pending' && ticket.payment?.status === 'pending') {
|
||||||
|
return { label: 'Mark Paid', onClick: () => handleMarkPaid(ticket.id), color: 'text-green-600' };
|
||||||
|
}
|
||||||
|
if (ticket.status === 'confirmed') {
|
||||||
|
return { label: 'Check In', onClick: () => handleCheckin(ticket.id), color: 'text-blue-600' };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-center py-12">
|
||||||
@@ -224,51 +228,61 @@ export default function AdminBookingsPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<h1 className="text-2xl font-bold text-primary-dark">Manage Bookings</h1>
|
<h1 className="text-xl md:text-2xl font-bold text-primary-dark">Manage Bookings</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats Cards */}
|
{/* Stats Cards */}
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4 mb-6">
|
<div className="grid grid-cols-3 md:grid-cols-3 lg:grid-cols-6 gap-2 md:gap-4 mb-6">
|
||||||
<Card className="p-4 text-center">
|
<Card className="p-3 md:p-4 text-center">
|
||||||
<p className="text-2xl font-bold text-primary-dark">{stats.total}</p>
|
<p className="text-xl md:text-2xl font-bold text-primary-dark">{stats.total}</p>
|
||||||
<p className="text-sm text-gray-500">Total</p>
|
<p className="text-xs md:text-sm text-gray-500">Total</p>
|
||||||
</Card>
|
</Card>
|
||||||
<Card className="p-4 text-center border-l-4 border-yellow-400">
|
<Card className="p-3 md:p-4 text-center border-l-4 border-yellow-400">
|
||||||
<p className="text-2xl font-bold text-yellow-600">{stats.pending}</p>
|
<p className="text-xl md:text-2xl font-bold text-yellow-600">{stats.pending}</p>
|
||||||
<p className="text-sm text-gray-500">Pending</p>
|
<p className="text-xs md:text-sm text-gray-500">Pending</p>
|
||||||
</Card>
|
</Card>
|
||||||
<Card className="p-4 text-center border-l-4 border-green-400">
|
<Card className="p-3 md:p-4 text-center border-l-4 border-green-400">
|
||||||
<p className="text-2xl font-bold text-green-600">{stats.confirmed}</p>
|
<p className="text-xl md:text-2xl font-bold text-green-600">{stats.confirmed}</p>
|
||||||
<p className="text-sm text-gray-500">Confirmed</p>
|
<p className="text-xs md:text-sm text-gray-500">Confirmed</p>
|
||||||
</Card>
|
</Card>
|
||||||
<Card className="p-4 text-center border-l-4 border-blue-400">
|
<Card className="p-3 md:p-4 text-center border-l-4 border-blue-400">
|
||||||
<p className="text-2xl font-bold text-blue-600">{stats.checkedIn}</p>
|
<p className="text-xl md:text-2xl font-bold text-blue-600">{stats.checkedIn}</p>
|
||||||
<p className="text-sm text-gray-500">Checked In</p>
|
<p className="text-xs md:text-sm text-gray-500">Checked In</p>
|
||||||
</Card>
|
</Card>
|
||||||
<Card className="p-4 text-center border-l-4 border-red-400">
|
<Card className="p-3 md:p-4 text-center border-l-4 border-red-400">
|
||||||
<p className="text-2xl font-bold text-red-600">{stats.cancelled}</p>
|
<p className="text-xl md:text-2xl font-bold text-red-600">{stats.cancelled}</p>
|
||||||
<p className="text-sm text-gray-500">Cancelled</p>
|
<p className="text-xs md:text-sm text-gray-500">Cancelled</p>
|
||||||
</Card>
|
</Card>
|
||||||
<Card className="p-4 text-center border-l-4 border-orange-400">
|
<Card className="p-3 md:p-4 text-center border-l-4 border-orange-400">
|
||||||
<p className="text-2xl font-bold text-orange-600">{stats.pendingPayment}</p>
|
<p className="text-xl md:text-2xl font-bold text-orange-600">{stats.pendingPayment}</p>
|
||||||
<p className="text-sm text-gray-500">Pending Payment</p>
|
<p className="text-xs md:text-sm text-gray-500">Pending Pay</p>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Desktop Filters */}
|
||||||
<Card className="p-4 mb-6">
|
<Card className="p-4 mb-6 hidden md:block">
|
||||||
<div className="flex items-center gap-2 mb-4">
|
<div className="flex items-center gap-2 mb-4">
|
||||||
<FunnelIcon className="w-5 h-5 text-gray-500" />
|
<FunnelIcon className="w-5 h-5 text-gray-500" />
|
||||||
<span className="font-medium">Filters</span>
|
<span className="font-medium">Filters</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Search</label>
|
||||||
|
<div className="relative">
|
||||||
|
<MagnifyingGlassIcon className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Name or email..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="w-full pl-9 pr-3 py-2 rounded-btn border border-secondary-light-gray text-sm focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Event</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">Event</label>
|
||||||
<select
|
<select value={selectedEvent} onChange={(e) => setSelectedEvent(e.target.value)}
|
||||||
value={selectedEvent}
|
className="w-full px-3 py-2 rounded-btn border border-secondary-light-gray text-sm">
|
||||||
onChange={(e) => setSelectedEvent(e.target.value)}
|
|
||||||
className="w-full px-3 py-2 rounded-btn border border-secondary-light-gray"
|
|
||||||
>
|
|
||||||
<option value="">All Events</option>
|
<option value="">All Events</option>
|
||||||
{events.map((event) => (
|
{events.map((event) => (
|
||||||
<option key={event.id} value={event.id}>{event.title}</option>
|
<option key={event.id} value={event.id}>{event.title}</option>
|
||||||
@@ -277,11 +291,8 @@ export default function AdminBookingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Booking Status</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">Booking Status</label>
|
||||||
<select
|
<select value={selectedStatus} onChange={(e) => setSelectedStatus(e.target.value)}
|
||||||
value={selectedStatus}
|
className="w-full px-3 py-2 rounded-btn border border-secondary-light-gray text-sm">
|
||||||
onChange={(e) => setSelectedStatus(e.target.value)}
|
|
||||||
className="w-full px-3 py-2 rounded-btn border border-secondary-light-gray"
|
|
||||||
>
|
|
||||||
<option value="">All Statuses</option>
|
<option value="">All Statuses</option>
|
||||||
<option value="pending">Pending</option>
|
<option value="pending">Pending</option>
|
||||||
<option value="confirmed">Confirmed</option>
|
<option value="confirmed">Confirmed</option>
|
||||||
@@ -291,12 +302,9 @@ export default function AdminBookingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Payment Status</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">Payment Status</label>
|
||||||
<select
|
<select value={selectedPaymentStatus} onChange={(e) => setSelectedPaymentStatus(e.target.value)}
|
||||||
value={selectedPaymentStatus}
|
className="w-full px-3 py-2 rounded-btn border border-secondary-light-gray text-sm">
|
||||||
onChange={(e) => setSelectedPaymentStatus(e.target.value)}
|
<option value="">All Payments</option>
|
||||||
className="w-full px-3 py-2 rounded-btn border border-secondary-light-gray"
|
|
||||||
>
|
|
||||||
<option value="">All Payment Statuses</option>
|
|
||||||
<option value="pending">Pending</option>
|
<option value="pending">Pending</option>
|
||||||
<option value="paid">Paid</option>
|
<option value="paid">Paid</option>
|
||||||
<option value="refunded">Refunded</option>
|
<option value="refunded">Refunded</option>
|
||||||
@@ -304,26 +312,66 @@ export default function AdminBookingsPage() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<div className="mt-3 text-xs text-gray-500 flex items-center gap-2">
|
||||||
|
<span>Showing {sortedTickets.length} of {tickets.length}</span>
|
||||||
|
<button onClick={clearFilters} className="text-primary-yellow hover:underline">Clear</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Bookings List */}
|
{/* Mobile Toolbar */}
|
||||||
<Card className="overflow-hidden">
|
<div className="md:hidden space-y-2 mb-4">
|
||||||
|
<div className="relative">
|
||||||
|
<MagnifyingGlassIcon className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search name or email..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="w-full pl-9 pr-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="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]',
|
||||||
|
hasActiveFilters
|
||||||
|
? 'border-primary-yellow bg-yellow-50 text-primary-dark'
|
||||||
|
: 'border-secondary-light-gray text-gray-600'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<FunnelIcon className="w-4 h-4" />
|
||||||
|
Filters
|
||||||
|
{hasActiveFilters && <span className="text-xs">({sortedTickets.length})</span>}
|
||||||
|
</button>
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<button onClick={clearFilters} className="text-xs text-primary-yellow ml-auto min-h-[44px] flex items-center">
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop: Table */}
|
||||||
|
<Card className="overflow-hidden hidden md:block">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead className="bg-secondary-gray">
|
<thead className="bg-secondary-gray">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Attendee</th>
|
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Attendee</th>
|
||||||
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Event</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-6 py-3 text-sm font-medium text-gray-600">Payment</th>
|
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Payment</th>
|
||||||
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Status</th>
|
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
||||||
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Booked</th>
|
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Booked</th>
|
||||||
<th className="text-right px-6 py-3 text-sm font-medium text-gray-600">Actions</th>
|
<th className="text-right px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-secondary-light-gray">
|
<tbody className="divide-y divide-secondary-light-gray">
|
||||||
{sortedTickets.length === 0 ? (
|
{sortedTickets.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={6} className="px-6 py-12 text-center text-gray-500">
|
<td colSpan={6} className="px-4 py-12 text-center text-gray-500 text-sm">
|
||||||
No bookings found.
|
No bookings found.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -332,118 +380,64 @@ export default function AdminBookingsPage() {
|
|||||||
const bookingInfo = getBookingInfo(ticket);
|
const bookingInfo = getBookingInfo(ticket);
|
||||||
return (
|
return (
|
||||||
<tr key={ticket.id} className="hover:bg-gray-50">
|
<tr key={ticket.id} className="hover:bg-gray-50">
|
||||||
<td className="px-6 py-4">
|
<td className="px-4 py-3">
|
||||||
<div className="space-y-1">
|
<p className="font-medium text-sm">{ticket.attendeeFirstName} {ticket.attendeeLastName || ''}</p>
|
||||||
<div className="flex items-center gap-2">
|
<p className="text-xs text-gray-500 truncate max-w-[200px]">{ticket.attendeeEmail || 'N/A'}</p>
|
||||||
<UserIcon className="w-4 h-4 text-gray-400" />
|
{ticket.attendeePhone && <p className="text-xs text-gray-400">{ticket.attendeePhone}</p>}
|
||||||
<span className="font-medium">{ticket.attendeeFirstName} {ticket.attendeeLastName || ''}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
|
||||||
<EnvelopeIcon className="w-4 h-4" />
|
|
||||||
<span>{ticket.attendeeEmail || 'N/A'}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
|
||||||
<PhoneIcon className="w-4 h-4" />
|
|
||||||
<span>{ticket.attendeePhone || 'N/A'}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4">
|
<td className="px-4 py-3">
|
||||||
<span className="text-sm">
|
<span className="text-sm truncate max-w-[150px] block">
|
||||||
{ticket.event?.title || events.find(e => e.id === ticket.eventId)?.title || 'Unknown'}
|
{ticket.event?.title || events.find(e => e.id === ticket.eventId)?.title || 'Unknown'}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4">
|
<td className="px-4 py-3">
|
||||||
<div className="space-y-1">
|
<span className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${getPaymentStatusColor(ticket.payment?.status || 'pending')}`}>
|
||||||
<span className={`inline-block px-2 py-1 rounded-full text-xs font-medium ${getPaymentStatusColor(ticket.payment?.status || 'pending')}`}>
|
|
||||||
{ticket.payment?.status || 'pending'}
|
{ticket.payment?.status || 'pending'}
|
||||||
</span>
|
</span>
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-xs text-gray-500 mt-0.5">{getPaymentMethodLabel(ticket.payment?.provider || 'cash')}</p>
|
||||||
{getPaymentMethodLabel(ticket.payment?.provider || 'cash')}
|
|
||||||
</p>
|
|
||||||
{ticket.payment && (
|
{ticket.payment && (
|
||||||
<div>
|
<p className="text-xs font-medium mt-0.5">{bookingInfo.bookingTotal.toLocaleString()} {ticket.payment.currency}</p>
|
||||||
<p className="text-sm font-medium">
|
|
||||||
{bookingInfo.bookingTotal.toLocaleString()} {ticket.payment.currency}
|
|
||||||
</p>
|
|
||||||
{bookingInfo.ticketCount > 1 && (
|
|
||||||
<p className="text-xs text-purple-600 mt-1">
|
|
||||||
📦 {bookingInfo.ticketCount} × {Number(ticket.payment.amount).toLocaleString()} {ticket.payment.currency}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4">
|
<td className="px-4 py-3">
|
||||||
<span className={`inline-block px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(ticket.status)}`}>
|
<span className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${getStatusColor(ticket.status)}`}>
|
||||||
{ticket.status}
|
{ticket.status.replace('_', ' ')}
|
||||||
</span>
|
</span>
|
||||||
{ticket.qrCode && (
|
|
||||||
<p className="text-xs text-gray-400 mt-1 font-mono">{ticket.qrCode}</p>
|
|
||||||
)}
|
|
||||||
{ticket.bookingId && (
|
{ticket.bookingId && (
|
||||||
<p className="text-xs text-purple-600 mt-1" title="Part of multi-ticket booking">
|
<p className="text-[10px] text-purple-600 mt-0.5">Group Booking</p>
|
||||||
📦 Group Booking
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 text-sm text-gray-600">
|
<td className="px-4 py-3 text-xs text-gray-500">
|
||||||
{formatDate(ticket.createdAt)}
|
{formatDate(ticket.createdAt)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4">
|
<td className="px-4 py-3">
|
||||||
<div className="flex items-center justify-end gap-2">
|
<div className="flex items-center justify-end gap-1">
|
||||||
{/* Mark as Paid (for pending payments) */}
|
|
||||||
{ticket.status === 'pending' && ticket.payment?.status === 'pending' && (
|
{ticket.status === 'pending' && ticket.payment?.status === 'pending' && (
|
||||||
<Button
|
<Button size="sm" variant="outline" onClick={() => handleMarkPaid(ticket.id)}
|
||||||
size="sm"
|
isLoading={processing === ticket.id} className="text-xs px-2 py-1">
|
||||||
variant="ghost"
|
|
||||||
onClick={() => handleMarkPaid(ticket.id)}
|
|
||||||
isLoading={processing === ticket.id}
|
|
||||||
className="text-green-600 hover:bg-green-50"
|
|
||||||
>
|
|
||||||
<CurrencyDollarIcon className="w-4 h-4 mr-1" />
|
|
||||||
Mark Paid
|
Mark Paid
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Check-in (for confirmed tickets) */}
|
|
||||||
{ticket.status === 'confirmed' && (
|
{ticket.status === 'confirmed' && (
|
||||||
<Button
|
<Button size="sm" onClick={() => handleCheckin(ticket.id)}
|
||||||
size="sm"
|
isLoading={processing === ticket.id} className="text-xs px-2 py-1">
|
||||||
variant="ghost"
|
|
||||||
onClick={() => handleCheckin(ticket.id)}
|
|
||||||
isLoading={processing === ticket.id}
|
|
||||||
className="text-blue-600 hover:bg-blue-50"
|
|
||||||
>
|
|
||||||
<CheckCircleIcon className="w-4 h-4 mr-1" />
|
|
||||||
Check In
|
Check In
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Cancel (for pending/confirmed) */}
|
|
||||||
{(ticket.status === 'pending' || ticket.status === 'confirmed') && (
|
{(ticket.status === 'pending' || ticket.status === 'confirmed') && (
|
||||||
<Button
|
<MoreMenu>
|
||||||
size="sm"
|
<DropdownItem onClick={() => handleCancel(ticket.id)} className="text-red-600">
|
||||||
variant="ghost"
|
<XCircleIcon className="w-4 h-4 mr-2" /> Cancel
|
||||||
onClick={() => handleCancel(ticket.id)}
|
</DropdownItem>
|
||||||
isLoading={processing === ticket.id}
|
</MoreMenu>
|
||||||
className="text-red-600 hover:bg-red-50"
|
|
||||||
>
|
|
||||||
<XCircleIcon className="w-4 h-4 mr-1" />
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{ticket.status === 'checked_in' && (
|
{ticket.status === 'checked_in' && (
|
||||||
<span className="text-sm text-green-600 flex items-center gap-1">
|
<span className="text-xs text-green-600 flex items-center gap-1">
|
||||||
<CheckCircleIcon className="w-4 h-4" />
|
<CheckCircleIcon className="w-4 h-4" /> Attended
|
||||||
Attended
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{ticket.status === 'cancelled' && (
|
{ticket.status === 'cancelled' && (
|
||||||
<span className="text-sm text-gray-400">Cancelled</span>
|
<span className="text-xs text-gray-400">Cancelled</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -455,6 +449,158 @@ export default function AdminBookingsPage() {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Mobile: Card List */}
|
||||||
|
<div className="md:hidden space-y-2">
|
||||||
|
{sortedTickets.length === 0 ? (
|
||||||
|
<div className="text-center py-10 text-gray-500 text-sm">
|
||||||
|
No bookings found.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
sortedTickets.map((ticket) => {
|
||||||
|
const bookingInfo = getBookingInfo(ticket);
|
||||||
|
const primary = getPrimaryAction(ticket);
|
||||||
|
const eventTitle = ticket.event?.title || events.find(e => e.id === ticket.eventId)?.title || 'Unknown';
|
||||||
|
return (
|
||||||
|
<Card key={ticket.id} className="p-3">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="font-medium text-sm truncate">{ticket.attendeeFirstName} {ticket.attendeeLastName || ''}</p>
|
||||||
|
<p className="text-xs text-gray-500 truncate">{ticket.attendeeEmail || 'N/A'}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||||
|
<span className={clsx('inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium', getStatusColor(ticket.status))}>
|
||||||
|
{ticket.status.replace('_', ' ')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex items-center gap-2 text-xs text-gray-500">
|
||||||
|
<span className="truncate">{eventTitle}</span>
|
||||||
|
<span className="text-gray-300">|</span>
|
||||||
|
<span className={clsx('inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium', getPaymentStatusColor(ticket.payment?.status || 'pending'))}>
|
||||||
|
{ticket.payment?.status || 'pending'}
|
||||||
|
</span>
|
||||||
|
{ticket.payment && (
|
||||||
|
<>
|
||||||
|
<span className="text-gray-300">|</span>
|
||||||
|
<span className="font-medium text-gray-700">{bookingInfo.bookingTotal.toLocaleString()} {ticket.payment.currency}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{ticket.bookingId && (
|
||||||
|
<p className="text-[10px] text-purple-600 mt-1">{bookingInfo.ticketCount} tickets - Group Booking</p>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center justify-between mt-2 pt-2 border-t border-gray-100">
|
||||||
|
<p className="text-[10px] text-gray-400">{formatDate(ticket.createdAt)}</p>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{primary && (
|
||||||
|
<Button size="sm" variant={ticket.status === 'confirmed' ? 'primary' : 'outline'}
|
||||||
|
onClick={primary.onClick} isLoading={processing === ticket.id}
|
||||||
|
className="text-xs px-2.5 py-1.5 min-h-[36px]">
|
||||||
|
{primary.label}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{(ticket.status === 'pending' || ticket.status === 'confirmed') && (
|
||||||
|
<MoreMenu>
|
||||||
|
{ticket.status === 'pending' && ticket.payment?.status === 'pending' && !primary && (
|
||||||
|
<DropdownItem onClick={() => handleMarkPaid(ticket.id)}>
|
||||||
|
<CurrencyDollarIcon className="w-4 h-4 mr-2" /> Mark Paid
|
||||||
|
</DropdownItem>
|
||||||
|
)}
|
||||||
|
<DropdownItem onClick={() => handleCancel(ticket.id)} className="text-red-600">
|
||||||
|
<XCircleIcon className="w-4 h-4 mr-2" /> Cancel Booking
|
||||||
|
</DropdownItem>
|
||||||
|
</MoreMenu>
|
||||||
|
)}
|
||||||
|
{ticket.status === 'checked_in' && (
|
||||||
|
<span className="text-[10px] text-green-600 flex items-center gap-1">
|
||||||
|
<CheckCircleIcon className="w-3.5 h-3.5" /> Attended
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{ticket.status === 'cancelled' && (
|
||||||
|
<span className="text-[10px] text-gray-400">Cancelled</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile Filter BottomSheet */}
|
||||||
|
<BottomSheet open={mobileFilterOpen} onClose={() => setMobileFilterOpen(false)} title="Filters">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Event</label>
|
||||||
|
<select value={selectedEvent} onChange={(e) => setSelectedEvent(e.target.value)}
|
||||||
|
className="w-full px-3 py-2.5 rounded-btn border border-secondary-light-gray text-sm min-h-[44px]">
|
||||||
|
<option value="">All Events</option>
|
||||||
|
{events.map((event) => (
|
||||||
|
<option key={event.id} value={event.id}>{event.title}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Booking Status</label>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{[
|
||||||
|
{ value: '', label: 'All Statuses' },
|
||||||
|
{ value: 'pending', label: `Pending (${stats.pending})` },
|
||||||
|
{ value: 'confirmed', label: `Confirmed (${stats.confirmed})` },
|
||||||
|
{ value: 'checked_in', label: `Checked In (${stats.checkedIn})` },
|
||||||
|
{ value: 'cancelled', label: `Cancelled (${stats.cancelled})` },
|
||||||
|
].map((opt) => (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
onClick={() => setSelectedStatus(opt.value)}
|
||||||
|
className={clsx(
|
||||||
|
'w-full text-left px-3 py-2.5 rounded-btn text-sm min-h-[44px] flex items-center justify-between',
|
||||||
|
selectedStatus === opt.value ? 'bg-yellow-50 text-primary-dark font-medium' : 'hover:bg-gray-50'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
{selectedStatus === opt.value && <CheckCircleIcon className="w-4 h-4 text-primary-yellow" />}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Payment Status</label>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{[
|
||||||
|
{ value: '', label: 'All Payments' },
|
||||||
|
{ value: 'pending', label: 'Pending' },
|
||||||
|
{ value: 'paid', label: 'Paid' },
|
||||||
|
{ value: 'refunded', label: 'Refunded' },
|
||||||
|
{ value: 'failed', label: 'Failed' },
|
||||||
|
].map((opt) => (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
onClick={() => setSelectedPaymentStatus(opt.value)}
|
||||||
|
className={clsx(
|
||||||
|
'w-full text-left px-3 py-2.5 rounded-btn text-sm min-h-[44px] flex items-center justify-between',
|
||||||
|
selectedPaymentStatus === opt.value ? 'bg-yellow-50 text-primary-dark font-medium' : 'hover:bg-gray-50'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
{selectedPaymentStatus === opt.value && <CheckCircleIcon className="w-4 h-4 text-primary-yellow" />}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 pt-2">
|
||||||
|
<Button variant="outline" onClick={() => { clearFilters(); setMobileFilterOpen(false); }} className="flex-1 min-h-[44px]">
|
||||||
|
Clear All
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => setMobileFilterOpen(false)} className="flex-1 min-h-[44px]">
|
||||||
|
Apply
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</BottomSheet>
|
||||||
|
|
||||||
|
<AdminMobileStyles />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { emailsApi, EmailTemplate, EmailLog, EmailStats } from '@/lib/api';
|
|||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import Input from '@/components/ui/Input';
|
import Input from '@/components/ui/Input';
|
||||||
|
import { MoreMenu, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
|
||||||
import {
|
import {
|
||||||
EnvelopeIcon,
|
EnvelopeIcon,
|
||||||
PencilIcon,
|
PencilIcon,
|
||||||
@@ -18,6 +19,7 @@ import {
|
|||||||
ExclamationTriangleIcon,
|
ExclamationTriangleIcon,
|
||||||
ChevronLeftIcon,
|
ChevronLeftIcon,
|
||||||
ChevronRightIcon,
|
ChevronRightIcon,
|
||||||
|
XMarkIcon,
|
||||||
} from '@heroicons/react/24/outline';
|
} from '@heroicons/react/24/outline';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
@@ -382,7 +384,7 @@ export default function AdminEmailsPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<h1 className="text-2xl font-bold text-primary-dark">Email Center</h1>
|
<h1 className="text-xl md:text-2xl font-bold text-primary-dark">Email Center</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats Cards */}
|
{/* Stats Cards */}
|
||||||
@@ -436,18 +438,15 @@ export default function AdminEmailsPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div className="border-b border-secondary-light-gray mb-6">
|
<div className="border-b border-secondary-light-gray mb-6 overflow-x-auto scrollbar-hide">
|
||||||
<nav className="flex gap-6">
|
<nav className="flex gap-4 md:gap-6 min-w-max">
|
||||||
{(['templates', 'compose', 'logs'] as TabType[]).map((tab) => (
|
{(['templates', 'compose', 'logs'] as TabType[]).map((tab) => (
|
||||||
<button
|
<button
|
||||||
key={tab}
|
key={tab}
|
||||||
onClick={() => setActiveTab(tab)}
|
onClick={() => setActiveTab(tab)}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'py-3 px-1 border-b-2 font-medium text-sm transition-colors relative',
|
'py-3 px-1 border-b-2 font-medium text-sm transition-colors relative whitespace-nowrap min-h-[44px]',
|
||||||
{
|
activeTab === tab ? 'border-primary-yellow text-primary-dark' : 'border-transparent text-gray-500 hover:text-gray-700'
|
||||||
'border-primary-yellow text-primary-dark': activeTab === tab,
|
|
||||||
'border-transparent text-gray-500 hover:text-gray-700': activeTab !== tab,
|
|
||||||
}
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{tab === 'templates' ? 'Templates' : tab === 'compose' ? 'Compose' : 'Email Logs'}
|
{tab === 'templates' ? 'Templates' : tab === 'compose' ? 'Compose' : 'Email Logs'}
|
||||||
@@ -499,31 +498,36 @@ export default function AdminEmailsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-1">
|
||||||
<button
|
<button onClick={() => handlePreviewTemplate(template)}
|
||||||
onClick={() => handlePreviewTemplate(template)}
|
className="p-2 hover:bg-gray-100 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center" title="Preview">
|
||||||
className="p-2 hover:bg-gray-100 rounded-btn"
|
|
||||||
title="Preview"
|
|
||||||
>
|
|
||||||
<EyeIcon className="w-5 h-5" />
|
<EyeIcon className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button onClick={() => handleEditTemplate(template)}
|
||||||
onClick={() => handleEditTemplate(template)}
|
className="p-2 hover:bg-gray-100 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center hidden md:flex" title="Edit">
|
||||||
className="p-2 hover:bg-gray-100 rounded-btn"
|
|
||||||
title="Edit"
|
|
||||||
>
|
|
||||||
<PencilIcon className="w-5 h-5" />
|
<PencilIcon className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
|
<div className="hidden md:block">
|
||||||
{!template.isSystem && (
|
{!template.isSystem && (
|
||||||
<button
|
<button onClick={() => handleDeleteTemplate(template.id)}
|
||||||
onClick={() => handleDeleteTemplate(template.id)}
|
className="p-2 hover:bg-red-100 text-red-600 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center" title="Delete">
|
||||||
className="p-2 hover:bg-red-100 text-red-600 rounded-btn"
|
|
||||||
title="Delete"
|
|
||||||
>
|
|
||||||
<XCircleIcon className="w-5 h-5" />
|
<XCircleIcon className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="md:hidden">
|
||||||
|
<MoreMenu>
|
||||||
|
<DropdownItem onClick={() => handleEditTemplate(template)}>
|
||||||
|
<PencilIcon className="w-4 h-4 mr-2" /> Edit
|
||||||
|
</DropdownItem>
|
||||||
|
{!template.isSystem && (
|
||||||
|
<DropdownItem onClick={() => handleDeleteTemplate(template.id)} className="text-red-600">
|
||||||
|
<XCircleIcon className="w-4 h-4 mr-2" /> Delete
|
||||||
|
</DropdownItem>
|
||||||
|
)}
|
||||||
|
</MoreMenu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
@@ -564,7 +568,7 @@ export default function AdminEmailsPage() {
|
|||||||
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray"
|
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray"
|
||||||
>
|
>
|
||||||
<option value="">Choose an event</option>
|
<option value="">Choose an event</option>
|
||||||
{events.filter(e => e.status === 'published').map((event) => (
|
{events.filter(e => e.status === 'published' || e.status === 'unlisted').map((event) => (
|
||||||
<option key={event.id} value={event.id}>
|
<option key={event.id} value={event.id}>
|
||||||
{event.title} - {new Date(event.startDatetime).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { timeZone: 'America/Asuncion' })}
|
{event.title} - {new Date(event.startDatetime).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { timeZone: 'America/Asuncion' })}
|
||||||
</option>
|
</option>
|
||||||
@@ -635,13 +639,17 @@ export default function AdminEmailsPage() {
|
|||||||
|
|
||||||
{/* Recipient Preview Modal */}
|
{/* Recipient Preview Modal */}
|
||||||
{showRecipientPreview && (
|
{showRecipientPreview && (
|
||||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
<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 max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
|
<Card className="w-full md:max-w-2xl max-h-[80vh] overflow-hidden flex flex-col rounded-t-2xl md:rounded-card">
|
||||||
<div className="p-4 border-b border-secondary-light-gray">
|
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray">
|
||||||
<h2 className="text-lg font-bold">Recipient Preview</h2>
|
<div>
|
||||||
<p className="text-sm text-gray-500">
|
<h2 className="text-base font-bold">Recipient Preview</h2>
|
||||||
{previewRecipients.length} recipient(s) will receive this email
|
<p className="text-xs text-gray-500">{previewRecipients.length} recipient(s)</p>
|
||||||
</p>
|
</div>
|
||||||
|
<button onClick={() => setShowRecipientPreview(false)}
|
||||||
|
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>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-4">
|
<div className="flex-1 overflow-y-auto p-4">
|
||||||
@@ -675,14 +683,10 @@ export default function AdminEmailsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-4 border-t border-secondary-light-gray flex gap-3">
|
<div className="p-4 border-t border-secondary-light-gray flex gap-3">
|
||||||
<Button
|
<Button onClick={handleSendEmail} isLoading={sending} disabled={previewRecipients.length === 0} className="flex-1 min-h-[44px]">
|
||||||
onClick={handleSendEmail}
|
Send to {previewRecipients.length}
|
||||||
isLoading={sending}
|
|
||||||
disabled={previewRecipients.length === 0}
|
|
||||||
>
|
|
||||||
Send to {previewRecipients.length} Recipients
|
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" onClick={() => setShowRecipientPreview(false)}>
|
<Button variant="outline" onClick={() => setShowRecipientPreview(false)} className="flex-1 min-h-[44px]">
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -695,51 +699,37 @@ export default function AdminEmailsPage() {
|
|||||||
{/* Logs Tab */}
|
{/* Logs Tab */}
|
||||||
{activeTab === 'logs' && (
|
{activeTab === 'logs' && (
|
||||||
<div>
|
<div>
|
||||||
<Card className="overflow-hidden">
|
{/* Desktop: Table */}
|
||||||
|
<Card className="overflow-hidden hidden md:block">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead className="bg-secondary-gray">
|
<thead className="bg-secondary-gray">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Status</th>
|
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
||||||
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Recipient</th>
|
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Recipient</th>
|
||||||
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Subject</th>
|
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Subject</th>
|
||||||
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Sent</th>
|
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Sent</th>
|
||||||
<th className="text-right px-6 py-3 text-sm font-medium text-gray-600">Actions</th>
|
<th className="text-right px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-secondary-light-gray">
|
<tbody className="divide-y divide-secondary-light-gray">
|
||||||
{logs.length === 0 ? (
|
{logs.length === 0 ? (
|
||||||
<tr>
|
<tr><td colSpan={5} className="px-4 py-12 text-center text-gray-500 text-sm">No emails sent yet</td></tr>
|
||||||
<td colSpan={5} className="px-6 py-12 text-center text-gray-500">
|
|
||||||
No emails sent yet
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : (
|
) : (
|
||||||
logs.map((log) => (
|
logs.map((log) => (
|
||||||
<tr key={log.id} className="hover:bg-gray-50">
|
<tr key={log.id} className="hover:bg-gray-50">
|
||||||
<td className="px-6 py-4">
|
<td className="px-4 py-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">{getStatusIcon(log.status)}<span className="capitalize text-sm">{log.status}</span></div>
|
||||||
{getStatusIcon(log.status)}
|
|
||||||
<span className="capitalize text-sm">{log.status}</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4">
|
<td className="px-4 py-3">
|
||||||
<p className="font-medium text-sm">{log.recipientName || 'Unknown'}</p>
|
<p className="font-medium text-sm">{log.recipientName || 'Unknown'}</p>
|
||||||
<p className="text-sm text-gray-500">{log.recipientEmail}</p>
|
<p className="text-xs text-gray-500">{log.recipientEmail}</p>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 max-w-xs">
|
<td className="px-4 py-3 max-w-xs"><p className="text-sm truncate">{log.subject}</p></td>
|
||||||
<p className="text-sm truncate">{log.subject}</p>
|
<td className="px-4 py-3 text-xs text-gray-500">{formatDate(log.sentAt || log.createdAt)}</td>
|
||||||
</td>
|
<td className="px-4 py-3">
|
||||||
<td className="px-6 py-4 text-sm text-gray-600">
|
<div className="flex items-center justify-end">
|
||||||
{formatDate(log.sentAt || log.createdAt)}
|
<button onClick={() => setSelectedLog(log)} className="p-2 hover:bg-gray-100 rounded-btn" title="View">
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
<div className="flex items-center justify-end gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => setSelectedLog(log)}
|
|
||||||
className="p-2 hover:bg-gray-100 rounded-btn"
|
|
||||||
title="View Email"
|
|
||||||
>
|
|
||||||
<EyeIcon className="w-4 h-4" />
|
<EyeIcon className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -750,46 +740,69 @@ export default function AdminEmailsPage() {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pagination */}
|
|
||||||
{logsTotal > 20 && (
|
{logsTotal > 20 && (
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-t border-secondary-light-gray">
|
<div className="flex items-center justify-between px-4 py-3 border-t border-secondary-light-gray">
|
||||||
<p className="text-sm text-gray-600">
|
<p className="text-sm text-gray-600">Showing {logsOffset + 1}-{Math.min(logsOffset + 20, logsTotal)} of {logsTotal}</p>
|
||||||
Showing {logsOffset + 1}-{Math.min(logsOffset + 20, logsTotal)} of {logsTotal}
|
|
||||||
</p>
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button variant="outline" size="sm" disabled={logsOffset === 0} onClick={() => setLogsOffset(Math.max(0, logsOffset - 20))}>
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={logsOffset === 0}
|
|
||||||
onClick={() => setLogsOffset(Math.max(0, logsOffset - 20))}
|
|
||||||
>
|
|
||||||
<ChevronLeftIcon className="w-4 h-4" />
|
<ChevronLeftIcon className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button variant="outline" size="sm" disabled={logsOffset + 20 >= logsTotal} onClick={() => setLogsOffset(logsOffset + 20)}>
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={logsOffset + 20 >= logsTotal}
|
|
||||||
onClick={() => setLogsOffset(logsOffset + 20)}
|
|
||||||
>
|
|
||||||
<ChevronRightIcon className="w-4 h-4" />
|
<ChevronRightIcon className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Mobile: Card List */}
|
||||||
|
<div className="md:hidden space-y-2">
|
||||||
|
{logs.length === 0 ? (
|
||||||
|
<div className="text-center py-10 text-gray-500 text-sm">No emails sent yet</div>
|
||||||
|
) : (
|
||||||
|
logs.map((log) => (
|
||||||
|
<Card key={log.id} className="p-3" onClick={() => setSelectedLog(log)}>
|
||||||
|
<div className="flex items-start gap-2.5">
|
||||||
|
<div className="mt-0.5 flex-shrink-0">{getStatusIcon(log.status)}</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="font-medium text-sm truncate">{log.subject}</p>
|
||||||
|
<p className="text-xs text-gray-500 truncate">{log.recipientName || 'Unknown'} <{log.recipientEmail}></p>
|
||||||
|
<p className="text-[10px] text-gray-400 mt-1">{formatDate(log.sentAt || log.createdAt)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
{logsTotal > 20 && (
|
||||||
|
<div className="flex items-center justify-between py-3">
|
||||||
|
<p className="text-xs text-gray-500">{logsOffset + 1}-{Math.min(logsOffset + 20, logsTotal)} of {logsTotal}</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" size="sm" disabled={logsOffset === 0} onClick={() => setLogsOffset(Math.max(0, logsOffset - 20))} className="min-h-[44px]">
|
||||||
|
<ChevronLeftIcon className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" disabled={logsOffset + 20 >= logsTotal} onClick={() => setLogsOffset(logsOffset + 20)} className="min-h-[44px]">
|
||||||
|
<ChevronRightIcon className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Template Form Modal */}
|
{/* Template Form Modal */}
|
||||||
{showTemplateForm && (
|
{showTemplateForm && (
|
||||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
<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 max-w-4xl max-h-[90vh] overflow-y-auto p-6">
|
<Card className="w-full md:max-w-4xl max-h-[90vh] flex flex-col overflow-hidden rounded-t-2xl md:rounded-card">
|
||||||
<h2 className="text-xl font-bold mb-6">
|
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray flex-shrink-0">
|
||||||
{editingTemplate ? 'Edit Template' : 'Create Template'}
|
<h2 className="text-base font-bold">{editingTemplate ? 'Edit Template' : 'Create Template'}</h2>
|
||||||
</h2>
|
<button onClick={() => { setShowTemplateForm(false); resetTemplateForm(); }}
|
||||||
|
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={handleSaveTemplate} className="space-y-4">
|
<form onSubmit={handleSaveTemplate} className="p-4 space-y-4 overflow-y-auto flex-1 min-h-0">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<Input
|
<Input
|
||||||
label="Template Name"
|
label="Template Name"
|
||||||
@@ -873,14 +886,10 @@ export default function AdminEmailsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-3 pt-4">
|
<div className="flex gap-3 pt-4">
|
||||||
<Button type="submit" isLoading={saving}>
|
<Button type="submit" isLoading={saving} className="flex-1 min-h-[44px]">
|
||||||
{editingTemplate ? 'Update Template' : 'Create Template'}
|
{editingTemplate ? 'Update' : 'Create'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button type="button" variant="outline" onClick={() => { setShowTemplateForm(false); resetTemplateForm(); }} className="flex-1 min-h-[44px]">
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => { setShowTemplateForm(false); resetTemplateForm(); }}
|
|
||||||
>
|
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -891,16 +900,17 @@ export default function AdminEmailsPage() {
|
|||||||
|
|
||||||
{/* Preview Modal */}
|
{/* Preview Modal */}
|
||||||
{previewHtml && (
|
{previewHtml && (
|
||||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
<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 max-w-3xl max-h-[90vh] overflow-hidden flex flex-col">
|
<Card className="w-full md:max-w-3xl max-h-[90vh] overflow-hidden flex flex-col rounded-t-2xl md:rounded-card">
|
||||||
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray">
|
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray">
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<h2 className="text-lg font-bold">Email Preview</h2>
|
<h2 className="text-base font-bold">Email Preview</h2>
|
||||||
<p className="text-sm text-gray-500">Subject: {previewSubject}</p>
|
<p className="text-xs text-gray-500 truncate">Subject: {previewSubject}</p>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" size="sm" onClick={() => setPreviewHtml(null)}>
|
<button onClick={() => setPreviewHtml(null)}
|
||||||
Close
|
className="p-2 hover:bg-gray-100 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center flex-shrink-0">
|
||||||
</Button>
|
<XMarkIcon className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
<iframe
|
<iframe
|
||||||
@@ -914,23 +924,26 @@ export default function AdminEmailsPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Log Detail Modal */}
|
{/* Log Detail Modal */}
|
||||||
|
<AdminMobileStyles />
|
||||||
|
|
||||||
{selectedLog && (
|
{selectedLog && (
|
||||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
<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 max-w-3xl max-h-[90vh] overflow-hidden flex flex-col">
|
<Card className="w-full md:max-w-3xl max-h-[90vh] overflow-hidden flex flex-col rounded-t-2xl md:rounded-card">
|
||||||
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray">
|
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray">
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<h2 className="text-lg font-bold">Email Details</h2>
|
<h2 className="text-base font-bold">Email Details</h2>
|
||||||
<div className="flex items-center gap-2 mt-1">
|
<div className="flex items-center gap-2 mt-1 flex-wrap">
|
||||||
{getStatusIcon(selectedLog.status)}
|
{getStatusIcon(selectedLog.status)}
|
||||||
<span className="capitalize text-sm">{selectedLog.status}</span>
|
<span className="capitalize text-sm">{selectedLog.status}</span>
|
||||||
{selectedLog.errorMessage && (
|
{selectedLog.errorMessage && (
|
||||||
<span className="text-sm text-red-500">- {selectedLog.errorMessage}</span>
|
<span className="text-xs text-red-500">- {selectedLog.errorMessage}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" size="sm" onClick={() => setSelectedLog(null)}>
|
<button onClick={() => setSelectedLog(null)}
|
||||||
Close
|
className="p-2 hover:bg-gray-100 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center flex-shrink-0">
|
||||||
</Button>
|
<XMarkIcon className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 space-y-2 border-b border-secondary-light-gray bg-gray-50">
|
<div className="p-4 space-y-2 border-b border-secondary-light-gray bg-gray-50">
|
||||||
<p><strong>To:</strong> {selectedLog.recipientName} <{selectedLog.recipientEmail}></p>
|
<p><strong>To:</strong> {selectedLog.recipientName} <{selectedLog.recipientEmail}></p>
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
|
||||||
import { useParams, useRouter } from 'next/navigation';
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useLanguage } from '@/context/LanguageContext';
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
import { eventsApi, ticketsApi, emailsApi, paymentOptionsApi, adminApi, Event, Ticket, EmailTemplate, PaymentOptionsConfig } from '@/lib/api';
|
import { eventsApi, ticketsApi, emailsApi, paymentOptionsApi, adminApi, Event, Ticket, EmailTemplate, PaymentOptionsConfig } from '@/lib/api';
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
|
import { Dropdown, DropdownItem, BottomSheet, MoreMenu, AdminMobileStyles } from '@/components/admin/MobileComponents';
|
||||||
import {
|
import {
|
||||||
ArrowLeftIcon,
|
ArrowLeftIcon,
|
||||||
CalendarIcon,
|
CalendarIcon,
|
||||||
@@ -44,160 +44,6 @@ import clsx from 'clsx';
|
|||||||
|
|
||||||
type TabType = 'overview' | 'attendees' | 'tickets' | 'email' | 'payments';
|
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() {
|
export default function AdminEventDetailPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -2239,23 +2085,7 @@ export default function AdminEventDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* CSS for animations */}
|
<AdminMobileStyles />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,19 +2,22 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { useSearchParams } from 'next/navigation';
|
||||||
import { useLanguage } from '@/context/LanguageContext';
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
import { eventsApi, siteSettingsApi, Event } from '@/lib/api';
|
import { eventsApi, siteSettingsApi, Event } from '@/lib/api';
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import Input from '@/components/ui/Input';
|
import Input from '@/components/ui/Input';
|
||||||
import MediaPicker from '@/components/MediaPicker';
|
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, LinkIcon } from '@heroicons/react/24/outline';
|
||||||
import { StarIcon as StarIconSolid } from '@heroicons/react/24/solid';
|
import { StarIcon as StarIconSolid } from '@heroicons/react/24/solid';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
|
|
||||||
export default function AdminEventsPage() {
|
export default function AdminEventsPage() {
|
||||||
const { t, locale } = useLanguage();
|
const { t, locale } = useLanguage();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
const [events, setEvents] = useState<Event[]>([]);
|
const [events, setEvents] = useState<Event[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [showForm, setShowForm] = useState(false);
|
const [showForm, setShowForm] = useState(false);
|
||||||
@@ -37,7 +40,7 @@ export default function AdminEventsPage() {
|
|||||||
price: number;
|
price: number;
|
||||||
currency: string;
|
currency: string;
|
||||||
capacity: number;
|
capacity: number;
|
||||||
status: 'draft' | 'published' | 'cancelled' | 'completed' | 'archived';
|
status: 'draft' | 'published' | 'unlisted' | 'cancelled' | 'completed' | 'archived';
|
||||||
bannerUrl: string;
|
bannerUrl: string;
|
||||||
externalBookingEnabled: boolean;
|
externalBookingEnabled: boolean;
|
||||||
externalBookingUrl: string;
|
externalBookingUrl: string;
|
||||||
@@ -66,6 +69,14 @@ export default function AdminEventsPage() {
|
|||||||
loadFeaturedEvent();
|
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 () => {
|
const loadEvents = async () => {
|
||||||
try {
|
try {
|
||||||
const { events } = await eventsApi.getAll();
|
const { events } = await eventsApi.getAll();
|
||||||
@@ -82,7 +93,7 @@ export default function AdminEventsPage() {
|
|||||||
const { settings } = await siteSettingsApi.get();
|
const { settings } = await siteSettingsApi.get();
|
||||||
setFeaturedEventId(settings.featuredEventId || null);
|
setFeaturedEventId(settings.featuredEventId || null);
|
||||||
} catch (error) {
|
} 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 = () => {
|
const resetForm = () => {
|
||||||
setFormData({
|
setFormData({
|
||||||
title: '',
|
title: '', titleEs: '', description: '', descriptionEs: '',
|
||||||
titleEs: '',
|
shortDescription: '', shortDescriptionEs: '',
|
||||||
description: '',
|
startDatetime: '', endDatetime: '', location: '', locationUrl: '',
|
||||||
descriptionEs: '',
|
price: 0, currency: 'PYG', capacity: 50, status: 'draft' as const,
|
||||||
shortDescription: '',
|
bannerUrl: '', externalBookingEnabled: false, externalBookingUrl: '',
|
||||||
shortDescriptionEs: '',
|
|
||||||
startDatetime: '',
|
|
||||||
endDatetime: '',
|
|
||||||
location: '',
|
|
||||||
locationUrl: '',
|
|
||||||
price: 0,
|
|
||||||
currency: 'PYG',
|
|
||||||
capacity: 50,
|
|
||||||
status: 'draft' as const,
|
|
||||||
bannerUrl: '',
|
|
||||||
externalBookingEnabled: false,
|
|
||||||
externalBookingUrl: '',
|
|
||||||
});
|
});
|
||||||
setEditingEvent(null);
|
setEditingEvent(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Convert ISO UTC string to local datetime-local format (YYYY-MM-DDTHH:MM)
|
|
||||||
const isoToLocalDatetime = (isoString: string): string => {
|
const isoToLocalDatetime = (isoString: string): string => {
|
||||||
const date = new Date(isoString);
|
const date = new Date(isoString);
|
||||||
const year = date.getFullYear();
|
const year = date.getFullYear();
|
||||||
@@ -135,21 +133,14 @@ export default function AdminEventsPage() {
|
|||||||
|
|
||||||
const handleEdit = (event: Event) => {
|
const handleEdit = (event: Event) => {
|
||||||
setFormData({
|
setFormData({
|
||||||
title: event.title,
|
title: event.title, titleEs: event.titleEs || '',
|
||||||
titleEs: event.titleEs || '',
|
description: event.description, descriptionEs: event.descriptionEs || '',
|
||||||
description: event.description,
|
shortDescription: event.shortDescription || '', shortDescriptionEs: event.shortDescriptionEs || '',
|
||||||
descriptionEs: event.descriptionEs || '',
|
|
||||||
shortDescription: event.shortDescription || '',
|
|
||||||
shortDescriptionEs: event.shortDescriptionEs || '',
|
|
||||||
startDatetime: isoToLocalDatetime(event.startDatetime),
|
startDatetime: isoToLocalDatetime(event.startDatetime),
|
||||||
endDatetime: event.endDatetime ? isoToLocalDatetime(event.endDatetime) : '',
|
endDatetime: event.endDatetime ? isoToLocalDatetime(event.endDatetime) : '',
|
||||||
location: event.location,
|
location: event.location, locationUrl: event.locationUrl || '',
|
||||||
locationUrl: event.locationUrl || '',
|
price: event.price, currency: event.currency, capacity: event.capacity,
|
||||||
price: event.price,
|
status: event.status, bannerUrl: event.bannerUrl || '',
|
||||||
currency: event.currency,
|
|
||||||
capacity: event.capacity,
|
|
||||||
status: event.status,
|
|
||||||
bannerUrl: event.bannerUrl || '',
|
|
||||||
externalBookingEnabled: event.externalBookingEnabled || false,
|
externalBookingEnabled: event.externalBookingEnabled || false,
|
||||||
externalBookingUrl: event.externalBookingUrl || '',
|
externalBookingUrl: event.externalBookingUrl || '',
|
||||||
});
|
});
|
||||||
@@ -160,9 +151,7 @@ export default function AdminEventsPage() {
|
|||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Validate external booking URL if enabled
|
|
||||||
if (formData.externalBookingEnabled && !formData.externalBookingUrl) {
|
if (formData.externalBookingEnabled && !formData.externalBookingUrl) {
|
||||||
toast.error('External booking URL is required when external booking is enabled');
|
toast.error('External booking URL is required when external booking is enabled');
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
@@ -173,27 +162,18 @@ export default function AdminEventsPage() {
|
|||||||
setSaving(false);
|
setSaving(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const eventData = {
|
const eventData = {
|
||||||
title: formData.title,
|
title: formData.title, titleEs: formData.titleEs || undefined,
|
||||||
titleEs: formData.titleEs || undefined,
|
description: formData.description, descriptionEs: formData.descriptionEs || undefined,
|
||||||
description: formData.description,
|
shortDescription: formData.shortDescription || undefined, shortDescriptionEs: formData.shortDescriptionEs || undefined,
|
||||||
descriptionEs: formData.descriptionEs || undefined,
|
|
||||||
shortDescription: formData.shortDescription || undefined,
|
|
||||||
shortDescriptionEs: formData.shortDescriptionEs || undefined,
|
|
||||||
startDatetime: new Date(formData.startDatetime).toISOString(),
|
startDatetime: new Date(formData.startDatetime).toISOString(),
|
||||||
endDatetime: formData.endDatetime ? new Date(formData.endDatetime).toISOString() : undefined,
|
endDatetime: formData.endDatetime ? new Date(formData.endDatetime).toISOString() : undefined,
|
||||||
location: formData.location,
|
location: formData.location, locationUrl: formData.locationUrl || undefined,
|
||||||
locationUrl: formData.locationUrl || undefined,
|
price: formData.price, currency: formData.currency, capacity: formData.capacity,
|
||||||
price: formData.price,
|
status: formData.status, bannerUrl: formData.bannerUrl || undefined,
|
||||||
currency: formData.currency,
|
|
||||||
capacity: formData.capacity,
|
|
||||||
status: formData.status,
|
|
||||||
bannerUrl: formData.bannerUrl || undefined,
|
|
||||||
externalBookingEnabled: formData.externalBookingEnabled,
|
externalBookingEnabled: formData.externalBookingEnabled,
|
||||||
externalBookingUrl: formData.externalBookingEnabled ? formData.externalBookingUrl : undefined,
|
externalBookingUrl: formData.externalBookingEnabled ? formData.externalBookingUrl : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (editingEvent) {
|
if (editingEvent) {
|
||||||
await eventsApi.update(editingEvent.id, eventData);
|
await eventsApi.update(editingEvent.id, eventData);
|
||||||
toast.success('Event updated');
|
toast.success('Event updated');
|
||||||
@@ -201,7 +181,6 @@ export default function AdminEventsPage() {
|
|||||||
await eventsApi.create(eventData);
|
await eventsApi.create(eventData);
|
||||||
toast.success('Event created');
|
toast.success('Event created');
|
||||||
}
|
}
|
||||||
|
|
||||||
setShowForm(false);
|
setShowForm(false);
|
||||||
resetForm();
|
resetForm();
|
||||||
loadEvents();
|
loadEvents();
|
||||||
@@ -214,7 +193,6 @@ export default function AdminEventsPage() {
|
|||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
const handleDelete = async (id: string) => {
|
||||||
if (!confirm('Are you sure you want to delete this event?')) return;
|
if (!confirm('Are you sure you want to delete this event?')) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await eventsApi.delete(id);
|
await eventsApi.delete(id);
|
||||||
toast.success('Event deleted');
|
toast.success('Event deleted');
|
||||||
@@ -234,23 +212,21 @@ export default function AdminEventsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const formatDate = (dateStr: string) => {
|
const formatDate = (dateStr: string) => {
|
||||||
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||||
month: 'short',
|
month: 'short', day: 'numeric', year: 'numeric', timeZone: 'America/Asuncion',
|
||||||
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 getStatusBadge = (status: string) => {
|
||||||
const styles: Record<string, string> = {
|
const styles: Record<string, string> = {
|
||||||
draft: 'badge-gray',
|
draft: 'badge-gray', published: 'badge-success', unlisted: 'badge-warning',
|
||||||
published: 'badge-success',
|
cancelled: 'badge-danger', completed: 'badge-info', archived: 'badge-gray',
|
||||||
cancelled: 'badge-danger',
|
|
||||||
completed: 'badge-info',
|
|
||||||
archived: 'badge-gray',
|
|
||||||
};
|
};
|
||||||
return <span className={`badge ${styles[status] || 'badge-gray'}`}>{status}</span>;
|
return <span className={`badge ${styles[status] || 'badge-gray'}`}>{status}</span>;
|
||||||
};
|
};
|
||||||
@@ -286,8 +262,8 @@ export default function AdminEventsPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<h1 className="text-2xl font-bold text-primary-dark">{t('admin.events.title')}</h1>
|
<h1 className="text-xl md:text-2xl font-bold text-primary-dark">{t('admin.events.title')}</h1>
|
||||||
<Button onClick={() => { resetForm(); setShowForm(true); }}>
|
<Button onClick={() => { resetForm(); setShowForm(true); }} className="hidden md:flex">
|
||||||
<PlusIcon className="w-5 h-5 mr-2" />
|
<PlusIcon className="w-5 h-5 mr-2" />
|
||||||
{t('admin.events.create')}
|
{t('admin.events.create')}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -295,221 +271,148 @@ export default function AdminEventsPage() {
|
|||||||
|
|
||||||
{/* Event Form Modal */}
|
{/* Event Form Modal */}
|
||||||
{showForm && (
|
{showForm && (
|
||||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
<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 max-w-2xl max-h-[90vh] overflow-y-auto p-6">
|
<Card className="w-full md:max-w-2xl max-h-[90vh] flex flex-col overflow-hidden rounded-t-2xl md:rounded-card">
|
||||||
<h2 className="text-xl font-bold mb-6">
|
<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')}
|
{editingEvent ? t('admin.events.edit') : t('admin.events.create')}
|
||||||
</h2>
|
</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">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<Input
|
<Input label="Title (English)" value={formData.title}
|
||||||
label="Title (English)"
|
onChange={(e) => setFormData({ ...formData, title: e.target.value })} required />
|
||||||
value={formData.title}
|
<Input label="Title (Spanish)" value={formData.titleEs}
|
||||||
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, titleEs: e.target.value })} />
|
||||||
required
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label="Title (Spanish)"
|
|
||||||
value={formData.titleEs}
|
|
||||||
onChange={(e) => setFormData({ ...formData, titleEs: e.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">Description (English)</label>
|
<label className="block text-sm font-medium mb-1">Description (English)</label>
|
||||||
<textarea
|
<textarea value={formData.description}
|
||||||
value={formData.description}
|
|
||||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
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"
|
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} required />
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">Description (Spanish)</label>
|
<label className="block text-sm font-medium mb-1">Description (Spanish)</label>
|
||||||
<textarea
|
<textarea value={formData.descriptionEs}
|
||||||
value={formData.descriptionEs}
|
|
||||||
onChange={(e) => setFormData({ ...formData, descriptionEs: e.target.value })}
|
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"
|
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>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">Short Description (English)</label>
|
<label className="block text-sm font-medium mb-1">Short Description (English)</label>
|
||||||
<textarea
|
<textarea value={formData.shortDescription}
|
||||||
value={formData.shortDescription}
|
|
||||||
onChange={(e) => setFormData({ ...formData, shortDescription: e.target.value.slice(0, 300) })}
|
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"
|
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}
|
rows={2} maxLength={300} placeholder="Brief summary for SEO and cards (max 300 chars)" />
|
||||||
maxLength={300}
|
<p className="text-xs text-gray-500 mt-1">{formData.shortDescription.length}/300</p>
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">Short Description (Spanish)</label>
|
<label className="block text-sm font-medium mb-1">Short Description (Spanish)</label>
|
||||||
<textarea
|
<textarea value={formData.shortDescriptionEs}
|
||||||
value={formData.shortDescriptionEs}
|
|
||||||
onChange={(e) => setFormData({ ...formData, shortDescriptionEs: e.target.value.slice(0, 300) })}
|
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"
|
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}
|
rows={2} maxLength={300} placeholder="Resumen breve (máx 300 caracteres)" />
|
||||||
maxLength={300}
|
<p className="text-xs text-gray-500 mt-1">{formData.shortDescriptionEs.length}/300</p>
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<Input
|
<Input label="Start Date & Time" type="datetime-local" value={formData.startDatetime}
|
||||||
label="Start Date & Time"
|
onChange={(e) => setFormData({ ...formData, startDatetime: e.target.value })} required />
|
||||||
type="datetime-local"
|
<Input label="End Date & Time" type="datetime-local" value={formData.endDatetime}
|
||||||
value={formData.startDatetime}
|
onChange={(e) => setFormData({ ...formData, endDatetime: e.target.value })} />
|
||||||
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>
|
</div>
|
||||||
|
|
||||||
<Input
|
<Input label="Location" value={formData.location}
|
||||||
label="Location"
|
onChange={(e) => setFormData({ ...formData, location: e.target.value })} required />
|
||||||
value={formData.location}
|
<Input label="Location URL (Google Maps)" type="url" value={formData.locationUrl}
|
||||||
onChange={(e) => setFormData({ ...formData, location: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, locationUrl: e.target.value })} />
|
||||||
required
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Input
|
<div className="grid grid-cols-3 gap-4">
|
||||||
label="Location URL (Google Maps)"
|
<Input label="Price" type="number" min="0" value={formData.price}
|
||||||
type="url"
|
onChange={(e) => setFormData({ ...formData, price: Number(e.target.value) })} />
|
||||||
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>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">Currency</label>
|
<label className="block text-sm font-medium mb-1">Currency</label>
|
||||||
<select
|
<select value={formData.currency} onChange={(e) => setFormData({ ...formData, currency: e.target.value })}
|
||||||
value={formData.currency}
|
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray">
|
||||||
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="PYG">PYG</option>
|
||||||
<option value="USD">USD</option>
|
<option value="USD">USD</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<Input
|
<Input label="Capacity" type="number" min="1" value={formData.capacity}
|
||||||
label="Capacity"
|
onChange={(e) => setFormData({ ...formData, capacity: Number(e.target.value) })} />
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
value={formData.capacity}
|
|
||||||
onChange={(e) => setFormData({ ...formData, capacity: Number(e.target.value) })}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">Status</label>
|
<label className="block text-sm font-medium mb-1">Status</label>
|
||||||
<select
|
<select value={formData.status} onChange={(e) => setFormData({ ...formData, status: e.target.value as any })}
|
||||||
value={formData.status}
|
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray">
|
||||||
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="draft">Draft</option>
|
||||||
<option value="published">Published</option>
|
<option value="published">Published</option>
|
||||||
|
<option value="unlisted">Unlisted</option>
|
||||||
<option value="cancelled">Cancelled</option>
|
<option value="cancelled">Cancelled</option>
|
||||||
<option value="completed">Completed</option>
|
<option value="completed">Completed</option>
|
||||||
<option value="archived">Archived</option>
|
<option value="archived">Archived</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* External Booking Section */}
|
|
||||||
<div className="border border-secondary-light-gray rounded-lg p-4 space-y-4">
|
<div className="border border-secondary-light-gray rounded-lg p-4 space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700">External Booking</label>
|
<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>
|
</div>
|
||||||
<button
|
<button type="button"
|
||||||
type="button"
|
|
||||||
onClick={() => setFormData({ ...formData, externalBookingEnabled: !formData.externalBookingEnabled })}
|
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'
|
formData.externalBookingEnabled ? 'bg-primary-yellow' : 'bg-gray-200'
|
||||||
}`}
|
}`}>
|
||||||
>
|
<span className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition ${
|
||||||
<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'
|
formData.externalBookingEnabled ? 'translate-x-5' : 'translate-x-0'
|
||||||
}`}
|
}`} />
|
||||||
/>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{formData.externalBookingEnabled && (
|
{formData.externalBookingEnabled && (
|
||||||
<div>
|
<div>
|
||||||
<Input
|
<Input label="External Booking URL" type="url" value={formData.externalBookingUrl}
|
||||||
label="External Booking URL"
|
|
||||||
type="url"
|
|
||||||
value={formData.externalBookingUrl}
|
|
||||||
onChange={(e) => setFormData({ ...formData, externalBookingUrl: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, externalBookingUrl: e.target.value })}
|
||||||
placeholder="https://example.com/book"
|
placeholder="https://example.com/book" required />
|
||||||
required
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-gray-500 mt-1">Must be a valid HTTPS URL</p>
|
<p className="text-xs text-gray-500 mt-1">Must be a valid HTTPS URL</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Image Upload / Media Picker */}
|
<MediaPicker value={formData.bannerUrl}
|
||||||
<MediaPicker
|
|
||||||
value={formData.bannerUrl}
|
|
||||||
onChange={(url) => setFormData({ ...formData, bannerUrl: url })}
|
onChange={(url) => setFormData({ ...formData, bannerUrl: url })}
|
||||||
relatedId={editingEvent?.id}
|
relatedId={editingEvent?.id} relatedType="event" />
|
||||||
relatedType="event"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Featured Event Section - Only show for published events when editing */}
|
|
||||||
{editingEvent && editingEvent.status === 'published' && (
|
{editingEvent && editingEvent.status === 'published' && (
|
||||||
<div className="border border-secondary-light-gray rounded-lg p-4 space-y-4 bg-amber-50">
|
<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 className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 flex items-center gap-2">
|
<label className="block text-sm font-medium text-gray-700 flex items-center gap-2">
|
||||||
<StarIcon className="w-5 h-5 text-amber-500" />
|
<StarIcon className="w-5 h-5 text-amber-500" /> Featured Event
|
||||||
Featured Event
|
|
||||||
</label>
|
</label>
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-xs text-gray-500">Prominently displayed on homepage</p>
|
||||||
Featured events are prominently displayed on the homepage and linktree
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button type="button" disabled={settingFeatured !== null}
|
||||||
type="button"
|
onClick={() => handleSetFeatured(featuredEventId === editingEvent.id ? null : editingEvent.id)}
|
||||||
disabled={settingFeatured !== null}
|
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors disabled:opacity-50 ${
|
||||||
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 ${
|
|
||||||
featuredEventId === editingEvent.id ? 'bg-amber-500' : 'bg-gray-200'
|
featuredEventId === editingEvent.id ? 'bg-amber-500' : 'bg-gray-200'
|
||||||
}`}
|
}`}>
|
||||||
>
|
<span className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition ${
|
||||||
<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'
|
featuredEventId === editingEvent.id ? 'translate-x-5' : 'translate-x-0'
|
||||||
}`}
|
}`} />
|
||||||
/>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{featuredEventId && featuredEventId !== editingEvent.id && (
|
{featuredEventId && featuredEventId !== editingEvent.id && (
|
||||||
@@ -521,14 +424,10 @@ export default function AdminEventsPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex gap-3 pt-4">
|
<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'}
|
{editingEvent ? 'Update Event' : 'Create Event'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button type="button" variant="outline" onClick={() => { setShowForm(false); resetForm(); }} className="flex-1 min-h-[44px]">
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => { setShowForm(false); resetForm(); }}
|
|
||||||
>
|
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -537,17 +436,17 @@ export default function AdminEventsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Events Table */}
|
{/* Desktop: Table */}
|
||||||
<Card className="overflow-hidden">
|
<Card className="overflow-hidden hidden md:block">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead className="bg-secondary-gray">
|
<thead className="bg-secondary-gray">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Event</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-6 py-3 text-sm font-medium text-gray-600">Date</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-6 py-3 text-sm font-medium text-gray-600">Capacity</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-6 py-3 text-sm font-medium text-gray-600">Status</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-6 py-3 text-sm font-medium text-gray-600">Actions</th>
|
<th className="text-right px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-secondary-light-gray">
|
<tbody className="divide-y divide-secondary-light-gray">
|
||||||
@@ -560,109 +459,90 @@ export default function AdminEventsPage() {
|
|||||||
) : (
|
) : (
|
||||||
events.map((event) => (
|
events.map((event) => (
|
||||||
<tr key={event.id} className={clsx("hover:bg-gray-50", featuredEventId === event.id && "bg-amber-50")}>
|
<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">
|
<div className="flex items-center gap-3">
|
||||||
{event.bannerUrl ? (
|
{event.bannerUrl ? (
|
||||||
<img
|
<img src={event.bannerUrl} alt={event.title}
|
||||||
src={event.bannerUrl}
|
className="w-10 h-10 rounded-lg object-cover flex-shrink-0" />
|
||||||
alt={event.title}
|
|
||||||
className="w-12 h-12 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">
|
<div className="w-10 h-10 rounded-lg bg-secondary-gray flex items-center justify-center flex-shrink-0">
|
||||||
<PhotoIcon className="w-6 h-6 text-gray-400" />
|
<PhotoIcon className="w-5 h-5 text-gray-400" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2">
|
<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 && (
|
{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">
|
<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-3 h-3" />
|
<StarIconSolid className="w-2.5 h-2.5" /> Featured
|
||||||
Featured
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 text-sm text-gray-600">
|
<td className="px-4 py-3 text-sm text-gray-600">{formatDate(event.startDatetime)}</td>
|
||||||
{formatDate(event.startDatetime)}
|
<td className="px-4 py-3 text-sm">{event.bookedCount || 0} / {event.capacity}</td>
|
||||||
</td>
|
<td className="px-4 py-3">
|
||||||
<td className="px-6 py-4 text-sm">
|
<div className="flex items-center gap-1.5">
|
||||||
{event.bookedCount || 0} / {event.capacity}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
{getStatusBadge(event.status)}
|
{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>
|
||||||
<td className="px-6 py-4">
|
<td className="px-4 py-3">
|
||||||
<div className="flex items-center justify-end gap-1">
|
<div className="flex items-center justify-end gap-1">
|
||||||
{event.status === 'draft' && (
|
{event.status === 'draft' && (
|
||||||
<Button
|
<Button size="sm" variant="ghost" onClick={() => handleStatusChange(event, 'published')}>
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => handleStatusChange(event, 'published')}
|
|
||||||
>
|
|
||||||
Publish
|
Publish
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{event.status === 'published' && (
|
{event.status === 'published' && (
|
||||||
<button
|
<button onClick={() => handleSetFeatured(featuredEventId === event.id ? null : event.id)}
|
||||||
onClick={() => handleSetFeatured(featuredEventId === event.id ? null : event.id)}
|
|
||||||
disabled={settingFeatured !== null}
|
disabled={settingFeatured !== null}
|
||||||
className={clsx(
|
className={clsx("p-2 rounded-btn disabled:opacity-50",
|
||||||
"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")}
|
||||||
featuredEventId === event.id
|
title={featuredEventId === event.id ? "Remove from featured" : "Set as featured"}>
|
||||||
? "bg-amber-100 text-amber-600 hover:bg-amber-200"
|
{featuredEventId === event.id ? <StarIconSolid className="w-4 h-4" /> : <StarIcon className="w-4 h-4" />}
|
||||||
: "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>
|
</button>
|
||||||
)}
|
)}
|
||||||
<Link
|
<Link href={`/admin/events/${event.id}`}
|
||||||
href={`/admin/events/${event.id}`}
|
className="p-2 hover:bg-primary-yellow/20 text-primary-dark rounded-btn" title="Manage">
|
||||||
className="p-2 hover:bg-primary-yellow/20 text-primary-dark rounded-btn"
|
|
||||||
title="Manage Event"
|
|
||||||
>
|
|
||||||
<EyeIcon className="w-4 h-4" />
|
<EyeIcon className="w-4 h-4" />
|
||||||
</Link>
|
</Link>
|
||||||
<button
|
<button onClick={() => handleEdit(event)} className="p-2 hover:bg-gray-100 rounded-btn" title="Edit">
|
||||||
onClick={() => handleEdit(event)}
|
|
||||||
className="p-2 hover:bg-gray-100 rounded-btn"
|
|
||||||
title="Edit"
|
|
||||||
>
|
|
||||||
<PencilIcon className="w-4 h-4" />
|
<PencilIcon className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<MoreMenu>
|
||||||
onClick={() => handleDuplicate(event)}
|
{(event.status === 'draft' || event.status === 'published') && (
|
||||||
className="p-2 hover:bg-blue-100 text-blue-600 rounded-btn"
|
<DropdownItem onClick={() => handleStatusChange(event, 'unlisted')}>
|
||||||
title="Duplicate"
|
<LinkIcon className="w-4 h-4 mr-2" /> Make Unlisted
|
||||||
>
|
</DropdownItem>
|
||||||
<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
|
{event.status === 'unlisted' && (
|
||||||
onClick={() => handleDelete(event.id)}
|
<DropdownItem onClick={() => handleStatusChange(event, 'published')}>
|
||||||
className="p-2 hover:bg-red-100 text-red-600 rounded-btn"
|
Make Public
|
||||||
title="Delete"
|
</DropdownItem>
|
||||||
>
|
)}
|
||||||
<TrashIcon className="w-4 h-4" />
|
{(event.status === 'published' || event.status === 'unlisted') && (
|
||||||
</button>
|
<DropdownItem onClick={() => handleStatusChange(event, 'draft')}>
|
||||||
|
Unpublish
|
||||||
|
</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>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -672,6 +552,109 @@ export default function AdminEventsPage() {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</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 === 'draft' || event.status === 'published') && (
|
||||||
|
<DropdownItem onClick={() => handleStatusChange(event, 'unlisted')}>
|
||||||
|
<LinkIcon className="w-4 h-4 mr-2" /> Make Unlisted
|
||||||
|
</DropdownItem>
|
||||||
|
)}
|
||||||
|
{event.status === 'unlisted' && (
|
||||||
|
<DropdownItem onClick={() => handleStatusChange(event, 'published')}>
|
||||||
|
Make Public
|
||||||
|
</DropdownItem>
|
||||||
|
)}
|
||||||
|
{(event.status === 'published' || event.status === 'unlisted') && (
|
||||||
|
<DropdownItem onClick={() => handleStatusChange(event, 'draft')}>
|
||||||
|
Unpublish
|
||||||
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { faqApi, FaqItemAdmin } from '@/lib/api';
|
|||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import Input from '@/components/ui/Input';
|
import Input from '@/components/ui/Input';
|
||||||
|
import { MoreMenu, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import {
|
import {
|
||||||
@@ -15,19 +16,14 @@ import {
|
|||||||
Bars3Icon,
|
Bars3Icon,
|
||||||
XMarkIcon,
|
XMarkIcon,
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
ArrowLeftIcon,
|
ChevronUpIcon,
|
||||||
|
ChevronDownIcon,
|
||||||
} from '@heroicons/react/24/outline';
|
} from '@heroicons/react/24/outline';
|
||||||
|
|
||||||
type FormState = { id: string | null; question: string; questionEs: string; answer: string; answerEs: string; enabled: boolean; showOnHomepage: boolean };
|
type FormState = { id: string | null; question: string; questionEs: string; answer: string; answerEs: string; enabled: boolean; showOnHomepage: boolean };
|
||||||
|
|
||||||
const emptyForm: FormState = {
|
const emptyForm: FormState = {
|
||||||
id: null,
|
id: null, question: '', questionEs: '', answer: '', answerEs: '', enabled: true, showOnHomepage: false,
|
||||||
question: '',
|
|
||||||
questionEs: '',
|
|
||||||
answer: '',
|
|
||||||
answerEs: '',
|
|
||||||
enabled: true,
|
|
||||||
showOnHomepage: false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function AdminFaqPage() {
|
export default function AdminFaqPage() {
|
||||||
@@ -40,9 +36,7 @@ export default function AdminFaqPage() {
|
|||||||
const [draggedId, setDraggedId] = useState<string | null>(null);
|
const [draggedId, setDraggedId] = useState<string | null>(null);
|
||||||
const [dragOverId, setDragOverId] = useState<string | null>(null);
|
const [dragOverId, setDragOverId] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => { loadFaqs(); }, []);
|
||||||
loadFaqs();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const loadFaqs = async () => {
|
const loadFaqs = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -57,20 +51,12 @@ export default function AdminFaqPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreate = () => {
|
const handleCreate = () => { setForm(emptyForm); setShowForm(true); };
|
||||||
setForm(emptyForm);
|
|
||||||
setShowForm(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEdit = (faq: FaqItemAdmin) => {
|
const handleEdit = (faq: FaqItemAdmin) => {
|
||||||
setForm({
|
setForm({
|
||||||
id: faq.id,
|
id: faq.id, question: faq.question, questionEs: faq.questionEs ?? '',
|
||||||
question: faq.question,
|
answer: faq.answer, answerEs: faq.answerEs ?? '', enabled: faq.enabled, showOnHomepage: faq.showOnHomepage,
|
||||||
questionEs: faq.questionEs ?? '',
|
|
||||||
answer: faq.answer,
|
|
||||||
answerEs: faq.answerEs ?? '',
|
|
||||||
enabled: faq.enabled,
|
|
||||||
showOnHomepage: faq.showOnHomepage,
|
|
||||||
});
|
});
|
||||||
setShowForm(true);
|
setShowForm(true);
|
||||||
};
|
};
|
||||||
@@ -84,22 +70,16 @@ export default function AdminFaqPage() {
|
|||||||
setSaving(true);
|
setSaving(true);
|
||||||
if (form.id) {
|
if (form.id) {
|
||||||
await faqApi.update(form.id, {
|
await faqApi.update(form.id, {
|
||||||
question: form.question.trim(),
|
question: form.question.trim(), questionEs: form.questionEs.trim() || null,
|
||||||
questionEs: form.questionEs.trim() || null,
|
answer: form.answer.trim(), answerEs: form.answerEs.trim() || null,
|
||||||
answer: form.answer.trim(),
|
enabled: form.enabled, showOnHomepage: form.showOnHomepage,
|
||||||
answerEs: form.answerEs.trim() || null,
|
|
||||||
enabled: form.enabled,
|
|
||||||
showOnHomepage: form.showOnHomepage,
|
|
||||||
});
|
});
|
||||||
toast.success(locale === 'es' ? 'FAQ actualizado' : 'FAQ updated');
|
toast.success(locale === 'es' ? 'FAQ actualizado' : 'FAQ updated');
|
||||||
} else {
|
} else {
|
||||||
await faqApi.create({
|
await faqApi.create({
|
||||||
question: form.question.trim(),
|
question: form.question.trim(), questionEs: form.questionEs.trim() || undefined,
|
||||||
questionEs: form.questionEs.trim() || undefined,
|
answer: form.answer.trim(), answerEs: form.answerEs.trim() || undefined,
|
||||||
answer: form.answer.trim(),
|
enabled: form.enabled, showOnHomepage: form.showOnHomepage,
|
||||||
answerEs: form.answerEs.trim() || undefined,
|
|
||||||
enabled: form.enabled,
|
|
||||||
showOnHomepage: form.showOnHomepage,
|
|
||||||
});
|
});
|
||||||
toast.success(locale === 'es' ? 'FAQ creado' : 'FAQ created');
|
toast.success(locale === 'es' ? 'FAQ creado' : 'FAQ created');
|
||||||
}
|
}
|
||||||
@@ -143,22 +123,44 @@ export default function AdminFaqPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleMoveUp = async (index: number) => {
|
||||||
|
if (index === 0) return;
|
||||||
|
const newOrder = [...faqs];
|
||||||
|
[newOrder[index - 1], newOrder[index]] = [newOrder[index], newOrder[index - 1]];
|
||||||
|
const ids = newOrder.map(f => f.id);
|
||||||
|
try {
|
||||||
|
const res = await faqApi.reorder(ids);
|
||||||
|
setFaqs(res.faqs);
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err.message || (locale === 'es' ? 'Error al reordenar' : 'Failed to reorder'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMoveDown = async (index: number) => {
|
||||||
|
if (index >= faqs.length - 1) return;
|
||||||
|
const newOrder = [...faqs];
|
||||||
|
[newOrder[index], newOrder[index + 1]] = [newOrder[index + 1], newOrder[index]];
|
||||||
|
const ids = newOrder.map(f => f.id);
|
||||||
|
try {
|
||||||
|
const res = await faqApi.reorder(ids);
|
||||||
|
setFaqs(res.faqs);
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err.message || (locale === 'es' ? 'Error al reordenar' : 'Failed to reorder'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Desktop drag handlers
|
||||||
const handleDragStart = (e: React.DragEvent, id: string) => {
|
const handleDragStart = (e: React.DragEvent, id: string) => {
|
||||||
setDraggedId(id);
|
setDraggedId(id);
|
||||||
e.dataTransfer.effectAllowed = 'move';
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
e.dataTransfer.setData('text/plain', id);
|
e.dataTransfer.setData('text/plain', id);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDragOver = (e: React.DragEvent, id: string) => {
|
const handleDragOver = (e: React.DragEvent, id: string) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.dataTransfer.dropEffect = 'move';
|
e.dataTransfer.dropEffect = 'move';
|
||||||
setDragOverId(id);
|
setDragOverId(id);
|
||||||
};
|
};
|
||||||
|
const handleDragLeave = () => { setDragOverId(null); };
|
||||||
const handleDragLeave = () => {
|
|
||||||
setDragOverId(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDrop = async (e: React.DragEvent, targetId: string) => {
|
const handleDrop = async (e: React.DragEvent, targetId: string) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setDragOverId(null);
|
setDragOverId(null);
|
||||||
@@ -180,11 +182,7 @@ export default function AdminFaqPage() {
|
|||||||
toast.error(err.message || (locale === 'es' ? 'Error al reordenar' : 'Failed to reorder'));
|
toast.error(err.message || (locale === 'es' ? 'Error al reordenar' : 'Failed to reorder'));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const handleDragEnd = () => { setDraggedId(null); setDragOverId(null); };
|
||||||
const handleDragEnd = () => {
|
|
||||||
setDraggedId(null);
|
|
||||||
setDragOverId(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -198,179 +196,120 @@ export default function AdminFaqPage() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold font-heading">
|
<h1 className="text-xl md:text-2xl font-bold font-heading">FAQ</h1>
|
||||||
{locale === 'es' ? 'FAQ' : 'FAQ'}
|
<p className="text-gray-500 text-xs md:text-sm mt-1 hidden md:block">
|
||||||
</h1>
|
|
||||||
<p className="text-gray-500 text-sm mt-1">
|
|
||||||
{locale === 'es'
|
{locale === 'es'
|
||||||
? 'Crear y editar preguntas frecuentes. Arrastra para cambiar el orden.'
|
? 'Crear y editar preguntas frecuentes. Arrastra para cambiar el orden.'
|
||||||
: 'Create and edit FAQ questions. Drag to change order.'}
|
: 'Create and edit FAQ questions. Drag to change order.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={handleCreate}>
|
<Button onClick={handleCreate} className="hidden md:flex">
|
||||||
<PlusIcon className="w-4 h-4 mr-2" />
|
<PlusIcon className="w-4 h-4 mr-2" />
|
||||||
{locale === 'es' ? 'Nueva pregunta' : 'Add question'}
|
{locale === 'es' ? 'Nueva pregunta' : 'Add question'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Form Modal - bottom sheet on mobile */}
|
||||||
{showForm && (
|
{showForm && (
|
||||||
<Card>
|
<div className="fixed inset-0 bg-black/50 z-50 flex items-end md:items-center justify-center p-0 md:p-4">
|
||||||
<div className="p-6 space-y-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 justify-between items-center">
|
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray flex-shrink-0">
|
||||||
<h2 className="text-lg font-semibold">
|
<h2 className="text-base font-semibold">
|
||||||
{form.id ? (locale === 'es' ? 'Editar pregunta' : 'Edit question') : (locale === 'es' ? 'Nueva pregunta' : 'New question')}
|
{form.id ? (locale === 'es' ? 'Editar pregunta' : 'Edit question') : (locale === 'es' ? 'Nueva pregunta' : 'New question')}
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<button onClick={() => { setForm(emptyForm); setShowForm(false); }}
|
||||||
onClick={() => { setForm(emptyForm); setShowForm(false); }}
|
className="p-2 hover:bg-gray-100 rounded-full min-h-[44px] min-w-[44px] flex items-center justify-center">
|
||||||
className="p-2 hover:bg-gray-100 rounded-full"
|
|
||||||
>
|
|
||||||
<XMarkIcon className="w-5 h-5" />
|
<XMarkIcon className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="p-4 space-y-4 overflow-y-auto flex-1 min-h-0">
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">Question (EN) *</label>
|
<label className="block text-sm font-medium mb-1">Question (EN) *</label>
|
||||||
<Input
|
<Input value={form.question} onChange={e => setForm(f => ({ ...f, question: e.target.value }))} placeholder="Question in English" />
|
||||||
value={form.question}
|
|
||||||
onChange={e => setForm(f => ({ ...f, question: e.target.value }))}
|
|
||||||
placeholder="Question in English"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">Pregunta (ES)</label>
|
<label className="block text-sm font-medium mb-1">Pregunta (ES)</label>
|
||||||
<Input
|
<Input value={form.questionEs} onChange={e => setForm(f => ({ ...f, questionEs: e.target.value }))} placeholder="Pregunta en español" />
|
||||||
value={form.questionEs}
|
|
||||||
onChange={e => setForm(f => ({ ...f, questionEs: e.target.value }))}
|
|
||||||
placeholder="Pregunta en español"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">Answer (EN) *</label>
|
<label className="block text-sm font-medium mb-1">Answer (EN) *</label>
|
||||||
<textarea
|
<textarea className="w-full border border-gray-300 rounded-btn px-3 py-2 min-h-[100px]"
|
||||||
className="w-full border border-gray-300 rounded-btn px-3 py-2 min-h-[100px]"
|
value={form.answer} onChange={e => setForm(f => ({ ...f, answer: e.target.value }))} placeholder="Answer in English" />
|
||||||
value={form.answer}
|
|
||||||
onChange={e => setForm(f => ({ ...f, answer: e.target.value }))}
|
|
||||||
placeholder="Answer in English"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">Respuesta (ES)</label>
|
<label className="block text-sm font-medium mb-1">Respuesta (ES)</label>
|
||||||
<textarea
|
<textarea className="w-full border border-gray-300 rounded-btn px-3 py-2 min-h-[100px]"
|
||||||
className="w-full border border-gray-300 rounded-btn px-3 py-2 min-h-[100px]"
|
value={form.answerEs} onChange={e => setForm(f => ({ ...f, answerEs: e.target.value }))} placeholder="Respuesta en español" />
|
||||||
value={form.answerEs}
|
|
||||||
onChange={e => setForm(f => ({ ...f, answerEs: e.target.value }))}
|
|
||||||
placeholder="Respuesta en español"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-6">
|
<div className="flex flex-wrap gap-6">
|
||||||
<label className="flex items-center gap-2 cursor-pointer">
|
<label className="flex items-center gap-2 cursor-pointer min-h-[44px]">
|
||||||
<input
|
<input type="checkbox" checked={form.enabled} onChange={e => setForm(f => ({ ...f, enabled: e.target.checked }))} className="rounded border-gray-300 w-4 h-4" />
|
||||||
type="checkbox"
|
|
||||||
checked={form.enabled}
|
|
||||||
onChange={e => setForm(f => ({ ...f, enabled: e.target.checked }))}
|
|
||||||
className="rounded border-gray-300"
|
|
||||||
/>
|
|
||||||
<span className="text-sm">{locale === 'es' ? 'Mostrar en el sitio' : 'Show on site'}</span>
|
<span className="text-sm">{locale === 'es' ? 'Mostrar en el sitio' : 'Show on site'}</span>
|
||||||
</label>
|
</label>
|
||||||
<label className="flex items-center gap-2 cursor-pointer">
|
<label className="flex items-center gap-2 cursor-pointer min-h-[44px]">
|
||||||
<input
|
<input type="checkbox" checked={form.showOnHomepage} onChange={e => setForm(f => ({ ...f, showOnHomepage: e.target.checked }))} className="rounded border-gray-300 w-4 h-4" />
|
||||||
type="checkbox"
|
|
||||||
checked={form.showOnHomepage}
|
|
||||||
onChange={e => setForm(f => ({ ...f, showOnHomepage: e.target.checked }))}
|
|
||||||
className="rounded border-gray-300"
|
|
||||||
/>
|
|
||||||
<span className="text-sm">{locale === 'es' ? 'Mostrar en inicio' : 'Show on homepage'}</span>
|
<span className="text-sm">{locale === 'es' ? 'Mostrar en inicio' : 'Show on homepage'}</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-3 pt-2">
|
||||||
<Button onClick={handleSave} isLoading={saving}>
|
<Button onClick={handleSave} isLoading={saving} className="flex-1 min-h-[44px]">
|
||||||
<CheckIcon className="w-4 h-4 mr-1" />
|
<CheckIcon className="w-4 h-4 mr-1" /> {locale === 'es' ? 'Guardar' : 'Save'}
|
||||||
{locale === 'es' ? 'Guardar' : 'Save'}
|
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" onClick={() => { setForm(emptyForm); setShowForm(false); }} disabled={saving}>
|
<Button variant="outline" onClick={() => { setForm(emptyForm); setShowForm(false); }} disabled={saving} className="flex-1 min-h-[44px]">
|
||||||
{locale === 'es' ? 'Cancelar' : 'Cancel'}
|
{locale === 'es' ? 'Cancelar' : 'Cancel'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Card>
|
{/* Desktop: Table */}
|
||||||
|
<Card className="hidden md:block">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead className="bg-gray-50 border-b border-gray-200">
|
<thead className="bg-gray-50 border-b border-gray-200">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="w-10 px-4 py-3" />
|
<th className="w-10 px-4 py-3" />
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-600 uppercase">
|
<th className="px-4 py-2 text-left text-xs font-semibold text-gray-500 uppercase">{locale === 'es' ? 'Pregunta' : 'Question'}</th>
|
||||||
{locale === 'es' ? 'Pregunta' : 'Question'}
|
<th className="px-4 py-2 text-left text-xs font-semibold text-gray-500 uppercase w-24">{locale === 'es' ? 'En sitio' : 'On site'}</th>
|
||||||
</th>
|
<th className="px-4 py-2 text-left text-xs font-semibold text-gray-500 uppercase w-28">{locale === 'es' ? 'En inicio' : 'Homepage'}</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-600 uppercase w-24">
|
<th className="px-4 py-2 text-right text-xs font-semibold text-gray-500 uppercase w-32">{locale === 'es' ? 'Acciones' : 'Actions'}</th>
|
||||||
{locale === 'es' ? 'En sitio' : 'On site'}
|
|
||||||
</th>
|
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-600 uppercase w-28">
|
|
||||||
{locale === 'es' ? 'En inicio' : 'Homepage'}
|
|
||||||
</th>
|
|
||||||
<th className="px-4 py-3 text-right text-xs font-semibold text-gray-600 uppercase w-32">
|
|
||||||
{locale === 'es' ? 'Acciones' : 'Actions'}
|
|
||||||
</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-200">
|
<tbody className="divide-y divide-gray-200">
|
||||||
{faqs.length === 0 ? (
|
{faqs.length === 0 ? (
|
||||||
<tr>
|
<tr><td colSpan={5} className="px-6 py-12 text-center text-gray-500 text-sm">{locale === 'es' ? 'No hay preguntas. Añade la primera.' : 'No questions yet. Add the first one.'}</td></tr>
|
||||||
<td colSpan={5} className="px-6 py-12 text-center text-gray-500">
|
|
||||||
{locale === 'es' ? 'No hay preguntas. Añade la primera.' : 'No questions yet. Add the first one.'}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : (
|
) : (
|
||||||
faqs.map((faq) => (
|
faqs.map((faq) => (
|
||||||
<tr
|
<tr key={faq.id} draggable onDragStart={e => handleDragStart(e, faq.id)}
|
||||||
key={faq.id}
|
onDragOver={e => handleDragOver(e, faq.id)} onDragLeave={handleDragLeave}
|
||||||
draggable
|
onDrop={e => handleDrop(e, faq.id)} onDragEnd={handleDragEnd}
|
||||||
onDragStart={e => handleDragStart(e, faq.id)}
|
className={clsx('hover:bg-gray-50', draggedId === faq.id && 'opacity-50', dragOverId === faq.id && 'bg-primary-yellow/10')}>
|
||||||
onDragOver={e => handleDragOver(e, faq.id)}
|
|
||||||
onDragLeave={handleDragLeave}
|
|
||||||
onDrop={e => handleDrop(e, faq.id)}
|
|
||||||
onDragEnd={handleDragEnd}
|
|
||||||
className={clsx(
|
|
||||||
'hover:bg-gray-50',
|
|
||||||
draggedId === faq.id && 'opacity-50',
|
|
||||||
dragOverId === faq.id && 'bg-primary-yellow/10'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<span className="cursor-grab active:cursor-grabbing text-gray-400 hover:text-gray-600" title={locale === 'es' ? 'Arrastrar para reordenar' : 'Drag to reorder'}>
|
<span className="cursor-grab active:cursor-grabbing text-gray-400 hover:text-gray-600" title={locale === 'es' ? 'Arrastrar para reordenar' : 'Drag to reorder'}>
|
||||||
<Bars3Icon className="w-5 h-5" />
|
<Bars3Icon className="w-5 h-5" />
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<p className="font-medium text-primary-dark line-clamp-1">
|
<p className="font-medium text-primary-dark text-sm line-clamp-1">{locale === 'es' && faq.questionEs ? faq.questionEs : faq.question}</p>
|
||||||
{locale === 'es' && faq.questionEs ? faq.questionEs : faq.question}
|
|
||||||
</p>
|
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<button
|
<button onClick={() => handleToggleEnabled(faq)}
|
||||||
onClick={() => handleToggleEnabled(faq)}
|
className={clsx('inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium min-h-[32px]',
|
||||||
className={clsx(
|
faq.enabled ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-500')}>
|
||||||
'inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium',
|
{faq.enabled ? (locale === 'es' ? 'Sí' : 'Yes') : 'No'}
|
||||||
faq.enabled ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-500'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{faq.enabled ? (locale === 'es' ? 'Sí' : 'Yes') : (locale === 'es' ? 'No' : 'No')}
|
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<button
|
<button onClick={() => handleToggleShowOnHomepage(faq)}
|
||||||
onClick={() => handleToggleShowOnHomepage(faq)}
|
className={clsx('inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium min-h-[32px]',
|
||||||
className={clsx(
|
faq.showOnHomepage ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-500')}>
|
||||||
'inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium',
|
{faq.showOnHomepage ? (locale === 'es' ? 'Sí' : 'Yes') : 'No'}
|
||||||
faq.showOnHomepage ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-500'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{faq.showOnHomepage ? (locale === 'es' ? 'Sí' : 'Yes') : (locale === 'es' ? 'No' : 'No')}
|
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-right">
|
<td className="px-4 py-3 text-right">
|
||||||
@@ -390,6 +329,65 @@ export default function AdminFaqPage() {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Mobile: Card List */}
|
||||||
|
<div className="md:hidden space-y-2">
|
||||||
|
{faqs.length === 0 ? (
|
||||||
|
<div className="text-center py-10 text-gray-500 text-sm">{locale === 'es' ? 'No hay preguntas. Añade la primera.' : 'No questions yet. Add the first one.'}</div>
|
||||||
|
) : (
|
||||||
|
faqs.map((faq, index) => (
|
||||||
|
<Card key={faq.id} className="p-3">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<div className="flex flex-col gap-0.5 flex-shrink-0 pt-0.5">
|
||||||
|
<button onClick={() => handleMoveUp(index)} disabled={index === 0}
|
||||||
|
className="p-1 text-gray-400 hover:text-gray-600 disabled:opacity-30 min-h-[28px] min-w-[28px] flex items-center justify-center">
|
||||||
|
<ChevronUpIcon className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => handleMoveDown(index)} disabled={index >= faqs.length - 1}
|
||||||
|
className="p-1 text-gray-400 hover:text-gray-600 disabled:opacity-30 min-h-[28px] min-w-[28px] flex items-center justify-center">
|
||||||
|
<ChevronDownIcon className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-medium text-sm text-primary-dark line-clamp-2">
|
||||||
|
{locale === 'es' && faq.questionEs ? faq.questionEs : faq.question}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2 mt-1.5">
|
||||||
|
<button onClick={() => handleToggleEnabled(faq)}
|
||||||
|
className={clsx('inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-medium min-h-[28px]',
|
||||||
|
faq.enabled ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-500')}>
|
||||||
|
{faq.enabled ? (locale === 'es' ? 'Sitio: Sí' : 'Site: Yes') : (locale === 'es' ? 'Sitio: No' : 'Site: No')}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => handleToggleShowOnHomepage(faq)}
|
||||||
|
className={clsx('inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-medium min-h-[28px]',
|
||||||
|
faq.showOnHomepage ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-500')}>
|
||||||
|
{faq.showOnHomepage ? (locale === 'es' ? 'Inicio: Sí' : 'Home: Yes') : (locale === 'es' ? 'Inicio: No' : 'Home: No')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<MoreMenu>
|
||||||
|
<DropdownItem onClick={() => handleEdit(faq)}>
|
||||||
|
<PencilSquareIcon className="w-4 h-4 mr-2" /> {locale === 'es' ? 'Editar' : 'Edit'}
|
||||||
|
</DropdownItem>
|
||||||
|
<DropdownItem onClick={() => handleDelete(faq.id)} className="text-red-600">
|
||||||
|
<TrashIcon className="w-4 h-4 mr-2" /> {locale === 'es' ? 'Eliminar' : 'Delete'}
|
||||||
|
</DropdownItem>
|
||||||
|
</MoreMenu>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile FAB */}
|
||||||
|
<div className="md:hidden fixed bottom-6 right-6 z-40">
|
||||||
|
<button onClick={handleCreate}
|
||||||
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { paymentsApi, adminApi, eventsApi, PaymentWithDetails, Event, ExportedPa
|
|||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import Input from '@/components/ui/Input';
|
import Input from '@/components/ui/Input';
|
||||||
|
import { BottomSheet, MoreMenu, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
|
||||||
import {
|
import {
|
||||||
CheckCircleIcon,
|
CheckCircleIcon,
|
||||||
ArrowPathIcon,
|
ArrowPathIcon,
|
||||||
@@ -20,8 +21,11 @@ import {
|
|||||||
BuildingLibraryIcon,
|
BuildingLibraryIcon,
|
||||||
CreditCardIcon,
|
CreditCardIcon,
|
||||||
EnvelopeIcon,
|
EnvelopeIcon,
|
||||||
|
FunnelIcon,
|
||||||
|
XMarkIcon,
|
||||||
} from '@heroicons/react/24/outline';
|
} from '@heroicons/react/24/outline';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
type Tab = 'pending_approval' | 'all';
|
type Tab = 'pending_approval' | 'all';
|
||||||
|
|
||||||
@@ -34,6 +38,7 @@ export default function AdminPaymentsPage() {
|
|||||||
const [activeTab, setActiveTab] = useState<Tab>('pending_approval');
|
const [activeTab, setActiveTab] = useState<Tab>('pending_approval');
|
||||||
const [statusFilter, setStatusFilter] = useState<string>('');
|
const [statusFilter, setStatusFilter] = useState<string>('');
|
||||||
const [providerFilter, setProviderFilter] = useState<string>('');
|
const [providerFilter, setProviderFilter] = useState<string>('');
|
||||||
|
const [mobileFilterOpen, setMobileFilterOpen] = useState(false);
|
||||||
|
|
||||||
// Modal state
|
// Modal state
|
||||||
const [selectedPayment, setSelectedPayment] = useState<PaymentWithDetails | null>(null);
|
const [selectedPayment, setSelectedPayment] = useState<PaymentWithDetails | null>(null);
|
||||||
@@ -329,10 +334,11 @@ export default function AdminPaymentsPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<h1 className="text-2xl font-bold text-primary-dark">{t('admin.payments.title')}</h1>
|
<h1 className="text-xl md:text-2xl font-bold text-primary-dark">{t('admin.payments.title')}</h1>
|
||||||
<Button onClick={() => setShowExportModal(true)}>
|
<Button onClick={() => setShowExportModal(true)} size="sm" className="min-h-[44px] md:min-h-0">
|
||||||
<DocumentArrowDownIcon className="w-5 h-5 mr-2" />
|
<DocumentArrowDownIcon className="w-4 h-4 mr-1.5" />
|
||||||
{locale === 'es' ? 'Exportar Datos' : 'Export Data'}
|
<span className="hidden md:inline">{locale === 'es' ? 'Exportar Datos' : 'Export Data'}</span>
|
||||||
|
<span className="md:hidden">{locale === 'es' ? 'Exportar' : 'Export'}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -340,11 +346,18 @@ export default function AdminPaymentsPage() {
|
|||||||
{selectedPayment && (() => {
|
{selectedPayment && (() => {
|
||||||
const modalBookingInfo = getBookingInfo(selectedPayment);
|
const modalBookingInfo = getBookingInfo(selectedPayment);
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
<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 max-w-lg max-h-[90vh] overflow-y-auto p-6">
|
<Card className="w-full md:max-w-lg max-h-[90vh] flex flex-col overflow-hidden rounded-t-2xl md:rounded-card">
|
||||||
<h2 className="text-xl font-bold mb-4">
|
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray flex-shrink-0">
|
||||||
|
<h2 className="text-base font-bold">
|
||||||
{locale === 'es' ? 'Verificar Pago' : 'Verify Payment'}
|
{locale === 'es' ? 'Verificar Pago' : 'Verify Payment'}
|
||||||
</h2>
|
</h2>
|
||||||
|
<button onClick={() => { setSelectedPayment(null); setNoteText(''); setSendEmail(true); }}
|
||||||
|
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>
|
||||||
|
<div className="p-4 overflow-y-auto flex-1 min-h-0">
|
||||||
|
|
||||||
<div className="space-y-4 mb-6">
|
<div className="space-y-4 mb-6">
|
||||||
<div className="bg-gray-50 rounded-lg p-4">
|
<div className="bg-gray-50 rounded-lg p-4">
|
||||||
@@ -442,43 +455,24 @@ export default function AdminPaymentsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<Button
|
<Button onClick={() => handleApprove(selectedPayment)} isLoading={processing} className="flex-1 min-h-[44px]">
|
||||||
onClick={() => handleApprove(selectedPayment)}
|
|
||||||
isLoading={processing}
|
|
||||||
className="flex-1"
|
|
||||||
>
|
|
||||||
<CheckCircleIcon className="w-5 h-5 mr-2" />
|
<CheckCircleIcon className="w-5 h-5 mr-2" />
|
||||||
{locale === 'es' ? 'Aprobar' : 'Approve'}
|
{locale === 'es' ? 'Aprobar' : 'Approve'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button variant="outline" onClick={() => handleReject(selectedPayment)} isLoading={processing}
|
||||||
variant="outline"
|
className="flex-1 border-red-300 text-red-600 hover:bg-red-50 min-h-[44px]">
|
||||||
onClick={() => handleReject(selectedPayment)}
|
|
||||||
isLoading={processing}
|
|
||||||
className="flex-1 border-red-300 text-red-600 hover:bg-red-50"
|
|
||||||
>
|
|
||||||
<XCircleIcon className="w-5 h-5 mr-2" />
|
<XCircleIcon className="w-5 h-5 mr-2" />
|
||||||
{locale === 'es' ? 'Rechazar' : 'Reject'}
|
{locale === 'es' ? 'Rechazar' : 'Reject'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pt-2 border-t">
|
<div className="pt-2 border-t">
|
||||||
<Button
|
<Button variant="outline" onClick={() => handleSendReminder(selectedPayment)} isLoading={sendingReminder} className="w-full min-h-[44px]">
|
||||||
variant="outline"
|
|
||||||
onClick={() => handleSendReminder(selectedPayment)}
|
|
||||||
isLoading={sendingReminder}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
<EnvelopeIcon className="w-5 h-5 mr-2" />
|
<EnvelopeIcon className="w-5 h-5 mr-2" />
|
||||||
{locale === 'es' ? 'Enviar recordatorio de pago' : 'Send payment reminder'}
|
{locale === 'es' ? 'Enviar recordatorio' : 'Send reminder'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<button
|
|
||||||
onClick={() => { setSelectedPayment(null); setNoteText(''); setSendEmail(true); }}
|
|
||||||
className="w-full mt-3 py-2 text-sm text-gray-500 hover:text-gray-700"
|
|
||||||
>
|
|
||||||
{locale === 'es' ? 'Cancelar' : 'Cancel'}
|
|
||||||
</button>
|
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -486,9 +480,16 @@ export default function AdminPaymentsPage() {
|
|||||||
|
|
||||||
{/* Export Modal */}
|
{/* Export Modal */}
|
||||||
{showExportModal && (
|
{showExportModal && (
|
||||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
<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 max-w-2xl max-h-[90vh] overflow-y-auto p-6">
|
<Card className="w-full md:max-w-2xl max-h-[90vh] flex flex-col overflow-hidden rounded-t-2xl md:rounded-card">
|
||||||
<h2 className="text-xl font-bold mb-6">{locale === 'es' ? 'Exportar Datos Financieros' : 'Export Financial Data'}</h2>
|
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray flex-shrink-0">
|
||||||
|
<h2 className="text-base font-bold">{locale === 'es' ? 'Exportar Datos Financieros' : 'Export Financial Data'}</h2>
|
||||||
|
<button onClick={() => { setShowExportModal(false); setExportData(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>
|
||||||
|
<div className="p-4 overflow-y-auto flex-1 min-h-0">
|
||||||
|
|
||||||
{!exportData ? (
|
{!exportData ? (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -522,10 +523,10 @@ export default function AdminPaymentsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-3 pt-4">
|
<div className="flex gap-3 pt-4">
|
||||||
<Button onClick={handleExport} isLoading={exporting}>
|
<Button onClick={handleExport} isLoading={exporting} className="flex-1 min-h-[44px]">
|
||||||
{locale === 'es' ? 'Generar Reporte' : 'Generate Report'}
|
{locale === 'es' ? 'Generar Reporte' : 'Generate Report'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" onClick={() => setShowExportModal(false)}>
|
<Button variant="outline" onClick={() => setShowExportModal(false)} className="flex-1 min-h-[44px]">
|
||||||
{locale === 'es' ? 'Cancelar' : 'Cancel'}
|
{locale === 'es' ? 'Cancelar' : 'Cancel'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -585,20 +586,21 @@ export default function AdminPaymentsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
<Button onClick={downloadCSV}>
|
<Button onClick={downloadCSV} className="min-h-[44px]">
|
||||||
<ArrowDownTrayIcon className="w-4 h-4 mr-2" />
|
<ArrowDownTrayIcon className="w-4 h-4 mr-2" />
|
||||||
{locale === 'es' ? 'Descargar CSV' : 'Download CSV'}
|
{locale === 'es' ? 'Descargar CSV' : 'Download CSV'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" onClick={() => setExportData(null)}>
|
<Button variant="outline" onClick={() => setExportData(null)} className="min-h-[44px]">
|
||||||
{locale === 'es' ? 'Nuevo Reporte' : 'New Report'}
|
{locale === 'es' ? 'Nuevo Reporte' : 'New Report'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" onClick={() => { setShowExportModal(false); setExportData(null); }}>
|
<Button variant="outline" onClick={() => { setShowExportModal(false); setExportData(null); }} className="min-h-[44px]">
|
||||||
{locale === 'es' ? 'Cerrar' : 'Close'}
|
{locale === 'es' ? 'Cerrar' : 'Close'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -657,31 +659,19 @@ export default function AdminPaymentsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div className="border-b mb-6">
|
<div className="border-b mb-6 overflow-x-auto scrollbar-hide">
|
||||||
<nav className="flex gap-4">
|
<nav className="flex gap-4 min-w-max">
|
||||||
<button
|
<button onClick={() => setActiveTab('pending_approval')}
|
||||||
onClick={() => setActiveTab('pending_approval')}
|
className={clsx('pb-3 px-1 text-sm font-medium border-b-2 transition-colors whitespace-nowrap min-h-[44px]',
|
||||||
className={`pb-3 px-1 text-sm font-medium border-b-2 transition-colors ${
|
activeTab === 'pending_approval' ? 'border-primary-yellow text-primary-dark' : 'border-transparent text-gray-500 hover:text-gray-700')}>
|
||||||
activeTab === 'pending_approval'
|
{locale === 'es' ? 'Pendientes' : 'Pending Approval'}
|
||||||
? 'border-primary-yellow text-primary-dark'
|
|
||||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{locale === 'es' ? 'Pendientes de Aprobación' : 'Pending Approval'}
|
|
||||||
{pendingApprovalPayments.length > 0 && (
|
{pendingApprovalPayments.length > 0 && (
|
||||||
<span className="ml-2 bg-yellow-100 text-yellow-700 px-2 py-0.5 rounded-full text-xs">
|
<span className="ml-2 bg-yellow-100 text-yellow-700 px-2 py-0.5 rounded-full text-xs">{pendingApprovalPayments.length}</span>
|
||||||
{pendingApprovalPayments.length}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button onClick={() => setActiveTab('all')}
|
||||||
onClick={() => setActiveTab('all')}
|
className={clsx('pb-3 px-1 text-sm font-medium border-b-2 transition-colors whitespace-nowrap min-h-[44px]',
|
||||||
className={`pb-3 px-1 text-sm font-medium border-b-2 transition-colors ${
|
activeTab === 'all' ? 'border-primary-yellow text-primary-dark' : 'border-transparent text-gray-500 hover:text-gray-700')}>
|
||||||
activeTab === 'all'
|
|
||||||
? 'border-primary-yellow text-primary-dark'
|
|
||||||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{locale === 'es' ? 'Todos los Pagos' : 'All Payments'}
|
{locale === 'es' ? 'Todos los Pagos' : 'All Payments'}
|
||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -748,7 +738,7 @@ export default function AdminPaymentsPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={() => setSelectedPayment(payment)}>
|
<Button onClick={() => setSelectedPayment(payment)} size="sm" className="min-h-[44px] md:min-h-0 flex-shrink-0">
|
||||||
{locale === 'es' ? 'Revisar' : 'Review'}
|
{locale === 'es' ? 'Revisar' : 'Review'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -763,16 +753,13 @@ export default function AdminPaymentsPage() {
|
|||||||
{/* All Payments Tab */}
|
{/* All Payments Tab */}
|
||||||
{activeTab === 'all' && (
|
{activeTab === 'all' && (
|
||||||
<>
|
<>
|
||||||
{/* Filters */}
|
{/* Desktop Filters */}
|
||||||
<Card className="p-4 mb-6">
|
<Card className="p-4 mb-6 hidden md:block">
|
||||||
<div className="flex flex-wrap gap-4">
|
<div className="flex flex-wrap gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">Status</label>
|
<label className="block text-sm font-medium mb-1">Status</label>
|
||||||
<select
|
<select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}
|
||||||
value={statusFilter}
|
className="px-4 py-2 rounded-btn border border-secondary-light-gray min-w-[150px] text-sm">
|
||||||
onChange={(e) => setStatusFilter(e.target.value)}
|
|
||||||
className="px-4 py-2 rounded-btn border border-secondary-light-gray min-w-[150px]"
|
|
||||||
>
|
|
||||||
<option value="">{locale === 'es' ? 'Todos los Estados' : 'All Statuses'}</option>
|
<option value="">{locale === 'es' ? 'Todos los Estados' : 'All Statuses'}</option>
|
||||||
<option value="pending">{locale === 'es' ? 'Pendiente' : 'Pending'}</option>
|
<option value="pending">{locale === 'es' ? 'Pendiente' : 'Pending'}</option>
|
||||||
<option value="pending_approval">{locale === 'es' ? 'Esperando Aprobación' : 'Pending Approval'}</option>
|
<option value="pending_approval">{locale === 'es' ? 'Esperando Aprobación' : 'Pending Approval'}</option>
|
||||||
@@ -783,119 +770,81 @@ export default function AdminPaymentsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">{locale === 'es' ? 'Método' : 'Provider'}</label>
|
<label className="block text-sm font-medium mb-1">{locale === 'es' ? 'Método' : 'Provider'}</label>
|
||||||
<select
|
<select value={providerFilter} onChange={(e) => setProviderFilter(e.target.value)}
|
||||||
value={providerFilter}
|
className="px-4 py-2 rounded-btn border border-secondary-light-gray min-w-[150px] text-sm">
|
||||||
onChange={(e) => setProviderFilter(e.target.value)}
|
|
||||||
className="px-4 py-2 rounded-btn border border-secondary-light-gray min-w-[150px]"
|
|
||||||
>
|
|
||||||
<option value="">{locale === 'es' ? 'Todos los Métodos' : 'All Providers'}</option>
|
<option value="">{locale === 'es' ? 'Todos los Métodos' : 'All Providers'}</option>
|
||||||
<option value="lightning">Lightning</option>
|
<option value="lightning">Lightning</option>
|
||||||
<option value="cash">{locale === 'es' ? 'Efectivo' : 'Cash'}</option>
|
<option value="cash">{locale === 'es' ? 'Efectivo' : 'Cash'}</option>
|
||||||
<option value="bank_transfer">{locale === 'es' ? 'Transferencia Bancaria' : 'Bank Transfer'}</option>
|
<option value="bank_transfer">{locale === 'es' ? 'Transferencia' : 'Bank Transfer'}</option>
|
||||||
<option value="tpago">TPago</option>
|
<option value="tpago">TPago</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Payments Table */}
|
{/* Mobile Filter Toolbar */}
|
||||||
<Card className="overflow-hidden">
|
<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]',
|
||||||
|
(statusFilter || providerFilter) ? 'border-primary-yellow bg-yellow-50 text-primary-dark' : 'border-secondary-light-gray text-gray-600')}>
|
||||||
|
<FunnelIcon className="w-4 h-4" /> Filters
|
||||||
|
</button>
|
||||||
|
{(statusFilter || providerFilter) && (
|
||||||
|
<button onClick={() => { setStatusFilter(''); setProviderFilter(''); }}
|
||||||
|
className="text-xs text-primary-yellow min-h-[44px] flex items-center">Clear</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop: Table */}
|
||||||
|
<Card className="overflow-hidden hidden md:block">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead className="bg-secondary-gray">
|
<thead className="bg-secondary-gray">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">{locale === 'es' ? 'Asistente' : 'Attendee'}</th>
|
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">{locale === 'es' ? 'Asistente' : 'Attendee'}</th>
|
||||||
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">{locale === 'es' ? 'Evento' : 'Event'}</th>
|
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">{locale === 'es' ? 'Evento' : 'Event'}</th>
|
||||||
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">{locale === 'es' ? 'Monto' : 'Amount'}</th>
|
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">{locale === 'es' ? 'Monto' : 'Amount'}</th>
|
||||||
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">{locale === 'es' ? 'Método' : 'Method'}</th>
|
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">{locale === 'es' ? 'Método' : 'Method'}</th>
|
||||||
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">{locale === 'es' ? 'Fecha' : 'Date'}</th>
|
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
||||||
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Status</th>
|
<th className="text-right px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">{locale === 'es' ? 'Acciones' : 'Actions'}</th>
|
||||||
<th className="text-right px-6 py-3 text-sm font-medium text-gray-600">{locale === 'es' ? 'Acciones' : 'Actions'}</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-secondary-light-gray">
|
<tbody className="divide-y divide-secondary-light-gray">
|
||||||
{payments.length === 0 ? (
|
{payments.length === 0 ? (
|
||||||
<tr>
|
<tr><td colSpan={6} className="px-4 py-12 text-center text-gray-500 text-sm">{locale === 'es' ? 'No se encontraron pagos' : 'No payments found'}</td></tr>
|
||||||
<td colSpan={7} className="px-6 py-12 text-center text-gray-500">
|
|
||||||
{locale === 'es' ? 'No se encontraron pagos' : 'No payments found'}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : (
|
) : (
|
||||||
payments.map((payment) => {
|
payments.map((payment) => {
|
||||||
const bookingInfo = getBookingInfo(payment);
|
const bookingInfo = getBookingInfo(payment);
|
||||||
return (
|
return (
|
||||||
<tr key={payment.id} className="hover:bg-gray-50">
|
<tr key={payment.id} className="hover:bg-gray-50">
|
||||||
<td className="px-6 py-4">
|
<td className="px-4 py-3">
|
||||||
{payment.ticket ? (
|
{payment.ticket ? (
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-sm">
|
<p className="font-medium text-sm">{payment.ticket.attendeeFirstName} {payment.ticket.attendeeLastName}</p>
|
||||||
{payment.ticket.attendeeFirstName} {payment.ticket.attendeeLastName}
|
<p className="text-xs text-gray-500 truncate max-w-[180px]">{payment.ticket.attendeeEmail}</p>
|
||||||
</p>
|
|
||||||
<p className="text-xs text-gray-500">{payment.ticket.attendeeEmail}</p>
|
|
||||||
{payment.payerName && (
|
|
||||||
<p className="text-xs text-amber-600 mt-1">
|
|
||||||
⚠️ {locale === 'es' ? 'Pagado por:' : 'Paid by:'} {payment.payerName}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : <span className="text-gray-400 text-sm">-</span>}
|
||||||
<span className="text-gray-400 text-sm">-</span>
|
|
||||||
)}
|
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4">
|
<td className="px-4 py-3 text-sm truncate max-w-[150px]">{payment.event?.title || '-'}</td>
|
||||||
{payment.event ? (
|
<td className="px-4 py-3">
|
||||||
<p className="text-sm">{payment.event.title}</p>
|
<p className="font-medium text-sm">{formatCurrency(bookingInfo.bookingTotal, payment.currency)}</p>
|
||||||
) : (
|
{bookingInfo.ticketCount > 1 && <p className="text-[10px] text-purple-600">{bookingInfo.ticketCount} tickets</p>}
|
||||||
<span className="text-gray-400 text-sm">-</span>
|
|
||||||
)}
|
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4">
|
<td className="px-4 py-3">
|
||||||
<div>
|
<div className="flex items-center gap-1.5 text-xs text-gray-600">
|
||||||
<p className="font-medium">{formatCurrency(bookingInfo.bookingTotal, payment.currency)}</p>
|
{getProviderIcon(payment.provider)} {getProviderLabel(payment.provider)}
|
||||||
{bookingInfo.ticketCount > 1 && (
|
|
||||||
<p className="text-xs text-purple-600 mt-1">
|
|
||||||
📦 {bookingInfo.ticketCount} × {formatCurrency(payment.amount, payment.currency)}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4">
|
<td className="px-4 py-3">{getStatusBadge(payment.status)}</td>
|
||||||
<div className="flex items-center gap-2 text-sm text-gray-600">
|
<td className="px-4 py-3">
|
||||||
{getProviderIcon(payment.provider)}
|
<div className="flex items-center justify-end gap-1">
|
||||||
{getProviderLabel(payment.provider)}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 text-sm text-gray-600">
|
|
||||||
{formatDate(payment.createdAt)}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
<div className="space-y-1">
|
|
||||||
{getStatusBadge(payment.status)}
|
|
||||||
{payment.ticket?.bookingId && (
|
|
||||||
<p className="text-xs text-purple-600" title="Part of multi-ticket booking">
|
|
||||||
📦 {locale === 'es' ? 'Grupo' : 'Group'}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
<div className="flex items-center justify-end gap-2">
|
|
||||||
{(payment.status === 'pending' || payment.status === 'pending_approval') && (
|
{(payment.status === 'pending' || payment.status === 'pending_approval') && (
|
||||||
<Button
|
<Button size="sm" onClick={() => setSelectedPayment(payment)} className="text-xs px-2 py-1">
|
||||||
size="sm"
|
|
||||||
onClick={() => setSelectedPayment(payment)}
|
|
||||||
>
|
|
||||||
<CheckCircleIcon className="w-4 h-4 mr-1" />
|
|
||||||
{locale === 'es' ? 'Revisar' : 'Review'}
|
{locale === 'es' ? 'Revisar' : 'Review'}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{payment.status === 'paid' && (
|
{payment.status === 'paid' && (
|
||||||
<Button
|
<Button size="sm" variant="outline" onClick={() => handleRefund(payment.id)} className="text-xs px-2 py-1">
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => handleRefund(payment.id)}
|
|
||||||
>
|
|
||||||
<ArrowPathIcon className="w-4 h-4 mr-1" />
|
|
||||||
{t('admin.payments.refund')}
|
{t('admin.payments.refund')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -909,8 +858,92 @@ export default function AdminPaymentsPage() {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Mobile: Card List */}
|
||||||
|
<div className="md:hidden space-y-2">
|
||||||
|
{payments.length === 0 ? (
|
||||||
|
<div className="text-center py-10 text-gray-500 text-sm">{locale === 'es' ? 'No se encontraron pagos' : 'No payments found'}</div>
|
||||||
|
) : (
|
||||||
|
payments.map((payment) => {
|
||||||
|
const bookingInfo = getBookingInfo(payment);
|
||||||
|
return (
|
||||||
|
<Card key={payment.id} className="p-3">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
{payment.ticket ? (
|
||||||
|
<p className="font-medium text-sm truncate">{payment.ticket.attendeeFirstName} {payment.ticket.attendeeLastName}</p>
|
||||||
|
) : <p className="text-sm text-gray-400">-</p>}
|
||||||
|
<p className="text-xs text-gray-500 truncate">{payment.event?.title || '-'}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||||
|
{getStatusBadge(payment.status)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex items-center gap-2 text-xs text-gray-500">
|
||||||
|
<span className="font-medium text-gray-700">{formatCurrency(bookingInfo.bookingTotal, payment.currency)}</span>
|
||||||
|
<span className="text-gray-300">|</span>
|
||||||
|
<span className="flex items-center gap-1">{getProviderIcon(payment.provider)} {getProviderLabel(payment.provider)}</span>
|
||||||
|
{bookingInfo.ticketCount > 1 && (
|
||||||
|
<><span className="text-gray-300">|</span><span className="text-purple-600">{bookingInfo.ticketCount} tickets</span></>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between mt-2 pt-2 border-t border-gray-100">
|
||||||
|
<p className="text-[10px] text-gray-400">{formatDate(payment.createdAt)}</p>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{(payment.status === 'pending' || payment.status === 'pending_approval') && (
|
||||||
|
<Button size="sm" onClick={() => setSelectedPayment(payment)} className="text-xs px-2.5 py-1.5 min-h-[36px]">
|
||||||
|
{locale === 'es' ? 'Revisar' : 'Review'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{payment.status === 'paid' && (
|
||||||
|
<Button size="sm" variant="outline" onClick={() => handleRefund(payment.id)} className="text-xs px-2.5 py-1.5 min-h-[36px]">
|
||||||
|
{t('admin.payments.refund')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile Filter BottomSheet */}
|
||||||
|
<BottomSheet open={mobileFilterOpen} onClose={() => setMobileFilterOpen(false)} title={locale === 'es' ? 'Filtros' : 'Filters'}>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Status</label>
|
||||||
|
<select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}
|
||||||
|
className="w-full px-3 py-2.5 rounded-btn border border-secondary-light-gray text-sm min-h-[44px]">
|
||||||
|
<option value="">{locale === 'es' ? 'Todos los Estados' : 'All Statuses'}</option>
|
||||||
|
<option value="pending">{locale === 'es' ? 'Pendiente' : 'Pending'}</option>
|
||||||
|
<option value="pending_approval">{locale === 'es' ? 'Esperando Aprobación' : 'Pending Approval'}</option>
|
||||||
|
<option value="paid">{locale === 'es' ? 'Pagado' : 'Paid'}</option>
|
||||||
|
<option value="refunded">{locale === 'es' ? 'Reembolsado' : 'Refunded'}</option>
|
||||||
|
<option value="failed">{locale === 'es' ? 'Fallido' : 'Failed'}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">{locale === 'es' ? 'Método' : 'Provider'}</label>
|
||||||
|
<select value={providerFilter} onChange={(e) => setProviderFilter(e.target.value)}
|
||||||
|
className="w-full px-3 py-2.5 rounded-btn border border-secondary-light-gray text-sm min-h-[44px]">
|
||||||
|
<option value="">{locale === 'es' ? 'Todos los Métodos' : 'All Providers'}</option>
|
||||||
|
<option value="lightning">Lightning</option>
|
||||||
|
<option value="cash">{locale === 'es' ? 'Efectivo' : 'Cash'}</option>
|
||||||
|
<option value="bank_transfer">{locale === 'es' ? 'Transferencia' : 'Bank Transfer'}</option>
|
||||||
|
<option value="tpago">TPago</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 pt-2">
|
||||||
|
<Button variant="outline" onClick={() => { setStatusFilter(''); setProviderFilter(''); setMobileFilterOpen(false); }} className="flex-1 min-h-[44px]">Clear</Button>
|
||||||
|
<Button onClick={() => setMobileFilterOpen(false)} className="flex-1 min-h-[44px]">Apply</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</BottomSheet>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<AdminMobileStyles />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -671,10 +671,11 @@ export default function AdminScannerPage() {
|
|||||||
|
|
||||||
// Load events
|
// Load events
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
eventsApi.getAll({ status: 'published' })
|
eventsApi.getAll()
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
setEvents(res.events);
|
const bookable = res.events.filter((e) => e.status === 'published' || e.status === 'unlisted');
|
||||||
const upcoming = res.events.filter((e) => new Date(e.startDatetime) >= new Date());
|
setEvents(bookable);
|
||||||
|
const upcoming = bookable.filter((e) => new Date(e.startDatetime) >= new Date());
|
||||||
if (upcoming.length === 1) {
|
if (upcoming.length === 1) {
|
||||||
setSelectedEventId(upcoming[0].id);
|
setSelectedEventId(upcoming[0].id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import { ticketsApi, eventsApi, Ticket, Event } from '@/lib/api';
|
|||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import Input from '@/components/ui/Input';
|
import Input from '@/components/ui/Input';
|
||||||
import { CheckCircleIcon, XCircleIcon, PlusIcon } from '@heroicons/react/24/outline';
|
import { BottomSheet, MoreMenu, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
|
||||||
|
import { CheckCircleIcon, XCircleIcon, PlusIcon, FunnelIcon, XMarkIcon } from '@heroicons/react/24/outline';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
|
|
||||||
@@ -17,26 +18,17 @@ export default function AdminTicketsPage() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [selectedEvent, setSelectedEvent] = useState<string>('');
|
const [selectedEvent, setSelectedEvent] = useState<string>('');
|
||||||
const [statusFilter, setStatusFilter] = useState<string>('');
|
const [statusFilter, setStatusFilter] = useState<string>('');
|
||||||
|
const [mobileFilterOpen, setMobileFilterOpen] = useState(false);
|
||||||
|
|
||||||
// Manual ticket creation state
|
|
||||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
const [createForm, setCreateForm] = useState({
|
const [createForm, setCreateForm] = useState({
|
||||||
eventId: '',
|
eventId: '', firstName: '', lastName: '', email: '', phone: '',
|
||||||
firstName: '',
|
preferredLanguage: 'en' as 'en' | 'es', autoCheckin: false, adminNote: '',
|
||||||
lastName: '',
|
|
||||||
email: '',
|
|
||||||
phone: '',
|
|
||||||
preferredLanguage: 'en' as 'en' | 'es',
|
|
||||||
autoCheckin: false,
|
|
||||||
adminNote: '',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([
|
Promise.all([ticketsApi.getAll(), eventsApi.getAll()])
|
||||||
ticketsApi.getAll(),
|
|
||||||
eventsApi.getAll(),
|
|
||||||
])
|
|
||||||
.then(([ticketsRes, eventsRes]) => {
|
.then(([ticketsRes, eventsRes]) => {
|
||||||
setTickets(ticketsRes.tickets);
|
setTickets(ticketsRes.tickets);
|
||||||
setEvents(eventsRes.events);
|
setEvents(eventsRes.events);
|
||||||
@@ -58,9 +50,7 @@ export default function AdminTicketsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!loading) {
|
if (!loading) loadTickets();
|
||||||
loadTickets();
|
|
||||||
}
|
|
||||||
}, [selectedEvent, statusFilter]);
|
}, [selectedEvent, statusFilter]);
|
||||||
|
|
||||||
const handleCheckin = async (id: string) => {
|
const handleCheckin = async (id: string) => {
|
||||||
@@ -75,7 +65,6 @@ export default function AdminTicketsPage() {
|
|||||||
|
|
||||||
const handleCancel = async (id: string) => {
|
const handleCancel = async (id: string) => {
|
||||||
if (!confirm('Are you sure you want to cancel this ticket?')) return;
|
if (!confirm('Are you sure you want to cancel this ticket?')) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await ticketsApi.cancel(id);
|
await ticketsApi.cancel(id);
|
||||||
toast.success('Ticket cancelled');
|
toast.success('Ticket cancelled');
|
||||||
@@ -97,35 +86,18 @@ export default function AdminTicketsPage() {
|
|||||||
|
|
||||||
const handleCreateTicket = async (e: React.FormEvent) => {
|
const handleCreateTicket = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!createForm.eventId) {
|
if (!createForm.eventId) { toast.error('Please select an event'); return; }
|
||||||
toast.error('Please select an event');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setCreating(true);
|
setCreating(true);
|
||||||
try {
|
try {
|
||||||
await ticketsApi.adminCreate({
|
await ticketsApi.adminCreate({
|
||||||
eventId: createForm.eventId,
|
eventId: createForm.eventId, firstName: createForm.firstName,
|
||||||
firstName: createForm.firstName,
|
lastName: createForm.lastName || undefined, email: createForm.email,
|
||||||
lastName: createForm.lastName || undefined,
|
phone: createForm.phone, preferredLanguage: createForm.preferredLanguage,
|
||||||
email: createForm.email,
|
autoCheckin: createForm.autoCheckin, adminNote: createForm.adminNote || undefined,
|
||||||
phone: createForm.phone,
|
|
||||||
preferredLanguage: createForm.preferredLanguage,
|
|
||||||
autoCheckin: createForm.autoCheckin,
|
|
||||||
adminNote: createForm.adminNote || undefined,
|
|
||||||
});
|
});
|
||||||
toast.success('Ticket created successfully');
|
toast.success('Ticket created successfully');
|
||||||
setShowCreateForm(false);
|
setShowCreateForm(false);
|
||||||
setCreateForm({
|
setCreateForm({ eventId: '', firstName: '', lastName: '', email: '', phone: '', preferredLanguage: 'en', autoCheckin: false, adminNote: '' });
|
||||||
eventId: '',
|
|
||||||
firstName: '',
|
|
||||||
lastName: '',
|
|
||||||
email: '',
|
|
||||||
phone: '',
|
|
||||||
preferredLanguage: 'en',
|
|
||||||
autoCheckin: false,
|
|
||||||
adminNote: '',
|
|
||||||
});
|
|
||||||
loadTickets();
|
loadTickets();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast.error(error.message || 'Failed to create ticket');
|
toast.error(error.message || 'Failed to create ticket');
|
||||||
@@ -136,33 +108,29 @@ export default function AdminTicketsPage() {
|
|||||||
|
|
||||||
const formatDate = (dateStr: string) => {
|
const formatDate = (dateStr: string) => {
|
||||||
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||||
month: 'short',
|
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', timeZone: 'America/Asuncion',
|
||||||
day: 'numeric',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
timeZone: 'America/Asuncion',
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStatusBadge = (status: string) => {
|
const getStatusBadge = (status: string) => {
|
||||||
const styles: Record<string, string> = {
|
const styles: Record<string, string> = {
|
||||||
pending: 'badge-warning',
|
pending: 'badge-warning', confirmed: 'badge-success', cancelled: 'badge-danger', checked_in: 'badge-info',
|
||||||
confirmed: 'badge-success',
|
|
||||||
cancelled: 'badge-danger',
|
|
||||||
checked_in: 'badge-info',
|
|
||||||
};
|
};
|
||||||
const labels: Record<string, string> = {
|
const labels: Record<string, string> = {
|
||||||
pending: t('admin.tickets.status.pending'),
|
pending: t('admin.tickets.status.pending'), confirmed: t('admin.tickets.status.confirmed'),
|
||||||
confirmed: t('admin.tickets.status.confirmed'),
|
cancelled: t('admin.tickets.status.cancelled'), checked_in: t('admin.tickets.status.checkedIn'),
|
||||||
cancelled: t('admin.tickets.status.cancelled'),
|
|
||||||
checked_in: t('admin.tickets.status.checkedIn'),
|
|
||||||
};
|
};
|
||||||
return <span className={`badge ${styles[status] || 'badge-gray'}`}>{labels[status] || status}</span>;
|
return <span className={`badge ${styles[status] || 'badge-gray'}`}>{labels[status] || status}</span>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getEventName = (eventId: string) => {
|
const getEventName = (eventId: string) => events.find(e => e.id === eventId)?.title || 'Unknown Event';
|
||||||
const event = events.find(e => e.id === eventId);
|
|
||||||
return event?.title || 'Unknown Event';
|
const hasActiveFilters = selectedEvent || statusFilter;
|
||||||
|
|
||||||
|
const getPrimaryAction = (ticket: Ticket) => {
|
||||||
|
if (ticket.status === 'pending') return { label: 'Confirm', onClick: () => handleConfirm(ticket.id) };
|
||||||
|
if (ticket.status === 'confirmed') return { label: t('admin.tickets.checkin'), onClick: () => handleCheckin(ticket.id) };
|
||||||
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -176,134 +144,86 @@ export default function AdminTicketsPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<h1 className="text-2xl font-bold text-primary-dark">{t('admin.tickets.title')}</h1>
|
<h1 className="text-xl md:text-2xl font-bold text-primary-dark">{t('admin.tickets.title')}</h1>
|
||||||
<Button onClick={() => setShowCreateForm(true)}>
|
<Button onClick={() => setShowCreateForm(true)} className="hidden md:flex">
|
||||||
<PlusIcon className="w-5 h-5 mr-2" />
|
<PlusIcon className="w-5 h-5 mr-2" /> Create Ticket
|
||||||
Create Ticket
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Manual Ticket Creation Modal */}
|
{/* Create Ticket Modal */}
|
||||||
{showCreateForm && (
|
{showCreateForm && (
|
||||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
<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 max-w-lg p-6">
|
<Card className="w-full md:max-w-lg max-h-[90vh] flex flex-col overflow-hidden rounded-t-2xl md:rounded-card">
|
||||||
<h2 className="text-xl font-bold mb-6">Create Ticket Manually</h2>
|
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray flex-shrink-0">
|
||||||
<form onSubmit={handleCreateTicket} className="space-y-4">
|
<h2 className="text-base font-bold">Create Ticket Manually</h2>
|
||||||
|
<button onClick={() => setShowCreateForm(false)}
|
||||||
|
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={handleCreateTicket} className="p-4 space-y-4 overflow-y-auto flex-1 min-h-0">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">Event *</label>
|
<label className="block text-sm font-medium mb-1">Event *</label>
|
||||||
<select
|
<select value={createForm.eventId}
|
||||||
value={createForm.eventId}
|
|
||||||
onChange={(e) => setCreateForm({ ...createForm, eventId: e.target.value })}
|
onChange={(e) => setCreateForm({ ...createForm, eventId: e.target.value })}
|
||||||
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray"
|
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray min-h-[44px]" required>
|
||||||
required
|
|
||||||
>
|
|
||||||
<option value="">Select an event</option>
|
<option value="">Select an event</option>
|
||||||
{events.filter(e => e.status === 'published').map((event) => (
|
{events.filter(e => e.status === 'published' || e.status === 'unlisted').map((event) => (
|
||||||
<option key={event.id} value={event.id}>
|
<option key={event.id} value={event.id}>{event.title} ({event.availableSeats} spots left)</option>
|
||||||
{event.title} ({event.availableSeats} spots left)
|
|
||||||
</option>
|
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<Input
|
<Input label="First Name *" value={createForm.firstName}
|
||||||
label="First Name *"
|
onChange={(e) => setCreateForm({ ...createForm, firstName: e.target.value })} required placeholder="First name" />
|
||||||
value={createForm.firstName}
|
<Input label="Last Name" value={createForm.lastName}
|
||||||
onChange={(e) => setCreateForm({ ...createForm, firstName: e.target.value })}
|
onChange={(e) => setCreateForm({ ...createForm, lastName: e.target.value })} placeholder="Last name" />
|
||||||
required
|
|
||||||
placeholder="First name"
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
label="Last Name (optional)"
|
|
||||||
value={createForm.lastName}
|
|
||||||
onChange={(e) => setCreateForm({ ...createForm, lastName: e.target.value })}
|
|
||||||
placeholder="Last name"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
<Input label="Email (optional)" type="email" value={createForm.email}
|
||||||
<Input
|
onChange={(e) => setCreateForm({ ...createForm, email: e.target.value })} placeholder="attendee@email.com" />
|
||||||
label="Email (optional)"
|
<Input label="Phone (optional)" value={createForm.phone}
|
||||||
type="email"
|
onChange={(e) => setCreateForm({ ...createForm, phone: e.target.value })} placeholder="+595 XXX XXX XXX" />
|
||||||
value={createForm.email}
|
|
||||||
onChange={(e) => setCreateForm({ ...createForm, email: e.target.value })}
|
|
||||||
placeholder="attendee@email.com"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
label="Phone (optional)"
|
|
||||||
value={createForm.phone}
|
|
||||||
onChange={(e) => setCreateForm({ ...createForm, phone: e.target.value })}
|
|
||||||
placeholder="+595 XXX XXX XXX"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">Preferred Language</label>
|
<label className="block text-sm font-medium mb-1">Preferred Language</label>
|
||||||
<select
|
<select value={createForm.preferredLanguage}
|
||||||
value={createForm.preferredLanguage}
|
|
||||||
onChange={(e) => setCreateForm({ ...createForm, preferredLanguage: e.target.value as 'en' | 'es' })}
|
onChange={(e) => setCreateForm({ ...createForm, preferredLanguage: e.target.value as 'en' | 'es' })}
|
||||||
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray"
|
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray min-h-[44px]">
|
||||||
>
|
|
||||||
<option value="en">English</option>
|
<option value="en">English</option>
|
||||||
<option value="es">Spanish</option>
|
<option value="es">Spanish</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">Admin Note</label>
|
<label className="block text-sm font-medium mb-1">Admin Note</label>
|
||||||
<textarea
|
<textarea value={createForm.adminNote}
|
||||||
value={createForm.adminNote}
|
|
||||||
onChange={(e) => setCreateForm({ ...createForm, adminNote: e.target.value })}
|
onChange={(e) => setCreateForm({ ...createForm, adminNote: e.target.value })}
|
||||||
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray"
|
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray" rows={2}
|
||||||
rows={2}
|
placeholder="Internal note (optional)" />
|
||||||
placeholder="Internal note about this booking (optional)"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex items-center gap-2">
|
<input type="checkbox" id="autoCheckin" checked={createForm.autoCheckin}
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
id="autoCheckin"
|
|
||||||
checked={createForm.autoCheckin}
|
|
||||||
onChange={(e) => setCreateForm({ ...createForm, autoCheckin: e.target.checked })}
|
onChange={(e) => setCreateForm({ ...createForm, autoCheckin: e.target.checked })}
|
||||||
className="w-4 h-4"
|
className="w-4 h-4 rounded border-secondary-light-gray text-primary-yellow focus:ring-primary-yellow" />
|
||||||
/>
|
<label htmlFor="autoCheckin" className="text-sm">Auto check-in immediately</label>
|
||||||
<label htmlFor="autoCheckin" className="text-sm">
|
|
||||||
Automatically check in (mark as present)
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-yellow-50 border border-yellow-200 rounded-btn p-3 text-sm text-yellow-800">
|
<div className="bg-yellow-50 border border-yellow-200 rounded-btn p-3 text-sm text-yellow-800">
|
||||||
Note: This creates a ticket with cash payment marked as paid. Use this for walk-ins at the door. Email and phone are optional for door entries.
|
Creates a ticket with cash payment marked as paid. Use for walk-ins at the door.
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex gap-3 pt-2">
|
||||||
<div className="flex gap-3 pt-4">
|
<Button type="button" variant="outline" onClick={() => setShowCreateForm(false)} className="flex-1 min-h-[44px]">Cancel</Button>
|
||||||
<Button type="submit" isLoading={creating}>
|
<Button type="submit" isLoading={creating} className="flex-1 min-h-[44px]">Create Ticket</Button>
|
||||||
Create Ticket
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setShowCreateForm(false)}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Desktop Filters */}
|
||||||
<Card className="p-4 mb-6">
|
<Card className="p-4 mb-6 hidden md:block">
|
||||||
<div className="flex flex-wrap gap-4">
|
<div className="flex flex-wrap gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">Event</label>
|
<label className="block text-sm font-medium mb-1">Event</label>
|
||||||
<select
|
<select value={selectedEvent} onChange={(e) => setSelectedEvent(e.target.value)}
|
||||||
value={selectedEvent}
|
className="px-4 py-2 rounded-btn border border-secondary-light-gray min-w-[200px] text-sm">
|
||||||
onChange={(e) => setSelectedEvent(e.target.value)}
|
|
||||||
className="px-4 py-2 rounded-btn border border-secondary-light-gray min-w-[200px]"
|
|
||||||
>
|
|
||||||
<option value="">All Events</option>
|
<option value="">All Events</option>
|
||||||
{events.map((event) => (
|
{events.map((event) => (
|
||||||
<option key={event.id} value={event.id}>{event.title}</option>
|
<option key={event.id} value={event.id}>{event.title}</option>
|
||||||
@@ -312,11 +232,8 @@ export default function AdminTicketsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">Status</label>
|
<label className="block text-sm font-medium mb-1">Status</label>
|
||||||
<select
|
<select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}
|
||||||
value={statusFilter}
|
className="px-4 py-2 rounded-btn border border-secondary-light-gray min-w-[150px] text-sm">
|
||||||
onChange={(e) => setStatusFilter(e.target.value)}
|
|
||||||
className="px-4 py-2 rounded-btn border border-secondary-light-gray min-w-[150px]"
|
|
||||||
>
|
|
||||||
<option value="">All Statuses</option>
|
<option value="">All Statuses</option>
|
||||||
<option value="pending">Pending</option>
|
<option value="pending">Pending</option>
|
||||||
<option value="confirmed">Confirmed</option>
|
<option value="confirmed">Confirmed</option>
|
||||||
@@ -327,70 +244,61 @@ export default function AdminTicketsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Tickets Table */}
|
{/* Mobile Toolbar */}
|
||||||
<Card className="overflow-hidden">
|
<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]',
|
||||||
|
hasActiveFilters ? 'border-primary-yellow bg-yellow-50 text-primary-dark' : 'border-secondary-light-gray text-gray-600'
|
||||||
|
)}>
|
||||||
|
<FunnelIcon className="w-4 h-4" />
|
||||||
|
Filters {hasActiveFilters && `(${tickets.length})`}
|
||||||
|
</button>
|
||||||
|
{hasActiveFilters && (
|
||||||
|
<button onClick={() => { setSelectedEvent(''); setStatusFilter(''); }}
|
||||||
|
className="text-xs text-primary-yellow min-h-[44px] flex items-center">Clear</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop: Table */}
|
||||||
|
<Card className="overflow-hidden hidden md:block">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead className="bg-secondary-gray">
|
<thead className="bg-secondary-gray">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Ticket</th>
|
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Ticket</th>
|
||||||
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Event</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-6 py-3 text-sm font-medium text-gray-600">Booked</th>
|
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Booked</th>
|
||||||
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Status</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-6 py-3 text-sm font-medium text-gray-600">Actions</th>
|
<th className="text-right px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-secondary-light-gray">
|
<tbody className="divide-y divide-secondary-light-gray">
|
||||||
{tickets.length === 0 ? (
|
{tickets.length === 0 ? (
|
||||||
<tr>
|
<tr><td colSpan={5} className="px-4 py-12 text-center text-gray-500 text-sm">No tickets found</td></tr>
|
||||||
<td colSpan={5} className="px-6 py-12 text-center text-gray-500">
|
|
||||||
No tickets found
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : (
|
) : (
|
||||||
tickets.map((ticket) => (
|
tickets.map((ticket) => (
|
||||||
<tr key={ticket.id} className="hover:bg-gray-50">
|
<tr key={ticket.id} className="hover:bg-gray-50">
|
||||||
<td className="px-6 py-4">
|
<td className="px-4 py-3">
|
||||||
<div>
|
|
||||||
<p className="font-mono text-sm font-medium">{ticket.qrCode}</p>
|
<p className="font-mono text-sm font-medium">{ticket.qrCode}</p>
|
||||||
<p className="text-xs text-gray-500">ID: {ticket.id.slice(0, 8)}...</p>
|
<p className="text-[10px] text-gray-400">ID: {ticket.id.slice(0, 8)}...</p>
|
||||||
</div>
|
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 text-sm">
|
<td className="px-4 py-3 text-sm">{getEventName(ticket.eventId)}</td>
|
||||||
{getEventName(ticket.eventId)}
|
<td className="px-4 py-3 text-xs text-gray-500">{formatDate(ticket.createdAt)}</td>
|
||||||
</td>
|
<td className="px-4 py-3">{getStatusBadge(ticket.status)}</td>
|
||||||
<td className="px-6 py-4 text-sm text-gray-600">
|
<td className="px-4 py-3">
|
||||||
{formatDate(ticket.createdAt)}
|
<div className="flex items-center justify-end gap-1">
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
{getStatusBadge(ticket.status)}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
<div className="flex items-center justify-end gap-2">
|
|
||||||
{ticket.status === 'pending' && (
|
{ticket.status === 'pending' && (
|
||||||
<Button
|
<Button size="sm" variant="ghost" onClick={() => handleConfirm(ticket.id)}>Confirm</Button>
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => handleConfirm(ticket.id)}
|
|
||||||
>
|
|
||||||
Confirm
|
|
||||||
</Button>
|
|
||||||
)}
|
)}
|
||||||
{ticket.status === 'confirmed' && (
|
{ticket.status === 'confirmed' && (
|
||||||
<Button
|
<Button size="sm" onClick={() => handleCheckin(ticket.id)}>
|
||||||
size="sm"
|
<CheckCircleIcon className="w-4 h-4 mr-1" /> {t('admin.tickets.checkin')}
|
||||||
onClick={() => handleCheckin(ticket.id)}
|
|
||||||
>
|
|
||||||
<CheckCircleIcon className="w-4 h-4 mr-1" />
|
|
||||||
{t('admin.tickets.checkin')}
|
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{ticket.status !== 'cancelled' && ticket.status !== 'checked_in' && (
|
{ticket.status !== 'cancelled' && ticket.status !== 'checked_in' && (
|
||||||
<button
|
<button onClick={() => handleCancel(ticket.id)}
|
||||||
onClick={() => handleCancel(ticket.id)}
|
className="p-2 hover:bg-red-100 text-red-600 rounded-btn" title="Cancel">
|
||||||
className="p-2 hover:bg-red-100 text-red-600 rounded-btn"
|
|
||||||
title="Cancel"
|
|
||||||
>
|
|
||||||
<XCircleIcon className="w-4 h-4" />
|
<XCircleIcon className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -403,6 +311,102 @@ export default function AdminTicketsPage() {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Mobile: Card List */}
|
||||||
|
<div className="md:hidden space-y-2">
|
||||||
|
{tickets.length === 0 ? (
|
||||||
|
<div className="text-center py-10 text-gray-500 text-sm">No tickets found</div>
|
||||||
|
) : (
|
||||||
|
tickets.map((ticket) => {
|
||||||
|
const primary = getPrimaryAction(ticket);
|
||||||
|
return (
|
||||||
|
<Card key={ticket.id} className="p-3">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="font-mono text-sm font-medium">{ticket.qrCode}</p>
|
||||||
|
<p className="text-xs text-gray-500 truncate">{getEventName(ticket.eventId)}</p>
|
||||||
|
</div>
|
||||||
|
{getStatusBadge(ticket.status)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between mt-2 pt-2 border-t border-gray-100">
|
||||||
|
<p className="text-[10px] text-gray-400">{formatDate(ticket.createdAt)}</p>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{primary && (
|
||||||
|
<Button size="sm" variant={ticket.status === 'confirmed' ? 'primary' : 'outline'}
|
||||||
|
onClick={primary.onClick} className="text-xs px-2.5 py-1.5 min-h-[36px]">
|
||||||
|
{primary.label}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{ticket.status !== 'cancelled' && ticket.status !== 'checked_in' && (
|
||||||
|
<MoreMenu>
|
||||||
|
<DropdownItem onClick={() => handleCancel(ticket.id)} className="text-red-600">
|
||||||
|
<XCircleIcon className="w-4 h-4 mr-2" /> Cancel Ticket
|
||||||
|
</DropdownItem>
|
||||||
|
</MoreMenu>
|
||||||
|
)}
|
||||||
|
{ticket.status === 'checked_in' && (
|
||||||
|
<span className="text-[10px] text-green-600 flex items-center gap-1">
|
||||||
|
<CheckCircleIcon className="w-3.5 h-3.5" /> Attended
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile FAB */}
|
||||||
|
<div className="md:hidden fixed bottom-6 right-6 z-40">
|
||||||
|
<button onClick={() => setShowCreateForm(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>
|
||||||
|
|
||||||
|
{/* Mobile Filter BottomSheet */}
|
||||||
|
<BottomSheet open={mobileFilterOpen} onClose={() => setMobileFilterOpen(false)} title="Filters">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Event</label>
|
||||||
|
<select value={selectedEvent} onChange={(e) => setSelectedEvent(e.target.value)}
|
||||||
|
className="w-full px-3 py-2.5 rounded-btn border border-secondary-light-gray text-sm min-h-[44px]">
|
||||||
|
<option value="">All Events</option>
|
||||||
|
{events.map((event) => (
|
||||||
|
<option key={event.id} value={event.id}>{event.title}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Status</label>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{[
|
||||||
|
{ value: '', label: 'All Statuses' },
|
||||||
|
{ value: 'pending', label: 'Pending' },
|
||||||
|
{ value: 'confirmed', label: 'Confirmed' },
|
||||||
|
{ value: 'checked_in', label: 'Checked In' },
|
||||||
|
{ value: 'cancelled', label: 'Cancelled' },
|
||||||
|
].map((opt) => (
|
||||||
|
<button key={opt.value} onClick={() => setStatusFilter(opt.value)}
|
||||||
|
className={clsx(
|
||||||
|
'w-full text-left px-3 py-2.5 rounded-btn text-sm min-h-[44px] flex items-center justify-between',
|
||||||
|
statusFilter === opt.value ? 'bg-yellow-50 text-primary-dark font-medium' : 'hover:bg-gray-50'
|
||||||
|
)}>
|
||||||
|
{opt.label}
|
||||||
|
{statusFilter === opt.value && <CheckCircleIcon className="w-4 h-4 text-primary-yellow" />}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 pt-2">
|
||||||
|
<Button variant="outline" onClick={() => { setSelectedEvent(''); setStatusFilter(''); setMobileFilterOpen(false); }} className="flex-1 min-h-[44px]">Clear</Button>
|
||||||
|
<Button onClick={() => setMobileFilterOpen(false)} className="flex-1 min-h-[44px]">Apply</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</BottomSheet>
|
||||||
|
|
||||||
|
<AdminMobileStyles />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,11 @@ import { usersApi, User } from '@/lib/api';
|
|||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import Input from '@/components/ui/Input';
|
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 toast from 'react-hot-toast';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
export default function AdminUsersPage() {
|
export default function AdminUsersPage() {
|
||||||
const { t, locale } = useLanguage();
|
const { t, locale } = useLanguage();
|
||||||
@@ -24,6 +27,7 @@ export default function AdminUsersPage() {
|
|||||||
accountStatus: '' as string,
|
accountStatus: '' as string,
|
||||||
});
|
});
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [mobileFilterOpen, setMobileFilterOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadUsers();
|
loadUsers();
|
||||||
@@ -52,7 +56,6 @@ export default function AdminUsersPage() {
|
|||||||
|
|
||||||
const handleDelete = async (userId: string) => {
|
const handleDelete = async (userId: string) => {
|
||||||
if (!confirm('Are you sure you want to delete this user?')) return;
|
if (!confirm('Are you sure you want to delete this user?')) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await usersApi.delete(userId);
|
await usersApi.delete(userId);
|
||||||
toast.success('User deleted');
|
toast.success('User deleted');
|
||||||
@@ -65,11 +68,8 @@ export default function AdminUsersPage() {
|
|||||||
const openEditModal = (user: User) => {
|
const openEditModal = (user: User) => {
|
||||||
setEditingUser(user);
|
setEditingUser(user);
|
||||||
setEditForm({
|
setEditForm({
|
||||||
name: user.name,
|
name: user.name, email: user.email, phone: user.phone || '',
|
||||||
email: user.email,
|
role: user.role, languagePreference: user.languagePreference || '',
|
||||||
phone: user.phone || '',
|
|
||||||
role: user.role,
|
|
||||||
languagePreference: user.languagePreference || '',
|
|
||||||
accountStatus: user.accountStatus || 'active',
|
accountStatus: user.accountStatus || 'active',
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -77,7 +77,6 @@ export default function AdminUsersPage() {
|
|||||||
const handleEditSubmit = async (e: React.FormEvent) => {
|
const handleEditSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!editingUser) return;
|
if (!editingUser) return;
|
||||||
|
|
||||||
if (!editForm.name.trim() || editForm.name.trim().length < 2) {
|
if (!editForm.name.trim() || editForm.name.trim().length < 2) {
|
||||||
toast.error('Name must be at least 2 characters');
|
toast.error('Name must be at least 2 characters');
|
||||||
return;
|
return;
|
||||||
@@ -86,14 +85,11 @@ export default function AdminUsersPage() {
|
|||||||
toast.error('Email is required');
|
toast.error('Email is required');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
await usersApi.update(editingUser.id, {
|
await usersApi.update(editingUser.id, {
|
||||||
name: editForm.name.trim(),
|
name: editForm.name.trim(), email: editForm.email.trim(),
|
||||||
email: editForm.email.trim(),
|
phone: editForm.phone.trim() || undefined, role: editForm.role,
|
||||||
phone: editForm.phone.trim() || undefined,
|
|
||||||
role: editForm.role,
|
|
||||||
languagePreference: editForm.languagePreference || undefined,
|
languagePreference: editForm.languagePreference || undefined,
|
||||||
accountStatus: editForm.accountStatus || undefined,
|
accountStatus: editForm.accountStatus || undefined,
|
||||||
} as Partial<User>);
|
} as Partial<User>);
|
||||||
@@ -109,20 +105,14 @@ export default function AdminUsersPage() {
|
|||||||
|
|
||||||
const formatDate = (dateStr: string) => {
|
const formatDate = (dateStr: string) => {
|
||||||
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||||
year: 'numeric',
|
year: 'numeric', month: 'short', day: 'numeric', timeZone: 'America/Asuncion',
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
timeZone: 'America/Asuncion',
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const getRoleBadge = (role: string) => {
|
const getRoleBadge = (role: string) => {
|
||||||
const styles: Record<string, string> = {
|
const styles: Record<string, string> = {
|
||||||
admin: 'badge-danger',
|
admin: 'badge-danger', organizer: 'badge-info', staff: 'badge-warning',
|
||||||
organizer: 'badge-info',
|
marketing: 'badge-success', user: 'badge-gray',
|
||||||
staff: 'badge-warning',
|
|
||||||
marketing: 'badge-success',
|
|
||||||
user: 'badge-gray',
|
|
||||||
};
|
};
|
||||||
return <span className={`badge ${styles[role] || 'badge-gray'}`}>{t(`admin.users.roles.${role}`)}</span>;
|
return <span className={`badge ${styles[role] || 'badge-gray'}`}>{t(`admin.users.roles.${role}`)}</span>;
|
||||||
};
|
};
|
||||||
@@ -138,19 +128,16 @@ export default function AdminUsersPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-6">
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Desktop Filters */}
|
||||||
<Card className="p-4 mb-6">
|
<Card className="p-4 mb-6 hidden md:block">
|
||||||
<div className="flex flex-wrap gap-4">
|
<div className="flex flex-wrap gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">{t('admin.users.role')}</label>
|
<label className="block text-sm font-medium mb-1">{t('admin.users.role')}</label>
|
||||||
<select
|
<select value={roleFilter} onChange={(e) => setRoleFilter(e.target.value)}
|
||||||
value={roleFilter}
|
className="px-4 py-2 rounded-btn border border-secondary-light-gray min-w-[150px] text-sm">
|
||||||
onChange={(e) => setRoleFilter(e.target.value)}
|
|
||||||
className="px-4 py-2 rounded-btn border border-secondary-light-gray min-w-[150px]"
|
|
||||||
>
|
|
||||||
<option value="">All Roles</option>
|
<option value="">All Roles</option>
|
||||||
<option value="admin">{t('admin.users.roles.admin')}</option>
|
<option value="admin">{t('admin.users.roles.admin')}</option>
|
||||||
<option value="organizer">{t('admin.users.roles.organizer')}</option>
|
<option value="organizer">{t('admin.users.roles.organizer')}</option>
|
||||||
@@ -162,51 +149,58 @@ export default function AdminUsersPage() {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Users Table */}
|
{/* Mobile Toolbar */}
|
||||||
<Card className="overflow-hidden">
|
<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">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead className="bg-secondary-gray">
|
<thead className="bg-secondary-gray">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">User</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-6 py-3 text-sm font-medium text-gray-600">Contact</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-6 py-3 text-sm font-medium text-gray-600">Role</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-6 py-3 text-sm font-medium text-gray-600">Joined</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-6 py-3 text-sm font-medium text-gray-600">Actions</th>
|
<th className="text-right px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-secondary-light-gray">
|
<tbody className="divide-y divide-secondary-light-gray">
|
||||||
{users.length === 0 ? (
|
{users.length === 0 ? (
|
||||||
<tr>
|
<tr><td colSpan={5} className="px-4 py-12 text-center text-gray-500 text-sm">No users found</td></tr>
|
||||||
<td colSpan={5} className="px-6 py-12 text-center text-gray-500">
|
|
||||||
No users found
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : (
|
) : (
|
||||||
users.map((user) => (
|
users.map((user) => (
|
||||||
<tr key={user.id} className="hover:bg-gray-50">
|
<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="flex items-center gap-3">
|
||||||
<div className="w-10 h-10 bg-primary-yellow/20 rounded-full flex items-center justify-center">
|
<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-primary-dark">
|
<span className="font-semibold text-sm text-primary-dark">{user.name.charAt(0).toUpperCase()}</span>
|
||||||
{user.name.charAt(0).toUpperCase()}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">{user.name}</p>
|
<p className="font-medium text-sm">{user.name}</p>
|
||||||
<p className="text-sm text-gray-500">{user.email}</p>
|
<p className="text-xs text-gray-500">{user.email}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 text-sm text-gray-600">
|
<td className="px-4 py-3 text-sm text-gray-600">{user.phone || '-'}</td>
|
||||||
{user.phone || '-'}
|
<td className="px-4 py-3">
|
||||||
</td>
|
<select value={user.role} onChange={(e) => handleRoleChange(user.id, e.target.value)}
|
||||||
<td className="px-6 py-4">
|
className="px-2 py-1 rounded border border-secondary-light-gray text-sm">
|
||||||
<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="user">{t('admin.users.roles.user')}</option>
|
||||||
<option value="staff">{t('admin.users.roles.staff')}</option>
|
<option value="staff">{t('admin.users.roles.staff')}</option>
|
||||||
<option value="marketing">{t('admin.users.roles.marketing')}</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>
|
<option value="admin">{t('admin.users.roles.admin')}</option>
|
||||||
</select>
|
</select>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 text-sm text-gray-600">
|
<td className="px-4 py-3 text-xs text-gray-500">{formatDate(user.createdAt)}</td>
|
||||||
{formatDate(user.createdAt)}
|
<td className="px-4 py-3">
|
||||||
</td>
|
<div className="flex items-center justify-end gap-1">
|
||||||
<td className="px-6 py-4">
|
<button onClick={() => openEditModal(user)}
|
||||||
<div className="flex items-center justify-end gap-2">
|
className="p-2 hover:bg-blue-100 text-blue-600 rounded-btn" title="Edit">
|
||||||
<button
|
|
||||||
onClick={() => openEditModal(user)}
|
|
||||||
className="p-2 hover:bg-blue-100 text-blue-600 rounded-btn"
|
|
||||||
title="Edit"
|
|
||||||
>
|
|
||||||
<PencilSquareIcon className="w-4 h-4" />
|
<PencilSquareIcon className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button onClick={() => handleDelete(user.id)}
|
||||||
onClick={() => handleDelete(user.id)}
|
className="p-2 hover:bg-red-100 text-red-600 rounded-btn" title="Delete">
|
||||||
className="p-2 hover:bg-red-100 text-red-600 rounded-btn"
|
|
||||||
title="Delete"
|
|
||||||
>
|
|
||||||
<TrashIcon className="w-4 h-4" />
|
<TrashIcon className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -243,43 +229,90 @@ export default function AdminUsersPage() {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</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 */}
|
{/* Edit User Modal */}
|
||||||
{editingUser && (
|
{editingUser && (
|
||||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
<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 max-w-lg max-h-[90vh] overflow-y-auto p-6">
|
<Card className="w-full md:max-w-lg max-h-[90vh] flex flex-col overflow-hidden rounded-t-2xl md:rounded-card">
|
||||||
<h2 className="text-xl font-bold text-primary-dark mb-6">Edit User</h2>
|
<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>
|
||||||
<form onSubmit={handleEditSubmit} className="space-y-4">
|
<button onClick={() => setEditingUser(null)}
|
||||||
<Input
|
className="p-2 hover:bg-gray-100 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center">
|
||||||
label="Name"
|
<XMarkIcon className="w-5 h-5" />
|
||||||
value={editForm.name}
|
</button>
|
||||||
onChange={(e) => setEditForm({ ...editForm, name: e.target.value })}
|
</div>
|
||||||
required
|
<form onSubmit={handleEditSubmit} className="p-4 space-y-4 overflow-y-auto flex-1 min-h-0">
|
||||||
minLength={2}
|
<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}
|
||||||
<Input
|
onChange={(e) => setEditForm({ ...editForm, email: e.target.value })} required />
|
||||||
label="Email"
|
<Input label="Phone" value={editForm.phone}
|
||||||
type="email"
|
onChange={(e) => setEditForm({ ...editForm, phone: e.target.value })} placeholder="Optional" />
|
||||||
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>
|
<div>
|
||||||
<label className="block text-sm font-medium text-primary-dark mb-1.5">Role</label>
|
<label className="block text-sm font-medium text-primary-dark mb-1.5">Role</label>
|
||||||
<select
|
<select value={editForm.role} onChange={(e) => setEditForm({ ...editForm, role: e.target.value as User['role'] })}
|
||||||
value={editForm.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]">
|
||||||
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"
|
|
||||||
>
|
|
||||||
<option value="user">{t('admin.users.roles.user')}</option>
|
<option value="user">{t('admin.users.roles.user')}</option>
|
||||||
<option value="staff">{t('admin.users.roles.staff')}</option>
|
<option value="staff">{t('admin.users.roles.staff')}</option>
|
||||||
<option value="marketing">{t('admin.users.roles.marketing')}</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>
|
<option value="admin">{t('admin.users.roles.admin')}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-primary-dark mb-1.5">Language Preference</label>
|
<label className="block text-sm font-medium text-primary-dark mb-1.5">Language Preference</label>
|
||||||
<select
|
<select value={editForm.languagePreference}
|
||||||
value={editForm.languagePreference}
|
|
||||||
onChange={(e) => setEditForm({ ...editForm, languagePreference: e.target.value })}
|
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="">Not set</option>
|
||||||
<option value="en">English</option>
|
<option value="en">English</option>
|
||||||
<option value="es">Español</option>
|
<option value="es">Español</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-primary-dark mb-1.5">Account Status</label>
|
<label className="block text-sm font-medium text-primary-dark mb-1.5">Account Status</label>
|
||||||
<select
|
<select value={editForm.accountStatus}
|
||||||
value={editForm.accountStatus}
|
|
||||||
onChange={(e) => setEditForm({ ...editForm, accountStatus: e.target.value })}
|
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="active">Active</option>
|
||||||
<option value="unclaimed">Unclaimed</option>
|
<option value="unclaimed">Unclaimed</option>
|
||||||
<option value="suspended">Suspended</option>
|
<option value="suspended">Suspended</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex gap-3 pt-2">
|
||||||
<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)} className="flex-1 min-h-[44px]">Cancel</Button>
|
||||||
<Button
|
<Button type="submit" isLoading={saving} className="flex-1 min-h-[44px]">Save Changes</Button>
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setEditingUser(null)}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" isLoading={saving}>
|
|
||||||
Save Changes
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<AdminMobileStyles />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,16 @@ export default function LinktreePage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
eventsApi.getNextUpcoming()
|
eventsApi.getNextUpcoming()
|
||||||
.then(({ event }) => setNextEvent(event))
|
.then(({ event }) => {
|
||||||
|
if (event) {
|
||||||
|
const endTime = event.endDatetime || event.startDatetime;
|
||||||
|
if (new Date(endTime).getTime() <= Date.now()) {
|
||||||
|
setNextEvent(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setNextEvent(event);
|
||||||
|
})
|
||||||
.catch(console.error)
|
.catch(console.error)
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
183
frontend/src/components/admin/MobileComponents.tsx
Normal file
183
frontend/src/components/admin/MobileComponents.tsx
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -516,7 +516,7 @@ export interface Event {
|
|||||||
price: number;
|
price: number;
|
||||||
currency: string;
|
currency: string;
|
||||||
capacity: number;
|
capacity: number;
|
||||||
status: 'draft' | 'published' | 'cancelled' | 'completed' | 'archived';
|
status: 'draft' | 'published' | 'unlisted' | 'cancelled' | 'completed' | 'archived';
|
||||||
bannerUrl?: string;
|
bannerUrl?: string;
|
||||||
externalBookingEnabled?: boolean;
|
externalBookingEnabled?: boolean;
|
||||||
externalBookingUrl?: string;
|
externalBookingUrl?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user