From e13854e918cf91b7a5140762928f66acff147f7e Mon Sep 17 00:00:00 2001 From: Yuriy Panov Date: Wed, 9 Sep 2026 00:58:15 +0600 Subject: [PATCH] leads: new phone number and an amoCRM lead on every call-button click MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- fitnes/README.md | 14 ++++ fitnes/server/src/amocrm.ts | 8 +- fitnes/server/src/index.ts | 54 +++++++++++- fitnes/server/src/lead-mapper.ts | 65 ++++++++++++--- fitnes/server/src/lead-service.ts | 36 +++++++- fitnes/shared/lead.ts | 23 ++++++ fitnes/src/components/CallbackModal.tsx | 8 +- fitnes/src/components/SiteFooter.tsx | 5 +- fitnes/src/data/content.ts | 4 +- fitnes/src/lib/lead.ts | 82 ++++++++++++++++++- hotel/README.md | 14 ++++ hotel/server/src/amocrm.ts | 8 +- hotel/server/src/index.ts | 54 +++++++++++- hotel/server/src/lead-mapper.ts | 65 ++++++++++++--- hotel/server/src/lead-service.ts | 36 +++++++- hotel/shared/lead.ts | 23 ++++++ hotel/src/components/SiteHeader.tsx | 2 + hotel/src/data/content.ts | 4 +- hotel/src/lib/lead.ts | 82 ++++++++++++++++++- medcenterphysio/README.md | 14 ++++ medcenterphysio/server/src/amocrm.ts | 8 +- medcenterphysio/server/src/index.ts | 54 +++++++++++- medcenterphysio/server/src/lead-mapper.ts | 65 ++++++++++++--- medcenterphysio/server/src/lead-service.ts | 36 +++++++- medcenterphysio/shared/lead.ts | 23 ++++++ .../src/components/MobileActionBar.tsx | 2 + medcenterphysio/src/components/SiteHeader.tsx | 2 + medcenterphysio/src/data/content.ts | 4 +- medcenterphysio/src/lib/lead.ts | 80 +++++++++++++++++- medcenterstart/README.md | 14 ++++ medcenterstart/server/src/amocrm.ts | 8 +- medcenterstart/server/src/index.ts | 54 +++++++++++- medcenterstart/server/src/lead-mapper.ts | 67 ++++++++++++--- medcenterstart/server/src/lead-service.ts | 36 +++++++- medcenterstart/shared/lead.ts | 23 ++++++ medcenterstart/src/components/SiteHeader.tsx | 2 + medcenterstart/src/components/ThumbBar.tsx | 2 + medcenterstart/src/data/content.ts | 4 +- medcenterstart/src/lib/lead.ts | 80 +++++++++++++++++- 39 files changed, 1059 insertions(+), 106 deletions(-) diff --git a/fitnes/README.md b/fitnes/README.md index 5a0e63e..6b32a60 100644 --- a/fitnes/README.md +++ b/fitnes/README.md @@ -70,6 +70,20 @@ npm run amo:check (`[lead] payload was: …`) и сохраняется в `localStorage` браузера, а посетитель видит телефон для связи. +### 4. Что происходит при клике на телефон + +Кнопка звонка (в футере и в модальном окне «Перезвоним вам») помимо набора номера отправляет +`POST /api/leads/fitness-centers/call` — маячком `navigator.sendBeacon`, чтобы +запрос пережил переход браузера на `tel:`. + +Сервер создаёт **сделку без контакта**: посетитель не оставил ни имени, ни +номера, известен только факт клика. Имя сделки — «Звонок с сайта — Recovery Zone», +тег — `клик по телефону` (плюс `AMO_LEAD_TAGS`), страница и `utm_*` идут в поля +или в примечание, как и у обычной заявки. + +Защита от дублей двойная: на клиенте — один лид на сессию браузера +(`sessionStorage`), на сервере — не больше 4 обращений с одного IP за 30 минут. + ### Переменные окружения | Переменная | Обязательна | Описание | diff --git a/fitnes/server/src/amocrm.ts b/fitnes/server/src/amocrm.ts index 26f9edb..a898e7c 100644 --- a/fitnes/server/src/amocrm.ts +++ b/fitnes/server/src/amocrm.ts @@ -196,8 +196,10 @@ export class AmoClient { * 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. */ - async createLead(input: { name: string; contactId: number; fields: AmoFieldEntry[]; tags: string[] }): Promise { - const embedded: Record = { contacts: [{ id: input.contactId }] } + async createLead(input: { name: string; contactId?: number; fields: AmoFieldEntry[]; tags: string[] }): Promise { + // `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 = input.contactId ? { contacts: [{ id: input.contactId }] } : {} const tags = [...new Set([...this.config.tags, ...input.tags])] if (tags.length) embedded.tags = tags.map((name) => ({ name })) @@ -209,7 +211,7 @@ export class AmoClient { pipeline_id: this.config.pipelineId, responsible_user_id: this.config.responsibleUserId, custom_fields_values: input.fields.length ? input.fields : undefined, - _embedded: embedded, + _embedded: Object.keys(embedded).length ? embedded : undefined, }, ], }) diff --git a/fitnes/server/src/index.ts b/fitnes/server/src/index.ts index dc3825e..ec2445f 100644 --- a/fitnes/server/src/index.ts +++ b/fitnes/server/src/index.ts @@ -1,7 +1,7 @@ import path from 'node:path' import { fileURLToPath } from 'node:url' 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 { readAmoConfig, serverConfig } from './config.ts' import { LeadService } from './lead-service.ts' @@ -27,6 +27,9 @@ app.set('trust proxy', 'loopback') app.use(express.json({ limit: '64kb' })) 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) => { 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) { // 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)) - return send(503, { ok: false, error: 'Форма временно недоступна. Позвоните нам: +7 939 717-80-80.' }) + return send(503, { ok: false, error: 'Форма временно недоступна. Позвоните нам: +7 927 789-60-71.' }) } try { @@ -79,12 +82,55 @@ app.post('/api/leads/fitness-centers', async (req, res) => { console.error('[lead] payload was:', JSON.stringify(payload)) const message = error instanceof AmoError && error.status === 401 - ? 'CRM отклонила запрос. Мы уже разбираемся — позвоните нам: +7 939 717-80-80.' - : 'Не удалось отправить заявку. Позвоните нам: +7 939 717-80-80.' + ? 'CRM отклонила запрос. Мы уже разбираемся — позвоните нам: +7 927 789-60-71.' + : 'Не удалось отправить заявку. Позвоните нам: +7 927 789-60-71.' 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) { app.use( express.static(clientDir, { diff --git a/fitnes/server/src/lead-mapper.ts b/fitnes/server/src/lead-mapper.ts index 62f20c7..3d946d9 100644 --- a/fitnes/server/src/lead-mapper.ts +++ b/fitnes/server/src/lead-mapper.ts @@ -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' +/** The landing this server belongs to, as it reads in every note. */ +const LANDING = 'EXO Recovery Zone для фитнес-клубов' + /** Human labels used in the fallback note. */ const LABELS: Record = { company: 'Фитнес-клуб / сеть', @@ -111,6 +114,25 @@ export interface MappedLead { 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, 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 { const data: Record = { company: payload.company, @@ -124,21 +146,12 @@ export function mapLead(payload: LeadPayload, fieldMap: LeadFieldMap): MappedLea ...payload.utm, } - 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}`) - } + const { fields, leftovers } = collect(data, fieldMap) // Contact details are always repeated in the note so a manager can read the // whole request without opening the linked contact card. const header = [ - `Заявка с лендинга «EXO Recovery Zone для фитнес-клубов»`, + `Заявка с лендинга «${LANDING}»`, `${LABELS.phone}: ${payload.phone}`, payload.email ? `${LABELS.email}: ${payload.email}` : undefined, ].filter(Boolean) as string[] @@ -151,3 +164,31 @@ export function buildLeadName(payload: LeadPayload): string { const who = payload.company ?? payload.name 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' +} diff --git a/fitnes/server/src/lead-service.ts b/fitnes/server/src/lead-service.ts index 2ffa5a3..70c22a7 100644 --- a/fitnes/server/src/lead-service.ts +++ b/fitnes/server/src/lead-service.ts @@ -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 { 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, '') @@ -121,4 +128,29 @@ export class LeadService { 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 } + } } diff --git a/fitnes/shared/lead.ts b/fitnes/shared/lead.ts index 0cf3ee4..f5f4ada 100644 --- a/fitnes/shared/lead.ts +++ b/fitnes/shared/lead.ts @@ -42,3 +42,26 @@ export type LeadInput = z.input export type LeadResponse = | { ok: true; leadId: number; contactId: number } | { ok: false; error: string; fields?: Record } + +/* -------------------------------------------------------------------------- */ +/* Клик по кнопке звонка */ +/* -------------------------------------------------------------------------- */ + +/** + * 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 +/** What the client sends — before zod's optional/empty-string normalisation. */ +export type CallClickInput = z.input + +export type CallClickResponse = { ok: true; leadId: number } | { ok: false; error: string } diff --git a/fitnes/src/components/CallbackModal.tsx b/fitnes/src/components/CallbackModal.tsx index 353760f..633def1 100644 --- a/fitnes/src/components/CallbackModal.tsx +++ b/fitnes/src/components/CallbackModal.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState, type FormEvent } from 'react' import { contacts } from '../data/content' import { useBodyLock } from '../hooks/useBodyLock' import { useEscapeKey } from '../hooks/useEscapeKey' -import { submitLead } from '../lib/lead' +import { reportCallClick, submitLead } from '../lib/lead' import { cx } from '../lib/cx' import { Button } from './Button' import { ConsentCheckbox, Honeypot, TextField } from './FormField' @@ -89,7 +89,11 @@ export function CallbackModal({ open, onClose }: { open: boolean; onClose: () =>

Оставьте имя и телефон — специалист свяжется и ответит на вопросы по зоне восстановления. Или позвоните сами:{' '} - + {contacts.phone} . diff --git a/fitnes/src/components/SiteFooter.tsx b/fitnes/src/components/SiteFooter.tsx index f166a6a..0a0ade2 100644 --- a/fitnes/src/components/SiteFooter.tsx +++ b/fitnes/src/components/SiteFooter.tsx @@ -1,4 +1,5 @@ import { contacts } from '../data/content' +import { reportCallClick } from '../lib/lead' import { Container } from './Layout' export function SiteFooter() { @@ -11,7 +12,9 @@ export function SiteFooter() { Российские технологии реабилитации

- {contacts.phone} + + {contacts.phone} + {contacts.email} {contacts.site} diff --git a/fitnes/src/data/content.ts b/fitnes/src/data/content.ts index 3c43abe..90e443a 100644 --- a/fitnes/src/data/content.ts +++ b/fitnes/src/data/content.ts @@ -4,8 +4,8 @@ import galleryPt from '../assets/images/gallery-pt-stretch.webp' import galleryFlagship from '../assets/images/gallery-flagship.webp' export const contacts = { - phone: '+7 939 717-80-80', - phoneHref: 'tel:+79397178080', + phone: '+7 927 789-60-71', + phoneHref: 'tel:+79277896071', email: 'info@exotherapy.ru', site: 'экзотерапия.рф', siteHref: 'https://экзотерапия.рф', diff --git a/fitnes/src/lib/lead.ts b/fitnes/src/lib/lead.ts index d6ebc10..28073da 100644 --- a/fitnes/src/lib/lead.ts +++ b/fitnes/src/lib/lead.ts @@ -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 DRAFT_KEY = 'exo_fitness_lead_draft' @@ -46,7 +52,7 @@ export async function submitLead(form: LeadFormId, values: Omit { + console.warn('Call click endpoint error', error) + }) + } + + window.dataLayer = window.dataLayer ?? [] + window.dataLayer.push({ event: 'fitness_call_click' }) +} + declare global { interface Window { dataLayer?: Record[] diff --git a/hotel/README.md b/hotel/README.md index ac9c0d1..b64e407 100644 --- a/hotel/README.md +++ b/hotel/README.md @@ -76,6 +76,20 @@ npm run amo:check (`[lead] payload was: …`) и сохраняется в `localStorage` браузера, а посетитель видит телефон для связи. +### 4. Что происходит при клике на телефон + +Кнопка звонка (в шапке) помимо набора номера отправляет +`POST /api/leads/hotels/call` — маячком `navigator.sendBeacon`, чтобы +запрос пережил переход браузера на `tel:`. + +Сервер создаёт **сделку без контакта**: посетитель не оставил ни имени, ни +номера, известен только факт клика. Имя сделки — «Звонок с сайта — ЭкзоОтель», +тег — `клик по телефону` (плюс `AMO_LEAD_TAGS`), страница и `utm_*` идут в поля +или в примечание, как и у обычной заявки. + +Защита от дублей двойная: на клиенте — один лид на сессию браузера +(`sessionStorage`), на сервере — не больше 4 обращений с одного IP за 30 минут. + ### Переменные окружения | Переменная | Обязательна | Описание | diff --git a/hotel/server/src/amocrm.ts b/hotel/server/src/amocrm.ts index 26f9edb..a898e7c 100644 --- a/hotel/server/src/amocrm.ts +++ b/hotel/server/src/amocrm.ts @@ -196,8 +196,10 @@ export class AmoClient { * 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. */ - async createLead(input: { name: string; contactId: number; fields: AmoFieldEntry[]; tags: string[] }): Promise { - const embedded: Record = { contacts: [{ id: input.contactId }] } + async createLead(input: { name: string; contactId?: number; fields: AmoFieldEntry[]; tags: string[] }): Promise { + // `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 = input.contactId ? { contacts: [{ id: input.contactId }] } : {} const tags = [...new Set([...this.config.tags, ...input.tags])] if (tags.length) embedded.tags = tags.map((name) => ({ name })) @@ -209,7 +211,7 @@ export class AmoClient { pipeline_id: this.config.pipelineId, responsible_user_id: this.config.responsibleUserId, custom_fields_values: input.fields.length ? input.fields : undefined, - _embedded: embedded, + _embedded: Object.keys(embedded).length ? embedded : undefined, }, ], }) diff --git a/hotel/server/src/index.ts b/hotel/server/src/index.ts index aa8e86d..77ff8e9 100644 --- a/hotel/server/src/index.ts +++ b/hotel/server/src/index.ts @@ -1,7 +1,7 @@ import path from 'node:path' import { fileURLToPath } from 'node:url' 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 { readAmoConfig, serverConfig } from './config.ts' import { LeadService } from './lead-service.ts' @@ -27,6 +27,9 @@ app.set('trust proxy', 'loopback') app.use(express.json({ limit: '64kb' })) 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) => { 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) { // 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)) - return send(503, { ok: false, error: 'Форма временно недоступна. Позвоните нам: +7 939 717-80-80.' }) + return send(503, { ok: false, error: 'Форма временно недоступна. Позвоните нам: +7 927 789-60-71.' }) } try { @@ -79,12 +82,55 @@ app.post('/api/leads/hotels', async (req, res) => { console.error('[lead] payload was:', JSON.stringify(payload)) const message = error instanceof AmoError && error.status === 401 - ? 'CRM отклонила запрос. Мы уже разбираемся — позвоните нам: +7 939 717-80-80.' - : 'Не удалось отправить заявку. Позвоните нам: +7 939 717-80-80.' + ? 'CRM отклонила запрос. Мы уже разбираемся — позвоните нам: +7 927 789-60-71.' + : 'Не удалось отправить заявку. Позвоните нам: +7 927 789-60-71.' 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) { app.use( express.static(clientDir, { diff --git a/hotel/server/src/lead-mapper.ts b/hotel/server/src/lead-mapper.ts index af031b2..9ada3c7 100644 --- a/hotel/server/src/lead-mapper.ts +++ b/hotel/server/src/lead-mapper.ts @@ -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' +/** The landing this server belongs to, as it reads in every note. */ +const LANDING = 'ЭкзоОтель' + /** Human labels used in the fallback note. */ const LABELS: Record = { company: 'Отель / сеть', @@ -105,16 +108,11 @@ export interface MappedLead { note: string } -export function mapLead(payload: LeadPayload, fieldMap: LeadFieldMap): MappedLead { - const data: Record = { - company: payload.company, - comment: payload.comment, - page: payload.page, - referrer: payload.referrer, - form: 'Заявка на предложение', - ...payload.utm, - } - +/** + * 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, fieldMap: LeadFieldMap) { const fields: AmoFieldEntry[] = [] const leftovers: string[] = [] @@ -126,10 +124,25 @@ export function mapLead(payload: LeadPayload, fieldMap: LeadFieldMap): MappedLea else leftovers.push(`${LABELS[key] ?? key}: ${value}`) } + return { fields, leftovers } +} + +export function mapLead(payload: LeadPayload, fieldMap: LeadFieldMap): MappedLead { + const data: Record = { + 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 // whole request without opening the linked contact card. const header = [ - 'Заявка с лендинга «ЭкзоОтель»', + `Заявка с лендинга «${LANDING}»`, `${LABELS.phone}: ${payload.phone}`, payload.email ? `${LABELS.email}: ${payload.email}` : undefined, ].filter(Boolean) as string[] @@ -141,3 +154,31 @@ export function mapLead(payload: LeadPayload, fieldMap: LeadFieldMap): MappedLea export function buildLeadName(payload: LeadPayload): string { 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 'Звонок с сайта — ЭкзоОтель' +} diff --git a/hotel/server/src/lead-service.ts b/hotel/server/src/lead-service.ts index cce035a..47f9553 100644 --- a/hotel/server/src/lead-service.ts +++ b/hotel/server/src/lead-service.ts @@ -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 { 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, '') @@ -121,4 +128,29 @@ export class LeadService { 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 } + } } diff --git a/hotel/shared/lead.ts b/hotel/shared/lead.ts index c063b19..4d68b7a 100644 --- a/hotel/shared/lead.ts +++ b/hotel/shared/lead.ts @@ -40,3 +40,26 @@ export type LeadInput = z.input export type LeadResponse = | { ok: true; leadId: number; contactId: number } | { ok: false; error: string; fields?: Record } + +/* -------------------------------------------------------------------------- */ +/* Клик по кнопке звонка */ +/* -------------------------------------------------------------------------- */ + +/** + * 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 +/** What the client sends — before zod's optional/empty-string normalisation. */ +export type CallClickInput = z.input + +export type CallClickResponse = { ok: true; leadId: number } | { ok: false; error: string } diff --git a/hotel/src/components/SiteHeader.tsx b/hotel/src/components/SiteHeader.tsx index 8a57793..4604199 100644 --- a/hotel/src/components/SiteHeader.tsx +++ b/hotel/src/components/SiteHeader.tsx @@ -2,6 +2,7 @@ import type { RefObject } from 'react' import logo from '../assets/images/exo-logo.png' import { contacts, navLinks } from '../data/content' import { cx } from '../lib/cx' +import { reportCallClick } from '../lib/lead' import { ButtonLink, buttonClass, headerSize } from './Button' import { PhoneIcon } from './Icons' import { Container } from './Layout' @@ -40,6 +41,7 @@ export function SiteHeader({ scrolled, headerRef }: { scrolled: boolean; headerR
diff --git a/hotel/src/data/content.ts b/hotel/src/data/content.ts index 6a81b71..cdf1a4c 100644 --- a/hotel/src/data/content.ts +++ b/hotel/src/data/content.ts @@ -8,8 +8,8 @@ import scenarioSpa from '../assets/images/scenario-spa.webp' import scenarioWellness from '../assets/images/scenario-wellness.webp' export const contacts = { - phone: '+7 939 717-80-80', - phoneHref: 'tel:+79397178080', + phone: '+7 927 789-60-71', + phoneHref: 'tel:+79277896071', } export const navLinks = [ diff --git a/hotel/src/lib/lead.ts b/hotel/src/lib/lead.ts index 69b6f9d..2c7bf7f 100644 --- a/hotel/src/lib/lead.ts +++ b/hotel/src/lib/lead.ts @@ -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 DRAFT_KEY = 'exo_hotel_lead_draft' @@ -49,7 +55,7 @@ export async function submitLead( error: body && !body.ok ? body.error - : 'Не удалось отправить форму автоматически. Данные сохранены в браузере — свяжитесь с нами по +7 939 717-80-80.', + : 'Не удалось отправить форму автоматически. Данные сохранены в браузере — свяжитесь с нами по +7 927 789-60-71.', fields: body && !body.ok ? body.fields : undefined, } } @@ -64,7 +70,7 @@ export async function submitLead( return { ok: false, 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 { interface Window { dataLayer?: Record[] diff --git a/medcenterphysio/README.md b/medcenterphysio/README.md index efeb33e..b232d73 100644 --- a/medcenterphysio/README.md +++ b/medcenterphysio/README.md @@ -71,6 +71,20 @@ npm run amo:check (`[lead] payload was: …`) и сохраняется в `localStorage` браузера, а посетитель видит телефон для связи. +### 4. Что происходит при клике на телефон + +Кнопка звонка (в шапке и в нижней мобильной панели) помимо набора номера отправляет +`POST /api/leads/medical-centers-existing-physio/call` — маячком `navigator.sendBeacon`, чтобы +запрос пережил переход браузера на `tel:`. + +Сервер создаёт **сделку без контакта**: посетитель не оставил ни имени, ни +номера, известен только факт клика. Имя сделки — «Звонок с сайта — Физиотерапия (усиление кабинета)», +тег — `клик по телефону` (плюс `AMO_LEAD_TAGS`), страница и `utm_*` идут в поля +или в примечание, как и у обычной заявки. + +Защита от дублей двойная: на клиенте — один лид на сессию браузера +(`sessionStorage`), на сервере — не больше 4 обращений с одного IP за 30 минут. + ### Переменные окружения | Переменная | Обязательна | Описание | diff --git a/medcenterphysio/server/src/amocrm.ts b/medcenterphysio/server/src/amocrm.ts index 26f9edb..a898e7c 100644 --- a/medcenterphysio/server/src/amocrm.ts +++ b/medcenterphysio/server/src/amocrm.ts @@ -196,8 +196,10 @@ export class AmoClient { * 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. */ - async createLead(input: { name: string; contactId: number; fields: AmoFieldEntry[]; tags: string[] }): Promise { - const embedded: Record = { contacts: [{ id: input.contactId }] } + async createLead(input: { name: string; contactId?: number; fields: AmoFieldEntry[]; tags: string[] }): Promise { + // `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 = input.contactId ? { contacts: [{ id: input.contactId }] } : {} const tags = [...new Set([...this.config.tags, ...input.tags])] if (tags.length) embedded.tags = tags.map((name) => ({ name })) @@ -209,7 +211,7 @@ export class AmoClient { pipeline_id: this.config.pipelineId, responsible_user_id: this.config.responsibleUserId, custom_fields_values: input.fields.length ? input.fields : undefined, - _embedded: embedded, + _embedded: Object.keys(embedded).length ? embedded : undefined, }, ], }) diff --git a/medcenterphysio/server/src/index.ts b/medcenterphysio/server/src/index.ts index 3dc19b7..346e593 100644 --- a/medcenterphysio/server/src/index.ts +++ b/medcenterphysio/server/src/index.ts @@ -1,7 +1,7 @@ import path from 'node:path' import { fileURLToPath } from 'node:url' 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 { readAmoConfig, serverConfig } from './config.ts' import { LeadService } from './lead-service.ts' @@ -27,6 +27,9 @@ app.set('trust proxy', 'loopback') app.use(express.json({ limit: '64kb' })) 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) => { 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) { // 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)) - return send(503, { ok: false, error: 'Форма временно недоступна. Позвоните нам: +7 939 717-80-80.' }) + return send(503, { ok: false, error: 'Форма временно недоступна. Позвоните нам: +7 927 789-60-71.' }) } try { @@ -79,12 +82,55 @@ app.post('/api/leads/medical-centers-existing-physio', async (req, res) => { console.error('[lead] payload was:', JSON.stringify(payload)) const message = error instanceof AmoError && error.status === 401 - ? 'CRM отклонила запрос. Мы уже разбираемся — позвоните нам: +7 939 717-80-80.' - : 'Не удалось отправить заявку. Позвоните нам: +7 939 717-80-80.' + ? 'CRM отклонила запрос. Мы уже разбираемся — позвоните нам: +7 927 789-60-71.' + : 'Не удалось отправить заявку. Позвоните нам: +7 927 789-60-71.' 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) { app.use( express.static(clientDir, { diff --git a/medcenterphysio/server/src/lead-mapper.ts b/medcenterphysio/server/src/lead-mapper.ts index b34f173..b0b076d 100644 --- a/medcenterphysio/server/src/lead-mapper.ts +++ b/medcenterphysio/server/src/lead-mapper.ts @@ -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' +/** The landing this server belongs to, as it reads in every note. */ +const LANDING = 'Усиление действующего кабинета физиотерапии' + /** Human labels used in the fallback note. */ const LABELS: Record = { company: 'Медицинский центр', @@ -105,16 +108,11 @@ export interface MappedLead { note: string } -export function mapLead(payload: LeadPayload, fieldMap: LeadFieldMap): MappedLead { - const data: Record = { - company: payload.company, - comment: payload.comment, - page: payload.page, - referrer: payload.referrer, - form: 'Заявка на усиление кабинета физиотерапии', - ...payload.utm, - } - +/** + * 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, fieldMap: LeadFieldMap) { const fields: AmoFieldEntry[] = [] const leftovers: string[] = [] @@ -126,10 +124,25 @@ export function mapLead(payload: LeadPayload, fieldMap: LeadFieldMap): MappedLea else leftovers.push(`${LABELS[key] ?? key}: ${value}`) } + return { fields, leftovers } +} + +export function mapLead(payload: LeadPayload, fieldMap: LeadFieldMap): MappedLead { + const data: Record = { + 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 // whole request without opening the linked contact card. const header = [ - 'Заявка с лендинга «Усиление действующего кабинета физиотерапии»', + `Заявка с лендинга «${LANDING}»`, `${LABELS.phone}: ${payload.phone}`, payload.email ? `${LABELS.email}: ${payload.email}` : undefined, ].filter(Boolean) as string[] @@ -141,3 +154,31 @@ export function mapLead(payload: LeadPayload, fieldMap: LeadFieldMap): MappedLea export function buildLeadName(payload: LeadPayload): string { 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 'Звонок с сайта — Физиотерапия (усиление кабинета)' +} diff --git a/medcenterphysio/server/src/lead-service.ts b/medcenterphysio/server/src/lead-service.ts index cce035a..47f9553 100644 --- a/medcenterphysio/server/src/lead-service.ts +++ b/medcenterphysio/server/src/lead-service.ts @@ -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 { 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, '') @@ -121,4 +128,29 @@ export class LeadService { 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 } + } } diff --git a/medcenterphysio/shared/lead.ts b/medcenterphysio/shared/lead.ts index 2ec6530..f97588b 100644 --- a/medcenterphysio/shared/lead.ts +++ b/medcenterphysio/shared/lead.ts @@ -39,3 +39,26 @@ export type LeadInput = z.input export type LeadResponse = | { ok: true; leadId: number; contactId: number } | { ok: false; error: string; fields?: Record } + +/* -------------------------------------------------------------------------- */ +/* Клик по кнопке звонка */ +/* -------------------------------------------------------------------------- */ + +/** + * 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 +/** What the client sends — before zod's optional/empty-string normalisation. */ +export type CallClickInput = z.input + +export type CallClickResponse = { ok: true; leadId: number } | { ok: false; error: string } diff --git a/medcenterphysio/src/components/MobileActionBar.tsx b/medcenterphysio/src/components/MobileActionBar.tsx index 373fe85..d957078 100644 --- a/medcenterphysio/src/components/MobileActionBar.tsx +++ b/medcenterphysio/src/components/MobileActionBar.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react' import { contacts } from '../data/content' import { cx } from '../lib/cx' +import { reportCallClick } from '../lib/lead' import { ButtonLink, buttonClass } from './Button' import { PhoneIcon } from './Icons' @@ -52,6 +53,7 @@ export function MobileActionBar() {
diff --git a/medcenterphysio/src/components/SiteHeader.tsx b/medcenterphysio/src/components/SiteHeader.tsx index b39b405..777644f 100644 --- a/medcenterphysio/src/components/SiteHeader.tsx +++ b/medcenterphysio/src/components/SiteHeader.tsx @@ -1,5 +1,6 @@ import logo from '../assets/images/exo-logo.png' import { contacts, navLinks } from '../data/content' +import { reportCallClick } from '../lib/lead' import { ButtonLink, buttonClass, headerSize } from './Button' import { PhoneIcon } from './Icons' import { Container } from './Layout' @@ -44,6 +45,7 @@ export function SiteHeader() {
{ @@ -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 { interface Window { dataLayer?: Record[] diff --git a/medcenterstart/README.md b/medcenterstart/README.md index a5f5f4d..2768222 100644 --- a/medcenterstart/README.md +++ b/medcenterstart/README.md @@ -77,6 +77,20 @@ npm run amo:check (`[lead] payload was: …`) и сохраняется в `localStorage` браузера, а посетитель видит телефон для связи. +### 4. Что происходит при клике на телефон + +Кнопка звонка (в шапке и в мобильном меню разделов) помимо набора номера отправляет +`POST /api/leads/medical-centers-no-physio/call` — маячком `navigator.sendBeacon`, чтобы +запрос пережил переход браузера на `tel:`. + +Сервер создаёт **сделку без контакта**: посетитель не оставил ни имени, ни +номера, известен только факт клика. Имя сделки — «Звонок с сайта — Физиотерапия (запуск с нуля)», +тег — `клик по телефону` (плюс `AMO_LEAD_TAGS`), страница и `utm_*` идут в поля +или в примечание, как и у обычной заявки. + +Защита от дублей двойная: на клиенте — один лид на сессию браузера +(`sessionStorage`), на сервере — не больше 4 обращений с одного IP за 30 минут. + ### Переменные окружения | Переменная | Обязательна | Описание | diff --git a/medcenterstart/server/src/amocrm.ts b/medcenterstart/server/src/amocrm.ts index 26f9edb..a898e7c 100644 --- a/medcenterstart/server/src/amocrm.ts +++ b/medcenterstart/server/src/amocrm.ts @@ -196,8 +196,10 @@ export class AmoClient { * 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. */ - async createLead(input: { name: string; contactId: number; fields: AmoFieldEntry[]; tags: string[] }): Promise { - const embedded: Record = { contacts: [{ id: input.contactId }] } + async createLead(input: { name: string; contactId?: number; fields: AmoFieldEntry[]; tags: string[] }): Promise { + // `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 = input.contactId ? { contacts: [{ id: input.contactId }] } : {} const tags = [...new Set([...this.config.tags, ...input.tags])] if (tags.length) embedded.tags = tags.map((name) => ({ name })) @@ -209,7 +211,7 @@ export class AmoClient { pipeline_id: this.config.pipelineId, responsible_user_id: this.config.responsibleUserId, custom_fields_values: input.fields.length ? input.fields : undefined, - _embedded: embedded, + _embedded: Object.keys(embedded).length ? embedded : undefined, }, ], }) diff --git a/medcenterstart/server/src/index.ts b/medcenterstart/server/src/index.ts index 53655fa..566d356 100644 --- a/medcenterstart/server/src/index.ts +++ b/medcenterstart/server/src/index.ts @@ -1,7 +1,7 @@ import path from 'node:path' import { fileURLToPath } from 'node:url' 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 { readAmoConfig, serverConfig } from './config.ts' import { LeadService } from './lead-service.ts' @@ -27,6 +27,9 @@ app.set('trust proxy', 'loopback') app.use(express.json({ limit: '64kb' })) 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) => { 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) { // 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)) - return send(503, { ok: false, error: 'Форма временно недоступна. Позвоните нам: +7 939 717-80-80.' }) + return send(503, { ok: false, error: 'Форма временно недоступна. Позвоните нам: +7 927 789-60-71.' }) } try { @@ -79,12 +82,55 @@ app.post('/api/leads/medical-centers-no-physio', async (req, res) => { console.error('[lead] payload was:', JSON.stringify(payload)) const message = error instanceof AmoError && error.status === 401 - ? 'CRM отклонила запрос. Мы уже разбираемся — позвоните нам: +7 939 717-80-80.' - : 'Не удалось отправить заявку. Позвоните нам: +7 939 717-80-80.' + ? 'CRM отклонила запрос. Мы уже разбираемся — позвоните нам: +7 927 789-60-71.' + : 'Не удалось отправить заявку. Позвоните нам: +7 927 789-60-71.' 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) { app.use( express.static(clientDir, { diff --git a/medcenterstart/server/src/lead-mapper.ts b/medcenterstart/server/src/lead-mapper.ts index 0db0d42..4629efb 100644 --- a/medcenterstart/server/src/lead-mapper.ts +++ b/medcenterstart/server/src/lead-mapper.ts @@ -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' +/** The landing this server belongs to, as it reads in every note. */ +const LANDING = 'Физиотерапия с нуля для медицинского центра' + /** Human labels used in the fallback note. */ const LABELS: Record = { company: 'Медицинский центр', @@ -110,17 +113,11 @@ export interface MappedLead { note: string } -export function mapLead(payload: LeadPayload, fieldMap: LeadFieldMap): MappedLead { - const data: Record = { - company: payload.company, - profile: payload.profile, - comment: payload.comment, - page: payload.page, - referrer: payload.referrer, - form: 'Заявка на запуск физиотерапии с нуля', - ...payload.utm, - } - +/** + * 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, fieldMap: LeadFieldMap) { const fields: AmoFieldEntry[] = [] const leftovers: string[] = [] @@ -132,10 +129,26 @@ export function mapLead(payload: LeadPayload, fieldMap: LeadFieldMap): MappedLea else leftovers.push(`${LABELS[key] ?? key}: ${value}`) } + return { fields, leftovers } +} + +export function mapLead(payload: LeadPayload, fieldMap: LeadFieldMap): MappedLead { + const data: Record = { + 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 // whole request without opening the linked contact card. const header = [ - 'Заявка с лендинга «Физиотерапия с нуля для медицинского центра»', + `Заявка с лендинга «${LANDING}»`, `${LABELS.phone}: ${payload.phone}`, payload.email ? `${LABELS.email}: ${payload.email}` : undefined, ].filter(Boolean) as string[] @@ -147,3 +160,31 @@ export function mapLead(payload: LeadPayload, fieldMap: LeadFieldMap): MappedLea export function buildLeadName(payload: LeadPayload): string { 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 'Звонок с сайта — Физиотерапия (запуск с нуля)' +} diff --git a/medcenterstart/server/src/lead-service.ts b/medcenterstart/server/src/lead-service.ts index cce035a..47f9553 100644 --- a/medcenterstart/server/src/lead-service.ts +++ b/medcenterstart/server/src/lead-service.ts @@ -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 { 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, '') @@ -121,4 +128,29 @@ export class LeadService { 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 } + } } diff --git a/medcenterstart/shared/lead.ts b/medcenterstart/shared/lead.ts index 8e180e3..76deda4 100644 --- a/medcenterstart/shared/lead.ts +++ b/medcenterstart/shared/lead.ts @@ -41,3 +41,26 @@ export type LeadInput = z.input export type LeadResponse = | { ok: true; leadId: number; contactId: number } | { ok: false; error: string; fields?: Record } + +/* -------------------------------------------------------------------------- */ +/* Клик по кнопке звонка */ +/* -------------------------------------------------------------------------- */ + +/** + * 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 +/** What the client sends — before zod's optional/empty-string normalisation. */ +export type CallClickInput = z.input + +export type CallClickResponse = { ok: true; leadId: number } | { ok: false; error: string } diff --git a/medcenterstart/src/components/SiteHeader.tsx b/medcenterstart/src/components/SiteHeader.tsx index 4b83a91..64cb176 100644 --- a/medcenterstart/src/components/SiteHeader.tsx +++ b/medcenterstart/src/components/SiteHeader.tsx @@ -2,6 +2,7 @@ import logo from '../assets/images/exo-logo.png' import { contacts, navLinks } from '../data/content' import { useScrolled } from '../hooks/useScrollState' import { cx } from '../lib/cx' +import { reportCallClick } from '../lib/lead' import { ButtonLink, buttonClass, headerSize } from './Button' import { PhoneIcon } from './Icons' import { Container } from './Layout' @@ -48,6 +49,7 @@ export function SiteHeader() {
svg]:phone:size-[18px]', headerSize)} > diff --git a/medcenterstart/src/components/ThumbBar.tsx b/medcenterstart/src/components/ThumbBar.tsx index 0f9fd01..8a86cd4 100644 --- a/medcenterstart/src/components/ThumbBar.tsx +++ b/medcenterstart/src/components/ThumbBar.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react' import { contacts, sectionNav } from '../data/content' import { useReadingPosition } from '../hooks/useReadingPosition' import { cx } from '../lib/cx' +import { reportCallClick } from '../lib/lead' import { ChevronUpIcon, CloseIcon, PhoneIcon } from './Icons' const hrefs = sectionNav.map((section) => section.href) @@ -145,6 +146,7 @@ export function ThumbBar() { { @@ -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 { interface Window { dataLayer?: Record[]