import type { LeadPayload } from '../../shared/lead.ts' import type { AmoCustomField, AmoFieldEntry } from './amocrm.ts' /** Human labels used in the fallback note. */ const LABELS: Record = { company: 'Медицинский центр', email: 'Email', phone: 'Телефон', profile: 'Основные направления центра', comment: 'Комментарий', page: 'Страница', referrer: 'Источник перехода', form: 'Форма', utm_source: 'utm_source', utm_medium: 'utm_medium', utm_campaign: 'utm_campaign', utm_content: 'utm_content', utm_term: 'utm_term', } /** * Candidate amoCRM field codes / name fragments for each piece of data we * collect. The first field in the account that matches wins; anything without * a match falls through to the lead note instead. */ const CANDIDATES: Record = { company: { codes: ['COMPANY', 'COMPANY_NAME'], names: ['компания', 'организация', 'медицинский центр', 'клиника', 'название центра', 'сеть'], }, profile: { codes: ['PROFILE', 'SPECIALITY'], names: ['профиль', 'основной профиль', 'направление', 'специализация'], }, comment: { codes: ['COMMENT', 'MESSAGE', 'DESCRIPTION'], names: ['комментарий', 'сообщение', 'описание заявки'] }, page: { codes: ['REFERRER', 'PAGE'], names: ['страница', 'посадочная страница', 'url страницы'] }, referrer: { codes: ['REFERER_URL'], names: ['источник перехода', 'referrer', 'реферер'] }, form: { codes: ['FORMNAME', 'FORM_NAME'], names: ['название формы', 'форма'] }, utm_source: { codes: ['UTM_SOURCE'], names: ['utm_source'] }, utm_medium: { codes: ['UTM_MEDIUM'], names: ['utm_medium'] }, utm_campaign: { codes: ['UTM_CAMPAIGN'], names: ['utm_campaign'] }, utm_content: { codes: ['UTM_CONTENT'], names: ['utm_content'] }, utm_term: { codes: ['UTM_TERM'], names: ['utm_term'] }, } /** Field types we know how to write a plain string into. */ const TEXTUAL = new Set(['text', 'textarea', 'url', 'tracking_data', 'numeric', 'price', 'monetary']) const ENUMERATED = new Set(['select', 'radiobutton', 'multiselect']) const normalise = (value: string) => value .toLowerCase() .replace(/ё/g, 'е') .replace(/[^a-zа-я0-9]+/gi, ' ') .trim() export type LeadFieldMap = Map /** Picks one account field per payload key. A field is never used twice. */ export function resolveLeadFieldMap(fields: AmoCustomField[]): LeadFieldMap { const map: LeadFieldMap = new Map() const taken = new Set() for (const [key, candidate] of Object.entries(CANDIDATES)) { const byCode = fields.find( (f) => f.code && candidate.codes.includes(f.code.toUpperCase()) && !taken.has(f.id), ) const match = byCode ?? fields.find((f) => { if (taken.has(f.id)) return false const name = normalise(f.name) return candidate.names.some((n) => name === normalise(n)) }) ?? fields.find((f) => { if (taken.has(f.id)) return false const name = normalise(f.name) return candidate.names.some((n) => name.includes(normalise(n))) }) if (!match) continue if (!TEXTUAL.has(match.type) && !ENUMERATED.has(match.type)) continue map.set(key, match) taken.add(match.id) } return map } /** Builds one amoCRM field entry, or null when the value cannot be represented. */ function toFieldEntry(field: AmoCustomField, value: string): AmoFieldEntry | null { if (ENUMERATED.has(field.type)) { const target = normalise(value) const option = field.enums?.find((e) => normalise(e.value) === target) // An unmatched option would silently create garbage, so let it hit the note. return option ? { field_id: field.id, values: [{ value: option.value, enum_id: option.id }] } : null } if (field.type === 'numeric' || field.type === 'price' || field.type === 'monetary') { const numeric = Number(value.replace(/[^\d.,-]/g, '').replace(',', '.')) return Number.isFinite(numeric) ? { field_id: field.id, values: [{ value: numeric }] } : null } return { field_id: field.id, values: [{ value }] } } export interface MappedLead { fields: AmoFieldEntry[] /** Everything that had no matching field, ready to be written as a note. */ note: string } export function mapLead(payload: LeadPayload, fieldMap: LeadFieldMap): MappedLead { const data: Record = { company: payload.company, profile: payload.profile, comment: payload.comment, page: payload.page, referrer: payload.referrer, form: 'Заявка на запуск физиотерапии с нуля', ...payload.utm, } const fields: AmoFieldEntry[] = [] const leftovers: string[] = [] for (const [key, value] of Object.entries(data)) { if (!value) continue const field = fieldMap.get(key) const entry = field ? toFieldEntry(field, value) : null if (entry) fields.push(entry) else leftovers.push(`${LABELS[key] ?? key}: ${value}`) } // Contact details are always repeated in the note so a manager can read the // whole request without opening the linked contact card. const header = [ 'Заявка с лендинга «Физиотерапия с нуля для медицинского центра»', `${LABELS.phone}: ${payload.phone}`, payload.email ? `${LABELS.email}: ${payload.email}` : undefined, ].filter(Boolean) as string[] return { fields, note: [...header, ...leftovers].join('\n') } } /** Lead title shown in the pipeline. */ export function buildLeadName(payload: LeadPayload): string { return `Физиотерапия — ${payload.company ?? payload.name}` }