feat(mera/b2c): лэндинг v3 доведён до макета 29.08 — лента, игра, два пути, возражения, статьи
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 12s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m18s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped

Макет «B2C модуль для МЕРА» (Макс, 29.08) — пять секций, которых превью
/mera-public/v3 не имело (его шапка их и перечисляла как отсутствующие):

- DealsTickerV3 — бегущая строка «ПРОГНОЗ → ФАКТ»; данные — ТЕ ЖЕ строки,
  что таблица сверок (PROOF_ROWS_PLACEHOLDER): один будущий контур — один
  набор реквизита, второй не заводится;
- GuessGameV3 — игра «Угадай цену», три раунда со слайдером, сверка
  «ваш ответ / МЕРА / факт», итог с медианной ошибкой игрока; раунды —
  GAME_ROUNDS_PLACEHOLDER под гейтом #2904;
- TwoPathsV3 — «сами / сделаем за вас»; заявка пути 2 ведёт в Telegram
  поддержки (формы и договора «под ключ» не существует);
- ObjectionsV3 — FAQ «перед оплатой» на нативных details; ответы из
  выверенной честной копии (content.ts/oferta/refund), макетный ответ про
  «отметки продавцов» заменён тем, что в продукте есть (Росреестр + снятие
  с публикации);
- ArticlesV3 — тизер «Разборы на данных»; карточки БЕЗ ссылок до появления
  раздела статей (следующий шард).

Решение «модалка → /estimate» (StickyCtaV3) сохранено для всех CTA макета.
Гейт noindex↔плейсхолдеры дополнен новыми файлами и GAME_ROUNDS_PLACEHOLDER.

Проверено: tsc чисто, next lint чисто, vitest 27 passed, живой прогон игры
на dev (раунд → сверка → следующая), полностраничный скрин 1:1 с макетом.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
bot-backend 2026-08-29 16:06:56 +05:00
parent 4a9df83209
commit be7d7d0205
9 changed files with 1279 additions and 10 deletions

View file

