diff --git a/src/app/admin/banners/banners-manager.tsx b/src/app/admin/banners/banners-manager.tsx new file mode 100644 index 00000000..39f0019b --- /dev/null +++ b/src/app/admin/banners/banners-manager.tsx @@ -0,0 +1,253 @@ +'use client' + +import { Pencil, Plus, Trash2 } from 'lucide-react' +import { useState } from 'react' +import { createBanner, deleteBanner, updateBanner } from '@/actions/banners' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { DateTimePicker } from '@/components/ui/date-time-picker' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Switch } from '@/components/ui/switch' +import { useServerAction } from '@/hooks/use-server-action' + +interface Banner { + id: number + title: string + subtitle: string + image: string + link: string + color: string + isActive: number + sortOrder: number + startDate: string | null + endDate: string | null +} + +interface Props { + banners: Banner[] +} + +export function BannersManager({ banners }: Props) { + const { run, isPending } = useServerAction() + const [showForm, setShowForm] = useState(false) + const [editingId, setEditingId] = useState(null) + const [form, setForm] = useState({ + title: '', + subtitle: '', + image: '', + link: '', + color: '', + isActive: 1, + sortOrder: 0, + startDate: '' as string | null, + endDate: '' as string | null, + }) + + function resetForm() { + setForm({ + title: '', + subtitle: '', + image: '', + link: '', + color: '', + isActive: 1, + sortOrder: 0, + startDate: '', + endDate: '', + }) + setEditingId(null) + setShowForm(false) + } + + function handleEdit(b: Banner) { + setForm({ + title: b.title, + subtitle: b.subtitle, + image: b.image, + link: b.link, + color: b.color, + isActive: b.isActive, + sortOrder: b.sortOrder, + startDate: b.startDate, + endDate: b.endDate, + }) + setEditingId(b.id) + setShowForm(true) + } + + function handleSubmit(e: React.FormEvent) { + e.preventDefault() + const data = { ...form, startDate: form.startDate || null, endDate: form.endDate || null } + if (editingId) { + run(() => updateBanner({ id: editingId, ...data }), { successMessage: 'Banner updated!' }) + } else { + run(() => createBanner(data), { successMessage: 'Banner created!' }) + } + resetForm() + } + + return ( +
+
+ +
+ + {showForm && ( + + + {editingId ? 'Edit Banner' : 'New Banner'} + + +
+
+
+ + setForm({ ...form, title: e.target.value })} + required + /> +
+
+ + setForm({ ...form, subtitle: e.target.value })} + /> +
+
+
+
+ + setForm({ ...form, image: e.target.value })} + required + /> +
+
+ + setForm({ ...form, link: e.target.value })} + /> +
+
+
+
+ + setForm({ ...form, color: e.target.value })} + /> +
+
+ + setForm({ ...form, startDate: d ? d.toISOString() : null })} + placeholder="Select start date" + /> +
+
+ + setForm({ ...form, endDate: d ? d.toISOString() : null })} + placeholder="Select end date" + /> +
+
+
+
+ setForm({ ...form, isActive: v ? 1 : 0 })} + /> + +
+
+ + setForm({ ...form, sortOrder: Number(e.target.value) })} + /> +
+
+
+ + +
+
+
+
+ )} + +
+ {banners.length === 0 && ( +

No banners yet.

+ )} + {banners.map((b) => ( + + {b.image && ( +
+ )} + +
+
+

{b.title}

+ {b.subtitle &&

{b.subtitle}

} +
+ + {b.isActive ? 'Active' : 'Inactive'} + + {b.startDate && Scheduled} +
+
+
+ + +
+
+
+ + ))} +
+
+ ) +} diff --git a/src/app/admin/banners/page.tsx b/src/app/admin/banners/page.tsx new file mode 100644 index 00000000..f45bc7b6 --- /dev/null +++ b/src/app/admin/banners/page.tsx @@ -0,0 +1,25 @@ +import { redirect } from 'next/navigation' +import { canAccess, getAdminContext, PERMS } from '@/lib/permissions' +import { prisma } from '@/lib/prisma' +import { BannersManager } from './banners-manager' + +export default async function BannersPage() { + const { session, permissions } = await getAdminContext() + if (!canAccess(permissions, PERMS.BANNERS_VIEW, session.user.rank)) { + redirect('/admin') + } + + const banners = await prisma.websiteBanner.findMany({ + orderBy: { sortOrder: 'asc' }, + }) + + return ( +
+
+

Banners

+

Manage homepage banners and promotional slides

+
+ +
+ ) +} diff --git a/src/app/admin/events/[id]/event-prizes.tsx b/src/app/admin/events/[id]/event-prizes.tsx new file mode 100644 index 00000000..6530057c --- /dev/null +++ b/src/app/admin/events/[id]/event-prizes.tsx @@ -0,0 +1,177 @@ +'use client' + +import { Plus, Trash2 } from 'lucide-react' +import { useState } from 'react' +import { addEventPrize, deleteEventPrize } from '@/actions/events' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { useServerAction } from '@/hooks/use-server-action' + +interface Prize { + id: number + position: number + prizeType: string + badgeCode: string | null + credits: number + pixels: number + points: number + itemId: number | null + description: string +} + +interface Props { + eventId: number + prizes: Prize[] +} + +export function EventPrizesManager({ eventId, prizes }: Props) { + const { run, isPending } = useServerAction() + const [showForm, setShowForm] = useState(false) + const [form, setForm] = useState({ + position: prizes.length + 1, + prizeType: 'badge' as string, + badgeCode: '', + credits: 0, + pixels: 0, + points: 0, + description: '', + }) + + function handleAdd(e: React.FormEvent) { + e.preventDefault() + // biome-ignore lint/suspicious/noExplicitAny: explicitly chosen here, see surrounding code + run(() => addEventPrize({ eventId, ...form } as any), { + successMessage: 'Prize added!', + onSuccess: () => { + setShowForm(false) + setForm({ ...form, position: form.position + 1, badgeCode: '', description: '' }) + }, + }) + } + + return ( + + + Prizes + + + + {showForm && ( +
+
+
+ + setForm({ ...form, position: Number(e.target.value) })} + /> +
+
+ + +
+
+ + setForm({ ...form, badgeCode: e.target.value })} + placeholder="ACH_..." + /> +
+
+ + setForm({ ...form, credits: Number(e.target.value) })} + /> +
+
+
+ + setForm({ ...form, description: e.target.value })} + /> +
+
+ + +
+
+ )} + + {prizes.length === 0 && !showForm && ( +

+ No prizes configured yet. +

+ )} + + {prizes.map((prize) => ( +
+
+ + #{prize.position} + +
+
+ {prize.prizeType} + {prize.badgeCode && {prize.badgeCode}} + {prize.credits > 0 && {prize.credits} credits} + {prize.pixels > 0 && {prize.pixels} pixels} + {prize.points > 0 && {prize.points} points} +
+ {prize.description && ( +

{prize.description}

+ )} +
+
+ +
+ ))} +
+
+ ) +} diff --git a/src/app/admin/events/[id]/event-registrations.tsx b/src/app/admin/events/[id]/event-registrations.tsx new file mode 100644 index 00000000..2cf5f646 --- /dev/null +++ b/src/app/admin/events/[id]/event-registrations.tsx @@ -0,0 +1,40 @@ +'use client' + +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' + +interface Registration { + id: number + userId: number + username: string + createdAt: Date +} + +interface Props { + registrations: Registration[] +} + +export function EventRegistrations({ registrations }: Props) { + return ( + + + Registrations ({registrations.length}) + + + {registrations.length === 0 ? ( +

