- Add organizer model/API, admin and public organizer pages, meetup cards - Refresh events/home/contact; add calendar dialog and carousel components - Optional Plausible via NEXT_PUBLIC_PLAUSIBLE_* env vars in root layout - Prisma migration, seed updates, baseline-and-migrate script Made-with: Cursor
154 lines
5.1 KiB
TypeScript
154 lines
5.1 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState } from "react";
|
|
import Link from "next/link";
|
|
import { api } from "@/lib/api";
|
|
import { getMeetupStartUtc } from "@/lib/meetupEventTime";
|
|
import { Navbar } from "@/components/public/Navbar";
|
|
import { Footer } from "@/components/public/Footer";
|
|
import { MeetupCard } from "@/components/public/MeetupCard";
|
|
import { AddToCalendarButton } from "@/components/public/AddToCalendarDialog";
|
|
import { ArrowLeft } from "lucide-react";
|
|
|
|
function CardSkeleton() {
|
|
return (
|
|
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-6 animate-pulse">
|
|
<div className="flex items-start gap-4 mb-4">
|
|
<div className="bg-zinc-800 rounded-lg w-[52px] h-[58px] shrink-0" />
|
|
<div className="flex-1 space-y-2">
|
|
<div className="h-4 bg-zinc-800 rounded w-3/4" />
|
|
<div className="h-3 bg-zinc-800 rounded w-1/2" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function OrganizerEventsClient({
|
|
slug,
|
|
organizerName,
|
|
}: {
|
|
slug: string;
|
|
organizerName: string;
|
|
}) {
|
|
const [meetups, setMeetups] = useState<any[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
setLoading(true);
|
|
api
|
|
.getMeetups({ organizerSlug: slug })
|
|
.then((data: any) => {
|
|
const list = Array.isArray(data) ? data : [];
|
|
setMeetups(list);
|
|
})
|
|
.catch((err) => setError(err.message))
|
|
.finally(() => setLoading(false));
|
|
}, [slug]);
|
|
|
|
const now = new Date();
|
|
const upcoming = meetups.filter((m) => {
|
|
const start = getMeetupStartUtc(m.date, m.time || "00:00");
|
|
if (Number.isNaN(start.getTime())) return false;
|
|
return start >= now;
|
|
});
|
|
const past = meetups
|
|
.filter((m) => {
|
|
const start = getMeetupStartUtc(m.date, m.time || "00:00");
|
|
if (Number.isNaN(start.getTime())) return false;
|
|
return start < now;
|
|
})
|
|
.reverse();
|
|
|
|
return (
|
|
<>
|
|
<Navbar />
|
|
<div className="min-h-screen">
|
|
<header className="pt-24 pb-12 px-8">
|
|
<div className="max-w-6xl mx-auto">
|
|
<Link
|
|
href="/events"
|
|
className="inline-flex items-center gap-2 text-on-surface-variant hover:text-primary transition-colors mb-6 text-sm font-medium"
|
|
>
|
|
<ArrowLeft size={16} />
|
|
All events
|
|
</Link>
|
|
<p className="uppercase tracking-[0.2em] text-primary mb-2 font-semibold text-xs">
|
|
Organizer
|
|
</p>
|
|
<h1 className="text-4xl md:text-5xl font-black tracking-tighter mb-4">
|
|
{organizerName}
|
|
</h1>
|
|
<p className="text-on-surface-variant max-w-md leading-relaxed">
|
|
Upcoming and past events hosted by this organizer.
|
|
</p>
|
|
</div>
|
|
</header>
|
|
|
|
<div className="max-w-6xl mx-auto px-8 pb-24 space-y-20">
|
|
{error && (
|
|
<div className="bg-red-900/20 text-red-400 rounded-xl p-6 text-sm">
|
|
Failed to load events: {error}
|
|
</div>
|
|
)}
|
|
|
|
<div>
|
|
<div className="flex items-center justify-between mb-8">
|
|
<h2 className="text-xl font-black flex items-center gap-3">
|
|
Upcoming
|
|
{!loading && upcoming.length > 0 && (
|
|
<span className="text-xs font-bold bg-primary/10 text-primary px-2.5 py-1 rounded-full">
|
|
{upcoming.length}
|
|
</span>
|
|
)}
|
|
</h2>
|
|
<AddToCalendarButton />
|
|
</div>
|
|
|
|
{loading ? (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
|
|
{[0, 1, 2].map((i) => (
|
|
<CardSkeleton key={i} />
|
|
))}
|
|
</div>
|
|
) : upcoming.length === 0 ? (
|
|
<div className="border border-zinc-800/60 rounded-xl px-8 py-12 text-center">
|
|
<p className="text-on-surface-variant text-sm">
|
|
No upcoming events from this organizer.
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
|
|
{upcoming.map((m) => (
|
|
<MeetupCard key={m.id} meetup={m} />
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{(loading || past.length > 0) && (
|
|
<div>
|
|
<h2 className="text-xl font-black mb-8 text-on-surface-variant/60">Past events</h2>
|
|
{loading ? (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
|
|
{[0, 1, 2].map((i) => (
|
|
<CardSkeleton key={i} />
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
|
|
{past.map((m) => (
|
|
<MeetupCard key={m.id} meetup={m} muted />
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<Footer />
|
|
</>
|
|
);
|
|
}
|