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
@@ -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, {
|
||||
|
||||
Reference in New Issue
Block a user