Files
exodevices/medcenter/server/src/lead-mapper.ts
T
Yuriy PanovandClaude Fable 5 8b954ea5db Convert medical-centre landing to React + Tailwind v4 with amoCRM lead capture
Ports the single-file MedCsFiz page — the medical centre that already runs
physiotherapy and wants the existing room to earn more — to the same
architecture as the fitness and hotel landings: Vite + React 19 + TypeScript +
Tailwind v4 on the client, Express 5 for the API, zod schema shared between the
two.

The original stays in legacy/index.html as the visual reference. Its 4 MB of
inlined CSS, JS and base64 images become 12 asset files (664 KB) plus a bundle
loaded on demand; the two 1.5 MB / 750 KB device PNGs are recompressed to webp.
The page is split into 18 components with all copy moved to src/data/content.ts.

Leads reuse the fitness amoCRM integration: contact lookup by phone in every
Russian spelling, deal in the first stage of pipeline 10980758, account fields
matched automatically with the rest written to a note. Rate limit, honeypot and
a localStorage fallback so a lead survives the CRM being down. Endpoint is
/api/leads/medical-centers-existing-physio; Vite runs on 5173 and the API on
3000.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 19:59:14 +06:00

156 lines
6.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { LeadPayload } from '../../shared/lead.ts'
import type { AmoCustomField, AmoFieldEntry } from './amocrm.ts'
/** Human labels used in the fallback note. */
const LABELS: Record<string, string> = {
company: 'Медицинский центр',
email: 'Email',
phone: 'Телефон',
cabinet_state: 'Состояние кабинета',
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<string, { codes: string[]; names: string[] }> = {
company: {
codes: ['COMPANY', 'COMPANY_NAME'],
names: ['компания', 'организация', 'медицинский центр', 'клиника', 'название центра', 'сеть'],
},
cabinet_state: {
codes: ['CABINET_STATE'],
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<string, AmoCustomField>
/** 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<number>()
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<string, string | undefined> = {
company: payload.company,
cabinet_state: payload.cabinet_state,
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}`
}