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 (
-