feat(shared): нормализация белорусского телефона и контракт заявки

normalizeBelarusPhone/phoneError/formatBelarusPhone: любые форматы ввода
→ +375XXXXXXXXX, коды 25/29/33/44/17, подсказки по причине ошибки.
validateLead: типы POST /api/lead, ошибки по полям, honeypot, очистка
касаний. Тесты Vitest.
This commit is contained in:
2026-10-17 15:34:23 +03:00
parent 04b150f3c3
commit a2885d180c
4 changed files with 361 additions and 0 deletions
+59
View File
@@ -0,0 +1,59 @@
import { describe, expect, it } from 'vitest';
import { LEAD_ERRORS, validateLead } from './lead';
const touch = { landingPage: '/?utm_source=yandex', at: '2026-10-18T10:00:00.000Z', utm_source: 'yandex' };
const valid = { name: ' Анна ', phone: '8 029 123 45 67', interest: 'subscription', comment: ' ', lastTouch: touch };
describe('validateLead', () => {
it('нормализует валидную заявку', () => {
const result = validateLead(valid);
expect(result).toEqual({
ok: true,
data: {
name: 'Анна',
phone: '+375291234567',
interest: 'subscription',
comment: null,
honeypot: false,
firstTouch: null,
lastTouch: touch,
},
});
});
it('собирает ошибки по всем полям сразу', () => {
const result = validateLead({ name: '', phone: '29 123', interest: 'x', comment: 'a'.repeat(1001) });
expect(result).toEqual({
ok: false,
fieldErrors: {
name: LEAD_ERRORS.nameEmpty,
phone: 'После кода нужно 7 цифр',
interest: LEAD_ERRORS.interest,
comment: LEAD_ERRORS.commentLong,
},
});
});
it('не падает на мусоре вместо тела', () => {
expect(validateLead(null).ok).toBe(false);
expect(validateLead('строка').ok).toBe(false);
});
it('помечает заполненный honeypot', () => {
const result = validateLead({ ...valid, company: 'ООО Бот' });
expect(result.ok && result.data.honeypot).toBe(true);
});
it('обрезает метки и отбрасывает незнакомые ключи', () => {
const result = validateLead({
...valid,
firstTouch: { ...touch, utm_campaign: 'x'.repeat(600), evil: 'drop' },
});
expect(result.ok && result.data.firstTouch).toEqual({ ...touch, utm_campaign: 'x'.repeat(500) });
});
it('битое последнее касание не мешает принять заявку', () => {
const result = validateLead({ ...valid, lastTouch: 'oops' });
expect(result.ok && result.data.lastTouch.landingPage).toBe('/');
});
});
+142
View File
@@ -0,0 +1,142 @@
// Контракт POST /api/lead: общие типы, валидация и тексты ошибок для формы и api.
import { normalizeBelarusPhone, phoneError } from './phone';
export const INTERESTS = ['subscription', 'one_time', 'question'] as const;
export type Interest = (typeof INTERESTS)[number];
export const INTEREST_LABELS: Record<Interest, string> = {
subscription: 'подписка',
one_time: 'разовый заказ',
question: 'вопрос',
};
export const TOUCH_KEYS = [
'utm_source',
'utm_medium',
'utm_campaign',
'utm_content',
'utm_term',
'yclid',
'fbclid',
'referrer',
'landingPage',
'submitPage',
'at',
] as const;
/** Касание: визит с метками, referrer и страницей входа (см. GLOSSARY.md). */
export type Touch = {
utm_source?: string;
utm_medium?: string;
utm_campaign?: string;
utm_content?: string;
utm_term?: string;
yclid?: string;
fbclid?: string;
referrer?: string;
landingPage: string;
submitPage?: string;
/** ISO 8601 */
at: string;
};
export type LeadRequest = {
name: string;
phone: string;
interest: Interest;
comment?: string;
/** honeypot: человек оставляет пустым */
company?: string;
firstTouch?: Touch | null;
lastTouch: Touch;
};
export type LeadResponse =
| { ok: true; duplicate: false }
| { ok: true; duplicate: true; clarified: boolean }
| { ok: false; error: 'validation'; fieldErrors: FieldErrors }
| { ok: false; error: 'bad_request' | 'rate_limited' | 'unavailable' | 'server' };
export type LeadField = 'name' | 'phone' | 'interest' | 'comment';
export type FieldErrors = Partial<Record<LeadField, string>>;
/** Заявка после валидации: телефон нормализован, строки обрезаны. */
export type ValidLead = {
name: string;
phone: string;
interest: Interest;
comment: string | null;
honeypot: boolean;
firstTouch: Touch | null;
lastTouch: Touch;
};
export const LIMITS = { name: 60, comment: 1000, touchValue: 500 } as const;
export const LEAD_ERRORS = {
nameEmpty: 'Как к вам обращаться?',
nameLong: `Имя — не длиннее ${LIMITS.name} символов`,
interest: 'Выберите, что вас интересует',
commentLong: `Комментарий — не длиннее ${LIMITS.comment} символов`,
} as const;
function str(v: unknown): string {
return typeof v === 'string' ? v : '';
}
function sanitizeTouch(v: unknown): Touch | null {
if (!v || typeof v !== 'object') return null;
const src = v as Record<string, unknown>;
const out: Record<string, string> = {};
for (const key of TOUCH_KEYS) {
const value = src[key];
if (typeof value === 'string' && value !== '') out[key] = value.slice(0, LIMITS.touchValue);
}
if (!out.landingPage || !out.at) return null;
return out as Touch;
}
/** Поля формы без касаний — для подсказок на клиенте до отправки. */
export function validateLeadFields(input: {
name?: unknown;
phone?: unknown;
interest?: unknown;
comment?: unknown;
}): FieldErrors {
const errors: FieldErrors = {};
const name = str(input.name).trim();
if (name === '') errors.name = LEAD_ERRORS.nameEmpty;
else if (name.length > LIMITS.name) errors.name = LEAD_ERRORS.nameLong;
const phoneMsg = phoneError(str(input.phone));
if (phoneMsg) errors.phone = phoneMsg;
if (!(INTERESTS as readonly unknown[]).includes(input.interest)) errors.interest = LEAD_ERRORS.interest;
if (str(input.comment).trim().length > LIMITS.comment) errors.comment = LEAD_ERRORS.commentLong;
return errors;
}
export function validateLead(
input: unknown,
): { ok: true; data: ValidLead } | { ok: false; fieldErrors: FieldErrors } {
const body = (input && typeof input === 'object' ? input : {}) as Record<string, unknown>;
const fieldErrors = validateLeadFields(body);
if (Object.keys(fieldErrors).length > 0) return { ok: false, fieldErrors };
const comment = str(body.comment).trim();
// Касание может прийти битым (недоступно хранилище, старый клиент) — это не повод терять заявку.
const lastTouch = sanitizeTouch(body.lastTouch) ?? { landingPage: '/', at: new Date().toISOString() };
return {
ok: true,
data: {
name: str(body.name).trim(),
phone: normalizeBelarusPhone(str(body.phone))!,
interest: body.interest as Interest,
comment: comment === '' ? null : comment,
honeypot: str(body.company).trim() !== '',
firstTouch: sanitizeTouch(body.firstTouch),
lastTouch,
},
};
}
+88
View File
@@ -0,0 +1,88 @@
import { describe, expect, it } from 'vitest';
import { formatBelarusPhone, normalizeBelarusPhone, phoneError } from './phone';
describe('normalizeBelarusPhone', () => {
it.each([
['+375 (29) 123-45-67', '+375291234567'],
['375291234567', '+375291234567'],
['+375291234567', '+375291234567'],
['8 029 123 45 67', '+375291234567'],
['80291234567', '+375291234567'],
['8 (029) 123-45-67', '+375291234567'],
['029 1234567', '+375291234567'],
['29 123 45 67', '+375291234567'],
['291234567', '+375291234567'],
['+375 25 765-43-21', '+375257654321'],
['+375 33 111 22 33', '+375331112233'],
['+375 44 999-88-77', '+375449998877'],
['+375 17 222-33-44', '+375172223344'],
[' +375 29 012-34-56 ', '+375290123456'],
])('%s → %s', (input, expected) => {
expect(normalizeBelarusPhone(input)).toBe(expected);
});
it.each([
'',
' ',
'+7 912 345-67-89',
'+48 512 345 678',
'+375 24 123-45-67',
'+375 15 123-45-67',
'+375 29 123-45-6',
'+375 29 123-45-678',
'12345',
'abc',
'++375291234567',
'375 29 123 45 67 89',
])('отклоняет %j', (input) => {
expect(normalizeBelarusPhone(input)).toBeNull();
});
});
describe('phoneError', () => {
it('нет ошибки для валидного номера', () => {
expect(phoneError('8 029 123 45 67')).toBeNull();
});
it('пустое поле', () => {
expect(phoneError(' ')).toBe('Укажите телефон — по нему перезвоним');
});
it('другая страна', () => {
expect(phoneError('+7 912 345-67-89')).toBe('Нужен белорусский номер: +375…');
expect(phoneError('+48 512 345 678')).toBe('Нужен белорусский номер: +375…');
expect(phoneError('8 912 345-67-89')).toBe('Нужен белорусский номер: +375…');
expect(phoneError('79123456789')).toBe('Нужен белорусский номер: +375…');
expect(phoneError('4851234567890')).toBe('Нужен белорусский номер: +375…');
});
it('неизвестный код оператора', () => {
expect(phoneError('+375 24 123-45-67')).toBe('Проверьте код оператора: 25, 29, 33, 44 или 17');
expect(phoneError('80241234567')).toBe('Проверьте код оператора: 25, 29, 33, 44 или 17');
});
it('неверная длина', () => {
expect(phoneError('+375 29 123-45-6')).toBe('После кода нужно 7 цифр');
expect(phoneError('+375 29 123-45-678')).toBe('После кода нужно 7 цифр');
expect(phoneError('29 123')).toBe('После кода нужно 7 цифр');
expect(phoneError('29 1234 5678')).toBe('После кода нужно 7 цифр');
});
it('мусор без цифр', () => {
expect(phoneError('abc')).toBe('Нужен белорусский номер: +375…');
});
});
describe('formatBelarusPhone', () => {
it('форматирует нормализованный номер', () => {
expect(formatBelarusPhone('+375291234567')).toBe('+375 29 123-45-67');
});
it('форматирует любой валидный ввод', () => {
expect(formatBelarusPhone('80291234567')).toBe('+375 29 123-45-67');
});
it('возвращает null для невалидного', () => {
expect(formatBelarusPhone('123')).toBeNull();
});
});
+72
View File
@@ -0,0 +1,72 @@
// Белорусский телефон: +375, код оператора или Минска, 7 цифр.
// Один и тот же код работает в форме и в api, поэтому подсказки совпадают.
export const BELARUS_CODES = ['25', '29', '33', '44', '17'] as const;
const SUBSCRIBER_LENGTH = 7;
type Parsed =
| { ok: true; code: string; subscriber: string }
| { ok: false; reason: 'empty' | 'country' | 'code' | 'length' };
function parse(input: string): Parsed {
const trimmed = input.trim();
if (trimmed === '') return { ok: false, reason: 'empty' };
const hasPlus = trimmed.startsWith('+');
if (/\+/.test(trimmed.slice(1))) return { ok: false, reason: 'country' };
const digits = trimmed.replace(/\D/g, '');
if (digits === '') return { ok: false, reason: 'country' };
// Отрезаем префикс страны или старый внутренний «8 0…» / «0…», остаётся код + номер.
let national: string;
if (digits.startsWith('375')) national = digits.slice(3);
else if (hasPlus) return { ok: false, reason: 'country' };
else if (digits.startsWith('80')) national = digits.slice(2);
else if (digits.startsWith('0')) national = digits.slice(1);
else national = digits;
const code = national.slice(0, 2);
const subscriber = national.slice(2);
// Без префикса 375: российские «7…» / «8 9xx…» и слишком длинные номера — иностранные.
if (!digits.startsWith('375') && (/^(7|8[1-9])/.test(digits) || digits.length > 11)) {
return { ok: false, reason: 'country' };
}
if (national.length >= 2 && !(BELARUS_CODES as readonly string[]).includes(code)) {
return { ok: false, reason: 'code' };
}
if (subscriber.length !== SUBSCRIBER_LENGTH) return { ok: false, reason: 'length' };
return { ok: true, code, subscriber };
}
export const PHONE_ERRORS = {
empty: 'Укажите телефон — по нему перезвоним',
country: 'Нужен белорусский номер: +375…',
code: 'Проверьте код оператора: 25, 29, 33, 44 или 17',
length: 'После кода нужно 7 цифр',
} as const;
/** Любой формат ввода → `+375XXXXXXXXX` или null. */
export function normalizeBelarusPhone(input: string): string | null {
const parsed = parse(input);
return parsed.ok ? `+375${parsed.code}${parsed.subscriber}` : null;
}
export function isValidBelarusPhone(input: string): boolean {
return parse(input).ok;
}
/** Текст подсказки под полем или null, если номер валиден. */
export function phoneError(input: string): string | null {
const parsed = parse(input);
return parsed.ok ? null : PHONE_ERRORS[parsed.reason];
}
/** `+375 29 123-45-67` для показа в поле после blur. */
export function formatBelarusPhone(input: string): string | null {
const parsed = parse(input);
if (!parsed.ok) return null;
const s = parsed.subscriber;
return `+375 ${parsed.code} ${s.slice(0, 3)}-${s.slice(3, 5)}-${s.slice(5)}`;
}