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/app/docs/b2b-channels/renderMarkdown.ts b/frontend/src/app/docs/b2b-channels/renderMarkdown.ts
index 2074001b..2b7be464 100644
--- a/frontend/src/app/docs/b2b-channels/renderMarkdown.ts
+++ b/frontend/src/app/docs/b2b-channels/renderMarkdown.ts
@@ -13,6 +13,24 @@ function escapeHtml(s: string): string {
.replace(/'/g, "'");
}
+/**
+ * Allow only safe URL schemes in rendered links.
+ * Blocks javascript:, data:, vbscript:, file:, etc.
+ */
+function safeUrl(url: string): string {
+ const trimmed = url.trim();
+ if (
+ trimmed.startsWith("https://") ||
+ trimmed.startsWith("http://") ||
+ trimmed.startsWith("/") ||
+ trimmed.startsWith("#") ||
+ trimmed.startsWith("mailto:")
+ ) {
+ return trimmed;
+ }
+ return "#";
+}
+
function inlineMd(line: string): string {
// escape first, then re-introduce safe markup
let html = escapeHtml(line);
@@ -20,10 +38,21 @@ function inlineMd(line: string): string {
html = html.replace(/`([^`]+)`/g, "$1");
// bold **...**
html = html.replace(/\*\*([^*]+)\*\*/g, "$1");
- // links [text](url)
+ // links [text](url) — URL is validated before being placed in href.
+ // Note: at this point `html` is already HTML-escaped, so we must unescape
+ // the captured URL before passing through safeUrl, then re-escape for href.
html = html.replace(
/\[([^\]]+)\]\(([^)]+)\)/g,
- '$1',
+ (_, text: string, escapedUrl: string) => {
+ const rawUrl = escapedUrl
+ .replace(/&/g, "&")
+ .replace(/</g, "<")
+ .replace(/>/g, ">")
+ .replace(/"/g, '"')
+ .replace(/'/g, "'");
+ const href = escapeHtml(safeUrl(rawUrl));
+ return `${text}`;
+ },
);
// checkboxes
html = html.replace(
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 && (
Нет данных
) : ( @@ -236,18 +207,9 @@ export function OverviewTab({ data, onIsochronesResult }: Props) { background: "#f9fafb", }} > -
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" };