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` diff --git a/app/(marketing)/blog/[slug]/page.tsx b/app/(marketing)/blog/[slug]/page.tsx index f915031..4437db9 100644 --- a/app/(marketing)/blog/[slug]/page.tsx +++ b/app/(marketing)/blog/[slug]/page.tsx @@ -6,6 +6,7 @@ import { ArrowLeft } from "lucide-react"; import { KeepReading } from "@/components/patterns/keep-reading"; import { LegalToc } from "@/components/patterns/legal-toc"; +import { PageHero } from "@/components/patterns/page-hero"; import { JsonLd } from "@/components/seo/json-ld"; import { allPosts, formatPostDate, getPost, morePosts } from "@/lib/blog"; import { articleSchema, breadcrumbSchema } from "@/lib/seo/schema"; @@ -66,41 +67,38 @@ export default async function BlogPostPage({ params }: PageProps) { ]), ]} /> -
- + + All Posts + + } + title={meta.title} + lead={meta.excerpt} > - All Posts - - -
-

- {meta.category} -

-

- {meta.title} -

-

{meta.excerpt}

-

+

By {meta.author} ·{" "} ·{" "} {meta.readingMinutes} min read

-
+
-
- {meta.coverAlt} +
+
+ {meta.coverAlt} +
-
+
diff --git a/app/(marketing)/blog/page.tsx b/app/(marketing)/blog/page.tsx index 06ac3b0..a606196 100644 --- a/app/(marketing)/blog/page.tsx +++ b/app/(marketing)/blog/page.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import { BlogCard } from "@/components/patterns/blog-card"; +import { PageHero } from "@/components/patterns/page-hero"; import { Pagination } from "@/components/patterns/pagination"; import { JsonLd } from "@/components/seo/json-ld"; import { allPosts } from "@/lib/blog"; @@ -14,7 +15,10 @@ export async function generateMetadata({ searchParams }: PageProps): Promise ({ title: meta.title, path: `/blog/${meta.slug}` })), )} /> -
-
-

- Blog -

-

- Stories And Guides From HackVillage -

-

- How prizes stay safe, how winners get paid, and how to run and win hackathons in Kenya - and beyond. -

-
- -
- {visible.map((post) => ( - - ))} -
- +
+ +
+
+
+ {visible.map((post) => ( + + ))} +
+ +
+
); diff --git a/app/(marketing)/developers/[handle]/page.tsx b/app/(marketing)/developers/[handle]/page.tsx index 7d589bf..9add8fc 100644 --- a/app/(marketing)/developers/[handle]/page.tsx +++ b/app/(marketing)/developers/[handle]/page.tsx @@ -3,7 +3,6 @@ import { notFound } from "next/navigation"; import { BadgeCheck, MapPin } from "lucide-react"; import { Badge } from "@/components/ui/badge"; -import { Card, CardDescription, CardTitle } from "@/components/ui/card"; import { JsonLd } from "@/components/seo/json-ld"; import { currentUser } from "@/lib/auth/guards"; import { prisma } from "@/lib/db"; @@ -107,203 +106,179 @@ export default async function DeveloperProfilePage({ params }: ProfilePageProps) linkedinUrl: profile.linkedinUrl, })} /> -
- -
-
-

- {user.name ?? `@${user.handle}`} -

-

- @{user.handle} - · - joined{" "} +

+
+
+
+ + @{user.handle} · joined{" "} {user.createdAt.toLocaleDateString("en-KE", { month: "long", year: "numeric" })} -

+
+ + Proof-of-Work verified +
- - Proof-of-Work verified - -
- - {profile.headline ? ( -

{profile.headline}

- ) : null} - - {profile.location ? ( -

- {profile.location} -

- ) : null} - - {profile.bio ?

{profile.bio}

: null} - - {profile.skills?.length ? ( -
- {profile.skills.map((skill) => ( - {skill} - ))} -
- ) : null} +
+
+

+ {user.name ?? `@${user.handle}`} +

+ {profile.headline ?

{profile.headline}

: null} + {profile.location ? ( +

+ {profile.location} +

+ ) : null} + {profile.bio ?

{profile.bio}

: null} + {profile.skills?.length ? ( +
    + {profile.skills.map((skill) => ( +
  • {skill}
  • + ))} +
+ ) : null} + {profile.githubLogin || profile.linkedinUrl ? ( +
+ {profile.githubLogin ? ( + + GitHub · {profile.githubLogin} + + ) : null} + {profile.linkedinUrl ? ( + + LinkedIn + + ) : null} +
+ ) : null} +
- {profile.githubLogin || profile.linkedinUrl ? ( -
- {profile.githubLogin ? ( - - GitHub · {profile.githubLogin} - - ) : null} - {profile.linkedinUrl ? ( - - LinkedIn - - ) : null} + {/* Verified record — platform-derived numbers only (§6.6) */} +
+
+
Hackathons
+
{metrics.eventsParticipated}
+
+
+
Wins
+
{metrics.wins}
+
+
+
Win rate
+
{metrics.winRate != null ? `${metrics.winRate}%` : "N/A"}
+
+
+
Earned
+
{formatKes(metrics.totalWonKes)}
+
+
+
Endorsed
+
{metrics.endorsementCount}
+
+
- ) : null} - - - {/* Verified record — platform-derived numbers only (§6.6) */} -
- -

Hackathons

-

- {metrics.eventsParticipated} -

-
- -

Wins

-

{metrics.wins}

-
- -

Win rate

-

- {metrics.winRate != null ? `${metrics.winRate}%` : "N/A"} -

-
- -

Earned

-

- {formatKes(metrics.totalWonKes)} -

-
- -

Endorsed

-

- {metrics.endorsementCount} -

-
-
+
+
{portfolio.length > 0 ? ( - - Portfolio - +
+ ) : null} {endorsements.length > 0 ? ( - - Judge Endorsements -
    - {endorsements.map((endorsement) => ( -
  • -

    “{endorsement.quote}”

    -

    - {endorsement.judge.name ?? `@${endorsement.judge.handle}`}, judge,{" "} - - {endorsement.event.title} - -

    -
  • - ))} -
-
+
+
+

+ Judge Endorsements +

+
    + {endorsements.map((endorsement) => ( +
  • +
    “{endorsement.quote}”
    +

    + {endorsement.judge.name ?? `@${endorsement.judge.handle}`}, judge,{" "} + + {endorsement.event.title} + +

    +
  • + ))} +
+
+
) : null} - - Hiring? - - Every metric above is derived from platform-verified activity: wins, payouts, and judge - endorsements, never self-reported.{" "} - {viewerIsHiring ? ( - <> - Request a verified introduction on the{" "} - - intro page - - . - - ) : ( - <> - Hiring partners can request a verified introduction from the{" "} - - talent directory - - . - - )} - - +
+
+

+ Hiring? +

+

+ Every metric above is derived from platform-verified activity: wins, payouts, and + judge endorsements, never self-reported.{" "} + {viewerIsHiring ? ( + <> + Request a verified introduction on the{" "} + intro page. + + ) : ( + <> + Hiring partners can request a verified introduction from the{" "} + talent directory. + + )} +

+
+
); 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/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..25669bc 100644 --- a/app/(marketing)/hackathons/page.tsx +++ b/app/(marketing)/hackathons/page.tsx @@ -1,270 +1,81 @@ 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 { prisma } from "@/lib/db"; +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 { hackathonPhase } from "@/lib/events/format"; import { - defaultPhase, - formatEventDates, - HACKATHON_PHASES, - hackathonPhase, - type HackathonPhase, -} from "@/lib/events/format"; -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"; + filterCards, + isFiltered, + listingStats, + parseListingQuery, + pickFeatured, + resolvePhase, + sortCards, + type ListingCard, + type ListingParams, +} from "@/lib/events/listing"; +import { getListingCards } from "@/lib/events/listing-data"; 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"), }; -/** - * 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. - */ -type StoredCard = Omit< - EventCardData, - "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, - orgName: event.org.name, - orgTrustScore: event.org.trustScore, - categories: event.categories, - coverUrl: event.coverUrl, - prizeVerifiedAt: event.prizeVerifiedAt?.toISOString() ?? null, - })); - }, - ["public-hackathon-listing"], - { 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, 9); } 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) => ({ - ...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 cards = await getListingCards(); - {usedCategories.length > 0 ? ( - - ) : null} + 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); - {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/(marketing)/how-it-works/page.tsx b/app/(marketing)/how-it-works/page.tsx index b27546e..8f2d0bd 100644 --- a/app/(marketing)/how-it-works/page.tsx +++ b/app/(marketing)/how-it-works/page.tsx @@ -14,8 +14,8 @@ export const metadata: Metadata = { export default function HowItWorksPage() { return ( -
- +
+
); diff --git a/app/(marketing)/newsletter/unsubscribe/page.tsx b/app/(marketing)/newsletter/unsubscribe/page.tsx index 4b8a954..3ba33d4 100644 --- a/app/(marketing)/newsletter/unsubscribe/page.tsx +++ b/app/(marketing)/newsletter/unsubscribe/page.tsx @@ -15,10 +15,14 @@ export default async function NewsletterUnsubscribePage({ }) { const { token } = await searchParams; return ( -
-
- -
+
+
+
+
+ +
+
+
); } diff --git a/app/(marketing)/organizers/[slug]/page.tsx b/app/(marketing)/organizers/[slug]/page.tsx index f724ba2..bd3198a 100644 --- a/app/(marketing)/organizers/[slug]/page.tsx +++ b/app/(marketing)/organizers/[slug]/page.tsx @@ -196,62 +196,66 @@ export default async function OrganizerTrustPage({ params }: PageProps) { : {}), }} /> -
-
-
-

{org.name}

- {facts.length > 0 ? ( -

- {location ? : null} - {facts.join(" · ")} -

- ) : null} -
- {verified ? ( - - Verified Organization - - ) : null} - Trust score {org.trustScore} -
- {org.about ? ( -

{org.about}

- ) : null} - {links.length > 0 ? ( - - ) : null} -
- {stats.map((stat) => ( -
-
{stat.label}
-
- {stat.value} -
+
+
+
+
+
+

+ {org.name} +

+ {facts.length > 0 ? ( +

+ {location ? : null} + {facts.join(" · ")} +

+ ) : null} +
+ {verified ? ( + + Verified Organization + + ) : null} + Trust score {org.trustScore}
- ))} -
-
+ {org.about ?

{org.about}

: null} + {links.length > 0 ? ( + + ) : null} +
+
+ {stats.map((stat) => ( +
+
{stat.label}
+
{stat.value}
+
+ ))} +
+
+
+ -
-

+
+
+

Payout Performance

-
- +
+ Instant Payouts {performance.instantTotal === 0 ? ( @@ -269,7 +273,7 @@ export default async function OrganizerTrustPage({ params }: PageProps) { )} - + Announce→Paid @@ -288,7 +292,7 @@ export default async function OrganizerTrustPage({ params }: PageProps) { )} - + Media Delivery @@ -308,27 +312,29 @@ export default async function OrganizerTrustPage({ params }: PageProps) { )}
-
+

+ -
-

+
+
+

Hackathons

{events.length === 0 ? ( - + {org.name} has not published a hackathon yet. When they do, it appears here with its escrow status. ) : ( -
    +
      {events.map((event) => { const isPublic = isPublicHackathon(event); const poolKes = event.prizes.reduce((sum, prize) => sum + prize.amountKes, 0); return (
    • - +
      {isPublic ? ( @@ -364,13 +370,15 @@ export default async function OrganizerTrustPage({ params }: PageProps) { })}
    )} -
+

+ -
-

+
+
+

Trust History

- + {trustEvents.length === 0 ? ( No trust events yet. The score moves with payout speed, media delivery and @@ -410,8 +418,8 @@ export default async function OrganizerTrustPage({ params }: PageProps) { .

-
-

+
+
); diff --git a/app/(marketing)/page.tsx b/app/(marketing)/page.tsx index e1ec378..3886073 100644 --- a/app/(marketing)/page.tsx +++ b/app/(marketing)/page.tsx @@ -1,12 +1,9 @@ import type { Metadata } from "next"; import { AudienceSections } from "@/components/landing/audience-sections"; -import { GetStarted } from "@/components/landing/get-started"; import { LandingAbout } from "@/components/landing/landing-about"; -import { LandingBanner } from "@/components/landing/landing-banner"; import { LandingHero } from "@/components/landing/landing-hero"; import { LatestPosts } from "@/components/landing/latest-posts"; -import { OpenInfrastructure } from "@/components/landing/open-infrastructure"; import { PartnerStrip } from "@/components/landing/partner-strip"; import { PlatformGrid } from "@/components/landing/platform-grid"; import { TrustBand } from "@/components/landing/trust-band"; @@ -32,13 +29,10 @@ export default function LandingPage() { - - -
); } diff --git a/app/(marketing)/trust/page.tsx b/app/(marketing)/trust/page.tsx index 5426bdb..c09f09a 100644 --- a/app/(marketing)/trust/page.tsx +++ b/app/(marketing)/trust/page.tsx @@ -3,6 +3,7 @@ import Link from "next/link"; import { ExternalLink, ScrollText, ShieldCheck } from "lucide-react"; import type { Prisma, VaultChainState } from "@prisma/client"; +import { PageHero } from "@/components/patterns/page-hero"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardDescription, CardTitle } from "@/components/ui/card"; @@ -13,12 +14,7 @@ import { getChainPort } from "@/lib/ports/chain"; import { pageOpenGraph } from "@/lib/seo/metadata"; import { formatKes } from "@/lib/utils"; -import { - LEDGER_TYPE_LABELS, - payloadAmountKes, - payloadReference, - shortenHash, -} from "./ledger"; +import { LEDGER_TYPE_LABELS, payloadAmountKes, payloadReference, shortenHash } from "./ledger"; export const metadata: Metadata = { title: "The Public Ledger: Every Prize Lock And Payout, Verifiable", @@ -81,201 +77,204 @@ export default async function TrustPage({ searchParams }: PageProps) { ]); return ( -
-
-

- The Public Ledger -

-

- Every prize pool locked, payout sent and vault refunded is recorded here, entry by - entry. Trust on HackVillage is not a promise; it is a record you can check. -

-
- - {!onChain ? ( - - - Simulation Mode - - - The ledger is running in simulation mode while we complete go-live. Entries are - application-verified and recorded in order; on-chain anchoring is pending, so - transaction references are not yet verifiable on a block explorer. - - - ) : null} +
+ + {!onChain ? ( + + + Simulation Mode + + + The ledger is running in simulation mode while we complete go-live. Entries are + application-verified and recorded in order; on-chain anchoring is pending, so + transaction references are not yet verifiable on a block explorer. + + + ) : null} + {vaults.length > 0 ? ( -
-

- Prize Vaults -

-
    - {vaults.map((vault) => ( -
  • - -

    - - {vault.event.title} - -

    -

    - {formatKes(vault.amountKes)} -

    -
    - - {VAULT_STATE_LABELS[vault.chainState]} - -
    - {vault.contractAddress ? ( -

    - {onChain ? ( - - {shortenHash(vault.contractAddress)} - - - ) : ( - shortenHash(vault.contractAddress) - )} +

    +
    +

    + Prize Vaults +

    +
      + {vaults.map((vault) => ( +
    • + +

      + + {vault.event.title} +

      - ) : null} -
      -
    • - ))} -
    -
    - ) : null} - - {filterEvents.length > 1 ? ( - - ) : null} - - {entries.length === 0 ? ( - - - - } - /> - ) : ( -
    -

    - Ledger Entries -

    -
    - - - - - - - - - - - - {entries.map((entry) => { - const amount = payloadAmountKes(entry.payload); - const reference = payloadReference(entry.payload); - return ( - - - - - - - - ); - })} - -
    - Time - - Hackathon - - Entry - - Amount - - {onChain ? "Transaction" : "Reference"} -
    - {timestampFormat.format(entry.mirroredAt)} - - - {entry.event.title} - - {LEDGER_TYPE_LABELS[entry.type]} - {amount === null ? "—" : formatKes(amount)} - +

    + {formatKes(vault.amountKes)} +

    +
    + + {VAULT_STATE_LABELS[vault.chainState]} + +
    + {vault.contractAddress ? ( +

    {onChain ? ( - {shortenHash(entry.txHash)} + {shortenHash(vault.contractAddress)} - (opens on Polygonscan in a new tab) ) : ( - - {shortenHash(reference ?? entry.txHash)} - + shortenHash(vault.contractAddress) )} -

    +

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

- Showing the {entries.length} most recent entries - {entries.length === 100 ? " (use the hackathon filter to narrow down)" : ""}.{" "} - {onChain - ? "Each transaction hash resolves on Polygonscan, the independent block explorer for the Polygon network." - : "References are synthetic while the ledger runs in simulation mode."} -

- )} + ) : null} + +
+
+ {filterEvents.length > 1 ? ( + + ) : null} + + {entries.length === 0 ? ( + + + + } + /> + ) : ( +
+

+ Ledger Entries +

+
+ + + + + + + + + + + + {entries.map((entry) => { + const amount = payloadAmountKes(entry.payload); + const reference = payloadReference(entry.payload); + return ( + + + + + + + + ); + })} + +
+ Time + + Hackathon + + Entry + + Amount + + {onChain ? "Transaction" : "Reference"} +
+ {timestampFormat.format(entry.mirroredAt)} + + + {entry.event.title} + + {LEDGER_TYPE_LABELS[entry.type]} + {amount === null ? "—" : formatKes(amount)} + + {onChain ? ( + + {shortenHash(entry.txHash)} + + (opens on Polygonscan in a new tab) + + ) : ( + + {shortenHash(reference ?? entry.txHash)} + + )} +
+
+

+ Showing the {entries.length} most recent entries + {entries.length === 100 ? " (use the hackathon filter to narrow down)" : ""}.{" "} + {onChain + ? "Each transaction hash resolves on Polygonscan, the independent block explorer for the Polygon network." + : "References are synthetic while the ledger runs in simulation mode."} +

+
+ )} +
+
); } diff --git a/app/(marketing)/unsubscribe/page.tsx b/app/(marketing)/unsubscribe/page.tsx index 96b6c14..1abbd2d 100644 --- a/app/(marketing)/unsubscribe/page.tsx +++ b/app/(marketing)/unsubscribe/page.tsx @@ -15,10 +15,14 @@ export default async function UnsubscribePage({ }) { const { token } = await searchParams; return ( -
-
- -
+
+
+
+
+ +
+
+
); } diff --git a/app/globals.css b/app/globals.css index 1da0875..e11d5ce 100644 --- a/app/globals.css +++ b/app/globals.css @@ -49,9 +49,10 @@ --site-header-height: 4rem; /* The landing page frame (and the header aligned to it): a hairline-ruled column with a narrow edge outside the rules and a gutter inside them. */ - --frame-max: 1440px; - --frame-edge: clamp(12px, 2vw, 32px); - --frame-gutter: clamp(16px, 1.5vw, 24px); + /* Grows with the screen so large monitors keep narrow side margins. */ + --frame-max: 2200px; + --frame-edge: clamp(12px, 2vw, 40px); + --frame-gutter: clamp(16px, 1.5vw, 32px); } @media (max-width: 1023px) { :root { @@ -161,220 +162,129 @@ --btn-fill-color: color-mix(in srgb, var(--color-ink) 6%, transparent); } - .how-it-works { - padding-block: clamp(24px, 4vw, 64px) clamp(64px, 7vw, 112px); - scroll-margin-top: calc(var(--site-header-height) + 12px); + /* How It Works steps (components/patterns/how-it-works.tsx): story cards + with a numbered tick line, in the ruled frame. */ + .hiw-steps { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: clamp(16px, 2vw, 24px); } - /* Centered stack: label, heading, then the description. */ - .how-it-works-header { - position: relative; + .hiw-step { display: flex; flex-direction: column; - align-items: center; - gap: 16px; - margin-bottom: clamp(36px, 5vw, 72px); - text-align: center; - } - .how-it-works-heading-block { - min-width: 0; - } - /* Brand stars flanking the centered heading block. */ - .how-it-works-star { - position: absolute; - width: clamp(36px, 3.6vw, 56px); - height: auto; - fill: var(--color-brand); - pointer-events: none; - } - .how-it-works-star-left { - top: 58%; - right: calc(50% + clamp(330px, 26vw, 420px)); - } - .how-it-works-star-right { - top: 22%; - left: calc(50% + clamp(330px, 26vw, 420px)); - } - @media (max-width: 1023px) { - .how-it-works-star { - display: none; - } - } - .how-it-works-description { - max-width: 56ch; - color: var(--color-body-copy); - font-size: clamp(15px, 1vw, 18px); - line-height: 1.6; + gap: 12px; } - .how-it-works-eyebrow { - margin-bottom: 18px; + .hiw-number { + position: relative; + padding-left: 14px; + border-left: 1px solid color-mix(in srgb, var(--color-ink) 10%, transparent); color: var(--color-ink-soft); - font-size: 11px; + font-family: var(--font-display); + font-size: 15px; font-weight: 600; - letter-spacing: 0.18em; - text-transform: uppercase; - } - /* Display-scale page heading, resized at the breakpoints further down. */ - .how-it-works-title { - max-width: 1100px; - font-size: clamp(2.75rem, 4.6vw, 5.5rem); - font-weight: 700; - letter-spacing: -0.045em; - line-height: 1.06; - text-wrap: balance; } - .how-it-works-title span { - color: var(--color-ink-soft); - } - .how-it-works-grid { - display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: clamp(16px, 1.5vw, 28px); - } - .how-it-works-step { - display: grid; - grid-template-rows: auto 1fr auto; - gap: 20px; - min-width: 0; - } - .how-it-works-number { - color: var(--color-ink); - font-size: clamp(24px, 1.8vw, 32px); - font-weight: 600; - letter-spacing: -0.03em; - line-height: 1; + .hiw-number::before { + content: ""; + position: absolute; + top: 2px; + left: -1px; + width: 1px; + height: 18px; + background: var(--color-brand); } - .how-it-works-intro { - max-width: 34ch; + .hiw-intro { + min-height: 3.2em; color: var(--color-body-copy); - font-size: clamp(14px, 0.9vw, 16px); + font-size: 14.5px; line-height: 1.55; } - .how-it-works-photo { + .hiw-photo { position: relative; - isolation: isolate; aspect-ratio: 4 / 5; - min-height: 320px; overflow: hidden; - border-radius: clamp(18px, 1.6vw, 28px); - background: var(--color-surface); - } - .how-it-works-photo img { - object-position: center 30%; + border-radius: var(--radius-card); + background: color-mix(in srgb, var(--color-brand) 10%, transparent); } - .how-it-works-photo::after { - content: ""; - position: absolute; - inset: 0; - background: linear-gradient( - 180deg, - transparent 32%, - rgb(0 0 45 / 25%) 48%, - rgb(0 0 45 / 94%) 100% - ); - pointer-events: none; + .hiw-photo img { + transition: transform 600ms ease; } - .how-it-works-caption { - position: absolute; - z-index: 1; - inset-inline: 0; - bottom: 0; - padding: clamp(18px, 1.6vw, 28px); - color: #ffffff; + .hiw-step:hover .hiw-photo img { + transform: scale(1.04); } - .how-it-works-caption h3 { - margin-bottom: 8px; - font-size: clamp(16px, 1.1vw, 20px); + .hiw-title { + margin-top: 4px; + color: var(--color-ink); + font-family: var(--font-display); + font-size: 19px; font-weight: 600; line-height: 1.25; - letter-spacing: -0.02em; } - .how-it-works-caption p { - font-size: clamp(13px, 0.8vw, 14px); + .hiw-description { + color: var(--color-body-copy); + font-size: 14.5px; line-height: 1.6; - color: #eef5ff; } - @media (min-width: 600px) and (max-width: 1023px) { - .how-it-works-grid { + @media (max-width: 1023px) { + .hiw-steps { grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 36px 20px; + row-gap: 40px; } } @media (max-width: 599px) { - .how-it-works-grid { + .hiw-steps { grid-template-columns: minmax(0, 1fr); - gap: 36px; } - .how-it-works-step { - gap: 16px; - } - .how-it-works-photo { + .hiw-intro { min-height: 0; } - .how-it-works-caption { - padding: 24px; - } - .how-it-works-caption h3 { - font-size: 19px; - } - .how-it-works-caption p { - font-size: 14px; - } } - /* - * Final CTA banner: a solid logo-navy card split in half, the platform's - * promise and a working newsletter signup on the left, and a photo that - * fills the right half edge to edge (the card's overflow and radius clip - * it). brand-ink stays navy in both themes; ink flips light in dark mode. - */ + /* Newsletter CTA (components/patterns/cta-banner.tsx): a tinted card in the + ruled frame, copy and signup on one side, a photo filling the other. */ .cta-banner { position: relative; - isolation: isolate; - overflow: hidden; display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); - min-height: clamp(360px, 38vw, 480px); - border-radius: clamp(24px, 2.6vw, 40px); - background: var(--color-brand-ink); + overflow: hidden; + border: 1px solid color-mix(in srgb, var(--color-ink) 10%, transparent); + border-radius: var(--radius-card); + background: color-mix(in srgb, var(--color-brand) 6%, var(--color-paper)); + isolation: isolate; } .cta-banner-content { - position: relative; - z-index: 1; display: flex; flex-direction: column; align-items: flex-start; justify-content: center; - gap: 20px; - padding: clamp(32px, 5vw, 64px) clamp(28px, 5vw, 80px); + gap: 16px; + padding: clamp(28px, 4vw, 56px); } .cta-banner-eyebrow { - font-size: 12px; - font-weight: 700; - letter-spacing: 0.14em; - text-transform: uppercase; - color: var(--color-brand-soft); + color: var(--color-ink-soft); + font-size: 14px; + font-weight: 600; } .cta-banner-heading { + color: var(--color-ink); font-family: var(--font-display); - font-weight: 700; - font-size: clamp(26px, 2.9vw, 38px); - line-height: 1.25; - color: var(--color-on-inverse); + font-size: clamp(26px, 2.6vw, 36px); + font-weight: 500; + line-height: 1.15; + letter-spacing: -0.02em; } .cta-banner-heading span { - color: var(--color-brand-soft); + color: var(--lp-quiet, var(--color-body-copy)); } .cta-banner-text { - max-width: 44ch; - font-size: clamp(14px, 1vw, 16px); - line-height: 1.6; - color: color-mix(in srgb, var(--color-on-inverse) 78%, transparent); + max-width: 48ch; + color: var(--color-body-copy); + font-size: 15.5px; + line-height: 1.65; } .cta-banner-photo { position: relative; - min-height: 100%; + min-height: 360px; } - /* Photo on the left half instead (How It Works page). Phones stack it on top either way. */ .cta-banner-photo-left .cta-banner-photo { order: -1; } @@ -389,7 +299,6 @@ } } - /* Newsletter signup — the CTA banner's one job (components/patterns/newsletter-form.tsx). */ .newsletter-form { width: 100%; max-width: 440px; @@ -491,18 +400,24 @@ font-weight: 600; } - /* Left-aligned bar: logo, then the links beside it, actions pushed right. */ + /* Left-aligned bar (logo, links beside it, actions pushed right) sitting in + the same ruled frame as every page body: same width, gutter and hairline + side rules, so the lines run unbroken from the header down the page. */ .site-header-grid { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; + width: min(100% - 2 * var(--frame-edge), var(--frame-max)); height: var(--site-header-height); + margin-inline: auto; + padding-inline: var(--frame-gutter); gap: 16px; + border-inline: 1px solid color-mix(in srgb, var(--color-ink) 10%, transparent); } - /* On the landing page the bar lines up with the ruled frame below it. */ - body:has(.lp) .site-header-grid { - width: min(100% - 2 * var(--frame-edge), var(--frame-max)); - padding-inline: var(--frame-gutter); + @media (max-width: 1023px) { + .site-header-grid { + border-inline-color: transparent; + } } .site-header-links, .site-header-actions { @@ -837,101 +752,22 @@ padding-inline: clamp(1rem, 3vw, 3.5rem); } - .hero-actions { - display: flex; - justify-content: center; - align-items: center; - gap: 22px; - margin-top: 20px; - } - .hero-action-primary, - .hero-action-secondary { - display: inline-flex; - justify-content: center; - align-items: center; - gap: 9px; - min-height: 46px; - font-size: 12px; - font-weight: 600; - transition: color 150ms; - } - .hero-action-primary { - position: relative; - isolation: isolate; - overflow: hidden; - padding: 0 24px; - border-radius: 999px; - background: var(--color-ink); - color: var(--color-paper); - --btn-fill-color: var(--color-ink-soft); - } - .hero-action-secondary { - color: var(--color-ink); - text-decoration: underline; - text-underline-offset: 5px; - text-decoration-color: var(--color-brand-soft); - } - .hero-action-secondary:hover { - color: var(--color-ink-soft); - } - @media (min-width: 1024px) and (min-height: 900px) { - .how-it-works-title { - font-size: clamp(4rem, 5vw, 6rem); - } - } - - @media (max-width: 1023px) { - .how-it-works-title { - font-size: clamp(2.5rem, 5.3vw, 3.4rem); - } - .hero-actions { - gap: 12px; - } - .hero-action-primary, - .hero-action-secondary { - font-size: 11px; - } - .hero-action-primary { - padding-inline: 18px; - } - } - - @media (max-width: 639px) { - .how-it-works-title { - font-size: clamp(2rem, 8.8vw, 3.5rem); - line-height: 1.1; - } - .hero-actions { - flex-direction: column; - gap: 0; - margin-top: 16px; - } - .hero-action-primary { - min-height: 44px; - padding-inline: 24px; - font-size: 12px; - } - .hero-action-secondary { - min-height: 44px; - font-size: 11px; - } - } - - /* Long-form legal document typography (Privacy Policy, Terms of Service) — - no @tailwindcss/typography plugin installed, so this scopes plain - semantic markup instead of repeating utility classes per element. */ - /* Full width text pages: contents sidebar on large screens, one column below. */ + /* Long-form document pages (components/patterns/legal-document.tsx): + privacy, terms, how escrow works, for organizers, contribute. No + typography plugin is installed, so this scopes plain semantic markup. + The layout sits in the landing page's ruled frame (.lp-frame). */ .legal-layout { display: grid; gap: 2.5rem; + padding-block: clamp(40px, 5vw, 72px) clamp(56px, 7vw, 96px); } .legal-aside { display: none; } @media (min-width: 1024px) { .legal-layout { - grid-template-columns: 260px minmax(0, 1fr); - gap: clamp(2.5rem, 5vw, 5rem); + grid-template-columns: 240px minmax(0, 1fr); + gap: clamp(2.5rem, 6vw, 6rem); } .legal-aside { display: block; @@ -944,24 +780,23 @@ } } .legal-toc-title { - margin-bottom: 0.75rem; - font-size: 11px; + margin-bottom: 0.875rem; + color: var(--color-ink); + font-size: 13px; font-weight: 600; - letter-spacing: 0.14em; - text-transform: uppercase; - color: var(--color-ink-soft); } .legal-toc ol { - border-left: 1px solid color-mix(in srgb, var(--color-ink) 12%, transparent); + border-left: 1px solid color-mix(in srgb, var(--color-ink) 10%, transparent); } .legal-toc-link { display: block; margin-left: -1px; padding: 0.375rem 0 0.375rem 0.875rem; - border-left: 2px solid transparent; - font-size: 14px; + border-left: 1px solid transparent; + font-size: 13.5px; line-height: 1.4; color: var(--color-muted); + transition: color 150ms ease; } .legal-toc-link:hover { color: var(--color-ink); @@ -969,30 +804,79 @@ .legal-toc-link.is-active { border-left-color: var(--color-brand); font-weight: 600; - color: var(--color-ink); + color: var(--color-ink-soft); } + /* Each section opens on a hairline, the way the landing page divides ideas. */ + /* The text fills the column beside the contents rail, up to a line length + that stays readable on very wide monitors. */ + .legal { + min-width: 0; + max-width: 1100px; + } .legal h2 { - @apply mt-10 font-display text-xl font-bold text-ink first:mt-0; + margin-top: 3rem; + padding-top: 2.25rem; + border-top: 1px solid color-mix(in srgb, var(--color-ink) 10%, transparent); + color: var(--color-ink); + font-family: var(--font-display); + font-size: clamp(22px, 2vw, 26px); + font-weight: 500; + line-height: 1.25; + letter-spacing: -0.015em; scroll-margin-top: calc(var(--site-header-height, 64px) + 1.5rem); } - + /* The opening section needs no rule above it, unless an intro comes first. */ + .legal > h2:first-of-type:not(:is(p, ul, ol, div) ~ h2) { + margin-top: 0; + padding-top: 0; + border-top: 0; + } .legal h3 { - @apply mt-6 text-base font-semibold text-ink; + margin-top: 1.75rem; + color: var(--color-ink); + font-size: 16px; + font-weight: 600; } - .legal p { - @apply mt-3 leading-7 text-ink-soft; + margin-top: 0.875rem; + color: var(--color-body-copy); + font-size: 16px; + line-height: 1.75; + } + .legal ul, + .legal ol { + display: flex; + flex-direction: column; + gap: 0.625rem; + margin-top: 0.875rem; + padding-left: 1.5rem; + color: var(--color-body-copy); + font-size: 16px; + line-height: 1.7; } - .legal ul { - @apply mt-3 list-disc space-y-2 pl-6 leading-7 text-ink-soft; + list-style: disc; + } + .legal ol { + list-style: decimal; + } + .legal li::marker { + color: var(--color-ink-soft); + font-weight: 600; } - .legal a { - @apply text-brand underline underline-offset-2 hover:text-ink-soft; + color: var(--color-ink-soft); + font-weight: 500; + text-decoration: underline; + text-decoration-color: color-mix(in srgb, var(--color-brand) 45%, transparent); + text-underline-offset: 3px; + transition: color 150ms ease, text-decoration-color 150ms ease; + } + .legal a:hover { + color: var(--color-ink); + text-decoration-color: currentColor; } - .legal code { padding: 0.1em 0.35em; border-radius: 6px; @@ -1002,11 +886,11 @@ color: var(--color-ink); } .legal pre { - margin-top: 0.75rem; + margin-top: 0.875rem; overflow-x: auto; padding: 1rem 1.25rem; - border-radius: 12px; - background: var(--color-brand-ink); + border-radius: var(--radius-card); + background: color-mix(in srgb, var(--lp-navy, #000092) 72%, #000); line-height: 1.7; } .legal pre code { @@ -1016,42 +900,298 @@ font-size: 13px; white-space: pre; } - .legal strong { - @apply font-semibold text-ink; + font-weight: 600; + color: var(--color-ink); } - /* 404 illustration — the face built into the middle "0" gets a little life. */ - .nf-eye { - transform-box: fill-box; - transform-origin: center; - animation: nf-blink 4.8s ease-in-out infinite; + /* Blog (app/(marketing)/blog, components/patterns/blog-card.tsx). */ + .blog-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 16px; } - .nf-eye-right { - animation-delay: 0.06s; + .blog-card { + display: flex; + height: 100%; + flex-direction: column; + overflow: hidden; + border: 1px solid color-mix(in srgb, var(--color-ink) 10%, transparent); + border-radius: var(--radius-card); + background: var(--color-surface); + transition: border-color 200ms ease, box-shadow 200ms ease; } - .nf-eyebrow { - transform-box: fill-box; - transform-origin: center; - animation: nf-eyebrow-float-left 3.6s ease-in-out infinite; + .blog-card:hover { + border-color: color-mix(in srgb, var(--color-brand) 45%, transparent); + box-shadow: var(--shadow-card); } - .nf-eyebrow-right { - animation-name: nf-eyebrow-float-right; + .blog-back { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--color-ink-soft); + font-size: 14px; + font-weight: 600; } - .nf-mouth { - transform-box: fill-box; - transform-origin: center; - animation: nf-mouth-pulse 2.8s ease-in-out infinite; + .blog-back:hover { + color: var(--color-ink); } - @keyframes nf-blink { - 0%, - 88%, - 100% { - transform: scaleY(1); - } - 92% { - transform: scaleY(0.08); - } + .blog-byline { + margin-top: 16px; + color: var(--color-muted); + font-size: 14px; + } + .blog-cover-frame { + padding-top: clamp(24px, 3vw, 40px); + } + .blog-cover { + position: relative; + aspect-ratio: 21 / 9; + overflow: hidden; + border-radius: var(--radius-card); + background: color-mix(in srgb, var(--color-brand) 10%, transparent); + } + @media (max-width: 1023px) { + .blog-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + } + @media (max-width: 639px) { + .blog-grid { + grid-template-columns: minmax(0, 1fr); + } + } + + /* Public profiles (developers/[handle], organizers/[slug]). */ + .pf-hero-grid { + align-items: start; + } + .pf-location { + display: flex; + align-items: center; + gap: 6px; + margin-top: 14px; + color: var(--color-muted); + font-size: 14px; + } + .pf-bio { + max-width: 60ch; + margin-top: 16px; + color: var(--color-body-copy); + font-size: 15.5px; + line-height: 1.7; + } + .pf-skills { + margin-top: 18px; + } + .pf-links { + display: flex; + flex-wrap: wrap; + gap: 16px; + margin-top: 20px; + font-size: 14px; + } + .pf-links a, + .pf-item-links a, + .pf-endorsements a, + .pf-hiring a { + color: var(--color-ink-soft); + font-weight: 600; + text-decoration: underline; + text-decoration-color: color-mix(in srgb, var(--color-brand) 45%, transparent); + text-underline-offset: 3px; + } + .pf-links a:hover, + .pf-item-links a:hover, + .pf-endorsements a:hover, + .pf-hiring a:hover { + color: var(--color-ink); + } + /* Tick-line stats, as on the hackathon rail. */ + .pf-stats { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 22px 24px; + } + .pf-stats div { + position: relative; + padding-left: 14px; + border-left: 1px solid color-mix(in srgb, var(--color-ink) 10%, transparent); + } + .pf-stats div::before { + content: ""; + position: absolute; + top: 3px; + left: -1px; + width: 1px; + height: 20px; + background: var(--color-brand); + } + .pf-stats dt { + color: var(--color-muted); + font-size: 12.5px; + font-weight: 500; + } + .pf-stats dd { + margin-top: 2px; + color: var(--color-ink); + font-family: var(--font-display); + font-size: clamp(22px, 2vw, 28px); + font-weight: 600; + letter-spacing: -0.02em; + } + .pf-portfolio { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; + margin-top: 28px; + } + .pf-item { + padding: 20px; + border: 1px solid color-mix(in srgb, var(--color-ink) 10%, transparent); + border-radius: var(--radius-card); + background: var(--color-surface); + } + .pf-item-title { + color: var(--color-ink); + font-family: var(--font-display); + font-size: 17px; + font-weight: 600; + } + .pf-item-summary { + margin-top: 8px; + color: var(--color-body-copy); + font-size: 14.5px; + line-height: 1.6; + } + .pf-item-links { + margin-top: 12px; + font-size: 14px; + } + .pf-endorsements { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 28px 40px; + margin-top: 28px; + } + .pf-endorsements li { + padding-left: 18px; + border-left: 2px solid var(--color-brand); + } + .pf-endorsements blockquote { + color: var(--color-ink); + font-family: var(--font-display); + font-size: 18px; + line-height: 1.5; + } + .pf-endorsements p { + margin-top: 8px; + color: var(--color-muted); + font-size: 13.5px; + } + @media (max-width: 639px) { + .pf-portfolio, + .pf-endorsements { + grid-template-columns: minmax(0, 1fr); + } + } + + /* One-card utility pages (unsubscribe links) and the 404 page. */ + .pg-utility-frame { + display: flex; + min-height: 60vh; + align-items: center; + justify-content: center; + padding-block: clamp(48px, 7vw, 96px); + } + .pg-404 { + display: flex; + min-height: 100dvh; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 24px; + text-align: center; + } + + /* Shared marketing page hero (components/patterns/page-hero.tsx). */ + .pg-hero-copy { + max-width: 860px; + margin-top: clamp(28px, 4vw, 48px); + } + .pg-hero-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + align-items: center; + gap: clamp(32px, 5vw, 72px); + margin-top: clamp(28px, 4vw, 48px); + } + .pg-title { + color: var(--color-ink); + font-family: var(--font-display); + font-size: clamp(34px, 4vw, 56px); + font-weight: 500; + line-height: 1.06; + letter-spacing: -0.03em; + text-wrap: balance; + } + .pg-title span { + color: var(--lp-quiet); + } + .pg-lead { + max-width: 60ch; + margin-top: 20px; + color: var(--color-body-copy); + font-size: 18px; + line-height: 1.6; + } + .pg-hero-centered .hk-hero-bar { + justify-content: center; + } + .pg-hero-centered .pg-hero-copy { + margin-inline: auto; + text-align: center; + } + .pg-hero-centered .pg-lead { + margin-inline: auto; + } + @media (max-width: 1023px) { + .pg-hero-grid { + grid-template-columns: minmax(0, 1fr); + } + } + + /* 404 illustration — the face built into the middle "0" gets a little life. */ + .nf-eye { + transform-box: fill-box; + transform-origin: center; + animation: nf-blink 4.8s ease-in-out infinite; + } + .nf-eye-right { + animation-delay: 0.06s; + } + .nf-eyebrow { + transform-box: fill-box; + transform-origin: center; + animation: nf-eyebrow-float-left 3.6s ease-in-out infinite; + } + .nf-eyebrow-right { + animation-name: nf-eyebrow-float-right; + } + .nf-mouth { + transform-box: fill-box; + transform-origin: center; + animation: nf-mouth-pulse 2.8s ease-in-out infinite; + } + @keyframes nf-blink { + 0%, + 88%, + 100% { + transform: scaleY(1); + } + 92% { + transform: scaleY(0.08); + } 96% { transform: scaleY(1); } @@ -1272,10 +1412,6 @@ overflow-x: clip; } - .btn-pill-inverse { - --btn-fill-color: color-mix(in srgb, var(--color-on-inverse) 12%, transparent); - } - .lp-frame { position: relative; width: min(100% - 2 * var(--frame-edge), var(--frame-max)); @@ -1286,12 +1422,14 @@ .lp-block { padding-block: var(--lp-space); } - .lp-block-tight { - padding-top: 0; - } .lp-block-flush { padding-bottom: var(--lp-space); } + /* For a section that directly follows one ending in a full block of + padding: only a little extra space before its heading. */ + .lp-block-follow { + padding-top: clamp(16px, 2.5vw, 32px); + } .lp-divided { border-top: 1px solid var(--lp-line); } @@ -1339,17 +1477,13 @@ .lp-text-link:hover .lp-text-link-chevron { transform: translateX(3px); } - .lp-text-link-inverse { - color: var(--color-brand-soft); - } - .lp-text-link-inverse:hover { - color: var(--color-on-inverse); - } /* ---- 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 +1494,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; @@ -1446,14 +1581,10 @@ .lp-bento-wide { grid-column: span 2; } - .lp-bento-full { - grid-column: 1 / -1; - } .lp-card { position: relative; display: flex; flex-direction: column; - min-height: 440px; overflow: hidden; border: 1px solid var(--lp-line); border-radius: var(--radius-card); @@ -1508,37 +1639,20 @@ background: var(--color-brand); color: var(--color-brand-ink); } + /* Cards size to their content: the mock sits right under the title, and any + height the row adds goes to the gradient below it, not a gap mid-card. */ .lp-card-visual { position: relative; display: flex; flex: 1; - align-items: flex-end; + align-items: flex-start; justify-content: center; - margin-top: 24px; + margin-top: 16px; padding: 24px; background: radial-gradient(120% 90% at 100% 100%, color-mix(in srgb, var(--color-brand) 26%, transparent), transparent 62%), radial-gradient(90% 70% at 0% 100%, color-mix(in srgb, var(--color-brand-soft) 30%, transparent), transparent 60%); } - .lp-bento-full { - display: grid; - grid-template-columns: 260px minmax(0, 1fr); - min-height: 0; - } - .lp-bento-full .lp-card-head { - padding-bottom: 24px; - } - .lp-bento-full .lp-card-chip { - position: absolute; - top: 24px; - right: 24px; - z-index: 1; - } - .lp-bento-full .lp-card-visual { - margin-top: 0; - padding: 56px 32px 0; - } - /* Mock screens inside the cards: sample data, real rules. */ .lp-mock { width: 100%; @@ -1860,181 +1974,45 @@ color: var(--color-ink); font-variant-numeric: tabular-nums; } - .lp-mock-dashboard { - max-width: 880px; - padding-bottom: 0; - border-bottom: 0; - border-radius: 10px 10px 0 0; - } - .lp-mock-dash-body { - display: grid; - grid-template-columns: 160px minmax(0, 1fr); - } - .lp-mock-dash-nav { - display: flex; - flex-direction: column; - gap: 2px; - padding: 0 14px 16px 0; - border-right: 1px solid var(--lp-line); - font-size: 11.5px; - } - .lp-mock-dash-nav strong { - margin-bottom: 8px; - color: var(--color-ink); - font-size: 12px; + /* ---- Tinted bands ---- */ + .lp-band { + background: linear-gradient(180deg, var(--color-paper) 0%, var(--lp-tint) 22%, var(--lp-tint) 100%); } - .lp-mock-dash-nav span { - padding: 4px 8px; - border-radius: 6px; + /* The section above already ends in a full block of padding, so the band + only adds a little of its own before the heading. */ + .lp-band-frame { + padding-top: clamp(16px, 2.5vw, 32px); } - .lp-mock-dash-nav .lp-mock-dash-active { - background: color-mix(in srgb, var(--color-brand) 12%, transparent); + .lp-band-heading { + font-family: var(--font-display); + font-size: clamp(30px, 3.4vw, 48px); + font-weight: 500; + line-height: 1.1; + letter-spacing: -0.025em; color: var(--color-ink); - font-weight: 600; + text-align: center; } - .lp-mock-dash-main { - padding-left: 16px; + /* Stats menu: one stat active at a time, marked by a gradient indicator + that slides along both rules (components/landing/trust-stats.tsx). */ + .lp-stats { + position: relative; + margin-top: clamp(40px, 5vw, 64px); + margin-inline: calc(-1 * var(--lp-gutter)); + border-block: 1px solid var(--lp-line); } - .lp-mock-dash-stats { + .lp-stats-list { display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 10px; + grid-template-columns: repeat(var(--lp-count, 4), minmax(0, 1fr)); } - .lp-mock-dash-stats div { - padding: 10px; - border: 1px solid var(--lp-line); - border-radius: 8px; - } - .lp-mock-dash-stats span { - display: block; - font-size: 10.5px; - } - .lp-mock-dash-stats strong { - color: var(--color-ink); - font-family: var(--font-display); - font-size: 18px; - font-weight: 600; - } - .lp-mock-table { - width: 100%; - margin-top: 14px; - font-size: 11.5px; - border-collapse: collapse; - } - .lp-mock-table th { - padding: 6px 0; - border-bottom: 1px solid var(--lp-line); - font-weight: 500; - text-align: left; - } - .lp-mock-table td { - padding: 8px 0; - border-bottom: 1px solid var(--lp-line); - color: var(--color-ink); - } - .lp-mock-status { - padding: 2px 8px; - border-radius: 999px; - font-size: 10.5px; - font-weight: 600; - } - .lp-mock-status-done { - background: color-mix(in srgb, var(--color-success) 13%, transparent); - color: var(--color-success); - } - .lp-mock-status-idle { - background: var(--lp-line); - color: var(--color-body-copy); - } - - /* ---- Feature banner ---- */ - .lp-banner { - position: relative; - min-height: 320px; - aspect-ratio: 1216 / 400; - overflow: hidden; - border-radius: var(--radius-card); - isolation: isolate; - } - .lp-banner-scrim { - position: absolute; - inset: 0; - background: linear-gradient( - 90deg, - rgb(0 0 70 / 0.85) 0%, - rgb(0 0 70 / 0.5) 45%, - rgb(0 0 70 / 0.05) 80% - ); - } - /* Vertically centred on the left, over the darkest part of the scrim. */ - .lp-banner-content { - position: absolute; - inset: 0 24px 0 clamp(24px, 3.5vw, 44px); - display: flex; - flex-direction: column; - align-items: flex-start; - justify-content: center; - } - .lp-banner-heading { - color: #fff; - font-family: var(--font-display); - font-size: clamp(28px, 3.3vw, 46px); - font-weight: 500; - line-height: 1.1; - letter-spacing: -0.025em; - } - .lp-banner-mark { - position: absolute; - right: clamp(20px, 3vw, 36px); - bottom: clamp(16px, 2.5vw, 28px); - color: #fff; - font-family: var(--font-display); - font-size: 20px; - font-weight: 700; - letter-spacing: -0.02em; - } - .lp-banner-mark span { - font-weight: 400; - opacity: 0.8; - } - - /* ---- Tinted bands ---- */ - .lp-band { - background: linear-gradient(180deg, var(--color-paper) 0%, var(--lp-tint) 22%, var(--lp-tint) 100%); - } - .lp-band-frame { - padding-top: var(--lp-space); - } - .lp-band-heading { - font-family: var(--font-display); - font-size: clamp(30px, 3.4vw, 48px); - font-weight: 500; - line-height: 1.1; - letter-spacing: -0.025em; - color: var(--color-ink); - text-align: center; - } - /* Stats menu: one stat active at a time, marked by a gradient indicator - that slides along both rules (components/landing/trust-stats.tsx). */ - .lp-stats { - position: relative; - margin-top: clamp(40px, 5vw, 64px); - margin-inline: calc(-1 * var(--lp-gutter)); - border-block: 1px solid var(--lp-line); - } - .lp-stats-list { - display: grid; - grid-template-columns: repeat(var(--lp-count, 4), minmax(0, 1fr)); - } - .lp-stats-indicator { - position: absolute; - left: 0; - width: calc(100% / var(--lp-count, 4)); - height: 1px; - background: linear-gradient(90deg, transparent, var(--color-brand) 30%, var(--color-brand-soft) 70%, transparent); - transform: translateX(calc(var(--lp-active, 0) * 100%)); - transition: transform 600ms cubic-bezier(0.65, 0, 0.35, 1); - pointer-events: none; + .lp-stats-indicator { + position: absolute; + left: 0; + width: calc(100% / var(--lp-count, 4)); + height: 1px; + background: linear-gradient(90deg, transparent, var(--color-brand) 30%, var(--color-brand-soft) 70%, transparent); + transform: translateX(calc(var(--lp-active, 0) * 100%)); + transition: transform 600ms cubic-bezier(0.65, 0, 0.35, 1); + pointer-events: none; } .lp-stats-indicator-top { top: -1px; @@ -2310,110 +2288,6 @@ color: var(--color-ink); } - .lp-promos { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 16px; - margin-top: 40px; - } - .lp-promo { - position: relative; - display: flex; - flex-direction: column; - justify-content: space-between; - gap: 16px; - min-height: 160px; - padding: 24px 44% 24px 24px; - overflow: hidden; - border: 1px solid var(--lp-line); - border-radius: var(--radius-card); - background: var(--lp-tint); - transition: border-color 200ms ease; - } - .lp-promo:hover { - border-color: color-mix(in srgb, var(--color-brand) 45%, transparent); - } - .lp-promo p { - color: var(--color-body-copy); - font-size: 14.5px; - line-height: 1.55; - } - .lp-promo strong { - color: var(--color-ink); - } - .lp-promo-art { - position: absolute; - top: 0; - right: 0; - bottom: 0; - width: 40%; - } - .lp-promo-art-a { - background: linear-gradient(135deg, var(--color-brand-soft), var(--color-brand) 45%, var(--lp-navy)); - clip-path: polygon(38% 0, 100% 0, 100% 100%, 0 100%); - } - .lp-promo-art-b { - background: linear-gradient(200deg, var(--lp-blue-deep), var(--color-brand) 50%, var(--color-brand-soft)); - clip-path: polygon(100% 0, 100% 100%, 0 100%); - } - - /* ---- Hiring showcase ---- */ - .lp-showcase { - position: relative; - display: flex; - align-items: center; - justify-content: center; - min-height: 340px; - padding: clamp(24px, 5vw, 56px); - overflow: hidden; - border-radius: var(--radius-card); - background: - repeating-linear-gradient(115deg, rgb(255 255 255 / 0.1) 0 1px, transparent 1px 7px), - linear-gradient(120deg, var(--color-brand-soft), var(--color-brand) 38%, var(--lp-blue-deep) 72%, var(--lp-navy)); - } - .lp-showcase-card { - width: min(460px, 100%); - padding: 20px; - border-radius: var(--radius-card); - background: var(--color-surface); - box-shadow: 0 24px 48px -12px rgb(0 0 70 / 0.35); - color: var(--color-body-copy); - font-size: 12.5px; - } - .lp-showcase-rows { - margin-top: 14px; - } - .lp-showcase-rows li { - display: grid; - grid-template-columns: 70px minmax(0, 1fr) auto; - align-items: center; - gap: 10px; - padding-block: 10px; - border-bottom: 1px solid var(--lp-line); - } - .lp-showcase-rows strong { - color: var(--color-ink); - font-weight: 600; - } - .lp-showcase-rows em { - padding: 2px 9px; - border-radius: 999px; - background: color-mix(in srgb, var(--color-success) 13%, transparent); - color: var(--color-success); - font-size: 11px; - font-style: normal; - font-weight: 600; - } - .lp-showcase-cta { - margin-top: 16px; - padding: 9px; - border-radius: 999px; - background: var(--color-brand); - color: var(--color-brand-ink); - font-weight: 600; - text-align: center; - } - .lp-features { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); @@ -2469,305 +2343,64 @@ color: var(--color-ink); } - /* ---- Navy infrastructure band ---- */ - .lp-dark { - --lp-line: color-mix(in srgb, var(--color-on-inverse) 14%, transparent); - --lp-quiet: color-mix(in srgb, var(--color-on-inverse) 58%, var(--color-brand-soft)); - background: color-mix(in srgb, var(--lp-navy) 72%, #000); - color: var(--color-on-inverse); - } - /* On the black dark-theme page the band needs its own navy to read as a band. */ - [data-theme="dark"] .lp-dark { - background: color-mix(in srgb, var(--lp-navy) 36%, #000); - } - .lp-dark .lp-statement { - color: var(--color-on-inverse); - } - .lp-dark-actions { - display: flex; - flex-wrap: wrap; - gap: 12px; - margin-top: 32px; + /* ---- Latest posts ---- */ + .lp-latest { + display: grid; + grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); + gap: 16px 32px; + margin-top: 40px; } - .lp-flow { - margin-top: 48px; - padding: clamp(28px, 4vw, 48px) clamp(16px, 3vw, 32px); - border: 1px solid var(--lp-line); + .lp-latest-feature { + position: relative; + min-height: 300px; + aspect-ratio: 16 / 8; + overflow: hidden; border-radius: var(--radius-card); - background-image: radial-gradient(color-mix(in srgb, var(--color-on-inverse) 14%, transparent) 1px, transparent 1px); - background-size: 16px 16px; + isolation: isolate; } - .lp-flow-row { - display: flex; - align-items: center; - justify-content: center; - gap: 10px; + .lp-latest-feature img { + transition: transform 500ms ease; } - .lp-flow-node { - padding: 10px 14px; - border: 1px solid color-mix(in srgb, var(--color-brand) 50%, transparent); - border-radius: var(--radius-control); - background: color-mix(in srgb, var(--color-brand) 16%, transparent); - text-align: center; + .lp-latest-feature:hover img { + transform: scale(1.03); } - .lp-flow-node span { - display: block; - color: var(--color-on-inverse); - font-size: 13px; - font-weight: 600; - white-space: nowrap; + .lp-latest-scrim { + position: absolute; + inset: 0; + background: linear-gradient(180deg, transparent 35%, rgb(0 0 70 / 0.82)); } - .lp-flow-node em { - display: block; - margin-top: 2px; - color: var(--lp-quiet); + .lp-latest-category { + position: absolute; + top: 16px; + left: 16px; + padding: 3px 10px; + border-radius: 999px; + background: rgb(255 255 255 / 0.88); + color: #000092; font-size: 11px; - font-style: normal; - white-space: nowrap; + font-weight: 600; } - .lp-flow-node-core { - border-color: var(--color-brand); - background: var(--color-brand); - box-shadow: 0 0 0 6px color-mix(in srgb, var(--color-brand) 22%, transparent); + .lp-latest-title { + position: absolute; + right: 24px; + bottom: 22px; + left: 24px; + color: #fff; + font-family: var(--font-display); + font-size: clamp(22px, 2.4vw, 32px); + font-weight: 500; + line-height: 1.15; + letter-spacing: -0.02em; } - .lp-flow-node-core span, - .lp-flow-node-core em { - color: var(--color-brand-ink); + .lp-latest-list { + border-top: 1px solid var(--lp-line); } - .lp-flow-split { - display: flex; - flex-direction: column; - gap: 10px; + .lp-latest-list li { + border-bottom: 1px solid var(--lp-line); } - .lp-flow-arrow { - flex-shrink: 0; - color: var(--lp-quiet); - } - .lp-flow-ledger { - display: flex; - flex-wrap: wrap; - align-items: center; - justify-content: center; - gap: 10px 14px; - width: fit-content; - max-width: 100%; - margin: 32px auto 0; - padding: 10px 18px; - border: 1px dashed var(--lp-line); - border-radius: 999px; - color: var(--lp-quiet); - font-size: 13px; - text-align: center; - } - .lp-flow-ledger strong { - color: var(--color-on-inverse); - } - .lp-flow-tag { - padding: 2px 9px; - border-radius: 999px; - background: color-mix(in srgb, var(--color-brand) 24%, transparent); - color: var(--color-brand-soft); - font-size: 11px; - font-weight: 600; - } - .lp-flow-ledger-link { - display: inline-flex; - align-items: center; - gap: 4px; - color: var(--color-brand-soft); - font-weight: 600; - } - .lp-flow-ledger-link:hover { - color: var(--color-on-inverse); - } - .lp-waves { - display: block; - width: calc(100% + 2 * var(--lp-gutter)); - height: clamp(150px, 20vw, 260px); - margin: 16px calc(-1 * var(--lp-gutter)) 8px; - } - .lp-dark-stats { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 24px; - } - .lp-dark-stats li { - display: flex; - flex-direction: column; - gap: 6px; - color: var(--lp-quiet); - font-size: 14px; - } - .lp-dark-stat-value { - width: fit-content; - background: linear-gradient(90deg, var(--color-brand-soft), var(--color-brand)); - -webkit-background-clip: text; - background-clip: text; - color: transparent; - font-family: var(--font-display); - font-size: clamp(40px, 4.4vw, 60px); - font-weight: 500; - line-height: 1.05; - letter-spacing: -0.03em; - } - .lp-paths { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 16px; - margin-top: 40px; - } - .lp-path { - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 14px; - } - .lp-path p { - color: var(--lp-quiet); - font-size: 14.5px; - line-height: 1.6; - } - .lp-path strong { - color: var(--color-on-inverse); - } - .lp-path-visual { - display: flex; - flex-direction: column; - justify-content: center; - gap: 8px; - width: 100%; - height: 200px; - margin: 0; - padding: 20px; - overflow: hidden; - border: 1px solid var(--lp-line); - border-radius: var(--radius-card); - background: color-mix(in srgb, var(--color-brand) 10%, transparent); - } - .lp-path-event { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - padding: 10px 12px; - border: 1px solid var(--lp-line); - border-radius: var(--radius-control); - background: color-mix(in srgb, var(--color-on-inverse) 7%, transparent); - font-size: 12px; - font-weight: 500; - } - .lp-path-event em { - display: inline-flex; - flex-shrink: 0; - align-items: center; - gap: 4px; - color: var(--color-brand-soft); - font-size: 10.5px; - font-style: normal; - font-weight: 600; - } - .lp-path-step { - display: flex; - align-items: center; - gap: 10px; - color: var(--lp-quiet); - font-size: 12.5px; - } - .lp-path-step span { - display: grid; - place-items: center; - width: 22px; - height: 22px; - border: 1px solid var(--lp-line); - border-radius: 50%; - font-size: 10.5px; - font-weight: 600; - } - .lp-path-step-done { - color: var(--color-on-inverse); - } - .lp-path-step-done span { - border-color: var(--color-brand); - background: var(--color-brand); - color: var(--color-brand-ink); - } - .lp-path-code { - justify-content: flex-start; - color: var(--color-on-inverse); - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; - font-size: 12.5px; - line-height: 1.85; - white-space: pre; - } - .lp-code-dim { - margin-right: 6px; - opacity: 0.4; - } - .lp-code-key { - color: var(--color-brand-soft); - } - .lp-code-ok { - color: #36d399; - } - - /* ---- Latest posts ---- */ - .lp-latest { - display: grid; - grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); - gap: 16px 32px; - margin-top: 40px; - } - .lp-latest-feature { - position: relative; - min-height: 300px; - aspect-ratio: 16 / 8; - overflow: hidden; - border-radius: var(--radius-card); - isolation: isolate; - } - .lp-latest-feature img { - transition: transform 500ms ease; - } - .lp-latest-feature:hover img { - transform: scale(1.03); - } - .lp-latest-scrim { - position: absolute; - inset: 0; - background: linear-gradient(180deg, transparent 35%, rgb(0 0 70 / 0.82)); - } - .lp-latest-category { - position: absolute; - top: 16px; - left: 16px; - padding: 3px 10px; - border-radius: 999px; - background: rgb(255 255 255 / 0.88); - color: #000092; - font-size: 11px; - font-weight: 600; - } - .lp-latest-title { - position: absolute; - right: 24px; - bottom: 22px; - left: 24px; - color: #fff; - font-family: var(--font-display); - font-size: clamp(22px, 2.4vw, 32px); - font-weight: 500; - line-height: 1.15; - letter-spacing: -0.02em; - } - .lp-latest-list { - border-top: 1px solid var(--lp-line); - } - .lp-latest-list li { - border-bottom: 1px solid var(--lp-line); - } - .lp-latest-item { - display: block; - padding-block: 18px; + .lp-latest-item { + display: block; + padding-block: 18px; } .lp-latest-meta { display: block; @@ -2857,12 +2490,6 @@ .lp-bento-wide { grid-column: 1 / -1; } - .lp-bento-full { - grid-template-columns: minmax(0, 1fr); - } - .lp-bento-full .lp-card-visual { - padding-top: 0; - } .lp-stats-list { grid-template-columns: repeat(2, minmax(0, 1fr)); } @@ -2878,27 +2505,16 @@ background: color-mix(in srgb, var(--color-brand) 7%, transparent); } .lp-split, - .lp-latest, - .lp-promos { + .lp-latest { grid-template-columns: minmax(0, 1fr); } .lp-carousel-track { grid-auto-columns: calc((100% - 16px) / 2.3); } - .lp-features, - .lp-paths { + .lp-features { grid-template-columns: minmax(0, 1fr); gap: 32px; } - .lp-flow-row { - flex-direction: column; - } - .lp-flow-arrow { - transform: rotate(90deg); - } - .lp-flow-split { - flex-direction: row; - } .lp-start { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); } @@ -2915,7 +2531,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%; @@ -2923,34 +2539,12 @@ .lp-bento { grid-template-columns: minmax(0, 1fr); } - .lp-card { - min-height: 380px; - } .lp-mock-pair { grid-template-columns: minmax(0, 1fr); } .lp-mock-vault { display: none; } - .lp-mock-dash-body { - grid-template-columns: minmax(0, 1fr); - } - .lp-mock-dash-nav { - display: none; - } - .lp-mock-dash-main { - padding-left: 0; - } - .lp-mock-dash-stats { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - .lp-banner { - aspect-ratio: auto; - min-height: 360px; - } - .lp-banner-scrim { - background: linear-gradient(180deg, rgb(0 0 70 / 0.85), rgb(0 0 70 / 0.35)); - } .lp-story-head { flex-wrap: wrap; } @@ -2967,21 +2561,6 @@ .lp-carousel-track { grid-auto-columns: 75%; } - .lp-promo { - padding-right: 24px; - padding-bottom: 96px; - } - .lp-promo-art { - top: auto; - width: 100%; - height: 72px; - } - .lp-dark-stats { - grid-template-columns: minmax(0, 1fr); - } - .lp-flow-split { - flex-direction: column; - } .lp-latest-feature { min-height: 0; aspect-ratio: 4 / 3; @@ -2994,3 +2573,1509 @@ } } } + +/* + * 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; + } + /* Three columns scrolling in alternate directions inside a faded window. */ + .hk-wall { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 14px; + height: clamp(460px, 42vw, 580px); + overflow: hidden; + mask-image: linear-gradient(180deg, transparent, #000 12%, #000 88%, transparent); + } + .hk-wall-col { + min-width: 0; + } + .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; + } + @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; + 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); + 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; + } + /* 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) + 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; + 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 { + align-items: stretch; + padding-bottom: 0; + } + .hk-about-side { + display: flex; + flex-direction: column; + gap: 24px; + } + .hk-about-photo { + position: relative; + flex: 1; + min-height: 0; + overflow: hidden; + border-radius: var(--radius-card); + } + .hk-about-photo img { + object-position: center 35%; + } + .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 { + height: 440px; + } + .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); + } + .hk-about-photo { + flex: none; + aspect-ratio: 21 / 9; + } + } + + @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%; + } + } +} + +/* + * 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); + } + } +} + +/* + * Large monitors: past 1800px the headings keep growing with the screen, + * starting from exactly the size they stop at below it (so nothing jumps) + * and capped about 45% larger. Width limits switch to em so line breaks + * keep their shape as the type grows. + */ +@layer components { + @media (min-width: 1800px) { + .lp-hero-heading { + font-size: clamp(52px, 2.889vw, 75px); + max-width: 17.5em; + } + .hk-hero-heading { + font-size: clamp(52px, 2.889vw, 75px); + } + .pg-title { + font-size: clamp(56px, 3.111vw, 81px); + } + .hkd-title { + font-size: clamp(60px, 3.333vw, 87px); + } + .lp-statement { + font-size: clamp(36px, 2.000vw, 52px); + max-width: 21.5em; + } + .lp-statement-sm { + font-size: clamp(26px, 1.444vw, 38px); + max-width: 27em; + } + .lp-band-heading { + font-size: clamp(48px, 2.667vw, 70px); + } + .lp-split-heading { + font-size: clamp(28px, 1.556vw, 41px); + } + .hk-featured-title { + font-size: clamp(38px, 2.111vw, 55px); + } + .lp-latest-title { + font-size: clamp(32px, 1.778vw, 46px); + } + .cta-banner-heading { + font-size: clamp(36px, 2.000vw, 52px); + } + .hkd-section-title { + font-size: clamp(26px, 1.444vw, 38px); + } + .legal h2 { + font-size: clamp(26px, 1.444vw, 38px); + } + .lp-card-title { + font-size: clamp(20px, 1.111vw, 29px); + } + .hk-card-title { + font-size: clamp(18px, 1.000vw, 26px); + } + .lp-stat-value { + font-size: clamp(46px, 2.556vw, 67px); + } + .hk-stats-value { + font-size: clamp(28px, 1.556vw, 41px); + } + /* 860px at 1800px, growing in step with the title. */ + .pg-hero-copy { + max-width: min(47.8vw, 1250px); + } + } +} + +/* + * 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/app/not-found.tsx b/app/not-found.tsx index 71b4fe3..1eb204e 100644 --- a/app/not-found.tsx +++ b/app/not-found.tsx @@ -5,21 +5,25 @@ import { Button } from "@/components/ui/button"; export default function NotFound() { return ( -
- -
-

This Page Took A Wrong Turn

-

- The page you are looking for does not exist, or is not built yet. -

-
-
- - - - - - +
+
+ +
+

This Page Took A Wrong Turn

+

+ The page you are looking for does not exist, or is not built yet. +

+
+
+ + + + + + +
); 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/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/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..7b7e920 --- /dev/null +++ b/components/hackathons/hackathon-directory.tsx @@ -0,0 +1,352 @@ +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; +} + +/** + * 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} +
      + {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..6bd4ca8 --- /dev/null +++ b/components/hackathons/listing-hero.tsx @@ -0,0 +1,151 @@ +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" }, +]; + +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; + return ( +
    + {dealColumns(tiles).map((column, c) => ( +
    +
      + {[false, true].map((hidden) => + column.map((event, i) => ( +
    +
    + ))} +
    + ); +} + +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..47bad49 --- /dev/null +++ b/components/hackathons/verify-section.tsx @@ -0,0 +1,116 @@ +import Image from "next/image"; +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 +

    + {/* Fills only the space the text column leaves under the heading; + it is positioned absolutely, so it never adds height. */} +
    + Kenyan hackathon winners checking their prize payout on a phone +
    +
    +
    +

    + 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/landing/audience-sections.tsx b/components/landing/audience-sections.tsx index c6279d4..89ac193 100644 --- a/components/landing/audience-sections.tsx +++ b/components/landing/audience-sections.tsx @@ -1,22 +1,16 @@ import Image from "next/image"; -import Link from "next/link"; import { - BadgeCheck, CalendarCheck, Camera, - Handshake, ListChecks, Plus, ShieldCheck, - Sparkles, type LucideIcon, } from "lucide-react"; import { JourneyCarousel } from "@/components/landing/journey-carousel"; import { LandingLink, TextLink } from "@/components/landing/landing-link"; -const GITHUB_URL = "https://github.com/CodeWithEugene/HackVillage"; - const ORGANIZER_FACTS = [ { value: "100%", label: "deposited before launch" }, { value: "5%", label: "platform fee, paid on top of the pool" }, @@ -84,42 +78,14 @@ const JOURNEY = [ }, ] as const; -const HIRING_FEATURES: { - Icon: LucideIcon; - title: string; - body: string; - href: string; - link: string; -}[] = [ - { - Icon: BadgeCheck, - title: "Verified results.", - body: "Every win on a profile is tied to a real hackathon with a funded, escrowed prize.", - href: "/how-escrow-works", - link: "How results are verified", - }, - { - Icon: Sparkles, - title: "Judge endorsements.", - body: "Judges who scored a project can endorse its builders, on the record and by name.", - href: "/blog/proof-of-work-portfolio", - link: "Read about Proof-of-Work", - }, - { - Icon: Handshake, - title: "Warm introductions.", - body: "Request an intro from a builder's profile. They decide whether to reply, and when.", - href: "/hiring/join", - link: "Become a hiring partner", - }, -]; - function OrganizersBlock() { return ( - <> +
    -

    Host a hackathon people trust from day one

    +

    + Host a hackathon people trust from day one +

    HackVillage For Organizers @@ -176,16 +142,18 @@ function OrganizersBlock() {
    - + ); } function BuildersBlock() { return ( - <> +
    -

    Build a portfolio that proves what you shipped

    +

    + Build a portfolio that proves what you shipped +

    Explore Hackathons @@ -217,90 +185,6 @@ function BuildersBlock() { ))} -
    - -

    - Open source, end to end. Read every line of the escrow and payout - code, open an issue, or ship a fix. -

    - View on GitHub -
    - -

    - New to hackathons? What to bring, how teams form, and how to finish - your first one with something you are proud of. -

    - Read the guide -
    -
    - - ); -} - -function HiringBlock() { - return ( - <> -
    -
    -

    Hire from verified Proof-of-Work, not CVs

    - - Join The Hiring Network - -
    -

    - Every HackVillage profile is built from real results: projects scored by judges, prizes - actually paid, and follow-ups three months on. Find builders by what they shipped. -

    -
    - -
    - - -
      - {HIRING_FEATURES.map(({ Icon, title, body, href, link }) => ( -
    • - - - -

      - {title} {body} -

      - {link} -
    • - ))} -
    -
    “If the prize isn't in the vault, the hackathon doesn't go live. Every @@ -312,25 +196,16 @@ function HiringBlock() { See the public ledger
    - +
    ); } +/** Who HackVillage is for: organizers first, then builders. */ export function AudienceSections() { return ( -
    -
    -

    - Built for everyone in the room.{" "} - - Organizers, builders, judges and hiring teams each get tools that fit how they already - work. - -

    -
    + <> - -
    + ); } 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-banner.tsx b/components/landing/landing-banner.tsx deleted file mode 100644 index cde8997..0000000 --- a/components/landing/landing-banner.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import Image from "next/image"; - -import { LandingLink } from "@/components/landing/landing-link"; - -/** The full-width feature banner: one photo, one promise, one next step. */ -export function LandingBanner() { - return ( -
    -
    -
    - Kenyan developers planning their hackathon project together in Nairobi - -
    -
    - ); -} 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/landing/landing-link.tsx b/components/landing/landing-link.tsx index 3e24891..0bf74cd 100644 --- a/components/landing/landing-link.tsx +++ b/components/landing/landing-link.tsx @@ -7,7 +7,7 @@ import { cn } from "@/lib/utils"; interface LandingLinkProps { href: string; children: React.ReactNode; - variant?: "primary" | "secondary" | "inverse"; + variant?: "primary" | "secondary"; className?: string; external?: boolean; } @@ -15,7 +15,7 @@ interface LandingLinkProps { /** * A navigation link dressed as the pill button, so the landing page keeps the * button design system (sweep fill + nudging arrow) without nesting a - *
    diff --git a/components/landing/trust-stats.tsx b/components/landing/trust-stats.tsx index f9cc402..f7fee77 100644 --- a/components/landing/trust-stats.tsx +++ b/components/landing/trust-stats.tsx @@ -46,7 +46,8 @@ function seededRays(): Ray[] { /** * Each stat drives the fan into a state that shows what the number means: * 100% lights every ray, 50% lights half, 1hr sweeps like a clock hand, - * and 0% evens every ray out (nothing is cut from anyone's prize). + * and 0% keeps every ray at full reach (nothing is cut from anyone's + * prize) with a glow rippling outward from the centre. */ function targetFor(stat: number, ray: Ray, index: number, time: number) { const t = index / (RAY_COUNT - 1); @@ -59,8 +60,14 @@ function targetFor(stat: number, ray: Ray, index: number, time: number) { const near = Math.exp(-(d * d) / 0.006); return { len: ray.reach * (0.7 + 0.35 * near), lit: 0.22 + 0.78 * near }; } - case 3: - return { len: 0.78, lit: 0.72 }; + case 3: { + // Every ray keeps its full reach (nothing is cut), while a soft glow + // ripples out from the centre to both edges, over and over. + const ripple = ((time / 2400) % 1) * 0.5; + const d = Math.abs(t - 0.5) - ripple; + const near = Math.exp(-(d * d) / 0.004); + return { len: ray.reach * (0.94 + 0.1 * near), lit: 0.5 + 0.5 * near }; + } default: return { len: ray.reach, lit: 1 }; } diff --git a/components/patterns/blog-card.tsx b/components/patterns/blog-card.tsx index 2a2e735..56e7752 100644 --- a/components/patterns/blog-card.tsx +++ b/components/patterns/blog-card.tsx @@ -11,7 +11,7 @@ export function BlogCard({ post }: { post: BlogPostMeta }) { return (
    -
    -
    -

    Stay In The Loop

    -

    - Organizers ghost winners. We fixed that. -

    -

    - Deposits are locked before the hackathon starts, payouts are recorded on a public ledger, - and every submission builds a permanent Proof-of-Work portfolio. Subscribe for new - Prize Verified hackathons as they open. -

    - -
    +
    +
    +
    +
    +

    Stay In The Loop

    +

    + Organizers ghost winners. We fixed that. +

    +

    + Deposits are locked before the hackathon starts, payouts are recorded on a public + ledger, and every submission builds a permanent Proof-of-Work portfolio. Subscribe for + new Prize Verified hackathons as they open. +

    + +
    -
    - Two Kenyan developers exploring their next hackathon together +
    + Two Kenyan developers exploring their next hackathon together +
    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/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/how-it-works.tsx b/components/patterns/how-it-works.tsx index e12cb4f..9d28696 100644 --- a/components/patterns/how-it-works.tsx +++ b/components/patterns/how-it-works.tsx @@ -1,10 +1,8 @@ import Image from "next/image"; -import Link from "next/link"; -import { ArrowUpRight } from "lucide-react"; -import { Sparkle } from "@/components/patterns/sparkle"; +import { LandingLink } from "@/components/landing/landing-link"; +import { PageHero } from "@/components/patterns/page-hero"; import { HOST_HACKATHON_HREF } from "@/lib/auth/signup-links"; - const STEPS = [ { number: "01", @@ -44,66 +42,62 @@ const STEPS = [ }, ]; -/** `headingLevel` is h1 on the standalone /how-it-works page; it looks the same either way. */ -export function HowItWorks({ headingLevel = "h2" }: { headingLevel?: "h1" | "h2" }) { - const Heading = headingLevel; +/** + * The /how-it-works page body: the shared page hero, then the four steps as + * story cards in the ruled frame. The copy is unchanged from the original + * section. + */ +export function HowItWorks() { return ( -
    -
    - - -
    -

    How It Works

    - - From First Idea -
    - to Final Payday. -
    + <> + + From First Idea to Final Payday. + + } + lead={ + <> + One simple flow: lock in the prize money, bring people together, judge everyone the same + way, and pay winners the moment they're announced. + + } + > +
    + Browse Hackathons + + Host A Hackathon +
    -

    - One simple flow: lock in the prize money, bring people together, judge everyone the same - way, and pay winners the moment they're announced. -

    - {/* Same pills as the home page hero. */} -
    - - - - Browse Hackathons - - - Host A Hackathon
    -
      - {STEPS.map((step) => ( -
    1. - -

      {step.intro}

      -
      - {step.alt} -
      -

      {step.title}

      -

      {step.description}

      -
      -
      -
    2. - ))} -
    -
    + + ); } diff --git a/components/patterns/keep-reading.tsx b/components/patterns/keep-reading.tsx index 6fe2311..9799faa 100644 --- a/components/patterns/keep-reading.tsx +++ b/components/patterns/keep-reading.tsx @@ -21,25 +21,23 @@ export function KeepReading({ posts }: { posts: BlogPostMeta[] }) { } return ( -
    -

    - Keep Reading -

    -
    - {pageSlice(posts, page, KEEP_READING_PAGE_SIZE).map((post) => ( - - ))} +
    +
    +

    + Keep Reading +

    +
    + {pageSlice(posts, page, KEEP_READING_PAGE_SIZE).map((post) => ( + + ))} +
    +
    -
    ); } 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/components/patterns/legal-document.tsx b/components/patterns/legal-document.tsx index b8a289f..9128242 100644 --- a/components/patterns/legal-document.tsx +++ b/components/patterns/legal-document.tsx @@ -1,4 +1,5 @@ import { LegalToc } from "@/components/patterns/legal-toc"; +import { PageHero } from "@/components/patterns/page-hero"; interface LegalDocumentProps { title: string; @@ -10,16 +11,16 @@ interface LegalDocumentProps { const CONTENT_ID = "legal-content"; -/** Full width text page (terms, privacy, how escrow works) with an "On This Page" sidebar. */ +/** + * Long-form text page (terms, privacy, how escrow works, for organizers, + * contribute) in the ruled frame: a hero with the title and intro, then an + * "On This Page" rail that stays put beside the sections as they scroll. + */ export function LegalDocument({ title, lastUpdated, intro, children }: LegalDocumentProps) { return ( -
    -
    -

    {title}

    - {intro ?

    {intro}

    : null} -

    Last updated {lastUpdated}

    -
    -
    +
    + +
    diff --git a/components/patterns/page-hero.tsx b/components/patterns/page-hero.tsx new file mode 100644 index 0000000..a84034e --- /dev/null +++ b/components/patterns/page-hero.tsx @@ -0,0 +1,57 @@ +/** + * The shared hero for marketing pages, in the landing page's ruled frame: + * a thin top bar (kicker on the left, optional links on the right), a large + * title, an optional lead paragraph and actions, and an optional visual on + * the right. Wrap the page in `.lp` so the frame tokens apply. It only lays + * out what the page passes in; it adds no copy of its own. + */ +export function PageHero({ + id = "page-title", + kicker, + bar, + title, + lead, + children, + aside, + centered = false, +}: { + id?: string; + /** Short label for the top bar, e.g. the page's existing eyebrow. */ + kicker?: React.ReactNode; + /** Right side of the top bar (links, a date). */ + bar?: React.ReactNode; + title: React.ReactNode; + lead?: React.ReactNode; + /** Actions or extra lines under the lead. */ + children?: React.ReactNode; + /** A visual beside the copy on wide screens. */ + aside?: React.ReactNode; + /** Centre the bar, title and lead (document pages). */ + centered?: boolean; +}) { + return ( +
    +
    + {kicker || bar ? ( +
    + {kicker ? {kicker} : } + {bar} +
    + ) : null} +
    +
    +

    + {title} +

    + {lead ?

    {lead}

    : null} + {children} +
    + {aside} +
    +
    +
    + ); +} diff --git a/components/patterns/site-header.tsx b/components/patterns/site-header.tsx index ccd1f19..818758f 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,8 +33,14 @@ 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 */} - - - ); -} 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, + })); +} 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"); + }); +});