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

This commit is contained in:
lekss361 2026-07-04 07:02:56 +00:00
parent 4f0cae5c18
commit 293184ebc6
2 changed files with 597 additions and 488 deletions

View file

@ -411,16 +411,25 @@ export default function TradeInV2Page() {
useEffect(() => setMounted(true), []); useEffect(() => setMounted(true), []);
// Scale-fit: shrink the fixed 1536×1024 artboard to fit smaller viewports // 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); 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(() => { useEffect(() => {
const compute = () => const compute = () => {
setArtboardScale( setArtboardScale(Math.min(1, window.innerWidth / 1536));
Math.max( setIsMobile(window.innerWidth < 768);
0.88, };
Math.min(1, window.innerWidth / 1536, window.innerHeight / 1024),
),
);
compute(); compute();
window.addEventListener("resize", compute); window.addEventListener("resize", compute);
return () => window.removeEventListener("resize", compute); return () => window.removeEventListener("resize", compute);
@ -573,7 +582,8 @@ export default function TradeInV2Page() {
const topNavUser = useMemo(() => { const topNavUser = useMemo(() => {
const scope = me.data; const scope = me.data;
if (!scope) return undefined; 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 = const initials =
nameParts.length >= 2 nameParts.length >= 2
? (nameParts[0][0] + nameParts[1][0]).toUpperCase() ? (nameParts[0][0] + nameParts[1][0]).toUpperCase()
@ -693,7 +703,49 @@ export default function TradeInV2Page() {
} }
return ( 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={{ width: 1536 * artboardScale, height: 1024 * artboardScale }}
>
<div <div
style={{ style={{
position: "relative", position: "relative",
@ -805,12 +857,16 @@ export default function TradeInV2Page() {
inert={nav !== 0 || drawerOpen ? true : undefined} inert={nav !== 0 || drawerOpen ? true : undefined}
style={{ display: "contents" }} 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 <HeroBar
data={{ report, object: objectInfo }} data={{ report, object: objectInfo }}
estimateId={mounted ? currentEstimateId : null} estimateId={mounted ? currentEstimateId : null}
onOpenInfo={() => setDrawerOpen(true)} onOpenInfo={() => setDrawerOpen(true)}
hasEstimate={hasEstimate} hasEstimate={hasEstimate}
/> />
)}
<div <div
style={{ style={{
@ -831,7 +887,9 @@ export default function TradeInV2Page() {
markers={markers} markers={markers}
/> />
{middleContent} {middleContent}
{rightContent} {/* #2275: on mobile ObjectSummary is rendered fluid in the
quick-view block above instead of here (tiny scaled copy). */}
{!isMobile && rightContent}
</div> </div>
</main> </main>
@ -861,5 +919,6 @@ export default function TradeInV2Page() {
/> />
</div> </div>
</div> </div>
</div>
); );
} }

View file

@ -45,6 +45,12 @@ interface HeroBarProps {
// are hidden, mirroring how the PDF control already gates on estimate presence. // are hidden, mirroring how the PDF control already gates on estimate presence.
hasEstimate: boolean; hasEstimate: boolean;
onOpenInfo: () => void; 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 = { const pdfBtnStyle: CSSProperties = {
@ -80,6 +86,7 @@ export default function HeroBar({
estimateId, estimateId,
hasEstimate, hasEstimate,
onOpenInfo, onOpenInfo,
compact = false,
}: HeroBarProps) { }: HeroBarProps) {
const pdfHref = hasEstimate ? pdfDownloadHref(estimateId) : null; const pdfHref = hasEstimate ? pdfDownloadHref(estimateId) : null;
// A downloadable report exists ⇔ the estimate is ready ⇒ the PDF button is the // 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"; const rule = filled ? "rgba(255,255,255,.75)" : "#6f8195";
return ( 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="M2 1h9l5 5v13H2z" stroke={frame} strokeWidth="1.2" />
<path d="M11 1v5h5" 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} /> <line x1="5" y1="11" x2="13" y2="11" stroke={rule} />
@ -110,8 +123,9 @@ export default function HeroBar({
<div <div
style={{ style={{
display: "flex", display: "flex",
gap: 30, flexDirection: compact ? "column" : "row",
padding: "14px 2px 12px", gap: compact ? 16 : 30,
padding: compact ? "0" : "14px 2px 12px",
flex: "0 0 auto", flex: "0 0 auto",
}} }}
> >
@ -134,10 +148,18 @@ export default function HeroBar({
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
justifyContent: "center", justifyContent: "center",
gap: 22, gap: compact ? 14 : 22,
}}
>
<div
style={{
display: "flex",
flexWrap: compact ? "wrap" : "nowrap",
gap: compact ? 18 : 26,
rowGap: compact ? 8 : undefined,
alignItems: "flex-start",
}} }}
> >
<div style={{ display: "flex", gap: 26, alignItems: "flex-start" }}>
<div> <div>
<div <div
style={{ style={{
@ -215,7 +237,15 @@ export default function HeroBar({
)} )}
</div> </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 ? ( {pdfHref ? (
<a <a
className={pdfFilled ? "hero-pdf-btn-filled" : "hero-pdf-btn"} className={pdfFilled ? "hero-pdf-btn-filled" : "hero-pdf-btn"}
@ -265,7 +295,12 @@ export default function HeroBar({
</div> </div>
</div> </div>
{/* RIGHT: photo + overlays */} {/* 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 <div
style={{ style={{
position: "relative", position: "relative",
@ -372,7 +407,11 @@ export default function HeroBar({
</span> </span>
</div> </div>
<div <div
style={{ height: 1, background: tokens.lineSoft, margin: "8px 0" }} style={{
height: 1,
background: tokens.lineSoft,
margin: "8px 0",
}}
/> />
<div <div
style={{ style={{
@ -385,7 +424,11 @@ export default function HeroBar({
{data.object.streetView} {data.object.streetView}
</div> </div>
<div <div
style={{ height: 1, background: tokens.lineSoft, margin: "8px 0" }} style={{
height: 1,
background: tokens.lineSoft,
margin: "8px 0",
}}
/> />
<div <div
// role=button + tabIndex + Enter/Space (#2264 C6): keyboard-operable // role=button + tabIndex + Enter/Space (#2264 C6): keyboard-operable
@ -490,7 +533,13 @@ export default function HeroBar({
color: tokens.accent, color: tokens.accent,
}} }}
> >
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" aria-hidden="true"> <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" /> <circle cx="11" cy="11" r="10" stroke="#2e8bff" strokeWidth="1" />
<path d="M11 3 L13 11 L11 9 L9 11 Z" fill="#2e8bff" /> <path d="M11 3 L13 11 L11 9 L9 11 Z" fill="#2e8bff" />
</svg> </svg>
@ -626,6 +675,7 @@ export default function HeroBar({
}} }}
/> />
</div> </div>
)}
</div> </div>
); );
} }