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:
co-authored by
Claude Opus 5
parent
e7385e8898
commit
e13854e918
@@ -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 минут.
|
||||
|
||||
### Переменные окружения
|
||||
|
||||
| Переменная | Обязательна | Описание |
|
||||
|
||||
@@ -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<AmoLead> {
|
||||
const embedded: Record<string, unknown> = { contacts: [{ id: input.contactId }] }
|
||||
async createLead(input: { name: string; contactId?: number; fields: AmoFieldEntry[]; tags: string[] }): Promise<AmoLead> {
|
||||
// `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])]
|
||||
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,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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<string, string> = {
|
||||
company: 'Отель / сеть',
|
||||
@@ -105,16 +108,11 @@ export interface MappedLead {
|
||||
note: string
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
/**
|
||||
* 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[] = []
|
||||
|
||||
@@ -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<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
|
||||
// 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 'Звонок с сайта — ЭкзоОтель'
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,3 +40,26 @@ export type LeadInput = z.input<typeof leadSchema>
|
||||
export type LeadResponse =
|
||||
| { ok: true; leadId: number; contactId: number }
|
||||
| { 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 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
|
||||
<div className="flex shrink-0 items-center gap-[10px] compact:gap-[6px]">
|
||||
<a
|
||||
href={contacts.phoneHref}
|
||||
onClick={reportCallClick}
|
||||
aria-label="Позвонить в Экзо Групп"
|
||||
className={buttonClass('call', 'compact:px-[12px]', headerSize)}
|
||||
>
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
+79
-3
@@ -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<string, unknown>[]
|
||||
|
||||
Reference in New Issue
Block a user