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.
545 lines
15 KiB
TypeScript
545 lines
15 KiB
TypeScript
"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>
|
||
);
|
||
}
|