feat(mera): публичный лэндинг «МЕРА» — контент и вёрстка #2615

Merged
lekss361 merged 1 commit from feat/mera-landing into main 2026-08-01 20:03:56 +00:00
16 changed files with 2424 additions and 97 deletions

View file

@ -0,0 +1,232 @@
"use client";
/**
* AddressForm поле адреса на первом экране.
*
* ЧТО ЭТА ФОРМА ДЕЛАЕТ СЕГОДНЯ И ПОЧЕМУ ИМЕННО ТАК
*
* Она не считает цену и не притворяется, что считает. Причина техническая и
* жёсткая: `rbac_guard` (backend/app/core/rbac.py) пропускает анонима только на
* пути из `_PUBLIC_PATHS`, а `/api/v1/geocode/suggest` и
* `/api/v1/trade-in/estimate` туда не входят любой запрос отсюда вернул бы
* 401. Открытие анонимного периметра отдельный backend-PR, вне границ этой
* задачи. Поэтому здесь честная валидация на клиенте + прямой ответ «публичный
* расчёт ещё не открыт» вместо фейкового спиннера.
*
* Что форма всё-таки делает по-настоящему:
* - проверяет, что адрес введён;
* - требует явно назвать город и не подставляет Екатеринбург молча. Это ровно
* тот баг, который чинил бэкенд в #2576: житель Нижнего Тагила вводил
* «Ленина, 1» и получал уверенную цену по одноимённой улице в ЕКБ. Правило
* из шапки `lib/city-registry.ts` город считается известным только если
* пользователь его выбрал ИЛИ `detectCityInText` нашёл его в тексте;
* - если названного города нет в покрытии мягко и честно говорит про
* Свердловскую область, не обещая «оценим любую квартиру в РФ».
*
* Осознанно НЕ переиспользован автокомплит из закрытого контура
* (ParamsPanel.tsx / AddressInput.tsx): он ходит в `/geocode/suggest` через
* `useGeocodeSuggest`, что для анонима = 401. Тянуть сюда хуки B2B-контура
* (useMe/useQuota/useHistory и соседей) запрещено публичный экран не должен
* иметь к ним доступа даже теоретически.
*
* Когда бэкенд откроет анонимные ручки: переключить `PUBLIC_ESTIMATE_ENABLED`
* в content.ts и заменить ветку `notLaunched` в `handleSubmit` на реальный
* переход/запрос (комбобокс подсказок по образцу ParamsPanel.tsx, вместе с
* его клавиатурной моделью и sr-live-регионом).
*/
import { useId, useRef, useState } from "react";
import type { FormEvent } from "react";
import { detectCityInText } from "@/lib/city-registry";
import {
COVERED_CITIES,
PRIMARY_CITY,
PUBLIC_ESTIMATE_ENABLED,
REGION_NAME,
SECONDARY_CITIES,
} from "../content";
import styles from "../landing.module.css";
/** Значение <option> «моего города нет в списке». */
const OTHER_CITY = "__other__";
type Feedback =
| { kind: "none" }
| { kind: "error"; field: "address" | "city"; text: string }
| { kind: "info"; title: string; lines: readonly string[] };
const NONE: Feedback = { kind: "none" };
export function AddressForm() {
const addressId = useId();
const cityId = useId();
const feedbackId = useId();
const [address, setAddress] = useState("");
const [city, setCity] = useState("");
const [feedback, setFeedback] = useState<Feedback>(NONE);
const addressRef = useRef<HTMLInputElement>(null);
const cityRef = useRef<HTMLSelectElement>(null);
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const trimmed = address.trim();
if (!trimmed) {
setFeedback({
kind: "error",
field: "address",
text: "Напишите улицу и номер дома — например, «Ленина, 5».",
});
addressRef.current?.focus();
return;
}
// Город известен, только если его выбрали руками или назвали в тексте
// адреса. Ничего не додумываем — см. шапку файла.
const detected = detectCityInText(trimmed);
const resolvedCity =
city === OTHER_CITY ? OTHER_CITY : city || detected || "";
if (!resolvedCity) {
setFeedback({
kind: "error",
field: "city",
text: "Выберите город: одинаковые названия улиц есть в разных городах области, и без города цена будет не про вашу квартиру.",
});
cityRef.current?.focus();
return;
}
if (resolvedCity === OTHER_CITY) {
setFeedback({
kind: "info",
title: `Пока мы считаем только по одному региону — ${REGION_NAME}`,
lines: [
`Данные мы собираем сами, город за городом: полностью — ${PRIMARY_CITY}, частично — ${SECONDARY_CITIES.join(", ")}. По остальным адресам оценка была бы догадкой, поэтому мы её не показываем.`,
],
});
return;
}
if (city === "" && detected) {
// Город распознали в тексте — синхронизируем селект, чтобы человек видел,
// что именно мы поняли, и мог поправить.
setCity(detected);
}
if (PUBLIC_ESTIMATE_ENABLED) {
// TODO(backend-периметр): здесь появится реальный расчёт. Отдельный PR.
return;
}
// Если город уже назван в самом тексте адреса — не дублируем его в эхо
// («Серов, Серов, Ленина 3»).
const echo =
detected === resolvedCity ? trimmed : `${resolvedCity}, ${trimmed}`;
setFeedback({
kind: "info",
title: "Расчёт по адресу мы ещё не открыли для всех",
lines: [
`Адрес выглядит как наш: ${echo}. Но публичная оценка пока выключена — сервис работает по доступу для партнёров, и мы не хотим показывать заглушку вместо цены.`,
"Оценка появится на этой же странице. Если вопрос срочный — напишите нам в поддержку, ссылка в подвале.",
],
});
}
const addressInvalid =
feedback.kind === "error" && feedback.field === "address";
const cityInvalid = feedback.kind === "error" && feedback.field === "city";
const describedBy = feedback.kind === "none" ? undefined : feedbackId;
return (
<form className={styles.form} onSubmit={handleSubmit} noValidate>
<div className={styles.formRow}>
<div className={`${styles.field} ${styles.fieldCity}`}>
<label className={styles.label} htmlFor={cityId}>
Город
</label>
<select
id={cityId}
ref={cityRef}
className={`${styles.select} ${cityInvalid ? styles.inputInvalid : ""}`}
value={city}
onChange={(event) => {
setCity(event.target.value);
setFeedback(NONE);
}}
aria-invalid={cityInvalid || undefined}
aria-describedby={cityInvalid ? describedBy : undefined}
>
<option value="">Выберите город</option>
{COVERED_CITIES.map((label) => (
<option key={label} value={label}>
{label}
</option>
))}
<option value={OTHER_CITY}>Другой город</option>
</select>
</div>
<div className={`${styles.field} ${styles.fieldAddress}`}>
<label className={styles.label} htmlFor={addressId}>
Улица и дом
</label>
<input
id={addressId}
ref={addressRef}
className={`${styles.input} ${addressInvalid ? styles.inputInvalid : ""}`}
type="text"
name="address"
autoComplete="street-address"
enterKeyHint="go"
placeholder="Например, Ленина, 5"
value={address}
onChange={(event) => {
setAddress(event.target.value);
setFeedback(NONE);
}}
aria-invalid={addressInvalid || undefined}
aria-describedby={addressInvalid ? describedBy : undefined}
/>
</div>
{/* Кнопка НИКОГДА не disabled по валидности: disabled-кнопка не
диспатчит submit, и невалидная попытка молча ничего бы не делала
вместо объяснения (тот же фикс, что в v2/LeadForm.tsx). */}
<button type="submit" className={styles.cta}>
Узнать цену
</button>
</div>
<p className={styles.formHint}>
Ничего не спишется и не позвонит: телефон мы спрашиваем, только если вы
сами оставите заявку.
</p>
{/* Живая область объявляется скринридеру при любой смене содержимого.
Держим её в DOM постоянно регион, добавленный в момент ошибки,
часть скринридеров не озвучивает. */}
<div id={feedbackId} role="status" aria-live="polite">
{feedback.kind === "error" && (
<div className={`${styles.formFeedback} ${styles.formFeedbackError}`}>
<p className={styles.formFeedbackText}>{feedback.text}</p>
</div>
)}
{feedback.kind === "info" && (
<div className={styles.formFeedback}>
<p className={styles.formFeedbackTitle}>{feedback.title}</p>
{feedback.lines.map((line) => (
<p key={line} className={styles.formFeedbackText}>
{line}
</p>
))}
</div>
)}
</div>
</form>
);
}

View file

@ -0,0 +1,55 @@
/**
* DataSources блок доверия «откуда мы берём цифры». Серверный компонент.
*
* Названия площадок НЕ вбиты строками: группы собираются в content.ts из
* `lib/source-registry.ts`, который и есть единственный источник правды по
* источникам (контракт честности #2211). Добавится площадка в реестр она
* появится здесь сама; исчезнет исчезнет и тут.
*
* Логотипов площадок нет намеренно: чужие товарные знаки на публичной странице
* отдельный юридический вопрос, а картинки пришлось бы тянуть с чужих
* доменов (запрещено). Текстовые чипы решают ту же задачу.
*/
import { SOURCE_GROUPS } from "../content";
import styles from "../landing.module.css";
export function DataSources() {
return (
<section className={styles.section} aria-labelledby="sources-title">
<div className={styles.container}>
<div className={styles.sectionHead}>
<h2 id="sources-title" className={styles.h2}>
Откуда мы берём данные
</h2>
{/* Без числа групп в тексте: группы выводятся из реестра источников,
и «два типа данных» уже однажды разъехалось с кодом оценочные
модели площадок участвуют в расчёте (estimator.py, IMV/Yandex
blend), но в тексте их не было. */}
<p className={styles.sectionLead}>
Мы не опрашиваем экспертов и не берём цифры из головы. Данные
разного происхождения отвечают на разные вопросы поэтому мы держим
их раздельно и показываем, что откуда.
</p>
</div>
<div className={styles.sourceGroups}>
{SOURCE_GROUPS.map((group) => (
<div key={group.title} className={styles.sourceGroup}>
<h3 className={styles.sourceGroupTitle}>{group.title}</h3>
<ul className={styles.sourceChips} role="list">
{group.items.map((item) => (
<li key={item} className={styles.sourceChip}>
<span className={styles.sourceChipDot} aria-hidden="true" />
{item}
</li>
))}
</ul>
<p className={styles.sourceGroupNote}>{group.note}</p>
</div>
))}
</div>
</div>
</section>
);
}

View file

