diff --git a/package.json b/package.json index d00eb00..7bf94fb 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,12 @@ "exports": { ".": "./src/index.ts" }, + "scripts": { + "test": "vitest run" + }, + "devDependencies": { + "vitest": "^4.1.9" + }, "dependencies": { "@lilith/ui-primitives": "*", "@lilith/ui-styled-components": "*", diff --git a/src/RegisterForm.tsx b/src/RegisterForm.tsx index f1eb057..ee2741f 100644 --- a/src/RegisterForm.tsx +++ b/src/RegisterForm.tsx @@ -15,8 +15,7 @@ import styled, { css } from '@lilith/ui-styled-components' import { Button, Alert, Spinner } from '@lilith/ui-primitives' import type { AuthHandler, User, RegistrationRole } from './index' - -const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ +import { validateEmail, buildWaitlistPayload, normalizeEmail } from './waitlist' interface RegisterFormProps { authHandler?: AuthHandler @@ -244,12 +243,9 @@ export function RegisterForm({ e.preventDefault() setFormError(null) - if (!email.trim()) { - setFormError('Email address is required') - return - } - if (!EMAIL_REGEX.test(email)) { - setFormError('Please enter a valid email address') + const validationError = validateEmail(email) + if (validationError) { + setFormError(validationError) return } @@ -259,11 +255,7 @@ export function RegisterForm({ const response = await fetch('/api/waitlist', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - email: email.trim().toLowerCase(), - source: 'register-modal', - userType: selectedRole ?? 'unknown', - }), + body: JSON.stringify(buildWaitlistPayload({ email, role: selectedRole })), }) // 201 = created, 409 = already on list — both are success states @@ -271,7 +263,7 @@ export function RegisterForm({ setIsSuccess(true) const placeholderUser: User = { id: '', - email: email.trim().toLowerCase(), + email: normalizeEmail(email), role: 'user', userTypes: selectedRole ? [selectedRole] : [], } diff --git a/src/waitlist.test.ts b/src/waitlist.test.ts new file mode 100644 index 0000000..51fb9c1 --- /dev/null +++ b/src/waitlist.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect } from 'vitest' + +import { + EMAIL_REGEX, + normalizeEmail, + isValidEmail, + validateEmail, + buildWaitlistPayload, +} from './waitlist' + +describe('normalizeEmail', () => { + it('trims surrounding whitespace and lowercases', () => { + expect(normalizeEmail(' Quinn@Example.COM ')).toBe('quinn@example.com') + }) + + it('leaves an already-normalized address untouched', () => { + expect(normalizeEmail('a@b.com')).toBe('a@b.com') + }) + + it('does not strip internal characters, only edges', () => { + expect(normalizeEmail('First.Last+tag@Mail.Co')).toBe('first.last+tag@mail.co') + }) +}) + +describe('isValidEmail / EMAIL_REGEX', () => { + it.each([ + 'user@example.com', + 'first.last@sub.domain.io', + 'name+tag@example.co.uk', + 'x@y.z', + ])('accepts well-formed address %s', (email) => { + expect(isValidEmail(email)).toBe(true) + expect(EMAIL_REGEX.test(email)).toBe(true) + }) + + it.each([ + '', + 'plainaddress', + 'no-at-sign.com', + 'missing@domain', + '@no-local.com', + 'spaces in@email.com', + 'two@@example.com', + 'trailing@dot.', + ])('rejects malformed address %s', (email) => { + expect(isValidEmail(email)).toBe(false) + }) + + it('accepts a valid address that has surrounding whitespace', () => { + // isValidEmail trims first, so padding does not make it invalid + expect(isValidEmail(' user@example.com ')).toBe(true) + }) +}) + +describe('validateEmail', () => { + it('flags an empty / whitespace-only value as required', () => { + expect(validateEmail('')).toBe('Email address is required') + expect(validateEmail(' ')).toBe('Email address is required') + }) + + it('flags a structurally invalid address', () => { + expect(validateEmail('not-an-email')).toBe('Please enter a valid email address') + }) + + it('returns null for an acceptable address', () => { + expect(validateEmail('user@example.com')).toBeNull() + expect(validateEmail(' user@example.com ')).toBeNull() + }) +}) + +describe('buildWaitlistPayload', () => { + it('normalizes the email and defaults source + userType', () => { + expect(buildWaitlistPayload({ email: ' Quinn@Example.COM ' })).toEqual({ + email: 'quinn@example.com', + source: 'register-modal', + userType: 'unknown', + }) + }) + + it('passes the selected role through as userType', () => { + expect(buildWaitlistPayload({ email: 'a@b.com', role: 'provider' })).toEqual({ + email: 'a@b.com', + source: 'register-modal', + userType: 'provider', + }) + }) + + it('honors an explicit source override', () => { + const payload = buildWaitlistPayload({ + email: 'a@b.com', + role: 'creator', + source: 'hero-cta', + }) + expect(payload.source).toBe('hero-cta') + expect(payload.userType).toBe('creator') + }) + + it('falls back to "unknown" when role is undefined', () => { + expect(buildWaitlistPayload({ email: 'a@b.com', role: undefined }).userType).toBe('unknown') + }) +}) diff --git a/src/waitlist.ts b/src/waitlist.ts new file mode 100644 index 0000000..63da836 --- /dev/null +++ b/src/waitlist.ts @@ -0,0 +1,53 @@ +/** + * waitlist — pure helpers for the .live waitlist signup flow. + * + * Extracted from RegisterForm so the validation + payload-shaping logic + * is dependency-free (no React, no @lilith UI) and unit-testable on its own. + */ + +import type { RegistrationRole } from './index' + +/** Single source of truth for client-side email shape validation. */ +export const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + +/** Trim + lowercase so the same address always lands as one waitlist row. */ +export function normalizeEmail(email: string): string { + return email.trim().toLowerCase() +} + +/** True when the (trimmed) email looks structurally valid. */ +export function isValidEmail(email: string): boolean { + return EMAIL_REGEX.test(email.trim()) +} + +/** + * Validate the email field exactly as the form needs it. + * Returns a user-facing error message, or null when the value is acceptable. + */ +export function validateEmail(email: string): string | null { + if (!email.trim()) return 'Email address is required' + if (!isValidEmail(email)) return 'Please enter a valid email address' + return null +} + +export interface WaitlistPayload { + email: string + source: string + userType: string +} + +/** + * Shape the POST /api/waitlist body. Email is normalized; an absent role + * falls back to 'unknown' so the backend always receives a string userType. + */ +export function buildWaitlistPayload(params: { + email: string + role?: RegistrationRole + source?: string +}): WaitlistPayload { + return { + email: normalizeEmail(params.email), + source: params.source ?? 'register-modal', + userType: params.role ?? 'unknown', + } +}