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:
co-authored by
Claude Fable 5
parent
35f713a267
commit
8b954ea5db
@@ -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}`
|
||||
}
|
||||
Reference in New Issue
Block a user