@ -0,0 +1,65 @@
/**
* Faq аккордеон на нативных <details>/<summary>. Серверный компонент.
*
* Почему без "use client": браузер уже умеет раскрывать details по Enter/Space,
* ставит фокус на summary и сам сообщает состояние скринридеру. Самописный
* аккордеон на useState потребовал бы вручную воспроизвести aria-expanded,
* управление фокусом и клавиатуру и работал бы хуже до гидратации. Клиентский
* JS здесь не нужен вообще.
*/
import { FAQ } from "../content";
import styles from "../landing.module.css";
export function Faq() {
return (
<section
className={`${styles.section} ${styles.sectionAlt}`}
aria-labelledby="faq-title"
>
<div className={styles.container}>
<div className={styles.sectionHead}>
<h2 id="faq-title" className={styles.h2}>
Частые вопросы
</h2>
</div>
<div className={styles.faqList}>
{FAQ.map((item) => (
<details key={item.id} className={styles.faqItem}>
<summary className={styles.faqSummary}>
<span>{item.q}</span>
<ChevronIcon />
</summary>
<div className={styles.faqAnswer}>
{item.a.map((paragraph) => (
<p key={paragraph}>{paragraph}</p>
))}
</div>
</details>
))}
</div>
</div>
</section>
);
}
function ChevronIcon() {
return (
<svg
className={styles.faqChevron}
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
>
<polyline points="6 9 12 15 18 9" />
</svg>
);
}

View file

@ -0,0 +1,87 @@
/**
* Hero первый экран: что это, для кого, и сразу граница по географии.
*
* Плашка «Свердловская область» стоит ДО формы намеренно (решение владельца):
* человек должен узнать про ограничение раньше, чем потратит время на ввод
* адреса, а не после. Города берутся из реестра (`COVERED_CITIES`), не из
* строки, иначе разъедутся с реальным покрытием сбора.
*
* Серверный компонент; клиентская часть только `AddressForm`.
*/
import { PRIMARY_CITY, REGION_NAME, SECONDARY_CITIES } from "../content";
import styles from "../landing.module.css";
import { AddressForm } from "./AddressForm";
export function Hero() {
return (
<section className={styles.hero} aria-labelledby="hero-title">
{/* Два вложенных div'а, а не два класса на одном: `.container` задаёт
общую 1120px-сетку страницы и центрирует её, `.heroInner` узкую
колонку измерения (720px) ВНУТРИ неё, прижатую к левому краю. Пока оба
класса висели на одном элементе, побеждал max-width: 720px, и весь
первый экран уезжал вправо относительно всех секций ниже (на 1440px
на 200px). */}
<div className={styles.container}>
<div className={styles.heroInner}>
<p className={styles.eyebrow}>Мера · оценка квартиры</p>
<h1 id="hero-title" className={styles.h1}>
Сколько на самом деле стоит ваша квартира
</h1>
<p className={styles.heroLead}>
Введите адрес покажем, за сколько продаются похожие квартиры рядом
и, если по вашему дому или поблизости есть зарегистрированные
сделки, за сколько их реально покупают. Без звонка риелтора и без
визита оценщика.
</p>
{/* Покрытие подано неравномерно намеренно см. COVERED_CITIES в
content.ts: список городов в реестре карта покрытия. */}
<div className={styles.regionBadge}>
<p className={styles.regionBadgeTitle}>
<PinIcon />
{REGION_NAME}
</p>
<p className={styles.regionBadgeCities}>
Полное покрытие {PRIMARY_CITY}. По остальным городам области (
{SECONDARY_CITIES.join(", ")}) данных меньше, и оценка там может
быть ориентировочной. По другим регионам не считаем вовсе не
хотим гадать.
</p>
</div>
<AddressForm />
<p className={styles.heroNote}>
«Мера» сервис оценки вторичного жилья по рыночным данным. Мы не
покупаем квартиры и не берём их на продажу: наша работа показать
цифру и то, откуда она взялась.
</p>
</div>
</div>
</section>
);
}
function PinIcon() {
return (
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
>
<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0z" />
<circle cx="12" cy="10" r="3" />
</svg>
);
}

View file

@ -0,0 +1,46 @@
/**
* HowItWorks три шага. Серверный компонент.
*
* Разметка упорядоченный список <ol>: порядок шагов несёт смысл, и
* скринридер объявит «список из 3 элементов, элемент 1», без декоративных
* кружков с цифрами.
*
* `role="list"` не избыточность. WebKit СНИМАЕТ роли list/listitem со списка,
* у которого `list-style: none` (а он здесь есть, см. `.steps`), и в
* Safari/VoiceOver порядковый номер исчезал бы полностью: из семантики из-за
* этого quirk'а, из текста потому что видимая подпись «ШАГ N» помечена
* aria-hidden именно чтобы не дублировать семантику. Атрибут возвращает роли
* обратно. То же самое сделано у остальных списков лэндинга с list-style: none.
*/
import { STEPS } from "../content";
import styles from "../landing.module.css";
export function HowItWorks() {
return (
<section className={styles.section} aria-labelledby="how-title">
<div className={styles.container}>
<div className={styles.sectionHead}>
<h2 id="how-title" className={styles.h2}>
Как это работает
</h2>
<p className={styles.sectionLead}>
Три шага, никакой регистрации на входе.
</p>
</div>
<ol className={styles.steps} role="list">
{STEPS.map((step, index) => (
<li key={step.title} className={styles.step}>
<span className={styles.stepNum} aria-hidden="true">
ШАГ {index + 1}
</span>
<h3 className={styles.stepTitle}>{step.title}</h3>
<p className={styles.stepText}>{step.text}</p>
</li>
))}
</ol>
</div>
</section>
);
}

View file

@ -0,0 +1,97 @@
/**
* SiteFooter подвал. Серверный компонент.
*
* Что здесь честно ОТСУТСТВУЕТ:
* - Реквизиты юрлица/ИП. В репозитории их нет (поиск по коду, бэкенду и
* разметке не дал ни наименования, ни ИНН/ОГРН), а выдумывать реквизиты
* оператора персональных данных на публичной странице нельзя. Блок
* рендерится, как только `LEGAL_ENTITY` в content.ts перестанет быть null;
* заполнить обязательно до открытия домена наружу 152-ФЗ требует
* идентифицируемого оператора.
* - E-mail поддержки: реального адреса в коде тоже нет. Единственный
* проверяемый канал телеграм-бот из `v2/SupportChatContext.tsx`.
*
* Внешняя ссылка проверяется `safeUrl` (правило frontend.md: ничего в href без
* валидации схемы) и открывается в новой вкладке с rel="noreferrer".
*/
import Link from "next/link";
import { safeUrl } from "@/lib/safeUrl";
import {
LEGAL_ENTITY,
PRIVACY_PATH,
REGION_NAME,
SUPPORT_TELEGRAM_LABEL,
SUPPORT_TELEGRAM_URL,
} from "../content";
import styles from "../landing.module.css";
export function SiteFooter() {
const telegramHref = safeUrl(SUPPORT_TELEGRAM_URL);
const year = new Date().getFullYear();
return (
<footer className={styles.footer}>
<div className={styles.container}>
<div className={styles.footerGrid}>
<div>
<div className={styles.wordmark}>
<span className={styles.wordmarkDot} aria-hidden="true" />
МЕРА
</div>
<p className={styles.footerText} style={{ marginTop: 10 }}>
Оценка квартир на вторичном рынке по сделкам и объявлениям.{" "}
{REGION_NAME}.
</p>
</div>
<div>
<p className={styles.footerTitle}>Связаться</p>
{telegramHref ? (
<p className={styles.footerText}>
Поддержка в Telegram:{" "}
<a
className={styles.link}
href={telegramHref}
target="_blank"
rel="noreferrer"
>
{SUPPORT_TELEGRAM_LABEL}
</a>
</p>
) : (
<p className={styles.footerText}>Контакты появятся к запуску.</p>
)}
</div>
<div>
<p className={styles.footerTitle}>Документы</p>
<ul className={styles.footerLinks} role="list">
<li>
<Link className={styles.link} href={PRIVACY_PATH}>
Обработка персональных данных
</Link>
</li>
</ul>
</div>
</div>
<div className={styles.footerBottom}>
<span>© {year} МЕРА</span>
{LEGAL_ENTITY && (
<span>
{LEGAL_ENTITY.name}, ИНН {LEGAL_ENTITY.inn},{" "}
{LEGAL_ENTITY.address}
</span>
)}
<span>
Оценка носит информационный характер и не является офертой или
отчётом об оценке.
</span>
</div>
</div>
</footer>
);
}

View file

@ -0,0 +1,26 @@
/**
* SiteHeader шапка лэндинга. Серверный компонент: интерактивности нет.
*
* Логотип НЕ является ссылкой на "/": в next.config.ts стоит redirect "/" "/v2",
* то есть клик по нему выкинул бы публичного посетителя в закрытое B2B-приложение
* (и дальше на /login). Пока публичная страница одна, вордмарк просто текст.
*/
import { REGION_NAME } from "../content";
import styles from "../landing.module.css";
export function SiteHeader() {
return (
<header className={styles.header}>
<div className={`${styles.container} ${styles.headerInner}`}>
<div className={styles.wordmark}>
<span className={styles.wordmarkDot} aria-hidden="true" />
МЕРА
</div>
<p className={styles.headerTag}>
Оценка вторичного жилья · {REGION_NAME}
</p>
</div>
</header>
);
}

View file

@ -0,0 +1,93 @@
/**
* WhatYouGet что человек получает по итогу. Серверный компонент.
*
* Каждый пункт соответствует блоку, который результат оценки рендерит сегодня
* (`v2/types.ts` ResultCard.value/range/ppm/delta/ResultMeta.builtOn).
* Ничего «планируемого» и ничего недоступного анониму в списке нет правило и
* разбор по PDF в шапке content.ts.
*
* Дисклеймер про 135-ФЗ обязателен: «Мера» даёт рыночную оценку по
* сопоставимым объектам, а не отчёт аккредитованного оценщика.
*/
import { DELIVERABLES, DELIVERABLES_DISCLAIMER } from "../content";
import styles from "../landing.module.css";
export function WhatYouGet() {
return (
<section
className={`${styles.section} ${styles.sectionAlt}`}
aria-labelledby="get-title"
>
<div className={styles.container}>
<div className={styles.sectionHead}>
<h2 id="get-title" className={styles.h2}>
Что вы получите
</h2>
<p className={styles.sectionLead}>
Не «примерную стоимость», а расчёт, который видно насквозь.
</p>
</div>
<ul className={styles.deliverables} role="list">
{DELIVERABLES.map((item) => (
<li key={item.title} className={styles.deliverable}>
<CheckIcon />
<div>
<h3 className={styles.deliverableTitle}>{item.title}</h3>
<p className={styles.deliverableText}>{item.text}</p>
</div>
</li>
))}
</ul>
<p className={styles.disclaimer}>
<InfoIcon />
<span>{DELIVERABLES_DISCLAIMER}</span>
</p>
</div>
</section>
);
}
function CheckIcon() {
return (
<svg
className={styles.checkIcon}
width="17"
height="17"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.4"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
>
<polyline points="20 6 9 17 4 12" />
</svg>
);
}
function InfoIcon() {
return (
<svg
width="17"
height="17"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
focusable="false"
style={{ flex: "0 0 auto", marginTop: 2 }}
>
<circle cx="12" cy="12" r="9" />
<line x1="12" y1="11" x2="12" y2="16.5" />
<line x1="12" y1="7.6" x2="12" y2="7.7" />
</svg>
);
}

