diff --git a/hotel/README.md b/hotel/README.md
index b64e407..a88846b 100644
--- a/hotel/README.md
+++ b/hotel/README.md
@@ -67,18 +67,26 @@ npm run amo:check
email дописывается в карточку.
2. Если контакта нет — создаётся новый с именем, телефоном и email.
3. Создаётся сделка в воронке `AMO_PIPELINE_ID`, в её **первом этапе**,
- с тегами `AMO_LEAD_TAGS` + тег `заявка с сайта`.
+ с тегами `AMO_LEAD_TAGS` + тег формы (`заявка с сайта` / `обратный звонок`).
4. Данные, для которых в аккаунте есть подходящее поле (отель, комментарий,
`utm_*` и т.д.), пишутся в поля; всё остальное — в примечание к сделке.
Ничего настраивать в amoCRM заранее не нужно.
+Форм две. Основная — блок «Следующий шаг» внизу страницы: обязательны только
+имя и телефон, отель, email и комментарий по желанию; сделка называется
+`ЭкзоОтель — <отель или имя>`. Вторая — модальное окно «Перезвоним вам», его
+открывает кнопка «Звонок» в шапке (то же окно, что на фитнес-лендинге): имя,
+телефон и согласие на обработку данных. Сделка называется
+`Обратный звонок — <имя>`, в поле или примечании «Форма» — «Быстрый обратный
+звонок».
+
Если amoCRM недоступна, заявка **не теряется**: она пишется в лог сервера
(`[lead] payload was: …`) и сохраняется в `localStorage` браузера, а посетитель
видит телефон для связи.
### 4. Что происходит при клике на телефон
-Кнопка звонка (в шапке) помимо набора номера отправляет
+Ссылка с номером в модальном окне «Перезвоним вам» помимо набора номера отправляет
`POST /api/leads/hotels/call` — маячком `navigator.sendBeacon`, чтобы
запрос пережил переход браузера на `tel:`.
@@ -112,14 +120,15 @@ index.html точка входа Vite (meta, Open Graph, JSON-LD)
src/
components/ секции лендинга и UI-примитивы
data/content.ts весь текстовый контент страницы
- hooks/ useReveal, useScrolled, useAnchorScroll
+ hooks/ useReveal, useScrolled, useAnchorScroll, useBodyLock, useEscapeKey
lib/ отправка заявки, форматирование чисел
assets/images/ 11 изображений, извлечённых из исходного HTML
index.css дизайн-токены Tailwind v4 (@theme) и базовые стили
shared/lead.ts схема заявки (zod), общая для клиента и сервера
server/src/ Express API + клиент amoCRM
scripts/amo-check.ts диагностика подключения к amoCRM
-legacy/index.html исходный однофайловый лендинг (визуальный эталон)
+legacy/index.html исходный однофайловый лендинг (первая версия дизайна)
+legacy/hotel v7/ макет v7 — текущий визуальный эталон
public/ favicon.png и og-image.jpg
```
@@ -129,30 +138,32 @@ public/ favicon.png и og-image.jpg
`@theme` внутри `src/index.css`. Палитра Tailwind по умолчанию отключена
(`--color-*: initial`), поэтому в разметке доступны только цвета EXO.
Брейкпоинты соответствуют исходной вёрстке: `sm` 640, `md` 900, плюс
-`max-width`-варианты `tiny` (430), `phone` (480), `compact` (620),
-`narrow` (780).
+`max-width`-варианты `narrow` (780), `slim` (640), `compact` (620), `phone` (480),
+`tiny` (430), `micro` (380). Tailwind выводит пользовательские варианты в порядке
+объявления, поэтому они объявлены от широкого к узкому: где действуют два,
+побеждает более узкий — как в каскаде макета.
Ряды карточек (выгоды, гости, оборудование, сценарии, программы) до 900px
скроллятся по горизонтали со snap, выше — становятся сеткой. Это единственный
крупный кусок вёрстки, оставшийся классом (`.scroll-cards` в `index.css`):
`grid-auto-columns` не выражается утилитами, а сам ряд используется пять раз.
-### Соответствие исходной вёрстке
+### Соответствие макету
-Текст страницы совпадает с `legacy/index.html` строка в строку (проверено
-диффом `innerText`), высоты секций — в пределах 0,6% на 375 и 1440 px.
-Расхождения дают метрики настоящего Inter: исходная страница просила Inter, но
-нигде его не подключала и рисовалась системным фолбэком.
+Визуальный эталон — макет `legacy/hotel v7/`. Сам макет Inter не подключает и
+рисуется системным шрифтом, поэтому сверять его с сайтом нужно, подставив в него
+Inter Variable. На одном шрифте текст страницы совпадает с макетом строка в строку
+(дифф `innerText`), а высоты всех секций и общая высота страницы — попиксельно на
+360 / 375 / 430 / 480 / 620 / 640 / 780 / 900 / 1120 / 1440 px.
-Три отличия сделаны намеренно:
+Намеренные отличия от макета:
-* `.eyebrow` и `.lead` в блоке заявки исходно оставались тёмными
- (`#008F84` и `#60778B`) на тёмно-синем фоне — контраст 3,4–3,8:1. Здесь они
- светлые, как в остальных тёмных секциях.
-* На экранах ≤430px значение в карточке hero-факта («4 аппарата», «1 оператор»)
- не помещалось в колонку 80px и наезжало на подпись. Колонка расширена до
- 124px.
+* `.eyebrow` в блоке заявки в макете остаётся тёмным (`#008F84`) на тёмно-синем
+ фоне. Здесь он светлый, как в остальных тёмных секциях.
* В подписи к рыночному блоку исправлена опечатка «2025–2026г..» → «2025–2026 гг.».
+* Модальное окно «Перезвоним вам» взято с фитнес-лендинга (светлые поля,
+ согласие отдельным чекбоксом), а не вёрстка `.exo-callback` из макета; маски
+ телефона, как и на фитнесе, нет. Тексты окна — из макета.
### Шрифты
diff --git a/hotel/server/src/lead-mapper.ts b/hotel/server/src/lead-mapper.ts
index 9ada3c7..d2c7e64 100644
--- a/hotel/server/src/lead-mapper.ts
+++ b/hotel/server/src/lead-mapper.ts
@@ -133,7 +133,7 @@ export function mapLead(payload: LeadPayload, fieldMap: LeadFieldMap): MappedLea
comment: payload.comment,
page: payload.page,
referrer: payload.referrer,
- form: 'Заявка на предложение',
+ form: payload.form === 'callback' ? 'Быстрый обратный звонок' : 'Заявка на предложение',
...payload.utm,
}
@@ -152,7 +152,8 @@ export function mapLead(payload: LeadPayload, fieldMap: LeadFieldMap): MappedLea
/** Lead title shown in the pipeline. */
export function buildLeadName(payload: LeadPayload): string {
- return `ЭкзоОтель — ${payload.company ?? payload.name}`
+ const who = payload.company ?? payload.name
+ return payload.form === 'callback' ? `Обратный звонок — ${who}` : `ЭкзоОтель — ${who}`
}
/**
diff --git a/hotel/server/src/lead-service.ts b/hotel/server/src/lead-service.ts
index 47f9553..70c22a7 100644
--- a/hotel/server/src/lead-service.ts
+++ b/hotel/server/src/lead-service.ts
@@ -115,7 +115,7 @@ export class LeadService {
name: buildLeadName(payload),
contactId,
fields,
- tags: ['заявка с сайта'],
+ tags: [payload.form === 'callback' ? 'обратный звонок' : 'заявка с сайта'],
})
// 3. Everything the account has no field for lands in a readable note.
diff --git a/hotel/shared/lead.ts b/hotel/shared/lead.ts
index 4d68b7a..e0a0415 100644
--- a/hotel/shared/lead.ts
+++ b/hotel/shared/lead.ts
@@ -1,7 +1,7 @@
import { z } from 'zod'
/** Which form on the page produced the lead. Surfaces as an amoCRM tag. */
-export const leadFormIds = ['request'] as const
+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
diff --git a/hotel/src/App.tsx b/hotel/src/App.tsx
index cb8e15a..c11a9e4 100644
--- a/hotel/src/App.tsx
+++ b/hotel/src/App.tsx
@@ -1,5 +1,6 @@
-import { useRef } from 'react'
+import { useCallback, useRef, useState } from 'react'
import { BenefitsSection } from './components/BenefitsSection'
+import { CallbackModal } from './components/CallbackModal'
import { EconomicsSection } from './components/EconomicsSection'
import { EquipmentSection } from './components/EquipmentSection'
import { FormatsSection } from './components/FormatsSection'
@@ -17,22 +18,27 @@ export default function App() {
const scrolled = useScrolled()
useAnchorScroll(headerRef)
+ const [callbackOpen, setCallbackOpen] = useState(false)
+ const openCallback = useCallback(() => setCallbackOpen(true), [])
+ const closeCallback = useCallback(() => setCallbackOpen(false), [])
+
return (
<>
-
+
-
+
+
>
)
}
diff --git a/hotel/src/components/BenefitsSection.tsx b/hotel/src/components/BenefitsSection.tsx
index efd5cbd..acbb760 100644
--- a/hotel/src/components/BenefitsSection.tsx
+++ b/hotel/src/components/BenefitsSection.tsx
@@ -186,8 +186,10 @@ function MetricCard({
foot: [string, string]
children: ReactNode
}) {
+ // The chart stays inline, on the baseline, as in the design: preflight makes
+ // SVGs blocks, which drops the line gap under it and shortens the card by ~7px.
return (
-
+
{label}{value}
diff --git a/hotel/src/components/Button.tsx b/hotel/src/components/Button.tsx
index 228fd7a..7b321af 100644
--- a/hotel/src/components/Button.tsx
+++ b/hotel/src/components/Button.tsx
@@ -18,7 +18,7 @@ const base =
const defaultSize = 'min-h-[52px] px-[22px]'
/** `.btn--header` — smaller, and never stretched to the full width on phones. */
-export const headerSize = 'min-h-[44px] px-[17px] text-[13px] tiny:w-auto'
+export const headerSize = 'min-h-[44px] px-[17px] text-[13px] tiny:w-auto micro:px-[13px] micro:text-[12px]'
const variants: Record = {
primary:
diff --git a/hotel/src/components/CallbackModal.tsx b/hotel/src/components/CallbackModal.tsx
new file mode 100644
index 0000000..d69f75f
--- /dev/null
+++ b/hotel/src/components/CallbackModal.tsx
@@ -0,0 +1,155 @@
+import { useCallback, useEffect, useRef, useState, type FormEvent } from 'react'
+import { contacts } from '../data/content'
+import { useBodyLock } from '../hooks/useBodyLock'
+import { useEscapeKey } from '../hooks/useEscapeKey'
+import { reportCallClick, submitLead } from '../lib/lead'
+import { cx } from '../lib/cx'
+import { Button } from './Button'
+import { ConsentCheckbox, Honeypot, TextField } from './FormField'
+import { ArrowRightIcon } from './Icons'
+
+type Status = { text: string; tone: 'idle' | 'success' | 'error' }
+
+/**
+ * Compact second entry point: name + phone only. Leads land in the same
+ * amoCRM pipeline, tagged so they can be told apart from the full request.
+ * Same dialog as the fitness landing's.
+ */
+export function CallbackModal({ open, onClose }: { open: boolean; onClose: () => void }) {
+ const formRef = useRef(null)
+ const firstFieldRef = useRef(null)
+ const [name, setName] = useState('')
+ const [phone, setPhone] = useState('')
+ const [consent, setConsent] = useState(false)
+ const [honeypot, setHoneypot] = useState('')
+ const [pending, setPending] = useState(false)
+ const [status, setStatus] = useState({ text: '', tone: 'idle' })
+
+ const close = useCallback(() => onClose(), [onClose])
+ useBodyLock(open)
+ useEscapeKey(open, close)
+
+ useEffect(() => {
+ if (open) firstFieldRef.current?.focus()
+ }, [open])
+
+ if (!open) return null
+
+ async function onSubmit(event: FormEvent) {
+ event.preventDefault()
+ const form = formRef.current
+ if (!form) return
+
+ if (!form.checkValidity()) {
+ form.reportValidity()
+ setStatus({ text: 'Проверьте обязательные поля и согласие.', tone: 'error' })
+ return
+ }
+
+ if (honeypot) return
+
+ setPending(true)
+ setStatus({ text: '', tone: 'idle' })
+
+ const result = await submitLead('callback', { name, phone, website: honeypot })
+
+ if (result.ok) {
+ setName('')
+ setPhone('')
+ setConsent(false)
+ setStatus({ text: 'Спасибо. Мы перезвоним в ближайшее рабочее время.', tone: 'success' })
+ } else {
+ setStatus({ text: result.error ?? 'Не удалось отправить заявку.', tone: 'error' })
+ }
+
+ setPending(false)
+ }
+
+ return (
+
+ Оставьте имя и телефон — наш специалист свяжется с вами и ответит на вопросы. Или позвоните сами:{' '}
+
+ {contacts.phone}
+
+ .
+
+
+
+
+
+ )
+}
diff --git a/hotel/src/components/EconomicsSection.tsx b/hotel/src/components/EconomicsSection.tsx
index bbb979a..93ccf25 100644
--- a/hotel/src/components/EconomicsSection.tsx
+++ b/hotel/src/components/EconomicsSection.tsx
@@ -3,6 +3,8 @@ import { econGain, econLoss, econMetrics, econOps } from '../data/content'
import { useReveal } from '../hooks/useReveal'
import { formatMoney, formatNumber, parseAmount } from '../lib/format'
import { cx } from '../lib/cx'
+import { ButtonLink } from './Button'
+import { ArrowRightIcon } from './Icons'
import { Container, Section } from './Layout'
const panelItem = 'grid grid-cols-[34px_1fr] items-start gap-[11px] rounded-[16px] p-[13px]'
@@ -27,7 +29,7 @@ export function EconomicsSection() {
Экономика текущего гостевого потока
-
+
Номер уже продан. Следующая выручка — внутри маршрута гостя
@@ -171,7 +173,7 @@ function RevenueCalculator() {
Посчитайте дополнительную выручку на своих тарифах
- Введите фактическое число оплаченных программ, средний чек и рабочие дни.
+ Введите фактическое число оплаченных сеансов, средний чек сеанса и рабочие дни.
@@ -179,7 +181,7 @@ function RevenueCalculator() {
@@ -210,7 +212,7 @@ function RevenueCalculator() {
+
+
+ Ориентир для расчёта
+
+
+ Средний чек сеанса составляет 2 500 ₽,
+ по данным нашей клиники «ЭкзоКлиник» в городе Тольятти за период 2025г.–2026г.
+
+
+ Средний чек сеанса может меняться в зависимости от региона и сезонных предложений.
+
+
+
+
+
+ Получить консультацию
+
+
+
+
Сценарный калькулятор, а не финансовая гарантия. CAPEX, ФОТ, налоги, расходники, тарифы и фактическая загрузка
рассчитываются после аудита отеля.
diff --git a/hotel/src/components/EquipmentSection.tsx b/hotel/src/components/EquipmentSection.tsx
index 23ce2bc..1c16262 100644
--- a/hotel/src/components/EquipmentSection.tsx
+++ b/hotel/src/components/EquipmentSection.tsx
@@ -18,9 +18,9 @@ export function EquipmentSection() {
diff --git a/hotel/src/components/FormField.tsx b/hotel/src/components/FormField.tsx
index 5d696d8..c2de930 100644
--- a/hotel/src/components/FormField.tsx
+++ b/hotel/src/components/FormField.tsx
@@ -1,16 +1,44 @@
import type { ComponentPropsWithRef, ReactNode } from 'react'
import { cx } from '../lib/cx'
-/** Glass controls on the navy CTA panel. */
-const controlClass =
- 'w-full rounded-[14px] border border-white/[0.18] bg-[#041624]/[0.44] text-white outline-none ' +
- 'transition-[border-color,box-shadow,background-color] duration-200 placeholder:text-white/[0.34] ' +
- 'focus:border-teal focus:bg-[#041624]/[0.62] focus:shadow-[0_0_0_4px_rgb(0_196_180/0.13)]'
+/**
+ * `glass` — controls on the navy CTA panel; `light` — on the white callback
+ * dialog, the same fields as the fitness landing's.
+ */
+type Tone = 'glass' | 'light'
-function Field({ id, label, full, children }: { id: string; label: string; full?: boolean; children: ReactNode }) {
+const controlClass: Record = {
+ glass:
+ 'w-full rounded-[14px] border border-white/[0.18] bg-[#041624]/[0.44] px-[15px] text-white outline-none ' +
+ 'transition-[border-color,box-shadow,background-color] duration-200 placeholder:text-white/[0.34] ' +
+ 'focus:border-teal focus:bg-[#041624]/[0.62] focus:shadow-[0_0_0_4px_rgb(0_196_180/0.13)]',
+ light:
+ 'w-full rounded-[15px] border border-navy/15 bg-field px-[15px] py-[14px] text-ink outline-none ' +
+ 'transition-[border-color,box-shadow,background-color] duration-200 ' +
+ 'focus:border-teal focus:bg-white focus:shadow-[0_0_0_4px_rgb(0_196_180/0.10)]',
+}
+
+const labelClass: Record = {
+ glass: 'text-[12px] font-[680] text-white/[0.74]',
+ light: 'text-[11px] font-extrabold text-muted',
+}
+
+function Field({
+ id,
+ label,
+ full,
+ tone,
+ children,
+}: {
+ id: string
+ label: string
+ full?: boolean
+ tone: Tone
+ children: ReactNode
+}) {
return (