From 8a4f86ae3045939eabd93ae9047a7b5aeae5e5af Mon Sep 17 00:00:00 2001 From: arcgod-design Date: Sun, 19 Jul 2026 17:03:51 +0530 Subject: [PATCH] =?UTF-8?q?feat:=20Hackathon=20Hub=20=E2=80=94=20discovery?= =?UTF-8?q?,=20team=20formation,=20bookmark=20(closes=20#139)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New events Firestore collection (kind: 'hackathon') - /hackathons — hub with real-time onSnapshot feed, search/mode/organizer/topic filters, sort by newest/deadline/start, inline bookmark toggle, 'Your Saved' shelf for signed-in users - /hackathons/post — authenticated form with title/description/dates/city/website/tags/maxTeamSize/banner color - /hackathons/[id] — detail page with real-time subscription, banner hero, mode/organizer-type badges, registration-closed indicator, external 'Register on Organizer Site' redirect, delete (author-only), bookmark - components/hackathons/HackathonCard — gradient banner, mode/organizer badges, tags, team-seeker count, bookmark button (aria-pressed) - components/hackathons/HackathonFilters — search + 4 selects (mode/organizer/topic/sort) using theme tokens - components/hackathons/TeamFinder — opt-in 'Looking for Team' list with note (max 280 chars), self-remove button, avatar fallback initial - lib/hackathons.js — CRUD, bookmark toggle, team-seeker add/remove, real-time subscribe helpers, normalizeEvent mapping Firestore timestamps → ISO - Navbar — new Hackathon Hub icon link - PostHog events — HACKATHON_VIEWED/BOOKMARKED/POSTED/TEAM_SEEKER_ADDED --- app/hackathons/[id]/page.js | 342 ++++++ app/hackathons/page.js | 228 ++++ app/hackathons/post/page.js | 315 ++++++ components/Navbar.js | 4 + components/hackathons/HackathonCard.js | 151 +++ components/hackathons/HackathonFilters.js | 112 ++ components/hackathons/TeamFinder.js | 187 ++++ lib/hackathons.js | 205 ++++ lib/posthog/events.js | 4 + package-lock.json | 1170 ++++++++++++++------- 10 files changed, 2311 insertions(+), 407 deletions(-) create mode 100644 app/hackathons/[id]/page.js create mode 100644 app/hackathons/page.js create mode 100644 app/hackathons/post/page.js create mode 100644 components/hackathons/HackathonCard.js create mode 100644 components/hackathons/HackathonFilters.js create mode 100644 components/hackathons/TeamFinder.js create mode 100644 lib/hackathons.js diff --git a/app/hackathons/[id]/page.js b/app/hackathons/[id]/page.js new file mode 100644 index 0000000..8f2a3d5 --- /dev/null +++ b/app/hackathons/[id]/page.js @@ -0,0 +1,342 @@ +"use client"; + +import Link from "next/link"; +import { useParams, useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; +import Navbar from "../../../components/Navbar"; +import ProtectedRoute from "../../../components/ProtectedRoute"; +import TeamFinder from "../../../components/hackathons/TeamFinder"; +import { useAuth } from "../../../context/AuthContext"; +import { + deleteHackathon, + getHackathon, + subscribeHackathon, + toggleBookmark, +} from "../../../lib/hackathons"; +import { captureEvent } from "../../../lib/posthog/helpers"; +import { EVENTS } from "../../../lib/posthog/events"; + +function formatNow(d) { + return d.toLocaleString(undefined, { + weekday: "short", + month: "short", + day: "numeric", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + +function DetailInner() { + const params = useParams(); + const id = params?.id; + const router = useRouter(); + const { user } = useAuth(); + const [event, setEvent] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + useEffect(() => { + if (!id) return; + let unsub = () => {}; + let active = true; + setLoading(true); + (async () => { + try { + const initial = await getHackathon(id); + if (!active) return; + if (!initial) { + setError("Hackathon not found"); + setLoading(false); + return; + } + setEvent(initial); + setLoading(false); + captureEvent(EVENTS.HACKATHON_VIEWED, { id, source: "detail", title: initial.title }); + unsub = await subscribeHackathon(id, (e) => { + if (active) setEvent(e); + }); + } catch (err) { + if (active) { + setError(err?.message || "Failed to load"); + setLoading(false); + } + } + })(); + return () => { + active = false; + try { + unsub(); + } catch {} + }; + }, [id]); + + async function onBookmark() { + if (!user || !event) return; + if (busy) return; + setBusy(true); + try { + await toggleBookmark(event.id, user); + captureEvent(EVENTS.HACKATHON_BOOKMARKED, { id: event.id, title: event.title }); + } catch (err) { + console.error("bookmark", err); + } finally { + setBusy(false); + } + } + + async function onDelete() { + if (!user || !event) return; + if (!confirm("Delete this hackathon listing? This cannot be undone.")) return; + if (busy) return; + setBusy(true); + try { + await deleteHackathon(event.id, user); + router.push("/hackathons"); + } catch (err) { + alert(err?.message || "Delete failed"); + setBusy(false); + } + } + + if (loading) { + return ( +
+ +

+ Loading... +

+
+ ); + } + if (error || !event) { + return ( +
+ +
+

+ {error || "Not found"} +

+ + Back to Hub + +
+
+ ); + } + + const isAuthor = user && event.createdBy === user.uid; + const bookmarked = user && (event.bookmarks || []).includes(user.uid); + const regDeadline = event.registrationDeadline ? new Date(event.registrationDeadline) : null; + const regClosed = regDeadline && regDeadline.getTime() < Date.now(); + + return ( +
+ +
+ + ← Back to Hub + + +
+ +
+
+
+ {event.mode === "offline" ? ( + + In-Person + + ) : ( + + Online + + )} + + {event.organizerType} + + {regClosed && ( + + Registration Closed + + )} +
+ +

+ {event.title} +

+ {event.organizer && ( +

+ by {event.organizer} +

+ )} + + {event.description && ( +

+ {event.description} +

+ )} + +
+ {event.startDate && ( +
+
+ Starts +
+
{formatNow(new Date(event.startDate))}
+
+ )} + {event.endDate && ( +
+
+ Ends +
+
{formatNow(new Date(event.endDate))}
+
+ )} + {event.registrationDeadline && ( +
+
+ Registration Deadline +
+
{formatNow(new Date(event.registrationDeadline))}
+
+ )} + {event.mode === "offline" && (event.city || event.location) && ( +
+
+ Location +
+
+ {[event.city, event.location].filter(Boolean).join(", ")} +
+
+ )} + {event.maxTeamSize && ( +
+
+ Max Team Size +
+
{event.maxTeamSize}
+
+ )} +
+ + {event.tags?.length > 0 && ( +
+ {event.tags.map((t) => ( + + #{t} + + ))} +
+ )} + +
+ {event.websiteUrl && ( + + Register on Organizer Site → + + )} + {user && !isAuthor && ( + + )} + {isAuthor && ( + + )} +
+
+ +
+ {}} /> +
+
+
+
+ ); +} + +export default function HackathonDetailPage() { + return ( + + + + ); +} diff --git a/app/hackathons/page.js b/app/hackathons/page.js new file mode 100644 index 0000000..7a23782 --- /dev/null +++ b/app/hackathons/page.js @@ -0,0 +1,228 @@ +"use client"; + +import Link from "next/link"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import Navbar from "../../components/Navbar"; +import ProtectedRoute from "../../components/ProtectedRoute"; +import HackathonCard from "../../components/hackathons/HackathonCard"; +import HackathonFilters from "../../components/hackathons/HackathonFilters"; +import { useAuth } from "../../context/AuthContext"; +import { subscribeHackathons } from "../../lib/hackathons"; +import { captureEvent } from "../../lib/posthog/helpers"; +import { EVENTS } from "../../lib/posthog/events"; + +function sortEvents(items, sort) { + const out = [...items]; + if (sort === "deadline") { + out.sort((a, b) => { + const ta = a.registrationDeadline ? new Date(a.registrationDeadline).getTime() : Infinity; + const tb = b.registrationDeadline ? new Date(b.registrationDeadline).getTime() : Infinity; + return ta - tb; + }); + } else if (sort === "start") { + out.sort((a, b) => { + const ta = a.startDate ? new Date(a.startDate).getTime() : Infinity; + const tb = b.startDate ? new Date(b.startDate).getTime() : Infinity; + return ta - tb; + }); + } else { + out.sort((a, b) => { + const ta = a.createdAt ? new Date(a.createdAt).getTime() : 0; + const tb = b.createdAt ? new Date(b.createdAt).getTime() : 0; + return tb - ta; + }); + } + return out; +} + +function HubInner() { + const { user } = useAuth(); + const [events, setEvents] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const [mode, setMode] = useState(""); + const [organizerType, setOrganizerType] = useState(""); + const [tag, setTag] = useState(""); + const [search, setSearch] = useState(""); + const [sort, setSort] = useState("newest"); + + useEffect(() => { + let unsub = () => {}; + let active = true; + setLoading(true); + subscribeHackathons((items) => { + if (!active) return; + setEvents(items); + setLoading(false); + }) + .then((u) => { + unsub = u; + }) + .catch((err) => { + console.error("subscribeHackathons failed", err); + if (active) { + setError(err?.message || "Failed to load hackathons"); + setLoading(false); + } + }); + return () => { + active = false; + try { + unsub(); + } catch {} + }; + }, []); + + const refresh = useCallback(() => { + setEvents((prev) => [...prev]); + }, []); + + const allTags = useMemo(() => { + const s = new Set(); + for (const e of events) for (const t of e.tags || []) s.add(t); + return s; + }, [events]); + + const filtered = useMemo(() => { + let list = sortEvents(events, sort); + if (mode) list = list.filter((e) => e.mode === mode); + if (organizerType) list = list.filter((e) => e.organizerType === organizerType); + if (tag) list = list.filter((e) => (e.tags || []).includes(tag)); + if (search.trim()) { + const s = search.trim().toLowerCase(); + list = list.filter( + (e) => + e.title.toLowerCase().includes(s) || + e.description.toLowerCase().includes(s) || + e.organizer.toLowerCase().includes(s) || + (e.city || "").toLowerCase().includes(s) || + (e.tags || []).some((t) => String(t).toLowerCase().includes(s)), + ); + } + return list; + }, [events, mode, organizerType, tag, search, sort]); + + const myBookmarks = useMemo( + () => (user ? filtered.filter((e) => (e.bookmarks || []).includes(user.uid)) : []), + [filtered, user], + ); + + useEffect(() => { + captureEvent(EVENTS.HACKATHON_VIEWED, { source: "hub", count: filtered.length }); + }, [filtered.length]); + + return ( +
+ +
+
+
+

+ Hackathon Hub +

+

+ Discover hackathons, find teammates, and bookmark the ones you love. +

+
+ + + Post a Hackathon + +
+ + + + {loading && ( +

+ Loading hackathons... +

+ )} + {error && ( +

+ {error} +

+ )} + + {!loading && !error && user && myBookmarks.length > 0 && ( +
+

+ Your Saved ({myBookmarks.length}) +

+
+ {myBookmarks.map((e) => ( + + ))} +
+
+ )} + + {!loading && !error && ( + <> +

+ {filtered.length} {filtered.length === 1 ? "Hackathon" : "Hackathons"} +

+ {filtered.length === 0 ? ( +
+

+ No hackathons match your filters +

+

+ Try clearing filters or post the first one! +

+ + Post a Hackathon + +
+ ) : ( +
+ {filtered.map((e) => ( + + ))} +
+ )} + + )} +
+
+ ); +} + +export default function HackathonsPage() { + return ( + + + + ); +} diff --git a/app/hackathons/post/page.js b/app/hackathons/post/page.js new file mode 100644 index 0000000..61dbd23 --- /dev/null +++ b/app/hackathons/post/page.js @@ -0,0 +1,315 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import Navbar from "../../../components/Navbar"; +import ProtectedRoute from "../../../components/ProtectedRoute"; +import { useAuth } from "../../../context/AuthContext"; +import { createHackathon } from "../../../lib/hackathons"; +import { captureEvent } from "../../../lib/posthog/helpers"; +import { EVENTS } from "../../../lib/posthog/events"; + +const BANNER_COLORS = [ + "var(--accent-primary)", + "var(--accent-ai)", + "var(--accent-success)", + "var(--accent-warning)", +]; + +function Field({ label, children, hint }) { + return ( + + ); +} + +const inputStyle = { + backgroundColor: "transparent", + borderColor: "var(--border-color)", + color: "var(--text-primary)", +}; + +function PostInner() { + const { user } = useAuth(); + const router = useRouter(); + const [form, setForm] = useState({ + title: "", + description: "", + organizer: user?.displayName || "", + organizerType: "student", + mode: "online", + location: "", + city: "", + startDate: "", + endDate: "", + registrationDeadline: "", + websiteUrl: "", + tags: "", + bannerColor: BANNER_COLORS[0], + maxTeamSize: 4, + }); + const [busy, setBusy] = useState(false); + const [err, setErr] = useState(null); + + function set(k, v) { + setForm((f) => ({ ...f, [k]: v })); + } + + async function onSubmit(e) { + e.preventDefault(); + if (busy) return; + if (!form.title.trim()) { + setErr("Title is required"); + return; + } + setBusy(true); + setErr(null); + try { + const payload = { + ...form, + tags: form.tags + ? form.tags.split(",").map((t) => t.trim()).filter(Boolean) + : [], + maxTeamSize: Number(form.maxTeamSize) || 4, + }; + const id = await createHackathon(payload, user); + captureEvent(EVENTS.HACKATHON_POSTED, { id, title: form.title }); + router.push(`/hackathons/${id}`); + } catch (e2) { + console.error("createHackathon failed", e2); + setErr(e2?.message || "Failed to post hackathon"); + } finally { + setBusy(false); + } + } + + return ( +
+ +
+
+

+ Post a Hackathon +

+

+ Share a hackathon for the DevConnect community. +

+
+ +
+ + set("title", e.target.value)} + className="rounded-md border px-3 py-2" + style={inputStyle} + /> + + + +