View file

@ -0,0 +1,295 @@
/**
* content весь текст и все внешние ссылки публичного лэндинга «МЕРА» в одном
* файле, чтобы обещания продукта можно было отревьюить одним чтением, не
* вылавливая строки по компонентам.
*
* ПРАВИЛО ЧЕСТНОСТИ (продолжение контракта из `lib/source-registry.ts` и
* `lib/city-registry.ts`): на этой странице не должно быть ни одного
* утверждения, которого не делает код.
* - Никаких цифр («N объявлений в базе», «точность 95%») таких величин в
* коде нет, посчитать их фронт не может.
* - Список городов и список источников НЕ хардкодятся здесь строками, а
* выводятся из реестров (`OBLAST_CITIES`, `SOURCES`) иначе они разъедутся
* с реальным покрытием при следующем расширении.
* - Обещания в «что вы получите» описывают ровно те блоки, которые сегодня
* рендерит результат оценки (`v2/types.ts` ResultCard/ResultMeta) И
* доступны тому, кому страница адресована. PDF-отчёта в списке НЕТ
* намеренно: `GET /api/v1/trade-in/estimate/{id}/pdf` защищён
* `_assert_estimate_access` (401 без `X-Authenticated-User`), в
* `rbac.py::_PUBLIC_PATHS` его нет и быть не может (ручка owner-scoped), а
* сама оценка живёт 24 часа (410 «estimate expired»). Анониму эта фича
* недоступна by design обещать её до появления anon-owner механизма
* нельзя.
*/
import { DEFAULT_CITY, OBLAST_CITIES } from "@/lib/city-registry";
import {
LIVE_LISTING_SOURCES,
SOURCES,
sourceLabel,
} from "@/lib/source-registry";
// ---------------------------------------------------------------------------
// Флаги состояния продукта
// ---------------------------------------------------------------------------
/**
* Включён ли публичный расчёт по адресу.
*
* Сегодня `false` и это не «недоделка фронта»: анонимный запрос к
* `/api/v1/geocode/suggest` и `/api/v1/trade-in/estimate` отбивается
* `rbac_guard` (backend/app/core/rbac.py::_PUBLIC_PATHS эти пути в белом
* списке отсутствуют), т.е. без отдельного backend-PR любая «живая» форма на
* лэндинге отдавала бы 401. Форма поэтому честно сообщает, что расчёт ещё не
* открыт, вместо имитации загрузки.
*
* Когда бэкенд откроет анонимный периметр переключить в `true` и подключить
* реальный сабмит в `_components/AddressForm.tsx` (там помечено TODO-местом).
*
* ГЕЙТ: это НЕ однострочник. Переключение в `true` делает ложными публичные
* утверждения, которые сегодня правдивы, поэтому вместе с флагом обязаны быть
* сделаны:
* 1. `privacy/page.tsx`, раздел «Что делает эта страница» он УЖЕ условный по
* этому флагу (ветка `true` описывает отправку и сохранение адреса);
* перечитать текст обеих веток перед включением.
* 2. Согласие на обработку ПДн должно фиксироваться ДО первого INSERT в
* `trade_in_estimates`: сегодня адрес физлица попадает в БД раньше любого
* согласия (`address` NOT NULL, `expires_at` применяется только на чтении).
* 3. Должен существовать реальный путь удаления данных в бэкенде нет ни
* DELETE-джоба в `app/tasks/**`, ни ручки erasure (проверено grep'ом);
* privacy-страница поэтому и не обещает удаление.
*/
export const PUBLIC_ESTIMATE_ENABLED: boolean = false;
// ---------------------------------------------------------------------------
// Контакты и юридическое
// ---------------------------------------------------------------------------
/**
* Телеграм-бот поддержки. Значение продублировано из
* `components/trade-in/v2/SupportChatContext.tsx::SUPPORT_BOT_URL` НАМЕРЕННО:
* тот модуль помечен "use client", и импорт константы из него в серверный
* компонент вернул бы client-reference, а не строку. Дублируется ровно так же,
* как `PHONE_PATTERN` в `v2/LeadForm.tsx` дублирует бэкендовый регэксп.
* При смене бота править оба места.
*/
export const SUPPORT_TELEGRAM_URL = "https://t.me/MERAsupport_bot";
export const SUPPORT_TELEGRAM_LABEL = "@MERAsupport_bot";
/**
* Реквизиты оператора персональных данных (наименование юрлица/ИП, ИНН, адрес).
* `null` потому что в репозитории их НЕТ: поиск по коду, бэкенду и разметке
* не дал ни ООО/ИП, ни ИНН/ОГРН. Выдумывать реквизиты на публичной странице
* нельзя, поэтому блок реквизитов просто не рендерится, пока значение null.
* Заполнить перед публичным запуском (обязательное требование 152-ФЗ).
*/
export const LEGAL_ENTITY: {
name: string;
inn: string;
address: string;
} | null = null;
/** Внутренний маршрут страницы про обработку персональных данных. */
export const PRIVACY_PATH = "/mera-public/privacy";
// ---------------------------------------------------------------------------
// География
// ---------------------------------------------------------------------------
export const REGION_NAME = "Свердловская область";
/**
* Города, которые сервис вообще умеет различать (это же список `city_hint` в
* форме). Берём из реестра, а не из строки иначе разъедется при расширении.
*
* ЭТО НЕ КАРТА ПОКРЫТИЯ. Реестр перечисляет опции city_hint, а не города с
* равным объёмом данных предупреждение стоит в шапке самого city-registry.ts.
* Фактическое положение дел (проверяемое по репозиторию):
* - `data/sql/179_scrape_schedules_seed_oblast_city_sweeps.sql` сеет ВСЕ 15
* областных city-sweep (avito/cian/yandex × 5 городов) с `enabled = false`
* и помечен «!!! DORMANT BY DESIGN !!! Оператор включает ВРУЧНУЮ по
* одному городу за раз»; ни одна последующая миграция их не включает.
* - Прод-замер, зафиксированный в шапке
* `data/sql/197_backfill_listings_city_from_url.sql` (read-only SELECT):
* Екатеринбург 26 770 объявлений, Нижний Тагил 551, Каменск-Уральский 244,
* Первоуральск 95, Серов 25, Верхняя Пышма 21.
* Поэтому публично мы НЕ подаём шесть городов как равнозначные: полное
* покрытие один город, остальные идут с честной оговоркой.
*/
export const COVERED_CITIES: readonly string[] = OBLAST_CITIES.map(
(c) => c.label,
);
/** Город с полным покрытием сбора (он же дефолт формы) — сегодня Екатеринбург. */
export const PRIMARY_CITY: string = DEFAULT_CITY.label;
/** Остальные города области: сбор заведён, но данных кратно меньше. */
export const SECONDARY_CITIES: readonly string[] = OBLAST_CITIES.filter(
(c) => c.id !== DEFAULT_CITY.id,
).map((c) => c.label);
// ---------------------------------------------------------------------------
// Источники данных
// ---------------------------------------------------------------------------
export interface SourceGroup {
readonly title: string;
readonly items: readonly string[];
readonly note: string;
}
/**
* Три группы источников. Листинговые выводятся из `LIVE_LISTING_SOURCES`
* (реестр помечает их как «источники, реально дающие аналоги»), сделки
* Росреестр, оценочные модели `kind: "valuation"` из того же реестра.
*
* ПОЧЕМУ ТРЕТЬЯ ГРУППА ЕСТЬ (а не «два типа данных», как было). Оценки площадок
* не украшение экрана: в `backend/app/services/estimator.py` (блок «#651: IMV
* / Yandex blend», Tier D — когда якоря по дому/500 м нет) медиана
* переписывается на `new_median` с весом `estimate_imv_blend_weight`,
* объяснение дополняется «Оценка скорректирована по», а `sources_used`
* пополняется `avito_imv`. Умолчать об этом значит утверждать на публичной
* странице то, чего код не делает.
*
* Осознанное сужение: в `SOURCES` у сделок есть ещё «Этажи» (kind: "deals"),
* но на публичной странице говорим только про Росреестр это продуктовое
* решение владельца («сделки Росреестра + объявления площадок»), а не
* недосмотр. Лейбл берём через `sourceLabel`, чтобы не разъехаться с реестром.
*/
export const SOURCE_GROUPS: readonly SourceGroup[] = [
{
title: "Зарегистрированные сделки",
items: [sourceLabel("rosreestr")],
note: "Цены, по которым квартиры действительно перешли к новым собственникам — по договорам купли-продажи. Сначала смотрим сделки по вашему дому, а если их мало — по ближайшему окружению.",
},
{
title: "Объявления о продаже",
items: LIVE_LISTING_SOURCES.map((s) => s.label),
note: "Что просят прямо сейчас за похожие квартиры: площадь, этаж, тип дома, состояние.",
},
{
title: "Оценочные модели площадок",
items: SOURCES.filter((s) => s.kind === "valuation").map((s) => s.label),
note: "Собственные оценки площадок мы не игнорируем, но и не выдаём за свои: они идут в дело как сверка, когда по дому не набралось ни сделок, ни близких аналогов. Если расчёт был скорректирован по такой оценке, это написано в самом отчёте.",
},
];
// ---------------------------------------------------------------------------
// Как это работает
// ---------------------------------------------------------------------------
export interface Step {
readonly title: string;
readonly text: string;
}
export const STEPS: readonly Step[] = [
{
title: "Указываете адрес",
text: "Город, улица и дом — это всё, что нужно на входе; площадь, этаж и число комнат уточняются на следующем шаге. Ничего про себя сообщать не нужно — телефон спрашиваем, только если вы сами захотите оставить заявку.",
},
{
title: "Мы собираем данные по дому и району",
text: "Сделки Росреестра и объявления с площадок — отбираем те, что сопоставимы с вашей квартирой по площади, этажу и типу дома.",
},
{
title: "Показываем цену и то, из чего она сложилась",
text: "Не одно число, а диапазон, цена за квадратный метр и то, на скольких сопоставимых объектах и сделках построен расчёт. Если данных по дому мало — это написано прямо в отчёте.",
},
];
// ---------------------------------------------------------------------------
// Что получает человек
// ---------------------------------------------------------------------------
export interface Deliverable {
readonly title: string;
readonly text: string;
}
export const DELIVERABLES: readonly Deliverable[] = [
{
title: "Диапазон цены",
text: "Нижняя, средняя и верхняя граница — вместо одного числа, которое всё равно не бывает точным.",
},
{
title: "Цена за квадратный метр",
text: "По вашей квартире и по сопоставимым объектам рядом — чтобы понимать, откуда взялась сумма.",
},
{
title: "Разница между объявлениями и сделками",
text: "Объявление — это запрашиваемая цена, сделка — та, по которой квартиру купили. Если по вашему дому и району есть зарегистрированные сделки, показываем оба числа и разрыв между ними; если их не нашлось — честно пишем, что данных нет, вместо прочерка.",
},
{
title: "На чём построен расчёт",
text: "Сколько нашлось сопоставимых квартир и сделок и насколько сильно они разошлись по цене. Если данных мало — так и написано, а не спрятано.",
},
];
/**
* Дисклеймер рядом со списком. Обязателен: «Мера» рыночная оценка по
* сопоставимым объектам, а не отчёт об оценке по 135-ФЗ.
*/
export const DELIVERABLES_DISCLAIMER =
"Это рыночная оценка по сопоставимым объектам, а не официальный отчёт оценщика: для банка, суда, опеки или нотариуса нужен отчёт аккредитованного оценщика.";
// ---------------------------------------------------------------------------
// Частые вопросы
// ---------------------------------------------------------------------------
export interface FaqItem {
readonly id: string;
readonly q: string;
readonly a: readonly string[];
}
export const FAQ: readonly FaqItem[] = [
{
id: "how-do-you-know",
q: "Откуда вы знаете, сколько стоит именно моя квартира?",
a: [
"По адресу мы находим ваш дом и смотрим, что происходило с похожими квартирами: какие сделки зарегистрированы по самому дому, а если их мало — по ближайшему окружению, и что сейчас продаётся рядом.",
"Сопоставимость считаем по понятным признакам — площадь, этаж, число комнат, тип дома. Итог — не мнение и не формула из воздуха: рядом с каждым числом видно, на скольких объектах и сделках оно построено и насколько они разошлись по цене.",
],
},
{
id: "how-accurate",
q: "Насколько это точно?",
a: [
"Мы намеренно показываем диапазон, а не одно число: реальная цена зависит от состояния квартиры, вида из окна и того, насколько срочно нужно продать.",
"Точность прямо зависит от того, сколько нашлось сопоставимых объектов. Поэтому мы всегда пишем, на скольких объектах построен расчёт, — и честно сообщаем, если данных по дому мало.",
],
},
{
id: "why-region",
q: "Почему только Свердловская область?",
a: [
`Мы собираем данные сами, город за городом, и по объёму эти города не равны: полнее всего покрыт ${PRIMARY_CITY}. По остальным городам области данных заметно меньше — там оценка скорее ориентировочная, и мы про это пишем, а не делаем вид, что разницы нет.`,
"Там, где сбора нет вовсе, оценка была бы догадкой с уверенным видом. Поэтому другие регионы мы не обещаем и добавляем их по мере появления реального покрытия, а не заранее.",
],
},
{
id: "vs-marketplace",
q: "Чем это отличается от калькулятора на сайте объявлений?",
a: [
"Калькулятор площадки считает по объявлениям этой же площадки — то есть по ценам, которые продавцы просят, а не получают.",
"Мы смотрим сразу несколько площадок и добавляем к объявлениям зарегистрированные сделки, чтобы была видна разница между запрашиваемой и реальной ценой. Оценки самих площадок при этом не выбрасываем — используем их как сверку, когда данных по дому не хватает.",
],
},
{
id: "personal-data",
q: "Что будет с моими данными?",
a: [
"Для расчёта нужны адрес и параметры квартиры. Имя, паспорт и документы на квартиру мы не спрашиваем.",
"Телефон появляется, только если вы сами решите оставить заявку, и всегда с отдельной галочкой согласия по 152-ФЗ. Заявка привязывается к вашему расчёту, чтобы с вами связались именно по нему. Подробнее — на странице про обработку персональных данных.",
],
},
{
id: "bank-report",
q: "Подойдёт для банка или суда?",
a: [
"Нет. Для ипотеки, суда, опеки и нотариуса нужен отчёт аккредитованного оценщика по 135-ФЗ — это отдельная платная процедура с выездом.",
"«Мера» отвечает на другой вопрос: за сколько эта квартира реально продаётся на рынке сегодня.",
],
},
];

