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:
Yuriy Panov
2026-08-28 14:17:10 +06:00
co-authored by Claude Opus 5
parent 60de4ef27c
commit 35f713a267
58 changed files with 8713 additions and 0 deletions
+247
View File
@@ -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))
}