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>
This commit is contained in:
Yuriy Panov
2026-08-28 19:59:14 +06:00
co-authored by Claude Fable 5
parent 35f713a267
commit 8b954ea5db
61 changed files with 9160 additions and 0 deletions
+155
View File
@@ -0,0 +1,155 @@
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}`
}