Merge pull request 'fix(ptica): left-rail — 7 icon+label items + working nav (match reference)' (#1853) from fix/ptica-left-rail into main
All checks were successful
Deploy / deploy (push) Successful in 1m8s
Deploy / build-frontend (push) Successful in 3m13s
Deploy / build-worker (push) Has been skipped
Deploy / changes (push) Successful in 7s
Deploy / build-backend (push) Has been skipped

This commit is contained in:
bot-backend 2026-06-20 19:45:32 +00:00
commit 5fb69b779e
7 changed files with 240 additions and 35 deletions

View file

@ -169,6 +169,25 @@
color: var(--text-muted); color: var(--text-muted);
background: var(--surface); background: var(--surface);
} }
/* Active rail item cyan accent + inset glow + left accent bar (prototype
.nav-item.active / .nav-item.active::before). */
.navItemActive,
.navItem.navItemActive {
color: var(--accent-cyan);
background: var(--surface);
border-color: var(--border);
box-shadow: inset 0 0 22px var(--glow);
}
.navItemActive::before {
content: "";
position: absolute;
left: -10px;
top: 14px;
bottom: 14px;
width: 2px;
background: var(--accent-cyan);
box-shadow: 0 0 10px var(--accent-cyan);
}
.navFoot { .navFoot {
margin-top: auto; margin-top: auto;
display: grid; display: grid;

View file

@ -36,7 +36,7 @@ export function DevelopmentScan({ analysis, onOpenDrawer }: Props) {
return ( return (
<> <>
<div id="scan" className={styles.sectionLabel}> <div className={styles.sectionLabel}>
Development Scan · <b>градостроительный анализ</b> Development Scan · <b>градостроительный анализ</b>
</div> </div>
<section className={styles.scanGrid}> <section className={styles.scanGrid}>
@ -60,16 +60,14 @@ export function DevelopmentScan({ analysis, onOpenDrawer }: Props) {
drawerKey="market" drawerKey="market"
onOpen={onOpenDrawer} onOpen={onOpenDrawer}
/> />
<div id="economy"> <ScanCard
<ScanCard card={adaptEconomyCard()}
card={adaptEconomyCard()} drawerKey="economy"
drawerKey="economy" onOpen={onOpenDrawer}
onOpen={onOpenDrawer} />
/>
</div>
<div <div
id="risks" id="ptica-risks"
className={`${styles.card} ${styles.scanCard} ${styles.scanCardCentered}`} className={`${styles.card} ${styles.scanCard} ${styles.scanCardCentered}`}
> >
<h3 className={styles.cardTitle}>Риски</h3> <h3 className={styles.cardTitle}>Риски</h3>

View file

@ -30,12 +30,12 @@ export function PticaHero({ analysis, forecastReport, onOpenDrawer }: Props) {
const buildGauge = adaptBuildabilityGauge(analysis); const buildGauge = adaptBuildabilityGauge(analysis);
return ( return (
<section id="passport" className={styles.hero}> <section id="ptica-hero" className={styles.hero}>
<PticaMap analysis={analysis} onOpenDrawer={onOpenDrawer} /> <PticaMap analysis={analysis} onOpenDrawer={onOpenDrawer} />
<ParcelPassportCard analysis={analysis} onOpenDrawer={onOpenDrawer} /> <ParcelPassportCard analysis={analysis} onOpenDrawer={onOpenDrawer} />
<div id="potential" className={`${styles.panel} ${styles.scorePanel}`}> <div className={`${styles.panel} ${styles.scorePanel}`}>
<button <button
type="button" type="button"
className={styles.gaugeButton} className={styles.gaugeButton}

View file

@ -2,8 +2,13 @@
/** /**
* PticaShell cockpit chrome: sticky topbar (ПТИЦА wordmark + 4 tab buttons, * PticaShell cockpit chrome: sticky topbar (ПТИЦА wordmark + 4 tab buttons,
* active state driven by props) and a left rail of anchor buttons that * active state driven by props) and a left rail of 7 icon+label nav buttons,
* scrollIntoView the matching hero/scan sections, plus a mono timestamp footer. * plus a mono timestamp footer.
*
* The rail mirrors the prototype (ptica-redesign/app.js RAIL_TARGET): five
* `scroll` items jump to a cockpit section by element id, two `tab` items switch
* the top tab. A `scroll` item first activates the analysis tab (if needed) and
* scrolls on the next frame so the target is mounted before scrollIntoView runs.
* *
* The timestamp is rendered only after mount (useState gated by an effect) to * The timestamp is rendered only after mount (useState gated by an effect) to
* avoid an SSR/client hydration mismatch on the clock value. The effect drives a * avoid an SSR/client hydration mismatch on the clock value. The effect drives a
@ -28,18 +33,175 @@ const TABS: TabDef[] = [
{ id: "compare", label: "Сравнение" }, { id: "compare", label: "Сравнение" },
]; ];
interface RailItem { type RailItem =
anchor: string; | {
label: string; key: string;
} label: string;
icon: React.ReactNode;
kind: "scroll";
target: string;
}
| {
key: string;
label: string;
icon: React.ReactNode;
kind: "tab";
target: PticaTab;
};
const RAIL: RailItem[] = [ const RAIL: RailItem[] = [
{ anchor: "passport", label: "Паспорт" }, {
{ anchor: "potential", label: "Потенциал" }, key: "parcel",
{ anchor: "scan", label: "Скан" }, label: "Участок",
{ anchor: "economy", label: "Экономика" }, kind: "scroll",
{ anchor: "risks", label: "Риски" }, target: "ptica-hero",
{ anchor: "compare-anchor", label: "Сравнение" }, icon: (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle cx="12" cy="12" r="8" stroke="currentColor" strokeWidth="1.5" />
<circle cx="12" cy="12" r="3" stroke="currentColor" strokeWidth="1.5" />
<path
d="M12 2v3M12 19v3M2 12h3M19 12h3"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
),
},
{
key: "potential",
label: "Потенциал",
kind: "scroll",
target: "ptica-potential",
icon: (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M4 16l5-5 4 3 7-8"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M16 6h4v4"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
),
},
{
key: "product",
label: "Продукт",
kind: "scroll",
target: "ptica-product",
icon: (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M12 3l8 4.5v9L12 21l-8-4.5v-9L12 3z"
stroke="currentColor"
strokeWidth="1.5"
strokeLinejoin="round"
/>
<path
d="M4 7.5l8 4.5 8-4.5M12 12v9"
stroke="currentColor"
strokeWidth="1.2"
opacity="0.7"
/>
</svg>
),
},
{
key: "economy",
label: "Экономика",
kind: "scroll",
target: "ptica-economy",
icon: (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M4 20V10M10 20V5M16 20v-7M22 20H2"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
),
},
{
key: "risks",
label: "Риски",
kind: "scroll",
target: "ptica-risks",
icon: (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M12 4l8 14H4L12 4z"
stroke="currentColor"
strokeWidth="1.5"
strokeLinejoin="round"
/>
<path
d="M12 10v4M12 16.5v.5"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
),
},
{
key: "compare",
label: "Сравнение",
kind: "tab",
target: "compare",
icon: (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
<rect
x="3"
y="5"
width="7"
height="14"
rx="1.5"
stroke="currentColor"
strokeWidth="1.5"
/>
<rect
x="14"
y="5"
width="7"
height="14"
rx="1.5"
stroke="currentColor"
strokeWidth="1.5"
/>
</svg>
),
},
{
key: "reports",
label: "Отчёты",
kind: "tab",
target: "reports",
icon: (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M6 3h8l4 4v14H6V3z"
stroke="currentColor"
strokeWidth="1.5"
strokeLinejoin="round"
/>
<path
d="M14 3v4h4M9 13h6M9 16h6"
stroke="currentColor"
strokeWidth="1.2"
strokeLinecap="round"
/>
</svg>
),
},
]; ];
const BirdMark = ({ className }: { className: string }) => ( const BirdMark = ({ className }: { className: string }) => (
@ -59,14 +221,36 @@ interface Props {
children: React.ReactNode; children: React.ReactNode;
} }
function scrollToAnchor(anchor: string) {
if (typeof document === "undefined") return;
const el = document.getElementById(anchor);
el?.scrollIntoView({ behavior: "smooth", block: "start" });
}
export function PticaShell({ activeTab, onTabChange, children }: Props) { export function PticaShell({ activeTab, onTabChange, children }: Props) {
const [clock, setClock] = useState<string | null>(null); const [clock, setClock] = useState<string | null>(null);
// Last clicked scroll target, used to highlight the matching rail item while
// the analysis tab is active. Defaults to the hero (Участок).
const [activeScroll, setActiveScroll] = useState<string>("ptica-hero");
function handleRailClick(item: RailItem) {
if (item.kind === "tab") {
onTabChange(item.target);
return;
}
setActiveScroll(item.target);
if (activeTab !== "analysis") {
onTabChange("analysis");
requestAnimationFrame(() => {
document
.getElementById(item.target)
?.scrollIntoView({ behavior: "smooth", block: "start" });
});
return;
}
document
.getElementById(item.target)
?.scrollIntoView({ behavior: "smooth", block: "start" });
}
function isRailActive(item: RailItem): boolean {
if (item.kind === "tab") return activeTab === item.target;
return activeTab === "analysis" && activeScroll === item.target;
}
useEffect(() => { useEffect(() => {
function tick() { function tick() {
@ -91,11 +275,15 @@ export function PticaShell({ activeTab, onTabChange, children }: Props) {
<nav className={styles.nav} aria-label="Разделы анализа"> <nav className={styles.nav} aria-label="Разделы анализа">
{RAIL.map((item) => ( {RAIL.map((item) => (
<button <button
key={item.anchor} key={item.key}
type="button" type="button"
className={styles.navItem} className={`${styles.navItem} ${
onClick={() => scrollToAnchor(item.anchor)} isRailActive(item) ? styles.navItemActive : ""
}`}
aria-current={isRailActive(item) ? "true" : undefined}
onClick={() => handleRailClick(item)}
> >
{item.icon}
<span>{item.label}</span> <span>{item.label}</span>
</button> </button>
))} ))}

View file

@ -27,7 +27,7 @@ export function PticaBottomGrid({
onOpenDrawer, onOpenDrawer,
}: Props) { }: Props) {
return ( return (
<section className={styles.bottomGrid}> <section id="ptica-economy" className={styles.bottomGrid}>
<InvestmentClearance onOpenDrawer={onOpenDrawer} /> <InvestmentClearance onOpenDrawer={onOpenDrawer} />
<BuySignalCard forecastReport={forecastReport} /> <BuySignalCard forecastReport={forecastReport} />
<LegalStatus analysis={analysis} onOpenDrawer={onOpenDrawer} /> <LegalStatus analysis={analysis} onOpenDrawer={onOpenDrawer} />

View file

@ -28,7 +28,7 @@ export function PticaLowerGrid({
onOpenDrawer, onOpenDrawer,
}: Props) { }: Props) {
return ( return (
<section className={styles.lowerGrid}> <section id="ptica-potential" className={styles.lowerGrid}>
<OksCard onOpenDrawer={onOpenDrawer} /> <OksCard onOpenDrawer={onOpenDrawer} />
<DevelopmentPotentialCard <DevelopmentPotentialCard
analysis={analysis} analysis={analysis}

View file

@ -85,7 +85,7 @@ export function RecommendedProductCard({
: { bars: [], byShare: false }; : { bars: [], byShare: false };
return ( return (
<div className={styles.card}> <div id="ptica-product" className={styles.card}>
<div className={styles.cardHead}> <div className={styles.cardHead}>
<h3 className={styles.cardTitle}> <h3 className={styles.cardTitle}>
Recommended Product Recommended Product