+ )}
+
+
+
diff --git a/src/tests/api/hackaton.test.ts b/src/tests/api/hackaton.test.ts
new file mode 100644
index 00000000..59718427
--- /dev/null
+++ b/src/tests/api/hackaton.test.ts
@@ -0,0 +1,19 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+
+// Mock de cloudflare:workers env (patrón del repo).
+vi.mock('cloudflare:workers', () => ({ env: { CLOUD_API_URL: 'https://api.test' } }));
+
+import { isAdminPath, backendURL } from '../../lib/hackaton';
+
+describe('hackaton proxy lib', () => {
+ it('bloquea paths admin', () => {
+ expect(isAdminPath('admin')).toBe(true);
+ expect(isAdminPath('admin/state')).toBe(true);
+ expect(isAdminPath('event')).toBe(false);
+ expect(isAdminPath('register')).toBe(false);
+ });
+ it('construye la URL del backend con query', () => {
+ expect(backendURL('event', '?x=1')).toBe('https://api.test/api/hackaton/event?x=1');
+ expect(backendURL('/register', '')).toBe('https://api.test/api/hackaton/register');
+ });
+});
From e90a621e8f47ce71b948edefac936307d762fc71 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Sa=C3=BAl=20G=C3=B3mez=20Jim=C3=A9nez?=
Date: Sun, 31 May 2026 18:53:00 +0200
Subject: [PATCH 2/8] =?UTF-8?q?feat(hackaton):=20barra=20de=20inscripcione?=
=?UTF-8?q?s,=20estados=20por=20fase,=20reasignaci=C3=B3n=20con=20qu=C3=B3?=
=?UTF-8?q?rum=20y=20submission?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Barra de inscripciones (RegistrationBar.astro, hackaton.astro):
- Nuevo componente que muestra plazas ocupadas/restantes antes del formulario,
con aviso de "pool de reservas" al llenarse las 40 principales y estado
"inscripciones completas" al llegar a 50. Lee `registered` de /event.
Detección de ya-inscrito y mensajería por fase (hackaton.astro):
- Consulta /me en todas las fases y distingue: no abierto (draft), inscripción
abierta, ya inscrito, inscripciones cerradas, y panel del participante según
fase (check-in pendiente / check-in hecho / en curso).
- Interpolación correcta de {cap}/{res} en los textos de capacidad.
Check-in (checkin.astro):
- El frontend refleja cuando el usuario ya hizo check-in (deja de ofrecer la
opción en bucle).
Reasignación (ReassignForm.tsx, me.astro):
- Botón de información que explica qué es y qué ocurre al convocar.
- UI del quórum=2: muestra "Convocatoria registrada, falta confirmación
(1/2)", y los resultados filled / no_pool / ya-convocada.
Submission (SubmissionForm.tsx, submission.astro):
- Si el equipo ya tiene submission, prefija los campos, muestra el banner
"Submission enviada ✓" con los checks persistidos y cambia el botón a
"Actualizar submission" (evita reenvíos a ciegas).
Login y navegación (LoginForm.tsx, api/auth/login-request.ts, Base.astro,
LoginButton.astro, VoteButton.tsx):
- Flujo de login por magic-link y enlace "Hackatón" en la barra superior.
i18n (es/en): textos de capacidad, fases, reasignación (incl. quórum) y
submission.
Co-Authored-By: Claude Opus 4.8
---
i18n/en.json | 65 +++++++-
i18n/es.json | 65 +++++++-
src/components/hackaton/LoginForm.tsx | 101 ++++++++++++
src/components/hackaton/ReassignForm.tsx | 75 ++++++++-
src/components/hackaton/RegistrationBar.astro | 86 ++++++++++
src/components/hackaton/SubmissionForm.tsx | 42 ++++-
src/components/hackaton/VoteButton.tsx | 2 +-
src/components/landing/LoginButton.astro | 2 +-
src/layouts/Base.astro | 10 +-
src/pages/api/auth/login-request.ts | 51 ++++++
src/pages/hackaton.astro | 149 +++++++++++++++---
src/pages/hackaton/checkin.astro | 6 +-
src/pages/hackaton/me.astro | 4 +-
src/pages/hackaton/submission.astro | 4 +-
14 files changed, 621 insertions(+), 41 deletions(-)
create mode 100644 src/components/hackaton/LoginForm.tsx
create mode 100644 src/components/hackaton/RegistrationBar.astro
create mode 100644 src/pages/api/auth/login-request.ts
diff --git a/i18n/en.json b/i18n/en.json
index edc33d6e..98375d56 100644
--- a/i18n/en.json
+++ b/i18n/en.json
@@ -326,7 +326,50 @@
"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.",
+ "checkinTitle": "Confirm your attendance",
+ "checkin": "Registration is closed. Now it's time to check in to secure your seat.",
+ "checkinCta": "Check in",
+ "checkinDoneTitle": "Check-in confirmed",
+ "checkinDone": "You've confirmed your attendance and your seat is secured. We'll reach out on Discord with the next steps."
+ },
+ "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 a confirmed participant doesn't check in or 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",
@@ -365,7 +408,24 @@
"teammates": "Teammates",
"rules": "Rules: Qwen 3.6 / Gemma 4 + open source.",
"submissionCta": "Edit submission",
- "reassignCta": "Request reassignment"
+ "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.",
+ "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) who checked in, 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",
@@ -376,8 +436,11 @@
"repoUrl": "Repo (open source)",
"mediaUrl": "Media (optional)",
"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.",
"checks": "Automatic checks",
"urlLive": "Public URL responds",
"inNanSpace": "Deployed in a NaN Space",
diff --git a/i18n/es.json b/i18n/es.json
index f9a0e41d..3003b92e 100644
--- a/i18n/es.json
+++ b/i18n/es.json
@@ -326,7 +326,50 @@
"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.",
+ "checkinTitle": "Confirma tu asistencia",
+ "checkin": "Las inscripciones están cerradas. Ahora toca hacer check-in para asegurar tu plaza.",
+ "checkinCta": "Hacer check-in",
+ "checkinDoneTitle": "Check-in confirmado",
+ "checkinDone": "Ya has confirmado tu asistencia y tu plaza está asegurada. Te avisaremos por Discord con los siguientes pasos."
+ },
+ "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 alguien confirmado no hace check-in o 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",
@@ -365,7 +408,24 @@
"teammates": "Compañeros",
"rules": "Reglas: Qwen 3.6 / Gemma 4 + open source.",
"submissionCta": "Editar submission",
- "reassignCta": "Convocar reasignación"
+ "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.",
+ "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) que hizo check-in 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",
@@ -376,8 +436,11 @@
"repoUrl": "Repo (open source)",
"mediaUrl": "Media (opcional)",
"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.",
"checks": "Checks automáticos",
"urlLive": "URL pública responde",
"inNanSpace": "Desplegada en Space de NaN",
diff --git a/src/components/hackaton/LoginForm.tsx b/src/components/hackaton/LoginForm.tsx
new file mode 100644
index 00000000..10553033
--- /dev/null
+++ b/src/components/hackaton/LoginForm.tsx
@@ -0,0 +1,101 @@
+import { useState } from 'preact/hooks';
+import type { TargetedSubmitEvent } from 'preact';
+
+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 (
+
))}
diff --git a/src/pages/hackaton/submission.astro b/src/pages/hackaton/submission.astro
index 1582015b..a15a7de9 100644
--- a/src/pages/hackaton/submission.astro
+++ b/src/pages/hackaton/submission.astro
@@ -1,7 +1,8 @@
---
import Page from '../../layouts/Page.astro';
import SubmissionForm from '../../components/hackaton/SubmissionForm.tsx';
-import { t, tObj } from '../../lib/i18n';
+import { t, tObj, withLang } from '../../lib/i18n';
+import { jsonData, type MeData } from '../../lib/hackaton';
import { env } from 'cloudflare:workers';
export const prerender = false;
@@ -12,16 +13,17 @@ let hasTeam = false;
let existing: any = null;
let status = 'draft';
try {
- const res = await fetch(`${base}/api/hackaton/me`, {
- headers: { cookie: Astro.request.headers.get('cookie') ?? '', origin: 'https://nan.builders' },
- });
+ const cookie = Astro.request.headers.get('cookie') ?? '';
+ const [res, ev] = await Promise.all([
+ fetch(`${base}/api/hackaton/me`, { headers: { cookie, origin: 'https://nan.builders' } }),
+ fetch(`${base}/api/hackaton/event`, { headers: { origin: 'https://nan.builders' } }),
+ ]);
if (res.ok) {
- const body = await res.json() as { data?: any };
- hasTeam = Boolean(body?.data?.team?.id);
- existing = body?.data?.submission ?? null;
+ const body = await jsonData(res);
+ hasTeam = Boolean(body?.team?.id);
+ existing = body?.submission ?? null;
}
- const ev = await fetch(`${base}/api/hackaton/event`, { headers: { origin: 'https://nan.builders' } });
- if (ev.ok) status = (await ev.json() as { data?: any })?.data?.status ?? 'draft';
+ if (ev.ok) status = (await jsonData<{ status?: string }>(ev))?.status ?? 'draft';
} catch {}
// La entrega solo se acepta durante `building` (el backend rechaza el resto con
@@ -43,7 +45,7 @@ const readonlyRows = existing
-
+
{t('hackaton.backToLanding', locale)}
{t('hackaton.submission.title', locale)}
diff --git a/src/tests/api/hackaton.test.ts b/src/tests/api/hackaton.test.ts
index 59718427..f106de2f 100644
--- a/src/tests/api/hackaton.test.ts
+++ b/src/tests/api/hackaton.test.ts
@@ -1,9 +1,11 @@
-import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { describe, it, expect, vi, afterEach } from 'vitest';
// Mock de cloudflare:workers env (patrón del repo).
vi.mock('cloudflare:workers', () => ({ env: { CLOUD_API_URL: 'https://api.test' } }));
import { isAdminPath, backendURL } from '../../lib/hackaton';
+import { GET, POST } from '../../pages/api/hackaton/[...path]';
+import { POST as LOGIN_POST } from '../../pages/api/auth/login-request';
describe('hackaton proxy lib', () => {
it('bloquea paths admin', () => {
@@ -12,8 +14,82 @@ describe('hackaton proxy lib', () => {
expect(isAdminPath('event')).toBe(false);
expect(isAdminPath('register')).toBe(false);
});
+ it('normaliza variantes admin', () => {
+ expect(isAdminPath('//admin/state')).toBe(true);
+ expect(isAdminPath('admin//x')).toBe(true);
+ expect(isAdminPath('Admin/state')).toBe(true);
+ expect(isAdminPath('%2fadmin/state')).toBe(true);
+ expect(isAdminPath('administration')).toBe(false);
+ });
it('construye la URL del backend con query', () => {
expect(backendURL('event', '?x=1')).toBe('https://api.test/api/hackaton/event?x=1');
expect(backendURL('/register', '')).toBe('https://api.test/api/hackaton/register');
});
});
+
+// Construye un contexto mínimo de APIRoute para el handler del proxy.
+function ctx(path: string, init?: { method?: string; cookie?: string; ip?: string; body?: string; search?: string }) {
+ const headers = new Headers();
+ if (init?.cookie) headers.set('cookie', init.cookie);
+ if (init?.ip) headers.set('cf-connecting-ip', init.ip);
+ const request = new Request('https://nan.builders/api/hackaton/' + path + (init?.search ?? ''), {
+ method: init?.method ?? 'GET',
+ headers,
+ body: init?.body,
+ });
+ const url = new URL(request.url);
+ return { params: { path }, request, url } as never;
+}
+
+describe('hackaton proxy handler', () => {
+ afterEach(() => vi.restoreAllMocks());
+
+ it('responde 404 a paths admin sin llamar al backend', async () => {
+ const spy = vi.spyOn(globalThis, 'fetch');
+ const resp = await GET(ctx('admin/state'));
+ expect(resp.status).toBe(404);
+ expect(spy).not.toHaveBeenCalled();
+ expect(await resp.json()).toEqual({ ok: false, error: 'not_found' });
+ });
+
+ it('reenvía la cookie y propaga múltiples Set-Cookie', async () => {
+ const upstream = new Response('{"ok":true}', { status: 200 });
+ upstream.headers.append('set-cookie', 'a=1; Path=/');
+ upstream.headers.append('set-cookie', 'b=2; Path=/');
+ const spy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(upstream);
+ const resp = await GET(ctx('event', { cookie: 'nan_session=xyz' }));
+ expect(spy).toHaveBeenCalledOnce();
+ const [target, reqInit] = spy.mock.calls[0] as [string, RequestInit];
+ expect(target).toBe('https://api.test/api/hackaton/event');
+ expect((reqInit.headers as Headers).get('cookie')).toBe('nan_session=xyz');
+ expect(resp.headers.getSetCookie()).toEqual(['a=1; Path=/', 'b=2; Path=/']);
+ });
+
+ it('devuelve 500 si el upstream falla', async () => {
+ vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('boom'));
+ vi.spyOn(console, 'error').mockImplementation(() => {});
+ const resp = await POST(ctx('register', { method: 'POST', body: '{}' }));
+ expect(resp.status).toBe(500);
+ expect(await resp.json()).toEqual({ ok: false, error: 'server_error' });
+ });
+});
+
+describe('auth login-request proxy', () => {
+ afterEach(() => vi.restoreAllMocks());
+
+ it('reenvía la IP real en ambos headers (X-Forwarded-For y CF-Connecting-IP)', async () => {
+ const spy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('{"ok":true}', { status: 200 }));
+ const request = new Request('https://nan.builders/api/auth/login-request', {
+ method: 'POST',
+ headers: { 'cf-connecting-ip': '1.2.3.4' },
+ body: '{"email":"a@b.c"}',
+ });
+ const resp = await LOGIN_POST({ request } as never);
+ expect(resp.status).toBe(200);
+ const [target, reqInit] = spy.mock.calls[0] as [string, RequestInit];
+ expect(target).toBe('https://api.test/api/auth/login/request');
+ const h = reqInit.headers as Headers;
+ expect(h.get('x-forwarded-for')).toBe('1.2.3.4');
+ expect(h.get('cf-connecting-ip')).toBe('1.2.3.4');
+ });
+});
From 7f5fc557d8671cb4ef006f3d4d4d8150873092d9 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Sa=C3=BAl=20G=C3=B3mez=20Jim=C3=A9nez?=
Date: Fri, 5 Jun 2026 01:05:14 +0200
Subject: [PATCH 7/8] refactor(hackaton): elimina la pantalla y los textos de
check-in
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Acompaña la eliminación del check-in en el backend: el participante
inscrito pasa directo a la fase de equipos, sin pantalla de confirmación.
- borrados checkin.astro y CheckinForm.tsx
- hackaton.astro: panel sin ramas de check-in
- lib/hackaton.ts: fuera checkin_at del tipo Participant
- i18n es/en: quitadas claves phase.checkin*, el bloque checkin y limpiados
poolExplain y reassignInfo
Co-Authored-By: Claude Opus 4.8
---
i18n/en.json | 18 ++-------------
i18n/es.json | 18 ++-------------
src/components/hackaton/CheckinForm.tsx | 24 --------------------
src/lib/hackaton.ts | 1 -
src/pages/hackaton.astro | 11 ++--------
src/pages/hackaton/checkin.astro | 29 -------------------------
6 files changed, 6 insertions(+), 95 deletions(-)
delete mode 100644 src/components/hackaton/CheckinForm.tsx
delete mode 100644 src/pages/hackaton/checkin.astro
diff --git a/i18n/en.json b/i18n/en.json
index 77128138..67dfeb48 100644
--- a/i18n/en.json
+++ b/i18n/en.json
@@ -347,11 +347,6 @@
"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.",
- "checkinTitle": "Confirm your attendance",
- "checkin": "Registration is closed. Now it's time to check in to secure your seat.",
- "checkinCta": "Check in",
- "checkinDoneTitle": "Check-in confirmed",
- "checkinDone": "You've confirmed your attendance and your seat is secured. We'll reach out on Discord with the next steps.",
"votingTitle": "Voting is open",
"voting": "Submissions are live. Head to the gallery, browse the projects and vote for your favorite.",
"votingCta": "View projects & vote",
@@ -371,7 +366,7 @@
"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 a confirmed participant doesn't check in or drops out, whoever is in the pool gets in by registration order.",
+ "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."
@@ -401,15 +396,6 @@
"errorNetwork": "Network error. Try again.",
"errorRate": "Too many attempts. Wait a moment and try again."
},
- "checkin": {
- "title": "Check-in",
- "intro": "Confirm your attendance to secure your seat.",
- "button": "Confirm attendance",
- "submitting": "Confirming…",
- "done": "Attendance confirmed ✓",
- "closed": "The check-in window is not open.",
- "error": "Couldn't confirm attendance. Try again."
- },
"me": {
"title": "My team",
"noTeam": "Teams haven't been announced yet.",
@@ -432,7 +418,7 @@
"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) who checked in, and adds them to your team in their place.",
+ "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."
]
diff --git a/i18n/es.json b/i18n/es.json
index 4aa3689c..b87c3ce8 100644
--- a/i18n/es.json
+++ b/i18n/es.json
@@ -347,11 +347,6 @@
"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.",
- "checkinTitle": "Confirma tu asistencia",
- "checkin": "Las inscripciones están cerradas. Ahora toca hacer check-in para asegurar tu plaza.",
- "checkinCta": "Hacer check-in",
- "checkinDoneTitle": "Check-in confirmado",
- "checkinDone": "Ya has confirmado tu asistencia y tu plaza está asegurada. Te avisaremos por Discord con los siguientes pasos.",
"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",
@@ -371,7 +366,7 @@
"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 alguien confirmado no hace check-in o se da de baja, quien está en la pool entra por orden de inscripción.",
+ "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."
@@ -401,15 +396,6 @@
"errorNetwork": "Error de red. Inténtalo de nuevo.",
"errorRate": "Demasiados intentos. Espera un momento e inténtalo de nuevo."
},
- "checkin": {
- "title": "Check-in",
- "intro": "Confirma tu asistencia para asegurar tu plaza.",
- "button": "Confirmar asistencia",
- "submitting": "Confirmando…",
- "done": "Asistencia confirmada ✓",
- "closed": "La ventana de check-in no está abierta.",
- "error": "No se pudo confirmar la asistencia. Inténtalo de nuevo."
- },
"me": {
"title": "Mi equipo",
"noTeam": "Aún no se han anunciado los equipos.",
@@ -432,7 +418,7 @@
"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) que hizo check-in y lo añade a tu equipo en su lugar.",
+ "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."
]
diff --git a/src/components/hackaton/CheckinForm.tsx b/src/components/hackaton/CheckinForm.tsx
deleted file mode 100644
index b915cdc8..00000000
--- a/src/components/hackaton/CheckinForm.tsx
+++ /dev/null
@@ -1,24 +0,0 @@
-import { useState } from 'preact/hooks';
-
-export default function CheckinForm({ t }: { t: { button: string; submitting: string; done: string; closed: string; error: string } }) {
- const [state, setState] = useState<'idle' | 'busy' | 'done' | 'closed' | 'error'>('idle');
- async function checkin() {
- setState('busy');
- let resp: Response;
- try {
- resp = await fetch('/api/hackaton/checkin', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
- } catch { setState('error'); return; }
- if (resp.ok) { setState('done'); return; }
- if (resp.status === 400) { setState('closed'); return; }
- setState('error');
- }
- if (state === 'done') return
{t.done}
;
- if (state === 'closed') return
{t.closed}
;
- if (state === 'error') return
{t.error}
;
- return (
-
- );
-}
diff --git a/src/lib/hackaton.ts b/src/lib/hackaton.ts
index c10c01d3..8a7684f8 100644
--- a/src/lib/hackaton.ts
+++ b/src/lib/hackaton.ts
@@ -39,7 +39,6 @@ export interface Participant {
id: string;
name: string;
status?: string;
- checkin_at?: string | null;
discord_user?: string;
specialty?: string;
level?: string;
diff --git a/src/pages/hackaton.astro b/src/pages/hackaton.astro
index 2227a120..308dac1f 100644
--- a/src/pages/hackaton.astro
+++ b/src/pages/hackaton.astro
@@ -70,25 +70,18 @@ const closedNote = String(cap.closedNote ?? '')
.replaceAll('{res}', String(reserve));
// Panel para participantes ya inscritos en fases posteriores a la inscripción.
-// Depende de si toca hacer check-in y de si ya lo han hecho (checkin_at).
const meUrl = locale === 'en' ? '/hackaton/me?lang=en' : '/hackaton/me';
-const checkinUrl = locale === 'en' ? '/hackaton/checkin?lang=en' : '/hackaton/checkin';
const projectsUrl = locale === 'en' ? '/hackaton/projects?lang=en' : '/hackaton/projects';
const leaderboardUrl = locale === 'en' ? '/hackaton/leaderboard?lang=en' : '/hackaton/leaderboard';
-const hasCheckedIn = !!myParticipant?.checkin_at;
-const needsCheckin = status === 'checkin' && !hasCheckedIn;
let participantPanel: { title: string; body: string; href: string; cta: string };
-if (needsCheckin) {
- participantPanel = { title: phase.checkinTitle, body: phase.checkin, href: checkinUrl, cta: phase.checkinCta };
-} else if (status === 'checkin' && hasCheckedIn) {
- participantPanel = { title: phase.checkinDoneTitle, body: phase.checkinDone, href: meUrl, cta: String(already.cta ?? '') };
-} else if (status === 'voting') {
+if (status === 'voting') {
// Durante la votación, el CTA lleva a la galería de proyectos a votar.
participantPanel = { title: phase.votingTitle, body: phase.voting, href: projectsUrl, cta: phase.votingCta };
} else if (status === 'closed') {
// Cerrado: el CTA lleva a la clasificación final.
participantPanel = { title: phase.resultsTitle, body: phase.results, href: leaderboardUrl, cta: phase.resultsCta };
} else {
+ // building / submission: estado y equipo.
participantPanel = { title: phase.inProgressTitle, body: phase.inProgress, href: meUrl, cta: String(already.cta ?? '') };
}
diff --git a/src/pages/hackaton/checkin.astro b/src/pages/hackaton/checkin.astro
deleted file mode 100644
index 1fd808cc..00000000
--- a/src/pages/hackaton/checkin.astro
+++ /dev/null
@@ -1,29 +0,0 @@
----
-import Page from '../../layouts/Page.astro';
-import CheckinForm from '../../components/hackaton/CheckinForm.tsx';
-import LoginForm from '../../components/hackaton/LoginForm.tsx';
-import { t, tObj, withLang } from '../../lib/i18n';
-
-export const prerender = false;
-
-const locale = Astro.url.searchParams.get('lang') === 'en' ? 'en' : 'es';
-const hasSession = Astro.request.headers.get('cookie')?.includes('nan_session') ?? false;
-const pageTitle = `${t('hackaton.checkin.title', locale)} — nan.builders`;
----
-
-
-
-
- {t('hackaton.backToLanding', locale)}
-
-
{t('hackaton.checkin.title', locale)}
-
{t('hackaton.checkin.intro', locale)}
-
- {!hasSession ? (
-
- ) : (
-
- )}
-
-
-
From 62201a361edbb83fc9c388b74db431ef9ad120e0 Mon Sep 17 00:00:00 2001
From: barckcode
Date: Fri, 5 Jun 2026 20:29:02 +0100
Subject: [PATCH 8/8] fix(hackaton): a11y labels, image alt text, narrow checks
type
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Three blocking issues from PR review:
- SubmissionForm: replace sr-only-placeholder pattern with visible labels
consistent with RegisterForm (labelCls + violet uppercase). Field context
stays visible while typing — WCAG 1.3.1 + 3.3.2.
- SubmissionForm: narrow b.data and existing into Checks via toChecks()
runtime validator. Removes unsafe `as unknown as Checks` cast and `any`
destructuring; UI no longer breaks silently if the backend response shape
drifts.
- projects.astro: meaningful alt on project cover image
(`title — team_name`) — WCAG 1.1.1 for the vote gallery flow. Also adds
`decoding="async"`.
Tests: 148 pass, astro check 0 errors, npm run build clean.
---
src/components/hackaton/SubmissionForm.tsx | 91 +++++++++++++++-------
src/pages/hackaton/projects.astro | 2 +-
2 files changed, 62 insertions(+), 31 deletions(-)
diff --git a/src/components/hackaton/SubmissionForm.tsx b/src/components/hackaton/SubmissionForm.tsx
index 103012a1..4f87186b 100644
--- a/src/components/hackaton/SubmissionForm.tsx
+++ b/src/components/hackaton/SubmissionForm.tsx
@@ -21,6 +21,22 @@ interface Existing {
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 ?? '',
@@ -31,8 +47,8 @@ export default function SubmissionForm({ t, existing }: { t: Record((existing as unknown as Checks) ?? null);
+ // 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('');
@@ -65,10 +81,10 @@ export default function SubmissionForm({ t, existing }: { t: Record null) as { ok?: boolean; data?: any; error?: string } | null;
+ const b = await resp.json().catch(() => null) as { ok?: boolean; data?: unknown; error?: string } | null;
setBusy(false);
if (resp.ok && b?.ok) {
- setChecks(b.data);
+ setChecks(toChecks(b.data));
setSubmitted(true);
return;
}
@@ -79,25 +95,36 @@ export default function SubmissionForm({ t, existing }: { t: Record
+