diff --git a/app/[slug]/page.tsx b/app/[slug]/page.tsx index f42c6e9..54b2284 100644 --- a/app/[slug]/page.tsx +++ b/app/[slug]/page.tsx @@ -20,7 +20,6 @@ export default async function SlugPage({ const topTeam = teams[0] const secondTeam = teams[1] - const mvp = participants[0] return ( <> @@ -31,9 +30,17 @@ export default async function SlugPage({ name={hackathon.name} edition={hackathon.edition} date={hackathon.date} + status={hackathon.status} + votingOpen={hackathon.votingOpen ?? false} + criteria={hackathon.criteria} participantCount={participants.length} teamCount={teams.length} criteriaCount={hackathon.criteria.length} + topTeam={ + topTeam + ? { name: topTeam.name, score: topTeam.totalScore ?? 0 } + : null + } /> {/* ── Highlights ─────────────────────────────────────────────── */} @@ -77,7 +84,7 @@ export default async function SlugPage({ {/* 2nd place */} -
+
02 @@ -99,43 +106,6 @@ export default async function SlugPage({
- {/* MVP */} - -
-
- - MVP - -
-

- {mvp?.name} -

-

- Embaixador de maior destaque -

-
-
-
-
-
- -
-
- pontos -
-
-
-
- - % -
-
- presença -
-
-
-
-
diff --git a/app/[slug]/times/[id]/page.tsx b/app/[slug]/times/[id]/page.tsx index 8d1fa0a..3934797 100644 --- a/app/[slug]/times/[id]/page.tsx +++ b/app/[slug]/times/[id]/page.tsx @@ -51,7 +51,9 @@ export default async function SlugTeamDetailPage({ )}

{team.name}

{team.project}

-

{team.description}

+ {team.description && ( +

{team.description}

+ )}
{team.tags.map(tag => ( diff --git a/app/[slug]/votar/page.tsx b/app/[slug]/votar/page.tsx index 555f1d1..6f69f60 100644 --- a/app/[slug]/votar/page.tsx +++ b/app/[slug]/votar/page.tsx @@ -177,6 +177,7 @@ export default function VotarPage() { {children} +} diff --git a/app/admin/hackathons/page.tsx b/app/admin/hackathons/page.tsx new file mode 100644 index 0000000..7413b73 --- /dev/null +++ b/app/admin/hackathons/page.tsx @@ -0,0 +1,334 @@ +'use client' +import { useMemo, useState } from 'react' +import Link from 'next/link' +import { useQuery, useMutation } from 'convex/react' +import { api } from '@/convex/_generated/api' +import type { Doc } from '@/convex/_generated/dataModel' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { Skeleton } from '@/components/ui/skeleton' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from '@/components/ui/dialog' +import { HackathonCreateDialog } from '@/components/admin/hackathon-create-dialog' +import { HackathonEditDialog } from '@/components/admin/hackathon-edit-dialog' +import { + Search, + Plus, + Pencil, + Trash2, + Rocket, + ExternalLink, +} from 'lucide-react' + +function statusBadge(status: Doc<'hackathons'>['status']) { + switch (status) { + case 'live': + return ( + + Em andamento + + ) + case 'upcoming': + return ( + + Em breve + + ) + case 'finished': + return ( + + Finalizado + + ) + } +} + +function votingBadge(open: boolean | undefined) { + return open ? ( + + Aberta + + ) : ( + + Fechada + + ) +} + +export default function AdminHackathonsPage() { + const hackathons = useQuery(api.hackathons.list) + const deleteHackathon = useMutation(api.mutations.deleteHackathon) + + const [search, setSearch] = useState('') + const [showCreate, setShowCreate] = useState(false) + const [editing, setEditing] = useState | null>(null) + const [deleting, setDeleting] = useState | null>(null) + const [deletePending, setDeletePending] = useState(false) + const [deleteError, setDeleteError] = useState(null) + + const filtered = useMemo(() => { + if (!hackathons) return [] + if (!search.trim()) return hackathons + const q = search.toLowerCase() + return hackathons.filter( + (h) => + h.name.toLowerCase().includes(q) || + h.edition.toLowerCase().includes(q) || + h.slug.toLowerCase().includes(q), + ) + }, [hackathons, search]) + + async function confirmDelete() { + if (!deleting) return + setDeletePending(true) + setDeleteError(null) + try { + await deleteHackathon({ id: deleting._id }) + setDeleting(null) + } catch (e) { + setDeleteError(e instanceof Error ? e.message : 'Erro ao deletar') + } finally { + setDeletePending(false) + } + } + + const isLoading = hackathons === undefined + + return ( +
+

Hackathons

+ + {isLoading && ( +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ + + +
+ + +
+
+ ))} +
+ )} + + {hackathons && ( + <> + {/* Action bar */} +
+
+ + setSearch(e.target.value)} + placeholder="Buscar por nome, edição ou slug…" + className="w-full rounded-lg border border-white/[0.12] bg-[#2a2a2b] py-2.5 pl-10 pr-4 text-sm text-white placeholder:text-[#636363] focus:outline-none focus:ring-2 focus:ring-[#9810fa]" + /> +
+ +
+ + {/* Result count */} +

+ {search.trim() + ? `${filtered.length} de ${hackathons.length} hackathons` + : `${hackathons.length} hackathons`} +

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

+ Nenhum hackathon cadastrado +

+

+ Crie a primeira edição do hackathon. +

+ +
+ ) : ( +
+ + + + + + + + + + + + + + {filtered.map((h, i) => ( + + + + + + + + + + ))} + {filtered.length === 0 && hackathons.length > 0 && ( + + + + )} + +
+ Hackathon + + Slug + + Data + + Status + + Critérios + + Votação +
+
{h.name}
+
{h.edition}
+
+ + /{h.slug} + + + {h.date}{statusBadge(h.status)} + {h.criteria.length}{' '} + + critério{h.criteria.length !== 1 ? 's' : ''} + + {votingBadge(h.votingOpen)} +
+ + +
+
+ Nenhum resultado encontrado. +
+
+ )} + + )} + + setShowCreate(false)} /> + + setEditing(null)} + /> + + !o && setDeleting(null)}> + + + Deletar hackathon? + + Esta ação é permanente. Todos os times, participantes, scores e votos + vinculados a{' '} + + {deleting?.name} — {deleting?.edition} + {' '} + serão removidos. + + + + {deleteError &&

{deleteError}

} + +
+ + +
+
+
+
+ ) +} diff --git a/app/not-found.tsx b/app/not-found.tsx new file mode 100644 index 0000000..9348bfb --- /dev/null +++ b/app/not-found.tsx @@ -0,0 +1,60 @@ +import Link from 'next/link' +import { ArrowRight } from 'lucide-react' + +export default function NotFound() { + return ( +
+ {/* Masthead */} +
+
+ Borderless · Hackathon + 404 +
+
+ + {/* Content */} +
+

+ A página que você procura saiu do ar, foi renomeada, ou nunca existiu. +

+ +

+ NOT +
+ + FOUND + . + +

+ +
+
+ Erro + 404 + · + Página não encontrada +
+ + + Voltar ao início + + +
+
+ + {/* Colophon */} +
+
+ Borderless · Hackathon + {new Date().getFullYear()} +
+
+
+ ) +} diff --git a/app/page.tsx b/app/page.tsx index 707578e..1307cbe 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,32 +1,264 @@ import { fetchQuery } from 'convex/nextjs' import { api } from '@/convex/_generated/api' -import { redirect } from 'next/navigation' import Link from 'next/link' +import { FadeUp } from '@/components/animated/fade-up' +import { ArrowRight, ArrowUpRight } from 'lucide-react' + +const STATUS_LABEL: Record = { + live: 'Em andamento', + upcoming: 'Em breve', + finished: 'Encerrada', +} + +function extractYear(date: string): string { + return date.match(/\d{4}/)?.[0] ?? '' +} export default async function HomePage() { const hackathons = await fetchQuery(api.hackathons.list, {}) - if (hackathons.length > 0) redirect(`/${hackathons[0].slug}`) + + if (hackathons.length === 0) { + return + } + + const featured = + hackathons.find((h) => h.votingOpen || h.status === 'live') ?? hackathons[0] + const statusRank: Record = { live: 0, upcoming: 1, finished: 2 } + const archive = [...hackathons].sort((a, b) => { + const ra = statusRank[a.status] ?? 3 + const rb = statusRank[b.status] ?? 3 + if (ra !== rb) return ra - rb + const ya = Number(extractYear(a.date)) || 0 + const yb = Number(extractYear(b.date)) || 0 + return yb - ya + }) + const currentYear = new Date().getFullYear() + + const yearsSpan = (() => { + const years = hackathons + .map((h) => Number(extractYear(h.date))) + .filter((y) => !Number.isNaN(y) && y > 0) + if (years.length === 0) return '' + const min = Math.min(...years) + const max = Math.max(...years) + return min === max ? `${min}` : `${min}–${max}` + })() return ( -
-
-

- Borderless Hackathon -

-

- Nenhum hackathon por aqui ainda -

-

- Ainda não há nenhum hackathon cadastrado. Volte em breve ou entre no painel - administrativo para criar o primeiro. +

+ {/* ═══════════════════════ MASTHEAD ═══════════════════════ */} +
+ {/* Masthead rule — tiny meta row */} +
+ Borderless · Hackathon + {yearsSpan || currentYear} +
+
+ + {/* ═══════════════════════ WORDMARK ═══════════════════════ */} +
+
+ +

+ Todas as edições do hackathon Borderless Coding em um só lugar. +

+
+ + +

+ + BORDERLESS + + + ARCHIVE + . + +

+
+ + +
+
+ + + {String(hackathons.length).padStart(2, '0')} + + edições catalogadas +
+
+
+
+
+ + {/* ═══════════════════ FEATURED / CURRENT EDITION ═══════════════════ */} +
+
+ +
+ + + {featured.votingOpen + ? 'Votação aberta' + : featured.status === 'live' + ? 'Edição atual' + : 'Última edição'} + +
+ + {featured.edition} + +
+ + + + +
+
+

+ {featured.name} +

+

+ {featured.date} + / + {featured.criteria.length} critérios de avaliação +

+
+ +
+ + {featured.votingOpen ? 'Votar agora' : 'Ver edição'} + + +
+
+ +
+
+
+ + {/* ═══════════════════ INDEX / PAST EDITIONS ═══════════════════ */} + {archive.length > 0 && ( +
+ +
+

+ Índice de edições +

+ + {String(archive.length).padStart(2, '0')} {archive.length === 1 ? 'registro' : 'registros'} + +
+
+ +
    + {archive.map((h, i) => { + const year = extractYear(h.date) + return ( + +
  • + + {/* Year — huge, dominant anchor */} + + {year || '—'} + + + {/* Name + edition meta */} +
    +

    + {h.name} +

    +

    + {h.edition} + · + {h.date} + · + + {STATUS_LABEL[h.status] ?? h.status} + +

    +
    + + {/* Arrow */} + + +
  • +
    + ) + })} +
+
+ )} + + {/* ═══════════════════ COLOPHON ═══════════════════ */} +
+
+ Borderless Coding + Embaixadores — {currentYear} +
+
+
+ ) +} + +function EmptyState() { + return ( +
+
+
+ Borderless · Hackathon + {new Date().getFullYear()} +
+
+ +
+

+ Nenhum hackathon catalogado ainda.

- - Acessar admin - -
+ BORDERLESS +
+ ARCHIVE. + +
+ + Criar primeira edição + + +
+
) } diff --git a/components/admin/admin-sidebar.tsx b/components/admin/admin-sidebar.tsx index 7d43e7e..52c69b0 100644 --- a/components/admin/admin-sidebar.tsx +++ b/components/admin/admin-sidebar.tsx @@ -4,11 +4,12 @@ import { usePathname, useRouter } from 'next/navigation' import { logout } from '@/lib/auth' import { motion } from 'framer-motion' import { - LayoutDashboard, Users, Trophy, LogOut + LayoutDashboard, Users, Trophy, LogOut, Rocket } from 'lucide-react' const links = [ { href: '/admin/dashboard', label: 'Dashboard', icon: LayoutDashboard }, + { href: '/admin/hackathons', label: 'Hackathons', icon: Rocket }, { href: '/admin/teams', label: 'Times', icon: Trophy }, { href: '/admin/participants', label: 'Participantes', icon: Users }, ] diff --git a/components/admin/hackathon-create-dialog.tsx b/components/admin/hackathon-create-dialog.tsx new file mode 100644 index 0000000..afa6785 --- /dev/null +++ b/components/admin/hackathon-create-dialog.tsx @@ -0,0 +1,206 @@ +'use client' +import { useState } from 'react' +import { useMutation } from 'convex/react' +import { api } from '@/convex/_generated/api' +import type { Id } from '@/convex/_generated/dataModel' +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { FormField } from './form-field' +import { CustomSelect } from './custom-select' +import { SectionHeader } from './section-header' +import { X } from 'lucide-react' + +const STATUS_OPTIONS = [ + { value: 'upcoming', label: 'Em breve' }, + { value: 'live', label: 'Em andamento' }, + { value: 'finished', label: 'Finalizado' }, +] + +const inputCls = + 'h-10 border-white/10 bg-white/[0.04] text-white placeholder:text-[#4a4a4a] focus:border-[#9810fa]/50 focus:ring-1 focus:ring-[#9810fa]/20' + +function formatDatePtBr(d: Date): string { + return d.toLocaleDateString('pt-BR', { day: 'numeric', month: 'long', year: 'numeric' }) +} + +interface Props { + open: boolean + onClose: () => void + onCreated?: (id: Id<'hackathons'>) => void +} + +export function HackathonCreateDialog({ open, onClose, onCreated }: Props) { + const createHackathon = useMutation(api.mutations.createHackathon) + + const [name, setName] = useState('Borderless Hackathon') + const [edition, setEdition] = useState('') + const [slug, setSlug] = useState('') + const [dateValue, setDateValue] = useState('') + const [status, setStatus] = useState<'upcoming' | 'live' | 'finished'>('upcoming') + const [criteriaInput, setCriteriaInput] = useState('') + const [criteria, setCriteria] = useState(['Inovação', 'Execução', 'Pitch', 'Impacto']) + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + + const dateString = dateValue ? formatDatePtBr(new Date(dateValue + 'T12:00:00')) : '' + + function addCriterion() { + const trimmed = criteriaInput.trim() + if (trimmed && !criteria.includes(trimmed)) { + setCriteria((prev) => [...prev, trimmed]) + setCriteriaInput('') + } + } + + function removeCriterion(c: string) { + setCriteria((prev) => prev.filter((x) => x !== c)) + } + + async function handleCreate() { + if (!name.trim() || !edition.trim() || !slug.trim() || !dateString || criteria.length === 0) return + setSaving(true) + setError(null) + try { + const id = await createHackathon({ + name: name.trim(), + edition: edition.trim(), + slug: slug.trim(), + date: dateString, + status, + criteria, + }) + onCreated?.(id) + onClose() + } catch (e) { + setError(e instanceof Error ? e.message : 'Erro ao criar') + } finally { + setSaving(false) + } + } + + const isValid = name.trim() && edition.trim() && slug.trim() && dateString && criteria.length > 0 + + return ( + !o && onClose()}> + + + Nova Edição + + Configure uma nova edição do hackathon. + + + +
+ + setName(e.target.value)} + placeholder="Borderless Hackathon" + className={inputCls} + /> + + +
+ + setEdition(e.target.value)} + placeholder="2026 — 2ª Edição" + className={inputCls} + /> + + + + setSlug(e.target.value)} + placeholder="borderless-2026-2" + className={inputCls} + /> + +
+ +
+ + setDateValue(e.target.value)} + className="h-10 w-full rounded-md border border-white/10 bg-white/[0.04] px-3 text-sm text-white focus:border-[#9810fa]/50 focus:ring-1 focus:ring-[#9810fa]/20 focus:outline-none [color-scheme:dark]" + /> + + + + setStatus(v as typeof status)} + options={STATUS_OPTIONS} + /> + +
+ +
+ +
+ {criteria.map((c) => ( + + {c} + + + ))} +
+
+ setCriteriaInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + addCriterion() + } + }} + placeholder="Novo critério…" + className={inputCls} + /> + +
+
+ + {error &&

{error}

} + +
+ + +
+
+
+
+ ) +} diff --git a/components/admin/hackathon-edit-dialog.tsx b/components/admin/hackathon-edit-dialog.tsx new file mode 100644 index 0000000..7d3e29c --- /dev/null +++ b/components/admin/hackathon-edit-dialog.tsx @@ -0,0 +1,194 @@ +'use client' +import { useEffect, useState } from 'react' +import { useMutation } from 'convex/react' +import { api } from '@/convex/_generated/api' +import type { Doc } from '@/convex/_generated/dataModel' +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { FormField } from './form-field' +import { CustomSelect } from './custom-select' +import { SectionHeader } from './section-header' +import { X } from 'lucide-react' + +const STATUS_OPTIONS = [ + { value: 'upcoming', label: 'Em breve' }, + { value: 'live', label: 'Em andamento' }, + { value: 'finished', label: 'Finalizado' }, +] + +const inputCls = + 'h-10 border-white/10 bg-white/[0.04] text-white placeholder:text-[#4a4a4a] focus:border-[#9810fa]/50 focus:ring-1 focus:ring-[#9810fa]/20' + +interface Props { + open: boolean + hackathon: Doc<'hackathons'> | null + onClose: () => void +} + +export function HackathonEditDialog({ open, hackathon, onClose }: Props) { + const updateHackathon = useMutation(api.mutations.updateHackathon) + + const [name, setName] = useState('') + const [edition, setEdition] = useState('') + const [slug, setSlug] = useState('') + const [date, setDate] = useState('') + const [status, setStatus] = useState<'upcoming' | 'live' | 'finished'>('upcoming') + const [criteriaInput, setCriteriaInput] = useState('') + const [criteria, setCriteria] = useState([]) + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + + useEffect(() => { + if (hackathon && open) { + setName(hackathon.name) + setEdition(hackathon.edition) + setSlug(hackathon.slug) + setDate(hackathon.date) + setStatus(hackathon.status) + setCriteria(hackathon.criteria) + setCriteriaInput('') + setError(null) + } + }, [hackathon, open]) + + function addCriterion() { + const trimmed = criteriaInput.trim() + if (trimmed && !criteria.includes(trimmed)) { + setCriteria((prev) => [...prev, trimmed]) + setCriteriaInput('') + } + } + + function removeCriterion(c: string) { + setCriteria((prev) => prev.filter((x) => x !== c)) + } + + const isValid = + name.trim() && edition.trim() && slug.trim() && date.trim() && criteria.length > 0 + + async function handleSave() { + if (!hackathon || !isValid) return + setSaving(true) + setError(null) + try { + await updateHackathon({ + id: hackathon._id, + name: name.trim(), + edition: edition.trim(), + slug: slug.trim(), + date: date.trim(), + status, + criteria, + }) + onClose() + } catch (e) { + setError(e instanceof Error ? e.message : 'Erro ao salvar') + } finally { + setSaving(false) + } + } + + return ( + !o && onClose()}> + + + Editar Hackathon + + Atualize os dados desta edição. + + + +
+ + setName(e.target.value)} className={inputCls} /> + + +
+ + setEdition(e.target.value)} className={inputCls} /> + + + + setSlug(e.target.value)} className={inputCls} /> + +
+ +
+ + setDate(e.target.value)} className={inputCls} /> + + + + setStatus(v as typeof status)} + options={STATUS_OPTIONS} + /> + +
+ +
+ +
+ {criteria.map((c) => ( + + {c} + + + ))} +
+
+ setCriteriaInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + addCriterion() + } + }} + placeholder="Novo critério…" + className={inputCls} + /> + +
+
+ + {error &&

{error}

} + +
+ + +
+
+
+
+ ) +} diff --git a/components/admin/hackathon-selector.tsx b/components/admin/hackathon-selector.tsx index 0c4d8f8..79be8f0 100644 --- a/components/admin/hackathon-selector.tsx +++ b/components/admin/hackathon-selector.tsx @@ -1,15 +1,11 @@ 'use client' import { useEffect, useState } from 'react' -import { useQuery, useMutation } from 'convex/react' +import { useQuery } from 'convex/react' import { api } from '@/convex/_generated/api' import type { Id } from '@/convex/_generated/dataModel' -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' -import { FormField } from './form-field' -import { CustomSelect } from './custom-select' -import { SectionHeader } from './section-header' -import { ChevronDown, Plus, X } from 'lucide-react' +import { HackathonCreateDialog } from './hackathon-create-dialog' +import { ChevronDown, Plus } from 'lucide-react' interface HackathonSelectorProps { value: Id<'hackathons'> | null @@ -73,13 +69,10 @@ export function HackathonSelector({ value, onChange, label = 'Hackathon' }: Hack
- setShowCreate(false)} - onCreated={(id) => { - onChange(id) - setShowCreate(false) - }} + onCreated={(id) => onChange(id)} />
) @@ -90,188 +83,3 @@ export function useSelectedHackathon(hackathonId: Id<'hackathons'> | null) { return hackathons?.find(h => h._id === hackathonId) ?? null } -// --- Create Hackathon Dialog --- - -const STATUS_OPTIONS = [ - { value: 'upcoming', label: 'Em breve' }, - { value: 'live', label: 'Em andamento' }, - { value: 'finished', label: 'Finalizado' }, -] - -const inputCls = 'h-10 border-white/10 bg-white/[0.04] text-white placeholder:text-[#4a4a4a] focus:border-[#9810fa]/50 focus:ring-1 focus:ring-[#9810fa]/20' - -function formatDatePtBr(d: Date): string { - return d.toLocaleDateString('pt-BR', { day: 'numeric', month: 'long', year: 'numeric' }) -} - -function CreateHackathonDialog({ - open, - onClose, - onCreated, -}: { - open: boolean - onClose: () => void - onCreated: (id: Id<'hackathons'>) => void -}) { - const createHackathon = useMutation(api.mutations.createHackathon) - - const [name, setName] = useState('Borderless Hackathon') - const [edition, setEdition] = useState('') - const [slug, setSlug] = useState('') - const [dateValue, setDateValue] = useState('') - const [status, setStatus] = useState<'upcoming' | 'live' | 'finished'>('upcoming') - const [criteriaInput, setCriteriaInput] = useState('') - const [criteria, setCriteria] = useState(['Inovação', 'Execução', 'Pitch', 'Impacto']) - const [saving, setSaving] = useState(false) - - const dateString = dateValue ? formatDatePtBr(new Date(dateValue + 'T12:00:00')) : '' - - function addCriterion() { - const trimmed = criteriaInput.trim() - if (trimmed && !criteria.includes(trimmed)) { - setCriteria(prev => [...prev, trimmed]) - setCriteriaInput('') - } - } - - function removeCriterion(c: string) { - setCriteria(prev => prev.filter(x => x !== c)) - } - - async function handleCreate() { - if (!name.trim() || !edition.trim() || !slug.trim() || !dateString || criteria.length === 0) return - setSaving(true) - try { - const id = await createHackathon({ - name: name.trim(), - edition: edition.trim(), - slug: slug.trim(), - date: dateString, - status, - criteria, - }) - onCreated(id) - } finally { - setSaving(false) - } - } - - const isValid = name.trim() && edition.trim() && slug.trim() && dateString && criteria.length > 0 - - return ( - - - - Nova Edição - - Configure uma nova edição do hackathon. - - - -
- - setName(e.target.value)} - placeholder="Borderless Hackathon" - className={inputCls} - /> - - -
- - setEdition(e.target.value)} - placeholder="2026 — 2ª Edição" - className={inputCls} - /> - - - - setSlug(e.target.value)} - placeholder="borderless-2026-2" - className={inputCls} - /> - -
- -
- - setDateValue(e.target.value)} - className="h-10 w-full rounded-md border border-white/10 bg-white/[0.04] px-3 text-sm text-white focus:border-[#9810fa]/50 focus:ring-1 focus:ring-[#9810fa]/20 focus:outline-none [color-scheme:dark]" - /> - - - - setStatus(v as typeof status)} - options={STATUS_OPTIONS} - /> - -
- - {/* Criteria */} -
- -
- {criteria.map(c => ( - - {c} - - - ))} -
-
- setCriteriaInput(e.target.value)} - onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); addCriterion() } }} - placeholder="Novo critério…" - className={inputCls} - /> - -
-
- -
- - -
-
-
-
- ) -} diff --git a/components/public/hero-section.tsx b/components/public/hero-section.tsx index 203c4ff..9c09ef8 100644 --- a/components/public/hero-section.tsx +++ b/components/public/hero-section.tsx @@ -5,119 +5,114 @@ import { CountingNumber } from '@/components/animated/counting-number' import { HeroGrain } from '@/components/animated/hero-grain' import { PixelCluster } from '@/components/animated/pixel-cluster' import Link from 'next/link' -import { ArrowRight } from 'lucide-react' +import { ArrowRight, Dot } from 'lucide-react' const E = [0.16, 1, 0.3, 1] as const +type Status = 'upcoming' | 'live' | 'finished' + interface HeroSectionProps { slug: string name: string edition: string date: string + status: Status + votingOpen: boolean + criteria: string[] participantCount: number teamCount: number criteriaCount: number + topTeam: { name: string; score: number } | null +} + +const STATUS_META: Record = { + live: { label: 'Em andamento', color: '#2debb1' }, + upcoming: { label: 'Em breve', color: '#9810fa' }, + finished: { label: 'Encerrada', color: '#b2b2b2' }, } export function HeroSection({ - slug, name, edition, date, - participantCount, teamCount, criteriaCount, + slug, + name, + edition, + date, + status, + votingOpen, + criteria, + participantCount, + teamCount, + criteriaCount, + topTeam, }: HeroSectionProps) { - const year = date.match(/\d{4}/)?.[0] ?? '' + // votingOpen overrides status label (voting is the loudest state) + const effectiveMeta = votingOpen + ? { label: 'Votação aberta', color: '#2debb1' } + : STATUS_META[status] + const showVoteCta = votingOpen + const showLeader = !votingOpen && status === 'finished' && topTeam return ( -
- - {/* Background — overflow scoped here so pixel clusters can bleed */} +
+ {/* Background */}
- {year && ( - - )}
- {/* Logo — centralizada verticalmente na direita, textura de fundo */} - - - {/* Pixel accent — top-right, inside padding so it feels intentional */} + {/* Purple pixel accent — top-right, subtle */} - {/* Pixel accent — bleeds into next section */} - - {/* Content */} -
-
- - {/* ── Left: editorial text column ── */} -
- - {/* Eyebrow */} - - Borderless - - Borderless Coding - - - - {edition} - - - - {/* Title */} +
+ {/* ── TOP: eyebrow row ─────────────────────────────────────── */} + + Borderless + + Borderless Coding + + + + {edition} + + + + + {effectiveMeta.label} + + + + {/* ── MIDDLE: two-column main content ─────────────────────── */} +
+ {/* LEFT: title + date */} +
- - {/* Date */} {date} +
- {/* Separator */} - - - {/* Stats */} - + {/* RIGHT: state panel */} + + {showVoteCta ? (
-
- -
-
- Participantes -
+

+ Votação popular aberta +

+

+ Vote no time que você acredita. Um voto por pessoa. +

+ + Votar agora + +
-
+ ) : showLeader && topTeam ? (
-
- -
-
- Times +

+ Primeiro lugar +

+

+ {topTeam.name} +

+
+ + + pontos +
+ + Ver resultados + +
-
+ ) : (
-
- -
-
- Critérios -
+

+ Critérios de avaliação +

+
    + {criteria.slice(0, 4).map((c, i) => ( +
  • + + {String(i + 1).padStart(2, '0')} + + {c} +
  • + ))} +
+ + Ver times + +
- - -
{/* /left */} - - {/* ── Right: CTA alinhado com os stats ── */} - - - Ver Resultados - - - - + )} +
-
+ {/* ── BOTTOM: stats strip ─────────────────────────────────── */} + +
+ + + +
+
+
) } + +function Stat({ label, value }: { label: string; value: number }) { + return ( +
+
+ +
+
+ {label} +
+
+ ) +} diff --git a/components/public/vote-card.tsx b/components/public/vote-card.tsx index d7473b0..87f534a 100644 --- a/components/public/vote-card.tsx +++ b/components/public/vote-card.tsx @@ -1,6 +1,7 @@ 'use client' import { motion } from 'framer-motion' -import { Users, Check, Heart } from 'lucide-react' +import Link from 'next/link' +import { Users, Check, Heart, ArrowUpRight } from 'lucide-react' interface VoteCardProps { team: { @@ -11,6 +12,7 @@ interface VoteCardProps { tags: string[] memberNames: string[] } + slug: string voteCount: number isSelected: boolean hasVoted: boolean @@ -21,6 +23,7 @@ interface VoteCardProps { export function VoteCard({ team, + slug, voteCount, isSelected, hasVoted, @@ -28,6 +31,7 @@ export function VoteCard({ disabled, index, }: VoteCardProps) { + const detailHref = `/${slug}/times/${team._id}` return ( -

{team.name}

+ + {team.name} + +

{team.project}

diff --git a/convex/mutations.ts b/convex/mutations.ts index 4463a83..1e391e6 100644 --- a/convex/mutations.ts +++ b/convex/mutations.ts @@ -22,6 +22,7 @@ export const createHackathon = mutation({ export const updateHackathon = mutation({ args: { id: v.id('hackathons'), + slug: v.optional(v.string()), name: v.optional(v.string()), edition: v.optional(v.string()), date: v.optional(v.string()), @@ -36,10 +37,52 @@ export const updateHackathon = mutation({ votingOpen: v.optional(v.boolean()), }, handler: async (ctx, { id, ...patch }) => { + if (patch.slug) { + const existing = await ctx.db + .query('hackathons') + .withIndex('by_slug', (q) => q.eq('slug', patch.slug!)) + .unique() + if (existing && existing._id !== id) { + throw new Error('Já existe um hackathon com esse slug') + } + } await ctx.db.patch(id, patch) }, }) +export const deleteHackathon = mutation({ + args: { id: v.id('hackathons') }, + handler: async (ctx, { id }) => { + const teams = await ctx.db + .query('teams') + .withIndex('by_hackathon', (q) => q.eq('hackathonId', id)) + .collect() + for (const team of teams) { + const scores = await ctx.db + .query('scores') + .withIndex('by_team', (q) => q.eq('teamId', team._id)) + .collect() + for (const s of scores) await ctx.db.delete(s._id) + + const parts = await ctx.db + .query('participants') + .withIndex('by_team', (q) => q.eq('teamId', team._id)) + .collect() + for (const p of parts) await ctx.db.delete(p._id) + + await ctx.db.delete(team._id) + } + + const votes = await ctx.db + .query('votes') + .withIndex('by_hackathon', (q) => q.eq('hackathonId', id)) + .collect() + for (const vote of votes) await ctx.db.delete(vote._id) + + await ctx.db.delete(id) + }, +}) + export const createTeam = mutation({ args: { hackathonId: v.id('hackathons'),