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>
This commit is contained in:
Yuriy Panov
2026-09-11 19:00:20 +06:00
co-authored by Claude Opus 5
parent 7ce8bee320
commit 1ee5431b92
21 changed files with 454 additions and 109 deletions
+29 -18
View File
@@ -67,18 +67,26 @@ npm run amo:check
email дописывается в карточку. email дописывается в карточку.
2. Если контакта нет — создаётся новый с именем, телефоном и email. 2. Если контакта нет — создаётся новый с именем, телефоном и email.
3. Создаётся сделка в воронке `AMO_PIPELINE_ID`, в её **первом этапе**, 3. Создаётся сделка в воронке `AMO_PIPELINE_ID`, в её **первом этапе**,
с тегами `AMO_LEAD_TAGS` + тег `заявка с сайта`. с тегами `AMO_LEAD_TAGS` + тег формы (`заявка с сайта` / `обратный звонок`).
4. Данные, для которых в аккаунте есть подходящее поле (отель, комментарий, 4. Данные, для которых в аккаунте есть подходящее поле (отель, комментарий,
`utm_*` и т.д.), пишутся в поля; всё остальное — в примечание к сделке. `utm_*` и т.д.), пишутся в поля; всё остальное — в примечание к сделке.
Ничего настраивать в amoCRM заранее не нужно. Ничего настраивать в amoCRM заранее не нужно.
Форм две. Основная — блок «Следующий шаг» внизу страницы: обязательны только
имя и телефон, отель, email и комментарий по желанию; сделка называется
`ЭкзоОтель — <отель или имя>`. Вторая — модальное окно «Перезвоним вам», его
открывает кнопка «Звонок» в шапке (то же окно, что на фитнес-лендинге): имя,
телефон и согласие на обработку данных. Сделка называется
`Обратный звонок — <имя>`, в поле или примечании «Форма» — «Быстрый обратный
звонок».
Если amoCRM недоступна, заявка **не теряется**: она пишется в лог сервера Если amoCRM недоступна, заявка **не теряется**: она пишется в лог сервера
(`[lead] payload was: …`) и сохраняется в `localStorage` браузера, а посетитель (`[lead] payload was: …`) и сохраняется в `localStorage` браузера, а посетитель
видит телефон для связи. видит телефон для связи.
### 4. Что происходит при клике на телефон ### 4. Что происходит при клике на телефон
Кнопка звонка (в шапке) помимо набора номера отправляет Ссылка с номером в модальном окне «Перезвоним вам» помимо набора номера отправляет
`POST /api/leads/hotels/call` — маячком `navigator.sendBeacon`, чтобы `POST /api/leads/hotels/call` — маячком `navigator.sendBeacon`, чтобы
запрос пережил переход браузера на `tel:`. запрос пережил переход браузера на `tel:`.
@@ -112,14 +120,15 @@ index.html точка входа Vite (meta, Open Graph, JSON-LD)
src/ src/
components/ секции лендинга и UI-примитивы components/ секции лендинга и UI-примитивы
data/content.ts весь текстовый контент страницы data/content.ts весь текстовый контент страницы
hooks/ useReveal, useScrolled, useAnchorScroll hooks/ useReveal, useScrolled, useAnchorScroll, useBodyLock, useEscapeKey
lib/ отправка заявки, форматирование чисел lib/ отправка заявки, форматирование чисел
assets/images/ 11 изображений, извлечённых из исходного HTML assets/images/ 11 изображений, извлечённых из исходного HTML
index.css дизайн-токены Tailwind v4 (@theme) и базовые стили index.css дизайн-токены Tailwind v4 (@theme) и базовые стили
shared/lead.ts схема заявки (zod), общая для клиента и сервера shared/lead.ts схема заявки (zod), общая для клиента и сервера
server/src/ Express API + клиент amoCRM server/src/ Express API + клиент amoCRM
scripts/amo-check.ts диагностика подключения к amoCRM scripts/amo-check.ts диагностика подключения к amoCRM
legacy/index.html исходный однофайловый лендинг (визуальный эталон) legacy/index.html исходный однофайловый лендинг (первая версия дизайна)
legacy/hotel v7/ макет v7 — текущий визуальный эталон
public/ favicon.png и og-image.jpg public/ favicon.png и og-image.jpg
``` ```
@@ -129,30 +138,32 @@ public/ favicon.png и og-image.jpg
`@theme` внутри `src/index.css`. Палитра Tailwind по умолчанию отключена `@theme` внутри `src/index.css`. Палитра Tailwind по умолчанию отключена
(`--color-*: initial`), поэтому в разметке доступны только цвета EXO. (`--color-*: initial`), поэтому в разметке доступны только цвета EXO.
Брейкпоинты соответствуют исходной вёрстке: `sm` 640, `md` 900, плюс Брейкпоинты соответствуют исходной вёрстке: `sm` 640, `md` 900, плюс
`max-width`-варианты `tiny` (430), `phone` (480), `compact` (620), `max-width`-варианты `narrow` (780), `slim` (640), `compact` (620), `phone` (480),
`narrow` (780). `tiny` (430), `micro` (380). Tailwind выводит пользовательские варианты в порядке
объявления, поэтому они объявлены от широкого к узкому: где действуют два,
побеждает более узкий — как в каскаде макета.
Ряды карточек (выгоды, гости, оборудование, сценарии, программы) до 900px Ряды карточек (выгоды, гости, оборудование, сценарии, программы) до 900px
скроллятся по горизонтали со snap, выше — становятся сеткой. Это единственный скроллятся по горизонтали со snap, выше — становятся сеткой. Это единственный
крупный кусок вёрстки, оставшийся классом (`.scroll-cards` в `index.css`): крупный кусок вёрстки, оставшийся классом (`.scroll-cards` в `index.css`):
`grid-auto-columns` не выражается утилитами, а сам ряд используется пять раз. `grid-auto-columns` не выражается утилитами, а сам ряд используется пять раз.
### Соответствие исходной вёрстке ### Соответствие макету
Текст страницы совпадает с `legacy/index.html` строка в строку (проверено Визуальный эталон — макет `legacy/hotel v7/`. Сам макет Inter не подключает и
диффом `innerText`), высоты секций — в пределах 0,6% на 375 и 1440 px. рисуется системным шрифтом, поэтому сверять его с сайтом нужно, подставив в него
Расхождения дают метрики настоящего Inter: исходная страница просила Inter, но Inter Variable. На одном шрифте текст страницы совпадает с макетом строка в строку
нигде его не подключала и рисовалась системным фолбэком. (дифф `innerText`), а высоты всех секций и общая высота страницы — попиксельно на
360 / 375 / 430 / 480 / 620 / 640 / 780 / 900 / 1120 / 1440 px.
Три отличия сделаны намеренно: Намеренные отличия от макета:
* `.eyebrow` и `.lead` в блоке заявки исходно оставались тёмными * `.eyebrow` в блоке заявки в макете остаётся тёмным (`#008F84`) на тёмно-синем
(`#008F84` и `#60778B`) на тёмно-синем фоне — контраст 3,4–3,8:1. Здесь они фоне. Здесь он светлый, как в остальных тёмных секциях.
светлые, как в остальных тёмных секциях.
* На экранах ≤430px значение в карточке hero-факта («4 аппарата», «1 оператор»)
не помещалось в колонку 80px и наезжало на подпись. Колонка расширена до
124px.
* В подписи к рыночному блоку исправлена опечатка «2025–2026г..» → «20252026 гг.». * В подписи к рыночному блоку исправлена опечатка «2025–2026г..» → «20252026 гг.».
* Модальное окно «Перезвоним вам» взято с фитнес-лендинга (светлые поля,
согласие отдельным чекбоксом), а не вёрстка `.exo-callback` из макета; маски
телефона, как и на фитнесе, нет. Тексты окна — из макета.
### Шрифты ### Шрифты
+3 -2
View File
@@ -133,7 +133,7 @@ export function mapLead(payload: LeadPayload, fieldMap: LeadFieldMap): MappedLea
comment: payload.comment, comment: payload.comment,
page: payload.page, page: payload.page,
referrer: payload.referrer, referrer: payload.referrer,
form: 'Заявка на предложение', form: payload.form === 'callback' ? 'Быстрый обратный звонок' : 'Заявка на предложение',
...payload.utm, ...payload.utm,
} }
@@ -152,7 +152,8 @@ export function mapLead(payload: LeadPayload, fieldMap: LeadFieldMap): MappedLea
/** Lead title shown in the pipeline. */ /** Lead title shown in the pipeline. */
export function buildLeadName(payload: LeadPayload): string { export function buildLeadName(payload: LeadPayload): string {
return `ЭкзоОтель — ${payload.company ?? payload.name}` const who = payload.company ?? payload.name
return payload.form === 'callback' ? `Обратный звонок — ${who}` : `ЭкзоОтель — ${who}`
} }
/** /**
+1 -1
View File
@@ -115,7 +115,7 @@ export class LeadService {
name: buildLeadName(payload), name: buildLeadName(payload),
contactId, contactId,
fields, fields,
tags: ['заявка с сайта'], tags: [payload.form === 'callback' ? 'обратный звонок' : 'заявка с сайта'],
}) })
// 3. Everything the account has no field for lands in a readable note. // 3. Everything the account has no field for lands in a readable note.
+1 -1
View File
@@ -1,7 +1,7 @@
import { z } from 'zod' import { z } from 'zod'
/** Which form on the page produced the lead. Surfaces as an amoCRM tag. */ /** 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 type LeadFormId = (typeof leadFormIds)[number]
export const utmKeys = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term'] as const export const utmKeys = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term'] as const
+9 -3
View File
@@ -1,5 +1,6 @@
import { useRef } from 'react' import { useCallback, useRef, useState } from 'react'
import { BenefitsSection } from './components/BenefitsSection' import { BenefitsSection } from './components/BenefitsSection'
import { CallbackModal } from './components/CallbackModal'
import { EconomicsSection } from './components/EconomicsSection' import { EconomicsSection } from './components/EconomicsSection'
import { EquipmentSection } from './components/EquipmentSection' import { EquipmentSection } from './components/EquipmentSection'
import { FormatsSection } from './components/FormatsSection' import { FormatsSection } from './components/FormatsSection'
@@ -17,22 +18,27 @@ export default function App() {
const scrolled = useScrolled() const scrolled = useScrolled()
useAnchorScroll(headerRef) useAnchorScroll(headerRef)
const [callbackOpen, setCallbackOpen] = useState(false)
const openCallback = useCallback(() => setCallbackOpen(true), [])
const closeCallback = useCallback(() => setCallbackOpen(false), [])
return ( return (
<> <>
<SiteHeader scrolled={scrolled} headerRef={headerRef} /> <SiteHeader scrolled={scrolled} headerRef={headerRef} onCallbackClick={openCallback} />
<main> <main>
<Hero /> <Hero />
<BenefitsSection /> <BenefitsSection />
<EconomicsSection /> <EconomicsSection />
<GuestsSection /> <GuestsSection />
<EquipmentSection />
<FormatsSection /> <FormatsSection />
<EquipmentSection />
<WhyExoSection /> <WhyExoSection />
<RequestSection /> <RequestSection />
</main> </main>
<SiteFooter /> <SiteFooter />
<CallbackModal open={callbackOpen} onClose={closeCallback} />
</> </>
) )
} }
+3 -1
View File
@@ -186,8 +186,10 @@ function MetricCard({
foot: [string, string] foot: [string, string]
children: ReactNode 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 ( return (
<article className="min-h-[190px] overflow-hidden rounded-[19px] border border-white/[0.12] bg-white/[0.07] p-[18px] [&>svg]:h-[88px] [&>svg]:w-full [&>svg]:overflow-visible"> <article className="min-h-[190px] overflow-hidden rounded-[19px] border border-white/[0.12] bg-white/[0.07] p-[18px] [&>svg]:inline [&>svg]:h-[88px] [&>svg]:w-full [&>svg]:overflow-visible [&>svg]:align-baseline">
<div className="flex min-h-[44px] items-start justify-between gap-[8px] text-[12px] font-[650] text-white/[0.72]"> <div className="flex min-h-[44px] items-start justify-between gap-[8px] text-[12px] font-[650] text-white/[0.72]">
<span>{label}</span> <span>{label}</span>
<strong className="text-[16px] font-[820] whitespace-nowrap text-teal-bright">{value}</strong> <strong className="text-[16px] font-[820] whitespace-nowrap text-teal-bright">{value}</strong>
+1 -1
View File
@@ -18,7 +18,7 @@ const base =
const defaultSize = 'min-h-[52px] px-[22px]' const defaultSize = 'min-h-[52px] px-[22px]'
/** `.btn--header` — smaller, and never stretched to the full width on phones. */ /** `.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<ButtonVariant, string> = { const variants: Record<ButtonVariant, string> = {
primary: primary:
+155
View File
@@ -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<HTMLFormElement>(null)
const firstFieldRef = useRef<HTMLInputElement>(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<Status>({ 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<HTMLFormElement>) {
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 (
<div
role="dialog"
aria-modal="true"
aria-labelledby="callbackTitle"
onClick={(event) => {
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]"
>
<div className="relative w-full max-w-[420px] rounded-[28px] bg-white p-[clamp(24px,4vw,34px)] text-ink shadow-deep">
<button
type="button"
aria-label="Закрыть"
onClick={close}
className="absolute top-[14px] right-[14px] size-[38px] cursor-pointer rounded-full border border-navy/15 text-[22px] text-muted hover:border-teal hover:text-navy"
>
×
</button>
<h3 id="callbackTitle" className="mb-[8px] text-[26px]">
Перезвоним вам
</h3>
<p className="mb-[20px] text-[13px] text-muted">
Оставьте имя и телефон наш специалист свяжется с вами и ответит на вопросы. Или позвоните сами:{' '}
<a
className="font-extrabold text-navy underline underline-offset-[3px]"
href={contacts.phoneHref}
onClick={reportCallClick}
>
{contacts.phone}
</a>
.
</p>
<form ref={formRef} noValidate onSubmit={onSubmit}>
<div className="grid gap-[12px]">
<TextField
ref={firstFieldRef}
tone="light"
id="callbackName"
label="Имя *"
name="name"
autoComplete="name"
placeholder="Ваше имя"
required
value={name}
onChange={(event) => setName(event.target.value)}
/>
<TextField
tone="light"
id="callbackPhone"
label="Телефон *"
name="phone"
type="tel"
autoComplete="tel"
inputMode="tel"
placeholder="+7 999 000-00-00"
required
value={phone}
onChange={(event) => setPhone(event.target.value)}
/>
</div>
<Honeypot id="callbackWebsite" value={honeypot} onChange={setHoneypot} />
<ConsentCheckbox checked={consent} onChange={setConsent} />
<Button type="submit" className="w-full" disabled={pending}>
{pending ? 'Отправляем…' : 'Жду звонка'}
{pending ? null : <ArrowRightIcon />}
</Button>
<div
aria-live="polite"
className={cx(
'mt-[11px] min-h-[20px] text-[12px]',
status.tone === 'success' && 'text-[#087F73]',
status.tone === 'error' && 'text-[#A14D12]',
status.tone === 'idle' && 'text-muted',
)}
>
{status.text}
</div>
</form>
</div>
</div>
)
}
+31 -6
View File
@@ -3,6 +3,8 @@ import { econGain, econLoss, econMetrics, econOps } from '../data/content'
import { useReveal } from '../hooks/useReveal' import { useReveal } from '../hooks/useReveal'
import { formatMoney, formatNumber, parseAmount } from '../lib/format' import { formatMoney, formatNumber, parseAmount } from '../lib/format'
import { cx } from '../lib/cx' import { cx } from '../lib/cx'
import { ButtonLink } from './Button'
import { ArrowRightIcon } from './Icons'
import { Container, Section } from './Layout' import { Container, Section } from './Layout'
const panelItem = 'grid grid-cols-[34px_1fr] items-start gap-[11px] rounded-[16px] p-[13px]' const panelItem = 'grid grid-cols-[34px_1fr] items-start gap-[11px] rounded-[16px] p-[13px]'
@@ -27,7 +29,7 @@ export function EconomicsSection() {
<span className="flex items-center gap-[10px] text-[11px] font-[850] tracking-[0.095em] text-teal-ink uppercase before:h-[2px] before:w-[28px] before:rounded-[3px] before:bg-teal before:content-['']"> <span className="flex items-center gap-[10px] text-[11px] font-[850] tracking-[0.095em] text-teal-ink uppercase before:h-[2px] before:w-[28px] before:rounded-[3px] before:bg-teal before:content-['']">
Экономика текущего гостевого потока Экономика текущего гостевого потока
</span> </span>
<h2 className="mb-0 text-[clamp(31px,5vw,58px)] leading-[1.02] tracking-[-0.048em] text-navy phone:text-[34px]"> <h2 className="mb-0 text-[clamp(31px,5vw,58px)] leading-[1.02] tracking-[-0.048em] text-navy compact:text-[32px] compact:leading-[1.04] compact:tracking-[-0.038em]">
Номер уже продан. Следующая выручка внутри маршрута гостя Номер уже продан. Следующая выручка внутри маршрута гостя
</h2> </h2>
<p className="mb-0 max-w-[860px] text-[clamp(15px,1.55vw,19px)] leading-[1.55] text-muted"> <p className="mb-0 max-w-[860px] text-[clamp(15px,1.55vw,19px)] leading-[1.55] text-muted">
@@ -171,7 +173,7 @@ function RevenueCalculator() {
Посчитайте дополнительную выручку на своих тарифах Посчитайте дополнительную выручку на своих тарифах
</h3> </h3>
<p className="mb-0 text-[11px] leading-[1.45] text-white/[0.58]"> <p className="mb-0 text-[11px] leading-[1.45] text-white/[0.58]">
Введите фактическое число оплаченных программ, средний чек и рабочие дни. Введите фактическое число оплаченных сеансов, средний чек сеанса и рабочие дни.
</p> </p>
</div> </div>
@@ -179,7 +181,7 @@ function RevenueCalculator() {
<div className="grid grid-cols-2 gap-[9px] sm:grid-cols-3 phone:grid-cols-1"> <div className="grid grid-cols-2 gap-[9px] sm:grid-cols-3 phone:grid-cols-1">
<CalcField <CalcField
id="hotelPrograms" id="hotelPrograms"
label="Программ в день" label="Сеансов в день"
value={programs} value={programs}
onChange={setPrograms} onChange={setPrograms}
placeholder="например, 8" placeholder="например, 8"
@@ -188,10 +190,10 @@ function RevenueCalculator() {
/> />
<CalcField <CalcField
id="hotelCheck" id="hotelCheck"
label="Средний чек, ₽" label="Средний чек сеанса, ₽"
value={check} value={check}
onChange={setCheck} onChange={setCheck}
placeholder="ваш тариф" placeholder="например, 2500"
min={0} min={0}
step={100} step={100}
/> />
@@ -210,7 +212,7 @@ function RevenueCalculator() {
<CalcResult <CalcResult
label="Дополнительная выручка / месяц" label="Дополнительная выручка / месяц"
value={ready ? formatMoney(month) : '—'} value={ready ? formatMoney(month) : '—'}
note={ready ? `${formatNumber(volume)} программ в месяц` : 'Введите программы и чек'} note={ready ? `${formatNumber(volume)} сеансов в месяц` : 'Введите сеансы и чек'}
/> />
<CalcResult <CalcResult
label="Дополнительная выручка / год" label="Дополнительная выручка / год"
@@ -220,6 +222,29 @@ function RevenueCalculator() {
</div> </div>
</div> </div>
<div
aria-label="Ориентир среднего чека"
className="mt-[18px] rounded-[18px] border border-teal/[0.28] bg-[linear-gradient(135deg,rgb(0_196_180/0.08),rgb(255_255_255/0.04))] px-[20px] py-[18px] shadow-[inset_0_1px_0_rgb(255_255_255/0.08)] slim:p-[16px]"
>
<span className="mb-[7px] block text-[11px] font-extrabold tracking-[0.11em] text-teal uppercase">
Ориентир для расчёта
</span>
<p className="mb-0 text-[14px] leading-[1.55]">
Средний чек сеанса составляет <strong className="text-[1.08em] whitespace-nowrap text-teal">2 500 </strong>,
по данным нашей клиники «ЭкзоКлиник» в городе Тольятти за период 2025г.2026г.
</p>
<b className="mt-[7px] block text-[12px] leading-[1.45] opacity-[0.72]">
Средний чек сеанса может меняться в зависимости от региона и сезонных предложений.
</b>
</div>
<div className="mt-[18px] flex">
<ButtonLink href="#proposal" className="min-w-[230px] slim:w-full slim:min-w-0">
Получить консультацию
<ArrowRightIcon />
</ButtonLink>
</div>
<p className="mt-[13px] mb-0 text-[9px] leading-[1.45] text-white/[0.43]"> <p className="mt-[13px] mb-0 text-[9px] leading-[1.45] text-white/[0.43]">
Сценарный калькулятор, а не финансовая гарантия. CAPEX, ФОТ, налоги, расходники, тарифы и фактическая загрузка Сценарный калькулятор, а не финансовая гарантия. CAPEX, ФОТ, налоги, расходники, тарифы и фактическая загрузка
рассчитываются после аудита отеля. рассчитываются после аудита отеля.
+3 -3
View File
@@ -18,9 +18,9 @@ export function EquipmentSection() {
<SectionHead <SectionHead
split split
tone="dark" tone="dark"
eyebrow="Что можно купить" eyebrow="Модель работы"
title="Комплект оборудования для отеля" title="Как работает сервис восстановления для гостя"
lead="Линейка из четырех аппаратов адаптируется под любой запрос клиента: от быстрых премиум-процедур в зоне отдыха до глубокой индивидуальной работы с оператором." lead="Ниже показана логика работы каждой технологии: для какого сценария гостя она подходит, сколько длится сеанс и требуется ли участие специалиста. Так отель заранее понимает, как встроить сервис в SPA-меню, расписание и загрузку команды."
/> />
<div className="scroll-cards relative z-1 gap-[16px] md:grid-cols-4"> <div className="scroll-cards relative z-1 gap-[16px] md:grid-cols-4">
+68 -15
View File
@@ -1,16 +1,44 @@
import type { ComponentPropsWithRef, ReactNode } from 'react' import type { ComponentPropsWithRef, ReactNode } from 'react'
import { cx } from '../lib/cx' import { cx } from '../lib/cx'
/** Glass controls on the navy CTA panel. */ /**
const controlClass = * `glass` — controls on the navy CTA panel; `light` — on the white callback
'w-full rounded-[14px] border border-white/[0.18] bg-[#041624]/[0.44] text-white outline-none ' + * dialog, the same fields as the fitness landing's.
'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)]' type Tone = 'glass' | 'light'
function Field({ id, label, full, children }: { id: string; label: string; full?: boolean; children: ReactNode }) { const controlClass: Record<Tone, string> = {
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<Tone, string> = {
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 ( return (
<div className={cx('grid gap-[7px]', full && 'sm:col-span-full')}> <div className={cx('grid gap-[7px]', full && 'sm:col-span-full')}>
<label htmlFor={id} className="text-[12px] font-[680] text-white/[0.74]"> <label htmlFor={id} className={labelClass[tone]}>
{label} {label}
</label> </label>
{children} {children}
@@ -22,11 +50,12 @@ export function TextField({
id, id,
label, label,
full, full,
tone = 'glass',
...rest ...rest
}: { id: string; label: string; full?: boolean } & ComponentPropsWithRef<'input'>) { }: { id: string; label: string; full?: boolean; tone?: Tone } & ComponentPropsWithRef<'input'>) {
return ( return (
<Field id={id} label={label} full={full}> <Field id={id} label={label} full={full} tone={tone}>
<input id={id} className={cx(controlClass, 'h-[52px] px-[15px]')} {...rest} /> <input id={id} className={cx(controlClass[tone], tone === 'glass' ? 'h-[52px]' : 'h-[51px]')} {...rest} />
</Field> </Field>
) )
} }
@@ -38,20 +67,29 @@ export function TextareaField({
...rest ...rest
}: { id: string; label: string; full?: boolean } & ComponentPropsWithRef<'textarea'>) { }: { id: string; label: string; full?: boolean } & ComponentPropsWithRef<'textarea'>) {
return ( return (
<Field id={id} label={label} full={full}> <Field id={id} label={label} full={full} tone="glass">
<textarea id={id} className={cx(controlClass, 'min-h-[105px] resize-y px-[15px] py-[13px]')} {...rest} /> <textarea id={id} className={cx(controlClass.glass, 'min-h-[105px] resize-y py-[13px]')} {...rest} />
</Field> </Field>
) )
} }
/** Bot trap. Invisible to people, but reachable by naive form-filling scripts. */ /** Bot trap. Invisible to people, but reachable by naive form-filling scripts. */
export function Honeypot({ value, onChange }: { value: string; onChange: (value: string) => void }) { export function Honeypot({
id = 'website',
value,
onChange,
}: {
/** Must differ per form: the request form and the callback dialog can be on screen together. */
id?: string
value: string
onChange: (value: string) => void
}) {
return ( return (
<div aria-hidden="true" className="honeypot"> <div aria-hidden="true" className="honeypot">
<label htmlFor="website">Не заполнять</label> <label htmlFor={id}>Не заполнять</label>
<input <input
autoComplete="off" autoComplete="off"
id="website" id={id}
name="website" name="website"
tabIndex={-1} tabIndex={-1}
type="text" type="text"
@@ -61,3 +99,18 @@ export function Honeypot({ value, onChange }: { value: string; onChange: (value:
</div> </div>
) )
} }
export function ConsentCheckbox({ checked, onChange }: { checked: boolean; onChange: (checked: boolean) => void }) {
return (
<label className="my-[13px] grid grid-cols-[18px_1fr] gap-[9px] text-[10px] text-muted">
<input
type="checkbox"
required
checked={checked}
onChange={(event) => onChange(event.target.checked)}
className="mt-[2px] mb-[3px] accent-teal"
/>
<span>Согласен на обработку персональных данных и получение обратного звонка.</span>
</label>
)
}
+4 -3
View File
@@ -11,9 +11,10 @@ export function FormatsSection() {
<Container> <Container>
<SectionHead <SectionHead
split split
eyebrow="Сценарии размещения" eyebrow="Решение для вашего бизнеса"
title="Встраивается в маршрут гостя" title="Отель — не больница. Пространство восстановления должно продолжать интерьер"
lead="Формат подстраивается под инфраструктуру отеля: отдельный SPA-кабинет, заметная капсула рядом с фитнесом, зона отдыха у бассейна или персональный сервис в номере." titleClassName="formats-title"
lead="Оборудование Экзо Групп интегрируется в SPA, wellness- или фитнес-зону и оформляется в стилистике вашего отеля. На фотографиях ниже — варианты интерьера и размещения, где аппараты становятся частью премиального сервиса, а не выглядят как медицинский кабинет."
/> />
<div className="scroll-cards gap-[16px] md:grid-cols-4"> <div className="scroll-cards gap-[16px] md:grid-cols-4">
+7 -10
View File
@@ -52,7 +52,7 @@ export function Hero() {
<p <p
ref={subtitle.ref} ref={subtitle.ref}
className={cx( className={cx(
'mb-[30px] max-w-[730px] text-[clamp(19px,2.2vw,27px)] leading-[1.42] text-white/[0.82] delay-[160ms]', 'mb-[30px] max-w-[730px] text-[clamp(19px,2.2vw,27px)] leading-[1.42] text-white/[0.82] delay-[160ms] compact:text-[18px] compact:leading-[1.46]',
subtitle.revealClass, subtitle.revealClass,
)} )}
> >
@@ -64,29 +64,26 @@ export function Hero() {
Получить предложение Получить предложение
<ArrowRightIcon /> <ArrowRightIcon />
</ButtonLink> </ButtonLink>
<ButtonLink href="#equipment" variant="secondary">
Подобрать оборудование
</ButtonLink>
</div> </div>
<div <div
ref={facts.ref} ref={facts.ref}
aria-label="Ключевые параметры формата" aria-label="Ключевая экономика сценария"
className={cx( className={cx(
'grid max-w-[700px] grid-cols-3 gap-[10px] delay-[240ms] sm:gap-[12px] tiny:max-w-[330px] tiny:grid-cols-1', 'grid max-w-[700px] grid-cols-3 gap-[10px] delay-[240ms] sm:gap-[12px] tiny:max-w-[330px] tiny:grid-cols-1',
facts.revealClass, facts.revealClass,
)} )}
> >
{heroFacts.map((fact) => ( {heroFacts.map((fact) => (
// Under 430px the fact turns into a value/label row. The original used an // Under 430px the fact turns into a value/label row. The value column
// 80px column, which the widest value overflowed into the label; 124px // grows with its content, so «600 000 ₽ / мес.» stays on one line.
// keeps every value on one line.
<div <div
key={fact.value} key={fact.value}
className="min-h-[82px] rounded-[18px] border border-white/[0.16] bg-[#051D2F]/[0.42] px-[16px] py-[15px] backdrop-blur-[16px] className="min-h-[82px] rounded-[18px] border border-white/[0.16] bg-[#051D2F]/[0.42] px-[16px] py-[15px] backdrop-blur-[16px]
tiny:grid tiny:min-h-0 tiny:grid-cols-[124px_1fr] tiny:items-center tiny:gap-[10px]" tiny:grid tiny:min-h-0 tiny:grid-cols-[minmax(118px,auto)_1fr] tiny:items-center tiny:gap-[10px]
micro:grid-cols-[minmax(105px,auto)_1fr]"
> >
<strong className="mb-[3px] block text-[clamp(20px,3vw,29px)] leading-[1.1] tracking-[-0.03em] tiny:mb-0 tiny:text-[21px]"> <strong className="mb-[3px] block text-[clamp(19px,2.55vw,27px)] leading-[1.1] tracking-[-0.03em] whitespace-nowrap tiny:mb-0 tiny:text-[18px] micro:text-[17px]">
{fact.value} {fact.value}
</strong> </strong>
<span className="text-[12px] text-white/[0.62]">{fact.label}</span> <span className="text-[12px] text-white/[0.62]">{fact.label}</span>
+12 -9
View File
@@ -35,15 +35,16 @@ export function Eyebrow({ tone = 'light', className, children }: { tone?: Tone;
type Tone = 'light' | 'dark' type Tone = 'light' | 'dark'
/**
* `.lead` type without width or colour, for the one lead that needs its own of
* both. Passing those to `SectionLead` instead would not work: Tailwind orders
* conflicting utilities by the stylesheet, not by `className`.
*/
export const leadText = 'mb-0 text-[clamp(18px,2vw,22px)] leading-[1.55] compact:text-[17px] compact:leading-[1.52]'
export function SectionLead({ tone = 'light', className, children }: { tone?: Tone; className?: string; children: ReactNode }) { export function SectionLead({ tone = 'light', className, children }: { tone?: Tone; className?: string; children: ReactNode }) {
return ( return (
<p <p className={cx(leadText, 'max-w-[760px]', tone === 'dark' ? 'text-white/[0.72]' : 'text-muted', className)}>
className={cx(
'mb-0 max-w-[760px] text-[clamp(18px,2vw,22px)] leading-[1.55]',
tone === 'dark' ? 'text-white/[0.72]' : 'text-muted',
className,
)}
>
{children} {children}
</p> </p>
) )
@@ -56,12 +57,14 @@ export function SectionLead({ tone = 'light', className, children }: { tone?: To
export function SectionHead({ export function SectionHead({
eyebrow, eyebrow,
title, title,
titleClassName,
lead, lead,
tone = 'light', tone = 'light',
split = false, split = false,
}: { }: {
eyebrow: string eyebrow: string
title: ReactNode title: ReactNode
titleClassName?: string
lead?: ReactNode lead?: ReactNode
tone?: Tone tone?: Tone
split?: boolean split?: boolean
@@ -72,7 +75,7 @@ export function SectionHead({
return ( return (
<div ref={ref} className={cx('mb-[clamp(34px,5vw,54px)]', revealClass)}> <div ref={ref} className={cx('mb-[clamp(34px,5vw,54px)]', revealClass)}>
<Eyebrow tone={tone}>{eyebrow}</Eyebrow> <Eyebrow tone={tone}>{eyebrow}</Eyebrow>
<h2>{title}</h2> <h2 className={titleClassName}>{title}</h2>
{lead ? <SectionLead tone={tone}>{lead}</SectionLead> : null} {lead ? <SectionLead tone={tone}>{lead}</SectionLead> : null}
</div> </div>
) )
@@ -88,7 +91,7 @@ export function SectionHead({
> >
<div> <div>
<Eyebrow tone={tone}>{eyebrow}</Eyebrow> <Eyebrow tone={tone}>{eyebrow}</Eyebrow>
<h2 className="mb-0">{title}</h2> <h2 className={titleClassName}>{title}</h2>
</div> </div>
{lead ? ( {lead ? (
<SectionLead tone={tone} className="md:justify-self-end"> <SectionLead tone={tone} className="md:justify-self-end">
-2
View File
@@ -87,7 +87,6 @@ export function LeadForm() {
autoComplete="organization" autoComplete="organization"
minLength={2} minLength={2}
placeholder="Название отеля" placeholder="Название отеля"
required
value={values.company} value={values.company}
onChange={set('company')} onChange={set('company')}
/> />
@@ -110,7 +109,6 @@ export function LeadForm() {
type="email" type="email"
autoComplete="email" autoComplete="email"
placeholder="name@hotel.ru" placeholder="name@hotel.ru"
required
value={values.email} value={values.email}
onChange={set('email')} onChange={set('email')}
/> />
+9 -6
View File
@@ -1,6 +1,6 @@
import { useReveal } from '../hooks/useReveal' import { useReveal } from '../hooks/useReveal'
import { cx } from '../lib/cx' import { cx } from '../lib/cx'
import { Container, Eyebrow, Section, SectionLead } from './Layout' import { Container, Eyebrow, Section, leadText } from './Layout'
import { LeadForm } from './LeadForm' import { LeadForm } from './LeadForm'
export function RequestSection() { export function RequestSection() {
@@ -13,11 +13,14 @@ export function RequestSection() {
<Container className="relative grid items-start gap-[34px] md:grid-cols-[0.95fr_1.05fr] md:gap-[clamp(44px,6vw,88px)]"> <Container className="relative grid items-start gap-[34px] md:grid-cols-[0.95fr_1.05fr] md:gap-[clamp(44px,6vw,88px)]">
<div ref={copy.ref} className={cx('pt-[4px]', copy.revealClass)}> <div ref={copy.ref} className={cx('pt-[4px]', copy.revealClass)}>
<Eyebrow tone="dark">Следующий шаг</Eyebrow> <Eyebrow tone="dark">Следующий шаг</Eyebrow>
<h2 className="max-w-[660px]">Подберём оборудование под формат вашего отеля</h2> <h2 className="max-w-[660px] compact:text-[34px] compact:leading-[1.04] compact:tracking-[-0.035em]">
<SectionLead tone="dark" className="max-w-[620px]"> Подберём решение под формат вашего отеля
Оставьте контакты специалист свяжется, ответит на вопросы и подготовит коммерческое предложение с расчётом </h2>
окупаемости и меню программ. <p className={cx(leadText, 'max-w-[620px] text-white/[0.76]')}>
</SectionLead> Оставьте контакты наш специалист свяжется с Вами, ответит на вопросы и подготовит коммерческое
предложение, исходя из{' '}
<strong>доступной площади, специфики инфраструктуры и ваших бизнес-задач.</strong>
</p>
</div> </div>
<LeadForm /> <LeadForm />
+17 -10
View File
@@ -1,13 +1,20 @@
import type { RefObject } from 'react' import type { RefObject } from 'react'
import logo from '../assets/images/exo-logo.png' import logo from '../assets/images/exo-logo.png'
import { contacts, navLinks } from '../data/content' import { navLinks } from '../data/content'
import { cx } from '../lib/cx' import { cx } from '../lib/cx'
import { reportCallClick } from '../lib/lead'
import { ButtonLink, buttonClass, headerSize } from './Button' import { ButtonLink, buttonClass, headerSize } from './Button'
import { PhoneIcon } from './Icons' import { PhoneIcon } from './Icons'
import { Container } from './Layout' import { Container } from './Layout'
export function SiteHeader({ scrolled, headerRef }: { scrolled: boolean; headerRef: RefObject<HTMLElement | null> }) { export function SiteHeader({
scrolled,
headerRef,
onCallbackClick,
}: {
scrolled: boolean
headerRef: RefObject<HTMLElement | null>
onCallbackClick: () => void
}) {
return ( return (
<header <header
ref={headerRef} ref={headerRef}
@@ -20,7 +27,7 @@ export function SiteHeader({ scrolled, headerRef }: { scrolled: boolean; headerR
<a className="inline-flex min-w-0 items-center" href="#top" aria-label="Экзо Групп — на первый экран"> <a className="inline-flex min-w-0 items-center" href="#top" aria-label="Экзо Групп — на первый экран">
<img <img
alt="Экзо Групп — российские технологии реабилитации" alt="Экзо Групп — российские технологии реабилитации"
className="block h-auto w-[clamp(172px,20vw,232px)] max-w-full object-contain object-left compact:w-[150px]" className="block h-auto w-[clamp(172px,20vw,232px)] max-w-full object-contain object-left compact:w-[150px] micro:w-[132px]"
src={logo} src={logo}
width={900} width={900}
height={204} height={204}
@@ -38,16 +45,16 @@ export function SiteHeader({ scrolled, headerRef }: { scrolled: boolean; headerR
))} ))}
</nav> </nav>
<div className="flex shrink-0 items-center gap-[10px] compact:gap-[6px]"> <div className="flex shrink-0 items-center gap-[10px] compact:gap-[6px] micro:gap-[5px]">
<a <button
href={contacts.phoneHref} type="button"
onClick={reportCallClick} onClick={onCallbackClick}
aria-label="Позвонить в Экзо Групп" aria-label="Заказать обратный звонок"
className={buttonClass('call', 'compact:px-[12px]', headerSize)} className={buttonClass('call', 'compact:px-[12px]', headerSize)}
> >
<PhoneIcon /> <PhoneIcon />
<span className="compact:hidden">Звонок</span> <span className="compact:hidden">Звонок</span>
</a> </button>
<ButtonLink href="#proposal" size={headerSize}> <ButtonLink href="#proposal" size={headerSize}>
Получить предложение Получить предложение
</ButtonLink> </ButtonLink>
+12 -12
View File
@@ -13,16 +13,16 @@ export const contacts = {
} }
export const navLinks = [ export const navLinks = [
{ href: '#benefits', label: 'Выгода' }, { href: '#hotel-economics', label: 'Экономика' },
{ href: '#guests', label: 'Гости' }, { href: '#guests', label: 'Для гостя' },
{ href: '#equipment', label: 'Оборудование' }, { href: '#formats', label: 'Интерьер' },
{ href: '#formats', label: 'Форматы' }, { href: '#equipment', label: 'Сервис' },
] ]
export const heroFacts = [ export const heroFacts = [
{ value: '4 аппарата', label: 'единое пространство восстановления' }, { value: '2 500 ₽', label: 'средний чек сеанса по данным «ЭкзоКлиник»' },
{ value: '1 оператор', label: 'при дисциплинированном расписании' }, { value: '600 000 ₽ / мес.', label: 'сценарий: 8 сеансов в день × 30 дней' },
{ value: '12 часов', label: 'базовый режим работы кабинета' }, { value: '7,2 млн ₽ / год', label: 'тот же сценарий, до вычета расходов' },
] ]
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
@@ -166,9 +166,9 @@ export const devices = [
{ {
name: 'ЭкзоТерапия', name: 'ЭкзоТерапия',
role: 'Флагман SPA', role: 'Флагман SPA',
duration: '10 минут', duration: '1015 минут',
tech: 'Высокоинтенсивная импульсная магнитотерапия.', tech: 'Высокоинтенсивная импульсная магнитотерапия.',
text: 'Короткий 10-минутный ритуал для гостя после дороги, лыж или долгой прогулки. Подходит как флагманская процедура SPA-зоны.', text: 'Короткий сеанс 1015 минут для гостя после дороги, лыж или долгой прогулки. Подходит как флагманская процедура SPA-зоны.',
image: deviceTherapy, image: deviceTherapy,
alt: 'ЭкзоТерапия в премиальной SPA-капсуле', alt: 'ЭкзоТерапия в премиальной SPA-капсуле',
}, },
@@ -193,16 +193,16 @@ export const devices = [
{ {
name: 'ЭкзоВодород', name: 'ЭкзоВодород',
role: 'Автономная пауза', role: 'Автономная пауза',
duration: '40 минут', duration: '3060 минут',
tech: 'Ингаляция молекулярным водородом', tech: 'Ингаляция молекулярным водородом',
text: 'Комфортная 40-минутная пауза в кресле с минимальным участием оператора. Сочетается с SPA, бассейном и ожиданием процедур.', text: 'Комфортная пауза 3060 минут в кресле с минимальным участием оператора. Сочетается с SPA, бассейном и ожиданием процедур.',
image: deviceHydrogen, image: deviceHydrogen,
alt: 'ЭкзоВодород в спокойной wellness-зоне', alt: 'ЭкзоВодород в спокойной wellness-зоне',
}, },
] ]
export const hotelModel = [ export const hotelModel = [
{ value: '1245 м²', text: 'Отдельная recovery-комната, SPA-кабинет или зона рядом с фитнесом и бассейном.' }, { value: '1236 м²', text: 'Отдельная recovery-комната, SPA-кабинет или зона рядом с фитнесом и бассейном.' },
{ {
value: '1 оператор', value: '1 оператор',
text: 'Основное активное участие требуется на ЭкзоТекар; остальные процедуры после запуска работают практически автономно.', text: 'Основное активное участие требуется на ЭкзоТекар; остальные процедуры после запуска работают практически автономно.',
+10
View File
@@ -0,0 +1,10 @@
import { useEffect } from 'react'
/** Freezes background scrolling while a modal is open. */
export function useBodyLock(locked: boolean) {
useEffect(() => {
if (!locked) return
document.body.classList.add('is-locked')
return () => document.body.classList.remove('is-locked')
}, [locked])
}
+12
View File
@@ -0,0 +1,12 @@
import { useEffect } from 'react'
export function useEscapeKey(active: boolean, onEscape: () => void) {
useEffect(() => {
if (!active) return
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') onEscape()
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [active, onEscape])
}
+67 -6
View File
@@ -26,6 +26,8 @@
--color-ink: #0a2540; --color-ink: #0a2540;
--color-muted: #60778b; --color-muted: #60778b;
--color-footer: #041421; --color-footer: #041421;
/* Light inputs of the callback dialog (shared with the fitness landing) */
--color-field: #f7fafb;
/* Economics section palette (shared with the fitness landing) */ /* Economics section palette (shared with the fitness landing) */
--color-econ-bg: #f4fafb; --color-econ-bg: #f4fafb;
@@ -58,13 +60,18 @@
--font-sans: "Inter Variable", Inter, Manrope, "Segoe UI", Arial, sans-serif; --font-sans: "Inter Variable", Inter, Manrope, "Segoe UI", Arial, sans-serif;
} }
/* The original had four `max-width` media queries that do not line up with the /* The design's `max-width` media queries do not line up with the min-width
min-width scale above; they are exposed as named variants instead of scale above; they are exposed as named variants instead of arbitrary values
arbitrary values so the intent stays readable in the markup. */ so the intent stays readable in the markup. Tailwind emits custom variants in
@custom-variant tiny (@media (max-width: 430px)); declaration order, so they go widest first: where two apply, the narrower
@custom-variant phone (@media (max-width: 480px)); one wins, as in the design's own cascade. */
@custom-variant compact (@media (max-width: 620px));
@custom-variant narrow (@media (max-width: 780px)); @custom-variant narrow (@media (max-width: 780px));
/* Overlaps `sm` at exactly 640px, as the design's `max-width: 640px` does. */
@custom-variant slim (@media (max-width: 640px));
@custom-variant compact (@media (max-width: 620px));
@custom-variant phone (@media (max-width: 480px));
@custom-variant tiny (@media (max-width: 430px));
@custom-variant micro (@media (max-width: 380px));
@layer base { @layer base {
html { html {
@@ -83,6 +90,16 @@
overflow-x: hidden; overflow-x: hidden;
} }
body.is-locked {
overflow: hidden;
}
/* Direct `#hash` loads land below the fixed header; in-page clicks are
offset by useAnchorScroll instead. */
section[id] {
scroll-margin-top: 96px;
}
::selection { ::selection {
background: rgb(0 196 180 / 0.28); background: rgb(0 196 180 / 0.28);
color: var(--color-navy); color: var(--color-navy);
@@ -125,6 +142,26 @@
font-weight: bold; font-weight: bold;
} }
/* v7 phone type scale. Headings that carry their own size utilities (the
economics and request blocks) restate it with `compact:` variants. */
@media (max-width: 620px) {
section[id] {
scroll-margin-top: 82px;
}
h1 {
font-size: clamp(36px, 10vw, 40px);
line-height: 1.01;
letter-spacing: -0.045em;
}
h2 {
font-size: clamp(29px, 8.3vw, 33px);
line-height: 1.06;
letter-spacing: -0.035em;
}
}
button, button,
a { a {
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
@@ -202,6 +239,30 @@
} }
} }
/* The «Интерьер» heading is longer than the rest and gets its own, smaller
scale, stepping down at 760px and then joining the phone scale at 620px. */
.formats-title {
max-width: 760px;
font-size: clamp(28px, 3.25vw, 48px);
line-height: 1.06;
letter-spacing: -0.025em;
}
@media (max-width: 760px) {
.formats-title {
font-size: clamp(26px, 8vw, 36px);
line-height: 1.08;
}
}
@media (max-width: 620px) {
.formats-title {
font-size: clamp(29px, 8.3vw, 33px);
line-height: 1.06;
letter-spacing: -0.035em;
}
}
.market-panel-surface { .market-panel-surface {
background: background:
radial-gradient(circle at 85% 15%, rgb(0 196 180 / 0.22), transparent 34%), radial-gradient(circle at 85% 15%, rgb(0 196 180 / 0.22), transparent 34%),