fix(tradein/v2): mobile layout — remove overflow floor + honest stacked mobile block (#2275) (#2389)
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m20s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / deploy (push) Successful in 47s
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m20s
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / deploy (push) Successful in 47s
This commit is contained in:
parent
4f0cae5c18
commit
293184ebc6
2 changed files with 597 additions and 488 deletions
|
|
@ -411,16 +411,25 @@ export default function TradeInV2Page() {
|
|||
useEffect(() => 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.
|
||||
// instead of clipping. Never upscale (capped at 1). #2275: this used to floor
|
||||
// at 0.88, which on a 390px phone still rendered the artboard ~1350px wide —
|
||||
// page-wide horizontal scroll + a hero clipped at the left edge + the summary
|
||||
// rail pushed off-screen. Width is now the only constraint (a scaled artboard
|
||||
// taller than the viewport just scrolls the page vertically, which is normal
|
||||
// and not the bug being fixed here).
|
||||
const [artboardScale, setArtboardScale] = useState(1);
|
||||
// #2275: below this viewport width the fixed-width artboard can no longer
|
||||
// read at a usable size even scaled-to-fit, so HeroBar + ObjectSummary (the
|
||||
// "at a glance" content — report meta, PDF CTA, per-section totals) get a real
|
||||
// fluid mobile block above the artboard instead of a tiny scaled-down copy.
|
||||
// The rest of the dashboard (ParamsPanel / ResultPanel / overlays) stays
|
||||
// inside the scaled artboard — smaller on phones, but no longer overflowing.
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
useEffect(() => {
|
||||
const compute = () =>
|
||||
setArtboardScale(
|
||||
Math.max(
|
||||
0.88,
|
||||
Math.min(1, window.innerWidth / 1536, window.innerHeight / 1024),
|
||||
),
|
||||
);
|
||||
const compute = () => {
|
||||
setArtboardScale(Math.min(1, window.innerWidth / 1536));
|
||||
setIsMobile(window.innerWidth < 768);
|
||||
};
|
||||
compute();
|
||||
window.addEventListener("resize", compute);
|
||||
return () => window.removeEventListener("resize", compute);
|
||||
|
|
@ -573,7 +582,8 @@ export default function TradeInV2Page() {
|
|||
const topNavUser = useMemo(() => {
|
||||
const scope = me.data;
|
||||
if (!scope) return undefined;
|
||||
const nameParts = scope.display_name?.trim().split(/\s+/).filter(Boolean) ?? [];
|
||||
const nameParts =
|
||||
scope.display_name?.trim().split(/\s+/).filter(Boolean) ?? [];
|
||||
const initials =
|
||||
nameParts.length >= 2
|
||||
? (nameParts[0][0] + nameParts[1][0]).toUpperCase()
|
||||
|
|
@ -693,172 +703,221 @@ export default function TradeInV2Page() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div style={{ width: 1536 * artboardScale, height: 1024 * artboardScale }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
{/* #2275 mobile quick-view — a real fluid, native-size block for the "at a
|
||||
glance" content (HeroBar's report meta + PDF CTA, ObjectSummary's
|
||||
per-section totals). Inside the scaled artboard below, these would read
|
||||
as tiny/illegible text on a phone rather than the honest mobile layout
|
||||
the design deserves — so under the mobile breakpoint they render here
|
||||
instead (and are skipped inside the artboard, see `!isMobile` below).
|
||||
The rest of the dashboard (ParamsPanel / ResultPanel / overlays) has no
|
||||
equivalent fluid mobile layout yet (follow-up) and stays inside the
|
||||
scaled artboard: smaller on phones, but — with the 0.88 floor removed
|
||||
above — never wider than the viewport. */}
|
||||
{isMobile && (
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
maxWidth: 480,
|
||||
margin: "0 auto",
|
||||
padding: "14px 14px 20px",
|
||||
boxSizing: "border-box",
|
||||
fontFamily: tokens.font.sans,
|
||||
color: tokens.ink,
|
||||
}}
|
||||
>
|
||||
<HeroBar
|
||||
compact
|
||||
data={{ report, object: objectInfo }}
|
||||
estimateId={mounted ? currentEstimateId : null}
|
||||
onOpenInfo={() => setDrawerOpen(true)}
|
||||
hasEstimate={hasEstimate}
|
||||
/>
|
||||
<div style={{ marginTop: 14 }}>{rightContent}</div>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
width: 1536,
|
||||
height: 1024,
|
||||
overflow: "hidden",
|
||||
background: tokens.gradientBg,
|
||||
fontFamily: tokens.font.sans,
|
||||
color: tokens.ink,
|
||||
transform: `scale(${artboardScale})`,
|
||||
transformOrigin: "top left",
|
||||
}}
|
||||
style={{ width: 1536 * artboardScale, height: 1024 * artboardScale }}
|
||||
>
|
||||
<style>{HELPER_STYLES}</style>
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
width: 1536,
|
||||
height: 1024,
|
||||
overflow: "hidden",
|
||||
background: tokens.gradientBg,
|
||||
fontFamily: tokens.font.sans,
|
||||
color: tokens.ink,
|
||||
transform: `scale(${artboardScale})`,
|
||||
transformOrigin: "top left",
|
||||
}}
|
||||
>
|
||||
<style>{HELPER_STYLES}</style>
|
||||
|
||||
{/* #2264 C5: the page's single visible "title" is the SVG logo in TopNav,
|
||||
{/* #2264 C5: the page's single visible "title" is the SVG logo in TopNav,
|
||||
so give the document a real, visually-hidden <h1> (page-has-heading-one
|
||||
+ a top of the heading order for the overlay/panel <h2>s). */}
|
||||
<h1
|
||||
style={{
|
||||
position: "absolute",
|
||||
width: 1,
|
||||
height: 1,
|
||||
padding: 0,
|
||||
margin: -1,
|
||||
overflow: "hidden",
|
||||
clip: "rect(0 0 0 0)",
|
||||
whiteSpace: "nowrap",
|
||||
border: 0,
|
||||
}}
|
||||
>
|
||||
Мера — оценка квартиры
|
||||
</h1>
|
||||
|
||||
{/* Polite, visually-hidden status announcer for SR users. */}
|
||||
<div
|
||||
aria-live="polite"
|
||||
role="status"
|
||||
style={{
|
||||
position: "absolute",
|
||||
width: 1,
|
||||
height: 1,
|
||||
padding: 0,
|
||||
margin: -1,
|
||||
overflow: "hidden",
|
||||
clip: "rect(0 0 0 0)",
|
||||
whiteSpace: "nowrap",
|
||||
border: 0,
|
||||
}}
|
||||
>
|
||||
{statusMessage}
|
||||
</div>
|
||||
|
||||
{/* OUTER HUD FRAME */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: FRAME_INSET,
|
||||
border: `1px solid ${tokens.line}`,
|
||||
borderRadius: 4,
|
||||
clipPath: FRAME_CLIP,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
/>
|
||||
{/* 4 corner brackets */}
|
||||
{brackets.map((b) => (
|
||||
<div
|
||||
key={b.key}
|
||||
<h1
|
||||
style={{
|
||||
position: "absolute",
|
||||
width: BRACKET_ARM,
|
||||
height: BRACKET_ARM,
|
||||
width: 1,
|
||||
height: 1,
|
||||
padding: 0,
|
||||
margin: -1,
|
||||
overflow: "hidden",
|
||||
clip: "rect(0 0 0 0)",
|
||||
whiteSpace: "nowrap",
|
||||
border: 0,
|
||||
}}
|
||||
>
|
||||
Мера — оценка квартиры
|
||||
</h1>
|
||||
|
||||
{/* Polite, visually-hidden status announcer for SR users. */}
|
||||
<div
|
||||
aria-live="polite"
|
||||
role="status"
|
||||
style={{
|
||||
position: "absolute",
|
||||
width: 1,
|
||||
height: 1,
|
||||
padding: 0,
|
||||
margin: -1,
|
||||
overflow: "hidden",
|
||||
clip: "rect(0 0 0 0)",
|
||||
whiteSpace: "nowrap",
|
||||
border: 0,
|
||||
}}
|
||||
>
|
||||
{statusMessage}
|
||||
</div>
|
||||
|
||||
{/* OUTER HUD FRAME */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: FRAME_INSET,
|
||||
border: `1px solid ${tokens.line}`,
|
||||
borderRadius: 4,
|
||||
clipPath: FRAME_CLIP,
|
||||
pointerEvents: "none",
|
||||
...b.style,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{/* 4 corner brackets */}
|
||||
{brackets.map((b) => (
|
||||
<div
|
||||
key={b.key}
|
||||
style={{
|
||||
position: "absolute",
|
||||
width: BRACKET_ARM,
|
||||
height: BRACKET_ARM,
|
||||
pointerEvents: "none",
|
||||
...b.style,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* CONTENT WRAP */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 14,
|
||||
padding: "16px 28px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
{/* TopNav stays interactive while a section overlay is open so the
|
||||
{/* CONTENT WRAP */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 14,
|
||||
padding: "16px 28px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
{/* TopNav stays interactive while a section overlay is open so the
|
||||
user can switch sections directly from the overlay. Only the
|
||||
LocationDrawer (a full modal over the whole HUD) inerts it. The
|
||||
dashboard body (<main>/<footer>) is still inerted behind ANY
|
||||
overlay/drawer — preserving the focus + click guard from a11y
|
||||
audit #2081 for everything except the top tabs. */}
|
||||
<nav
|
||||
aria-label="Разделы"
|
||||
inert={drawerOpen ? true : undefined}
|
||||
style={{ display: "contents" }}
|
||||
>
|
||||
<TopNav
|
||||
active={nav}
|
||||
onNavigate={setNav}
|
||||
reports={reportsCount ?? 0}
|
||||
user={topNavUser}
|
||||
onLogout={logout}
|
||||
/>
|
||||
</nav>
|
||||
<main
|
||||
inert={nav !== 0 || drawerOpen ? true : undefined}
|
||||
style={{ display: "contents" }}
|
||||
>
|
||||
<HeroBar
|
||||
data={{ report, object: objectInfo }}
|
||||
estimateId={mounted ? currentEstimateId : null}
|
||||
onOpenInfo={() => setDrawerOpen(true)}
|
||||
hasEstimate={hasEstimate}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "474px 1fr 250px",
|
||||
gap: 18,
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
}}
|
||||
<nav
|
||||
aria-label="Разделы"
|
||||
inert={drawerOpen ? true : undefined}
|
||||
style={{ display: "contents" }}
|
||||
>
|
||||
<ParamsPanel
|
||||
key={estimate?.estimate_id ?? "new"}
|
||||
onSubmit={handleSubmit}
|
||||
isPending={mutation.isPending}
|
||||
hasEstimate={hasEstimate}
|
||||
error={apiError}
|
||||
initialValues={initialValues}
|
||||
markers={markers}
|
||||
<TopNav
|
||||
active={nav}
|
||||
onNavigate={setNav}
|
||||
reports={reportsCount ?? 0}
|
||||
user={topNavUser}
|
||||
onLogout={logout}
|
||||
/>
|
||||
{middleContent}
|
||||
{rightContent}
|
||||
</div>
|
||||
</main>
|
||||
</nav>
|
||||
<main
|
||||
inert={nav !== 0 || drawerOpen ? true : undefined}
|
||||
style={{ display: "contents" }}
|
||||
>
|
||||
{/* #2275: on mobile the readable HeroBar lives in the fluid
|
||||
quick-view block above instead of a tiny scaled-down copy. */}
|
||||
{!isMobile && (
|
||||
<HeroBar
|
||||
data={{ report, object: objectInfo }}
|
||||
estimateId={mounted ? currentEstimateId : null}
|
||||
onOpenInfo={() => setDrawerOpen(true)}
|
||||
hasEstimate={hasEstimate}
|
||||
/>
|
||||
)}
|
||||
|
||||
<footer
|
||||
inert={nav !== 0 || drawerOpen ? true : undefined}
|
||||
style={{ display: "contents" }}
|
||||
>
|
||||
<Footer data={report} hasEstimate={hasEstimate} />
|
||||
</footer>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "474px 1fr 250px",
|
||||
gap: 18,
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
}}
|
||||
>
|
||||
<ParamsPanel
|
||||
key={estimate?.estimate_id ?? "new"}
|
||||
onSubmit={handleSubmit}
|
||||
isPending={mutation.isPending}
|
||||
hasEstimate={hasEstimate}
|
||||
error={apiError}
|
||||
initialValues={initialValues}
|
||||
markers={markers}
|
||||
/>
|
||||
{middleContent}
|
||||
{/* #2275: on mobile ObjectSummary is rendered fluid in the
|
||||
quick-view block above instead of here (tiny scaled copy). */}
|
||||
{!isMobile && rightContent}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{nav !== 0 && (
|
||||
<SectionOverlay
|
||||
active={nav}
|
||||
onClose={() => setNav(0)}
|
||||
onNavigate={setNav}
|
||||
history={historyData}
|
||||
analytics={analyticsViewData}
|
||||
sources={sourcesData}
|
||||
cache={cacheData}
|
||||
<footer
|
||||
inert={nav !== 0 || drawerOpen ? true : undefined}
|
||||
style={{ display: "contents" }}
|
||||
>
|
||||
<Footer data={report} hasEstimate={hasEstimate} />
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
{nav !== 0 && (
|
||||
<SectionOverlay
|
||||
active={nav}
|
||||
onClose={() => setNav(0)}
|
||||
onNavigate={setNav}
|
||||
history={historyData}
|
||||
analytics={analyticsViewData}
|
||||
sources={sourcesData}
|
||||
cache={cacheData}
|
||||
/>
|
||||
)}
|
||||
<LocationDrawer
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
data={locationData}
|
||||
/>
|
||||
)}
|
||||
<LocationDrawer
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
data={locationData}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -45,6 +45,12 @@ interface HeroBarProps {
|
|||
// are hidden, mirroring how the PDF control already gates on estimate presence.
|
||||
hasEstimate: boolean;
|
||||
onOpenInfo: () => void;
|
||||
// #2275 mobile quick-view: a real fluid layout instead of the fixed-width
|
||||
// desktop one — meta/buttons stack, the decorative building photo (and the
|
||||
// address/coef card baked into it) is dropped since it assumes a 560×152 box
|
||||
// that cannot reflow. «КАК РАССЧИТАНО» still opens the same location-coef
|
||||
// drawer, so no functionality is lost, only the redundant photo-card copy.
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const pdfBtnStyle: CSSProperties = {
|
||||
|
|
@ -80,6 +86,7 @@ export default function HeroBar({
|
|||
estimateId,
|
||||
hasEstimate,
|
||||
onOpenInfo,
|
||||
compact = false,
|
||||
}: HeroBarProps) {
|
||||
const pdfHref = hasEstimate ? pdfDownloadHref(estimateId) : null;
|
||||
// A downloadable report exists ⇔ the estimate is ready ⇒ the PDF button is the
|
||||
|
|
@ -93,7 +100,13 @@ export default function HeroBar({
|
|||
const rule = filled ? "rgba(255,255,255,.75)" : "#6f8195";
|
||||
return (
|
||||
<>
|
||||
<svg width="18" height="20" viewBox="0 0 18 20" fill="none" aria-hidden="true">
|
||||
<svg
|
||||
width="18"
|
||||
height="20"
|
||||
viewBox="0 0 18 20"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M2 1h9l5 5v13H2z" stroke={frame} strokeWidth="1.2" />
|
||||
<path d="M11 1v5h5" stroke={frame} strokeWidth="1.2" />
|
||||
<line x1="5" y1="11" x2="13" y2="11" stroke={rule} />
|
||||
|
|
@ -110,8 +123,9 @@ export default function HeroBar({
|
|||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 30,
|
||||
padding: "14px 2px 12px",
|
||||
flexDirection: compact ? "column" : "row",
|
||||
gap: compact ? 16 : 30,
|
||||
padding: compact ? "0" : "14px 2px 12px",
|
||||
flex: "0 0 auto",
|
||||
}}
|
||||
>
|
||||
|
|
@ -134,10 +148,18 @@ export default function HeroBar({
|
|||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
gap: 22,
|
||||
gap: compact ? 14 : 22,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", gap: 26, alignItems: "flex-start" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexWrap: compact ? "wrap" : "nowrap",
|
||||
gap: compact ? 18 : 26,
|
||||
rowGap: compact ? 8 : undefined,
|
||||
alignItems: "flex-start",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
|
|
@ -215,7 +237,15 @@ export default function HeroBar({
|
|||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: 12, width: "100%", maxWidth: 440 }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: compact ? "column" : "row",
|
||||
gap: 12,
|
||||
width: "100%",
|
||||
maxWidth: compact ? "none" : 440,
|
||||
}}
|
||||
>
|
||||
{pdfHref ? (
|
||||
<a
|
||||
className={pdfFilled ? "hero-pdf-btn-filled" : "hero-pdf-btn"}
|
||||
|
|
@ -265,367 +295,387 @@ export default function HeroBar({
|
|||
</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 && (
|
||||
<img
|
||||
src={`${BP}/trade-in-v2/building.png`}
|
||||
alt=""
|
||||
onError={() => setImgFailed(true)}
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "contain",
|
||||
objectPosition: "right center",
|
||||
filter: "saturate(.25) brightness(1.06) contrast(.95)",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* blue duotone tint toward HUD accent — recedes the photo (only over
|
||||
the real image; skip when it 404s so the placeholder stays clean) */}
|
||||
{!imgFailed && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
background: "rgba(46,139,255,.14)",
|
||||
mixBlendMode: "multiply",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* RIGHT: photo + overlays — dropped in compact mode (#2275): the box is
|
||||
a fixed 560×152 with several absolutely-positioned children (address
|
||||
card, compass, distance scale) pinned to that size, so it cannot
|
||||
reflow to a phone width. «КАК РАССЧИТАНО» above still opens the same
|
||||
location-coef drawer, so no functionality is lost. */}
|
||||
{!compact && (
|
||||
<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",
|
||||
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)",
|
||||
}}
|
||||
>
|
||||
<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
|
||||
// role=button + tabIndex + Enter/Space (#2264 C6): keyboard-operable
|
||||
// without switching the element to a <button> (which, with preflight
|
||||
// off, would need a UA-chrome reset and risk the row's exact box
|
||||
// geometry / negative-margin hover bleed). Opens the methodology drawer.
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="hero-coef-row"
|
||||
onClick={onOpenInfo}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onOpenInfo();
|
||||
}
|
||||
}}
|
||||
aria-label="Пояснение к расчёту коэффициента локации"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
cursor: "pointer",
|
||||
borderRadius: 4,
|
||||
margin: "-3px -5px",
|
||||
padding: "3px 5px",
|
||||
transition: "background .15s",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
{!imgFailed && (
|
||||
<img
|
||||
src={`${BP}/trade-in-v2/building.png`}
|
||||
alt=""
|
||||
onError={() => setImgFailed(true)}
|
||||
style={{
|
||||
fontSize: "8.5px",
|
||||
letterSpacing: "1.5px",
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: "contain",
|
||||
objectPosition: "right center",
|
||||
filter: "saturate(.25) brightness(1.06) contrast(.95)",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* blue duotone tint toward HUD accent — recedes the photo (only over
|
||||
the real image; skip when it 404s so the placeholder stays clean) */}
|
||||
{!imgFailed && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
background: "rgba(46,139,255,.14)",
|
||||
mixBlendMode: "multiply",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<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,
|
||||
}}
|
||||
>
|
||||
КОЭФ. ЛОКАЦИИ
|
||||
</span>
|
||||
<span style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
||||
{data.object.locationCoef === "—" ? (
|
||||
<span
|
||||
style={{
|
||||
fontSize: "9px",
|
||||
letterSpacing: ".5px",
|
||||
// body2 (not muted2): муть на badgeTint давала 2.3:1 < AA;
|
||||
// body2 на badgeTint ≈ 6.3:1 — читаемо, остаётся «приглушённым».
|
||||
color: tokens.body2,
|
||||
background: tokens.badgeTint,
|
||||
border: `1px solid ${tokens.line2}`,
|
||||
borderRadius: 10,
|
||||
padding: "1px 8px",
|
||||
}}
|
||||
>
|
||||
{/* #2317: coef is a live feature now (GET /location-coef) — a
|
||||
АДРЕС
|
||||
</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
|
||||
// role=button + tabIndex + Enter/Space (#2264 C6): keyboard-operable
|
||||
// without switching the element to a <button> (which, with preflight
|
||||
// off, would need a UA-chrome reset and risk the row's exact box
|
||||
// geometry / negative-margin hover bleed). Opens the methodology drawer.
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="hero-coef-row"
|
||||
onClick={onOpenInfo}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onOpenInfo();
|
||||
}
|
||||
}}
|
||||
aria-label="Пояснение к расчёту коэффициента локации"
|
||||
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 }}>
|
||||
{data.object.locationCoef === "—" ? (
|
||||
<span
|
||||
style={{
|
||||
fontSize: "9px",
|
||||
letterSpacing: ".5px",
|
||||
// body2 (not muted2): муть на badgeTint давала 2.3:1 < AA;
|
||||
// body2 на badgeTint ≈ 6.3:1 — читаемо, остаётся «приглушённым».
|
||||
color: tokens.body2,
|
||||
background: tokens.badgeTint,
|
||||
border: `1px solid ${tokens.line2}`,
|
||||
borderRadius: 10,
|
||||
padding: "1px 8px",
|
||||
}}
|
||||
>
|
||||
{/* #2317: coef is a live feature now (GET /location-coef) — a
|
||||
dash here means unavailable/loading for THIS estimate
|
||||
(unavailable geo_source, no lat/lon, or query pending),
|
||||
never "not built yet", so «скоро» would be stale/false. */}
|
||||
нет данных
|
||||
</span>
|
||||
) : (
|
||||
нет данных
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
style={{
|
||||
fontFamily: tokens.font.mono,
|
||||
fontSize: 15,
|
||||
fontWeight: 500,
|
||||
color: tokens.accent,
|
||||
}}
|
||||
>
|
||||
{data.object.locationCoef}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
style={{
|
||||
fontFamily: tokens.font.mono,
|
||||
fontSize: 15,
|
||||
fontWeight: 500,
|
||||
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,
|
||||
}}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{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,
|
||||
}}
|
||||
aria-hidden="true"
|
||||
>
|
||||
?
|
||||
</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"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<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>
|
||||
</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" aria-hidden="true">
|
||||
<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
|
||||
{/* distance scale */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
bottom: 16,
|
||||
width: 170,
|
||||
fontFamily: tokens.font.mono,
|
||||
fontSize: 8,
|
||||
letterSpacing: ".5px",
|
||||
color: tokens.muted,
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{data.object.compass}
|
||||
</span>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
{/* distance scale */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
bottom: 16,
|
||||
width: 170,
|
||||
fontFamily: tokens.font.mono,
|
||||
}}
|
||||
>
|
||||
{/* corner brackets */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "flex-end",
|
||||
height: 8,
|
||||
borderBottom: "1px solid rgba(46,139,255,.55)",
|
||||
position: "absolute",
|
||||
left: 8,
|
||||
top: 8,
|
||||
width: 14,
|
||||
height: 14,
|
||||
borderLeft: `1.5px solid ${tokens.accent}`,
|
||||
borderTop: `1.5px solid ${tokens.accent}`,
|
||||
}}
|
||||
>
|
||||
<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,
|
||||
position: "absolute",
|
||||
right: 8,
|
||||
top: 8,
|
||||
width: 14,
|
||||
height: 14,
|
||||
borderRight: `1.5px solid ${tokens.accent}`,
|
||||
borderTop: `1.5px solid ${tokens.accent}`,
|
||||
}}
|
||||
>
|
||||
<span>0</span>
|
||||
<span>25</span>
|
||||
<span>50</span>
|
||||
<span>75</span>
|
||||
<span>100м</span>
|
||||
</div>
|
||||
/>
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue