diff --git a/src/app/(protected)/events/manage/[eventId]/page.tsx b/src/app/(protected)/events/manage/[eventId]/page.tsx index 3de9755..e6126c2 100644 --- a/src/app/(protected)/events/manage/[eventId]/page.tsx +++ b/src/app/(protected)/events/manage/[eventId]/page.tsx @@ -1,263 +1,74 @@ "use client"; -import { useParams } from "next/navigation"; import { useEffect, useState } from "react"; -import { AlertTriangle } from "lucide-react"; -import { toast } from "react-toastify"; -import { Modal } from "@/components/ui"; +import { useParams } from "next/navigation"; +import Link from "next/link"; import { Breadcrumb } from "@/components/ui"; -import { performEventAction } from "@/lib/eventActions"; -import TabSelector from "@/components/TabSelector"; -import AttendeesTab from "@/components/events/manage/AttendeesTab"; -import { TicketTypeRow } from "@/components/events/manage/TicketTypeRow"; -import { useEventInventory } from "@/hooks/useEventInventory"; - -interface ManagedEvent { - id: string; - name: string; - status: string; -} - -const TABS = ["Overview", "Attendees"] as const; -type Tab = (typeof TABS)[number]; +import EventManagementTabs, { type ManagedEvent } from "@/features/events/components/EventManagementTabs"; export default function ManageEventPage() { const { eventId } = useParams<{ eventId: string }>(); const [event, setEvent] = useState(null); - const [eventLoading, setEventLoading] = useState(true); - const [confirmOpen, setConfirmOpen] = useState(false); - const [submitting, setSubmitting] = useState(false); - const [activeTab, setActiveTab] = useState("Overview"); - - const [confirmOpen, setConfirmOpen] = useState(false); - const [submitting, setSubmitting] = useState(false); - const [activeTab, setActiveTab] = useState("Overview"); - const { - data: ticketTypes, - loading: inventoryLoading, - error: inventoryError, - refresh, - } = useEventInventory(eventId); + const [loading, setLoading] = useState(true); useEffect(() => { if (!eventId) return; let cancelled = false; - setEventLoading(true); + setLoading(true); fetch(`/api/events/${eventId}`) .then((r) => (r.ok ? r.json() : null)) .catch(() => null) - .then((data) => { - if (cancelled) return; - setEvent( - data ?? { id: eventId, name: `Event ${eventId}`, status: "active" }, - ); + .then((data: ManagedEvent | null) => { + if (!cancelled) setEvent(data ?? { id: eventId, name: `Event ${eventId}`, status: "DRAFT" }); }) - .finally(() => { - if (!cancelled) setEventLoading(false); - }); - return () => { - cancelled = true; - }; + .finally(() => { if (!cancelled) setLoading(false); }); + return () => { cancelled = true; }; }, [eventId]); - const handleConfirmCancel = async () => { - if (!event || submitting) return; - setSubmitting(true); - try { - const result = await performEventAction(event.id, "cancel"); - if (!result.success) { - toast.error(result.message); - return; - } - toast.success("Event cancelled. Refunds and notifications have been queued."); - setEvent({ ...event, status: "cancelled" }); - setConfirmOpen(false); - } catch (err) { - toast.error(err instanceof Error ? err.message : "Failed to cancel event."); - } finally { - setSubmitting(false); - } - }; - - if (eventLoading) return

Loading event...

; - if (!event) return

Event not found.

; + if (loading) { + return ( +
+ +
+ ); + } - const isCancelled = event.status === "cancelled"; + if (!event) { + return ( +
+

Event not found.

+
+ ); + } return (
-

Manage: {event.name}

-

Status: {event.status}

- - const isCancelled = event?.status === "cancelled"; - - return ( -
-
-
+
← Back to events
-
+
-

- {eventLoading ? "Loading event…" : event?.name ?? "Event not found"} -

- {event && !eventLoading && ( -

Status: {event.status}

- )} +

{event.name}

+
-
- {event && ( - - )} - - - tabs={TABS as unknown as Tab[]} - activeTab={activeTab} - onTabChange={setActiveTab} - className="!px-0" + setEvent((prev) => prev ? { ...prev, ...updates } : prev)} /> - - {activeTab === "Overview" && ( - <> -
-

- Ticket Types -

- {inventoryError ? ( -
- {inventoryError} - -
- ) : inventoryLoading && ticketTypes.length === 0 ? ( -

Loading ticket types…

- ) : ticketTypes.length === 0 ? ( -

- No ticket types have been created for this event yet. -

- ) : ( -
    - {ticketTypes.map((ticket) => ( - - ))} -
- )} -
- -
-

Danger zone

-

- Cancelling this event is irreversible. All ticket holders will be refunded and - notified automatically. -

- -
- - )} - - {activeTab === "Attendees" && ( -
- {event && } -
- )}
- - { - if (!submitting) setConfirmOpen(false); - }} - title="Cancel this event?" - description="This action is permanent and cannot be undone." - size="md" - > -
-
-
-
- - -
-
-
-
+
); } diff --git a/src/app/(public)/events/[eventId]/EventDetailClient.tsx b/src/app/(public)/events/[eventId]/EventDetailClient.tsx new file mode 100644 index 0000000..be5b524 --- /dev/null +++ b/src/app/(public)/events/[eventId]/EventDetailClient.tsx @@ -0,0 +1,533 @@ +'use client'; + +import { useParams, useRouter } from 'next/navigation'; +import { useState } from 'react'; +import { motion } from 'framer-motion'; +import { WaitlistButton } from "@/features/events/components/WaitlistButton"; +import { PurchaseModal } from "@/features/events/components/PurchaseModal"; +import { HiCalendar, HiClock, HiLocationMarker, HiUsers, HiShare, HiHeart, HiCheck, HiArrowLeft } from 'react-icons/hi'; +import TabSelector from '@/components/TabSelector'; +import { Breadcrumb } from '@/components/ui'; +import { AppImage } from '@/components/shared/AppImage'; +import { useEvent } from '@/hooks/useEvents'; +import { useFavorite } from '@/hooks/useFavorite'; +type TabType = 'about' | 'schedule' | 'performers'; + +export default function EventDetailClient() { + const params = useParams(); + const router = useRouter(); + const eventId = params.eventId as string; + const { event, loading: eventLoading } = useEvent(eventId); + const [activeTab, setActiveTab] = useState('about'); + const { isLiked, isPending, error: favoriteError, toggle: toggleFavorite } = useFavorite(eventId); + const [isModalOpen, setIsModalOpen] = useState(false); + const [shareCopied, setShareCopied] = useState(false); + const [selectedTicketIndex, setSelectedTicketIndex] = useState(0); + const [quantity, setQuantity] = useState(1); + + const handleShare = async () => { + const url = window.location.href; + if (navigator.share) { + try { + await navigator.share({ title: event?.name, url }); + } catch { + // user cancelled or error — do nothing + } + } else { + await navigator.clipboard.writeText(url); + setShareCopied(true); + setTimeout(() => setShareCopied(false), 2000); + } + }; + + if (eventLoading) { + return ( +
+
+
+ ); + } + + if (!event) { + return ( +
+
+

Event Not Found

+

The event you're looking for doesn't exist.

+ router.push('/events')} + className="mt-6 px-8 py-3 bg-linear-to-r from-cyan-500 via-purple-500 to-pink-500 text-white font-semibold rounded-xl" + > + Back to Events + +
+
+ ); + } + + return ( +
+ {/* Hero Section */} +
+ {/* Background Image with large text overlay */} +
+
+ + {/* Large background text with event name */} +
+
+ {event.name} +
+
+ + {/* Dark overlay */} +
+
+ + {/* Content */} +
+
+ + router.back()} + className="flex items-center gap-2 text-white hover:text-gray-200 transition-colors" + > + + Back + + +
+

+ {event.name} +

+ +
+ + {shareCopied ? : } + + + + + +
+
+
+
+
+
+ + {/* Event Info Bar */} +
+ {favoriteError && ( +
+ {favoriteError} +
+ )} +
+
+ +
+ +
+
+

{event.date}

+
+
+ + +
+ +
+
+

{event.time}

+
+
+ + +
+ +
+
+

{event.venue}

+
+
+ + {event.attendees && ( + +
+ +
+
+

{event.attendees}+ attendees

+
+
+ )} +
+
+
+ + + + {/* Content */} +
+
+ {/* Main Content */} +
+ {activeTab === 'about' && ( + + {event.description && ( +
+

{event.description}

+
+ )} + +
+

What to expect:

+
    +
  • + + Live performances from internationally acclaimed DJs and artists +
  • +
  • + + Dance battles, workshops, and flash mobs +
  • +
  • + + Themed stages from house, EDM, Afrobeat, and more +
  • +
  • + + Food trucks, art installations, and wellness lounges +
  • +
  • + + After-dark glow parties and sunrise dance sessions +
  • +
+
+ +
+

+ Whether you're a hardcore raver, casual music fan, or just looking to soak up the sun with good vibes, the Summer Dance Festival is your ticket to a weekend of freedom, connection, and movement. Let's dance the summer away! +

+
+
+ )} + + {activeTab === 'schedule' && ( + + {event.schedule && event.schedule.length > 0 ? ( + <> +

Schedule

+ {event.schedule.map((item, i) => ( +
+
{item.time}
+
+

{item.title}

+ {item.description &&

{item.description}

} +
+
+ ))} + + ) : ( +
+
📅
+

Schedule Coming Soon

+

Event schedule will be announced closer to the date

+
+ )} +
+ )} + + {activeTab === 'performers' && ( + + {event.performers && event.performers.length > 0 ? ( + <> +

Performers

+
+ {event.performers.map((performer, i) => ( +
+ {performer.image ? ( + + ) : ( +
+ {performer.name.charAt(0)} +
+ )} +

{performer.name}

+

{performer.role}

+
+ ))} +
+ + ) : ( +
+
🎤
+

Performers Coming Soon

+

Lineup will be announced soon

+
+ )} +
+ )} +
+ + {/* Ticket Options - Full Width Below */} +
+ +
+

Ticket Options

+
+ +
+ {event.ticketOptions && event.ticketOptions.length > 0 ? ( +
+ {event.ticketOptions.map((ticket, index) => ( + { setSelectedTicketIndex(index); setQuantity(1); }} + className={`p-5 rounded-xl bg-[#00062580]/50 border hover:border-white/10 transition-all duration-300 cursor-pointer ${selectedTicketIndex === index ? 'border-[#4D21FF]' : 'border-[#E0E0E033]/20'}`} + > +
+

{ticket.name}

+ {ticket.popular && ( + + Popular + + )} +
+ +

{ticket.description}

+ + {ticket.benefits.length > 0 && ( +
    + {ticket.benefits.map((benefit, i) => ( +
  • + {benefit} +
  • + ))} +
+ )} + +
+
+
+ {ticket.price} ETH +
+
+ {ticket.remaining === 0 ? ( + Sold out + ) : ( +

{ticket.remaining} remaining

+ )} +
+
+ ))} +
+ ) : ( +
+
🎫
+

Ticket info coming soon

+
+ )} + + {event.ticketOptions && event.ticketOptions.length > 0 && (() => { + const selected = event.ticketOptions![selectedTicketIndex]; + const isSoldOut = selected.remaining === 0; + const maxQty = Math.min(selected.remaining, 10); + const totalPrice = (selected.price * quantity).toFixed(4); + return ( +
+
+ Quantity +
+ + {quantity} + +
+
+
+ {selected.name} × {quantity} + {totalPrice} ETH +
+
+ ); + })()} + +
+ setIsModalOpen(true)} + disabled={!!(event.ticketOptions && event.ticketOptions[selectedTicketIndex]?.remaining === 0)} + className="w-full max-w-[519px] py-3.5 lg:py-4.25 lg:px-17.25 lg:h-15 lg:rounded-lg bg-gradient-to-r from-[#4D21FF] to-[#21D4FF] text-white font-bold rounded-xl hover:opacity-90 transition-all duration-300 mx-auto block disabled:opacity-50 disabled:cursor-not-allowed" + > + {event.ticketOptions && event.ticketOptions[selectedTicketIndex]?.remaining === 0 + ? 'Sold Out' + : 'Purchase Tickets'} + + +

+ All tickets are minted as unique NFTs on the Ethereum blockchain — secure, verifiable, and yours to own! + Tickets are fully transferable and resellable via official marketplace or any compatible platform. + Gas fees are not included in the listed prices and may vary at checkout. + Gain exclusive digital collectibles and perks with select ticket tiers +

+
+
+ {/* Event Organizer - Below Tickets */} + {event.organizer && ( + +

Event Organizer

+
+
+ {event.organizer.name.charAt(0)} +
+
+
+

{event.organizer.name}

+ +
+

{event.organizer.description}

+ {event.organizer.verified && ( + + Verified Organizer + )} +
+
+
+ )} +
+ +
+
+
+ + setIsModalOpen(false)} + eventName={event.name} + ticket={ + event.ticketOptions?.[selectedTicketIndex] + ? { + name: event.ticketOptions[selectedTicketIndex].name, + description: event.ticketOptions[selectedTicketIndex].description, + benefits: event.ticketOptions[selectedTicketIndex].benefits, + price: event.ticketOptions[selectedTicketIndex].price, + remaining: event.ticketOptions[selectedTicketIndex].remaining, + } + : null + } + quantity={quantity} + eventId={eventId} + /> +
+ ); +} \ No newline at end of file diff --git a/src/app/(public)/events/[eventId]/page.tsx b/src/app/(public)/events/[eventId]/page.tsx index fa446db..ee81164 100644 --- a/src/app/(public)/events/[eventId]/page.tsx +++ b/src/app/(public)/events/[eventId]/page.tsx @@ -1,534 +1,25 @@ -'use client'; +import type { Metadata } from "next"; +import { fetchEventById } from "@/lib/eventsApi"; +import EventDetailClient from "./EventDetailClient"; -import { useParams, useRouter } from 'next/navigation'; -import { useState, useEffect } from 'react'; -import { motion } from 'framer-motion'; -import { WaitlistButton } from "@/features/events/components/WaitlistButton"; -import { HiCalendar, HiClock, HiLocationMarker, HiUsers, HiShare, HiHeart, HiCheck, HiArrowLeft } from 'react-icons/hi'; -import TabSelector from '@/components/TabSelector'; -import { Breadcrumb } from '@/components/ui'; -import WalletConnectModal from '@/components/events/WalletConnectModal'; -import { AppImage } from '@/components/shared/AppImage'; -import { fetchEventById } from '@/lib/eventsApi'; -import { useFavorite } from '@/hooks/useFavorite'; -import type { Event } from '@/types/event'; -import type { WalletConnection } from '@/components/events/WalletConnectModal'; -type TabType = 'about' | 'schedule' | 'performers'; - -export default function EventDetailsPage() { - const params = useParams(); - const router = useRouter(); - const eventId = params.eventId as string; - const [event, setEvent] = useState(undefined); - const [activeTab, setActiveTab] = useState('about'); - const { isLiked, isPending, error: favoriteError, toggle: toggleFavorite } = useFavorite(eventId); - const [isModalOpen, setIsModalOpen] = useState(false); - const [connectedWallet, setConnectedWallet] = useState(null); - const [shareCopied, setShareCopied] = useState(false); - const [selectedTicketIndex, setSelectedTicketIndex] = useState(0); - const [quantity, setQuantity] = useState(1); - - const handleShare = async () => { - const url = window.location.href; - if (navigator.share) { - try { - await navigator.share({ title: event?.name, url }); - } catch { - // user cancelled or error — do nothing - } - } else { - await navigator.clipboard.writeText(url); - setShareCopied(true); - setTimeout(() => setShareCopied(false), 2000); - } - }; - - useEffect(() => { - fetchEventById(params.eventId as string) - .then(setEvent) - .catch(() => setEvent(null)); - }, [params.eventId]); - - if (event === undefined) { - return ( -
-
-
- ); - } +interface Props { + params: Promise<{ eventId: string }>; +} +export async function generateMetadata({ params }: Props): Promise { + const { eventId } = await params; + const event = await fetchEventById(eventId).catch(() => null); if (!event) { - return ( -
-
-

Event Not Found

-

The event you're looking for doesn't exist.

- router.push('/events')} - className="mt-6 px-8 py-3 bg-linear-to-r from-cyan-500 via-purple-500 to-pink-500 text-white font-semibold rounded-xl" - > - Back to Events - -
-
- ); + return { title: "Event Not Found | VeriTix" }; } + return { + title: `${event.name} | VeriTix`, + description: event.description + ? event.description.slice(0, 160) + : `Buy tickets for ${event.name} on VeriTix.`, + }; +} - return ( -
- {/* Hero Section */} -
- {/* Background Image with large text overlay */} -
-
- - {/* Large background text with event name */} -
-
- {event.name} -
-
- - {/* Dark overlay */} -
-
- - {/* Content */} -
-
- - router.back()} - className="flex items-center gap-2 text-white hover:text-gray-200 transition-colors" - > - - Back - - -
-

- {event.name} -

- -
- - {shareCopied ? : } - - - - - -
-
-
-
-
-
- - {/* Event Info Bar */} -
- {favoriteError && ( -
- {favoriteError} -
- )} -
-
- -
- -
-
-

{event.date}

-
-
- - -
- -
-
-

{event.time}

-
-
- - -
- -
-
-

{event.venue}

-
-
- - {event.attendees && ( - -
- -
-
-

{event.attendees}+ attendees

-
-
- )} -
-
-
- - - - {/* Content */} -
-
- {/* Main Content */} -
- {activeTab === 'about' && ( - - {event.description && ( -
-

{event.description}

-
- )} - -
-

What to expect:

-
    -
  • - - Live performances from internationally acclaimed DJs and artists -
  • -
  • - - Dance battles, workshops, and flash mobs -
  • -
  • - - Themed stages from house, EDM, Afrobeat, and more -
  • -
  • - - Food trucks, art installations, and wellness lounges -
  • -
  • - - After-dark glow parties and sunrise dance sessions -
  • -
-
- -
-

- Whether you're a hardcore raver, casual music fan, or just looking to soak up the sun with good vibes, the Summer Dance Festival is your ticket to a weekend of freedom, connection, and movement. Let's dance the summer away! -

-
-
- )} - - {activeTab === 'schedule' && ( - - {event.schedule && event.schedule.length > 0 ? ( - <> -

Schedule

- {event.schedule.map((item, i) => ( -
-
{item.time}
-
-

{item.title}

- {item.description &&

{item.description}

} -
-
- ))} - - ) : ( -
-
📅
-

Schedule Coming Soon

-

Event schedule will be announced closer to the date

-
- )} -
- )} - - {activeTab === 'performers' && ( - - {event.performers && event.performers.length > 0 ? ( - <> -

Performers

-
- {event.performers.map((performer, i) => ( -
- {performer.image ? ( - - ) : ( -
- {performer.name.charAt(0)} -
- )} -

{performer.name}

-

{performer.role}

-
- ))} -
- - ) : ( -
-
🎤
-

Performers Coming Soon

-

Lineup will be announced soon

-
- )} -
- )} -
- - {/* Ticket Options - Full Width Below */} -
- -
-

Ticket Options

-
- -
- {event.ticketOptions && event.ticketOptions.length > 0 ? ( -
- {event.ticketOptions.map((ticket, index) => ( - { setSelectedTicketIndex(index); setQuantity(1); }} - className={`p-5 rounded-xl bg-[#00062580]/50 border hover:border-white/10 transition-all duration-300 cursor-pointer ${selectedTicketIndex === index ? 'border-[#4D21FF]' : 'border-[#E0E0E033]/20'}`} - > -
-

{ticket.name}

- {ticket.popular && ( - - Popular - - )} -
- -

{ticket.description}

- - {ticket.benefits.length > 0 && ( -
    - {ticket.benefits.map((benefit, i) => ( -
  • - {benefit} -
  • - ))} -
- )} - -
-
-
- {ticket.price} ETH -
-
- {ticket.remaining === 0 ? ( - Sold out - ) : ( -

{ticket.remaining} remaining

- )} -
-
- ))} -
- ) : ( -
-
🎫
-

Ticket info coming soon

-
- )} - - {event.ticketOptions && event.ticketOptions.length > 0 && (() => { - const selected = event.ticketOptions![selectedTicketIndex]; - const isSoldOut = selected.remaining === 0; - const maxQty = Math.min(selected.remaining, 10); - const totalPrice = (selected.price * quantity).toFixed(4); - return ( -
-
- Quantity -
- - {quantity} - -
-
-
- {selected.name} × {quantity} - {totalPrice} ETH -
-
- ); - })()} - -
- setIsModalOpen(true)} - disabled={!!(event.ticketOptions && event.ticketOptions[selectedTicketIndex]?.remaining === 0)} - className="w-full max-w-[519px] py-3.5 lg:py-4.25 lg:px-17.25 lg:h-15 lg:rounded-lg bg-gradient-to-r from-[#4D21FF] to-[#21D4FF] text-white font-bold rounded-xl hover:opacity-90 transition-all duration-300 mx-auto block disabled:opacity-50 disabled:cursor-not-allowed" - > - {event.ticketOptions && event.ticketOptions[selectedTicketIndex]?.remaining === 0 - ? 'Sold Out' - : connectedWallet - ? `Purchase (${connectedWallet.address.slice(0, 6)}…${connectedWallet.address.slice(-4)})` - : 'Connect Wallet to Purchase'} - - -

- All tickets are minted as unique NFTs on the Ethereum blockchain — secure, verifiable, and yours to own! - Tickets are fully transferable and resellable via official marketplace or any compatible platform. - Gas fees are not included in the listed prices and may vary at checkout. - Gain exclusive digital collectibles and perks with select ticket tiers -

-
-
- {/* Event Organizer - Below Tickets */} - {event.organizer && ( - -

Event Organizer

-
-
- {event.organizer.name.charAt(0)} -
-
-
-

{event.organizer.name}

- -
-

{event.organizer.description}

- {event.organizer.verified && ( - - Verified Organizer - )} -
-
-
- )} -
- -
-
-
- - setIsModalOpen(false)} - onConnected={(wallet) => { - setConnectedWallet(wallet); - setIsModalOpen(false); - }} - /> -
- ); -} \ No newline at end of file +export default function EventDetailPage() { + return ; +} diff --git a/src/app/(public)/page.tsx b/src/app/(public)/page.tsx index 2977e1f..922c524 100644 --- a/src/app/(public)/page.tsx +++ b/src/app/(public)/page.tsx @@ -16,7 +16,10 @@ import { ChevronDown, Search, } from "lucide-react"; -import { howItWorksSteps, trendingEvents } from "@/mocks/landing"; +import { howItWorksSteps } from "@/mocks/landing"; +import useSWR from "swr"; +import { fetchEvents } from "@/lib/eventsApi"; +import type { Event } from "@/types/event"; import { WalletButton } from "@/components/navbar/WalletButton"; const LandingTestimonials = dynamic( @@ -44,6 +47,20 @@ export default function Home() { const [searchLocation, setSearchLocation] = useState(""); const [searchDate, setSearchDate] = useState(""); + const { data: allEvents, isLoading: eventsLoading } = useSWR( + "events", + fetchEvents, + { revalidateOnFocus: false, dedupingInterval: 60_000 }, + ); + + const trendingEvents = (() => { + if (!allEvents) return []; + const featured = allEvents.filter((e) => e.featured); + if (featured.length >= 3) return featured.slice(0, 3); + const recent = allEvents.filter((e) => !e.featured).slice(0, 3 - featured.length); + return [...featured, ...recent].slice(0, 3); + })(); + const handleSearchSubmit = (e: React.FormEvent) => { e.preventDefault(); const params = new URLSearchParams(); @@ -265,7 +282,15 @@ export default function Home() {
- {trendingEvents.map((event) => ( + {eventsLoading + ? [0, 1, 2].map((i) => ( +

- {event.title} + {event.name}

- {event.location} + {event.city ?? event.location}
@@ -320,7 +345,7 @@ export default function Home() { {event.price}
(30); + + const sliced = useMemo(() => data.slice(-window), [data, window]); + + const total = useMemo( + () => sliced.reduce((sum, d) => sum + d.ticketsSold, 0), + [sliced], + ); + + const peak = useMemo( + () => sliced.reduce((max, d) => (d.ticketsSold > max.ticketsSold ? d : max), sliced[0] ?? { date: "", ticketsSold: 0 }), + [sliced], + ); + + return ( +
+
+
+

Sales Velocity

+

+ {total} tickets sold in {window} days +

+
+
+ {WINDOWS.map(({ label, value }) => ( + + ))} +
+
+ + + + + + + + + + + + + { + const isPeak = entry.payload.date === peak.date; + return [ + `${value} tickets${isPeak ? " 🔥 Peak" : ""}`, + "Sold", + ]; + }} + labelStyle={{ color: "#21D4FF" }} + /> + { + const { cx, cy, payload } = props; + if (payload.date !== peak.date) return ; + return ( + + ); + }} + /> + + +
+ ); +} diff --git a/src/features/events/components/EventAnalyticsTab.tsx b/src/features/events/components/EventAnalyticsTab.tsx new file mode 100644 index 0000000..796e2d0 --- /dev/null +++ b/src/features/events/components/EventAnalyticsTab.tsx @@ -0,0 +1,94 @@ +"use client"; + +import useSWR from "swr"; +import { fetchEventAnalytics } from "@/lib/eventAnalytics"; +import { SalesVelocityChart } from "@/components/dashboard/charts/SalesVelocityChart"; +import { Skeleton } from "@/components/ui/Skeleton"; + +interface EventAnalyticsTabProps { + eventId: string; +} + +export default function EventAnalyticsTab({ eventId }: EventAnalyticsTabProps) { + const { data, error, isLoading } = useSWR( + eventId ? `analytics-${eventId}` : null, + () => fetchEventAnalytics(eventId), + { revalidateOnFocus: false }, + ); + + if (isLoading) { + return ( +
+ + +
+ ); + } + + if (error || !data) { + return ( +

+ {error?.message ?? "Failed to load analytics."} +

+ ); + } + + const velocityData = data.salesByDay.map((d) => ({ + date: d.date, + ticketsSold: d.count, + })); + + const scanRate = + data.totalSold > 0 ? Math.round((data.checkIns / data.totalSold) * 100) : 0; + + return ( +
+ {/* Quick stats */} +
+ {[ + { label: "Tickets Sold", value: data.totalSold }, + { label: "Capacity", value: data.totalCapacity }, + { label: "Revenue", value: `$${data.revenue.toLocaleString()}` }, + { label: "Scan Rate", value: `${scanRate}%` }, + ].map(({ label, value }) => ( +
+

{value}

+

{label}

+
+ ))} +
+ + {/* Sales velocity chart */} + + + {/* Ticket mix */} + {data.ticketMix.length > 0 && ( +
+

Ticket Mix

+
    + {data.ticketMix.map((t) => { + const pct = t.total > 0 ? (t.sold / t.total) * 100 : 0; + return ( +
  • +
    + {t.type} + {t.sold} / {t.total} +
    +
    +
    +
    +
  • + ); + })} +
+
+ )} +
+ ); +} diff --git a/src/features/events/components/EventHero.tsx b/src/features/events/components/EventHero.tsx new file mode 100644 index 0000000..2b9eebe --- /dev/null +++ b/src/features/events/components/EventHero.tsx @@ -0,0 +1,106 @@ +"use client"; + +import Image from "next/image"; +import Link from "next/link"; +import { HiCalendar, HiLocationMarker, HiUsers } from "react-icons/hi"; +import type { Event } from "@/types/event"; + +interface EventHeroProps { + event: Event; + onShare: () => void; + shareCopied: boolean; +} + +const STATUS_STYLES: Record = { + PUBLISHED: "bg-emerald-500/20 text-emerald-300 border-emerald-500/40", + DRAFT: "bg-yellow-500/20 text-yellow-300 border-yellow-500/40", + CANCELLED: "bg-red-500/20 text-red-300 border-red-500/40", + COMPLETED: "bg-blue-500/20 text-blue-300 border-blue-500/40", + POSTPONED: "bg-orange-500/20 text-orange-300 border-orange-500/40", +}; + +export function EventHero({ event, onShare, shareCopied }: EventHeroProps) { + const statusStyle = STATUS_STYLES[event.status] ?? STATUS_STYLES.PUBLISHED; + const organizerId = event.organizerId ?? event.organizer?.name?.toLowerCase().replace(/\s+/g, "-"); + + return ( +
+ {/* Banner */} +
+ {event.name} +
+
+ + {/* Info overlay */} +
+
+
+ + {event.status} + +

+ {event.name} +

+ +
+ + + {new Date(event.eventDate).toLocaleDateString("en-US", { + weekday: "short", + month: "long", + day: "numeric", + year: "numeric", + })} + {" · "} + {new Date(event.eventDate).toLocaleTimeString("en-US", { + hour: "2-digit", + minute: "2-digit", + })} + + + + {event.venue}{event.city ? `, ${event.city}` : ""} + + {event.attendees != null && ( + + + {event.attendees.toLocaleString()} attending + + )} +
+ + {event.organizer && ( +

+ Organized by{" "} + + {event.organizer.name} + + {event.organizer.verified && ( + ✓ Verified + )} +

+ )} +
+ + +
+
+
+ ); +} diff --git a/src/features/events/components/EventManagementTabs.tsx b/src/features/events/components/EventManagementTabs.tsx new file mode 100644 index 0000000..0a646ca --- /dev/null +++ b/src/features/events/components/EventManagementTabs.tsx @@ -0,0 +1,269 @@ +"use client"; + +import { useState } from "react"; +import { toast } from "react-toastify"; +import { AlertTriangle } from "lucide-react"; +import { Modal } from "@/components/ui/Modal"; +import TabSelector from "@/components/TabSelector"; +import AttendeesTab from "@/components/events/manage/AttendeesTab"; +import { TicketTypeRow } from "@/components/events/manage/TicketTypeRow"; +import EventAnalyticsTab from "@/features/events/components/EventAnalyticsTab"; +import { useEventInventory } from "@/hooks/useEventInventory"; +import { performEventAction } from "@/lib/eventActions"; + +export interface ManagedEvent { + id: string; + name: string; + status: string; + description?: string; + location?: string; + capacity?: number; + soldTickets?: number; + revenue?: number; + scanRate?: number; +} + +interface Props { + event: ManagedEvent; + onEventUpdate: (updated: Partial) => void; +} + +const TABS = ["Overview", "Edit", "Ticket Types", "Attendees", "Analytics"] as const; +type Tab = (typeof TABS)[number]; + +const STATUS_TRANSITIONS: Record = { + DRAFT: [{ action: "publish", label: "Publish", cls: "bg-emerald-600 hover:bg-emerald-500" }], + PUBLISHED: [ + { action: "unpublish", label: "Unpublish", cls: "bg-yellow-600 hover:bg-yellow-500" }, + { action: "cancel", label: "Cancel Event", cls: "bg-red-600 hover:bg-red-500" }, + { action: "archive", label: "Mark Complete", cls: "bg-blue-600 hover:bg-blue-500" }, + ], + POSTPONED: [{ action: "publish", label: "Re-publish", cls: "bg-emerald-600 hover:bg-emerald-500" }], +}; + +const STATUS_STYLES: Record = { + PUBLISHED: "bg-emerald-500/20 text-emerald-300 border-emerald-500/40", + DRAFT: "bg-yellow-500/20 text-yellow-300 border-yellow-500/40", + CANCELLED: "bg-red-500/20 text-red-300 border-red-500/40", + COMPLETED: "bg-blue-500/20 text-blue-300 border-blue-500/40", + POSTPONED: "bg-orange-500/20 text-orange-300 border-orange-500/40", +}; + +const ACTION_STATUS_MAP: Record = { + publish: "PUBLISHED", + unpublish: "DRAFT", + cancel: "CANCELLED", + archive: "COMPLETED", +}; + +export default function EventManagementTabs({ event, onEventUpdate }: Props) { + const [activeTab, setActiveTab] = useState("Overview"); + const [confirmAction, setConfirmAction] = useState(null); + const [submitting, setSubmitting] = useState(false); + const [editName, setEditName] = useState(event.name); + const [editDesc, setEditDesc] = useState(event.description ?? ""); + const [editLocation, setEditLocation] = useState(event.location ?? ""); + const [editSaving, setEditSaving] = useState(false); + + const { data: ticketTypes, loading: invLoading, error: invError, refresh } = + useEventInventory(event.id); + + const statusKey = event.status.toUpperCase(); + const statusStyle = STATUS_STYLES[statusKey] ?? STATUS_STYLES.DRAFT; + const transitions = STATUS_TRANSITIONS[statusKey] ?? []; + const capacity = event.capacity ?? 0; + const sold = event.soldTickets ?? 0; + const pct = capacity > 0 ? Math.min(Math.round((sold / capacity) * 100), 100) : 0; + + const handleAction = async () => { + if (!confirmAction) return; + setSubmitting(true); + try { + const result = await performEventAction(event.id, confirmAction as never); + if (!result.success) { toast.error(result.message); return; } + toast.success(result.message); + onEventUpdate({ status: ACTION_STATUS_MAP[confirmAction] }); + setConfirmAction(null); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Action failed."); + } finally { + setSubmitting(false); + } + }; + + const handleEditSave = async (e: React.FormEvent) => { + e.preventDefault(); + setEditSaving(true); + try { + const res = await fetch(`/api/events/${event.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: editName, description: editDesc, location: editLocation }), + }); + if (!res.ok) throw new Error("Save failed"); + onEventUpdate({ name: editName, description: editDesc, location: editLocation }); + toast.success("Event updated."); + } catch { + toast.error("Failed to save changes."); + } finally { + setEditSaving(false); + } + }; + + return ( + <> + + tabs={TABS as unknown as Tab[]} + activeTab={activeTab} + onTabChange={setActiveTab} + className="!px-0" + /> + + {activeTab === "Overview" && ( +
+
+ {[ + { label: "Status", value: {event.status} }, + { label: "Tickets Sold", value: `${sold} / ${capacity || "—"}` }, + { label: "Revenue", value: event.revenue != null ? `$${event.revenue.toLocaleString()}` : "—" }, + { label: "Scan Rate", value: event.scanRate != null ? `${event.scanRate}%` : "—" }, + ].map(({ label, value }) => ( +
+

{label}

+

{value}

+
+ ))} +
+ + {capacity > 0 && ( +
+
+ Capacity + {pct}% +
+
+
= 90 ? "bg-red-500" : pct >= 70 ? "bg-yellow-500" : "bg-gradient-to-r from-[#4D21FF] to-[#21D4FF]"}`} + style={{ width: `${pct}%` }} + /> +
+

{Math.max(capacity - sold, 0)} spots remaining

+
+ )} + + {transitions.length > 0 && ( +
+

Status Controls

+
+ {transitions.map(({ action, label, cls }) => ( + + ))} +
+
+ )} +
+ )} + + {activeTab === "Edit" && ( +
+ {[ + { id: "edit-name", label: "Event Name", value: editName, onChange: setEditName, type: "input" as const }, + { id: "edit-location", label: "Location", value: editLocation, onChange: setEditLocation, type: "input" as const }, + ].map(({ id, label, value, onChange }) => ( +
+ + onChange(e.target.value)} + className="w-full rounded-lg border border-[#4D21FF]/40 bg-[#101428] px-4 py-2.5 text-sm text-white focus-visible:outline focus-visible:outline-2 focus-visible:outline-[#4D21FF]" + /> +
+ ))} +
+ +