View file

@ -0,0 +1,896 @@
/*
* landing.module.css вёрстка публичного лэндинга «МЕРА».
*
* Почему CSS-модуль, а не inline-стили (как в v2): лэндинг обязан быть
* адаптивным (B2C, основной трафик телефон), а медиа-запросы, :hover,
* :focus-visible, ::before и details[open] через `style={{}}` не выражаются.
* v2-компоненты порт фиксированного артборда 1536×1024 и переиспользованию
* на мобильной странице не подлежат; здесь собственная mobile-first сетка на
* тех же токенах.
*
* Все цвета/шрифты через var(--m-*), которые проставляет `theme.ts` на
* корневом <div> в layout.tsx. Хардкод hex здесь запрещён (шапка tokens.ts).
*
* Контраст: цвет текста берётся только из ink2/body/body2/muted на самом
* тёмном фоне страницы (--m-page-bg #dde6ef) у них 4.5:1 по замерам в
* tokens.ts. Акцент используется как заливка/обводка; белым по акценту
* набрана только кнопка (сплошной --m-accent-deep, 4.93:1) подробности в
* шапке theme.ts.
*
* Брейкпоинты: база 360px, затем 720px (планшет / 768) и 1080px (десктоп /
* 1280). Три штуки, больше не нужно.
*
* ТИПОГРАФИКА В px осознанное решение, а не недосмотр; не переписывать на rem
* «заодно». Перевод этого файла на rem САМ ПО СЕБЕ ничего бы не дал: корень
* задан жёстко в `app/globals.css` (`html, body { font-size: 14px }`), а rem
* считается от <html>. То есть пользовательская настройка размера шрифта в
* браузере гасится там, а не здесь, и rem-значения просто отмасштабировались бы
* от тех же 14px плюс лэндинг стал бы мельче (14px вместо 16px базы).
* Настоящий фикс снять хардкод с <html> в globals.css, но это перекраивает
* ВСЕ экраны закрытого контура (они порт фиксированного артборда 1536×1024) и
* делается отдельной задачей, не в PR про публичную страницу.
* Полноэкранный zoom работает и сейчас, поэтому WCAG 1.4.4 не нарушен.
*/
/* --------------------------------------------------------------------------
* Каркас
* -------------------------------------------------------------------------- */
.page {
min-height: 100vh;
display: flex;
flex-direction: column;
background: var(--m-page-bg);
color: var(--m-body);
/* Перебиваем глобальные body-стили из app/globals.css (Inter 14px, --bg-app):
лэндинг живёт в типографике «Меры», а не аналитической панели Site Finder. */
font-family: var(--m-font-sans);
font-size: 16px;
line-height: 1.55;
/* Табличные цифры глобально включены в globals.css ради выравнивания чисел в
таблицах; в продающем тексте они выглядят механически. */
font-variant-numeric: normal;
font-feature-settings: normal;
}
.container {
width: 100%;
max-width: 1120px;
margin-inline: auto;
padding-inline: 16px;
}
@media (min-width: 720px) {
.container {
padding-inline: 32px;
}
}
.main {
flex: 1 1 auto;
}
/* Ссылка «к содержимому» — видна только с клавиатуры. */
.skipLink {
position: absolute;
left: -9999px;
top: 0;
z-index: 10;
padding: 10px 16px;
border-radius: 0 0 8px 0;
background: var(--m-accent-deep);
color: var(--m-on-accent);
font-size: 14px;
font-weight: 600;
text-decoration: none;
}
.skipLink:focus {
left: 0;
}
/* Единое кольцо фокуса заметное на всех поверхностях лэндинга.
БЕЗ border-radius: свойства `outline-radius` в стандарте нет, и радиус здесь
применялся бы к самому элементу, а не к контуру. Специфичность (0,2,0) бьёт
.input/.cta/.faqItem (0,1,0), поэтому при табуляции углы поля, селекта и
кнопки скачком менялись с 8px на 4px, а у <summary> скругление появлялось из
ниоткуда. Современные браузеры и так рисуют outline по форме элемента. */
.page :focus-visible {
outline: 2px solid var(--m-accent-deep);
outline-offset: 2px;
}
/* --------------------------------------------------------------------------
* Шапка
* -------------------------------------------------------------------------- */
.header {
border-bottom: 1px solid var(--m-line-soft);
background: var(--m-surface-70);
backdrop-filter: blur(6px);
}
.headerInner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 56px;
padding-block: 10px;
}
.wordmark {
display: flex;
align-items: center;
gap: 9px;
font-size: 15px;
font-weight: 600;
letter-spacing: 0.32em;
color: var(--m-ink2);
}
.wordmarkDot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--m-accent);
flex: 0 0 auto;
}
.headerTag {
font-family: var(--m-font-mono);
font-size: 11px;
letter-spacing: 0.06em;
color: var(--m-muted);
text-align: right;
}
/* --------------------------------------------------------------------------
* Первый экран
* -------------------------------------------------------------------------- */
.hero {
background: var(--m-gradient-bg);
border-bottom: 1px solid var(--m-line-soft);
padding-block: 36px 44px;
}
@media (min-width: 720px) {
.hero {
padding-block: 60px 64px;
}
}
@media (min-width: 1080px) {
.hero {
padding-block: 76px 80px;
}
}
/* Колонка измерения первого экрана ВЛОЖЕНА в `.container` (см. Hero.tsx), а
не навешена на него же. На одном элементе побеждал этот max-width, hero
центрировался по вьюпорту в своих 720px, и его левый край не совпадал с
левым краем всех секций ниже (на 1440px расхождение 200px).
`margin-inline-end: auto` фиксирует прижатие к левому краю контейнера явно,
не полагаясь на дефолт блочного элемента. */
.heroInner {
max-width: 720px;
margin-inline-end: auto;
}
.eyebrow {
display: inline-flex;
align-items: center;
gap: 8px;
font-family: var(--m-font-mono);
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--m-muted);
margin: 0 0 14px;
}
.h1 {
margin: 0 0 14px;
font-size: 27px;
line-height: 1.2;
font-weight: 700;
letter-spacing: -0.01em;
color: var(--m-ink2);
text-wrap: balance;
}
@media (min-width: 720px) {
.h1 {
font-size: 38px;
}
}
@media (min-width: 1080px) {
.h1 {
font-size: 44px;
}
}
.heroLead {
margin: 0 0 20px;
font-size: 16px;
line-height: 1.6;
color: var(--m-body);
max-width: 34em;
}
@media (min-width: 720px) {
.heroLead {
font-size: 18px;
}
}
/* Плашка «работаем по области» — граница честно стоит на первом экране. */
.regionBadge {
display: flex;
flex-direction: column;
gap: 4px;
padding: 11px 14px;
margin-bottom: 22px;
border: 1px solid var(--m-info-border);
border-radius: 8px;
background: var(--m-badge-tint);
}
.regionBadgeTitle {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 600;
color: var(--m-ink2);
}
.regionBadgeCities {
margin: 0;
font-size: 13px;
line-height: 1.5;
color: var(--m-body2);
}
.heroNote {
margin: 18px 0 0;
font-size: 13px;
line-height: 1.55;
color: var(--m-muted);
max-width: 46em;
}
/* --------------------------------------------------------------------------
* Форма адреса
* -------------------------------------------------------------------------- */
.form {
position: relative;
padding: 16px;
border: 1px solid var(--m-line2);
border-radius: 10px;
background: var(--m-surface-85);
box-shadow: 0 1px 2px rgba(28, 44, 64, 0.04);
}
@media (min-width: 720px) {
.form {
padding: 20px;
}
}
.formRow {
display: flex;
flex-direction: column;
gap: 12px;
}
@media (min-width: 720px) {
.formRow {
flex-direction: row;
align-items: flex-end;
}
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.fieldAddress {
flex: 1 1 auto;
}
.fieldCity {
flex: 0 0 auto;
}
@media (min-width: 720px) {
.fieldCity {
width: 210px;
}
}
.label {
font-family: var(--m-font-mono);
font-size: 11px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--m-muted);
}
.input,
.select {
width: 100%;
min-width: 0;
box-sizing: border-box;
/* 48px комфортная зона нажатия на телефоне; 16px шрифт не даёт iOS
зумить страницу при фокусе на поле. */
height: 48px;
padding: 0 13px;
border: 1px solid var(--m-line);
border-radius: 8px;
background: var(--m-surface-98);
font-family: inherit;
font-size: 16px;
color: var(--m-ink2);
}
.select {
/* Нативный select на мобиле = системный пикер, ничего лучше не изобретаем. */
appearance: none;
padding-right: 34px;
background-image:
linear-gradient(45deg, transparent 50%, var(--m-body2) 50%),
linear-gradient(135deg, var(--m-body2) 50%, transparent 50%);
background-position:
calc(100% - 18px) 21px,
calc(100% - 13px) 21px;
background-size:
5px 5px,
5px 5px;
background-repeat: no-repeat;
}
.input::placeholder {
color: var(--m-hint);
}
.input:hover,
.select:hover {
border-color: var(--m-bracket);
}
.inputInvalid {
border-color: var(--m-danger);
}
.cta {
height: 48px;
flex: 0 0 auto;
padding: 0 22px;
border: none;
border-radius: 8px;
/* Сплошная заливка, НЕ градиент: у светлого --m-accent контраст с белым
3.36:1 ниже AA. У --m-accent-deep 4.93:1. */
background: var(--m-accent-deep);
color: var(--m-on-accent);
font-family: inherit;
font-size: 15px;
font-weight: 600;
letter-spacing: 0.02em;
cursor: pointer;
transition: filter 0.16s ease;
}
.cta:hover {
filter: brightness(0.92);
}
.cta:active {
transform: translateY(1px);
}
@media (prefers-reduced-motion: reduce) {
.cta {
transition: none;
}
.cta:active {
transform: none;
}
}
.formHint {
margin: 12px 0 0;
font-size: 12.5px;
line-height: 1.5;
color: var(--m-muted);
}
/* Ответ формы (ошибка / честное «расчёт ещё не открыт»). */
.formFeedback {
margin-top: 14px;
padding: 12px 14px;
border-radius: 8px;
border: 1px solid var(--m-info-border);
background: var(--m-info-bg);
font-size: 14px;
line-height: 1.55;
color: var(--m-body2);
}
.formFeedbackError {
border-color: var(--m-danger);
background: var(--m-surface-70);
}
.formFeedbackTitle {
margin: 0 0 4px;
font-size: 14px;
font-weight: 600;
color: var(--m-ink2);
}
.formFeedbackText {
margin: 0;
}
.formFeedbackText + .formFeedbackText {
margin-top: 8px;
}
/* --------------------------------------------------------------------------
* Секции
* -------------------------------------------------------------------------- */
.section {
padding-block: 40px;
border-bottom: 1px solid var(--m-line-soft2);
}
@media (min-width: 720px) {
.section {
padding-block: 60px;
}
}
.sectionAlt {
background: var(--m-surface-50);
}
.sectionHead {
max-width: 46em;
margin-bottom: 24px;
}
.h2 {
margin: 0 0 8px;
font-size: 22px;
line-height: 1.25;
font-weight: 700;
letter-spacing: -0.005em;
color: var(--m-ink2);
text-wrap: balance;
}
@media (min-width: 720px) {
.h2 {
font-size: 28px;
}
}
.sectionLead {
margin: 0;
font-size: 15px;
line-height: 1.6;
color: var(--m-body);
}
/* --------------------------------------------------------------------------
* Шаги
* -------------------------------------------------------------------------- */
.steps {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 14px;
grid-template-columns: 1fr;
}
@media (min-width: 1080px) {
.steps {
grid-template-columns: repeat(3, 1fr);
gap: 20px;
}
}
.step {
position: relative;
padding: 18px 18px 20px;
border: 1px solid var(--m-line2);
border-radius: 10px;
background: var(--m-surface-85);
}
/* Номер шага mono-подпись в теле карточки, а не декоративный кружок: сам
порядок несёт <ol>, поэтому подпись помечена aria-hidden и не дублирует
скринридеру «элемент 1 ШАГ 1». */
.stepNum {
display: block;
margin-bottom: 8px;
font-family: var(--m-font-mono);
font-size: 11px;
letter-spacing: 0.08em;
color: var(--m-muted);
}
.stepTitle {
margin: 0 0 6px;
font-size: 17px;
font-weight: 600;
line-height: 1.3;
color: var(--m-ink2);
}
.stepText {
margin: 0;
font-size: 14.5px;
line-height: 1.6;
color: var(--m-body);
}
/* --------------------------------------------------------------------------
* Что получает человек
* -------------------------------------------------------------------------- */
.deliverables {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 2px;
grid-template-columns: 1fr;
border: 1px solid var(--m-line2);
border-radius: 10px;
overflow: hidden;
background: var(--m-line-soft2);
}
@media (min-width: 720px) {
.deliverables {
grid-template-columns: repeat(2, 1fr);
}
/* Разделители здесь это фон контейнера, просвечивающий сквозь gap: 2px.
У приёма есть цена: при НЕЧЁТНОМ числе пунктов вторая ячейка последнего
ряда остаётся пустой, и сквозь неё видно сплошной серый прямоугольник
читается как сломанная карточка, а не как разделитель. Последний пункт в
таком случае растягиваем на всю строку. Правило страхует список от
будущих правок: сегодня пунктов чётное число, завтра может стать не так. */
.deliverable:last-child:nth-child(odd) {
grid-column: 1 / -1;
}
}
.deliverable {
display: flex;
gap: 12px;
padding: 16px 18px;
background: var(--m-surface-85);
}
.checkIcon {
flex: 0 0 auto;
margin-top: 3px;
color: var(--m-accent-deep);
}
.deliverableTitle {
margin: 0 0 4px;
font-size: 16px;
font-weight: 600;
line-height: 1.3;
color: var(--m-ink2);
}
.deliverableText {
margin: 0;
font-size: 14.5px;
line-height: 1.55;
color: var(--m-body);
}
.disclaimer {
display: flex;
gap: 10px;
margin: 16px 0 0;
padding: 13px 15px;
border: 1px solid var(--m-info-border);
border-radius: 8px;
background: var(--m-info-bg);
font-size: 14px;
line-height: 1.55;
color: var(--m-body2);
}
/* --------------------------------------------------------------------------
* Источники данных
* -------------------------------------------------------------------------- */
.sourceGroups {
display: grid;
gap: 14px;
grid-template-columns: 1fr;
}
@media (min-width: 720px) {
.sourceGroups {
grid-template-columns: repeat(2, 1fr);
gap: 20px;
}
}
.sourceGroup {
padding: 18px;
border: 1px solid var(--m-line2);
border-radius: 10px;
background: var(--m-surface-85);
}
.sourceGroupTitle {
margin: 0 0 12px;
font-size: 17px;
font-weight: 600;
color: var(--m-ink2);
}
.sourceChips {
list-style: none;
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 0 0 12px;
padding: 0;
}
.sourceChip {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 5px 11px;
border: 1px solid var(--m-line);
border-radius: 999px;
background: var(--m-surface-98);
font-size: 13.5px;
color: var(--m-ink2);
}
.sourceChipDot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--m-accent);
flex: 0 0 auto;
}
.sourceGroupNote {
margin: 0;
font-size: 14px;
line-height: 1.55;
color: var(--m-body);
}
/* --------------------------------------------------------------------------
* FAQ (нативный details доступность из коробки, без клиентского JS)
* -------------------------------------------------------------------------- */
.faqList {
display: grid;
gap: 10px;
max-width: 60em;
}
.faqItem {
border: 1px solid var(--m-line2);
border-radius: 10px;
background: var(--m-surface-85);
overflow: hidden;
}
.faqSummary {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 14px;
padding: 15px 16px;
cursor: pointer;
font-size: 16px;
font-weight: 600;
line-height: 1.4;
color: var(--m-ink2);
list-style: none;
}
.faqSummary::-webkit-details-marker {
display: none;
}
.faqSummary:hover {
background: var(--m-surface-98);
}
.faqChevron {
flex: 0 0 auto;
margin-top: 3px;
color: var(--m-body2);
transition: transform 0.18s ease;
}
.faqItem[open] .faqChevron {
transform: rotate(180deg);
}
@media (prefers-reduced-motion: reduce) {
.faqChevron {
transition: none;
}
}
.faqAnswer {
padding: 0 16px 16px;
}
.faqAnswer p {
margin: 0;
font-size: 14.5px;
line-height: 1.62;
color: var(--m-body);
}
.faqAnswer p + p {
margin-top: 10px;
}
/* --------------------------------------------------------------------------
* Подвал
* -------------------------------------------------------------------------- */
.footer {
padding-block: 28px 34px;
background: var(--m-surface-60);
border-top: 1px solid var(--m-line-soft);
}
.footerGrid {
display: grid;
gap: 20px;
grid-template-columns: 1fr;
}
@media (min-width: 720px) {
.footerGrid {
grid-template-columns: 1.2fr 1fr 1fr;
gap: 32px;
}
}
.footerTitle {
margin: 0 0 8px;
font-family: var(--m-font-mono);
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--m-muted);
}
.footerText {
margin: 0;
font-size: 14px;
line-height: 1.6;
color: var(--m-body2);
}
.footerText + .footerText {
margin-top: 6px;
}
/* gap: 0 разделение между пунктами теперь даёт вертикальный padding самих
ссылок (см. `.footerLinks .link`). Оставить и gap, и padding нельзя: цели
нажатия по 44px разъехались бы, а подвал вырос бы вдвое. */
.footerLinks {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0;
}
/* Ссылки набраны ink2, а не акцентом: --m-accent-deep на бледно-голубом фоне
даёт 3.90:1 ниже AA. Подчёркивание остаётся всегда, чтобы ссылка
опознавалась не только цветом. */
.link {
color: var(--m-ink2);
font-size: 14px;
text-decoration: underline;
text-underline-offset: 3px;
text-decoration-color: var(--m-bracket);
text-decoration-thickness: 1px;
}
.link:hover {
text-decoration-color: var(--m-accent-deep);
text-decoration-thickness: 2px;
}
/* Ссылки-ПУНКТЫ (подвал, « На главную») самостоятельные цели нажатия, а не
часть фразы: строка в 14px даёт высоту ~19px, то есть вдвое меньше ориентира
~44px, и в подвал на телефоне приходится целиться. Формальный минимум WCAG
2.5.8 они проходили и раньше, но «проходит формально» «удобно пальцем».
Инлайновую ссылку внутри предложения («Поддержка в Telegram: ») правило
намеренно НЕ трогает она под inline-исключением, и раздувать строку текста
было бы хуже, чем оставить как есть. */
.footerLinks .link,
.docBack {
display: inline-block;
min-height: 44px;
padding-block: 12px;
}
.footerBottom {
display: flex;
flex-wrap: wrap;
gap: 6px 18px;
margin-top: 24px;
padding-top: 16px;
border-top: 1px solid var(--m-line-soft);
font-size: 12.5px;
color: var(--m-muted);
}
/* --------------------------------------------------------------------------
* Страница про персональные данные
* -------------------------------------------------------------------------- */
.doc {
padding-block: 32px 56px;
max-width: 46em;
}
/* margin-bottom скомпенсирован на padding-block из правила выше: 8 + 12 = те же
20px визуального отступа, что были до увеличения зоны нажатия. */
.docBack {
margin-bottom: 8px;
}
.doc h2 {
margin: 28px 0 8px;
font-size: 19px;
font-weight: 600;
line-height: 1.3;
color: var(--m-ink2);
}
.doc p,
.doc li {
font-size: 15px;
line-height: 1.65;
color: var(--m-body);
}
.doc p {
margin: 0 0 10px;
}
.doc ul {
margin: 0 0 10px;
padding-left: 20px;
display: grid;
gap: 6px;
}

View file

@ -0,0 +1,78 @@
import type { Metadata } from "next";
import { IBM_Plex_Mono, Manrope } from "next/font/google";
import { REGION_NAME } from "./content";
import styles from "./landing.module.css";
import { landingVars } from "./theme";
import { SiteFooter } from "./_components/SiteFooter";
import { SiteHeader } from "./_components/SiteHeader";
/**
* Публичный (B2C) периметр «МЕРЫ»: лэндинг для собственника квартиры.
*
* ИЗОЛЯЦИЯ ОТ ЗАКРЫТОГО КОНТУРА главное требование этого дерева. Ни один
* файл под `app/mera-public/**` не импортирует хуки и компоненты B2B-части:
* useMe / useQuota / useBrand / useLogout / useEstimateHistory, RouteGuard,
* Topnav/UserMenu, провайдер чата поддержки. Единственные общие модули
* заведомо безопасные, без сети и авторизации: `v2/tokens.ts` (палитра),
* `lib/city-registry.ts` (города области), `lib/source-registry.ts` (реестр
* источников), `lib/safeUrl.ts` (валидация href).
*
* RouteGuard (`components/auth/RouteGuard.tsx`) отдаёт это поддерево ДО вызова
* `useMe()` на публичной странице запроса к `/api/v1/me` не происходит вовсе,
* см. `PublicRouteBypass` там же.
*
* ШРИФТЫ next/font/google, то есть self-hosted: файлы скачиваются на этапе
* сборки и раздаются с нашего домена, запроса к fonts.googleapis.com в рантайме
* нет. Требование «никаких внешних CDN» соблюдено (в отличие от Leaflet в
* v2-картах, который тянется с unpkg на лэндинге карт нет).
*
* robots: noindex сохранён намеренно. Маршрут наружу ещё не открыт (домен и
* периметр Caddy делаются отдельным PR); до этого момента страница не должна
* попадать в индекс. Снять флаг вместе с открытием домена.
*/
const manrope = Manrope({
subsets: ["latin", "cyrillic"],
weight: ["400", "500", "600", "700"],
variable: "--font-manrope",
display: "swap",
});
const plexMono = IBM_Plex_Mono({
subsets: ["latin", "cyrillic"],
weight: ["400", "500"],
variable: "--font-plex-mono",
display: "swap",
});
export const metadata: Metadata = {
title: "МЕРА — оценка квартиры на вторичном рынке",
description: `Узнайте рыночную стоимость квартиры по адресу: сделки Росреестра и объявления площадок. ${REGION_NAME}.`,
robots: { index: false, follow: false },
};
export default function MeraPublicLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div
className={`${manrope.variable} ${plexMono.variable} ${styles.page}`}
style={landingVars}
>
{/* Цель ссылки <main id="content"> с tabIndex={-1} (см. page.tsx и
privacy/page.tsx). Без tabIndex Safari/VoiceOver не переносит фокус на
фрагмент: он остаётся на самой ссылке, и следующий Tab возвращает
пользователя в шапку то есть skip-link не работает ровно в том
браузере, где он нужнее всего. */}
<a className={styles.skipLink} href="#content">
Перейти к содержимому
</a>
<SiteHeader />
{children}
<SiteFooter />
</div>
);
}

