Files
exodevices/hotel/server/src/lead-service.ts
T
Yuriy PanovandClaude Opus 5 1ee5431b92 hotel: apply the v7 design and add the callback dialog
Ports legacy/hotel v7 into the React landing.

- Copy: nav is Экономика / Для гостя / Интерьер / Сервис, and «Интерьер»
  now comes before «Сервис». The hero keeps one button and gets the
  2 500 ₽ / 600 000 ₽ / 7,2 млн ₽ facts. New headings and leads for the
  interior, service and request blocks. Session lengths 10–15 and 30–60
  min, area 12–36 m².
- Calculator counts sessions and gains the ЭкзоКлиник benchmark and a
  «Получить консультацию» button.
- Request form: company and email are optional. The server schema
  already treats them as optional.
- v8 phone type scale (≤620px), a 380px `micro` step, and a `slim`
  (≤640px) variant. The max-width variants are now declared widest first,
  so the narrower one wins where two apply.
- The market charts' SVGs stay inline, as in the mockup.

The header «Звонок» button opens the «Перезвоним вам» dialog from the
fitness landing (name, phone, consent). The server accepts form
'callback', names the deal «Обратный звонок — <имя>» and tags it
«обратный звонок». The phone link inside the dialog still reports a call
click.

With Inter substituted into the mockup, the page text matches it line for
line (except the fixed «2025–2026 гг.» typo), and every section height
matches at 360–1440 px.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 19:00:20 +06:00

157 lines
5.3 KiB
TypeScript

import type { CallClickPayload, LeadPayload } from '../../shared/lead.ts'
import { AmoClient, type AmoContact, type AmoFieldEntry } from './amocrm.ts'
import {
buildCallClickName,
buildLeadName,
mapCallClick,
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: [payload.form === 'callback' ? 'обратный звонок' : 'заявка с сайта'],
})
// 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 }
}
/**
* A visitor tapped the phone button. There is no contact to reuse or create —
* only the click itself — so the lead stands alone, and its tag and note are
* what tell a manager to expect an incoming call from this landing.
*/
async submitCallClick(payload: CallClickPayload): Promise<{ leadId: number }> {
const fieldMap = await this.getFieldMap()
const { fields, note } = mapCallClick(payload, fieldMap)
const lead = await this.amo.createLead({
name: buildCallClickName(),
fields,
tags: ['клик по телефону'],
})
// As above: a failed note must not fail the lead.
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 }
}
}