- 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
350 lines
12 KiB
TypeScript
350 lines
12 KiB
TypeScript
"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>
|
||
);
|
||
}
|