Rename the two medcenter landings to their audience names: medcenter -> medcenterphysio, medcenterpersonal -> medcenterstart. Alongside the rename: - add a RevenueCalculator section to both landings; - rework the copy and figures in src/data/content.ts; - simplify the lead form: drop the "cabinet_state" and "profile" selects (along with SelectField and the matching fields in shared/lead.ts, lead-mapper.ts and amo-check.ts) and make company and email optional; - add the legacy/new static prototypes for both landings; - add pnpm-lock.yaml to medcenterstart (package-lock.json is still there too). deploy/apps.conf and deploy/README.md still refer to the old directory names and need a follow-up. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
125 lines
4.3 KiB
TypeScript
125 lines
4.3 KiB
TypeScript
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 }
|
|
}
|
|
}
|