feat(shared): нормализация белорусского телефона и контракт заявки
normalizeBelarusPhone/phoneError/formatBelarusPhone: любые форматы ввода → +375XXXXXXXXX, коды 25/29/33/44/17, подсказки по причине ошибки. validateLead: типы POST /api/lead, ошибки по полям, honeypot, очистка касаний. Тесты Vitest.
This commit is contained in:
@@ -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('/');
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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)}`;
|
||||
}
|
||||
Reference in New Issue
Block a user