Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
4 changes: 4 additions & 0 deletions i18n/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
22 changes: 22 additions & 0 deletions src/components/events/MembershipRequired.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<span role="alert" class="font-mono text-xs text-neutral-400">
{t.message}{' '}
<a href={HACKATHON_UNLOCK_URL} rel="noopener" class="text-violet-400 underline hover:text-violet-300">{t.cta} →</a>
</span>
);
}
return (
<div role="alert" class="border border-violet-500/40 bg-violet-500/10 p-4">
<p class="text-sm leading-relaxed text-white">{t.message}</p>
<a href={HACKATHON_UNLOCK_URL} rel="noopener" class="btn btn-primary mt-4">{t.cta} →</a>
</div>
);
}
12 changes: 11 additions & 1 deletion src/components/events/ReassignForm.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { useState } from 'preact/hooks';
import { isEntitlementRequired, type MembershipStrings } from '../../lib/eventEntitlement';
import MembershipRequired from './MembershipRequired';

interface Member {
id: string;
Expand Down Expand Up @@ -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
Expand All @@ -63,6 +68,10 @@ export default function ReassignForm({
setState('done');
return;
}
if (isEntitlementRequired(body?.error)) {
setState('membership');
return;
}
setState('error');
return;
}
Expand Down Expand Up @@ -131,6 +140,7 @@ export default function ReassignForm({
))}
</select>
{state === 'error' && <p role="alert" class="text-sm text-red-400 font-mono">{labels.errorSubmit}</p>}
{state === 'membership' && <MembershipRequired t={membership} />}
<button type="button" onClick={submit} disabled={state === 'busy' || !ghostId}
class="font-mono text-xs px-5 py-2 rounded-lg bg-violet-600 text-white hover:bg-violet-500 disabled:opacity-50">
{state === 'busy' ? labels.sending : ctaLabel}
Expand Down
36 changes: 26 additions & 10 deletions src/components/events/RegisterForm.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { useState } from 'preact/hooks';
import type { TargetedSubmitEvent } from 'preact';
import { optionLabel } from '../../lib/i18n';
import { isEntitlementRequired, type MembershipStrings } from '../../lib/eventEntitlement';
import MembershipRequired from './MembershipRequired';

