feat(mera/b2c): перенести потерянные блоки v1 в лэндинг v3
Корень meraocenka.ru отдаёт v1, новый дизайн живёт на /v3 и скоро станет корнем. Шесть вещей из v1 в v3 отсутствовали — при переключении они бы просто исчезли с публичного сайта: - реестра источников не было вовсе (SourcesV3, из SOURCE_GROUPS); - «что вы получите» и дисклеймера 135-ФЗ не было (WhatYouGetV3): о том, что это не отчёт аккредитованного оценщика, узнавал только тот, кто сам догадался открыть нужное возражение; - герой не называл границу покрытия (полное — Екатеринбург, частично — остальные города области, по другим регионам не считаем) и не говорил, что мы не покупаем квартиры и не берём их на продажу; - два вопроса FAQ («откуда вы знаете, сколько стоит именно моя квартира», «почему только Свердловская область») в возражениях v3 не звучали — теперь берутся из FAQ по id, а не копией строк; - карточка проверки не спрашивала город: гео-гейт #2576 на новом лэндинге был сломан (без города «Ленина, 1» уходит в одноимённую улицу ЕКБ); - она же спрашивала состояние обязательным полем и выбрасывала ответ на переходе — теперь он доезжает в черновике. Тексты перенесены дословно (проходили юр-ревью), новых чисел нет. Новые секции встали после «Точности»: сверяем прогноз с фактом → на каких данных → что получаете на руки, всё до первого разговора про деньги. Гейт: __tests__/v3-ported-copy.test.ts — проверен мутацией (снятие секции со страницы и переименование id вопроса красят его).
This commit is contained in:
parent
deddef80ad
commit
d4e106882c
10 changed files with 665 additions and 10 deletions
|
|
@ -48,6 +48,21 @@ describe("черновик", () => {
|
|||
expect(takeDraft()).toBeNull();
|
||||
});
|
||||
|
||||
it("доносит всё, что человек ввёл на лэндинге", () => {
|
||||
// Город — гео-гейт (#2576), состояние — обязательное поле карточки v3:
|
||||
// спросить и выбросить хуже, чем донести до платного шага.
|
||||
saveDraft({
|
||||
address: "Ленина 5",
|
||||
rooms: "2",
|
||||
area: "54",
|
||||
city: "Нижний Тагил",
|
||||
condition: "Хорошее",
|
||||
});
|
||||
const draft = takeDraft();
|
||||
expect(draft?.city).toBe("Нижний Тагил");
|
||||
expect(draft?.condition).toBe("Хорошее");
|
||||
});
|
||||
|
||||
it("не падает на мусоре в хранилище", () => {
|
||||
window.sessionStorage.setItem("mera:estimate-draft", "{это не json");
|
||||
expect(takeDraft()).toBeNull();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,88 @@
|
|||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { FAQ } from "../content";
|
||||
|
||||
/**
|
||||
* Гейт «при переключении корня на v3 ничего не потерялось».
|
||||
*
|
||||
* Корень meraocenka.ru отдаёт лэндинг v1, а новый дизайн живёт на /v3 и скоро
|
||||
* станет корнем. Разведка нашла блоки, которых в v3 не было вовсе: реестр
|
||||
* источников, «что вы получите» с дисклеймером 135-ФЗ, плашка покрытия и
|
||||
* строка «мы не покупаем квартиры» в герое, два вопроса из FAQ, город в форме.
|
||||
* Они перенесены — здесь проверяется, что перенос ЖИВОЙ: секции подключены к
|
||||
* странице, а компоненты читают те же константы, а не копии строк.
|
||||
*
|
||||
* Проверки читают исходники с диска — тот же приём, что в
|
||||
* `public-perimeter.test.ts` и `marketing-placeholders-gate.test.ts`: ломается
|
||||
* здесь связь между файлами (страница ↔ секция, секция ↔ content.ts), а её
|
||||
* юнит-тест на модуле не видит. Удалённая из вёрстки секция собирается,
|
||||
* тайпчек молчит, и заметить это может только человек на живой странице.
|
||||
*/
|
||||
|
||||
const MERA_PUBLIC = path.resolve(__dirname, "..");
|
||||
|
||||
function read(rel: string): string {
|
||||
return readFileSync(path.join(MERA_PUBLIC, rel), "utf8");
|
||||
}
|
||||
|
||||
describe("страница v3 рендерит перенесённые секции", () => {
|
||||
const page = read("v3/page.tsx");
|
||||
|
||||
it.each(["SourcesV3", "WhatYouGetV3"])("<%s /> подключён", (name) => {
|
||||
expect(page).toContain(`<${name} />`);
|
||||
expect(page).toContain(`_components/v3/${name}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("секции читают константы, а не копии строк", () => {
|
||||
it("реестр источников выводится из SOURCE_GROUPS", () => {
|
||||
expect(read("_components/v3/SourcesV3.tsx")).toContain(
|
||||
'SOURCE_GROUPS } from "../../content"',
|
||||
);
|
||||
});
|
||||
|
||||
it("«что вы получите» несёт дисклеймер 135-ФЗ рядом со списком", () => {
|
||||
const src = read("_components/v3/WhatYouGetV3.tsx");
|
||||
expect(src).toContain("DELIVERABLES");
|
||||
expect(
|
||||
src,
|
||||
"дисклеймер обязан стоять рядом со списком того, что человек получает",
|
||||
).toContain("DELIVERABLES_DISCLAIMER");
|
||||
});
|
||||
|
||||
it("герой называет границу покрытия и то, чем сервис не является", () => {
|
||||
const hero = read("_components/v3/HeroV3.tsx");
|
||||
expect(hero).toContain("SECONDARY_CITIES");
|
||||
expect(hero).toContain("не покупаем квартиры и не берём их на продажу");
|
||||
});
|
||||
|
||||
it("карточка проверки спрашивает город и не додумывает его", () => {
|
||||
const card = read("_components/v3/FreeCheckCard.tsx");
|
||||
// #2576: без города «Ленина, 1» давало уверенную цену по одноимённой улице
|
||||
// в Екатеринбурге жителю Нижнего Тагила.
|
||||
expect(card).toContain("COVERED_CITIES");
|
||||
expect(card).toContain("detectCityInText");
|
||||
// Состояние спрашивается обязательным полем — значит обязано доехать.
|
||||
expect(card).toMatch(/saveDraft\([^)]*condition/s);
|
||||
});
|
||||
});
|
||||
|
||||
describe("два вопроса из FAQ v1 живут в возражениях v3", () => {
|
||||
const objections = read("_components/v3/ObjectionsV3.tsx");
|
||||
|
||||
/**
|
||||
* Проверка ДВУСТОРОННЯЯ: id должен и существовать в FAQ, и упоминаться в
|
||||
* секции. Односторонняя пропустила бы главную ошибку — переименование id, от
|
||||
* которого `FAQ.find` вернёт undefined, и вопрос молча исчезнет со страницы.
|
||||
*/
|
||||
it.each(["how-do-you-know", "why-region"])("%s", (id) => {
|
||||
expect(
|
||||
FAQ.map((f) => f.id),
|
||||
`id пропал из FAQ — ObjectionsV3 отрендерит на один вопрос меньше и никто не заметит`,
|
||||
).toContain(id);
|
||||
expect(objections).toContain(`"${id}"`);
|
||||
});
|
||||
});
|
||||
|
|
@ -20,27 +20,61 @@
|
|||
*
|
||||
* Ветка результата ПОСТРОЕНА (`FreeResultV3.tsx`), но не подключена — см.
|
||||
* TODO у `PUBLIC_ESTIMATE_ENABLED` ниже и JSDoc в `FreeResultV3.tsx`.
|
||||
*
|
||||
* ГОРОД (порт из `../AddressForm.tsx`, v1). До этого карточка города не
|
||||
* спрашивала вовсе — то есть гео-гейт продукта на новом лэндинге был сломан:
|
||||
* это ровно тот баг, который бэкенд чинил в #2576 (житель Нижнего Тагила
|
||||
* вводил «Ленина, 1» и получал уверенную цену по одноимённой улице в ЕКБ).
|
||||
* Правило из шапки `lib/city-registry.ts`: город считается известным, только
|
||||
* если человек выбрал его сам ИЛИ `detectCityInText` нашёл его в тексте
|
||||
* адреса; молча подставлять Екатеринбург нельзя. Ответ про город вне покрытия
|
||||
* — тот же и дословно тот же, что в v1. Выпадашка города в шапке
|
||||
* (`CityPicker`) сюда НЕ пробрасывается: она презентационная и своим выбором
|
||||
* не владеет (см. её JSDoc), а гейт обязан читать то, что человек указал
|
||||
* рядом с адресом.
|
||||
*
|
||||
* СОСТОЯНИЕ ТЕПЕРЬ ДОЕЗЖАЕТ ДО ЧЕРНОВИКА. Поле было обязательным, а его
|
||||
* значение никуда не клалось — человек выбирал состояние квартиры, и мы его
|
||||
* выбрасывали на переходе. Бесплатная проба покрытия состояние действительно
|
||||
* не использует (см. `CoverageProbeInput` на бэке), но выбросить уже
|
||||
* полученный ответ и спросить его второй раз на платном шаге — хуже, чем
|
||||
* донести его в `sessionStorage` до того шага.
|
||||
*/
|
||||
|
||||
import { useId, useRef, useState } from "react";
|
||||
import type { FormEvent } from "react";
|
||||
|
||||
import { PRIMARY_CITY, PUBLIC_ROUTES } from "../../content";
|
||||
import { detectCityInText } from "@/lib/city-registry";
|
||||
|
||||
import {
|
||||
COVERED_CITIES,
|
||||
PRIMARY_CITY,
|
||||
PUBLIC_ROUTES,
|
||||
REGION_NAME,
|
||||
SECONDARY_CITIES,
|
||||
} from "../../content";
|
||||
import { saveDraft } from "../../estimate-draft";
|
||||
import styles from "../../landing-v3.module.css";
|
||||
|
||||
/** Значение <option> «моего города нет в списке» (то же, что в v1-форме). */
|
||||
const OTHER_CITY = "__other__";
|
||||
|
||||
/** Три состояния квартиры — дословно из макета (строка 734 исходника). */
|
||||
const CONDITIONS = ["Требует ремонта", "Хорошее", "Отличное"] as const;
|
||||
type Condition = (typeof CONDITIONS)[number];
|
||||
|
||||
type FieldName = "address" | "area" | "rooms" | "floor" | "condition";
|
||||
type FieldName = "address" | "city" | "area" | "rooms" | "floor" | "condition";
|
||||
|
||||
type Feedback = { kind: "none" } | { kind: "error"; field: FieldName; text: string };
|
||||
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 cityId = useId();
|
||||
const areaId = useId();
|
||||
const roomsId = useId();
|
||||
const floorId = useId();
|
||||
|
|
@ -48,6 +82,7 @@ export function FreeCheckCard() {
|
|||
const feedbackId = useId();
|
||||
|
||||
const [address, setAddress] = useState("");
|
||||
const [city, setCity] = useState("");
|
||||
const [area, setArea] = useState("");
|
||||
const [rooms, setRooms] = useState("");
|
||||
const [floor, setFloor] = useState("");
|
||||
|
|
@ -55,6 +90,7 @@ export function FreeCheckCard() {
|
|||
const [feedback, setFeedback] = useState<Feedback>(NONE);
|
||||
|
||||
const addressRef = useRef<HTMLInputElement>(null);
|
||||
const cityRef = useRef<HTMLSelectElement>(null);
|
||||
const areaRef = useRef<HTMLInputElement>(null);
|
||||
const roomsRef = useRef<HTMLInputElement>(null);
|
||||
const floorRef = useRef<HTMLInputElement>(null);
|
||||
|
|
@ -82,6 +118,33 @@ export function FreeCheckCard() {
|
|||
return;
|
||||
}
|
||||
|
||||
// Город известен, только если его выбрали руками или назвали в тексте
|
||||
// адреса. Ничего не додумываем — см. шапку файла.
|
||||
const detected = detectCityInText(trimmedAddress);
|
||||
const resolvedCity =
|
||||
city === OTHER_CITY ? OTHER_CITY : city || detected || "";
|
||||
|
||||
if (!resolvedCity) {
|
||||
setFeedback({
|
||||
kind: "error",
|
||||
field: "city",
|
||||
text: "Выберите город: одинаковые названия улиц есть в разных городах области, и без города цена будет не про вашу квартиру.",
|
||||
});
|
||||
cityRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (resolvedCity === OTHER_CITY) {
|
||||
setFeedback({
|
||||
kind: "info",
|
||||
title: `Пока мы считаем только по одному региону — ${REGION_NAME}`,
|
||||
lines: [
|
||||
`Данные мы собираем сами, город за городом: полностью — ${PRIMARY_CITY}, частично — ${SECONDARY_CITIES.join(", ")}. По остальным адресам оценка была бы догадкой, поэтому мы её не показываем.`,
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!trimmedArea) {
|
||||
setFeedback({
|
||||
kind: "error",
|
||||
|
|
@ -122,15 +185,24 @@ export function FreeCheckCard() {
|
|||
return;
|
||||
}
|
||||
|
||||
if (city === "" && detected) {
|
||||
// Город распознали в тексте — синхронизируем селект, чтобы человек видел,
|
||||
// что именно мы поняли, и мог поправить.
|
||||
setCity(detected);
|
||||
}
|
||||
|
||||
// Уходим на отдельный экран проверки — там живёт автокомплит и результат.
|
||||
// Черновиком отдаём только то, что использует бесплатная проба покрытия:
|
||||
// адрес, комнаты, площадь. Этаж и состояние она не использует вовсе (см.
|
||||
// CoverageProbeInput на бэке) — их спросит платный шаг, когда появится,
|
||||
// и спросит на том же экране, а не тащит через переход.
|
||||
// Черновик кладём в sessionStorage, а НЕ в query-параметры: адрес в URL
|
||||
// попал бы в access-логи Caddy рядом с IP посетителя (см. estimate-draft.ts).
|
||||
// Этаж не передаём — его не использует ни проба покрытия, ни экран
|
||||
// /estimate, и повторно набирать одно короткое поле дешевле, чем хранить
|
||||
// лишнее. Состояние передаём: его спросили обязательным полем здесь.
|
||||
saveDraft({
|
||||
address: trimmedAddress,
|
||||
rooms: trimmedRooms,
|
||||
area: trimmedArea,
|
||||
city: resolvedCity,
|
||||
condition,
|
||||
});
|
||||
window.location.assign(PUBLIC_ROUTES.estimate);
|
||||
}
|
||||
|
|
@ -146,6 +218,33 @@ export function FreeCheckCard() {
|
|||
|
||||
<div className={styles.heroCardBody}>
|
||||
<form className={styles.heroForm} onSubmit={handleSubmit} noValidate>
|
||||
{/* Город — ПЕРВЫМ полем: одинаковые названия улиц есть в разных
|
||||
городах области, и вопрос «какой город» должен стоять до того,
|
||||
как человек наберёт улицу, а не после (#2576). */}
|
||||
<label className={styles.heroField} htmlFor={cityId}>
|
||||
<span className={styles.heroFieldLabel}>Город</span>
|
||||
<select
|
||||
id={cityId}
|
||||
ref={cityRef}
|
||||
className={`${styles.heroSelect} ${errorField === "city" ? styles.heroInputInvalid : ""}`}
|
||||
value={city}
|
||||
onChange={(event) => {
|
||||
setCity(event.target.value);
|
||||
clearFeedback();
|
||||
}}
|
||||
aria-invalid={errorField === "city" || undefined}
|
||||
aria-describedby={errorField === "city" ? describedBy : undefined}
|
||||
>
|
||||
<option value="">Выберите город</option>
|
||||
{COVERED_CITIES.map((label) => (
|
||||
<option key={label} value={label}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
<option value={OTHER_CITY}>Другой город</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className={styles.heroField} htmlFor={addressId}>
|
||||
<span className={styles.heroFieldLabel}>Адрес</span>
|
||||
<input
|
||||
|
|
@ -274,6 +373,16 @@ export function FreeCheckCard() {
|
|||
<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>
|
||||
|
|
|
|||
|
|
@ -9,8 +9,26 @@
|
|||
* `FREE_SAMPLE_PLACEHOLDER.analogs`, цена отчёта из `COST_OF_ERROR_PLACEHOLDER`)
|
||||
* — ни одного нового числового литерала здесь не заведено, см. шапку
|
||||
* `../../marketing-v3.ts`.
|
||||
*
|
||||
* ПОРТ ИЗ v1 (`_components/Hero.tsx`), два блока внизу левой колонки:
|
||||
* - плашка покрытия: полное — `PRIMARY_CITY`, частично — `SECONDARY_CITIES`,
|
||||
* по другим регионам не считаем вовсе. Города берутся из реестра, не из
|
||||
* строки (см. COVERED_CITIES в content.ts: список городов ≠ карта
|
||||
* покрытия). В v3 этой границы не было нигде, кроме подписи в выпадашке
|
||||
* города в шапке — то есть человек узнавал про неё, только открыв список.
|
||||
* - строка «мы не покупаем квартиры и не берём их на продажу» — единственное
|
||||
* место, где сказано, чем сервис НЕ является; в v3 её не было.
|
||||
* Оба стоят В КОНЦЕ левой колонки намеренно: на узком экране колонка целиком
|
||||
* идёт до карточки проверки, значит человек прочитает ограничение раньше, чем
|
||||
* начнёт вводить адрес — то самое требование, ради которого плашка в v1 стояла
|
||||
* перед формой. Текст перенесён дословно.
|
||||
*/
|
||||
|
||||
import {
|
||||
PRIMARY_CITY,
|
||||
REGION_NAME,
|
||||
SECONDARY_CITIES,
|
||||
} from "../../content";
|
||||
import { ACCURACY_PLACEHOLDER, COST_OF_ERROR_PLACEHOLDER, FREE_SAMPLE_PLACEHOLDER } from "../../marketing-v3";
|
||||
import styles from "../../landing-v3.module.css";
|
||||
|
||||
|
|
@ -98,6 +116,24 @@ export function HeroV3() {
|
|||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Покрытие подано неравномерно намеренно — см. COVERED_CITIES в
|
||||
content.ts: список городов в реестре ≠ карта покрытия. */}
|
||||
<div className={styles.heroRegion}>
|
||||
<p className={styles.heroRegionTitle}>{REGION_NAME}</p>
|
||||
<p className={styles.heroRegionText}>
|
||||
Полное покрытие — {PRIMARY_CITY}. По остальным городам области (
|
||||
{SECONDARY_CITIES.join(", ")}) данных меньше, и оценка там может
|
||||
быть ориентировочной. По другим регионам не считаем вовсе — не
|
||||
хотим гадать.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className={styles.heroNote}>
|
||||
«Мера» — сервис оценки вторичного жилья по рыночным данным. Мы не
|
||||
покупаем квартиры и не берём их на продажу: наша работа — показать
|
||||
цифру и то, откуда она взялась.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<FreeCheckCard />
|
||||
|
|
|
|||
|
|
@ -11,8 +11,21 @@
|
|||
* сделки отвечаем тем, что в продукте ЕСТЬ (сделки Росреестра + снятие
|
||||
* объявлений с публикации), а не макетной формулировкой про «отметки
|
||||
* продавцов», механики которых не существует.
|
||||
*
|
||||
* ДВА ВОПРОСА ИЗ v1 (`FAQ` в content.ts) ИМПОРТИРУЮТСЯ, А НЕ ПЕРЕПИСАНЫ.
|
||||
* Макетные шесть возражений — про оплату и доверие; в v1 было два вопроса,
|
||||
* которых среди них нет вовсе, и оба несут границы продукта:
|
||||
* - `how-do-you-know` — как вообще получается цена именно этой квартиры;
|
||||
* - `why-region` — почему одна область, и чем «полное покрытие» отличается
|
||||
* от «данных меньше».
|
||||
* Первый стоит ПЕРВЫМ (это вопрос до всяких сомнений об оплате), второй —
|
||||
* последним (география — то, с чем человек уходит). Берём их из `FAQ` по id, а
|
||||
* не копией строк: копия разъедется с ответом на странице /docs и в v1, где
|
||||
* тот же текст уже прошёл юр-ревью. Если id переименуют, вопрос молча
|
||||
* исчезнет — это гейтится `__tests__/v3-ported-copy.test.ts`.
|
||||
*/
|
||||
|
||||
import { FAQ } from "../../content";
|
||||
import styles from "../../landing-v3.module.css";
|
||||
|
||||
import { PublicLink } from "../PublicLink";
|
||||
|
|
@ -63,6 +76,18 @@ const OBJECTIONS: readonly Objection[] = [
|
|||
},
|
||||
];
|
||||
|
||||
/** Вопрос v1 по id — `[]`, если id переименовали (тест это ловит). */
|
||||
function fromFaq(id: string): readonly Objection[] {
|
||||
const item = FAQ.find((f) => f.id === id);
|
||||
return item ? [{ q: item.q, a: item.a }] : [];
|
||||
}
|
||||
|
||||
const ITEMS: readonly Objection[] = [
|
||||
...fromFaq("how-do-you-know"),
|
||||
...OBJECTIONS,
|
||||
...fromFaq("why-region"),
|
||||
];
|
||||
|
||||
export function ObjectionsV3() {
|
||||
return (
|
||||
<section className={styles.objSection} aria-labelledby="obj-v3-title">
|
||||
|
|
@ -77,7 +102,7 @@ export function ObjectionsV3() {
|
|||
</PublicLink>
|
||||
</div>
|
||||
<div className={styles.objList}>
|
||||
{OBJECTIONS.map((o) => (
|
||||
{ITEMS.map((o) => (
|
||||
<details key={o.q} className={styles.objItem}>
|
||||
<summary className={styles.objSummary}>
|
||||
<span className={styles.objQuestion}>{o.q}</span>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,66 @@
|
|||
/**
|
||||
* SourcesV3 — реестр источников данных в дизайне v3. Серверный компонент.
|
||||
*
|
||||
* ОТКУДА ВЗЯТО. Это порт `_components/DataSources.tsx` (лэндинг v1): в v3
|
||||
* реестра источников не было вовсе — единственное упоминание жило одной фразой
|
||||
* внутри ответа ObjectionsV3. Заголовок, лид и содержимое групп перенесены
|
||||
* ДОСЛОВНО: текст проходил юр-ревью, а группы (`SOURCE_GROUPS` в content.ts)
|
||||
* по-прежнему выводятся из `lib/source-registry.ts` — названия площадок здесь
|
||||
* не вбиты строками и не могут разъехаться с тем, что реально участвует в
|
||||
* расчёте (контракт честности #2211).
|
||||
*
|
||||
* ГДЕ СТОИТ И ПОЧЕМУ. Сразу после `AccuracyV3`. «Точность» утверждает, что мы
|
||||
* сверяем прогноз с фактом сделки; первый вопрос к такому утверждению — «на
|
||||
* каких данных». Держать ответ через две секции (в возражениях) значит дать
|
||||
* человеку усомниться раньше, чем он дочитает до ответа. Секция без своей
|
||||
* подложки — чередование фонов v3 (accSection surface → эта page-bg →
|
||||
* wygSection surface) сохраняется.
|
||||
*
|
||||
* Логотипов площадок нет — по той же причине, что и в v1: чужие товарные знаки
|
||||
* это отдельный юридический вопрос, а картинки пришлось бы тянуть с чужих
|
||||
* доменов. Текстовые чипы решают ту же задачу.
|
||||
*/
|
||||
|
||||
import { SOURCE_GROUPS } from "../../content";
|
||||
import styles from "../../landing-v3.module.css";
|
||||
|
||||
export function SourcesV3() {
|
||||
return (
|
||||
<section className={styles.srcSection} aria-labelledby="sources-v3-title">
|
||||
<div className={styles.srcHead}>
|
||||
<div className={styles.accEyebrow} aria-hidden="true">
|
||||
ОТКУДА ЦИФРЫ
|
||||
</div>
|
||||
<h2 id="sources-v3-title" className={styles.srcTitle}>
|
||||
Откуда мы берём данные
|
||||
</h2>
|
||||
{/* Без числа групп в тексте: группы выводятся из реестра источников,
|
||||
и «два типа данных» уже однажды разъехалось с кодом — оценочные
|
||||
модели площадок участвуют в расчёте (estimator.py, IMV/Yandex
|
||||
blend), но в тексте их не было. */}
|
||||
<p className={styles.srcLead}>
|
||||
Мы не опрашиваем экспертов и не берём цифры из головы. Данные разного
|
||||
происхождения отвечают на разные вопросы — поэтому мы держим их
|
||||
раздельно и показываем, что откуда.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ul className={styles.srcGrid} role="list">
|
||||
{SOURCE_GROUPS.map((group) => (
|
||||
<li key={group.title} className={styles.srcCard}>
|
||||
<h3 className={styles.srcCardTitle}>{group.title}</h3>
|
||||
<ul className={styles.srcChips} role="list">
|
||||
{group.items.map((item) => (
|
||||
<li key={item} className={styles.srcChip}>
|
||||
<span className={styles.srcChipDot} aria-hidden="true" />
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className={styles.srcNote}>{group.note}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
/**
|
||||
* WhatYouGetV3 — «что вы получите» + дисклеймер 135-ФЗ в дизайне v3.
|
||||
* Серверный компонент.
|
||||
*
|
||||
* ОТКУДА ВЗЯТО. Порт `_components/WhatYouGet.tsx` (лэндинг v1). Заголовок, лид,
|
||||
* пункты (`DELIVERABLES`) и дисклеймер (`DELIVERABLES_DISCLAIMER`) перенесены
|
||||
* дословно из content.ts — там же разобрано, почему в списке нет PDF-отчёта.
|
||||
*
|
||||
* ЗАЧЕМ ЭТО ОБЯЗАТЕЛЬНО. В v3 не было ни списка того, что человек получает, ни
|
||||
* дисклеймера про 135-ФЗ. Второе — не украшение: «Мера» даёт рыночную оценку по
|
||||
* сопоставимым объектам, а не отчёт аккредитованного оценщика, и сказать об
|
||||
* этом нужно ровно там, где человек читает, что именно он купит. В v3 ближайшее
|
||||
* по смыслу было в ответе «Это официальная оценка для банка?» — то есть только
|
||||
* тому, кто сам догадался спросить.
|
||||
*
|
||||
* ГДЕ СТОИТ И ПОЧЕМУ. После `SourcesV3` и до «Цены ошибки»: «сверяем с фактом» →
|
||||
* «вот на каких данных» → «вот что вы получаете на руки» — доказательная часть
|
||||
* целиком, и всё это ДО первого разговора про деньги. Подложка `--b2c-surface`
|
||||
* с рамками — чередование фонов v3 (Sources page-bg → эта surface → игра
|
||||
* page-bg).
|
||||
*/
|
||||
|
||||
import { DELIVERABLES, DELIVERABLES_DISCLAIMER } from "../../content";
|
||||
import styles from "../../landing-v3.module.css";
|
||||
|
||||
export function WhatYouGetV3() {
|
||||
return (
|
||||
<section className={styles.wygSection} aria-labelledby="get-v3-title">
|
||||
<div className={styles.wygContainer}>
|
||||
<div className={styles.wygHead}>
|
||||
<div className={styles.accEyebrow} aria-hidden="true">
|
||||
ЧТО НА РУКАХ
|
||||
</div>
|
||||
<h2 id="get-v3-title" className={styles.wygTitle}>
|
||||
Что вы получите
|
||||
</h2>
|
||||
<p className={styles.wygLead}>
|
||||
Не «примерную стоимость», а расчёт, который видно насквозь.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ul className={styles.wygGrid} role="list">
|
||||
{DELIVERABLES.map((item) => (
|
||||
<li key={item.title} className={styles.wygItem}>
|
||||
<span className={styles.wygIcon} aria-hidden="true">
|
||||
✓
|
||||
</span>
|
||||
<div>
|
||||
<h3 className={styles.wygItemTitle}>{item.title}</h3>
|
||||
<p className={styles.wygItemText}>{item.text}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<p className={styles.wygDisclaimer}>{DELIVERABLES_DISCLAIMER}</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -11,8 +11,11 @@
|
|||
* Referer соседнего сайта. Живёт до закрытия вкладки — ровно столько, сколько
|
||||
* нужно для перехода между двумя страницами.
|
||||
*
|
||||
* Не заводить здесь ничего сверх этих трёх полей: чем больше сюда положить,
|
||||
* тем сильнее соблазн начать хранить черновики «на подольше».
|
||||
* Не заводить здесь ничего сверх того, что человек УЖЕ ввёл на лэндинге: чем
|
||||
* больше сюда положить, тем сильнее соблазн начать хранить черновики «на
|
||||
* подольше». `condition` появился по этому же правилу, а не вопреки ему —
|
||||
* карточка v3 спрашивает состояние обязательным полем, и до появления этого
|
||||
* поля ответ просто выбрасывался на переходе.
|
||||
*/
|
||||
|
||||
const KEY = "mera:estimate-draft";
|
||||
|
|
@ -23,6 +26,12 @@ export interface EstimateDraft {
|
|||
rooms?: string;
|
||||
area?: string;
|
||||
city?: string;
|
||||
/**
|
||||
* Состояние квартиры, как его назвал человек в карточке v3. Бесплатная проба
|
||||
* покрытия его не использует (`CoverageProbeInput` на бэке о нём не знает) —
|
||||
* он ждёт платного шага; хранится ровно один переход, как и всё остальное.
|
||||
*/
|
||||
condition?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -58,6 +67,8 @@ export function takeDraft(): EstimateDraft | null {
|
|||
rooms: typeof value.rooms === "string" ? value.rooms : undefined,
|
||||
area: typeof value.area === "string" ? value.area : undefined,
|
||||
city: typeof value.city === "string" ? value.city : undefined,
|
||||
condition:
|
||||
typeof value.condition === "string" ? value.condition : undefined,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -2832,3 +2832,236 @@
|
|||
margin-top: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
* Порт v1-блоков в дизайн v3 (волна «ничего не потерять при переключении
|
||||
* корня»): SourcesV3 (префикс src), WhatYouGetV3 (префикс wyg) и добавки к
|
||||
* герою/карточке проверки (префикс hero — дописаны сюда, а не в блок героя
|
||||
* выше, по правилу из шапки файла: «дописывай в конец, не переписывай чужие
|
||||
* блоки»).
|
||||
* ========================================================================== */
|
||||
|
||||
/* -- Герой: плашка покрытия и строка «мы не покупаем квартиры» ------------- */
|
||||
|
||||
/* Акцентная полоса слева, а не заливка: плашка должна читаться как оговорка
|
||||
рядом с обещанием, а не как ещё один CTA. Тот же приём, что у
|
||||
.stepsItemAccent (граница акцентом на светлой подложке) — сплошной заливкой
|
||||
accent красить нельзя, текст на ней не проходит AA (см. b2c-tokens.ts). */
|
||||
.heroRegion {
|
||||
margin-top: 24px;
|
||||
padding: 14px 16px;
|
||||
background: var(--b2c-surface);
|
||||
border: 1px solid var(--b2c-line);
|
||||
border-left: 3px solid var(--b2c-accent);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.heroRegionTitle {
|
||||
margin: 0 0 6px;
|
||||
font-family: var(--b2c-font-mono);
|
||||
font-size: 10.5px;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: var(--b2c-accent-text);
|
||||
}
|
||||
|
||||
.heroRegionText {
|
||||
margin: 0;
|
||||
font-size: 13.5px;
|
||||
line-height: 1.6;
|
||||
color: var(--b2c-muted);
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
.heroNote {
|
||||
margin: 16px 0 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--b2c-muted);
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
/* Селект города в карточке проверки. Наследует поле ввода целиком и НЕ снимает
|
||||
appearance: без нативной стрелки селект неотличим от input, а рисовать свою
|
||||
пришлось бы фоновой картинкой — ради одного поля не стоит. */
|
||||
.heroSelect {
|
||||
composes: heroInput;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* -- SourcesV3 — «Откуда мы берём данные» ---------------------------------- */
|
||||
|
||||
.srcSection {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: clamp(48px, 6vw, 80px) clamp(20px, 5vw, 56px);
|
||||
}
|
||||
|
||||
.srcHead {
|
||||
max-width: 700px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.srcTitle {
|
||||
margin: 0 0 14px;
|
||||
font-size: clamp(26px, 3.4vw, 38px);
|
||||
line-height: 1.12;
|
||||
letter-spacing: -0.015em;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.srcLead {
|
||||
margin: 0;
|
||||
font-size: 16.5px;
|
||||
line-height: 1.6;
|
||||
color: var(--b2c-muted);
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
.srcGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr));
|
||||
gap: 20px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.srcCard {
|
||||
padding: 26px;
|
||||
background: var(--b2c-surface);
|
||||
border: 1px solid var(--b2c-line);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.srcCardTitle {
|
||||
margin: 0 0 14px;
|
||||
font-weight: 800;
|
||||
font-size: 17.5px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.srcChips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin: 0 0 14px;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.srcChip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 6px 11px;
|
||||
background: var(--b2c-page-bg);
|
||||
border: 1px solid var(--b2c-line);
|
||||
border-radius: 999px;
|
||||
font-family: var(--b2c-font-mono);
|
||||
font-size: 11.5px;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.srcChipDot {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: var(--b2c-accent);
|
||||
}
|
||||
|
||||
.srcNote {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: var(--b2c-muted);
|
||||
}
|
||||
|
||||
/* -- WhatYouGetV3 — «Что вы получите» + дисклеймер 135-ФЗ ------------------ */
|
||||
|
||||
.wygSection {
|
||||
background: var(--b2c-surface);
|
||||
border-top: 1px solid var(--b2c-line);
|
||||
border-bottom: 1px solid var(--b2c-line);
|
||||
}
|
||||
|
||||
.wygContainer {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: clamp(48px, 6vw, 80px) clamp(20px, 5vw, 56px);
|
||||
}
|
||||
|
||||
.wygHead {
|
||||
max-width: 700px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.wygTitle {
|
||||
margin: 0 0 14px;
|
||||
font-size: clamp(26px, 3.4vw, 38px);
|
||||
line-height: 1.12;
|
||||
letter-spacing: -0.015em;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.wygLead {
|
||||
margin: 0;
|
||||
font-size: 16.5px;
|
||||
line-height: 1.6;
|
||||
color: var(--b2c-muted);
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
.wygGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr));
|
||||
gap: 20px;
|
||||
margin: 0 0 24px;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.wygItem {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 24px;
|
||||
background: var(--b2c-page-bg);
|
||||
border: 1px solid var(--b2c-line);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.wygIcon {
|
||||
flex: 0 0 auto;
|
||||
font-weight: 800;
|
||||
line-height: 1.4;
|
||||
color: var(--b2c-accent-text);
|
||||
}
|
||||
|
||||
.wygItemTitle {
|
||||
margin: 0 0 6px;
|
||||
font-weight: 800;
|
||||
font-size: 16.5px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.wygItemText {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: var(--b2c-muted);
|
||||
}
|
||||
|
||||
/* Дисклеймер красится ink, а не muted: это ограничение услуги, а не сноска —
|
||||
приглушать его значило бы прятать. Полоса акцентом — как у .heroRegion. */
|
||||
.wygDisclaimer {
|
||||
margin: 0;
|
||||
padding: 16px 18px;
|
||||
background: var(--b2c-page-bg);
|
||||
border: 1px solid var(--b2c-line);
|
||||
border-left: 3px solid var(--b2c-accent);
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: var(--b2c-ink);
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,9 +9,11 @@ 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 { SourcesV3 } from "../_components/v3/SourcesV3";
|
||||
import { StepsV3 } from "../_components/v3/StepsV3";
|
||||
import { StickyCtaV3 } from "../_components/v3/StickyCtaV3";
|
||||
import { TwoPathsV3 } from "../_components/v3/TwoPathsV3";
|
||||
import { WhatYouGetV3 } from "../_components/v3/WhatYouGetV3";
|
||||
import styles from "../landing.module.css";
|
||||
|
||||
/**
|
||||
|
|
@ -29,6 +31,14 @@ import styles from "../landing.module.css";
|
|||
* статей. Данные ленты/игры — реквизит из marketing-v3.ts под тем же гейтом
|
||||
* #2904 (контура сверки прогноза с фактом в продукте по-прежнему нет).
|
||||
*
|
||||
* ПОРТ v1-БЛОКОВ (перед тем, как v3 станет корнем): реестр источников
|
||||
* (`SourcesV3`) и «что вы получите» с дисклеймером 135-ФЗ (`WhatYouGetV3`)
|
||||
* встали сразу после «Точности» — доказательная часть идёт подряд: сверяем
|
||||
* прогноз с фактом → вот на каких данных → вот что вы получаете на руки, и всё
|
||||
* это до первого разговора про деньги. Плашка покрытия и строка «мы не
|
||||
* покупаем квартиры» вернулись в героя, два вопроса из `FAQ` — в
|
||||
* «Возражения» (обоснования — в докстрингах компонентов).
|
||||
*
|
||||
* ЧЕГО ЗДЕСЬ ПО-ПРЕЖНЕМУ НЕТ:
|
||||
* - страницы статей («МЕРА статьи»/«МЕРА статья») — карточки тизера
|
||||
* рендерятся без ссылок до появления раздела;
|
||||
|
|
@ -56,6 +66,8 @@ export default function MeraPublicV3Page() {
|
|||
<HeroV3 />
|
||||
<StepsV3 />
|
||||
<AccuracyV3 />
|
||||
<SourcesV3 />
|
||||
<WhatYouGetV3 />
|
||||
<GuessGameV3 />
|
||||
<CostOfErrorV3 />
|
||||
<TwoPathsV3 />
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue