From 4ae0e6d8ac0e8669586e443417f9aede8d6d00e4 Mon Sep 17 00:00:00 2001 From: crstian19 Date: Fri, 2 Oct 2026 12:57:38 +0200 Subject: [PATCH] fix(waitlist): treat USA and LATAM as open regions and stop showing the position USA and LATAM are open regions now, but the form still showed them the "not open in your region yet" text: registerViaBackend derived an `interest` status from the region. The success message also showed a "position" that was a cumulative arrival counter, not a place in the queue, over a fake total equal to itself. All regions now get the same registered message, the `interest` status and its copy are gone, and the position is no longer shown nor forwarded by the Worker (`{ ok, status: 'registered', region }`). Cached old JS reads the missing fields as 0 and still shows the registered message. Closes #140 --- ARCHITECTURE.md | 6 +- i18n/en.json | 2 - i18n/es.json | 2 - src/components/nan/home/Hero.astro | 2 - src/lib/waitlist.ts | 23 +++---- src/lib/waitlistClient.ts | 32 ++------- src/pages/api/waitlist.ts | 2 - src/scripts/waitlist.ts | 6 +- src/tests/api/waitlist.test.ts | 31 +++++---- src/tests/landing/waitlistVanilla.test.ts | 32 ++++++++- src/tests/lib/waitlist.test.ts | 45 +++++++++++-- src/tests/lib/waitlistClient.test.ts | 80 ++++++++--------------- 12 files changed, 133 insertions(+), 130 deletions(-) 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'); + } }); });