diff --git a/.env.example b/.env.example index 01d3fd1..775cf38 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,10 @@ +# HackVillage environment template. Mirrors lib/env.ts (zod-validated server +# env) plus the handful of vars read from process.env directly (noted inline). +# Copy to .env for local dev. NEVER commit a filled-in .env (.gitignore blocks it). + # ── Core ──────────────────────────────────────────────────────────────── # PostgreSQL (Neon in prod, Docker locally) -DATABASE_URL=postgresql://user:password@localhost:5432/hackvillage -# Direct (non-pooled) connection for migrations — Neon-specific, optional locally -DIRECT_URL=postgresql://user:password@localhost:5432/hackvillage +DATABASE_URL=postgresql://user:***@localhost:5432/hackvillage # Auth.js v5 (Phase 1) NEXTAUTH_SECRET= # generate: openssl rand -base64 32 @@ -20,25 +22,52 @@ BREVO_API_KEY= EMAIL_FROM="HackVillage " # ── Paystack (test keys are sufficient for local development) ──────────── +# Absent key switches payments to the dev simulation port. PAYSTACK_SECRET_KEY=sk_test_... -NEXT_PUBLIC_PAYSTACK_PUBLIC_KEY=pk_test_... -PAYSTACK_WEBHOOK_SECRET= +# Explicit opt-in to run the SIMULATION payment port in production — the +# pre-go-live state. Without this a keyless production boot fails closed. +# Set to "true" ONLY until real Paystack keys go in. +PAYSTACK_ALLOW_SIMULATION_IN_PROD= # ── Chain — Polygon (Amoy testnet first; mainnet only after audit) ────── +# Absent config switches attestations to simulation (marked on /trust). SMART_CONTRACT_ADDRESS=0x... # PrizeVaultFactory (ADR-007) -CHAIN_ID=80002 RPC_URL=https://rpc.amoy.polygonscan.com ATTESTER_PRIVATE_KEY=0x... # platform signer — env-only, NEVER in the repo POLYGONSCAN_URL=https://amoy.polygonscan.com -# ── Storage ──────────────────────────────────────────────────────────── +# ── Storage (read from process.env by lib/ports/storage.ts) ────────────── +# R2/S3-compatible in production; without these, media saves to public/uploads. R2_ACCOUNT_ID= R2_ACCESS_KEY_ID= R2_SECRET_ACCESS_KEY= R2_BUCKET=hackvillage-media +# Public URL prefix for uploaded media. Defaults to +# https://pub-.r2.dev/ — set when using a custom domain. +R2_PUBLIC_BASE= -# ── Ops (defaults shown; values are enforced by lib/env.ts) ────────────── +# ── Error tracking (Sentry — all optional; unset = complete no-op) ─────── +# Server + edge runtimes (instrumentation.ts, sentry.*.config.ts) SENTRY_DSN= +# Browser runtime (instrumentation-client.ts, app/error.tsx) — public by design +NEXT_PUBLIC_SENTRY_DSN= +# Source-map upload at build time (next.config.ts withSentryConfig) +SENTRY_ORG= +SENTRY_PROJECT= +SENTRY_AUTH_TOKEN= + +# ── SEO ops ───────────────────────────────────────────────────────────── +# scripts/ping-indexnow.ts: site root submitted to IndexNow (defaults to +# https://www.hackvillage.xyz; override for a future domain) +INDEXNOW_SITE= + +# ── Ops (defaults shown; values are enforced by lib/env.ts) ────────────── PAYOUT_MANUAL_REVIEW_THRESHOLD_KES=500000 PLATFORM_FEE_BPS=500 # 5% organizer-funded fee at deposit (ADR-012) MIN_PRIZE_POOL_KES=10000 # minimum escrowed pool (Decision D3) + +# ── Removed dead vars (2026-09-27 audit; grep confirms zero code refs) ──── +# DIRECT_URL — Prisma datasource uses DATABASE_URL only (no directUrl). +# PAYSTACK_WEBHOOK_SECRET — webhook HMAC uses PAYSTACK_SECRET_KEY. +# CHAIN_ID — the chain port hardcodes Amoy; RPC_URL selects the network. +# NEXT_PUBLIC_PAYSTACK_PUBLIC_KEY — checkout is server-initialized. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e305fde..c5d819b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,8 +61,8 @@ jobs: - name: Apply database migrations run: pnpm run db:deploy - - name: Unit + integration tests - run: pnpm test + - name: Unit + integration tests (coverage gate on services/escrow + services/payout) + run: pnpm run test:coverage - name: Build run: pnpm run build diff --git a/.gitignore b/.gitignore index 9d7384e..df08d10 100644 --- a/.gitignore +++ b/.gitignore @@ -26,9 +26,12 @@ pnpm-debug.log* *.log # local env files — never commit credentials (CONTRIBUTING.md) +# .env.* covers .env.development/.env.test/.env.production etc.; the only +# committable env file is the documented template. .env .env.local -.env.*.local +.env.* +!.env.example # vercel .vercel @@ -46,6 +49,3 @@ next-env.d.ts # claude-flow local tooling data .claude-flow/ - -# production env (pulled from Vercel — contains real credentials) -.env.production diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index da6315c..f05a9be 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -81,14 +81,14 @@ Large features (new escrow flows, DAO mechanics, cross-border payment rails) sho | Requirement | Minimum Version | |---|---| | Node.js | 22.13+ | -| pnpm | 9.x | +| pnpm | 11.x (`corepack enable` pins the version from `package.json`) | | PostgreSQL | 15.x | | Git | 2.x | You will also need: - A **Paystack** account — test keys are sufficient for local development. -- An RPC endpoint for the target chain (Polygon Mumbai testnet or Solana Devnet) for escrow contract work. +- An RPC endpoint for the target chain (Polygon Amoy testnet) for escrow contract work. ### Installation @@ -112,12 +112,16 @@ cp .env.example .env.local | Variable | Description | |---|---| | `DATABASE_URL` | PostgreSQL connection string | -| `PAYSTACK_SECRET_KEY` | Paystack secret key (`sk_test_...` for local) | -| `NEXT_PUBLIC_PAYSTACK_PUBLIC_KEY` | Paystack public key (`pk_test_...` for local) | -| `SMART_CONTRACT_ADDRESS` | Deployed escrow contract address | -| `RPC_URL` | JSON-RPC endpoint for the target chain | | `NEXTAUTH_SECRET` | Random secret for session signing | | `NEXTAUTH_URL` | Base URL (`http://localhost:3000` locally) | +| `NEXT_PUBLIC_APP_URL` | Public base URL used in links/metadata | +| `PAYSTACK_SECRET_KEY` | Paystack secret key (`sk_test_...` for local; absent = simulation) | +| `SMART_CONTRACT_ADDRESS` | Deployed escrow contract address | +| `RPC_URL` | JSON-RPC endpoint for the target chain | +| `ATTESTER_PRIVATE_KEY` | Platform signer for chain attestations (env-only) | + +The full, annotated variable list lives in `.env.example` and is validated by +`lib/env.ts`. Never commit `.env.local` or any file containing real credentials. @@ -146,8 +150,12 @@ HackVillage/ ├── components/ # Shared React components ├── lib/ # Shared utilities, DB client, API helpers ├── services/ -│ ├── escrow/ # Escrow microservice — Paystack + smart contract calls -│ └── payout/ # Split disbursement logic +│ ├── escrow/ # Deposits, vault, webhook, attestations, reconcile +│ ├── payout/ # Split disbursement engine, tranches, milestones +│ ├── judging/ # Rubrics, scoring, feedback gate, results +│ ├── media/ # 48h deadline, trust events, notifications +│ ├── legacy/ # 3-month check-ins, disputes +│ └── pow/ # Endorsements, portfolio, hiring intros ├── contracts/ # Smart contract source and ABI ├── db/ │ ├── migrations/ # PostgreSQL migrations @@ -292,9 +300,10 @@ CI will fail on lint or type errors. Do not disable rules with `eslint-disable` Tests live in the `tests/` directory, mirroring the source structure. ```bash -pnpm test # Run all tests -pnpm run test:unit # Unit tests only -pnpm run test:e2e # End-to-end tests (requires running dev server) +pnpm test # Run all tests +pnpm run test:unit # Unit tests only +pnpm run test:integration # Integration tests (live Postgres — see tests/integration) +pnpm run test:coverage # Suite + coverage gate on services/escrow + services/payout ``` ### Requirements diff --git a/README.md b/README.md index 05fbbdd..536f0d8 100644 --- a/README.md +++ b/README.md @@ -135,7 +135,7 @@ HackVillage operates as a three-phase engine: ``` ┌───────────────────────────────────────────────────────┐ │ CLIENT LAYER │ -│ Next.js 14 (SEO-optimized, │ +│ Next.js 15 (SEO-optimized, │ │ server components, App Router) │ └────────────────────────┬──────────────────────────────┘ │ @@ -147,19 +147,22 @@ HackVillage operates as a three-phase engine: └──────────┬─────────────────────────┬──────────────────┘ │ │ ┌──────────▼──────────┐ ┌──────────▼──────────────────┐ -│ PostgreSQL DB │ │ Escrow Microservice │ +│ PostgreSQL DB │ │ Escrow service (in-repo) │ │ Structured Proof │ │ Paystack + Smart Contract │ -│ of Work tracking │ │ (Polygon / Solana layer) │ +│ of Work tracking │ │ (Polygon Amoy) │ └─────────────────────┘ └─────────────────────────────┘ ``` +> The separate escrow microservice and Paystack Split API were both evaluated +> and rejected — see ADR-001 (modular monolith) and ADR-006 (direct transfers). + | Layer | Technology | Purpose | |---|---|---| -| Frontend | Next.js 14 | SEO-optimized project discovery | +| Frontend | Next.js 15 | SEO-optimized project discovery | | Backend | Node.js / TypeScript | High-concurrency event-day API | | Database | PostgreSQL | Structured Proof of Work records | | Payments | Paystack | Fiat deposits, M-Pesa & bank payouts | -| Ledger | Smart Contract (Polygon/Solana) | Transparent, tamper-proof transaction log | +| Ledger | Smart Contract (Polygon Amoy) | Transparent, tamper-proof transaction log | | Open Source Core | GitHub | Judging logic & escrow mechanics are public | --- @@ -229,12 +232,13 @@ Paystack API ──── Final transfer ──── Public Ledger entry record | Phase | Feature | Status | |---|---|---| -| v1.0 | Prize Vault & Escrow Engine | In Development | -| v1.0 | Developer Proof of Work Profiles | In Development | -| v1.0 | Paystack Split Disbursement | In Development | -| v1.1 | 48-Hour Media Vault + Trust Penalty | Planned | -| v1.1 | One-Click Internship Matching | Planned | -| v1.2 | Legacy Tracker (3-month check-ins) | Planned | +| v1.0 | Prize Vault & Escrow Engine | Done — live in production | +| v1.0 | Developer Proof of Work Profiles | Done | +| v1.0 | Paystack Split Disbursement (50/50 tranches) | Done | +| v1.1 | 48-Hour Media Vault + Trust Penalty | Done | +| v1.1 | One-Click Internship Matching | Done | +| v1.1 | Newsletters (subscribe + organizer blasts) | Done | +| v1.2 | Legacy Tracker (3-month check-ins + dispute window) | Done | | v2.0 | **HackVillage DAO** — Community-driven Elite Organizer rankings | Moonshot | | v2.0 | **Global Node** — Nigeria, Rwanda, cross-border prize handling | Moonshot | | v2.0 | **AI Judging Co-pilot** — Pre-screen repos for quality & plagiarism | Moonshot | @@ -260,14 +264,25 @@ pnpm install ### Environment Variables -Create a `.env.local` file at the project root: +Copy the template and fill in your values — `.env.example` documents every +variable the code reads (validated by `lib/env.ts`): + +```bash +cp .env.example .env +``` + +The minimum set for a local boot: ```env -DATABASE_URL=postgresql://user:password@localhost:5432/hackvillage +DATABASE_URL=postgresql://user:***@localhost:5432/hackvillage +NEXTAUTH_SECRET= # generate: openssl rand -base64 32 +NEXTAUTH_URL=http://localhost:3000 +NEXT_PUBLIC_APP_URL=http://localhost:3000 +# Optional: without these, payments/chain run in documented simulation modes PAYSTACK_SECRET_KEY=sk_test_... -NEXT_PUBLIC_PAYSTACK_PUBLIC_KEY=pk_test_... SMART_CONTRACT_ADDRESS=0x... -RPC_URL=https://... +RPC_URL=https://rpc.amoy.polygonscan.com +ATTESTER_PRIVATE_KEY=0x... ``` ### Development diff --git a/app/(app)/dashboard/hackathons/page.tsx b/app/(app)/dashboard/hackathons/page.tsx index f0a4763..f143bbf 100644 --- a/app/(app)/dashboard/hackathons/page.tsx +++ b/app/(app)/dashboard/hackathons/page.tsx @@ -2,6 +2,7 @@ import type { Metadata } from "next"; import Link from "next/link"; import { CalendarDays } from "lucide-react"; +import { PrizeVerifiedBadge } from "@/components/patterns/prize-verified-badge"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; @@ -65,7 +66,7 @@ export default async function DashboardEventsPage() {
{verified ? ( - Prize Verified + ) : ( {STATUS_LABELS[event.status]} )} diff --git a/app/(app)/dashboard/loading.tsx b/app/(app)/dashboard/loading.tsx new file mode 100644 index 0000000..348d49f --- /dev/null +++ b/app/(app)/dashboard/loading.tsx @@ -0,0 +1,21 @@ +import { Skeleton, SkeletonCard } from "@/components/ui/skeleton"; + +export default function Loading() { + return ( +
+
+
+ + +
+ +
+
+ {[0, 1, 2].map((i) => ( + + ))} +
+ +
+ ); +} diff --git a/app/(app)/dashboard/page.tsx b/app/(app)/dashboard/page.tsx index 8339a6a..cc1ce35 100644 --- a/app/(app)/dashboard/page.tsx +++ b/app/(app)/dashboard/page.tsx @@ -1,11 +1,10 @@ import type { Metadata } from "next"; import Link from "next/link"; -import { CalendarX2, Rocket } from "lucide-react"; +import { CalendarX2 } from "lucide-react"; import { LegacyCheckinCard } from "@/components/legacy/legacy-cards"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { Card, CardDescription, CardTitle } from "@/components/ui/card"; import { EmptyState } from "@/components/ui/empty-state"; import { requireOnboardedUser } from "@/lib/auth/guards"; import { prisma } from "@/lib/db"; @@ -71,34 +70,17 @@ export default async function DashboardPage() { ) : null} - 0 ? "" : "No active hackathons right now"} - description={ - dueCheckins.length > 0 - ? "" - : "Prize Verified hackathons appear here the moment you register: active hackathons, teams, and in-flight payouts at a glance." - } - action={ - dueCheckins.length > 0 ? undefined : ( + {dueCheckins.length === 0 ? ( + - ) - } - /> - - {dueCheckins.length === 0 ? ( - - - The Full Loop Is Live - - - Escrowed prize pools, 50/50 payouts, structured judge feedback, verified Proof-of-Work - profiles, hiring intros, the 48-hour media standard, and 3-month legacy tracking. The - platform runs end to end. Next: production hardening (runbooks, load tests, audit). - - + } + /> ) : null}
); diff --git a/app/(app)/dashboard/winnings/page.tsx b/app/(app)/dashboard/winnings/page.tsx index 944e2b6..087f087 100644 --- a/app/(app)/dashboard/winnings/page.tsx +++ b/app/(app)/dashboard/winnings/page.tsx @@ -3,6 +3,7 @@ import Link from "next/link"; import { HandCoins } from "lucide-react"; import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; import { Card, CardDescription, CardTitle } from "@/components/ui/card"; import { DisputeForm } from "@/components/legacy/legacy-cards"; import { canOpenDispute } from "@/services/legacy/dispute-window"; @@ -96,7 +97,9 @@ export default async function WinningsPage() { It takes a minute. - Set up payouts in Settings → + ) : null} @@ -108,7 +111,7 @@ export default async function WinningsPage() { description="When you win a Prize Verified hackathon, the 50% instant tranche lands here the same day, tracked to the shilling." action={ - Find a hackathon + } /> diff --git a/app/(app)/hackathons/[slug]/workspace/loading.tsx b/app/(app)/hackathons/[slug]/workspace/loading.tsx new file mode 100644 index 0000000..729ad5f --- /dev/null +++ b/app/(app)/hackathons/[slug]/workspace/loading.tsx @@ -0,0 +1,23 @@ +import { Skeleton, SkeletonCard } from "@/components/ui/skeleton"; + +export default function Loading() { + return ( +
+
+
+ + +
+ +
+ + +
+ {[0, 1, 2, 3, 4].map((i) => ( + + ))} +
+
+
+ ); +} diff --git a/app/(app)/hackathons/[slug]/workspace/page.tsx b/app/(app)/hackathons/[slug]/workspace/page.tsx index 05e51df..9cb8f48 100644 --- a/app/(app)/hackathons/[slug]/workspace/page.tsx +++ b/app/(app)/hackathons/[slug]/workspace/page.tsx @@ -90,6 +90,12 @@ export default async function WorkspacePage({ }, }); + // The endorse page is judge-only; only surface the link to active judges. + const activeJudgeship = await prisma.judgeAssignment.findFirst({ + where: { eventId: event.id, userId: user.id, status: "ACTIVE" }, + select: { id: true }, + }); + const windowOpen = submissionWindowOpen(event); return ( @@ -201,17 +207,19 @@ export default async function WorkspacePage({ ) ) : null} - - What Happens After Announcement - - Endorse the winners you judged:{" "} - - write endorsements - {" "} - once results are announced. Your one-liner becomes permanent proof on their - Proof-of-Work profiles. - - + {activeJudgeship ? ( + + What Happens After Announcement + + Endorse the winners you judged:{" "} + + write endorsements + {" "} + once results are announced. Your one-liner becomes permanent proof on their + Proof-of-Work profiles. + + + ) : null}
); } diff --git a/app/(app)/judge/loading.tsx b/app/(app)/judge/loading.tsx new file mode 100644 index 0000000..69a20c0 --- /dev/null +++ b/app/(app)/judge/loading.tsx @@ -0,0 +1,25 @@ +import { Skeleton, SkeletonCard } from "@/components/ui/skeleton"; + +export default function Loading() { + return ( +
+
+ + +
+ +
    + {[0, 1, 2].map((i) => ( +
  • +
    + + +
    + +
  • + ))} +
+
+
+ ); +} diff --git a/app/(app)/organizer/hackathons/[slug]/edit/page.tsx b/app/(app)/organizer/hackathons/[slug]/edit/page.tsx index 8bb46c8..03daaf1 100644 --- a/app/(app)/organizer/hackathons/[slug]/edit/page.tsx +++ b/app/(app)/organizer/hackathons/[slug]/edit/page.tsx @@ -36,8 +36,8 @@ export default async function EditEventPage({

Edit Draft Hackathon

- Drafts stay editable. Once published, the hackathon locks until the vault deposit flow - arrives. + Drafts stay editable. Once published, the hackathon locks and goes live when the vault + deposit confirms.

+
+ +
+ + + +
+
+
+ {[0, 1, 2].map((i) => ( + + ))} +
+ + + + ); +} diff --git a/app/(app)/organizer/hackathons/[slug]/page.tsx b/app/(app)/organizer/hackathons/[slug]/page.tsx index 5a9268c..88590ee 100644 --- a/app/(app)/organizer/hackathons/[slug]/page.tsx +++ b/app/(app)/organizer/hackathons/[slug]/page.tsx @@ -4,6 +4,7 @@ import { notFound } from "next/navigation"; import { Lock } from "lucide-react"; import { CoverUploader } from "@/components/organizer/cover-uploader"; +import { EventCancelButton } from "@/components/organizer/event-cancel-button"; import { PublishGate } from "@/components/organizer/publish-gate"; import { JudgingSection } from "@/components/judging/judging-section"; import { Badge } from "@/components/ui/badge"; @@ -78,11 +79,16 @@ export default async function EventCommandCenterPage({ params }: PageProps) { hackvillage.xyz/hackathons/{event.slug}

- {isDraft ? ( - - - - ) : null} +
+ {isDraft ? ( + + + + ) : null} + {event.status === "DRAFT" || event.status === "PENDING_DEPOSIT" ? ( + + ) : null} +
diff --git a/app/(app)/organizer/hackathons/[slug]/winners/page.tsx b/app/(app)/organizer/hackathons/[slug]/winners/page.tsx index 03383d1..648e24b 100644 --- a/app/(app)/organizer/hackathons/[slug]/winners/page.tsx +++ b/app/(app)/organizer/hackathons/[slug]/winners/page.tsx @@ -55,6 +55,33 @@ export default async function EventWinnersPage({ const results = event.status === "JUDGING" ? await computeEventResults(event.id) : null; const scoreFor = (teamId: string) => results?.results.find((r) => r.teamId === teamId) ?? null; + /** The final-50% due for a winner: the milestone tranche when it exists, else pool minus instant. */ + const milestoneDueKes = (winner: (typeof event.winners)[number]): number => { + const instant = winner.payouts.find((p) => p.tranche === "INSTANT"); + const milestonePayout = winner.payouts.find((p) => p.tranche === "MILESTONE"); + return ( + milestonePayout?.amountKes ?? + (winner.milestoneRequired ? winner.amountKes - (instant?.amountKes ?? 0) : 0) + ); + }; + + const milestoneDues = event.winners + .filter((winner) => winner.milestoneRequired) + .map((winner) => { + const milestonePayout = winner.payouts.find((p) => p.tranche === "MILESTONE"); + return { + id: winner.id, + place: winner.place, + teamName: winner.team.name, + amountKes: milestoneDueKes(winner), + dueAt: winner.milestone?.dueAt ?? null, + confirmedAt: winner.milestone?.confirmedAt ?? null, + payoutStatus: milestonePayout?.status ?? null, + }; + }) + .sort((a, b) => a.place - b.place); + const outstandingDues = milestoneDues.filter((due) => due.payoutStatus !== "SUCCEEDED"); + return (
@@ -181,20 +208,68 @@ export default async function EventWinnersPage({
{winner.milestone && !winner.milestone.confirmedAt ? ( - + ) : null} ); })} - - Milestone Dues - - The final 50% releases when you confirm each winner's handover. Confirming - triggers the milestone payout immediately. - - + {milestoneDues.length > 0 ? ( + + Milestone Dues + + {outstandingDues.length === 0 + ? "Every milestone tranche is settled." + : `${formatKes(outstandingDues.reduce((sum, due) => sum + due.amountKes, 0))} outstanding across ${outstandingDues.length} handover${outstandingDues.length === 1 ? "" : "s"}. Confirming a handover releases the tranche immediately.`} + +
    + {milestoneDues.map((due) => ( +
  • + + {due.place} + {["st", "nd", "rd"][due.place - 1] ?? "th"} · {due.teamName} + + + + {formatKes(due.amountKes)} + + + {due.payoutStatus + ? due.payoutStatus.toLowerCase().replace("_", " ") + : due.confirmedAt + ? "releasing" + : "awaiting handover"} + + {due.dueAt ? ( + + due{" "} + {new Date(due.dueAt).toLocaleDateString("en-KE", { + day: "numeric", + month: "short", + })} + + ) : null} + +
  • + ))} +
+
+ ) : null} ) : null} diff --git a/app/(app)/organizer/loading.tsx b/app/(app)/organizer/loading.tsx new file mode 100644 index 0000000..18ad7aa --- /dev/null +++ b/app/(app)/organizer/loading.tsx @@ -0,0 +1,21 @@ +import { Skeleton, SkeletonCard } from "@/components/ui/skeleton"; + +export default function Loading() { + return ( +
+
+
+ + +
+
+ + +
+
+ + + +
+ ); +} diff --git a/app/(app)/organizer/organization/loading.tsx b/app/(app)/organizer/organization/loading.tsx new file mode 100644 index 0000000..baabf3d --- /dev/null +++ b/app/(app)/organizer/organization/loading.tsx @@ -0,0 +1,19 @@ +import { Skeleton, SkeletonCard } from "@/components/ui/skeleton"; + +export default function Loading() { + return ( +
+
+
+ + +
+ +
+ + + + +
+ ); +} diff --git a/app/(app)/organizer/organization/page.tsx b/app/(app)/organizer/organization/page.tsx new file mode 100644 index 0000000..4bdae0b --- /dev/null +++ b/app/(app)/organizer/organization/page.tsx @@ -0,0 +1,197 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { ShieldCheck, Users } from "lucide-react"; +import type { TrustEventType } from "@prisma/client"; + +import { InviteCodeManager } from "@/components/organizer/invite-code-manager"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardDescription, CardTitle } from "@/components/ui/card"; +import { EmptyState } from "@/components/ui/empty-state"; +import { requireSurface } from "@/lib/auth/guards"; +import { prisma } from "@/lib/db"; + +export const metadata: Metadata = { title: "Organization Settings" }; + +const KYB_BADGE = { + NONE: { label: "KYB: Not Started", tone: "warning" }, + PENDING: { label: "KYB: In Review", tone: "warning" }, + VERIFIED: { label: "Verified Organization", tone: "success" }, + FAILED: { label: "KYB: Needs Attention", tone: "danger" }, +} as const; + +const TRUST_EVENT_LABELS: Record = { + MEDIA_PENALTY: "Media delivered late", + PAYOUT_EXCELLENCE: "Payout excellence", + MANUAL_ADJUST: "Trust adjustment", + APPEAL_GRANTED: "Appeal granted", +}; + +const ROLE_ORDER = { OWNER: 0, ADMIN: 1, MEMBER: 2 } as const; + +const dateFormat = new Intl.DateTimeFormat("en-KE", { + day: "numeric", + month: "short", + year: "numeric", + timeZone: "Africa/Nairobi", +}); + +export default async function OrganizationSettingsPage() { + const user = await requireSurface("organizer"); + + const membership = await prisma.orgMember.findFirst({ + where: { userId: user.id, status: "ACTIVE" }, + include: { org: true }, + }); + + if (!membership) { + return ( + + + + } + /> + ); + } + + const org = membership.org; + const [members, activeInvites, trustEvents] = await Promise.all([ + prisma.orgMember.findMany({ + where: { orgId: org.id, status: "ACTIVE" }, + include: { user: { select: { name: true, email: true, handle: true } } }, + }), + prisma.orgInvitation.findMany({ + where: { orgId: org.id, acceptedAt: null, expiresAt: { gt: new Date() } }, + orderBy: { createdAt: "desc" }, + take: 5, + }), + prisma.trustEvent.findMany({ + where: { orgId: org.id }, + orderBy: { createdAt: "desc" }, + take: 20, + }), + ]); + const sortedMembers = [...members].sort( + (a, b) => ROLE_ORDER[a.role] - ROLE_ORDER[b.role], + ); + const kyb = KYB_BADGE[org.kycStatus]; + + return ( +
+
+
+

{org.name}

+

+ Organization settings · public page at{" "} + + /organizers/{org.slug} + +

+
+ Trust score {org.trustScore} +
+ + +
+
+ + Business Verification + + + {org.kycStatus === "VERIFIED" + ? "Your organization is verified. Your hackathons carry the Verified Organization badge." + : "Verification is required before your first prize-pool deposit. Review takes up to 48 hours."} + +
+
+ {kyb.label} + + + +
+
+
+ + + + Members + + + Everyone with organizer access to {org.name}. Owners and admins can manage hackathons, + invites and this page. + +
    + {sortedMembers.map((member) => ( +
  • +
    +

    + {member.user.name ?? member.user.email} + {member.userId === user.id ? ( + (you) + ) : null} +

    +

    {member.user.email}

    +
    + + {member.role.toLowerCase()} + +
  • + ))} +
+
+ + ({ + token: invite.token, + expiresAt: invite.expiresAt.toISOString(), + }))} + /> + + + Trust History + + Why the score is where it is. Penalties and awards land here the moment they are + recorded; the same history (minus internal notes) is public on your organizer page. + + {trustEvents.length === 0 ? ( +

+ No trust events yet. The score moves with payout speed, media delivery and milestone + honesty. +

+ ) : ( +
    + {trustEvents.map((event) => ( +
  • +
    +

    + {TRUST_EVENT_LABELS[event.type]} +

    +

    + {dateFormat.format(event.createdAt)} + {event.reason ? ` · ${event.reason}` : ""} +

    +
    + + {event.delta > 0 ? `+${event.delta}` : event.delta} points + +
  • + ))} +
+ )} +
+
+ ); +} diff --git a/app/(app)/organizer/page.tsx b/app/(app)/organizer/page.tsx index 1837925..95582d5 100644 --- a/app/(app)/organizer/page.tsx +++ b/app/(app)/organizer/page.tsx @@ -87,7 +87,10 @@ export default async function OrganizerPage() {

{org.name}

- {membership.role.toLowerCase()} · hackvillage.xyz/organizers/{org.slug} + {membership.role.toLowerCase()} ·{" "} + + /organizers/{org.slug} +

@@ -123,8 +126,8 @@ export default async function OrganizerPage() { Run A Hackathon - Five steps to a draft; publishing declares the prize pool. The vault deposit flow - (Phase 3) flips it live with the Prize Verified badge. + Five steps to a draft; publishing declares the prize pool. Locking the pool in the + Prize Vault flips it live with the Prize Verified badge.
diff --git a/app/(auth)/onboarding/choose/page.tsx b/app/(auth)/onboarding/choose/page.tsx index a55e806..45c8758 100644 --- a/app/(auth)/onboarding/choose/page.tsx +++ b/app/(auth)/onboarding/choose/page.tsx @@ -1,7 +1,10 @@ import type { Metadata } from "next"; -import { Building2, Code2 } from "lucide-react"; +import Link from "next/link"; +import { Building2, Code2, LogIn } from "lucide-react"; import { RoleChoiceCards } from "@/components/onboarding/role-choice-cards"; +import { Button } from "@/components/ui/button"; +import { Card, CardDescription, CardTitle } from "@/components/ui/card"; import { currentUser } from "@/lib/auth/guards"; export const metadata: Metadata = { title: "Choose Your Path" }; @@ -9,10 +12,27 @@ export const metadata: Metadata = { title: "Choose Your Path" }; export default async function OnboardingChoosePage() { const user = await currentUser(); if (!user) { + // Signed-out visitors get a real way forward, not a dead end. After + // sign-in the auth flow returns unonboarded users to this page itself + // (lib/auth/actions.ts), so no ?next= round-trip is needed. return ( -

- Sign in first, then we'll set you up. -

+ + + Sign In To Choose Your Path + + + Pick organizer or developer right after you sign in — we'll bring you straight back + here. + +
+ + + + + + +
+
); } diff --git a/app/(marketing)/developers/[handle]/loading.tsx b/app/(marketing)/developers/[handle]/loading.tsx new file mode 100644 index 0000000..9a2c388 --- /dev/null +++ b/app/(marketing)/developers/[handle]/loading.tsx @@ -0,0 +1,20 @@ +import { Skeleton, SkeletonCard } from "@/components/ui/skeleton"; + +export default function Loading() { + return ( +
+ + + + + + +
+ {[0, 1, 2].map((i) => ( + + ))} +
+ +
+ ); +} diff --git a/app/(marketing)/hackathons/[slug]/loading.tsx b/app/(marketing)/hackathons/[slug]/loading.tsx new file mode 100644 index 0000000..a97c994 --- /dev/null +++ b/app/(marketing)/hackathons/[slug]/loading.tsx @@ -0,0 +1,23 @@ +import { Skeleton, SkeletonCard } from "@/components/ui/skeleton"; + +export default function Loading() { + return ( +
+
+ + + +
+
+
+ + +
+
+ + +
+
+
+ ); +} diff --git a/app/(marketing)/hackathons/[slug]/page.tsx b/app/(marketing)/hackathons/[slug]/page.tsx index 3c9f94f..b58dca6 100644 --- a/app/(marketing)/hackathons/[slug]/page.tsx +++ b/app/(marketing)/hackathons/[slug]/page.tsx @@ -5,6 +5,7 @@ import { CalendarDays, Clock, MapPin, Users } from "lucide-react"; import { KeyDatesCard } from "@/components/patterns/key-dates-card"; 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"; @@ -175,6 +176,7 @@ export default async function EventDetailPage({ params, searchParams }: PageProp {event._count.teams} team {event._count.teams === 1 ? "" : "s"} + {event.prizeVerifiedAt ? : null} {event.summary ? (

{event.summary}

diff --git a/app/(marketing)/hackathons/loading.tsx b/app/(marketing)/hackathons/loading.tsx new file mode 100644 index 0000000..4f94f85 --- /dev/null +++ b/app/(marketing)/hackathons/loading.tsx @@ -0,0 +1,22 @@ +import { Skeleton, SkeletonCard } from "@/components/ui/skeleton"; + +export default function Loading() { + return ( +
+
+ + +
+
+ {[0, 1, 2].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 57bdf5b..d508c2c 100644 --- a/app/(marketing)/hackathons/page.tsx +++ b/app/(marketing)/hackathons/page.tsx @@ -73,6 +73,7 @@ const getStoredCards = unstable_cache( orgTrustScore: event.org.trustScore, categories: event.categories, coverUrl: event.coverUrl, + prizeVerifiedAt: event.prizeVerifiedAt?.toISOString() ?? null, })); }, ["public-hackathon-listing"], diff --git a/app/(marketing)/organizers/[slug]/loading.tsx b/app/(marketing)/organizers/[slug]/loading.tsx new file mode 100644 index 0000000..fe08171 --- /dev/null +++ b/app/(marketing)/organizers/[slug]/loading.tsx @@ -0,0 +1,29 @@ +import { Skeleton, SkeletonCard } from "@/components/ui/skeleton"; + +export default function Loading() { + return ( +
+
+ + +
+ + +
+ + +
+ {[0, 1, 2].map((i) => ( + + ))} +
+ +
+ {[0, 1, 2].map((i) => ( + + ))} +
+
+
+ ); +} diff --git a/app/(marketing)/organizers/[slug]/page.tsx b/app/(marketing)/organizers/[slug]/page.tsx new file mode 100644 index 0000000..f724ba2 --- /dev/null +++ b/app/(marketing)/organizers/[slug]/page.tsx @@ -0,0 +1,418 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { CalendarDays, ExternalLink, MapPin, ShieldCheck, Timer } from "lucide-react"; +import type { TrustEventType } from "@prisma/client"; + +import { JsonLd } from "@/components/seo/json-ld"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardDescription, CardTitle } from "@/components/ui/card"; +import { prisma } from "@/lib/db"; +import { formatEventDates } from "@/lib/events/format"; +import { isPublicHackathon } from "@/lib/events/visibility"; +import { formatOrgLocation, isOrgKind, ORG_KIND_LABELS } from "@/lib/orgs/details"; +import { pageOpenGraph } from "@/lib/seo/metadata"; +import { appUrl } from "@/lib/url"; +import { formatKes } from "@/lib/utils"; + +import { summarizeMediaTimeliness, summarizePayoutPerformance } from "./performance"; + +interface PageProps { + params: Promise<{ slug: string }>; +} + +/** + * Public-safe organization select. contactPhone is deliberately absent — it + * is for the HackVillage team only (schema comment) and must never reach a + * public page. + */ +const PUBLIC_ORG_SELECT = { + id: true, + name: true, + slug: true, + about: true, + kind: true, + city: true, + country: true, + website: true, + socialUrl: true, + kycStatus: true, + trustScore: true, + createdAt: true, +} as const; + +export async function generateMetadata({ params }: PageProps): Promise { + const { slug } = await params; + const org = await prisma.organization.findUnique({ + where: { slug }, + select: { name: true, about: true, kycStatus: true }, + }); + if (!org) return { title: "Organizer Not Found", robots: { index: false } }; + const verified = org.kycStatus === "VERIFIED"; + return { + title: `${org.name}, ${verified ? "Verified " : ""}Hackathon Organizer`, + description: + org.about ?? + `${org.name} hosts hackathons on HackVillage with 100% escrowed prize pools and payouts recorded on a public ledger.`, + alternates: { canonical: `/organizers/${slug}` }, + openGraph: pageOpenGraph(`/organizers/${slug}`), + }; +} + +const joinedFormat = new Intl.DateTimeFormat("en-KE", { + month: "short", + year: "numeric", + timeZone: "Africa/Nairobi", +}); + +const dateFormat = new Intl.DateTimeFormat("en-KE", { + day: "numeric", + month: "short", + year: "numeric", + timeZone: "Africa/Nairobi", +}); + +const TRUST_EVENT_LABELS: Record = { + MEDIA_PENALTY: "Media delivered late", + PAYOUT_EXCELLENCE: "Payout excellence", + MANUAL_ADJUST: "Trust adjustment", + APPEAL_GRANTED: "Appeal granted", +}; + +/** "technetium.co.ke" from "https://www.technetium.co.ke/" (same rule as the Organizer card). */ +function linkLabel(url: string): string { + try { + const { hostname, pathname } = new URL(url); + const host = hostname.replace(/^www\./, ""); + return pathname.length > 1 ? `${host}${pathname.replace(/\/$/, "")}` : host; + } catch { + return url; + } +} + +function formatMedianMinutes(minutes: number): string { + if (minutes < 60) return `${Math.round(minutes)} min`; + const hours = minutes / 60; + return `${hours < 10 ? hours.toFixed(1) : Math.round(hours)} h`; +} + +export default async function OrganizerTrustPage({ params }: PageProps) { + const { slug } = await params; + const org = await prisma.organization.findUnique({ + where: { slug }, + select: PUBLIC_ORG_SELECT, + }); + if (!org) notFound(); + + const [events, trustEvents, payouts, media, escrowed] = await Promise.all([ + prisma.event.findMany({ + where: { orgId: org.id, publishedAt: { not: null } }, + select: { + slug: true, + title: true, + status: true, + startsAt: true, + endsAt: true, + publishedAt: true, + prizeVerifiedAt: true, + prizes: { select: { amountKes: true } }, + }, + orderBy: { startsAt: "desc" }, + take: 12, + }), + prisma.trustEvent.findMany({ + where: { orgId: org.id }, + orderBy: { createdAt: "desc" }, + take: 10, + }), + prisma.payout.findMany({ + where: { winner: { event: { orgId: org.id } } }, + select: { + tranche: true, + status: true, + paidAt: true, + winner: { select: { announcedAt: true } }, + }, + }), + prisma.mediaAsset.findMany({ + where: { event: { orgId: org.id }, status: { not: "HIDDEN" } }, + select: { + eventId: true, + uploadedAt: true, + event: { select: { mediaDeadlineAt: true } }, + }, + }), + prisma.prizeBreakdown.aggregate({ + _sum: { amountKes: true }, + where: { event: { orgId: org.id, prizeVerifiedAt: { not: null } } }, + }), + ]); + + const performance = summarizePayoutPerformance( + payouts.map((payout) => ({ + tranche: payout.tranche, + status: payout.status, + paidAt: payout.paidAt, + announcedAt: payout.winner.announcedAt, + })), + ); + const timeliness = summarizeMediaTimeliness( + media.map((asset) => ({ + eventId: asset.eventId, + uploadedAt: asset.uploadedAt, + mediaDeadlineAt: asset.event.mediaDeadlineAt, + })), + ); + + const location = formatOrgLocation(org); + const facts = [isOrgKind(org.kind) ? ORG_KIND_LABELS[org.kind] : null, location].filter(Boolean); + const links = [org.website, org.socialUrl].filter((link): link is string => Boolean(link)); + const verified = org.kycStatus === "VERIFIED"; + + const stats = [ + { label: "Hackathons hosted", value: String(events.length) }, + { label: "Prizes escrowed", value: formatKes(escrowed._sum.amountKes ?? 0) }, + { label: "On HackVillage since", value: joinedFormat.format(org.createdAt) }, + ]; + + return ( + <> + 0 ? { sameAs: links } : {}), + ...(location + ? { + address: { + "@type": "PostalAddress", + ...(org.city ? { addressLocality: org.city } : {}), + addressCountry: org.country ?? "KE", + }, + } + : {}), + }} + /> +
+
+
+

