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:
@@ -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}>
|
||||
|
||||
Reference in New Issue
Block a user