diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 490299f8..e1fdd228 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -141,10 +141,10 @@ Accepts waitlist signups. - **Honeypot:** `_hp` field filled → 200 OK without persisting (bot protection) - **Rate limit:** 60s per IP via in-memory map (best-effort, resets on isolate eviction) - **Flow:** `validateWaitlistInput()` → rate limit check → `registerViaBackend()` → cloud-api POST → send confirmation email via Resend (best-effort) -- **EU entries:** take an arrival position assigned by the backend (PostgreSQL sequence) -- **Non-EU entries:** stored as interest signals with position 0 +- **Regions:** EU, LATAM and USA are all open; every valid signup is a regular waitlist entry and gets the same "you're on the waitlist" message (a duplicate email answers the same way) +- **Position:** the backend's arrival counter is not a queue rank, so it is neither forwarded in the response nor shown on the site - **Responses:** - - `200`: `{ ok: true, position, total, status, region }` + - `200`: `{ ok: true, status: 'registered', region }` - `400`: `{ ok: false, error: 'invalid_email' | 'invalid_region' }` - `429`: `{ ok: false, error: 'rate_limited' }` - `500`: `{ ok: false, error: 'server_error' }` diff --git a/i18n/en.json b/i18n/en.json index 55e01b61..a943ebfe 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -325,13 +325,11 @@ "submit": "Join waitlist", "sending": "Sending…", "okRegistered": "You're on the waitlist.", - "okPosition": "position", "okText": "We'll approve your spot in the next few days. Keep an eye on your inbox ^__^", "errEmail": "That email does not look valid.", "errRegion": "Pick a region.", "errNetwork": "The network failed, not you. Try again.", "errGeneric": "Something broke on our side. Try again in a bit.", - "okInterest": "Noted. We are not open in your region yet, but we will write as soon as we are.", "errRateLimited": "Too many attempts. Wait a minute." }, "why": { diff --git a/i18n/es.json b/i18n/es.json index 1d8a8fd5..8e2b7ab7 100644 --- a/i18n/es.json +++ b/i18n/es.json @@ -325,13 +325,11 @@ "submit": "Únete a la waitlist", "sending": "Enviando…", "okRegistered": "Ya estás en la waitlist.", - "okPosition": "posición", "okText": "Aprobaremos tu plaza en los próximos días. No pierdas de vista tu bandeja ^__^", "errEmail": "Ese email no parece válido.", "errRegion": "Elige una región.", "errNetwork": "Ha fallado la red, no tú. Prueba otra vez.", "errGeneric": "Algo se ha roto por nuestro lado. Prueba en un rato.", - "okInterest": "Anotado. Todavía no estamos abiertos en tu región, pero te escribimos en cuanto lo estemos.", "errRateLimited": "Demasiados intentos. Espera un minuto." }, "why": { diff --git a/src/components/nan/home/Hero.astro b/src/components/nan/home/Hero.astro index c307d9a6..986efadb 100644 --- a/src/components/nan/home/Hero.astro +++ b/src/components/nan/home/Hero.astro @@ -16,8 +16,6 @@ const glitchText = `'${`${tt.line1}\\A ${tt.line2}`.replace(/'/g, "\\'")}'`; const waitlistMsgs = JSON.stringify({ sending: tt.sending, okRegistered: tt.okRegistered, - okInterest: tt.okInterest, - okPosition: tt.okPosition, okText: tt.okText, errEmail: tt.errEmail, errRegion: tt.errRegion, diff --git a/src/lib/waitlist.ts b/src/lib/waitlist.ts index c3d2c3f6..1c9a3c67 100644 --- a/src/lib/waitlist.ts +++ b/src/lib/waitlist.ts @@ -4,8 +4,10 @@ * signups to the backend. * * Region model: - * EU signups take an arrival position (1, 2, 3…) assigned by the backend. - * LATAM and USA signups are stored as interest signals with position 0. + * EU, LATAM and USA are all open: every signup is a regular waitlist entry + * and gets the same "you're on the waitlist" answer. The backend keeps its + * own arrival counter, but it is not a queue rank, so the site neither + * forwards nor shows it. */ const RATE_LIMIT_TTL_MS = 60_000; @@ -50,8 +52,6 @@ function isBlockedEmailDomain(email: string): boolean { export const REGIONS = ['EU', 'LATAM', 'USA'] as const; export type WaitlistRegion = (typeof REGIONS)[number]; -export type WaitlistStatus = 'registered' | 'interest'; - export type WaitlistErrorCode = | 'invalid_email' | 'invalid_region' @@ -78,9 +78,8 @@ export type MemberState = (typeof MEMBER_STATES)[number]; export interface JoinResult { ok: true; - position: number; - total: number; - status: WaitlistStatus; + /** Always 'registered': kept on the wire for clients that still read it. */ + status: 'registered'; region: WaitlistRegion; } @@ -183,9 +182,7 @@ export async function registerViaBackend( // shows the same "you're in" message rather than an error. return { ok: true, - position: 0, - total: 0, - status: input.region === 'EU' ? 'registered' : 'interest', + status: 'registered', region: input.region, }; } @@ -195,12 +192,10 @@ export async function registerViaBackend( throw new Error(`cloud-api register failed: ${response.status} ${body}`); } - const data = (await response.json()) as { email: string; region: string; position: number }; + const data = (await response.json()) as { email: string; region: string }; return { ok: true, - position: data.position, - total: data.position, - status: data.region === 'EU' ? 'registered' : 'interest', + status: 'registered', region: data.region as WaitlistRegion, }; } diff --git a/src/lib/waitlistClient.ts b/src/lib/waitlistClient.ts index 7d82259f..4b493e85 100644 --- a/src/lib/waitlistClient.ts +++ b/src/lib/waitlistClient.ts @@ -60,13 +60,8 @@ export function isWaitlistRegion(value: string): value is WaitlistRegion { return (REGIONS as readonly string[]).includes(value); } -export type WaitlistStatus = 'registered' | 'interest'; - export type WaitlistSuccess = { ok: true; - position: number; - total: number; - status: WaitlistStatus; region: WaitlistRegion; }; @@ -94,16 +89,10 @@ export function parseWaitlistResponse( const data = body as Record; if (status === 200 && data.ok === true) { - const rawStatus = data.status; - const parsedStatus: WaitlistStatus = - rawStatus === 'interest' ? 'interest' : 'registered'; const rawRegion = typeof data.region === 'string' ? data.region : 'EU'; const region: WaitlistRegion = isWaitlistRegion(rawRegion) ? rawRegion : 'EU'; return { ok: true, - position: typeof data.position === 'number' ? data.position : 0, - total: typeof data.total === 'number' ? data.total : 0, - status: parsedStatus, region, }; } @@ -127,8 +116,8 @@ export function parseWaitlistResponse( // Los TEXTOS los pone el componente en `data-msgs` (serializados del // diccionario, para no arrastrar todo el i18n al bundle de cliente). Lo que vive // aquí es la DECISIÓN de qué texto toca y con qué forma, que es lo que tiene -// reglas: el rate limit no es un error genérico, y una región sin apertura no -// tiene puesto que enseñar. +// reglas: el rate limit no es un error genérico, y un alta correcta es la +// misma en todas las regiones. // // Antes esto estaba dentro del manejador de `submit`, o sea intestable sin DOM. // --------------------------------------------------------------------------- @@ -136,8 +125,6 @@ export function parseWaitlistResponse( /** Los textos que el componente serializa en data-msgs. */ export interface WaitlistMessages { okRegistered: string; - okInterest: string; - okPosition: string; okText: string; errEmail: string; errRegion: string; @@ -173,15 +160,10 @@ export function waitlistErrorText( /** * Texto para un alta correcta. * - * EU recibe una posición de llegada. LATAM y USA se guardan como interés con - * posición 0: ahí no hay puesto que enseñar, así que no se inventa uno. + * Las tres regiones están abiertas y reciben el mismo mensaje, siempre. No se + * enseña posición: el número del backend es un contador acumulado de llegadas, + * no un puesto en la cola, y enseñarlo confunde. */ -export function waitlistSuccessText(result: WaitlistSuccess, m: WaitlistMessages): string { - if (result.status === 'interest') return m.okInterest; - - const pos = - result.position && result.total - ? ` ${m.okPosition} ${String(result.position).padStart(3, '0')} / ${result.total} ·` - : ''; - return `${m.okRegistered}${pos} ${m.okText}`; +export function waitlistSuccessText(m: WaitlistMessages): string { + return `${m.okRegistered} ${m.okText}`; } diff --git a/src/pages/api/waitlist.ts b/src/pages/api/waitlist.ts index e712112c..6ccbf790 100644 --- a/src/pages/api/waitlist.ts +++ b/src/pages/api/waitlist.ts @@ -57,8 +57,6 @@ export const POST: APIRoute = async ({ request }) => { if (honeypot) { return json({ ok: true, - position: 0, - total: 0, status: 'registered', region, }); diff --git a/src/scripts/waitlist.ts b/src/scripts/waitlist.ts index b323e617..20c0e39a 100644 --- a/src/scripts/waitlist.ts +++ b/src/scripts/waitlist.ts @@ -23,8 +23,6 @@ import { interface Msgs { sending: string; okRegistered: string; - okInterest: string; - okPosition: string; okText: string; errEmail: string; errRegion: string; @@ -36,8 +34,6 @@ interface Msgs { const FALLBACK: Msgs = { sending: 'Sending…', okRegistered: "You're on the waitlist.", - okInterest: 'Noted. We are not open in your region yet.', - okPosition: 'position', okText: "We'll approve your spot in the next few days.", errEmail: 'That email does not look valid.', errRegion: 'Pick a region.', @@ -125,7 +121,7 @@ function wire(form: HTMLFormElement): void { form.querySelectorAll('input, select, button').forEach((el) => ((el as HTMLInputElement).disabled = true)); - status.textContent = waitlistSuccessText(result, t); + status.textContent = waitlistSuccessText(t); }); } diff --git a/src/tests/api/waitlist.test.ts b/src/tests/api/waitlist.test.ts index fd735d39..92e9801d 100644 --- a/src/tests/api/waitlist.test.ts +++ b/src/tests/api/waitlist.test.ts @@ -114,26 +114,32 @@ describe('POST /api/waitlist', () => { expect(status).toBe(200); expect(payload).toMatchObject({ ok: true, - position: 42, status: 'registered', region: 'EU', }); }); - it('accepts a LATAM signup as interest', async () => { - const { status, payload } = await callPost({ - email: 'luis@acme.co', - region: 'LATAM', - }); + it.each([ + ['LATAM', 'luis@acme.co'], + ['USA', 'sam@acme.co'], + ])('accepts a %s signup as registered, like EU', async (region, email) => { + const { status, payload } = await callPost({ email, region }); expect(status).toBe(200); - expect(payload).toMatchObject({ + expect(payload).toEqual({ ok: true, - position: 0, - status: 'interest', - region: 'LATAM', + status: 'registered', + region, }); }); + it('does not expose the backend arrival counter in the response', async () => { + globalThis.fetch = mockBackendFetch({ + registerBody: { email: 'sam@acme.co', region: 'USA', position: 424242 }, + }); + const { payload } = await callPost({ email: 'sam@acme.co', region: 'USA' }); + expect(payload).toEqual({ ok: true, status: 'registered', region: 'USA' }); + }); + it('returns invalid_email for a malformed address', async () => { const { status, payload } = await callPost({ email: 'not-an-email', @@ -194,8 +200,7 @@ describe('POST /api/waitlist', () => { region: 'EU', }); expect(status).toBe(200); - expect(payload.ok).toBe(true); - expect(payload.status).toBe('registered'); + expect(payload).toEqual({ ok: true, status: 'registered', region: 'EU' }); }); it('rate-limits repeat attempts from the same IP', async () => { @@ -222,7 +227,7 @@ describe('POST /api/waitlist', () => { }); expect(status).toBe(200); expect(payload.ok).toBe(true); - expect(payload.position).toBe(0); + expect(payload).toEqual({ ok: true, status: 'registered', region: 'EU' }); // Backend register should not have been called (only Resend could be called) const fetchMock = globalThis.fetch as ReturnType; diff --git a/src/tests/landing/waitlistVanilla.test.ts b/src/tests/landing/waitlistVanilla.test.ts index 3061ffa5..e2f3ac68 100644 --- a/src/tests/landing/waitlistVanilla.test.ts +++ b/src/tests/landing/waitlistVanilla.test.ts @@ -14,6 +14,7 @@ import { isValidEmail, isWaitlistRegion } from '../../lib/waitlistClient'; const here = dirname(fileURLToPath(import.meta.url)); const source = readFileSync(resolve(here, '../../scripts/waitlist.ts'), 'utf-8'); +const heroSource = readFileSync(resolve(here, '../../components/nan/home/Hero.astro'), 'utf-8'); describe('waitlist vanilla', () => { describe('reutiliza los helpers compartidos', () => { @@ -60,13 +61,13 @@ describe('waitlist vanilla', () => { describe('delega la decisión del mensaje, no la reimplementa', () => { /** - * Antes esto comprobaba que el TEXTO del script contuviera - * `result.status === 'interest'` y `rate_limited`. Esa lógica se movió a + * Antes esto comprobaba que el TEXTO del script contuviera la rama de + * estado del alta y `rate_limited`. Esa lógica se movió a * lib/waitlistClient para poder probarla sin DOM, y los asserts de texto * se rompieron sin que nada estuviera mal: el caso exacto que hace frágiles * las pruebas sobre el fuente. * - * El COMPORTAMIENTO (interest sin posición, rate limit con su propio texto, + * El COMPORTAMIENTO (alta igual en todas las regiones y sin posición, rate limit con su propio texto, * red aparte del servidor) vive ahora en `tests/lib/waitlistClient.test.ts`. * Aquí solo queda lo que de verdad depende de este fichero: que no se monte * su propia versión. @@ -76,9 +77,34 @@ describe('waitlist vanilla', () => { expect(source).toContain('waitlistSuccessText'); }); + test('no conserva el texto de "región sin abrir": las tres regiones están abiertas', () => { + expect(source).not.toContain('okInterest'); + expect(source).not.toMatch(/not open in your region/i); + }); + + test('no enseña la posición: ni clave okPosition ni el número del backend', () => { + expect(source).not.toMatch(/\bresult\.position\b|okPosition/); + }); + test('no reimplementa el mapa de códigos de error', () => { expect(source).not.toContain('rate_limited:'); expect(source).not.toContain('const map: Record'); }); }); + + describe('Hero.astro serializa en data-msgs solo los textos que se usan', () => { + const block = heroSource.match(/const waitlistMsgs = JSON\.stringify\(\{([\s\S]*?)\}\);/); + const keys = [...(block?.[1] ?? '').matchAll(/^\s*(\w+):/gm)].map((k) => k[1]); + + test('encuentra el bloque de mensajes', () => { + expect(block, 'waitlistMsgs = JSON.stringify({...}) no está en Hero.astro').not.toBeNull(); + expect(keys).toContain('okRegistered'); + expect(keys).toContain('okText'); + }); + + test('sin claves de posición ni de "región sin abrir"', () => { + expect(keys).not.toContain('okPosition'); + expect(keys).not.toContain('okInterest'); + }); + }); }); diff --git a/src/tests/lib/waitlist.test.ts b/src/tests/lib/waitlist.test.ts index 44cef3f1..275f588c 100644 --- a/src/tests/lib/waitlist.test.ts +++ b/src/tests/lib/waitlist.test.ts @@ -144,9 +144,8 @@ describe('registerViaBackend', () => { { email: 'alice@acme.co', region: 'EU' }, ); - expect(result).toMatchObject({ + expect(result).toEqual({ ok: true, - position: 42, status: 'registered', region: 'EU', }); @@ -165,21 +164,37 @@ describe('registerViaBackend', () => { ); }); - it('returns interest status for non-EU region', async () => { + it.each(['LATAM', 'USA'] as const)('treats a %s signup as registered, like EU', async (region) => { globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, status: 201, - json: () => Promise.resolve({ email: 'bob@acme.co', region: 'LATAM', position: 0 }), + json: () => Promise.resolve({ email: 'bob@acme.co', region, position: 7 }), }); const result = await registerViaBackend( 'https://cloud-api.nan.builders', 'test-key', - { email: 'bob@acme.co', region: 'LATAM' }, + { email: 'bob@acme.co', region }, ); - expect(result.status).toBe('interest'); - expect(result.position).toBe(0); + expect(result).toEqual({ ok: true, status: 'registered', region }); + }); + + it('does not forward the backend arrival counter (no position, no total)', async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + status: 201, + json: () => Promise.resolve({ email: 'alice@acme.co', region: 'EU', position: 424242 }), + }); + + const result = await registerViaBackend( + 'https://cloud-api.nan.builders', + 'test-key', + { email: 'alice@acme.co', region: 'EU' }, + ); + + expect(result).not.toHaveProperty('position'); + expect(result).not.toHaveProperty('total'); }); it('handles 409 duplicate as success', async () => { @@ -199,6 +214,22 @@ describe('registerViaBackend', () => { expect(result.status).toBe('registered'); }); + it.each(['LATAM', 'USA'] as const)('handles a %s 409 as registered', async (region) => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 409, + text: () => Promise.resolve('{"error":"email already registered"}'), + }); + + const result = await registerViaBackend( + 'https://cloud-api.nan.builders', + 'test-key', + { email: 'bob@acme.co', region }, + ); + + expect(result).toEqual({ ok: true, status: 'registered', region }); + }); + it('throws on server error', async () => { globalThis.fetch = vi.fn().mockResolvedValue({ ok: false, diff --git a/src/tests/lib/waitlistClient.test.ts b/src/tests/lib/waitlistClient.test.ts index 89f019cb..bb613a58 100644 --- a/src/tests/lib/waitlistClient.test.ts +++ b/src/tests/lib/waitlistClient.test.ts @@ -7,6 +7,7 @@ import { waitlistErrorText, waitlistSuccessText, } from '../../lib/waitlistClient'; +import { useT } from '../../lib/i18n'; describe('normalizeEmail', () => { it('trims and lowercases', () => { @@ -67,44 +68,25 @@ describe('isWaitlistRegion', () => { }); describe('parseWaitlistResponse', () => { - it('parses a successful EU registration', () => { - const result = parseWaitlistResponse(200, { - ok: true, - position: 3, - total: 3, - status: 'registered', - region: 'EU', - }); - expect(result).toEqual({ - ok: true, - position: 3, - total: 3, - status: 'registered', - region: 'EU', - }); + it.each(['EU', 'LATAM', 'USA'])('parses a %s registration the same way', (region) => { + const result = parseWaitlistResponse(200, { ok: true, status: 'registered', region }); + expect(result).toEqual({ ok: true, region }); }); - it('parses a non-EU interest registration', () => { + it("drops legacy position, total and status: 'interest' from the parsed result", () => { const result = parseWaitlistResponse(200, { ok: true, - position: 0, - total: 4, + position: 19, + total: 19, status: 'interest', - region: 'LATAM', + region: 'USA', }); - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.status).toBe('interest'); - expect(result.region).toBe('LATAM'); - expect(result.position).toBe(0); - } + expect(result).toEqual({ ok: true, region: 'USA' }); }); it('defaults an unknown region on the wire to EU', () => { const result = parseWaitlistResponse(200, { ok: true, - position: 1, - total: 1, status: 'registered', region: 'MARS', }); @@ -153,7 +135,7 @@ describe('parseWaitlistResponse', () => { describe('waitlistErrorText', () => { const m = { - okRegistered: 'ok', okInterest: 'interes', okPosition: 'puesto', okText: 'texto', + okRegistered: 'ok', okText: 'texto', errEmail: 'email mal', errRegion: 'elige region', errRateLimited: 'espera un minuto', errNetwork: 'sin conexion', errGeneric: 'algo se rompio', }; @@ -184,33 +166,27 @@ describe('waitlistErrorText', () => { }); describe('waitlistSuccessText', () => { - const m = { - okRegistered: 'Estás dentro.', okInterest: 'Aún no abrimos ahí.', okPosition: 'puesto', - okText: 'Te aprobamos en unos días.', errEmail: '', errRegion: '', errRateLimited: '', - errNetwork: '', errGeneric: '', + // Copia real del diccionario: si alguien vuelve a meter la posición o el + // texto de "región sin abrir" en el i18n o en el mensaje, esto lo ve. + const msgs = { en: useT('en').hero, es: useT('es').hero }; + const expected = { + en: `You're on the waitlist. ${msgs.en.okText}`, + es: `Ya estás en la waitlist. ${msgs.es.okText}`, }; - it('una región sin apertura no enseña puesto: no hay puesto que enseñar', () => { - const text = waitlistSuccessText( - { ok: true, position: 0, total: 0, status: 'interest', region: 'LATAM' }, m, - ); - expect(text).toBe('Aún no abrimos ahí.'); - expect(text).not.toContain('puesto'); + // El texto no depende de la respuesta (región, posición o 409): el parseo que + // descarta position/total/status antiguos se prueba en parseWaitlistResponse. + it.each(['en', 'es'] as const)('%s: siempre la copia de alta exacta, sin posición', (lang) => { + const text = waitlistSuccessText(msgs[lang]); + expect(text).toBe(expected[lang]); + expect(text).not.toMatch(/#|\d{3}|position|posición|\//i); + expect(text).not.toMatch(/not open|no estamos abiertos/i); }); - it('un alta en EU enseña la posición, rellenada a tres cifras', () => { - const text = waitlistSuccessText( - { ok: true, position: 7, total: 450, status: 'registered', region: 'EU' }, m, - ); - expect(text).toContain('puesto 007 / 450'); - expect(text).toContain('Estás dentro.'); - }); - - it('sin cifras válidas no inventa un puesto', () => { - const text = waitlistSuccessText( - { ok: true, position: 0, total: 0, status: 'registered', region: 'EU' }, m, - ); - expect(text).not.toContain('puesto'); - expect(text).toContain('Estás dentro.'); + it('el diccionario ya no tiene claves de posición ni de "región sin abrir"', () => { + for (const lang of ['en', 'es'] as const) { + expect(msgs[lang]).not.toHaveProperty('okPosition'); + expect(msgs[lang]).not.toHaveProperty('okInterest'); + } }); });