View file

@ -0,0 +1,30 @@
/**
* Публичный лэндинг «МЕРА» первое публичное лицо продукта.
*
* Серверный компонент: никаких хуков, никакого клиентского JS, кроме одного
* острова `AddressForm` (её "use client" оправдан состоянием поля и ответом
* на сабмит). FAQ-аккордеон клиентским НЕ является: он на нативных <details>.
*
* Порядок блоков продиктован тем, в каком порядке у собственника квартиры
* возникают вопросы: что это и работает ли у меня как это устроено что я
* получу можно ли этому верить а если у меня возражение как связаться.
*/
import { DataSources } from "./_components/DataSources";
import { Faq } from "./_components/Faq";
import { Hero } from "./_components/Hero";
import { HowItWorks } from "./_components/HowItWorks";
import { WhatYouGet } from "./_components/WhatYouGet";
import styles from "./landing.module.css";
export default function MeraPublicPage() {
return (
<main id="content" className={styles.main} tabIndex={-1}>
<Hero />
<HowItWorks />
<WhatYouGet />
<DataSources />
<Faq />
</main>
);
}

View file

@ -0,0 +1,161 @@
import type { Metadata } from "next";
import Link from "next/link";
import {
LEGAL_ENTITY,
PUBLIC_ESTIMATE_ENABLED,
SUPPORT_TELEGRAM_LABEL,
SUPPORT_TELEGRAM_URL,
} from "../content";
import styles from "../landing.module.css";
import { safeUrl } from "@/lib/safeUrl";
/**
* Страница «Обработка персональных данных» для публичного лэндинга.
*
* ЧТО ЭТО ЗА ДОКУМЕНТ. Это НЕ утверждённая политика по ст. 18.1 152-ФЗ:
* полноценная политика обязана называть оператора (наименование, ИНН, адрес), а
* этих данных в проекте нет см. `LEGAL_ENTITY` в content.ts. Выдумывать
* реквизиты на публичной странице нельзя, поэтому здесь честное описание
* того, что сервис делает с данными СЕГОДНЯ по факту кода:
*
* - расчёт: адрес + параметры квартиры (`TradeInEstimateInput`);
* - заявка: телефон + явное согласие галочкой (`v2/LeadForm.tsx`
* `POST /api/v1/trade-in/lead`), факт согласия сохраняется отдельно;
* - сама эта страница не делает ни одного запроса к API и не подключает
* счётчики (проверено: RouteGuard отдаёт публичный путь до useMe,
* `app/providers.tsx` поднимает только пустой QueryClient, шрифты
* self-hosted через next/font).
*
* ЧЕГО ЗДЕСЬ СОЗНАТЕЛЬНО НЕ НАПИСАНО:
* - «адрес и параметры квартиры это не данные о вас». Правовая
* квалификация не наше дело: заявка привязывается к конкретному расчёту
* (`TradeInLeadInput.estimate_id`), то есть телефон связывается с ранее
* сохранённым адресом. Финальную формулировку даёт юрист.
* - «мы удалим ваш телефон и заявку». Механизма удаления в бэкенде НЕТ:
* ни `DELETE FROM trade_in_leads/trade_in_estimates` в коде, ни
* retention/erasure-джоба среди `app/tasks/**` (проверено grep'ом);
* `expires_at` применяется только на чтении. Обещать удаление до появления
* процедуры нельзя это самое дорогое из обещаний.
*
* Раздел «Что делает эта страница» УСЛОВЕН по `PUBLIC_ESTIMATE_ENABLED`: пока
* расчёт выключен, адрес действительно не покидает браузер; после включения это
* перестанет быть правдой, и текст должен смениться вместе с флагом, а не
* когда-нибудь потом.
*
* Перед открытием домена наружу текст обязан быть заменён на утверждённую
* политику с реквизитами оператора.
*/
export const metadata: Metadata = {
title: "Обработка персональных данных — МЕРА",
robots: { index: false, follow: false },
};
export default function MeraPublicPrivacyPage() {
const telegramHref = safeUrl(SUPPORT_TELEGRAM_URL);
return (
<main id="content" className={styles.main} tabIndex={-1}>
<div className={`${styles.container} ${styles.doc}`}>
<Link
className={`${styles.link} ${styles.docBack}`}
href="/mera-public"
>
На главную
</Link>
<h1 className={styles.h2}>Обработка персональных данных</h1>
<p>
Здесь по-человечески описано, какие данные нужны сервису «Мера», зачем
и что с ними происходит.
</p>
<h2>Что нужно для оценки</h2>
<p>
Адрес дома и параметры квартиры: площадь, этаж, число комнат, тип
дома, состояние. Имя, паспорт и документы на квартиру мы не
спрашиваем, и сами по себе эти сведения описывают объект недвижимости.
</p>
<p>
При этом мы не делаем вид, что связи с вами нет совсем: если вы потом
оставите заявку, ваш телефон будет привязан именно к этому расчёту
то есть к конкретному адресу. Поэтому телефон и обращаемся с ним как с
персональными данными, с отдельным согласием.
</p>
<h2>Когда появляется телефон</h2>
<p>
Только если вы сами решите оставить заявку и поставите отдельную
галочку согласия на обработку персональных данных в соответствии с
Федеральным законом 152-ФЗ. Без этой галочки заявка не отправляется.
</p>
<p>
Телефон используется, чтобы связаться с вами по вашей же заявке.
Вместе с ним сохраняется сам факт согласия когда именно и на каком
тексте оно было дано.
</p>
<h2>Что делает эта страница</h2>
{PUBLIC_ESTIMATE_ENABLED ? (
<p>
Введённый адрес и параметры квартиры уходят на наш сервер, чтобы по
ним посчитать оценку, и сохраняются вместе с результатом расчёта
иначе отчёт нельзя было бы открыть повторно. Счётчиков и рекламных
пикселей на странице нет, шрифты отдаются с нашего домена, а не со
сторонних сервисов.
</p>
) : (
<p>
Ничего не отправляет. Пока публичная оценка не открыта, форма адреса
работает только в браузере: введённый адрес никуда не уходит и нигде
не сохраняется. Счётчиков и рекламных пикселей на странице нет,
шрифты отдаются с нашего домена, а не со сторонних сервисов.
</p>
)}
<h2>Как отозвать согласие</h2>
<p>
Напишите нам в поддержку обращение об отзыве согласия мы принимаем и
разбираем вручную, после чего перестаём использовать ваш телефон для
связи по заявке.{" "}
{telegramHref ? (
<>
Канал связи:{" "}
<a
className={styles.link}
href={telegramHref}
target="_blank"
rel="noreferrer"
>
{SUPPORT_TELEGRAM_LABEL}
</a>
.
</>
) : null}
</p>
<p>
Автоматической кнопки «удалить мои данные» в сервисе пока нет, и мы не
обещаем то, чего не умеем: порядок и сроки удаления будут описаны в
утверждённой политике обработки, которая появится здесь до открытия
публичного доступа.
</p>
<h2>Оператор</h2>
{LEGAL_ENTITY ? (
<p>
{LEGAL_ENTITY.name}, ИНН {LEGAL_ENTITY.inn}, {LEGAL_ENTITY.address}.
</p>
) : (
<p>
Реквизиты оператора персональных данных и утверждённая политика
обработки будут опубликованы здесь до открытия публичного доступа к
сервису. До этого момента страница доступна не публично, а по прямой
ссылке.
</p>
)}
</div>
</main>
);
}

