feat(mera): публичный лэндинг «МЕРА» — контент и вёрстка #2615
16 changed files with 2424 additions and 97 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
65
tradein-mvp/frontend/src/app/mera-public/_components/Faq.tsx
Normal file
65
tradein-mvp/frontend/src/app/mera-public/_components/Faq.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
295
tradein-mvp/frontend/src/app/mera-public/content.ts
Normal file
295
tradein-mvp/frontend/src/app/mera-public/content.ts
Normal 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-ФЗ — это отдельная платная процедура с выездом.",
|
||||
"«Мера» отвечает на другой вопрос: за сколько эта квартира реально продаётся на рынке сегодня.",
|
||||
],
|
||||
},
|
||||
];
|
||||
896
tradein-mvp/frontend/src/app/mera-public/landing.module.css
Normal file
896
tradein-mvp/frontend/src/app/mera-public/landing.module.css
Normal 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;
|
||||
}
|
||||
78
tradein-mvp/frontend/src/app/mera-public/layout.tsx
Normal file
78
tradein-mvp/frontend/src/app/mera-public/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
tradein-mvp/frontend/src/app/mera-public/page.tsx
Normal file
30
tradein-mvp/frontend/src/app/mera-public/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
161
tradein-mvp/frontend/src/app/mera-public/privacy/page.tsx
Normal file
161
tradein-mvp/frontend/src/app/mera-public/privacy/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
65
tradein-mvp/frontend/src/app/mera-public/theme.ts
Normal file
65
tradein-mvp/frontend/src/app/mera-public/theme.ts
Normal 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;
|
||||
142
tradein-mvp/frontend/src/components/auth/GuardedRoute.tsx
Normal file
142
tradein-mvp/frontend/src/components/auth/GuardedRoute.tsx
Normal 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}</>;
|
||||
}
|
||||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue