gendesign/tradein-mvp/frontend/src/components/trade-in/EstimateForm.tsx
lekss361 02267d0306 feat: add tradein-mvp subproject (Trade-In Estimator под /trade-in)
- FastAPI backend: PostGIS estimator + 3 scrapers (Avito/Cian/Yandex)
- Next.js 15 frontend: tradein.html mockup design, basePath=/trade-in
- WeasyPrint PDF (Брусника-style 4-page report)
- Address autocomplete с typo-tolerance + 6 EKB presets
- Изолированный docker stack gendesign-tradein (отдельная postgres БД)
- Caddy inline routes: gendsgn.ru/trade-in/* и /trade-in/api/v1/*
- Forgejo Actions: .forgejo/workflows/deploy-tradein.yml (shell-based GHCR login)
- Триггер только по paths: tradein-mvp/** (не пересекается с deploy.yml)
- Образы: ghcr.io/lekss361/gendesign-tradein-{backend,frontend}:latest

Первый запуск на сервере (вручную, один раз):
  - создать /opt/gendesign/tradein-mvp/.env.runtime (postgres pwd, contact email)
  - docker network create gendesign_shared (если нет)
  - docker compose -p gendesign-tradein up -d
  - docker compose -p gendesign exec caddy caddy reload
2026-05-21 00:25:39 +03:00

350 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";
import { useState } from "react";
import type {
HouseType,
RepairState,
TradeInEstimateInput,
} from "@/types/trade-in";
import { AddressInput } from "@/components/trade-in/AddressInput";
interface Props {
onSubmit: (input: TradeInEstimateInput) => void;
isPending: boolean;
error: string | 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;
}
const INITIAL: FormState = {
address: "",
area_m2: "",
rooms: "",
floor: "",
total_floors: "",
year_built: "",
house_type: "",
repair_state: "",
has_balcony: false,
};
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";
const fl = Number(f.floor);
if (!f.floor || isNaN(fl) || fl < 1 || fl > 100) return "Этаж: 1—100";
const tf = Number(f.total_floors);
if (!f.total_floors || isNaN(tf) || tf < 1 || tf > 100) return "Этажей в доме: 1—100";
if (fl > tf) return "Этаж не может быть больше этажей в доме";
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): TradeInEstimateInput {
const out: TradeInEstimateInput = {
address: f.address.trim(),
area_m2: Number(f.area_m2),
rooms: Number(f.rooms),
floor: Number(f.floor),
total_floors: Number(f.total_floors),
has_balcony: f.has_balcony,
};
if (f.year_built !== "") out.year_built = Number(f.year_built);
if (f.house_type) out.house_type = f.house_type as HouseType;
if (f.repair_state) out.repair_state = f.repair_state as RepairState;
return out;
}
export function EstimateForm({ onSubmit, isPending, error }: Props) {
const [form, setForm] = useState<FormState>(INITIAL);
const [touched, setTouched] = useState(false);
const valError = validate(form);
const canSubmit = !valError && !isPending;
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));
}
}
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 className="control-with-icon">
<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 }))}
placeholder="ул. Малышева, 30 · Цвиллинга, 58 · Куйбышева, 48…"
/>
</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="req">*</span>
</label>
<input
className="control mono"
id="floor"
type="number"
placeholder="5"
min={1}
max={100}
value={form.floor}
onChange={field("floor")}
required
/>
</div>
<div className="field">
<label className="field-label" htmlFor="totalfloor">
Всего этажей <span className="req">*</span>
</label>
<input
className="control mono"
id="totalfloor"
type="number"
placeholder="17"
min={1}
max={100}
value={form.total_floors}
onChange={field("total_floors")}
required
/>
</div>
</div>
{/* 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>
{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">
<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>
<style>{`
@keyframes ti-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
`}</style>
</form>
);
}