diff --git a/i18n/en.json b/i18n/en.json index 664b113..64c0154 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -18,7 +18,25 @@ "faqCancelQ": "Can I cancel?", "faqCancelA": "Yes, anytime from your member dashboard. No commitment.", "backToLanding": "← Back to nan.builders", - "footer": "Cancel anytime." + "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.", + "alreadyMemberLabel": "// already a member" }, "hackaton": { "pageTitle": "#1 NaN Hackathon", @@ -862,4 +880,4 @@ "closeCta": "Join the community" } } -} \ No newline at end of file +} diff --git a/i18n/es.json b/i18n/es.json index fa0b424..a38388f 100644 --- a/i18n/es.json +++ b/i18n/es.json @@ -18,7 +18,25 @@ "faqCancelQ": "¿Puedo cancelar?", "faqCancelA": "Sí, en cualquier momento desde tu panel de miembro. Sin permanencia.", "backToLanding": "← Volver a nan.builders", - "footer": "Cancela cuando quieras." + "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.", + "alreadyMemberLabel": "// ya eres miembro" }, "hackaton": { "pageTitle": "#1 Hackatón NaN", @@ -862,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 new file mode 100644 index 0000000..d24ab2d --- /dev/null +++ b/src/components/landing/CommunitySignupForm.tsx @@ -0,0 +1,264 @@ +import { useState } from 'preact/hooks'; +import type { TargetedSubmitEvent } from 'preact'; +import { resolveSignupResponse, errorMessageFor } from '../../lib/communitySignup'; + +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; + alreadyMemberLabel: 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 [status, setStatus] = useState({ kind: 'idle' }); + + async function onSubmit(e: TargetedSubmitEvent) { + e.preventDefault(); + + // 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 }); + 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, + }), + }); + } catch { + setStatus({ kind: 'error', message: t.errorNetwork }); + return; + } + + let body: unknown = null; + try { + body = await response.json(); + } catch { + body = null; + } + + 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; + } + } + + 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 + +

+
+ )} +
+
+
+ + 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/components/nan/home/Pricing.astro b/src/components/nan/home/Pricing.astro index 46e142a..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: 'https://cloud.nan.builders/', + 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/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..dd73c9c 100644 --- a/src/pages/_community.astro +++ b/src/pages/_community.astro @@ -1,13 +1,39 @@ --- 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; -const home = lang === 'es' ? '/es' : '/'; -const joinHref = `${home}#pricing`; +// 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), + alreadyMemberLabel: t('community.alreadyMemberLabel', 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 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 @@ -176,10 +202,18 @@ const pct = membersPct(); -
+

{tt.closeTitle}

{tt.closeText}

+
+
+ {t('community.priceLabel', lang)} + {t('community.priceTaxNote', lang)} +
+ + +
@@ -315,6 +349,11 @@ 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; } + .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/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..c7f590a 100644 --- a/src/tests/landing/pricing.test.ts +++ b/src/tests/landing/pricing.test.ts @@ -57,6 +57,15 @@ 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). 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/); + }); + /** * 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); + }); +}); 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([]); + }); +});