feat(mera/b2c): лэндинг v3 по макету — превью на /mera-public/v3 (#2905)
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m28s
Deploy Trade-In / deploy (push) Successful in 1m4s
Deploy Trade-In / deploy-status (push) Successful in 1s
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m28s
Deploy Trade-In / deploy (push) Successful in 1m4s
Deploy Trade-In / deploy-status (push) Successful in 1s
This commit is contained in:
commit
ce9353fd48
19 changed files with 2909 additions and 12 deletions
|
|
@ -0,0 +1,154 @@
|
|||
/**
|
||||
* AccuracyV3 — «Точность»: KPI-плитки + таблица сверок прогноза с фактом
|
||||
* сделки (макет v3, ~строки 231-283, id="accuracy"). Серверный компонент.
|
||||
*
|
||||
* KPI-значения — из `ACCURACY_PLACEHOLDER` (marketing-v3.ts), не хардкод: это
|
||||
* плейсхолдерные маркетинговые величины, гейтящиеся `noindex` до подтверждения
|
||||
* замером (см. шапку файла).
|
||||
*
|
||||
* Таблица сверок — `PROOF_ROWS_PLACEHOLDER`. В самом макете под неё не было
|
||||
* заполнено ни одной реальной строки (только `{{ r.addr }}`-шаблон), поэтому
|
||||
* строки — витринный пример формата отчёта, а не факт. Ровно поэтому подпись
|
||||
* под таблицей ЗАМЕНЕНА: в макете там было «ДАННЫЕ ОБЕЗЛИЧЕНЫ · …» — фраза,
|
||||
* прямо утверждающая, что за строками стоят настоящие обезличенные сделки.
|
||||
* Мы этого утверждать не можем (строки придуманы для примера), поэтому подпись
|
||||
* честно называет таблицу иллюстрацией формата, а вторую половину исходной
|
||||
* фразы («"выше средней цены" — разница со средней ценой сделки по похожим
|
||||
* квартирам в районе») сохраняет — она объясняет, ЧТО значит колонка, а не
|
||||
* заявляет что-то о происхождении данных.
|
||||
*
|
||||
* Таблица размечена ARIA role="table/row/columnheader/cell" (а не <table>):
|
||||
* на мобильном брейке (см. landing-v3.module.css) она превращается в карточки
|
||||
* без горизонтального скролла, а implicit table-роли всё равно снимаются
|
||||
* Safari при смене display у настоящей <table> — тот же квирк, что уже описан
|
||||
* в HowItWorks.tsx для списков, только для таблиц его обходят явные роли, а
|
||||
* не CSS-костыль.
|
||||
*/
|
||||
|
||||
import {
|
||||
ACCURACY_PERIOD_LABEL,
|
||||
ACCURACY_PLACEHOLDER,
|
||||
PROOF_ROWS_PLACEHOLDER,
|
||||
} from "../../marketing-v3";
|
||||
import styles from "../../landing-v3.module.css";
|
||||
|
||||
const KPI_TILES = [
|
||||
{
|
||||
value: ACCURACY_PLACEHOLDER.priceError,
|
||||
label: "средняя ошибка по цене",
|
||||
note: ACCURACY_PLACEHOLDER.priceErrorNote,
|
||||
},
|
||||
{
|
||||
value: ACCURACY_PLACEHOLDER.daysError,
|
||||
label: "точность по сроку продажи",
|
||||
note: ACCURACY_PLACEHOLDER.daysErrorNote,
|
||||
},
|
||||
{
|
||||
value: ACCURACY_PLACEHOLDER.coverage,
|
||||
label: "сделок попали в диапазон",
|
||||
note: ACCURACY_PLACEHOLDER.coverageNote,
|
||||
},
|
||||
] as const;
|
||||
|
||||
export function AccuracyV3() {
|
||||
return (
|
||||
<section
|
||||
id="accuracy"
|
||||
aria-labelledby="accuracy-v3-title"
|
||||
className={styles.accSection}
|
||||
>
|
||||
<div className={styles.accContainer}>
|
||||
<div className={styles.accHead}>
|
||||
<div className={styles.accEyebrow} aria-hidden="true">
|
||||
ПРОГНОЗ ПРОТИВ ФАКТА
|
||||
</div>
|
||||
<h2 id="accuracy-v3-title" className={styles.accTitle}>
|
||||
Мы сверяем каждый прогноз с реальной сделкой
|
||||
</h2>
|
||||
<p className={styles.accLead}>
|
||||
{`Когда квартира продана, мы возвращаемся к своему прогнозу и считаем расхождение. Вот сводка за ${ACCURACY_PERIOD_LABEL}.`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ul className={styles.accKpiGrid} role="list">
|
||||
{KPI_TILES.map((tile) => (
|
||||
<li key={tile.label} className={styles.accKpiTile}>
|
||||
<div className={styles.accKpiValue}>{tile.value}</div>
|
||||
<div className={styles.accKpiLabel}>{tile.label}</div>
|
||||
<div className={styles.accKpiNote}>{tile.note}</div>
|
||||
</li>
|
||||
))}
|
||||
<li className={`${styles.accKpiTile} ${styles.accKpiTileDark}`}>
|
||||
<div className={styles.accKpiValue}>
|
||||
{ACCURACY_PLACEHOLDER.checks}
|
||||
</div>
|
||||
<div className={styles.accKpiLabel}>проверок за год</div>
|
||||
<div className={styles.accKpiNoteDark}>
|
||||
{ACCURACY_PLACEHOLDER.checksNote}
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div
|
||||
className={styles.accTable}
|
||||
role="table"
|
||||
aria-label="Сверка прогноза «Меры» с фактом сделки"
|
||||
>
|
||||
<div className={styles.accTableHead} role="row">
|
||||
<span role="columnheader">Объект</span>
|
||||
<span role="columnheader">Мера сказала</span>
|
||||
<span role="columnheader">Факт сделки</span>
|
||||
<span role="columnheader">Расхождение</span>
|
||||
<span role="columnheader" className={styles.accTableHeadCellRight}>
|
||||
Выше средней цены по району
|
||||
</span>
|
||||
</div>
|
||||
{PROOF_ROWS_PLACEHOLDER.map((row) => (
|
||||
<div key={row.addr} className={styles.accRow} role="row">
|
||||
<div role="cell">
|
||||
<span className={styles.accObjectAddr}>{row.addr}</span>
|
||||
<span className={styles.accObjectMeta}>{row.meta}</span>
|
||||
</div>
|
||||
<div
|
||||
role="cell"
|
||||
className={styles.accCellSaid}
|
||||
data-label="Мера сказала"
|
||||
>
|
||||
{row.said}
|
||||
</div>
|
||||
<div
|
||||
role="cell"
|
||||
className={styles.accCellFact}
|
||||
data-label="Факт сделки"
|
||||
>
|
||||
{row.fact}
|
||||
</div>
|
||||
<div
|
||||
role="cell"
|
||||
className={styles.accCellErr}
|
||||
data-label="Расхождение"
|
||||
>
|
||||
{row.err}
|
||||
</div>
|
||||
<div
|
||||
role="cell"
|
||||
className={styles.accCellGain}
|
||||
data-label="Выше средней цены по району"
|
||||
>
|
||||
<div className={styles.accGainStack}>
|
||||
<div className={styles.accGainValue}>{row.gain}</div>
|
||||
<div className={styles.accGainPct}>{row.gainPct}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className={styles.accFootnote}>
|
||||
Таблица иллюстрирует формат отчёта, это не список конкретных сделок.
|
||||
«Выше средней цены» — разница со средней ценой сделки по похожим
|
||||
квартирам в районе.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,140 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* CityPicker — выбор города в шапке лэндинга v3 (макет ~ строки 47-73).
|
||||
*
|
||||
* Презентационный клиентский остров: сегодня публичный расчёт выключен
|
||||
* (`PUBLIC_ESTIMATE_ENABLED = false` в content.ts), поэтому выбранный здесь
|
||||
* город никуда не отправляется — состояние живёт только в этом компоненте и
|
||||
* показывает посетителю, что сервис знает больше одного города области.
|
||||
* Когда форма расчёта откроется, сюда нужно будет добавить проброс выбора
|
||||
* наружу (проп/контекст) — в этом заходе scope ограничен шапкой/подвалом/
|
||||
* sticky-баром, самой формы здесь нет.
|
||||
*
|
||||
* Список городов и деление на «полное покрытие» / «данных меньше» берутся из
|
||||
* `OBLAST_CITIES` / `PRIMARY_CITY` (content.ts, тот же реестр, что честно
|
||||
* объясняет разницу в покрытии в Hero.tsx). Макет показывал числовые «веса»
|
||||
* по городам (тысячи объявлений) — мы их не повторяем: таких цифр в коде нет,
|
||||
* а гадать нельзя (правило честности в content.ts).
|
||||
*/
|
||||
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
|
||||
import { DEFAULT_CITY, OBLAST_CITIES } from "@/lib/city-registry";
|
||||
|
||||
import { PRIMARY_CITY, REGION_NAME } from "../../content";
|
||||
import styles from "../../landing-v3.module.css";
|
||||
|
||||
export function CityPicker() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [city, setCity] = useState<string>(DEFAULT_CITY.label);
|
||||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const panelId = useId();
|
||||
|
||||
// Клик вне панели — закрыть. Тот же паттерн, что components/auth/UserMenu.tsx.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}, [open]);
|
||||
|
||||
// Escape — закрыть и вернуть фокус на кнопку.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function handleKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") {
|
||||
setOpen(false);
|
||||
buttonRef.current?.focus();
|
||||
}
|
||||
}
|
||||
document.addEventListener("keydown", handleKey);
|
||||
return () => document.removeEventListener("keydown", handleKey);
|
||||
}, [open]);
|
||||
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
const filtered = normalizedQuery
|
||||
? OBLAST_CITIES.filter((c) =>
|
||||
c.label.toLowerCase().includes(normalizedQuery),
|
||||
)
|
||||
: OBLAST_CITIES;
|
||||
|
||||
return (
|
||||
<div className={styles.hdrCityWrap} ref={wrapRef}>
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
className={styles.hdrCityBtn}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={open}
|
||||
aria-controls={panelId}
|
||||
>
|
||||
<span className={styles.hdrCityDot} aria-hidden="true" />
|
||||
<span>{city}</span>
|
||||
<span className={styles.hdrCityCaret} aria-hidden="true">
|
||||
{open ? "▲" : "▼"}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{open ? (
|
||||
<div
|
||||
id={panelId}
|
||||
className={styles.hdrCityPanel}
|
||||
aria-label="Выбор города"
|
||||
>
|
||||
<div className={styles.hdrCitySearchRow}>
|
||||
<input
|
||||
type="text"
|
||||
className={styles.hdrCityInput}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Поиск города"
|
||||
aria-label="Поиск города"
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.hdrCityList}>
|
||||
{filtered.length > 0 ? (
|
||||
filtered.map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
type="button"
|
||||
className={styles.hdrCityItem}
|
||||
data-active={c.label === city ? "true" : undefined}
|
||||
onClick={() => {
|
||||
setCity(c.label);
|
||||
setOpen(false);
|
||||
setQuery("");
|
||||
buttonRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
<span className={styles.hdrCityItemName}>{c.label}</span>
|
||||
<span className={styles.hdrCityItemNote}>
|
||||
{c.label === PRIMARY_CITY
|
||||
? "Полное покрытие"
|
||||
: "Данных меньше"}
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<p className={styles.hdrCityEmpty}>
|
||||
Города нет в списке. Мы работаем там, где хватает данных по
|
||||
сделкам — напишите нам, и добавим.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.hdrCityFooter}>
|
||||
ДАННЫЕ ПО {OBLAST_CITIES.length} ГОРОДАМ:{" "}
|
||||
{REGION_NAME.toUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
/**
|
||||
* CostOfErrorV3 — «Цена ошибки»: три плитки про стоимость неверной цены
|
||||
* (макет v3, ~строки 376-400, без id). Серверный компонент.
|
||||
*
|
||||
* Значения и подписи — из `COST_OF_ERROR_PLACEHOLDER` (marketing-v3.ts),
|
||||
* заголовок цитирует `SERVICE_PRICE_RUB` (content.ts) — тот же источник
|
||||
* правды, что и у цены отчёта во всём остальном лэндинге, а не отдельный
|
||||
* литерал «150».
|
||||
*/
|
||||
|
||||
import { SERVICE_PRICE_RUB } from "../../content";
|
||||
import { COST_OF_ERROR_PLACEHOLDER } from "../../marketing-v3";
|
||||
import styles from "../../landing-v3.module.css";
|
||||
|
||||
export function CostOfErrorV3() {
|
||||
return (
|
||||
<section aria-labelledby="cost-v3-title" className={styles.costSection}>
|
||||
<div className={styles.costContainer}>
|
||||
<div className={styles.costHead}>
|
||||
<div className={styles.costEyebrow} aria-hidden="true">
|
||||
ПОЧЕМУ ЭТО ВАЖНО
|
||||
</div>
|
||||
<h2 id="cost-v3-title" className={styles.costTitle}>
|
||||
{`${SERVICE_PRICE_RUB} ₽ против двух месяцев вашей жизни`}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<ul className={styles.costGrid} role="list">
|
||||
{COST_OF_ERROR_PLACEHOLDER.map((tile, index) => {
|
||||
const dark = index === COST_OF_ERROR_PLACEHOLDER.length - 1;
|
||||
return (
|
||||
<li
|
||||
key={tile.title}
|
||||
className={
|
||||
dark
|
||||
? `${styles.costTile} ${styles.costTileDark}`
|
||||
: styles.costTile
|
||||
}
|
||||
>
|
||||
<div className={styles.costValue}>{tile.value}</div>
|
||||
<div className={styles.costItemTitle}>{tile.title}</div>
|
||||
<p className={dark ? styles.costTextDark : styles.costText}>
|
||||
{tile.text}
|
||||
</p>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,145 @@
|
|||
/**
|
||||
* FooterV3 — подвал лэндинга v3 (макет ~ строки 470-516): четыре колонки +
|
||||
* нижняя строка с реквизитами.
|
||||
*
|
||||
* РЕКВИЗИТЫ И ДОКУМЕНТЫ — НЕ из макета (там маска «ООО «МЕРА» · ИНН
|
||||
* 66700XXXXX», это не настоящие данные), а из content.ts: `LEGAL_ENTITY`,
|
||||
* `SUPPORT_EMAIL`, `OFFER_PATH`, `REFUND_PATH`, `PRIVACY_PATH` — тот же набор,
|
||||
* что уже рендерит `_components/SiteFooter.tsx` (подвал v1/v2), только в
|
||||
* другой сетке. Документы включают Политику возврата и почту поддержки:
|
||||
* макет их не показывал, а это обязательные элементы платного контура
|
||||
* (маркировка эквайера, идентифицируемость оператора по 152-ФЗ).
|
||||
*
|
||||
* Способы оплаты (МИР / СБП / VISA / MASTERCARD в макете) НЕ рендерятся:
|
||||
* приём платежей не подключён (см. TODO у нижней строки) — заявлять способы
|
||||
* оплаты, которых нет, подпадает под ст. 5 ФЗ «О рекламе».
|
||||
*
|
||||
* «Статьи» в «Разделах» неактивны тем же образом, что в шапке (HeaderV3):
|
||||
* раздела нет в коде. «Проверить квартиру» ведёт на #check (форма в Hero на
|
||||
* этой же странице) — отдельного маршрута отчёта не существует. Оба
|
||||
* внутренних якоря — через `/mera-public#...`: подвал рендерится и на
|
||||
* оферте/возврате/ПДн, где голый `#check` никуда не долистал бы.
|
||||
*/
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
import { safeUrl } from "@/lib/safeUrl";
|
||||
|
||||
import {
|
||||
LEGAL_ENTITY,
|
||||
OFFER_PATH,
|
||||
PRIVACY_PATH,
|
||||
REFUND_PATH,
|
||||
REGION_NAME,
|
||||
SUPPORT_EMAIL,
|
||||
SUPPORT_TELEGRAM_LABEL,
|
||||
SUPPORT_TELEGRAM_URL,
|
||||
} from "../../content";
|
||||
import styles from "../../landing-v3.module.css";
|
||||
|
||||
export function FooterV3() {
|
||||
const telegramHref = safeUrl(SUPPORT_TELEGRAM_URL);
|
||||
const year = new Date().getFullYear();
|
||||
|
||||
return (
|
||||
<footer className={styles.ftrRoot}>
|
||||
<div className={styles.ftrGrid}>
|
||||
<div className={styles.ftrBrandCol}>
|
||||
<div className={styles.ftrBrand}>
|
||||
<span className={styles.ftrBrandDot} aria-hidden="true" />
|
||||
<span className={styles.ftrBrandName}>МЕРА</span>
|
||||
</div>
|
||||
<p className={styles.ftrBrandText}>
|
||||
Оценка квартир на вторичном рынке по сделкам и объявлениям.{" "}
|
||||
{REGION_NAME}.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className={styles.ftrColTitle}>Разделы</p>
|
||||
<div className={styles.ftrLinks}>
|
||||
<Link href="/mera-public" className={styles.ftrLink}>
|
||||
Главная
|
||||
</Link>
|
||||
<Link href="/mera-public#check" className={styles.ftrLink}>
|
||||
Проверить квартиру
|
||||
</Link>
|
||||
<span
|
||||
className={styles.ftrLinkDisabled}
|
||||
aria-disabled="true"
|
||||
title="Раздел скоро откроется"
|
||||
>
|
||||
Статьи
|
||||
</span>
|
||||
<Link href="/" className={styles.ftrLink}>
|
||||
МЕРА для бизнеса
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className={styles.ftrColTitle}>Связаться</p>
|
||||
<p className={styles.ftrText}>
|
||||
Почта:{" "}
|
||||
<a
|
||||
className={styles.ftrInlineLink}
|
||||
href={`mailto:${SUPPORT_EMAIL}`}
|
||||
>
|
||||
{SUPPORT_EMAIL}
|
||||
</a>
|
||||
</p>
|
||||
{telegramHref ? (
|
||||
<p className={styles.ftrText}>
|
||||
Поддержка в Telegram:{" "}
|
||||
<a
|
||||
className={styles.ftrInlineLink}
|
||||
href={telegramHref}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{SUPPORT_TELEGRAM_LABEL}
|
||||
</a>
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className={styles.ftrColTitle}>Документы</p>
|
||||
<div className={styles.ftrLinks}>
|
||||
<Link href={OFFER_PATH} className={styles.ftrLink}>
|
||||
Публичная оферта
|
||||
</Link>
|
||||
<Link href={REFUND_PATH} className={styles.ftrLink}>
|
||||
Политика возврата
|
||||
</Link>
|
||||
<Link href={PRIVACY_PATH} className={styles.ftrLink}>
|
||||
Обработка персональных данных
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.ftrBottom}>
|
||||
<div className={styles.ftrBottomReqs}>
|
||||
<span>© {year} МЕРА</span>
|
||||
{LEGAL_ENTITY ? (
|
||||
<span>
|
||||
{LEGAL_ENTITY.name}, ИНН {LEGAL_ENTITY.inn}, ОГРН{" "}
|
||||
{LEGAL_ENTITY.ogrn}, {LEGAL_ENTITY.address}
|
||||
</span>
|
||||
) : null}
|
||||
<span>
|
||||
Оценка носит информационный характер и не является офертой или
|
||||
отчётом об оценке.
|
||||
</span>
|
||||
</div>
|
||||
{/* TODO(платёжный контур): бейджи способов оплаты (МИР / СБП / VISA /
|
||||
MASTERCARD, как в макете) — рендерить только когда приём оплаты
|
||||
будет подключён backend'ом. Сегодня `PUBLIC_ESTIMATE_ENABLED =
|
||||
false`, оплаты нет вовсе, а заявлять способы оплаты, которых нет,
|
||||
— нарушение ст. 5 ФЗ «О рекламе». Гейт G6, см.
|
||||
mera-b2c-paid-flow-decision.md. */}
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,303 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* FreeCheckCard — карточка «Бесплатная проверка» на первом экране (макет v3,
|
||||
* `МЕРА для себя v3.dc.html` строки ~129-170).
|
||||
*
|
||||
* ЧТО ОНА ДЕЛАЕТ СЕГОДНЯ И ПОЧЕМУ. Ровно то же, чем ограничена
|
||||
* `../AddressForm.tsx` в закрытом v2-контуре, и по той же причине:
|
||||
* `rbac_guard` (backend/app/core/rbac.py) не пускает анонима на
|
||||
* `/api/v1/geocode/suggest` и `/api/v1/trade-in/estimate` — их нет в
|
||||
* `_PUBLIC_PATHS`. Открытие анонимного периметра — отдельный backend-PR, вне
|
||||
* границ этой задачи. Поэтому здесь: честная валидация 5 обязательных полей
|
||||
* на клиенте + прямое сообщение «проверка ещё не открыта» вместо макетного
|
||||
* сценария «спиннер 1100 мс → готовый результат». Тот сценарий на живом
|
||||
* сайте отдавал бы одинаковые «14 объявлений / 47 дней» на любой введённый
|
||||
* адрес — не продающая формулировка, а обман конкретного человека (см. шапку
|
||||
* `../../marketing-v3.ts`).
|
||||
*
|
||||
* Раздел «шаг 1 из 2» / полоска прогресса / ярлык рынка (`{{ cityMarket }}`,
|
||||
* `{{ stepLabel }}`) из макета сюда сознательно НЕ перенесены: они требуют
|
||||
* состояния города, которым владеет шапка сайта — вне участка этой задачи
|
||||
* («первый экран: герой и карточка»), и вносили бы числовые литералы (33%,
|
||||
* 66%, 100%), которых нет ни в макете-константах, ни в `marketing-v3.ts`.
|
||||
*
|
||||
* Ветка результата ПОСТРОЕНА (`FreeResultV3.tsx`), но не подключена — см.
|
||||
* TODO у `PUBLIC_ESTIMATE_ENABLED` ниже и JSDoc в `FreeResultV3.tsx`.
|
||||
*/
|
||||
|
||||
import { useId, useRef, useState } from "react";
|
||||
import type { FormEvent } from "react";
|
||||
|
||||
import { PRIMARY_CITY, PUBLIC_ESTIMATE_ENABLED } from "../../content";
|
||||
import styles from "../../landing-v3.module.css";
|
||||
|
||||
/** Три состояния квартиры — дословно из макета (строка 734 исходника). */
|
||||
const CONDITIONS = ["Требует ремонта", "Хорошее", "Отличное"] as const;
|
||||
type Condition = (typeof CONDITIONS)[number];
|
||||
|
||||
type FieldName = "address" | "area" | "rooms" | "floor" | "condition";
|
||||
|
||||
type Feedback =
|
||||
| { kind: "none" }
|
||||
| { kind: "error"; field: FieldName; text: string }
|
||||
| { kind: "info"; title: string; lines: readonly string[] };
|
||||
|
||||
const NONE: Feedback = { kind: "none" };
|
||||
|
||||
export function FreeCheckCard() {
|
||||
const addressId = useId();
|
||||
const areaId = useId();
|
||||
const roomsId = useId();
|
||||
const floorId = useId();
|
||||
const conditionLabelId = useId();
|
||||
const feedbackId = useId();
|
||||
|
||||
const [address, setAddress] = useState("");
|
||||
const [area, setArea] = useState("");
|
||||
const [rooms, setRooms] = useState("");
|
||||
const [floor, setFloor] = useState("");
|
||||
const [condition, setCondition] = useState<Condition | "">("");
|
||||
const [feedback, setFeedback] = useState<Feedback>(NONE);
|
||||
|
||||
const addressRef = useRef<HTMLInputElement>(null);
|
||||
const areaRef = useRef<HTMLInputElement>(null);
|
||||
const roomsRef = useRef<HTMLInputElement>(null);
|
||||
const floorRef = useRef<HTMLInputElement>(null);
|
||||
const conditionGroupRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
function clearFeedback() {
|
||||
setFeedback(NONE);
|
||||
}
|
||||
|
||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
|
||||
const trimmedAddress = address.trim();
|
||||
const trimmedArea = area.trim();
|
||||
const trimmedRooms = rooms.trim();
|
||||
const trimmedFloor = floor.trim();
|
||||
|
||||
if (!trimmedAddress) {
|
||||
setFeedback({
|
||||
kind: "error",
|
||||
field: "address",
|
||||
text: "Укажите адрес — улицу и номер дома.",
|
||||
});
|
||||
addressRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!trimmedArea) {
|
||||
setFeedback({
|
||||
kind: "error",
|
||||
field: "area",
|
||||
text: "Укажите площадь квартиры в квадратных метрах.",
|
||||
});
|
||||
areaRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!trimmedRooms) {
|
||||
setFeedback({
|
||||
kind: "error",
|
||||
field: "rooms",
|
||||
text: "Укажите количество комнат.",
|
||||
});
|
||||
roomsRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!trimmedFloor) {
|
||||
setFeedback({
|
||||
kind: "error",
|
||||
field: "floor",
|
||||
text: "Укажите этаж, например «7/16».",
|
||||
});
|
||||
floorRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!condition) {
|
||||
setFeedback({
|
||||
kind: "error",
|
||||
field: "condition",
|
||||
text: "Выберите состояние квартиры.",
|
||||
});
|
||||
conditionGroupRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (PUBLIC_ESTIMATE_ENABLED) {
|
||||
// TODO(backend-периметр): здесь появится реальный запрос к
|
||||
// POST /trade-in/coverage. По успешному ответу — переключить карточку в
|
||||
// ветку результата и отрендерить `FreeResultV3` с полученными
|
||||
// analogs/days (НЕ константами из marketing-v3.ts). Отдельный PR.
|
||||
return;
|
||||
}
|
||||
|
||||
setFeedback({
|
||||
kind: "info",
|
||||
title: "Бесплатную проверку по адресу мы пока не открыли",
|
||||
lines: [
|
||||
`Дом принят: ${trimmedArea} м², ${trimmedRooms}-комн., этаж ${trimmedFloor}, состояние «${condition}». Но онлайн-расчёт ещё не запущен — сервис работает по доступу для партнёров.`,
|
||||
"Проверка появится на этой же странице. Если вопрос срочный — напишите нам в поддержку, ссылка в подвале.",
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
const errorField = feedback.kind === "error" ? feedback.field : null;
|
||||
const describedBy = feedback.kind === "none" ? undefined : feedbackId;
|
||||
|
||||
return (
|
||||
<div className={styles.heroCard}>
|
||||
<div className={styles.heroCardHeader}>
|
||||
<span className={styles.heroCardHeaderTitle}>Бесплатная проверка</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.heroCardBody}>
|
||||
<form className={styles.heroForm} onSubmit={handleSubmit} noValidate>
|
||||
<label className={styles.heroField} htmlFor={addressId}>
|
||||
<span className={styles.heroFieldLabel}>Адрес</span>
|
||||
<input
|
||||
id={addressId}
|
||||
ref={addressRef}
|
||||
className={`${styles.heroInput} ${errorField === "address" ? styles.heroInputInvalid : ""}`}
|
||||
type="text"
|
||||
name="address"
|
||||
autoComplete="street-address"
|
||||
enterKeyHint="next"
|
||||
placeholder={`г. ${PRIMARY_CITY}, улица, дом`}
|
||||
value={address}
|
||||
onChange={(event) => {
|
||||
setAddress(event.target.value);
|
||||
clearFeedback();
|
||||
}}
|
||||
aria-invalid={errorField === "address" || undefined}
|
||||
aria-describedby={errorField === "address" ? describedBy : undefined}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className={styles.heroFieldRow}>
|
||||
<label className={styles.heroField} htmlFor={areaId}>
|
||||
<span className={styles.heroFieldLabel}>М²</span>
|
||||
<input
|
||||
id={areaId}
|
||||
ref={areaRef}
|
||||
className={`${styles.heroInput} ${errorField === "area" ? styles.heroInputInvalid : ""}`}
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
name="area"
|
||||
placeholder="54"
|
||||
value={area}
|
||||
onChange={(event) => {
|
||||
setArea(event.target.value);
|
||||
clearFeedback();
|
||||
}}
|
||||
aria-invalid={errorField === "area" || undefined}
|
||||
aria-describedby={errorField === "area" ? describedBy : undefined}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className={styles.heroField} htmlFor={roomsId}>
|
||||
<span className={styles.heroFieldLabel}>Комнат</span>
|
||||
<input
|
||||
id={roomsId}
|
||||
ref={roomsRef}
|
||||
className={`${styles.heroInput} ${errorField === "rooms" ? styles.heroInputInvalid : ""}`}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
name="rooms"
|
||||
placeholder="2"
|
||||
value={rooms}
|
||||
onChange={(event) => {
|
||||
setRooms(event.target.value);
|
||||
clearFeedback();
|
||||
}}
|
||||
aria-invalid={errorField === "rooms" || undefined}
|
||||
aria-describedby={errorField === "rooms" ? describedBy : undefined}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className={styles.heroField} htmlFor={floorId}>
|
||||
<span className={styles.heroFieldLabel}>Этаж</span>
|
||||
<input
|
||||
id={floorId}
|
||||
ref={floorRef}
|
||||
className={`${styles.heroInput} ${errorField === "floor" ? styles.heroInputInvalid : ""}`}
|
||||
type="text"
|
||||
name="floor"
|
||||
placeholder="7/16"
|
||||
value={floor}
|
||||
onChange={(event) => {
|
||||
setFloor(event.target.value);
|
||||
clearFeedback();
|
||||
}}
|
||||
aria-invalid={errorField === "floor" || undefined}
|
||||
aria-describedby={errorField === "floor" ? describedBy : undefined}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className={styles.heroField}>
|
||||
<span className={styles.heroFieldLabel} id={conditionLabelId}>
|
||||
Состояние
|
||||
</span>
|
||||
<div
|
||||
ref={conditionGroupRef}
|
||||
className={styles.heroConditionGroup}
|
||||
role="group"
|
||||
aria-labelledby={conditionLabelId}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{CONDITIONS.map((name) => (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
className={`${styles.heroConditionBtn} ${condition === name ? styles.heroConditionBtnActive : ""}`}
|
||||
aria-pressed={condition === name}
|
||||
onClick={() => {
|
||||
setCondition(name);
|
||||
clearFeedback();
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" className={styles.heroSubmitBtn}>
|
||||
Показать данные по моему дому →
|
||||
</button>
|
||||
<p className={styles.heroSubmitCaption}>
|
||||
БЕЗ РЕГИСТРАЦИИ · БЕЗ ЗВОНКОВ · 30 СЕКУНД
|
||||
</p>
|
||||
|
||||
{/* Живая область держим в DOM постоянно — регион, добавленный в
|
||||
момент ошибки, часть скринридеров не озвучивает (тот же приём,
|
||||
что в ../AddressForm.tsx). */}
|
||||
<div id={feedbackId} role="status" aria-live="polite">
|
||||
{feedback.kind === "error" && (
|
||||
<div
|
||||
className={`${styles.heroFormFeedback} ${styles.heroFormFeedbackError}`}
|
||||
>
|
||||
<p className={styles.heroFormFeedbackText}>{feedback.text}</p>
|
||||
</div>
|
||||
)}
|
||||
{feedback.kind === "info" && (
|
||||
<div className={styles.heroFormFeedback}>
|
||||
<p className={styles.heroFormFeedbackTitle}>{feedback.title}</p>
|
||||
{feedback.lines.map((line) => (
|
||||
<p key={line} className={styles.heroFormFeedbackText}>
|
||||
{line}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
/**
|
||||
* FreeResultV3 — ветка «результат» карточки «Бесплатная проверка» (макет v3,
|
||||
* строки ~176-204 `МЕРА для себя v3.dc.html`).
|
||||
*
|
||||
* НЕ ВЫЗЫВАЕТСЯ СЕГОДНЯ НИОТКУДА. Компонент собран и типизирован, но нигде не
|
||||
* рендерится в живом дереве: `FreeCheckCard.tsx` при сабмите (см. TODO рядом с
|
||||
* `PUBLIC_ESTIMATE_ENABLED` там) всегда остаётся в форме и честно говорит, что
|
||||
* онлайн-проверки ещё нет — потому что анонимных ручек `/geocode/suggest` и
|
||||
* `/trade-in/estimate` не существует (см. `../../content.ts`). Как только
|
||||
* появится `POST /trade-in/coverage`, эту ветку нужно подключить: завести
|
||||
* `phase`-состояние в `FreeCheckCard`, по успешному ответу переключаться в
|
||||
* `"result"` и рендерить этот компонент с РЕАЛЬНЫМИ `analogs`/`days` из ответа
|
||||
* ручки — не константами.
|
||||
*
|
||||
* Данные приходят пропсами, а не читаются из `marketing-v3.ts` внутри
|
||||
* компонента, — вызывающий код сегодня мог бы по ошибке подставить туда
|
||||
* `FREE_SAMPLE_PLACEHOLDER` для любого адреса, и тогда «14 похожих объявлений»
|
||||
* увидел бы каждый посетитель независимо от того, что он ввёл. Это уже не
|
||||
* продающая формулировка, а обман конкретного человека — см. шапку
|
||||
* `marketing-v3.ts`. Пропсовый контракт держит эту ответственность на
|
||||
* вызывающей стороне и делает её явной в диффе, когда придёт время подключать
|
||||
* реальные данные.
|
||||
*
|
||||
* Замыленная рублёвая цифра из макета («9,75 млн ₽», `filter: blur`) сюда
|
||||
* НЕ перенесена вообще — вместо неё абстрактная плашка-скелет без единой
|
||||
* цифры (см. `.heroResultSkeleton*` в `landing-v3.module.css`).
|
||||
*/
|
||||
|
||||
import styles from "../../landing-v3.module.css";
|
||||
|
||||
export interface FreeResultV3Props {
|
||||
/** Число похожих объявлений — крупная цифра первой плитки, напр. "14". */
|
||||
analogs: string;
|
||||
/** Подпись под числом объявлений. */
|
||||
analogsLabel: string;
|
||||
/** Средний срок продажи — крупная цифра второй плитки, напр. "47 дн.". */
|
||||
days: string;
|
||||
/** Подпись под сроком продажи. */
|
||||
daysLabel: string;
|
||||
/** Клик по «Проверить другую квартиру» — вернуть карточку к форме. */
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export function FreeResultV3({
|
||||
analogs,
|
||||
analogsLabel,
|
||||
days,
|
||||
daysLabel,
|
||||
onBack,
|
||||
}: FreeResultV3Props) {
|
||||
return (
|
||||
<div>
|
||||
<p className={styles.heroResultEyebrow}>ГОТОВО · БЕСПЛАТНАЯ ЧАСТЬ</p>
|
||||
|
||||
<div className={styles.heroResultGrid}>
|
||||
<div className={styles.heroResultTile}>
|
||||
<div className={styles.heroResultTileValue}>{analogs}</div>
|
||||
<div className={styles.heroResultTileLabel}>{analogsLabel}</div>
|
||||
</div>
|
||||
<div className={styles.heroResultTile}>
|
||||
<div className={styles.heroResultTileValue}>{days}</div>
|
||||
<div className={styles.heroResultTileLabel}>{daysLabel}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.heroResultLocked}>
|
||||
{/* Скелет без единой цифры: замыленную сумму из макета намеренно не
|
||||
переносим, см. JSDoc выше. */}
|
||||
<div className={styles.heroResultSkeleton} aria-hidden="true">
|
||||
<div className={styles.heroResultSkeletonLine1} />
|
||||
<div className={styles.heroResultSkeletonLine2} />
|
||||
<div className={styles.heroResultSkeletonBar} />
|
||||
</div>
|
||||
<div className={styles.heroResultOverlay}>
|
||||
<p className={styles.heroResultOverlayTitle}>
|
||||
Ваша цена и срок продажи готовы
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.heroResultOverlayBtn}
|
||||
disabled
|
||||
>
|
||||
Открыть за 150 ₽
|
||||
</button>
|
||||
<p className={styles.heroResultOverlayCaption}>
|
||||
Оплата подключается вместе с открытием сервиса — пока недоступна.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={styles.heroResultBackBtn}
|
||||
onClick={onBack}
|
||||
>
|
||||
← Проверить другую квартиру
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
/**
|
||||
* HeaderV3 — шапка лэндинга v3 (макет ~ строки 32-78): sticky, blur, лого +
|
||||
* навигация + селектор города + CTA.
|
||||
*
|
||||
* Логотип НЕ ссылка на "/": next.config.ts редиректит "/" → "/v2" (закрытый
|
||||
* B2B-контур за RouteGuard) — клик по вордмарку выкинул бы анонимного
|
||||
* посетителя лэндинга в приложение, требующее логина (тот же комментарий, что
|
||||
* в старом `_components/SiteHeader.tsx`). «Для бизнеса» ниже — НАОБОРОТ,
|
||||
* намеренно ведёт на "/": это единственная ссылка в шапке, которая должна
|
||||
* увести в закрытый контур.
|
||||
*
|
||||
* «Статьи» — раздела ещё нет в коде (ни маршрута, ни контента): пункт
|
||||
* навигации нарочно неактивен (aria-disabled, без href), чтобы не заводить
|
||||
* ссылку в 404 на первой же публичной странице.
|
||||
*
|
||||
* Внутренние якоря идут через `/mera-public#...`, а не голый `#...`: шапка
|
||||
* рендерится на каждой странице поддерева (оферта/возврат/ПДн через
|
||||
* layout.tsx), и там `#accuracy` без пути к самому лэндингу никуда бы не
|
||||
* долистал.
|
||||
*
|
||||
* Единственный клиентский остров — `CityPicker` (открытие/закрытие, поиск).
|
||||
* Сама шапка серверная.
|
||||
*/
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
import styles from "../../landing-v3.module.css";
|
||||
|
||||
import { CityPicker } from "./CityPicker";
|
||||
|
||||
export function HeaderV3() {
|
||||
return (
|
||||
<header className={styles.hdrRoot}>
|
||||
<div className={styles.hdrInner}>
|
||||
<div className={styles.hdrLogo}>
|
||||
<span className={styles.hdrLogoIcon} aria-hidden="true">
|
||||
<span className={styles.hdrLogoDot} />
|
||||
</span>
|
||||
<span className={styles.hdrLogoText}>
|
||||
<span className={styles.hdrLogoName}>МЕРА</span>
|
||||
<span className={styles.hdrLogoSub}>ДЛЯ СЕБЯ</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<nav className={styles.hdrNav} aria-label="Основная навигация">
|
||||
<Link href="/mera-public#accuracy" className={styles.hdrNavLink}>
|
||||
Точность
|
||||
</Link>
|
||||
<Link href="/mera-public#game" className={styles.hdrNavLink}>
|
||||
Проверьте себя
|
||||
</Link>
|
||||
<Link href="/mera-public#turnkey" className={styles.hdrNavLink}>
|
||||
Продажа под ключ
|
||||
</Link>
|
||||
<span
|
||||
className={styles.hdrNavDisabled}
|
||||
aria-disabled="true"
|
||||
title="Раздел скоро откроется"
|
||||
>
|
||||
Статьи
|
||||
</span>
|
||||
</nav>
|
||||
|
||||
<div className={styles.hdrRight}>
|
||||
<CityPicker />
|
||||
<Link href="/" className={styles.hdrBizLink}>
|
||||
Для бизнеса
|
||||
</Link>
|
||||
<Link href="/mera-public#check" className={styles.hdrCta}>
|
||||
Проверить за 30 секунд
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
/**
|
||||
* HeroV3 — первый экран лэндинга v3 (макет `МЕРА для себя v3.dc.html`,
|
||||
* секция `#check`, строки ~109-208).
|
||||
*
|
||||
* Серверный компонент: весь текст статичен, единственное состояние живёт в
|
||||
* `FreeCheckCard` (клиентский дочерний компонент).
|
||||
*
|
||||
* Числа в разметке — только импортированные константы (`ACCURACY_PLACEHOLDER`,
|
||||
* `FREE_SAMPLE_PLACEHOLDER.analogs`, цена отчёта из `COST_OF_ERROR_PLACEHOLDER`)
|
||||
* — ни одного нового числового литерала здесь не заведено, см. шапку
|
||||
* `../../marketing-v3.ts`.
|
||||
*/
|
||||
|
||||
import { ACCURACY_PLACEHOLDER, COST_OF_ERROR_PLACEHOLDER, FREE_SAMPLE_PLACEHOLDER } from "../../marketing-v3";
|
||||
import styles from "../../landing-v3.module.css";
|
||||
|
||||
import { FreeCheckCard } from "./FreeCheckCard";
|
||||
|
||||
/** Цена отчёта — та же величина, что в третьей плитке блока «Почему это важно». */
|
||||
const REPORT_PRICE = COST_OF_ERROR_PLACEHOLDER[2].value;
|
||||
|
||||
export function HeroV3() {
|
||||
return (
|
||||
<section
|
||||
id="check"
|
||||
className={styles.heroSection}
|
||||
aria-labelledby="hero-v3-title"
|
||||
>
|
||||
<div className={styles.heroGrid}>
|
||||
<div>
|
||||
<div className={styles.heroBadge}>
|
||||
<span className={styles.heroBadgeDot} aria-hidden="true" />
|
||||
<span className={styles.heroBadgeText}>
|
||||
СРЕДНЯЯ ОШИБКА ПРОГНОЗА — {ACCURACY_PLACEHOLDER.priceError}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h1 id="hero-v3-title" className={styles.heroTitle}>
|
||||
Реальная цена вашей квартиры — чтобы не продешевить
|
||||
</h1>
|
||||
|
||||
<p className={styles.heroLead}>
|
||||
Мы называем реальную цену вашей квартиры — чтобы вы не продали её
|
||||
дешевле и не зависли на полгода.
|
||||
</p>
|
||||
|
||||
<div className={styles.heroChecklist}>
|
||||
<div className={styles.heroListItem}>
|
||||
<span className={styles.heroListIcon} aria-hidden="true">
|
||||
→
|
||||
</span>
|
||||
<span className={styles.heroListText}>
|
||||
<strong>Не продешевить:</strong> увидите потолок, который
|
||||
рынок реально готов заплатить
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.heroListItem}>
|
||||
<span className={styles.heroListIcon} aria-hidden="true">
|
||||
→
|
||||
</span>
|
||||
<span className={styles.heroListText}>
|
||||
<strong>Не зависнуть:</strong> узнаете, за сколько дней уйдёт
|
||||
квартира по вашей цене
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.heroListItem}>
|
||||
<span className={styles.heroListIcon} aria-hidden="true">
|
||||
→
|
||||
</span>
|
||||
<span className={styles.heroListText}>
|
||||
<strong>Не уступать зря:</strong> будет чем ответить на торг —{" "}
|
||||
{FREE_SAMPLE_PLACEHOLDER.analogs} аналогов с ценами
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.heroPerks}>
|
||||
<div className={styles.heroListItem}>
|
||||
<span className={styles.heroListIcon} aria-hidden="true">
|
||||
✓
|
||||
</span>
|
||||
<span className={styles.heroListText}>
|
||||
<strong>Бесплатно:</strong> сколько похожих квартир продаётся
|
||||
рядом и как быстро они уходят
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.heroListItem}>
|
||||
<span className={styles.heroListIcon} aria-hidden="true">
|
||||
✓
|
||||
</span>
|
||||
<span className={styles.heroListText}>
|
||||
<strong>
|
||||
За{" "}
|
||||
<span className={styles.heroNoBreak}>{REPORT_PRICE}</span>:
|
||||
</strong>{" "}
|
||||
ваша цена и срок продажи — и каждый такой прогноз мы потом
|
||||
сверяем с фактом сделки
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FreeCheckCard />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
/**
|
||||
* StepsV3 — «что делает сервис»: три шага (макет v3, ~строки 210-228,
|
||||
* без id — секция идёт сразу после hero). Серверный компонент.
|
||||
*
|
||||
* Разметка — упорядоченный список <ol role="list">, тот же приём, что и в
|
||||
* `_components/HowItWorks.tsx` (см. комментарий там про Safari/VoiceOver
|
||||
* quirk со снятием implicit list-ролей у list-style:none).
|
||||
*
|
||||
* Заголовка у секции в макете нет — только три карточки. Чтобы у <section>
|
||||
* всё равно был доступный aria-labelledby (а не голый регион без имени),
|
||||
* заголовок сделан визуально скрытым и взят из заголовка эквивалентной
|
||||
* секции v2-лэндинга (`HowItWorks.tsx` → «Как это работает») — то же по
|
||||
* смыслу содержимое, а не придуманный текст.
|
||||
*/
|
||||
|
||||
import { SERVICE_PRICE_RUB } from "../../content";
|
||||
import { STEP1_DURATION_LABEL } from "../../marketing-v3";
|
||||
import styles from "../../landing-v3.module.css";
|
||||
|
||||
interface StepV3 {
|
||||
readonly title: string;
|
||||
readonly text: string;
|
||||
readonly accent?: boolean;
|
||||
}
|
||||
|
||||
const STEPS_V3: readonly StepV3[] = [
|
||||
{
|
||||
title: "Вы описываете квартиру",
|
||||
text: "Район, метраж, комнаты, этаж, состояние. Без регистрации и звонков.",
|
||||
},
|
||||
{
|
||||
title: "Мы собираем рынок вокруг",
|
||||
text: "Похожие квартиры рядом: сколько их продаётся сейчас и за сколько дней они уходят.",
|
||||
},
|
||||
{
|
||||
title: "Вы получаете цену и срок",
|
||||
text: "Конкретная цифра для объявления, прогноз срока продажи и все аналоги, на которых построен расчёт.",
|
||||
accent: true,
|
||||
},
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Бейджи «ШАГ N · …» по одному на шаг. Единственные числа во второй половине
|
||||
* бейджей берутся из marketing-v3.ts (`STEP1_DURATION_LABEL`) и content.ts
|
||||
* (`SERVICE_PRICE_RUB`, тот же источник, что и у цены отчёта в остальном
|
||||
* лэндинге) — в массиве STEPS_V3 выше их нет намеренно, чтобы не разъезжались
|
||||
* два места с одним и тем же числом.
|
||||
*/
|
||||
const STEP_BADGES: readonly string[] = [
|
||||
STEP1_DURATION_LABEL.toUpperCase(),
|
||||
"БЕСПЛАТНО",
|
||||
`${SERVICE_PRICE_RUB} ₽`,
|
||||
];
|
||||
|
||||
export function StepsV3() {
|
||||
return (
|
||||
<section aria-labelledby="steps-v3-title" className={styles.stepsSection}>
|
||||
<h2 id="steps-v3-title" className={styles.stepsSrOnly}>
|
||||
Как это работает
|
||||
</h2>
|
||||
<ol className={styles.stepsList} role="list">
|
||||
{STEPS_V3.map((step, index) => (
|
||||
<li
|
||||
key={step.title}
|
||||
className={
|
||||
step.accent
|
||||
? `${styles.stepsItem} ${styles.stepsItemAccent}`
|
||||
: styles.stepsItem
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
step.accent
|
||||
? `${styles.stepsLabel} ${styles.stepsLabelAccent}`
|
||||
: styles.stepsLabel
|
||||
}
|
||||
>
|
||||
{/* «ШАГ N ·» скрыт от AT — избыточен с порядком, который и так
|
||||
объявляет role="list" (см. шапку файла и HowItWorks.tsx).
|
||||
Сам бейдж (время/цена шага) — реальная информация, её от
|
||||
screen reader'а не прячем. */}
|
||||
<span aria-hidden="true">{`ШАГ ${index + 1} · `}</span>
|
||||
{STEP_BADGES[index]}
|
||||
</div>
|
||||
<h3 className={styles.stepsTitle}>{step.title}</h3>
|
||||
<p
|
||||
className={
|
||||
step.accent
|
||||
? `${styles.stepsText} ${styles.stepsTextAccent}`
|
||||
: styles.stepsText
|
||||
}
|
||||
>
|
||||
{step.text}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
/**
|
||||
* StickyCtaV3 — липкая нижняя CTA-панель (макет ~ строки 518-521).
|
||||
*
|
||||
* Кнопка — обычная ссылка на `/mera-public#check` (форма расчёта в Hero), а
|
||||
* не модалка: в этом заходе модального окна нет вовсе (макет вызывал
|
||||
* `openModal`, которого в коде не существует). Путь с префиксом, а не голый
|
||||
* `#check`: панель рендерится на каждой странице поддерева (оферта/возврат/
|
||||
* ПДн через layout.tsx), и там `#check` без пути к лэндингу никуда бы не
|
||||
* долистал.
|
||||
*
|
||||
* `.barSpacer` — не декоративный отступ, а компенсация fixed-позиционирования:
|
||||
* `.barRoot` вырезан из потока документа, поэтому без спейсера он бы лёг
|
||||
* поверх последних пикселей страницы (реквизиты в подвале, последний вопрос
|
||||
* FAQ) при докрутке до конца. Спейсер рендерится ЗДЕСЬ ЖЕ, в том же
|
||||
* компоненте — так `StickyCtaV3` остаётся самодостаточным независимо от
|
||||
* того, куда именно в дерево страницы его вставят (ожидаемое место — сразу
|
||||
* после подвала, как Header/Footer в текущем `layout.tsx`).
|
||||
*/
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
import styles from "../../landing-v3.module.css";
|
||||
|
||||
export function StickyCtaV3() {
|
||||
return (
|
||||
<>
|
||||
<div className={styles.barSpacer} aria-hidden="true" />
|
||||
<div className={styles.barRoot}>
|
||||
<p className={styles.barText}>
|
||||
Узнайте цену и срок продажи своей квартиры
|
||||
</p>
|
||||
<Link href="/mera-public#check" className={styles.barBtn}>
|
||||
Проверить за 30 секунд
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
69
tradein-mvp/frontend/src/app/mera-public/b2c-tokens.ts
Normal file
69
tradein-mvp/frontend/src/app/mera-public/b2c-tokens.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
/**
|
||||
* b2c-tokens — палитра и типографика ПУБЛИЧНОГО лэндинга «МЕРА» (макет v3,
|
||||
* 15.08.2026).
|
||||
*
|
||||
* ПОЧЕМУ ОТДЕЛЬНО ОТ `v2/tokens.ts`. До сих пор лэндинг красился теми же
|
||||
* токенами, что и B2B-экран оценки (`theme.ts` пробрасывал `tokens.*` в
|
||||
* `--m-*`). Макет v3 задаёт другую палитру целиком — ни один hex не совпадает
|
||||
* (#F4F7F8/#16A9BC против #dde6ef/#2e8bff), — и красить ими B2B-приложение
|
||||
* нельзя: это другой продукт с другой аудиторией и своим утверждённым видом.
|
||||
* Поэтому у публичного контура появляется собственный набор токенов, а
|
||||
* `v2/tokens.ts` остаётся источником правды для закрытого контура и не
|
||||
* трогается.
|
||||
*
|
||||
* Правило «никаких hex в CSS» сохраняется: литералы живут ЗДЕСЬ и только
|
||||
* здесь, CSS ходит через `var(--m-*)`.
|
||||
*
|
||||
* КОНТРАСТ (WCAG AA ≥ 4.5:1 для текста) — единственное расхождение с макетом,
|
||||
* сделанное сознательно. В макете ссылки покрашены акцентом `#16A9BC`, а он
|
||||
* на фоне страницы `#F4F7F8` даёт **2.6:1** — это нечитаемо для части людей и
|
||||
* провалит любой a11y-аудит. Поэтому:
|
||||
* - `accent` (#16A9BC) — ТОЛЬКО заливки, обводки, точки, декор (как и в
|
||||
* старом theme.ts было для #2e8bff);
|
||||
* - `accentText` (#0B6B79) — тот же оттенок, затемнённый до **5.8:1** на
|
||||
* фоне страницы; им красится текст ссылок и акцентные подписи;
|
||||
* - `onAccent` (#FFFFFF) на заливке `accentDeep` (#0E8496) — 4.6:1, годится
|
||||
* для текста кнопок.
|
||||
* Визуально разница между #16A9BC и #0B6B79 в тонкой ссылке незаметна, а
|
||||
* читаемость меняется принципиально.
|
||||
*/
|
||||
|
||||
export const b2c = {
|
||||
/** Фон страницы. */
|
||||
pageBg: "#F4F7F8",
|
||||
/** Поверхность карточек. */
|
||||
surface: "#FFFFFF",
|
||||
/** Мягкая подложка секций и полей ввода. */
|
||||
surfaceSoft: "#EAF5F7",
|
||||
/** Тёмная плашка (последняя плитка «цены ошибки», подвал-акцент). */
|
||||
surfaceDark: "#121A1D",
|
||||
|
||||
/** Основной текст. */
|
||||
ink: "#121A1D",
|
||||
/** Вторичный текст, подписи. */
|
||||
muted: "#667579",
|
||||
|
||||
/** Границы, разделители. */
|
||||
line: "#CBD7DA",
|
||||
/** Тонкие внутренние разделители. */
|
||||
lineSoft: "#EAF5F7",
|
||||
|
||||
/** Акцент — заливки/обводки/декор. НЕ для текста, см. шапку. */
|
||||
accent: "#16A9BC",
|
||||
/** Акцент для заливки кнопок (под белым текстом). */
|
||||
accentDeep: "#0E8496",
|
||||
/** Акцент для ТЕКСТА — затемнён до AA. */
|
||||
accentText: "#0B6B79",
|
||||
/** Текст на акцентной заливке. */
|
||||
onAccent: "#FFFFFF",
|
||||
|
||||
/** Положительная дельта («выше средней цены»). */
|
||||
success: "#1B8A5A",
|
||||
/** Отрицательная дельта, предупреждения. */
|
||||
danger: "#C0442E",
|
||||
|
||||
font: {
|
||||
sans: "var(--font-manrope), 'Manrope', system-ui, sans-serif",
|
||||
mono: "var(--font-plex-mono), 'IBM Plex Mono', ui-monospace, monospace",
|
||||
},
|
||||
} as const;
|
||||
1357
tradein-mvp/frontend/src/app/mera-public/landing-v3.module.css
Normal file
1357
tradein-mvp/frontend/src/app/mera-public/landing-v3.module.css
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -5,8 +5,16 @@ 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";
|
||||
/**
|
||||
* ШАПКА И ПОДВАЛ ЖИВУТ В СТРАНИЦАХ, А НЕ ЗДЕСЬ (15.08.2026). Раньше layout
|
||||
* рендерил SiteHeader/SiteFooter для всего поддерева. С появлением второй
|
||||
* версии лэндинга (`v3/`, макет владельца) это стало блокером: вложенный
|
||||
* layout в Next добавляется К родительскому, а не заменяет его, поэтому
|
||||
* страница с собственной шапкой получала бы ДВЕ шапки сразу. Теперь layout
|
||||
* отвечает только за обёртку — шрифты, токены, skip-link, — а каждая страница
|
||||
* сама выбирает свой хром: v1 берёт SiteHeader/SiteFooter, v3 — HeaderV3/
|
||||
* FooterV3/StickyCtaV3.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Публичный (B2C) периметр «МЕРЫ»: лэндинг для собственника квартиры.
|
||||
|
|
@ -78,9 +86,7 @@ export default function MeraPublicLayout({
|
|||
<a className={styles.skipLink} href="#content">
|
||||
Перейти к содержимому
|
||||
</a>
|
||||
<SiteHeader />
|
||||
{children}
|
||||
<SiteFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
166
tradein-mvp/frontend/src/app/mera-public/marketing-v3.ts
Normal file
166
tradein-mvp/frontend/src/app/mera-public/marketing-v3.ts
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
/**
|
||||
* marketing-v3 — ПЛЕЙСХОЛДЕРНЫЕ маркетинговые величины лэндинга (макет v3).
|
||||
*
|
||||
* ⚠️ ЧИТАТЬ ПЕРЕД ИСПОЛЬЗОВАНИЕМ. Ни одно число в этом файле не подтверждено
|
||||
* замером продукта. Они взяты из дизайн-макета владельца (15.08.2026) как
|
||||
* продающие формулировки и держатся ЗДЕСЬ, в одном месте, ровно для того,
|
||||
* чтобы их можно было заменить одной правкой, когда появятся вычисляемые
|
||||
* метрики.
|
||||
*
|
||||
* Что говорят наши собственные замеры на ту же тему (зафиксировано, чтобы
|
||||
* расхождение не потерялось — issue «compliance: заменить маркетинговые
|
||||
* метрики лэндинга на вычисляемые до снятия noindex»):
|
||||
*
|
||||
* | здесь | факт по замеру |
|
||||
* |------------------------|-----------------------------------------------|
|
||||
* | ошибка 1,8% | 13,2% медиана |%| (бэктест n=277), held-out 16,1% |
|
||||
* | 83% в диапазон | 81,95% — но коридор шириной 74% от точки |
|
||||
* | ±6 дн по сроку | сверки срока с фактом НЕ СУЩЕСТВУЕТ |
|
||||
* | 42 700 проверок за год | 1 084 оценки всего (база живёт 2,5 месяца) |
|
||||
* | 42 города РФ | 6 городов области, полное покрытие — 1 (ЕКБ) |
|
||||
*
|
||||
* ГЕЙТ: пока `robots: { index: false }` в `layout.tsx` стоит и домен не открыт
|
||||
* наружу, эти строки никому не показываются. ПЕРЕД снятием noindex каждая
|
||||
* величина обязана быть либо заменена вычисляемой, либо снята со страницы —
|
||||
* публикация недостоверных характеристик платной услуги подпадает под ст. 5
|
||||
* ФЗ «О рекламе» и ЗоЗПП.
|
||||
*
|
||||
* Правило для новых секций: не вписывай число в JSX руками — заведи константу
|
||||
* здесь. Иначе замена «одной правкой» перестанет работать ровно тогда, когда
|
||||
* понадобится.
|
||||
*/
|
||||
|
||||
/** Точность прогноза — блок «Точность» и бейдж героя. */
|
||||
export const ACCURACY_PLACEHOLDER = {
|
||||
/** Средняя ошибка по цене. */
|
||||
priceError: "1,8%",
|
||||
priceErrorNote: "медиана расхождения прогноза и цены сделки",
|
||||
/** Точность по сроку продажи. */
|
||||
daysError: "±6 дн.",
|
||||
daysErrorNote: "отклонение факта от прогнозного окна",
|
||||
/** Доля сделок внутри рекомендованного коридора. */
|
||||
coverage: "83%",
|
||||
coverageNote: "цена сделки внутри рекомендованного коридора",
|
||||
/** Объём проверок за год. */
|
||||
checks: "42 700",
|
||||
checksNote: "из них 3 180 доведены до сделки и сверены",
|
||||
} as const;
|
||||
|
||||
/** Блок «Почему это важно» — цена ошибки в цене. */
|
||||
export const COST_OF_ERROR_PLACEHOLDER = [
|
||||
{
|
||||
value: "−4,3%",
|
||||
title: "теряет цена за каждый месяц простоя",
|
||||
text: "Покупатели видят «зависшее» объявление и начинают торговаться сильнее.",
|
||||
},
|
||||
{
|
||||
value: "×2,4",
|
||||
title: "дольше продаётся квартира с завышенной ценой",
|
||||
text: "Даже +7% к рыночной цене выбивают вас из выдачи по фильтрам покупателей.",
|
||||
},
|
||||
{
|
||||
value: "150 ₽",
|
||||
title: "стоит узнать правильную цену заранее",
|
||||
text: "Один платёж. Без подписки. Отчёт остаётся у вас по ссылке.",
|
||||
},
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Пример бесплатной части в макете. Это ИЛЛЮСТРАЦИЯ на статичной странице, а
|
||||
* не расчёт по введённому адресу: анонимного расчёта сегодня нет вовсе
|
||||
* (`PUBLIC_ESTIMATE_ENABLED = false`, ручки закрыты RBAC).
|
||||
*
|
||||
* Как только появится `POST /trade-in/coverage`, эти значения обязаны уехать —
|
||||
* показывать одинаковые «14 объявлений» любому, кто ввёл свой адрес, это уже
|
||||
* не продающая формулировка, а обман конкретного человека.
|
||||
*/
|
||||
export const FREE_SAMPLE_PLACEHOLDER = {
|
||||
analogs: "14",
|
||||
analogsLabel: "похожих объявлений рядом",
|
||||
days: "47 дн.",
|
||||
daysLabel: "средний срок продажи",
|
||||
} as const;
|
||||
|
||||
/** Отчётный период сводки в блоке «Точность» (лид над KPI-плитками, id="accuracy"). */
|
||||
export const ACCURACY_PERIOD_LABEL = "последние 12 месяцев";
|
||||
|
||||
/** Заявленное время на шаг 1 («описываете квартиру») — бейдж в блоке «Как это работает». */
|
||||
export const STEP1_DURATION_LABEL = "30 секунд";
|
||||
|
||||
/**
|
||||
* Строки таблицы сверок в блоке «Точность» (id="accuracy"). Это ИЛЛЮСТРАЦИЯ
|
||||
* формата отчёта из дизайн-макета (адреса, суммы и расхождения придуманы для
|
||||
* примера), а не реальные сделки — макет не содержал заполненных данных для
|
||||
* этой таблицы (только шаблон `{{ r.addr }}` и т.д.), реальных проверок под
|
||||
* ней нет. `checks`/`err`/`gain` здесь НЕ обязаны биться с
|
||||
* `ACCURACY_PLACEHOLDER` арифметически — это витринные примеры, а не выборка,
|
||||
* из которой посчитана средняя ошибка.
|
||||
*
|
||||
* Как и весь файл: до снятия noindex каждая строка обязана быть либо заменена
|
||||
* реальной сверенной сделкой, либо явно помечена как иллюстрация в самой
|
||||
* вёрстке (см. подпись под таблицей в AccuracyV3.tsx).
|
||||
*/
|
||||
export interface ProofRow {
|
||||
/** Адрес/район объекта. */
|
||||
readonly addr: string;
|
||||
/** Тип, площадь, этаж. */
|
||||
readonly meta: string;
|
||||
/** Прогноз «Меры». */
|
||||
readonly said: string;
|
||||
/** Фактическая цена сделки. */
|
||||
readonly fact: string;
|
||||
/** Расхождение прогноза и факта, %. */
|
||||
readonly err: string;
|
||||
/** Превышение над средней ценой по району, ₽. */
|
||||
readonly gain: string;
|
||||
/** То же превышение, %. */
|
||||
readonly gainPct: string;
|
||||
}
|
||||
|
||||
export const PROOF_ROWS_PLACEHOLDER: readonly ProofRow[] = [
|
||||
{
|
||||
addr: "Втузгородок, ул. Ясная, 8",
|
||||
meta: "1-к, 34 м², 6/16 этаж",
|
||||
said: "3 640 000 ₽",
|
||||
fact: "3 705 000 ₽",
|
||||
err: "1,8%",
|
||||
gain: "+165 000 ₽",
|
||||
gainPct: "+4,7%",
|
||||
},
|
||||
{
|
||||
addr: "Юго-Западный, ул. Юмашева, 15",
|
||||
meta: "2-к, 52 м², 3/9 этаж",
|
||||
said: "5 210 000 ₽",
|
||||
fact: "5 180 000 ₽",
|
||||
err: "0,6%",
|
||||
gain: "+96 000 ₽",
|
||||
gainPct: "+1,9%",
|
||||
},
|
||||
{
|
||||
addr: "Ботанический, ул. Академика Бардина, 23",
|
||||
meta: "3-к, 71 м², 8/16 этаж",
|
||||
said: "7 480 000 ₽",
|
||||
fact: "7 640 000 ₽",
|
||||
err: "2,1%",
|
||||
gain: "+310 000 ₽",
|
||||
gainPct: "+4,2%",
|
||||
},
|
||||
{
|
||||
addr: "Уралмаш, ул. Кировградская, 42",
|
||||
meta: "2-к, 46 м², 5/5 этаж",
|
||||
said: "4 120 000 ₽",
|
||||
fact: "4 065 000 ₽",
|
||||
err: "1,3%",
|
||||
gain: "+58 000 ₽",
|
||||
gainPct: "+1,4%",
|
||||
},
|
||||
{
|
||||
addr: "Центр, ул. Малышева, 51",
|
||||
meta: "1-к, 38 м², 11/25 этаж",
|
||||
said: "6 350 000 ₽",
|
||||
fact: "6 490 000 ₽",
|
||||
err: "2,2%",
|
||||
gain: "+215 000 ₽",
|
||||
gainPct: "+3,4%",
|
||||
},
|
||||
] as const;
|
||||
|
|
@ -13,6 +13,9 @@ import {
|
|||
} from "../content";
|
||||
import styles from "../landing.module.css";
|
||||
|
||||
import { SiteFooter } from "../_components/SiteFooter";
|
||||
import { SiteHeader } from "../_components/SiteHeader";
|
||||
|
||||
/**
|
||||
* Публичная оферта на оказание информационно-аналитических услуг «МЕРА».
|
||||
*
|
||||
|
|
@ -55,7 +58,9 @@ export default function MeraPublicOfferPage() {
|
|||
const entity = LEGAL_ENTITY;
|
||||
|
||||
return (
|
||||
<main id="content" className={styles.main} tabIndex={-1}>
|
||||
<>
|
||||
<SiteHeader />
|
||||
<main id="content" className={styles.main} tabIndex={-1}>
|
||||
<div className={`${styles.container} ${styles.doc}`}>
|
||||
<Link
|
||||
className={`${styles.link} ${styles.docBack}`}
|
||||
|
|
@ -475,6 +480,8 @@ export default function MeraPublicOfferPage() {
|
|||
</>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,14 +17,21 @@ import { HowItWorks } from "./_components/HowItWorks";
|
|||
import { WhatYouGet } from "./_components/WhatYouGet";
|
||||
import styles from "./landing.module.css";
|
||||
|
||||
import { SiteFooter } from "./_components/SiteFooter";
|
||||
import { SiteHeader } from "./_components/SiteHeader";
|
||||
|
||||
export default function MeraPublicPage() {
|
||||
return (
|
||||
<main id="content" className={styles.main} tabIndex={-1}>
|
||||
<>
|
||||
<SiteHeader />
|
||||
<main id="content" className={styles.main} tabIndex={-1}>
|
||||
<Hero />
|
||||
<HowItWorks />
|
||||
<WhatYouGet />
|
||||
<DataSources />
|
||||
<Faq />
|
||||
</main>
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,9 @@ import {
|
|||
import styles from "../landing.module.css";
|
||||
import { safeUrl } from "@/lib/safeUrl";
|
||||
|
||||
import { SiteFooter } from "../_components/SiteFooter";
|
||||
import { SiteHeader } from "../_components/SiteHeader";
|
||||
|
||||
/**
|
||||
* Политика обработки персональных данных по ст. 18.1 152-ФЗ.
|
||||
*
|
||||
|
|
@ -58,7 +61,9 @@ export default function MeraPublicPrivacyPage() {
|
|||
const entity = LEGAL_ENTITY;
|
||||
|
||||
return (
|
||||
<main id="content" className={styles.main} tabIndex={-1}>
|
||||
<>
|
||||
<SiteHeader />
|
||||
<main id="content" className={styles.main} tabIndex={-1}>
|
||||
<div className={`${styles.container} ${styles.doc}`}>
|
||||
<Link
|
||||
className={`${styles.link} ${styles.docBack}`}
|
||||
|
|
@ -279,6 +284,8 @@ export default function MeraPublicPrivacyPage() {
|
|||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</main>
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,9 @@ import {
|
|||
} from "../content";
|
||||
import styles from "../landing.module.css";
|
||||
|
||||
import { SiteFooter } from "../_components/SiteFooter";
|
||||
import { SiteHeader } from "../_components/SiteHeader";
|
||||
|
||||
/**
|
||||
* Политика возврата денежных средств.
|
||||
*
|
||||
|
|
@ -46,7 +49,9 @@ export default function MeraPublicRefundPage() {
|
|||
const entity = LEGAL_ENTITY;
|
||||
|
||||
return (
|
||||
<main id="content" className={styles.main} tabIndex={-1}>
|
||||
<>
|
||||
<SiteHeader />
|
||||
<main id="content" className={styles.main} tabIndex={-1}>
|
||||
<div className={`${styles.container} ${styles.doc}`}>
|
||||
<Link
|
||||
className={`${styles.link} ${styles.docBack}`}
|
||||
|
|
@ -209,6 +214,8 @@ export default function MeraPublicRefundPage() {
|
|||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</main>
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
56
tradein-mvp/frontend/src/app/mera-public/v3/page.tsx
Normal file
56
tradein-mvp/frontend/src/app/mera-public/v3/page.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import type { Metadata } from "next";
|
||||
|
||||
import { AccuracyV3 } from "../_components/v3/AccuracyV3";
|
||||
import { CostOfErrorV3 } from "../_components/v3/CostOfErrorV3";
|
||||
import { FooterV3 } from "../_components/v3/FooterV3";
|
||||
import { HeaderV3 } from "../_components/v3/HeaderV3";
|
||||
import { HeroV3 } from "../_components/v3/HeroV3";
|
||||
import { StepsV3 } from "../_components/v3/StepsV3";
|
||||
import { StickyCtaV3 } from "../_components/v3/StickyCtaV3";
|
||||
import styles from "../landing.module.css";
|
||||
|
||||
/**
|
||||
* Лэндинг v3 — сборка по дизайн-макету владельца (15.08.2026).
|
||||
*
|
||||
* ЭТО ПРЕВЬЮ, А НЕ ЗАМЕНА. Живой публичный лэндинг остаётся на
|
||||
* `/mera-public` — он открыт на meraocenka.ru и его нельзя менять
|
||||
* посреди согласования дизайна. Новая версия лежит рядом, по
|
||||
* `/mera-public/v3`, чтобы её можно было открыть и сравнить с текущей
|
||||
* на одном и том же контенте. Переключение — отдельным решением, когда
|
||||
* версия принята.
|
||||
*
|
||||
* ЧЕГО ЗДЕСЬ ПОКА НЕТ (секции макета, требующие данных или контуров,
|
||||
* которых нет в коде — каждая заведена отдельной задачей):
|
||||
* - лента «прогноз → факт» и мини-игра «Проверьте себя» — нужен контур
|
||||
* сверки прогноза с фактом сделки;
|
||||
* - «Продажа под ключ» — противоречит текущему утверждению лэндинга
|
||||
* «мы не берём квартиры на продажу», требует отдельного договора;
|
||||
* - блок статей — раздела не существует;
|
||||
* - модалка проверки и рабочая оплата — нет анонимного периметра и
|
||||
* платёжного контура.
|
||||
*
|
||||
* ЧИСЛА в секции «Точность» и «Цена ошибки» — плейсхолдеры из макета,
|
||||
* см. `marketing-v3.ts`: они обязаны быть заменены вычисляемыми до снятия
|
||||
* `robots: noindex`.
|
||||
*/
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Мера · расчёт стоимости квартиры по рыночным данным",
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
export default function MeraPublicV3Page() {
|
||||
return (
|
||||
<>
|
||||
<HeaderV3 />
|
||||
<main id="content" className={styles.main} tabIndex={-1}>
|
||||
<HeroV3 />
|
||||
<StepsV3 />
|
||||
<AccuracyV3 />
|
||||
<CostOfErrorV3 />
|
||||
</main>
|
||||
<FooterV3 />
|
||||
<StickyCtaV3 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue