From b7b715c1a1f93d6169e6194328821b2bd0a24cc8 Mon Sep 17 00:00:00 2001 From: barckcode Date: Wed, 19 Aug 2026 18:08:21 +0200 Subject: [PATCH 1/4] feat(community): restore public signup form on /community Reverts the form removal from 8d2ee6d. The community-tier Stripe checkout is the only path to the community tier (14.99/mo) - the admin UI only invites to inference - so removing the public form left no way for new members to join community. Restores the CommunitySignupForm Preact island, the cclose__form section and CSS on _community.astro, the 17 community.formStrings i18n keys (en + es), the styles/global.css import, and the waitlistClient.ts comment about the third copy of isValidEmail/REGIONS being back. Both /community and /es/community render the form again. This is how the 22 existing community members originally signed up (before 2026-07-30). 670 tests pass - astro check 0/0/0 - npm run build OK. --- i18n/en.json | 17 + i18n/es.json | 17 + .../landing/CommunitySignupForm.tsx | 305 ++++++++++++++++++ src/lib/waitlistClient.ts | 7 +- src/pages/_community.astro | 32 +- 5 files changed, 373 insertions(+), 5 deletions(-) create mode 100644 src/components/landing/CommunitySignupForm.tsx diff --git a/i18n/en.json b/i18n/en.json index 664b113..efe1973 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -18,6 +18,23 @@ "faqCancelQ": "Can I cancel?", "faqCancelA": "Yes, anytime from your member dashboard. No commitment.", "backToLanding": "← Back to nan.builders", + "emailLabel": "email", + "emailPlaceholder": "you@email.com", + "regionLabel": "region", + "regionDefault": "Select your region", + "regionEU": "Europe", + "regionUSA": "USA", + "regionLATAM": "LATAM", + "submit": "Join the community", + "submitting": "Processing…", + "redirecting": "Redirecting to checkout…", + "alreadySubscribed": "You're already in. If you didn't get a receipt, write to us at", + "honeypot": "Do not fill this field", + "errorInvalidEmail": "Invalid email. Check the format.", + "errorInvalidRegion": "Select a region.", + "errorRateLimited": "Too many attempts. Wait a minute.", + "errorServer": "Something went wrong. Try again in a moment.", + "errorNetwork": "No connection. Try again.", "footer": "Cancel anytime." }, "hackaton": { diff --git a/i18n/es.json b/i18n/es.json index fa0b424..7feccfe 100644 --- a/i18n/es.json +++ b/i18n/es.json @@ -18,6 +18,23 @@ "faqCancelQ": "¿Puedo cancelar?", "faqCancelA": "Sí, en cualquier momento desde tu panel de miembro. Sin permanencia.", "backToLanding": "← Volver a nan.builders", + "emailLabel": "email", + "emailPlaceholder": "tu@email.com", + "regionLabel": "región", + "regionDefault": "Selecciona tu región", + "regionEU": "Europa", + "regionUSA": "USA", + "regionLATAM": "LATAM", + "submit": "Únete a la comunidad", + "submitting": "Procesando…", + "redirecting": "Redirigiendo al pago…", + "alreadySubscribed": "Ya estás dentro. Si no recibiste el recibo, escríbenos a", + "honeypot": "Do not fill this field", + "errorInvalidEmail": "Email inválido. Revisa el formato.", + "errorInvalidRegion": "Selecciona una región.", + "errorRateLimited": "Demasiados intentos. Espera un minuto.", + "errorServer": "Algo falló. Vuelve a intentarlo en un momento.", + "errorNetwork": "Sin conexión. Vuelve a intentarlo.", "footer": "Cancela cuando quieras." }, "hackaton": { diff --git a/src/components/landing/CommunitySignupForm.tsx b/src/components/landing/CommunitySignupForm.tsx new file mode 100644 index 0000000..8393b1c --- /dev/null +++ b/src/components/landing/CommunitySignupForm.tsx @@ -0,0 +1,305 @@ +import { useState } from 'preact/hooks'; +import type { TargetedSubmitEvent } from 'preact'; + +const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +const BLOCKED_EMAIL_DOMAINS: ReadonlySet = new Set([ + 'example.com', + 'example.net', + 'example.org', + 'test.com', + 'mail.com', +]); + +const BLOCKED_EMAIL_TLDS: readonly string[] = [ + 'test', + 'invalid', + 'localhost', + 'example', +]; + +function isBlockedEmailDomain(email: string): boolean { + const at = email.lastIndexOf('@'); + if (at === -1) return false; + const domain = email.slice(at + 1); + if (BLOCKED_EMAIL_DOMAINS.has(domain)) return true; + const lastDot = domain.lastIndexOf('.'); + const tld = lastDot === -1 ? domain : domain.slice(lastDot + 1); + return BLOCKED_EMAIL_TLDS.includes(tld); +} + +function normalizeEmail(value: string): string { + return value.trim().toLowerCase(); +} + +function isValidEmail(value: string): boolean { + const email = normalizeEmail(value); + if (email.length === 0 || email.length > 254) return false; + if (!EMAIL_REGEX.test(email)) return false; + if (isBlockedEmailDomain(email)) return false; + return true; +} + +const REGIONS = ['EU', 'LATAM', 'USA'] as const; +type Region = (typeof REGIONS)[number]; + +function isRegion(value: string): value is Region { + return (REGIONS as readonly string[]).includes(value); +} + +export interface CommunityTranslations { + emailLabel: string; + emailPlaceholder: string; + regionLabel: string; + regionEU: string; + regionUSA: string; + regionLATAM: string; + regionDefault: string; + submit: string; + submitting: string; + redirecting: string; + alreadySubscribed: string; + honeypot: string; + errorInvalidEmail: string; + errorInvalidRegion: string; + errorRateLimited: string; + errorServer: string; + errorNetwork: string; +} + +interface Props { + t: CommunityTranslations; +} + +type Status = + | { kind: 'idle' } + | { kind: 'submitting' } + | { kind: 'redirecting' } + | { kind: 'already' } + | { kind: 'error'; message: string }; + +export default function CommunitySignupForm({ t }: Props) { + const [email, setEmail] = useState(''); + const [region, setRegion] = useState(''); + const [honeypot, setHoneypot] = useState(''); + const [status, setStatus] = useState({ kind: 'idle' }); + + async function onSubmit(e: TargetedSubmitEvent) { + e.preventDefault(); + + // Honeypot: bots get a fake-success response and we don't call the API. + if (honeypot.trim() !== '') { + setStatus({ kind: 'redirecting' }); + return; + } + + if (!isValidEmail(email)) { + setStatus({ kind: 'error', message: t.errorInvalidEmail }); + return; + } + if (!isRegion(region)) { + setStatus({ kind: 'error', message: t.errorInvalidRegion }); + return; + } + + setStatus({ kind: 'submitting' }); + + let response: Response; + try { + response = await fetch('/api/community-signup', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + email: normalizeEmail(email), + region, + _hp: honeypot, + }), + }); + } catch { + setStatus({ kind: 'error', message: t.errorNetwork }); + return; + } + + let body: unknown = null; + try { + body = await response.json(); + } catch { + body = null; + } + + if ( + response.status === 200 && + body && + typeof body === 'object' && + (body as { ok?: unknown }).ok === true && + typeof (body as { url?: unknown }).url === 'string' + ) { + const url = (body as { url: string }).url; + setStatus({ kind: 'redirecting' }); + window.location.href = url; + return; + } + + if (response.status === 409) { + setStatus({ kind: 'already' }); + return; + } + + const errorCode = + body && typeof body === 'object' && typeof (body as { error?: unknown }).error === 'string' + ? (body as { error: string }).error + : ''; + + let message = t.errorServer; + switch (errorCode) { + case 'invalid_email': + message = t.errorInvalidEmail; + break; + case 'invalid_region': + message = t.errorInvalidRegion; + break; + case 'rate_limited': + message = t.errorRateLimited; + break; + default: + message = t.errorServer; + } + setStatus({ kind: 'error', message }); + } + + if (status.kind === 'already') { + return ( +
+

+ // already a member +

+

+ {t.alreadySubscribed}{' '} + + hello@nan.builders + +

+
+ ); + } + + const submitting = status.kind === 'submitting'; + const redirecting = status.kind === 'redirecting'; + const disabled = submitting || redirecting; + const errorMsg = status.kind === 'error' ? status.message : null; + + return ( +
+ + +
+
+ + setEmail((e.currentTarget as HTMLInputElement).value)} + placeholder={t.emailPlaceholder} + class="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" + /> +
+ +
+ + +
+ + {errorMsg && ( + + )} + + +
+
+ ); +} diff --git a/src/lib/waitlistClient.ts b/src/lib/waitlistClient.ts index ecda5f0..7d82259 100644 --- a/src/lib/waitlistClient.ts +++ b/src/lib/waitlistClient.ts @@ -10,10 +10,9 @@ * el de `lib/waitlist.ts`. Si divergen, el formulario acepta cosas que el * servidor rechaza (o al revés) y el usuario ve un error que no entiende. * - * La tercera copia de `isValidEmail` y `REGIONS` vivía en - * `components/landing/CommunitySignupForm.tsx` y se fue con el formulario de la - * página de comunidad, así que quedan dos: esta y la del servidor. Conviene no - * añadir una tercera. + * OJO, deuda conocida: `components/landing/CommunitySignupForm.tsx` tiene su + * PROPIA copia de `isValidEmail` y `REGIONS`, así que hoy hay tres. Unificarlas + * es trabajo aparte, pero conviene no añadir una cuarta. */ const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; diff --git a/src/pages/_community.astro b/src/pages/_community.astro index 2c4feb0..e3be99f 100644 --- a/src/pages/_community.astro +++ b/src/pages/_community.astro @@ -1,11 +1,37 @@ --- import NanPage from '../layouts/NanPage.astro'; -import { getLang, useT } from '../lib/i18n'; +import CommunitySignupForm from '../components/landing/CommunitySignupForm.tsx'; +import { getLang, useT, t } from '../lib/i18n'; +// La isla del formulario sigue escrita con utilidades de Tailwind; el tema de +// Tailwind está apuntado a nuestros tokens (ver styles/global.css). +import '../styles/global.css'; import { MEMBERS_NOW, MEMBERS_GOAL, membersPct } from '../lib/members'; const lang = getLang(Astro.url); const tt = useT(lang).community; +// El formulario de alta es de este repo, no venía en el rediseño: se conserva +// tal cual, con sus strings, porque es el punto de conversión de la página. +const formStrings = { + emailLabel: t('community.emailLabel', lang), + emailPlaceholder: t('community.emailPlaceholder', lang), + regionLabel: t('community.regionLabel', lang), + regionEU: t('community.regionEU', lang), + regionUSA: t('community.regionUSA', lang), + regionLATAM: t('community.regionLATAM', lang), + regionDefault: t('community.regionDefault', lang), + submit: t('community.submit', lang), + submitting: t('community.submitting', lang), + redirecting: t('community.redirecting', lang), + alreadySubscribed: t('community.alreadySubscribed', lang), + honeypot: t('community.honeypot', lang), + errorInvalidEmail: t('community.errorInvalidEmail', lang), + errorInvalidRegion: t('community.errorInvalidRegion', lang), + errorRateLimited: t('community.errorRateLimited', lang), + errorServer: t('community.errorServer', lang), + errorNetwork: t('community.errorNetwork', lang), +}; + const home = lang === 'es' ? '/es' : '/'; const joinHref = `${home}#pricing`; @@ -180,6 +206,9 @@ const pct = membersPct();

{tt.closeTitle}

{tt.closeText}

+
+ +
@@ -315,6 +344,7 @@ const pct = membersPct(); font-family: var(--font-serif); font-size: clamp(17px, 1.3vw, 20px); line-height: 1.55; } .cclose .btn { margin-top: var(--space-8); } + .cclose__form { margin-top: var(--space-8); max-width: 460px; } @media (max-width: 760px) { .pillars { grid-template-columns: 1fr; } From b3f2755e3019479adadada48f48a532b6aa6348c Mon Sep 17 00:00:00 2001 From: barckcode Date: Wed, 19 Aug 2026 18:30:05 +0200 Subject: [PATCH 2/4] fix(pricing): point community card CTA to /community form, not the portal The nan_community pricing card linked to https://cloud.nan.builders/ (the member portal login), which has no signup form. Now that the public community signup form is restored on /community, the CTA must send visitors there so they can actually sign up. Region-aware: /community (en) or /es/community (es). --- src/components/nan/home/Pricing.astro | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/nan/home/Pricing.astro b/src/components/nan/home/Pricing.astro index 46e142a..dd47a91 100644 --- a/src/components/nan/home/Pricing.astro +++ b/src/components/nan/home/Pricing.astro @@ -52,7 +52,7 @@ const tiers = [ includes: tt.communityIncludes, cond: tt.communityCond, cta: tt.communityCta, - href: 'https://cloud.nan.builders/', + href: lang === 'es' ? '/es/community' : '/community', primary: false, }, ]; From 68f269126075a397b71705395e2b472a4ff97ab9 Mon Sep 17 00:00:00 2001 From: barckcode Date: Wed, 19 Aug 2026 18:59:47 +0200 Subject: [PATCH 3/4] fix(community): address QA/UX/Security review blockers on signup form QA blockers: - Add CommunitySignupForm logic tests (resolveSignupResponse + errorMessageFor) in src/tests/lib/communitySignup.test.ts (18 cases) - Extract response classification into lib/communitySignup.ts as pure functions so the component's error mapping is testable in node - Extend i18nParity.test.ts to cover root-level community + hackaton subtrees, not just nan.* (catches en-only keys rendering as raw paths) - Assert the community pricing card CTA href in pricing.test.ts UX blockers: - Button now uses the .btn .btn-primary system classes instead of raw Tailwind utilities (the un-layered button reset in tokens.css was cancelling bg-violet-600, leaving the submit button unstyled) - Break the /community <-> #pricing loop: hero CTA scrolls to #signup (the form) on the same page, pricing card routes to /community#signup - Remove the client-side honeypot short-circuit: it hung real users (password manager / autofill) on a spinner with no recovery, and did not stop bots that POST directly. The server is authoritative - already_subscribed no longer destroys the form: it renders an inline banner above the still-mounted form so the user can retry with another email - Add alreadyMemberLabel i18n key (was a hardcoded English literal) 688 tests pass - astro check 0/0/0 - npm run build OK. --- i18n/en.json | 5 +- i18n/es.json | 5 +- .../landing/CommunitySignupForm.tsx | 108 +++++++----------- src/components/nan/home/Pricing.astro | 2 +- src/lib/communitySignup.ts | 55 +++++++++ src/pages/_community.astro | 6 +- src/tests/landing/i18nParity.test.ts | 11 ++ src/tests/landing/pricing.test.ts | 8 ++ src/tests/lib/communitySignup.test.ts | 90 +++++++++++++++ 9 files changed, 216 insertions(+), 74 deletions(-) create mode 100644 src/tests/lib/communitySignup.test.ts diff --git a/i18n/en.json b/i18n/en.json index efe1973..64c0154 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -35,7 +35,8 @@ "errorRateLimited": "Too many attempts. Wait a minute.", "errorServer": "Something went wrong. Try again in a moment.", "errorNetwork": "No connection. Try again.", - "footer": "Cancel anytime." + "footer": "Cancel anytime.", + "alreadyMemberLabel": "// already a member" }, "hackaton": { "pageTitle": "#1 NaN Hackathon", @@ -879,4 +880,4 @@ "closeCta": "Join the community" } } -} \ No newline at end of file +} diff --git a/i18n/es.json b/i18n/es.json index 7feccfe..a38388f 100644 --- a/i18n/es.json +++ b/i18n/es.json @@ -35,7 +35,8 @@ "errorRateLimited": "Demasiados intentos. Espera un minuto.", "errorServer": "Algo falló. Vuelve a intentarlo en un momento.", "errorNetwork": "Sin conexión. Vuelve a intentarlo.", - "footer": "Cancela cuando quieras." + "footer": "Cancela cuando quieras.", + "alreadyMemberLabel": "// ya eres miembro" }, "hackaton": { "pageTitle": "#1 Hackatón NaN", @@ -879,4 +880,4 @@ "closeCta": "Únete a la comunidad" } } -} \ No newline at end of file +} diff --git a/src/components/landing/CommunitySignupForm.tsx b/src/components/landing/CommunitySignupForm.tsx index 8393b1c..adda7f7 100644 --- a/src/components/landing/CommunitySignupForm.tsx +++ b/src/components/landing/CommunitySignupForm.tsx @@ -1,5 +1,6 @@ import { useState } from 'preact/hooks'; import type { TargetedSubmitEvent } from 'preact'; +import { resolveSignupResponse, errorMessageFor } from '../../lib/communitySignup'; const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; @@ -59,6 +60,7 @@ export interface CommunityTranslations { submitting: string; redirecting: string; alreadySubscribed: string; + alreadyMemberLabel: string; honeypot: string; errorInvalidEmail: string; errorInvalidRegion: string; @@ -87,11 +89,10 @@ export default function CommunitySignupForm({ t }: Props) { async function onSubmit(e: TargetedSubmitEvent) { e.preventDefault(); - // Honeypot: bots get a fake-success response and we don't call the API. - if (honeypot.trim() !== '') { - setStatus({ kind: 'redirecting' }); - return; - } + // Honeypot: the server is authoritative - it returns a benign 200 for a + // filled honeypot without creating anything. A client-side short-circuit + // would hang a real user (password manager / autofill) on a spinner with + // no recovery, and would not stop bots that POST directly to the endpoint. if (!isValidEmail(email)) { setStatus({ kind: 'error', message: t.errorInvalidEmail }); @@ -127,75 +128,49 @@ export default function CommunitySignupForm({ t }: Props) { body = null; } - if ( - response.status === 200 && - body && - typeof body === 'object' && - (body as { ok?: unknown }).ok === true && - typeof (body as { url?: unknown }).url === 'string' - ) { - const url = (body as { url: string }).url; - setStatus({ kind: 'redirecting' }); - window.location.href = url; - return; - } - - if (response.status === 409) { - setStatus({ kind: 'already' }); - return; - } - - const errorCode = - body && typeof body === 'object' && typeof (body as { error?: unknown }).error === 'string' - ? (body as { error: string }).error - : ''; - - let message = t.errorServer; - switch (errorCode) { - case 'invalid_email': - message = t.errorInvalidEmail; - break; - case 'invalid_region': - message = t.errorInvalidRegion; - break; - case 'rate_limited': - message = t.errorRateLimited; - break; - default: - message = t.errorServer; + const outcome = resolveSignupResponse(response.status, body); + switch (outcome.kind) { + case 'redirect': + setStatus({ kind: 'redirecting' }); + window.location.href = outcome.url; + return; + case 'already': + setStatus({ kind: 'already' }); + return; + case 'error': + setStatus({ kind: 'error', message: errorMessageFor(outcome.code, t) }); + return; } - setStatus({ kind: 'error', message }); - } - - if (status.kind === 'already') { - return ( -
-

- // already a member -

-

- {t.alreadySubscribed}{' '} - - hello@nan.builders - -

-
- ); } const submitting = status.kind === 'submitting'; const redirecting = status.kind === 'redirecting'; const disabled = submitting || redirecting; const errorMsg = status.kind === 'error' ? status.message : null; + const already = status.kind === 'already'; return ( +
+ {already && ( +
+

+ {t.alreadyMemberLabel} +

+

+ {t.alreadySubscribed}{' '} + + hello@nan.builders + +

+
+ )}
{redirecting ? ( <> @@ -301,5 +276,6 @@ export default function CommunitySignupForm({ t }: Props) {
+ ); } diff --git a/src/components/nan/home/Pricing.astro b/src/components/nan/home/Pricing.astro index dd47a91..93d8876 100644 --- a/src/components/nan/home/Pricing.astro +++ b/src/components/nan/home/Pricing.astro @@ -52,7 +52,7 @@ const tiers = [ includes: tt.communityIncludes, cond: tt.communityCond, cta: tt.communityCta, - href: lang === 'es' ? '/es/community' : '/community', + href: lang === 'es' ? '/es/community#signup' : '/community#signup', primary: false, }, ]; diff --git a/src/lib/communitySignup.ts b/src/lib/communitySignup.ts index be51228..3e96eca 100644 --- a/src/lib/communitySignup.ts +++ b/src/lib/communitySignup.ts @@ -128,6 +128,61 @@ export type CommunitySignupSuccess = { ok: true; url: string }; export type CommunitySignupFailure = { ok: false; error: CommunityErrorCode }; export type CommunitySignupResult = CommunitySignupSuccess | CommunitySignupFailure; +export type SignupOutcome = + | { kind: 'redirect'; url: string } + | { kind: 'already' } + | { kind: 'error'; code: CommunityErrorCode }; + +/** + * Maps a raw response (status + parsed body) from the community-signup edge + * endpoint to a structured outcome the form component can act on. Pure so it + * can be unit-tested in node without a DOM. + * + * The fetch-throws case (no response at all) is handled by the caller as a + * network error; this function only classifies responses that did arrive. + */ +export function resolveSignupResponse(status: number, body: unknown): SignupOutcome { + if ( + status === 200 && + body && typeof body === 'object' && + (body as { ok?: unknown }).ok === true && + typeof (body as { url?: unknown }).url === 'string' + ) { + return { kind: 'redirect', url: (body as { url: string }).url }; + } + + if (status === 409) { + return { kind: 'already' }; + } + + const errStr = + body && typeof body === 'object' && typeof (body as { error?: unknown }).error === 'string' + ? (body as { error: string }).error + : ''; + + const known: CommunityErrorCode[] = [ + 'invalid_email', 'invalid_region', 'rate_limited', 'already_subscribed', 'server_error', + ]; + if (known.includes(errStr as CommunityErrorCode)) { + return { kind: 'error', code: errStr as CommunityErrorCode }; + } + return { kind: 'error', code: 'server_error' }; +} + +/** Localizes an error code using the form's translation table. */ +export function errorMessageFor( + code: CommunityErrorCode, + t: { errorInvalidEmail: string; errorInvalidRegion: string; errorRateLimited: string; errorServer: string }, +): string { + switch (code) { + case 'invalid_email': return t.errorInvalidEmail; + case 'invalid_region': return t.errorInvalidRegion; + case 'rate_limited': return t.errorRateLimited; + default: return t.errorServer; + } +} + + /** * Calls the cloud-api community signup endpoint. On success, the backend * returns a Stripe Checkout Session URL the user should be redirected to. diff --git a/src/pages/_community.astro b/src/pages/_community.astro index e3be99f..37d98e9 100644 --- a/src/pages/_community.astro +++ b/src/pages/_community.astro @@ -24,6 +24,7 @@ const formStrings = { submitting: t('community.submitting', lang), redirecting: t('community.redirecting', lang), alreadySubscribed: t('community.alreadySubscribed', lang), + alreadyMemberLabel: t('community.alreadyMemberLabel', lang), honeypot: t('community.honeypot', lang), errorInvalidEmail: t('community.errorInvalidEmail', lang), errorInvalidRegion: t('community.errorInvalidRegion', lang), @@ -32,8 +33,7 @@ const formStrings = { errorNetwork: t('community.errorNetwork', lang), }; -const home = lang === 'es' ? '/es' : '/'; -const joinHref = `${home}#pricing`; +const joinHref = '#signup'; // La cuenta de miembros se actualiza a mano al alcanzar un hito y NO se // traduce, así que vive en lib/members y no en el diccionario, donde estaba @@ -202,7 +202,7 @@ const pct = membersPct(); -
+

{tt.closeTitle}

{tt.closeText}

diff --git a/src/tests/landing/i18nParity.test.ts b/src/tests/landing/i18nParity.test.ts index ffd933a..d130c27 100644 --- a/src/tests/landing/i18nParity.test.ts +++ b/src/tests/landing/i18nParity.test.ts @@ -55,6 +55,17 @@ describe('paridad de los diccionarios', () => { expect(diffs).toEqual([]); }); + // Root-level subtrees (community, hackaton) are consumed directly by + // t('community.*', lang) / t('hackaton.*', lang) — a key that exists only + // in en renders the raw key path in /es. Parity must cover them too. + test.each(['community', 'hackaton'])('en.%s y es.%s tienen la misma forma', (key) => { + const enSub = (en as Record)[key]; + const esSub = (es as Record)[key]; + const diffs: string[] = []; + shapeDiffs(enSub, esSub, key, diffs); + expect(diffs).toEqual([]); + }); + test('ninguna cadena está vacía', () => { for (const [dict, name] of [ [en.nan, 'en'], diff --git a/src/tests/landing/pricing.test.ts b/src/tests/landing/pricing.test.ts index 136e555..232c466 100644 --- a/src/tests/landing/pricing.test.ts +++ b/src/tests/landing/pricing.test.ts @@ -57,6 +57,14 @@ describe('Pricing — no per-region tier', () => { expect(tiers).toMatch(/amount:\s*'14,99€'/); }); + test('the community card CTA routes to the /community signup form, not the portal', () => { + // The card must land on the restored form (with the #signup anchor), + // not on cloud.nan.builders (which has no signup form). + expect(tiers).toMatch(/\/es\/community#signup/); + expect(tiers).toMatch(/\/community#signup/); + expect(tiers).not.toMatch(/cloud\.nan\.builders/); + }); + /** * Nothing in the section may quote a dollar amount: all three Checkouts are * created in EUR for every region. The legacy USD subscriptions are true and diff --git a/src/tests/lib/communitySignup.test.ts b/src/tests/lib/communitySignup.test.ts new file mode 100644 index 0000000..489d201 --- /dev/null +++ b/src/tests/lib/communitySignup.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest'; +import { + resolveSignupResponse, + errorMessageFor, + type CommunityErrorCode, +} from '../../lib/communitySignup'; + +/** + * resolveSignupResponse is the pure response classifier the + * CommunitySignupForm island uses to turn a fetch result into a UI state. It + * used to live inline in the component (untestable without a DOM); it was + * extracted so every error path is fixed here. + * + * The form's other branch — the fetch throwing — is handled by the caller as + * errorNetwork before this function is ever called, so it has no case here. + */ + +const T = { + errorInvalidEmail: 'Invalid email. Check the format.', + errorInvalidRegion: 'Select a region.', + errorRateLimited: 'Too many attempts. Wait a minute.', + errorServer: 'Something went wrong. Try again in a moment.', +}; + +describe('resolveSignupResponse', () => { + it('redirects on 200 with { ok: true, url }', () => { + const out = resolveSignupResponse(200, { ok: true, url: 'https://checkout.stripe.com/c/pay/abc' }); + expect(out).toEqual({ kind: 'redirect', url: 'https://checkout.stripe.com/c/pay/abc' }); + }); + + it('reports already on 409', () => { + const out = resolveSignupResponse(409, { ok: false, error: 'already_subscribed' }); + expect(out).toEqual({ kind: 'already' }); + }); + + it('maps rate_limited to an error', () => { + const out = resolveSignupResponse(429, { ok: false, error: 'rate_limited' }); + expect(out).toEqual({ kind: 'error', code: 'rate_limited' }); + }); + + it('maps invalid_email to an error', () => { + const out = resolveSignupResponse(400, { ok: false, error: 'invalid_email' }); + expect(out).toEqual({ kind: 'error', code: 'invalid_email' }); + }); + + it('maps invalid_region to an error', () => { + const out = resolveSignupResponse(400, { ok: false, error: 'invalid_region' }); + expect(out).toEqual({ kind: 'error', code: 'invalid_region' }); + }); + + it('falls back to server_error on an unrecognized error code', () => { + const out = resolveSignupResponse(500, { ok: false, error: 'something_unexpected' }); + expect(out).toEqual({ kind: 'error', code: 'server_error' }); + }); + + it('falls back to server_error when the body is not JSON (null)', () => { + const out = resolveSignupResponse(502, null); + expect(out).toEqual({ kind: 'error', code: 'server_error' }); + }); + + it('falls back to server_error when 200 lacks ok:true', () => { + // A 200 without the expected shape is not a redirect — treat as server error. + const out = resolveSignupResponse(200, { url: 'https://checkout.stripe.com/c/pay/abc' }); + expect(out).toEqual({ kind: 'error', code: 'server_error' }); + }); + + it('falls back to server_error when 200 url is not a string', () => { + const out = resolveSignupResponse(200, { ok: true, url: 42 }); + expect(out).toEqual({ kind: 'error', code: 'server_error' }); + }); + + it('does not treat 409 as a redirect even if ok:true sneaks in', () => { + const out = resolveSignupResponse(409, { ok: true, url: 'https://x' }); + expect(out).toEqual({ kind: 'already' }); + }); +}); + +describe('errorMessageFor', () => { + const cases: Array<[CommunityErrorCode, string]> = [ + ['invalid_email', T.errorInvalidEmail], + ['invalid_region', T.errorInvalidRegion], + ['rate_limited', T.errorRateLimited], + ['already_subscribed', T.errorServer], // no dedicated message in the form -> server fallback + ['server_error', T.errorServer], + ]; + + it.each(cases)('maps %s to the right localized string', (code, expected) => { + expect(errorMessageFor(code, T)).toBe(expected); + }); +}); From 610c508016df4cd0f7a977413bab89c24d5ad825 Mon Sep 17 00:00:00 2001 From: barckcode Date: Wed, 19 Aug 2026 19:23:34 +0200 Subject: [PATCH 4/4] fix(community): address QA+UX re-review blockers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QA re-review (3 blockers): - Fix tautological CTA href regex in pricing.test.ts: assert the full ternary so the EN branch regression is caught, not masked by the ES substring also containing '/community#signup' - Add communityPage.test.ts: assert id="signup" exists on .cclose, CommunitySignupForm mounted with client:load, and every t('community.*') call resolves to a real string (not the raw key path) in both locales — closes the gap that let '// already a member' slip through as a hardcoded literal UX re-review (2 blockers): - Show the price on /community: render community.priceLabel (14,99€), priceTaxNote and footer next to the form. The price was only visible on the home pricing card; the /community form sent users to Stripe Checkout without ever showing the amount. The strings already existed but were orphaned. - Remove the client-side honeypot field entirely: removing only the short-circuit left a honeypot-filled response redirecting real users (password manager / autofill) to the home with a fake-success query param. The field is gone from the island; the server rate-limiter and server-side honeypot (when _hp is sent) are the real bot defence. 691 tests pass - astro check 0/0/0 - npm run build OK. --- .../landing/CommunitySignupForm.tsx | 23 +------- src/pages/_community.astro | 9 +++ src/tests/landing/pricing.test.ts | 7 ++- src/tests/pages/communityPage.test.ts | 57 +++++++++++++++++++ 4 files changed, 73 insertions(+), 23 deletions(-) create mode 100644 src/tests/pages/communityPage.test.ts diff --git a/src/components/landing/CommunitySignupForm.tsx b/src/components/landing/CommunitySignupForm.tsx index adda7f7..d24ab2d 100644 --- a/src/components/landing/CommunitySignupForm.tsx +++ b/src/components/landing/CommunitySignupForm.tsx @@ -83,16 +83,14 @@ type Status = export default function CommunitySignupForm({ t }: Props) { const [email, setEmail] = useState(''); const [region, setRegion] = useState(''); - const [honeypot, setHoneypot] = useState(''); const [status, setStatus] = useState({ kind: 'idle' }); async function onSubmit(e: TargetedSubmitEvent) { e.preventDefault(); - // Honeypot: the server is authoritative - it returns a benign 200 for a - // filled honeypot without creating anything. A client-side short-circuit - // would hang a real user (password manager / autofill) on a spinner with - // no recovery, and would not stop bots that POST directly to the endpoint. + // No client-side honeypot field: a hidden _hp risks autofill by a + // password manager, and the server rate-limiter + the server-side honeypot + // (when _hp is sent) are the real bot defence. The form sends no _hp. if (!isValidEmail(email)) { setStatus({ kind: 'error', message: t.errorInvalidEmail }); @@ -113,7 +111,6 @@ export default function CommunitySignupForm({ t }: Props) { body: JSON.stringify({ email: normalizeEmail(email), region, - _hp: honeypot, }), }); } catch { @@ -177,20 +174,6 @@ export default function CommunitySignupForm({ t }: Props) { class="rounded-xl border border-neutral-800 bg-neutral-900/30 p-6 md:p-8" aria-describedby={errorMsg ? 'community-error' : undefined} > - -
@@ -345,6 +350,10 @@ const pct = membersPct(); } .cclose .btn { margin-top: var(--space-8); } .cclose__form { margin-top: var(--space-8); max-width: 460px; } + .cclose__price { display: flex; flex-direction: column; gap: var(--space-1); margin-bottom: var(--space-4); } + .cclose__amount { font-family: var(--font-mono); font-size: 18px; font-weight: 600; color: var(--color-text); } + .cclose__note { font-family: var(--font-mono); font-size: 12px; color: var(--color-text-dim); } + .cclose__footer { margin-top: var(--space-3); font-family: var(--font-mono); font-size: 11px; color: var(--color-muted); text-align: center; } @media (max-width: 760px) { .pillars { grid-template-columns: 1fr; } diff --git a/src/tests/landing/pricing.test.ts b/src/tests/landing/pricing.test.ts index 232c466..c7f590a 100644 --- a/src/tests/landing/pricing.test.ts +++ b/src/tests/landing/pricing.test.ts @@ -59,9 +59,10 @@ describe('Pricing — no per-region tier', () => { test('the community card CTA routes to the /community signup form, not the portal', () => { // The card must land on the restored form (with the #signup anchor), - // not on cloud.nan.builders (which has no signup form). - expect(tiers).toMatch(/\/es\/community#signup/); - expect(tiers).toMatch(/\/community#signup/); + // not on cloud.nan.builders (which has no signup form). Assert the full + // ternary so a regression on the EN branch (else '/#pricing') is caught, + // not masked by the ES substring also containing '/community#signup'. + expect(tiers).toMatch(/href: lang === 'es' \? '\/es\/community#signup' : '\/community#signup'/); expect(tiers).not.toMatch(/cloud\.nan\.builders/); }); diff --git a/src/tests/pages/communityPage.test.ts b/src/tests/pages/communityPage.test.ts new file mode 100644 index 0000000..b51510a --- /dev/null +++ b/src/tests/pages/communityPage.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; +import { t } from '../../lib/i18n'; + +/** + * Guards the /community page body (src/pages/_community.astro, shared by + * /community and /es/community). These are the invariants the restored form + * depends on but that astro check and the runtime can't see: + * + * - the #signup anchor exists and is the scroll target of every CTA + * (hero, home pricing EN, home pricing ES); + * - the CommunitySignupForm island is mounted with client:load; + * - every t('community.*', lang) call in the source resolves to a real + * localized string in both locales, not the raw key path. t() returns the + * key itself when it can't resolve, so a typo ships `community.submit` as + * the button label in BOTH languages with no type or test error — this is + * exactly how the "// already a member" literal slipped through. + */ + +const here = dirname(fileURLToPath(import.meta.url)); +const source = readFileSync(resolve(here, '../../pages/_community.astro'), 'utf-8'); + +describe('/community page body (_community.astro)', () => { + test('the signup section has id="signup" (scroll target of every CTA)', () => { + expect(source).toMatch(/]*class="cclose"[^>]*id="signup"/); + }); + + test('the CommunitySignupForm island is mounted with client:load', () => { + expect(source).toMatch(/CommunitySignupForm[^>]*client:load/); + }); + + test('every t(\'community.*\', lang) call resolves to a real string in both locales', () => { + // Extract all t('community.SOMETHING', ...) call sites from the source. + const calls = [...source.matchAll(/t\(\s*'community\.([A-Za-z0-9_]+)'\s*,/g)].map( + (m) => `community.${m[1]}`, + ); + // Sanity: the form wires at least the core keys (submit, emailLabel, etc.). + expect(calls.length).toBeGreaterThan(10); + + const problems: string[] = []; + for (const key of calls) { + for (const locale of ['en', 'es'] as const) { + const value = t(key, locale); + if (typeof value !== 'string' || value.trim() === '') { + problems.push(`${key} [${locale}]: not a non-empty string`); + } else if (value === key) { + // t() returns the key when it can't resolve — a typo renders the raw + // path as the visible label in both languages. + problems.push(`${key} [${locale}]: resolved to the raw key path (missing translation)`); + } + } + } + expect(problems, problems.join('\n')).toEqual([]); + }); +});