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).
This commit is contained in:
parent
fb25180929
commit
e23dabe4f5
6 changed files with 136 additions and 30 deletions
|
|
@ -27,6 +27,11 @@ class TradeInEstimateInput(BaseModel):
|
||||||
# geocode() (который падает на DaData-формах при мёртвом Yandex-ключе).
|
# geocode() (который падает на DaData-формах при мёртвом Yandex-ключе).
|
||||||
lat: float | None = Field(default=None, ge=-90, le=90)
|
lat: float | None = Field(default=None, ge=-90, le=90)
|
||||||
lon: float | None = Field(default=None, ge=-180, le=180)
|
lon: float | None = Field(default=None, ge=-180, le=180)
|
||||||
|
# #2044: опциональный радиус анализа (контрол РАДИУС на /trade-in/v2), метры.
|
||||||
|
# None → текущее дефолтное поведение (1000 м поиск аналогов, 2000 м fallback).
|
||||||
|
# Задан → и первичный, и fallback-поиск аналогов/сделок используют ровно этот
|
||||||
|
# радиус (без авто-расширения). Диапазон 100–5000 м — sane guard.
|
||||||
|
radius_m: int | None = Field(default=None, ge=100, le=5000)
|
||||||
# CRM-поля (#395) — операционные, на расчёт оценки не влияют
|
# CRM-поля (#395) — операционные, на расчёт оценки не влияют
|
||||||
ownership_type: str | None = Field(default=None, max_length=100)
|
ownership_type: str | None = Field(default=None, max_length=100)
|
||||||
has_mortgage: bool | None = None
|
has_mortgage: bool | None = None
|
||||||
|
|
|
||||||
|
|
@ -2592,6 +2592,13 @@ async def estimate_quality(
|
||||||
# a) 1km + ±15% area (без cohort — drop fallback)
|
# a) 1km + ±15% area (без cohort — drop fallback)
|
||||||
# b) 2km + ±15% area (fallback_used = True)
|
# b) 2km + ±15% area (fallback_used = True)
|
||||||
# c) 2km + ±25% area (fallback_used = True, area_widened = True)
|
# c) 2km + ±25% area (fallback_used = True, area_widened = True)
|
||||||
|
#
|
||||||
|
# #2044: опциональный override радиуса анализа (контрол РАДИУС). None →
|
||||||
|
# точное текущее поведение (DEFAULT 1000 м первичный, FALLBACK 2000 м
|
||||||
|
# расширение). Задан → и первичный, и fallback-поиск используют ровно этот
|
||||||
|
# радиус (он же — максимум, без авто-расширения за пределы выбранного).
|
||||||
|
base_radius_m = payload.radius_m or DEFAULT_RADIUS_M
|
||||||
|
fallback_radius_m = payload.radius_m or FALLBACK_RADIUS_M
|
||||||
cohort_range = _target_cohort_range(target_year)
|
cohort_range = _target_cohort_range(target_year)
|
||||||
|
|
||||||
if cohort_range is not None:
|
if cohort_range is not None:
|
||||||
|
|
@ -2602,7 +2609,7 @@ async def estimate_quality(
|
||||||
lon=geo.lon,
|
lon=geo.lon,
|
||||||
rooms=payload.rooms,
|
rooms=payload.rooms,
|
||||||
area=payload.area_m2,
|
area=payload.area_m2,
|
||||||
radius_m=DEFAULT_RADIUS_M,
|
radius_m=base_radius_m,
|
||||||
full_address=geo.full_address,
|
full_address=geo.full_address,
|
||||||
target_house_id=target_house_id,
|
target_house_id=target_house_id,
|
||||||
year_built=target_year,
|
year_built=target_year,
|
||||||
|
|
@ -2626,7 +2633,7 @@ async def estimate_quality(
|
||||||
lon=geo.lon,
|
lon=geo.lon,
|
||||||
rooms=payload.rooms,
|
rooms=payload.rooms,
|
||||||
area=payload.area_m2,
|
area=payload.area_m2,
|
||||||
radius_m=DEFAULT_RADIUS_M,
|
radius_m=base_radius_m,
|
||||||
full_address=geo.full_address,
|
full_address=geo.full_address,
|
||||||
target_house_id=target_house_id,
|
target_house_id=target_house_id,
|
||||||
year_built=target_year,
|
year_built=target_year,
|
||||||
|
|
@ -2642,7 +2649,7 @@ async def estimate_quality(
|
||||||
lon=geo.lon,
|
lon=geo.lon,
|
||||||
rooms=payload.rooms,
|
rooms=payload.rooms,
|
||||||
area=payload.area_m2,
|
area=payload.area_m2,
|
||||||
radius_m=FALLBACK_RADIUS_M,
|
radius_m=fallback_radius_m,
|
||||||
full_address=geo.full_address,
|
full_address=geo.full_address,
|
||||||
target_house_id=target_house_id,
|
target_house_id=target_house_id,
|
||||||
year_built=target_year,
|
year_built=target_year,
|
||||||
|
|
@ -2663,7 +2670,7 @@ async def estimate_quality(
|
||||||
lon=geo.lon,
|
lon=geo.lon,
|
||||||
rooms=payload.rooms,
|
rooms=payload.rooms,
|
||||||
area=payload.area_m2,
|
area=payload.area_m2,
|
||||||
radius_m=FALLBACK_RADIUS_M,
|
radius_m=fallback_radius_m,
|
||||||
area_tolerance=0.25,
|
area_tolerance=0.25,
|
||||||
full_address=geo.full_address,
|
full_address=geo.full_address,
|
||||||
target_house_id=target_house_id,
|
target_house_id=target_house_id,
|
||||||
|
|
@ -2877,7 +2884,7 @@ async def estimate_quality(
|
||||||
lon=geo.lon,
|
lon=geo.lon,
|
||||||
rooms=payload.rooms,
|
rooms=payload.rooms,
|
||||||
area=payload.area_m2,
|
area=payload.area_m2,
|
||||||
radius_m=DEFAULT_RADIUS_M,
|
radius_m=base_radius_m,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 6. Сохраняем в trade_in_estimates
|
# 6. Сохраняем в trade_in_estimates
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, type CSSProperties } from "react";
|
import { useState, type CSSProperties } from "react";
|
||||||
import Image from "next/image";
|
|
||||||
|
|
||||||
import { API_BASE_URL } from "@/lib/api";
|
import { API_BASE_URL } from "@/lib/api";
|
||||||
import { safeUrl } from "@/lib/safeUrl";
|
import { safeUrl } from "@/lib/safeUrl";
|
||||||
|
|
@ -13,6 +12,11 @@ import type { HeroBarData } from "./mappers";
|
||||||
// Default presentation data (unwired usage): the existing design fixtures.
|
// Default presentation data (unwired usage): the existing design fixtures.
|
||||||
const HERO_FIXTURE: HeroBarData = { report, object };
|
const HERO_FIXTURE: HeroBarData = { report, object };
|
||||||
|
|
||||||
|
// next/image does NOT prepend the configured basePath ("/trade-in") to a
|
||||||
|
// literal src, so an <Image src="/trade-in-v2/…"> 404s behind Caddy. A plain
|
||||||
|
// <img> with the basePath baked in resolves to /trade-in/trade-in-v2/… → 200.
|
||||||
|
const BP = process.env.NEXT_PUBLIC_BASE_PATH ?? "";
|
||||||
|
|
||||||
// estimate ids are server-issued UUIDs — reject anything else before it lands
|
// estimate ids are server-issued UUIDs — reject anything else before it lands
|
||||||
// in the PDF request path so a tampered id cannot be injected.
|
// in the PDF request path so a tampered id cannot be injected.
|
||||||
const PDF_UUID_RE =
|
const PDF_UUID_RE =
|
||||||
|
|
@ -244,14 +248,15 @@ export default function HeroBar({
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{!imgFailed && (
|
{!imgFailed && (
|
||||||
<Image
|
<img
|
||||||
src="/trade-in-v2/building.png"
|
src={`${BP}/trade-in-v2/building.png`}
|
||||||
alt=""
|
alt=""
|
||||||
fill
|
|
||||||
sizes="560px"
|
|
||||||
unoptimized
|
|
||||||
onError={() => setImgFailed(true)}
|
onError={() => setImgFailed(true)}
|
||||||
style={{
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
inset: 0,
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
objectFit: "contain",
|
objectFit: "contain",
|
||||||
objectPosition: "right center",
|
objectPosition: "right center",
|
||||||
filter: "saturate(.9) brightness(1.04)",
|
filter: "saturate(.9) brightness(1.04)",
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,9 @@
|
||||||
// styles are UNCHANGED — only the data plumbing differs (display <div>s became
|
// styles are UNCHANGED — only the data plumbing differs (display <div>s became
|
||||||
// <input>s styled identically, dropdowns now feed real enum values). RU dropdown
|
// <input>s styled identically, dropdowns now feed real enum values). RU dropdown
|
||||||
// labels <-> API enum values go through HOUSE_TYPE_*/REPAIR_* maps in ./mappers.
|
// labels <-> API enum values go through HOUSE_TYPE_*/REPAIR_* maps in ./mappers.
|
||||||
// The РАДИУС and CRM dropdowns have no backend yet → kept visually but disabled
|
// РАДИУС is now wired (radius_m on submit + the outer map ring scales with it);
|
||||||
// (see TODOs). Hover/active + @keyframes live in a pp-prefixed local <style>.
|
// 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 { useRef, useState, type CSSProperties } from "react";
|
||||||
import { tokens } from "./tokens";
|
import { tokens } from "./tokens";
|
||||||
|
|
@ -28,7 +29,7 @@ import type {
|
||||||
TradeInEstimateInput,
|
TradeInEstimateInput,
|
||||||
} from "@/types/trade-in";
|
} from "@/types/trade-in";
|
||||||
|
|
||||||
type DdKey = "rooms" | "houseType" | "repair" | null;
|
type DdKey = "rooms" | "houseType" | "repair" | "radius" | null;
|
||||||
|
|
||||||
interface DdProps {
|
interface DdProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
|
@ -273,6 +274,21 @@ const analogPin: CSSProperties = {
|
||||||
color: tokens.ink,
|
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 {
|
interface ParamsPanelProps {
|
||||||
/** Called with the validated form payload on «ОЦЕНИТЬ КВАРТИРУ». Optional so
|
/** Called with the validated form payload on «ОЦЕНИТЬ КВАРТИРУ». Optional so
|
||||||
* the still-unwired v2 page renders without props (tsc-safe). */
|
* the still-unwired v2 page renders without props (tsc-safe). */
|
||||||
|
|
@ -305,6 +321,19 @@ function initRepairLabel(rs: RepairState | undefined): string {
|
||||||
return rs ? REPAIR_RU[rs] : "Хороший";
|
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({
|
export default function ParamsPanel({
|
||||||
onSubmit,
|
onSubmit,
|
||||||
isPending = false,
|
isPending = false,
|
||||||
|
|
@ -313,6 +342,9 @@ export default function ParamsPanel({
|
||||||
markers = [],
|
markers = [],
|
||||||
}: ParamsPanelProps) {
|
}: ParamsPanelProps) {
|
||||||
const [openDd, setOpenDd] = useState<DdKey>(null);
|
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 [address, setAddress] = useState(initialValues?.address ?? "");
|
||||||
const [area, setArea] = useState(
|
const [area, setArea] = useState(
|
||||||
initialValues?.area_m2 != null ? String(initialValues.area_m2) : "",
|
initialValues?.area_m2 != null ? String(initialValues.area_m2) : "",
|
||||||
|
|
@ -335,6 +367,9 @@ export default function ParamsPanel({
|
||||||
const [repair, setRepair] = useState(
|
const [repair, setRepair] = useState(
|
||||||
initRepairLabel(initialValues?.repair_state),
|
initRepairLabel(initialValues?.repair_state),
|
||||||
);
|
);
|
||||||
|
const [radius, setRadius] = useState(
|
||||||
|
initRadiusLabel(initialValues?.radius_m),
|
||||||
|
);
|
||||||
const [balcony, setBalcony] = useState(initialValues?.has_balcony ?? true);
|
const [balcony, setBalcony] = useState(initialValues?.has_balcony ?? true);
|
||||||
const [validationError, setValidationError] = useState<string | null>(null);
|
const [validationError, setValidationError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
|
@ -368,9 +403,6 @@ export default function ParamsPanel({
|
||||||
setSuggestOpen(false);
|
setSuggestOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
// TODO(wire): радиус анализа пока не выбирается (бэкенд не поддерживает) —
|
|
||||||
// дропдаун оставлен визуально, но non-functional.
|
|
||||||
const radius = "500 м";
|
|
||||||
// TODO(#395): CRM-интеграция не подключена — дропдаун присутствует визуально,
|
// TODO(#395): CRM-интеграция не подключена — дропдаун присутствует визуально,
|
||||||
// но неактивен (выбор ничего не делает).
|
// но неактивен (выбор ничего не делает).
|
||||||
const crm = dropdownOptions.crm[0];
|
const crm = dropdownOptions.crm[0];
|
||||||
|
|
@ -411,6 +443,10 @@ export default function ParamsPanel({
|
||||||
has_balcony: balcony,
|
has_balcony: balcony,
|
||||||
lat: coords?.lat ?? null,
|
lat: coords?.lat ?? null,
|
||||||
lon: coords?.lon ?? 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),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -438,6 +474,14 @@ export default function ParamsPanel({
|
||||||
transition: "all .15s",
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -524,6 +568,18 @@ export default function ParamsPanel({
|
||||||
flex: "0 0 auto",
|
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
|
<svg
|
||||||
viewBox="0 0 440 210"
|
viewBox="0 0 440 210"
|
||||||
preserveAspectRatio="xMidYMid slice"
|
preserveAspectRatio="xMidYMid slice"
|
||||||
|
|
@ -552,7 +608,7 @@ export default function ParamsPanel({
|
||||||
<g fill="none" stroke="#2e8bff" strokeDasharray="3 4">
|
<g fill="none" stroke="#2e8bff" strokeDasharray="3 4">
|
||||||
<circle cx="208" cy="100" r="44" opacity={0.55} />
|
<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="80" opacity={0.4} />
|
||||||
<circle cx="208" cy="100" r="112" opacity={0.28} />
|
<circle cx="208" cy="100" r={outerRingR} opacity={0.28} />
|
||||||
</g>
|
</g>
|
||||||
<circle cx="208" cy="100" r="80" fill="#2e8bff" opacity={0.04} />
|
<circle cx="208" cy="100" r="80" fill="#2e8bff" opacity={0.04} />
|
||||||
</svg>
|
</svg>
|
||||||
|
|
@ -615,6 +671,7 @@ export default function ParamsPanel({
|
||||||
<span style={{ color: tokens.muted }}>{m.sub}</span>
|
<span style={{ color: tokens.muted }}>{m.sub}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* map controls */}
|
{/* map controls */}
|
||||||
<div
|
<div
|
||||||
|
|
@ -642,6 +699,7 @@ export default function ParamsPanel({
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
color: tokens.muted,
|
color: tokens.muted,
|
||||||
}}
|
}}
|
||||||
|
onClick={() => setZoom((z) => Math.min(2.5, z + 0.25))}
|
||||||
>
|
>
|
||||||
+
|
+
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -660,6 +718,7 @@ export default function ParamsPanel({
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
color: tokens.muted,
|
color: tokens.muted,
|
||||||
}}
|
}}
|
||||||
|
onClick={() => setZoom((z) => Math.max(0.6, z - 0.25))}
|
||||||
>
|
>
|
||||||
−
|
−
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -715,26 +774,50 @@ export default function ParamsPanel({
|
||||||
<span style={{ fontSize: 10, letterSpacing: 1.5, color: tokens.muted }}>
|
<span style={{ fontSize: 10, letterSpacing: 1.5, color: tokens.muted }}>
|
||||||
РАДИУС АНАЛИЗА
|
РАДИУС АНАЛИЗА
|
||||||
</span>
|
</span>
|
||||||
{/* РАДИУС — non-functional (no backend for radius selection yet). Markup
|
{/* РАДИУС — selectable analysis radius (wired: parsed to radius_m on
|
||||||
kept; trigger is static (no toggle / dropdown panel). TODO(wire). */}
|
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 style={{ position: "relative" }}>
|
||||||
<div
|
<div
|
||||||
|
className="pp-dd-trigger"
|
||||||
|
onClick={() => toggle("radius")}
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: 8,
|
gap: 8,
|
||||||
background: tokens.surface.w60,
|
background: tokens.surface.w60,
|
||||||
border: `1px solid ${tokens.line}`,
|
|
||||||
borderRadius: 5,
|
borderRadius: 5,
|
||||||
padding: "6px 10px",
|
padding: "6px 10px",
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontFamily: tokens.font.mono,
|
fontFamily: tokens.font.mono,
|
||||||
cursor: "default",
|
cursor: "pointer",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span>{radius}</span>
|
<span>{radius}</span>
|
||||||
<span style={{ color: tokens.muted2, fontSize: 9 }}>▼</span>
|
<span style={{ color: tokens.muted2, fontSize: 9 }}>▼</span>
|
||||||
</div>
|
</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>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|
|
||||||
|
|
@ -1040,16 +1040,19 @@ function dealMeta(l: AnalogLot): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Finding #7 — de-glue addresses. Some geocoded analog/deal addresses run a
|
* Finding #7 — de-glue addresses. Some geocoded analog/deal addresses run two
|
||||||
* metro/word straight onto the house number ("…, 69/2Геологическая"). Insert a
|
* tokens together with no separator — either a word glued onto the house number
|
||||||
* space where a digit is immediately followed by the start of a Cyrillic WORD
|
* ("…, 69/2Геологическая") OR two words glued together ("БольшаковаГеологическая").
|
||||||
* (capital + lowercase). NB: deliberately NOT the looser /(\d)([А-ЯЁ])/ — that
|
* Insert a space wherever a digit OR a lowercase letter is immediately followed
|
||||||
* would also split legitimate single-letter house litereras (14А, 5Б, д.5К),
|
* by the start of a Cyrillic WORD (capital + lowercase). NB: the trailing
|
||||||
* which are valid ЕКБ house numbers. Pure display cleanup; never touches the
|
* lowercase in [А-ЯЁ][а-яё] is deliberate — it means we do NOT split legitimate
|
||||||
* comma-splitting parseAddress.
|
* single-letter house litereras (14А, 5Б, д.5К — capital NOT followed by
|
||||||
|
* lowercase) and do NOT touch already-separated tokens ("ул.Белинского" — the
|
||||||
|
* capital is preceded by ".", not a glue char). Pure display cleanup; never
|
||||||
|
* touches the comma-splitting parseAddress.
|
||||||
*/
|
*/
|
||||||
function deglueAddr(addr: string): string {
|
function deglueAddr(addr: string): string {
|
||||||
return addr.replace(/(\d)([А-ЯЁ][а-яё])/g, "$1 $2");
|
return addr.replace(/([а-яёa-z\d])([А-ЯЁ][а-яё])/g, "$1 $2");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 04 ПРОДАЖИ В ДОМЕ (HistoryView) ─────────────────────────────────────────
|
// ── 04 ПРОДАЖИ В ДОМЕ (HistoryView) ─────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,9 @@ export interface TradeInEstimateInput {
|
||||||
// для адресов, которые DaData/Yandex не резолвят по строке.
|
// для адресов, которые DaData/Yandex не резолвят по строке.
|
||||||
lat?: number | null;
|
lat?: number | null;
|
||||||
lon?: number | null;
|
lon?: number | null;
|
||||||
|
// Радиус поиска аналогов/сделок в метрах (опциональный override дефолта бэкенда).
|
||||||
|
// Зеркалит новое поле request-схемы; absent → бэкенд применяет свой дефолт.
|
||||||
|
radius_m?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AnalogLot {
|
export interface AnalogLot {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue