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))} > −
-
- - - - - - - -
{/* map corner bracket */} @@ -873,6 +860,7 @@ export default function ParamsPanel({ HUD
panel styling. Picking an item fills the full address and captures lat/lon for the estimate payload. */} handleAddressChange(e.target.value)} @@ -941,12 +929,11 @@ export default function ParamsPanel({
ПЛОЩАДЬ, М²
- setArea(e.target.value.replace(/[^\d.,]/g, "")) - } + onChange={(e) => setArea(e.target.value.replace(/[^\d.,]/g, ""))} onKeyDown={(e) => { if (e.key === "Enter") handleSubmit(); }} @@ -976,6 +963,7 @@ export default function ParamsPanel({ если знаешь
если знаешь
опц.
CRM — ДЛЯ МЕНЕДЖЕРА - опц. + скоро
{/* CRM — non-functional (no backend, #395). Markup kept; open is forced false + no-op handlers so it never expands. TODO(wire). */} @@ -1113,6 +1103,7 @@ export default function ParamsPanel({ triggerBg={tokens.surface.w50} triggerColor={tokens.hint} caretColor={tokens.muted4} + disabled />
@@ -1136,7 +1127,7 @@ export default function ParamsPanel({ cursor: isPending ? "wait" : "pointer", opacity: isPending ? 0.6 : 1, fontFamily: tokens.font.sans, - color: tokens.ink, + color: tokens.onAccent, flex: "0 0 auto", }} > @@ -1152,9 +1143,9 @@ export default function ParamsPanel({ justifyContent: "center", width: 26, height: 26, - border: `1px solid ${tokens.accent}`, + border: "1px solid rgba(255,255,255,.7)", borderRadius: "50%", - color: tokens.accent, + color: tokens.onAccent, }} > → diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/ResultPanel.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/ResultPanel.tsx index e65de293..a5777628 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/ResultPanel.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/ResultPanel.tsx @@ -231,6 +231,19 @@ export default function ResultPanel({ > {card.ppm} + {card.note && ( +
+ {card.note} +
+ )} {card.bars ? (
- ПОСТРОЕНО ПО 6 АНАЛОГАМ И 10 СДЕЛКАМ ЗА 6 МЕСЯЦЕВ + {data.meta.builtOn ?? "—"}
diff --git a/tradein-mvp/frontend/src/components/trade-in/v2/SourcesView.tsx b/tradein-mvp/frontend/src/components/trade-in/v2/SourcesView.tsx index 00d075ee..618ea259 100644 --- a/tradein-mvp/frontend/src/components/trade-in/v2/SourcesView.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/v2/SourcesView.tsx @@ -15,7 +15,6 @@ import type { SourcesData } from "./mappers"; // One-off tints not present in the v2 palette (kept literal, faithful to design). const KPI_BG = "rgba(238,244,250,.5)"; const BADGE_BG = "#eaf1f8"; -const AVITO_DOT = "#e8743b"; const AD_GRID = "2.4fr 1fr 1fr 1.2fr .7fr 1fr"; const DEAL_GRID = "2.6fr 1.2fr 1fr 1.2fr 1fr"; @@ -187,7 +186,9 @@ interface SourcesViewProps { data?: SourcesData; } -export default function SourcesView({ data = SOURCES_FIXTURE }: SourcesViewProps) { +export default function SourcesView({ + data = SOURCES_FIXTURE, +}: SourcesViewProps) { return (