Convert hotel landing to React + Tailwind v4 with amoCRM lead capture
Ports the single-file ЭкзоОтель page to the same architecture as the fitness
landing: Vite + React 19 + TypeScript + Tailwind v4 on the client, Express 5 for
the /api/leads/hotels endpoint, zod schema shared between the two.
The original page is kept in legacy/index.html as the visual reference. Its 15
inlined base64 images are extracted to files (the 1 MB HTML becomes ~318 KB of
JS plus assets loaded on demand), and its text is reproduced line for line —
verified with an innerText diff. Section heights stay within 0.6% at 375 and
1440 px; the drift comes from Inter actually loading, which the original asked
for but never served.
Three deliberate departures, documented in the README:
* eyebrow and lead in the CTA block were dark teal on navy (3.4:1); they now
match the other dark sections
* hero fact values overflowed their 80px column into the label below 430px
* "2025–2026г.." typo in the market source note
Leads reuse the fitness amoCRM integration: contact lookup by phone in every
spelling, deal in the first stage of pipeline 10980758, account fields matched
automatically with the rest written to a note. Rate limit, honeypot, and a
localStorage fallback so a lead survives the CRM being down. Vite runs on 5174
and the API on 3001 so both landings can run at once.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
60de4ef27c
commit
35f713a267
@@ -0,0 +1,112 @@
|
||||
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()
|
||||
app.set('trust proxy', true)
|
||||
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/hotels', 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, () => {
|
||||
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)',
|
||||
)
|
||||
})
|
||||
Reference in New Issue
Block a user