Merge pull request 'fix(tradein/v2): wire РАДИУС (backend radius_m) + map +/− zoom + building image + deglue word+word' (#2060) from feat/tradein-v2-audit-fixes-2 into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 1m32s
Deploy Trade-In / build-frontend (push) Successful in 4m10s
Deploy Trade-In / build-backend (push) Successful in 6m40s
Deploy Trade-In / deploy (push) Successful in 1m7s
All checks were successful
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 1m32s
Deploy Trade-In / build-frontend (push) Successful in 4m10s
Deploy Trade-In / build-backend (push) Successful in 6m40s
Deploy Trade-In / deploy (push) Successful in 1m7s
This commit is contained in:
commit
bcbbb53495
6 changed files with 136 additions and 30 deletions
|
|
@ -27,6 +27,11 @@ class TradeInEstimateInput(BaseModel):
|
|||
# geocode() (который падает на DaData-формах при мёртвом Yandex-ключе).
|
||||
lat: float | None = Field(default=None, ge=-90, le=90)
|
||||
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) — операционные, на расчёт оценки не влияют
|
||||
ownership_type: str | None = Field(default=None, max_length=100)
|
||||
has_mortgage: bool | None = None
|
||||
|
|
|
|||
|
|
@ -2592,6 +2592,13 @@ async def estimate_quality(
|
|||
# a) 1km + ±15% area (без cohort — drop fallback)
|
||||
# b) 2km + ±15% area (fallback_used = 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)
|
||||
|
||||
if cohort_range is not None:
|
||||
|
|
@ -2602,7 +2609,7 @@ async def estimate_quality(
|
|||
lon=geo.lon,
|
||||
rooms=payload.rooms,
|
||||
area=payload.area_m2,
|
||||
radius_m=DEFAULT_RADIUS_M,
|
||||
radius_m=base_radius_m,
|
||||
full_address=geo.full_address,
|
||||
target_house_id=target_house_id,
|
||||
year_built=target_year,
|
||||
|
|
@ -2626,7 +2633,7 @@ async def estimate_quality(
|
|||
lon=geo.lon,
|
||||
rooms=payload.rooms,
|
||||
area=payload.area_m2,
|
||||
radius_m=DEFAULT_RADIUS_M,
|
||||
radius_m=base_radius_m,
|
||||
full_address=geo.full_address,
|
||||
target_house_id=target_house_id,
|
||||
year_built=target_year,
|
||||
|
|
@ -2642,7 +2649,7 @@ async def estimate_quality(
|
|||
lon=geo.lon,
|
||||
rooms=payload.rooms,
|
||||
area=payload.area_m2,
|
||||
radius_m=FALLBACK_RADIUS_M,
|
||||
radius_m=fallback_radius_m,
|
||||
full_address=geo.full_address,
|
||||
target_house_id=target_house_id,
|
||||
year_built=target_year,
|
||||
|
|
@ -2663,7 +2670,7 @@ async def estimate_quality(
|
|||
lon=geo.lon,
|
||||
rooms=payload.rooms,
|
||||
area=payload.area_m2,
|
||||
radius_m=FALLBACK_RADIUS_M,
|
||||
radius_m=fallback_radius_m,
|
||||
area_tolerance=0.25,
|
||||
full_address=geo.full_address,
|
||||
target_house_id=target_house_id,
|
||||
|
|
@ -2877,7 +2884,7 @@ async def estimate_quality(
|
|||
lon=geo.lon,
|
||||
rooms=payload.rooms,
|
||||
area=payload.area_m2,
|
||||
radius_m=DEFAULT_RADIUS_M,
|
||||
radius_m=base_radius_m,
|
||||
)
|
||||
|
||||
# 6. Сохраняем в trade_in_estimates
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useState, type CSSProperties } from "react";
|
||||
import Image from "next/image";
|
||||
|
||||
import { API_BASE_URL } from "@/lib/api";
|
||||
import { safeUrl } from "@/lib/safeUrl";
|
||||
|
|
@ -13,6 +12,11 @@ import type { HeroBarData } from "./mappers";
|
|||
// Default presentation data (unwired usage): the existing design fixtures.
|
||||
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
|
||||
// in the PDF request path so a tampered id cannot be injected.
|
||||
const PDF_UUID_RE =
|
||||
|
|
@ -244,14 +248,15 @@ export default function HeroBar({
|
|||
}}
|
||||
>
|
||||
{!imgFailed && (
|
||||
<Image
|
||||
src="/trade-in-v2/building.png"
|
||||
<img
|
||||
src={`${BP}/trade-in-v2/building.png`}
|
||||
alt=""
|
||||
fill
|
||||
sizes="560px"
|
||||
unoptimized
|
||||
onError={() => setImgFailed(true)}
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "contain",
|
||||
objectPosition: "right center",
|
||||
filter: "saturate(.9) brightness(1.04)",
|
||||
|
|
|
|||
|
|
@ -7,8 +7,9 @@
|
|||
// 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.
|
||||
// The РАДИУС and CRM dropdowns have no backend yet → kept visually but disabled
|
||||
// (see TODOs). Hover/active + @keyframes live in a pp-prefixed local <style>.
|
||||
// РАДИУС 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";
|
||||
|
|
@ -28,7 +29,7 @@ import type {
|
|||
TradeInEstimateInput,
|
||||
} from "@/types/trade-in";
|
||||
|
||||
type DdKey = "rooms" | "houseType" | "repair" | null;
|
||||
type DdKey = "rooms" | "houseType" | "repair" | "radius" | null;
|
||||
|
||||
interface DdProps {
|
||||
open: boolean;
|
||||
|
|
@ -273,6 +274,21 @@ const analogPin: CSSProperties = {
|
|||
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). */
|
||||
|
|
@ -305,6 +321,19 @@ 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,
|
||||
|
|
@ -313,6 +342,9 @@ export default function ParamsPanel({
|
|||
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) : "",
|
||||
|
|
@ -335,6 +367,9 @@ export default function ParamsPanel({
|
|||
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);
|
||||
|
||||
|
|
@ -368,9 +403,6 @@ export default function ParamsPanel({
|
|||
setSuggestOpen(false);
|
||||
};
|
||||
|
||||
// TODO(wire): радиус анализа пока не выбирается (бэкенд не поддерживает) —
|
||||
// дропдаун оставлен визуально, но non-functional.
|
||||
const radius = "500 м";
|
||||
// TODO(#395): CRM-интеграция не подключена — дропдаун присутствует визуально,
|
||||
// но неактивен (выбор ничего не делает).
|
||||
const crm = dropdownOptions.crm[0];
|
||||
|
|
@ -411,6 +443,10 @@ export default function ParamsPanel({
|
|||
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),
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -438,6 +474,14 @@ export default function ParamsPanel({
|
|||
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={{
|
||||
|
|
@ -523,6 +567,18 @@ export default function ParamsPanel({
|
|||
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"
|
||||
|
|
@ -552,7 +608,7 @@ export default function ParamsPanel({
|
|||
<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="112" opacity={0.28} />
|
||||
<circle cx="208" cy="100" r={outerRingR} opacity={0.28} />
|
||||
</g>
|
||||
<circle cx="208" cy="100" r="80" fill="#2e8bff" opacity={0.04} />
|
||||
</svg>
|
||||
|
|
@ -615,6 +671,7 @@ export default function ParamsPanel({
|
|||
<span style={{ color: tokens.muted }}>{m.sub}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* map controls */}
|
||||
<div
|
||||
|
|
@ -642,6 +699,7 @@ export default function ParamsPanel({
|
|||
fontSize: 15,
|
||||
color: tokens.muted,
|
||||
}}
|
||||
onClick={() => setZoom((z) => Math.min(2.5, z + 0.25))}
|
||||
>
|
||||
+
|
||||
</div>
|
||||
|
|
@ -660,6 +718,7 @@ export default function ParamsPanel({
|
|||
fontSize: 15,
|
||||
color: tokens.muted,
|
||||
}}
|
||||
onClick={() => setZoom((z) => Math.max(0.6, z - 0.25))}
|
||||
>
|
||||
−
|
||||
</div>
|
||||
|
|
@ -715,26 +774,50 @@ export default function ParamsPanel({
|
|||
<span style={{ fontSize: 10, letterSpacing: 1.5, color: tokens.muted }}>
|
||||
РАДИУС АНАЛИЗА
|
||||
</span>
|
||||
{/* РАДИУС — non-functional (no backend for radius selection yet). Markup
|
||||
kept; trigger is static (no toggle / dropdown panel). TODO(wire). */}
|
||||
{/* РАДИУС — 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,
|
||||
border: `1px solid ${tokens.line}`,
|
||||
borderRadius: 5,
|
||||
padding: "6px 10px",
|
||||
fontSize: 12,
|
||||
fontFamily: tokens.font.mono,
|
||||
cursor: "default",
|
||||
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={{
|
||||
|
|
|
|||
|
|
@ -1040,16 +1040,19 @@ function dealMeta(l: AnalogLot): string {
|
|||
}
|
||||
|
||||
/**
|
||||
* Finding #7 — de-glue addresses. Some geocoded analog/deal addresses run a
|
||||
* metro/word straight onto the house number ("…, 69/2Геологическая"). Insert a
|
||||
* space where a digit is immediately followed by the start of a Cyrillic WORD
|
||||
* (capital + lowercase). NB: deliberately NOT the looser /(\d)([А-ЯЁ])/ — that
|
||||
* would also split legitimate single-letter house litereras (14А, 5Б, д.5К),
|
||||
* which are valid ЕКБ house numbers. Pure display cleanup; never touches the
|
||||
* comma-splitting parseAddress.
|
||||
* Finding #7 — de-glue addresses. Some geocoded analog/deal addresses run two
|
||||
* tokens together with no separator — either a word glued onto the house number
|
||||
* ("…, 69/2Геологическая") OR two words glued together ("БольшаковаГеологическая").
|
||||
* Insert a space wherever a digit OR a lowercase letter is immediately followed
|
||||
* by the start of a Cyrillic WORD (capital + lowercase). NB: the trailing
|
||||
* lowercase in [А-ЯЁ][а-яё] is deliberate — it means we do NOT split legitimate
|
||||
* 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 {
|
||||
return addr.replace(/(\d)([А-ЯЁ][а-яё])/g, "$1 $2");
|
||||
return addr.replace(/([а-яёa-z\d])([А-ЯЁ][а-яё])/g, "$1 $2");
|
||||
}
|
||||
|
||||
// ── 04 ПРОДАЖИ В ДОМЕ (HistoryView) ─────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -68,6 +68,9 @@ export interface TradeInEstimateInput {
|
|||
// для адресов, которые DaData/Yandex не резолвят по строке.
|
||||
lat?: number | null;
|
||||
lon?: number | null;
|
||||
// Радиус поиска аналогов/сделок в метрах (опциональный override дефолта бэкенда).
|
||||
// Зеркалит новое поле request-схемы; absent → бэкенд применяет свой дефолт.
|
||||
radius_m?: number | null;
|
||||
}
|
||||
|
||||
export interface AnalogLot {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue