Files
Yuriy PanovandClaude Opus 5 1ee5431b92 hotel: apply the v7 design and add the callback dialog
Ports legacy/hotel v7 into the React landing.

- Copy: nav is Экономика / Для гостя / Интерьер / Сервис, and «Интерьер»
  now comes before «Сервис». The hero keeps one button and gets the
  2 500 ₽ / 600 000 ₽ / 7,2 млн ₽ facts. New headings and leads for the
  interior, service and request blocks. Session lengths 10–15 and 30–60
  min, area 12–36 m².
- Calculator counts sessions and gains the ЭкзоКлиник benchmark and a
  «Получить консультацию» button.
- Request form: company and email are optional. The server schema
  already treats them as optional.
- v8 phone type scale (≤620px), a 380px `micro` step, and a `slim`
  (≤640px) variant. The max-width variants are now declared widest first,
  so the narrower one wins where two apply.
- The market charts' SVGs stay inline, as in the mockup.

The header «Звонок» button opens the «Перезвоним вам» dialog from the
fitness landing (name, phone, consent). The server accepts form
'callback', names the deal «Обратный звонок — <имя>» and tags it
«обратный звонок». The phone link inside the dialog still reports a call
click.

With Inter substituted into the mockup, the page text matches it line for
line (except the fixed «2025–2026 гг.» typo), and every section height
matches at 360–1440 px.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 19:00:20 +06:00

66 lines
2.6 KiB
TypeScript

import { z } from 'zod'
/** Which form on the page produced the lead. Surfaces as an amoCRM tag. */
export const leadFormIds = ['request', 'callback'] as const
export type LeadFormId = (typeof leadFormIds)[number]
export const utmKeys = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term'] as const
const optionalText = (max: number) =>
z
.string()
.trim()
.max(max)
.optional()
.transform((v) => (v ? v : undefined))
export const leadSchema = z.object({
form: z.enum(leadFormIds),
name: z.string().trim().min(1, 'Укажите имя').max(200),
phone: z.string().trim().min(5, 'Укажите телефон').max(50),
email: z
.union([z.email().max(200), z.literal('')])
.optional()
.transform((v) => (v ? v : undefined)),
/** Hotel or hotel chain the request comes from. */
company: optionalText(200),
comment: optionalText(4000),
page: optionalText(300),
referrer: optionalText(500),
// partialRecord, not record: z.record over an enum requires every key.
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 LeadPayload = z.infer<typeof leadSchema>
/** What the client sends — before zod's optional/empty-string normalisation. */
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 }