/** * Phone normalisation shared by any lookup that has to match a number a customer typed * against one already stored. Ethiopian numbers reach us in three interchangeable shapes * (+2519…, 2519…, 09…) depending on whether they came from IAM, a guest booking form or a * saved profile, so an exact-string match silently misses. */ /** * Returns all plausible normalised variants of a raw phone string so that the * DB query matches regardless of how the number was stored (local 09… vs international +251…). * Returns an empty array when the input is clearly invalid (< 7 digits). */ export function normalizePhoneVariants(raw: string): string[] { // Strip whitespace, dashes, dots, parentheses — keep digits and a leading + const stripped = raw.replace(/[^\d+]/g, ''); const digits = stripped.replace(/^\+/, ''); if (digits.length < 7) return []; const variants = new Set([stripped]); if (stripped.startsWith('+251') && digits.length === 12) { // +251 9XXXXXXXX → 251 9XXXXXXXX (no +) → 09XXXXXXXX variants.add(digits); // 251XXXXXXXXX variants.add('0' + digits.slice(3)); // 09XXXXXXXXX } else if (stripped.startsWith('251') && digits.length === 12) { // 251 9XXXXXXXX → +251 9XXXXXXXX → 09XXXXXXXX variants.add('+' + stripped); // +251XXXXXXXXX variants.add('0' + digits.slice(3)); // 09XXXXXXXXX } else if (stripped.startsWith('0') && digits.length === 10) { // 09XXXXXXXX → +251 9XXXXXXXX → 251 9XXXXXXXX (no +) variants.add('+251' + digits.slice(1)); // +251XXXXXXXXX variants.add('251' + digits.slice(1)); // 251XXXXXXXXX } else if (!stripped.startsWith('+') && digits.length >= 9) { // bare international digits without + variants.add('+' + digits); } return [...variants]; } /** * A sign-in identifier is a single free-text field: the passenger types either an email * address or a phone number and the server works out which. Phone is the default reading — * an email must contain an `@` with something either side of it, everything else is treated * as a number so that malformed emails don't silently fall through to a phone lookup that * can never match. */ export type ResolvedIdentifier = { kind: 'email' | 'phone'; /** Lower-cased email, or null when the input is a phone number. */ email: string | null; /** Every stored shape the number could have, or [] when the input is an email. */ phoneVariants: string[]; }; const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; export function resolveIdentifier(raw: string): ResolvedIdentifier { const trimmed = raw.trim(); if (EMAIL_RE.test(trimmed)) { return { kind: 'email', email: trimmed.toLowerCase(), phoneVariants: [] }; } return { kind: 'phone', email: null, phoneVariants: normalizePhoneVariants(trimmed) }; } /** * `+251912345678` → `+2519****678`. Shown on the OTP screen so the passenger can tell which * number the code went to without the server handing back the full number to an unauthenticated * caller. */ export function maskPhone(phone: string): string { const stripped = phone.replace(/[^\d+]/g, ''); if (stripped.length <= 7) return stripped; const head = stripped.slice(0, stripped.startsWith('+') ? 5 : 4); const tail = stripped.slice(-3); return `${head}${'*'.repeat(4)}${tail}`; }