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