gendesign/tradein-mvp/frontend/src/components/trade-in/v2/HeroBar.tsx
bot-backend 5a2b6d91cd
All checks were successful
CI / changes (pull_request) Successful in 7s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
fix(tradein/v2): audit findings — de-fabricate location drawer, real map markers, validation, hero image, real user (#2036)
Browser audit of the live /trade-in/v2 found honesty + UX defects; fixes:
- 🔴 LocationDrawer: removed FABRICATED coef 0.87 / fake 'base×coef=result' / invented
  POI factors / false 'OpenStreetMap' source. Now honest static 'ПОЯСНЕНИЕ К РАСЧЁТУ'
  (real methodology) + 'коэф. локации в разработке' note. + Escape-to-close.
- 🔴 Map (ParamsPanel): removed static fixture price markers; renders REAL analog
  markers via new mapMarkers(estimate) (projects lat/lon around target, cap 6).
- ParamsPanel: empty площадь/address now shows a validation message instead of
  silently blocking submit; numeric placeholders made clearly placeholders ('напр. 54').
- HeroBar: dead 'КАК РАССЧИТАНО' button now opens the info drawer; building.png next/image
  400 fixed (unoptimized + onError fallback — basePath wasn't applied to the optimizer URL).
- TopNav: real user from useMe() (name/initials/org/email) instead of fixture
  'Андрей Петров'; dead Профиль/Настройки/Помощь dimmed/disabled; Выйти → logout().
- mappers: de-glue analog addresses ('69/2Геологическая'→'69/2 Геологическая', preserves
  house letters like 14А); 'Продажи в доме'→'Продажи рядом' (radius data); торг sign fixed
  (backend (start−last)/start positive=reduction → renders '−3.3%').
- HistoryView: '0 ЛОТА' → correct plural via pluralRu.

next build green; code-reviewer  APPROVE.
2026-06-28 17:26:12 +03:00

545 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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";
import { tokens } from "./tokens";
import { object, report } from "./fixtures";
import type { HeroBarData } from "./mappers";
// Default presentation data (unwired usage): the existing design fixtures.
const HERO_FIXTURE: HeroBarData = { report, object };
// 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 =
/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
// Build + validate the PDF download URL for an estimate id. Returns null when
// there is no estimate yet (→ disabled button), the id is not a UUID, or the
// resolved URL fails the safeUrl scheme allowlist. API_BASE_URL is relative
// ("" in dev, "/trade-in" behind Caddy), so resolve against the document origin
// purely for the safeUrl http/https check; the <a> keeps the relative href
// (the browser resolves it against the current page, like OfferCard's link).
function pdfDownloadHref(estimateId: string | null | undefined): string | null {
if (!estimateId || !PDF_UUID_RE.test(estimateId)) return null;
const href = `${API_BASE_URL}/api/v1/trade-in/estimate/${estimateId}/pdf`;
if (typeof window === "undefined") return href;
return safeUrl(new URL(href, window.location.origin).toString()) ? href : null;
}
interface HeroBarProps {
data?: HeroBarData;
estimateId?: string | null;
onOpenInfo: () => void;
}
const pdfBtnStyle: CSSProperties = {
flex: 1,
height: 50,
background: tokens.surface.w55,
border: `1px solid ${tokens.line}`,
borderRadius: 7,
display: "flex",
alignItems: "center",
gap: 11,
padding: "0 16px",
cursor: "pointer",
fontFamily: tokens.font.sans,
color: tokens.ink,
textDecoration: "none",
transition: "all .15s",
};
export default function HeroBar({
data = HERO_FIXTURE,
estimateId,
onOpenInfo,
}: HeroBarProps) {
const pdfHref = pdfDownloadHref(estimateId);
// Hide the building photo if the asset 404s/400s so the photoBg fill shows
// instead of a broken-image icon.
const [imgFailed, setImgFailed] = useState(false);
const pdfBtnInner = (
<>
<svg width="18" height="20" viewBox="0 0 18 20" fill="none">
<path d="M2 1h9l5 5v13H2z" stroke="#2e8bff" strokeWidth="1.2" />
<path d="M11 1v5h5" stroke="#2e8bff" strokeWidth="1.2" />
<line x1="5" y1="11" x2="13" y2="11" stroke="#6f8195" />
<line x1="5" y1="14" x2="13" y2="14" stroke="#6f8195" />
</svg>
<span style={{ fontSize: 11, fontWeight: 600, letterSpacing: "1px" }}>
СКАЧАТЬ PDF-ОТЧЁТ
</span>
</>
);
return (
<div
style={{
display: "flex",
gap: 30,
padding: "14px 2px 12px",
flex: "0 0 auto",
}}
>
<style>{`
.hero-pdf-btn:hover { border-color: ${tokens.accent}; background: rgba(46,139,255,.07); }
.hero-pdf-btn:active { transform: translateY(1px); }
.hero-calc-btn:hover { border-color: ${tokens.accent}; color: ${tokens.accent}; }
.hero-coef-row:hover { background: rgba(46,139,255,.09); }
@keyframes hero-scanv { 0% { transform: translateY(-100%); } 100% { transform: translateY(900%); } }
`}</style>
{/* LEFT: meta + buttons */}
<div
style={{
flex: 1,
minWidth: 0,
display: "flex",
flexDirection: "column",
justifyContent: "center",
gap: 22,
}}
>
<div style={{ display: "flex", gap: 26, alignItems: "flex-start" }}>
<div>
<div
style={{
fontSize: 9,
letterSpacing: "2px",
color: tokens.muted2,
marginBottom: 6,
}}
>
ОТЧЁТ
</div>
<div
style={{
fontFamily: tokens.font.mono,
fontSize: 14,
color: tokens.ink,
}}
>
{data.report.id}
</div>
</div>
<div
style={{
borderLeft: `1px solid ${tokens.lineSoft}`,
paddingLeft: 26,
}}
>
<div
style={{
fontSize: 9,
letterSpacing: "2px",
color: tokens.muted2,
marginBottom: 6,
}}
>
ДАТА
</div>
<div
style={{
fontFamily: tokens.font.mono,
fontSize: 14,
color: tokens.ink,
}}
>
{data.report.date}
</div>
</div>
<div
style={{
borderLeft: `1px solid ${tokens.lineSoft}`,
paddingLeft: 26,
}}
>
<div
style={{
fontSize: 9,
letterSpacing: "2px",
color: tokens.muted2,
marginBottom: 6,
}}
>
ДЕЙСТВИТЕЛЕН ДО
</div>
<div
style={{
fontFamily: tokens.font.mono,
fontSize: 14,
color: tokens.ink,
}}
>
{data.report.validUntil}
</div>
</div>
</div>
<div style={{ display: "flex", gap: 12, width: "100%", maxWidth: 440 }}>
{pdfHref ? (
<a
className="hero-pdf-btn"
href={pdfHref}
target="_blank"
rel="noopener noreferrer"
style={pdfBtnStyle}
>
{pdfBtnInner}
</a>
) : (
<button
type="button"
disabled
title="Отчёт станет доступен после расчёта оценки"
style={{ ...pdfBtnStyle, cursor: "not-allowed", opacity: 0.45 }}
>
{pdfBtnInner}
</button>
)}
<button
type="button"
className="hero-calc-btn"
onClick={onOpenInfo}
style={{
flex: 1,
height: 50,
background: tokens.surface.w40,
border: `1px solid ${tokens.line}`,
borderRadius: 7,
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
fontFamily: tokens.font.sans,
color: tokens.muted,
fontSize: 11,
fontWeight: 600,
letterSpacing: "1.5px",
transition: "all .15s",
}}
>
КАК РАССЧИТАНО
</button>
</div>
</div>
{/* RIGHT: photo + overlays */}
<div
style={{
position: "relative",
width: 560,
height: 152,
flex: "0 0 auto",
border: `1px solid ${tokens.line3}`,
borderRadius: 6,
overflow: "hidden",
background: tokens.photoBg,
boxShadow: "0 6px 26px rgba(40,80,130,.10)",
}}
>
{!imgFailed && (
<Image
src="/trade-in-v2/building.png"
alt=""
fill
sizes="560px"
unoptimized
onError={() => setImgFailed(true)}
style={{
objectFit: "contain",
objectPosition: "right center",
filter: "saturate(.9) brightness(1.04)",
}}
/>
)}
<div
style={{
position: "absolute",
inset: 0,
background:
"linear-gradient(90deg,rgba(238,244,250,.96) 0%,rgba(238,244,250,.5) 22%,transparent 40%)",
}}
/>
<div
style={{
position: "absolute",
inset: 0,
background:
"linear-gradient(0deg,rgba(230,240,250,.45),transparent 40%)",
}}
/>
<div
style={{
position: "absolute",
left: 0,
right: 0,
top: "34%",
height: 1,
background:
"linear-gradient(90deg,transparent,rgba(46,139,255,.5),transparent)",
animation: "hero-scanv 6s linear infinite",
}}
/>
{/* address card */}
<div
style={{
position: "absolute",
left: 16,
top: "50%",
transform: "translateY(-50%)",
width: 182,
background: tokens.surface.w72,
backdropFilter: "blur(5px)",
border: `1px solid ${tokens.line}`,
borderRadius: 5,
padding: "11px 13px",
}}
>
<div
style={{
fontSize: 9,
letterSpacing: "2.5px",
color: tokens.muted2,
marginBottom: 3,
}}
>
АДРЕС
</div>
<div style={{ fontSize: 14, fontWeight: 600, lineHeight: 1.3 }}>
{data.object.address}
<br />
<span
style={{ fontSize: 11, fontWeight: 400, color: tokens.muted }}
>
{data.object.city}
</span>
</div>
<div
style={{ height: 1, background: tokens.lineSoft, margin: "8px 0" }}
/>
<div
style={{
fontFamily: tokens.font.mono,
fontSize: 9,
color: tokens.muted,
lineHeight: 1.55,
}}
>
{data.object.streetView}
</div>
<div
style={{ height: 1, background: tokens.lineSoft, margin: "8px 0" }}
/>
<div
className="hero-coef-row"
onClick={onOpenInfo}
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
cursor: "pointer",
borderRadius: 4,
margin: "-3px -5px",
padding: "3px 5px",
transition: "background .15s",
}}
>
<span
style={{
fontSize: "8.5px",
letterSpacing: "1.5px",
color: tokens.muted2,
}}
>
КОЭФ. ЛОКАЦИИ
</span>
<span style={{ display: "flex", alignItems: "center", gap: 6 }}>
<span
style={{
fontFamily: tokens.font.mono,
fontSize: 15,
fontWeight: 500,
color: tokens.accent,
}}
>
{data.object.locationCoef}
</span>
<span
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 14,
height: 14,
border: `1px solid ${tokens.accent}`,
borderRadius: "50%",
fontSize: "8.5px",
color: tokens.accent,
fontWeight: 600,
}}
>
?
</span>
</span>
</div>
</div>
{/* compass */}
<div
style={{
position: "absolute",
right: 16,
top: 14,
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 2,
color: tokens.accent,
}}
>
<svg width="22" height="22" viewBox="0 0 22 22" fill="none">
<circle cx="11" cy="11" r="10" stroke="#2e8bff" strokeWidth="1" />
<path d="M11 3 L13 11 L11 9 L9 11 Z" fill="#2e8bff" />
</svg>
<span
style={{
fontFamily: tokens.font.mono,
fontSize: 8,
letterSpacing: ".5px",
color: tokens.muted,
whiteSpace: "nowrap",
}}
>
{data.object.compass}
</span>
</div>
{/* distance scale */}
<div
style={{
position: "absolute",
left: "50%",
transform: "translateX(-50%)",
bottom: 16,
width: 170,
fontFamily: tokens.font.mono,
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "flex-end",
height: 8,
borderBottom: "1px solid rgba(46,139,255,.55)",
}}
>
<span
style={{
width: 1,
height: 8,
background: "rgba(46,139,255,.55)",
}}
/>
<span
style={{
width: 1,
height: 5,
background: "rgba(46,139,255,.45)",
}}
/>
<span
style={{
width: 1,
height: 5,
background: "rgba(46,139,255,.45)",
}}
/>
<span
style={{
width: 1,
height: 5,
background: "rgba(46,139,255,.45)",
}}
/>
<span
style={{
width: 1,
height: 8,
background: "rgba(46,139,255,.55)",
}}
/>
</div>
<div
style={{
display: "flex",
justifyContent: "space-between",
fontSize: "7.5px",
color: tokens.muted,
marginTop: 3,
}}
>
<span>0</span>
<span>25</span>
<span>50</span>
<span>75</span>
<span>100м</span>
</div>
</div>
{/* corner brackets */}
<div
style={{
position: "absolute",
left: 8,
top: 8,
width: 14,
height: 14,
borderLeft: `1.5px solid ${tokens.accent}`,
borderTop: `1.5px solid ${tokens.accent}`,
}}
/>
<div
style={{
position: "absolute",
right: 8,
top: 8,
width: 14,
height: 14,
borderRight: `1.5px solid ${tokens.accent}`,
borderTop: `1.5px solid ${tokens.accent}`,
}}
/>
<div
style={{
position: "absolute",
left: 8,
bottom: 8,
width: 14,
height: 14,
borderLeft: `1.5px solid ${tokens.accent}`,
borderBottom: `1.5px solid ${tokens.accent}`,
}}
/>
<div
style={{
position: "absolute",
right: 8,
bottom: 8,
width: 14,
height: 14,
borderRight: `1.5px solid ${tokens.accent}`,
borderBottom: `1.5px solid ${tokens.accent}`,
}}
/>
</div>
</div>
);
}