Convert hotel landing to React + Tailwind v4 with amoCRM lead capture
Ports the single-file ЭкзоОтель page to the same architecture as the fitness
landing: Vite + React 19 + TypeScript + Tailwind v4 on the client, Express 5 for
the /api/leads/hotels endpoint, zod schema shared between the two.
The original page is kept in legacy/index.html as the visual reference. Its 15
inlined base64 images are extracted to files (the 1 MB HTML becomes ~318 KB of
JS plus assets loaded on demand), and its text is reproduced line for line —
verified with an innerText diff. Section heights stay within 0.6% at 375 and
1440 px; the drift comes from Inter actually loading, which the original asked
for but never served.
Three deliberate departures, documented in the README:
* eyebrow and lead in the CTA block were dark teal on navy (3.4:1); they now
match the other dark sections
* hero fact values overflowed their 80px column into the label below 430px
* "2025–2026г.." typo in the market source note
Leads reuse the fitness amoCRM integration: contact lookup by phone in every
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. Vite runs on 5174
and the API on 3001 so both landings can run at once.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
60de4ef27c
commit
35f713a267
@@ -0,0 +1,124 @@
|
||||
import type { LeadPayload } from '../../shared/lead.ts'
|
||||
import { AmoClient, type AmoContact, type AmoFieldEntry } from './amocrm.ts'
|
||||
import { buildLeadName, mapLead, resolveLeadFieldMap, type LeadFieldMap } from './lead-mapper.ts'
|
||||
|
||||
const digitsOnly = (value: string) => value.replace(/\D/g, '')
|
||||
|
||||
/**
|
||||
* Last ten digits — the part that is stable across "+7 (999) 123-45-67",
|
||||
* "8 999 1234567" and "9991234567". Used to decide whether amoCRM already
|
||||
* knows a phone number.
|
||||
*/
|
||||
const phoneKey = (value: string) => digitsOnly(value).slice(-10)
|
||||
|
||||
/**
|
||||
* amoCRM's full-text search matches digit substrings, but a contact stored as
|
||||
* "+7 999…" is not found by a query starting with "8 999…". Try every spelling
|
||||
* a Russian visitor might type.
|
||||
*/
|
||||
function phoneQueries(phone: string): string[] {
|
||||
const digits = digitsOnly(phone)
|
||||
const queries = new Set<string>([phone.trim(), digits])
|
||||
if (digits.length >= 10) {
|
||||
const national = digits.slice(-10)
|
||||
queries.add(national)
|
||||
queries.add(`7${national}`)
|
||||
queries.add(`8${national}`)
|
||||
}
|
||||
return [...queries].filter(Boolean)
|
||||
}
|
||||
|
||||
/** Reads the values already stored in a contact's standard multitext field. */
|
||||
function existingValues(contact: AmoContact, code: 'PHONE' | 'EMAIL'): string[] {
|
||||
const entry = contact.custom_fields_values?.find((f) => f.field_code === code)
|
||||
return (entry?.values ?? []).map((v) => String(v.value ?? '')).filter(Boolean)
|
||||
}
|
||||
|
||||
function contactFieldEntries(payload: LeadPayload): AmoFieldEntry[] {
|
||||
const entries: AmoFieldEntry[] = [
|
||||
{ field_code: 'PHONE', values: [{ value: payload.phone, enum_code: 'WORK' }] },
|
||||
]
|
||||
if (payload.email) {
|
||||
entries.push({ field_code: 'EMAIL', values: [{ value: payload.email, enum_code: 'WORK' }] })
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
export interface CreatedLead {
|
||||
leadId: number
|
||||
contactId: number
|
||||
contactCreated: boolean
|
||||
}
|
||||
|
||||
export class LeadService {
|
||||
private fieldMap?: Promise<LeadFieldMap>
|
||||
|
||||
constructor(private readonly amo: AmoClient) {}
|
||||
|
||||
private getFieldMap(): Promise<LeadFieldMap> {
|
||||
this.fieldMap ??= this.amo.getLeadFields().then(resolveLeadFieldMap)
|
||||
return this.fieldMap
|
||||
}
|
||||
|
||||
async submit(payload: LeadPayload): Promise<CreatedLead> {
|
||||
const fieldMap = await this.getFieldMap()
|
||||
|
||||
// 1. Reuse the existing contact when the phone or email is already known.
|
||||
const existing = await this.amo.findContact([...phoneQueries(payload.phone), payload.email ?? ''])
|
||||
|
||||
let contactId: number
|
||||
let contactCreated = false
|
||||
|
||||
if (existing) {
|
||||
contactId = existing.id
|
||||
// Add whichever channel amoCRM does not have on file yet.
|
||||
const missing: AmoFieldEntry[] = []
|
||||
const knownPhones = existingValues(existing, 'PHONE').map(phoneKey)
|
||||
if (!knownPhones.includes(phoneKey(payload.phone))) {
|
||||
missing.push({
|
||||
field_code: 'PHONE',
|
||||
values: [
|
||||
...existingValues(existing, 'PHONE').map((value) => ({ value })),
|
||||
{ value: payload.phone, enum_code: 'WORK' },
|
||||
],
|
||||
})
|
||||
}
|
||||
if (payload.email) {
|
||||
const knownEmails = existingValues(existing, 'EMAIL').map((e) => e.toLowerCase())
|
||||
if (!knownEmails.includes(payload.email.toLowerCase())) {
|
||||
missing.push({
|
||||
field_code: 'EMAIL',
|
||||
values: [
|
||||
...existingValues(existing, 'EMAIL').map((value) => ({ value })),
|
||||
{ value: payload.email, enum_code: 'WORK' },
|
||||
],
|
||||
})
|
||||
}
|
||||
}
|
||||
await this.amo.appendContactFields(contactId, missing)
|
||||
} else {
|
||||
const created = await this.amo.createContact({ name: payload.name, fields: contactFieldEntries(payload) })
|
||||
contactId = created.id
|
||||
contactCreated = true
|
||||
}
|
||||
|
||||
// 2. Lead in the configured pipeline, first stage.
|
||||
const { fields, note } = mapLead(payload, fieldMap)
|
||||
const lead = await this.amo.createLead({
|
||||
name: buildLeadName(payload),
|
||||
contactId,
|
||||
fields,
|
||||
tags: ['заявка с сайта'],
|
||||
})
|
||||
|
||||
// 3. Everything the account has no field for lands in a readable note.
|
||||
// A failed note must not fail the lead — the lead is the valuable part.
|
||||
if (note) {
|
||||
await this.amo.addLeadNote(lead.id, note).catch((error: unknown) => {
|
||||
console.warn('[amo] lead %d created but the note failed:', lead.id, error)
|
||||
})
|
||||
}
|
||||
|
||||
return { leadId: lead.id, contactId, contactCreated }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user