fix(ptica): left-rail — 7 icon+label items + working nav (match reference) #1853
7 changed files with 240 additions and 35 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
<ScanCard
|
||||
card={adaptEconomyCard()}
|
||||
drawerKey="economy"
|
||||
onOpen={onOpenDrawer}
|
||||
/>
|
||||
|
||||
<div
|
||||
id="risks"
|
||||
id="ptica-risks"
|
||||
className={`${styles.card} ${styles.scanCard} ${styles.scanCardCentered}`}
|
||||
>
|
||||
<h3 className={styles.cardTitle}>Риски</h3>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
label: 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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue