diff --git a/packages/shared/src/lead.test.ts b/packages/shared/src/lead.test.ts new file mode 100644 index 0000000..dcf24dc --- /dev/null +++ b/packages/shared/src/lead.test.ts @@ -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('/'); + }); +}); diff --git a/packages/shared/src/lead.ts b/packages/shared/src/lead.ts new file mode 100644 index 0000000..de840ed --- /dev/null +++ b/packages/shared/src/lead.ts @@ -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 = { + 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>; + +/** Заявка после валидации: телефон нормализован, строки обрезаны. */ +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; + const out: Record = {}; + 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; + 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, + }, + }; +} diff --git a/packages/shared/src/phone.test.ts b/packages/shared/src/phone.test.ts new file mode 100644 index 0000000..cb29d94 --- /dev/null +++ b/packages/shared/src/phone.test.ts @@ -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(); + }); +}); diff --git a/packages/shared/src/phone.ts b/packages/shared/src/phone.ts new file mode 100644 index 0000000..502a18d --- /dev/null +++ b/packages/shared/src/phone.ts @@ -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)}`; +}