From 1ee5431b922c496599175df51829d9de1bd027dc Mon Sep 17 00:00:00 2001 From: Yuriy Panov Date: Fri, 11 Sep 2026 19:00:20 +0600 Subject: [PATCH] hotel: apply the v7 design and add the callback dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- hotel/README.md | 47 ++++--- hotel/server/src/lead-mapper.ts | 5 +- hotel/server/src/lead-service.ts | 2 +- hotel/shared/lead.ts | 2 +- hotel/src/App.tsx | 12 +- hotel/src/components/BenefitsSection.tsx | 4 +- hotel/src/components/Button.tsx | 2 +- hotel/src/components/CallbackModal.tsx | 155 ++++++++++++++++++++++ hotel/src/components/EconomicsSection.tsx | 37 +++++- hotel/src/components/EquipmentSection.tsx | 6 +- hotel/src/components/FormField.tsx | 83 +++++++++--- hotel/src/components/FormatsSection.tsx | 7 +- hotel/src/components/Hero.tsx | 17 +-- hotel/src/components/Layout.tsx | 21 +-- hotel/src/components/LeadForm.tsx | 2 - hotel/src/components/RequestSection.tsx | 15 ++- hotel/src/components/SiteHeader.tsx | 27 ++-- hotel/src/data/content.ts | 24 ++-- hotel/src/hooks/useBodyLock.ts | 10 ++ hotel/src/hooks/useEscapeKey.ts | 12 ++ hotel/src/index.css | 73 +++++++++- 21 files changed, 454 insertions(+), 109 deletions(-) create mode 100644 hotel/src/components/CallbackModal.tsx create mode 100644 hotel/src/hooks/useBodyLock.ts create mode 100644 hotel/src/hooks/useEscapeKey.ts 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 ( +
{ + if (event.target === event.currentTarget) close() + }} + className="fixed inset-0 z-100 flex items-center justify-center bg-[#020D16]/[0.92] p-[18px] backdrop-blur-[10px]" + > +
+ + +

+ Перезвоним вам +

+

+ Оставьте имя и телефон — наш специалист свяжется с вами и ответит на вопросы. Или позвоните сами:{' '} + + {contacts.phone} + + . +

+ +
+
+ setName(event.target.value)} + /> + setPhone(event.target.value)} + /> +
+ + + + + + +
+ {status.text} +
+ +
+
+ ) +} 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 (
-