Redesign the phone layer of the landing
Three layout bugs and a mobile experience that had no wayfinding. The `#proposal` section was clipped, not merely cramped: the submit button carried `whitespace-nowrap` from the shared button base, giving it a min-content width of 358px, which sized the section's implicit `auto` grid track to 406px inside a 343px container. `overflow-hidden` then sliced the copy and every form field off the right edge of a 375px screen. Fixed at the root — `whitespace-nowrap` moves out of `base` and into `headerSize`, where buttons are sized by their label — plus an explicit `grid-cols-[minmax(0,1fr)]` on the section. The market stat cards put their source line in absolute position against a guessed min-height, so the longest label ran underneath it. It is in the flow now, pinned by `mt-auto`. Below 980px the header nav is not rendered, which left seventeen screens of scroll with no way to navigate them. The header gives that width back to the page — logo and call button only — and <ThumbBar /> takes over: scroll progress, a live section indicator that opens a section sheet, and one short CTA, hidden over the hero and over the form. Card rows now say what they are. Rows of unordered peers keep scrolling sideways and gain a measured segment indicator (<Rail />), replacing the two prose "swipe sideways" hints. The launch timeline goes the other way: sideways scrolling hid week 3-5 from someone reading week 1-2, so below 900px it becomes four disclosures on a spine — which also recovers the scroll length the taller hero spent. Below 560px the type scale steps down half a level and the display tracking opens back up, and the hero photo zooms past `cover` onto the wave wall, since fitting by height left only ceiling tiles above the copy. Also: the route card inside the market section split into two 165px columns at 760px, so that split now waits for 980px. Desktop above 980px is unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,7 @@ import { ResidencySection } from './components/ResidencySection'
|
|||||||
import { ScenariosSection } from './components/ScenariosSection'
|
import { ScenariosSection } from './components/ScenariosSection'
|
||||||
import { SiteFooter } from './components/SiteFooter'
|
import { SiteFooter } from './components/SiteFooter'
|
||||||
import { SiteHeader } from './components/SiteHeader'
|
import { SiteHeader } from './components/SiteHeader'
|
||||||
|
import { ThumbBar } from './components/ThumbBar'
|
||||||
import { WhyExoSection } from './components/WhyExoSection'
|
import { WhyExoSection } from './components/WhyExoSection'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
@@ -30,6 +31,8 @@ export default function App() {
|
|||||||
|
|
||||||
<ResidencySection />
|
<ResidencySection />
|
||||||
<SiteFooter />
|
<SiteFooter />
|
||||||
|
|
||||||
|
<ThumbBar />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,20 @@ import { cx } from '../lib/cx'
|
|||||||
export type ButtonVariant = 'primary' | 'secondary' | 'call'
|
export type ButtonVariant = 'primary' | 'secondary' | 'call'
|
||||||
|
|
||||||
const base =
|
const base =
|
||||||
'inline-flex cursor-pointer items-center justify-center gap-[10px] rounded-full border border-transparent font-[790] leading-none whitespace-nowrap ' +
|
'inline-flex cursor-pointer items-center justify-center gap-[10px] rounded-full border border-transparent font-[790] leading-none ' +
|
||||||
'transition-[transform,box-shadow,background-color,border-color] duration-200 hover:-translate-y-[2px] active:translate-y-0 ' +
|
'transition-[transform,box-shadow,background-color,border-color] duration-200 hover:-translate-y-[2px] active:translate-y-0 ' +
|
||||||
'[&>svg]:size-[18px] [&>svg]:shrink-0'
|
'[&>svg]:size-[18px] [&>svg]:shrink-0'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `whitespace-nowrap` belongs on buttons sized by their label, not on every
|
||||||
|
* button. A full-width button whose label cannot wrap reports a min-content
|
||||||
|
* width of its whole label — 358px for «Получить предварительный аудит» — and
|
||||||
|
* that width propagates out through any `auto` grid track around it. That is
|
||||||
|
* what pushed the whole `#proposal` section 47px off a 375px screen and let
|
||||||
|
* `overflow-hidden` slice the copy and the form fields off the right edge.
|
||||||
|
*/
|
||||||
|
export const nowrap = 'whitespace-nowrap'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sizing gets its own slot rather than living in `base`. Tailwind resolves
|
* Sizing gets its own slot rather than living in `base`. Tailwind resolves
|
||||||
* conflicting utilities by their order in the stylesheet, not by the order they
|
* conflicting utilities by their order in the stylesheet, not by the order they
|
||||||
@@ -18,7 +28,7 @@ const base =
|
|||||||
const defaultSize = 'min-h-[52px] px-[22px]'
|
const defaultSize = 'min-h-[52px] px-[22px]'
|
||||||
|
|
||||||
/** `.btn--header` — smaller, and tighter still on the narrowest phones. */
|
/** `.btn--header` — smaller, and tighter still on the narrowest phones. */
|
||||||
export const headerSize = 'min-h-[44px] px-[17px] text-[13px] phone:px-[14px] phone:text-[12px]'
|
export const headerSize = 'min-h-[44px] px-[17px] text-[13px] whitespace-nowrap phone:px-[14px] phone:text-[12px]'
|
||||||
|
|
||||||
const variants: Record<ButtonVariant, string> = {
|
const variants: Record<ButtonVariant, string> = {
|
||||||
primary:
|
primary:
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { devices, equipmentNote } from '../data/content'
|
|||||||
import { useReveal } from '../hooks/useReveal'
|
import { useReveal } from '../hooks/useReveal'
|
||||||
import { cx } from '../lib/cx'
|
import { cx } from '../lib/cx'
|
||||||
import { CheckIcon } from './Icons'
|
import { CheckIcon } from './Icons'
|
||||||
import { Container, ScrollHint, Section, SectionHead } from './Layout'
|
import { Container, Section, SectionHead } from './Layout'
|
||||||
|
import { Rail } from './Rail'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Product shots differ in framing, so the last stylesheet pass in the original
|
* Product shots differ in framing, so the last stylesheet pass in the original
|
||||||
@@ -37,10 +38,12 @@ export function EquipmentSection() {
|
|||||||
lead="Конфигурация подбирается под профиль центра, поток пациентов и задачи врачей. Используем технологическое ядро ЭКЗО без универсального набора и лишнего дублирования."
|
lead="Конфигурация подбирается под профиль центра, поток пациентов и задачи врачей. Используем технологическое ядро ЭКЗО без универсального набора и лишнего дублирования."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<Rail
|
||||||
ref={track.ref}
|
outerRef={track.ref}
|
||||||
aria-label="Оборудование Экзо Групп"
|
wrapperClassName={track.revealClass}
|
||||||
className={cx('scroll-cards auto-cols-[minmax(275px,84%)] gap-[14px] md:grid-still md:grid-cols-3', track.revealClass)}
|
tone="dark"
|
||||||
|
label="Оборудование Экзо Групп"
|
||||||
|
className="scroll-cards auto-cols-[minmax(275px,84%)] gap-[14px] md:grid-still md:grid-cols-3"
|
||||||
>
|
>
|
||||||
{devices.map((device) => (
|
{devices.map((device) => (
|
||||||
<article
|
<article
|
||||||
@@ -76,11 +79,7 @@ export function EquipmentSection() {
|
|||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
</div>
|
</Rail>
|
||||||
|
|
||||||
<ScrollHint tone="dark" className="md:hidden">
|
|
||||||
На мобильном — листайте карточки в сторону
|
|
||||||
</ScrollHint>
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
ref={note.ref}
|
ref={note.ref}
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ export function Hero() {
|
|||||||
<section
|
<section
|
||||||
id="top"
|
id="top"
|
||||||
aria-labelledby="hero-title"
|
aria-labelledby="hero-title"
|
||||||
className="relative isolate flex min-h-[780px] items-end overflow-hidden bg-navy-deep pt-[126px] pb-[48px] text-white lg:min-h-[810px]"
|
className="relative isolate flex min-h-[780px] items-end overflow-hidden bg-navy-deep pt-[126px] pb-[48px] text-white
|
||||||
|
compact:min-h-[660px] compact:pt-[132px] compact:pb-[36px] lg:min-h-[810px]"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
@@ -32,33 +33,49 @@ export function Hero() {
|
|||||||
|
|
||||||
<h1 id="hero-title">Физиотерапия с нуля — готовое направление под поток вашего центра</h1>
|
<h1 id="hero-title">Физиотерапия с нуля — готовое направление под поток вашего центра</h1>
|
||||||
|
|
||||||
<p className="mb-[28px] max-w-[760px] text-[clamp(19px,2vw,24px)] leading-[1.52] text-white/[0.8]">
|
<p className="mb-[28px] max-w-[760px] text-[clamp(19px,2vw,24px)] leading-[1.52] text-white/[0.8] compact:mb-[22px] compact:text-[17px]">
|
||||||
Проводим аудит клиентской базы и помещения. Проектируем кабинеты, подбираем технологии ЭКЗО, создаём
|
Проводим аудит клиентской базы и помещения. Проектируем кабинеты, подбираем технологии ЭКЗО, создаём
|
||||||
клинические маршруты, обучаем команду.
|
клинические маршруты, обучаем команду.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="mb-[34px] flex flex-col items-stretch gap-[12px] sm:flex-row sm:items-center">
|
<div className="mb-[34px] flex flex-col items-stretch gap-[12px] compact:mb-[26px] sm:flex-row sm:items-center">
|
||||||
<ButtonLink href="#proposal">
|
<ButtonLink href="#proposal">
|
||||||
Получить предварительный аудит
|
Получить предварительный аудит
|
||||||
<ArrowRightIcon />
|
<ArrowRightIcon />
|
||||||
</ButtonLink>
|
</ButtonLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Two specs and a claim, not three facts. On a phone the third one
|
||||||
|
stops being an orphan on its own row and starts being the point:
|
||||||
|
it is the only number here about money. */}
|
||||||
<div
|
<div
|
||||||
aria-label="Ключевые параметры проекта"
|
aria-label="Ключевые параметры проекта"
|
||||||
className="grid max-w-[760px] grid-cols-3 gap-[10px] phone:grid-cols-2 phone:[&>*:last-child]:col-span-full"
|
className="grid max-w-[760px] grid-cols-3 gap-[10px] phone:grid-cols-2 phone:gap-[9px] phone:[&>*:last-child]:col-span-full"
|
||||||
>
|
>
|
||||||
{heroFacts.map((fact) => (
|
{heroFacts.map((fact, index) => {
|
||||||
<div
|
const claim = index === heroFacts.length - 1
|
||||||
key={fact.value}
|
|
||||||
className="min-h-[92px] rounded-md border border-white/[0.14] bg-white/[0.08] px-[16px] py-[17px] backdrop-blur-[13px]"
|
return (
|
||||||
>
|
<div
|
||||||
<strong className="mb-[3px] block text-[clamp(20px,3vw,30px)] leading-[1.05] tracking-[-0.03em] [overflow-wrap:anywhere]">
|
key={fact.value}
|
||||||
{fact.value}
|
className={cx(
|
||||||
</strong>
|
'min-h-[92px] rounded-md border border-white/[0.14] bg-white/[0.08] px-[16px] py-[17px] backdrop-blur-[13px]',
|
||||||
<span className="block text-[12px] leading-[1.35] text-white/[0.65]">{fact.label}</span>
|
'phone:min-h-0 phone:px-[14px] phone:py-[13px]',
|
||||||
</div>
|
claim && 'phone:border-teal/[0.42] phone:bg-teal/[0.11]',
|
||||||
))}
|
)}
|
||||||
|
>
|
||||||
|
<strong
|
||||||
|
className={cx(
|
||||||
|
'mb-[3px] block text-[clamp(20px,3vw,30px)] leading-[1.05] tracking-[-0.03em] [overflow-wrap:anywhere]',
|
||||||
|
claim && 'phone:text-[#9FF6EC]',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{fact.value}
|
||||||
|
</strong>
|
||||||
|
<span className="block text-[12px] leading-[1.35] text-white/[0.65]">{fact.label}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-[18px] inline-flex items-center gap-[9px] text-[14px] font-[720] text-[#C8FFF9]">
|
<div className="mt-[18px] inline-flex items-center gap-[9px] text-[14px] font-[720] text-[#C8FFF9]">
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
/** Every icon on the page is a 24×24 outline drawn in `currentColor`. */
|
/** Every icon on the page is a 24×24 outline drawn in `currentColor`. */
|
||||||
function Icon({ children }: { children: ReactNode }) {
|
function Icon({ className, children }: { className?: string; children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none">
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" className={className}>
|
||||||
{children}
|
{children}
|
||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
@@ -25,6 +25,22 @@ export function CheckIcon() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ChevronUpIcon({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<Icon className={className}>
|
||||||
|
<path d="m6 15 6-6 6 6" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" />
|
||||||
|
</Icon>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CloseIcon({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<Icon className={className}>
|
||||||
|
<path d="M6 6l12 12M18 6 6 18" stroke="currentColor" strokeLinecap="round" strokeWidth="2" />
|
||||||
|
</Icon>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function PhoneIcon() {
|
export function PhoneIcon() {
|
||||||
return (
|
return (
|
||||||
<Icon>
|
<Icon>
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
import { formatNote, formats, timeline } from '../data/content'
|
import { formatNote, formats, timeline } from '../data/content'
|
||||||
|
import { useMediaQuery } from '../hooks/useMediaQuery'
|
||||||
import { useReveal } from '../hooks/useReveal'
|
import { useReveal } from '../hooks/useReveal'
|
||||||
import { cx } from '../lib/cx'
|
import { cx } from '../lib/cx'
|
||||||
|
import { ChevronUpIcon } from './Icons'
|
||||||
import { Container, Section, SectionHead, SmallNote } from './Layout'
|
import { Container, Section, SectionHead, SmallNote } from './Layout'
|
||||||
|
import { Rail } from './Rail'
|
||||||
|
|
||||||
export function LaunchSection() {
|
export function LaunchSection() {
|
||||||
const grid = useReveal<HTMLDivElement>()
|
const grid = useReveal<HTMLDivElement>()
|
||||||
const steps = useReveal<HTMLDivElement>()
|
const steps = useReveal<HTMLDivElement>()
|
||||||
|
const stacked = useMediaQuery('(max-width: 899px)')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section id="launch" tone="white">
|
<Section id="launch" tone="white">
|
||||||
@@ -17,9 +22,11 @@ export function LaunchSection() {
|
|||||||
lead="Вместо хаотичной закупки техники мы внедряем экосистему: совмещаем технологии, настраиваем санитарную логистику, обеспечиваем приватность пациентов и внедряем четкие протоколы работы."
|
lead="Вместо хаотичной закупки техники мы внедряем экосистему: совмещаем технологии, настраиваем санитарную логистику, обеспечиваем приватность пациентов и внедряем четкие протоколы работы."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<Rail
|
||||||
ref={grid.ref}
|
outerRef={grid.ref}
|
||||||
className={cx('scroll-cards auto-cols-[minmax(255px,82%)] gap-[12px] sm:grid-still sm:grid-cols-3', grid.revealClass)}
|
wrapperClassName={grid.revealClass}
|
||||||
|
label="Форматы кабинета"
|
||||||
|
className="scroll-cards auto-cols-[minmax(255px,82%)] gap-[12px] sm:grid-still sm:grid-cols-3"
|
||||||
>
|
>
|
||||||
{formats.map((format) => (
|
{formats.map((format) => (
|
||||||
<article
|
<article
|
||||||
@@ -37,41 +44,102 @@ export function LaunchSection() {
|
|||||||
<p className="text-[14px] text-muted">{format.text}</p>
|
<p className="text-[14px] text-muted">{format.text}</p>
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
</div>
|
</Rail>
|
||||||
|
|
||||||
<SmallNote className="mt-[16px]">{formatNote}</SmallNote>
|
<SmallNote className="mt-[16px]">{formatNote}</SmallNote>
|
||||||
|
|
||||||
{/* The timeline breaks into two columns at 900px, not at the shared
|
{/* Twelve weeks in order — deliberately not a rail. Sideways scrolling
|
||||||
760px step the rest of the page uses. */}
|
would hide week 3–5 from someone still reading week 1–2, and the
|
||||||
|
order is the whole point of this block. Below 900px the four weeks
|
||||||
|
stack along a teal spine and open one at a time: the sequence stays
|
||||||
|
visible at a glance, and the eighteen bullets underneath it stop
|
||||||
|
costing 2400px of scroll to walk past. */}
|
||||||
<div
|
<div
|
||||||
ref={steps.ref}
|
ref={steps.ref}
|
||||||
className={cx(
|
className={cx(
|
||||||
'scroll-cards mt-[30px] auto-cols-[minmax(330px,92%)] gap-[14px] min-[900px]:grid-still min-[900px]:grid-cols-2',
|
'mt-[30px] grid grid-cols-[minmax(0,1fr)] gap-[14px]',
|
||||||
|
'relative pl-[26px] before:absolute before:top-[16px] before:bottom-[16px] before:left-[5px] before:w-[2px]',
|
||||||
|
"before:rounded-full before:bg-[linear-gradient(180deg,var(--color-teal),rgb(0_196_180/0.14))] before:content-['']",
|
||||||
|
'min-[900px]:grid-cols-2 min-[900px]:gap-[14px] min-[900px]:pl-0 min-[900px]:before:hidden',
|
||||||
steps.revealClass,
|
steps.revealClass,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{timeline.map((step) => (
|
{timeline.map((step, index) => (
|
||||||
<article
|
<TimelineStep key={step.week} step={step} defaultOpen={index === 0} collapsible={stacked} />
|
||||||
key={step.week}
|
|
||||||
className="rounded-[20px] border border-teal/[0.16] bg-ice px-[20px] py-[22px]"
|
|
||||||
>
|
|
||||||
<span className="mb-[8px] block text-[12px] font-black tracking-[0.11em] text-teal-ink uppercase">
|
|
||||||
{step.week}
|
|
||||||
</span>
|
|
||||||
<strong className="mb-[10px] block text-[18px] leading-[1.2] text-navy">{step.title}</strong>
|
|
||||||
<span className="mb-[14px] block text-[13px] leading-[1.55] text-muted italic">{step.lead}</span>
|
|
||||||
<ul className="grid list-disc gap-[8px] pl-[18px] text-[13px] leading-[1.55] text-muted">
|
|
||||||
{step.items.map((item) => (
|
|
||||||
<li key={item.title}>
|
|
||||||
<strong className="inline text-[13px] text-navy">{item.title}</strong>
|
|
||||||
{item.text ? `: ${item.text}` : null}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</article>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
</Section>
|
</Section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One week of the launch plan. A disclosure below 900px, a plain card above it
|
||||||
|
* — the difference is real markup, not a class, because a heading that is a
|
||||||
|
* button on a phone should not stay a button on a desktop where nothing
|
||||||
|
* collapses.
|
||||||
|
*/
|
||||||
|
function TimelineStep({
|
||||||
|
step,
|
||||||
|
defaultOpen,
|
||||||
|
collapsible,
|
||||||
|
}: {
|
||||||
|
step: (typeof timeline)[number]
|
||||||
|
defaultOpen: boolean
|
||||||
|
collapsible: boolean
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(defaultOpen)
|
||||||
|
const expanded = !collapsible || open
|
||||||
|
|
||||||
|
const week = (
|
||||||
|
<span className="block text-[12px] font-black tracking-[0.11em] text-teal-ink uppercase">{step.week}</span>
|
||||||
|
)
|
||||||
|
const title = <strong className="mt-[6px] block text-[17px] leading-[1.2] text-navy">{step.title}</strong>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
className="relative rounded-[20px] border border-teal/[0.16] bg-ice px-[20px] py-[20px]
|
||||||
|
before:absolute before:top-[26px] before:-left-[26px] before:size-[12px] before:rounded-full before:border-[3px]
|
||||||
|
before:border-white before:bg-teal before:content-[''] min-[900px]:py-[22px] min-[900px]:before:hidden"
|
||||||
|
>
|
||||||
|
{collapsible ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-expanded={open}
|
||||||
|
onClick={() => setOpen((current) => !current)}
|
||||||
|
className="flex w-full cursor-pointer items-start gap-[12px] text-left outline-none focus-visible:rounded-[6px] focus-visible:ring-2 focus-visible:ring-teal"
|
||||||
|
>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
{week}
|
||||||
|
{title}
|
||||||
|
</span>
|
||||||
|
<ChevronUpIcon
|
||||||
|
className={cx(
|
||||||
|
'mt-[3px] size-[18px] shrink-0 text-teal-ink transition-transform duration-250',
|
||||||
|
open ? 'rotate-0' : 'rotate-180',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{week}
|
||||||
|
{title}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{expanded ? (
|
||||||
|
<>
|
||||||
|
<span className="mt-[12px] mb-[14px] block text-[13px] leading-[1.55] text-muted italic">{step.lead}</span>
|
||||||
|
<ul className="grid list-disc gap-[8px] pl-[18px] text-[13px] leading-[1.55] text-muted">
|
||||||
|
{step.items.map((item) => (
|
||||||
|
<li key={item.title}>
|
||||||
|
<strong className="inline text-[13px] text-navy">{item.title}</strong>
|
||||||
|
{item.text ? `: ${item.text}` : null}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -151,19 +151,3 @@ export function Section({
|
|||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `.scroll-hint` — only meaningful while the card row still scrolls sideways. */
|
|
||||||
export function ScrollHint({ tone = 'light', className, children }: { tone?: Tone; className?: string; children: ReactNode }) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
aria-hidden="true"
|
|
||||||
className={cx(
|
|
||||||
"mt-[12px] flex items-center gap-[8px] text-[11px] before:h-px before:w-[24px] before:bg-current before:content-['']",
|
|
||||||
tone === 'dark' ? 'text-white/[0.48]' : 'text-muted',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ export function LeadForm() {
|
|||||||
noValidate
|
noValidate
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
className={cx(
|
className={cx(
|
||||||
'rounded-xl border border-white/[0.14] bg-white/[0.1] p-[clamp(23px,4vw,34px)] shadow-[0_30px_90px_rgb(0_0_0/0.23)] backdrop-blur-[20px]',
|
'min-w-0 rounded-xl border border-white/[0.14] bg-white/[0.1] p-[clamp(23px,4vw,34px)] shadow-[0_30px_90px_rgb(0_0_0/0.23)] backdrop-blur-[20px]',
|
||||||
revealClass,
|
revealClass,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { marketNotes, marketStats, routeWith, routeWithout } from '../data/conte
|
|||||||
import { useReveal } from '../hooks/useReveal'
|
import { useReveal } from '../hooks/useReveal'
|
||||||
import { cx } from '../lib/cx'
|
import { cx } from '../lib/cx'
|
||||||
import { Container, Eyebrow, Section, SectionHead, SmallNote } from './Layout'
|
import { Container, Eyebrow, Section, SectionHead, SmallNote } from './Layout'
|
||||||
|
import { Rail } from './Rail'
|
||||||
|
|
||||||
export function MarketSection() {
|
export function MarketSection() {
|
||||||
const stats = useReveal<HTMLDivElement>()
|
const stats = useReveal<HTMLDivElement>()
|
||||||
@@ -17,39 +18,41 @@ export function MarketSection() {
|
|||||||
lead="Забудьте о расходах на привлечение новых клиентов. Мы встраиваем готовое направление физиотерапии в вашу структуру, превращая лояльную базу пациентов и текущий штат врачей в источник новой прибыли."
|
lead="Забудьте о расходах на привлечение новых клиентов. Мы встраиваем готовое направление физиотерапии в вашу структуру, превращая лояльную базу пациентов и текущий штат врачей в источник новой прибыли."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="grid gap-[24px] md:grid-cols-[minmax(0,1.08fr)_minmax(360px,0.92fr)] md:items-stretch">
|
<div className="grid grid-cols-[minmax(0,1fr)] gap-[24px] md:grid-cols-[minmax(0,1.08fr)_minmax(360px,0.92fr)] md:items-stretch">
|
||||||
<div
|
<Rail
|
||||||
ref={stats.ref}
|
outerRef={stats.ref}
|
||||||
className={cx(
|
wrapperClassName={stats.revealClass}
|
||||||
'scroll-cards auto-cols-[minmax(255px,82%)] gap-[12px] sm:grid-still sm:grid-cols-2',
|
label="Рыночные показатели"
|
||||||
stats.revealClass,
|
className="scroll-cards auto-cols-[minmax(255px,82%)] gap-[12px] sm:grid-still sm:grid-cols-2"
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{marketStats.map((stat) => (
|
{marketStats.map((stat) => (
|
||||||
|
/* The source line used to be absolutely positioned against the
|
||||||
|
card's min-height, so the longest label ran underneath it.
|
||||||
|
It is in the flow now, pinned to the bottom by `mt-auto`. */
|
||||||
<article
|
<article
|
||||||
key={stat.label}
|
key={stat.label}
|
||||||
className="relative min-h-[185px] overflow-hidden rounded-lg border border-navy/[0.12] bg-white p-[24px] shadow-[0_16px_48px_rgb(4_29_46/0.06)]
|
className="relative flex min-h-[185px] flex-col overflow-hidden rounded-lg border border-navy/[0.12] bg-white p-[24px] shadow-[0_16px_48px_rgb(4_29_46/0.06)]
|
||||||
phone:min-h-[172px] phone:p-[20px] sm:min-h-[205px]"
|
phone:min-h-[168px] phone:p-[20px] sm:min-h-[205px]"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="teal-bloom-soft absolute -right-[36px] -bottom-[48px] size-[150px] rounded-full"
|
className="teal-bloom-soft absolute -right-[36px] -bottom-[48px] size-[150px] rounded-full"
|
||||||
/>
|
/>
|
||||||
<strong className="mb-[8px] block text-[clamp(34px,5vw,54px)] leading-none font-[830] tracking-[-0.06em] text-navy">
|
<strong className="relative mb-[8px] block text-[clamp(34px,5vw,54px)] leading-none font-[830] tracking-[-0.06em] text-navy">
|
||||||
{stat.value}
|
{stat.value}
|
||||||
</strong>
|
</strong>
|
||||||
<p className="max-w-[240px] text-[14px] leading-[1.45] text-muted">{stat.label}</p>
|
<p className="relative max-w-[240px] text-[14px] leading-[1.45] text-muted">{stat.label}</p>
|
||||||
<span className="absolute bottom-[19px] left-[24px] text-[10px] tracking-[0.09em] text-[#7E93A3] uppercase phone:bottom-[15px] phone:left-[20px]">
|
<span className="relative mt-auto pt-[14px] text-[10px] tracking-[0.09em] text-[#7E93A3] uppercase">
|
||||||
{stat.source}
|
{stat.source}
|
||||||
</span>
|
</span>
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
</div>
|
</Rail>
|
||||||
|
|
||||||
<article
|
<article
|
||||||
ref={gap.ref}
|
ref={gap.ref}
|
||||||
className={cx(
|
className={cx(
|
||||||
'gap-card-surface relative grid gap-[16px] overflow-hidden rounded-xl p-[27px] text-white shadow-deep',
|
'gap-card-surface relative grid gap-[16px] overflow-hidden rounded-xl p-[27px] text-white shadow-deep compact:p-[20px]',
|
||||||
gap.revealClass,
|
gap.revealClass,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -60,12 +63,15 @@ export function MarketSection() {
|
|||||||
|
|
||||||
<div className="relative z-1">
|
<div className="relative z-1">
|
||||||
<Eyebrow tone="dark">Исходная точка</Eyebrow>
|
<Eyebrow tone="dark">Исходная точка</Eyebrow>
|
||||||
<h3 className="max-w-[600px] text-[clamp(27px,3.5vw,42px)]">
|
<h3 className="max-w-[600px] text-[clamp(27px,3.5vw,42px)] compact:text-[22px] compact:tracking-[-0.02em]">
|
||||||
Маршрут пациента: как перестать отдавать прибыль конкурентам.
|
Маршрут пациента: как перестать отдавать прибыль конкурентам.
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative z-1 grid gap-[12px] md:grid-cols-2">
|
{/* Not `md:` — at 760 the card is already sharing the row with the
|
||||||
|
stats, so splitting it again gives two 165px columns of
|
||||||
|
four-word lines. It waits for 980, where the card is wide. */}
|
||||||
|
<div className="relative z-1 grid gap-[12px] lg:grid-cols-2">
|
||||||
<RouteColumn sign="—" title="Пока физиотерапии нет" items={routeWithout} />
|
<RouteColumn sign="—" title="Пока физиотерапии нет" items={routeWithout} />
|
||||||
<RouteColumn sign="+" title="После запуска направления" items={routeWith} accent />
|
<RouteColumn sign="+" title="После запуска направления" items={routeWith} accent />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { programs, routeSteps } from '../data/content'
|
import { programs, routeSteps } from '../data/content'
|
||||||
import { useReveal } from '../hooks/useReveal'
|
import { useReveal } from '../hooks/useReveal'
|
||||||
import { cx } from '../lib/cx'
|
import { cx } from '../lib/cx'
|
||||||
import { Container, Eyebrow, ScrollHint, Section, SectionHead } from './Layout'
|
import { Container, Eyebrow, Section, SectionHead } from './Layout'
|
||||||
|
import { Rail } from './Rail'
|
||||||
|
|
||||||
export function ProgramsSection() {
|
export function ProgramsSection() {
|
||||||
const route = useReveal<HTMLElement>()
|
const route = useReveal<HTMLElement>()
|
||||||
@@ -15,13 +16,14 @@ export function ProgramsSection() {
|
|||||||
lead="Программа связывает врача, технологии, длительность и контроль динамики. Название и состав адаптируются под профиль центра и утверждённую клиническую модель."
|
lead="Программа связывает врача, технологии, длительность и контроль динамики. Название и состав адаптируются под профиль центра и утверждённую клиническую модель."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="scroll-cards auto-cols-[minmax(300px,88%)] gap-[14px] sm:auto-cols-[minmax(300px,48%)] md:grid-still md:grid-cols-5">
|
<Rail
|
||||||
|
label="Клинические программы"
|
||||||
|
className="scroll-cards auto-cols-[minmax(300px,88%)] gap-[14px] sm:auto-cols-[minmax(300px,48%)] md:grid-still md:grid-cols-5"
|
||||||
|
>
|
||||||
{programs.map((program) => (
|
{programs.map((program) => (
|
||||||
<ProgramCard key={program.num} {...program} />
|
<ProgramCard key={program.num} {...program} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</Rail>
|
||||||
|
|
||||||
<ScrollHint className="md:hidden">На мобильном — листайте программы</ScrollHint>
|
|
||||||
|
|
||||||
<aside
|
<aside
|
||||||
ref={route.ref}
|
ref={route.ref}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useReveal } from '../hooks/useReveal'
|
|||||||
import { cx } from '../lib/cx'
|
import { cx } from '../lib/cx'
|
||||||
import { promiseIcons } from './Icons'
|
import { promiseIcons } from './Icons'
|
||||||
import { Container, Section, SectionHead, SmallNote } from './Layout'
|
import { Container, Section, SectionHead, SmallNote } from './Layout'
|
||||||
|
import { Rail } from './Rail'
|
||||||
|
|
||||||
export function ProjectSection() {
|
export function ProjectSection() {
|
||||||
const strip = useReveal<HTMLDivElement>()
|
const strip = useReveal<HTMLDivElement>()
|
||||||
@@ -23,12 +24,11 @@ export function ProjectSection() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<Rail
|
||||||
ref={strip.ref}
|
outerRef={strip.ref}
|
||||||
className={cx(
|
wrapperClassName={cx('mb-[24px] sm:mb-[28px]', strip.revealClass)}
|
||||||
'scroll-cards mb-[24px] auto-cols-[minmax(255px,82%)] gap-[12px] sm:grid-still sm:mb-[28px] sm:grid-cols-2 md:grid-cols-4',
|
label="Что получает центр"
|
||||||
strip.revealClass,
|
className="scroll-cards auto-cols-[minmax(255px,82%)] gap-[12px] sm:grid-still sm:grid-cols-2 md:grid-cols-4"
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{promises.map((promise) => (
|
{promises.map((promise) => (
|
||||||
<article key={promise.title} className="rounded-[20px] border border-navy/[0.12] bg-white p-[22px]">
|
<article key={promise.title} className="rounded-[20px] border border-navy/[0.12] bg-white p-[22px]">
|
||||||
@@ -39,7 +39,7 @@ export function ProjectSection() {
|
|||||||
<p className="text-[14px] text-muted">{promise.text}</p>
|
<p className="text-[14px] text-muted">{promise.text}</p>
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
</div>
|
</Rail>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
ref={panel.ref}
|
ref={panel.ref}
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { useEffect, useRef, useState, type ReactNode, type RefObject } from 'react'
|
||||||
|
import { cx } from '../lib/cx'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A row of peer cards: scrolls sideways on phones, becomes a plain grid at the
|
||||||
|
* breakpoint the caller sets in `className` (`sm:grid-still` / `md:grid-still`).
|
||||||
|
*
|
||||||
|
* Only rows whose cards are unordered peers belong here. Sequences — the launch
|
||||||
|
* timeline, the patient route — stay stacked, because sideways scrolling hides
|
||||||
|
* step two from someone still reading step one.
|
||||||
|
*
|
||||||
|
* The segmented indicator underneath measures the row instead of guessing from
|
||||||
|
* a breakpoint, so it appears exactly when the sideways gesture exists and
|
||||||
|
* disappears the moment the row turns back into a grid.
|
||||||
|
*/
|
||||||
|
export function Rail({
|
||||||
|
label,
|
||||||
|
tone = 'light',
|
||||||
|
className,
|
||||||
|
wrapperClassName,
|
||||||
|
outerRef,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label?: string
|
||||||
|
tone?: 'light' | 'dark'
|
||||||
|
/** Classes for the scrolling row itself. */
|
||||||
|
className?: string
|
||||||
|
/** Classes for the wrapper that holds the row and the indicator. */
|
||||||
|
wrapperClassName?: string
|
||||||
|
outerRef?: RefObject<HTMLDivElement | null>
|
||||||
|
children: ReactNode
|
||||||
|
}) {
|
||||||
|
const rowRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [{ index, count }, setPosition] = useState({ index: 0, count: 0 })
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const row = rowRef.current
|
||||||
|
if (!row) return
|
||||||
|
|
||||||
|
const measure = () => {
|
||||||
|
/* `count: 0` hides the indicator — the row is a grid at this width. */
|
||||||
|
if (row.scrollWidth - row.clientWidth < 4) {
|
||||||
|
setPosition((current) => (current.count === 0 ? current : { index: 0, count: 0 }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const cards = Array.from(row.children) as HTMLElement[]
|
||||||
|
const middle = row.getBoundingClientRect().left + row.clientWidth / 2
|
||||||
|
let nearest = 0
|
||||||
|
let shortest = Infinity
|
||||||
|
|
||||||
|
cards.forEach((card, position) => {
|
||||||
|
const box = card.getBoundingClientRect()
|
||||||
|
const distance = Math.abs(box.left + box.width / 2 - middle)
|
||||||
|
if (distance < shortest) {
|
||||||
|
shortest = distance
|
||||||
|
nearest = position
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
setPosition((current) =>
|
||||||
|
current.index === nearest && current.count === cards.length
|
||||||
|
? current
|
||||||
|
: { index: nearest, count: cards.length },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
measure()
|
||||||
|
row.addEventListener('scroll', measure, { passive: true })
|
||||||
|
|
||||||
|
const observer = new ResizeObserver(measure)
|
||||||
|
observer.observe(row)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
row.removeEventListener('scroll', measure)
|
||||||
|
observer.disconnect()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const goTo = (position: number) => {
|
||||||
|
const row = rowRef.current
|
||||||
|
const card = row?.children[position] as HTMLElement | undefined
|
||||||
|
if (!row || !card) return
|
||||||
|
|
||||||
|
/* 16px is the row's own inline padding — the resting offset of card one. */
|
||||||
|
row.scrollBy({
|
||||||
|
left: card.getBoundingClientRect().left - row.getBoundingClientRect().left - 16,
|
||||||
|
behavior: 'smooth',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={outerRef} className={wrapperClassName}>
|
||||||
|
<div ref={rowRef} aria-label={label} className={className}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{count > 1 ? (
|
||||||
|
<div className="mt-[2px] flex items-center gap-[7px]">
|
||||||
|
{Array.from({ length: count }, (_, position) => (
|
||||||
|
<button
|
||||||
|
key={position}
|
||||||
|
type="button"
|
||||||
|
aria-label={`Показать карточку ${position + 1} из ${count}`}
|
||||||
|
aria-current={position === index}
|
||||||
|
onClick={() => goTo(position)}
|
||||||
|
className="flex h-[22px] cursor-pointer items-center rounded-full outline-none focus-visible:ring-2 focus-visible:ring-teal"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cx(
|
||||||
|
'block h-[4px] rounded-full transition-[width,background-color] duration-300',
|
||||||
|
position === index ? 'w-[28px]' : 'w-[10px]',
|
||||||
|
tone === 'dark'
|
||||||
|
? position === index
|
||||||
|
? 'bg-teal-bright'
|
||||||
|
: 'bg-white/[0.22]'
|
||||||
|
: position === index
|
||||||
|
? 'bg-teal'
|
||||||
|
: 'bg-navy/[0.16]',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -10,10 +10,15 @@ export function RequestSection() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section id="proposal" className="cta-surface relative overflow-hidden py-[clamp(72px,9vw,120px)] text-white">
|
<section id="proposal" className="cta-surface relative overflow-hidden py-[clamp(72px,9vw,120px)] text-white">
|
||||||
<Container className="relative grid items-center gap-[30px] md:grid-cols-[minmax(0,1fr)_minmax(430px,0.83fr)]">
|
{/* `grid-cols-[minmax(0,1fr)]` is load-bearing: an implicit `auto` track
|
||||||
<div ref={copy.ref} className={cx(copy.revealClass)}>
|
takes its minimum from the widest thing inside it, so anything in the
|
||||||
|
form that cannot wrap sets the width of the whole section. */}
|
||||||
|
<Container className="relative grid grid-cols-[minmax(0,1fr)] items-center gap-[30px] md:grid-cols-[minmax(0,1fr)_minmax(430px,0.83fr)]">
|
||||||
|
<div ref={copy.ref} className={cx('min-w-0', copy.revealClass)}>
|
||||||
<Eyebrow tone="dark">Следующий шаг</Eyebrow>
|
<Eyebrow tone="dark">Следующий шаг</Eyebrow>
|
||||||
<h2 className="text-[clamp(39px,6vw,68px)]">Запустим физиотерапию с нуля под ваш поток</h2>
|
<h2 className="text-[clamp(39px,6vw,68px)] compact:text-[32px] compact:tracking-[-0.035em]">
|
||||||
|
Запустим физиотерапию с нуля под ваш поток
|
||||||
|
</h2>
|
||||||
<SectionLead tone="dark">
|
<SectionLead tone="dark">
|
||||||
Оставьте контакты — специалист уточнит профиль центра, пациентский поток и доступные помещения, после чего
|
Оставьте контакты — специалист уточнит профиль центра, пациентский поток и доступные помещения, после чего
|
||||||
подготовит предварительную концепцию, конфигурацию и финансовую модель направления.
|
подготовит предварительную концепцию, конфигурацию и финансовую модель направления.
|
||||||
|
|||||||
@@ -20,10 +20,10 @@ export function ScenariosSection() {
|
|||||||
Сценарии выручки
|
Сценарии выручки
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2 className="mb-[10px] max-w-[980px] text-[clamp(32px,4.8vw,56px)] leading-[1.04] tracking-[-0.04em]">
|
<h2 className="mb-[10px] max-w-[980px] text-[clamp(32px,4.8vw,56px)] leading-[1.04] tracking-[-0.04em] compact:text-[29px] compact:tracking-[-0.03em]">
|
||||||
Даже умеренная загрузка кабинета даёт понятную выручку
|
Даже умеренная загрузка кабинета даёт понятную выручку
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mb-[34px] max-w-[900px] text-[clamp(17px,1.8vw,21px)] leading-[1.55] text-white/[0.72]">
|
<p className="mb-[34px] max-w-[900px] text-[clamp(17px,1.8vw,21px)] leading-[1.55] text-white/[0.72] compact:mb-[26px]">
|
||||||
Модель считается просто: количество оплаченных курсов × средний чек курса. Дальше на аудите добавляем расходы и
|
Модель считается просто: количество оплаченных курсов × средний чек курса. Дальше на аудите добавляем расходы и
|
||||||
срок окупаемости.
|
срок окупаемости.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import { Container } from './Layout'
|
|||||||
|
|
||||||
export function SiteFooter() {
|
export function SiteFooter() {
|
||||||
return (
|
return (
|
||||||
<footer className="bg-footer py-[26px] text-[12px] text-white/[0.52]">
|
/* The extra bottom padding below 980px is the landing strip for <ThumbBar />. */
|
||||||
|
<footer className="bg-footer py-[26px] pb-[96px] text-[12px] text-white/[0.52] lg:pb-[26px]">
|
||||||
<Container className="grid gap-[14px] md:grid-cols-[220px_1fr]">
|
<Container className="grid gap-[14px] md:grid-cols-[220px_1fr]">
|
||||||
<div className="flex max-w-[180px] shrink-0 items-center">
|
<div className="flex max-w-[180px] shrink-0 items-center">
|
||||||
<img
|
<img
|
||||||
|
|||||||
@@ -6,14 +6,20 @@ import { ButtonLink, buttonClass, headerSize } from './Button'
|
|||||||
import { PhoneIcon } from './Icons'
|
import { PhoneIcon } from './Icons'
|
||||||
import { Container } from './Layout'
|
import { Container } from './Layout'
|
||||||
|
|
||||||
/** Transparent over the hero; gains the blurred bar once the page scrolls. */
|
/**
|
||||||
|
* Transparent over the hero; gains the blurred bar once the page scrolls.
|
||||||
|
*
|
||||||
|
* Below 980px it carries a logo and a call button and nothing else. The nav is
|
||||||
|
* not shown at that width and the proposal button has moved to <ThumbBar />,
|
||||||
|
* so there is nothing left up here to compete with the page.
|
||||||
|
*/
|
||||||
export function SiteHeader() {
|
export function SiteHeader() {
|
||||||
const scrolled = useScrolled()
|
const scrolled = useScrolled()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header
|
<header
|
||||||
className={cx(
|
className={cx(
|
||||||
'fixed inset-x-0 top-0 z-[60] py-[14px] transition-[background-color,box-shadow,backdrop-filter] duration-250',
|
'fixed inset-x-0 top-0 z-[60] py-[14px] transition-[background-color,box-shadow,backdrop-filter] duration-250 phone:py-[9px]',
|
||||||
scrolled && 'bg-navy-deep/[0.88] shadow-[0_12px_42px_rgb(3_20_34/0.18)] backdrop-blur-[18px]',
|
scrolled && 'bg-navy-deep/[0.88] shadow-[0_12px_42px_rgb(3_20_34/0.18)] backdrop-blur-[18px]',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -43,12 +49,15 @@ export function SiteHeader() {
|
|||||||
<a
|
<a
|
||||||
href={contacts.phoneHref}
|
href={contacts.phoneHref}
|
||||||
aria-label="Позвонить в Экзо Групп"
|
aria-label="Позвонить в Экзо Групп"
|
||||||
className={buttonClass('call', 'phone:px-[12px] [&>svg]:phone:size-[18px]', headerSize)}
|
className={buttonClass('call', 'phone:min-h-[40px] phone:px-[13px] [&>svg]:phone:size-[18px]', headerSize)}
|
||||||
>
|
>
|
||||||
<PhoneIcon />
|
<PhoneIcon />
|
||||||
<span className="phone:hidden">Звонок</span>
|
<span className="phone:hidden">Звонок</span>
|
||||||
</a>
|
</a>
|
||||||
<ButtonLink href="#proposal" size={headerSize}>
|
{/* `max-lg:hidden`, not `hidden lg:inline-flex`: `inline-flex` from
|
||||||
|
the button base is emitted after `hidden` in the utility layer and
|
||||||
|
would win at every width. */}
|
||||||
|
<ButtonLink href="#proposal" className="max-lg:hidden" size={headerSize}>
|
||||||
Получить предложение
|
Получить предложение
|
||||||
</ButtonLink>
|
</ButtonLink>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { contacts, sectionNav } from '../data/content'
|
||||||
|
import { useReadingPosition } from '../hooks/useReadingPosition'
|
||||||
|
import { cx } from '../lib/cx'
|
||||||
|
import { ChevronUpIcon, CloseIcon, PhoneIcon } from './Icons'
|
||||||
|
|
||||||
|
const hrefs = sectionNav.map((section) => section.href)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wayfinding and the one call to action, moved to the thumb — below 980px,
|
||||||
|
* which is exactly where the header nav stops being rendered.
|
||||||
|
*
|
||||||
|
* The page runs to seventeen screens on a phone. What a reader needs there is
|
||||||
|
* "where am I" and "how do I ask", and until now the top of the screen carried
|
||||||
|
* neither: it carried a logo and a button, pinned over the content the whole
|
||||||
|
* way down. The header gives that width back to the page; this takes over.
|
||||||
|
*/
|
||||||
|
export function ThumbBar() {
|
||||||
|
const { index, progress } = useReadingPosition(hrefs)
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const triggerRef = useRef<HTMLButtonElement>(null)
|
||||||
|
|
||||||
|
const current = sectionNav[index]
|
||||||
|
|
||||||
|
/* Hidden over the hero, which has its own call to action, and again over the
|
||||||
|
form, where the bar would sit on top of the submit button. */
|
||||||
|
const visible = index > 0 && current.href !== '#proposal'
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
|
||||||
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') setOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { overflow } = document.body.style
|
||||||
|
document.body.style.overflow = 'hidden'
|
||||||
|
window.addEventListener('keydown', onKeyDown)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = overflow
|
||||||
|
window.removeEventListener('keydown', onKeyDown)
|
||||||
|
}
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
setOpen(false)
|
||||||
|
triggerRef.current?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={cx(
|
||||||
|
'fixed inset-x-0 bottom-0 z-70 transition-[transform,opacity] duration-300 lg:hidden',
|
||||||
|
visible ? 'translate-y-0 opacity-100' : 'pointer-events-none translate-y-full opacity-0',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="border-t border-white/[0.1] bg-navy-deep/[0.93] backdrop-blur-[20px]">
|
||||||
|
<div aria-hidden="true" className="h-[2px] bg-white/[0.08]">
|
||||||
|
<i className="block h-full bg-teal transition-[width] duration-150" style={{ width: `${progress * 100}%` }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mx-auto flex w-[min(calc(100%_-_24px),var(--container-page))] items-center gap-[10px] py-[10px]">
|
||||||
|
<button
|
||||||
|
ref={triggerRef}
|
||||||
|
type="button"
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
className="flex min-w-0 flex-1 cursor-pointer items-center gap-[10px] rounded-[16px] border border-white/[0.14] bg-white/[0.06]
|
||||||
|
px-[13px] py-[8px] text-left transition-[background-color,border-color] duration-200 outline-none
|
||||||
|
hover:bg-white/[0.1] focus-visible:border-teal focus-visible:ring-2 focus-visible:ring-teal/[0.4]"
|
||||||
|
>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block text-[10px] leading-[1.4] font-[850] tracking-[0.13em] text-teal uppercase">
|
||||||
|
Раздел {index + 1} / {sectionNav.length}
|
||||||
|
</span>
|
||||||
|
<span className="block truncate text-[14px] font-[760] text-white">{current.label}</span>
|
||||||
|
</span>
|
||||||
|
<ChevronUpIcon className="ml-auto size-[18px] shrink-0 text-white/[0.5]" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="#proposal"
|
||||||
|
className="inline-flex min-h-[46px] shrink-0 cursor-pointer items-center rounded-full bg-teal px-[18px] text-[14px]
|
||||||
|
font-[790] text-navy shadow-[0_12px_32px_rgb(0_196_180/0.28)] transition-colors duration-200 hover:bg-teal-bright"
|
||||||
|
>
|
||||||
|
Получить аудит
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{open ? (
|
||||||
|
<div className="fixed inset-0 z-80 lg:hidden" role="dialog" aria-modal="true" aria-label="Разделы страницы">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Закрыть список разделов"
|
||||||
|
onClick={close}
|
||||||
|
className="veil-in absolute inset-0 size-full cursor-pointer bg-navy-deep/[0.66] backdrop-blur-[3px]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="sheet-in absolute inset-x-0 bottom-0 max-h-[86vh] overflow-y-auto rounded-t-[26px] border-t border-white/[0.12]
|
||||||
|
bg-navy-deep px-[16px] pt-[12px] pb-[20px] shadow-[0_-24px_60px_rgb(3_20_34/0.5)]"
|
||||||
|
>
|
||||||
|
<div className="mb-[14px] flex items-center justify-between">
|
||||||
|
<span className="text-[11px] font-[850] tracking-[0.14em] text-teal uppercase">Разделы страницы</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={close}
|
||||||
|
aria-label="Закрыть"
|
||||||
|
className="grid size-[34px] cursor-pointer place-items-center rounded-full border border-white/[0.14] text-white/[0.6]
|
||||||
|
transition-colors duration-200 hover:text-white"
|
||||||
|
>
|
||||||
|
<CloseIcon className="size-[16px]" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="grid gap-[2px]">
|
||||||
|
{sectionNav.map((section, position) => (
|
||||||
|
<a
|
||||||
|
key={section.href}
|
||||||
|
href={section.href}
|
||||||
|
onClick={close}
|
||||||
|
aria-current={position === index ? 'true' : undefined}
|
||||||
|
className={cx(
|
||||||
|
'flex items-center gap-[14px] rounded-[14px] px-[13px] py-[12px] text-[15px] transition-colors duration-200',
|
||||||
|
position === index ? 'bg-white/[0.09] font-[780] text-white' : 'text-white/[0.66] hover:text-white',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cx(
|
||||||
|
'w-[18px] shrink-0 text-[11px] font-[820] tabular-nums',
|
||||||
|
position === index ? 'text-teal' : 'text-white/[0.34]',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{String(position + 1).padStart(2, '0')}
|
||||||
|
</span>
|
||||||
|
{section.label}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href={contacts.phoneHref}
|
||||||
|
className="mt-[14px] flex min-h-[52px] items-center justify-center gap-[10px] rounded-[16px] border border-white/[0.16]
|
||||||
|
bg-white/[0.07] text-[15px] font-[760] text-white transition-colors duration-200 hover:bg-white/[0.12]
|
||||||
|
[&>svg]:size-[18px]"
|
||||||
|
>
|
||||||
|
<PhoneIcon />
|
||||||
|
{contacts.phone}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { useReveal } from '../hooks/useReveal'
|
|||||||
import { cx } from '../lib/cx'
|
import { cx } from '../lib/cx'
|
||||||
import { whyIcons } from './Icons'
|
import { whyIcons } from './Icons'
|
||||||
import { Container, Section, SectionHead, SmallNote } from './Layout'
|
import { Container, Section, SectionHead, SmallNote } from './Layout'
|
||||||
|
import { Rail } from './Rail'
|
||||||
|
|
||||||
export function WhyExoSection() {
|
export function WhyExoSection() {
|
||||||
const photo = useReveal<HTMLDivElement>()
|
const photo = useReveal<HTMLDivElement>()
|
||||||
@@ -41,9 +42,11 @@ export function WhyExoSection() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div
|
<Rail
|
||||||
ref={list.ref}
|
outerRef={list.ref}
|
||||||
className={cx('scroll-cards auto-cols-[minmax(280px,84%)] gap-[10px] md:grid-still', list.revealClass)}
|
wrapperClassName={list.revealClass}
|
||||||
|
label="Почему Экзо Групп"
|
||||||
|
className="scroll-cards auto-cols-[minmax(280px,84%)] gap-[10px] md:grid-still"
|
||||||
>
|
>
|
||||||
{whyItems.map((item) => (
|
{whyItems.map((item) => (
|
||||||
<div
|
<div
|
||||||
@@ -59,7 +62,7 @@ export function WhyExoSection() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</Rail>
|
||||||
|
|
||||||
<div ref={stats.ref} className={cx('mt-[18px] grid grid-cols-2 gap-[10px] md:grid-cols-4', stats.revealClass)}>
|
<div ref={stats.ref} className={cx('mt-[18px] grid grid-cols-2 gap-[10px] md:grid-cols-4', stats.revealClass)}>
|
||||||
{companyStats.map((stat) => (
|
{companyStats.map((stat) => (
|
||||||
|
|||||||
@@ -18,6 +18,25 @@ export const navLinks = [
|
|||||||
{ href: '#launch', label: 'Запуск' },
|
{ href: '#launch', label: 'Запуск' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every section, in reading order — the list behind the thumb bar below 980px,
|
||||||
|
* where the header nav above is not shown. It is longer than `navLinks` on
|
||||||
|
* purpose: the four header links are a summary for a reader who can see the
|
||||||
|
* whole page at once, this is a map for one scrolling through seventeen
|
||||||
|
* screens of it.
|
||||||
|
*/
|
||||||
|
export const sectionNav = [
|
||||||
|
{ href: '#top', label: 'Начало' },
|
||||||
|
{ href: '#market', label: 'Почему сейчас' },
|
||||||
|
{ href: '#financial-scenarios', label: 'Сценарии выручки' },
|
||||||
|
{ href: '#project', label: 'Что запускаем' },
|
||||||
|
{ href: '#programs', label: 'Курсовая модель' },
|
||||||
|
{ href: '#technology', label: 'Технологии' },
|
||||||
|
{ href: '#launch', label: 'Запуск' },
|
||||||
|
{ href: '#why-exo', label: 'Почему Экзо' },
|
||||||
|
{ href: '#proposal', label: 'Заявка на аудит' },
|
||||||
|
]
|
||||||
|
|
||||||
/* -------------------------------------------------------------------------- */
|
/* -------------------------------------------------------------------------- */
|
||||||
/* Первый экран */
|
/* Первый экран */
|
||||||
/* -------------------------------------------------------------------------- */
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For the few places where a phone needs different markup, not just different
|
||||||
|
* classes — the launch timeline, which becomes a set of disclosures rather
|
||||||
|
* than four full-height cards. `query` must be a literal, not a computed
|
||||||
|
* string.
|
||||||
|
*/
|
||||||
|
export function useMediaQuery(query: string) {
|
||||||
|
const [matches, setMatches] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const list = window.matchMedia(query)
|
||||||
|
const onChange = () => setMatches(list.matches)
|
||||||
|
|
||||||
|
onChange()
|
||||||
|
list.addEventListener('change', onChange)
|
||||||
|
return () => list.removeEventListener('change', onChange)
|
||||||
|
}, [query])
|
||||||
|
|
||||||
|
return matches
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which section the reader is currently in, plus how far through the page they
|
||||||
|
* are. Driven by scroll position rather than by an IntersectionObserver: every
|
||||||
|
* section here is taller than a phone viewport, so the question is not "is this
|
||||||
|
* visible" but "which one is under the reading line", and a threshold-based
|
||||||
|
* observer cannot answer that.
|
||||||
|
*
|
||||||
|
* `hrefs` must be a stable array — pass a module-level constant.
|
||||||
|
*/
|
||||||
|
export function useReadingPosition(hrefs: readonly string[]) {
|
||||||
|
const [index, setIndex] = useState(0)
|
||||||
|
const [progress, setProgress] = useState(0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let frame = 0
|
||||||
|
|
||||||
|
const measure = () => {
|
||||||
|
frame = 0
|
||||||
|
|
||||||
|
/* A third of the way down the screen: the line the eye actually reads
|
||||||
|
from, not the very top edge. */
|
||||||
|
const line = window.scrollY + window.innerHeight * 0.3
|
||||||
|
let current = 0
|
||||||
|
|
||||||
|
hrefs.forEach((href, position) => {
|
||||||
|
const element = document.querySelector(href)
|
||||||
|
if (element && element.getBoundingClientRect().top + window.scrollY <= line) current = position
|
||||||
|
})
|
||||||
|
|
||||||
|
const scrollable = document.documentElement.scrollHeight - window.innerHeight
|
||||||
|
|
||||||
|
setIndex(current)
|
||||||
|
setProgress(scrollable > 0 ? Math.min(1, window.scrollY / scrollable) : 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onScroll = () => {
|
||||||
|
if (!frame) frame = requestAnimationFrame(measure)
|
||||||
|
}
|
||||||
|
|
||||||
|
measure()
|
||||||
|
window.addEventListener('scroll', onScroll, { passive: true })
|
||||||
|
window.addEventListener('resize', onScroll)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('scroll', onScroll)
|
||||||
|
window.removeEventListener('resize', onScroll)
|
||||||
|
if (frame) cancelAnimationFrame(frame)
|
||||||
|
}
|
||||||
|
}, [hrefs])
|
||||||
|
|
||||||
|
return { index, progress }
|
||||||
|
}
|
||||||
@@ -274,6 +274,110 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
Phone.
|
||||||
|
|
||||||
|
The scale above is a display scale. At 375px every level of it collapses
|
||||||
|
onto the same note — a 42px h1 and a 34px h2 set at display tracking, one
|
||||||
|
after another for seventeen screens, all shouting equally. Below 560px the
|
||||||
|
whole page steps down half a level and the tracking opens back up (-0.055em
|
||||||
|
closes Cyrillic counters at 38px in a way it never does at 84px), so the
|
||||||
|
figures the argument actually rests on — 2,41, +63%, 2,70 млн ₽ — are left
|
||||||
|
as the loudest thing on the page.
|
||||||
|
--------------------------------------------------------------------------- */
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
:root {
|
||||||
|
--spacing-section: 54px;
|
||||||
|
--spacing-section-compact: 46px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The phone header is 62px tall, not 78px. */
|
||||||
|
section[id] {
|
||||||
|
scroll-margin-top: 66px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin-bottom: 18px;
|
||||||
|
font-size: 38px;
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: -0.035em;
|
||||||
|
/* `balance` is for two or three lines. Over five it starts trimming lines
|
||||||
|
to lengths it likes and leaves a ragged right edge instead of a filled
|
||||||
|
column. */
|
||||||
|
text-wrap: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
font-size: 29px;
|
||||||
|
line-height: 1.06;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 19px;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
/* The hero scrim runs left-to-right, which on a 375px screen means it runs
|
||||||
|
across the whole photo: the image is paid for and then buried. On phones
|
||||||
|
it turns vertical instead, so the equipment reads across the top third and
|
||||||
|
the copy sits on solid ground below it. */
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.hero-scrim {
|
||||||
|
background: linear-gradient(
|
||||||
|
180deg,
|
||||||
|
/* The header is transparent over the hero and its logo is white; the
|
||||||
|
wave wall behind it is cream. This first stop is what keeps it
|
||||||
|
readable. */ rgb(4 24 41 / 0.82) 0%,
|
||||||
|
rgb(4 24 41 / 0.3) 12%,
|
||||||
|
rgb(4 24 41 / 0.34) 22%,
|
||||||
|
rgb(4 24 41 / 0.62) 34%,
|
||||||
|
rgb(5 28 47 / 0.93) 54%,
|
||||||
|
rgb(4 22 37 / 0.99) 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* At 375px `cover` fits the photo by height, so the only part left above
|
||||||
|
the copy is the strip the room happens to put at the top — ceiling
|
||||||
|
tiles. Zooming past cover and dropping the frame lands the backlit wave
|
||||||
|
wall in that strip instead, which is the one thing in this photo worth
|
||||||
|
showing at 110px tall. */
|
||||||
|
.hero-photo {
|
||||||
|
background-size: auto 150%;
|
||||||
|
background-position: 55% 72%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Room for the rail indicator that replaced the "swipe sideways" hints. */
|
||||||
|
.scroll-cards {
|
||||||
|
padding-bottom: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes sheet-in {
|
||||||
|
from {
|
||||||
|
transform: translateY(16px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes veil-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sheet-in {
|
||||||
|
animation: sheet-in 0.26s cubic-bezier(0.2, 0.7, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.veil-in {
|
||||||
|
animation: veil-in 0.2s ease;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Turns a `.scroll-cards` row back into an ordinary grid. Applied with a
|
/* Turns a `.scroll-cards` row back into an ordinary grid. Applied with a
|
||||||
breakpoint prefix, because sections switch over at different widths. */
|
breakpoint prefix, because sections switch over at different widths. */
|
||||||
@utility grid-still {
|
@utility grid-still {
|
||||||
|
|||||||
Reference in New Issue
Block a user