Rename medcenter landings and add the revenue calculator
Rename the two medcenter landings to their audience names: medcenter -> medcenterphysio, medcenterpersonal -> medcenterstart. Alongside the rename: - add a RevenueCalculator section to both landings; - rework the copy and figures in src/data/content.ts; - simplify the lead form: drop the "cabinet_state" and "profile" selects (along with SelectField and the matching fields in shared/lead.ts, lead-mapper.ts and amo-check.ts) and make company and email optional; - add the legacy/new static prototypes for both landings; - add pnpm-lock.yaml to medcenterstart (package-lock.json is still there too). deploy/apps.conf and deploy/README.md still refer to the old directory names and need a follow-up. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
be7741e269
commit
0732aa2096
@@ -0,0 +1,247 @@
|
||||
import type { AmoConfig } from './config.ts'
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* amoCRM REST types (only the parts this integration touches) */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export interface AmoEnum {
|
||||
id: number
|
||||
value: string
|
||||
enum_code?: string | null
|
||||
}
|
||||
|
||||
export interface AmoCustomField {
|
||||
id: number
|
||||
name: string
|
||||
code: string | null
|
||||
type: string
|
||||
enums?: AmoEnum[] | null
|
||||
}
|
||||
|
||||
interface AmoFieldValue {
|
||||
value: string | number | boolean | null
|
||||
enum_id?: number
|
||||
enum_code?: string
|
||||
}
|
||||
|
||||
export interface AmoFieldEntry {
|
||||
field_id?: number
|
||||
field_code?: string
|
||||
values: AmoFieldValue[]
|
||||
}
|
||||
|
||||
export interface AmoContact {
|
||||
id: number
|
||||
name?: string
|
||||
custom_fields_values?: AmoFieldEntry[] | null
|
||||
}
|
||||
|
||||
export interface AmoLead {
|
||||
id: number
|
||||
}
|
||||
|
||||
export class AmoError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status?: number,
|
||||
readonly detail?: unknown,
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'AmoError'
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Client */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
const RETRYABLE = new Set([429, 500, 502, 503, 504])
|
||||
|
||||
export class AmoClient {
|
||||
private leadFields?: Promise<AmoCustomField[]>
|
||||
private contactFields?: Promise<AmoCustomField[]>
|
||||
|
||||
constructor(private readonly config: AmoConfig) {}
|
||||
|
||||
get baseUrl(): string {
|
||||
return this.config.baseUrl
|
||||
}
|
||||
|
||||
/**
|
||||
* A single amoCRM call. Returns `null` for 204 responses, which amoCRM uses
|
||||
* for "found nothing" on every collection endpoint.
|
||||
*/
|
||||
private async request<T>(
|
||||
path: string,
|
||||
init: { method?: string; body?: unknown; query?: Record<string, string | number | undefined> } = {},
|
||||
attempt = 1,
|
||||
): Promise<T | null> {
|
||||
const url = new URL(path, this.config.baseUrl)
|
||||
for (const [key, value] of Object.entries(init.query ?? {})) {
|
||||
if (value !== undefined) url.searchParams.set(key, String(value))
|
||||
}
|
||||
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
method: init.method ?? 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.config.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: init.body === undefined ? undefined : JSON.stringify(init.body),
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
})
|
||||
} catch (cause) {
|
||||
if (attempt < 3) {
|
||||
await sleep(attempt * 400)
|
||||
return this.request<T>(path, init, attempt + 1)
|
||||
}
|
||||
throw new AmoError(`amoCRM unreachable: ${(cause as Error).message}`)
|
||||
}
|
||||
|
||||
if (response.status === 204) return null
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = await response.text().catch(() => '')
|
||||
if (RETRYABLE.has(response.status) && attempt < 3) {
|
||||
await sleep(attempt * 700)
|
||||
return this.request<T>(path, init, attempt + 1)
|
||||
}
|
||||
if (response.status === 401) {
|
||||
throw new AmoError('amoCRM rejected the token (401). Regenerate the long-lived token.', 401, detail)
|
||||
}
|
||||
throw new AmoError(`amoCRM ${init.method ?? 'GET'} ${path} failed: ${response.status}`, response.status, detail)
|
||||
}
|
||||
|
||||
return (await response.json()) as T
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------- */
|
||||
/* Field metadata — fetched once per process, then reused */
|
||||
/* ---------------------------------------------------------------------- */
|
||||
|
||||
private async fetchFields(entity: 'leads' | 'contacts'): Promise<AmoCustomField[]> {
|
||||
const collected: AmoCustomField[] = []
|
||||
for (let page = 1; page <= 10; page++) {
|
||||
const res = await this.request<{ _embedded?: { custom_fields?: AmoCustomField[] } }>(
|
||||
`/api/v4/${entity}/custom_fields`,
|
||||
{ query: { page, limit: 250 } },
|
||||
)
|
||||
const batch = res?._embedded?.custom_fields ?? []
|
||||
collected.push(...batch)
|
||||
if (batch.length < 250) break
|
||||
}
|
||||
return collected
|
||||
}
|
||||
|
||||
getLeadFields(): Promise<AmoCustomField[]> {
|
||||
this.leadFields ??= this.fetchFields('leads')
|
||||
return this.leadFields
|
||||
}
|
||||
|
||||
getContactFields(): Promise<AmoCustomField[]> {
|
||||
this.contactFields ??= this.fetchFields('contacts')
|
||||
return this.contactFields
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------- */
|
||||
/* Contacts */
|
||||
/* ---------------------------------------------------------------------- */
|
||||
|
||||
/** Full-text search. amoCRM matches phones and emails regardless of format. */
|
||||
async findContact(queries: string[]): Promise<AmoContact | null> {
|
||||
for (const query of queries) {
|
||||
if (!query) continue
|
||||
const res = await this.request<{ _embedded?: { contacts?: AmoContact[] } }>('/api/v4/contacts', {
|
||||
query: { query, limit: 1, with: 'leads' },
|
||||
})
|
||||
const contact = res?._embedded?.contacts?.[0]
|
||||
if (contact) return contact
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async createContact(input: { name: string; fields: AmoFieldEntry[] }): Promise<AmoContact> {
|
||||
const res = await this.request<{ _embedded?: { contacts?: AmoContact[] } }>('/api/v4/contacts', {
|
||||
method: 'POST',
|
||||
body: [
|
||||
{
|
||||
name: input.name,
|
||||
responsible_user_id: this.config.responsibleUserId,
|
||||
custom_fields_values: input.fields.length ? input.fields : undefined,
|
||||
},
|
||||
],
|
||||
})
|
||||
const contact = res?._embedded?.contacts?.[0]
|
||||
if (!contact) throw new AmoError('amoCRM did not return the created contact')
|
||||
return contact
|
||||
}
|
||||
|
||||
/** Adds phone/email to an existing contact without dropping what is there. */
|
||||
async appendContactFields(contactId: number, fields: AmoFieldEntry[]): Promise<void> {
|
||||
if (!fields.length) return
|
||||
await this.request(`/api/v4/contacts/${contactId}`, {
|
||||
method: 'PATCH',
|
||||
body: { custom_fields_values: fields },
|
||||
})
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------- */
|
||||
/* Leads */
|
||||
/* ---------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* 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 }] }
|
||||
const tags = [...new Set([...this.config.tags, ...input.tags])]
|
||||
if (tags.length) embedded.tags = tags.map((name) => ({ name }))
|
||||
|
||||
const res = await this.request<{ _embedded?: { leads?: AmoLead[] } }>('/api/v4/leads', {
|
||||
method: 'POST',
|
||||
body: [
|
||||
{
|
||||
name: input.name,
|
||||
pipeline_id: this.config.pipelineId,
|
||||
responsible_user_id: this.config.responsibleUserId,
|
||||
custom_fields_values: input.fields.length ? input.fields : undefined,
|
||||
_embedded: embedded,
|
||||
},
|
||||
],
|
||||
})
|
||||
const lead = res?._embedded?.leads?.[0]
|
||||
if (!lead) throw new AmoError('amoCRM did not return the created lead')
|
||||
return lead
|
||||
}
|
||||
|
||||
async addLeadNote(leadId: number, text: string): Promise<void> {
|
||||
await this.request(`/api/v4/leads/${leadId}/notes`, {
|
||||
method: 'POST',
|
||||
body: [{ note_type: 'common', params: { text } }],
|
||||
})
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------- */
|
||||
/* Diagnostics */
|
||||
/* ---------------------------------------------------------------------- */
|
||||
|
||||
async getAccount(): Promise<{ id: number; name: string; subdomain: string } | null> {
|
||||
return this.request('/api/v4/account')
|
||||
}
|
||||
|
||||
async getPipeline(id: number): Promise<{
|
||||
id: number
|
||||
name: string
|
||||
_embedded?: { statuses?: { id: number; name: string; sort: number }[] }
|
||||
} | null> {
|
||||
return this.request(`/api/v4/leads/pipelines/${id}`)
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { config as loadEnv } from 'dotenv'
|
||||
|
||||
loadEnv()
|
||||
|
||||
function optional(name: string): string | undefined {
|
||||
const value = process.env[name]?.trim()
|
||||
return value ? value : undefined
|
||||
}
|
||||
|
||||
function optionalNumber(name: string): number | undefined {
|
||||
const raw = optional(name)
|
||||
if (raw === undefined) return undefined
|
||||
const value = Number(raw)
|
||||
if (!Number.isFinite(value)) throw new Error(`${name} must be a number, got "${raw}"`)
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* amoCRM settings. Deliberately not throwing when they are missing: the site
|
||||
* must still boot and serve pages without a CRM connection — only the lead
|
||||
* endpoint degrades, and it says so explicitly.
|
||||
*/
|
||||
export interface AmoConfig {
|
||||
baseUrl: string
|
||||
token: string
|
||||
pipelineId: number
|
||||
responsibleUserId?: number
|
||||
tags: string[]
|
||||
}
|
||||
|
||||
export function readAmoConfig(): AmoConfig | null {
|
||||
const subdomain = optional('AMO_SUBDOMAIN')
|
||||
const token = optional('AMO_LONG_LIVED_TOKEN')
|
||||
if (!subdomain || !token) return null
|
||||
|
||||
// Accepts "exotherapy", "exotherapy.amocrm.ru", or a full URL (an explicit
|
||||
// scheme is kept as-is, which is what local mocks and self-hosted setups need).
|
||||
const host = subdomain.replace(/\/+$/, '')
|
||||
const baseUrl = /^https?:\/\//.test(host)
|
||||
? host
|
||||
: `https://${host.includes('.') ? host : `${host}.${optional('AMO_DOMAIN') ?? 'amocrm.ru'}`}`
|
||||
|
||||
return {
|
||||
baseUrl,
|
||||
token,
|
||||
pipelineId: optionalNumber('AMO_PIPELINE_ID') ?? 10980758,
|
||||
responsibleUserId: optionalNumber('AMO_RESPONSIBLE_USER_ID'),
|
||||
tags: (optional('AMO_LEAD_TAGS') ?? '')
|
||||
.split(',')
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean),
|
||||
}
|
||||
}
|
||||
|
||||
export const serverConfig = {
|
||||
port: optionalNumber('PORT') ?? 3002,
|
||||
// Loopback by default: in production nginx is the only thing that should
|
||||
// reach this process, so the port is never exposed to the network.
|
||||
host: optional('HOST') ?? '127.0.0.1',
|
||||
isProduction: process.env.NODE_ENV === 'production',
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import express from 'express'
|
||||
import { leadSchema, type LeadResponse } from '../../shared/lead.ts'
|
||||
import { AmoClient, AmoError } from './amocrm.ts'
|
||||
import { readAmoConfig, serverConfig } from './config.ts'
|
||||
import { LeadService } from './lead-service.ts'
|
||||
import { createRateLimiter } from './rate-limit.ts'
|
||||
|
||||
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')
|
||||
const clientDir = path.join(rootDir, 'dist/client')
|
||||
|
||||
const amoConfig = readAmoConfig()
|
||||
const leadService = amoConfig ? new LeadService(new AmoClient(amoConfig)) : null
|
||||
|
||||
if (!leadService) {
|
||||
console.warn(
|
||||
'[amo] AMO_SUBDOMAIN / AMO_LONG_LIVED_TOKEN are not set — the site runs, but /api/leads/* will return 503.',
|
||||
)
|
||||
}
|
||||
|
||||
const app = express()
|
||||
// 'loopback', not true: only the local nginx hop is trusted, so req.ip is the
|
||||
// address nginx actually observed and the rate limiter below cannot be
|
||||
// side-stepped with a forged X-Forwarded-For header.
|
||||
app.set('trust proxy', 'loopback')
|
||||
app.use(express.json({ limit: '64kb' }))
|
||||
|
||||
const limiter = createRateLimiter({ limit: 8, windowMs: 10 * 60 * 1000 })
|
||||
|
||||
app.get('/api/health', (_req, res) => {
|
||||
res.json({ ok: true, amo: Boolean(leadService), pipelineId: amoConfig?.pipelineId ?? null })
|
||||
})
|
||||
|
||||
app.post('/api/leads/medical-centers-existing-physio', async (req, res) => {
|
||||
const send = (status: number, body: LeadResponse) => res.status(status).json(body)
|
||||
|
||||
const parsed = leadSchema.safeParse(req.body)
|
||||
if (!parsed.success) {
|
||||
const fields: Record<string, string> = {}
|
||||
for (const issue of parsed.error.issues) {
|
||||
const key = issue.path.join('.')
|
||||
if (key && !fields[key]) fields[key] = issue.message
|
||||
}
|
||||
return send(400, { ok: false, error: 'Проверьте заполненные поля.', fields })
|
||||
}
|
||||
|
||||
const payload = parsed.data
|
||||
|
||||
// Honeypot: answer exactly like a success so bots learn nothing.
|
||||
if (payload.website) {
|
||||
console.info('[lead] honeypot triggered from %s', req.ip)
|
||||
return send(200, { ok: true, leadId: 0, contactId: 0 })
|
||||
}
|
||||
|
||||
const { allowed, retryAfterSeconds } = limiter(req.ip ?? 'unknown')
|
||||
if (!allowed) {
|
||||
res.setHeader('Retry-After', String(retryAfterSeconds))
|
||||
return send(429, { ok: false, error: 'Слишком много заявок с этого адреса. Попробуйте позже.' })
|
||||
}
|
||||
|
||||
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.' })
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await leadService.submit(payload)
|
||||
console.info(
|
||||
'[lead] amoCRM lead %d (contact %d, %s)',
|
||||
result.leadId,
|
||||
result.contactId,
|
||||
result.contactCreated ? 'new contact' : 'existing contact',
|
||||
)
|
||||
return send(200, { ok: true, leadId: result.leadId, contactId: result.contactId })
|
||||
} catch (error) {
|
||||
console.error('[lead] amoCRM submission failed:', error)
|
||||
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.'
|
||||
return send(502, { ok: false, error: message })
|
||||
}
|
||||
})
|
||||
|
||||
if (serverConfig.isProduction) {
|
||||
app.use(
|
||||
express.static(clientDir, {
|
||||
index: false,
|
||||
setHeaders(res, filePath) {
|
||||
// Vite fingerprints everything under /assets, so it can be cached hard.
|
||||
if (filePath.includes(`${path.sep}assets${path.sep}`)) {
|
||||
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable')
|
||||
}
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
// SPA fallback. Written as middleware because Express 5 no longer accepts a
|
||||
// bare '*' route pattern.
|
||||
app.use((req, res, next) => {
|
||||
if (req.method !== 'GET' || req.path.startsWith('/api/')) return next()
|
||||
res.sendFile(path.join(clientDir, 'index.html'))
|
||||
})
|
||||
}
|
||||
|
||||
app.listen(serverConfig.port, serverConfig.host, () => {
|
||||
console.info(
|
||||
'[server] listening on http://localhost:%d%s',
|
||||
serverConfig.port,
|
||||
serverConfig.isProduction ? ` (serving ${path.relative(rootDir, clientDir)})` : ' (API only — run vite for the UI)',
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,143 @@
|
||||
import type { LeadPayload } from '../../shared/lead.ts'
|
||||
import type { AmoCustomField, AmoFieldEntry } from './amocrm.ts'
|
||||
|
||||
/** Human labels used in the fallback note. */
|
||||
const LABELS: Record<string, string> = {
|
||||
company: 'Медицинский центр',
|
||||
email: 'Email',
|
||||
phone: 'Телефон',
|
||||
comment: 'Комментарий',
|
||||
page: 'Страница',
|
||||
referrer: 'Источник перехода',
|
||||
form: 'Форма',
|
||||
utm_source: 'utm_source',
|
||||
utm_medium: 'utm_medium',
|
||||
utm_campaign: 'utm_campaign',
|
||||
utm_content: 'utm_content',
|
||||
utm_term: 'utm_term',
|
||||
}
|
||||
|
||||
/**
|
||||
* Candidate amoCRM field codes / name fragments for each piece of data we
|
||||
* collect. The first field in the account that matches wins; anything without
|
||||
* a match falls through to the lead note instead.
|
||||
*/
|
||||
const CANDIDATES: Record<string, { codes: string[]; names: string[] }> = {
|
||||
company: {
|
||||
codes: ['COMPANY', 'COMPANY_NAME'],
|
||||
names: ['компания', 'организация', 'медицинский центр', 'клиника', 'название центра', 'сеть'],
|
||||
},
|
||||
comment: { codes: ['COMMENT', 'MESSAGE', 'DESCRIPTION'], names: ['комментарий', 'сообщение', 'описание заявки'] },
|
||||
page: { codes: ['REFERRER', 'PAGE'], names: ['страница', 'посадочная страница', 'url страницы'] },
|
||||
referrer: { codes: ['REFERER_URL'], names: ['источник перехода', 'referrer', 'реферер'] },
|
||||
form: { codes: ['FORMNAME', 'FORM_NAME'], names: ['название формы', 'форма'] },
|
||||
utm_source: { codes: ['UTM_SOURCE'], names: ['utm_source'] },
|
||||
utm_medium: { codes: ['UTM_MEDIUM'], names: ['utm_medium'] },
|
||||
utm_campaign: { codes: ['UTM_CAMPAIGN'], names: ['utm_campaign'] },
|
||||
utm_content: { codes: ['UTM_CONTENT'], names: ['utm_content'] },
|
||||
utm_term: { codes: ['UTM_TERM'], names: ['utm_term'] },
|
||||
}
|
||||
|
||||
/** Field types we know how to write a plain string into. */
|
||||
const TEXTUAL = new Set(['text', 'textarea', 'url', 'tracking_data', 'numeric', 'price', 'monetary'])
|
||||
const ENUMERATED = new Set(['select', 'radiobutton', 'multiselect'])
|
||||
|
||||
const normalise = (value: string) =>
|
||||
value
|
||||
.toLowerCase()
|
||||
.replace(/ё/g, 'е')
|
||||
.replace(/[^a-zа-я0-9]+/gi, ' ')
|
||||
.trim()
|
||||
|
||||
export type LeadFieldMap = Map<string, AmoCustomField>
|
||||
|
||||
/** Picks one account field per payload key. A field is never used twice. */
|
||||
export function resolveLeadFieldMap(fields: AmoCustomField[]): LeadFieldMap {
|
||||
const map: LeadFieldMap = new Map()
|
||||
const taken = new Set<number>()
|
||||
|
||||
for (const [key, candidate] of Object.entries(CANDIDATES)) {
|
||||
const byCode = fields.find(
|
||||
(f) => f.code && candidate.codes.includes(f.code.toUpperCase()) && !taken.has(f.id),
|
||||
)
|
||||
const match =
|
||||
byCode ??
|
||||
fields.find((f) => {
|
||||
if (taken.has(f.id)) return false
|
||||
const name = normalise(f.name)
|
||||
return candidate.names.some((n) => name === normalise(n))
|
||||
}) ??
|
||||
fields.find((f) => {
|
||||
if (taken.has(f.id)) return false
|
||||
const name = normalise(f.name)
|
||||
return candidate.names.some((n) => name.includes(normalise(n)))
|
||||
})
|
||||
|
||||
if (!match) continue
|
||||
if (!TEXTUAL.has(match.type) && !ENUMERATED.has(match.type)) continue
|
||||
map.set(key, match)
|
||||
taken.add(match.id)
|
||||
}
|
||||
|
||||
return map
|
||||
}
|
||||
|
||||
/** Builds one amoCRM field entry, or null when the value cannot be represented. */
|
||||
function toFieldEntry(field: AmoCustomField, value: string): AmoFieldEntry | null {
|
||||
if (ENUMERATED.has(field.type)) {
|
||||
const target = normalise(value)
|
||||
const option = field.enums?.find((e) => normalise(e.value) === target)
|
||||
// An unmatched option would silently create garbage, so let it hit the note.
|
||||
return option ? { field_id: field.id, values: [{ value: option.value, enum_id: option.id }] } : null
|
||||
}
|
||||
|
||||
if (field.type === 'numeric' || field.type === 'price' || field.type === 'monetary') {
|
||||
const numeric = Number(value.replace(/[^\d.,-]/g, '').replace(',', '.'))
|
||||
return Number.isFinite(numeric) ? { field_id: field.id, values: [{ value: numeric }] } : null
|
||||
}
|
||||
|
||||
return { field_id: field.id, values: [{ value }] }
|
||||
}
|
||||
|
||||
export interface MappedLead {
|
||||
fields: AmoFieldEntry[]
|
||||
/** Everything that had no matching field, ready to be written as a note. */
|
||||
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,
|
||||
}
|
||||
|
||||
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}`)
|
||||
}
|
||||
|
||||
// Contact details are always repeated in the note so a manager can read the
|
||||
// whole request without opening the linked contact card.
|
||||
const header = [
|
||||
'Заявка с лендинга «Усиление действующего кабинета физиотерапии»',
|
||||
`${LABELS.phone}: ${payload.phone}`,
|
||||
payload.email ? `${LABELS.email}: ${payload.email}` : undefined,
|
||||
].filter(Boolean) as string[]
|
||||
|
||||
return { fields, note: [...header, ...leftovers].join('\n') }
|
||||
}
|
||||
|
||||
/** Lead title shown in the pipeline. */
|
||||
export function buildLeadName(payload: LeadPayload): string {
|
||||
return `Физиотерапия — ${payload.company ?? payload.name}`
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import type { LeadPayload } from '../../shared/lead.ts'
|
||||
import { AmoClient, type AmoContact, type AmoFieldEntry } from './amocrm.ts'
|
||||
import { buildLeadName, mapLead, resolveLeadFieldMap, type LeadFieldMap } from './lead-mapper.ts'
|
||||
|
||||
const digitsOnly = (value: string) => value.replace(/\D/g, '')
|
||||
|
||||
/**
|
||||
* Last ten digits — the part that is stable across "+7 (999) 123-45-67",
|
||||
* "8 999 1234567" and "9991234567". Used to decide whether amoCRM already
|
||||
* knows a phone number.
|
||||
*/
|
||||
const phoneKey = (value: string) => digitsOnly(value).slice(-10)
|
||||
|
||||
/**
|
||||
* amoCRM's full-text search matches digit substrings, but a contact stored as
|
||||
* "+7 999…" is not found by a query starting with "8 999…". Try every spelling
|
||||
* a Russian visitor might type.
|
||||
*/
|
||||
function phoneQueries(phone: string): string[] {
|
||||
const digits = digitsOnly(phone)
|
||||
const queries = new Set<string>([phone.trim(), digits])
|
||||
if (digits.length >= 10) {
|
||||
const national = digits.slice(-10)
|
||||
queries.add(national)
|
||||
queries.add(`7${national}`)
|
||||
queries.add(`8${national}`)
|
||||
}
|
||||
return [...queries].filter(Boolean)
|
||||
}
|
||||
|
||||
/** Reads the values already stored in a contact's standard multitext field. */
|
||||
function existingValues(contact: AmoContact, code: 'PHONE' | 'EMAIL'): string[] {
|
||||
const entry = contact.custom_fields_values?.find((f) => f.field_code === code)
|
||||
return (entry?.values ?? []).map((v) => String(v.value ?? '')).filter(Boolean)
|
||||
}
|
||||
|
||||
function contactFieldEntries(payload: LeadPayload): AmoFieldEntry[] {
|
||||
const entries: AmoFieldEntry[] = [
|
||||
{ field_code: 'PHONE', values: [{ value: payload.phone, enum_code: 'WORK' }] },
|
||||
]
|
||||
if (payload.email) {
|
||||
entries.push({ field_code: 'EMAIL', values: [{ value: payload.email, enum_code: 'WORK' }] })
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
export interface CreatedLead {
|
||||
leadId: number
|
||||
contactId: number
|
||||
contactCreated: boolean
|
||||
}
|
||||
|
||||
export class LeadService {
|
||||
private fieldMap?: Promise<LeadFieldMap>
|
||||
|
||||
constructor(private readonly amo: AmoClient) {}
|
||||
|
||||
private getFieldMap(): Promise<LeadFieldMap> {
|
||||
this.fieldMap ??= this.amo.getLeadFields().then(resolveLeadFieldMap)
|
||||
return this.fieldMap
|
||||
}
|
||||
|
||||
async submit(payload: LeadPayload): Promise<CreatedLead> {
|
||||
const fieldMap = await this.getFieldMap()
|
||||
|
||||
// 1. Reuse the existing contact when the phone or email is already known.
|
||||
const existing = await this.amo.findContact([...phoneQueries(payload.phone), payload.email ?? ''])
|
||||
|
||||
let contactId: number
|
||||
let contactCreated = false
|
||||
|
||||
if (existing) {
|
||||
contactId = existing.id
|
||||
// Add whichever channel amoCRM does not have on file yet.
|
||||
const missing: AmoFieldEntry[] = []
|
||||
const knownPhones = existingValues(existing, 'PHONE').map(phoneKey)
|
||||
if (!knownPhones.includes(phoneKey(payload.phone))) {
|
||||
missing.push({
|
||||
field_code: 'PHONE',
|
||||
values: [
|
||||
...existingValues(existing, 'PHONE').map((value) => ({ value })),
|
||||
{ value: payload.phone, enum_code: 'WORK' },
|
||||
],
|
||||
})
|
||||
}
|
||||
if (payload.email) {
|
||||
const knownEmails = existingValues(existing, 'EMAIL').map((e) => e.toLowerCase())
|
||||
if (!knownEmails.includes(payload.email.toLowerCase())) {
|
||||
missing.push({
|
||||
field_code: 'EMAIL',
|
||||
values: [
|
||||
...existingValues(existing, 'EMAIL').map((value) => ({ value })),
|
||||
{ value: payload.email, enum_code: 'WORK' },
|
||||
],
|
||||
})
|
||||
}
|
||||
}
|
||||
await this.amo.appendContactFields(contactId, missing)
|
||||
} else {
|
||||
const created = await this.amo.createContact({ name: payload.name, fields: contactFieldEntries(payload) })
|
||||
contactId = created.id
|
||||
contactCreated = true
|
||||
}
|
||||
|
||||
// 2. Lead in the configured pipeline, first stage.
|
||||
const { fields, note } = mapLead(payload, fieldMap)
|
||||
const lead = await this.amo.createLead({
|
||||
name: buildLeadName(payload),
|
||||
contactId,
|
||||
fields,
|
||||
tags: ['заявка с сайта'],
|
||||
})
|
||||
|
||||
// 3. Everything the account has no field for lands in a readable note.
|
||||
// A failed note must not fail the lead — the lead is the valuable part.
|
||||
if (note) {
|
||||
await this.amo.addLeadNote(lead.id, note).catch((error: unknown) => {
|
||||
console.warn('[amo] lead %d created but the note failed:', lead.id, error)
|
||||
})
|
||||
}
|
||||
|
||||
return { leadId: lead.id, contactId, contactCreated }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
interface Bucket {
|
||||
count: number
|
||||
resetAt: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal fixed-window limiter for the public lead endpoint. In-memory on
|
||||
* purpose: a single landing page behind one Node process does not warrant a
|
||||
* shared store, and losing the counters on restart is harmless.
|
||||
*/
|
||||
export function createRateLimiter(options: { limit: number; windowMs: number }) {
|
||||
const buckets = new Map<string, Bucket>()
|
||||
|
||||
return function check(key: string): { allowed: boolean; retryAfterSeconds: number } {
|
||||
const now = Date.now()
|
||||
|
||||
if (buckets.size > 5000) {
|
||||
for (const [k, bucket] of buckets) if (bucket.resetAt <= now) buckets.delete(k)
|
||||
}
|
||||
|
||||
const bucket = buckets.get(key)
|
||||
if (!bucket || bucket.resetAt <= now) {
|
||||
buckets.set(key, { count: 1, resetAt: now + options.windowMs })
|
||||
return { allowed: true, retryAfterSeconds: 0 }
|
||||
}
|
||||
|
||||
bucket.count += 1
|
||||
return {
|
||||
allowed: bucket.count <= options.limit,
|
||||
retryAfterSeconds: Math.ceil((bucket.resetAt - now) / 1000),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user