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>
152 lines
4.1 KiB
TypeScript
152 lines
4.1 KiB
TypeScript
import type { ReactNode } from 'react'
|
|
import { useReveal } from '../hooks/useReveal'
|
|
import { cx } from '../lib/cx'
|
|
import { ChevronsIcon } from './Icons'
|
|
|
|
/** `.container` — 1200px max, 16px gutters that grow to 24px from 640px up. */
|
|
export function Container({ className, children }: { className?: string; children: ReactNode }) {
|
|
return (
|
|
<div
|
|
className={cx(
|
|
'mx-auto w-[min(calc(100%_-_32px),var(--container-page))] sm:w-[min(calc(100%_-_48px),var(--container-page))]',
|
|
className,
|
|
)}
|
|
>
|
|
{children}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/** Small uppercase kicker with the leading teal rule. */
|
|
export function Eyebrow({ tone = 'light', className, children }: { tone?: Tone; className?: string; children: ReactNode }) {
|
|
return (
|
|
<div
|
|
className={cx(
|
|
'mb-[16px] inline-flex items-center gap-[10px] text-[12px] font-extrabold tracking-[0.14em] uppercase',
|
|
"before:h-[2px] before:w-[26px] before:rounded-[2px] before:bg-teal before:content-['']",
|
|
tone === 'dark' ? 'text-teal-bright' : 'text-teal-ink',
|
|
className,
|
|
)}
|
|
>
|
|
{children}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
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(leadText, 'max-w-[760px]', tone === 'dark' ? 'text-white/[0.72]' : 'text-muted', className)}>
|
|
{children}
|
|
</p>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* `.section-head` — stacked by default; `split` moves the lead into a second
|
|
* column from 900px up, right-aligned against the heading.
|
|
*/
|
|
export function SectionHead({
|
|
eyebrow,
|
|
title,
|
|
titleClassName,
|
|
lead,
|
|
tone = 'light',
|
|
split = false,
|
|
}: {
|
|
eyebrow: string
|
|
title: ReactNode
|
|
titleClassName?: string
|
|
lead?: ReactNode
|
|
tone?: Tone
|
|
split?: boolean
|
|
}) {
|
|
const { ref, revealClass } = useReveal<HTMLDivElement>()
|
|
|
|
if (!split) {
|
|
return (
|
|
<div ref={ref} className={cx('mb-[clamp(34px,5vw,54px)]', revealClass)}>
|
|
<Eyebrow tone={tone}>{eyebrow}</Eyebrow>
|
|
<h2 className={titleClassName}>{title}</h2>
|
|
{lead ? <SectionLead tone={tone}>{lead}</SectionLead> : null}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div
|
|
ref={ref}
|
|
className={cx(
|
|
'mb-[clamp(34px,5vw,54px)] grid gap-[18px] md:grid-cols-[1.02fr_0.78fr] md:items-end',
|
|
revealClass,
|
|
)}
|
|
>
|
|
<div>
|
|
<Eyebrow tone={tone}>{eyebrow}</Eyebrow>
|
|
<h2 className={titleClassName}>{title}</h2>
|
|
</div>
|
|
{lead ? (
|
|
<SectionLead tone={tone} className="md:justify-self-end">
|
|
{lead}
|
|
</SectionLead>
|
|
) : null}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const sectionTones = {
|
|
light: 'bg-paper',
|
|
white: 'bg-white',
|
|
dark: 'bg-navy text-white',
|
|
/** For sections that paint their own multi-layer background in index.css. */
|
|
none: '',
|
|
}
|
|
|
|
/** `.section` — the shared vertical rhythm and background variants. */
|
|
export function Section({
|
|
id,
|
|
tone = 'light',
|
|
compact = false,
|
|
className,
|
|
children,
|
|
}: {
|
|
id?: string
|
|
tone?: keyof typeof sectionTones
|
|
compact?: boolean
|
|
className?: string
|
|
children: ReactNode
|
|
}) {
|
|
return (
|
|
<section
|
|
id={id}
|
|
className={cx('relative', compact ? 'py-section-compact' : 'py-section', sectionTones[tone], className)}
|
|
>
|
|
{children}
|
|
</section>
|
|
)
|
|
}
|
|
|
|
/** "Листайте карточки в сторону" — only shown while the row still scrolls. */
|
|
export function ScrollHint({ tone = 'light', children }: { tone?: Tone; children: ReactNode }) {
|
|
return (
|
|
<div
|
|
aria-hidden="true"
|
|
className={cx(
|
|
'mt-[8px] flex items-center gap-[7px] text-[11px] md:hidden [&>svg]:size-[16px]',
|
|
tone === 'dark' ? 'text-white/[0.52]' : 'text-muted',
|
|
)}
|
|
>
|
|
<ChevronsIcon />
|
|
{children}
|
|
</div>
|
|
)
|
|
}
|