gendesign/tradein-mvp/frontend/src/app/mera-public/_components/v3/FreeCheckCard.tsx
bot-backend 8f954fb98c
All checks were successful
CI Trade-In / changes (pull_request) Successful in 16s
CI / changes (pull_request) Successful in 14s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
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
CI Trade-In / frontend-checks (pull_request) Successful in 1m13s
feat(mera/b2c): лэндинг v3 по макету — превью на /mera-public/v3
Дизайн-макет владельца (15.08.2026). Собрано рядом с живым лэндингом, а не
вместо него: /mera-public открыт на meraocenka.ru, менять его посреди
согласования дизайна нельзя.

- b2c-tokens.ts — палитра макета отдельно от v2/tokens.ts. Красить теми же
  токенами B2B-экран нельзя, это другой продукт; hex-литералы остаются в
  одном файле.
- marketing-v3.ts — числа из макета в ОДНОМ месте, с таблицей расхождений
  с нашими замерами и гейтом: заменить вычисляемыми до снятия noindex.
- _components/v3/ — шапка с селектором города, подвал, липкая CTA, герой
  с карточкой проверки, три шага, «Точность», «Цена ошибки».
- layout.tsx больше не рендерит шапку/подвал: вложенный layout в Next
  ДОБАВЛЯЕТСЯ к родительскому, поэтому v3 со своим хромом получал бы две
  шапки. Теперь хром выбирает страница — v1 берёт SiteHeader/SiteFooter,
  v3 свой.

Отступления от макета (все задокументированы в коде):
- реквизиты и документы берутся из LEGAL_ENTITY, а не из маски
  «ООО «МЕРА» · ИНН 66700XXXXX» в макете;
- в подвал добавлены Политика возврата и почта поддержки, которых в макете
  нет, — обязательные элементы платного контура;
- бейджи МИР/СБП/VISA не рендерятся: приём платежей не подключён;
- форма не выдаёт результат — анонимного расчёта нет, а одинаковые
  «14 объявлений» на любой адрес это обман конкретного человека, а не
  продающая формулировка. Ветка результата свёрстана и ждёт /coverage;
- замыленная рублёвая цифра не перенесена: в макете она лежит в разметке
  текстом под filter:blur и достаётся из исходника;
- акцент #16A9BC не используется как цвет текста (2,6:1 на фоне страницы) —
  для текста затемнённый #0B6B79, 5,8:1;
- города и объёмы в селекторе — из нашего реестра, а не 42 города РФ.

Секции макета, требующие отсутствующих данных и контуров (лента
прогноз→факт, игра, продажа под ключ, статьи, модалка, оплата), не
верстались — заведены задачами #2894-#2904.

Проверено: tsc --noEmit, next lint, isolation guard (31 файл),
next build — /mera-public/v3 пререндерится статикой.
2026-08-15 19:24:32 +03:00

303 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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>
);
}