export interface FormStrings {
name: string; discord: string; discordHelp: string; discordOptional: string;
Expand All @@ -15,7 +17,25 @@ type Status =
| { kind: 'idle' }
| { kind: 'submitting' }
| { kind: 'success'; reserve: boolean }
| { kind: 'error'; message: string };
| { kind: 'error'; message: string }
| { kind: 'membership' };

type ErrorKey = 'errorFull' | 'errorClosed' | 'errorEligible' | 'errorRate' | 'errorDiscord' | 'errorServer';

/**
* Which message a failed registration shows. `entitlement_required` (no plan
* that unlocks hackathons) is its own notice with a CTA; any other 403 keeps
* the "not eligible" message.
*/
export function registerErrorKey(status: number, code: string): ErrorKey | 'membership' {
if (isEntitlementRequired(code)) return 'membership';
if (code === 'registration_full') return 'errorFull';
if (code === 'registration_closed') return 'errorClosed';
if (status === 403 || code === 'not_eligible') return 'errorEligible';
if (status === 429 || code === 'rate_limited') return 'errorRate';
if (code === 'discord_required') return 'errorDiscord';
return 'errorServer';
}

/**
* Inscripción a un evento (SPEC §6.3 `POST /api/events/{slug}/register`).
Expand All @@ -24,9 +44,10 @@ type Status =
* no se pregunta). `options` traduce cada valor; los que no conoce (v3 los deja
* escribir a quien organiza) salen tal cual, con la inicial en mayúscula.
*/
export default function RegisterForm({ slug, t, meHref, discordMode, specialties, levels, options }: {
export default function RegisterForm({ slug, t, membership, meHref, discordMode, specialties, levels, options }: {
slug: string;
t: FormStrings;
membership: MembershipStrings;
meHref: string;
discordMode: 'required' | 'optional' | 'none';
specialties: string[];
Expand Down Expand Up @@ -74,14 +95,8 @@ export default function RegisterForm({ slug, t, meHref, discordMode, specialties
setStatus({ kind: 'success', reserve: !!body?.data?.is_reserve });
return;
}
let message = t.errorServer;
const code = body?.error ?? '';
if (code === 'registration_full') message = t.errorFull;
else if (code === 'registration_closed') message = t.errorClosed;
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 });
const key = registerErrorKey(resp.status, body?.error ?? '');
setStatus(key === 'membership' ? { kind: 'membership' } : { kind: 'error', message: t[key] });
}

if (status.kind === 'success') {
Expand Down Expand Up @@ -143,6 +158,7 @@ export default function RegisterForm({ slug, t, meHref, discordMode, specialties
</div>
)}
{err && <p role="alert" class="font-mono text-sm text-red-400">{err}</p>}
{status.kind === 'membership' && <MembershipRequired t={membership} />}
<button type="submit" disabled={submitting} class="btn btn-primary w-full disabled:opacity-50">
{submitting ? t.submitting : t.submit}
</button>
Expand Down
10 changes: 9 additions & 1 deletion src/components/events/SubmissionForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -43,6 +45,8 @@ function toResult(raw: unknown): Result | null {
interface Props {
slug: string;
t: Record<string, string>;
/** 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;
Expand Down Expand Up @@ -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 ?? '',
Expand All @@ -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';
Expand All @@ -110,6 +115,7 @@ export default function SubmissionForm({
e.preventDefault();
setBusy(true);
setError('');
setNeedsPlan(false);
const body: Record<string, unknown> = { ...f };
if (askParticipant) {
const part: Record<string, string> = { name: p.name.trim() };
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -297,6 +304,7 @@ export default function SubmissionForm({
)}
</div>
{error && <p role="alert" class="text-sm font-mono text-red-400">{error}</p>}
{needsPlan && <MembershipRequired t={membership} />}
{result && checks.length > 0 && (
<div class="mt-4 text-sm font-mono text-neutral-300">
<p>{t.checks}: {result.auto_points}/{autoMax}</p>
Expand Down
28 changes: 19 additions & 9 deletions src/components/events/VoteButton.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,32 @@
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`).
* Cada tarjeta es una isla independiente: se sincronizan con el evento
* `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<string, string> }
{ slug, submissionId, votedSubmissionId, loginHref, t, membership }:
{ slug: string; submissionId: string; votedSubmissionId?: string | null; loginHref: string; t: Record<string, string>; 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<VoteState>(initial);

useEffect(() => {
// Otra tarjeta registró un voto: si fue para esta entrega, marca ✓; si fue
Expand All @@ -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 <span class="font-mono text-xs text-violet-400">{t.yourVote}</span>;
if (state === 'self') return <span class="font-mono text-xs text-neutral-500">{t.selfVote}</span>;
if (state === 'login') return <a href={loginHref} class="font-mono text-xs text-violet-400">{t.loginToVote}</a>;
if (state === 'not_eligible') return <span class="font-mono text-xs text-neutral-500">{t.notEligible}</span>;
if (state === 'membership') return <MembershipRequired t={membership} compact />;
if (state === 'closed') return <span class="font-mono text-xs text-neutral-500">{t.votingClosed}</span>;
return (
<button onClick={vote} disabled={state === 'busy'}
Expand Down
24 changes: 24 additions & 0 deletions src/lib/eventEntitlement.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/**
* Hackathons are unlocked by a NaN plan (Community, Inference or Premium).
* cloud-api answers register / submission PUT / reassign / vote for an account
* without one with:
* 403 {"error":"entitlement_required","capability":"hackathons",
* "unlockedBy":["community","inference","premium"]}
* The events forms turn that into a notice linking to the community plan.
*/

/**
* Community is the cheapest plan that unlocks hackathons. The platform's
* /signup sends an account that is already signed in to /welcome?plan=community,
* which goes straight to checkout, so one link works for everyone.
*/
export const HACKATHON_UNLOCK_URL = 'https://cloud.nan.builders/signup?plan=community';

export interface MembershipStrings {
message: string;
cta: string;
}

export function isEntitlementRequired(code: string | null | undefined): boolean {
return code === 'entitlement_required';
}
4 changes: 3 additions & 1 deletion src/pages/events/[slug]/_index.astro
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
---
// Landing de un evento (SPEC §8.2): copy desde la API, CTA según fase/ventanas.
// El marcado sigue escrito con utilidades de Tailwind (tema → tokens, ver styles/global.css).
import type { MembershipStrings } from '../../../lib/eventEntitlement';
import '../../../styles/global.css';
import NanPage from '../../../layouts/NanPage.astro';
import EventHeader from '../../../components/events/EventHeader.astro';
Expand Down Expand Up @@ -39,6 +40,7 @@ const hasCapacity = capacity > 0;
const full = hasCapacity && ev.counts.registered >= capacity + reserve;

const form = tObj<FormStrings>('events.form', locale);
const membership = tObj<MembershipStrings>('events.membership', locale);
const options = tObj<Record<string, string>>('events.options', locale);
const already = tObj<Record<string, string>>('events.alreadyRegistered', locale);
const login = tObj<LoginStrings>('events.login', locale);
Expand Down Expand Up @@ -256,7 +258,7 @@ const closedNote = fill(cap.closedNote, { cap: capacity, res: reserve });
) : !sessionOk ? (
<LoginForm client:load t={login} />
) : (
<RegisterForm client:load slug={slug} t={form} meHref={meUrl}
<RegisterForm client:load slug={slug} t={form} membership={membership} meHref={meUrl}
discordMode={ev.registration.discord_user}
specialties={ev.registration.specialties ?? []} levels={ev.registration.levels ?? []}
options={options} />
Expand Down
4 changes: 3 additions & 1 deletion src/pages/events/[slug]/_me.astro
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
---
// Estado del participante (SPEC §8.2): equipo en `team`, ficha + entrega en `solo`.
import type { MembershipStrings } from '../../../lib/eventEntitlement';
import '../../../styles/global.css';
import NanPage from '../../../layouts/NanPage.astro';
import EventHeader from '../../../components/events/EventHeader.astro';
Expand All @@ -24,6 +25,7 @@ const isTeam = ev.format === 'team';
const options = tObj<Record<string, string>>('events.options', locale);
const label = (v?: string | null) => optionLabel(options, v);
const m = tObj<Record<string, string>>('events.me', locale);
const membership = tObj<MembershipStrings>('events.membership', locale);
const login = tObj<LoginStrings>('events.login', locale);
const submissionUrl = withLang(`/events/${slug}/submission`, locale);
const title = isTeam ? m.title : m.titleSolo;
Expand Down Expand Up @@ -86,7 +88,7 @@ const absent = (team?.members ?? []).filter((x) => x.id !== participant?.id);
{ev.phase === 'building' && absent.length > 0 && (
<div class="mt-6">
<ReassignForm client:visible slug={slug} members={absent.map((x) => ({ id: x.id, name: x.name, discord_user: x.discord_user ?? undefined }))}
ctaLabel={m.reassignCta} info={tObj('events.me.reassignInfo', locale)}
ctaLabel={m.reassignCta} membership={membership} info={tObj('events.me.reassignInfo', locale)}
labels={{ pending: m.reassignPending, filled: m.reassignFilled, noPool: m.reassignNoPool, already: m.reassignAlready, selectAbsent: m.reassignSelectAbsent, errorSubmit: m.reassignErrorSubmit, sending: m.reassignSending, refresh: m.reassignRefresh }} />
</div>
)}
Expand Down
4 changes: 3 additions & 1 deletion src/pages/events/[slug]/_projects.astro
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
---
// Galería pública de entregas (SPEC §8.2). Vacía hasta que `windows.gallery.visible`.
import type { MembershipStrings } from '../../../lib/eventEntitlement';
import '../../../styles/global.css';
import NanPage from '../../../layouts/NanPage.astro';
import EventHeader from '../../../components/events/EventHeader.astro';
Expand All @@ -25,6 +26,7 @@ const list = projects ?? [];
const myVote = me?.my_vote ?? null;
const canVote = ev.windows.voting.open;
const pr = tObj<Record<string, string>>('events.projects', locale);
const membership = tObj<MembershipStrings>('events.membership', locale);

// Solo http/https en enlaces de proyecto (defensa en profundidad frente a
// javascript:, data:, etc. aunque el backend ya valide).
Expand Down Expand Up @@ -61,7 +63,7 @@ const safeUrl = (u?: string): string | null =>
{canVote && (
<div class="mt-5">
<VoteButton client:visible slug={slug} submissionId={p.id} votedSubmissionId={myVote}
loginHref={withLang(`/events/${slug}`, locale)} t={pr} />
loginHref={withLang(`/events/${slug}`, locale)} t={pr} membership={membership} />
</div>
)}
</article>
Expand Down
Loading
Loading