From e0adc1f2f25b2e8cf280a519aa46d91ff4976e5a Mon Sep 17 00:00:00 2001 From: barckcode Date: Thu, 15 Oct 2026 17:38:13 +0200 Subject: [PATCH 1/2] fix(events): explain entitlement_required with a link to the community plan cloud-api now gates hackathon register, submission PUT, reassign and vote behind a plan (Community, Inference or Premium) and answers an account without one with 403 entitlement_required. The register form showed 'not eligible', and the vote, submission and reassign forms a generic error. All four now show a membership notice (EN/ES) linking to cloud.nan.builders/signup?plan=community; signed-in accounts are sent on to /welcome?plan=community by the platform. not_eligible and the other codes keep their messages. Co-Authored-By: Claude Opus 5.5 --- i18n/en.json | 4 + i18n/es.json | 4 + src/components/events/MembershipRequired.tsx | 22 +++ src/components/events/ReassignForm.tsx | 12 +- src/components/events/RegisterForm.tsx | 36 +++-- src/components/events/SubmissionForm.tsx | 10 +- src/components/events/VoteButton.tsx | 28 ++-- src/lib/eventEntitlement.ts | 24 +++ src/pages/events/[slug]/_index.astro | 4 +- src/pages/events/[slug]/_me.astro | 4 +- src/pages/events/[slug]/_projects.astro | 4 +- src/pages/events/[slug]/_submission.astro | 4 +- src/tests/pages/eventsEntitlement.test.ts | 155 +++++++++++++++++++ 13 files changed, 286 insertions(+), 25 deletions(-) create mode 100644 src/components/events/MembershipRequired.tsx create mode 100644 src/lib/eventEntitlement.ts create mode 100644 src/tests/pages/eventsEntitlement.test.ts diff --git a/i18n/en.json b/i18n/en.json index e7d218a7..92f0a437 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -239,6 +239,10 @@ "total": "Total", "winner": "🏆 Winner", "notPrizeNote": "Not eligible for the prize." + }, + "membership": { + "message": "Hackathons are for NaN members. Join the community (from 14.99€/month) to take part.", + "cta": "Join the community" } }, "nan": { diff --git a/i18n/es.json b/i18n/es.json index a932b8ed..76a1879d 100644 --- a/i18n/es.json +++ b/i18n/es.json @@ -239,6 +239,10 @@ "total": "Total", "winner": "🏆 Ganador", "notPrizeNote": "No opta al premio." + }, + "membership": { + "message": "Los hackathons son para miembros de NaN. Únete a la comunidad (desde 14,99 €/mes) para participar.", + "cta": "Únete a la comunidad" } }, "nan": { diff --git a/src/components/events/MembershipRequired.tsx b/src/components/events/MembershipRequired.tsx new file mode 100644 index 00000000..7228ea74 --- /dev/null +++ b/src/components/events/MembershipRequired.tsx @@ -0,0 +1,22 @@ +import { HACKATHON_UNLOCK_URL, type MembershipStrings } from '../../lib/eventEntitlement'; + +/** + * Shown when cloud-api answers `entitlement_required`: the account has no + * plan that unlocks hackathons. Says so and links to the community plan. + */ +export default function MembershipRequired({ t, compact = false }: { t: MembershipStrings; compact?: boolean }) { + if (compact) { + return ( + + {t.message}{' '} + {t.cta} → + + ); + } + return ( + + ); +} diff --git a/src/components/events/ReassignForm.tsx b/src/components/events/ReassignForm.tsx index 600ff17c..3cd7057a 100644 --- a/src/components/events/ReassignForm.tsx +++ b/src/components/events/ReassignForm.tsx @@ -1,4 +1,6 @@ import { useState } from 'preact/hooks'; +import { isEntitlementRequired, type MembershipStrings } from '../../lib/eventEntitlement'; +import MembershipRequired from './MembershipRequired'; interface Member { id: string; @@ -30,15 +32,18 @@ export default function ReassignForm({ ctaLabel, info, labels, + membership, }: { slug: string; members: Member[]; ctaLabel: string; info: ReassignInfo; labels: ReassignLabels; + /** Notice for `entitlement_required` (no plan that unlocks hackathons). */ + membership: MembershipStrings; }) { const [ghostId, setGhostId] = useState(''); - const [state, setState] = useState<'idle' | 'busy' | 'done' | 'error'>('idle'); + const [state, setState] = useState<'idle' | 'busy' | 'done' | 'error' | 'membership'>('idle'); const [result, setResult] = useState(''); // La convocatoria que se ejecuta cambia el equipo (sale el ausente, entra la // reserva), y la lista de compañeros viene del servidor: sin recargar seguiría @@ -63,6 +68,10 @@ export default function ReassignForm({ setState('done'); return; } + if (isEntitlementRequired(body?.error)) { + setState('membership'); + return; + } setState('error'); return; } @@ -131,6 +140,7 @@ export default function ReassignForm({ ))} {state === 'error' && } + {state === 'membership' && } diff --git a/src/components/events/SubmissionForm.tsx b/src/components/events/SubmissionForm.tsx index cb844c31..bef99776 100644 --- a/src/components/events/SubmissionForm.tsx +++ b/src/components/events/SubmissionForm.tsx @@ -2,6 +2,8 @@ import { useState } from 'preact/hooks'; import type { TargetedSubmitEvent } from 'preact'; import { optionLabel } from '../../lib/i18n'; import type { Check } from '../../lib/events'; +import { isEntitlementRequired, type MembershipStrings } from '../../lib/eventEntitlement'; +import MembershipRequired from './MembershipRequired'; type FieldMode = 'required' | 'optional' | 'hidden'; interface Fields { @@ -43,6 +45,8 @@ function toResult(raw: unknown): Result | null { interface Props { slug: string; t: Record; + /** Notice for `entitlement_required` (no plan that unlocks hackathons). */ + membership: MembershipStrings; existing?: Existing | null; /** Modo de cada campo opcional del evento (SPEC §3.3). `title` y `public_url` son siempre obligatorios. */ fields: Fields; @@ -70,7 +74,7 @@ interface Props { * (equipo o participante) a partir de la sesión. */ export default function SubmissionForm({ - slug, t, existing, fields, format, hasParticipant, checks, autoMax, discordMode, specialties, levels, options, + slug, t, membership, existing, fields, format, hasParticipant, checks, autoMax, discordMode, specialties, levels, options, }: Props) { const [f, setF] = useState({ title: existing?.title ?? '', @@ -86,6 +90,7 @@ export default function SubmissionForm({ const [submitted, setSubmitted] = useState(Boolean(existing)); const [busy, setBusy] = useState(false); const [error, setError] = useState(''); + const [needsPlan, setNeedsPlan] = useState(false); const [withdrawn, setWithdrawn] = useState(false); const askParticipant = format === 'solo' && !hasParticipant && !submitted; const mediaConfigured = fields.image_url !== 'hidden' || fields.video_url !== 'hidden'; @@ -110,6 +115,7 @@ export default function SubmissionForm({ e.preventDefault(); setBusy(true); setError(''); + setNeedsPlan(false); const body: Record = { ...f }; if (askParticipant) { const part: Record = { name: p.name.trim() }; @@ -141,6 +147,7 @@ export default function SubmissionForm({ const code = b?.error ?? ''; const data = b?.data ?? {}; if (resp.status === 401) setError(t.errorLogin); + else if (isEntitlementRequired(code)) setNeedsPlan(true); else if (code === 'submission_closed') setError(t.errorClosed); else if (code === 'validation_failed') { const names = (Array.isArray(data.fields) ? data.fields as string[] : []).map((k) => fieldLabel[k] ?? k); @@ -297,6 +304,7 @@ export default function SubmissionForm({ )} {error && } + {needsPlan && } {result && checks.length > 0 && (

{t.checks}: {result.auto_points}/{autoMax}

diff --git a/src/components/events/VoteButton.tsx b/src/components/events/VoteButton.tsx index 97fc9efa..458401d4 100644 --- a/src/components/events/VoteButton.tsx +++ b/src/components/events/VoteButton.tsx @@ -1,4 +1,18 @@ import { useState, useEffect } from 'preact/hooks'; +import { isEntitlementRequired, type MembershipStrings } from '../../lib/eventEntitlement'; +import MembershipRequired from './MembershipRequired'; + +type VoteState = 'idle' | 'busy' | 'voted' | 'self' | 'login' | 'not_eligible' | 'closed' | 'membership' | 'error'; + +/** State after a failed vote. `entitlement_required` = no plan that unlocks hackathons. */ +export function voteOutcome(status: number, code: string): VoteState { + if (status === 401) return 'login'; + if (isEntitlementRequired(code)) return 'membership'; + if (code === 'self_vote') return 'self'; + if (code === 'not_eligible') return 'not_eligible'; + if (code === 'voting_closed') return 'closed'; + return 'error'; +} /** * Botón de voto de una entrega (SPEC §6.3 `POST /api/events/{slug}/vote`). @@ -6,13 +20,13 @@ import { useState, useEffect } from 'preact/hooks'; * `nan:voted` para que solo una muestre el ✓. */ export default function VoteButton( - { slug, submissionId, votedSubmissionId, loginHref, t }: - { slug: string; submissionId: string; votedSubmissionId?: string | null; loginHref: string; t: Record } + { slug, submissionId, votedSubmissionId, loginHref, t, membership }: + { slug: string; submissionId: string; votedSubmissionId?: string | null; loginHref: string; t: Record; membership: MembershipStrings } ) { // ¿Ya votó (a cualquier entrega)? ¿Es ESTA la entrega votada? const hasVoted = Boolean(votedSubmissionId); const initial: 'idle' | 'voted' = votedSubmissionId === submissionId ? 'voted' : 'idle'; - const [state, setState] = useState<'idle' | 'busy' | 'voted' | 'self' | 'login' | 'not_eligible' | 'closed' | 'error'>(initial); + const [state, setState] = useState(initial); useEffect(() => { // Otra tarjeta registró un voto: si fue para esta entrega, marca ✓; si fue @@ -39,18 +53,14 @@ export default function VoteButton( return; } const body = await resp.json().catch(() => null) as { error?: string } | null; - const code = body?.error ?? ''; - if (resp.status === 401) { setState('login'); return; } - if (code === 'self_vote') { setState('self'); return; } - if (code === 'not_eligible') { setState('not_eligible'); return; } - if (code === 'voting_closed') { setState('closed'); return; } - setState('error'); + setState(voteOutcome(resp.status, body?.error ?? '')); } if (state === 'voted') return {t.yourVote}; if (state === 'self') return {t.selfVote}; if (state === 'login') return {t.loginToVote}; if (state === 'not_eligible') return {t.notEligible}; + if (state === 'membership') return ; if (state === 'closed') return {t.votingClosed}; return (