{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 ? ( + + No payouts yet. The first winners paid will start this record. + + ) : ( + <> +

+ {performance.instantSucceeded} of {performance.instantTotal} +

+ + instant-tranche payouts confirmed. Winners are promised 50% of their prize + within an hour of results. + + + )} +
+ + + Announce→Paid + + {performance.medianInstantMinutes === null ? ( + + No confirmed instant payouts yet, so there is no median time to report. + + ) : ( + <> +

+ {formatMedianMinutes(performance.medianInstantMinutes)} +

+ + median time from winners announced to the instant payout landing. + + + )} +
+ + + Media Delivery + + {timeliness.eventsWithMedia === 0 ? ( + + No event media yet. Event photos delivered within 48 hours start this record. + + ) : ( + <> +

+ {timeliness.onTime} of {timeliness.eventsWithMedia} +

+ + events delivered media inside the 48-hour standard. + + + )} +
+
+
+ +
+

+ 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 ? ( + + {event.title} + + ) : ( +

    + {event.title} +

    + )} +

    + {formatEventDates(event.startsAt, event.endsAt)} ·{" "} + {formatKes(poolKes)} prize pool +

    +
    +
    + {event.prizeVerifiedAt ? ( + + Prize Verified + + ) : ( + {event.status.toLowerCase()} + )} +
    +
    +
    +
  • + ); + })} +
+ )} +
+ +
+

+ Trust History +

+ + {trustEvents.length === 0 ? ( + + No trust events yet. The score moves with payout speed, media delivery and + milestone honesty; a quiet history is a good history. + + ) : ( +
    + {trustEvents.map((event) => ( +
  • +
    +

    + {TRUST_EVENT_LABELS[event.type]} +

    +

    + {dateFormat.format(event.createdAt)} + {event.type !== "MANUAL_ADJUST" && event.reason + ? ` · ${event.reason}` + : ""} +

    +
    + + {event.delta > 0 ? `+${event.delta}` : event.delta} points + +
  • + ))} +
+ )} +
+

+ Trust scores start at 100 and move with payout speed, media delivery and milestone + honesty. Every lock and payout by this organizer is on the{" "} + + public ledger + + . +

+
+
+
+ + ); +} diff --git a/app/(marketing)/organizers/[slug]/performance.ts b/app/(marketing)/organizers/[slug]/performance.ts new file mode 100644 index 0000000..11095f2 --- /dev/null +++ b/app/(marketing)/organizers/[slug]/performance.ts @@ -0,0 +1,88 @@ +import type { PayoutStatus, PayoutTranche } from "@prisma/client"; + +/** + * Pure math for the /organizers/[slug] payout-performance section, colocated + * with the route so it stays unit-testable (Next.js page modules may only + * export route entrypoints). Every metric degrades to an explicit empty + * state rather than a misleading zero — an organizer with no payouts yet has + * a clean record, not a failed one. + */ + +export interface PayoutRecord { + tranche: PayoutTranche; + status: PayoutStatus; + paidAt: Date | null; + /** The parent winner's announcedAt — the start of the payout clock. */ + announcedAt: Date; +} + +export interface PayoutPerformance { + /** All instant-tranche payouts ever attempted for the organizer's events. */ + instantTotal: number; + instantSucceeded: number; + /** Median minutes from winners announced to instant payout confirmed. */ + medianInstantMinutes: number | null; +} + +/** Median of a numeric sample; null when empty (never a fake zero). */ +export function median(values: number[]): number | null { + if (values.length === 0) return null; + const sorted = [...values].sort((a, b) => a - b); + const middle = Math.floor(sorted.length / 2); + return sorted.length % 2 === 0 + ? (sorted[middle - 1] + sorted[middle]) / 2 + : sorted[middle]; +} + +export function summarizePayoutPerformance(records: PayoutRecord[]): PayoutPerformance { + const instant = records.filter((record) => record.tranche === "INSTANT"); + const succeeded = instant.filter((record) => record.status === "SUCCEEDED"); + // Negative durations mean bad clock data upstream; exclude them rather + // than silently dragging the median down. + const paid = succeeded.filter( + (record): record is PayoutRecord & { paidAt: Date } => record.paidAt !== null, + ); + const minutes = paid + .map((record) => (record.paidAt.getTime() - record.announcedAt.getTime()) / 60_000) + .filter((value) => value >= 0); + return { + instantTotal: instant.length, + instantSucceeded: succeeded.length, + medianInstantMinutes: median(minutes), + }; +} + +export interface MediaRecord { + eventId: string; + uploadedAt: Date; + /** The event's mediaDeadlineAt (endsAt + 48h); null means no deadline was set. */ + mediaDeadlineAt: Date | null; +} + +export interface MediaTimeliness { + /** Events with a media deadline and at least one visible upload. */ + eventsWithMedia: number; + /** Of those, events whose first upload beat the 48-hour deadline. */ + onTime: number; +} + +export function summarizeMediaTimeliness(records: MediaRecord[]): MediaTimeliness { + const firstUploadByEvent = new Map(); + for (const record of records) { + const current = firstUploadByEvent.get(record.eventId); + if (!current || record.uploadedAt.getTime() < current.uploadedAt.getTime()) { + firstUploadByEvent.set(record.eventId, { + uploadedAt: record.uploadedAt, + mediaDeadlineAt: record.mediaDeadlineAt, + }); + } + } + let eventsWithMedia = 0; + let onTime = 0; + for (const first of firstUploadByEvent.values()) { + if (!first.mediaDeadlineAt) continue; + eventsWithMedia += 1; + if (first.uploadedAt.getTime() <= first.mediaDeadlineAt.getTime()) onTime += 1; + } + return { eventsWithMedia, onTime }; +} diff --git a/app/(marketing)/trust/ledger.ts b/app/(marketing)/trust/ledger.ts new file mode 100644 index 0000000..067391f --- /dev/null +++ b/app/(marketing)/trust/ledger.ts @@ -0,0 +1,45 @@ +import type { LedgerEntryType, Prisma } from "@prisma/client"; + +/** + * Pure display logic for the /trust public ledger page, colocated with the + * route (Next.js page modules may only export route entrypoints, so testable + * logic lives here — see tests/unit/public-trust-pages.test.ts). + */ + +/** Human labels for the on-chain attestation types (plan §11.3). */ +export const LEDGER_TYPE_LABELS: Record = { + VAULT_CREATED: "Vault created", + DEPOSIT_LOCKED: "Prize pool locked", + INSTANT_PAYOUT: "Instant payout", + MILESTONE_PAYOUT: "Milestone payout", + VAULT_REFUNDED: "Vault refunded", +}; + +/** + * Ledger payloads are JSON written by the escrow/payout services: amountKes + * is present on every type today, but the page must survive older rows and + * future shapes — so extraction is defensive, never a cast. + */ +export function payloadAmountKes(payload: Prisma.JsonValue): number | null { + if (payload && typeof payload === "object" && !Array.isArray(payload)) { + const value = (payload as Record).amountKes; + if (typeof value === "number" && Number.isFinite(value)) return value; + } + return null; +} + +/** The most human-readable reference in a payload: transfer ref, payment ref, refund ref or contract. */ +export function payloadReference(payload: Prisma.JsonValue): string | null { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null; + const record = payload as Record; + for (const key of ["txRef", "paystackRef", "refundRef", "contractAddress"] as const) { + const value = record[key]; + if (typeof value === "string" && value.length > 0) return value; + } + return null; +} + +/** "0x1234…abcd" — enough to recognise a hash, short enough for a table row. */ +export function shortenHash(hash: string): string { + return hash.length > 12 ? `${hash.slice(0, 6)}…${hash.slice(-4)}` : hash; +} diff --git a/app/(marketing)/trust/loading.tsx b/app/(marketing)/trust/loading.tsx new file mode 100644 index 0000000..a586993 --- /dev/null +++ b/app/(marketing)/trust/loading.tsx @@ -0,0 +1,20 @@ +import { Skeleton, SkeletonCard } from "@/components/ui/skeleton"; + +export default function Loading() { + return ( +
+
+ + + +
+
+ {[0, 1, 2].map((i) => ( + + ))} +
+ + +
+ ); +} diff --git a/app/(marketing)/trust/page.tsx b/app/(marketing)/trust/page.tsx new file mode 100644 index 0000000..5426bdb --- /dev/null +++ b/app/(marketing)/trust/page.tsx @@ -0,0 +1,281 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { ExternalLink, ScrollText, ShieldCheck } from "lucide-react"; +import type { Prisma, VaultChainState } from "@prisma/client"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardDescription, CardTitle } from "@/components/ui/card"; +import { EmptyState } from "@/components/ui/empty-state"; +import { prisma } from "@/lib/db"; +import { getEnv } from "@/lib/env"; +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"; + +export const metadata: Metadata = { + title: "The Public Ledger: Every Prize Lock And Payout, Verifiable", + description: + "Every hackathon prize pool locked, paid out or refunded on HackVillage is recorded on a public ledger. Verify each entry yourself — radical transparency is the product.", + alternates: { canonical: "/trust" }, + openGraph: pageOpenGraph("/trust"), +}; + +const VAULT_STATE_LABELS: Record = { + AWAITING: "Awaiting deposit", + LOCKED: "Locked in escrow", + HALF_RELEASED: "50% paid out", + SETTLED: "Fully settled", + REFUNDED: "Refunded", +}; + +const timestampFormat = new Intl.DateTimeFormat("en-KE", { + day: "numeric", + month: "short", + year: "numeric", + hour: "numeric", + minute: "2-digit", + timeZone: "Africa/Nairobi", +}); + +interface PageProps { + searchParams: Promise<{ event?: string }>; +} + +export default async function TrustPage({ searchParams }: PageProps) { + const { event: eventFilter } = await searchParams; + const chainMode = getChainPort().mode; + const polygonscanUrl = getEnv().POLYGONSCAN_URL; + const onChain = chainMode !== "simulation"; + + const eventWhere: Prisma.EventWhereInput | undefined = eventFilter + ? { slug: eventFilter } + : undefined; + + const [entries, vaults, filterEvents] = await Promise.all([ + prisma.ledgerEntry.findMany({ + where: eventWhere ? { event: eventWhere } : undefined, + include: { event: { select: { title: true, slug: true } } }, + orderBy: { mirroredAt: "desc" }, + take: 100, + }), + prisma.vaultState.findMany({ + where: eventWhere ? { event: eventWhere } : undefined, + include: { event: { select: { title: true, slug: true } } }, + orderBy: { updatedAt: "desc" }, + }), + // Filter chips always list every event that has a ledger, so switching + // away from the current filter is one click. + prisma.event.findMany({ + where: { ledgerEntries: { some: {} } }, + select: { slug: true, title: true }, + orderBy: { startsAt: "desc" }, + }), + ]); + + 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} + + {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) + )} +

    + ) : 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)} + + {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/admin/hackathons/page.tsx b/app/admin/hackathons/page.tsx index f447a8b..1903e92 100644 --- a/app/admin/hackathons/page.tsx +++ b/app/admin/hackathons/page.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import Link from "next/link"; +import { AdminEventCancelForm } from "@/components/admin/event-cancel-form"; import { Badge } from "@/components/ui/badge"; import { Card, CardTitle } from "@/components/ui/card"; import { prisma } from "@/lib/db"; @@ -51,6 +52,11 @@ export default async function AdminEventsPage() { > {STATUS_LABELS[event.status]} + {["DRAFT", "PENDING_DEPOSIT", "LIVE", "IN_PROGRESS", "JUDGING"].includes( + event.status + ) ? ( + + ) : null} ))} diff --git a/app/admin/loading.tsx b/app/admin/loading.tsx new file mode 100644 index 0000000..ae8e096 --- /dev/null +++ b/app/admin/loading.tsx @@ -0,0 +1,20 @@ +import { Skeleton, SkeletonCard } from "@/components/ui/skeleton"; + +export default function Loading() { + return ( +
+ +
+ {[0, 1, 2, 3].map((i) => ( + + ))} +
+
+ {[0, 1, 2].map((i) => ( + + ))} +
+ +
+ ); +} diff --git a/app/admin/payments/page.tsx b/app/admin/payments/page.tsx index 6b757b5..5fa6979 100644 --- a/app/admin/payments/page.tsx +++ b/app/admin/payments/page.tsx @@ -83,7 +83,7 @@ export default async function AdminPaymentsPage() { - + ))} diff --git a/app/admin/users/page.tsx b/app/admin/users/page.tsx index 60f483a..57d2ef3 100644 --- a/app/admin/users/page.tsx +++ b/app/admin/users/page.tsx @@ -59,26 +59,39 @@ export default async function AdminUsersPage({ Results ({users.length}) -
    - {users.map((user) => ( -
  • - - {user.name ?? `@${user.handle}`}{" "} - @{user.handle} - {user.email} - - - {user.deletedAt ? deleted : null} - - {user.emailVerified ? "verified" : "unverified"} - - {user.roleGrants.map((grant) => ( - {grant.role.toLowerCase()} - ))} - -
  • - ))} -
+ {users.length === 0 ? ( +

+ {q ? ( + <> + No users match “{q}”. Try a + different name, handle, or email. + + ) : ( + "No users yet." + )} +

+ ) : ( +
    + {users.map((user) => ( +
  • + + {user.name ?? `@${user.handle}`}{" "} + @{user.handle} + {user.email} + + + {user.deletedAt ? deleted : null} + + {user.emailVerified ? "verified" : "unverified"} + + {user.roleGrants.map((grant) => ( + {grant.role.toLowerCase()} + ))} + +
  • + ))} +
+ )}
); diff --git a/app/api/dev/covers/upload/route.ts b/app/api/dev/covers/upload/route.ts index 4f4e1e6..29599a1 100644 --- a/app/api/dev/covers/upload/route.ts +++ b/app/api/dev/covers/upload/route.ts @@ -1,4 +1,4 @@ -import { requireUser } from "@/lib/auth/guards"; +import { currentUser } from "@/lib/auth/guards"; import { authorizeCoverEdit, CoverError } from "@/lib/events/cover-service"; import { isCoverKeyFor, validateCoverUpload } from "@/lib/events/cover-upload"; @@ -6,16 +6,18 @@ export const dynamic = "force-dynamic"; /** * DEV MODE cover upload (storage port in local mode): receives the processed - * cover and saves it under public/uploads. 404 whenever R2 is configured, so - * there is no dev path in production. + * cover and saves it under public/uploads. 404 in production and whenever R2 + * is configured, so there is no dev path in production. */ export async function POST(request: Request) { + if (process.env.NODE_ENV === "production") { + return new Response("Not Found", { status: 404 }); + } const storage = (await import("@/lib/ports/storage")).getStoragePort(); if (storage.mode !== "local") { return new Response("Not Found", { status: 404 }); } - const user = await requireUser(); const key = new URL(request.url).searchParams.get("key") ?? ""; const eventId = key.split("/")[1] ?? ""; if (!isCoverKeyFor(eventId, key)) { @@ -27,8 +29,13 @@ export async function POST(request: Request) { const problem = validateCoverUpload(contentType, body.length); if (problem) return Response.json({ error: problem }, { status: 400 }); + const user = await currentUser(); + if (!user) return Response.json({ error: "Sign in first." }, { status: 401 }); + try { + // Only organization owners/admins of the target event may write its cover. await authorizeCoverEdit(user.id, eventId); + // saveLocalAt joins under public/uploads and refuses escaping keys. const url = await storage.saveLocalAt(key, body); return Response.json({ ok: true, url }); } catch (error) { diff --git a/app/api/dev/media/upload/[key]/route.ts b/app/api/dev/media/upload/[key]/route.ts index 420dc2f..4e4942d 100644 --- a/app/api/dev/media/upload/[key]/route.ts +++ b/app/api/dev/media/upload/[key]/route.ts @@ -1,47 +1,57 @@ -import { registerMediaAsset } from "@/services/media/service"; -import { requireUser } from "@/lib/auth/guards"; +import { currentUser } from "@/lib/auth/guards"; +import { validateMediaUpload } from "@/lib/ports/storage"; +import { MediaError, requireOrgAdmin } from "@/services/media/service"; export const dynamic = "force-dynamic"; +/** keys are issued as events//- — anything else is rejected. */ +const MEDIA_KEY_PATTERN = /^events\/([a-z0-9]+)\/[a-zA-Z0-9._-]+$/; + /** - * DEV MODE media upload (storage port in local mode): receives the file - * directly and registers the asset in one step. 404 whenever R2 is - * configured (P4 — no dev paths in production). + * DEV MODE media upload (storage port in local mode): receives the file and + * saves it under public/uploads. The MediaAsset row is NOT created here — + * the client confirms via confirmMediaUploadAction, identical to the R2 + * flow. 404 in production and whenever R2 is configured (P4 — no dev paths + * in production). */ export async function POST( request: Request, { params }: { params: Promise<{ key: string }> } ) { + if (process.env.NODE_ENV === "production") { + return new Response("Not Found", { status: 404 }); + } const storage = (await import("@/lib/ports/storage")).getStoragePort(); if (storage.mode !== "local") { return new Response("Not Found", { status: 404 }); } + // Key shape first — traversal attempts get a cheap 400 before any auth or IO. const { key } = await params; - const user = await requireUser(); + const match = MEDIA_KEY_PATTERN.exec(key); + if (!match || key.includes("..")) { + return Response.json({ error: "Malformed key." }, { status: 400 }); + } + const eventId = match[1]; const contentType = request.headers.get("content-type") ?? "application/octet-stream"; const body = Buffer.from(await request.arrayBuffer()); - if (body.length === 0) { - return Response.json({ error: "Empty upload." }, { status: 400 }); - } + const problem = validateMediaUpload(contentType, body.length); + if (problem) return Response.json({ error: problem }, { status: 400 }); - // The key embeds the event: events// - const eventId = key.split("/")[1]; - if (!eventId) { - return Response.json({ error: "Malformed key." }, { status: 400 }); - } + const user = await currentUser(); + if (!user) return Response.json({ error: "Sign in first." }, { status: 401 }); try { - await storage.saveLocal(key, body); - await registerMediaAsset({ - eventId, - userId: user.id, - key, - kind: contentType.startsWith("video/") ? "VIDEO" : "PHOTO", - }); - return Response.json({ ok: true, url: storage.publicUrlFor(key) }); + // Only organization owners/admins of the target event may write its files. + await requireOrgAdmin(eventId, user.id); + // saveLocalAt joins under public/uploads and refuses escaping keys. + const url = await storage.saveLocalAt(key, body); + return Response.json({ ok: true, url }); } catch (error) { + if (error instanceof MediaError) { + return Response.json({ error: error.message }, { status: 403 }); + } console.error("[media:dev] upload failed", error); return Response.json({ error: "Upload failed." }, { status: 500 }); } diff --git a/app/api/events/[slug]/cancel/route.ts b/app/api/events/[slug]/cancel/route.ts index 7db27b4..c23ea4e 100644 --- a/app/api/events/[slug]/cancel/route.ts +++ b/app/api/events/[slug]/cancel/route.ts @@ -1,12 +1,29 @@ import { NextResponse } from "next/server"; +import { currentUser } from "@/lib/auth/guards"; import { cancelRegistrationAction } from "@/lib/events/registration-actions"; +import { rateLimit } from "@/lib/rate-limit"; +/** + * Authenticated callers are throttled (30 mutations/hour shared across the + * mutation routes); unauthenticated requests fall through to the action, + * which keeps its existing redirect-to-sign-in behavior. + */ export async function POST( _request: Request, { params }: { params: Promise<{ slug: string }> } ) { const { slug } = await params; + const user = await currentUser(); + if (user) { + const limit = await rateLimit(`mutate:${user.id}`, 30, 60 * 60 * 1000); + if (!limit.ok) { + return NextResponse.json( + { error: "Too many attempts. Try again later." }, + { status: 429, headers: { "retry-after": String(limit.retryAfterSeconds) } } + ); + } + } await cancelRegistrationAction(slug); return NextResponse.redirect(new URL("/dashboard/hackathons", _request.url), 303); } diff --git a/app/api/events/[slug]/register/route.ts b/app/api/events/[slug]/register/route.ts index 4d5ccb7..0557a6b 100644 --- a/app/api/events/[slug]/register/route.ts +++ b/app/api/events/[slug]/register/route.ts @@ -1,17 +1,33 @@ import { NextResponse } from "next/server"; +import { currentUser } from "@/lib/auth/guards"; import { registerForEventAction } from "@/lib/events/registration-actions"; +import { rateLimit } from "@/lib/rate-limit"; /** * Plain-form-friendly registration endpoint (P6: no client JS required). * The server action owns the logic; this handler only exists because HTML * forms cannot invoke server actions with dynamic arguments. + * + * Authenticated callers are throttled (30 mutations/hour shared across the + * mutation routes); unauthenticated requests fall through to the action, + * which keeps its existing redirect-to-sign-in behavior. */ export async function POST( _request: Request, { params }: { params: Promise<{ slug: string }> } ) { const { slug } = await params; + const user = await currentUser(); + if (user) { + const limit = await rateLimit(`mutate:${user.id}`, 30, 60 * 60 * 1000); + if (!limit.ok) { + return NextResponse.json( + { error: "Too many attempts. Try again later." }, + { status: 429, headers: { "retry-after": String(limit.retryAfterSeconds) } } + ); + } + } await registerForEventAction(slug); return NextResponse.redirect(new URL(`/hackathons/${slug}/workspace`, _request.url), 303); } diff --git a/app/api/health/route.ts b/app/api/health/route.ts new file mode 100644 index 0000000..9dd983a --- /dev/null +++ b/app/api/health/route.ts @@ -0,0 +1,70 @@ +import { NextResponse } from "next/server"; + +import { prisma } from "@/lib/db"; +import { getChainPort } from "@/lib/ports/chain"; +import { getPaystackPort } from "@/lib/ports/paystack"; + +/** + * Liveness/readiness probe (no auth — uptime monitors and load balancers + * poll it). Reports component status only: no versions, hosts, or secret + * presence beyond the already-public port modes. 200 when the app can serve + * real traffic (database reachable, queue schema present), 503 otherwise. + */ +export const dynamic = "force-dynamic"; + +interface ComponentHealth { + ok: boolean; + db: "ok" | "down"; + queue: "ok" | "down"; + paystackMode: string; + chainMode: string; + uptime: number; +} + +async function databaseOk(): Promise { + try { + await prisma.$queryRaw`SELECT 1`; + return true; + } catch { + return false; + } +} + +/** pg-boss creates its schema on first start; without it no job will run. */ +async function queueOk(): Promise { + try { + // ::text — Prisma cannot deserialize the regclass type from to_regclass. + const rows = await prisma.$queryRaw>` + SELECT to_regclass('pgboss.job')::text AS name + `; + return rows[0]?.name != null; + } catch { + return false; + } +} + +export async function GET(): Promise> { + const [db, queue] = await Promise.all([databaseOk(), queueOk()]); + + // Port getters never reach the network (simulation fallbacks are in-process); + // if env validation itself throws, the whole app is down — report honestly. + let paystackMode = "unknown"; + let chainMode = "unknown"; + try { + paystackMode = getPaystackPort().mode; + chainMode = getChainPort().mode; + } catch { + // Modes are informational; db/queue decide the status code. + } + + const ok = db && queue; + const body: ComponentHealth = { + ok, + db: db ? "ok" : "down", + queue: queue ? "ok" : "down", + paystackMode, + chainMode, + uptime: Math.floor(process.uptime()), + }; + return NextResponse.json(body, { status: ok ? 200 : 503 }); +} diff --git a/app/api/judge/invites/[assignmentId]/route.ts b/app/api/judge/invites/[assignmentId]/route.ts index ca8020c..5b79973 100644 --- a/app/api/judge/invites/[assignmentId]/route.ts +++ b/app/api/judge/invites/[assignmentId]/route.ts @@ -1,8 +1,15 @@ import { NextResponse } from "next/server"; +import { currentUser } from "@/lib/auth/guards"; +import { rateLimit } from "@/lib/rate-limit"; import { respondToJudgeInviteAction } from "@/services/judging/actions"; -/** Plain-form judge invite responses (no client JS required — P6). */ +/** + * Plain-form judge invite responses (no client JS required — P6). + * Authenticated callers are throttled (30 mutations/hour shared across the + * mutation routes); unauthenticated requests fall through to the action, + * which keeps its existing redirect-to-sign-in behavior. + */ export async function POST( request: Request, { params }: { params: Promise<{ assignmentId: string }> } @@ -13,6 +20,16 @@ export async function POST( if (decision !== "accept" && decision !== "decline") { return NextResponse.redirect(new URL("/judge", request.url), 303); } + const user = await currentUser(); + if (user) { + const limit = await rateLimit(`mutate:${user.id}`, 30, 60 * 60 * 1000); + if (!limit.ok) { + return NextResponse.json( + { error: "Too many attempts. Try again later." }, + { status: 429, headers: { "retry-after": String(limit.retryAfterSeconds) } } + ); + } + } await respondToJudgeInviteAction(assignmentId, decision === "accept"); return NextResponse.redirect(new URL("/judge", request.url), 303); } diff --git a/app/error.tsx b/app/error.tsx index 567a311..1d2a21f 100644 --- a/app/error.tsx +++ b/app/error.tsx @@ -13,7 +13,13 @@ export default function Error({ reset: () => void; }) { useEffect(() => { - // Sentry wiring lands in Phase 9 (plan §16); console for now. + // Report to Sentry only when a public DSN is configured; the SDK is + // loaded lazily so error reporting never blocks the error UI itself. + if (process.env.NEXT_PUBLIC_SENTRY_DSN) { + void import("@sentry/nextjs") + .then((Sentry) => Sentry.captureException(error)) + .catch(() => undefined); + } console.error("[app-error]", error); }, [error]); diff --git a/app/global-error.tsx b/app/global-error.tsx index 212c3f9..972a471 100644 --- a/app/global-error.tsx +++ b/app/global-error.tsx @@ -15,6 +15,13 @@ export default function GlobalError({ reset: () => void; }) { useEffect(() => { + // Report to Sentry only when a public DSN is configured; the SDK is + // loaded lazily so error reporting never blocks the error UI itself. + if (process.env.NEXT_PUBLIC_SENTRY_DSN) { + void import("@sentry/nextjs") + .then((Sentry) => Sentry.captureException(error)) + .catch(() => undefined); + } console.error("[global-error]", error); }, [error]); diff --git a/app/llms.txt/route.ts b/app/llms.txt/route.ts index 737a081..0d74261 100644 --- a/app/llms.txt/route.ts +++ b/app/llms.txt/route.ts @@ -75,9 +75,11 @@ HackVillage ("Hack Village") is open-source infrastructure for high-impact hacka - [How it works](${appUrl("/how-it-works")}): the full lifecycle: escrowed prize, team formation, structured judging, instant payouts. - [How it works for organizers](${appUrl("/for-organizers")}): hosting a hackathon step by step: setup, verification, the 5% fee, funding the prize vault, judging, payouts, milestones and the trust score. - [How escrow works](${appUrl("/how-escrow-works")}): exactly how prize money is deposited, locked, held and paid, step by step. +- [Public ledger](${appUrl("/trust")}): every vault lock, payout and refund recorded entry by entry, with the per-hackathon vault state. - [Blog](${appUrl("/blog")}): guides and stories on escrow, payouts, judging and building hackathons builders trust. - [Contribute](${appUrl("/contribute")}): how to contribute to the open-source platform. - Developer profiles: every builder who takes part has a public Proof-of-Work profile at ${appUrl("/developers/")}. +- Organizer trust pages: every organizer has a public record at ${appUrl("/organizers/")}: verification status, trust score, payout performance and hosted hackathons. ## Current hackathons diff --git a/app/sitemap.ts b/app/sitemap.ts index 0424adc..1b2ab57 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -34,6 +34,8 @@ export default async function sitemap(): Promise { images: imagesIn("hero/kenya", "journey", "trust"), }, { url: appUrl("/hackathons"), changeFrequency: "daily", priority: 0.9 }, + // The public ledger is the trust pillar — crawlers should find it easily. + { url: appUrl("/trust"), changeFrequency: "hourly", priority: 0.8 }, { url: appUrl("/how-it-works"), changeFrequency: "weekly", @@ -72,7 +74,7 @@ export default async function sitemap(): Promise { // unreachable during a build, static and blog entries still ship and the // rest return at the next regeneration. try { - const [events, developers] = await Promise.all([ + const [events, developers, organizers] = await Promise.all([ prisma.event.findMany({ // Demo hackathons are noindex, so they don't belong in the sitemap. where: { ...PUBLIC_HACKATHON_WHERE, isDemo: false }, @@ -83,6 +85,12 @@ export default async function sitemap(): Promise { where: INDEXABLE_DEVELOPER_WHERE, select: { handle: true, email: true, updatedAt: true }, }), + // Public organizer trust pages: only organizations with at least one + // published hackathon earn a sitemap entry, matching page prominence. + prisma.organization.findMany({ + where: { events: { some: { publishedAt: { not: null } } } }, + select: { slug: true, updatedAt: true }, + }), ]); for (const event of events) { entries.push({ @@ -106,6 +114,14 @@ export default async function sitemap(): Promise { priority: 0.5, }); } + for (const organizer of organizers) { + entries.push({ + url: appUrl(`/organizers/${organizer.slug}`), + lastModified: organizer.updatedAt, + changeFrequency: "weekly", + priority: 0.6, + }); + } } catch (error) { // Keep the sitemap useful with what we have, but say why it's short. console.error("[sitemap] database sections skipped", error); diff --git a/components/admin/event-cancel-form.tsx b/components/admin/event-cancel-form.tsx new file mode 100644 index 0000000..7d1ea89 --- /dev/null +++ b/components/admin/event-cancel-form.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { Ban } from "lucide-react"; + +import { ConfirmMoneyAction } from "@/components/patterns/confirm-money-action"; +import { Input, Label } from "@/components/ui/input"; +import { adminCancelEventAction } from "@/lib/admin/event-actions"; + +/** + * Admin cancels an event with a reason (the organizer sees it). Calls Agent + * C's contract `adminCancelEventAction(eventId, reason)` from + * lib/admin/event-actions.ts. + */ +export function AdminEventCancelForm({ + eventId, + eventTitle, +}: { + eventId: string; + eventTitle: string; +}) { + return ( + + Cancel + + } + triggerVariant="ghost" + triggerSize="sm" + onConfirm={async (formData) => { + const reason = String(formData.get("reason") ?? "").trim(); + if (reason.length < 4) { + return { error: "Give a reason (at least 4 characters) — the organizer sees it." }; + } + return adminCancelEventAction(eventId, reason); + }} + > +
+ + +
+
+ ); +} diff --git a/components/admin/payout-ops-actions.tsx b/components/admin/payout-ops-actions.tsx index 289425e..734108d 100644 --- a/components/admin/payout-ops-actions.tsx +++ b/components/admin/payout-ops-actions.tsx @@ -1,23 +1,22 @@ "use client"; -import { useActionState, useState, useTransition } from "react"; +import { useState, useTransition } from "react"; import { RotateCcw } from "lucide-react"; +import { ConfirmMoneyAction } from "@/components/patterns/confirm-money-action"; import { Button } from "@/components/ui/button"; -import { FormError, FormSuccess, Input } from "@/components/ui/input"; -import { - adminMarkPaidAction, - adminRetryPayoutAction, - type PayoutActionState, -} from "@/services/payout/actions"; +import { Input, Label } from "@/components/ui/input"; +import { adminMarkPaidAction, adminRetryPayoutAction } from "@/services/payout/actions"; -export function PayoutOpsActions({ payoutId }: { payoutId: string }) { +export function PayoutOpsActions({ + payoutId, + amountKes, +}: { + payoutId: string; + amountKes: number; +}) { const [retry, startRetry] = useTransition(); const [retryError, setRetryError] = useState(null); - const [markState, markPaid, marking] = useActionState( - adminMarkPaidAction, - {} - ); return (
@@ -43,24 +42,36 @@ export function PayoutOpsActions({ payoutId }: { payoutId: string }) { ) : null}
-
+ { + const receipt = String(formData.get("receipt") ?? "").trim(); + if (receipt.length < 6) { + return { error: "Paste the payment receipt reference (at least 6 characters)." }; + } + return adminMarkPaidAction({}, formData); + }} + > -
+
+
- - - - +
); } diff --git a/components/events/event-wizard.tsx b/components/events/event-wizard.tsx index dc7c34f..2a1ecfd 100644 --- a/components/events/event-wizard.tsx +++ b/components/events/event-wizard.tsx @@ -1,6 +1,6 @@ "use client"; -import { useActionState, useState } from "react"; +import { useActionState, useRef, useState } from "react"; import { ArrowLeft, ArrowRight, Plus, Trash2 } from "lucide-react"; import { Badge } from "@/components/ui/badge"; @@ -9,6 +9,14 @@ import { Card } from "@/components/ui/card"; import { FormError, Input, Label, Textarea } from "@/components/ui/input"; import { HACKATHON_CATEGORIES, MAX_CATEGORIES } from "@/lib/events/categories"; import { COVER_HEIGHT, COVER_WIDTH } from "@/lib/events/cover-upload"; +import { + eventBasicsSchema, + eventProblemSchema, + eventPrizesSchema, + eventSettingsSchema, + eventWizardSchema, + placesAreUnique, +} from "@/lib/events/validation"; import { formatKes } from "@/lib/utils"; import { saveEventAction, type EventActionState } from "@/lib/events/actions"; @@ -38,6 +46,23 @@ interface PrizeRow { const STEPS = ["Basics", "Problem", "Prizes", "Teams", "Review"] as const; +/** Which step a schema field lives on — validation failures jump back to it. */ +const STEP_FOR_FIELD: Record = { + title: 0, + summary: 0, + venueType: 0, + location: 0, + startsAt: 0, + endsAt: 0, + registrationDeadline: 0, + categories: 0, + problemStatement: 1, + rules: 1, + rolesWanted: 1, + prizes: 2, + maxTeams: 3, +}; + function toLocalInput(date?: Date): string { if (!date) return ""; return new Date(date.getTime() - date.getTimezoneOffset() * 60_000) @@ -48,6 +73,8 @@ function toLocalInput(date?: Date): string { export function EventWizard({ defaults, minPoolKes }: { defaults?: WizardDefaults; minPoolKes: number }) { const [state, action, pending] = useActionState(saveEventAction, {}); const [step, setStep] = useState(0); + const [stepError, setStepError] = useState(null); + const formRef = useRef(null); const [venueType, setVenueType] = useState<"PHYSICAL" | "ONLINE" | "HYBRID">( defaults?.venueType ?? "PHYSICAL" ); @@ -82,6 +109,115 @@ export function EventWizard({ defaults, minPoolKes }: { defaults?: WizardDefault const visible = (index: number) => (step === index ? "block" : "hidden"); + /** + * The steps hide instead of unmounting, so the answers persist across + * steps — and the form is noValidate, because a required input inside a + * display:none step can silently block the final submit. Validation runs + * per step (and again on submit) through the same zod schemas the server + * uses. + */ + const fieldValue = (name: string): string => { + const element = formRef.current?.elements.namedItem(name); + return element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement + ? element.value + : ""; + }; + + const completePrizes = () => + prizes + .filter((p) => p.label.trim() && Number(p.amount) > 0) + .map((p) => ({ + place: p.place, + label: p.label.trim(), + amountKes: Number(p.amount), + milestoneRequired: p.milestoneRequired, + })); + + /** First problem on a step, or null when the step passes the server's schema. */ + const validateStep = (index: number): string | null => { + switch (index) { + case 0: { + if (!fieldValue("registrationDeadline") || !fieldValue("startsAt") || !fieldValue("endsAt")) { + return "Set the registration deadline, start, and end dates."; + } + const parsed = eventBasicsSchema.safeParse({ + title: fieldValue("title"), + summary: fieldValue("summary"), + venueType, + location: fieldValue("location"), + startsAt: fieldValue("startsAt"), + endsAt: fieldValue("endsAt"), + registrationDeadline: fieldValue("registrationDeadline"), + categories: categories.join(","), + }); + if (!parsed.success) return parsed.error.issues[0]?.message ?? "Check the basics."; + const { startsAt, endsAt, registrationDeadline } = parsed.data; + if (!(registrationDeadline < startsAt)) { + return "Registration must close before the hackathon starts."; + } + if (!(startsAt < endsAt)) return "The hackathon must end after it starts."; + return null; + } + case 1: { + const parsed = eventProblemSchema.safeParse({ + problemStatement: fieldValue("problemStatement"), + rules: fieldValue("rules") || undefined, + rolesWanted: fieldValue("rolesWanted") || undefined, + }); + return parsed.success ? null : (parsed.error.issues[0]?.message ?? "Check the problem."); + } + case 2: { + const halfFilled = prizes.some( + (p) => Boolean(p.label.trim()) !== Boolean(Number(p.amount) > 0) + ); + if (halfFilled) return "Finish or remove the half-filled prize row: label and KES amount."; + const parsed = eventPrizesSchema.safeParse({ prizes: completePrizes() }); + if (!parsed.success) return parsed.error.issues[0]?.message ?? "Check the prizes."; + if (!placesAreUnique(parsed.data.prizes)) { + return "Prize places must be unique: 1st, 2nd, 3rd…"; + } + return null; + } + case 3: { + const parsed = eventSettingsSchema.safeParse({ maxTeams: fieldValue("maxTeams") }); + return parsed.success ? null : (parsed.error.issues[0]?.message ?? "Check the team settings."); + } + default: + return null; + } + }; + + /** Whole-form check before the real submit; jumps to the failing step. */ + const validateAll = (): { error: string; step: number } | null => { + const parsed = eventWizardSchema.safeParse({ + title: fieldValue("title"), + summary: fieldValue("summary"), + venueType, + location: fieldValue("location"), + startsAt: fieldValue("startsAt"), + endsAt: fieldValue("endsAt"), + registrationDeadline: fieldValue("registrationDeadline"), + categories: categories.join(","), + problemStatement: fieldValue("problemStatement"), + rules: fieldValue("rules") || undefined, + rolesWanted: fieldValue("rolesWanted") || undefined, + prizes: completePrizes(), + maxTeams: fieldValue("maxTeams"), + }); + if (!parsed.success) { + const issue = parsed.error.issues[0]; + const field = String(issue?.path[0] ?? ""); + return { + error: issue?.message ?? "Check the hackathon details and try again.", + step: STEP_FOR_FIELD[field] ?? 0, + }; + } + if (!placesAreUnique(parsed.data.prizes)) { + return { error: "Prize places must be unique: 1st, 2nd, 3rd…", step: 2 }; + } + return null; + }; + return (
{/* Stepper */} @@ -102,24 +238,26 @@ export function EventWizard({ defaults, minPoolKes }: { defaults?: WizardDefault ))} -
+ { + const problem = validateAll(); + if (problem) { + event.preventDefault(); + setStep(problem.step); + setStepError(problem.error); + } else { + setStepError(null); + } + }} + className="space-y-6" + > - p.label.trim() && Number(p.amount) > 0) - .map((p) => ({ - place: p.place, - label: p.label.trim(), - amountKes: Number(p.amount), - milestoneRequired: p.milestoneRequired, - })) - )} - /> + {/* Step 1 — Basics */}
@@ -259,7 +397,7 @@ export function EventWizard({ defaults, minPoolKes }: { defaults?: WizardDefault type="checkbox" checked={prize.milestoneRequired} onChange={(e) => setPrize(index, { milestoneRequired: e.target.checked })} - className="size-3.5 accent-[#222]" + className="size-3.5 accent-ink" /> Milestone required before the final 50% releases @@ -315,19 +453,34 @@ export function EventWizard({ defaults, minPoolKes }: { defaults?: WizardDefault
+
{step < STEPS.length - 1 ? ( - ) : ( diff --git a/components/judging/scoring-screen.tsx b/components/judging/scoring-screen.tsx index 8f3b97a..83af6c3 100644 --- a/components/judging/scoring-screen.tsx +++ b/components/judging/scoring-screen.tsx @@ -49,6 +49,14 @@ export function ScoringScreen({ ); const [finalizeState, setFinalizeState] = useState(null); const [pending, startTransition] = useTransition(); + // Scores the judge explicitly moved this session. Sliders never pre-fill a + // fabricated value: a criterion counts as scored only when it already has a + // saved score or the judge touched its slider. + const [touchedScores, setTouchedScores] = useState>({}); + const valueFor = (criterionId: string): number | undefined => + touchedScores[criterionId] ?? existingScores[criterionId]; + const unscoredCount = criteria.filter((criterion) => valueFor(criterion.id) === undefined).length; + const allScored = unscoredCount === 0; const byKind = (kind: FeedbackKind) => feedback.filter((f) => f.kind === kind); const locked = finalized || !judgingOpen; @@ -60,46 +68,67 @@ export function ScoringScreen({ Rubric Scoring: {teamName} - Whole numbers 0-10 per criterion. Save as often as you like; finalization locks - everything. + Whole numbers 0-10 per criterion. Move every slider to score it, even if you keep the + middle value. Save as often as you like; finalization locks everything.
- {criteria.map((criterion) => ( -
- -
- - - {existingScores[criterion.id] ?? 5} - + {criteria.map((criterion) => { + const value = valueFor(criterion.id); + return ( +
+ +
+ + setTouchedScores((current) => ({ + ...current, + [criterion.id]: Number(event.target.value), + })) + } + disabled={locked} + className="h-2 flex-1 accent-ink" + /> + + {value ?? "–"} + +
-
- ))} + ); + })}
{!locked ? ( - +
+ + {!allScored ? ( +

+ {unscoredCount} {unscoredCount === 1 ? "criterion" : "criteria"} still unscored — + move every slider to enable saving. +

+ ) : null} +
) : null} diff --git a/components/media/media-uploader.tsx b/components/media/media-uploader.tsx index 711525f..5c0d6d8 100644 --- a/components/media/media-uploader.tsx +++ b/components/media/media-uploader.tsx @@ -6,12 +6,17 @@ import { Image as ImageIcon, Upload } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Card, CardDescription, CardTitle } from "@/components/ui/card"; import { FormError } from "@/components/ui/input"; -import { requestUploadUrlAction, type MediaActionState } from "@/services/media/actions"; +import { + confirmMediaUploadAction, + requestUploadUrlAction, + type MediaActionState, +} from "@/services/media/actions"; /** * The media vault uploader: requests the upload target, PUTs the file (or - * POSTs to the dev route locally), then refreshes. Simple, no chunking — - * files are capped at 15MB by the port. + * POSTs to the dev route locally), then CONFIRMS the upload — the MediaAsset + * row only exists once the file is verifiably in storage. Simple, no + * chunking — files are capped at 15MB by the port. */ export function MediaUploader({ eventId }: { eventId: string }) { const [state, action, pending] = useActionState( @@ -19,39 +24,52 @@ export function MediaUploader({ eventId }: { eventId: string }) { {} ); const [uploading, setUploading] = useState(null); + const [error, setError] = useState(null); const formRef = useRef(null); async function handleFile(formData: FormData, file: File) { + setError(null); formData.set("filename", file.name); formData.set("contentType", file.type); formData.set("sizeBytes", String(file.size)); const targetState = await requestUploadUrlAction({}, formData); - if (targetState.error) return; - if (!targetState.uploadUrl) return; + if (targetState.error || !targetState.uploadUrl || !targetState.key) { + // Was previously swallowed — the uploader failed silently. + setError(targetState.error ?? "We couldn't prepare the upload. Try again."); + return; + } setUploading(file.name); try { - if (targetState.message === "dev-upload") { - // Dev mode: POST the raw file to our route. - await fetch(targetState.uploadUrl, { - method: "POST", - headers: { "Content-Type": file.type }, - body: file, - }); - } else { - // R2 mode: PUT directly with the presigned headers. - await fetch(targetState.uploadUrl, { - method: "PUT", - headers: targetState.uploadHeaders ?? { "Content-Type": file.type }, - body: file, - }); + const response = await fetch(targetState.uploadUrl, { + method: targetState.uploadUrl.includes("/api/dev/media/upload/") ? "POST" : "PUT", + headers: targetState.uploadHeaders ?? { "Content-Type": file.type }, + body: file, + }); + if (!response.ok) { + const body = (await response.json().catch(() => null)) as { error?: string } | null; + setError(body?.error ?? "The upload didn't complete. Try again."); + return; + } + + // The row is created only now, after the file verifiably landed. + const confirmed = await confirmMediaUploadAction({ + eventId, + key: targetState.key, + kind: file.type.startsWith("video/") ? "VIDEO" : "PHOTO", + caption: String(formData.get("caption") ?? "") || undefined, + }); + if (confirmed.error) { + setError(confirmed.error); + return; } - // Registration for R2 mode happened in the action; dev mode registers - // in the route. Either way, reload to show the gallery. + window.location.reload(); - } catch (error) { - console.error("[media] upload failed", error); + } catch (uploadError) { + // Was previously a console.error only — the UI gave no signal. + console.error("[media] upload failed", uploadError); + setError("The upload didn't complete. Check your connection and try again."); } finally { setUploading(null); } @@ -99,7 +117,7 @@ export function MediaUploader({ eventId }: { eventId: string }) { required /> - + diff --git a/components/organizer/event-cancel-button.tsx b/components/organizer/event-cancel-button.tsx new file mode 100644 index 0000000..2e2fe4c --- /dev/null +++ b/components/organizer/event-cancel-button.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { Ban } from "lucide-react"; + +import { ConfirmMoneyAction } from "@/components/patterns/confirm-money-action"; +import { cancelEventAction } from "@/lib/events/actions"; + +export function EventCancelButton({ + eventId, + eventTitle, +}: { + eventId: string; + eventTitle: string; +}) { + return ( + + Cancel Event + + } + triggerVariant="ghost" + triggerSize="sm" + onConfirm={() => cancelEventAction(eventId)} + /> + ); +} diff --git a/components/organizer/publish-gate.tsx b/components/organizer/publish-gate.tsx index 34c781b..5d783e0 100644 --- a/components/organizer/publish-gate.tsx +++ b/components/organizer/publish-gate.tsx @@ -23,7 +23,7 @@ export function PublishGate({ eventId }: { eventId: string }) { Publishing makes the event publicly visible as pending deposit. The - event goes live when the prize pool is locked in the Prize Vault (Phase 3). + event goes live when the prize pool is locked in the Prize Vault. {error ? (

diff --git a/components/patterns/app-nav-link.tsx b/components/patterns/app-nav-link.tsx new file mode 100644 index 0000000..eb2e16b --- /dev/null +++ b/components/patterns/app-nav-link.tsx @@ -0,0 +1,33 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import type { ReactNode } from "react"; + +import { isActivePath } from "@/lib/nav"; +import { cn } from "@/lib/utils"; + +/** + * App chrome nav link: marks itself aria-current="page" when it points at the + * page being viewed, and the active styling keys off that state so what you + * see and what screen readers announce can never disagree. /dashboard matches + * exactly (its siblings live under it); other links also match sub-pages. + */ +export function AppNavLink({ href, children }: { href: string; children: ReactNode }) { + const pathname = usePathname(); + const active = href === "/dashboard" ? pathname === href : isActivePath(pathname, href); + return ( + + {children} + + ); +} diff --git a/components/patterns/app-nav.tsx b/components/patterns/app-nav.tsx index d39f213..2afc2bd 100644 --- a/components/patterns/app-nav.tsx +++ b/components/patterns/app-nav.tsx @@ -1,5 +1,6 @@ import Link from "next/link"; +import { AppNavLink } from "@/components/patterns/app-nav-link"; import { UserMenu } from "@/components/patterns/user-menu"; import { ThemeSwitcher } from "@/components/patterns/theme-switcher"; import { cn } from "@/lib/utils"; @@ -54,13 +55,9 @@ export default function AppTopNav({ className="flex min-w-0 items-center gap-1 overflow-x-auto py-2 sm:gap-4" > {links.map((link) => ( - + {link.label} - + ))} diff --git a/components/patterns/confirm-money-action.tsx b/components/patterns/confirm-money-action.tsx new file mode 100644 index 0000000..89104ac --- /dev/null +++ b/components/patterns/confirm-money-action.tsx @@ -0,0 +1,178 @@ +"use client"; + +import { + useEffect, + useRef, + useState, + useTransition, + type FormEvent, + type ReactNode, +} from "react"; + +import { Button } from "@/components/ui/button"; +import { FormError, FormSuccess, Input, Label } from "@/components/ui/input"; +import { formatKes } from "@/lib/utils"; + +/** Money-movement typed confirmation threshold (plan §8.2). */ +export const MONEY_CONFIRM_THRESHOLD_KES = 250_000; + +export interface ConfirmMoneyResult { + error?: string; + message?: string; +} + +/** Above the threshold the user must type the confirm word; at or below, a plain confirm suffices. */ +export function typedConfirmRequired( + amountKes: number, + thresholdKes: number = MONEY_CONFIRM_THRESHOLD_KES +): boolean { + return amountKes > thresholdKes; +} + +/** + * The money-action confirm gate (plan §8.2): every action that moves money + * passes through a confirmation dialog, and above the KES 250k threshold the + * user types a confirm word. The server still re-verifies everything — this + * is the human tripwire, not the enforcement. + * + * Renders the trigger button; the dialog carries any extra fields (children) + * and submits them as FormData to onConfirm. + */ +export function ConfirmMoneyAction({ + amountKes, + thresholdKes = MONEY_CONFIRM_THRESHOLD_KES, + confirmWord = "CONFIRM", + title, + description, + confirmLabel = "Confirm", + triggerLabel, + triggerVariant = "primary", + triggerSize = "md", + triggerDisabled = false, + className, + children, + onConfirm, +}: { + /** Money at stake in KES; 0 keeps the dialog but never asks for typed confirmation. */ + amountKes: number; + thresholdKes?: number; + confirmWord?: string; + title: string; + description?: ReactNode; + confirmLabel?: string; + triggerLabel: ReactNode; + triggerVariant?: "primary" | "secondary" | "ghost" | "danger"; + triggerSize?: "sm" | "md" | "lg"; + triggerDisabled?: boolean; + className?: string; + children?: ReactNode; + onConfirm: (formData: FormData) => Promise; +}) { + const [open, setOpen] = useState(false); + const [typed, setTyped] = useState(""); + const [result, setResult] = useState({}); + const [pending, startTransition] = useTransition(); + const formRef = useRef(null); + + const needsTyping = typedConfirmRequired(amountKes, thresholdKes); + const typedOk = !needsTyping || typed === confirmWord; + + useEffect(() => { + if (!open) return; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") setOpen(false); + }; + document.addEventListener("keydown", onKeyDown); + return () => document.removeEventListener("keydown", onKeyDown); + }, [open]); + + const openDialog = () => { + setTyped(""); + setResult({}); + setOpen(true); + }; + + const submit = (event: FormEvent) => { + event.preventDefault(); + if (!typedOk || !formRef.current) return; + const formData = new FormData(formRef.current); + startTransition(async () => { + const outcome = await onConfirm(formData); + setResult(outcome); + if (!outcome.error) setOpen(false); + }); + }; + + return ( +

+ + {!open && result.message ? : null} + + {open ? ( +
{ + if (event.target === event.currentTarget) setOpen(false); + }} + > +
+

+ {title} +

+ {description ? ( +

{description}

+ ) : null} + {amountKes > 0 ? ( +

{formatKes(amountKes)}

+ ) : null} + +
+ {children} + {needsTyping ? ( +
+ + setTyped(event.target.value.toUpperCase())} + placeholder={confirmWord} + className="font-mono" + autoFocus + autoComplete="off" + /> +
+ ) : null} + + + +
+ + +
+ +
+
+ ) : null} +
+ ); +} diff --git a/components/patterns/event-card.tsx b/components/patterns/event-card.tsx index 691f552..302d075 100644 --- a/components/patterns/event-card.tsx +++ b/components/patterns/event-card.tsx @@ -2,11 +2,12 @@ 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 { registrationOpen, type EventStatus } from "@/lib/events/lifecycle"; +import { isPrizeVerified, registrationOpen, type EventStatus } from "@/lib/events/lifecycle"; import { cn, formatKes } from "@/lib/utils"; export interface EventCardData { @@ -19,6 +20,7 @@ export interface EventCardData { endsAt: Date; registrationDeadline: Date; publishedAt?: Date | null; + prizeVerifiedAt?: Date | string | null; status: EventStatus; poolKes: number; teamCount: number; @@ -48,6 +50,10 @@ export function EventCard({ 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); @@ -82,6 +88,7 @@ export function EventCard({ ))} ) : null} + {verified ? : null}
diff --git a/components/patterns/mobile-nav.tsx b/components/patterns/mobile-nav.tsx index 4749443..e6d5951 100644 --- a/components/patterns/mobile-nav.tsx +++ b/components/patterns/mobile-nav.tsx @@ -6,6 +6,7 @@ import { useEffect, useState } from "react"; import { Menu, X } from "lucide-react"; import { NavLink } from "@/components/patterns/nav-link"; +import { cn } from "@/lib/utils"; interface NavLinkItem { href: string; @@ -71,7 +72,7 @@ export function MobileNav({ navLinks, organizerLinks, trailingLinks }: MobileNav ? createPortal( <>