feat(sprint-5.5): add "Save version" button, update navigation block and block components

- Add "Сохранить версию" button to BlockMetaBar that persists current
  version + changelog from code to PostgreSQL via PATCH API
- Update navigation page: menu items section now renders like live example
  with underlined links, hover dropdowns, and submenus
- Restore uncommitted changes from previous session (thirsty-mayer worktree):
  navigation v1.3 with dropdowns, updated hero/ceo/doctors/reviews/news/
  contact-forms/footer blocks, navData.ts extraction, seed updates
- Extract nav menu data to shared navData.ts module

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
AR 15 M4
2026-03-24 23:28:05 +05:00
parent 3094e9a2b7
commit 196526ffc4
15 changed files with 465 additions and 225 deletions
+4 -4
View File
@@ -20,10 +20,10 @@ export function CeoBlock() {
>
<p>
Клиника ухо, нос специализируется на оториноларингологии лечении взрослых и детей
с ЛОР заболеваниями. ЛОР клиника представлена на двух адресах:{" "}
<a href="#" style={{ color: "#0089c3" }}>ул. Цитная, 9</a>,{" "}
<a href="#" style={{ color: "#0089c3" }}>ул. Г. Звезда, 31а</a>.{" "}
Это <a href="#" style={{ color: "#0089c3" }}>Клиника лечения кашля и аллергии</a>.
с ЛОР заболеваниями. ЛОР клиника ухо, горло, нос в Перми представлена по двум адресам:{" "}
<a href="#" style={{ color: "#52b4bd" }}>ул. Клары Цеткин, 9</a> |{" "}
<a href="#" style={{ color: "#52b4bd" }}>ул. Г. Звезда, 31а</a>.{" "}
Это <a href="#" style={{ color: "#52b4bd" }}>Клиника лечения кашля и аллергии</a>.
</p>
<div style={{ marginTop: 16 }}>
@@ -36,7 +36,7 @@ export function ContactFormsBlock() {
Отправляя данные, я даю согласие на обработку персональных данных
</span>
</label>
<button className="bb-btn bb-btn-md bb-btn-primary w-full">
<button className="bb-btn bb-btn-lg bb-btn-primary w-full">
Запишите меня!
</button>
</div>
@@ -74,7 +74,7 @@ export function ContactFormsBlock() {
Отправляя данные, я даю согласие на обработку персональных данных
</span>
</label>
<button className="bb-btn bb-btn-md bb-btn-primary w-full">
<button className="bb-btn bb-btn-lg bb-btn-primary w-full">
Перезвоните мне
</button>
</div>
+49 -39
View File
@@ -1,24 +1,37 @@
const FOOTER_COLUMNS = [
{
title: "О клинике",
links: ["Лицензия", "Миссия", "Врачи", "Вакансии", "История", "Образовательная деятельность", "При инфо"],
links: [
"Врачи", "Цены", "Контакты", "Новости", "Отзывы", "История",
"Официальная информация", "Оборудование", "Слуховые аппараты",
"ЛОР конференции", "Вакансии", "Клиника кашля и аллергии", "Доверенность",
],
},
{
title: "Заболевания",
links: ["Ринит", "Отит", "Гайморит", "Тонзиллит", "Полипы носа", "Искривление перегородки"],
links: [
"Заболевания уха", "Заболевания горла", "Заболевания носа",
"У детей", "У беременных",
],
},
{
title: "Вопрос-ответ",
links: [
"Что нужно знать до операции на ухо",
"Что нужно знать до операции на нос",
"Отзывы до и после лечения у детей",
"Что нужно знать при лечении у детей",
"Вопросы-ответы по заболеваниям уха",
"Вопросы-ответы по заболеваниям горла",
"Вопросы-ответы по заболеваниям носа",
"Вопросы-ответы по детским заболеваниям",
"Вопросы-ответы по операциям",
"Задать свой вопрос?",
],
},
{
title: "Операции",
links: ["Септопластика", "Турбинопластика", "Тонзиллэктомия", "Аденотомия", "Тимпанопластика", "Мирингопластика"],
links: [
"Аденотомия", "Вазотомия", "Мастоидэктомия", "Мирингопластика",
"Оссикулопластика", "Септопластика", "Стапедопластика",
"Тимпанопластика", "Тонзиллотомия", "Микрогайморотомия", "Полипотомия",
],
},
];
@@ -43,8 +56,8 @@ export function FooterBlock() {
<li key={link}>
<a
href="#"
className="text-xs bb-nav-link"
style={{ color: "#53514e", textDecoration: "none" }}
className="text-xs"
style={{ color: "#52b4bd", textDecoration: "none" }}
>
{link}
</a>
@@ -61,33 +74,23 @@ export function FooterBlock() {
style={{ background: "#fff" }}
>
{/* Логотип */}
<div className="flex items-center gap-2 shrink-0">
<div
className="w-10 h-10 rounded-full flex items-center justify-center text-white font-bold"
style={{ background: "#0089c3" }}
>
</div>
<div>
<div
className="text-[10px] font-bold uppercase leading-tight"
style={{ color: "#53514e" }}
>
Клиника<br />ухо, горло, нос
</div>
<div className="text-[8px] leading-tight" style={{ color: "#9ca3af" }}>
им. проф. Е.Н.Оленевой
</div>
</div>
<div className="flex items-center shrink-0">
<img
src="/logo/logo-main.png"
alt="Клиника ухо, горло, нос"
className="h-12 w-auto"
/>
</div>
{/* Адрес и соцсети */}
{/* Адреса и соцсети */}
<div className="text-center space-y-2">
<p className="text-xs" style={{ color: "#374151" }}>
Мы находимся по адресу: Пермь, ул. Г. Звезда, 31а
</p>
<div className="text-xs space-y-0.5" style={{ color: "#374151" }}>
<p>Мы находимся по адресам:</p>
<p>г. Пермь, ул. Клары Цеткин, 9</p>
<p>г. Пермь, ул. Газеты Звезда, 31А</p>
</div>
<div className="flex items-center justify-center gap-2">
{["VK", "OK", "YT", "TG"].map((s) => (
{["VK", "YT", "TG", "OK", "Дзен"].map((s) => (
<a
key={s}
href="#"
@@ -104,14 +107,21 @@ export function FooterBlock() {
</div>
</div>
{/* Часы работы */}
<div className="text-right text-xs space-y-1" style={{ color: "#374151" }}>
<p className="font-semibold text-xs mb-1" style={{ color: "#111827" }}>
Часы работы:
{/* Часы работы — два филиала */}
<div className="text-right text-xs space-y-2" style={{ color: "#374151" }}>
<p className="font-semibold text-xs" style={{ color: "#111827" }}>
Время работы клиники:
</p>
<p>Пнпт: 9:0021:00</p>
<p>Сб: 9:0018:00</p>
<p>Вс: выходной</p>
<div>
<p className="font-medium" style={{ color: "#111827" }}>Газеты Звезда 31а</p>
<p>пн пт: с 09.00 до 21.00</p>
<p>сб вс: с 09:00 до 19:00</p>
</div>
<div>
<p className="font-medium" style={{ color: "#111827" }}>Клары Цеткин 9</p>
<p>пн сб: с 09.00 до 17.00</p>
<p>вс выходной</p>
</div>
</div>
</div>
</div>
+11 -20
View File
@@ -75,26 +75,17 @@ export function HeroBlock() {
</div>
{/* Под баннером: соцсети + просмотры */}
<div className="flex items-center gap-3 pt-1">
<span className="text-xs" style={{ color: "#9ca3af" }}>
Поделиться:
</span>
{["VK", "FB", "TW"].map((s) => (
<button
key={s}
className="text-xs px-2 py-1 rounded"
style={{
background: "#f9fafb",
border: "1px solid #e5e7eb",
color: "#9ca3af",
}}
>
{s}
</button>
))}
<span className="text-xs ml-2" style={{ color: "#9ca3af" }}>
👁 98 573 просмотра
</span>
<div className="flex items-center gap-2 pt-1">
<button
className="text-xs px-3 py-1.5 rounded"
style={{
background: "#f9fafb",
border: "1px solid #e5e7eb",
color: "#9ca3af",
}}
>
Поделиться 98572
</button>
</div>
</div>
);
+77 -65
View File
@@ -1,81 +1,93 @@
export const NAV_ITEMS = [
"Клиника",
"ЛОР врачи",
"Заболевания",
"Вопрос-ответ",
"ЛОР-операции",
"Сурдология",
"Цены",
"Контакты",
];
"use client";
import { NAV_ITEMS } from "./navData";
export { NAV_ITEMS };
function NavItem({ item, isActive }: { item: (typeof NAV_ITEMS)[number]; isActive: boolean }) {
const hasSubmenu = item.submenu.length > 0;
return (
<div className="bb-nav-item-wrap relative group">
<a
href="#"
className="bb-nav-item px-2.5 py-3 whitespace-nowrap inline-flex items-center gap-1"
style={{
fontSize: 18,
color: "#000",
fontWeight: 400,
textDecoration: "underline",
textUnderlineOffset: "4px",
}}
>
{item.label}
{hasSubmenu && (
<span className="text-xs" style={{ color: "#9ca3af", textDecoration: "none", display: "inline-block" }}></span>
)}
</a>
{hasSubmenu && (
<div
className="absolute left-0 top-full z-50 hidden group-hover:block min-w-[220px] py-1 rounded shadow-lg"
style={{ background: "#fff", border: "1px solid #e5e7eb" }}
>
{item.submenu.map((sub) => (
<a
key={sub}
href="#"
className="block px-4 py-2 text-sm bb-nav-sub-item"
style={{ color: "#333", textDecoration: "none" }}
>
{sub}
</a>
))}
</div>
)}
</div>
);
}
export function NavigationBlock() {
return (
<div
className="overflow-hidden"
style={{ border: "1px solid #e5e7eb", boxShadow: "0 1px 4px rgba(0,0,0,0.06)" }}
>
{/* Топ-бар */}
<div
className="flex items-center justify-between px-6 py-2 text-xs border-b"
style={{ background: "#fff", borderColor: "#e5e7eb", color: "#6b7280" }}
>
<div className="flex items-center gap-4">
<span>📍 Б. Цитная, 3</span>
<a href="#" style={{ color: "#0089c3" }}>
Клиника ухо горло нос и аллергия
<div className="bg-white relative">
{/* Верхняя панель: три столбца — логотип | ссылки | телефон+кнопка */}
<div className="flex items-center px-6 py-4">
{/* Столбец 1: Логотип */}
<div className="shrink-0">
<img
src="/logo/logo-main.png"
alt="Клиника ухо, горло, нос"
className="h-20 w-auto"
/>
</div>
{/* Столбец 2: Три ссылки вертикально — прижат к логотипу */}
<div className="flex flex-col gap-1.5 ml-6">
<a href="#" className="flex items-center gap-1.5 text-sm" style={{ color: "#9ca3af", textDecoration: "none" }}>
<span style={{ color: "#9ca3af" }}>📍</span>
<span style={{ color: "#52b4bd", textDecoration: "underline" }}>К. Цеткин, 9</span>
</a>
<a href="#" style={{ color: "#0089c3" }}>
Центр аллергологии и пульмонологии
<a href="#" className="flex items-center gap-1.5 text-sm" style={{ color: "#9ca3af", textDecoration: "none" }}>
<span style={{ color: "#9ca3af" }}>📍</span>
<span style={{ color: "#52b4bd", textDecoration: "underline" }}>Клиника лечения кашля и аллергии</span>
</a>
<a href="#" className="flex items-center gap-1.5 text-sm" style={{ color: "#9ca3af", textDecoration: "none" }}>
<span style={{ color: "#9ca3af" }}>📍</span>
<span style={{ color: "#52b4bd", textDecoration: "underline" }}>Центр диагностики и реабилитации</span>
</a>
</div>
<div className="flex items-center gap-4">
<span className="font-bold text-sm" style={{ color: "#111827" }}>
{/* Столбец 3: Телефон + кнопка вертикально — прижат вправо */}
<div className="flex flex-col items-end gap-2 shrink-0 ml-auto">
<span className="font-bold" style={{ color: "#000", fontSize: 25 }}>
/342/ 255-53-84
</span>
<button className="bb-btn bb-btn-sm bb-btn-pill">Заказать звонок</button>
</div>
</div>
{/* Логотип */}
<div className="flex items-center px-6 py-3 bg-white">
<div className="flex items-center gap-3">
<div
className="w-10 h-10 rounded-full flex items-center justify-center text-white font-bold text-lg shrink-0"
style={{ background: "#0089c3" }}
>
</div>
<div>
<div
className="text-[11px] font-bold leading-tight uppercase tracking-wide"
style={{ color: "#53514e" }}
>
Клиника<br />ухо, горло, нос
</div>
<div className="text-[9px] leading-tight mt-0.5" style={{ color: "#9ca3af" }}>
им. проф. Е.Н.Оленевой
</div>
</div>
<button className="bb-btn bb-btn-md bb-btn-pill">Заказать звонок</button>
</div>
</div>
{/* Главное меню */}
<nav className="flex border-t bg-white" style={{ borderColor: "#e5e7eb" }}>
<nav className="flex bg-white">
{NAV_ITEMS.map((item, i) => (
<a
key={item}
href="#"
className="px-4 py-3 text-sm whitespace-nowrap"
style={{
color: i === 0 ? "#0089c3" : "#53514e",
borderRight: "1px solid #f3f4f6",
fontWeight: i === 0 ? 500 : 400,
textDecoration: "none",
}}
>
{item}
</a>
<NavItem key={item.label} item={item} isActive={i === 0} />
))}
</nav>
</div>
+34
View File
@@ -0,0 +1,34 @@
export const NAV_ITEMS = [
{
label: "Клиника",
submenu: [
"О клинике", "Официальная информация", "История", "Оборудование",
"Новости", "Отзывы", "ЛОР конференции", "Вакансии", "Доверенность",
"Клиника лечения кашля и аллергии", "Налоговый вычет",
],
},
{ label: "ЛОР врачи", submenu: [] as string[] },
{
label: "Заболевания",
submenu: ["Горло", "Ухо", "Нос", "У детей", "У беременных", "Диагностика"],
},
{
label: "Вопрос-ответ",
submenu: [
"Вопросы по заболеваниям уха", "Вопросы по заболеваниям горла",
"Вопросы по заболеваниям носа", "Вопросы по детским заболеваниям",
"Вопросы по операциям", "Задать свой вопрос?",
],
},
{
label: "ЛОР операции",
submenu: [
"Аденотомия", "Вазотомия", "Мастоидэктомия", "Тимпанопластика 1 типа",
"Тимпанопластика 2 типа", "Оссикулопластика", "Септопластика",
"Стапедопластика", "Тонзиллотомия", "Полипотомия", "Микрогайморотомия",
],
},
{ label: "Сурдология", submenu: [] as string[] },
{ label: "Цены", submenu: [] as string[] },
{ label: "Контакты", submenu: [] as string[] },
];
+41
View File
@@ -37,6 +37,7 @@ export function BlockMetaBar({ path, defaultVersion, defaultIsInPreview, default
const [editing, setEditing] = useState(false);
const [versionInput, setVersionInput] = useState(defaultVersion);
const [saving, setSaving] = useState(false);
const [savingVersion, setSavingVersion] = useState<false | 'saving' | 'done'>(false);
const [togglingPreview, setTogglingPreview] = useState(false);
const [apiDown, setApiDown] = useState(false);
const [localPreview, setLocalPreview] = useState(defaultIsInPreview);
@@ -79,6 +80,22 @@ export function BlockMetaBar({ path, defaultVersion, defaultIsInPreview, default
}
}
async function saveFullVersion() {
if (apiDown) return;
setSavingVersion('saving');
try {
const updated = await patch({ version: defaultVersion, changelog: defaultChangelog });
if (updated) {
setMeta(updated);
setVersionInput(updated.version);
}
setSavingVersion('done');
setTimeout(() => setSavingVersion(false), 1500);
} catch {
setSavingVersion(false);
}
}
async function togglePreview() {
if (apiDown) {
const newVal = !localPreview;
@@ -180,6 +197,30 @@ export function BlockMetaBar({ path, defaultVersion, defaultIsInPreview, default
: "Не в превью"}
</button>
<span style={{ color: "var(--bb-border)" }}>·</span>
{/* Save version button */}
<button
onClick={saveFullVersion}
disabled={apiDown || savingVersion === 'saving'}
title={apiDown ? 'API недоступен' : 'Сохранить текущую версию и changelog в БД'}
className="px-2.5 py-1 rounded font-medium transition-colors"
style={savingVersion === 'done' ? {
background: "#dcfce7",
color: "#16a34a",
border: "1px solid #86efac",
cursor: "default",
} : {
background: "var(--bb-sidebar-bg)",
color: apiDown ? "var(--bb-text-muted)" : "var(--brand-053m)",
border: "1px solid var(--bb-border)",
cursor: apiDown ? "default" : "pointer",
opacity: apiDown ? 0.5 : 1,
}}
>
{savingVersion === 'saving' ? '...' : savingVersion === 'done' ? 'Сохранено' : 'Сохранить версию'}
</button>
{/* Subtle offline dot instead of "API офлайн" text */}
{apiDown && (
<span