fix(ptica): left-rail — 7 icon+label items + working nav (match reference) #1853

Merged
bot-backend merged 1 commit from fix/ptica-left-rail into main 2026-06-20 19:45:33 +00:00
7 changed files with 240 additions and 35 deletions

View file

@ -169,6 +169,25 @@
color: var(--text-muted);
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 {
margin-top: auto;
display: grid;

View file

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

View file

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

View file

@ -2,8 +2,13 @@
/**
* PticaShell cockpit chrome: sticky topbar (ПТИЦА wordmark + 4 tab buttons,
* active state driven by props) and a left rail of anchor buttons that
* scrollIntoView the matching hero/scan sections, plus a mono timestamp footer.
* active state driven by props) and a left rail of 7 icon+label nav buttons,
* 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
* avoid an SSR/client hydration mismatch on the clock value. The effect drives a
@ -28,18 +33,175 @@ const TABS: TabDef[] = [
{ id: "compare", label: "Сравнение" },
];
interface RailItem {
anchor: string;
type RailItem =
| {
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[] = [
{ anchor: "passport", label: "Паспорт" },
{ anchor: "potential", label: "Потенциал" },
{ anchor: "scan", label: "Скан" },
{ anchor: "economy", label: "Экономика" },
{ anchor: "risks", label: "Риски" },
{ anchor: "compare-anchor", label: "Сравнение" },
{
key: "parcel",
label: "Участок",
kind: "scroll",
target: "ptica-hero",
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 }) => (
@ -59,14 +221,36 @@ interface Props {
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) {
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(() => {
function tick() {
@ -91,11 +275,15 @@ export function PticaShell({ activeTab, onTabChange, children }: Props) {
<nav className={styles.nav} aria-label="Разделы анализа">
{RAIL.map((item) => (
<button
key={item.anchor}
key={item.key}
type="button"
className={styles.navItem}
onClick={() => scrollToAnchor(item.anchor)}
className={`${styles.navItem} ${
isRailActive(item) ? styles.navItemActive : ""
}`}
aria-current={isRailActive(item) ? "true" : undefined}
onClick={() => handleRailClick(item)}
>
{item.icon}
<span>{item.label}</span>
</button>
))}

View file

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

View file

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

View file

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