diff --git a/tradein-mvp/frontend/src/app/v2/page.tsx b/tradein-mvp/frontend/src/app/v2/page.tsx
index 2e0ecb4d..706ed177 100644
--- a/tradein-mvp/frontend/src/app/v2/page.tsx
+++ b/tradein-mvp/frontend/src/app/v2/page.tsx
@@ -24,7 +24,11 @@ import { ObjectSummary } from "@/components/trade-in/v2/ObjectSummary";
import { Footer } from "@/components/trade-in/v2/Footer";
import SectionOverlay from "@/components/trade-in/v2/SectionOverlay";
import { LocationDrawer } from "@/components/trade-in/v2/LocationDrawer";
-import type { Analytics, ObjectInfo, Report } from "@/components/trade-in/v2/types";
+import type {
+ Analytics,
+ ObjectInfo,
+ Report,
+} from "@/components/trade-in/v2/types";
import type {
CacheData,
HistoryData,
@@ -156,13 +160,7 @@ const retryBtnStyle: CSSProperties = {
// ── Result/summary area state panels (kept inside the page — these are layout
// states, not reusable design components, so they do not belong in v2/). ───
-function SkeletonBlock({
- height,
- flex,
-}: {
- height?: number;
- flex?: number;
-}) {
+function SkeletonBlock({ height, flex }: { height?: number; flex?: number }) {
return (
setMounted(true), []);
+ // Scale-fit: shrink the fixed 1536×1024 artboard to fit smaller viewports
+ // instead of clipping. Never upscale (capped at 1); recomputed on resize.
+ const [artboardScale, setArtboardScale] = useState(1);
+ useEffect(() => {
+ const compute = () =>
+ setArtboardScale(
+ Math.min(1, window.innerWidth / 1536, window.innerHeight / 1024),
+ );
+ compute();
+ window.addEventListener("resize", compute);
+ return () => window.removeEventListener("resize", compute);
+ }, []);
+
const mutation = useEstimateMutation();
// Skip the restore fetch once we already hold a fresh result.
const restored = useEstimate(freshResult === null ? urlId : null);
- const estimate: AggregatedEstimate | null = freshResult ?? restored.data ?? null;
+ const estimate: AggregatedEstimate | null =
+ freshResult ?? restored.data ?? null;
const currentEstimateId = freshResult?.estimate_id ?? urlId;
// Per-user side data, independent of the current estimate. Both fail-soft
@@ -454,7 +466,8 @@ export default function TradeInV2Page() {
const loading = mutation.isPending || restoreLoading;
const insufficient =
- estimate != null && (estimate.insufficient_data || estimate.n_analogs === 0);
+ estimate != null &&
+ (estimate.insufficient_data || estimate.n_analogs === 0);
const apiError = mutation.error?.message ?? null;
// ── Mapped presentation data (memoised so nav/drawer toggles don't recompute
@@ -472,7 +485,8 @@ export default function TradeInV2Page() {
[estimate, streetDealsData],
);
const summaryData = useMemo(
- () => (estimate ? mapSummary(estimate, analyticsData, streetDealsData) : null),
+ () =>
+ estimate ? mapSummary(estimate, analyticsData, streetDealsData) : null,
[estimate, analyticsData, streetDealsData],
);
@@ -604,103 +618,116 @@ export default function TradeInV2Page() {
}
return (
-
-
-
- {/* OUTER HUD FRAME */}
+
- {/* 4 corner brackets */}
- {brackets.map((b) => (
-
- ))}
-
- {/* CONTENT WRAP */}
-
-
-
setDrawerOpen(true)}
- />
+
+ {/* OUTER HUD FRAME */}
+ {/* 4 corner brackets */}
+ {brackets.map((b) => (
+
+ ))}
+
+ {/* CONTENT WRAP */}
+
-
- {middleContent}
- {rightContent}
+
+
+
+
+ setDrawerOpen(true)}
+ />
+
+
+
+ {middleContent}
+ {rightContent}
+
+
+
+
-
-
-
- {nav !== 0 && (
-
setNav(0)}
- onNavigate={setNav}
- history={historyData}
- analytics={analyticsViewData}
- sources={sourcesData}
- cache={cacheData}
+ {nav !== 0 && (
+ setNav(0)}
+ onNavigate={setNav}
+ history={historyData}
+ analytics={analyticsViewData}
+ sources={sourcesData}
+ cache={cacheData}
+ />
+ )}
+ setDrawerOpen(false)}
/>
- )}
- setDrawerOpen(false)} />
+
);
}
diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/CacheView.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/CacheView.tsx
index 5de4979f..9c501fcb 100644
--- a/tradein-mvp/frontend/src/components/trade-in/v2/CacheView.tsx
+++ b/tradein-mvp/frontend/src/components/trade-in/v2/CacheView.tsx
@@ -6,11 +6,11 @@ import { tokens } from "./tokens";
import { cacheKpi, cacheRows } from "./fixtures";
import type { CacheData } from "./mappers";
-const GRID_COLUMNS = "2.4fr 1fr 1fr 1fr";
-
const FIXTURE_CACHE: CacheData = { kpis: cacheKpi, rows: cacheRows };
export function CacheView({ data = FIXTURE_CACHE }: { data?: CacheData }) {
+ const showSrc = data.rows.some((r) => r.src && r.src !== "—");
+ const gridCols = showSrc ? "2.4fr 1fr 1fr 1fr" : "2.4fr 1fr 1fr";
return (
{/* 3 KPI cards */}
@@ -87,7 +87,7 @@ export function CacheView({ data = FIXTURE_CACHE }: { data?: CacheData }) {
АДРЕС
ВРЕМЯ
- ИСТОЧНИКОВ
+ {showSrc && ИСТОЧНИКОВ }
СТАТУС
{data.rows.map((r, i) => (
@@ -106,7 +106,7 @@ export function CacheView({ data = FIXTURE_CACHE }: { data?: CacheData }) {
key={`${r.addr}-${i}`}
style={{
display: "grid",
- gridTemplateColumns: GRID_COLUMNS,
+ gridTemplateColumns: gridCols,
gap: 10,
padding: "12px 18px",
fontSize: 11,
@@ -120,9 +120,13 @@ export function CacheView({ data = FIXTURE_CACHE }: { data?: CacheData }) {
>
{r.time}
-
- {r.src}
-
+ {showSrc && (
+
+ {r.src}
+
+ )}
-
- {data.object.locationCoef}
-
+ {data.object.locationCoef === "—" ? (
+
+ скоро
+
+ ) : (
+
+ {data.object.locationCoef}
+
+ )}
r.ask && r.ask !== "—");
+ const dkpGridCols = showAsk
+ ? "2.2fr 1fr 1.3fr 1fr .9fr 2fr"
+ : "2.2fr 1fr 1.3fr 1fr .9fr";
return (
{/* ── История продаж в этом доме ───────────────────────────── */}
@@ -278,7 +283,7 @@ export default function HistoryView({ data = HISTORY_FIXTURE }: HistoryViewProps
ЦЕНА СДЕЛКИ
₽/М²
ДАТА
- ОБЪЯВЛЕНИЕ ДО СДЕЛКИ
+ {showAsk && ОБЪЯВЛЕНИЕ ДО СДЕЛКИ }
{data.dkpRows.map((r, i) => (
@@ -300,7 +305,7 @@ export default function HistoryView({ data = HISTORY_FIXTURE }: HistoryViewProps
key={i}
style={{
display: "grid",
- gridTemplateColumns: dkpGrid,
+ gridTemplateColumns: dkpGridCols,
gap: "10px",
padding: "11px 18px",
fontSize: "11px",
@@ -323,32 +328,34 @@ export default function HistoryView({ data = HISTORY_FIXTURE }: HistoryViewProps
>
{r.date ?? "—"}
-
+ {showAsk && (
- {r.source ?? "—"}
+
+ {r.source ?? "—"}
+
+ {r.ask}
+ {r.delta}
- {r.ask}
- {r.delta}
-
+ )}
))}
diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/ObjectSummary.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/ObjectSummary.tsx
index 1cf4f4b2..5f09f651 100644
--- a/tradein-mvp/frontend/src/components/trade-in/v2/ObjectSummary.tsx
+++ b/tradein-mvp/frontend/src/components/trade-in/v2/ObjectSummary.tsx
@@ -101,7 +101,7 @@ export function ObjectSummary({
background: tokens.success,
}}
/>
- В РАСЧЁТЕ
+ АКТУАЛЬНО
@@ -140,7 +140,9 @@ export function ObjectSummary({
-
{data.object.address}
+
+ {data.object.address}
+
{data.object.city}
diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/ParamsPanel.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/ParamsPanel.tsx
index db330cb1..73042a75 100644
--- a/tradein-mvp/frontend/src/components/trade-in/v2/ParamsPanel.tsx
+++ b/tradein-mvp/frontend/src/components/trade-in/v2/ParamsPanel.tsx
@@ -46,6 +46,7 @@ interface DdProps {
triggerBg?: string;
triggerColor?: string;
caretColor?: string;
+ disabled?: boolean;
}
function Dd({
@@ -63,6 +64,7 @@ function Dd({
triggerBg = tokens.surface.w62,
triggerColor = tokens.ink,
caretColor = tokens.muted2,
+ disabled = false,
}: DdProps) {
const panelStyle: CSSProperties = {
position: "absolute",
@@ -88,7 +90,11 @@ function Dd({
{value}
-
- ▼
-
+ {!disabled && (
+
+ ▼
+
+ )}
{open && (
@@ -149,11 +157,14 @@ const styles = `
.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-trigger-disabled{border:1px dashed ${tokens.line}}
.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-input:focus-visible{outline:none;border-color:${tokens.accent};box-shadow:0 0 0 3px rgba(46,139,255,.18)}
+.pp-eval-btn{background:linear-gradient(90deg,${tokens.accentDeep},${tokens.accent});box-shadow:0 2px 10px rgba(46,139,255,.25);transition:all .18s}
+.pp-eval-btn:hover{background:${tokens.accentDeep};box-shadow:0 6px 22px rgba(46,139,255,.4)}
.pp-eval-btn:active{transform:translateY(1px)}
+.pp-eval-btn:focus-visible{outline:none;box-shadow:0 0 0 3px rgba(46,139,255,.35)}
`;
const hintLabel: CSSProperties = {
@@ -343,7 +354,9 @@ export default function ParamsPanel({
}: ParamsPanelProps) {
const [openDd, setOpenDd] = useState(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.
+ // whole panel). Default 1 (identity → pixel-identical), step .25, clamp 1–2.5
+ // (no zoom-out below 1: there is no real basemap behind the blueprint SVG, so
+ // shrinking would only expose empty corners — zoom-in only).
const [zoom, setZoom] = useState(1);
const [address, setAddress] = useState(initialValues?.address ?? "");
const [area, setArea] = useState(
@@ -378,7 +391,9 @@ export default function ParamsPanel({
// 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 [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
@@ -479,7 +494,9 @@ export default function ParamsPanel({
// 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))))
+ ? Math.round(
+ Math.max(70, Math.min(155, 112 * Math.pow(radiusM / 500, 0.35))),
+ )
: 112;
return (
@@ -544,16 +561,6 @@ export default function ParamsPanel({
ПАРАМЕТРЫ КВАРТИРЫ
-
- ШАГ 1/1
-
{/* MAP */}
@@ -580,97 +587,97 @@ export default function ParamsPanel({
transition: "transform .15s ease-out",
}}
>
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {/* center pin */}
-
-
-
-
- {address.trim() || "Адрес квартиры"}
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* center pin */}
-
+ >
+
+
+
+ {address.trim() || "Адрес квартиры"}
+
+
+
+
- {/* analog price pins — projected from the real estimate via mapMarkers().
+ {/* 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) => (
-
- {m.label}
-
- {m.sub}
-
- ))}
+ {markers.map((m, i) => (
+
+ {m.label}
+
+ {m.sub}
+
+ ))}
{/* map controls */}
@@ -713,38 +720,18 @@ export default function ParamsPanel({
display: "flex",
alignItems: "center",
justifyContent: "center",
- cursor: "pointer",
+ cursor: zoom <= 1 ? "default" : "pointer",
transition: "all .15s",
fontSize: 15,
color: tokens.muted,
+ opacity: zoom <= 1 ? 0.4 : 1,
+ }}
+ onClick={() => {
+ if (zoom > 1) setZoom((z) => Math.max(1, z - 0.25));
}}
- onClick={() => setZoom((z) => Math.max(0.6, z - 0.25))}
>
−
-