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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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' }`
Expand Down
2 changes: 0 additions & 2 deletions i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
2 changes: 0 additions & 2 deletions i18n/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
2 changes: 0 additions & 2 deletions src/components/nan/home/Hero.astro
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
23 changes: 9 additions & 14 deletions src/lib/waitlist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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'
Expand All @@ -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;
}

Expand Down Expand Up @@ -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,
};
}
Expand All @@ -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,
};
}
32 changes: 7 additions & 25 deletions src/lib/waitlistClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};

Expand Down Expand Up @@ -94,16 +89,10 @@ export function parseWaitlistResponse(
const data = body as Record<string, unknown>;

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,
};
}
Expand All @@ -127,17 +116,15 @@ 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.
// ---------------------------------------------------------------------------

/** Los textos que el componente serializa en data-msgs. */
export interface WaitlistMessages {
okRegistered: string;
okInterest: string;
okPosition: string;
okText: string;
errEmail: string;
errRegion: string;
Expand Down Expand Up @@ -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}`;
}
2 changes: 0 additions & 2 deletions src/pages/api/waitlist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,6 @@ export const POST: APIRoute = async ({ request }) => {
if (honeypot) {
return json({
ok: true,
position: 0,
total: 0,
status: 'registered',
region,
});
Expand Down
6 changes: 1 addition & 5 deletions src/scripts/waitlist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,6 @@ import {
interface Msgs {
sending: string;
okRegistered: string;
okInterest: string;
okPosition: string;
okText: string;
errEmail: string;
errRegion: string;
Expand All @@ -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.',
Expand Down Expand Up @@ -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);
});
}

Expand Down
31 changes: 18 additions & 13 deletions src/tests/api/waitlist.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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<typeof vi.fn>;
Expand Down
32 changes: 29 additions & 3 deletions src/tests/landing/waitlistVanilla.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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.
Expand All @@ -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<string, string>');
});
});

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');
});
});
});
Loading
Loading