gendesign/tradein-mvp/frontend/src/components/trade-in/v2/ParamsPanel.tsx
bot-backend e23dabe4f5
All checks were successful
CI / changes (pull_request) Successful in 7s
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
fix(tradein/v2): wire РАДИУС (backend radius_m) + map +/- zoom + building image + deglue word+word
Live-browser audit (round 2): РАДИУС/+- dead, building photo missing.
- РАДИУС: backend BE-2 — optional radius_m (int 100–5000) on TradeInEstimateInput,
  threaded into estimate_quality comp search (base + fallback). None preserves the
  exact two-tier default (1000m primary / 2000m fallback) byte-identically. FE: РАДИУС
  dropdown with **Авто** default (sends no radius_m → legacy behaviour, NOT a silent
  500m narrowing) + 300/500/1000/2000 overrides; included as radius_m on submit; outer
  map ring tracks the value.
- Map +/− buttons: now a real zoom (transform: scale() on the map content layer, step
  .25, clamp .6–2.5); controls/bracket stay unscaled.
- building.png: next/image didn't apply basePath → 400; switched to plain <img> with
  NEXT_PUBLIC_BASE_PATH-prefixed src (→ 200) + onError fallback. HERO АДРЕС card шоу.
- deglueAddr: also splits word+word glue ('БольшаковаГеологическая'→'Большакова
  Геологическая'), still preserves house letters (14А, д.5К).

next build green (/v2 34.3 kB); estimator None-default byte-identical (ge=100 rules out
falsy-0); code-reviewer  after the Авто-default fix (avoided a default-radius regression).
2026-06-28 18:03:07 +03:00

1208 lines
38 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";
// 01 ПАРАМЕТРЫ КВАРТИРЫ — left card of the /trade-in/v2 "МЕРА Оценка" port.
// Faithful markup port (МЕРА Оценка.dc.html lines 146-240). Wire phase: this is
// now a CONTROLLED form. Each field is local useState producing a
// TradeInEstimateInput that is handed to onSubmit; the visual markup / layout /
// styles are UNCHANGED — only the data plumbing differs (display <div>s became
// <input>s styled identically, dropdowns now feed real enum values). RU dropdown
// labels <-> API enum values go through HOUSE_TYPE_*/REPAIR_* maps in ./mappers.
// РАДИУС is now wired (radius_m on submit + the outer map ring scales with it);
// the CRM dropdown still has no backend → kept visually but disabled. Hover/active
// + @keyframes live in a pp-prefixed local <style>.
import { useRef, useState, type CSSProperties } from "react";
import { tokens } from "./tokens";
import { dropdownOptions } from "./fixtures";
import {
HOUSE_TYPE_FROM_RU,
HOUSE_TYPE_RU,
REPAIR_FROM_RU,
REPAIR_RU,
type MapMarker,
} from "./mappers";
import { useGeocodeSuggest } from "@/lib/trade-in-api";
import type {
GeocodeSuggestion,
HouseType,
RepairState,
TradeInEstimateInput,
} from "@/types/trade-in";
type DdKey = "rooms" | "houseType" | "repair" | "radius" | null;
interface DdProps {
open: boolean;
onToggle: () => void;
value: string;
options: string[];
onSelect: (v: string) => void;
mono: boolean;
triggerFontSize: number;
optionFontSize: number;
variant?: "solid" | "dashed";
openUp?: boolean;
triggerHeight?: number;
triggerBg?: string;
triggerColor?: string;
caretColor?: string;
}
function Dd({
open,
onToggle,
value,
options,
onSelect,
mono,
triggerFontSize,
optionFontSize,
variant = "solid",
openUp = false,
triggerHeight = 33,
triggerBg = tokens.surface.w62,
triggerColor = tokens.ink,
caretColor = tokens.muted2,
}: DdProps) {
const panelStyle: CSSProperties = {
position: "absolute",
left: 0,
right: 0,
background: tokens.surface.w98,
border: `1px solid ${tokens.line}`,
borderRadius: 6,
zIndex: 30,
padding: 3,
...(openUp
? {
bottom: "calc(100% + 4px)",
boxShadow: "0 -10px 26px rgba(40,80,130,.18)",
}
: {
top: "calc(100% + 4px)",
boxShadow: "0 10px 26px rgba(40,80,130,.18)",
}),
};
return (
<div style={{ position: "relative" }}>
<div
className={
variant === "dashed" ? "pp-dd-trigger-dashed" : "pp-dd-trigger"
}
onClick={onToggle}
style={{
height: triggerHeight,
background: triggerBg,
borderRadius: 6,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "0 13px",
fontFamily: mono ? tokens.font.mono : tokens.font.sans,
fontSize: triggerFontSize,
color: triggerColor,
cursor: "pointer",
}}
>
{value}
<span
style={{
color: caretColor,
fontSize: 9,
fontFamily: tokens.font.sans,
}}
>
</span>
</div>
{open && (
<div style={panelStyle}>
{options.map((o) => (
<div
key={o}
className="pp-dd-opt"
onClick={() => onSelect(o)}
style={{
padding: "7px 10px",
fontSize: optionFontSize,
fontFamily: mono ? tokens.font.mono : tokens.font.sans,
borderRadius: 4,
cursor: "pointer",
color: o === value ? tokens.accent : tokens.ink,
fontWeight: o === value ? 600 : 400,
}}
>
{o}
</div>
))}
</div>
)}
</div>
);
}
const styles = `
@keyframes pp-pulsedot{0%,100%{opacity:.45}50%{opacity:1}}
.pp-dot{animation:pp-pulsedot 2.6s ease-in-out infinite}
.pp-dd-trigger{border:1px solid ${tokens.line};transition:border-color .15s}
.pp-dd-trigger:hover{border-color:${tokens.accent}}
.pp-dd-trigger-dashed{border:1px dashed ${tokens.line};transition:border-color .15s}
.pp-dd-trigger-dashed:hover{border-color:${tokens.accent}}
.pp-dd-opt{transition:background .15s}
.pp-dd-opt:hover{background:rgba(46,139,255,.1)}
.pp-eval-btn{background:linear-gradient(90deg,rgba(255,255,255,.7),rgba(233,242,252,.7));box-shadow:0 0 0 0 rgba(46,139,255,0);transition:all .18s}
.pp-eval-btn:hover{background:linear-gradient(90deg,rgba(46,139,255,.12),rgba(46,139,255,.18));box-shadow:0 6px 22px rgba(46,139,255,.28)}
.pp-eval-btn:active{transform:translateY(1px)}
`;
const hintLabel: CSSProperties = {
fontSize: 9.5,
letterSpacing: 1.5,
color: tokens.muted2,
marginBottom: 3,
};
const optHint: CSSProperties = {
fontSize: 9,
color: tokens.muted4,
};
const fieldRow: CSSProperties = {
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: 12,
};
// Numeric text input — visually identical to the former display <div> (textField)
// but as a real <input>: inputs do NOT inherit font-family, so it is set
// explicitly; outline removed to keep the HUD look (focus ring would break it).
const inputField: CSSProperties = {
height: 33,
width: "100%",
boxSizing: "border-box",
background: tokens.surface.w62,
border: `1px solid ${tokens.line}`,
borderRadius: 6,
padding: "0 13px",
fontFamily: tokens.font.mono,
fontSize: 14,
color: tokens.ink,
outline: "none",
};
// Address input (sans, room on the right for the decorative search glyph).
const addressField: CSSProperties = {
height: 33,
width: "100%",
boxSizing: "border-box",
background: tokens.surface.w62,
border: `1px solid ${tokens.line}`,
borderRadius: 6,
padding: "0 38px 0 13px",
fontFamily: tokens.font.sans,
fontSize: 13,
color: tokens.ink,
outline: "none",
};
// Autocomplete dropdown — mirrors the <Dd> HUD panel (surface.w98 + soft blue
// shadow). Absolutely positioned under the address input; scrolls past ~6 rows.
const suggestPanel: CSSProperties = {
position: "absolute",
left: 0,
right: 0,
top: "calc(100% + 4px)",
background: tokens.surface.w98,
border: `1px solid ${tokens.line}`,
borderRadius: 6,
zIndex: 30,
padding: 3,
maxHeight: 196,
overflowY: "auto",
boxShadow: "0 10px 26px rgba(40,80,130,.18)",
};
const suggestOpt: CSSProperties = {
padding: "7px 10px",
borderRadius: 4,
cursor: "pointer",
};
const suggestOptMain: CSSProperties = {
fontFamily: tokens.font.sans,
fontSize: 12.5,
color: tokens.ink,
};
const suggestOptSub: CSSProperties = {
fontFamily: tokens.font.sans,
fontSize: 10,
color: tokens.muted2,
marginTop: 1,
};
const suggestNote: CSSProperties = {
padding: "8px 10px",
fontFamily: tokens.font.sans,
fontSize: 11,
color: tokens.muted2,
};
// Inline validation / server error — only rendered when there is an error, so
// the happy-path layout is pixel-unchanged.
const errorText: CSSProperties = {
marginTop: 8,
fontSize: 10.5,
letterSpacing: 0.5,
lineHeight: 1.4,
color: tokens.danger,
};
// Analog price pin on the 01 map. left/top come from mapMarkers() per-analog
// (projected from the real estimate); the chrome matches the former fixture
// pins 1:1 — only the data source changed (Finding #2).
const analogPin: CSSProperties = {
position: "absolute",
background: tokens.surface.w70,
border: `1px solid ${tokens.line}`,
borderRadius: 4,
padding: "3px 7px",
fontFamily: tokens.font.mono,
fontSize: 9,
lineHeight: 1.4,
color: tokens.ink,
};
// РАДИУС dropdown panel — mirrors the <Dd> HUD panel (surface.w98 + soft blue
// shadow), sized to the narrow radius trigger and dropped just beneath it.
const radiusPanel: CSSProperties = {
position: "absolute",
left: 0,
right: 0,
top: "calc(100% + 4px)",
background: tokens.surface.w98,
border: `1px solid ${tokens.line}`,
borderRadius: 6,
zIndex: 30,
padding: 3,
boxShadow: "0 10px 26px rgba(40,80,130,.18)",
};
interface ParamsPanelProps {
/** Called with the validated form payload on «ОЦЕНИТЬ КВАРТИРУ». Optional so
* the still-unwired v2 page renders without props (tsc-safe). */
onSubmit?: (input: TradeInEstimateInput) => void;
/** Disables the button + shows a pending label while the estimate is running. */
isPending?: boolean;
/** Server-side error to surface inline (validation errors are handled locally). */
error?: string | null;
/** Prefill for restore-by-id (?id=) — maps API enums back to RU dropdown labels. */
initialValues?: Partial<TradeInEstimateInput>;
/** Analog price pins for the 01 map, projected from the real estimate via
* mapMarkers(). Default [] → no price pins (never the fabricated fixtures). */
markers?: MapMarker[];
}
// rooms number -> dropdown label. The design has no «Студия» option, so studio
// (0) and 1-room both map to "1"; >=5 collapses to "5+". null -> design default.
function initRoomsLabel(rooms: number | null | undefined): string {
if (rooms == null) return "2";
if (rooms >= 5) return "5+";
if (rooms <= 1) return "1";
return String(rooms);
}
function initHouseTypeLabel(ht: HouseType | undefined): string {
return ht ? HOUSE_TYPE_RU[ht] : "Панельный";
}
function initRepairLabel(rs: RepairState | undefined): string {
return rs ? REPAIR_RU[rs] : "Хороший";
}
// РАДИУС options. "Авто" (default) sends no radius_m → the backend keeps its
// two-tier default (1000 m primary / 2000 m fallback). A fixed value overrides
// both ("ищем строго в пределах X м"). Design dropdown was values-only.
const RADIUS_OPTIONS = ["Авто", "300 м", "500 м", "1000 м", "2000 м"];
// radius_m (metres) -> dropdown label. null/absent → "Авто" (legacy two-tier
// default), so a re-submit never silently narrows the search.
function initRadiusLabel(radiusM: number | null | undefined): string {
if (radiusM == null) return "Авто";
const label = `${radiusM} м`;
return RADIUS_OPTIONS.includes(label) ? label : "Авто";
}
export default function ParamsPanel({
onSubmit,
isPending = false,
error = null,
initialValues,
markers = [],
}: ParamsPanelProps) {
const [openDd, setOpenDd] = useState<DdKey>(null);
// Map zoom — applied as transform: scale() on the map content layer (not the
// whole panel). Default 1 (identity → pixel-identical), step .25, clamp .62.5.
const [zoom, setZoom] = useState(1);
const [address, setAddress] = useState(initialValues?.address ?? "");
const [area, setArea] = useState(
initialValues?.area_m2 != null ? String(initialValues.area_m2) : "",
);
const [rooms, setRooms] = useState(initRoomsLabel(initialValues?.rooms));
const [floor, setFloor] = useState(
initialValues?.floor != null ? String(initialValues.floor) : "",
);
const [totalFloors, setTotalFloors] = useState(
initialValues?.total_floors != null
? String(initialValues.total_floors)
: "",
);
const [year, setYear] = useState(
initialValues?.year_built != null ? String(initialValues.year_built) : "",
);
const [houseType, setHouseType] = useState(
initHouseTypeLabel(initialValues?.house_type),
);
const [repair, setRepair] = useState(
initRepairLabel(initialValues?.repair_state),
);
const [radius, setRadius] = useState(
initRadiusLabel(initialValues?.radius_m),
);
const [balcony, setBalcony] = useState(initialValues?.has_balcony ?? true);
const [validationError, setValidationError] = useState<string | null>(null);
// ── Address autocomplete (geocode suggest, ЕКБ viewbox) ──
// The typed string is debounced into `addressQuery` from inside the change
// handler (a plain timer, not a useEffect-for-HTTP); `useGeocodeSuggest` is
// gated at >=3 chars and fires on the debounced value. Picking a suggestion
// fills the full address and captures lat/lon so the backend can skip geocoding.
const [addressQuery, setAddressQuery] = useState(initialValues?.address ?? "");
const [suggestOpen, setSuggestOpen] = useState(false);
const [coords, setCoords] = useState<{ lat: number; lon: number } | null>(
initialValues?.lat != null && initialValues?.lon != null
? { lat: initialValues.lat, lon: initialValues.lon }
: null,
);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const suggest = useGeocodeSuggest(addressQuery);
const handleAddressChange = (v: string) => {
setAddress(v);
setCoords(null); // a manual edit invalidates a previously picked point
setSuggestOpen(v.trim().length >= 3);
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => setAddressQuery(v), 200);
};
const pickSuggestion = (s: GeocodeSuggestion) => {
setAddress(s.full_address);
setAddressQuery(s.full_address);
setCoords({ lat: s.lat, lon: s.lon });
setSuggestOpen(false);
};
// TODO(#395): CRM-интеграция не подключена — дропдаун присутствует визуально,
// но неактивен (выбор ничего не делает).
const crm = dropdownOptions.crm[0];
const toggle = (k: Exclude<DdKey, null>) =>
setOpenDd((cur) => (cur === k ? null : k));
const pick = (setter: (v: string) => void) => (v: string) => {
setter(v);
setOpenDd(null);
};
const handleSubmit = () => {
const trimmedAddress = address.trim();
const areaNum = Number(area.replace(",", "."));
if (trimmedAddress.length < 3) {
setValidationError("Укажите адрес квартиры — минимум 3 символа");
return;
}
if (!area.trim()) {
setValidationError("Укажите площадь квартиры");
return;
}
if (!Number.isFinite(areaNum) || areaNum <= 10) {
setValidationError("Площадь должна быть больше 10 м²");
return;
}
setValidationError(null);
setSuggestOpen(false);
onSubmit?.({
address: trimmedAddress,
area_m2: areaNum,
rooms: rooms === "5+" ? 5 : Number(rooms),
floor: floor.trim() ? Number(floor) : null,
total_floors: totalFloors.trim() ? Number(totalFloors) : null,
year_built: year.trim() ? Number(year) : undefined,
house_type: HOUSE_TYPE_FROM_RU[houseType],
repair_state: REPAIR_FROM_RU[repair],
has_balcony: balcony,
lat: coords?.lat ?? null,
lon: coords?.lon ?? null,
// "Авто" → null → backend keeps its two-tier default (1000 m primary /
// 2000 m fallback). A fixed value overrides both. (Не отправлять 500 по
// умолчанию — это сузило бы поиск аналогов и убрало fallback.)
radius_m: radius === "Авто" ? null : parseInt(radius, 10),
});
};
const balOn: CSSProperties = {
border: `1px solid ${tokens.accent}`,
color: tokens.accent,
fontWeight: 600,
background: "rgba(46,139,255,.1)",
};
const balOff: CSSProperties = {
border: `1px solid ${tokens.line}`,
color: tokens.muted,
fontWeight: 400,
background: "transparent",
};
const balBase: CSSProperties = {
flex: 1,
borderRadius: 6,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 11,
letterSpacing: 1,
cursor: "pointer",
transition: "all .15s",
};
// Outer radius ring tracks the selected РАДИУС (subtle, best-effort): 500 м
// keeps the design's r=112 exactly; other values scale gently (and clamp) so the
// ring never blows past the map box. The inner two rings stay fixed.
const radiusM = parseInt(radius, 10);
const outerRingR = Number.isFinite(radiusM)
? Math.round(Math.max(70, Math.min(155, 112 * Math.pow(radiusM / 500, 0.35))))
: 112;
return (
<div
style={{
position: "relative",
background: tokens.surface.w50,
backdropFilter: "blur(6px)",
border: `1px solid ${tokens.line2}`,
borderRadius: 8,
padding: "16px 18px",
display: "flex",
flexDirection: "column",
}}
>
<style>{styles}</style>
{/* HUD corner brackets */}
<div
style={{
position: "absolute",
left: -1,
top: -1,
width: 13,
height: 13,
borderLeft: `1.5px solid ${tokens.bracket}`,
borderTop: `1.5px solid ${tokens.bracket}`,
}}
/>
<div
style={{
position: "absolute",
right: -1,
bottom: -1,
width: 13,
height: 13,
borderRight: `1.5px solid ${tokens.bracket}`,
borderBottom: `1.5px solid ${tokens.bracket}`,
}}
/>
{/* header */}
<div
style={{
display: "flex",
alignItems: "baseline",
justifyContent: "space-between",
marginBottom: 13,
}}
>
<div style={{ display: "flex", alignItems: "baseline", gap: 10 }}>
<span
style={{
fontFamily: tokens.font.mono,
fontSize: 15,
color: tokens.accent,
}}
>
01
</span>
<span style={{ fontSize: 13, fontWeight: 600, letterSpacing: 2 }}>
ПАРАМЕТРЫ КВАРТИРЫ
</span>
</div>
<span
style={{
fontFamily: tokens.font.mono,
fontSize: 10,
letterSpacing: 1,
color: tokens.muted2,
}}
>
ШАГ 1/1
</span>
</div>
{/* MAP */}
<div
style={{
position: "relative",
height: 140,
border: `1px solid ${tokens.line}`,
borderRadius: 6,
overflow: "hidden",
background: tokens.mapBg,
flex: "0 0 auto",
}}
>
{/* zoomable map content — scaled by the +/ controls. The map controls
and corner bracket below are SIBLINGS (outside this layer) so they
never scale. zoom=1 → scale() identity → pixel-identical to design. */}
<div
style={{
position: "absolute",
inset: 0,
transform: `scale(${zoom})`,
transformOrigin: "center",
transition: "transform .15s ease-out",
}}
>
<svg
viewBox="0 0 440 210"
preserveAspectRatio="xMidYMid slice"
style={{
position: "absolute",
inset: 0,
width: "100%",
height: "100%",
}}
>
<rect width="440" height="210" fill="#e6edf4" />
<g stroke="#dbe5ef" strokeWidth={1}>
<path d="M0 38H440M0 78H440M0 118H440M0 158H440M0 198H440" />
<path d="M40 0V210M110 0V210M180 0V210M250 0V210M320 0V210M390 0V210" />
</g>
<g stroke="#f5f8fc" strokeWidth={8} strokeLinecap="round">
<path d="M-10 92 H450" />
<path d="M150 -10 V220" />
<path d="M-10 30 L260 0" />
</g>
<g stroke="#eef3f9" strokeWidth={5}>
<path d="M300 -10 L470 150" />
<path d="M-10 170 H450" />
</g>
<path d="M150 92 L470 200" stroke="#cfe0f3" strokeWidth={6} />
<g fill="none" stroke="#2e8bff" strokeDasharray="3 4">
<circle cx="208" cy="100" r="44" opacity={0.55} />
<circle cx="208" cy="100" r="80" opacity={0.4} />
<circle cx="208" cy="100" r={outerRingR} opacity={0.28} />
</g>
<circle cx="208" cy="100" r="80" fill="#2e8bff" opacity={0.04} />
</svg>
{/* center pin */}
<div
style={{
position: "absolute",
left: "47%",
top: "48%",
transform: "translate(-50%,-100%)",
display: "flex",
flexDirection: "column",
alignItems: "center",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: 6,
background: tokens.surface.w85,
border: `1px solid ${tokens.accent}`,
borderRadius: 4,
padding: "4px 8px",
whiteSpace: "nowrap",
boxShadow: "0 3px 10px rgba(46,139,255,.25)",
}}
>
<span
style={{
width: 7,
height: 7,
borderRadius: "50%",
background: tokens.accent,
}}
/>
<span style={{ fontSize: 11, fontWeight: 600 }}>
{address.trim() || "Адрес квартиры"}
</span>
</div>
<div
style={{
width: 0,
height: 0,
borderLeft: "5px solid transparent",
borderRight: "5px solid transparent",
borderTop: `7px solid ${tokens.accent}`,
}}
/>
</div>
{/* analog price pins — projected from the real estimate via mapMarkers().
Empty markers (no estimate yet) → no price pins, just the subject pin +
radius rings (Finding #2: never the fabricated fixture prices/dots). */}
{markers.map((m, i) => (
<div key={i} style={{ ...analogPin, left: m.left, top: m.top }}>
{m.label}
<br />
<span style={{ color: tokens.muted }}>{m.sub}</span>
</div>
))}
</div>
{/* map controls */}
<div
style={{
position: "absolute",
right: 9,
bottom: 9,
display: "flex",
flexDirection: "column",
gap: 5,
}}
>
<div
style={{
width: 24,
height: 24,
background: tokens.surface.w80,
border: `1px solid ${tokens.line}`,
borderRadius: 4,
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
transition: "all .15s",
fontSize: 15,
color: tokens.muted,
}}
onClick={() => setZoom((z) => Math.min(2.5, z + 0.25))}
>
+
</div>
<div
style={{
width: 24,
height: 24,
background: tokens.surface.w80,
border: `1px solid ${tokens.line}`,
borderRadius: 4,
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
transition: "all .15s",
fontSize: 15,
color: tokens.muted,
}}
onClick={() => setZoom((z) => Math.max(0.6, z - 0.25))}
>
</div>
<div
style={{
width: 24,
height: 24,
background: tokens.surface.w80,
border: `1px solid ${tokens.line}`,
borderRadius: 4,
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
transition: "all .15s",
color: tokens.accent,
}}
>
<svg width="13" height="13" viewBox="0 0 13 13" fill="none">
<circle cx="6.5" cy="6.5" r="4" stroke="#2e8bff" />
<line x1="6.5" y1="0" x2="6.5" y2="2.5" stroke="#2e8bff" />
<line x1="6.5" y1="10.5" x2="6.5" y2="13" stroke="#2e8bff" />
<line x1="0" y1="6.5" x2="2.5" y2="6.5" stroke="#2e8bff" />
<line x1="10.5" y1="6.5" x2="13" y2="6.5" stroke="#2e8bff" />
</svg>
</div>
</div>
{/* map corner bracket */}
<div
style={{
position: "absolute",
left: 8,
top: 8,
width: 12,
height: 12,
borderLeft: `1.5px solid ${tokens.accent}`,
borderTop: `1.5px solid ${tokens.accent}`,
}}
/>
</div>
{/* radius row */}
<div
style={{
display: "flex",
alignItems: "center",
gap: 14,
marginTop: 13,
flex: "0 0 auto",
}}
>
<span style={{ fontSize: 10, letterSpacing: 1.5, color: tokens.muted }}>
РАДИУС АНАЛИЗА
</span>
{/* РАДИУС — selectable analysis radius (wired: parsed to radius_m on
submit). Trigger markup unchanged; now toggles a Dd-style option panel
and uses the shared pp-dd-trigger hover cue. */}
<div style={{ position: "relative" }}>
<div
className="pp-dd-trigger"
onClick={() => toggle("radius")}
style={{
display: "flex",
alignItems: "center",
gap: 8,
background: tokens.surface.w60,
borderRadius: 5,
padding: "6px 10px",
fontSize: 12,
fontFamily: tokens.font.mono,
cursor: "pointer",
}}
>
<span>{radius}</span>
<span style={{ color: tokens.muted2, fontSize: 9 }}></span>
</div>
{openDd === "radius" && (
<div style={radiusPanel}>
{RADIUS_OPTIONS.map((o) => (
<div
key={o}
className="pp-dd-opt"
onClick={() => pick(setRadius)(o)}
style={{
padding: "7px 10px",
fontSize: 12,
fontFamily: tokens.font.mono,
borderRadius: 4,
cursor: "pointer",
color: o === radius ? tokens.accent : tokens.ink,
fontWeight: o === radius ? 600 : 400,
}}
>
{o}
</div>
))}
</div>
)}
</div>
<div
style={{
display: "flex",
alignItems: "center",
gap: 16,
fontSize: 10.5,
color: tokens.muted,
marginLeft: "auto",
}}
>
<span style={{ display: "flex", alignItems: "center", gap: 6 }}>
<span
style={{
width: 8,
height: 8,
borderRadius: "50%",
background: tokens.accent,
}}
/>
Объявления
</span>
<span style={{ display: "flex", alignItems: "center", gap: 6 }}>
<span
style={{
width: 8,
height: 8,
borderRadius: "50%",
background: tokens.dealDot,
}}
/>
Сделки
</span>
</div>
</div>
{/* INPUTS */}
<div
style={{
marginTop: 10,
display: "flex",
flexDirection: "column",
gap: 5,
flex: 1,
minHeight: 0,
}}
>
<div>
<div style={hintLabel}>АДРЕС (YANDEX / NOMINATIM)</div>
<div style={{ position: "relative" }}>
{/* Address autocomplete (geocode suggest, ЕКБ viewbox). Typed text
is debounced into `addressQuery`; the dropdown below mirrors the
HUD <Dd> panel styling. Picking an item fills the full address
and captures lat/lon for the estimate payload. */}
<input
type="text"
value={address}
onChange={(e) => handleAddressChange(e.target.value)}
onFocus={() => {
if (address.trim().length >= 3) setSuggestOpen(true);
}}
onBlur={() => setSuggestOpen(false)}
onKeyDown={(e) => {
if (e.key === "Enter") handleSubmit();
if (e.key === "Escape") setSuggestOpen(false);
}}
placeholder="Город, улица, дом"
autoComplete="off"
style={addressField}
/>
<svg
style={{
position: "absolute",
right: 12,
top: "50%",
transform: "translateY(-50%)",
}}
width="15"
height="15"
viewBox="0 0 15 15"
fill="none"
>
<circle cx="7.5" cy="7.5" r="4.5" stroke="#2e8bff" />
<line x1="7.5" y1="0" x2="7.5" y2="3" stroke="#2e8bff" />
<line x1="7.5" y1="12" x2="7.5" y2="15" stroke="#2e8bff" />
<line x1="0" y1="7.5" x2="3" y2="7.5" stroke="#2e8bff" />
<line x1="12" y1="7.5" x2="15" y2="7.5" stroke="#2e8bff" />
</svg>
{suggestOpen && addressQuery.trim().length >= 3 && (
<div style={suggestPanel}>
{suggest.data && suggest.data.length > 0 ? (
suggest.data.map((s, i) => (
<div
key={`${s.full_address}-${i}`}
className="pp-dd-opt"
// onMouseDown (not onClick) + preventDefault fires before
// the input's onBlur, so the pick lands before the list closes.
onMouseDown={(e) => {
e.preventDefault();
pickSuggestion(s);
}}
style={suggestOpt}
>
<div style={suggestOptMain}>{s.label}</div>
{s.full_address !== s.label && (
<div style={suggestOptSub}>{s.full_address}</div>
)}
</div>
))
) : suggest.isFetching ? (
<div style={suggestNote}>Поиск</div>
) : (
<div style={suggestNote}>Ничего не найдено</div>
)}
</div>
)}
</div>
</div>
<div style={fieldRow}>
<div>
<div style={hintLabel}>ПЛОЩАДЬ, М²</div>
<input
type="text"
inputMode="decimal"
value={area}
onChange={(e) =>
setArea(e.target.value.replace(/[^\d.,]/g, ""))
}
onKeyDown={(e) => {
if (e.key === "Enter") handleSubmit();
}}
placeholder="напр. 54"
style={inputField}
/>
</div>
<div>
<div style={hintLabel}>КОМНАТ</div>
<Dd
open={openDd === "rooms"}
onToggle={() => toggle("rooms")}
value={rooms}
options={dropdownOptions.rooms}
onSelect={pick(setRooms)}
mono
triggerFontSize={14}
optionFontSize={13}
/>
</div>
</div>
<div style={fieldRow}>
<div>
<div style={{ display: "flex", justifyContent: "space-between" }}>
<span style={hintLabel}>ЭТАЖ</span>
<span style={optHint}>если знаешь</span>
</div>
<input
type="text"
inputMode="numeric"
value={floor}
onChange={(e) => setFloor(e.target.value.replace(/\D/g, ""))}
onKeyDown={(e) => {
if (e.key === "Enter") handleSubmit();
}}
placeholder="—"
style={inputField}
/>
</div>
<div>
<div style={{ display: "flex", justifyContent: "space-between" }}>
<span style={hintLabel}>ВСЕГО ЭТАЖЕЙ</span>
<span style={optHint}>если знаешь</span>
</div>
<input
type="text"
inputMode="numeric"
value={totalFloors}
onChange={(e) =>
setTotalFloors(e.target.value.replace(/\D/g, ""))
}
onKeyDown={(e) => {
if (e.key === "Enter") handleSubmit();
}}
placeholder="—"
style={inputField}
/>
</div>
</div>
<div
style={{
fontSize: 10,
color: tokens.hint,
lineHeight: 1.4,
marginTop: -3,
}}
>
1-й и последний этаж снижают цену на 510% заполни если знаешь
</div>
<div style={fieldRow}>
<div>
<div style={{ display: "flex", justifyContent: "space-between" }}>
<span style={hintLabel}>ГОД ПОСТРОЙКИ</span>
<span style={optHint}>опц.</span>
</div>
<input
type="text"
inputMode="numeric"
value={year}
onChange={(e) => setYear(e.target.value.replace(/\D/g, ""))}
onKeyDown={(e) => {
if (e.key === "Enter") handleSubmit();
}}
placeholder="—"
style={inputField}
/>
</div>
<div>
<div style={{ display: "flex", justifyContent: "space-between" }}>
<span style={hintLabel}>ТИП ДОМА</span>
<span style={optHint}>опц.</span>
</div>
<Dd
open={openDd === "houseType"}
onToggle={() => toggle("houseType")}
value={houseType}
options={dropdownOptions.houseType}
onSelect={pick(setHouseType)}
mono={false}
triggerFontSize={13}
optionFontSize={12}
/>
</div>
</div>
<div style={fieldRow}>
<div>
<div style={{ display: "flex", justifyContent: "space-between" }}>
<span style={hintLabel}>СОСТОЯНИЕ РЕМОНТА</span>
<span style={optHint}>опц.</span>
</div>
<Dd
open={openDd === "repair"}
onToggle={() => toggle("repair")}
value={repair}
options={dropdownOptions.repair}
onSelect={pick(setRepair)}
mono={false}
triggerFontSize={13}
optionFontSize={12}
/>
</div>
<div>
<div style={hintLabel}>БАЛКОН / ЛОДЖИЯ</div>
<div style={{ height: 40, display: "flex", gap: 6 }}>
<div
onClick={() => setBalcony(false)}
style={{ ...balBase, ...(balcony ? balOff : balOn) }}
>
НЕТ
</div>
<div
onClick={() => setBalcony(true)}
style={{ ...balBase, ...(balcony ? balOn : balOff) }}
>
ДА
</div>
</div>
</div>
</div>
<div>
<div style={{ display: "flex", justifyContent: "space-between" }}>
<span style={hintLabel}>CRM ДЛЯ МЕНЕДЖЕРА</span>
<span style={optHint}>опц.</span>
</div>
{/* CRM — non-functional (no backend, #395). Markup kept; open is
forced false + no-op handlers so it never expands. TODO(wire). */}
<Dd
open={false}
onToggle={() => undefined}
value={crm}
options={dropdownOptions.crm}
onSelect={() => undefined}
mono={false}
triggerFontSize={12}
optionFontSize={12}
variant="dashed"
openUp
triggerHeight={34}
triggerBg={tokens.surface.w50}
triggerColor={tokens.hint}
caretColor={tokens.muted4}
/>
</div>
</div>
{/* button */}
<button
type="button"
className="pp-eval-btn"
onClick={handleSubmit}
disabled={isPending}
style={{
marginTop: 9,
height: 46,
width: "100%",
border: `1px solid ${tokens.accent}`,
borderRadius: 7,
display: "flex",
alignItems: "center",
justifyContent: "center",
position: "relative",
cursor: isPending ? "wait" : "pointer",
opacity: isPending ? 0.6 : 1,
fontFamily: tokens.font.sans,
color: tokens.ink,
flex: "0 0 auto",
}}
>
<span style={{ fontSize: 14, fontWeight: 600, letterSpacing: 3 }}>
{isPending ? "ОЦЕНИВАЕМ…" : "ОЦЕНИТЬ КВАРТИРУ"}
</span>
<span
style={{
position: "absolute",
right: 16,
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 26,
height: 26,
border: `1px solid ${tokens.accent}`,
borderRadius: "50%",
color: tokens.accent,
}}
>
</span>
</button>
{/* inline error (validation or server) — only when present */}
{(validationError || error) && (
<div style={errorText} role="alert">
{validationError ?? error}
</div>
)}
{/* cache status */}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginTop: 11,
flex: "0 0 auto",
fontSize: 9.5,
letterSpacing: 1.5,
color: tokens.muted2,
}}
>
<span>КЭШ ПО АДРЕСУ 24 Ч</span>
<span
style={{
display: "flex",
alignItems: "center",
gap: 6,
color: tokens.success,
}}
>
<span
className="pp-dot"
style={{
width: 7,
height: 7,
borderRadius: "50%",
background: tokens.success,
boxShadow: `0 0 6px ${tokens.success}`,
}}
/>
ГОТОВ
</span>
</div>
</div>
);
}