538 lines
19 KiB
TypeScript
538 lines
19 KiB
TypeScript
"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-й и последний этаж снижают цену на 5–10% — заполни если знаешь
|
||
</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>
|
||
);
|
||
}
|