@ -41,6 +41,9 @@ const V3_SOURCES = [
"_components/v3/AccuracyV3.tsx",
"_components/v3/FreeCheckCard.tsx",
"_components/v3/FreeResultV3.tsx",
// Макет 29.08: лента и игра тоже стоят на реквизитных величинах.
"_components/v3/DealsTickerV3.tsx",
"_components/v3/GuessGameV3.tsx",
];
const PLACEHOLDER_EXPORTS = [
@ -48,6 +51,7 @@ const PLACEHOLDER_EXPORTS = [
"COST_OF_ERROR_PLACEHOLDER",
"FREE_SAMPLE_PLACEHOLDER",
"PROOF_ROWS_PLACEHOLDER",
"GAME_ROUNDS_PLACEHOLDER",
];
function placeholdersInUse(): string[] {

View file

@ -0,0 +1,51 @@
/**
* ArticlesV3 «Разборы на данных» (макет 29.08, секция «V3 статьи»).
*
* Раздела статей в продукте НЕ СУЩЕСТВУЕТ (страницы «МЕРА статьи»/«МЕРА
* статья» из макета следующий шард). Поэтому карточки рендерятся <article>,
* а НЕ <a>: ссылка «в никуда» на превью читалась бы как сломанная навигация.
* Ссылка «Все статьи » по той же причине не рендерится вовсе появится
* вместе с разделом. Темы и рубрики карточек из макета дословно.
*/
import styles from "../../landing-v3.module.css";
interface ArticleCard {
readonly rubric: string;
readonly title: string;
}
const ARTICLES: readonly ArticleCard[] = [
{
rubric: "ЦЕНООБРАЗОВАНИЕ · 6 МИН",
title: "Как оценить квартиру для продажи — без риелтора и «на глазок»",
},
{
rubric: "СРОКИ · 5 МИН",
title: "Сколько продаётся квартира в 2026 году и что «подвешивает» объявление",
},
{
rubric: "ТОРГ · 7 МИН",
title: "Завысить и торговаться или поставить в рынок: что выгоднее",
},
];
export function ArticlesV3() {
return (
<section className={styles.artSection} aria-labelledby="articles-v3-title">
<div className={styles.artHead}>
<h2 id="articles-v3-title" className={styles.artTitle}>
Разборы на данных
</h2>
</div>
<div className={styles.artGrid}>
{ARTICLES.map((a) => (
<article key={a.title} className={styles.artCard}>
<div className={styles.artRubric}>{a.rubric}</div>
<div className={styles.artCardTitle}>{a.title}</div>
</article>
))}
</div>
</section>
);
}

View file

@ -0,0 +1,52 @@
/**
* DealsTickerV3 бегущая строка «ПРОГНОЗ ФАКТ» (макет 29.08, секция
* «V3 лента сделок»): тёмная полоса сразу под шапкой.
*
* Данные ТЕ ЖЕ строки, что в таблице сверок (`PROOF_ROWS_PLACEHOLDER`):
* лента и таблица иллюстрируют один и тот же будущий контур «прогноз факт»,
* которого в продукте пока нет заводить второй набор выдуманных сделок
* значило бы удвоить сущности под одним и тем же гейтом (#2904). Список
* дублируется дважды подряд стандартный приём бесшовного CSS-marquee
* (анимация уводит ровно на 50%).
*
* `aria-hidden` на второй копии и `role="marquee"`-семантика намеренно НЕ
* используются: полоса декоративна, весь её смысл продублирован таблицей
* сверок ниже по странице скринридеру достаточно одного источника.
* `prefers-reduced-motion` глушится глобальным правилом из css-модуля.
*/
import { PROOF_ROWS_PLACEHOLDER } from "../../marketing-v3";
import styles from "../../landing-v3.module.css";
export function DealsTickerV3() {
const items = [...PROOF_ROWS_PLACEHOLDER, ...PROOF_ROWS_PLACEHOLDER];
return (
<div className={styles.tickerRoot}>
<div className={styles.tickerBadge}>
<span className={styles.tickerDot} aria-hidden="true" />
<span className={styles.tickerBadgeText}>ПРОГНОЗ ФАКТ</span>
</div>
<div className={styles.tickerTrack} aria-hidden="true">
{items.map((r, i) => (
<div key={`${r.addr}-${i}`} className={styles.tickerItem}>
<span className={styles.tickerObj}>{r.addr}</span>
<span className={styles.tickerPair}>
<span className={styles.tickerLabelMuted}>ПРОГНОЗ</span>
<span className={styles.tickerSaid}>{r.said}</span>
</span>
<span className={styles.tickerArrow}></span>
<span className={styles.tickerPair}>
<span className={styles.tickerLabelAccent}>ФАКТ</span>
<span className={styles.tickerFact}>{r.fact}</span>
</span>
<span className={styles.tickerErr}>{r.err}</span>
<span className={styles.tickerGain}>
<span className={styles.tickerGainCaret}></span>
<span>{r.gain}</span>
</span>
</div>
))}
</div>
</div>
);
}

View file

@ -0,0 +1,214 @@
"use client";
/**
* GuessGameV3 мини-игра «Сыграйте против МЕРЫ на реальных сделках»
* (макет 29.08, секция `#game` «V3 игра "Угадай цену"»).
*
* Клиентский компонент: три раунда «слайдер ответ сверка», итоговый экран
* со средней ошибкой игрока против ошибки МЕРЫ. Раунды реквизит из
* `GAME_ROUNDS_PLACEHOLDER` (см. шапку marketing-v3.ts: контура сверки
* «прогноз факт» в продукте нет, «реальные сделки» здесь макетные, и
* страница закрыта noindex до появления вычисляемых).
*
* Ошибка игрока считается медианой |ответ факт| / факт по трём раундам
* та же метрика, которой описана ошибка МЕРЫ (GAME_MERA_ERROR_LABEL).
* Финальная CTA ведёт на `/estimate` (решение StickyCtaV3 вместо макетной
* модалки).
*/
import { useState } from "react";
import {
GAME_MERA_ERROR_LABEL,
GAME_ROUNDS_PLACEHOLDER,
} from "../../marketing-v3";
import styles from "../../landing-v3.module.css";
import { PublicLink } from "../PublicLink";
type Phase = "playing" | "revealed" | "finished";
const fmtMln = (v: number) =>
`${v.toFixed(2).replace(".", ",")} млн ₽`;
function median(xs: number[]): number {
const s = [...xs].sort((a, b) => a - b);
const mid = Math.floor(s.length / 2);
return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2;
}
export function GuessGameV3() {
const rounds = GAME_ROUNDS_PLACEHOLDER;
const [idx, setIdx] = useState(0);
const [phase, setPhase] = useState<Phase>("playing");
const [guess, setGuess] = useState(rounds[0].start);
const [errors, setErrors] = useState<number[]>([]);
const r = rounds[idx];
const isLast = idx === rounds.length - 1;
const answer = () => {
setErrors((prev) => [...prev, Math.abs(guess - r.fact) / r.fact]);
setPhase("revealed");
};
const next = () => {
if (isLast) {
setPhase("finished");
return;
}
const n = idx + 1;
setIdx(n);
setGuess(rounds[n].start);
setPhase("playing");
};
const restart = () => {
setIdx(0);
setGuess(rounds[0].start);
setErrors([]);
setPhase("playing");
};
const userErrPct = errors.length
? `${(median(errors) * 100).toFixed(1).replace(".", ",")}%`
: "—";
const roundErrPct = errors.length
? `${(errors[errors.length - 1] * 100).toFixed(1).replace(".", ",")}%`
: "—";
return (
<section id="game" className={styles.gameSection} aria-labelledby="game-v3-title">
<div className={styles.gameHead}>
<div className={styles.accEyebrow}>
ПРОВЕРЬТЕ СЕБЯ · РАУНД {Math.min(idx + 1, rounds.length)} ИЗ {rounds.length}
</div>
<h2 id="game-v3-title" className={styles.accTitle}>
Сыграйте против МЕРЫ на реальных сделках
</h2>
<p className={styles.gameLead}>
Три квартиры, которые уже проданы. Назовите свою цифру потом
увидите, что сказала МЕРА и чем всё закончилось.
</p>
</div>
<div className={styles.gameCard}>
<div className={styles.gameGrid}>
<div className={styles.gameMap}>
<div className={styles.gameMapGrid} aria-hidden="true" />
<div className={styles.gameMapRoadH} aria-hidden="true" />
<div className={styles.gameMapRoadV} aria-hidden="true" />
<div
className={styles.gameMapDot}
style={{ left: `${r.dotX}%`, top: `${r.dotY}%` }}
aria-hidden="true"
>
<span className={styles.gameMapPing} />
<span className={styles.gameMapCore} />
</div>
<div className={styles.gameMapLabel}>
ОБЪЕКТ НА КАРТЕ · {r.district}
</div>
<div className={styles.gameMapBottom}>
<div className={styles.gameAddr}>{r.addr}</div>
<div className={styles.gameMeta}>{r.meta}</div>
<div className={styles.gameTags}>
{r.tags.map((t) => (
<span key={t} className={styles.gameTag}>
{t}
</span>
))}
</div>
</div>
</div>
<div className={styles.gamePanel}>
{phase === "playing" && (
<div className={styles.gameFade}>
<div className={styles.gameQuestion}>{r.question}</div>
<div className={styles.gameHint}>{r.hint}</div>
<div className={styles.gameGuess}>{fmtMln(guess)}</div>
<input
type="range"
min={r.min}
max={r.max}
step={r.step}
value={guess}
onChange={(e) => setGuess(Number(e.target.value))}
className={styles.gameRange}
aria-label="Ваша цена, млн ₽"
/>
<div className={styles.gameScale}>
<span>{fmtMln(r.min)}</span>
<span>{fmtMln(r.max)}</span>
</div>
<button type="button" onClick={answer} className={styles.gameBtnAccent}>
Ответить
</button>
</div>
)}
{phase === "revealed" && (
<div className={styles.gameFade}>
<div className={styles.gameResultEyebrow}>РЕЗУЛЬТАТ РАУНДА</div>
<div className={styles.gameRows}>
<div className={styles.gameRowLight}>
<span className={styles.gameRowLabel}>Ваш ответ</span>
<span className={styles.gameRowValue}>{fmtMln(guess)}</span>
</div>
<div className={styles.gameRowSoft}>
<span className={styles.gameRowLabelAccent}>МЕРА сказала</span>
<span className={styles.gameRowValueAccent}>{fmtMln(r.mera)}</span>
</div>
<div className={styles.gameRowDark}>
<span className={styles.gameRowLabelMuted}>Факт сделки</span>
<span className={styles.gameRowValueLight}>{fmtMln(r.fact)}</span>
</div>
</div>
<div className={styles.gameVerdict}>
Вы разошлись с фактом на {roundErrPct}
{" · "}МЕРА на{" "}
{`${(Math.abs(r.mera - r.fact) / r.fact * 100)
.toFixed(1)
.replace(".", ",")}%`}
.
</div>
<button type="button" onClick={next} className={styles.gameBtnDark}>
{isLast ? "Показать итог" : "Следующая квартира →"}
</button>
</div>
)}
{phase === "finished" && (
<div className={`${styles.gameFade} ${styles.gameFinal}`}>
<div className={styles.gameResultEyebrow}>ИТОГ ТРЁХ РАУНДОВ</div>
<div className={styles.gameFinalGrid}>
<div className={styles.gameFinalTileLight}>
<div className={styles.gameFinalValue}>{userErrPct}</div>
<div className={styles.gameFinalLabel}>ваша ошибка по цене</div>
</div>
<div className={styles.gameFinalTileSoft}>
<div className={styles.gameFinalValueAccent}>
{GAME_MERA_ERROR_LABEL}
</div>
<div className={styles.gameFinalLabelAccent}>
ошибка МЕРЫ по цене
</div>
</div>
</div>
<div className={styles.gameVerdict}>
Рынок сложно угадать на глаз для этого и существует расчёт
по данным.
</div>
<PublicLink to="estimate" className={styles.gameBtnAccent}>
Узнать цифру для своей квартиры
</PublicLink>
<button type="button" onClick={restart} className={styles.gameRestart}>
Сыграть заново
</button>
</div>
)}
</div>
</div>
</div>
</section>
);
}

View file

@ -0,0 +1,97 @@
/**
* ObjectionsV3 «Что обычно спрашивают перед оплатой» (макет 29.08, секция
* «V3 возражения»): двухколоночный FAQ с CTA слева.
*
* Аккордеон нативные <details>/<summary>, как в Faq старого лэндинга:
* макетный клиентский toggle не нужен, поведение и клавиатурная доступность
* бесплатны. Иконка +/ рисуется через CSS по [open].
*
* Ответы НЕ из макета (там реквизит рендера): собраны из уже выверенной
* честной копии продукта content.ts (FAQ), oferta/refund. Вопрос про факт
* сделки отвечаем тем, что в продукте ЕСТЬ (сделки Росреестра + снятие
* объявлений с публикации), а не макетной формулировкой про «отметки
* продавцов», механики которых не существует.
*/
import styles from "../../landing-v3.module.css";
import { PublicLink } from "../PublicLink";
interface Objection {
readonly q: string;
readonly a: readonly string[];
}
const OBJECTIONS: readonly Objection[] = [
{
q: "Откуда вы знаете факт сделки?",
a: [
"К объявлениям мы добавляем зарегистрированные сделки Росреестра и отслеживаем снятие объявлений с публикации. По этим данным считается расхождение прогноза с реальностью — и честно показывается, на скольких объектах построен каждый расчёт.",
],
},
{
q: "Почему так дёшево — это точно работает?",
a: [
"Дёшево, потому что расчёт автоматический: без выезда, без оценщика, без комиссии. Вы платите за данные, а не за часы специалиста.",
"Точность прямо зависит от того, сколько нашлось сопоставимых объектов, — поэтому мы всегда пишем, на скольких объектах и сделках построен расчёт, и честно сообщаем, если данных по дому мало.",
],
},
{
q: "Чем это лучше бесплатных калькуляторов?",
a: [
"Калькулятор площадки считает по объявлениям этой же площадки — то есть по ценам, которые продавцы просят, а не получают.",
"Мы смотрим сразу несколько площадок и добавляем зарегистрированные сделки, чтобы была видна разница между запрашиваемой и реальной ценой.",
],
},
{
q: "Это подписка? Спишут ли ещё раз?",
a: [
"Нет. Это разовый платёж за один отчёт — без подписки, автопродлений и скрытых списаний. Отчёт остаётся у вас по ссылке.",
],
},
{
q: "Мне начнут звонить после проверки?",
a: [
"Нет. Для расчёта нужны адрес и параметры квартиры — телефон мы не спрашиваем. Он появляется, только если вы сами решите оставить заявку, и всегда с отдельной галочкой согласия.",
],
},
{
q: "Это официальная оценка для банка?",
a: [
"Нет. Для ипотеки, суда, опеки и нотариуса нужен отчёт аккредитованного оценщика по 135-ФЗ — это отдельная процедура с выездом. МЕРА отвечает на другой вопрос: за сколько квартира реально продаётся на рынке сегодня.",
],
},
];
export function ObjectionsV3() {
return (
<section className={styles.objSection} aria-labelledby="obj-v3-title">
<div className={styles.objGrid}>
<div>
<div className={styles.accEyebrow}>СОМНЕНИЯ</div>
<h2 id="obj-v3-title" className={styles.objTitle}>
Что обычно спрашивают перед оплатой
</h2>
<PublicLink to="estimate" className={styles.objCtaBtn}>
Проверить за 30 секунд
</PublicLink>
</div>
<div className={styles.objList}>
{OBJECTIONS.map((o) => (
<details key={o.q} className={styles.objItem}>
<summary className={styles.objSummary}>
<span className={styles.objQuestion}>{o.q}</span>
<span className={styles.objIcon} aria-hidden="true" />
</summary>
<div className={styles.objAnswer}>
{o.a.map((p) => (
<p key={p.slice(0, 24)}>{p}</p>
))}
</div>
</details>
))}
</div>
</div>
</section>
);
}

View file

@ -0,0 +1,73 @@
/**
* TwoPathsV3 «С отчётом справитесь сами. Не уверены сделаем за вас»
* (макет 29.08, секция `#turnkey` «V3 два пути»).
*
* Кнопка пути 1 ведёт на `/estimate` то же решение, что у StickyCtaV3:
* макетный `openModal` в коде заменён отдельным экраном проверки.
*
* Кнопка пути 2 («Оставить заявку») ведёт в Telegram поддержки: формы
* партнёрской заявки и договора «продажи под ключ» в продукте НЕТ до их
* появления честный путь заявки один, живой канал. Текст пути 2 из макета
* сохранён дословно: он аккуратно не обещает ничего сверх «узнайте условия».
*/
import { SUPPORT_TELEGRAM_URL } from "../../content";
import { COST_OF_ERROR_PLACEHOLDER } from "../../marketing-v3";
import styles from "../../landing-v3.module.css";
import { PublicLink } from "../PublicLink";
const REPORT_PRICE = COST_OF_ERROR_PLACEHOLDER[2].value;
export function TwoPathsV3() {
return (
<section
id="turnkey"
className={styles.pathsSection}
aria-labelledby="paths-v3-title"
>
<div className={styles.pathsHead}>
<div className={styles.accEyebrow}>ВЫБЕРИТЕ ПУТЬ</div>
<h2 id="paths-v3-title" className={styles.accTitle}>
С отчётом справитесь сами. Не уверены сделаем за вас
</h2>
</div>
<div className={styles.pathsGrid}>
<div className={styles.pathCard}>
<div className={styles.pathEyebrow}>ПУТЬ 1 · САМОСТОЯТЕЛЬНО</div>
<div className={styles.pathTitle}>Отчёт за {REPORT_PRICE}</div>
<p className={styles.pathBody}>
Цена, срок, аналоги, факторы. Дальше вы ведёте продажу сами на
руках все цифры, чтобы уверенно держать свою цену в переговорах.
</p>
<PublicLink to="estimate" className={styles.pathBtnLight}>
Проверить квартиру
</PublicLink>
</div>
<div className={styles.pathCardDark}>
<div className={styles.pathEyebrowAccent}>
ПУТЬ 2 · ЕСЛИ НУЖНА ПОМОЩЬ
</div>
<div className={styles.pathTitle}>
Можем продать за вас но только если захотите
</div>
<p className={styles.pathBodyDark}>
Отчёта достаточно, чтобы справиться самому. Но если нет времени или
не хочется вести показы и торг передайте продажу нашим
специалистам: объявление, фото, показы, переговоры, сделка. Работаем
по цене из вашего отчёта, стоимость фиксированная и известна
заранее. Ни к чему не обязывает просто узнайте условия.
</p>
<a
href={SUPPORT_TELEGRAM_URL}
target="_blank"
rel="noreferrer"
className={styles.pathBtnAccent}
>
Оставить заявку
</a>
</div>
</div>
</section>
);
}

View file

@ -1482,3 +1482,685 @@
border-radius: 12px;
background: var(--b2c-surface-soft);
}
/* ==========================================================================
Макет 29.08 («B2C модуль», Макс): лента сделок, игра, два пути,
возражения, статьи. Та же палитра b2c-tokens, что и выше.
========================================================================== */
/* --- Лента «ПРОГНОЗ → ФАКТ» ---------------------------------------------- */
.tickerRoot {
position: relative;
overflow: hidden;
background: var(--b2c-surface-dark);
}
.tickerBadge {
position: absolute;
inset: 0 auto 0 0;
z-index: 2;
display: flex;
align-items: center;
gap: 10px;
padding: 0 18px 0 clamp(20px, 5vw, 56px);
background: var(--b2c-surface-dark);
box-shadow: 18px 0 24px rgba(18, 26, 29, 0.9);
}
.tickerDot {
display: block;
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--b2c-accent);
animation: tickerPulse 2s ease-in-out infinite;
}
.tickerBadgeText {
font-family: var(--b2c-font-mono);
font-size: 10px;
letter-spacing: 0.16em;
color: var(--b2c-accent);
white-space: nowrap;
}
.tickerTrack {
display: flex;
width: max-content;
padding: 12px 0;
animation: tickerMarquee 90s linear infinite;
}
.tickerItem {
display: flex;
align-items: center;
gap: 20px;
padding: 0 30px;
border-right: 1px solid #2c3a3f;
white-space: nowrap;
}
.tickerObj {
font-size: 15px;
font-weight: 800;
color: #ffffff;
}
.tickerPair {
display: flex;
align-items: center;
gap: 8px;
}
.tickerLabelMuted {
font-family: var(--b2c-font-mono);
font-size: 10px;
letter-spacing: 0.12em;
color: var(--b2c-muted);
}
.tickerLabelAccent {
composes: tickerLabelMuted;
color: var(--b2c-accent-text);
}
.tickerSaid {
font-size: 14px;
font-weight: 600;
color: #9fb0b4;
}
.tickerArrow {
color: #2c3a3f;
font-size: 15px;
}
.tickerFact {
font-size: 14px;
font-weight: 700;
color: var(--b2c-accent);
}
.tickerErr {
font-family: var(--b2c-font-mono);
font-size: 11px;
color: #9fb0b4;
}
.tickerGain {
display: flex;
align-items: center;
gap: 5px;
font-family: var(--b2c-font-mono);
font-size: 13px;
font-weight: 500;
color: #3fd1a6;
}
.tickerGainCaret {
font-size: 10px;
}
@keyframes tickerMarquee {
from { transform: translateX(0); }
to { transform: translateX(-50%); }
}
@keyframes tickerPulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.45; }
}
@media (prefers-reduced-motion: reduce) {
.tickerTrack { animation: none; }
.tickerDot { animation: none; }
}
/* --- Игра «Угадай цену» --------------------------------------------------- */
.gameSection {
max-width: 1280px;
margin: 0 auto;
padding: clamp(48px, 6vw, 80px) clamp(20px, 5vw, 56px);
}
.gameHead {
max-width: 700px;
margin-bottom: 32px;
}
.gameLead {
margin: 14px 0 0;
font-size: 16.5px;
line-height: 1.6;
color: var(--b2c-muted);
text-wrap: pretty;
}
.gameCard {
overflow: hidden;
background: var(--b2c-surface);
border: 1px solid var(--b2c-line);
border-radius: 14px;
box-shadow: 0 16px 44px rgba(18, 26, 29, 0.07);
}
.gameGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 300px), 1fr));
}
.gameMap {
position: relative;
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 24px;
min-height: 300px;
padding: clamp(24px, 3vw, 34px);
background: var(--b2c-surface-dark);
}
.gameMapGrid {
position: absolute;
inset: 0;
opacity: 0.16;
background-image:
linear-gradient(var(--b2c-accent) 1px, transparent 1px),
linear-gradient(90deg, var(--b2c-accent) 1px, transparent 1px);
background-size: 44px 44px;
}
.gameMapRoadH {
position: absolute;
left: 0;
right: 0;
top: 38%;
height: 14px;
background: #2c3a3f;
opacity: 0.7;
}
.gameMapRoadV {
position: absolute;
top: 0;
bottom: 0;
left: 62%;
width: 12px;
background: #2c3a3f;
opacity: 0.7;
}
.gameMapDot {
position: absolute;
width: 16px;
height: 16px;
margin: -8px 0 0 -8px;
}
.gameMapPing {
position: absolute;
inset: 0;
display: block;
border-radius: 50%;
background: var(--b2c-accent);
animation: gamePing 2.4s ease-out infinite;
}
.gameMapCore {
position: absolute;
inset: 0;
display: block;
border-radius: 50%;
background: var(--b2c-accent);
border: 3px solid var(--b2c-surface-dark);
}
@keyframes gamePing {
0% { transform: scale(1); opacity: 0.55; }
70%, 100% { transform: scale(2.6); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.gameMapPing { animation: none; }
}
.gameMapLabel {
position: relative;
font-family: var(--b2c-font-mono);
font-size: 9.5px;
letter-spacing: 0.14em;
color: var(--b2c-accent);
}
.gameMapBottom {
position: relative;
}
.gameAddr {
font-weight: 800;
font-size: clamp(19px, 2.2vw, 24px);
line-height: 1.2;
color: #ffffff;
}
.gameMeta {
margin-top: 8px;
font-size: 14px;
color: #9fb0b4;
}
.gameTags {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 16px;
}
.gameTag {
font-family: var(--b2c-font-mono);
font-size: 10.5px;
color: #9fb0b4;
border: 1px solid #2c3a3f;
border-radius: 4px;
padding: 5px 10px;
}
.gamePanel {
display: flex;
flex-direction: column;
justify-content: center;
padding: clamp(24px, 3vw, 34px);
}
.gameFade {
animation: gameFadeIn 0.3s ease;
}
@keyframes gameFadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
@media (prefers-reduced-motion: reduce) {
.gameFade { animation: none; }
}
.gameQuestion {
margin-bottom: 6px;
font-weight: 800;
font-size: clamp(17px, 1.8vw, 20px);
line-height: 1.35;
}
.gameHint {
margin-bottom: 26px;
font-size: 14px;
line-height: 1.55;
color: var(--b2c-muted);
}
.gameGuess {
margin-bottom: 18px;
font-size: clamp(30px, 3.6vw, 40px);
font-weight: 800;
letter-spacing: -0.02em;
line-height: 1;
font-variant-numeric: tabular-nums;
}
.gameRange {
width: 100%;
height: 6px;
margin: 0 0 10px;
appearance: none;
border-radius: 3px;
background: var(--b2c-line);
outline: none;
}
.gameRange::-webkit-slider-thumb {
appearance: none;
width: 26px;
height: 26px;
border-radius: 50%;
background: var(--b2c-accent);
border: 3px solid var(--b2c-surface);
box-shadow: 0 2px 8px rgba(18, 26, 29, 0.25);
cursor: pointer;
}
.gameRange::-moz-range-thumb {
width: 26px;
height: 26px;
border-radius: 50%;
background: var(--b2c-accent);
border: 3px solid var(--b2c-surface);
cursor: pointer;
}
.gameScale {
display: flex;
justify-content: space-between;
margin-bottom: 24px;
font-family: var(--b2c-font-mono);
font-size: 10.5px;
color: #9fb0b4;
}
.gameBtnAccent {
display: block;
width: 100%;
padding: 15px 20px;
border: none;
border-radius: 6px;
background: var(--b2c-accent-deep);
color: var(--b2c-on-accent);
font-family: var(--b2c-font-sans);
font-weight: 700;
font-size: 16px;
text-align: center;
cursor: pointer;
box-sizing: border-box;
}
.gameBtnAccent:hover {
background: var(--b2c-ink);
}
.gameBtnDark {
composes: gameBtnAccent;
background: var(--b2c-surface-dark);
}
.gameBtnDark:hover {
background: var(--b2c-accent-deep);
}
.gameResultEyebrow {
margin-bottom: 16px;
font-family: var(--b2c-font-mono);
font-size: 9.5px;
letter-spacing: 0.14em;
color: var(--b2c-accent-text);
}
.gameRows {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 20px;
}
.gameRowLight {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 12px;
padding: 14px 18px;
border-radius: 8px;
background: var(--b2c-page-bg);
}
.gameRowSoft {
composes: gameRowLight;
background: var(--b2c-surface-soft);
}
.gameRowDark {
composes: gameRowLight;
background: var(--b2c-surface-dark);
}
.gameRowLabel {
font-size: 14px;
color: var(--b2c-muted);
}
.gameRowLabelAccent {
composes: gameRowLabel;
color: var(--b2c-accent-text);
}
.gameRowLabelMuted {
composes: gameRowLabel;
color: #9fb0b4;
}
.gameRowValue {
font-weight: 800;
font-size: 19px;
font-variant-numeric: tabular-nums;
}
.gameRowValueAccent {
composes: gameRowValue;
color: var(--b2c-accent-text);
}
.gameRowValueLight {
composes: gameRowValue;
color: #ffffff;
}
.gameVerdict {
margin-bottom: 22px;
font-size: 15px;
line-height: 1.6;
color: #3a4a4f;
}
.gameFinal {
text-align: center;
}
.gameFinalGrid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-bottom: 20px;
}
.gameFinalTileLight {
padding: 20px;
border-radius: 8px;
background: var(--b2c-page-bg);
}
.gameFinalTileSoft {
composes: gameFinalTileLight;
background: var(--b2c-surface-soft);
}
.gameFinalValue {
font-size: 30px;
font-weight: 800;
line-height: 1;
font-variant-numeric: tabular-nums;
}
.gameFinalValueAccent {
composes: gameFinalValue;
color: var(--b2c-accent-text);
}
.gameFinalLabel {
margin-top: 6px;
font-size: 13px;
color: var(--b2c-muted);
}
.gameFinalLabelAccent {
composes: gameFinalLabel;
color: var(--b2c-accent-text);
}
.gameRestart {
margin-top: 14px;
padding: 0;
border: none;
background: none;
color: var(--b2c-muted);
font-family: var(--b2c-font-sans);
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.gameRestart:hover {
color: var(--b2c-accent-text);
}
/* --- Два пути ------------------------------------------------------------- */
.pathsSection {
max-width: 1280px;
margin: 0 auto;
padding: clamp(48px, 6vw, 80px) clamp(20px, 5vw, 56px);
}
.pathsHead {
max-width: 680px;
margin-bottom: 32px;
}
.pathsGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 320px), 1fr));
gap: 20px;
}
.pathCard {
display: flex;
flex-direction: column;
padding: clamp(24px, 3vw, 34px);
background: var(--b2c-surface);
border: 1px solid var(--b2c-line);
border-radius: 12px;
}
.pathCardDark {
composes: pathCard;
background: var(--b2c-surface-dark);
border-color: var(--b2c-surface-dark);
color: #ffffff;
}
.pathEyebrow {
margin-bottom: 14px;
font-family: var(--b2c-font-mono);
font-size: 10px;
letter-spacing: 0.14em;
color: var(--b2c-muted);
}
.pathEyebrowAccent {
composes: pathEyebrow;
color: var(--b2c-accent);
}
.pathTitle {
margin-bottom: 10px;
font-weight: 800;
font-size: 21px;
}
.pathBody {
flex: 1;
margin: 0;
font-size: 15px;
line-height: 1.6;
color: var(--b2c-muted);
}
.pathBodyDark {
composes: pathBody;
color: #9fb0b4;
}
.pathBtnLight {
display: block;
margin-top: 24px;
padding: 14px 20px;
border: 1px solid var(--b2c-line);
border-radius: 6px;
background: var(--b2c-page-bg);
color: var(--b2c-ink);
font-weight: 700;
font-size: 15px;
text-align: center;
}
.pathBtnLight:hover {
border-color: var(--b2c-accent);
color: var(--b2c-accent-text);
}
.pathBtnAccent {
display: block;
margin-top: 24px;
padding: 14px 20px;
border-radius: 6px;
background: var(--b2c-accent-deep);
color: var(--b2c-on-accent);
font-weight: 700;
font-size: 15px;
text-align: center;
}
.pathBtnAccent:hover {
background: var(--b2c-accent);
color: var(--b2c-on-accent);
}
/* --- Возражения (FAQ) ----------------------------------------------------- */
.objSection {
background: var(--b2c-surface);
border-top: 1px solid var(--b2c-line);
border-bottom: 1px solid var(--b2c-line);
}
.objGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr));
gap: clamp(28px, 4vw, 56px);
max-width: 1280px;
margin: 0 auto;
padding: clamp(48px, 6vw, 80px) clamp(20px, 5vw, 56px);
}
.objTitle {
margin: 0 0 20px;
font-size: clamp(26px, 3.4vw, 38px);
line-height: 1.12;
letter-spacing: -0.015em;
font-weight: 800;
}
.objCtaBtn {
display: inline-block;
padding: 13px 24px;
border-radius: 6px;
background: var(--b2c-surface-dark);
color: var(--b2c-on-accent);
font-weight: 700;
font-size: 14.5px;
}
.objCtaBtn:hover {
background: var(--b2c-accent-deep);
color: var(--b2c-on-accent);
}
.objList {
display: flex;
flex-direction: column;
}
.objItem {
border-bottom: 1px solid var(--b2c-line);
}
.objSummary {
display: flex;
justify-content: space-between;
align-items: center;
gap: 20px;
padding: 20px 0;
cursor: pointer;
list-style: none;
}
.objSummary::-webkit-details-marker {
display: none;
}
.objQuestion {
font-weight: 800;
font-size: 16.5px;
color: var(--b2c-ink);
}
.objIcon {
position: relative;
flex: none;
width: 16px;
height: 16px;
}
.objIcon::before,
.objIcon::after {
content: "";
position: absolute;
inset: 50% auto auto 0;
width: 16px;
height: 2px;
margin-top: -1px;
border-radius: 1px;
background: var(--b2c-accent);
transition: transform 0.2s ease;
}
.objIcon::after {
transform: rotate(90deg);
}
.objItem[open] .objIcon::after {
transform: rotate(0deg);
}
.objAnswer {
max-width: 720px;
padding: 0 0 22px;
}
.objAnswer p {
margin: 0 0 10px;
font-size: 15px;
line-height: 1.65;
color: var(--b2c-muted);
}
.objAnswer p:last-child {
margin-bottom: 0;
}
/* --- Статьи --------------------------------------------------------------- */
.artSection {
max-width: 1280px;
margin: 0 auto;
padding: clamp(48px, 6vw, 80px) clamp(20px, 5vw, 56px);
}
.artHead {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 24px;
flex-wrap: wrap;
margin-bottom: 28px;
}
.artTitle {
margin: 0;
font-size: clamp(24px, 3vw, 32px);
line-height: 1.15;
letter-spacing: -0.015em;
font-weight: 800;
}
.artGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr));
gap: 20px;
}
.artCard {
padding: 26px;
background: var(--b2c-surface);
border: 1px solid var(--b2c-line);
border-radius: 10px;
}
.artRubric {
margin-bottom: 14px;
font-family: var(--b2c-font-mono);
font-size: 9.5px;
letter-spacing: 0.14em;
color: var(--b2c-muted);
}
.artCardTitle {
font-weight: 800;
font-size: 17.5px;
line-height: 1.3;
}

