gendesign/tradein-mvp/frontend/src/components/trade-in/EstimateForm.tsx
bot-backend ce0aaa4fb1
All checks were successful
CI / changes (pull_request) Successful in 6s
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
feat(tradein): send picked autocomplete coords to /estimate (Variant A)
2026-06-19 14:16:08 +03:00

538 lines
19 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";
import { useState } from "react";
import type { TradeInEstimateInput } from "@/types/trade-in";
import { asHouseType, asRepairState } from "@/types/trade-in";
import { AddressInput } from "@/components/trade-in/AddressInput";
import { MapPicker } from "@/components/trade-in/MapPicker";
interface Props {
onSubmit: (input: TradeInEstimateInput) => void;
isPending: boolean;
error: string | null;
blocked?: boolean;
remaining?: number | null;
limit?: number | null;
}
interface FormState {
address: string;
area_m2: string;
rooms: string;
floor: string;
total_floors: string;
year_built: string;
house_type: string;
repair_state: string;
has_balcony: boolean;
ownership_type: string;
has_mortgage: boolean;
client_name: string;
client_phone: string;
}
const INITIAL: FormState = {
address: "",
area_m2: "",
rooms: "",
floor: "",
total_floors: "",
year_built: "",
house_type: "",
repair_state: "",
has_balcony: false,
ownership_type: "",
has_mortgage: false,
client_name: "",
client_phone: "",
};
function validate(f: FormState): string | null {
if (f.address.trim().length < 3) return "Введите адрес (минимум 3 символа)";
const a = Number(f.area_m2);
if (!f.area_m2 || isNaN(a) || a <= 10 || a >= 500) return "Площадь: от 10 до 500 м²";
const r = Number(f.rooms);
if (f.rooms === "" || isNaN(r) || r < 0 || r > 10) return "Комнат: 0 (студия) — 10";
if (f.floor !== "") {
const fl = Number(f.floor);
if (isNaN(fl) || fl < 1 || fl > 100) return "Этаж: 1—100";
if (f.total_floors !== "") {
const tf = Number(f.total_floors);
if (!isNaN(tf) && fl > tf) return "Этаж не может быть больше этажей в доме";
}
}
if (f.total_floors !== "") {
const tf = Number(f.total_floors);
if (isNaN(tf) || tf < 1 || tf > 100) return "Этажей в доме: 1—100";
}
if (f.year_built !== "") {
const y = Number(f.year_built);
if (isNaN(y) || y < 1800 || y > 2100) return "Год постройки: 1800—2100";
}
return null;
}
function toPayload(
f: FormState,
coords: { lat: number; lon: number } | null,
): TradeInEstimateInput {
const out: TradeInEstimateInput = {
address: f.address.trim(),
area_m2: Number(f.area_m2),
rooms: Number(f.rooms),
floor: f.floor !== "" ? Number(f.floor) : null,
total_floors: f.total_floors !== "" ? Number(f.total_floors) : null,
has_balcony: f.has_balcony,
};
if (coords) {
out.lat = coords.lat;
out.lon = coords.lon;
}
if (f.year_built !== "") out.year_built = Number(f.year_built);
const ht = asHouseType(f.house_type);
if (ht) out.house_type = ht;
const rs = asRepairState(f.repair_state);
if (rs) out.repair_state = rs;
if (f.ownership_type) out.ownership_type = f.ownership_type;
if (f.has_mortgage) out.has_mortgage = true;
if (f.client_name.trim()) out.client_name = f.client_name.trim();
if (f.client_phone.trim()) out.client_phone = f.client_phone.trim();
return out;
}
export function EstimateForm({
onSubmit,
isPending,
error,
blocked = false,
remaining = null,
limit = null,
}: Props) {
const [form, setForm] = useState<FormState>(INITIAL);
const [coords, setCoords] = useState<{ lat: number; lon: number } | null>(null);
const [touched, setTouched] = useState(false);
const [mapOpen, setMapOpen] = useState(false);
// CRM-блок свёрнут по умолчанию — экономит ~190px высоты, чтобы форма влезала
// на ноутбуки без скролла (поля для менеджера опциональны, открываются по клику).
const [crmOpen, setCrmOpen] = useState(false);
const valError = validate(form);
const canSubmit = !valError && !isPending && !blocked;
function field(key: keyof FormState) {
return (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const value = e.target.type === "checkbox"
? (e.target as HTMLInputElement).checked
: e.target.value;
setForm((s) => ({ ...s, [key]: value }));
};
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setTouched(true);
if (canSubmit) {
onSubmit(toPayload(form, coords));
}
}
return (
<form onSubmit={handleSubmit}>
<div className="form-head">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ color: "var(--accent)" }}>
<path d="M3 21v-7l9-7 9 7v7h-6v-6h-6v6z" />
</svg>
<h2>Параметры квартиры</h2>
<span className="form-step">шаг 1/1</span>
</div>
<div className="form-body">
{/* Address with autocomplete */}
<div className="field autocomplete">
<label className="field-label" htmlFor="addr">
Адрес <span className="hint">Yandex / Nominatim</span>
<span className="req">обязательно</span>
</label>
<div style={{ display: "flex", gap: 8, alignItems: "stretch" }}>
<div className="control-with-icon" style={{ flex: 1, minWidth: 0 }}>
<svg className="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 10c0 7-9 13-9 13S3 17 3 10a9 9 0 0 1 18 0z" />
<circle cx="12" cy="10" r="3" />
</svg>
<AddressInput
value={form.address}
onChange={(val) => setForm((s) => ({ ...s, address: val }))}
onPickCoords={setCoords}
placeholder="ул. Малышева, 30 · Куйбышева, 48…"
/>
</div>
<button
type="button"
onClick={() => setMapOpen(true)}
title="Выбрать на карте ЕКБ"
aria-label="Выбрать на карте"
style={{
flex: "0 0 auto",
width: 44,
display: "flex",
alignItems: "center",
justifyContent: "center",
border: "1px solid var(--border, #d4d4d4)",
borderRadius: 8,
background: "var(--bg, #fff)",
color: "var(--accent)",
cursor: "pointer",
}}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polygon points="1 6 1 22 8 18 16 22 23 18 23 2 16 6 8 2 1 6" />
<line x1="8" y1="2" x2="8" y2="18" />
<line x1="16" y1="6" x2="16" y2="22" />
</svg>
</button>
</div>
</div>
{/* Area + Rooms */}
<div className="field-row">
<div className="field">
<label className="field-label" htmlFor="area">
Площадь <span className="req">*</span>
</label>
<div className="control-with-suffix">
<input
className="control mono"
id="area"
type="number"
step="0.1"
placeholder="54"
min={11}
max={499}
value={form.area_m2}
onChange={field("area_m2")}
required
/>
<span className="suffix">м²</span>
</div>
</div>
<div className="field">
<label className="field-label" htmlFor="rooms">
Комнат <span className="req">*</span>
</label>
<select
className="control"
id="rooms"
value={form.rooms}
onChange={field("rooms")}
required
>
<option value=""> выбрать </option>
<option value="0">Студия</option>
<option value="1">1-к</option>
<option value="2">2-к</option>
<option value="3">3-к</option>
<option value="4">4-к+</option>
</select>
</div>
</div>
{/* Floor + Total floors */}
<div className="field-row">
<div className="field">
<label className="field-label" htmlFor="floor">
Этаж <span className="hint">если знаешь</span>
</label>
<input
className="control mono"
id="floor"
type="number"
placeholder="5"
min={1}
max={100}
value={form.floor}
onChange={field("floor")}
/>
</div>
<div className="field">
<label className="field-label" htmlFor="totalfloor">
Всего этажей <span className="hint">если знаешь</span>
</label>
<input
className="control mono"
id="totalfloor"
type="number"
placeholder="17"
min={1}
max={100}
value={form.total_floors}
onChange={field("total_floors")}
/>
</div>
</div>
<small style={{ fontSize: 11, color: "var(--muted)", marginTop: -4, display: "block" }}>
1-й и последний этаж снижают цену на 510% заполни если знаешь
</small>
{/* Year + House type */}
<div className="field-row">
<div className="field">
<label className="field-label" htmlFor="year">
Год постройки <span className="hint">опц.</span>
</label>
<input
className="control mono"
id="year"
type="number"
placeholder="1985"
min={1800}
max={2100}
value={form.year_built}
onChange={field("year_built")}
/>
</div>
<div className="field">
<label className="field-label" htmlFor="type">
Тип дома <span className="hint">опц.</span>
</label>
<select className="control" id="type" value={form.house_type} onChange={field("house_type")}>
<option value=""> не указан </option>
<option value="panel">Панельный</option>
<option value="brick">Кирпичный</option>
<option value="monolith">Монолит</option>
<option value="monolith_brick">Монолит-кирпич</option>
<option value="other">Другое</option>
</select>
</div>
</div>
{/* Repair */}
<div className="field">
<label className="field-label" htmlFor="repair">
Состояние ремонта <span className="hint">опц.</span>
</label>
<select
className="control"
id="repair"
value={form.repair_state}
onChange={field("repair_state")}
>
<option value=""> не указано </option>
<option value="needs_repair">Требует ремонта</option>
<option value="standard">Стандартный</option>
<option value="good">Хороший</option>
<option value="excellent">Евроремонт</option>
</select>
</div>
{/* Balcony */}
<div className="field">
<label
className="field-label"
style={{ display: "flex", alignItems: "center", gap: 8, cursor: "pointer" }}
>
<input
type="checkbox"
checked={form.has_balcony}
onChange={field("has_balcony")}
style={{ width: 14, height: 14, cursor: "pointer" }}
/>
<span style={{ color: "var(--fg)" }}>Есть балкон / лоджия</span>
</label>
</div>
{/* CRM — для менеджера (#395) — сворачиваемый, по умолчанию закрыт */}
<div style={{ borderTop: "1px solid var(--border, #e5e5e5)", paddingTop: 14, marginTop: 4 }}>
<button
type="button"
onClick={() => setCrmOpen((v) => !v)}
aria-expanded={crmOpen}
style={{ width: "100%", display: "flex", alignItems: "center", gap: 6, marginBottom: crmOpen ? 10 : 0, padding: 0, background: "none", border: "none", cursor: "pointer", fontSize: 11, fontWeight: 600, color: "var(--muted)", textTransform: "uppercase", letterSpacing: 0.4 }}
>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" style={{ transform: crmOpen ? "rotate(90deg)" : "none", transition: "transform .12s" }}>
<polyline points="9 6 15 12 9 18" />
</svg>
CRM для менеджера
<span style={{ marginLeft: "auto", fontWeight: 400, textTransform: "none", letterSpacing: 0 }}>
{crmOpen ? "скрыть" : "опц."}
</span>
</button>
{crmOpen && (
<>
<div className="field">
<label className="field-label" htmlFor="ownership">
Тип собственности <span className="hint">опц.</span>
</label>
<select className="control" id="ownership" value={form.ownership_type} onChange={field("ownership_type")}>
<option value=""> не указано </option>
<option value="индивидуальная">Индивидуальная</option>
<option value="долевая">Долевая</option>
<option value="совместная">Совместная</option>
</select>
</div>
<div className="field">
<label className="field-label" style={{ display: "flex", alignItems: "center", gap: 8, cursor: "pointer" }}>
<input
type="checkbox"
checked={form.has_mortgage}
onChange={field("has_mortgage")}
style={{ width: 14, height: 14, cursor: "pointer" }}
/>
<span style={{ color: "var(--fg)" }}>Ипотека / обременение</span>
</label>
</div>
<div className="field-row">
<div className="field">
<label className="field-label" htmlFor="cname">
Имя клиента <span className="hint">опц.</span>
</label>
<input
className="control"
id="cname"
type="text"
placeholder="Иван Петров"
value={form.client_name}
onChange={field("client_name")}
/>
</div>
<div className="field">
<label className="field-label" htmlFor="cphone">
Телефон <span className="hint">опц.</span>
</label>
<input
className="control"
id="cphone"
type="tel"
placeholder="+7 999 …"
value={form.client_phone}
onChange={field("client_phone")}
/>
</div>
</div>
</>
)}
</div>
{touched && valError && (
<div
style={{
padding: "10px 12px",
background: "var(--danger-soft)",
border: "1px solid var(--danger)",
borderRadius: 6,
color: "var(--danger)",
fontSize: 12,
}}
>
{valError}
</div>
)}
{error && (
<div
style={{
padding: "10px 12px",
background: "var(--danger-soft)",
border: "1px solid var(--danger)",
borderRadius: 6,
color: "var(--danger)",
fontSize: 12,
}}
>
{error}
</div>
)}
</div>
<div className="form-foot">
{blocked ? (
<div
style={{
padding: "10px 12px",
background: "var(--danger-soft)",
border: "1px solid var(--danger)",
borderRadius: 6,
color: "var(--danger)",
fontSize: 12,
marginBottom: 8,
}}
>
Лимит из {limit ?? 15} оценок в этом месяце исчерпан. За полной версией
обращайтесь к Копылову.
</div>
) : (
<>
{remaining != null && limit != null && (
<div
style={{
fontSize: 11,
color: "var(--muted)",
marginBottom: 8,
textAlign: "right",
}}
>
Осталось{" "}
<span className="mono" style={{ color: "var(--fg-2)" }}>
{remaining}
</span>{" "}
из{" "}
<span className="mono">{limit}</span>{" "}
оценок в этом месяце
</div>
)}
<button
type="submit"
disabled={!canSubmit}
className="btn btn-primary"
style={{ opacity: canSubmit ? 1 : 0.55 }}
>
{isPending ? (
<>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
style={{ animation: "ti-spin 1s linear infinite" }}
>
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
Считаем
</>
) : (
<>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12" />
</svg>
Оценить квартиру
</>
)}
</button>
</>
)}
<div className="form-foot-meta" style={{ marginTop: 8, fontSize: 11, color: "var(--muted)" }}>
<span>Кэш по адресу <span className="num">24 ч</span></span>
<span className="ok" style={{ marginLeft: 12, color: "var(--success)" }}> готов</span>
</div>
</div>
{mapOpen && (
<MapPicker
onPick={(addr, mapCoords) => {
setForm((s) => ({ ...s, address: addr }));
setCoords(mapCoords ?? null);
}}
onClose={() => setMapOpen(false)}
/>
)}
<style>{`
@keyframes ti-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
`}</style>
</form>
);
}