diff --git a/i18n/en.json b/i18n/en.json index 9ff67207..67dfeb48 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -316,5 +316,167 @@ "errorServer": "Something went wrong. Try again in a moment.", "errorNetwork": "No connection. Try again.", "footer": "Cancel anytime." + }, + "hackaton": { + "pageTitle": "#1 NaN Hackathon", + "label": "// hackathon", + "title": "#1 NaN Hackathon", + "subtitle": "7 days to build with the cluster's open source models. Teams of 4, balanced. Prize: 1 month of NaN free.", + "dates": "June 7–13 · build week · Sunday 14th: Demo Day", + "backToLanding": "← Back", + "rules": "Allowed models: Qwen 3.6 or Gemma 4. The project must be open source. Free theme.", + "loginCta": "Sign in to NaN to register", + "login": { + "prompt": "Sign in with your NaN account", + "description": "Enter your NaN account email and we'll send you a link to sign in. It's the same access as the platform.", + "emailLabel": "Email", + "emailPlaceholder": "you@email.com", + "submit": "Send link", + "submitting": "Sending…", + "success": "If that email belongs to a NaN member, we've sent you a sign-in link. Check your inbox (and spam folder), then come back here.", + "errorEmail": "Enter a valid email.", + "errorRate": "Too many attempts. Wait a moment and try again.", + "errorServer": "Couldn't send the link. Try again.", + "errorNetwork": "Network error. Try again." + }, + "full": "All 40 seats are full. We'll add you to the reserve list (pool).", + "phase": { + "notOpenTitle": "Registration not open yet", + "notOpen": "Registration for this hackathon isn't open yet. Check back soon to sign up.", + "closedTitle": "Registration closed", + "closed": "The registration window has closed. We hope to see you at the next NaN hackathon!", + "inProgressTitle": "The hackathon is underway", + "inProgress": "Registration is closed, but you're already in. Check your status and your team.", + "votingTitle": "Voting is open", + "voting": "Submissions are live. Head to the gallery, browse the projects and vote for your favorite.", + "votingCta": "View projects & vote", + "resultsTitle": "Results published", + "results": "The hackathon has ended. Check out the final leaderboard.", + "resultsCta": "View leaderboard" + }, + "alreadyRegistered": { + "title": "You're already registered", + "confirmed": "Your seat is confirmed. We'll reach out on Discord with the next steps.", + "reserve": "You're in the reserve pool. If a seat frees up you'll get in by registration order; we'll let you know on Discord.", + "cta": "View my status and team" + }, + "capacity": { + "seatsTaken": "{n} of {cap} seats taken", + "seatsRemaining": "{n} seats left", + "lastSeats": "Last {n} seats!", + "poolTitle": "Main seats full", + "poolNote": "All {cap} main seats are full. If you register now you'll join the reserve pool ({taken} of {res} taken).", + "poolExplain": "The pool is the waitlist: if an active seat drops out, whoever is in the pool gets in by registration order.", + "poolBarLabel": "Reserve pool", + "closedTitle": "Registration full", + "closedNote": "All {cap} seats and the {res} reserve pool spots are taken. Keep an eye on Discord in case one frees up." + }, + "form": { + "name": "Name", + "discord": "Discord username", + "discordHelp": "Required — to coordinate your team", + "specialty": "Specialty", + "level": "Level", + "specFrontend": "Frontend", + "specBackend": "Backend", + "specMlAiData": "ML / AI / Data", + "specDesignProduct": "Design / Product", + "specOther": "Other", + "levelJunior": "Junior", + "levelMid": "Mid", + "levelSenior": "Senior", + "submit": "Register", + "submitting": "Sending…", + "success": "Registration recorded! Check your status in My team.", + "successReserve": "You're on the reserve list. If a seat frees up, you'll get in by order.", + "errorDiscord": "The Discord username is required.", + "errorFull": "Registration full (40 + 10 reserves).", + "errorEligible": "Your NaN account is not eligible to register.", + "errorServer": "Something went wrong. Try again.", + "errorNetwork": "Network error. Try again.", + "errorRate": "Too many attempts. Wait a moment and try again." + }, + "me": { + "title": "My team", + "noTeam": "Teams haven't been announced yet.", + "teammates": "Teammates", + "rules": "Rules: Qwen 3.6 / Gemma 4 + open source.", + "submissionCta": "Edit submission", + "reassignCta": "Request reassignment", + "reassignPending": "Request registered. Another teammate must confirm it ({have}/{need}).", + "reassignFilled": "Reassignment complete: a member from the reserve pool has joined.", + "reassignNoPool": "Reassignment complete: the pool was empty, your team continues with 3 members.", + "reassignAlready": "You've already requested this reassignment. Another teammate still needs to confirm it.", + "reassignSelectAbsent": "Select absent member", + "reassignErrorSubmit": "Couldn't register.", + "reassignSending": "Sending…", + "reassignInfo": { + "label": "Reassign absent member", + "toggleAria": "More information about reassignment", + "title": "What is it and what happens when you request a reassignment?", + "points": [ + "It replaces a teammate who won't take part (didn't show up or has gone silent).", + "It takes TWO team members: the request isn't executed until a second teammate confirms the same reassignment.", + "Once that agreement is reached, that person is marked absent and removed from your team and from the hackathon. The action is immediate and irreversible.", + "The system automatically picks the first candidate from the reserve pool (by registration order) and adds them to your team in their place.", + "If the pool is empty, your team continues with 3 members (the allowed minimum) and the organizers are notified.", + "It's only available during the building phase." + ] + } + }, + "submission": { + "title": "Team submission", + "fTitle": "Title", + "description": "Description (optional)", + "publicUrl": "Public URL", + "spaceUrl": "Space URL (optional · +1 point)", + "repoUrl": "Repo (open source)", + "imageUrl": "Cover image link", + "videoUrl": "Video link (YouTube)", + "attachMedia": "Attach media", + "videoInfo": "Upload a short video to YouTube presenting your project and set it as “Unlisted”: it won’t show up in search or on your channel, but anyone with the link can watch it. Paste that link here.", + "imageHint": "It will be shown as the project cover during voting.", + "submit": "Save submission", + "update": "Update submission", + "submitting": "Saving…", + "saved": "Submission saved. Checks executed.", + "alreadyTitle": "Submission sent ✓", + "alreadyNote": "Your team already has a submission. You can edit it and save again while the deadline is open.", + "error": "Couldn't save the submission.", + "errorRequired": "Missing required fields: title, public URL and repo.", + "errorClosed": "The submission window is closed: it can no longer be saved.", + "closedTitle": "Submission window closed", + "closedNote": "The building phase has ended. The submission can no longer be created or edited; it's now being verified before voting.", + "notSubmitted": "Your team didn't submit anything before the deadline closed.", + "checks": "Automatic checks", + "urlLive": "Public URL responds", + "inNanSpace": "Deployed in a NaN Space", + "repoPublic": "Public repo", + "notPrizeEligible": "No valid public repo: not eligible for prize." + }, + "projects": { + "title": "Projects", + "empty": "No projects published yet.", + "vote": "Vote", + "voted": "Voted ✓", + "voting": "Sending…", + "loginToVote": "Sign in to vote", + "selfVote": "You can't vote for your own team.", + "alreadyVoted": "You've already voted.", + "notEligible": "Your NaN account is not eligible to vote.", + "yourVote": "Your vote ✓", + "changeVote": "Vote for this one", + "presentationVideo": "Presentation video" + }, + "leaderboard": { + "title": "Leaderboard", + "hidden": "The leaderboard will be published when voting closes.", + "rank": "#", + "team": "Team", + "votes": "Votes", + "auto": "Auto", + "total": "Total", + "winner": "🏆 Winner" + } } -} \ No newline at end of file +} diff --git a/i18n/es.json b/i18n/es.json index 6a74e095..b87c3ce8 100644 --- a/i18n/es.json +++ b/i18n/es.json @@ -316,5 +316,167 @@ "errorServer": "Algo falló. Vuelve a intentarlo en un momento.", "errorNetwork": "Sin conexión. Vuelve a intentarlo.", "footer": "Cancela cuando quieras." + }, + "hackaton": { + "pageTitle": "#1 Hackatón NaN", + "label": "// hackatón", + "title": "#1 Hackatón NaN", + "subtitle": "7 días para construir con modelos open source del cluster. Equipos de 4, balanceados. Premio: 1 mes gratis de NaN.", + "dates": "7–13 de junio · construcción · Domingo 14: Demo Day", + "backToLanding": "← Volver", + "rules": "Modelos permitidos: Qwen 3.6 o Gemma 4. El proyecto debe ser open source. Tema libre.", + "loginCta": "Inicia sesión en NaN para inscribirte", + "login": { + "prompt": "Entra con tu cuenta NaN", + "description": "Introduce el email de tu cuenta NaN y te enviaremos un enlace para iniciar sesión. Es el mismo acceso que la plataforma.", + "emailLabel": "Email", + "emailPlaceholder": "tu@email.com", + "submit": "Enviar enlace", + "submitting": "Enviando…", + "success": "Si ese email pertenece a un miembro de NaN, te hemos enviado un enlace para entrar. Revisa tu correo (y la carpeta de spam) y luego vuelve aquí.", + "errorEmail": "Introduce un email válido.", + "errorRate": "Demasiados intentos. Espera un momento e inténtalo de nuevo.", + "errorServer": "No se pudo enviar el enlace. Inténtalo de nuevo.", + "errorNetwork": "Error de red. Inténtalo de nuevo." + }, + "full": "Las 40 plazas están llenas. Te apuntamos a la lista de reservas (pool).", + "phase": { + "notOpenTitle": "Inscripciones aún no abiertas", + "notOpen": "Las inscripciones para este hackatón todavía no están abiertas. Vuelve pronto para apuntarte.", + "closedTitle": "Inscripciones cerradas", + "closed": "El plazo de inscripción ya se ha cerrado. ¡Te esperamos en el próximo hackatón de NaN!", + "inProgressTitle": "El hackatón está en marcha", + "inProgress": "Las inscripciones están cerradas, pero tú ya estás dentro. Consulta tu estado y tu equipo.", + "votingTitle": "La votación está abierta", + "voting": "Las entregas ya están publicadas. Entra a la galería, revisa los proyectos y vota tu favorito.", + "votingCta": "Ver proyectos y votar", + "resultsTitle": "Resultados publicados", + "results": "El hackatón ha terminado. Consulta la clasificación final.", + "resultsCta": "Ver clasificación" + }, + "alreadyRegistered": { + "title": "Ya estás inscrito", + "confirmed": "Tu plaza está confirmada. Te avisaremos por Discord con los siguientes pasos.", + "reserve": "Estás en la pool de reservas. Si se libera una plaza entrarás por orden de inscripción; te avisaremos por Discord.", + "cta": "Ver mi estado y equipo" + }, + "capacity": { + "seatsTaken": "{n} de {cap} plazas ocupadas", + "seatsRemaining": "Quedan {n} plazas", + "lastSeats": "¡Últimas {n} plazas!", + "poolTitle": "Plazas principales completas", + "poolNote": "Las {cap} plazas principales están llenas. Si te inscribes ahora entrarás en la pool de reservas ({taken} de {res} ocupadas).", + "poolExplain": "La pool es la lista de espera: si una plaza activa se da de baja, quien está en la pool entra por orden de inscripción.", + "poolBarLabel": "Pool de reservas", + "closedTitle": "Inscripciones completas", + "closedNote": "Las {cap} plazas y las {res} de la pool de reservas están ocupadas. Atento al Discord por si se libera alguna." + }, + "form": { + "name": "Nombre", + "discord": "Usuario de Discord", + "discordHelp": "Obligatorio — para coordinar tu equipo", + "specialty": "Especialidad", + "level": "Nivel", + "specFrontend": "Frontend", + "specBackend": "Backend", + "specMlAiData": "ML / AI / Data", + "specDesignProduct": "Diseño / Producto", + "specOther": "Otro", + "levelJunior": "Junior", + "levelMid": "Mid", + "levelSenior": "Senior", + "submit": "Inscribirme", + "submitting": "Enviando…", + "success": "¡Inscripción registrada! Revisa tu estado en Mi equipo.", + "successReserve": "Estás en la lista de reservas. Si se libera plaza, entrarás por orden.", + "errorDiscord": "El usuario de Discord es obligatorio.", + "errorFull": "Inscripción completa (40 + 10 reservas).", + "errorEligible": "Tu cuenta NaN no es elegible para inscribirse.", + "errorServer": "Algo falló. Inténtalo de nuevo.", + "errorNetwork": "Error de red. Inténtalo de nuevo.", + "errorRate": "Demasiados intentos. Espera un momento e inténtalo de nuevo." + }, + "me": { + "title": "Mi equipo", + "noTeam": "Aún no se han anunciado los equipos.", + "teammates": "Compañeros", + "rules": "Reglas: Qwen 3.6 / Gemma 4 + open source.", + "submissionCta": "Editar submission", + "reassignCta": "Convocar reasignación", + "reassignPending": "Convocatoria registrada. Hace falta que otro compañero la confirme ({have}/{need}).", + "reassignFilled": "Reasignación completada: se ha incorporado un miembro de la pool de reservas.", + "reassignNoPool": "Reasignación completada: no quedaba nadie en la pool, el equipo continúa con 3 miembros.", + "reassignAlready": "Ya has convocado esta reasignación. Falta que otro compañero la confirme.", + "reassignSelectAbsent": "Selecciona ausente", + "reassignErrorSubmit": "No se pudo registrar.", + "reassignSending": "Enviando…", + "reassignInfo": { + "label": "Reasignar miembro ausente", + "toggleAria": "Más información sobre la reasignación", + "title": "¿Qué es y qué ocurre al convocar una reasignación?", + "points": [ + "Sirve para sustituir a un compañero de tu equipo que no va a participar (no apareció o ha desaparecido).", + "Hacen falta DOS miembros del equipo: la convocatoria no se ejecuta hasta que un segundo compañero confirma la misma reasignación.", + "Cuando se alcanza ese acuerdo, esa persona se marca como ausente y sale de tu equipo y del hackatón. La acción es inmediata e irreversible.", + "El sistema busca automáticamente al primer candidato de la pool de reservas (por orden de inscripción) y lo añade a tu equipo en su lugar.", + "Si no queda nadie en la pool, tu equipo continúa con 3 miembros (el mínimo permitido) y se avisa a la organización.", + "Solo está disponible durante la fase de construcción." + ] + } + }, + "submission": { + "title": "Submission del equipo", + "fTitle": "Título", + "description": "Descripción (opcional)", + "publicUrl": "URL pública", + "spaceUrl": "URL del Space (opcional · +1 punto)", + "repoUrl": "Repo (open source)", + "imageUrl": "Enlace de imagen de portada", + "videoUrl": "Enlace de vídeo (YouTube)", + "attachMedia": "Adjuntar media", + "videoInfo": "Sube a YouTube un vídeo corto presentando tu proyecto y márcalo como «Oculto» (no listado): no aparecerá en búsquedas ni en tu canal, pero cualquiera con el enlace podrá verlo. Pega aquí ese enlace.", + "imageHint": "Se mostrará como portada del proyecto durante la votación.", + "submit": "Guardar submission", + "update": "Actualizar submission", + "submitting": "Guardando…", + "saved": "Submission guardada. Checks ejecutados.", + "alreadyTitle": "Submission enviada ✓", + "alreadyNote": "Tu equipo ya tiene submission enviada. Puedes editarla y volver a guardar mientras el plazo siga abierto.", + "error": "No se pudo guardar la submission.", + "errorRequired": "Faltan campos obligatorios: título, URL pública y repo.", + "errorClosed": "El plazo de entrega está cerrado: ya no se puede guardar.", + "closedTitle": "Plazo de entrega cerrado", + "closedNote": "La fase de construcción ha terminado. Ya no se puede crear ni editar la submission; ahora se verifica antes de la votación.", + "notSubmitted": "Tu equipo no llegó a enviar ninguna submission antes de cerrarse el plazo.", + "checks": "Checks automáticos", + "urlLive": "URL pública responde", + "inNanSpace": "Desplegada en Space de NaN", + "repoPublic": "Repo público", + "notPrizeEligible": "Sin repo público válido: no elegible a premio." + }, + "projects": { + "title": "Proyectos", + "empty": "Aún no hay proyectos publicados.", + "vote": "Votar", + "voted": "Votado ✓", + "voting": "Enviando…", + "loginToVote": "Inicia sesión para votar", + "selfVote": "No puedes votar a tu propio equipo.", + "alreadyVoted": "Ya has votado.", + "notEligible": "Tu cuenta NaN no es elegible para votar.", + "yourVote": "Tu voto ✓", + "changeVote": "Votar por este", + "presentationVideo": "Vídeo presentación" + }, + "leaderboard": { + "title": "Leaderboard", + "hidden": "El leaderboard se publicará al cerrar la votación.", + "rank": "#", + "team": "Equipo", + "votes": "Votos", + "auto": "Auto", + "total": "Total", + "winner": "🏆 Ganador" + } } -} \ No newline at end of file +} diff --git a/src/components/hackaton/LoginForm.tsx b/src/components/hackaton/LoginForm.tsx new file mode 100644 index 00000000..641b8c20 --- /dev/null +++ b/src/components/hackaton/LoginForm.tsx @@ -0,0 +1,101 @@ +import { useState } from 'preact/hooks'; +import type { TargetedSubmitEvent } from 'preact'; + +export interface LoginStrings { + prompt: string; + description: string; + emailLabel: string; + emailPlaceholder: string; + submit: string; + submitting: string; + success: string; + errorEmail: string; + errorRate: string; + errorServer: string; + errorNetwork: string; +} + +type Status = + | { kind: 'idle' } + | { kind: 'submitting' } + | { kind: 'success' } + | { kind: 'error'; message: string }; + +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +// Formulario de acceso al hackatón por magic link. Dispara el MISMO login de +// NaN que usa la plataforma (POST /api/auth/login/request vía proxy same-origin) +// y produce una sesión nan_session completa. Una vez validado el email, al +// volver a /hackaton el usuario ya tiene sesión y puede inscribirse / votar. +export default function LoginForm({ t }: { t: LoginStrings }) { + const [email, setEmail] = useState(''); + const [status, setStatus] = useState({ kind: 'idle' }); + + async function onSubmit(e: TargetedSubmitEvent) { + e.preventDefault(); + const addr = email.trim(); + if (!EMAIL_RE.test(addr)) { + setStatus({ kind: 'error', message: t.errorEmail }); + return; + } + setStatus({ kind: 'submitting' }); + + let resp: Response; + try { + resp = await fetch('/api/auth/login-request', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: addr }), + }); + } catch { + setStatus({ kind: 'error', message: t.errorNetwork }); + return; + } + + // El backend responde 202 tanto si el email existe como si no + // (anti-enumeración). Por eso el mensaje de éxito es neutro. + if (resp.ok) { + setStatus({ kind: 'success' }); + return; + } + let message = t.errorServer; + if (resp.status === 400) message = t.errorEmail; + else if (resp.status === 429) message = t.errorRate; + setStatus({ kind: 'error', message }); + } + + if (status.kind === 'success') { + return ( +
+

