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:
co-authored by
Claude Opus 5
parent
7ce8bee320
commit
1ee5431b92
+29
-18
@@ -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` из макета; маски
|
||||
телефона, как и на фитнесе, нет. Тексты окна — из макета.
|
||||
|
||||
### Шрифты
|
||||
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
+9
-3
@@ -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 (
|
||||
<>
|
||||
<SiteHeader scrolled={scrolled} headerRef={headerRef} />
|
||||
<SiteHeader scrolled={scrolled} headerRef={headerRef} onCallbackClick={openCallback} />
|
||||
|
||||
<main>
|
||||
<Hero />
|
||||
<BenefitsSection />
|
||||
<EconomicsSection />
|
||||
<GuestsSection />
|
||||
<EquipmentSection />
|
||||
<FormatsSection />
|
||||
<EquipmentSection />
|
||||
<WhyExoSection />
|
||||
<RequestSection />
|
||||
</main>
|
||||
|
||||
<SiteFooter />
|
||||
<CallbackModal open={callbackOpen} onClose={closeCallback} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<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]">
|
||||
<span>{label}</span>
|
||||
<strong className="text-[16px] font-[820] whitespace-nowrap text-teal-bright">{value}</strong>
|
||||
|
||||
@@ -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<ButtonVariant, string> = {
|
||||
primary:
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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() {
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<p className="mb-0 text-[11px] leading-[1.45] text-white/[0.58]">
|
||||
Введите фактическое число оплаченных программ, средний чек и рабочие дни.
|
||||
Введите фактическое число оплаченных сеансов, средний чек сеанса и рабочие дни.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -179,7 +181,7 @@ function RevenueCalculator() {
|
||||
<div className="grid grid-cols-2 gap-[9px] sm:grid-cols-3 phone:grid-cols-1">
|
||||
<CalcField
|
||||
id="hotelPrograms"
|
||||
label="Программ в день"
|
||||
label="Сеансов в день"
|
||||
value={programs}
|
||||
onChange={setPrograms}
|
||||
placeholder="например, 8"
|
||||
@@ -188,10 +190,10 @@ function RevenueCalculator() {
|
||||
/>
|
||||
<CalcField
|
||||
id="hotelCheck"
|
||||
label="Средний чек, ₽"
|
||||
label="Средний чек сеанса, ₽"
|
||||
value={check}
|
||||
onChange={setCheck}
|
||||
placeholder="ваш тариф"
|
||||
placeholder="например, 2500"
|
||||
min={0}
|
||||
step={100}
|
||||
/>
|
||||
@@ -210,7 +212,7 @@ function RevenueCalculator() {
|
||||
<CalcResult
|
||||
label="Дополнительная выручка / месяц"
|
||||
value={ready ? formatMoney(month) : '—'}
|
||||
note={ready ? `${formatNumber(volume)} программ в месяц` : 'Введите программы и чек'}
|
||||
note={ready ? `${formatNumber(volume)} сеансов в месяц` : 'Введите сеансы и чек'}
|
||||
/>
|
||||
<CalcResult
|
||||
label="Дополнительная выручка / год"
|
||||
@@ -220,6 +222,29 @@ function RevenueCalculator() {
|
||||
</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]">
|
||||
Сценарный калькулятор, а не финансовая гарантия. CAPEX, ФОТ, налоги, расходники, тарифы и фактическая загрузка
|
||||
рассчитываются после аудита отеля.
|
||||
|
||||
@@ -18,9 +18,9 @@ export function EquipmentSection() {
|
||||
<SectionHead
|
||||
split
|
||||
tone="dark"
|
||||
eyebrow="Что можно купить"
|
||||
title="Комплект оборудования для отеля"
|
||||
lead="Линейка из четырех аппаратов адаптируется под любой запрос клиента: от быстрых премиум-процедур в зоне отдыха до глубокой индивидуальной работы с оператором."
|
||||
eyebrow="Модель работы"
|
||||
title="Как работает сервис восстановления для гостя"
|
||||
lead="Ниже показана логика работы каждой технологии: для какого сценария гостя она подходит, сколько длится сеанс и требуется ли участие специалиста. Так отель заранее понимает, как встроить сервис в SPA-меню, расписание и загрузку команды."
|
||||
/>
|
||||
|
||||
<div className="scroll-cards relative z-1 gap-[16px] md:grid-cols-4">
|
||||
|
||||
@@ -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<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 (
|
||||
<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>
|
||||
{children}
|
||||
@@ -22,11 +50,12 @@ export function TextField({
|
||||
id,
|
||||
label,
|
||||
full,
|
||||
tone = 'glass',
|
||||
...rest
|
||||
}: { id: string; label: string; full?: boolean } & ComponentPropsWithRef<'input'>) {
|
||||
}: { id: string; label: string; full?: boolean; tone?: Tone } & ComponentPropsWithRef<'input'>) {
|
||||
return (
|
||||
<Field id={id} label={label} full={full}>
|
||||
<input id={id} className={cx(controlClass, 'h-[52px] px-[15px]')} {...rest} />
|
||||
<Field id={id} label={label} full={full} tone={tone}>
|
||||
<input id={id} className={cx(controlClass[tone], tone === 'glass' ? 'h-[52px]' : 'h-[51px]')} {...rest} />
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
@@ -38,20 +67,29 @@ export function TextareaField({
|
||||
...rest
|
||||
}: { id: string; label: string; full?: boolean } & ComponentPropsWithRef<'textarea'>) {
|
||||
return (
|
||||
<Field id={id} label={label} full={full}>
|
||||
<textarea id={id} className={cx(controlClass, 'min-h-[105px] resize-y px-[15px] py-[13px]')} {...rest} />
|
||||
<Field id={id} label={label} full={full} tone="glass">
|
||||
<textarea id={id} className={cx(controlClass.glass, 'min-h-[105px] resize-y py-[13px]')} {...rest} />
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<div aria-hidden="true" className="honeypot">
|
||||
<label htmlFor="website">Не заполнять</label>
|
||||
<label htmlFor={id}>Не заполнять</label>
|
||||
<input
|
||||
autoComplete="off"
|
||||
id="website"
|
||||
id={id}
|
||||
name="website"
|
||||
tabIndex={-1}
|
||||
type="text"
|
||||
@@ -61,3 +99,18 @@ export function Honeypot({ value, onChange }: { value: string; onChange: (value:
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -11,9 +11,10 @@ export function FormatsSection() {
|
||||
<Container>
|
||||
<SectionHead
|
||||
split
|
||||
eyebrow="Сценарии размещения"
|
||||
title="Встраивается в маршрут гостя"
|
||||
lead="Формат подстраивается под инфраструктуру отеля: отдельный SPA-кабинет, заметная капсула рядом с фитнесом, зона отдыха у бассейна или персональный сервис в номере."
|
||||
eyebrow="Решение для вашего бизнеса"
|
||||
title="Отель — не больница. Пространство восстановления должно продолжать интерьер"
|
||||
titleClassName="formats-title"
|
||||
lead="Оборудование Экзо Групп интегрируется в SPA, wellness- или фитнес-зону и оформляется в стилистике вашего отеля. На фотографиях ниже — варианты интерьера и размещения, где аппараты становятся частью премиального сервиса, а не выглядят как медицинский кабинет."
|
||||
/>
|
||||
|
||||
<div className="scroll-cards gap-[16px] md:grid-cols-4">
|
||||
|
||||
@@ -52,7 +52,7 @@ export function Hero() {
|
||||
<p
|
||||
ref={subtitle.ref}
|
||||
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,
|
||||
)}
|
||||
>
|
||||
@@ -64,29 +64,26 @@ export function Hero() {
|
||||
Получить предложение
|
||||
<ArrowRightIcon />
|
||||
</ButtonLink>
|
||||
<ButtonLink href="#equipment" variant="secondary">
|
||||
Подобрать оборудование
|
||||
</ButtonLink>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={facts.ref}
|
||||
aria-label="Ключевые параметры формата"
|
||||
aria-label="Ключевая экономика сценария"
|
||||
className={cx(
|
||||
'grid max-w-[700px] grid-cols-3 gap-[10px] delay-[240ms] sm:gap-[12px] tiny:max-w-[330px] tiny:grid-cols-1',
|
||||
facts.revealClass,
|
||||
)}
|
||||
>
|
||||
{heroFacts.map((fact) => (
|
||||
// Under 430px the fact turns into a value/label row. The original used an
|
||||
// 80px column, which the widest value overflowed into the label; 124px
|
||||
// keeps every value on one line.
|
||||
// Under 430px the fact turns into a value/label row. The value column
|
||||
// grows with its content, so «600 000 ₽ / мес.» stays on one line.
|
||||
<div
|
||||
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]
|
||||
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}
|
||||
</strong>
|
||||
<span className="text-[12px] text-white/[0.62]">{fact.label}</span>
|
||||
|
||||
@@ -35,15 +35,16 @@ export function Eyebrow({ tone = 'light', className, children }: { tone?: Tone;
|
||||
|
||||
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 }) {
|
||||
return (
|
||||
<p
|
||||
className={cx(
|
||||
'mb-0 max-w-[760px] text-[clamp(18px,2vw,22px)] leading-[1.55]',
|
||||
tone === 'dark' ? 'text-white/[0.72]' : 'text-muted',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<p className={cx(leadText, 'max-w-[760px]', tone === 'dark' ? 'text-white/[0.72]' : 'text-muted', className)}>
|
||||
{children}
|
||||
</p>
|
||||
)
|
||||
@@ -56,12 +57,14 @@ export function SectionLead({ tone = 'light', className, children }: { tone?: To
|
||||
export function SectionHead({
|
||||
eyebrow,
|
||||
title,
|
||||
titleClassName,
|
||||
lead,
|
||||
tone = 'light',
|
||||
split = false,
|
||||
}: {
|
||||
eyebrow: string
|
||||
title: ReactNode
|
||||
titleClassName?: string
|
||||
lead?: ReactNode
|
||||
tone?: Tone
|
||||
split?: boolean
|
||||
@@ -72,7 +75,7 @@ export function SectionHead({
|
||||
return (
|
||||
<div ref={ref} className={cx('mb-[clamp(34px,5vw,54px)]', revealClass)}>
|
||||
<Eyebrow tone={tone}>{eyebrow}</Eyebrow>
|
||||
<h2>{title}</h2>
|
||||
<h2 className={titleClassName}>{title}</h2>
|
||||
{lead ? <SectionLead tone={tone}>{lead}</SectionLead> : null}
|
||||
</div>
|
||||
)
|
||||
@@ -88,7 +91,7 @@ export function SectionHead({
|
||||
>
|
||||
<div>
|
||||
<Eyebrow tone={tone}>{eyebrow}</Eyebrow>
|
||||
<h2 className="mb-0">{title}</h2>
|
||||
<h2 className={titleClassName}>{title}</h2>
|
||||
</div>
|
||||
{lead ? (
|
||||
<SectionLead tone={tone} className="md:justify-self-end">
|
||||
|
||||
@@ -87,7 +87,6 @@ export function LeadForm() {
|
||||
autoComplete="organization"
|
||||
minLength={2}
|
||||
placeholder="Название отеля"
|
||||
required
|
||||
value={values.company}
|
||||
onChange={set('company')}
|
||||
/>
|
||||
@@ -110,7 +109,6 @@ export function LeadForm() {
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
placeholder="name@hotel.ru"
|
||||
required
|
||||
value={values.email}
|
||||
onChange={set('email')}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useReveal } from '../hooks/useReveal'
|
||||
import { cx } from '../lib/cx'
|
||||
import { Container, Eyebrow, Section, SectionLead } from './Layout'
|
||||
import { Container, Eyebrow, Section, leadText } from './Layout'
|
||||
import { LeadForm } from './LeadForm'
|
||||
|
||||
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)]">
|
||||
<div ref={copy.ref} className={cx('pt-[4px]', copy.revealClass)}>
|
||||
<Eyebrow tone="dark">Следующий шаг</Eyebrow>
|
||||
<h2 className="max-w-[660px]">Подберём оборудование под формат вашего отеля</h2>
|
||||
<SectionLead tone="dark" className="max-w-[620px]">
|
||||
Оставьте контакты — специалист свяжется, ответит на вопросы и подготовит коммерческое предложение с расчётом
|
||||
окупаемости и меню программ.
|
||||
</SectionLead>
|
||||
<h2 className="max-w-[660px] compact:text-[34px] compact:leading-[1.04] compact:tracking-[-0.035em]">
|
||||
Подберём решение под формат вашего отеля
|
||||
</h2>
|
||||
<p className={cx(leadText, 'max-w-[620px] text-white/[0.76]')}>
|
||||
Оставьте контакты — наш специалист свяжется с Вами, ответит на вопросы и подготовит коммерческое
|
||||
предложение, исходя из{' '}
|
||||
<strong>доступной площади, специфики инфраструктуры и ваших бизнес-задач.</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<LeadForm />
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
import type { RefObject } from 'react'
|
||||
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 { reportCallClick } from '../lib/lead'
|
||||
import { ButtonLink, buttonClass, headerSize } from './Button'
|
||||
import { PhoneIcon } from './Icons'
|
||||
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 (
|
||||
<header
|
||||
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="Экзо Групп — на первый экран">
|
||||
<img
|
||||
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}
|
||||
width={900}
|
||||
height={204}
|
||||
@@ -38,16 +45,16 @@ export function SiteHeader({ scrolled, headerRef }: { scrolled: boolean; headerR
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-[10px] compact:gap-[6px]">
|
||||
<a
|
||||
href={contacts.phoneHref}
|
||||
onClick={reportCallClick}
|
||||
aria-label="Позвонить в Экзо Групп"
|
||||
<div className="flex shrink-0 items-center gap-[10px] compact:gap-[6px] micro:gap-[5px]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCallbackClick}
|
||||
aria-label="Заказать обратный звонок"
|
||||
className={buttonClass('call', 'compact:px-[12px]', headerSize)}
|
||||
>
|
||||
<PhoneIcon />
|
||||
<span className="compact:hidden">Звонок</span>
|
||||
</a>
|
||||
</button>
|
||||
<ButtonLink href="#proposal" size={headerSize}>
|
||||
Получить предложение
|
||||
</ButtonLink>
|
||||
|
||||
+12
-12
@@ -13,16 +13,16 @@ export const contacts = {
|
||||
}
|
||||
|
||||
export const navLinks = [
|
||||
{ href: '#benefits', label: 'Выгода' },
|
||||
{ href: '#guests', label: 'Гости' },
|
||||
{ href: '#equipment', label: 'Оборудование' },
|
||||
{ href: '#formats', label: 'Форматы' },
|
||||
{ href: '#hotel-economics', label: 'Экономика' },
|
||||
{ href: '#guests', label: 'Для гостя' },
|
||||
{ href: '#formats', label: 'Интерьер' },
|
||||
{ href: '#equipment', label: 'Сервис' },
|
||||
]
|
||||
|
||||
export const heroFacts = [
|
||||
{ value: '4 аппарата', label: 'единое пространство восстановления' },
|
||||
{ value: '1 оператор', label: 'при дисциплинированном расписании' },
|
||||
{ value: '12 часов', label: 'базовый режим работы кабинета' },
|
||||
{ value: '2 500 ₽', label: 'средний чек сеанса по данным «ЭкзоКлиник»' },
|
||||
{ value: '600 000 ₽ / мес.', label: 'сценарий: 8 сеансов в день × 30 дней' },
|
||||
{ value: '7,2 млн ₽ / год', label: 'тот же сценарий, до вычета расходов' },
|
||||
]
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
@@ -166,9 +166,9 @@ export const devices = [
|
||||
{
|
||||
name: 'ЭкзоТерапия',
|
||||
role: 'Флагман SPA',
|
||||
duration: '10 минут',
|
||||
duration: '10–15 минут',
|
||||
tech: 'Высокоинтенсивная импульсная магнитотерапия.',
|
||||
text: 'Короткий 10-минутный ритуал для гостя после дороги, лыж или долгой прогулки. Подходит как флагманская процедура SPA-зоны.',
|
||||
text: 'Короткий сеанс 10–15 минут для гостя после дороги, лыж или долгой прогулки. Подходит как флагманская процедура SPA-зоны.',
|
||||
image: deviceTherapy,
|
||||
alt: 'ЭкзоТерапия в премиальной SPA-капсуле',
|
||||
},
|
||||
@@ -193,16 +193,16 @@ export const devices = [
|
||||
{
|
||||
name: 'ЭкзоВодород',
|
||||
role: 'Автономная пауза',
|
||||
duration: '40 минут',
|
||||
duration: '30–60 минут',
|
||||
tech: 'Ингаляция молекулярным водородом',
|
||||
text: 'Комфортная 40-минутная пауза в кресле с минимальным участием оператора. Сочетается с SPA, бассейном и ожиданием процедур.',
|
||||
text: 'Комфортная пауза 30–60 минут в кресле с минимальным участием оператора. Сочетается с SPA, бассейном и ожиданием процедур.',
|
||||
image: deviceHydrogen,
|
||||
alt: 'ЭкзоВодород в спокойной wellness-зоне',
|
||||
},
|
||||
]
|
||||
|
||||
export const hotelModel = [
|
||||
{ value: '12–45 м²', text: 'Отдельная recovery-комната, SPA-кабинет или зона рядом с фитнесом и бассейном.' },
|
||||
{ value: '12–36 м²', text: 'Отдельная recovery-комната, SPA-кабинет или зона рядом с фитнесом и бассейном.' },
|
||||
{
|
||||
value: '1 оператор',
|
||||
text: 'Основное активное участие требуется на ЭкзоТекар; остальные процедуры после запуска работают практически автономно.',
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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
@@ -26,6 +26,8 @@
|
||||
--color-ink: #0a2540;
|
||||
--color-muted: #60778b;
|
||||
--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) */
|
||||
--color-econ-bg: #f4fafb;
|
||||
@@ -58,13 +60,18 @@
|
||||
--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
|
||||
min-width scale above; they are exposed as named variants instead of
|
||||
arbitrary values so the intent stays readable in the markup. */
|
||||
@custom-variant tiny (@media (max-width: 430px));
|
||||
@custom-variant phone (@media (max-width: 480px));
|
||||
@custom-variant compact (@media (max-width: 620px));
|
||||
/* The design's `max-width` media queries do not line up with the min-width
|
||||
scale above; they are exposed as named variants instead of arbitrary values
|
||||
so the intent stays readable in the markup. Tailwind emits custom variants in
|
||||
declaration order, so they go widest first: where two apply, the narrower
|
||||
one wins, as in the design's own cascade. */
|
||||
@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 {
|
||||
html {
|
||||
@@ -83,6 +90,16 @@
|
||||
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 {
|
||||
background: rgb(0 196 180 / 0.28);
|
||||
color: var(--color-navy);
|
||||
@@ -125,6 +142,26 @@
|
||||
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,
|
||||
a {
|
||||
-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 {
|
||||
background:
|
||||
radial-gradient(circle at 85% 15%, rgb(0 196 180 / 0.22), transparent 34%),
|
||||
|
||||
Reference in New Issue
Block a user