From 2416ed0db1a483ddd9bdf162882ab6812556825b Mon Sep 17 00:00:00 2001 From: lekss361 <47113017+lekss361@users.noreply.github.com> Date: Thu, 14 May 2026 23:55:38 +0300 Subject: [PATCH] refactor(frontend): extract SectionLabel + EmptyState + adminStyles shared (#132) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(docs): block javascript: URLs in renderMarkdown href (XSS guard) Per audit batch #127 P2 security minor (issue #130). ## Risk `renderMarkdown` substituted `[text](url)` → `` verbatim без URL validation. Edge case: `[click](javascript:alert(1))` → `click` execute'нется при click. Currently low risk (markdown source = build-time fs.readFileSync из public/docs/, checked into repo), но защита-в-глубину: если когда-либо markdown source станет user-supplied, vuln materialize. ## Fix `frontend/src/app/docs/b2b-channels/renderMarkdown.ts`: 1. New `safeUrl(url)` function — allowlist `https://`, `http://`, `/`, `#`, `mailto:`. Everything else → `"#"`. 2. Link regex replacement switched from backreference string to callback so URL passes через `safeUrl` ДО injection в href. 3. URL unescape → safeUrl → re-escape (HTML escape applied per-line). ## Test cases verified - `[ok](https://example.com)` → href=`https://example.com` ✅ - `[ok](/local)` → href=`/local` ✅ - `[ok](#anchor)` → href=`#anchor` ✅ - `[ok](mailto:foo@bar.com)` → href=`mailto:foo@bar.com` ✅ - `[bad](javascript:alert(1))` → href=`#` ✅ - `[bad](data:text/html,...)` → href=`#` ✅ - `[bad](vbscript:msgbox(1))` → href=`#` ✅ ## Checks - tsc: 0 errors - lint: 0 warnings - No existing `__tests__/renderMarkdown` to break ## Vault `fixes/Bug_RenderMarkdown_JavascriptUrl_May14.md` — created. Closes #130 * refactor(frontend): SectionLabel + EmptyState + adminStyles shared (fixup) Per audit batch #127 P2 hygiene (issue #129). Previous commit лошил файлы из-за pre-commit (видимо). Fixup-коммит с actual diff: ## New shared modules (3) - frontend/src/components/ui/SectionLabel.tsx - frontend/src/components/ui/EmptyState.tsx - frontend/src/lib/adminStyles.ts ## Replacements - SectionLabel: 12 inline usages → import (Overview/Land/Market/Environment Tab) - EmptyState: 3 inline usages → import (Land/Market/Environment) - adminStyles: 5 admin pages import cardStyle/labelStyle/inputStyle/th/td - BulkGeoPanel: cardStyle only (preserves local labelStyle для span) - leads/page.tsx: cardStyle с marginTop extend; th/td/inputStyle local (divergent) ## Visual regression: ZERO All px/colors/CSS properties copied verbatim. Files с divergent values left local. ## Checks - ruff (no files) - prettier - tsc + lint clean (per agent run) Closes #129 Refs: #127 * docs(adminStyles): JSDoc warning про divergent local overrides Per bot review #132 non-blocking minor — задокументировать почему leads/page.tsx и BulkGeoPanel.tsx имеют local overrides, чтобы будущий читатель не «унифицировал» обратно по ошибке. Refs: #129, #132 --------- Co-authored-by: lekss361 --- frontend/src/app/admin/leads/page.tsx | 10 ++-- frontend/src/app/admin/scrape/all/page.tsx | 30 +---------- frontend/src/app/admin/scrape/geo/page.tsx | 30 +---------- .../src/app/admin/scrape/objective/page.tsx | 30 +---------- frontend/src/app/admin/scrape/page.tsx | 30 +---------- .../src/components/admin/BulkGeoPanel.tsx | 8 +-- .../components/site-finder/EnvironmentTab.tsx | 52 +++--------------- .../src/components/site-finder/LandTab.tsx | 54 +++---------------- .../src/components/site-finder/MarketTab.tsx | 41 +++----------- .../components/site-finder/OverviewTab.tsx | 52 +++--------------- frontend/src/components/ui/EmptyState.tsx | 22 ++++++++ frontend/src/components/ui/SectionLabel.tsx | 23 ++++++++ frontend/src/lib/adminStyles.ts | 49 +++++++++++++++++ 13 files changed, 131 insertions(+), 300 deletions(-) create mode 100644 frontend/src/components/ui/EmptyState.tsx create mode 100644 frontend/src/components/ui/SectionLabel.tsx create mode 100644 frontend/src/lib/adminStyles.ts diff --git a/frontend/src/app/admin/leads/page.tsx b/frontend/src/app/admin/leads/page.tsx index 378409f1..cbdbef25 100644 --- a/frontend/src/app/admin/leads/page.tsx +++ b/frontend/src/app/admin/leads/page.tsx @@ -6,6 +6,9 @@ import { useMemo, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { apiFetch } from "@/lib/api"; +import { cardStyle as cardStyleBase } from "@/lib/adminStyles"; + +const cardStyle = { ...cardStyleBase, marginTop: 16 }; const ReactECharts = dynamic(() => import("echarts-for-react"), { ssr: false }); @@ -717,13 +720,6 @@ function StatusBadge({ status }: { status: string | null }) { ); } -const cardStyle = { - background: "#fff", - border: "1px solid #e6e8ec", - borderRadius: 12, - padding: 20, - marginTop: 16, -}; const inputStyle = { padding: "8px 10px", border: "1px solid #d1d5db", diff --git a/frontend/src/app/admin/scrape/all/page.tsx b/frontend/src/app/admin/scrape/all/page.tsx index c03eb4c5..b4feb6ff 100644 --- a/frontend/src/app/admin/scrape/all/page.tsx +++ b/frontend/src/app/admin/scrape/all/page.tsx @@ -4,6 +4,7 @@ import { useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { apiFetch } from "@/lib/api"; +import { cardStyle, inputStyle, labelStyle, td, th } from "@/lib/adminStyles"; import { BulkGeoPanel } from "@/components/admin/BulkGeoPanel"; import { JobSettingsPanel } from "@/components/admin/JobSettingsPanel"; @@ -498,33 +499,11 @@ function Stat({ ); } -const cardStyle = { - background: "#fff", - border: "1px solid #e6e8ec", - borderRadius: 12, - padding: 20, -}; const sectionTitle = { margin: "0 0 12px", fontSize: 16, fontWeight: 600 as const, }; -const labelStyle = { - display: "block", - fontSize: 12, - color: "#5b6066", - marginBottom: 4, - textTransform: "uppercase" as const, - letterSpacing: 0.4, -}; -const inputStyle = { - padding: "8px 10px", - border: "1px solid #d1d5db", - borderRadius: 6, - fontSize: 14, - width: "100%", - boxSizing: "border-box" as const, -}; const filterTab = { padding: "6px 14px", border: "none", @@ -547,10 +526,3 @@ const tableStyle = { borderCollapse: "collapse" as const, fontSize: 12, }; -const th = { - padding: "8px 10px", - textAlign: "left" as const, - fontWeight: 600 as const, - borderBottom: "1px solid #e6e8ec", -}; -const td = { padding: "8px 10px" }; diff --git a/frontend/src/app/admin/scrape/geo/page.tsx b/frontend/src/app/admin/scrape/geo/page.tsx index ad14c77b..8a495954 100644 --- a/frontend/src/app/admin/scrape/geo/page.tsx +++ b/frontend/src/app/admin/scrape/geo/page.tsx @@ -5,6 +5,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import ScrapeLogsPanel from "@/components/admin/ScrapeLogsPanel"; import { apiFetch } from "@/lib/api"; +import { cardStyle, inputStyle, labelStyle, td, th } from "@/lib/adminStyles"; interface GeoJob { job_id: number; @@ -551,33 +552,11 @@ export default function GeoScrapeAdminPage() { ); } -const cardStyle = { - background: "#fff", - border: "1px solid #e6e8ec", - borderRadius: 12, - padding: 20, -}; const sectionTitle = { margin: "0 0 12px", fontSize: 16, fontWeight: 600 as const, }; -const labelStyle = { - display: "block", - fontSize: 12, - color: "#5b6066", - marginBottom: 4, - textTransform: "uppercase" as const, - letterSpacing: 0.4, -}; -const inputStyle = { - padding: "8px 10px", - border: "1px solid #d1d5db", - borderRadius: 6, - fontSize: 14, - width: "100%", - boxSizing: "border-box" as const, -}; const primaryBtn = { padding: "10px 18px", background: "#1d4ed8", @@ -602,10 +581,3 @@ const tableStyle = { borderCollapse: "collapse" as const, fontSize: 12, }; -const th = { - padding: "8px 10px", - textAlign: "left" as const, - fontWeight: 600 as const, - borderBottom: "1px solid #e6e8ec", -}; -const td = { padding: "8px 10px" }; diff --git a/frontend/src/app/admin/scrape/objective/page.tsx b/frontend/src/app/admin/scrape/objective/page.tsx index 424297bd..8321bbb7 100644 --- a/frontend/src/app/admin/scrape/objective/page.tsx +++ b/frontend/src/app/admin/scrape/objective/page.tsx @@ -5,6 +5,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import ScrapeLogsPanel from "@/components/admin/ScrapeLogsPanel"; import { apiFetch } from "@/lib/api"; +import { cardStyle, inputStyle, labelStyle, td, th } from "@/lib/adminStyles"; interface ObjectiveRunRow { run_id: number; @@ -825,33 +826,11 @@ function Stat({ label, value }: { label: string; value: number }) { // ── Styles ────────────────────────────────────────────────────────────────── -const cardStyle = { - background: "#fff", - border: "1px solid #e6e8ec", - borderRadius: 12, - padding: 20, -}; const sectionTitle = { margin: "0 0 12px", fontSize: 16, fontWeight: 600 as const, }; -const labelStyle = { - display: "block", - fontSize: 12, - color: "#5b6066", - marginBottom: 4, - textTransform: "uppercase" as const, - letterSpacing: 0.4, -}; -const inputStyle = { - padding: "8px 10px", - border: "1px solid #d1d5db", - borderRadius: 6, - fontSize: 14, - width: "100%", - boxSizing: "border-box" as const, -}; const primaryBtn = { padding: "10px 18px", background: "#1d4ed8", @@ -871,10 +850,3 @@ const secondaryBtn = { fontSize: 13, cursor: "pointer", }; -const th = { - padding: "8px 10px", - textAlign: "left" as const, - fontWeight: 600 as const, - borderBottom: "1px solid #e6e8ec", -}; -const td = { padding: "8px 10px" }; diff --git a/frontend/src/app/admin/scrape/page.tsx b/frontend/src/app/admin/scrape/page.tsx index 48189ee2..187e075b 100644 --- a/frontend/src/app/admin/scrape/page.tsx +++ b/frontend/src/app/admin/scrape/page.tsx @@ -4,6 +4,7 @@ import { useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { apiFetch } from "@/lib/api"; +import { cardStyle, inputStyle, labelStyle, td, th } from "@/lib/adminStyles"; interface RunRow { run_id: number; @@ -975,28 +976,6 @@ export default function ScrapeAdminPage() { ); } -const cardStyle = { - background: "#fff", - border: "1px solid #e6e8ec", - borderRadius: 12, - padding: 20, -}; -const labelStyle = { - display: "block", - fontSize: 12, - color: "#5b6066", - marginBottom: 4, - textTransform: "uppercase" as const, - letterSpacing: 0.4, -}; -const inputStyle = { - padding: "8px 10px", - border: "1px solid #d1d5db", - borderRadius: 6, - fontSize: 14, - width: "100%", - boxSizing: "border-box" as const, -}; const primaryBtn = { padding: "10px 18px", background: "#1d4ed8", @@ -1031,10 +1010,3 @@ const queueTable = { fontSize: 13, marginBottom: 4, }; -const th = { - padding: "8px 10px", - textAlign: "left" as const, - fontWeight: 600, - borderBottom: "1px solid #e6e8ec", -}; -const td = { padding: "8px 10px" }; diff --git a/frontend/src/components/admin/BulkGeoPanel.tsx b/frontend/src/components/admin/BulkGeoPanel.tsx index 3304bb52..06195b45 100644 --- a/frontend/src/components/admin/BulkGeoPanel.tsx +++ b/frontend/src/components/admin/BulkGeoPanel.tsx @@ -4,6 +4,7 @@ import { useState } from "react"; import { useMutation } from "@tanstack/react-query"; import { apiFetch } from "@/lib/api"; +import { cardStyle } from "@/lib/adminStyles"; // ── Types ──────────────────────────────────────────────────────────────────── @@ -261,13 +262,6 @@ export function BulkGeoPanel({ token }: { token: string }) { // ── Styles ────────────────────────────────────────────────────────────────── -const cardStyle = { - background: "#fff", - border: "1px solid #e6e8ec", - borderRadius: 12, - padding: 20, -}; - const sectionTitle = { margin: "0 0 8px", fontSize: 16, diff --git a/frontend/src/components/site-finder/EnvironmentTab.tsx b/frontend/src/components/site-finder/EnvironmentTab.tsx index 55ed592d..fd447ed8 100644 --- a/frontend/src/components/site-finder/EnvironmentTab.tsx +++ b/frontend/src/components/site-finder/EnvironmentTab.tsx @@ -7,6 +7,8 @@ import type { ParcelAnalysisWind, ParcelAnalysisWeather, } from "@/types/site-finder"; +import { SectionLabel } from "@/components/ui/SectionLabel"; +import { EmptyState } from "@/components/ui/EmptyState"; import { SeasonalWeatherBlock } from "./SeasonalWeatherBlock"; import { HydrologyBlock } from "./HydrologyBlock"; @@ -424,18 +426,9 @@ export function EnvironmentTab({ data }: Props) { {/* Primary env grid */} {hasEnv && (
-
+ Внешние факторы -
+
-
+ Климат (нормали 30 лет) -
+
)} @@ -499,18 +483,7 @@ export function EnvironmentTab({ data }: Props) { {/* Hydrology */} {data.hydrology !== undefined && (
-
- Гидрология -
+ Гидрология
)} @@ -518,16 +491,7 @@ export function EnvironmentTab({ data }: Props) { {!hasEnv && !("seasonal_weather" in data) && data.hydrology === undefined && ( -
- Данные об окружающей среде недоступны -
+ )}
); diff --git a/frontend/src/components/site-finder/LandTab.tsx b/frontend/src/components/site-finder/LandTab.tsx index 8176c7f4..ddc0fed7 100644 --- a/frontend/src/components/site-finder/LandTab.tsx +++ b/frontend/src/components/site-finder/LandTab.tsx @@ -1,6 +1,8 @@ "use client"; import type { ParcelAnalysis } from "@/types/site-finder"; +import { SectionLabel } from "@/components/ui/SectionLabel"; +import { EmptyState } from "@/components/ui/EmptyState"; import { GeologyBlock } from "./GeologyBlock"; import { GeometrySuitabilityBlock } from "./GeometrySuitabilityBlock"; import { GeotechRiskBlock } from "./GeotechRiskBlock"; @@ -38,18 +40,9 @@ export function LandTab({ data }: Props) { padding: "14px 18px", }} > -
+ Зонирование (ПЗЗ) -
+
Данные ПЗЗ доступны через{" "} -
+ Геотехнический риск -
+
)} @@ -105,34 +89,12 @@ export function LandTab({ data }: Props) { {/* Geology */} {data.geology !== undefined && (
-
- Геология -
+ Геология
)} - {!hasAny && ( -
- Данные о земле и геологии недоступны -
- )} + {!hasAny && } ); } diff --git a/frontend/src/components/site-finder/MarketTab.tsx b/frontend/src/components/site-finder/MarketTab.tsx index b7215d3f..0ce61c46 100644 --- a/frontend/src/components/site-finder/MarketTab.tsx +++ b/frontend/src/components/site-finder/MarketTab.tsx @@ -1,6 +1,8 @@ "use client"; import type { ParcelAnalysis } from "@/types/site-finder"; +import { SectionLabel } from "@/components/ui/SectionLabel"; +import { EmptyState } from "@/components/ui/EmptyState"; import { MarketTrendBlock } from "./MarketTrendBlock"; import { CompetitorTable } from "./CompetitorTable"; import { Pipeline24moBlock } from "./Pipeline24moBlock"; @@ -39,18 +41,7 @@ export function MarketTab({ data }: Props) { {/* Competitors */} {data.competitors.length > 0 && (
-
- Конкуренты -
+ Конкуренты -
+ Что хорошо продаётся -
+
)} - {!hasAny && ( -
- Рыночные данные недоступны -
- )} + {!hasAny && } ); } diff --git a/frontend/src/components/site-finder/OverviewTab.tsx b/frontend/src/components/site-finder/OverviewTab.tsx index eaffd4c4..5fd5b245 100644 --- a/frontend/src/components/site-finder/OverviewTab.tsx +++ b/frontend/src/components/site-finder/OverviewTab.tsx @@ -2,6 +2,7 @@ import type { FeatureCollection } from "geojson"; import type { ParcelAnalysis } from "@/types/site-finder"; +import { SectionLabel } from "@/components/ui/SectionLabel"; import { ConfidenceBadge } from "./ConfidenceBadge"; import { IsochronesPanel } from "./IsochronesPanel"; import { ScoreBreakdownPanel } from "./ScoreBreakdownPanel"; @@ -62,17 +63,7 @@ export function OverviewTab({ data, onIsochronesResult }: Props) { gap: 6, }} > -
- Район -
+ Район
{data.district.district_name}
@@ -138,18 +129,7 @@ export function OverviewTab({ data, onIsochronesResult }: Props) { gap: 4, }} > -
- Балл -
+ Балл
{data.poi_count} POI ·{" "} {data.source === "cad_quarter" ? "квартал" : "участок"} @@ -186,18 +166,9 @@ export function OverviewTab({ data, onIsochronesResult }: Props) { background: "#fff", }} > -
+ POI по категориям -
+ {Object.entries(data.score_breakdown).length === 0 ? (

Нет данных

) : ( @@ -236,18 +207,9 @@ export function OverviewTab({ data, onIsochronesResult }: Props) { background: "#f9fafb", }} > -
+ Изохроны доступности -
+
Недоступны — нужен OpenRouteService API key (см.{" "} diff --git a/frontend/src/components/ui/EmptyState.tsx b/frontend/src/components/ui/EmptyState.tsx new file mode 100644 index 00000000..30eb4549 --- /dev/null +++ b/frontend/src/components/ui/EmptyState.tsx @@ -0,0 +1,22 @@ +import type { CSSProperties } from "react"; + +interface EmptyStateProps { + message: string; + style?: CSSProperties; +} + +export function EmptyState({ message, style }: EmptyStateProps) { + return ( +
+ {message} +
+ ); +} diff --git a/frontend/src/components/ui/SectionLabel.tsx b/frontend/src/components/ui/SectionLabel.tsx new file mode 100644 index 00000000..881fafa9 --- /dev/null +++ b/frontend/src/components/ui/SectionLabel.tsx @@ -0,0 +1,23 @@ +import type { CSSProperties, ReactNode } from "react"; + +interface SectionLabelProps { + children: ReactNode; + style?: CSSProperties; +} + +export function SectionLabel({ children, style }: SectionLabelProps) { + return ( +
+ {children} +
+ ); +} diff --git a/frontend/src/lib/adminStyles.ts b/frontend/src/lib/adminStyles.ts new file mode 100644 index 00000000..da0852c4 --- /dev/null +++ b/frontend/src/lib/adminStyles.ts @@ -0,0 +1,49 @@ +/** + * Shared inline styles для admin pages. + * + * **Divergent local overrides — НЕ унифицировать:** + * - `frontend/src/app/admin/leads/page.tsx` — local `th` (padding "10px 12px", + * color "#374151"), local `td` (verticalAlign "top"), local `inputStyle` + * (fontSize 13, minWidth 160). Cause: разная плотность UI на leads dashboard. + * - `frontend/src/components/admin/BulkGeoPanel.tsx` — local `labelStyle` без + * `display: block` и `marginBottom`. Cause: labels на внутри строки, + * block-display и margin сломали бы inline layout. + * + * При добавлении новой admin page — сначала проверь что shared values fit + * перед introducing local override. + */ +import type { CSSProperties } from "react"; + +export const cardStyle: CSSProperties = { + background: "#fff", + border: "1px solid #e6e8ec", + borderRadius: 12, + padding: 20, +}; + +export const labelStyle: CSSProperties = { + display: "block", + fontSize: 12, + color: "#5b6066", + marginBottom: 4, + textTransform: "uppercase", + letterSpacing: 0.4, +}; + +export const inputStyle: CSSProperties = { + padding: "8px 10px", + border: "1px solid #d1d5db", + borderRadius: 6, + fontSize: 14, + width: "100%", + boxSizing: "border-box", +}; + +export const th: CSSProperties = { + padding: "8px 10px", + textAlign: "left", + fontWeight: 600, + borderBottom: "1px solid #e6e8ec", +}; + +export const td: CSSProperties = { padding: "8px 10px" };