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
+66 -18
View File
@@ -4,6 +4,11 @@ import { useState, useEffect } from "react";
import { NavigationBlock } from "@/components/blocks/NavigationBlock";
import { HeroBlock } from "@/components/blocks/HeroBlock";
import { DoctorsBlock } from "@/components/blocks/DoctorsBlock";
import { CeoBlock } from "@/components/blocks/CeoBlock";
import { ReviewsBlock } from "@/components/blocks/ReviewsBlock";
import { NewsBlock } from "@/components/blocks/NewsBlock";
import { ContactFormsBlock } from "@/components/blocks/ContactFormsBlock";
import { FooterBlock } from "@/components/blocks/FooterBlock";
const STORAGE_KEY = "bb-preview-created";
@@ -30,71 +35,89 @@ function BlockPlaceholder({ name, href }: { name: string; href: string }) {
);
}
const BLOCKS: Array<{
interface BlockDef {
id: string;
name: string;
href: string;
ready: boolean;
path: string;
defaultReady: boolean;
component?: React.ReactNode;
}> = [
}
const BLOCKS: BlockDef[] = [
{
id: "navigation",
name: "Шапка / Навигация",
href: "/components/navigation",
ready: true,
path: "/components/navigation",
defaultReady: true,
component: <NavigationBlock />,
},
{
id: "hero",
name: "Hero-баннер",
href: "/blocks/hero",
ready: true,
path: "/blocks/hero",
defaultReady: true,
component: <HeroBlock />,
},
{
id: "ceo",
name: "Вводный текст (CEO-блок)",
href: "/blocks/ceo",
ready: false,
path: "/blocks/ceo",
defaultReady: false,
component: <CeoBlock />,
},
{
id: "doctors",
name: "Наши врачи",
href: "/blocks/doctors",
ready: true,
path: "/blocks/doctors",
defaultReady: true,
component: <DoctorsBlock />,
},
{
id: "reviews",
name: "Отзывы",
href: "/blocks/reviews",
ready: false,
path: "/blocks/reviews",
defaultReady: false,
component: <ReviewsBlock />,
},
{
id: "contact-forms",
name: "Формы записи",
href: "/blocks/contact-forms",
ready: false,
path: "/blocks/contact-forms",
defaultReady: false,
component: <ContactFormsBlock />,
},
{
id: "news",
name: "Новости",
href: "/blocks/news",
ready: false,
path: "/blocks/news",
defaultReady: false,
component: <NewsBlock />,
},
{
id: "footer",
name: "Подвал / Контакт",
href: "/blocks/contact",
ready: false,
path: "/blocks/contact",
defaultReady: false,
component: <FooterBlock />,
},
];
const READY_COUNT = BLOCKS.filter((b) => b.ready).length;
export function PreviewClient() {
const [created, setCreated] = useState(false);
const [mounted, setMounted] = useState(false);
// Map of path → isInPreview from API; null = not loaded yet
const [apiMeta, setApiMeta] = useState<Record<string, boolean> | null>(null);
const apiUrl = process.env.NEXT_PUBLIC_API_URL;
useEffect(() => {
setMounted(true);
@@ -103,6 +126,27 @@ export function PreviewClient() {
}
}, []);
useEffect(() => {
if (!apiUrl) return;
fetch(`${apiUrl}/blocks`)
.then((r) => r.json())
.then((data: Array<{ path: string; isInPreview: boolean }>) => {
const map: Record<string, boolean> = {};
for (const b of data) map[b.path] = b.isInPreview;
setApiMeta(map);
})
.catch(() => {
// API offline — fall back to defaultReady
});
}, [apiUrl]);
function isReady(block: BlockDef): boolean {
if (apiMeta !== null && block.path in apiMeta) {
return apiMeta[block.path] && !!block.component;
}
return block.defaultReady && !!block.component;
}
function handleCreate() {
localStorage.setItem(STORAGE_KEY, "true");
setCreated(true);
@@ -113,9 +157,10 @@ export function PreviewClient() {
setCreated(false);
}
// Avoid hydration mismatch — render nothing until mounted
if (!mounted) return null;
const readyCount = BLOCKS.filter(isReady).length;
/* ── ПУСТОЕ СОСТОЯНИЕ ── */
if (!created) {
return (
@@ -147,11 +192,11 @@ export function PreviewClient() {
style={{ background: "#22c55e" }}
/>
<span style={{ color: "var(--bb-text-muted)" }}>
Готово блоков: {READY_COUNT} из {BLOCKS.length}
Готово блоков: {readyCount} из {BLOCKS.length}
</span>
<span style={{ color: "var(--bb-text-muted)" }}>·</span>
<span style={{ color: "var(--bb-text-muted)" }}>
{BLOCKS.length - READY_COUNT} плейсхолдеров
{BLOCKS.length - readyCount} плейсхолдеров
</span>
</div>
<div>
@@ -183,7 +228,10 @@ export function PreviewClient() {
Просмотр текущей страницы
</p>
<p className="text-xs mt-0.5" style={{ color: "var(--bb-text-muted)" }}>
perm.oclinica.ru/lor · {READY_COUNT}/{BLOCKS.length} блоков готово
perm.oclinica.ru/lor · {readyCount}/{BLOCKS.length} блоков готово
{apiMeta === null && apiUrl && (
<span style={{ color: "#f59e0b" }}> · API загружается...</span>
)}
</p>
</div>
<button onClick={handleRebuild} className="bb-btn bb-btn-sm bb-btn-outline">
@@ -194,7 +242,7 @@ export function PreviewClient() {
{/* Собранная страница */}
<div className="max-w-5xl mx-auto px-8 py-8 space-y-12">
{BLOCKS.map((block) =>
block.ready && block.component ? (
isReady(block) ? (
<section key={block.id}>{block.component}</section>
) : (
<section key={block.id}>