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
Дизайн-макет владельца (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 пререндерится статикой.
303 lines
12 KiB
TypeScript
303 lines
12 KiB
TypeScript
"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>
|
||
);
|
||
}
|