View file

@ -164,3 +164,86 @@ export const PROOF_ROWS_PLACEHOLDER: readonly ProofRow[] = [
gainPct: "+3,4%",
},
] as const;
// ---------------------------------------------------------------------------
// Дополнение по макету 29.08 («B2C модуль для МЕРА», Макс) — секции «лента
// сделок», «игра "Угадай цену"», «возражения», «статьи». Тот же контракт, что
// у величин выше: ничего из этого НЕ подтверждено замером, контура сверки
// «прогноз → факт» в продукте не существует (см. шапку модуля и гейт-тест) —
// поэтому данные живут здесь, под noindex, до появления вычисляемых.
// ---------------------------------------------------------------------------
/** Раунд мини-игры «Угадай цену»: реквизит из макета, НЕ реальная сделка. */
export interface GameRound {
readonly district: string;
readonly addr: string;
readonly meta: string;
readonly tags: readonly string[];
readonly question: string;
readonly hint: string;
/** Слайдер: млн ₽. */
readonly min: number;
readonly max: number;
readonly step: number;
readonly start: number;
/** «МЕРА сказала» и «факт сделки», млн ₽ — числа из макета. */
readonly mera: number;
readonly fact: number;
/** Позиция точки на декоративной карте, в процентах. */
readonly dotX: number;
readonly dotY: number;
}
export const GAME_ROUNDS_PLACEHOLDER: readonly GameRound[] = [
{
district: "ЦЕНТРАЛЬНЫЙ Р-Н",
addr: "1-комнатная, 34 м²",
meta: "Центральный р-н · 4/9 этаж · панель",
tags: ["ХОРОШЕЕ СОСТОЯНИЕ", "9 АНАЛОГОВ", "БЛИЗКО К МЕТРО"],
question: "Какую цену поставить, чтобы продать за 30 дней?",
hint: "Продано в марте 2026. Ваш ответ — в миллионах рублей.",
min: 4.5,
max: 7.5,
step: 0.05,
start: 5.4,
mera: 5.55,
fact: 5.5,
dotX: 32,
dotY: 42,
},
{
district: "ЮГО-ЗАПАД",
addr: "2-комнатная, 52 м²",
meta: "Юго-Западный р-н · 7/16 этаж · монолит",
tags: ["КОСМЕТИКА", "14 АНАЛОГОВ", "ДВОР БЕЗ МАШИН"],
question: "За сколько её реально купили?",
hint: "Продано в мае 2026. Ваш ответ — в миллионах рублей.",
min: 5.5,
max: 9.5,
step: 0.05,
start: 7.2,
mera: 7.4,
fact: 7.55,
dotX: 58,
dotY: 61,
},
{
district: "УРАЛМАШ",
addr: "3-комнатная, 74 м²",
meta: "Орджоникидзевский р-н · 2/5 этаж · кирпич",
tags: ["ТРЕБУЕТ РЕМОНТА", "6 АНАЛОГОВ", "ТИХИЙ ДВОР"],
question: "Какая цена ушла бы за 45 дней?",
hint: "Продано в июне 2026. Ваш ответ — в миллионах рублей.",
min: 4.0,
max: 8.0,
step: 0.05,
start: 6.0,
mera: 5.85,
fact: 5.95,
dotX: 44,
dotY: 28,
},
];
/** Ошибка МЕРЫ в финале игры — та же величина, что в бейдже героя. */
export const GAME_MERA_ERROR_LABEL = "1,8%";

