The published number becomes +7 927 789-60-71 across all four landings — `contacts` in each `src/data/content.ts`, plus the fallback messages in the lead endpoints and `src/lib/lead.ts` that spell it out when amoCRM is down. The `legacy/` reference pages keep the old number: they are the visual originals, not something that ships. Tapping the phone button now also creates a lead. It cannot reuse the form path: a click carries no name and no number, so `callClickSchema` in `shared/lead.ts` validates the tracking data alone, `LeadService.submitCallClick` creates a contactless lead tagged `клик по телефону`, and `AmoClient.createLead` takes an optional `contactId` for it. The mapper's field/note split moved into `collect()` so both lead kinds share it. The browser fires this with `sendBeacon` (falling back to `fetch keepalive`), because the same click hands the page to `tel:` and a plain fetch would be cut off mid-flight. Duplicates are held down from both ends: one lead per browser session on the client, four per IP per 30 minutes on the server. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
157 lines
5.2 KiB
TypeScript
157 lines
5.2 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: ['заявка с сайта'],
|
|
})
|
|
|
|
// 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 }
|
|
}
|
|
}
|