No registrations yet.

+ ) : ( +
+ {registrations.map((r) => ( +
+ {r.username} + + {new Date(r.createdAt).toLocaleString()} + +
+ ))} +
+ )} +
+
+ ) +} diff --git a/src/app/admin/events/[id]/event-winners.tsx b/src/app/admin/events/[id]/event-winners.tsx new file mode 100644 index 00000000..8028fe3d --- /dev/null +++ b/src/app/admin/events/[id]/event-winners.tsx @@ -0,0 +1,99 @@ +'use client' + +import { Plus } from 'lucide-react' +import { useState } from 'react' +import { addEventWinner } from '@/actions/events' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { useServerAction } from '@/hooks/use-server-action' + +interface Winner { + id: number + userId: number + position: number + username: string + createdAt: Date +} + +interface Props { + eventId: number + winners: Winner[] +} + +export function EventWinnersManager({ eventId, winners }: Props) { + const { run, isPending } = useServerAction() + const [showForm, setShowForm] = useState(false) + const [userId, setUserId] = useState('') + const [position, setPosition] = useState(winners.length + 1) + + function handleAdd(e: React.FormEvent) { + e.preventDefault() + run(() => addEventWinner({ eventId, userId: Number(userId), position }), { + successMessage: 'Winner added!', + onSuccess: () => { + setShowForm(false) + setUserId('') + setPosition(position + 1) + }, + }) + } + + return ( + + + Winners + + + + {showForm && ( +
+
+ + setUserId(e.target.value)} + required + placeholder="12345" + /> +
+
+ + setPosition(Number(e.target.value))} + min={1} + /> +
+ +
+ )} + + {winners.length === 0 && !showForm && ( +

No winners yet.

+ )} + +
+ {winners.map((w) => ( +
+ + {w.position} + + {w.username} + + {new Date(w.createdAt).toLocaleDateString()} + +
+ ))} +
+
+
+ ) +} diff --git a/src/app/admin/events/[id]/page.tsx b/src/app/admin/events/[id]/page.tsx new file mode 100644 index 00000000..9cd00e84 --- /dev/null +++ b/src/app/admin/events/[id]/page.tsx @@ -0,0 +1,173 @@ +import { CalendarDays, Gift, Trophy, Users } from 'lucide-react' +import { notFound, redirect } from 'next/navigation' +import { AdminBreadcrumb } from '@/components/admin/admin-breadcrumb' +import { Badge } from '@/components/ui/badge' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { canAccess, getAdminContext, PERMS } from '@/lib/permissions' +import { prisma } from '@/lib/prisma' +import { EventForm } from '../event-form' +import { EventPrizesManager } from './event-prizes' +import { EventRegistrations } from './event-registrations' +import { EventWinnersManager } from './event-winners' + +export default async function EventDetailPage({ + params, +}: { + params: Promise<{ locale: string; id: string }> +}) { + const { id } = await params + const { session, permissions } = await getAdminContext() + if (!canAccess(permissions, PERMS.EVENTS_EDIT, session.user.rank)) { + redirect('/admin/events') + } + + const eventId = parseInt(id, 10) + if (Number.isNaN(eventId)) notFound() + + const event = await prisma.websiteEvent.findUnique({ + where: { id: eventId }, + include: { + type: true, + prizes: { orderBy: { position: 'asc' } }, + winners: { orderBy: { position: 'asc' } }, + registrations: { orderBy: { createdAt: 'desc' } }, + }, + }) + + if (!event) notFound() + + const eventTypes = await prisma.websiteEventType.findMany({ + where: { isActive: 1 }, + orderBy: { name: 'asc' }, + }) + + // Fetch usernames for registrations and winners + const userIds = [ + ...event.registrations.map((r) => r.userId), + ...event.winners.map((w) => w.userId), + ] + const users = + userIds.length > 0 + ? await prisma.user.findMany({ + where: { id: { in: [...new Set(userIds)] } }, + select: { id: true, username: true }, + }) + : [] + const userMap = new Map(users.map((u) => [u.id, u.username])) + + return ( +
+
+ +
+

{event.title}

+ + {event.status} + +
+

+ {event.type.name} · {new Date(event.startsAt).toLocaleString()} +

+
+ + {/* Stats */} +
+ + + Registrations + + + +
{event.registrations.length}
+ {event.maxPlayers && ( +

Max: {event.maxPlayers}

+ )} +
+
+ + + Prizes + + + +
{event.prizes.length}
+
+
+ + + Winners + + + +
{event.winners.length}
+
+
+ + + Time + + + +
+ {new Date(event.startsAt) > new Date() ? 'Upcoming' : 'Past'} +
+ {event.endsAt && ( +

+ Ends: {new Date(event.endsAt).toLocaleString()} +

+ )} +
+
+
+ + {/* Edit form */} + + + {/* Prizes */} + + + {/* Winners */} + ({ + ...w, + username: userMap.get(w.userId) ?? `User #${w.userId}`, + }))} + /> + + {/* Registrations */} + ({ + ...r, + username: userMap.get(r.userId) ?? `User #${r.userId}`, + }))} + /> +
+ ) +} diff --git a/src/app/admin/events/create/page.tsx b/src/app/admin/events/create/page.tsx new file mode 100644 index 00000000..a64ed754 --- /dev/null +++ b/src/app/admin/events/create/page.tsx @@ -0,0 +1,27 @@ +import { redirect } from 'next/navigation' +import { canAccess, getAdminContext, PERMS } from '@/lib/permissions' +import { prisma } from '@/lib/prisma' +import { EventForm } from '../event-form' + +export default async function CreateEventPage() { + const { session, permissions } = await getAdminContext() + if (!canAccess(permissions, PERMS.EVENTS_EDIT, session.user.rank)) { + redirect('/admin/events') + } + + const eventTypes = await prisma.websiteEventType.findMany({ + where: { isActive: 1 }, + orderBy: { name: 'asc' }, + }) + + return ( +
+
+

Create Event

+

Schedule a new hotel event

+
+ + +
+ ) +} diff --git a/src/app/admin/events/event-form.tsx b/src/app/admin/events/event-form.tsx new file mode 100644 index 00000000..a006f7ab --- /dev/null +++ b/src/app/admin/events/event-form.tsx @@ -0,0 +1,181 @@ +'use client' + +import { useForm } from 'react-hook-form' +import { createEvent, updateEvent } from '@/actions/events' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { DateTimePicker } from '@/components/ui/date-time-picker' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { Textarea } from '@/components/ui/textarea' +import { useServerAction } from '@/hooks/use-server-action' +import type { CreateEventInput } from '@/lib/validators/event' + +interface EventType { + id: number + name: string + slug: string + color: string +} + +interface EventFormProps { + eventTypes: EventType[] + defaultValues?: Partial & { id?: number } +} + +export function EventForm({ eventTypes, defaultValues }: EventFormProps) { + const { run, isPending } = useServerAction() + const isEditing = !!defaultValues?.id + + const form = useForm({ + defaultValues: { + title: '', + description: '', + typeId: eventTypes[0]?.id, + status: 'draft', + isRecurring: 0, + ...defaultValues, + }, + }) + + // biome-ignore lint/suspicious/noExplicitAny: form values typed by useForm defaults; createEvent does its own zod validation + function onSubmit(data: any) { + if (isEditing && defaultValues?.id) { + run(() => updateEvent({ id: defaultValues.id!, ...data }), { + successMessage: 'Event updated!', + }) + } else { + run(() => createEvent(data), { + successMessage: 'Event created!', + onSuccess: (result) => { + const data = result.data as Record | undefined + if (data?.id) { + window.location.href = `/admin/events/${data.id}` + } + }, + }) + } + } + + return ( +
+ + + {isEditing ? 'Edit Event' : 'Event Details'} + + +
+
+ + + {form.formState.errors.title && ( +

{form.formState.errors.title.message}

+ )} +
+ +
+ + +
+
+ +
+ +