leads: new phone number and an amoCRM lead on every call-button click

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>
This commit is contained in:
Yuriy Panov
2026-09-09 00:58:15 +06:00
co-authored by Claude Opus 5
parent e7385e8898
commit e13854e918
39 changed files with 1059 additions and 106 deletions
+14
View File
@@ -70,6 +70,20 @@ npm run amo:check
(`[lead] payload was: …`) и сохраняется в `localStorage` браузера, а посетитель (`[lead] payload was: …`) и сохраняется в `localStorage` браузера, а посетитель
видит телефон для связи. видит телефон для связи.
### 4. Что происходит при клике на телефон
Кнопка звонка (в футере и в модальном окне «Перезвоним вам») помимо набора номера отправляет
`POST /api/leads/fitness-centers/call` — маячком `navigator.sendBeacon`, чтобы
запрос пережил переход браузера на `tel:`.
Сервер создаёт **сделку без контакта**: посетитель не оставил ни имени, ни
номера, известен только факт клика. Имя сделки — «Звонок с сайта — Recovery Zone»,
тег — `клик по телефону` (плюс `AMO_LEAD_TAGS`), страница и `utm_*` идут в поля
или в примечание, как и у обычной заявки.
Защита от дублей двойная: на клиенте — один лид на сессию браузера
(`sessionStorage`), на сервере — не больше 4 обращений с одного IP за 30 минут.
### Переменные окружения ### Переменные окружения
| Переменная | Обязательна | Описание | | Переменная | Обязательна | Описание |
+5 -3
View File
@@ -196,8 +196,10 @@ export class AmoClient {
* Creates the lead in the configured pipeline. Omitting `status_id` makes * Creates the lead in the configured pipeline. Omitting `status_id` makes
* amoCRM drop it into that pipeline's first stage, which is what we want. * amoCRM drop it into that pipeline's first stage, which is what we want.
*/ */
async createLead(input: { name: string; contactId: number; fields: AmoFieldEntry[]; tags: string[] }): Promise<AmoLead> { async createLead(input: { name: string; contactId?: number; fields: AmoFieldEntry[]; tags: string[] }): Promise<AmoLead> {
const embedded: Record<string, unknown> = { contacts: [{ id: input.contactId }] } // `contactId` is optional because a call-button click has nobody to attach:
// the visitor never typed a name or a number. See LeadService.submitCallClick.
const embedded: Record<string, unknown> = input.contactId ? { contacts: [{ id: input.contactId }] } : {}
const tags = [...new Set([...this.config.tags, ...input.tags])] const tags = [...new Set([...this.config.tags, ...input.tags])]
if (tags.length) embedded.tags = tags.map((name) => ({ name })) if (tags.length) embedded.tags = tags.map((name) => ({ name }))
@@ -209,7 +211,7 @@ export class AmoClient {
pipeline_id: this.config.pipelineId, pipeline_id: this.config.pipelineId,
responsible_user_id: this.config.responsibleUserId, responsible_user_id: this.config.responsibleUserId,
custom_fields_values: input.fields.length ? input.fields : undefined, custom_fields_values: input.fields.length ? input.fields : undefined,
_embedded: embedded, _embedded: Object.keys(embedded).length ? embedded : undefined,
}, },
], ],
}) })
+50 -4
View File
@@ -1,7 +1,7 @@
import path from 'node:path' import path from 'node:path'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import express from 'express' import express from 'express'
import { leadSchema, type LeadResponse } from '../../shared/lead.ts' import { callClickSchema, leadSchema, type CallClickResponse, type LeadResponse } from '../../shared/lead.ts'
import { AmoClient, AmoError } from './amocrm.ts' import { AmoClient, AmoError } from './amocrm.ts'
import { readAmoConfig, serverConfig } from './config.ts' import { readAmoConfig, serverConfig } from './config.ts'
import { LeadService } from './lead-service.ts' import { LeadService } from './lead-service.ts'
@@ -27,6 +27,9 @@ app.set('trust proxy', 'loopback')
app.use(express.json({ limit: '64kb' })) app.use(express.json({ limit: '64kb' }))
const limiter = createRateLimiter({ limit: 8, windowMs: 10 * 60 * 1000 }) const limiter = createRateLimiter({ limit: 8, windowMs: 10 * 60 * 1000 })
// Its own window: a call click costs the visitor one tap, so the same budget as
// the form would let a single page hold a tab open and fill the pipeline.
const callLimiter = createRateLimiter({ limit: 4, windowMs: 30 * 60 * 1000 })
app.get('/api/health', (_req, res) => { app.get('/api/health', (_req, res) => {
res.json({ ok: true, amo: Boolean(leadService), pipelineId: amoConfig?.pipelineId ?? null }) res.json({ ok: true, amo: Boolean(leadService), pipelineId: amoConfig?.pipelineId ?? null })
@@ -62,7 +65,7 @@ app.post('/api/leads/fitness-centers', async (req, res) => {
if (!leadService) { if (!leadService) {
// Never drop a real lead silently — it must be findable in the logs. // Never drop a real lead silently — it must be findable in the logs.
console.error('[lead] amoCRM is not configured. Lead payload:', JSON.stringify(payload)) console.error('[lead] amoCRM is not configured. Lead payload:', JSON.stringify(payload))
return send(503, { ok: false, error: 'Форма временно недоступна. Позвоните нам: +7 939 717-80-80.' }) return send(503, { ok: false, error: 'Форма временно недоступна. Позвоните нам: +7 927 789-60-71.' })
} }
try { try {
@@ -79,12 +82,55 @@ app.post('/api/leads/fitness-centers', async (req, res) => {
console.error('[lead] payload was:', JSON.stringify(payload)) console.error('[lead] payload was:', JSON.stringify(payload))
const message = const message =
error instanceof AmoError && error.status === 401 error instanceof AmoError && error.status === 401
? 'CRM отклонила запрос. Мы уже разбираемся — позвоните нам: +7 939 717-80-80.' ? 'CRM отклонила запрос. Мы уже разбираемся — позвоните нам: +7 927 789-60-71.'
: 'Не удалось отправить заявку. Позвоните нам: +7 939 717-80-80.' : 'Не удалось отправить заявку. Позвоните нам: +7 927 789-60-71.'
return send(502, { ok: false, error: message }) return send(502, { ok: false, error: message })
} }
}) })
/**
* Call-button clicks. Deliberately separate from the form endpoint: there is
* nothing to validate beyond the tracking data, and the browser fires this with
* `sendBeacon` while it is already navigating to `tel:` — nobody reads the
* response, so the handler must never make the visitor wait.
*/
app.post('/api/leads/fitness-centers/call', async (req, res) => {
const send = (status: number, body: CallClickResponse) => res.status(status).json(body)
const parsed = callClickSchema.safeParse(req.body ?? {})
if (!parsed.success) return send(400, { ok: false, error: 'Некорректные данные.' })
const payload = parsed.data
// Honeypot: answer exactly like a success so bots learn nothing.
if (payload.website) {
console.info('[call] honeypot triggered from %s', req.ip)
return send(200, { ok: true, leadId: 0 })
}
const { allowed, retryAfterSeconds } = callLimiter(req.ip ?? 'unknown')
if (!allowed) {
res.setHeader('Retry-After', String(retryAfterSeconds))
return send(429, { ok: false, error: 'Слишком много обращений с этого адреса.' })
}
if (!leadService) {
// The visitor is dialling regardless — at least leave a trace in the logs.
console.error('[call] amoCRM is not configured. Call click payload:', JSON.stringify(payload))
return send(503, { ok: false, error: 'CRM недоступна.' })
}
try {
const { leadId } = await leadService.submitCallClick(payload)
console.info('[call] amoCRM lead %d (клик по телефону)', leadId)
return send(200, { ok: true, leadId })
} catch (error) {
console.error('[call] amoCRM submission failed:', error)
console.error('[call] payload was:', JSON.stringify(payload))
return send(502, { ok: false, error: 'Не удалось зарегистрировать обращение.' })
}
})
if (serverConfig.isProduction) { if (serverConfig.isProduction) {
app.use( app.use(
express.static(clientDir, { express.static(clientDir, {
+53 -12
View File
@@ -1,6 +1,9 @@
import type { LeadPayload } from '../../shared/lead.ts' import type { CallClickPayload, LeadPayload } from '../../shared/lead.ts'
import type { AmoCustomField, AmoFieldEntry } from './amocrm.ts' import type { AmoCustomField, AmoFieldEntry } from './amocrm.ts'
/** The landing this server belongs to, as it reads in every note. */
const LANDING = 'EXO Recovery Zone для фитнес-клубов'
/** Human labels used in the fallback note. */ /** Human labels used in the fallback note. */
const LABELS: Record<string, string> = { const LABELS: Record<string, string> = {
company: 'Фитнес-клуб / сеть', company: 'Фитнес-клуб / сеть',
@@ -111,6 +114,25 @@ export interface MappedLead {
note: string note: string
} }
/**
* Splits the collected data into amoCRM field entries and the note lines that
* carry whatever the account has no field for.
*/
function collect(data: Record<string, string | undefined>, fieldMap: LeadFieldMap) {
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}`)
}
return { fields, leftovers }
}
export function mapLead(payload: LeadPayload, fieldMap: LeadFieldMap): MappedLead { export function mapLead(payload: LeadPayload, fieldMap: LeadFieldMap): MappedLead {
const data: Record<string, string | undefined> = { const data: Record<string, string | undefined> = {
company: payload.company, company: payload.company,
@@ -124,21 +146,12 @@ export function mapLead(payload: LeadPayload, fieldMap: LeadFieldMap): MappedLea
...payload.utm, ...payload.utm,
} }
const fields: AmoFieldEntry[] = [] const { fields, leftovers } = collect(data, fieldMap)
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 // Contact details are always repeated in the note so a manager can read the
// whole request without opening the linked contact card. // whole request without opening the linked contact card.
const header = [ const header = [
`Заявка с лендинга «EXO Recovery Zone для фитнес-клубов»`, `Заявка с лендинга «${LANDING}»`,
`${LABELS.phone}: ${payload.phone}`, `${LABELS.phone}: ${payload.phone}`,
payload.email ? `${LABELS.email}: ${payload.email}` : undefined, payload.email ? `${LABELS.email}: ${payload.email}` : undefined,
].filter(Boolean) as string[] ].filter(Boolean) as string[]
@@ -151,3 +164,31 @@ export function buildLeadName(payload: LeadPayload): string {
const who = payload.company ?? payload.name const who = payload.company ?? payload.name
return payload.form === 'callback' ? `Обратный звонок — ${who}` : `Recovery Zone — ${who}` return payload.form === 'callback' ? `Обратный звонок — ${who}` : `Recovery Zone — ${who}`
} }
/**
* The same mapping for a call-button click. Only the tracking data exists, so
* the note carries the whole story a manager needs.
*/
export function mapCallClick(payload: CallClickPayload, fieldMap: LeadFieldMap): MappedLead {
const { fields, leftovers } = collect(
{
page: payload.page,
referrer: payload.referrer,
form: 'Клик по кнопке звонка',
...payload.utm,
},
fieldMap,
)
const header = [
`Клик по кнопке звонка на лендинге «${LANDING}»`,
'Посетитель нажал кнопку звонка и контактов не оставил — ждём входящий вызов.',
]
return { fields, note: [...header, ...leftovers].join('\n') }
}
/** Lead title for a call-button click, which has no company or name to use. */
export function buildCallClickName(): string {
return 'Звонок с сайта — Recovery Zone'
}
+34 -2
View File
@@ -1,6 +1,13 @@
import type { LeadPayload } from '../../shared/lead.ts' import type { CallClickPayload, LeadPayload } from '../../shared/lead.ts'
import { AmoClient, type AmoContact, type AmoFieldEntry } from './amocrm.ts' import { AmoClient, type AmoContact, type AmoFieldEntry } from './amocrm.ts'
import { buildLeadName, mapLead, resolveLeadFieldMap, type LeadFieldMap } from './lead-mapper.ts' import {
buildCallClickName,
buildLeadName,
mapCallClick,
mapLead,
resolveLeadFieldMap,
type LeadFieldMap,
} from './lead-mapper.ts'
const digitsOnly = (value: string) => value.replace(/\D/g, '') const digitsOnly = (value: string) => value.replace(/\D/g, '')
@@ -121,4 +128,29 @@ export class LeadService {
return { leadId: lead.id, contactId, contactCreated } 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 }
}
} }
+23
View File
@@ -42,3 +42,26 @@ export type LeadInput = z.input<typeof leadSchema>
export type LeadResponse = export type LeadResponse =
| { ok: true; leadId: number; contactId: number } | { ok: true; leadId: number; contactId: number }
| { ok: false; error: string; fields?: Record<string, string> } | { ok: false; error: string; fields?: Record<string, string> }
/* -------------------------------------------------------------------------- */
/* Клик по кнопке звонка */
/* -------------------------------------------------------------------------- */
/**
* A visitor tapping the phone button leaves no contact details, so this shares
* nothing with `leadSchema` but the tracking data: it records where the click
* happened, and the lead it produces carries no contact at all.
*/
export const callClickSchema = z.object({
page: optionalText(300),
referrer: optionalText(500),
utm: z.partialRecord(z.enum(utmKeys), z.string().max(300)).optional(),
/** Honeypot. Bots fill it in; humans never see it. */
website: z.string().max(200).optional(),
})
export type CallClickPayload = z.infer<typeof callClickSchema>
/** What the client sends — before zod's optional/empty-string normalisation. */
export type CallClickInput = z.input<typeof callClickSchema>
export type CallClickResponse = { ok: true; leadId: number } | { ok: false; error: string }
+6 -2
View File
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState, type FormEvent } from 'react'
import { contacts } from '../data/content' import { contacts } from '../data/content'
import { useBodyLock } from '../hooks/useBodyLock' import { useBodyLock } from '../hooks/useBodyLock'
import { useEscapeKey } from '../hooks/useEscapeKey' import { useEscapeKey } from '../hooks/useEscapeKey'
import { submitLead } from '../lib/lead' import { reportCallClick, submitLead } from '../lib/lead'
import { cx } from '../lib/cx' import { cx } from '../lib/cx'
import { Button } from './Button' import { Button } from './Button'
import { ConsentCheckbox, Honeypot, TextField } from './FormField' import { ConsentCheckbox, Honeypot, TextField } from './FormField'
@@ -89,7 +89,11 @@ export function CallbackModal({ open, onClose }: { open: boolean; onClose: () =>
</h3> </h3>
<p className="mb-[20px] text-[13px] text-muted"> <p className="mb-[20px] text-[13px] text-muted">
Оставьте имя и телефон специалист свяжется и ответит на вопросы по зоне восстановления. Или позвоните сами:{' '} Оставьте имя и телефон специалист свяжется и ответит на вопросы по зоне восстановления. Или позвоните сами:{' '}
<a className="font-extrabold text-navy underline underline-offset-[3px]" href={contacts.phoneHref}> <a
className="font-extrabold text-navy underline underline-offset-[3px]"
href={contacts.phoneHref}
onClick={reportCallClick}
>
{contacts.phone} {contacts.phone}
</a> </a>
. .
+4 -1
View File
@@ -1,4 +1,5 @@
import { contacts } from '../data/content' import { contacts } from '../data/content'
import { reportCallClick } from '../lib/lead'
import { Container } from './Layout' import { Container } from './Layout'
export function SiteFooter() { export function SiteFooter() {
@@ -11,7 +12,9 @@ export function SiteFooter() {
Российские технологии реабилитации Российские технологии реабилитации
</div> </div>
<div className="flex flex-wrap gap-[16px] [&>a:hover]:text-white"> <div className="flex flex-wrap gap-[16px] [&>a:hover]:text-white">
<a href={contacts.phoneHref}>{contacts.phone}</a> <a href={contacts.phoneHref} onClick={reportCallClick}>
{contacts.phone}
</a>
<a href={`mailto:${contacts.email}`}>{contacts.email}</a> <a href={`mailto:${contacts.email}`}>{contacts.email}</a>
<a href={contacts.siteHref} rel="noopener" target="_blank"> <a href={contacts.siteHref} rel="noopener" target="_blank">
{contacts.site} {contacts.site}
+2 -2
View File
@@ -4,8 +4,8 @@ import galleryPt from '../assets/images/gallery-pt-stretch.webp'
import galleryFlagship from '../assets/images/gallery-flagship.webp' import galleryFlagship from '../assets/images/gallery-flagship.webp'
export const contacts = { export const contacts = {
phone: '+7 939 717-80-80', phone: '+7 927 789-60-71',
phoneHref: 'tel:+79397178080', phoneHref: 'tel:+79277896071',
email: 'info@exotherapy.ru', email: 'info@exotherapy.ru',
site: 'экзотерапия.рф', site: 'экзотерапия.рф',
siteHref: 'https://экзотерапия.рф', siteHref: 'https://экзотерапия.рф',
+79 -3
View File
@@ -1,4 +1,10 @@
import { utmKeys, type LeadFormId, type LeadInput, type LeadResponse } from '../../shared/lead' import {
utmKeys,
type CallClickInput,
type LeadFormId,
type LeadInput,
type LeadResponse,
} from '../../shared/lead'
const ENDPOINT = '/api/leads/fitness-centers' const ENDPOINT = '/api/leads/fitness-centers'
const DRAFT_KEY = 'exo_fitness_lead_draft' const DRAFT_KEY = 'exo_fitness_lead_draft'
@@ -46,7 +52,7 @@ export async function submitLead(form: LeadFormId, values: Omit<LeadInput, 'form
error: error:
body && !body.ok body && !body.ok
? body.error ? body.error
: 'Не удалось отправить форму автоматически. Данные сохранены в браузере — свяжитесь с нами по +7 939 717-80-80.', : 'Не удалось отправить форму автоматически. Данные сохранены в браузере — свяжитесь с нами по +7 927 789-60-71.',
fields: body && !body.ok ? body.fields : undefined, fields: body && !body.ok ? body.fields : undefined,
} }
} }
@@ -61,7 +67,7 @@ export async function submitLead(form: LeadFormId, values: Omit<LeadInput, 'form
return { return {
ok: false, ok: false,
error: error:
'Не удалось отправить форму автоматически. Данные сохранены в браузере — свяжитесь с нами по +7 939 717-80-80.', 'Не удалось отправить форму автоматически. Данные сохранены в браузере — свяжитесь с нами по +7 927 789-60-71.',
} }
} }
} }
@@ -74,6 +80,76 @@ function saveDraft(payload: LeadInput) {
} }
} }
/* -------------------------------------------------------------------------- */
/* Клик по кнопке звонка */
/* -------------------------------------------------------------------------- */
const CALL_ENDPOINT = `${ENDPOINT}/call`
const CALL_SENT_KEY = 'exo_fitness_call_click_sent'
/** Session-scoped so a visitor who redials is still one lead, not three. */
function callAlreadyReported(): boolean {
try {
return sessionStorage.getItem(CALL_SENT_KEY) === '1'
} catch {
// Private-mode browsers throw on access; one extra lead beats none.
return false
}
}
function markCallReported() {
try {
sessionStorage.setItem(CALL_SENT_KEY, '1')
} catch {
// See above.
}
}
/**
* Registers a tap on the phone button as an amoCRM lead.
*
* Fire-and-forget by necessity: the same click hands the page to `tel:`, so the
* request has to outlive the document. `sendBeacon` queues it in the browser
* process itself; `keepalive` is the fallback for the few browsers without it.
* Nothing here may throw or await — the dialler must open instantly.
*/
export function reportCallClick(): void {
if (callAlreadyReported()) return
markCallReported()
const payload: CallClickInput = {
page: window.location.pathname,
referrer: document.referrer || undefined,
utm: collectUtm(),
}
// A Blob, not a string: it is what gives the beacon its JSON content type.
const beacon = new Blob([JSON.stringify(payload)], { type: 'application/json' })
// sendBeacon returns false when the browser refuses to queue the payload, and
// a few throw instead of returning anything. Either way, fetch picks it up.
let queued = false
try {
queued = navigator.sendBeacon?.(CALL_ENDPOINT, beacon) ?? false
} catch {
queued = false
}
if (!queued) {
void fetch(CALL_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
keepalive: true,
}).catch((error: unknown) => {
console.warn('Call click endpoint error', error)
})
}
window.dataLayer = window.dataLayer ?? []
window.dataLayer.push({ event: 'fitness_call_click' })
}
declare global { declare global {
interface Window { interface Window {
dataLayer?: Record<string, unknown>[] dataLayer?: Record<string, unknown>[]
+14
View File
@@ -76,6 +76,20 @@ npm run amo:check
(`[lead] payload was: …`) и сохраняется в `localStorage` браузера, а посетитель (`[lead] payload was: …`) и сохраняется в `localStorage` браузера, а посетитель
видит телефон для связи. видит телефон для связи.
### 4. Что происходит при клике на телефон
Кнопка звонка (в шапке) помимо набора номера отправляет
`POST /api/leads/hotels/call` — маячком `navigator.sendBeacon`, чтобы
запрос пережил переход браузера на `tel:`.
Сервер создаёт **сделку без контакта**: посетитель не оставил ни имени, ни
номера, известен только факт клика. Имя сделки — «Звонок с сайта — ЭкзоОтель»,
тег — `клик по телефону` (плюс `AMO_LEAD_TAGS`), страница и `utm_*` идут в поля
или в примечание, как и у обычной заявки.
Защита от дублей двойная: на клиенте — один лид на сессию браузера
(`sessionStorage`), на сервере — не больше 4 обращений с одного IP за 30 минут.
### Переменные окружения ### Переменные окружения
| Переменная | Обязательна | Описание | | Переменная | Обязательна | Описание |
+5 -3
View File
@@ -196,8 +196,10 @@ export class AmoClient {
* Creates the lead in the configured pipeline. Omitting `status_id` makes * Creates the lead in the configured pipeline. Omitting `status_id` makes
* amoCRM drop it into that pipeline's first stage, which is what we want. * amoCRM drop it into that pipeline's first stage, which is what we want.
*/ */
async createLead(input: { name: string; contactId: number; fields: AmoFieldEntry[]; tags: string[] }): Promise<AmoLead> { async createLead(input: { name: string; contactId?: number; fields: AmoFieldEntry[]; tags: string[] }): Promise<AmoLead> {
const embedded: Record<string, unknown> = { contacts: [{ id: input.contactId }] } // `contactId` is optional because a call-button click has nobody to attach:
// the visitor never typed a name or a number. See LeadService.submitCallClick.
const embedded: Record<string, unknown> = input.contactId ? { contacts: [{ id: input.contactId }] } : {}
const tags = [...new Set([...this.config.tags, ...input.tags])] const tags = [...new Set([...this.config.tags, ...input.tags])]
if (tags.length) embedded.tags = tags.map((name) => ({ name })) if (tags.length) embedded.tags = tags.map((name) => ({ name }))
@@ -209,7 +211,7 @@ export class AmoClient {
pipeline_id: this.config.pipelineId, pipeline_id: this.config.pipelineId,
responsible_user_id: this.config.responsibleUserId, responsible_user_id: this.config.responsibleUserId,
custom_fields_values: input.fields.length ? input.fields : undefined, custom_fields_values: input.fields.length ? input.fields : undefined,
_embedded: embedded, _embedded: Object.keys(embedded).length ? embedded : undefined,
}, },
], ],
}) })
+50 -4
View File
@@ -1,7 +1,7 @@
import path from 'node:path' import path from 'node:path'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import express from 'express' import express from 'express'
import { leadSchema, type LeadResponse } from '../../shared/lead.ts' import { callClickSchema, leadSchema, type CallClickResponse, type LeadResponse } from '../../shared/lead.ts'
import { AmoClient, AmoError } from './amocrm.ts' import { AmoClient, AmoError } from './amocrm.ts'
import { readAmoConfig, serverConfig } from './config.ts' import { readAmoConfig, serverConfig } from './config.ts'
import { LeadService } from './lead-service.ts' import { LeadService } from './lead-service.ts'
@@ -27,6 +27,9 @@ app.set('trust proxy', 'loopback')
app.use(express.json({ limit: '64kb' })) app.use(express.json({ limit: '64kb' }))
const limiter = createRateLimiter({ limit: 8, windowMs: 10 * 60 * 1000 }) const limiter = createRateLimiter({ limit: 8, windowMs: 10 * 60 * 1000 })
// Its own window: a call click costs the visitor one tap, so the same budget as
// the form would let a single page hold a tab open and fill the pipeline.
const callLimiter = createRateLimiter({ limit: 4, windowMs: 30 * 60 * 1000 })
app.get('/api/health', (_req, res) => { app.get('/api/health', (_req, res) => {
res.json({ ok: true, amo: Boolean(leadService), pipelineId: amoConfig?.pipelineId ?? null }) res.json({ ok: true, amo: Boolean(leadService), pipelineId: amoConfig?.pipelineId ?? null })
@@ -62,7 +65,7 @@ app.post('/api/leads/hotels', async (req, res) => {
if (!leadService) { if (!leadService) {
// Never drop a real lead silently — it must be findable in the logs. // Never drop a real lead silently — it must be findable in the logs.
console.error('[lead] amoCRM is not configured. Lead payload:', JSON.stringify(payload)) console.error('[lead] amoCRM is not configured. Lead payload:', JSON.stringify(payload))
return send(503, { ok: false, error: 'Форма временно недоступна. Позвоните нам: +7 939 717-80-80.' }) return send(503, { ok: false, error: 'Форма временно недоступна. Позвоните нам: +7 927 789-60-71.' })
} }
try { try {
@@ -79,12 +82,55 @@ app.post('/api/leads/hotels', async (req, res) => {
console.error('[lead] payload was:', JSON.stringify(payload)) console.error('[lead] payload was:', JSON.stringify(payload))
const message = const message =
error instanceof AmoError && error.status === 401 error instanceof AmoError && error.status === 401
? 'CRM отклонила запрос. Мы уже разбираемся — позвоните нам: +7 939 717-80-80.' ? 'CRM отклонила запрос. Мы уже разбираемся — позвоните нам: +7 927 789-60-71.'
: 'Не удалось отправить заявку. Позвоните нам: +7 939 717-80-80.' : 'Не удалось отправить заявку. Позвоните нам: +7 927 789-60-71.'
return send(502, { ok: false, error: message }) return send(502, { ok: false, error: message })
} }
}) })
/**
* Call-button clicks. Deliberately separate from the form endpoint: there is
* nothing to validate beyond the tracking data, and the browser fires this with
* `sendBeacon` while it is already navigating to `tel:` — nobody reads the
* response, so the handler must never make the visitor wait.
*/
app.post('/api/leads/hotels/call', async (req, res) => {
const send = (status: number, body: CallClickResponse) => res.status(status).json(body)
const parsed = callClickSchema.safeParse(req.body ?? {})
if (!parsed.success) return send(400, { ok: false, error: 'Некорректные данные.' })
const payload = parsed.data
// Honeypot: answer exactly like a success so bots learn nothing.
if (payload.website) {
console.info('[call] honeypot triggered from %s', req.ip)
return send(200, { ok: true, leadId: 0 })
}
const { allowed, retryAfterSeconds } = callLimiter(req.ip ?? 'unknown')
if (!allowed) {
res.setHeader('Retry-After', String(retryAfterSeconds))
return send(429, { ok: false, error: 'Слишком много обращений с этого адреса.' })
}
if (!leadService) {
// The visitor is dialling regardless — at least leave a trace in the logs.
console.error('[call] amoCRM is not configured. Call click payload:', JSON.stringify(payload))
return send(503, { ok: false, error: 'CRM недоступна.' })
}
try {
const { leadId } = await leadService.submitCallClick(payload)
console.info('[call] amoCRM lead %d (клик по телефону)', leadId)
return send(200, { ok: true, leadId })
} catch (error) {
console.error('[call] amoCRM submission failed:', error)
console.error('[call] payload was:', JSON.stringify(payload))
return send(502, { ok: false, error: 'Не удалось зарегистрировать обращение.' })
}
})
if (serverConfig.isProduction) { if (serverConfig.isProduction) {
app.use( app.use(
express.static(clientDir, { express.static(clientDir, {
+53 -12
View File
@@ -1,6 +1,9 @@
import type { LeadPayload } from '../../shared/lead.ts' import type { CallClickPayload, LeadPayload } from '../../shared/lead.ts'
import type { AmoCustomField, AmoFieldEntry } from './amocrm.ts' import type { AmoCustomField, AmoFieldEntry } from './amocrm.ts'
/** The landing this server belongs to, as it reads in every note. */
const LANDING = 'ЭкзоОтель'
/** Human labels used in the fallback note. */ /** Human labels used in the fallback note. */
const LABELS: Record<string, string> = { const LABELS: Record<string, string> = {
company: 'Отель / сеть', company: 'Отель / сеть',
@@ -105,16 +108,11 @@ export interface MappedLead {
note: string note: string
} }
export function mapLead(payload: LeadPayload, fieldMap: LeadFieldMap): MappedLead { /**
const data: Record<string, string | undefined> = { * Splits the collected data into amoCRM field entries and the note lines that
company: payload.company, * carry whatever the account has no field for.
comment: payload.comment, */
page: payload.page, function collect(data: Record<string, string | undefined>, fieldMap: LeadFieldMap) {
referrer: payload.referrer,
form: 'Заявка на предложение',
...payload.utm,
}
const fields: AmoFieldEntry[] = [] const fields: AmoFieldEntry[] = []
const leftovers: string[] = [] const leftovers: string[] = []
@@ -126,10 +124,25 @@ export function mapLead(payload: LeadPayload, fieldMap: LeadFieldMap): MappedLea
else leftovers.push(`${LABELS[key] ?? key}: ${value}`) else leftovers.push(`${LABELS[key] ?? key}: ${value}`)
} }
return { fields, leftovers }
}
export function mapLead(payload: LeadPayload, fieldMap: LeadFieldMap): MappedLead {
const data: Record<string, string | undefined> = {
company: payload.company,
comment: payload.comment,
page: payload.page,
referrer: payload.referrer,
form: 'Заявка на предложение',
...payload.utm,
}
const { fields, leftovers } = collect(data, fieldMap)
// Contact details are always repeated in the note so a manager can read the // Contact details are always repeated in the note so a manager can read the
// whole request without opening the linked contact card. // whole request without opening the linked contact card.
const header = [ const header = [
'Заявка с лендинга «ЭкзоОтель»', `Заявка с лендинга «${LANDING}»`,
`${LABELS.phone}: ${payload.phone}`, `${LABELS.phone}: ${payload.phone}`,
payload.email ? `${LABELS.email}: ${payload.email}` : undefined, payload.email ? `${LABELS.email}: ${payload.email}` : undefined,
].filter(Boolean) as string[] ].filter(Boolean) as string[]
@@ -141,3 +154,31 @@ export function mapLead(payload: LeadPayload, fieldMap: LeadFieldMap): MappedLea
export function buildLeadName(payload: LeadPayload): string { export function buildLeadName(payload: LeadPayload): string {
return `ЭкзоОтель — ${payload.company ?? payload.name}` return `ЭкзоОтель — ${payload.company ?? payload.name}`
} }
/**
* The same mapping for a call-button click. Only the tracking data exists, so
* the note carries the whole story a manager needs.
*/
export function mapCallClick(payload: CallClickPayload, fieldMap: LeadFieldMap): MappedLead {
const { fields, leftovers } = collect(
{
page: payload.page,
referrer: payload.referrer,
form: 'Клик по кнопке звонка',
...payload.utm,
},
fieldMap,
)
const header = [
`Клик по кнопке звонка на лендинге «${LANDING}»`,
'Посетитель нажал кнопку звонка и контактов не оставил — ждём входящий вызов.',
]
return { fields, note: [...header, ...leftovers].join('\n') }
}
/** Lead title for a call-button click, which has no company or name to use. */
export function buildCallClickName(): string {
return 'Звонок с сайта — ЭкзоОтель'
}
+34 -2
View File
@@ -1,6 +1,13 @@
import type { LeadPayload } from '../../shared/lead.ts' import type { CallClickPayload, LeadPayload } from '../../shared/lead.ts'
import { AmoClient, type AmoContact, type AmoFieldEntry } from './amocrm.ts' import { AmoClient, type AmoContact, type AmoFieldEntry } from './amocrm.ts'
import { buildLeadName, mapLead, resolveLeadFieldMap, type LeadFieldMap } from './lead-mapper.ts' import {
buildCallClickName,
buildLeadName,
mapCallClick,
mapLead,
resolveLeadFieldMap,
type LeadFieldMap,
} from './lead-mapper.ts'
const digitsOnly = (value: string) => value.replace(/\D/g, '') const digitsOnly = (value: string) => value.replace(/\D/g, '')
@@ -121,4 +128,29 @@ export class LeadService {
return { leadId: lead.id, contactId, contactCreated } 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 }
}
} }
+23
View File
@@ -40,3 +40,26 @@ export type LeadInput = z.input<typeof leadSchema>
export type LeadResponse = export type LeadResponse =
| { ok: true; leadId: number; contactId: number } | { ok: true; leadId: number; contactId: number }
| { ok: false; error: string; fields?: Record<string, string> } | { ok: false; error: string; fields?: Record<string, string> }
/* -------------------------------------------------------------------------- */
/* Клик по кнопке звонка */
/* -------------------------------------------------------------------------- */
/**
* A visitor tapping the phone button leaves no contact details, so this shares
* nothing with `leadSchema` but the tracking data: it records where the click
* happened, and the lead it produces carries no contact at all.
*/
export const callClickSchema = z.object({
page: optionalText(300),
referrer: optionalText(500),
utm: z.partialRecord(z.enum(utmKeys), z.string().max(300)).optional(),
/** Honeypot. Bots fill it in; humans never see it. */
website: z.string().max(200).optional(),
})
export type CallClickPayload = z.infer<typeof callClickSchema>
/** What the client sends — before zod's optional/empty-string normalisation. */
export type CallClickInput = z.input<typeof callClickSchema>
export type CallClickResponse = { ok: true; leadId: number } | { ok: false; error: string }
+2
View File
@@ -2,6 +2,7 @@ import type { RefObject } from 'react'
import logo from '../assets/images/exo-logo.png' import logo from '../assets/images/exo-logo.png'
import { contacts, navLinks } from '../data/content' import { contacts, navLinks } from '../data/content'
import { cx } from '../lib/cx' import { cx } from '../lib/cx'
import { reportCallClick } from '../lib/lead'
import { ButtonLink, buttonClass, headerSize } from './Button' import { ButtonLink, buttonClass, headerSize } from './Button'
import { PhoneIcon } from './Icons' import { PhoneIcon } from './Icons'
import { Container } from './Layout' import { Container } from './Layout'
@@ -40,6 +41,7 @@ export function SiteHeader({ scrolled, headerRef }: { scrolled: boolean; headerR
<div className="flex shrink-0 items-center gap-[10px] compact:gap-[6px]"> <div className="flex shrink-0 items-center gap-[10px] compact:gap-[6px]">
<a <a
href={contacts.phoneHref} href={contacts.phoneHref}
onClick={reportCallClick}
aria-label="Позвонить в Экзо Групп" aria-label="Позвонить в Экзо Групп"
className={buttonClass('call', 'compact:px-[12px]', headerSize)} className={buttonClass('call', 'compact:px-[12px]', headerSize)}
> >
+2 -2
View File
@@ -8,8 +8,8 @@ import scenarioSpa from '../assets/images/scenario-spa.webp'
import scenarioWellness from '../assets/images/scenario-wellness.webp' import scenarioWellness from '../assets/images/scenario-wellness.webp'
export const contacts = { export const contacts = {
phone: '+7 939 717-80-80', phone: '+7 927 789-60-71',
phoneHref: 'tel:+79397178080', phoneHref: 'tel:+79277896071',
} }
export const navLinks = [ export const navLinks = [
+79 -3
View File
@@ -1,4 +1,10 @@
import { utmKeys, type LeadFormId, type LeadInput, type LeadResponse } from '../../shared/lead' import {
utmKeys,
type CallClickInput,
type LeadFormId,
type LeadInput,
type LeadResponse,
} from '../../shared/lead'
const ENDPOINT = '/api/leads/hotels' const ENDPOINT = '/api/leads/hotels'
const DRAFT_KEY = 'exo_hotel_lead_draft' const DRAFT_KEY = 'exo_hotel_lead_draft'
@@ -49,7 +55,7 @@ export async function submitLead(
error: error:
body && !body.ok body && !body.ok
? body.error ? body.error
: 'Не удалось отправить форму автоматически. Данные сохранены в браузере — свяжитесь с нами по +7 939 717-80-80.', : 'Не удалось отправить форму автоматически. Данные сохранены в браузере — свяжитесь с нами по +7 927 789-60-71.',
fields: body && !body.ok ? body.fields : undefined, fields: body && !body.ok ? body.fields : undefined,
} }
} }
@@ -64,7 +70,7 @@ export async function submitLead(
return { return {
ok: false, ok: false,
error: error:
'Не удалось отправить форму автоматически. Данные сохранены в браузере — свяжитесь с нами по +7 939 717-80-80.', 'Не удалось отправить форму автоматически. Данные сохранены в браузере — свяжитесь с нами по +7 927 789-60-71.',
} }
} }
} }
@@ -77,6 +83,76 @@ function saveDraft(payload: LeadInput) {
} }
} }
/* -------------------------------------------------------------------------- */
/* Клик по кнопке звонка */
/* -------------------------------------------------------------------------- */
const CALL_ENDPOINT = `${ENDPOINT}/call`
const CALL_SENT_KEY = 'exo_hotel_call_click_sent'
/** Session-scoped so a visitor who redials is still one lead, not three. */
function callAlreadyReported(): boolean {
try {
return sessionStorage.getItem(CALL_SENT_KEY) === '1'
} catch {
// Private-mode browsers throw on access; one extra lead beats none.
return false
}
}
function markCallReported() {
try {
sessionStorage.setItem(CALL_SENT_KEY, '1')
} catch {
// See above.
}
}
/**
* Registers a tap on the phone button as an amoCRM lead.
*
* Fire-and-forget by necessity: the same click hands the page to `tel:`, so the
* request has to outlive the document. `sendBeacon` queues it in the browser
* process itself; `keepalive` is the fallback for the few browsers without it.
* Nothing here may throw or await — the dialler must open instantly.
*/
export function reportCallClick(): void {
if (callAlreadyReported()) return
markCallReported()
const payload: CallClickInput = {
page: window.location.pathname,
referrer: document.referrer || undefined,
utm: collectUtm(),
}
// A Blob, not a string: it is what gives the beacon its JSON content type.
const beacon = new Blob([JSON.stringify(payload)], { type: 'application/json' })
// sendBeacon returns false when the browser refuses to queue the payload, and
// a few throw instead of returning anything. Either way, fetch picks it up.
let queued = false
try {
queued = navigator.sendBeacon?.(CALL_ENDPOINT, beacon) ?? false
} catch {
queued = false
}
if (!queued) {
void fetch(CALL_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
keepalive: true,
}).catch((error: unknown) => {
console.warn('Call click endpoint error', error)
})
}
window.dataLayer = window.dataLayer ?? []
window.dataLayer.push({ event: 'hotel_call_click' })
}
declare global { declare global {
interface Window { interface Window {
dataLayer?: Record<string, unknown>[] dataLayer?: Record<string, unknown>[]
+14
View File
@@ -71,6 +71,20 @@ npm run amo:check
(`[lead] payload was: …`) и сохраняется в `localStorage` браузера, а посетитель (`[lead] payload was: …`) и сохраняется в `localStorage` браузера, а посетитель
видит телефон для связи. видит телефон для связи.
### 4. Что происходит при клике на телефон
Кнопка звонка (в шапке и в нижней мобильной панели) помимо набора номера отправляет
`POST /api/leads/medical-centers-existing-physio/call` — маячком `navigator.sendBeacon`, чтобы
запрос пережил переход браузера на `tel:`.
Сервер создаёт **сделку без контакта**: посетитель не оставил ни имени, ни
номера, известен только факт клика. Имя сделки — «Звонок с сайта — Физиотерапия (усиление кабинета)»,
тег — `клик по телефону` (плюс `AMO_LEAD_TAGS`), страница и `utm_*` идут в поля
или в примечание, как и у обычной заявки.
Защита от дублей двойная: на клиенте — один лид на сессию браузера
(`sessionStorage`), на сервере — не больше 4 обращений с одного IP за 30 минут.
### Переменные окружения ### Переменные окружения
| Переменная | Обязательна | Описание | | Переменная | Обязательна | Описание |
+5 -3
View File
@@ -196,8 +196,10 @@ export class AmoClient {
* Creates the lead in the configured pipeline. Omitting `status_id` makes * Creates the lead in the configured pipeline. Omitting `status_id` makes
* amoCRM drop it into that pipeline's first stage, which is what we want. * amoCRM drop it into that pipeline's first stage, which is what we want.
*/ */
async createLead(input: { name: string; contactId: number; fields: AmoFieldEntry[]; tags: string[] }): Promise<AmoLead> { async createLead(input: { name: string; contactId?: number; fields: AmoFieldEntry[]; tags: string[] }): Promise<AmoLead> {
const embedded: Record<string, unknown> = { contacts: [{ id: input.contactId }] } // `contactId` is optional because a call-button click has nobody to attach:
// the visitor never typed a name or a number. See LeadService.submitCallClick.
const embedded: Record<string, unknown> = input.contactId ? { contacts: [{ id: input.contactId }] } : {}
const tags = [...new Set([...this.config.tags, ...input.tags])] const tags = [...new Set([...this.config.tags, ...input.tags])]
if (tags.length) embedded.tags = tags.map((name) => ({ name })) if (tags.length) embedded.tags = tags.map((name) => ({ name }))
@@ -209,7 +211,7 @@ export class AmoClient {
pipeline_id: this.config.pipelineId, pipeline_id: this.config.pipelineId,
responsible_user_id: this.config.responsibleUserId, responsible_user_id: this.config.responsibleUserId,
custom_fields_values: input.fields.length ? input.fields : undefined, custom_fields_values: input.fields.length ? input.fields : undefined,
_embedded: embedded, _embedded: Object.keys(embedded).length ? embedded : undefined,
}, },
], ],
}) })
+50 -4
View File
@@ -1,7 +1,7 @@
import path from 'node:path' import path from 'node:path'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import express from 'express' import express from 'express'
import { leadSchema, type LeadResponse } from '../../shared/lead.ts' import { callClickSchema, leadSchema, type CallClickResponse, type LeadResponse } from '../../shared/lead.ts'
import { AmoClient, AmoError } from './amocrm.ts' import { AmoClient, AmoError } from './amocrm.ts'
import { readAmoConfig, serverConfig } from './config.ts' import { readAmoConfig, serverConfig } from './config.ts'
import { LeadService } from './lead-service.ts' import { LeadService } from './lead-service.ts'
@@ -27,6 +27,9 @@ app.set('trust proxy', 'loopback')
app.use(express.json({ limit: '64kb' })) app.use(express.json({ limit: '64kb' }))
const limiter = createRateLimiter({ limit: 8, windowMs: 10 * 60 * 1000 }) const limiter = createRateLimiter({ limit: 8, windowMs: 10 * 60 * 1000 })
// Its own window: a call click costs the visitor one tap, so the same budget as
// the form would let a single page hold a tab open and fill the pipeline.
const callLimiter = createRateLimiter({ limit: 4, windowMs: 30 * 60 * 1000 })
app.get('/api/health', (_req, res) => { app.get('/api/health', (_req, res) => {
res.json({ ok: true, amo: Boolean(leadService), pipelineId: amoConfig?.pipelineId ?? null }) res.json({ ok: true, amo: Boolean(leadService), pipelineId: amoConfig?.pipelineId ?? null })
@@ -62,7 +65,7 @@ app.post('/api/leads/medical-centers-existing-physio', async (req, res) => {
if (!leadService) { if (!leadService) {
// Never drop a real lead silently — it must be findable in the logs. // Never drop a real lead silently — it must be findable in the logs.
console.error('[lead] amoCRM is not configured. Lead payload:', JSON.stringify(payload)) console.error('[lead] amoCRM is not configured. Lead payload:', JSON.stringify(payload))
return send(503, { ok: false, error: 'Форма временно недоступна. Позвоните нам: +7 939 717-80-80.' }) return send(503, { ok: false, error: 'Форма временно недоступна. Позвоните нам: +7 927 789-60-71.' })
} }
try { try {
@@ -79,12 +82,55 @@ app.post('/api/leads/medical-centers-existing-physio', async (req, res) => {
console.error('[lead] payload was:', JSON.stringify(payload)) console.error('[lead] payload was:', JSON.stringify(payload))
const message = const message =
error instanceof AmoError && error.status === 401 error instanceof AmoError && error.status === 401
? 'CRM отклонила запрос. Мы уже разбираемся — позвоните нам: +7 939 717-80-80.' ? 'CRM отклонила запрос. Мы уже разбираемся — позвоните нам: +7 927 789-60-71.'
: 'Не удалось отправить заявку. Позвоните нам: +7 939 717-80-80.' : 'Не удалось отправить заявку. Позвоните нам: +7 927 789-60-71.'
return send(502, { ok: false, error: message }) return send(502, { ok: false, error: message })
} }
}) })
/**
* Call-button clicks. Deliberately separate from the form endpoint: there is
* nothing to validate beyond the tracking data, and the browser fires this with
* `sendBeacon` while it is already navigating to `tel:` — nobody reads the
* response, so the handler must never make the visitor wait.
*/
app.post('/api/leads/medical-centers-existing-physio/call', async (req, res) => {
const send = (status: number, body: CallClickResponse) => res.status(status).json(body)
const parsed = callClickSchema.safeParse(req.body ?? {})
if (!parsed.success) return send(400, { ok: false, error: 'Некорректные данные.' })
const payload = parsed.data
// Honeypot: answer exactly like a success so bots learn nothing.
if (payload.website) {
console.info('[call] honeypot triggered from %s', req.ip)
return send(200, { ok: true, leadId: 0 })
}
const { allowed, retryAfterSeconds } = callLimiter(req.ip ?? 'unknown')
if (!allowed) {
res.setHeader('Retry-After', String(retryAfterSeconds))
return send(429, { ok: false, error: 'Слишком много обращений с этого адреса.' })
}
if (!leadService) {
// The visitor is dialling regardless — at least leave a trace in the logs.
console.error('[call] amoCRM is not configured. Call click payload:', JSON.stringify(payload))
return send(503, { ok: false, error: 'CRM недоступна.' })
}
try {
const { leadId } = await leadService.submitCallClick(payload)
console.info('[call] amoCRM lead %d (клик по телефону)', leadId)
return send(200, { ok: true, leadId })
} catch (error) {
console.error('[call] amoCRM submission failed:', error)
console.error('[call] payload was:', JSON.stringify(payload))
return send(502, { ok: false, error: 'Не удалось зарегистрировать обращение.' })
}
})
if (serverConfig.isProduction) { if (serverConfig.isProduction) {
app.use( app.use(
express.static(clientDir, { express.static(clientDir, {
+53 -12
View File
@@ -1,6 +1,9 @@
import type { LeadPayload } from '../../shared/lead.ts' import type { CallClickPayload, LeadPayload } from '../../shared/lead.ts'
import type { AmoCustomField, AmoFieldEntry } from './amocrm.ts' import type { AmoCustomField, AmoFieldEntry } from './amocrm.ts'
/** The landing this server belongs to, as it reads in every note. */
const LANDING = 'Усиление действующего кабинета физиотерапии'
/** Human labels used in the fallback note. */ /** Human labels used in the fallback note. */
const LABELS: Record<string, string> = { const LABELS: Record<string, string> = {
company: 'Медицинский центр', company: 'Медицинский центр',
@@ -105,16 +108,11 @@ export interface MappedLead {
note: string note: string
} }
export function mapLead(payload: LeadPayload, fieldMap: LeadFieldMap): MappedLead { /**
const data: Record<string, string | undefined> = { * Splits the collected data into amoCRM field entries and the note lines that
company: payload.company, * carry whatever the account has no field for.
comment: payload.comment, */
page: payload.page, function collect(data: Record<string, string | undefined>, fieldMap: LeadFieldMap) {
referrer: payload.referrer,
form: 'Заявка на усиление кабинета физиотерапии',
...payload.utm,
}
const fields: AmoFieldEntry[] = [] const fields: AmoFieldEntry[] = []
const leftovers: string[] = [] const leftovers: string[] = []
@@ -126,10 +124,25 @@ export function mapLead(payload: LeadPayload, fieldMap: LeadFieldMap): MappedLea
else leftovers.push(`${LABELS[key] ?? key}: ${value}`) else leftovers.push(`${LABELS[key] ?? key}: ${value}`)
} }
return { fields, leftovers }
}
export function mapLead(payload: LeadPayload, fieldMap: LeadFieldMap): MappedLead {
const data: Record<string, string | undefined> = {
company: payload.company,
comment: payload.comment,
page: payload.page,
referrer: payload.referrer,
form: 'Заявка на усиление кабинета физиотерапии',
...payload.utm,
}
const { fields, leftovers } = collect(data, fieldMap)
// Contact details are always repeated in the note so a manager can read the // Contact details are always repeated in the note so a manager can read the
// whole request without opening the linked contact card. // whole request without opening the linked contact card.
const header = [ const header = [
'Заявка с лендинга «Усиление действующего кабинета физиотерапии»', `Заявка с лендинга «${LANDING}»`,
`${LABELS.phone}: ${payload.phone}`, `${LABELS.phone}: ${payload.phone}`,
payload.email ? `${LABELS.email}: ${payload.email}` : undefined, payload.email ? `${LABELS.email}: ${payload.email}` : undefined,
].filter(Boolean) as string[] ].filter(Boolean) as string[]
@@ -141,3 +154,31 @@ export function mapLead(payload: LeadPayload, fieldMap: LeadFieldMap): MappedLea
export function buildLeadName(payload: LeadPayload): string { export function buildLeadName(payload: LeadPayload): string {
return `Физиотерапия — ${payload.company ?? payload.name}` return `Физиотерапия — ${payload.company ?? payload.name}`
} }
/**
* The same mapping for a call-button click. Only the tracking data exists, so
* the note carries the whole story a manager needs.
*/
export function mapCallClick(payload: CallClickPayload, fieldMap: LeadFieldMap): MappedLead {
const { fields, leftovers } = collect(
{
page: payload.page,
referrer: payload.referrer,
form: 'Клик по кнопке звонка',
...payload.utm,
},
fieldMap,
)
const header = [
`Клик по кнопке звонка на лендинге «${LANDING}»`,
'Посетитель нажал кнопку звонка и контактов не оставил — ждём входящий вызов.',
]
return { fields, note: [...header, ...leftovers].join('\n') }
}
/** Lead title for a call-button click, which has no company or name to use. */
export function buildCallClickName(): string {
return 'Звонок с сайта — Физиотерапия (усиление кабинета)'
}
+34 -2
View File
@@ -1,6 +1,13 @@
import type { LeadPayload } from '../../shared/lead.ts' import type { CallClickPayload, LeadPayload } from '../../shared/lead.ts'
import { AmoClient, type AmoContact, type AmoFieldEntry } from './amocrm.ts' import { AmoClient, type AmoContact, type AmoFieldEntry } from './amocrm.ts'
import { buildLeadName, mapLead, resolveLeadFieldMap, type LeadFieldMap } from './lead-mapper.ts' import {
buildCallClickName,
buildLeadName,
mapCallClick,
mapLead,
resolveLeadFieldMap,
type LeadFieldMap,
} from './lead-mapper.ts'
const digitsOnly = (value: string) => value.replace(/\D/g, '') const digitsOnly = (value: string) => value.replace(/\D/g, '')
@@ -121,4 +128,29 @@ export class LeadService {
return { leadId: lead.id, contactId, contactCreated } 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 }
}
} }
+23
View File
@@ -39,3 +39,26 @@ export type LeadInput = z.input<typeof leadSchema>
export type LeadResponse = export type LeadResponse =
| { ok: true; leadId: number; contactId: number } | { ok: true; leadId: number; contactId: number }
| { ok: false; error: string; fields?: Record<string, string> } | { ok: false; error: string; fields?: Record<string, string> }
/* -------------------------------------------------------------------------- */
/* Клик по кнопке звонка */
/* -------------------------------------------------------------------------- */
/**
* A visitor tapping the phone button leaves no contact details, so this shares
* nothing with `leadSchema` but the tracking data: it records where the click
* happened, and the lead it produces carries no contact at all.
*/
export const callClickSchema = z.object({
page: optionalText(300),
referrer: optionalText(500),
utm: z.partialRecord(z.enum(utmKeys), z.string().max(300)).optional(),
/** Honeypot. Bots fill it in; humans never see it. */
website: z.string().max(200).optional(),
})
export type CallClickPayload = z.infer<typeof callClickSchema>
/** What the client sends — before zod's optional/empty-string normalisation. */
export type CallClickInput = z.input<typeof callClickSchema>
export type CallClickResponse = { ok: true; leadId: number } | { ok: false; error: string }
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { contacts } from '../data/content' import { contacts } from '../data/content'
import { cx } from '../lib/cx' import { cx } from '../lib/cx'
import { reportCallClick } from '../lib/lead'
import { ButtonLink, buttonClass } from './Button' import { ButtonLink, buttonClass } from './Button'
import { PhoneIcon } from './Icons' import { PhoneIcon } from './Icons'
@@ -52,6 +53,7 @@ export function MobileActionBar() {
<div className="flex items-center gap-[10px]"> <div className="flex items-center gap-[10px]">
<a <a
href={contacts.phoneHref} href={contacts.phoneHref}
onClick={reportCallClick}
aria-label={`Позвонить в Экзо Групп по номеру ${contacts.phone}`} aria-label={`Позвонить в Экзо Групп по номеру ${contacts.phone}`}
className={buttonClass('call', 'shrink-0', 'min-h-[52px] w-[52px] px-0')} className={buttonClass('call', 'shrink-0', 'min-h-[52px] w-[52px] px-0')}
> >
@@ -1,5 +1,6 @@
import logo from '../assets/images/exo-logo.png' import logo from '../assets/images/exo-logo.png'
import { contacts, navLinks } from '../data/content' import { contacts, navLinks } from '../data/content'
import { reportCallClick } from '../lib/lead'
import { ButtonLink, buttonClass, headerSize } from './Button' import { ButtonLink, buttonClass, headerSize } from './Button'
import { PhoneIcon } from './Icons' import { PhoneIcon } from './Icons'
import { Container } from './Layout' import { Container } from './Layout'
@@ -44,6 +45,7 @@ export function SiteHeader() {
<div className="flex shrink-0 items-center gap-[8px]"> <div className="flex shrink-0 items-center gap-[8px]">
<a <a
href={contacts.phoneHref} href={contacts.phoneHref}
onClick={reportCallClick}
aria-label={`Позвонить в Экзо Групп по номеру ${contacts.phone}`} aria-label={`Позвонить в Экзо Групп по номеру ${contacts.phone}`}
className={buttonClass( className={buttonClass(
'call', 'call',
+2 -2
View File
@@ -7,8 +7,8 @@ import deviceTecar from '../assets/images/device-tecar.webp'
import deviceTherapy from '../assets/images/device-therapy.webp' import deviceTherapy from '../assets/images/device-therapy.webp'
export const contacts = { export const contacts = {
phone: '+7 939 717-80-80', phone: '+7 927 789-60-71',
phoneHref: 'tel:+79397178080', phoneHref: 'tel:+79277896071',
} }
export const navLinks = [ export const navLinks = [
+78 -2
View File
@@ -1,10 +1,16 @@
import { utmKeys, type LeadFormId, type LeadInput, type LeadResponse } from '../../shared/lead' import {
utmKeys,
type CallClickInput,
type LeadFormId,
type LeadInput,
type LeadResponse,
} from '../../shared/lead'
const ENDPOINT = '/api/leads/medical-centers-existing-physio' const ENDPOINT = '/api/leads/medical-centers-existing-physio'
const DRAFT_KEY = 'exo_medcenter_lead_draft' const DRAFT_KEY = 'exo_medcenter_lead_draft'
const FALLBACK_ERROR = const FALLBACK_ERROR =
'Не удалось отправить форму автоматически. Данные сохранены в браузере — свяжитесь с нами по +7 939 717-80-80.' 'Не удалось отправить форму автоматически. Данные сохранены в браузере — свяжитесь с нами по +7 927 789-60-71.'
/** Query-string UTM tags, forwarded to amoCRM with the lead. */ /** Query-string UTM tags, forwarded to amoCRM with the lead. */
function collectUtm(): Record<string, string> { function collectUtm(): Record<string, string> {
@@ -73,6 +79,76 @@ function saveDraft(payload: LeadInput) {
} }
} }
/* -------------------------------------------------------------------------- */
/* Клик по кнопке звонка */
/* -------------------------------------------------------------------------- */
const CALL_ENDPOINT = `${ENDPOINT}/call`
const CALL_SENT_KEY = 'exo_medcenter_call_click_sent'
/** Session-scoped so a visitor who redials is still one lead, not three. */
function callAlreadyReported(): boolean {
try {
return sessionStorage.getItem(CALL_SENT_KEY) === '1'
} catch {
// Private-mode browsers throw on access; one extra lead beats none.
return false
}
}
function markCallReported() {
try {
sessionStorage.setItem(CALL_SENT_KEY, '1')
} catch {
// See above.
}
}
/**
* Registers a tap on the phone button as an amoCRM lead.
*
* Fire-and-forget by necessity: the same click hands the page to `tel:`, so the
* request has to outlive the document. `sendBeacon` queues it in the browser
* process itself; `keepalive` is the fallback for the few browsers without it.
* Nothing here may throw or await — the dialler must open instantly.
*/
export function reportCallClick(): void {
if (callAlreadyReported()) return
markCallReported()
const payload: CallClickInput = {
page: window.location.pathname,
referrer: document.referrer || undefined,
utm: collectUtm(),
}
// A Blob, not a string: it is what gives the beacon its JSON content type.
const beacon = new Blob([JSON.stringify(payload)], { type: 'application/json' })
// sendBeacon returns false when the browser refuses to queue the payload, and
// a few throw instead of returning anything. Either way, fetch picks it up.
let queued = false
try {
queued = navigator.sendBeacon?.(CALL_ENDPOINT, beacon) ?? false
} catch {
queued = false
}
if (!queued) {
void fetch(CALL_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
keepalive: true,
}).catch((error: unknown) => {
console.warn('Call click endpoint error', error)
})
}
window.dataLayer = window.dataLayer ?? []
window.dataLayer.push({ event: 'medcenter_call_click' })
}
declare global { declare global {
interface Window { interface Window {
dataLayer?: Record<string, unknown>[] dataLayer?: Record<string, unknown>[]
+14
View File
@@ -77,6 +77,20 @@ npm run amo:check
(`[lead] payload was: …`) и сохраняется в `localStorage` браузера, а посетитель (`[lead] payload was: …`) и сохраняется в `localStorage` браузера, а посетитель
видит телефон для связи. видит телефон для связи.
### 4. Что происходит при клике на телефон
Кнопка звонка (в шапке и в мобильном меню разделов) помимо набора номера отправляет
`POST /api/leads/medical-centers-no-physio/call` — маячком `navigator.sendBeacon`, чтобы
запрос пережил переход браузера на `tel:`.
Сервер создаёт **сделку без контакта**: посетитель не оставил ни имени, ни
номера, известен только факт клика. Имя сделки — «Звонок с сайта — Физиотерапия (запуск с нуля)»,
тег — `клик по телефону` (плюс `AMO_LEAD_TAGS`), страница и `utm_*` идут в поля
или в примечание, как и у обычной заявки.
Защита от дублей двойная: на клиенте — один лид на сессию браузера
(`sessionStorage`), на сервере — не больше 4 обращений с одного IP за 30 минут.
### Переменные окружения ### Переменные окружения
| Переменная | Обязательна | Описание | | Переменная | Обязательна | Описание |
+5 -3
View File
@@ -196,8 +196,10 @@ export class AmoClient {
* Creates the lead in the configured pipeline. Omitting `status_id` makes * Creates the lead in the configured pipeline. Omitting `status_id` makes
* amoCRM drop it into that pipeline's first stage, which is what we want. * amoCRM drop it into that pipeline's first stage, which is what we want.
*/ */
async createLead(input: { name: string; contactId: number; fields: AmoFieldEntry[]; tags: string[] }): Promise<AmoLead> { async createLead(input: { name: string; contactId?: number; fields: AmoFieldEntry[]; tags: string[] }): Promise<AmoLead> {
const embedded: Record<string, unknown> = { contacts: [{ id: input.contactId }] } // `contactId` is optional because a call-button click has nobody to attach:
// the visitor never typed a name or a number. See LeadService.submitCallClick.
const embedded: Record<string, unknown> = input.contactId ? { contacts: [{ id: input.contactId }] } : {}
const tags = [...new Set([...this.config.tags, ...input.tags])] const tags = [...new Set([...this.config.tags, ...input.tags])]
if (tags.length) embedded.tags = tags.map((name) => ({ name })) if (tags.length) embedded.tags = tags.map((name) => ({ name }))
@@ -209,7 +211,7 @@ export class AmoClient {
pipeline_id: this.config.pipelineId, pipeline_id: this.config.pipelineId,
responsible_user_id: this.config.responsibleUserId, responsible_user_id: this.config.responsibleUserId,
custom_fields_values: input.fields.length ? input.fields : undefined, custom_fields_values: input.fields.length ? input.fields : undefined,
_embedded: embedded, _embedded: Object.keys(embedded).length ? embedded : undefined,
}, },
], ],
}) })
+50 -4
View File
@@ -1,7 +1,7 @@
import path from 'node:path' import path from 'node:path'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import express from 'express' import express from 'express'
import { leadSchema, type LeadResponse } from '../../shared/lead.ts' import { callClickSchema, leadSchema, type CallClickResponse, type LeadResponse } from '../../shared/lead.ts'
import { AmoClient, AmoError } from './amocrm.ts' import { AmoClient, AmoError } from './amocrm.ts'
import { readAmoConfig, serverConfig } from './config.ts' import { readAmoConfig, serverConfig } from './config.ts'
import { LeadService } from './lead-service.ts' import { LeadService } from './lead-service.ts'
@@ -27,6 +27,9 @@ app.set('trust proxy', 'loopback')
app.use(express.json({ limit: '64kb' })) app.use(express.json({ limit: '64kb' }))
const limiter = createRateLimiter({ limit: 8, windowMs: 10 * 60 * 1000 }) const limiter = createRateLimiter({ limit: 8, windowMs: 10 * 60 * 1000 })
// Its own window: a call click costs the visitor one tap, so the same budget as
// the form would let a single page hold a tab open and fill the pipeline.
const callLimiter = createRateLimiter({ limit: 4, windowMs: 30 * 60 * 1000 })
app.get('/api/health', (_req, res) => { app.get('/api/health', (_req, res) => {
res.json({ ok: true, amo: Boolean(leadService), pipelineId: amoConfig?.pipelineId ?? null }) res.json({ ok: true, amo: Boolean(leadService), pipelineId: amoConfig?.pipelineId ?? null })
@@ -62,7 +65,7 @@ app.post('/api/leads/medical-centers-no-physio', async (req, res) => {
if (!leadService) { if (!leadService) {
// Never drop a real lead silently — it must be findable in the logs. // Never drop a real lead silently — it must be findable in the logs.
console.error('[lead] amoCRM is not configured. Lead payload:', JSON.stringify(payload)) console.error('[lead] amoCRM is not configured. Lead payload:', JSON.stringify(payload))
return send(503, { ok: false, error: 'Форма временно недоступна. Позвоните нам: +7 939 717-80-80.' }) return send(503, { ok: false, error: 'Форма временно недоступна. Позвоните нам: +7 927 789-60-71.' })
} }
try { try {
@@ -79,12 +82,55 @@ app.post('/api/leads/medical-centers-no-physio', async (req, res) => {
console.error('[lead] payload was:', JSON.stringify(payload)) console.error('[lead] payload was:', JSON.stringify(payload))
const message = const message =
error instanceof AmoError && error.status === 401 error instanceof AmoError && error.status === 401
? 'CRM отклонила запрос. Мы уже разбираемся — позвоните нам: +7 939 717-80-80.' ? 'CRM отклонила запрос. Мы уже разбираемся — позвоните нам: +7 927 789-60-71.'
: 'Не удалось отправить заявку. Позвоните нам: +7 939 717-80-80.' : 'Не удалось отправить заявку. Позвоните нам: +7 927 789-60-71.'
return send(502, { ok: false, error: message }) return send(502, { ok: false, error: message })
} }
}) })
/**
* Call-button clicks. Deliberately separate from the form endpoint: there is
* nothing to validate beyond the tracking data, and the browser fires this with
* `sendBeacon` while it is already navigating to `tel:` — nobody reads the
* response, so the handler must never make the visitor wait.
*/
app.post('/api/leads/medical-centers-no-physio/call', async (req, res) => {
const send = (status: number, body: CallClickResponse) => res.status(status).json(body)
const parsed = callClickSchema.safeParse(req.body ?? {})
if (!parsed.success) return send(400, { ok: false, error: 'Некорректные данные.' })
const payload = parsed.data
// Honeypot: answer exactly like a success so bots learn nothing.
if (payload.website) {
console.info('[call] honeypot triggered from %s', req.ip)
return send(200, { ok: true, leadId: 0 })
}
const { allowed, retryAfterSeconds } = callLimiter(req.ip ?? 'unknown')
if (!allowed) {
res.setHeader('Retry-After', String(retryAfterSeconds))
return send(429, { ok: false, error: 'Слишком много обращений с этого адреса.' })
}
if (!leadService) {
// The visitor is dialling regardless — at least leave a trace in the logs.
console.error('[call] amoCRM is not configured. Call click payload:', JSON.stringify(payload))
return send(503, { ok: false, error: 'CRM недоступна.' })
}
try {
const { leadId } = await leadService.submitCallClick(payload)
console.info('[call] amoCRM lead %d (клик по телефону)', leadId)
return send(200, { ok: true, leadId })
} catch (error) {
console.error('[call] amoCRM submission failed:', error)
console.error('[call] payload was:', JSON.stringify(payload))
return send(502, { ok: false, error: 'Не удалось зарегистрировать обращение.' })
}
})
if (serverConfig.isProduction) { if (serverConfig.isProduction) {
app.use( app.use(
express.static(clientDir, { express.static(clientDir, {
+54 -13
View File
@@ -1,6 +1,9 @@
import type { LeadPayload } from '../../shared/lead.ts' import type { CallClickPayload, LeadPayload } from '../../shared/lead.ts'
import type { AmoCustomField, AmoFieldEntry } from './amocrm.ts' import type { AmoCustomField, AmoFieldEntry } from './amocrm.ts'
/** The landing this server belongs to, as it reads in every note. */
const LANDING = 'Физиотерапия с нуля для медицинского центра'
/** Human labels used in the fallback note. */ /** Human labels used in the fallback note. */
const LABELS: Record<string, string> = { const LABELS: Record<string, string> = {
company: 'Медицинский центр', company: 'Медицинский центр',
@@ -110,17 +113,11 @@ export interface MappedLead {
note: string note: string
} }
export function mapLead(payload: LeadPayload, fieldMap: LeadFieldMap): MappedLead { /**
const data: Record<string, string | undefined> = { * Splits the collected data into amoCRM field entries and the note lines that
company: payload.company, * carry whatever the account has no field for.
profile: payload.profile, */
comment: payload.comment, function collect(data: Record<string, string | undefined>, fieldMap: LeadFieldMap) {
page: payload.page,
referrer: payload.referrer,
form: 'Заявка на запуск физиотерапии с нуля',
...payload.utm,
}
const fields: AmoFieldEntry[] = [] const fields: AmoFieldEntry[] = []
const leftovers: string[] = [] const leftovers: string[] = []
@@ -132,10 +129,26 @@ export function mapLead(payload: LeadPayload, fieldMap: LeadFieldMap): MappedLea
else leftovers.push(`${LABELS[key] ?? key}: ${value}`) else leftovers.push(`${LABELS[key] ?? key}: ${value}`)
} }
return { fields, leftovers }
}
export function mapLead(payload: LeadPayload, fieldMap: LeadFieldMap): MappedLead {
const data: Record<string, string | undefined> = {
company: payload.company,
profile: payload.profile,
comment: payload.comment,
page: payload.page,
referrer: payload.referrer,
form: 'Заявка на запуск физиотерапии с нуля',
...payload.utm,
}
const { fields, leftovers } = collect(data, fieldMap)
// Contact details are always repeated in the note so a manager can read the // Contact details are always repeated in the note so a manager can read the
// whole request without opening the linked contact card. // whole request without opening the linked contact card.
const header = [ const header = [
'Заявка с лендинга «Физиотерапия с нуля для медицинского центра»', `Заявка с лендинга «${LANDING}»`,
`${LABELS.phone}: ${payload.phone}`, `${LABELS.phone}: ${payload.phone}`,
payload.email ? `${LABELS.email}: ${payload.email}` : undefined, payload.email ? `${LABELS.email}: ${payload.email}` : undefined,
].filter(Boolean) as string[] ].filter(Boolean) as string[]
@@ -147,3 +160,31 @@ export function mapLead(payload: LeadPayload, fieldMap: LeadFieldMap): MappedLea
export function buildLeadName(payload: LeadPayload): string { export function buildLeadName(payload: LeadPayload): string {
return `Физиотерапия — ${payload.company ?? payload.name}` return `Физиотерапия — ${payload.company ?? payload.name}`
} }
/**
* The same mapping for a call-button click. Only the tracking data exists, so
* the note carries the whole story a manager needs.
*/
export function mapCallClick(payload: CallClickPayload, fieldMap: LeadFieldMap): MappedLead {
const { fields, leftovers } = collect(
{
page: payload.page,
referrer: payload.referrer,
form: 'Клик по кнопке звонка',
...payload.utm,
},
fieldMap,
)
const header = [
`Клик по кнопке звонка на лендинге «${LANDING}»`,
'Посетитель нажал кнопку звонка и контактов не оставил — ждём входящий вызов.',
]
return { fields, note: [...header, ...leftovers].join('\n') }
}
/** Lead title for a call-button click, which has no company or name to use. */
export function buildCallClickName(): string {
return 'Звонок с сайта — Физиотерапия (запуск с нуля)'
}
+34 -2
View File
@@ -1,6 +1,13 @@
import type { LeadPayload } from '../../shared/lead.ts' import type { CallClickPayload, LeadPayload } from '../../shared/lead.ts'
import { AmoClient, type AmoContact, type AmoFieldEntry } from './amocrm.ts' import { AmoClient, type AmoContact, type AmoFieldEntry } from './amocrm.ts'
import { buildLeadName, mapLead, resolveLeadFieldMap, type LeadFieldMap } from './lead-mapper.ts' import {
buildCallClickName,
buildLeadName,
mapCallClick,
mapLead,
resolveLeadFieldMap,
type LeadFieldMap,
} from './lead-mapper.ts'
const digitsOnly = (value: string) => value.replace(/\D/g, '') const digitsOnly = (value: string) => value.replace(/\D/g, '')
@@ -121,4 +128,29 @@ export class LeadService {
return { leadId: lead.id, contactId, contactCreated } 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 }
}
} }
+23
View File
@@ -41,3 +41,26 @@ export type LeadInput = z.input<typeof leadSchema>
export type LeadResponse = export type LeadResponse =
| { ok: true; leadId: number; contactId: number } | { ok: true; leadId: number; contactId: number }
| { ok: false; error: string; fields?: Record<string, string> } | { ok: false; error: string; fields?: Record<string, string> }
/* -------------------------------------------------------------------------- */
/* Клик по кнопке звонка */
/* -------------------------------------------------------------------------- */
/**
* A visitor tapping the phone button leaves no contact details, so this shares
* nothing with `leadSchema` but the tracking data: it records where the click
* happened, and the lead it produces carries no contact at all.
*/
export const callClickSchema = z.object({
page: optionalText(300),
referrer: optionalText(500),
utm: z.partialRecord(z.enum(utmKeys), z.string().max(300)).optional(),
/** Honeypot. Bots fill it in; humans never see it. */
website: z.string().max(200).optional(),
})
export type CallClickPayload = z.infer<typeof callClickSchema>
/** What the client sends — before zod's optional/empty-string normalisation. */
export type CallClickInput = z.input<typeof callClickSchema>
export type CallClickResponse = { ok: true; leadId: number } | { ok: false; error: string }
@@ -2,6 +2,7 @@ import logo from '../assets/images/exo-logo.png'
import { contacts, navLinks } from '../data/content' import { contacts, navLinks } from '../data/content'
import { useScrolled } from '../hooks/useScrollState' import { useScrolled } from '../hooks/useScrollState'
import { cx } from '../lib/cx' import { cx } from '../lib/cx'
import { reportCallClick } from '../lib/lead'
import { ButtonLink, buttonClass, headerSize } from './Button' import { ButtonLink, buttonClass, headerSize } from './Button'
import { PhoneIcon } from './Icons' import { PhoneIcon } from './Icons'
import { Container } from './Layout' import { Container } from './Layout'
@@ -48,6 +49,7 @@ export function SiteHeader() {
<div className="flex shrink-0 items-center gap-[10px] phone:gap-[6px]"> <div className="flex shrink-0 items-center gap-[10px] phone:gap-[6px]">
<a <a
href={contacts.phoneHref} href={contacts.phoneHref}
onClick={reportCallClick}
aria-label="Позвонить в Экзо Групп" aria-label="Позвонить в Экзо Групп"
className={buttonClass('call', 'phone:min-h-[40px] phone:px-[13px] [&>svg]:phone:size-[18px]', headerSize)} className={buttonClass('call', 'phone:min-h-[40px] phone:px-[13px] [&>svg]:phone:size-[18px]', headerSize)}
> >
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react'
import { contacts, sectionNav } from '../data/content' import { contacts, sectionNav } from '../data/content'
import { useReadingPosition } from '../hooks/useReadingPosition' import { useReadingPosition } from '../hooks/useReadingPosition'
import { cx } from '../lib/cx' import { cx } from '../lib/cx'
import { reportCallClick } from '../lib/lead'
import { ChevronUpIcon, CloseIcon, PhoneIcon } from './Icons' import { ChevronUpIcon, CloseIcon, PhoneIcon } from './Icons'
const hrefs = sectionNav.map((section) => section.href) const hrefs = sectionNav.map((section) => section.href)
@@ -145,6 +146,7 @@ export function ThumbBar() {
<a <a
href={contacts.phoneHref} href={contacts.phoneHref}
onClick={reportCallClick}
className="mt-[14px] flex min-h-[52px] items-center justify-center gap-[10px] rounded-[16px] border border-white/[0.16] className="mt-[14px] flex min-h-[52px] items-center justify-center gap-[10px] rounded-[16px] border border-white/[0.16]
bg-white/[0.07] text-[15px] font-[760] text-white transition-colors duration-200 hover:bg-white/[0.12] bg-white/[0.07] text-[15px] font-[760] text-white transition-colors duration-200 hover:bg-white/[0.12]
[&>svg]:size-[18px]" [&>svg]:size-[18px]"
+2 -2
View File
@@ -7,8 +7,8 @@ import deviceTecar from '../assets/images/device-tecar.webp'
import deviceTherapy from '../assets/images/device-therapy.webp' import deviceTherapy from '../assets/images/device-therapy.webp'
export const contacts = { export const contacts = {
phone: '+7 939 717-80-80', phone: '+7 927 789-60-71',
phoneHref: 'tel:+79397178080', phoneHref: 'tel:+79277896071',
} }
export const navLinks = [ export const navLinks = [
+78 -2
View File
@@ -1,10 +1,16 @@
import { utmKeys, type LeadFormId, type LeadInput, type LeadResponse } from '../../shared/lead' import {
utmKeys,
type CallClickInput,
type LeadFormId,
type LeadInput,
type LeadResponse,
} from '../../shared/lead'
const ENDPOINT = '/api/leads/medical-centers-no-physio' const ENDPOINT = '/api/leads/medical-centers-no-physio'
const DRAFT_KEY = 'exo_medcenter_no_physio_lead_draft' const DRAFT_KEY = 'exo_medcenter_no_physio_lead_draft'
const FALLBACK_ERROR = const FALLBACK_ERROR =
'Не удалось отправить форму автоматически. Данные сохранены в браузере — свяжитесь с нами по +7 939 717-80-80.' 'Не удалось отправить форму автоматически. Данные сохранены в браузере — свяжитесь с нами по +7 927 789-60-71.'
/** Query-string UTM tags, forwarded to amoCRM with the lead. */ /** Query-string UTM tags, forwarded to amoCRM with the lead. */
function collectUtm(): Record<string, string> { function collectUtm(): Record<string, string> {
@@ -73,6 +79,76 @@ function saveDraft(payload: LeadInput) {
} }
} }
/* -------------------------------------------------------------------------- */
/* Клик по кнопке звонка */
/* -------------------------------------------------------------------------- */
const CALL_ENDPOINT = `${ENDPOINT}/call`
const CALL_SENT_KEY = 'exo_medcenter_no_physio_call_click_sent'
/** Session-scoped so a visitor who redials is still one lead, not three. */
function callAlreadyReported(): boolean {
try {
return sessionStorage.getItem(CALL_SENT_KEY) === '1'
} catch {
// Private-mode browsers throw on access; one extra lead beats none.
return false
}
}
function markCallReported() {
try {
sessionStorage.setItem(CALL_SENT_KEY, '1')
} catch {
// See above.
}
}
/**
* Registers a tap on the phone button as an amoCRM lead.
*
* Fire-and-forget by necessity: the same click hands the page to `tel:`, so the
* request has to outlive the document. `sendBeacon` queues it in the browser
* process itself; `keepalive` is the fallback for the few browsers without it.
* Nothing here may throw or await — the dialler must open instantly.
*/
export function reportCallClick(): void {
if (callAlreadyReported()) return
markCallReported()
const payload: CallClickInput = {
page: window.location.pathname,
referrer: document.referrer || undefined,
utm: collectUtm(),
}
// A Blob, not a string: it is what gives the beacon its JSON content type.
const beacon = new Blob([JSON.stringify(payload)], { type: 'application/json' })
// sendBeacon returns false when the browser refuses to queue the payload, and
// a few throw instead of returning anything. Either way, fetch picks it up.
let queued = false
try {
queued = navigator.sendBeacon?.(CALL_ENDPOINT, beacon) ?? false
} catch {
queued = false
}
if (!queued) {
void fetch(CALL_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
keepalive: true,
}).catch((error: unknown) => {
console.warn('Call click endpoint error', error)
})
}
window.dataLayer = window.dataLayer ?? []
window.dataLayer.push({ event: 'medcenter_no_physio_call_click' })
}
declare global { declare global {
interface Window { interface Window {
dataLayer?: Record<string, unknown>[] dataLayer?: Record<string, unknown>[]