View file

@ -1,12 +1,17 @@
import type { Metadata } from "next";
import { AccuracyV3 } from "../_components/v3/AccuracyV3";
import { ArticlesV3 } from "../_components/v3/ArticlesV3";
import { CostOfErrorV3 } from "../_components/v3/CostOfErrorV3";
import { DealsTickerV3 } from "../_components/v3/DealsTickerV3";
import { FooterV3 } from "../_components/v3/FooterV3";
import { HeaderV3 } from "../_components/v3/HeaderV3";
import { GuessGameV3 } from "../_components/v3/GuessGameV3";
import { HeroV3 } from "../_components/v3/HeroV3";
import { ObjectionsV3 } from "../_components/v3/ObjectionsV3";
import { StepsV3 } from "../_components/v3/StepsV3";
import { StickyCtaV3 } from "../_components/v3/StickyCtaV3";
import { TwoPathsV3 } from "../_components/v3/TwoPathsV3";
import styles from "../landing.module.css";
/**
@ -19,15 +24,18 @@ import styles from "../landing.module.css";
* на одном и том же контенте. Переключение отдельным решением, когда
* версия принята.
*
* ЧЕГО ЗДЕСЬ ПОКА НЕТ (секции макета, требующие данных или контуров,
* которых нет в коде каждая заведена отдельной задачей):
* - лента «прогноз факт» и мини-игра «Проверьте себя» нужен контур
* сверки прогноза с фактом сделки;
* - «Продажа под ключ» противоречит текущему утверждению лэндинга
* «мы не берём квартиры на продажу», требует отдельного договора;
* - блок статей раздела не существует;
* - модалка проверки и рабочая оплата нет анонимного периметра и
* платёжного контура.
* ОБНОВЛЕНИЕ 29.08 (макет «B2C модуль», Макс): добавлены лента
* «прогноз факт», игра «Угадай цену», «Два пути», «Возражения» и тизер
* статей. Данные ленты/игры реквизит из marketing-v3.ts под тем же гейтом
* #2904 (контура сверки прогноза с фактом в продукте по-прежнему нет).
*
* ЧЕГО ЗДЕСЬ ПО-ПРЕЖНЕМУ НЕТ:
* - страницы статей («МЕРА статьи»/«МЕРА статья») карточки тизера
* рендерятся без ссылок до появления раздела;
* - модалка проверки сознательно заменена экраном /estimate (см.
* StickyCtaV3), все CTA макетного `openModal` ведут туда;
* - партнёрская заявка «продажи под ключ» кнопка ведёт в Telegram
* поддержки до появления формы и договора.
*
* ЧИСЛА в секции «Точность» и «Цена ошибки» плейсхолдеры из макета,
* см. `marketing-v3.ts`: они обязаны быть заменены вычисляемыми до снятия
@ -44,10 +52,15 @@ export default function MeraPublicV3Page() {
<>
<HeaderV3 />
<main id="content" className={styles.main} tabIndex={-1}>
<HeroV3 />
<DealsTickerV3 />
<HeroV3 />
<StepsV3 />
<AccuracyV3 />
<GuessGameV3 />
<CostOfErrorV3 />
<TwoPathsV3 />
<ObjectionsV3 />
<ArticlesV3 />
</main>
<FooterV3 />
<StickyCtaV3 />