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>
);
}
+174
View File
@@ -0,0 +1,174 @@
"use client";
import { useState, useEffect } from "react";
interface BlockMeta {
path: string;
name: string;
version: string;
isInPreview: boolean;
}
interface BlockMetaBarProps {
path: string;
defaultVersion: string;
defaultIsInPreview: boolean;
}
export function BlockMetaBar({ path, defaultVersion, defaultIsInPreview }: BlockMetaBarProps) {
const [meta, setMeta] = useState<BlockMeta | null>(null);
const [editing, setEditing] = useState(false);
const [versionInput, setVersionInput] = useState(defaultVersion);
const [saving, setSaving] = useState(false);
const [togglingPreview, setTogglingPreview] = useState(false);
const [apiDown, setApiDown] = useState(false);
const apiUrl = process.env.NEXT_PUBLIC_API_URL;
useEffect(() => {
if (!apiUrl) { setApiDown(true); return; }
fetch(`${apiUrl}/blocks/by-path?path=${encodeURIComponent(path)}`)
.then((r) => r.json())
.then((data: BlockMeta) => {
setMeta(data);
setVersionInput(data.version);
})
.catch(() => setApiDown(true));
}, [apiUrl, path]);
async function patch(data: { version?: string; isInPreview?: boolean }) {
if (!apiUrl) return null;
const r = await fetch(`${apiUrl}/blocks/by-path?path=${encodeURIComponent(path)}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
return r.json() as Promise<BlockMeta>;
}
async function saveVersion() {
if (!meta) return;
setSaving(true);
try {
const updated = await patch({ version: versionInput });
if (updated) { setMeta(updated); setEditing(false); }
} finally {
setSaving(false);
}
}
async function togglePreview() {
if (!meta) return;
setTogglingPreview(true);
try {
const updated = await patch({ isInPreview: !meta.isInPreview });
if (updated) setMeta(updated);
} finally {
setTogglingPreview(false);
}
}
const version = meta?.version ?? defaultVersion;
const isInPreview = meta?.isInPreview ?? defaultIsInPreview;
return (
<div
className="flex items-center flex-wrap gap-3 px-4 py-2 rounded-lg text-xs mb-6"
style={{ background: "var(--bb-sidebar-bg)", border: "1px solid var(--bb-border)" }}
>
{/* Version badge */}
<span className="font-semibold" style={{ color: "var(--bb-text-muted)" }}>
Версия:
</span>
{editing ? (
<span className="flex items-center gap-1.5">
<input
className="px-2 py-0.5 rounded border text-xs font-mono w-20"
style={{ borderColor: "var(--bb-border)", color: "var(--bb-text)" }}
value={versionInput}
onChange={(e) => setVersionInput(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') saveVersion(); if (e.key === 'Escape') setEditing(false); }}
autoFocus
/>
<button
onClick={saveVersion}
disabled={saving}
className="px-2 py-0.5 rounded text-xs font-medium"
style={{ background: "var(--brand-053m)", color: "#fff" }}
>
{saving ? '...' : 'Сохранить'}
</button>
<button
onClick={() => { setEditing(false); setVersionInput(version); }}
className="px-2 py-0.5 rounded text-xs"
style={{ color: "var(--bb-text-muted)" }}
>
Отмена
</button>
</span>
) : (
<button
onClick={() => !apiDown && setEditing(true)}
title={apiDown ? 'API недоступен' : 'Изменить версию'}
className="font-mono px-2 py-0.5 rounded"
style={{
background: "var(--bb-sidebar-active-bg, #dff0fa)",
color: "var(--brand-053m)",
cursor: apiDown ? 'default' : 'pointer',
}}
>
{version}
</button>
)}
<span style={{ color: "var(--bb-border)" }}>·</span>
{/* Preview toggle */}
{apiDown ? (
<span
className="flex items-center gap-1"
style={{ color: isInPreview ? "#16a34a" : "var(--bb-text-muted)" }}
>
<span
className="inline-block w-1.5 h-1.5 rounded-full"
style={{ background: isInPreview ? "#22c55e" : "#d1d5db" }}
/>
{isInPreview ? "В превью" : "Не в превью"}
</span>
) : (
<button
onClick={togglePreview}
disabled={togglingPreview || !meta}
title={isInPreview ? "Убрать из превью страницы" : "Включить в превью страницы"}
className="flex items-center gap-1.5 px-2.5 py-1 rounded font-medium transition-colors"
style={isInPreview ? {
background: "#dcfce7",
color: "#16a34a",
border: "1px solid #86efac",
} : {
background: "var(--bb-sidebar-bg)",
color: "var(--bb-text-muted)",
border: "1px solid var(--bb-border)",
}}
>
<span
className="inline-block w-1.5 h-1.5 rounded-full"
style={{ background: isInPreview ? "#22c55e" : "#d1d5db" }}
/>
{togglingPreview
? '...'
: isInPreview
? "В превью"
: "Добавить в превью"}
</button>
)}
{apiDown && (
<>
<span style={{ color: "var(--bb-border)" }}>·</span>
<span style={{ color: "#f59e0b" }}>API офлайн</span>
</>
)}
</div>
);
}