View file

@ -0,0 +1,65 @@
/**
* theme мост между TS-токенами «Меры» и CSS-модулем лэндинга.
*
* Зачем: вёрстка лэндинга живёт в `landing.module.css` (нужны медиа-запросы,
* :hover/:focus-visible, ::before inline-стилями это не выражается), а
* единственный источник правды по цветам/шрифтам `v2/tokens.ts`. Чтобы не
* дублировать hex-литералы в CSS (прямой запрет в шапке tokens.ts), токены
* пробрасываются в CSS как кастомные свойства на корневом <div> лэндинга, а
* CSS ссылается на них через var(--m-*).
*
* ВАЖНО про контраст (WCAG AA 4.5:1): худший фон лэндинга `pageBg`
* (#dde6ef). На нём `accentDeep` (#0d6fd6) даёт лишь 3.90:1, а `accent`
* (#2e8bff) 3.36:1 даже на чистом белом. Поэтому акцентные цвета
* используются ТОЛЬКО как заливка/обводка/декор, но НИКОГДА как цвет текста:
* весь текст берёт ink2/body/body2/muted (у них запас проверен в tokens.ts).
* Единственное исключение белый текст на сплошной заливке accentDeep
* (4.93:1), см. `.cta` в landing.module.css.
*/
import type { CSSProperties } from "react";
import { tokens } from "@/components/trade-in/v2/tokens";
/**
* Набор CSS-переменных лэндинга. Приводится к CSSProperties: TS не знает про
* произвольные `--*` ключи, но React их корректно проставляет в style.
*/
export const landingVars = {
"--m-accent": tokens.accent,
"--m-accent-deep": tokens.accentDeep,
"--m-on-accent": tokens.onAccent,
"--m-ink": tokens.ink,
"--m-ink2": tokens.ink2,
"--m-body": tokens.body,
"--m-body2": tokens.body2,
"--m-muted": tokens.muted,
"--m-muted3": tokens.muted3,
"--m-hint": tokens.hint,
"--m-line": tokens.line,
"--m-line2": tokens.line2,
"--m-line-soft": tokens.lineSoft,
"--m-line-soft2": tokens.lineSoft2,
"--m-bracket": tokens.bracket,
"--m-success": tokens.success,
"--m-danger": tokens.danger,
"--m-info-bg": tokens.infoSoftBg,
"--m-info-border": tokens.infoSoftBorder,
"--m-badge-tint": tokens.badgeTint,
"--m-surface-50": tokens.surface.w50,
"--m-surface-60": tokens.surface.w60,
"--m-surface-70": tokens.surface.w70,
"--m-surface-85": tokens.surface.w85,
"--m-surface-98": tokens.surface.w98,
"--m-page-bg": tokens.pageBg,
"--m-gradient-bg": tokens.gradientBg,
"--m-font-sans": tokens.font.sans,
"--m-font-mono": tokens.font.mono,
} as CSSProperties;

View file

@ -0,0 +1,142 @@
"use client";
/**
* GuardedRoute вся RBAC-механика закрытого контура: запрос `/api/v1/me`,
* редирект на логин по 401, экраны отказа. Раньше жила прямо в `RouteGuard.tsx`.
*
* ПОЧЕМУ ВЫНЕСЕНО В ОТДЕЛЬНЫЙ МОДУЛЬ (а не «так аккуратнее»). Root-layout
* (`app/layout.tsx`) оборачивает в `RouteGuard` ВСЁ дерево приложения, включая
* публичный лэндинг `/mera-public`. Пока этот код лежал в одном модуле с
* `RouteGuard`, webpack складывал в чанк root-layout'а весь статический граф:
* `useMe` `lib/api` `lib/sessionId`, `isPathAllowed`, а через
* `NoAccessScreen` `AnonSupportWidget` `SupportChatProvider` ещё и клиент
* чата поддержки. Анонимный посетитель публичной страницы физически скачивал
* этот JS и мог прочитать в нём имена внутренних ручек (`/api/v1/me`,
* `/api/v1/trade-in/support`, `/api/v1/trade-in/support/anon`) и модель полей
* RBAC (`allowed_paths` / `deny_paths`). Утечки пользовательских данных не было
* ни один запрос не уходил, ни один компонент не рендерился, но
* information disclosure о внутреннем периметре был, и «изоляция публичной
* страницы» соблюдалась только на уровне выполнения, а не поставки бандла.
*
* `RouteGuard` подключает этот модуль через `next/dynamic`, то есть точкой
* разрыва графа. На публичном пути компонент не рендерится чанк не
* запрашивается закрытый код до анонима не доезжает вообще.
*
* SSR намеренно НЕ отключён (`ssr: false` не ставим): на сервере этот компонент
* и раньше отдавал `null` (`useMe` там всегда в isLoading), так что поведение
* закрытых страниц не меняется меняется только момент загрузки чанка на
* клиенте.
*
* Особенность tradein-mvp: Next.js basePath=/trade-in (см. `next.config.ts`).
* `usePathname()` возвращает путь БЕЗ basePath например на странице
* `gendsgn.ru/trade-in/scrapers/avito` хук вернёт `/scrapers/avito`.
* RBAC config (`auth/roles.yaml`) использует абсолютные пути сайта
* (`/trade-in/**`, `/trade-in/api/v1/admin/**`), поэтому перед проверкой
* isPathAllowed мы префиксим pathname через NEXT_PUBLIC_BASE_PATH.
*
* #2555 login redirect: `router.push()` (как и `usePathname()`) работает в
* пространстве путей БЕЗ basePath Next сам префиксит basePath на навигации
* (см. `next.config.ts` комментарий `basePath`). Поэтому `next=` в query
* строится из `rawPath` (БЕЗ basePath), а не `absolutePath` иначе
* `/login/page.tsx` сделал бы `router.push("/trade-in/history")`, и Next
* задвоил бы префикс в `/trade-in/trade-in/history`.
*/
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { NoAccessScreen } from "@/components/auth/NoAccessScreen";
import { HTTPError } from "@/lib/api";
import { isPathAllowed } from "@/lib/isPathAllowed";
import { useMe } from "@/lib/useMe";
const BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH ?? "";
// #801: dev/CI-only preview-маршрут (a11y/lighthouse) рендерится оффлайн без RBAC.
const ENABLE_PREVIEW = process.env.NEXT_PUBLIC_ENABLE_PREVIEW === "1";
export default function GuardedRoute({
rawPath,
children,
}: {
rawPath: string;
children: React.ReactNode;
}) {
const router = useRouter();
// Абсолютный путь сайта: BASE_PATH + rawPath. Аккуратно с двойным слэшем
// на `/`: `BASE_PATH = "/trade-in"` + `"/"` → `/trade-in/` (ок).
const absolutePath = BASE_PATH
? `${BASE_PATH}${rawPath === "/" ? "/" : rawPath}`
: rawPath;
const { data, isLoading, error } = useMe();
// #2555: /login сам себя не гейтит — иначе редирект-петля (401 на /me →
// редирект на /login → RouteGuard на /login опять видит 401 → редирект…).
const isLoginPage = rawPath === "/login";
// Prod-only: сессия истекла/отсутствует → уводим на логин вместо старого
// NoAccessScreen variant="session". Редирект — побочный эффект (нельзя
// router.push во время рендера), поэтому useEffect; пока он не сработал,
// рендерим null (см. return ниже), чтобы не мигал старый contents.
const shouldRedirectToLogin =
!isLoginPage &&
process.env.NODE_ENV === "production" &&
error instanceof HTTPError &&
error.status === 401;
useEffect(() => {
if (!shouldRedirectToLogin) return;
// PR #2562 review finding 3: deep-links carry их state в query (`/v2?id=
// <uuid>` — см. next.config.ts redirect comment про restore-by-id). Без
// `window.location.search` юзер, чья сессия истекла mid-session на такой
// ссылке, после логина попадал бы на голый `/v2` и терял отчёт. Effect
// — гарантированно client-side (useEffect тело никогда не бежит на SSR),
// поэтому `window` тут безопасен без typeof-guard.
const next = `${rawPath}${window.location.search}`;
router.push(`/login?next=${encodeURIComponent(next)}`);
}, [shouldRedirectToLogin, rawPath, router]);
// #801: preview-страница самодостаточна (свой QueryClient с фейковым me),
// RBAC к ней не применяем. Только под флагом — в проде по умолчанию выключено.
if (ENABLE_PREVIEW && rawPath.startsWith("/ui-preview")) {
return <>{children}</>;
}
if (isLoginPage) {
return <>{children}</>;
}
if (isLoading) return null;
if (error instanceof HTTPError && error.status === 401) {
// Dev without Caddy: 401 is normal, mount the app so local dev works.
if (process.env.NODE_ENV !== "production") return <>{children}</>;
// Prod: редирект уже запущен эффектом выше — ничего не рендерим, пока
// навигация не завершится (mounting children on 401 causes TanStack
// Query re-subscribe storm, см. историю до #2555 в git blame).
return null;
}
if (error instanceof HTTPError && error.status === 403) {
return <NoAccessScreen variant="user" />;
}
if (error) {
// Не 401/403 — 500 / сетевой сбой / таймаут. НЕ переиспользуем variant="user"
// (текст «аккаунт не привязан к роли» вводит в заблуждение при техническом
// сбое) — отдельный экран с честной формулировкой + reload CTA.
return <NoAccessScreen variant="error" />;
}
if (!data) return null;
// Пробный доступ закончился (#praktika): role=expired → спец-экран, а не generic path-deny.
if (data.role === "expired") {
return <NoAccessScreen variant="trial" />;
}
if (!isPathAllowed(data.allowed_paths, data.deny_paths, absolutePath)) {
return <NoAccessScreen variant="path" path={absolutePath} />;
}
return <>{children}</>;
}

