feat(sprint-5.5): add NestJS API, BlockMetaBar, block components + fix Vercel build

- Add vercel.json to build only apps/web (fix Vercel build failure)
- NestJS API: BlocksModule, BlocksController, BlocksService with Prisma 7
- PostgreSQL migration: Block model (path, version, isInPreview)
- BlockMetaBar component: inline version edit, API fetch with offline fallback
- New block components: CeoBlock, ContactFormsBlock, FooterBlock, NewsBlock, ReviewsBlock
- PreviewClient: fetch isInPreview from API, block visibility toggle
- Pages updated: hero, doctors, ceo, contact-forms, contact, news, reviews
- docker-compose: PostgreSQL on port 5434

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
AR 15 M4
2026-03-24 10:38:12 +05:00
parent c8217cfc2f
commit 2ed7eee63d
33 changed files with 1361 additions and 348 deletions
+45
View File
@@ -0,0 +1,45 @@
export const CEO_QUESTIONS = [
"У вас болит ухо, заложен нос, першит в горле, и вы не можете понять причину?",
"Вам срочно нужен платный ЛОР в Перми или, как ещё говорят, «ухогорлонос»?",
"Заболел ребёнок?",
"Срочно ищете частные ЛОР-клиники Перми для детей 0+ и взрослых с удобным режимом работы с 9:00 до 21:00 по будням?",
"Вам назначили проведение эндоскопической операции на ухе, горле или носе?",
];
export function CeoBlock() {
return (
<div
style={{
background: "#fff",
fontFamily: "var(--font-web, 'Fira Sans', sans-serif)",
fontSize: 14,
lineHeight: 1.75,
color: "#374151",
padding: "40px 48px",
}}
>
<p>
Клиника ухо, нос специализируется на оториноларингологии лечении взрослых и детей
с ЛОР заболеваниями. ЛОР клиника представлена на двух адресах:{" "}
<a href="#" style={{ color: "#0089c3" }}>ул. Цитная, 9</a>,{" "}
<a href="#" style={{ color: "#0089c3" }}>ул. Г. Звезда, 31а</a>.{" "}
Это <a href="#" style={{ color: "#0089c3" }}>Клиника лечения кашля и аллергии</a>.
</p>
<div style={{ marginTop: 16 }}>
{CEO_QUESTIONS.map((q) => (
<p key={q} style={{ marginBottom: 8 }}> {q}</p>
))}
</div>
<p style={{ marginTop: 16 }}>
Обращайтесь в ЛОР центр ухо, горло, нос в Перми, наши врачи оториноларингологи обязательно Вам помогут!
</p>
<p style={{ marginTop: 8 }}>
Клиника ЛОР болезней ухо, горло, нос это наиболее современный центр оториноларингологии в Перми,
благодаря эндоскопическому оборудованию, высокому профессионализму оториноларингологов.
</p>
<p style={{ marginTop: 16, fontWeight: 500 }}>Будьте здоровы!</p>
</div>
);
}
@@ -0,0 +1,84 @@
export function ContactFormsBlock() {
return (
<div className="space-y-8">
{/* Форма 1: Будьте здоровы */}
<div
className="rounded-xl py-10 px-8 flex flex-col items-center gap-6"
style={{ background: "#b8e6ed" }}
>
<div className="text-center">
<h2 className="text-2xl font-bold mb-1" style={{ color: "#111827" }}>
Будьте здоровы!
</h2>
<p className="text-sm" style={{ color: "#374151" }}>
Запишитесь на приём к врачу!
</p>
</div>
<div className="w-full max-w-sm space-y-3">
<input
className="bb-input"
type="text"
placeholder="Введите ваше имя"
readOnly
/>
<input
className="bb-input"
type="tel"
placeholder="Введите ваш телефон"
readOnly
/>
<select className="bb-select" disabled>
<option>Выберите ЛОР врача</option>
</select>
<label className="flex items-start gap-2 text-xs cursor-pointer" style={{ color: "#374151" }}>
<input type="checkbox" className="bb-checkbox mt-0.5" readOnly />
<span>
Отправляя данные, я даю согласие на обработку персональных данных
</span>
</label>
<button className="bb-btn bb-btn-md bb-btn-primary w-full">
Запишите меня!
</button>
</div>
</div>
{/* Форма 2: Узнайте стоимость */}
<div
className="rounded-xl py-10 px-8 flex flex-col items-center gap-6"
style={{ background: "#fff", border: "1px solid var(--bb-border)" }}
>
<div className="text-center">
<h2 className="text-2xl font-bold mb-1" style={{ color: "#111827" }}>
Узнайте стоимость операции
</h2>
<p className="text-sm" style={{ color: "#374151" }}>
Проконсультируйтесь с ассистентом хирурга
</p>
</div>
<div className="w-full max-w-sm space-y-3">
<input
className="bb-input"
type="text"
placeholder="Введите ваше имя"
readOnly
/>
<input
className="bb-input"
type="tel"
placeholder="Введите ваш телефон"
readOnly
/>
<label className="flex items-start gap-2 text-xs cursor-pointer" style={{ color: "#374151" }}>
<input type="checkbox" className="bb-checkbox mt-0.5" readOnly />
<span>
Отправляя данные, я даю согласие на обработку персональных данных
</span>
</label>
<button className="bb-btn bb-btn-md bb-btn-primary w-full">
Перезвоните мне
</button>
</div>
</div>
</div>
);
}
+119
View File
@@ -0,0 +1,119 @@
const FOOTER_COLUMNS = [
{
title: "О клинике",
links: ["Лицензия", "Миссия", "Врачи", "Вакансии", "История", "Образовательная деятельность", "При инфо"],
},
{
title: "Заболевания",
links: ["Ринит", "Отит", "Гайморит", "Тонзиллит", "Полипы носа", "Искривление перегородки"],
},
{
title: "Вопрос-ответ",
links: [
"Что нужно знать до операции на ухо",
"Что нужно знать до операции на нос",
"Отзывы до и после лечения у детей",
"Что нужно знать при лечении у детей",
],
},
{
title: "Операции",
links: ["Септопластика", "Турбинопластика", "Тонзиллэктомия", "Аденотомия", "Тимпанопластика", "Мирингопластика"],
},
];
export function FooterBlock() {
return (
<div
className="rounded-xl overflow-hidden"
style={{ border: "1px solid var(--bb-border)" }}
>
{/* 4 колонки ссылок */}
<div
className="grid grid-cols-4 gap-6 p-8"
style={{ background: "#f8f9fa", borderBottom: "1px solid #e5e7eb" }}
>
{FOOTER_COLUMNS.map((col) => (
<div key={col.title}>
<p className="font-semibold text-sm mb-3" style={{ color: "#111827" }}>
{col.title}
</p>
<ul className="space-y-1.5">
{col.links.map((link) => (
<li key={link}>
<a
href="#"
className="text-xs bb-nav-link"
style={{ color: "#53514e", textDecoration: "none" }}
>
{link}
</a>
</li>
))}
</ul>
</div>
))}
</div>
{/* Нижняя полоса */}
<div
className="flex items-start justify-between gap-6 px-8 py-5"
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>
{/* Адрес и соцсети */}
<div className="text-center space-y-2">
<p className="text-xs" style={{ color: "#374151" }}>
Мы находимся по адресу: Пермь, ул. Г. Звезда, 31а
</p>
<div className="flex items-center justify-center gap-2">
{["VK", "OK", "YT", "TG"].map((s) => (
<a
key={s}
href="#"
className="text-[11px] px-2 py-1 rounded"
style={{
background: "var(--bb-sidebar-bg)",
border: "1px solid var(--bb-border)",
color: "var(--bb-text-muted)",
}}
>
{s}
</a>
))}
</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" }}>
Часы работы:
</p>
<p>Пнпт: 9:0021:00</p>
<p>Сб: 9:0018:00</p>
<p>Вс: выходной</p>
</div>
</div>
</div>
);
}
+63
View File
@@ -0,0 +1,63 @@
const MOCK_NEWS = [
{
date: "20.12.2025",
title: "Наша работа клиники и новогодние праздники",
href: "#",
},
{
date: "11.08.2025",
title: "СЕРВИС ОБНОВЛЕНИЕ: Обновление графика работы клиники",
href: "#",
},
{
date: "12.06.2025",
title: "СЕРВИС ОБНОВЛЕНИЕ: Временное изменение работы клиники 22.06.25 г.",
href: "#",
},
{
date: "11.06.2025",
title: "График работы клиники в ближайшие праздники",
href: "#",
},
];
export function NewsBlock() {
return (
<div
className="rounded-xl p-8 space-y-6"
style={{ background: "#fff", border: "1px solid var(--bb-border)" }}
>
<h2 className="text-xl font-bold" style={{ color: "#111827" }}>
Новости
</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{MOCK_NEWS.map((n) => (
<div
key={n.date}
className="bb-news-card rounded-lg p-3 cursor-pointer"
style={{
border: "1px solid var(--bb-border)",
transition: "background 0.15s, box-shadow 0.15s",
}}
>
<p className="text-xs mb-2" style={{ color: "#6b7280" }}>
{n.date}
</p>
<a
href={n.href}
className="text-sm font-medium leading-snug block"
style={{ color: "#0089c3", textDecoration: "none" }}
>
{n.title}
</a>
</div>
))}
</div>
<div className="text-center">
<button className="bb-btn bb-btn-md bb-btn-outline">
Все новости
</button>
</div>
</div>
);
}
@@ -0,0 +1,86 @@
const MOCK_REVIEWS = [
{
text: "Спасибо за приём, мне всё понравилось, спасибо за приём, мне всё понравилось. Врач очень внимательный и профессиональный.",
author: "Пациент клиники",
doctor: "Тимофеева Наталья Александровна",
},
{
text: "Очень довольна лечением! Прошла курс процедур, нос дышит отлично. Рекомендую клинику всем.",
author: "Наталья К.",
doctor: "Макарова Людмила Тимофеевна",
},
];
export function ReviewsBlock() {
return (
<div
className="rounded-xl p-8 space-y-6"
style={{ background: "#fff", border: "1px solid var(--bb-border)" }}
>
{/* Заголовок */}
<div>
<h2 className="text-xl font-bold mb-2" style={{ color: "#111827" }}>
Отзывы о нас
</h2>
<p className="text-sm" style={{ color: "#374151", lineHeight: 1.7 }}>
За 12 лет работы наши врачи оториноларингологи избавили от болезней ухо, горло, носа
более 50 000 пациентов. Но даже сейчас мы высоко ценим каждый положительный отзыв
и искренние слова благодарности.
</p>
</div>
{/* Карусель */}
<div className="relative flex items-center gap-4">
{/* Стрелка влево */}
<button
className="shrink-0 w-10 h-10 rounded-full flex items-center justify-center font-bold text-lg"
style={{
background: "var(--bb-sidebar-bg)",
border: "1px solid var(--bb-border)",
color: "var(--bb-text-muted)",
}}
>
</button>
{/* Карточка отзыва */}
<div
className="flex-1 rounded-xl p-6"
style={{ background: "#eef4d1" }}
>
<div
className="text-8xl leading-none mb-2 font-serif"
style={{ color: "#b8e6ed", lineHeight: 0.8 }}
>
«
</div>
<p
className="text-sm italic leading-relaxed"
style={{ color: "#374151" }}
>
{MOCK_REVIEWS[0].text}
</p>
<a
href="#"
className="inline-block mt-3 text-sm"
style={{ color: "#0089c3" }}
>
Читать отзыв полностью
</a>
</div>
{/* Стрелка вправо */}
<button
className="shrink-0 w-10 h-10 rounded-full flex items-center justify-center font-bold text-lg"
style={{
background: "var(--bb-sidebar-bg)",
border: "1px solid var(--bb-border)",
color: "var(--bb-text-muted)",
}}
>
</button>
</div>
</div>
);
}