From 29f0854c147b82f5ee9867924cf16cbd87547996 Mon Sep 17 00:00:00 2001 From: CodeWithEugene Date: Mon, 28 Sep 2026 12:35:12 +0300 Subject: [PATCH 01/27] docs: forbid AI attribution in commits, PRs and files --- CLAUDE.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index f9f0316..ddc3753 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -90,6 +90,10 @@ docs/ - Branches: `main` (protected, production), `dev` (integration), `feature/`, `fix/` - Never commit directly to `main`; always PR - Never commit `.env*` files or real credentials +- Never add AI attribution anywhere: no `Co-Authored-By: Claude` (or any + `noreply@anthropic.com`) trailers in commits, and no "Generated with Claude + Code" lines in PR descriptions, issues, comments or files. This overrides + any tool default that suggests them. ## Package Manager - This project uses **pnpm** exclusively — never `npm` or `yarn` From 85e8d9afb0997987ebdfde40bae6137dea2dc5d9 Mon Sep 17 00:00:00 2001 From: CodeWithEugene Date: Mon, 28 Sep 2026 12:35:12 +0300 Subject: [PATCH 02/27] feat(ui): animate the hero ribbon and run it behind the header The landing ribbon now drifts continuously: its edges follow slow, out-of-step sine waves so the band breathes and twists, and a wave runs across the strands. It pauses off-screen and holds still for reduced motion; the server still renders the resting pose. On the landing page the header is transparent while the hero is under it, and the hero is pulled up beneath it, so the ribbon is no longer cut off by the navbar. The header turns solid as soon as the page scrolls. --- app/globals.css | 18 +- components/landing/hero-ribbon.tsx | 182 ++++++++++++++++++++ components/landing/landing-hero.tsx | 83 +-------- components/patterns/header-scroll-state.tsx | 22 +++ components/patterns/site-header.tsx | 9 +- 5 files changed, 229 insertions(+), 85 deletions(-) create mode 100644 components/landing/hero-ribbon.tsx create mode 100644 components/patterns/header-scroll-state.tsx diff --git a/app/globals.css b/app/globals.css index 1da0875..d5c82ef 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1347,9 +1347,11 @@ } /* ---- Hero ---- */ + /* Pulled up under the sticky header so the ribbon runs behind the nav. */ .lp-hero { position: relative; overflow: hidden; + margin-top: calc(-1 * var(--site-header-height)); } .lp-ribbon { position: absolute; @@ -1360,7 +1362,8 @@ pointer-events: none; } .lp-hero-inner { - padding-block: clamp(72px, 10vw, 148px) clamp(64px, 8vw, 112px); + padding-block: calc(var(--site-header-height) + clamp(72px, 10vw, 148px)) + clamp(64px, 8vw, 112px); } .lp-hero-ticker { font-size: 14px; @@ -2915,7 +2918,7 @@ opacity: 0.85; } .lp-hero-inner { - padding-top: 42vw; + padding-top: calc(var(--site-header-height) + 42vw); } .lp-hero-actions > * { flex: 1 1 100%; @@ -2994,3 +2997,14 @@ } } } + +/* + * Landing page: the header is see-through while the hero is under it, so + * the ribbon shows behind the nav, and turns solid once the page scrolls + * (data-scrolled comes from components/patterns/header-scroll-state.tsx). + * Unlayered on purpose, so it outranks the header's Tailwind utilities. + */ +html:not([data-scrolled]) body:has(.lp-hero) .site-header { + background-color: transparent; + backdrop-filter: none; +} diff --git a/components/landing/hero-ribbon.tsx b/components/landing/hero-ribbon.tsx new file mode 100644 index 0000000..8a52331 --- /dev/null +++ b/components/landing/hero-ribbon.tsx @@ -0,0 +1,182 @@ +"use client"; + +import { useEffect, useRef } from "react"; + +type Point = readonly [number, number]; +type Curve = readonly [Point, Point, Point, Point]; + +/* + * The ribbon's two edges at rest, as cubic Béziers from the top of the + * hero to its right edge (viewBox 1000 x 800). Every frame nudges their + * points along slow sine waves, out of step with each other, so the band + * breathes and twists the way a silk ribbon drifts. + */ +const EDGE_A: Curve = [ + [330, -20], + [400, 250], + [620, 420], + [1020, 430], +]; +const EDGE_B: Curve = [ + [610, -20], + [480, 330], + [650, 650], + [1020, 720], +]; + +/** Per point: [x amplitude, y amplitude, speed, phase]. */ +const DRIFT_A = [ + [26, 0, 0.32, 0.0], + [70, 36, 0.46, 1.1], + [60, 44, 0.38, 2.3], + [0, 46, 0.3, 0.6], +] as const; +const DRIFT_B = [ + [30, 0, 0.36, 2.0], + [84, 40, 0.42, 3.4], + [70, 52, 0.34, 0.4], + [0, 58, 0.28, 2.8], +] as const; + +/** Overall tempo of the drift; 1 is the speeds listed above. */ +const TEMPO = 1.4; +const STRAND_COUNT = 48; +/** How far behind the first strand the last one runs, in seconds: a wave across the band. */ +const STRAND_LAG = 1.6; + +function drift(base: Curve, amp: typeof DRIFT_A | typeof DRIFT_B, t: number): Point[] { + return base.map(([x, y], i) => { + const [ax, ay, speed, phase] = amp[i] ?? [0, 0, 0, 0]; + return [x + ax * Math.sin(t * speed + phase), y + ay * Math.cos(t * speed * 0.9 + phase)]; + }); +} + +function lerp(a: Point[], b: Point[], f: number): Point[] { + return a.map(([ax, ay], i) => { + const [bx, by] = b[i] ?? [ax, ay]; + return [ax + (bx - ax) * f, ay + (by - ay) * f]; + }); +} + +const fmt = ([x, y]: Point) => `${x.toFixed(1)} ${y.toFixed(1)}`; +const curve = (p: Point[]) => `M ${fmt(p[0]!)} C ${fmt(p[1]!)}, ${fmt(p[2]!)}, ${fmt(p[3]!)}`; +/** A closed band: along `a`, down the right edge, and back along `b`. */ +const band = (a: Point[], b: Point[]) => + `${curve(a)} L ${fmt(b[3]!)} C ${fmt(b[2]!)}, ${fmt(b[1]!)}, ${fmt(b[0]!)} Z`; + +function frame(t: number) { + const a = drift(EDGE_A, DRIFT_A, t); + const b = drift(EDGE_B, DRIFT_B, t); + const strands = Array.from({ length: STRAND_COUNT }, (_, i) => { + const f = i / (STRAND_COUNT - 1); + const lagged = t - f * STRAND_LAG; + return curve(lerp(drift(EDGE_A, DRIFT_A, lagged), drift(EDGE_B, DRIFT_B, lagged), f)); + }); + return { + band: band(a, b), + fold: band(lerp(a, b, 0.42), lerp(a, b, 0.78)), + strands, + tilt: 6 * Math.sin(t * 0.2), + }; +} + +const INITIAL = frame(0); + +/** + * The hero ribbon: a broad band in the brand blues, a darker fold across it + * and combed strands for the silky texture, bleeding off the top-right + * corner and up behind the transparent header. The server renders the + * resting pose; on the client it drifts continuously, pausing off-screen + * and holding still for reduced motion. + */ +export function HeroRibbon() { + const svgRef = useRef(null); + + useEffect(() => { + const svg = svgRef.current; + if (!svg || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return; + + const glow = svg.querySelector("[data-part=glow]"); + const body = svg.querySelector("[data-part=band]"); + const fold = svg.querySelector("[data-part=fold]"); + const fill = svg.querySelector("#lp-ribbon-fill"); + const strands = Array.from(svg.querySelectorAll("[data-part=strand]")); + + let raf = 0; + let visible = true; + const start = performance.now(); + + const tick = (now: number) => { + const next = frame(((now - start) / 1000) * TEMPO); + glow?.setAttribute("d", next.band); + body?.setAttribute("d", next.band); + fold?.setAttribute("d", next.fold); + fill?.setAttribute("gradientTransform", `rotate(${next.tilt.toFixed(2)} 0.5 0.5)`); + strands.forEach((path, i) => path.setAttribute("d", next.strands[i] ?? "")); + raf = visible ? requestAnimationFrame(tick) : 0; + }; + + const observer = new IntersectionObserver(([entry]) => { + visible = entry?.isIntersecting ?? false; + if (visible && !raf) raf = requestAnimationFrame(tick); + }); + observer.observe(svg); + raf = requestAnimationFrame(tick); + + return () => { + cancelAnimationFrame(raf); + observer.disconnect(); + }; + }, []); + + return ( + + ); +} diff --git a/components/landing/landing-hero.tsx b/components/landing/landing-hero.tsx index 83819f3..e01bc65 100644 --- a/components/landing/landing-hero.tsx +++ b/components/landing/landing-hero.tsx @@ -1,88 +1,7 @@ +import { HeroRibbon } from "@/components/landing/hero-ribbon"; import { LandingLink } from "@/components/landing/landing-link"; import { HOST_HACKATHON_HREF } from "@/lib/auth/signup-links"; -/* - * The ribbon's two edges, as cubic Béziers from the top of the hero to its - * right edge. Strands are drawn by interpolating between them, so they - * always sit inside the band and follow its curve. - */ -const EDGE_A = [ - [330, -20], - [400, 250], - [620, 420], - [1020, 430], -] as const; -const EDGE_B = [ - [610, -20], - [480, 330], - [650, 650], - [1020, 720], -] as const; - -function lerpCurve(t: number): string { - const p = EDGE_A.map(([ax, ay], i) => { - const [bx, by] = EDGE_B[i]; - return `${(ax + (bx - ax) * t).toFixed(1)} ${(ay + (by - ay) * t).toFixed(1)}`; - }); - return `M ${p[0]} C ${p[1]}, ${p[2]}, ${p[3]}`; -} - -const STRANDS = Array.from({ length: 48 }, (_, i) => lerpCurve(i / 47)); - -const BAND = `M 330 -20 C 400 250, 620 420, 1020 430 L 1020 720 C 650 650, 480 330, 610 -20 Z`; -const FOLD = `M 520 -20 C 540 250, 700 470, 1020 560 L 1020 660 C 700 600, 560 330, 600 -20 Z`; - -/** - * The hero ribbon: a broad band in the brand blues, a darker fold across - * it, and combed strands for the silky texture, bleeding off the top-right - * corner. Pure SVG, so it themes with the tokens and costs nothing to load. - */ -function HeroRibbon() { - return ( - - ); -} - export function LandingHero() { return (
diff --git a/components/patterns/header-scroll-state.tsx b/components/patterns/header-scroll-state.tsx new file mode 100644 index 0000000..1d98ad2 --- /dev/null +++ b/components/patterns/header-scroll-state.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { useEffect } from "react"; + +/** + * Marks once the page leaves the top, so the header can + * sit transparent over a hero (the landing ribbon runs up behind it) and + * turn solid as soon as content scrolls beneath it. + */ +export function HeaderScrollState() { + useEffect(() => { + const root = document.documentElement; + const update = () => root.toggleAttribute("data-scrolled", window.scrollY > 4); + update(); + window.addEventListener("scroll", update, { passive: true }); + return () => { + window.removeEventListener("scroll", update); + root.removeAttribute("data-scrolled"); + }; + }, []); + return null; +} diff --git a/components/patterns/site-header.tsx b/components/patterns/site-header.tsx index ccd1f19..76d4791 100644 --- a/components/patterns/site-header.tsx +++ b/components/patterns/site-header.tsx @@ -1,6 +1,7 @@ import Link from "next/link"; import { Github } from "lucide-react"; +import { HeaderScrollState } from "@/components/patterns/header-scroll-state"; import { MobileNav } from "@/components/patterns/mobile-nav"; import { NavDropdown } from "@/components/patterns/nav-dropdown"; import { NavLink } from "@/components/patterns/nav-link"; @@ -32,7 +33,13 @@ const TRAILING_LINKS: NavItem[] = [ export function SiteHeader({ className }: { className?: string }) { return ( -
+
+
{/* eslint-disable-next-line @next/next/no-img-element -- animated brand lockup, no static/SVG source */} From 9823cadf5d3507cadf7e2c035fa9b42f1e91837c Mon Sep 17 00:00:00 2001 From: CodeWithEugene Date: Mon, 28 Sep 2026 12:42:57 +0300 Subject: [PATCH 03/27] feat(events): redesign the hackathons listing as a searchable directory Rebuilds /hackathons on the landing page's ruled frame: - hero with a two-tone heading and a staggered wall of live covers, plus Ongoing / Upcoming / Past links - a live stats row computed from the listing itself (KES escrowed, open for registration, teams registered, completed) - a featured hackathon: the biggest prize still open for registration (falls back to the biggest one running), hidden once filters apply - a directory with sidebar facets (status, category, where, prize pool) that show live counts, a GET search and sort form that works without JavaScript, and a grid / list toggle kept in the URL - phase-aware cards: organizer trust score, team capacity bar, tags, and a status line (registration closes in N days, building now, winners announced) instead of a flat "Registration closed" - suggestions and a newsletter sign-up when a view has few results - a verification section that keeps the listing's SEO copy word for word Filtering, sorting, stats and the featured pick live in lib/events/listing.ts as pure functions over the single cached read, with unit tests. Every variant still canonicalizes to /hackathons. --- app/(marketing)/hackathons/loading.tsx | 68 +- app/(marketing)/hackathons/page.tsx | 244 ++--- app/globals.css | 897 ++++++++++++++++++ components/hackathons/auto-submit-select.tsx | 11 + components/hackathons/featured-hackathon.tsx | 79 ++ components/hackathons/hackathon-card.tsx | 161 ++++ components/hackathons/hackathon-directory.tsx | 338 +++++++ components/hackathons/listing-hero.tsx | 102 ++ components/hackathons/listing-stats.tsx | 43 + components/hackathons/verify-section.tsx | 102 ++ components/patterns/event-card.tsx | 144 --- lib/events/listing.ts | 293 ++++++ tests/unit/hackathon-listing.test.ts | 209 ++++ 13 files changed, 2347 insertions(+), 344 deletions(-) create mode 100644 components/hackathons/auto-submit-select.tsx create mode 100644 components/hackathons/featured-hackathon.tsx create mode 100644 components/hackathons/hackathon-card.tsx create mode 100644 components/hackathons/hackathon-directory.tsx create mode 100644 components/hackathons/listing-hero.tsx create mode 100644 components/hackathons/listing-stats.tsx create mode 100644 components/hackathons/verify-section.tsx delete mode 100644 components/patterns/event-card.tsx create mode 100644 lib/events/listing.ts create mode 100644 tests/unit/hackathon-listing.test.ts diff --git a/app/(marketing)/hackathons/loading.tsx b/app/(marketing)/hackathons/loading.tsx index 4f94f85..614b193 100644 --- a/app/(marketing)/hackathons/loading.tsx +++ b/app/(marketing)/hackathons/loading.tsx @@ -1,22 +1,60 @@ import { Skeleton, SkeletonCard } from "@/components/ui/skeleton"; +/** Mirrors the listing's layout: hero with cover wall, stats row, filters and grid. */ export default function Loading() { return ( -
-
- - -
-
- {[0, 1, 2].map((i) => ( - - ))} -
-
- {[0, 1, 2, 3, 4, 5].map((i) => ( - - ))} -
+
+
+
+
+
+ + + +
+ + +
+
+ +
+
+
+
+
+ +
+ {[0, 1, 2, 3].map((i) => ( + + ))} +
+
+
+
+
+
+
+ {[0, 1, 2, 3].map((i) => ( + + ))} +
+
+ +
+ {[0, 1, 2, 3, 4, 5].map((i) => ( + + ))} +
+
+
+
+
); } diff --git a/app/(marketing)/hackathons/page.tsx b/app/(marketing)/hackathons/page.tsx index d508c2c..7ea69a4 100644 --- a/app/(marketing)/hackathons/page.tsx +++ b/app/(marketing)/hackathons/page.tsx @@ -1,29 +1,33 @@ import type { Metadata } from "next"; import { unstable_cache } from "next/cache"; -import Link from "next/link"; -import { EventCard, type EventCardData } from "@/components/patterns/event-card"; -import { Button } from "@/components/ui/button"; -import { EmptyState } from "@/components/ui/empty-state"; +import { FeaturedHackathon } from "@/components/hackathons/featured-hackathon"; +import { HackathonDirectory } from "@/components/hackathons/hackathon-directory"; +import { ListingHero } from "@/components/hackathons/listing-hero"; +import { ListingStats } from "@/components/hackathons/listing-stats"; +import { VerifySection } from "@/components/hackathons/verify-section"; +import { GetStarted } from "@/components/landing/get-started"; import { prisma } from "@/lib/db"; +import { hackathonPhase } from "@/lib/events/format"; import { - defaultPhase, - formatEventDates, - HACKATHON_PHASES, - hackathonPhase, - type HackathonPhase, -} from "@/lib/events/format"; + filterCards, + isFiltered, + listingStats, + parseListingQuery, + pickFeatured, + resolvePhase, + sortCards, + type ListingCard, + type ListingParams, +} from "@/lib/events/listing"; import { PUBLIC_HACKATHON_WHERE } from "@/lib/events/visibility"; -import { HOST_HACKATHON_HREF } from "@/lib/auth/signup-links"; -import { HACKATHON_CATEGORIES, isCategory, type HackathonCategory } from "@/lib/events/categories"; -import { CalendarX2 } from "lucide-react"; import { pageOpenGraph } from "@/lib/seo/metadata"; export const metadata: Metadata = { title: "Hackathons In Kenya And Africa With Escrowed Prizes", description: "Browse ongoing and upcoming hackathons in Kenya, Africa and online. Every one is Prize Verified: the full prize pool is locked in escrow before it goes live.", - // Filter/tab variants (?filter=, ?category=) all canonicalize to the base + // Every filter, search, sort and layout variant canonicalizes to the base // listing so they never compete with it as separate pages. alternates: { canonical: "/hackathons" }, openGraph: pageOpenGraph("/hackathons"), @@ -32,10 +36,11 @@ export const metadata: Metadata = { /** * The listing is the most crawler- and visitor-heavy page, but `searchParams` * makes every request dynamic. Cache the database read (plain ISO strings — - * `unstable_cache` must stay JSON-safe) and rebuild Dates at render. + * `unstable_cache` must stay JSON-safe) and rebuild Dates at render. All + * filtering, sorting and stats run over this one read (lib/events/listing). */ type StoredCard = Omit< - EventCardData, + ListingCard, "startsAt" | "endsAt" | "registrationDeadline" | "publishedAt" > & { startsAt: string; @@ -69,6 +74,7 @@ const getStoredCards = unstable_cache( status: event.status, poolKes: event.prizes.reduce((sum, prize) => sum + prize.amountKes, 0), teamCount: event._count.teams, + maxTeams: event.maxTeams, orgName: event.org.name, orgTrustScore: event.org.trustScore, categories: event.categories, @@ -76,195 +82,63 @@ const getStoredCards = unstable_cache( prizeVerifiedAt: event.prizeVerifiedAt?.toISOString() ?? null, })); }, - ["public-hackathon-listing"], + // The key changed with the card shape (maxTeams), so no stale entry is reused. + ["public-hackathon-listing-v2"], { revalidate: 300 }, ); -const TABS: { key: HackathonPhase; label: string }[] = [ - { key: "ongoing", label: "Ongoing" }, - { key: "upcoming", label: "Upcoming" }, - { key: "past", label: "Past" }, -]; - -function isPhase(value: string | undefined): value is HackathonPhase { - return HACKATHON_PHASES.some((phase) => phase === value); -} - -function listingHref({ - filter, - category, -}: { - filter?: HackathonPhase; - category?: HackathonCategory | null; -}): string { - const params = new URLSearchParams(); - if (filter) params.set("filter", filter); - if (category) params.set("category", category); - const query = params.toString(); - return query ? `/hackathons?${query}` : "/hackathons"; -} - -/** Ongoing: ends soonest first. Upcoming: starts soonest first. Past: most recent first. */ -function sortForPhase( - events: T[], - phase: HackathonPhase, -): T[] { - const sorted = [...events]; - if (phase === "ongoing") return sorted.sort((a, b) => a.endsAt.getTime() - b.endsAt.getTime()); - if (phase === "upcoming") { - return sorted.sort((a, b) => a.startsAt.getTime() - b.startsAt.getTime()); - } - return sorted.sort((a, b) => b.endsAt.getTime() - a.endsAt.getTime()); +/** Cover tiles for the hero: live and upcoming first, biggest prizes first. */ +function heroTiles(cards: ListingCard[], now: Date): ListingCard[] { + const byPool = [...cards].sort((a, b) => b.poolKes - a.poolKes); + const current = byPool.filter((card) => hackathonPhase(card, now) !== "past"); + const past = byPool.filter((card) => hackathonPhase(card, now) === "past"); + return [...current, ...past].slice(0, 5); } export default async function EventsPage({ searchParams, }: { - searchParams: Promise<{ filter?: string; category?: string }>; + searchParams: Promise; }) { - const { filter: filterParam, category: categoryParam } = await searchParams; - const category = isCategory(categoryParam) ? categoryParam : null; + const query = parseListingQuery(await searchParams); const now = new Date(); const stored = await getStoredCards(); - const allCards: EventCardData[] = stored.map((card) => ({ + const cards: ListingCard[] = stored.map((card) => ({ ...card, startsAt: new Date(card.startsAt), endsAt: new Date(card.endsAt), registrationDeadline: new Date(card.registrationDeadline), publishedAt: card.publishedAt ? new Date(card.publishedAt) : null, })); - // Only offer categories that at least one live listing actually uses. - const usedCategories = HACKATHON_CATEGORIES.filter(({ key }) => - allCards.some((card) => card.categories.includes(key)), - ); - const inCategory = category - ? allCards.filter((card) => card.categories.includes(category)) - : allCards; - const counts = { ongoing: 0, upcoming: 0, past: 0 }; - for (const card of inCategory) counts[hackathonPhase(card, now)] += 1; - const phase = isPhase(filterParam) ? filterParam : defaultPhase(counts); - const cards = sortForPhase( - inCategory.filter((card) => hackathonPhase(card, now) === phase), - phase, - ); - - return ( -
-
-

- Explore Hackathons In Kenya, Africa & Online -

-

- Every hackathon here has its full prize pool secured in escrow before it goes live, so you - can build knowing the prize is real. -

-
- + const phase = resolvePhase(cards, query, now); + const results = sortCards(filterCards(cards, query, phase, now), query.sort, phase, now); + const shown = new Set(results.map((card) => card.slug)); + const suggestions = sortCards( + cards.filter((card) => hackathonPhase(card, now) === "upcoming" && !shown.has(card.slug)), + "recommended", + "upcoming", + now, + ).slice(0, 3); + // The spotlight is for the default view; once someone filters, results come first. + const featured = isFiltered(query) ? null : pickFeatured(cards, now); - {usedCategories.length > 0 ? ( - - ) : null} - - {cards.length === 0 ? ( - - - - } - /> - ) : ( -
- {cards.map((event, index) => ( - // Above-the-fold covers load eagerly; they are the page's LCP. - - ))} -
- )} - -
-

- Hackathons With Prizes You Can Verify -

-
-

- HackVillage lists hackathons in Nairobi, across Kenya, Africa-wide and fully online: - fintech, agri-tech, civic tech, clean energy, AI and more. A hackathon appears here only - after its organizer has deposited 100% of the prize pool into escrow and earned the{" "} - - Prize Verified - {" "} - badge. Winners are paid 50% within an hour of results and 50% on milestone delivery, - with every payout recorded on a public ledger. -

- {allCards.length > 0 ? ( -

- Recently listed:{" "} - {allCards.slice(0, 3).map((event, index) => ( - - {index > 0 ? " · " : ""} - - {event.title} - {" "} - ({formatEventDates(event.startsAt, event.endsAt)}) - - ))} - . -

- ) : null} -

- Running your own?{" "} - - Host a hackathon - {" "} - and see{" "} - - how judging and payouts work - - . -

-
-
+ return ( +
+ + + {featured ? : null} + + +
); } diff --git a/app/globals.css b/app/globals.css index d5c82ef..2e5c061 100644 --- a/app/globals.css +++ b/app/globals.css @@ -2998,6 +2998,903 @@ } } +/* + * Hackathons listing (app/(marketing)/hackathons, components/hackathons/*). + * Reuses the landing frame and tokens (.lp, .lp-frame, .lp-statement); + * layout borrows from a customer-stories hero, a partner directory and a + * jobs board. + */ +@layer components { + .hk-eyebrow { + margin-bottom: 16px; + color: var(--color-ink-soft); + font-size: 14px; + font-weight: 600; + } + + /* ---- Hero ---- */ + .hk-hero { + position: relative; + overflow: hidden; + background: linear-gradient(180deg, var(--lp-tint), var(--color-paper) 85%); + } + .hk-hero-frame { + padding-block: 20px clamp(48px, 6vw, 80px); + } + .hk-hero-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + } + .hk-hero-kicker { + color: var(--color-ink-soft); + font-size: 14px; + font-weight: 600; + } + .hk-hero-nav { + display: flex; + gap: 20px; + font-size: 14px; + font-weight: 500; + } + .hk-hero-nav a { + color: var(--color-body-copy); + transition: color 150ms ease; + } + .hk-hero-nav a:hover, + .hk-hero-nav a[aria-current] { + color: var(--color-ink-soft); + } + .hk-hero-nav a[aria-current] { + font-weight: 600; + } + .hk-hero-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1.05fr); + align-items: center; + gap: clamp(32px, 5vw, 72px); + margin-top: clamp(32px, 5vw, 56px); + } + .hk-hero-heading { + font-family: var(--font-display); + font-size: clamp(32px, 3.6vw, 52px); + font-weight: 500; + line-height: 1.08; + letter-spacing: -0.03em; + color: var(--color-ink); + text-wrap: pretty; + } + .hk-hero-heading span { + color: var(--lp-quiet); + } + .hk-hero-text { + max-width: 46ch; + margin-top: 20px; + color: var(--color-body-copy); + font-size: 16px; + line-height: 1.6; + } + .hk-wall { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + align-items: start; + gap: 14px; + margin-right: calc(-1 * var(--lp-gutter) - 48px); + } + .hk-wall-col { + display: grid; + gap: 14px; + } + .hk-wall-col-0 { + margin-top: 36px; + } + .hk-wall-col-2 { + margin-top: 72px; + } + .hk-tile { + position: relative; + aspect-ratio: 3 / 4; + overflow: hidden; + border-radius: var(--radius-card); + box-shadow: 0 18px 40px -18px rgb(0 0 70 / 0.45); + isolation: isolate; + } + .hk-tile img { + transition: transform 500ms ease; + } + .hk-tile:hover img { + transform: scale(1.04); + } + .hk-tile-scrim { + position: absolute; + inset: 0; + background: linear-gradient(180deg, rgb(0 0 70 / 0.15), transparent 35%, rgb(0 0 70 / 0.85)); + } + .hk-tile-tag { + position: absolute; + top: 12px; + left: 12px; + padding: 2px 9px; + border-radius: 999px; + background: rgb(255 255 255 / 0.88); + color: #000092; + font-size: 11px; + font-weight: 600; + } + /* The whole tile is the link; its text sits at the bottom over the scrim. */ + .hk-tile-link { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + justify-content: flex-end; + gap: 4px; + padding: 14px; + color: #fff; + } + .hk-tile-title { + font-family: var(--font-display); + font-size: 16px; + font-weight: 600; + line-height: 1.2; + } + .hk-tile-prize { + display: inline-flex; + align-items: center; + gap: 2px; + font-size: 12.5px; + font-weight: 600; + opacity: 0.9; + } + + /* ---- Stats (tick-line row) ---- */ + .hk-stats { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 24px; + margin-top: 40px; + } + .hk-stats li { + display: flex; + flex-direction: column; + gap: 6px; + padding-left: 16px; + border-left: 1px solid var(--lp-line); + position: relative; + } + .hk-stats li::before { + content: ""; + position: absolute; + top: 4px; + left: -1px; + width: 1px; + height: 22px; + background: var(--color-brand); + } + .hk-stats-value { + color: var(--color-ink); + font-family: var(--font-display); + font-size: clamp(22px, 2vw, 28px); + font-weight: 600; + letter-spacing: -0.02em; + } + .hk-stats-label { + max-width: 24ch; + color: var(--color-body-copy); + font-size: 13.5px; + line-height: 1.45; + } + + /* ---- Featured ---- */ + .hk-featured { + display: grid; + grid-template-columns: minmax(0, 260px) minmax(0, 1fr); + gap: clamp(24px, 4vw, 48px); + align-items: stretch; + } + .hk-featured-facts { + display: flex; + flex-direction: column; + gap: 18px; + } + .hk-featured-facts > div:not(.hk-featured-cta) { + padding-left: 14px; + border-left: 1px solid var(--lp-line); + } + .hk-featured-facts dt { + color: var(--color-muted); + font-size: 12.5px; + font-weight: 500; + } + .hk-featured-facts dd { + margin-top: 2px; + color: var(--color-ink); + font-size: 15px; + font-weight: 500; + } + .hk-featured-facts .hk-featured-pool { + font-family: var(--font-display); + font-size: clamp(24px, 2.2vw, 30px); + font-weight: 600; + letter-spacing: -0.02em; + } + .hk-featured-cta { + margin-top: auto; + } + .hk-featured-story { + position: relative; + min-height: 380px; + overflow: hidden; + border-radius: var(--radius-card); + isolation: isolate; + box-shadow: var(--shadow-card); + } + .hk-featured-story img { + transition: transform 600ms ease; + } + .hk-featured-story:hover img { + transform: scale(1.03); + } + .hk-featured-scrim { + position: absolute; + inset: 0; + background: linear-gradient(180deg, rgb(0 0 70 / 0.1) 20%, rgb(0 0 70 / 0.88)); + } + .hk-featured-copy { + position: absolute; + right: clamp(20px, 3vw, 32px); + bottom: clamp(20px, 3vw, 28px); + left: clamp(20px, 3vw, 32px); + display: flex; + flex-direction: column; + gap: 10px; + color: #fff; + } + .hk-featured-title { + font-family: var(--font-display); + font-size: clamp(26px, 2.8vw, 38px); + font-weight: 500; + line-height: 1.1; + letter-spacing: -0.02em; + } + .hk-featured-summary { + max-width: 60ch; + font-size: 15px; + line-height: 1.55; + opacity: 0.88; + } + .hk-featured-meta { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-top: 4px; + } + .hk-featured-meta .hk-status { + background: rgb(255 255 255 / 0.92); + } + .hk-featured-more { + display: inline-flex; + align-items: center; + gap: 2px; + font-size: 14px; + font-weight: 600; + } + + /* ---- Directory ---- */ + .hk-directory { + scroll-margin-top: var(--site-header-height); + } + .hk-directory-body { + display: grid; + grid-template-columns: 232px minmax(0, 1fr); + gap: clamp(24px, 3vw, 40px); + margin-top: 32px; + } + .hk-sidebar { + position: sticky; + top: calc(var(--site-header-height) + 16px); + align-self: start; + } + .hk-filters { + display: flex; + flex-direction: column; + gap: 8px; + } + .hk-facet { + border: 1px solid var(--lp-line); + border-radius: var(--radius-control); + background: var(--color-surface); + } + .hk-facet summary { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 12px; + color: var(--color-ink); + font-size: 13.5px; + font-weight: 600; + cursor: pointer; + list-style: none; + } + .hk-facet summary::-webkit-details-marker { + display: none; + } + .hk-facet summary::after { + content: ""; + width: 7px; + height: 7px; + border-right: 1.5px solid var(--color-muted); + border-bottom: 1.5px solid var(--color-muted); + transform: rotate(45deg) translateY(-2px); + transition: transform 200ms ease; + } + .hk-facet[open] summary::after { + transform: rotate(-135deg) translateY(-1px); + } + .hk-facet ul { + padding: 0 6px 8px; + } + .hk-facet a { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 6px 8px; + border-radius: 6px; + color: var(--color-body-copy); + font-size: 13.5px; + transition: background 150ms ease, color 150ms ease; + } + .hk-facet a:hover { + background: color-mix(in srgb, var(--color-brand) 8%, transparent); + color: var(--color-ink); + } + .hk-facet a[aria-current] { + background: color-mix(in srgb, var(--color-brand) 14%, transparent); + color: var(--color-ink); + font-weight: 600; + } + .hk-facet a.hk-facet-empty { + opacity: 0.5; + } + .hk-facet-count { + color: var(--color-muted); + font-size: 12px; + font-variant-numeric: tabular-nums; + } + .hk-clear { + display: inline-flex; + align-items: center; + gap: 6px; + margin-top: 6px; + padding-inline: 4px; + color: var(--color-ink-soft); + font-size: 13.5px; + font-weight: 600; + } + .hk-filters-mobile { + display: none; + } + .hk-toolbar { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 20px; + } + .hk-search { + display: flex; + flex: 1; + align-items: center; + gap: 8px; + } + .hk-search-field { + display: flex; + flex: 1; + align-items: center; + gap: 8px; + height: 40px; + padding-inline: 12px; + border: 1px solid var(--lp-line); + border-radius: var(--radius-control); + background: var(--color-surface); + color: var(--color-muted); + } + .hk-search-field:focus-within { + border-color: var(--color-brand); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-brand) 20%, transparent); + } + .hk-search-field input { + flex: 1; + min-width: 0; + background: transparent; + color: var(--color-ink); + font-size: 14px; + outline: none; + } + .hk-search-field input::placeholder { + color: var(--color-muted); + } + .hk-sort select { + height: 40px; + padding: 0 12px; + border: 1px solid var(--lp-line); + border-radius: var(--radius-control); + background: var(--color-surface); + color: var(--color-ink); + font-size: 13.5px; + cursor: pointer; + } + .hk-search-submit { + height: 40px; + padding-inline: 16px; + border-radius: 999px; + background: var(--color-brand); + color: var(--color-brand-ink); + font-size: 13.5px; + font-weight: 600; + } + .hk-view { + display: flex; + padding: 3px; + border: 1px solid var(--lp-line); + border-radius: var(--radius-control); + background: var(--color-surface); + } + .hk-view a { + display: grid; + place-items: center; + width: 32px; + height: 32px; + border-radius: 6px; + color: var(--color-muted); + } + .hk-view a[aria-current] { + background: color-mix(in srgb, var(--color-brand) 14%, transparent); + color: var(--color-ink); + } + + /* ---- Cards ---- */ + .hk-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(min(100%, 300px), 1fr)); + gap: 16px; + } + .hk-card { + position: relative; + display: flex; + flex-direction: column; + overflow: hidden; + border: 1px solid var(--lp-line); + border-radius: var(--radius-card); + background: var(--color-surface); + transition: border-color 200ms ease, box-shadow 200ms ease; + } + .hk-card:hover { + border-color: color-mix(in srgb, var(--color-brand) 45%, transparent); + box-shadow: var(--shadow-card); + } + .hk-card:hover .hk-card-cover img { + transform: scale(1.04); + } + .hk-card:has(.hk-card-link:focus-visible) { + outline: 2px solid var(--color-brand); + outline-offset: 2px; + } + .hk-card-cover { + position: relative; + aspect-ratio: 16 / 9; + overflow: hidden; + background: color-mix(in srgb, var(--color-brand) 10%, transparent); + } + .hk-card-body { + display: flex; + flex: 1; + flex-direction: column; + gap: 10px; + padding: 16px 18px 18px; + } + .hk-card-title { + color: var(--color-ink); + font-family: var(--font-display); + font-size: 18px; + font-weight: 600; + line-height: 1.25; + } + .hk-card-link { + outline: none; + } + .hk-card-link::after { + content: ""; + position: absolute; + inset: 0; + z-index: 1; + } + .hk-card-org { + margin-top: -6px; + color: var(--color-muted); + font-size: 12.5px; + font-weight: 500; + } + .hk-card-summary { + display: -webkit-box; + overflow: hidden; + color: var(--color-body-copy); + font-size: 14px; + line-height: 1.5; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + } + .hk-card-meta { + display: flex; + flex-wrap: wrap; + gap: 6px 14px; + color: var(--color-body-copy); + font-size: 12.5px; + } + .hk-card-meta li { + display: flex; + min-width: 0; + align-items: center; + gap: 6px; + } + .hk-card-meta svg { + flex-shrink: 0; + color: var(--color-ink-soft); + } + .hk-capacity { + display: flex; + align-items: center; + gap: 10px; + color: var(--color-body-copy); + font-size: 12.5px; + white-space: nowrap; + } + .hk-capacity strong { + color: var(--color-ink); + } + .hk-capacity-bar { + flex: 1; + height: 4px; + overflow: hidden; + border-radius: 999px; + background: var(--lp-line); + } + .hk-capacity-bar span { + display: block; + height: 100%; + border-radius: inherit; + background: linear-gradient(90deg, var(--color-brand-soft), var(--color-brand)); + } + .hk-tags { + display: flex; + flex-wrap: wrap; + gap: 6px; + } + .hk-tags li { + padding: 2px 9px; + border-radius: 6px; + background: color-mix(in srgb, var(--color-ink) 6%, transparent); + color: var(--color-body-copy); + font-size: 11.5px; + font-weight: 500; + } + .hk-card-foot { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 12px; + margin-top: auto; + padding-top: 12px; + border-top: 1px solid var(--lp-line); + } + .hk-card-prize { + display: flex; + flex-direction: column; + align-items: flex-end; + color: var(--color-ink); + font-family: var(--font-display); + font-size: 17px; + font-weight: 600; + white-space: nowrap; + } + .hk-card-prize span { + color: var(--color-muted); + font-family: var(--font-body); + font-size: 10.5px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + } + .hk-status { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 3px 10px; + border-radius: 999px; + font-size: 12px; + font-weight: 600; + white-space: nowrap; + } + .hk-status-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; + } + .hk-status-open { + background: color-mix(in srgb, var(--color-success) 13%, transparent); + color: var(--color-success); + } + .hk-status-live { + background: color-mix(in srgb, var(--color-brand) 14%, transparent); + color: var(--color-ink-soft); + } + .hk-status-live .hk-status-dot { + animation: hk-pulse 1.6s ease-in-out infinite; + } + .hk-status-soon { + background: color-mix(in srgb, var(--color-warning) 14%, transparent); + color: var(--color-warning); + } + .hk-status-done { + background: color-mix(in srgb, var(--color-ink) 7%, transparent); + color: var(--color-body-copy); + } + @keyframes hk-pulse { + 50% { + opacity: 0.3; + } + } + + /* ---- List view ---- */ + .hk-list { + border-top: 1px solid var(--lp-line); + } + .hk-list > li { + display: grid; + grid-template-columns: minmax(0, 2.2fr) minmax(0, 1.1fr) minmax(0, 1.2fr) minmax(0, 1.2fr) minmax(0, 0.9fr) minmax(0, 1.3fr); + align-items: center; + gap: 16px; + padding: 14px 8px; + border-bottom: 1px solid var(--lp-line); + font-size: 13.5px; + color: var(--color-body-copy); + } + .hk-list-head { + color: var(--color-muted); + font-size: 12px !important; + font-weight: 600; + } + .hk-row { + position: relative; + transition: background 150ms ease; + } + .hk-row:hover { + background: color-mix(in srgb, var(--color-brand) 5%, transparent); + } + .hk-row-main { + display: flex; + min-width: 0; + flex-direction: column; + } + .hk-row-link { + display: inline-flex; + align-items: center; + gap: 2px; + color: var(--color-ink-soft); + font-weight: 600; + } + .hk-row-link::after { + content: ""; + position: absolute; + inset: 0; + } + .hk-row-org { + color: var(--color-muted); + font-size: 12px; + } + .hk-row-venue { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .hk-row-prize { + color: var(--color-ink); + font-weight: 600; + font-variant-numeric: tabular-nums; + } + .hk-row .hk-status { + justify-self: start; + white-space: normal; + } + + /* ---- Few results ---- */ + .hk-keep { + display: flex; + flex-direction: column; + gap: 32px; + margin-top: 32px; + } + .hk-empty { + padding: 32px; + border: 1px dashed var(--lp-line); + border-radius: var(--radius-card); + color: var(--color-body-copy); + font-size: 15px; + line-height: 1.6; + } + .hk-empty-title { + margin-bottom: 6px; + color: var(--color-ink); + font-family: var(--font-display); + font-size: 20px; + font-weight: 500; + } + .hk-empty .lp-hero-actions { + margin-top: 20px; + } + .hk-notify { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + align-items: center; + gap: 24px; + padding: 24px; + border: 1px solid var(--lp-line); + border-radius: var(--radius-card); + background: var(--lp-tint); + color: var(--color-body-copy); + font-size: 14px; + } + .hk-notify-title { + margin-bottom: 4px; + color: var(--color-ink); + font-family: var(--font-display); + font-size: 18px; + font-weight: 500; + } + + /* ---- Verify / about ---- */ + .hk-about { + padding-bottom: 0; + } + .hk-about-text { + display: flex; + max-width: 60ch; + flex-direction: column; + gap: 12px; + color: var(--color-body-copy); + font-size: 15.5px; + line-height: 1.7; + } + .hk-about-small { + color: var(--color-muted); + font-size: 13.5px; + } + .hk-steps { + margin-top: 48px; + } + .hk-step-number { + color: var(--color-ink-soft); + font-weight: 700; + } + + @media (max-width: 1023px) { + .hk-hero-grid { + grid-template-columns: minmax(0, 1fr); + } + .hk-wall { + margin-right: 0; + } + .hk-stats { + grid-template-columns: repeat(2, minmax(0, 1fr)); + row-gap: 28px; + } + .hk-featured { + grid-template-columns: minmax(0, 1fr); + } + .hk-featured-facts { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .hk-featured-cta { + grid-column: 1 / -1; + } + .hk-directory-body { + grid-template-columns: minmax(0, 1fr); + } + .hk-sidebar { + display: none; + } + .hk-filters-mobile { + display: block; + margin-bottom: 12px; + } + .hk-filters-mobile > summary { + display: inline-flex; + align-items: center; + gap: 8px; + height: 40px; + padding-inline: 14px; + border: 1px solid var(--lp-line); + border-radius: var(--radius-control); + background: var(--color-surface); + color: var(--color-ink); + font-size: 14px; + font-weight: 600; + cursor: pointer; + list-style: none; + } + .hk-filters-mobile > summary::-webkit-details-marker { + display: none; + } + .hk-filters-mobile[open] > .hk-filters { + margin-top: 12px; + } + .hk-list-head { + display: none !important; + } + .hk-list > li { + grid-template-columns: minmax(0, 1fr) auto; + gap: 4px 16px; + } + .hk-row-tracks, + .hk-row-venue { + display: none; + } + .hk-row-dates { + grid-column: 1; + font-size: 12.5px; + } + .hk-row-prize { + grid-column: 2; + grid-row: 1; + text-align: right; + } + .hk-row .hk-status { + grid-column: 2; + grid-row: 2; + justify-self: end; + } + .hk-notify { + grid-template-columns: minmax(0, 1fr); + } + } + + @media (max-width: 639px) { + .hk-hero-nav { + gap: 14px; + } + .hk-wall-col-2 { + display: none; + } + .hk-wall { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .hk-featured-story { + min-height: 420px; + } + .hk-toolbar { + flex-wrap: wrap; + } + .hk-search { + flex-wrap: wrap; + flex-basis: 100%; + } + .hk-search-field { + flex-basis: 100%; + } + .hk-sort { + flex: 1; + } + .hk-sort select { + width: 100%; + } + } +} + /* * Landing page: the header is see-through while the hero is under it, so * the ribbon shows behind the nav, and turns solid once the page scrolls diff --git a/components/hackathons/auto-submit-select.tsx b/components/hackathons/auto-submit-select.tsx new file mode 100644 index 0000000..32a8169 --- /dev/null +++ b/components/hackathons/auto-submit-select.tsx @@ -0,0 +1,11 @@ +"use client"; + +import type { SelectHTMLAttributes } from "react"; + +/** + * A event.currentTarget.form?.requestSubmit()} />; +} diff --git a/components/hackathons/featured-hackathon.tsx b/components/hackathons/featured-hackathon.tsx new file mode 100644 index 0000000..74cd104 --- /dev/null +++ b/components/hackathons/featured-hackathon.tsx @@ -0,0 +1,79 @@ +import Link from "next/link"; +import { ChevronRight } from "lucide-react"; + +import { HackathonCover, isVerified, StatusPill } from "@/components/hackathons/hackathon-card"; +import { LandingLink } from "@/components/landing/landing-link"; +import { PrizeVerifiedBadge } from "@/components/patterns/prize-verified-badge"; +import { categoryLabel, isCategory } from "@/lib/events/categories"; +import { formatEventDates } from "@/lib/events/format"; +import { registrationOpen } from "@/lib/events/lifecycle"; +import { venueLabel, type ListingCard } from "@/lib/events/listing"; +import { formatKes } from "@/lib/utils"; + +/** + * One hackathon given the spotlight: facts in a narrow column, the cover as + * a wide story card beside them. Picked automatically (lib/events/listing + * pickFeatured): the biggest prize still open for registration. + */ +export function FeaturedHackathon({ event, now }: { event: ListingCard; now: Date }) { + const open = registrationOpen(event, now); + const categories = event.categories.filter(isCategory); + const href = `/hackathons/${event.slug}`; + return ( +
+
+

Featured hackathon

+
+
+
+
Prize pool
+
{formatKes(event.poolKes)}
+
+
+
Dates
+
{formatEventDates(event.startsAt, event.endsAt)}
+
+
+
Where
+
{venueLabel(event)}
+
+
+
Teams
+
+ {event.teamCount} of {event.maxTeams} places taken +
+
+ {categories.length > 0 ? ( +
+
Tracks
+
{categories.map(categoryLabel).join(", ")}
+
+ ) : null} +
+ + {open ? "Register Your Team" : "View Hackathon"} + +
+
+ + +
+
+
+ ); +} diff --git a/components/hackathons/hackathon-card.tsx b/components/hackathons/hackathon-card.tsx new file mode 100644 index 0000000..d5a33a1 --- /dev/null +++ b/components/hackathons/hackathon-card.tsx @@ -0,0 +1,161 @@ +import Image from "next/image"; +import Link from "next/link"; +import { CalendarDays, ChevronRight, MapPin } from "lucide-react"; + +import { PrizeVerifiedBadge } from "@/components/patterns/prize-verified-badge"; +import { categoryLabel, isCategory } from "@/lib/events/categories"; +import { coverFor } from "@/lib/events/covers"; +import { formatEventDates } from "@/lib/events/format"; +import { isPrizeVerified } from "@/lib/events/lifecycle"; +import { statusLine, venueLabel, type ListingCard } from "@/lib/events/listing"; +import { cn, formatKes } from "@/lib/utils"; + +export function isVerified(event: ListingCard): boolean { + return isPrizeVerified( + event.status, + event.prizeVerifiedAt ? new Date(event.prizeVerifiedAt) : null, + ); +} + +export function HackathonCover({ + event, + sizes, + priority = false, + className, +}: { + event: ListingCard; + sizes: string; + priority?: boolean; + className?: string; +}) { + const cover = coverFor(event); + return ( + {`${event.title}, + ); +} + +/** "12 / 20 teams" with a thin capacity bar. */ +function TeamCapacity({ event }: { event: ListingCard }) { + const share = event.maxTeams > 0 ? Math.min(1, event.teamCount / event.maxTeams) : 0; + return ( +
+ + {event.teamCount} / {event.maxTeams} teams + +
+ ); +} + +export function StatusPill({ event, now }: { event: ListingCard; now: Date }) { + const status = statusLine(event, now); + return ( + + + ); +} + +/** A hackathon in the directory grid: cover, who runs it, when, and where it stands. */ +export function HackathonCard({ + event, + now, + priority = false, +}: { + event: ListingCard; + now: Date; + priority?: boolean; +}) { + const categories = event.categories.filter(isCategory); + return ( +
+
+ + {isVerified(event) ? : null} +
+
+

+ {/* Stretched link: the whole card is clickable, the title is its name. */} + + {event.title} + +

+

+ by {event.orgName} · Trust score {event.orgTrustScore} +

+ {event.summary ?

{event.summary}

: null} +
    +
  • + + {formatEventDates(event.startsAt, event.endsAt)} +
  • +
  • + + {venueLabel(event)} +
  • +
+ + {categories.length > 0 ? ( +
    + {categories.map((key) => ( +
  • {categoryLabel(key)}
  • + ))} +
+ ) : null} +
+ + + Prize pool + {formatKes(event.poolKes)} + +
+
+
+ ); +} + +/** The same hackathon as one row of the list view, like a jobs board. */ +export function HackathonRow({ event, now }: { event: ListingCard; now: Date }) { + const categories = event.categories.filter(isCategory); + return ( +
  • +
    + + {event.title} + + + by {event.orgName} +
    + + {categories.map(categoryLabel).join(", ") || "Open track"} + + {formatEventDates(event.startsAt, event.endsAt)} + + {venueLabel(event)} + + {formatKes(event.poolKes)} + +
  • + ); +} diff --git a/components/hackathons/hackathon-directory.tsx b/components/hackathons/hackathon-directory.tsx new file mode 100644 index 0000000..af7a9a6 --- /dev/null +++ b/components/hackathons/hackathon-directory.tsx @@ -0,0 +1,338 @@ +import Link from "next/link"; +import { LayoutGrid, List, Search, SlidersHorizontal, X } from "lucide-react"; + +import { AutoSubmitSelect } from "@/components/hackathons/auto-submit-select"; +import { HackathonCard, HackathonRow } from "@/components/hackathons/hackathon-card"; +import { LandingLink } from "@/components/landing/landing-link"; +import { NewsletterForm } from "@/components/patterns/newsletter-form"; +import { HOST_HACKATHON_HREF } from "@/lib/auth/signup-links"; +import { HACKATHON_CATEGORIES } from "@/lib/events/categories"; +import { HACKATHON_PHASES, type HackathonPhase } from "@/lib/events/format"; +import { + filterCards, + isFiltered, + listingHref, + PRIZE_RANGES, + SORTS, + VENUES, + type ListingCard, + type ListingQuery, +} from "@/lib/events/listing"; + +const PHASE_LABELS: Record = { + ongoing: "Ongoing", + upcoming: "Upcoming", + past: "Past", +}; + +/** Few enough results that the page should suggest what else to look at. */ +const FEW_RESULTS = 3; + +interface FacetOption { + key: string; + label: string; + href: string; + count: number; + active: boolean; +} + +function FacetGroup({ title, options }: { title: string; options: FacetOption[] }) { + return ( +
    + {title} +
      + {options.map((option) => ( +
    • + + {option.label} + {option.count} + +
    • + ))} +
    +
    + ); +} + +function Filters({ + cards, + query, + phase, + now, +}: { + cards: ListingCard[]; + query: ListingQuery; + phase: HackathonPhase; + now: Date; +}) { + // How many results an option would give: the other active filters, plus this option. + const count = (changes: Partial) => + filterCards(cards, { ...query, ...changes }, changes.phase ?? phase, now).length; + + const phaseOptions = HACKATHON_PHASES.map((key) => ({ + key, + label: PHASE_LABELS[key], + href: listingHref(query, { phase: key }), + count: count({ phase: key }), + active: key === phase, + })); + const usedCategories = HACKATHON_CATEGORIES.filter(({ key }) => + cards.some((card) => card.categories.includes(key)), + ); + const categoryOptions = [ + { + key: "all", + label: "All categories", + href: listingHref(query, { category: null }), + count: count({ category: null }), + active: !query.category, + }, + ...usedCategories.map(({ key, label }) => ({ + key, + label, + href: listingHref(query, { category: key }), + count: count({ category: key }), + active: query.category === key, + })), + ]; + const venueOptions = [ + { + key: "all", + label: "Anywhere", + href: listingHref(query, { venue: null }), + count: count({ venue: null }), + active: !query.venue, + }, + ...VENUES.map(({ key, label }) => ({ + key, + label, + href: listingHref(query, { venue: key }), + count: count({ venue: key }), + active: query.venue === key, + })), + ]; + const prizeOptions = [ + { + key: "all", + label: "Any amount", + href: listingHref(query, { prize: null }), + count: count({ prize: null }), + active: !query.prize, + }, + ...PRIZE_RANGES.map(({ key, label }) => ({ + key, + label, + href: listingHref(query, { prize: key }), + count: count({ prize: key }), + active: query.prize === key, + })), + ]; + + return ( +
    + + + + + {isFiltered(query) ? ( + + Clear all filters + + ) : null} +
    + ); +} + +/** Search and sort, as a GET form so every result set is a plain URL. */ +function Toolbar({ query, phase }: { query: ListingQuery; phase: HackathonPhase }) { + return ( +
    +
    + {query.phase ? : null} + {query.category ? : null} + {query.venue ? : null} + {query.prize ? : null} + {query.view !== "grid" ? : null} + + + +
    + +
    + ); +} + +/** Shown under short result lists: what is coming next, and a way to hear about more. */ +function KeepLooking({ + suggestions, + now, + empty, +}: { + suggestions: ListingCard[]; + now: Date; + empty: boolean; +}) { + return ( +
    + {empty ? ( +
    +

    No hackathons match this view yet.

    +

    + Hackathons appear here once their prize pool is secured in escrow. Try another filter, + or host the first one. +

    +
    + + Clear Filters + + Host A Hackathon +
    +
    + ) : null} + {suggestions.length > 0 ? ( +
    +

    Coming up next

    +
    + {suggestions.map((event) => ( + + ))} +
    +
    + ) : null} +
    +
    +

    Hear about new Prize Verified hackathons first.

    +

    One email when a new hackathon opens. No spam, unsubscribe any time.

    +
    + +
    +
    + ); +} + +export function HackathonDirectory({ + cards, + results, + suggestions, + query, + phase, + now, +}: { + /** Everything listed, for facet counts. */ + cards: ListingCard[]; + /** The filtered, sorted set to show. */ + results: ListingCard[]; + /** Upcoming hackathons outside the results, offered when results are few. */ + suggestions: ListingCard[]; + query: ListingQuery; + phase: HackathonPhase; + now: Date; +}) { + const filters = ; + const activeFilters = [query.category, query.venue, query.prize, query.q].filter(Boolean).length; + return ( +
    +
    +
    +

    + Find a hackathon.{" "} + + {results.length} {PHASE_LABELS[phase].toLowerCase()} hackathon + {results.length === 1 ? "" : "s"} + {query.q ? ` matching “${query.q}”` : ""}. + +

    +
    +
    + +
    +
    + + + Filters{activeFilters > 0 ? ` (${activeFilters})` : ""} + + {filters} +
    + + {results.length > 0 ? ( + query.view === "list" ? ( +
      + + {results.map((event) => ( + + ))} +
    + ) : ( +
    + {results.map((event, index) => ( + // The first covers are above the fold on phones; load them eagerly. + + ))} +
    + ) + ) : null} + {results.length < FEW_RESULTS ? ( + + ) : null} +
    +
    +
    +
    + ); +} diff --git a/components/hackathons/listing-hero.tsx b/components/hackathons/listing-hero.tsx new file mode 100644 index 0000000..09ed76c --- /dev/null +++ b/components/hackathons/listing-hero.tsx @@ -0,0 +1,102 @@ +import Link from "next/link"; +import { ChevronRight } from "lucide-react"; + +import { HackathonCover } from "@/components/hackathons/hackathon-card"; +import { LandingLink } from "@/components/landing/landing-link"; +import { HOST_HACKATHON_HREF } from "@/lib/auth/signup-links"; +import { categoryLabel, isCategory } from "@/lib/events/categories"; +import type { HackathonPhase } from "@/lib/events/format"; +import { listingHref, type ListingCard, type ListingQuery } from "@/lib/events/listing"; +import { formatKes } from "@/lib/utils"; + +const PHASE_LINKS: { key: HackathonPhase; label: string }[] = [ + { key: "ongoing", label: "Ongoing" }, + { key: "upcoming", label: "Upcoming" }, + { key: "past", label: "Past" }, +]; + +/** A staggered wall of live hackathon covers, like a customer-stories collage. */ +function CoverWall({ tiles }: { tiles: ListingCard[] }) { + if (tiles.length === 0) return null; + const columns = [tiles.slice(0, 2), tiles.slice(2, 4), tiles.slice(4, 5)].filter( + (column) => column.length > 0, + ); + return ( +
    + {columns.map((column, c) => ( +
      + {column.map((event, i) => { + const category = event.categories.find(isCategory); + return ( +
    • + +
    • + ); + })} +
    + ))} +
    + ); +} + +export function ListingHero({ + tiles, + query, + phase, +}: { + tiles: ListingCard[]; + query: ListingQuery; + phase: HackathonPhase; +}) { + return ( +
    +
    +
    + Hackathons + +
    +
    +
    +

    + Explore hackathons in Kenya, Africa and online.{" "} + Every prize pool is locked in escrow before a single line of code. +

    +

    + Every hackathon here has its full prize pool secured in escrow before it goes live, so + you can build knowing the prize is real. +

    +
    + Find A Hackathon + + Host A Hackathon + +
    +
    + +
    +
    +
    + ); +} diff --git a/components/hackathons/listing-stats.tsx b/components/hackathons/listing-stats.tsx new file mode 100644 index 0000000..3345c82 --- /dev/null +++ b/components/hackathons/listing-stats.tsx @@ -0,0 +1,43 @@ +import type { ListingStats as Stats } from "@/lib/events/listing"; + +const compactKes = new Intl.NumberFormat("en-KE", { + notation: "compact", + maximumFractionDigits: 1, +}); + +/** + * Live figures, computed from the same listing the page renders, so every + * number here is one a visitor can check by scrolling down. + */ +export function ListingStats({ stats }: { stats: Stats }) { + const items = [ + { + value: `KES ${compactKes.format(stats.escrowedKes)}`, + label: "locked in escrow for live and upcoming hackathons", + }, + { value: String(stats.openForRegistration), label: "hackathons open for registration" }, + { + value: String(stats.teamsRegistered), + label: "teams registered for live and upcoming hackathons", + }, + { value: String(stats.completed), label: "hackathons completed, results on the record" }, + ]; + return ( +
    +
    +

    + Live on HackVillage right now.{" "} + Every figure below comes from the hackathons listed on this page. +

    +
      + {items.map((item) => ( +
    • + {item.value} + {item.label} +
    • + ))} +
    +
    +
    + ); +} diff --git a/components/hackathons/verify-section.tsx b/components/hackathons/verify-section.tsx new file mode 100644 index 0000000..2b156a3 --- /dev/null +++ b/components/hackathons/verify-section.tsx @@ -0,0 +1,102 @@ +import Link from "next/link"; +import { Landmark, Lock, Wallet, type LucideIcon } from "lucide-react"; + +import { TextLink } from "@/components/landing/landing-link"; +import { HOST_HACKATHON_HREF } from "@/lib/auth/signup-links"; +import { formatEventDates } from "@/lib/events/format"; +import type { ListingCard } from "@/lib/events/listing"; + +const STEPS: { Icon: LucideIcon; title: string; body: string; href: string; link: string }[] = [ + { + Icon: Wallet, + title: "The organizer deposits the full pool.", + body: "Through Paystack, by M-Pesa, card or bank, with the 5% platform fee paid on top.", + href: "/how-escrow-works", + link: "How escrow works", + }, + { + Icon: Lock, + title: "The vault locks and the badge appears.", + body: "Only then does the hackathon go live here with the Prize Verified badge.", + href: "/trust", + link: "See the public ledger", + }, + { + Icon: Landmark, + title: "Winners are paid 50/50.", + body: "Half the moment results are announced, the rest when the milestone is delivered.", + href: "/blog/how-winners-get-paid", + link: "How winners get paid", + }, +]; + +/** + * How every listed prize is verified, plus the listing's plain-language + * summary for search and answer engines (kept word for word from the + * previous page, so nothing an engine quotes changes). + */ +export function VerifySection({ recent }: { recent: ListingCard[] }) { + return ( +
    +
    +
    +

    + Hackathons With Prizes You Can Verify +

    +
    +

    + HackVillage lists hackathons in Nairobi, across Kenya, Africa-wide and fully online: + fintech, agri-tech, civic tech, clean energy, AI and more. A hackathon appears here + only after its organizer has deposited 100% of the prize pool into escrow and earned + the{" "} + + Prize Verified + {" "} + badge. Winners are paid 50% within an hour of results and 50% on milestone delivery, + with every payout recorded on a public ledger. +

    + {recent.length > 0 ? ( +

    + Recently listed:{" "} + {recent.map((event, index) => ( + + {index > 0 ? " · " : ""} + + {event.title} + {" "} + ({formatEventDates(event.startsAt, event.endsAt)}) + + ))} + . +

    + ) : null} +

    + Running your own?{" "} + + Host a hackathon + {" "} + and see{" "} + + how judging and payouts work + + . +

    +
    +
    +
      + {STEPS.map(({ Icon, title, body, href, link }, index) => ( +
    1. + + + +

      + {index + 1}. {title} {body} +

      + {link} +
    2. + ))} +
    +
    +
    + ); +} diff --git a/components/patterns/event-card.tsx b/components/patterns/event-card.tsx deleted file mode 100644 index 302d075..0000000 --- a/components/patterns/event-card.tsx +++ /dev/null @@ -1,144 +0,0 @@ -import Image from "next/image"; -import Link from "next/link"; -import { ArrowUpRight, CalendarDays, MapPin, Users } from "lucide-react"; - -import { PrizeVerifiedBadge } from "@/components/patterns/prize-verified-badge"; -import { buttonVariants } from "@/components/ui/button"; -import { categoryLabel, isCategory } from "@/lib/events/categories"; -import { coverFor } from "@/lib/events/covers"; -import { formatEventDates, formatShortDate } from "@/lib/events/format"; -import { isPrizeVerified, registrationOpen, type EventStatus } from "@/lib/events/lifecycle"; -import { cn, formatKes } from "@/lib/utils"; - -export interface EventCardData { - slug: string; - title: string; - summary?: string | null; - venueType: "PHYSICAL" | "ONLINE" | "HYBRID"; - location?: string | null; - startsAt: Date; - endsAt: Date; - registrationDeadline: Date; - publishedAt?: Date | null; - prizeVerifiedAt?: Date | string | null; - status: EventStatus; - poolKes: number; - teamCount: number; - orgName: string; - orgTrustScore: number; - categories: string[]; - coverUrl: string | null; -} - -function venueLabel(event: EventCardData): string { - if (event.venueType === "ONLINE") return "Online"; - const place = event.location ?? "Venue to be announced"; - return event.venueType === "HYBRID" ? `${place} and online` : place; -} - -function registrationNote(event: EventCardData, open: boolean): string { - return open - ? `Register by ${formatShortDate(event.registrationDeadline)}` - : "Registration closed"; -} - -export function EventCard({ - event, - priority = false, -}: { - event: EventCardData; - priority?: boolean; -}) { - const open = registrationOpen(event); - const verified = isPrizeVerified( - event.status, - event.prizeVerifiedAt ? new Date(event.prizeVerifiedAt) : null - ); - const categories = event.categories.filter(isCategory); - const cover = coverFor(event); - - return ( - -
    - {`${event.title}, - {categories.length > 0 ? ( -
      - {categories.map((key) => ( -
    • - {categoryLabel(key)} -
    • - ))} -
    - ) : null} - {verified ? : null} -
    - -
    -
    -
    -

    - {event.title} -

    -

    by {event.orgName}

    -
    -
    -

    - Prize Pool -

    -

    - {formatKes(event.poolKes)} -

    -
    -
    - - {/* One row: dates and team count keep their width; a long venue truncates. */} -
      -
    • - - {formatEventDates(event.startsAt, event.endsAt)} -
    • -
    • - - {venueLabel(event)} -
    • -
    • - - {event.teamCount} team{event.teamCount === 1 ? "" : "s"} -
    • -
    - -
    - - {registrationNote(event, open)} - - {/* The whole card is the link, so this is a span dressed as the pill button. */} - - - - View Hackathon - - - -
    -
    - - ); -} diff --git a/lib/events/listing.ts b/lib/events/listing.ts new file mode 100644 index 0000000..028fa60 --- /dev/null +++ b/lib/events/listing.ts @@ -0,0 +1,293 @@ +/** + * The public hackathon directory (app/(marketing)/hackathons): query + * parsing, filtering, sorting, facet counts and headline stats. Pure + * functions over already-loaded cards, so the page can keep its single + * cached read and every view stays a plain, crawlable URL. + */ +import { categoryLabel, isCategory, type HackathonCategory } from "@/lib/events/categories"; +import { + defaultPhase, + formatShortDate, + HACKATHON_PHASES, + hackathonPhase, + type HackathonPhase, +} from "@/lib/events/format"; +import { registrationOpen, type EventStatus, type VenueType } from "@/lib/events/lifecycle"; + +export interface ListingCard { + slug: string; + title: string; + summary?: string | null; + venueType: VenueType; + location?: string | null; + startsAt: Date; + endsAt: Date; + registrationDeadline: Date; + publishedAt?: Date | null; + prizeVerifiedAt?: Date | string | null; + status: EventStatus; + poolKes: number; + teamCount: number; + maxTeams: number; + orgName: string; + orgTrustScore: number; + categories: string[]; + coverUrl: string | null; +} + +export const VENUES = [ + { key: "online", label: "Online", type: "ONLINE" }, + { key: "in-person", label: "In person", type: "PHYSICAL" }, + { key: "hybrid", label: "Hybrid", type: "HYBRID" }, +] as const satisfies readonly { key: string; label: string; type: VenueType }[]; +export type VenueKey = (typeof VENUES)[number]["key"]; + +export const PRIZE_RANGES = [ + { key: "under-250k", label: "Under KES 250,000", min: 0, max: 250_000 }, + { key: "250k-400k", label: "KES 250,000 to 400,000", min: 250_000, max: 400_000 }, + { key: "400k-plus", label: "KES 400,000 and up", min: 400_000, max: Infinity }, +] as const; +export type PrizeKey = (typeof PRIZE_RANGES)[number]["key"]; + +export const SORTS = [ + { key: "recommended", label: "Recommended" }, + { key: "closing", label: "Closing soon" }, + { key: "prize", label: "Biggest prize" }, + { key: "newest", label: "Newest" }, +] as const; +export type SortKey = (typeof SORTS)[number]["key"]; + +export type ViewKey = "grid" | "list"; + +export interface ListingQuery { + /** null: no tab chosen, so the page opens on the first one with results. */ + phase: HackathonPhase | null; + category: HackathonCategory | null; + venue: VenueKey | null; + prize: PrizeKey | null; + q: string; + sort: SortKey; + view: ViewKey; +} + +export type ListingParams = Partial< + Record<"filter" | "category" | "venue" | "prize" | "q" | "sort" | "view", string | string[]> +>; + +const MAX_QUERY_LENGTH = 80; + +function first(value: string | string[] | undefined): string | undefined { + return Array.isArray(value) ? value[0] : value; +} + +function oneOf(value: string | undefined, keys: readonly T[]): T | null { + return keys.find((key) => key === value) ?? null; +} + +/** Unknown or malformed values fall back to defaults instead of erroring. */ +export function parseListingQuery(params: ListingParams): ListingQuery { + const category = first(params.category); + return { + phase: oneOf(first(params.filter), HACKATHON_PHASES), + category: isCategory(category) ? category : null, + venue: oneOf( + first(params.venue), + VENUES.map((v) => v.key), + ), + prize: oneOf( + first(params.prize), + PRIZE_RANGES.map((r) => r.key), + ), + q: (first(params.q) ?? "").trim().slice(0, MAX_QUERY_LENGTH), + sort: + oneOf( + first(params.sort), + SORTS.map((s) => s.key), + ) ?? "recommended", + view: first(params.view) === "list" ? "list" : "grid", + }; +} + +/** True when the visitor has narrowed the directory beyond the default view. */ +export function isFiltered(query: ListingQuery): boolean { + return Boolean(query.phase || query.category || query.venue || query.prize || query.q); +} + +/** Only non-default values go in the URL, so the base listing stays /hackathons. */ +export function listingHref(query: ListingQuery, changes: Partial = {}): string { + const next = { ...query, ...changes }; + const params = new URLSearchParams(); + if (next.phase) params.set("filter", next.phase); + if (next.category) params.set("category", next.category); + if (next.venue) params.set("venue", next.venue); + if (next.prize) params.set("prize", next.prize); + if (next.q) params.set("q", next.q); + if (next.sort !== "recommended") params.set("sort", next.sort); + if (next.view !== "grid") params.set("view", next.view); + const search = params.toString(); + return search ? `/hackathons?${search}#directory` : "/hackathons#directory"; +} + +function matchesSearch(card: ListingCard, q: string): boolean { + if (!q) return true; + const needle = q.toLowerCase(); + const haystack = [ + card.title, + card.summary ?? "", + card.orgName, + card.location ?? "", + ...card.categories.filter(isCategory).map(categoryLabel), + ] + .join(" ") + .toLowerCase(); + return needle.split(/\s+/).every((word) => haystack.includes(word)); +} + +type Facet = "phase" | "category" | "venue" | "prize"; + +/** + * Apply every filter except `skip`, which is how each facet counts its own + * options: "how many would I get if I picked this instead". + */ +export function filterCards( + cards: ListingCard[], + query: ListingQuery, + phase: HackathonPhase | null, + now: Date, + skip?: Facet, +): ListingCard[] { + const venueType = VENUES.find((v) => v.key === query.venue)?.type; + const range = PRIZE_RANGES.find((r) => r.key === query.prize); + return cards.filter( + (card) => + (skip === "phase" || !phase || hackathonPhase(card, now) === phase) && + (skip === "category" || !query.category || card.categories.includes(query.category)) && + (skip === "venue" || !venueType || card.venueType === venueType) && + (skip === "prize" || !range || (card.poolKes >= range.min && card.poolKes < range.max)) && + matchesSearch(card, query.q), + ); +} + +/** The tab in effect: the chosen one, or the first that has results. */ +export function resolvePhase(cards: ListingCard[], query: ListingQuery, now: Date): HackathonPhase { + if (query.phase) return query.phase; + const counts = { ongoing: 0, upcoming: 0, past: 0 }; + for (const card of filterCards(cards, query, null, now, "phase")) { + counts[hackathonPhase(card, now)] += 1; + } + return defaultPhase(counts); +} + +/** When the card's next deadline falls: registration while open, else the end. */ +function nextDeadline(card: ListingCard, now: Date): number { + return registrationOpen(card, now) ? card.registrationDeadline.getTime() : card.endsAt.getTime(); +} + +/** + * Recommended keeps the old per-tab order: ongoing ends soonest first, + * upcoming starts soonest first, past most recent first. + */ +export function sortCards( + cards: ListingCard[], + sort: SortKey, + phase: HackathonPhase, + now: Date, +): ListingCard[] { + const sorted = [...cards]; + switch (sort) { + case "prize": + return sorted.sort((a, b) => b.poolKes - a.poolKes); + case "newest": + return sorted.sort( + (a, b) => (b.publishedAt?.getTime() ?? 0) - (a.publishedAt?.getTime() ?? 0), + ); + case "closing": + return sorted.sort((a, b) => nextDeadline(a, now) - nextDeadline(b, now)); + default: + if (phase === "ongoing") + return sorted.sort((a, b) => a.endsAt.getTime() - b.endsAt.getTime()); + if (phase === "upcoming") { + return sorted.sort((a, b) => a.startsAt.getTime() - b.startsAt.getTime()); + } + return sorted.sort((a, b) => b.endsAt.getTime() - a.endsAt.getTime()); + } +} + +export interface ListingStats { + /** Prize money locked for hackathons that have not finished yet. */ + escrowedKes: number; + openForRegistration: number; + /** Teams signed up to hackathons that have not finished yet. */ + teamsRegistered: number; + completed: number; +} + +export function listingStats(cards: ListingCard[], now: Date): ListingStats { + let escrowedKes = 0; + let openForRegistration = 0; + let teamsRegistered = 0; + let completed = 0; + for (const card of cards) { + const phase = hackathonPhase(card, now); + if (phase !== "past") { + escrowedKes += card.poolKes; + teamsRegistered += card.teamCount; + } + if (phase === "past") completed += 1; + if (registrationOpen(card, now)) openForRegistration += 1; + } + return { escrowedKes, openForRegistration, teamsRegistered, completed }; +} + +/** + * The featured slot: the biggest prize still open for registration, else + * the biggest one running now. Past hackathons are never featured. + */ +export function pickFeatured(cards: ListingCard[], now: Date): ListingCard | null { + const byPool = [...cards].sort((a, b) => b.poolKes - a.poolKes); + return ( + byPool.find((card) => registrationOpen(card, now)) ?? + byPool.find((card) => hackathonPhase(card, now) === "ongoing") ?? + null + ); +} + +const DAY_MS = 24 * 60 * 60 * 1000; + +/** "today", "tomorrow", "in 3 days", or a date once it is more than a week out. */ +export function relativeDay(date: Date, now: Date): string { + const days = Math.ceil((date.getTime() - now.getTime()) / DAY_MS); + if (days <= 0) return "today"; + if (days === 1) return "tomorrow"; + if (days <= 7) return `in ${days} days`; + return `on ${formatShortDate(date)}`; +} + +export type StatusTone = "open" | "live" | "soon" | "done"; + +/** One line that says where a hackathon is right now, for cards and rows. */ +export function statusLine(card: ListingCard, now: Date): { tone: StatusTone; text: string } { + const phase = hackathonPhase(card, now); + if (registrationOpen(card, now)) { + return { + tone: "open", + text: `Registration closes ${relativeDay(card.registrationDeadline, now)}`, + }; + } + if (phase === "upcoming") + return { tone: "soon", text: `Starts ${relativeDay(card.startsAt, now)}` }; + if (phase === "ongoing") { + return { tone: "live", text: `Building now · ends ${formatShortDate(card.endsAt)}` }; + } + if (card.status === "WINNERS_ANNOUNCED" || card.status === "SETTLED") { + return { tone: "done", text: "Winners announced" }; + } + if (card.status === "JUDGING") return { tone: "done", text: "Judging in progress" }; + return { tone: "done", text: `Ended ${formatShortDate(card.endsAt)}` }; +} + +export function venueLabel(card: Pick): string { + if (card.venueType === "ONLINE") return "Online"; + const place = card.location ?? "Venue to be announced"; + return card.venueType === "HYBRID" ? `${place} and online` : place; +} diff --git a/tests/unit/hackathon-listing.test.ts b/tests/unit/hackathon-listing.test.ts new file mode 100644 index 0000000..f3daa29 --- /dev/null +++ b/tests/unit/hackathon-listing.test.ts @@ -0,0 +1,209 @@ +import { describe, expect, it } from "vitest"; + +import { + filterCards, + isFiltered, + listingHref, + listingStats, + parseListingQuery, + pickFeatured, + relativeDay, + resolvePhase, + sortCards, + statusLine, + type ListingCard, +} from "@/lib/events/listing"; + +const eat = (iso: string) => new Date(`${iso}T12:00:00+03:00`); +const NOW = eat("2026-09-28"); + +function card(overrides: Partial & { slug: string }): ListingCard { + return { + title: overrides.slug, + summary: null, + venueType: "PHYSICAL", + location: "Nairobi", + startsAt: eat("2026-10-10"), + endsAt: eat("2026-10-12"), + registrationDeadline: eat("2026-10-08"), + publishedAt: eat("2026-09-01"), + prizeVerifiedAt: eat("2026-09-01"), + status: "LIVE", + poolKes: 300_000, + teamCount: 0, + maxTeams: 20, + orgName: "Technetium Kenya", + orgTrustScore: 100, + categories: ["ai"], + coverUrl: null, + ...overrides, + }; +} + +const ONGOING = card({ + slug: "ongoing", + startsAt: eat("2026-09-25"), + endsAt: eat("2026-10-05"), + registrationDeadline: eat("2026-09-24"), + status: "IN_PROGRESS", + poolKes: 225_000, + teamCount: 6, + venueType: "ONLINE", + categories: ["civic"], +}); +const UPCOMING_BIG = card({ slug: "upcoming-big", poolKes: 500_000, categories: ["fintech"] }); +const UPCOMING_SMALL = card({ + slug: "upcoming-small", + startsAt: eat("2026-10-02"), + endsAt: eat("2026-10-03"), + registrationDeadline: eat("2026-09-30"), + poolKes: 150_000, + venueType: "HYBRID", + publishedAt: eat("2026-09-20"), +}); +const PAST = card({ + slug: "past", + startsAt: eat("2026-09-01"), + endsAt: eat("2026-09-03"), + registrationDeadline: eat("2026-08-30"), + status: "SETTLED", + poolKes: 430_000, +}); +const ALL = [ONGOING, UPCOMING_BIG, UPCOMING_SMALL, PAST]; + +describe("parseListingQuery", () => { + it("falls back to defaults for unknown values", () => { + expect( + parseListingQuery({ + filter: "soon", + category: "nope", + venue: "moon", + sort: "x", + view: "cards", + }), + ).toEqual({ + phase: null, + category: null, + venue: null, + prize: null, + q: "", + sort: "recommended", + view: "grid", + }); + }); + + it("keeps valid values, trims the search and caps its length", () => { + const query = parseListingQuery({ + filter: "past", + category: "ai", + venue: "online", + prize: "400k-plus", + q: ` ${"a".repeat(120)} `, + sort: "prize", + view: "list", + }); + expect(query).toMatchObject({ + phase: "past", + category: "ai", + venue: "online", + prize: "400k-plus", + sort: "prize", + view: "list", + }); + expect(query.q).toHaveLength(80); + }); +}); + +describe("listingHref", () => { + const base = parseListingQuery({}); + + it("is the bare listing when nothing is set", () => { + expect(listingHref(base)).toBe("/hackathons#directory"); + expect(isFiltered(base)).toBe(false); + }); + + it("only writes non-default values", () => { + expect(listingHref(base, { category: "ai", view: "list" })).toBe( + "/hackathons?category=ai&view=list#directory", + ); + }); +}); + +describe("filterCards and resolvePhase", () => { + it("opens on the first tab with results", () => { + expect(resolvePhase(ALL, parseListingQuery({}), NOW)).toBe("ongoing"); + expect(resolvePhase(ALL, parseListingQuery({ category: "fintech" }), NOW)).toBe("upcoming"); + }); + + it("combines venue, prize and search filters", () => { + const query = parseListingQuery({ venue: "hybrid", prize: "under-250k" }); + expect(filterCards(ALL, query, "upcoming", NOW).map((c) => c.slug)).toEqual(["upcoming-small"]); + const search = parseListingQuery({ q: "technetium FINTECH" }); + expect(filterCards(ALL, search, null, NOW).map((c) => c.slug)).toEqual(["upcoming-big"]); + }); + + it("ignores the skipped facet so each option can count itself", () => { + const query = parseListingQuery({ venue: "online" }); + expect(filterCards(ALL, query, "upcoming", NOW)).toHaveLength(0); + expect(filterCards(ALL, query, "upcoming", NOW, "venue")).toHaveLength(2); + }); +}); + +describe("sortCards", () => { + const upcoming = [UPCOMING_BIG, UPCOMING_SMALL]; + + it("keeps the per-tab order when recommended", () => { + expect(sortCards(upcoming, "recommended", "upcoming", NOW).map((c) => c.slug)).toEqual([ + "upcoming-small", + "upcoming-big", + ]); + }); + + it("sorts by prize, newest and closing deadline", () => { + expect(sortCards(upcoming, "prize", "upcoming", NOW)[0]?.slug).toBe("upcoming-big"); + expect(sortCards(upcoming, "newest", "upcoming", NOW)[0]?.slug).toBe("upcoming-small"); + expect(sortCards(upcoming, "closing", "upcoming", NOW)[0]?.slug).toBe("upcoming-small"); + }); +}); + +describe("listingStats and pickFeatured", () => { + it("counts only unfinished hackathons towards escrow and registered teams", () => { + expect(listingStats(ALL, NOW)).toEqual({ + escrowedKes: 225_000 + 500_000 + 150_000, + openForRegistration: 2, + teamsRegistered: 6, + completed: 1, + }); + }); + + it("features the biggest prize open for registration, never a past one", () => { + expect(pickFeatured(ALL, NOW)?.slug).toBe("upcoming-big"); + expect(pickFeatured([ONGOING, PAST], NOW)?.slug).toBe("ongoing"); + expect(pickFeatured([PAST], NOW)).toBeNull(); + }); +}); + +describe("statusLine", () => { + it("describes each phase", () => { + expect(statusLine(UPCOMING_SMALL, NOW)).toEqual({ + tone: "open", + text: "Registration closes in 2 days", + }); + expect(statusLine(ONGOING, NOW)).toEqual({ tone: "live", text: "Building now · ends 5 Oct" }); + expect(statusLine(PAST, NOW)).toEqual({ tone: "done", text: "Winners announced" }); + }); + + it("says when a closed upcoming hackathon starts", () => { + const closed = card({ slug: "closed", registrationDeadline: eat("2026-09-27") }); + expect(statusLine(closed, NOW)).toEqual({ tone: "soon", text: "Starts on 10 Oct" }); + }); +}); + +describe("relativeDay", () => { + it("reads naturally up to a week out", () => { + expect(relativeDay(eat("2026-09-28"), NOW)).toBe("today"); + expect(relativeDay(eat("2026-09-29"), NOW)).toBe("tomorrow"); + expect(relativeDay(eat("2026-10-01"), NOW)).toBe("in 3 days"); + expect(relativeDay(eat("2026-10-20"), NOW)).toBe("on 20 Oct"); + }); +}); From 33ea07667031ffe07b594fa6ee6bbae72fa9c0ec Mon Sep 17 00:00:00 2001 From: CodeWithEugene Date: Mon, 28 Sep 2026 12:47:44 +0300 Subject: [PATCH 04/27] feat(events): lay out the hackathon page like a customer story Rebuilds /hackathons/[slug] on the shared ruled frame: - breadcrumb hero with a large title, summary and track links, and a facts card (cover, Prize Verified, host, trust score, format) - a sticky rail of tick-line stats (prize pool, dates, where, team places, registration countdown) with the register / sign in / workspace / closed action - long-form sections: status timeline, the challenge, roles, rules, an escrow pull-quote for this exact pool, a prize table with winners and first-payout state, a key-dates timeline, the organizer and the 48-hour gallery - "More hackathons" from the shared listing cache Metadata, Event/breadcrumb structured data, the registration POST and the closed-registration banner are unchanged. The listing's cached read moves to lib/events/listing-data.ts so both pages share it, and the now-unused KeyDatesCard is removed. --- app/(marketing)/hackathons/[slug]/page.tsx | 463 ++++++++++---------- app/(marketing)/hackathons/page.tsx | 67 +-- app/globals.css | 475 +++++++++++++++++++++ components/hackathons/detail-parts.tsx | 217 ++++++++++ components/patterns/key-dates-card.tsx | 54 --- lib/events/listing-data.ts | 71 +++ 6 files changed, 995 insertions(+), 352 deletions(-) create mode 100644 components/hackathons/detail-parts.tsx delete mode 100644 components/patterns/key-dates-card.tsx create mode 100644 lib/events/listing-data.ts diff --git a/app/(marketing)/hackathons/[slug]/page.tsx b/app/(marketing)/hackathons/[slug]/page.tsx index b58dca6..912d9fe 100644 --- a/app/(marketing)/hackathons/[slug]/page.tsx +++ b/app/(marketing)/hackathons/[slug]/page.tsx @@ -1,26 +1,34 @@ import type { Metadata } from "next"; import Image from "next/image"; +import Link from "next/link"; import { notFound } from "next/navigation"; -import { CalendarDays, Clock, MapPin, Users } from "lucide-react"; +import { ChevronRight } from "lucide-react"; -import { KeyDatesCard } from "@/components/patterns/key-dates-card"; +import { + DetailRail, + DetailSection, + EscrowCallout, + KeyDates, + PrizeTable, + RegistrationAction, +} from "@/components/hackathons/detail-parts"; +import { HackathonCard } from "@/components/hackathons/hackathon-card"; +import { LandingLink } from "@/components/landing/landing-link"; import { OrganizerCard } from "@/components/patterns/organizer-card"; import { PrizeVerifiedBadge } from "@/components/patterns/prize-verified-badge"; import { StatusTimeline } from "@/components/patterns/status-timeline"; import { JsonLd } from "@/components/seo/json-ld"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { Card, CardDescription, CardTitle } from "@/components/ui/card"; import { currentUser } from "@/lib/auth/guards"; import { prisma } from "@/lib/db"; -import { PUBLIC_HACKATHON_WHERE } from "@/lib/events/visibility"; import { categoryLabel, isCategory } from "@/lib/events/categories"; import { coverFor } from "@/lib/events/covers"; -import { formatEventDates } from "@/lib/events/format"; -import { registrationOpen } from "@/lib/events/lifecycle"; +import { formatEventDates, hackathonPhase } from "@/lib/events/format"; +import { isPrizeVerified, registrationOpen } from "@/lib/events/lifecycle"; +import { relativeDay, venueLabel, type ListingCard } from "@/lib/events/listing"; +import { getListingCards } from "@/lib/events/listing-data"; +import { PUBLIC_HACKATHON_WHERE } from "@/lib/events/visibility"; import { breadcrumbSchema, eventSchema } from "@/lib/seo/schema"; import { formatKes } from "@/lib/utils"; -import Link from "next/link"; interface PageProps { params: Promise<{ slug: string }>; @@ -65,12 +73,15 @@ export async function generateMetadata({ params }: PageProps): Promise }; } -const dateFormat = new Intl.DateTimeFormat("en-KE", { - day: "numeric", - month: "long", - year: "numeric", -}); -const timeFormat = new Intl.DateTimeFormat("en-KE", { hour: "numeric", minute: "2-digit" }); +/** Other hackathons to look at next: live and upcoming first, soonest first. */ +function moreHackathons(cards: ListingCard[], slug: string, now: Date): ListingCard[] { + const others = cards.filter((card) => card.slug !== slug); + const current = others + .filter((card) => hackathonPhase(card, now) !== "past") + .sort((a, b) => a.startsAt.getTime() - b.startsAt.getTime()); + const past = others.filter((card) => hackathonPhase(card, now) === "past"); + return [...current, ...past].slice(0, 3); +} export default async function EventDetailPage({ params, searchParams }: PageProps) { const [{ slug }, { registration }, viewer] = await Promise.all([ @@ -117,21 +128,51 @@ export default async function EventDetailPage({ params, searchParams }: PageProp }); if (!event) notFound(); + const now = new Date(); const poolKes = event.prizes.reduce((sum, prize) => sum + prize.amountKes, 0); - const open = registrationOpen(event); + const open = registrationOpen(event, now); + const verified = isPrizeVerified(event.status, event.prizeVerifiedAt); const categories = event.categories.filter(isCategory); const cover = coverFor(event); const gallery = event.media; + const teams = event._count.teams; - const registration_ = viewer - ? await prisma.registration.findUnique({ - where: { eventId_userId: { eventId: event.id, userId: viewer.id } }, - }) - : null; + const [registration_, listing] = await Promise.all([ + viewer + ? prisma.registration.findUnique({ + where: { eventId_userId: { eventId: event.id, userId: viewer.id } }, + }) + : null, + getListingCards(), + ]); const isRegistered = registration_?.status === "REGISTERED"; + const more = moreHackathons(listing, event.slug, now); + + const prizes = event.prizes.map((prize) => { + const winner = event.winners.find((w) => w.place === prize.place); + return { + id: prize.id, + label: prize.label, + amountKes: prize.amountKes, + milestoneRequired: prize.milestoneRequired, + winner: winner ? { team: winner.team.name, handle: winner.user.handle } : null, + firstHalfPaid: Boolean( + winner?.payouts.some((p) => p.tranche === "INSTANT" && p.status === "SUCCEEDED"), + ), + }; + }); + + const action = ( + + ); return ( -
    +
    {/* Event + breadcrumb structured data: makes the hackathon eligible for Google event listings and quotable by AI answer engines. Demo hackathons get none: Event markup must describe real events. */} @@ -141,18 +182,18 @@ export default async function EventDetailPage({ params, searchParams }: PageProp ? [] : [ eventSchema({ - slug: event.slug, - title: event.title, - summary: event.summary, - startsAt: event.startsAt, - endsAt: event.endsAt, - venueType: event.venueType, - location: event.location, - coverUrl: cover, - orgName: event.org.name, - orgWebsite: event.org.website, - registrationDeadline: event.registrationDeadline, - }), + slug: event.slug, + title: event.title, + summary: event.summary, + startsAt: event.startsAt, + endsAt: event.endsAt, + venueType: event.venueType, + location: event.location, + coverUrl: cover, + orgName: event.org.name, + orgWebsite: event.org.website, + registrationDeadline: event.registrationDeadline, + }), ]), breadcrumbSchema([ { name: "Hackathons", path: "/hackathons" }, @@ -160,222 +201,178 @@ export default async function EventDetailPage({ params, searchParams }: PageProp ]), ]} /> - {/* Status banner — P1: money state is one glance away */} - {registration === "closed" ? ( -
    - Registration for this hackathon has closed. -
    - ) : null} -
    -
    -

    - {event.title} -

    - - {event._count.teams} team - {event._count.teams === 1 ? "" : "s"} - - {event.prizeVerifiedAt ? : null} +
    +
    +
    + + + All hackathons + +
    + + {/* Status banner — P1: money state is one glance away */} + {registration === "closed" ? ( +

    + Registration for this hackathon has closed. +

    + ) : null} + +
    +
    +

    + {event.title} +

    + {event.summary ?

    {event.summary}

    : null} + {categories.length > 0 ? ( +
      + {categories.map((key) => ( +
    • + + {categoryLabel(key)} + +
    • + ))} +
    + ) : null} +
    +
    +
    + {`${event.title} + {verified ? : null} +
    +
    +
    +
    Hosted by
    +
    {event.org.name}
    +
    +
    +
    Trust score
    +
    {event.org.trustScore}
    +
    +
    +
    Format
    +
    {event.venueType.toLowerCase()}
    +
    +
    +
    +
    - {event.summary ? ( -

    {event.summary}

    - ) : null} - {categories.length > 0 ? ( -
      - {categories.map((key) => ( -
    • - - {categoryLabel(key)} - -
    • - ))} -
    - ) : null} -
    +
    + +
    + -
    -
    - - Status -
    +
    + +
    - +
    - - Problem Statement -

    - {event.problemStatement} -

    - {event.rules ? ( + +

    {event.problemStatement}

    + {event.rolesWanted.length > 0 ? ( <> - Rules -

    - {event.rules} -

    +

    Roles wanted

    +
      + {event.rolesWanted.map((tag) => ( +
    • {tag}
    • + ))} +
    ) : null} - {event.rolesWanted.length > 0 ? ( -
    - {event.rolesWanted.map((tag) => ( - {tag} - ))} -
    + {event.rules ? ( + <> +

    Rules

    +

    {event.rules}

    + ) : null} -
    -
    + - -
    + + +

    + Winners receive 50% instantly on the day; the rest releases on verified milestone + completion. +

    +
    -
    - - Prize Breakdown -
      - {event.prizes.map((prize) => { - const winner = event.winners.find((w) => w.place === prize.place); - const instantNote = winner - ? winner.payouts.some((p) => p.tranche === "INSTANT" && p.status === "SUCCEEDED") - ? "50% paid ✓" - : "paying…" - : "50% on the day"; - const restNote = prize.milestoneRequired ? "50% on milestone" : "full payout on win"; - return ( -
    • -
      -

      {prize.label}

      - {winner ? ( -

      - won by {winner.team.name} ·{" "} - - @{winner.user.handle} - -

      - ) : null} -

      - {instantNote} · {restNote} -

      -
      -

      - {formatKes(prize.amountKes)} -

      -
    • - ); - })} -
    - - Winners receive 50% instantly on the day; the rest releases on verified milestone - completion. - -
    + + + - + + + - + {/* Public gallery — 48-hour media vault */} + {gallery.length > 0 ? ( + +
      + {gallery.map((asset) => ( +
    • + {asset.kind === "PHOTO" ? ( + // eslint-disable-next-line @next/next/no-img-element -- media vault assets from dynamic storage + {asset.caption + ) : ( +
    • + ))} +
    +

    + Delivered within the 48-hour standard: high-resolution, community-first. +

    +
    + ) : null} +
    - {/* Public gallery — 48-hour media vault */} - {gallery.length > 0 ? ( - - Hackathon Gallery -
      - {gallery.map((asset) => ( -
    • - {asset.kind === "PHOTO" ? ( - // eslint-disable-next-line @next/next/no-img-element -- media vault assets from dynamic storage - {asset.caption - ) : ( -
    • - ))} -
    - - Delivered within the 48-hour standard: high-resolution, community-first. - -
    + {more.length > 0 ? ( +
    +
    +
    +

    + More hackathons. Every one Prize Verified before it went live. +

    + + Browse All + +
    +
    + {more.map((card) => ( + + ))} +
    +
    +
    ) : null}
    ); diff --git a/app/(marketing)/hackathons/page.tsx b/app/(marketing)/hackathons/page.tsx index 7ea69a4..b1947b2 100644 --- a/app/(marketing)/hackathons/page.tsx +++ b/app/(marketing)/hackathons/page.tsx @@ -1,5 +1,4 @@ import type { Metadata } from "next"; -import { unstable_cache } from "next/cache"; import { FeaturedHackathon } from "@/components/hackathons/featured-hackathon"; import { HackathonDirectory } from "@/components/hackathons/hackathon-directory"; @@ -7,7 +6,6 @@ import { ListingHero } from "@/components/hackathons/listing-hero"; import { ListingStats } from "@/components/hackathons/listing-stats"; import { VerifySection } from "@/components/hackathons/verify-section"; import { GetStarted } from "@/components/landing/get-started"; -import { prisma } from "@/lib/db"; import { hackathonPhase } from "@/lib/events/format"; import { filterCards, @@ -20,7 +18,7 @@ import { type ListingCard, type ListingParams, } from "@/lib/events/listing"; -import { PUBLIC_HACKATHON_WHERE } from "@/lib/events/visibility"; +import { getListingCards } from "@/lib/events/listing-data"; import { pageOpenGraph } from "@/lib/seo/metadata"; export const metadata: Metadata = { @@ -33,60 +31,6 @@ export const metadata: Metadata = { openGraph: pageOpenGraph("/hackathons"), }; -/** - * The listing is the most crawler- and visitor-heavy page, but `searchParams` - * makes every request dynamic. Cache the database read (plain ISO strings — - * `unstable_cache` must stay JSON-safe) and rebuild Dates at render. All - * filtering, sorting and stats run over this one read (lib/events/listing). - */ -type StoredCard = Omit< - ListingCard, - "startsAt" | "endsAt" | "registrationDeadline" | "publishedAt" -> & { - startsAt: string; - endsAt: string; - registrationDeadline: string; - publishedAt: string | null; -}; - -const getStoredCards = unstable_cache( - async (): Promise => { - const events = await prisma.event.findMany({ - where: PUBLIC_HACKATHON_WHERE, - include: { - org: { select: { name: true, trustScore: true } }, - prizes: { select: { amountKes: true } }, - _count: { select: { teams: { where: { status: { not: "DISBANDED" } } } } }, - }, - orderBy: { startsAt: "desc" }, - take: 60, - }); - return events.map((event) => ({ - slug: event.slug, - title: event.title, - summary: event.summary, - venueType: event.venueType, - location: event.location, - startsAt: event.startsAt.toISOString(), - endsAt: event.endsAt.toISOString(), - registrationDeadline: event.registrationDeadline.toISOString(), - publishedAt: event.publishedAt?.toISOString() ?? null, - status: event.status, - poolKes: event.prizes.reduce((sum, prize) => sum + prize.amountKes, 0), - teamCount: event._count.teams, - maxTeams: event.maxTeams, - orgName: event.org.name, - orgTrustScore: event.org.trustScore, - categories: event.categories, - coverUrl: event.coverUrl, - prizeVerifiedAt: event.prizeVerifiedAt?.toISOString() ?? null, - })); - }, - // The key changed with the card shape (maxTeams), so no stale entry is reused. - ["public-hackathon-listing-v2"], - { revalidate: 300 }, -); - /** Cover tiles for the hero: live and upcoming first, biggest prizes first. */ function heroTiles(cards: ListingCard[], now: Date): ListingCard[] { const byPool = [...cards].sort((a, b) => b.poolKes - a.poolKes); @@ -103,14 +47,7 @@ export default async function EventsPage({ const query = parseListingQuery(await searchParams); const now = new Date(); - const stored = await getStoredCards(); - const cards: ListingCard[] = stored.map((card) => ({ - ...card, - startsAt: new Date(card.startsAt), - endsAt: new Date(card.endsAt), - registrationDeadline: new Date(card.registrationDeadline), - publishedAt: card.publishedAt ? new Date(card.publishedAt) : null, - })); + const cards = await getListingCards(); const phase = resolvePhase(cards, query, now); const results = sortCards(filterCards(cards, query, phase, now), query.sort, phase, now); diff --git a/app/globals.css b/app/globals.css index 2e5c061..d3a526f 100644 --- a/app/globals.css +++ b/app/globals.css @@ -3895,6 +3895,481 @@ } } +/* + * Hackathon detail page (app/(marketing)/hackathons/[slug], + * components/hackathons/detail-parts.tsx), laid out like a customer story: + * breadcrumb hero with a facts card, then a sticky rail beside sections. + */ +@layer components { + .hkd-crumbs { + display: flex; + min-width: 0; + align-items: center; + gap: 8px; + font-size: 14px; + } + .hkd-crumbs a { + color: var(--color-ink-soft); + font-weight: 600; + } + .hkd-crumbs [aria-current] { + overflow: hidden; + color: var(--color-body-copy); + text-overflow: ellipsis; + white-space: nowrap; + } + .hkd-crumbs span[aria-hidden] { + color: var(--color-muted); + } + .hkd-all { + display: inline-flex; + flex-shrink: 0; + align-items: center; + gap: 2px; + color: var(--color-ink-soft); + font-size: 14px; + font-weight: 600; + } + .hkd-banner { + margin-top: 20px; + padding: 12px 16px; + border: 1px solid color-mix(in srgb, var(--color-warning) 40%, transparent); + border-radius: var(--radius-control); + background: color-mix(in srgb, var(--color-warning) 10%, transparent); + color: var(--color-ink); + font-size: 14px; + font-weight: 600; + } + .hkd-hero-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 380px); + align-items: start; + gap: clamp(32px, 6vw, 96px); + margin-top: clamp(32px, 5vw, 56px); + } + .hkd-title { + color: var(--color-ink); + font-family: var(--font-display); + font-size: clamp(36px, 4.4vw, 60px); + font-weight: 500; + line-height: 1.05; + letter-spacing: -0.035em; + text-wrap: balance; + } + .hkd-summary { + max-width: 56ch; + margin-top: 20px; + color: var(--color-body-copy); + font-size: 18px; + line-height: 1.6; + } + .hkd-tracks { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 24px; + } + .hkd-tracks a { + display: inline-block; + padding: 4px 12px; + border: 1px solid var(--lp-line); + border-radius: 999px; + color: var(--color-ink-soft); + font-size: 13px; + font-weight: 600; + transition: border-color 150ms ease, background 150ms ease; + } + .hkd-tracks a:hover { + border-color: var(--color-brand); + background: color-mix(in srgb, var(--color-brand) 8%, transparent); + } + .hkd-card { + overflow: hidden; + border: 1px solid var(--lp-line); + border-radius: var(--radius-card); + background: var(--color-surface); + box-shadow: var(--shadow-card); + } + .hkd-card-cover { + position: relative; + aspect-ratio: 16 / 9; + background: color-mix(in srgb, var(--color-brand) 10%, transparent); + } + .hkd-card-facts { + display: flex; + flex-direction: column; + padding: 6px 18px 10px; + } + .hkd-card-facts div { + display: flex; + justify-content: space-between; + gap: 16px; + padding-block: 10px; + border-bottom: 1px solid var(--lp-line); + font-size: 14px; + } + .hkd-card-facts div:last-child { + border-bottom: 0; + } + .hkd-card-facts dt { + color: var(--color-muted); + } + .hkd-card-facts dd { + color: var(--color-ink); + font-weight: 600; + text-align: right; + } + + /* ---- Body: sticky rail + sections ---- */ + .hkd-body { + display: grid; + grid-template-columns: minmax(0, 220px) minmax(0, 1fr); + gap: clamp(32px, 6vw, 96px); + padding-block: clamp(48px, 6vw, 80px); + } + .hkd-rail { + position: sticky; + top: calc(var(--site-header-height) + 24px); + align-self: start; + } + .hkd-rail-stats { + display: flex; + flex-direction: column; + gap: 22px; + } + .hkd-rail-stats div { + position: relative; + padding-left: 14px; + border-left: 1px solid var(--lp-line); + } + .hkd-rail-stats div::before { + content: ""; + position: absolute; + top: 3px; + left: -1px; + width: 1px; + height: 20px; + background: var(--color-brand); + } + .hkd-rail-stats dt { + color: var(--color-muted); + font-size: 12.5px; + font-weight: 500; + } + .hkd-rail-stats dd { + margin-top: 2px; + color: var(--color-ink); + font-size: 15px; + font-weight: 600; + line-height: 1.35; + } + .hkd-rail-stats div:first-child dd { + font-family: var(--font-display); + font-size: 24px; + letter-spacing: -0.02em; + } + .hkd-rail-action { + margin-top: 28px; + } + .hkd-action { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 10px; + } + .hkd-action-note { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--color-body-copy); + font-size: 13.5px; + } + .hkd-action-note a { + color: var(--color-ink-soft); + font-weight: 600; + } + .hkd-action-note-ok { + color: var(--color-success); + font-weight: 600; + } + .hkd-register { + position: relative; + isolation: isolate; + display: inline-flex; + height: 44px; + align-items: center; + overflow: hidden; + padding-inline: 20px; + border-radius: 999px; + background: var(--color-brand); + color: var(--color-brand-ink); + font-size: 15px; + font-weight: 600; + white-space: nowrap; + } + .hkd-content { + display: flex; + min-width: 0; + max-width: 760px; + flex-direction: column; + gap: clamp(40px, 5vw, 56px); + } + .hkd-section-title { + margin-bottom: 16px; + color: var(--color-ink); + font-family: var(--font-display); + font-size: clamp(22px, 2vw, 26px); + font-weight: 500; + letter-spacing: -0.015em; + } + .hkd-subtitle { + margin: 24px 0 10px; + color: var(--color-ink); + font-size: 15px; + font-weight: 600; + } + .hkd-prose { + color: var(--color-body-copy); + font-size: 16.5px; + line-height: 1.75; + white-space: pre-line; + } + .hkd-prose-sm { + font-size: 15px; + } + .hkd-muted { + color: var(--color-muted); + } + .hkd-small { + margin-top: 12px; + font-size: 13px; + } + .hkd-status { + overflow-x: auto; + padding: 18px; + border: 1px solid var(--lp-line); + border-radius: var(--radius-card); + background: var(--color-surface); + } + + /* The escrow pull-quote: a brand-gradient card like a testimonial block. */ + .hkd-callout { + position: relative; + overflow: hidden; + padding: clamp(24px, 4vw, 40px); + border-radius: var(--radius-card); + background: linear-gradient(125deg, var(--lp-navy), var(--lp-blue-deep) 55%, var(--color-brand)); + color: #fff; + isolation: isolate; + } + .hkd-callout-art { + position: absolute; + inset: 0; + z-index: -1; + background: + repeating-linear-gradient(115deg, rgb(255 255 255 / 0.08) 0 1px, transparent 1px 8px), + radial-gradient(60% 90% at 90% 20%, rgb(127 208 248 / 0.55), transparent 70%); + } + .hkd-callout blockquote { + max-width: 44ch; + font-family: var(--font-display); + font-size: clamp(19px, 1.9vw, 24px); + font-weight: 500; + line-height: 1.4; + } + .hkd-callout figcaption { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 12px; + margin-top: 20px; + font-size: 14px; + font-weight: 600; + opacity: 0.95; + } + .hkd-callout-link { + display: inline-flex; + align-items: center; + gap: 2px; + color: #cdeefe; + } + .hkd-callout-link:hover { + color: #fff; + } + + .hkd-table-wrap { + overflow-x: auto; + border: 1px solid var(--lp-line); + border-radius: var(--radius-card); + background: var(--color-surface); + } + .hkd-table { + width: 100%; + min-width: 560px; + border-collapse: collapse; + font-size: 14px; + } + .hkd-table th, + .hkd-table td { + padding: 14px 16px; + border-bottom: 1px solid var(--lp-line); + color: var(--color-body-copy); + text-align: left; + vertical-align: top; + } + .hkd-table thead th { + padding-block: 10px; + color: var(--color-muted); + font-size: 12px; + font-weight: 600; + } + .hkd-table tbody tr:last-child > * { + border-bottom: 0; + } + .hkd-table tbody th { + color: var(--color-ink); + font-weight: 600; + } + .hkd-table-amount { + color: var(--color-ink) !important; + font-family: var(--font-display); + font-weight: 600; + white-space: nowrap; + } + .hkd-winner { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 6px; + } + .hkd-winner a { + color: var(--color-ink-soft); + font-weight: 600; + } + + .hkd-dates { + border-left: 1px solid var(--lp-line); + margin-left: 10px; + } + .hkd-date { + position: relative; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 4px 16px; + padding: 0 0 22px 26px; + } + .hkd-date:last-child { + padding-bottom: 0; + } + .hkd-date-mark { + position: absolute; + top: 1px; + left: -11px; + display: grid; + place-items: center; + width: 21px; + height: 21px; + border: 1px solid var(--lp-line); + border-radius: 50%; + background: var(--color-paper); + color: var(--color-brand-ink); + } + .hkd-date-done .hkd-date-mark { + border-color: var(--color-brand); + background: var(--color-brand); + } + .hkd-date-next .hkd-date-mark { + border-color: var(--color-brand); + box-shadow: 0 0 0 4px color-mix(in srgb, var(--color-brand) 22%, transparent); + } + .hkd-date-label { + color: var(--color-ink); + font-size: 15px; + font-weight: 600; + } + .hkd-date-done .hkd-date-label { + color: var(--color-muted); + } + .hkd-date-next { + margin-left: 8px; + padding: 1px 8px; + border-radius: 999px; + background: color-mix(in srgb, var(--color-brand) 15%, transparent); + color: var(--color-ink-soft); + font-size: 11px; + font-weight: 600; + } + .hkd-date time { + color: var(--color-body-copy); + font-size: 14px; + font-variant-numeric: tabular-nums; + } + + .hkd-gallery { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; + } + .hkd-gallery li { + overflow: hidden; + border: 1px solid var(--lp-line); + border-radius: var(--radius-card); + } + .hkd-gallery img, + .hkd-gallery video { + display: block; + width: 100%; + aspect-ratio: 4 / 3; + object-fit: cover; + } + + .hkd-more-head { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 24px; + } + .hkd-more-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + margin-top: 32px; + } + + @media (max-width: 1023px) { + .hkd-hero-grid, + .hkd-body { + grid-template-columns: minmax(0, 1fr); + } + .hkd-card { + max-width: 520px; + } + .hkd-rail { + position: static; + } + .hkd-rail-stats { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .hkd-more-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + } + @media (max-width: 639px) { + .hkd-gallery { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .hkd-more-head { + flex-direction: column; + align-items: flex-start; + } + .hkd-date { + grid-template-columns: minmax(0, 1fr); + } + .hkd-more-grid { + grid-template-columns: minmax(0, 1fr); + } + } +} + /* * Landing page: the header is see-through while the hero is under it, so * the ribbon shows behind the nav, and turns solid once the page scrolls diff --git a/components/hackathons/detail-parts.tsx b/components/hackathons/detail-parts.tsx new file mode 100644 index 0000000..3e1b83a --- /dev/null +++ b/components/hackathons/detail-parts.tsx @@ -0,0 +1,217 @@ +import Link from "next/link"; +import { Check, ChevronRight, Lock } from "lucide-react"; + +import { LandingLink } from "@/components/landing/landing-link"; +import { formatDateTime, keyDates } from "@/lib/events/format"; +import { cn, formatKes } from "@/lib/utils"; + +/* + * Building blocks for the hackathon detail page, laid out like a customer + * story: a facts rail beside long-form sections. + */ + +export function DetailSection({ + id, + title, + children, +}: { + id: string; + title: string; + children: React.ReactNode; +}) { + return ( +
    +

    + {title} +

    + {children} +
    + ); +} + +export interface RailStat { + value: string; + label: string; +} + +/** Tick-line stats plus the one action that matters for this visitor. */ +export function DetailRail({ stats, action }: { stats: RailStat[]; action: React.ReactNode }) { + return ( + + ); +} + +/** + * The register / workspace / closed call to action, shared by the rail and + * the mobile bar. Registration is a plain POST form (the API route handles + * redirects), so it works without JavaScript. + */ +export function RegistrationAction({ + slug, + open, + signedIn, + registered, +}: { + slug: string; + open: boolean; + signedIn: boolean; + registered: boolean; +}) { + if (registered) { + return ( +
    +

    + You're registered +

    + Open Team Workspace +
    + ); + } + if (!open) { + return ( +
    +

    Registration for this hackathon has closed.

    + + Find Another Hackathon + +
    + ); + } + if (!signedIn) { + return ( +
    + Sign In To Register +

    + New here? Create a free account +

    +
    + ); + } + return ( +
    + +
    + ); +} + +/** The escrow promise for this exact pool, in the style of a pull-quote card. */ +export function EscrowCallout({ poolKes, verified }: { poolKes: number; verified: boolean }) { + return ( +
    +
    + ); +} + +export interface PrizeRow { + id: string; + label: string; + amountKes: number; + milestoneRequired: boolean; + winner: { team: string; handle: string } | null; + firstHalfPaid: boolean; +} + +/** Each place, what it pays, how it pays, and who won it once results are out. */ +export function PrizeTable({ prizes }: { prizes: PrizeRow[] }) { + return ( +
    + + + + + + + + + + + {prizes.map((prize) => ( + + + + + + + ))} + +
    PlacePrizeHow it paysWinner
    {prize.label}{formatKes(prize.amountKes)} + {prize.milestoneRequired ? "50% on the day, 50% on milestone" : "100% on the day"} + + {prize.winner ? ( + + + {prize.winner.team} ·{" "} + + @{prize.winner.handle} + + + + + + ) : ( + Announced after judging + )} +
    +
    + ); +} + +/** The hackathon's commitments in time, with the next one highlighted. */ +export function KeyDates({ + event, +}: { + event: { registrationDeadline: Date; startsAt: Date; endsAt: Date; mediaDeadlineAt: Date | null }; +}) { + return ( + <> +
      + {keyDates(event).map((date) => ( +
    1. + + + {date.label} + {date.state === "next" ? Next : null} + + +
    2. + ))} +
    +

    All times are Nairobi time (EAT).

    + + ); +} diff --git a/components/patterns/key-dates-card.tsx b/components/patterns/key-dates-card.tsx deleted file mode 100644 index 03555e2..0000000 --- a/components/patterns/key-dates-card.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { Check } from "lucide-react"; - -import { Card, CardDescription, CardTitle } from "@/components/ui/card"; -import { formatDateTime, keyDates } from "@/lib/events/format"; -import { cn } from "@/lib/utils"; - -/** The hackathon's commitments in time: when things close, start, and get delivered. */ -export function KeyDatesCard({ - event, -}: { - event: { registrationDeadline: Date; startsAt: Date; endsAt: Date; mediaDeadlineAt: Date | null }; -}) { - const dates = keyDates(event); - - return ( - - Key Dates -
      - {dates.map((date) => ( -
    1. - - {date.state === "done" ? : null} - -
      -

      - {date.label} - {date.state === "next" ? ( - - Next - - ) : null} -

      -

      - -

      -
      -
    2. - ))} -
    - All times are Nairobi time (EAT). -
    - ); -} diff --git a/lib/events/listing-data.ts b/lib/events/listing-data.ts new file mode 100644 index 0000000..19f28d9 --- /dev/null +++ b/lib/events/listing-data.ts @@ -0,0 +1,71 @@ +import { unstable_cache } from "next/cache"; + +import { prisma } from "@/lib/db"; +import type { ListingCard } from "@/lib/events/listing"; +import { PUBLIC_HACKATHON_WHERE } from "@/lib/events/visibility"; + +/** + * The hackathon listing is the most crawler- and visitor-heavy page, but + * `searchParams` makes every request dynamic. Cache the database read (plain ISO strings — + * `unstable_cache` must stay JSON-safe) and rebuild Dates at render. All + * filtering, sorting and stats run over this one read (lib/events/listing). + */ +type StoredCard = Omit< + ListingCard, + "startsAt" | "endsAt" | "registrationDeadline" | "publishedAt" +> & { + startsAt: string; + endsAt: string; + registrationDeadline: string; + publishedAt: string | null; +}; + +const getStoredCards = unstable_cache( + async (): Promise => { + const events = await prisma.event.findMany({ + where: PUBLIC_HACKATHON_WHERE, + include: { + org: { select: { name: true, trustScore: true } }, + prizes: { select: { amountKes: true } }, + _count: { select: { teams: { where: { status: { not: "DISBANDED" } } } } }, + }, + orderBy: { startsAt: "desc" }, + take: 60, + }); + return events.map((event) => ({ + slug: event.slug, + title: event.title, + summary: event.summary, + venueType: event.venueType, + location: event.location, + startsAt: event.startsAt.toISOString(), + endsAt: event.endsAt.toISOString(), + registrationDeadline: event.registrationDeadline.toISOString(), + publishedAt: event.publishedAt?.toISOString() ?? null, + status: event.status, + poolKes: event.prizes.reduce((sum, prize) => sum + prize.amountKes, 0), + teamCount: event._count.teams, + maxTeams: event.maxTeams, + orgName: event.org.name, + orgTrustScore: event.org.trustScore, + categories: event.categories, + coverUrl: event.coverUrl, + prizeVerifiedAt: event.prizeVerifiedAt?.toISOString() ?? null, + })); + }, + // The key changed with the card shape (maxTeams), so no stale entry is reused. + ["public-hackathon-listing-v2"], + { revalidate: 300 }, +); + +/** Every public hackathon as a directory card, newest start first (at most 60). */ +export async function getListingCards(): Promise { + const stored = await getStoredCards(); + return stored.map((card) => ({ + ...card, + startsAt: new Date(card.startsAt), + endsAt: new Date(card.endsAt), + registrationDeadline: new Date(card.registrationDeadline), + publishedAt: card.publishedAt ? new Date(card.publishedAt) : null, + })); +} From ec1ebb7301da8f4e64d4b7805ffa516dd5404816 Mon Sep 17 00:00:00 2001 From: CodeWithEugene Date: Mon, 28 Sep 2026 14:21:01 +0300 Subject: [PATCH 05/27] feat(events): scroll the listing hero's cover wall in alternate directions The hackathons hero wall is now three columns that loop forever: the outer two drift up, the middle one down. Tiles are smaller (4:5) so each column shows two or three at once, dealt round-robin from up to nine hackathons and topped up so no column runs short. Each column is rendered twice for a seamless loop (the copy hidden from assistive tech and the keyboard), the window fades at the top and bottom, and the motion pauses on hover or focus and stops for reduced motion. --- app/(marketing)/hackathons/page.tsx | 2 +- app/globals.css | 48 ++++++++++--- components/hackathons/listing-hero.tsx | 99 +++++++++++++++++++------- 3 files changed, 113 insertions(+), 36 deletions(-) diff --git a/app/(marketing)/hackathons/page.tsx b/app/(marketing)/hackathons/page.tsx index b1947b2..25669bc 100644 --- a/app/(marketing)/hackathons/page.tsx +++ b/app/(marketing)/hackathons/page.tsx @@ -36,7 +36,7 @@ function heroTiles(cards: ListingCard[], now: Date): ListingCard[] { const byPool = [...cards].sort((a, b) => b.poolKes - a.poolKes); const current = byPool.filter((card) => hackathonPhase(card, now) !== "past"); const past = byPool.filter((card) => hackathonPhase(card, now) === "past"); - return [...current, ...past].slice(0, 5); + return [...current, ...past].slice(0, 9); } export default async function EventsPage({ diff --git a/app/globals.css b/app/globals.css index d3a526f..fe75b1c 100644 --- a/app/globals.css +++ b/app/globals.css @@ -3075,26 +3075,54 @@ font-size: 16px; line-height: 1.6; } + /* Three columns scrolling in alternate directions inside a faded window. */ .hk-wall { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); - align-items: start; gap: 14px; - margin-right: calc(-1 * var(--lp-gutter) - 48px); + height: clamp(460px, 42vw, 580px); + overflow: hidden; + mask-image: linear-gradient(180deg, transparent, #000 12%, #000 88%, transparent); } .hk-wall-col { - display: grid; - gap: 14px; + min-width: 0; } - .hk-wall-col-0 { - margin-top: 36px; + .hk-wall-track { + display: flex; + flex-direction: column; + animation: hk-wall-up 40s linear infinite; + } + /* Spacing is a bottom margin, not a gap, so both copies are the same height. */ + .hk-wall-track > .hk-tile { + margin-bottom: 14px; + } + .hk-wall-down { + animation-name: hk-wall-down; + } + .hk-wall:hover .hk-wall-track, + .hk-wall:focus-within .hk-wall-track { + animation-play-state: paused; } - .hk-wall-col-2 { - margin-top: 72px; + @keyframes hk-wall-up { + from { + transform: translateY(0); + } + to { + transform: translateY(-50%); + } + } + @keyframes hk-wall-down { + from { + transform: translateY(-50%); + } + to { + transform: translateY(0); + } } .hk-tile { position: relative; - aspect-ratio: 3 / 4; + flex-shrink: 0; + aspect-ratio: 4 / 5; overflow: hidden; border-radius: var(--radius-card); box-shadow: 0 18px 40px -18px rgb(0 0 70 / 0.45); @@ -3786,7 +3814,7 @@ grid-template-columns: minmax(0, 1fr); } .hk-wall { - margin-right: 0; + height: 440px; } .hk-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); diff --git a/components/hackathons/listing-hero.tsx b/components/hackathons/listing-hero.tsx index 09ed76c..6bd4ca8 100644 --- a/components/hackathons/listing-hero.tsx +++ b/components/hackathons/listing-hero.tsx @@ -15,37 +15,86 @@ const PHASE_LINKS: { key: HackathonPhase; label: string }[] = [ { key: "past", label: "Past" }, ]; -/** A staggered wall of live hackathon covers, like a customer-stories collage. */ +const WALL_COLUMNS = 3; +/** Each column needs a few tiles to scroll through without looking sparse. */ +const MIN_PER_COLUMN = 3; +/** Seconds each tile takes to scroll past, so columns of any length move at one pace. */ +const SECONDS_PER_TILE = 7; + +/** + * Deal the tiles round-robin into columns, topping short columns up by + * cycling through the list (starting at a different point per column, so + * neighbours don't mirror each other). + */ +function dealColumns(tiles: ListingCard[]): ListingCard[][] { + return Array.from({ length: WALL_COLUMNS }, (_, c) => { + const column = tiles.filter((_, i) => i % WALL_COLUMNS === c); + for (let k = 0; column.length < MIN_PER_COLUMN && k < tiles.length * 2; k++) { + const next = tiles[(c + k) % tiles.length]; + if (next && (!column.includes(next) || tiles.length < MIN_PER_COLUMN)) column.push(next); + } + return column; + }); +} + +function Tile({ + event, + hidden, + priority, +}: { + event: ListingCard; + hidden: boolean; + priority: boolean; +}) { + const category = event.categories.find(isCategory); + return ( +
  • + +
  • + ); +} + +/** + * A wall of live hackathon covers in three columns that scroll forever: + * the outer two drift up, the middle one down. Each column's tiles are + * rendered twice so the loop has no seam; the copy is hidden from + * assistive tech and the keyboard. Pauses on hover or focus, and holds + * still for reduced motion (the global reduced-motion rule). + */ function CoverWall({ tiles }: { tiles: ListingCard[] }) { if (tiles.length === 0) return null; - const columns = [tiles.slice(0, 2), tiles.slice(2, 4), tiles.slice(4, 5)].filter( - (column) => column.length > 0, - ); return (
    - {columns.map((column, c) => ( -
      - {column.map((event, i) => { - const category = event.categories.find(isCategory); - return ( -
    • - ( +
      +
        + {[false, true].map((hidden) => + column.map((event, i) => ( +
      + )), + )} +
    +
    ))}
    ); From 72e0c2cbaf20fb323165d6ac93d841dc87f95e24 Mon Sep 17 00:00:00 2001 From: CodeWithEugene Date: Mon, 28 Sep 2026 14:27:32 +0300 Subject: [PATCH 06/27] feat(events): keep the directory filters in place while results scroll The hackathons filter sidebar now behaves like the legal pages' table of contents: it sticks under the header while the results beside it scroll, capped to the window height with its own scroll if the open groups outgrow it. Filter groups start collapsed (Status stays open, and a group opens when one of its options is in use), so the sidebar is shorter than the results and actually has room to sit still. --- app/globals.css | 8 +++++++- components/hackathons/hackathon-directory.tsx | 20 ++++++++++++++++--- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/app/globals.css b/app/globals.css index fe75b1c..1724c24 100644 --- a/app/globals.css +++ b/app/globals.css @@ -3321,10 +3321,16 @@ gap: clamp(24px, 3vw, 40px); margin-top: 32px; } + /* Stays put while the results scroll past, like the legal pages' table of + contents; if the open groups outgrow the window it scrolls on its own. */ .hk-sidebar { position: sticky; - top: calc(var(--site-header-height) + 16px); + top: calc(var(--site-header-height) + 24px); align-self: start; + max-height: calc(100dvh - var(--site-header-height) - 48px); + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-width: thin; } .hk-filters { display: flex; diff --git a/components/hackathons/hackathon-directory.tsx b/components/hackathons/hackathon-directory.tsx index af7a9a6..f7a8502 100644 --- a/components/hackathons/hackathon-directory.tsx +++ b/components/hackathons/hackathon-directory.tsx @@ -36,9 +36,23 @@ interface FacetOption { active: boolean; } -function FacetGroup({ title, options }: { title: string; options: FacetOption[] }) { +/** + * One collapsible filter group. Groups start collapsed, like a directory's + * filter menus, unless told to open or one of their options is in use, so + * the sidebar stays short enough to sit still beside the results. + */ +function FacetGroup({ + title, + options, + defaultOpen = false, +}: { + title: string; + options: FacetOption[]; + defaultOpen?: boolean; +}) { + const inUse = options.some((option, index) => option.active && index > 0); return ( -
    +
    {title}