View file

@ -1,118 +1,77 @@
"use client";
/**
* MIRROR of main frontend `frontend/src/components/auth/RouteGuard.tsx`
* keep in sync manually.
* РАСХОДИТСЯ с зеркалом `frontend/src/components/auth/RouteGuard.tsx` (Site
* Finder) намеренно и уже не является его копией: там нет ни публичного
* B2C-периметра, ни выноса гварда в отдельный чанк. Синхронизировать построчно
* больше нельзя при правках RBAC-логики править `GuardedRoute.tsx`, сверяясь
* с зеркалом по смыслу, а не по диффу.
*
* Особенность tradein-mvp: Next.js basePath=/trade-in (см. `next.config.ts`).
* `usePathname()` возвращает путь БЕЗ basePath например на странице
* `gendsgn.ru/trade-in/scrapers/avito` хук вернёт `/scrapers/avito`.
* RBAC config (`auth/roles.yaml`) использует абсолютные пути сайта
* (`/trade-in/**`, `/trade-in/api/v1/admin/**`), поэтому перед проверкой
* isPathAllowed мы префиксим pathname через NEXT_PUBLIC_BASE_PATH.
* Этот модуль намеренно ДЕРЖИТСЯ ПУСТЫМ по зависимостям: он импортирует только
* `usePathname` и `next/dynamic`. Причина `app/layout.tsx` оборачивает в него
* ВСЁ дерево, включая публичный лэндинг `/mera-public`, поэтому всё, что здесь
* импортировано статически, webpack кладёт в чанк root-layout'а и отдаёт
* анонимному посетителю публичной страницы. До выноса `GuardedRoute` в
* `next/dynamic` туда уезжали `useMe` `lib/api` `lib/sessionId`,
* `isPathAllowed` и через `NoAccessScreen` `AnonSupportWidget` клиент
* чата поддержки; в публичном JS читались имена внутренних ручек и модель
* RBAC-полей. Подробный разбор в шапке `GuardedRoute.tsx`.
*
* #2555 login redirect: `router.push()` (как и `usePathname()`) работает в
* пространстве путей БЕЗ basePath Next сам префиксит basePath на навигации
* (см. `next.config.ts` комментарий `basePath`). Поэтому `next=` в query
* строится из `rawPath` (БЕЗ basePath), а не `absolutePath` иначе
* `/login/page.tsx` сделал бы `router.push("/trade-in/history")`, и Next
* задвоил бы префикс в `/trade-in/trade-in/history`.
* НЕ добавляй сюда статических импортов из закрытого контура. Всё, что нужно
* гварду, живёт за `dynamic()`.
*/
import { useRouter, usePathname } from "next/navigation";
import { useEffect } from "react";
import dynamic from "next/dynamic";
import { usePathname } from "next/navigation";
import { NoAccessScreen } from "@/components/auth/NoAccessScreen";
import { HTTPError } from "@/lib/api";
import { isPathAllowed } from "@/lib/isPathAllowed";
import { useMe } from "@/lib/useMe";
// Точка разрыва графа модулей: отдельный чанк, который запрашивается ТОЛЬКО
// если компонент реально отрендерился, т.е. никогда — на публичном пути.
// ssr не отключаем: на сервере GuardedRoute и раньше отдавал null (useMe там
// всегда isLoading), поведение закрытых страниц не меняется.
const GuardedRoute = dynamic(() => import("@/components/auth/GuardedRoute"));
const BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH ?? "";
// #801: dev/CI-only preview-маршрут (a11y/lighthouse) рендерится оффлайн без RBAC.
const ENABLE_PREVIEW = process.env.NEXT_PUBLIC_ENABLE_PREVIEW === "1";
// Публичный (B2C) периметр МЕРЫ. В отличие от NEXT_PUBLIC_ENABLE_PREVIEW —
// ЭТОТ bypass ВСЕГДА включён (в т.ч. в проде): страница публична по
// продуктовому решению, а не временный QA-артефакт. Домен/rewrite для неё
// настраиваются отдельным PR — на поведение гварда это не влияет, он смотрит
// на путь.
//
// ⚠️ Пути здесь — БЕЗ basePath. `usePathname()` в Next возвращает путь в
// пространстве без префикса: в проде (basePath=/trade-in) на странице
// `gendsgn.ru/trade-in/mera-public` хук вернёт именно `/mera-public`. Тот же
// инвариант подтверждается тем, что `GuardedRoute` вынужден вручную клеить
// BASE_PATH обратно (`absolutePath`), чтобы сматчить globs из roles.yaml.
// Если добавлять сюда `/trade-in/mera-public` — bypass молча перестанет
// срабатывать в проде, и аноним поедет на /login.
const PUBLIC_PATHS = ["/mera-public"];
function isPublicPath(rawPath: string): boolean {
return PUBLIC_PATHS.some((p) => rawPath === p || rawPath.startsWith(`${p}/`));
}
interface RouteGuardProps {
children: React.ReactNode;
}
/**
* Внешняя оболочка: решает ТОЛЬКО по пути и не вызывает ни одного хука данных.
*
* Почему разделено на два компонента, а не «ранний return внутри одного»:
* хуки выполняются всегда, до любых early-return. Пока `useMe()` и
* redirect-эффект жили в одном компоненте с bypass'ом, на публичной странице в
* проде происходило вот что: фоновый GET /api/v1/me 401 эффект
* `shouldRedirectToLogin` уводил анонимного посетителя на
* `/login?next=%2Fmera-public`, полностью аннулируя «безусловный bypass».
* Вынос useMe() в дочерний `GuardedRoute` единственный способ гарантировать,
* что на публичном пути запроса к /me не происходит ВООБЩЕ (заодно исчезает
* лишний 401 в консоли посетителя и подписка TanStack Query).
*/
export function RouteGuard({ children }: RouteGuardProps) {
const rawPath = usePathname() ?? "/";
const router = useRouter();
// Абсолютный путь сайта: BASE_PATH + rawPath. Аккуратно с двойным слэшем
// на `/`: `BASE_PATH = "/trade-in"` + `"/"` → `/trade-in/` (ок).
const absolutePath = BASE_PATH
? `${BASE_PATH}${rawPath === "/" ? "/" : rawPath}`
: rawPath;
const { data, isLoading, error } = useMe();
// #2555: /login сам себя не гейтит — иначе редирект-петля (401 на /me →
// редирект на /login → RouteGuard на /login опять видит 401 → редирект…).
const isLoginPage = rawPath === "/login";
// Prod-only: сессия истекла/отсутствует → уводим на логин вместо старого
// NoAccessScreen variant="session". Редирект — побочный эффект (нельзя
// router.push во время рендера), поэтому useEffect; пока он не сработал,
// рендерим null (см. return ниже), чтобы не мигал старый contents.
const shouldRedirectToLogin =
!isLoginPage &&
process.env.NODE_ENV === "production" &&
error instanceof HTTPError &&
error.status === 401;
useEffect(() => {
if (!shouldRedirectToLogin) return;
// PR #2562 review finding 3: deep-links carry их state в query (`/v2?id=
// <uuid>` — см. next.config.ts redirect comment про restore-by-id). Без
// `window.location.search` юзер, чья сессия истекла mid-session на такой
// ссылке, после логина попадал бы на голый `/v2` и терял отчёт. Effect
// — гарантированно client-side (useEffect тело никогда не бежит на SSR),
// поэтому `window` тут безопасен без typeof-guard.
const next = `${rawPath}${window.location.search}`;
router.push(`/login?next=${encodeURIComponent(next)}`);
}, [shouldRedirectToLogin, rawPath, router]);
// #801: preview-страница самодостаточна (свой QueryClient с фейковым me),
// RBAC к ней не применяем. Только под флагом — в проде по умолчанию выключено.
if (ENABLE_PREVIEW && rawPath.startsWith("/ui-preview")) {
if (isPublicPath(rawPath)) {
return <>{children}</>;
}
if (isLoginPage) {
return <>{children}</>;
}
if (isLoading) return null;
if (error instanceof HTTPError && error.status === 401) {
// Dev without Caddy: 401 is normal, mount the app so local dev works.
if (process.env.NODE_ENV !== "production") return <>{children}</>;
// Prod: редирект уже запущен эффектом выше — ничего не рендерим, пока
// навигация не завершится (mounting children on 401 causes TanStack
// Query re-subscribe storm, см. историю до #2555 в git blame).
return null;
}
if (error instanceof HTTPError && error.status === 403) {
return <NoAccessScreen variant="user" />;
}
if (error) {
// Не 401/403 — 500 / сетевой сбой / таймаут. НЕ переиспользуем variant="user"
// (текст «аккаунт не привязан к роли» вводит в заблуждение при техническом
// сбое) — отдельный экран с честной формулировкой + reload CTA.
return <NoAccessScreen variant="error" />;
}
if (!data) return null;
// Пробный доступ закончился (#praktika): role=expired → спец-экран, а не generic path-deny.
if (data.role === "expired") {
return <NoAccessScreen variant="trial" />;
}
if (!isPathAllowed(data.allowed_paths, data.deny_paths, absolutePath)) {
return <NoAccessScreen variant="path" path={absolutePath} />;
}
return <>{children}</>;
return <GuardedRoute rawPath={rawPath}>{children}</GuardedRoute>;
}