{t.success}

+
+ ); + } + + const submitting = status.kind === 'submitting'; + const err = status.kind === 'error' ? status.message : null; + const inputCls = + 'w-full bg-neutral-950 border border-neutral-800 rounded-lg px-4 py-3 text-sm text-white font-mono placeholder-neutral-600 focus:outline-none focus:border-violet-500/60 focus:ring-1 focus:ring-violet-500/40 transition-colors'; + + return ( +
+
+

{t.prompt}

+

{t.description}

+
+
+ + setEmail((e.currentTarget as HTMLInputElement).value)} class={inputCls} /> +
+ {err && } + +
+ ); +} diff --git a/src/components/hackaton/ReassignForm.tsx b/src/components/hackaton/ReassignForm.tsx new file mode 100644 index 00000000..34a32fb7 --- /dev/null +++ b/src/components/hackaton/ReassignForm.tsx @@ -0,0 +1,123 @@ +import { useState } from 'preact/hooks'; + +interface Member { + id: string; + name: string; + discord_user?: string; +} + +interface ReassignInfo { + label: string; + toggleAria: string; + title: string; + points: string[]; +} + +interface ReassignLabels { + pending: string; // admite {have}/{need} + filled: string; + noPool: string; + already: string; + selectAbsent: string; + errorSubmit: string; + sending: string; +} + +export default function ReassignForm({ + members, + ctaLabel, + info, + labels, +}: { + members: Member[]; + ctaLabel: string; + info: ReassignInfo; + labels: ReassignLabels; +}) { + const [ghostId, setGhostId] = useState(''); + const [state, setState] = useState<'idle' | 'busy' | 'done' | 'error'>('idle'); + const [result, setResult] = useState(''); + const [showInfo, setShowInfo] = useState(false); + + async function submit() { + if (!ghostId) return; + setState('busy'); + try { + const resp = await fetch('/api/hackaton/reassign', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ghost_id: ghostId }), + }); + const body = await resp.json().catch(() => null) as { data?: any; error?: string } | null; + if (!resp.ok) { + // 409 already_voted: ya convocó esta reasignación, sigue pendiente del 2º voto. + if (resp.status === 409 && body?.error === 'already_voted') { + setResult(labels.already); + setState('done'); + return; + } + setState('error'); + return; + } + const r = body?.data; + if (r?.status === 'filled') setResult(labels.filled); + else if (r?.status === 'no_pool') setResult(labels.noPool); + else { + const have = String(r?.requested_by?.length ?? 1); + const need = String(r?.quorum ?? 2); + setResult(labels.pending.replaceAll('{have}', have).replaceAll('{need}', need)); + } + setState('done'); + } catch { + setState('error'); + } + } + + if (state === 'done') { + return

{result}

; + } + + return ( +
+
+ {info.label} + +
+ + {showInfo && ( +
+

{info.title}

+
    + {info.points.map((p) => ( +
  • {p}
  • + ))} +
+
+ )} + + + {state === 'error' && } + +
+ ); +} diff --git a/src/components/hackaton/RegisterForm.tsx b/src/components/hackaton/RegisterForm.tsx new file mode 100644 index 00000000..3a567b55 --- /dev/null +++ b/src/components/hackaton/RegisterForm.tsx @@ -0,0 +1,130 @@ +import { useState } from 'preact/hooks'; +import type { TargetedSubmitEvent } from 'preact'; + +export interface FormStrings { + name: string; discord: string; discordHelp: string; + specialty: string; level: string; + specFrontend: string; specBackend: string; specMlAiData: string; + specDesignProduct: string; specOther: string; + levelJunior: string; levelMid: string; levelSenior: string; + submit: string; submitting: string; success: string; successReserve: string; + errorDiscord: string; errorFull: string; errorEligible: string; + errorServer: string; errorNetwork: string; errorRate: string; +} + +type Status = + | { kind: 'idle' } + | { kind: 'submitting' } + | { kind: 'success'; reserve: boolean } + | { kind: 'error'; message: string }; + +export default function RegisterForm({ t, meHref }: { t: FormStrings; meHref: string }) { + const [name, setName] = useState(''); + const [discord, setDiscord] = useState(''); + const [specialty, setSpecialty] = useState(''); + const [level, setLevel] = useState(''); + const [status, setStatus] = useState({ kind: 'idle' }); + + async function onSubmit(e: TargetedSubmitEvent) { + e.preventDefault(); + if (discord.trim() === '') { + setStatus({ kind: 'error', message: t.errorDiscord }); + return; + } + setStatus({ kind: 'submitting' }); + + let resp: Response; + try { + resp = await fetch('/api/hackaton/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: name.trim(), + discord_user: discord.trim(), + specialty, + level, + }), + }); + } catch { + setStatus({ kind: 'error', message: t.errorNetwork }); + return; + } + + let body: any = null; + try { body = await resp.json(); } catch { body = null; } + + if (resp.status === 201 && body?.ok) { + setStatus({ kind: 'success', reserve: !!body?.data?.is_reserve }); + return; + } + let message = t.errorServer; + const code = body?.error ?? ''; + if (resp.status === 409 || code === 'registration_full') message = t.errorFull; + else if (resp.status === 403 || code === 'not_eligible') message = t.errorEligible; + else if (resp.status === 429 || code === 'rate_limited') message = t.errorRate; + else if (code === 'discord_required') message = t.errorDiscord; + setStatus({ kind: 'error', message }); + } + + if (status.kind === 'success') { + return ( +
+

+ {status.reserve ? t.successReserve : t.success} +

+ + → Mi equipo + +
+ ); + } + + const submitting = status.kind === 'submitting'; + const err = status.kind === 'error' ? status.message : null; + const inputCls = + 'w-full bg-neutral-950 border border-neutral-800 rounded-lg px-4 py-3 text-sm text-white font-mono placeholder-neutral-600 focus:outline-none focus:border-violet-500/60 focus:ring-1 focus:ring-violet-500/40 transition-colors'; + const labelCls = 'block font-mono text-[10px] uppercase tracking-widest text-violet-400 mb-2'; + + return ( +
+
+ + setName((e.currentTarget as HTMLInputElement).value)} class={inputCls} /> +
+
+ + setDiscord((e.currentTarget as HTMLInputElement).value)} class={inputCls} /> +

{t.discordHelp}

+
+
+ + +
+
+ + +
+ {err && } + +
+ ); +} diff --git a/src/components/hackaton/RegistrationBar.astro b/src/components/hackaton/RegistrationBar.astro new file mode 100644 index 00000000..e25463d6 --- /dev/null +++ b/src/components/hackaton/RegistrationBar.astro @@ -0,0 +1,86 @@ +--- +import { tObj } from '../../lib/i18n'; + +interface Props { + registered: number; + capacity: number; + reserve: number; + locale: string; +} + +interface CapacityStrings { + seatsTaken: string; + seatsRemaining: string; + lastSeats: string; + poolTitle: string; + poolNote: string; + poolExplain: string; + poolBarLabel: string; + closedTitle: string; + closedNote: string; +} + +const { registered, capacity, reserve, locale } = Astro.props; +const cap = tObj('hackaton.capacity', locale); + +// Tramos: normal (r < capacity) y pool (capacity <= r < capacity+reserve). +// El caso "completo" (r >= capacity+reserve) lo gestiona la página, no este componente. +const inPool = registered >= capacity; +const mainTaken = Math.min(registered, capacity); +const mainPct = capacity > 0 ? Math.round((mainTaken / capacity) * 100) : 0; +const poolTaken = Math.max(0, registered - capacity); +const poolPct = reserve > 0 ? Math.round((poolTaken / reserve) * 100) : 0; +const remaining = Math.max(0, capacity - registered); +const lowSeats = !inPool && remaining > 0 && remaining <= 5; + +const fill = (s: string, vars: Record) => + Object.entries(vars).reduce((acc, [k, v]) => acc.replaceAll(`{${k}}`, String(v)), s); + +const takenLabel = fill(cap.seatsTaken, { n: mainTaken, cap: capacity }); +const remainingLabel = lowSeats + ? fill(cap.lastSeats, { n: remaining }) + : fill(cap.seatsRemaining, { n: remaining }); +const poolNote = fill(cap.poolNote, { cap: capacity, taken: poolTaken, res: reserve }); +--- + +
+
+ {takenLabel} + + {!inPool && remainingLabel} + +
+ + {/* Barra principal: 40 plazas */} +
+
+
+
+ + {inPool && ( +
+
+ {cap.poolBarLabel} + {poolTaken} / {reserve} +
+
+
+
+
+ +
+

+ {cap.poolTitle} +

+

{poolNote}

+

{cap.poolExplain}

+
+
+ )} +
diff --git a/src/components/hackaton/SubmissionForm.tsx b/src/components/hackaton/SubmissionForm.tsx new file mode 100644 index 00000000..4f87186b --- /dev/null +++ b/src/components/hackaton/SubmissionForm.tsx @@ -0,0 +1,176 @@ +import { useState } from 'preact/hooks'; +import type { TargetedSubmitEvent } from 'preact'; + +interface CheckItem { pass: boolean } +interface Checks { + auto_points: number; + not_prize_eligible?: boolean; + checks: { url_live: CheckItem; in_nan_space: CheckItem; repo_public: CheckItem }; +} + +interface Existing { + title?: string; + description?: string; + public_url?: string; + space_url?: string; + repo_url?: string; + image_url?: string; + video_url?: string; + auto_points?: number; + not_prize_eligible?: boolean; + checks?: Checks; +} + +// Narrow the unknown response payload into a Checks shape. Returns null if any +// required slice is missing so the UI never reads from a half-shaped object. +function toChecks(raw: unknown): Checks | null { + if (!raw || typeof raw !== 'object') return null; + const r = raw as Partial; + const c = r.checks; + if (!c || typeof c.url_live?.pass !== 'boolean' + || typeof c.in_nan_space?.pass !== 'boolean' + || typeof c.repo_public?.pass !== 'boolean') return null; + return { + auto_points: typeof r.auto_points === 'number' ? r.auto_points : 0, + not_prize_eligible: r.not_prize_eligible, + checks: c, + }; +} + +export default function SubmissionForm({ t, existing }: { t: Record; existing?: Existing | null }) { + const [f, setF] = useState({ + title: existing?.title ?? '', + description: existing?.description ?? '', + public_url: existing?.public_url ?? '', + space_url: existing?.space_url ?? '', + repo_url: existing?.repo_url ?? '', + image_url: existing?.image_url ?? '', + video_url: existing?.video_url ?? '', + }); + // checks: arranca con los de la submission existente (validados); se actualiza al guardar. + const [checks, setChecks] = useState(toChecks(existing)); + const [submitted, setSubmitted] = useState(Boolean(existing)); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + // Sección de media plegable: abierta si ya hay imagen o vídeo guardados. + const [showMedia, setShowMedia] = useState(Boolean(existing?.image_url || existing?.video_url)); + const [showInfo, setShowInfo] = useState(false); + const set = (k: string) => (e: Event) => + setF({ ...f, [k]: (e.currentTarget as HTMLInputElement | HTMLTextAreaElement).value }); + + async function onSubmit(e: TargetedSubmitEvent) { + e.preventDefault(); + setBusy(true); + setError(''); + // Revalidar fase: si pasó a congelado mientras el form estaba abierto, + // avisar sin enviar (el usuario conserva lo escrito). El backend sigue + // siendo la autoridad (mapea wrong_state → errorClosed); esto mejora el caso común. + try { + const ev = await fetch('/api/hackaton/event'); + const evb = await ev.json().catch(() => null) as { data?: { status?: string } } | null; + const st = evb?.data?.status; + if (st && st !== 'building') { setBusy(false); setError(t.errorClosed); return; } + } catch { /* best-effort: si falla, dejamos que el backend decida */ } + let resp: Response; + try { + resp = await fetch('/api/hackaton/submission', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(f), + }); + } catch { + setBusy(false); + setError(t.error); + return; + } + const b = await resp.json().catch(() => null) as { ok?: boolean; data?: unknown; error?: string } | null; + setBusy(false); + if (resp.ok && b?.ok) { + setChecks(toChecks(b.data)); + setSubmitted(true); + return; + } + // Mapear errores conocidos del backend a un mensaje claro. + const code = b?.error as string | undefined; + if (code === 'validation_failed') setError(t.errorRequired); + else if (code === 'wrong_state') setError(t.errorClosed); + else setError(t.error); + } + + const inputCls = 'w-full bg-neutral-950 border border-neutral-800 rounded-lg px-4 py-3 text-sm text-white font-mono placeholder-neutral-600 focus:outline-none focus:border-violet-500/60 focus:ring-1 focus:ring-violet-500/40 transition-colors'; + const labelCls = 'block font-mono text-[10px] uppercase tracking-widest text-violet-400 mb-2'; + return ( +
+ {submitted && ( +
+

{t.alreadyTitle}

+

{t.alreadyNote}

+
+ )} +
+ + +
+
+ +