Compare commits
8 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
3094e9a2b7 | 1 week ago |
|
|
e20d222183 | 1 week ago |
|
|
36d5faf67d | 1 week ago |
|
|
950799193a | 1 week ago |
|
|
2ed7eee63d | 1 week ago |
|
|
c8217cfc2f | 1 week ago |
|
|
be37ae2508 | 1 week ago |
|
|
72829b5d46 | 1 week ago |
43 changed files with 2266 additions and 674 deletions
@ -0,0 +1,53 @@ |
|||||||
|
-- CreateEnum |
||||||
|
CREATE TYPE "Role" AS ENUM ('viewer', 'editor'); |
||||||
|
|
||||||
|
-- CreateEnum |
||||||
|
CREATE TYPE "ComponentStatus" AS ENUM ('draft', 'review', 'approved'); |
||||||
|
|
||||||
|
-- CreateTable |
||||||
|
CREATE TABLE "User" ( |
||||||
|
"id" TEXT NOT NULL, |
||||||
|
"email" TEXT NOT NULL, |
||||||
|
"name" TEXT NOT NULL, |
||||||
|
"passwordHash" TEXT NOT NULL, |
||||||
|
"role" "Role" NOT NULL DEFAULT 'viewer', |
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, |
||||||
|
|
||||||
|
CONSTRAINT "User_pkey" PRIMARY KEY ("id") |
||||||
|
); |
||||||
|
|
||||||
|
-- CreateTable |
||||||
|
CREATE TABLE "ExperimentalComponent" ( |
||||||
|
"id" TEXT NOT NULL, |
||||||
|
"name" TEXT NOT NULL, |
||||||
|
"baseComponent" TEXT NOT NULL, |
||||||
|
"attributes" JSONB NOT NULL, |
||||||
|
"status" "ComponentStatus" NOT NULL DEFAULT 'draft', |
||||||
|
"authorId" TEXT NOT NULL, |
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, |
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL, |
||||||
|
|
||||||
|
CONSTRAINT "ExperimentalComponent_pkey" PRIMARY KEY ("id") |
||||||
|
); |
||||||
|
|
||||||
|
-- CreateTable |
||||||
|
CREATE TABLE "Block" ( |
||||||
|
"id" TEXT NOT NULL, |
||||||
|
"path" TEXT NOT NULL, |
||||||
|
"name" TEXT NOT NULL, |
||||||
|
"version" TEXT NOT NULL, |
||||||
|
"isInPreview" BOOLEAN NOT NULL DEFAULT false, |
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL, |
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, |
||||||
|
|
||||||
|
CONSTRAINT "Block_pkey" PRIMARY KEY ("id") |
||||||
|
); |
||||||
|
|
||||||
|
-- CreateIndex |
||||||
|
CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); |
||||||
|
|
||||||
|
-- CreateIndex |
||||||
|
CREATE UNIQUE INDEX "Block_path_key" ON "Block"("path"); |
||||||
|
|
||||||
|
-- AddForeignKey |
||||||
|
ALTER TABLE "ExperimentalComponent" ADD CONSTRAINT "ExperimentalComponent_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; |
||||||
@ -0,0 +1,2 @@ |
|||||||
|
-- AlterTable |
||||||
|
ALTER TABLE "Block" ADD COLUMN "changelog" JSONB NOT NULL DEFAULT '[]'; |
||||||
@ -0,0 +1,3 @@ |
|||||||
|
# Please do not edit this file manually |
||||||
|
# It should be added in your version-control system (e.g., Git) |
||||||
|
provider = "postgresql" |
||||||
@ -0,0 +1,134 @@ |
|||||||
|
import 'dotenv/config'; |
||||||
|
import { PrismaPg } from '@prisma/adapter-pg'; |
||||||
|
import { PrismaClient } from '@prisma/client'; |
||||||
|
|
||||||
|
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL }); |
||||||
|
const prisma = new PrismaClient({ adapter }); |
||||||
|
|
||||||
|
const BLOCKS = [ |
||||||
|
{ |
||||||
|
path: '/blocks/navigation', |
||||||
|
name: 'Шапка / Навигация', |
||||||
|
version: 'v1.0', |
||||||
|
isInPreview: true, |
||||||
|
changelog: [ |
||||||
|
{ version: 'v1.0', date: '23.03.2026', changes: ['Топ-бар, логотип, главное меню из 8 пунктов'] }, |
||||||
|
], |
||||||
|
}, |
||||||
|
{ |
||||||
|
path: '/blocks/hero', |
||||||
|
name: 'Hero-баннер', |
||||||
|
version: 'v1.2', |
||||||
|
isInPreview: true, |
||||||
|
changelog: [ |
||||||
|
{ version: 'v1.2', date: '24.03.2026', changes: [ |
||||||
|
'H1: цвет #cb9768, размер 36px (было ~20px #53514e)', |
||||||
|
'Заголовок баннера: 22px #333 (было 16px #111827)', |
||||||
|
'CTA-кнопка: pill-стиль (было outline)', |
||||||
|
'Дефис в H1: «–» → «-»', |
||||||
|
]}, |
||||||
|
{ version: 'v1.1', date: '23.03.2026', changes: [ |
||||||
|
'Единый фон #f9f4e7 (ранее разбит на две зоны)', |
||||||
|
'Реальное фото врача с пациентом', |
||||||
|
]}, |
||||||
|
], |
||||||
|
}, |
||||||
|
{ |
||||||
|
path: '/blocks/ceo', |
||||||
|
name: 'Вводный текст (CEO-блок)', |
||||||
|
version: 'v1.0', |
||||||
|
isInPreview: false, |
||||||
|
changelog: [ |
||||||
|
{ version: 'v1.0', date: '23.03.2026', changes: ['Текст специализации клиники, вопросы-стимулы'] }, |
||||||
|
], |
||||||
|
}, |
||||||
|
{ |
||||||
|
path: '/blocks/doctors', |
||||||
|
name: 'Наши врачи', |
||||||
|
version: 'v1.2', |
||||||
|
isInPreview: true, |
||||||
|
changelog: [ |
||||||
|
{ version: 'v1.2', date: '24.03.2026', changes: [ |
||||||
|
'H2: 36px #000000 (было ~30px #111827)', |
||||||
|
'line-height: 38px', |
||||||
|
]}, |
||||||
|
{ version: 'v1.1', date: '23.03.2026', changes: [ |
||||||
|
'6 реальных фото врачей с сайта', |
||||||
|
'Статистика без фона, только border-bottom #60959c', |
||||||
|
]}, |
||||||
|
], |
||||||
|
}, |
||||||
|
{ |
||||||
|
path: '/blocks/reviews', |
||||||
|
name: 'Отзывы', |
||||||
|
version: 'v1.1', |
||||||
|
isInPreview: true, |
||||||
|
changelog: [ |
||||||
|
{ version: 'v1.1', date: '24.03.2026', changes: [ |
||||||
|
'H2: 36px #000000 (было ~20px #111827)', |
||||||
|
'line-height: 38px', |
||||||
|
]}, |
||||||
|
{ version: 'v1.0', date: '23.03.2026', changes: [ |
||||||
|
'Карусель отзывов: кавычка, текст, стрелки', |
||||||
|
]}, |
||||||
|
], |
||||||
|
}, |
||||||
|
{ |
||||||
|
path: '/blocks/contact-forms', |
||||||
|
name: 'Формы записи', |
||||||
|
version: 'v1.1', |
||||||
|
isInPreview: true, |
||||||
|
changelog: [ |
||||||
|
{ version: 'v1.1', date: '24.03.2026', changes: [ |
||||||
|
'H2: 36px #000000', |
||||||
|
'Фон формы 1: #b8e6ed → #d4f6f8', |
||||||
|
'Фон формы 2: #ffffff → #d4f6f8 (обе формы на одном фоне)', |
||||||
|
]}, |
||||||
|
{ version: 'v1.0', date: '23.03.2026', changes: [ |
||||||
|
'Две формы записи: «Будьте здоровы!» и «Узнайте стоимость операции»', |
||||||
|
]}, |
||||||
|
], |
||||||
|
}, |
||||||
|
{ |
||||||
|
path: '/blocks/news', |
||||||
|
name: 'Новости', |
||||||
|
version: 'v1.1', |
||||||
|
isInPreview: true, |
||||||
|
changelog: [ |
||||||
|
{ version: 'v1.1', date: '24.03.2026', changes: [ |
||||||
|
'H2: 36px #000000', |
||||||
|
'Фон секции: #fff → #f2fee6 (светло-зелёный)', |
||||||
|
]}, |
||||||
|
{ version: 'v1.0', date: '23.03.2026', changes: [ |
||||||
|
'4 карточки новостей в ряд, кнопка «Все новости»', |
||||||
|
]}, |
||||||
|
], |
||||||
|
}, |
||||||
|
{ |
||||||
|
path: '/blocks/contact', |
||||||
|
name: 'Подвал / Контакт', |
||||||
|
version: 'v1.0', |
||||||
|
isInPreview: false, |
||||||
|
changelog: [ |
||||||
|
{ version: 'v1.0', date: '23.03.2026', changes: ['4 колонки ссылок, адрес, часы работы'] }, |
||||||
|
], |
||||||
|
}, |
||||||
|
]; |
||||||
|
|
||||||
|
async function main() { |
||||||
|
for (const block of BLOCKS) { |
||||||
|
await prisma.block.upsert({ |
||||||
|
where: { path: block.path }, |
||||||
|
update: { version: block.version, isInPreview: block.isInPreview, changelog: block.changelog }, |
||||||
|
create: block, |
||||||
|
}); |
||||||
|
} |
||||||
|
console.log(`Seeded ${BLOCKS.length} blocks`); |
||||||
|
} |
||||||
|
|
||||||
|
main() |
||||||
|
.catch((e) => { |
||||||
|
console.error(e); |
||||||
|
process.exit(1); |
||||||
|
}) |
||||||
|
.finally(() => prisma.$disconnect()); |
||||||
@ -0,0 +1,25 @@ |
|||||||
|
import { Controller, Get, Patch, Query, Body } from '@nestjs/common'; |
||||||
|
import { BlocksService } from './blocks.service'; |
||||||
|
|
||||||
|
@Controller('blocks') |
||||||
|
export class BlocksController { |
||||||
|
constructor(private blocks: BlocksService) {} |
||||||
|
|
||||||
|
@Get() |
||||||
|
findAll() { |
||||||
|
return this.blocks.findAll(); |
||||||
|
} |
||||||
|
|
||||||
|
@Get('by-path') |
||||||
|
findByPath(@Query('path') path: string) { |
||||||
|
return this.blocks.findByPath(path); |
||||||
|
} |
||||||
|
|
||||||
|
@Patch('by-path') |
||||||
|
update( |
||||||
|
@Query('path') path: string, |
||||||
|
@Body() body: { version?: string; isInPreview?: boolean; changelog?: object[] }, |
||||||
|
) { |
||||||
|
return this.blocks.update(path, body); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,10 @@ |
|||||||
|
import { Module } from '@nestjs/common'; |
||||||
|
import { BlocksController } from './blocks.controller'; |
||||||
|
import { BlocksService } from './blocks.service'; |
||||||
|
import { PrismaService } from '../prisma/prisma.service'; |
||||||
|
|
||||||
|
@Module({ |
||||||
|
controllers: [BlocksController], |
||||||
|
providers: [BlocksService, PrismaService], |
||||||
|
}) |
||||||
|
export class BlocksModule {} |
||||||
@ -0,0 +1,23 @@ |
|||||||
|
import { Injectable } from '@nestjs/common'; |
||||||
|
import { PrismaService } from '../prisma/prisma.service'; |
||||||
|
|
||||||
|
@Injectable() |
||||||
|
export class BlocksService { |
||||||
|
constructor(private prisma: PrismaService) {} |
||||||
|
|
||||||
|
findAll() { |
||||||
|
return this.prisma.block.findMany({ orderBy: { createdAt: 'asc' } }); |
||||||
|
} |
||||||
|
|
||||||
|
async findByPath(path: string) { |
||||||
|
const existing = await this.prisma.block.findUnique({ where: { path } }); |
||||||
|
if (existing) return existing; |
||||||
|
return this.prisma.block.create({ |
||||||
|
data: { path, name: path, version: 'v0.1', isInPreview: false }, |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
update(path: string, data: { version?: string; isInPreview?: boolean; changelog?: object[] }) { |
||||||
|
return this.prisma.block.update({ where: { path }, data }); |
||||||
|
} |
||||||
|
} |
||||||
@ -1,8 +1,10 @@ |
|||||||
|
import 'dotenv/config'; |
||||||
import { NestFactory } from '@nestjs/core'; |
import { NestFactory } from '@nestjs/core'; |
||||||
import { AppModule } from './app.module'; |
import { AppModule } from './app.module'; |
||||||
|
|
||||||
async function bootstrap() { |
async function bootstrap() { |
||||||
const app = await NestFactory.create(AppModule); |
const app = await NestFactory.create(AppModule); |
||||||
|
app.enableCors({ origin: [/^http:\/\/localhost:\d+$/] }); |
||||||
await app.listen(process.env.PORT ?? 3000); |
await app.listen(process.env.PORT ?? 3000); |
||||||
} |
} |
||||||
bootstrap(); |
bootstrap(); |
||||||
|
|||||||
@ -0,0 +1,19 @@ |
|||||||
|
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; |
||||||
|
import { PrismaPg } from '@prisma/adapter-pg'; |
||||||
|
import { PrismaClient } from '@prisma/client'; |
||||||
|
|
||||||
|
@Injectable() |
||||||
|
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy { |
||||||
|
constructor() { |
||||||
|
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL }); |
||||||
|
super({ adapter }); |
||||||
|
} |
||||||
|
|
||||||
|
async onModuleInit() { |
||||||
|
await this.$connect(); |
||||||
|
} |
||||||
|
|
||||||
|
async onModuleDestroy() { |
||||||
|
await this.$disconnect(); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,270 @@ |
|||||||
|
"use client"; |
||||||
|
|
||||||
|
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"; |
||||||
|
const LS_PREFIX = "bb-block-preview:"; |
||||||
|
|
||||||
|
function BlockPlaceholder({ name, href }: { name: string; href: string }) { |
||||||
|
return ( |
||||||
|
<div |
||||||
|
className="w-full py-14 flex flex-col items-center justify-center rounded-xl gap-2" |
||||||
|
style={{ |
||||||
|
border: "2px dashed #d1d5db", |
||||||
|
background: "#f9fafb", |
||||||
|
}} |
||||||
|
> |
||||||
|
<p className="text-sm font-medium" style={{ color: "#6b7280" }}> |
||||||
|
{name} |
||||||
|
</p> |
||||||
|
<a |
||||||
|
href={href} |
||||||
|
className="text-xs underline" |
||||||
|
style={{ color: "var(--brand-053m)" }} |
||||||
|
> |
||||||
|
Открыть в брендбуке → |
||||||
|
</a> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
interface BlockDef { |
||||||
|
id: string; |
||||||
|
name: string; |
||||||
|
href: string; |
||||||
|
path: string; |
||||||
|
defaultReady: boolean; |
||||||
|
component?: React.ReactNode; |
||||||
|
} |
||||||
|
|
||||||
|
const BLOCKS: BlockDef[] = [ |
||||||
|
{ |
||||||
|
id: "navigation", |
||||||
|
name: "Шапка / Навигация", |
||||||
|
href: "/blocks/navigation", |
||||||
|
path: "/blocks/navigation", |
||||||
|
defaultReady: true, |
||||||
|
component: <NavigationBlock />, |
||||||
|
}, |
||||||
|
{ |
||||||
|
id: "hero", |
||||||
|
name: "Hero-баннер", |
||||||
|
href: "/blocks/hero", |
||||||
|
path: "/blocks/hero", |
||||||
|
defaultReady: true, |
||||||
|
component: <HeroBlock />, |
||||||
|
}, |
||||||
|
{ |
||||||
|
id: "ceo", |
||||||
|
name: "Вводный текст (CEO-блок)", |
||||||
|
href: "/blocks/ceo", |
||||||
|
path: "/blocks/ceo", |
||||||
|
defaultReady: false, |
||||||
|
component: <CeoBlock />, |
||||||
|
}, |
||||||
|
{ |
||||||
|
id: "doctors", |
||||||
|
name: "Наши врачи", |
||||||
|
href: "/blocks/doctors", |
||||||
|
path: "/blocks/doctors", |
||||||
|
defaultReady: true, |
||||||
|
component: <DoctorsBlock />, |
||||||
|
}, |
||||||
|
{ |
||||||
|
id: "reviews", |
||||||
|
name: "Отзывы", |
||||||
|
href: "/blocks/reviews", |
||||||
|
path: "/blocks/reviews", |
||||||
|
defaultReady: false, |
||||||
|
component: <ReviewsBlock />, |
||||||
|
}, |
||||||
|
{ |
||||||
|
id: "contact-forms", |
||||||
|
name: "Формы записи", |
||||||
|
href: "/blocks/contact-forms", |
||||||
|
path: "/blocks/contact-forms", |
||||||
|
defaultReady: false, |
||||||
|
component: <ContactFormsBlock />, |
||||||
|
}, |
||||||
|
{ |
||||||
|
id: "news", |
||||||
|
name: "Новости", |
||||||
|
href: "/blocks/news", |
||||||
|
path: "/blocks/news", |
||||||
|
defaultReady: false, |
||||||
|
component: <NewsBlock />, |
||||||
|
}, |
||||||
|
{ |
||||||
|
id: "footer", |
||||||
|
name: "Подвал / Контакт", |
||||||
|
href: "/blocks/contact", |
||||||
|
path: "/blocks/contact", |
||||||
|
defaultReady: false, |
||||||
|
component: <FooterBlock />, |
||||||
|
}, |
||||||
|
]; |
||||||
|
|
||||||
|
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; |
||||||
|
|
||||||
|
// Counter to force re-render when localStorage preview toggles change
|
||||||
|
const [, setRefresh] = useState(0); |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
setMounted(true); |
||||||
|
if (localStorage.getItem(STORAGE_KEY) === "true") { |
||||||
|
setCreated(true); |
||||||
|
} |
||||||
|
const handler = () => setRefresh((n) => n + 1); |
||||||
|
window.addEventListener("bb-preview-change", handler); |
||||||
|
return () => window.removeEventListener("bb-preview-change", handler); |
||||||
|
}, []); |
||||||
|
|
||||||
|
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 { |
||||||
|
// 1. API online → use API data
|
||||||
|
if (apiMeta !== null && block.path in apiMeta) { |
||||||
|
return apiMeta[block.path] && !!block.component; |
||||||
|
} |
||||||
|
// 2. Check localStorage (set by BlockMetaBar toggle)
|
||||||
|
const lsVal = typeof window !== "undefined" ? localStorage.getItem(LS_PREFIX + block.path) : null; |
||||||
|
if (lsVal !== null) { |
||||||
|
return lsVal === "true" && !!block.component; |
||||||
|
} |
||||||
|
// 3. Fallback to defaultReady
|
||||||
|
return block.defaultReady && !!block.component; |
||||||
|
} |
||||||
|
|
||||||
|
function handleCreate() { |
||||||
|
localStorage.setItem(STORAGE_KEY, "true"); |
||||||
|
setCreated(true); |
||||||
|
} |
||||||
|
|
||||||
|
function handleRebuild() { |
||||||
|
localStorage.removeItem(STORAGE_KEY); |
||||||
|
setCreated(false); |
||||||
|
} |
||||||
|
|
||||||
|
if (!mounted) return null; |
||||||
|
|
||||||
|
const readyCount = BLOCKS.filter(isReady).length; |
||||||
|
|
||||||
|
/* ── ПУСТОЕ СОСТОЯНИЕ ── */ |
||||||
|
if (!created) { |
||||||
|
return ( |
||||||
|
<div |
||||||
|
className="flex flex-col items-center justify-center min-h-screen" |
||||||
|
style={{ background: "var(--bb-bg)" }} |
||||||
|
> |
||||||
|
<div className="text-center max-w-md space-y-5 p-8"> |
||||||
|
<p |
||||||
|
className="text-xs font-semibold uppercase tracking-widest" |
||||||
|
style={{ color: "var(--brand-053m)" }} |
||||||
|
> |
||||||
|
Страницы |
||||||
|
</p> |
||||||
|
<h1 className="text-2xl font-bold" style={{ color: "var(--bb-text)" }}> |
||||||
|
Просмотр текущей страницы |
||||||
|
</h1> |
||||||
|
<p className="text-sm" style={{ color: "var(--bb-text-muted)", lineHeight: 1.7 }}> |
||||||
|
Нажмите «Создать», чтобы собрать главную страницу{" "} |
||||||
|
<span className="font-mono text-xs">perm.oclinica.ru/lor</span> из блоков, |
||||||
|
задокументированных в брендбуке. |
||||||
|
</p> |
||||||
|
<div |
||||||
|
className="flex items-center justify-center gap-2 text-xs px-4 py-2 rounded-lg" |
||||||
|
style={{ background: "var(--bb-sidebar-bg)", border: "1px solid var(--bb-border)" }} |
||||||
|
> |
||||||
|
<span |
||||||
|
className="inline-block w-2 h-2 rounded-full" |
||||||
|
style={{ background: "#22c55e" }} |
||||||
|
/> |
||||||
|
<span style={{ color: "var(--bb-text-muted)" }}> |
||||||
|
Готово блоков: {readyCount} из {BLOCKS.length} |
||||||
|
</span> |
||||||
|
<span style={{ color: "var(--bb-text-muted)" }}>·</span> |
||||||
|
<span style={{ color: "var(--bb-text-muted)" }}> |
||||||
|
{BLOCKS.length - readyCount} плейсхолдеров |
||||||
|
</span> |
||||||
|
</div> |
||||||
|
<div> |
||||||
|
<button onClick={handleCreate} className="bb-btn bb-btn-md bb-btn-primary"> |
||||||
|
Создать |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
/* ── СОЗДАННОЕ СОСТОЯНИЕ ── */ |
||||||
|
return ( |
||||||
|
<div> |
||||||
|
{/* Топ-бар */} |
||||||
|
<div |
||||||
|
className="sticky top-0 z-10 flex items-center justify-between px-6 py-3 border-b" |
||||||
|
style={{ |
||||||
|
background: "var(--bb-sidebar-bg)", |
||||||
|
borderColor: "var(--bb-border)", |
||||||
|
}} |
||||||
|
> |
||||||
|
<div> |
||||||
|
<p |
||||||
|
className="text-xs font-semibold uppercase tracking-widest" |
||||||
|
style={{ color: "var(--brand-053m)" }} |
||||||
|
> |
||||||
|
Просмотр текущей страницы |
||||||
|
</p> |
||||||
|
<p className="text-xs mt-0.5" style={{ color: "var(--bb-text-muted)" }}> |
||||||
|
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"> |
||||||
|
Пересобрать |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
|
||||||
|
{/* Собранная страница */} |
||||||
|
<div className="max-w-5xl mx-auto px-8 py-8 space-y-12"> |
||||||
|
{BLOCKS.map((block) => |
||||||
|
isReady(block) ? ( |
||||||
|
<section key={block.id}>{block.component}</section> |
||||||
|
) : ( |
||||||
|
<section key={block.id}> |
||||||
|
<BlockPlaceholder name={block.name} href={block.href} /> |
||||||
|
</section> |
||||||
|
) |
||||||
|
)} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
@ -0,0 +1,11 @@ |
|||||||
|
import type { Metadata } from "next"; |
||||||
|
import { PreviewClient } from "./PreviewClient"; |
||||||
|
|
||||||
|
export const metadata: Metadata = { |
||||||
|
title: |
||||||
|
"Просмотр текущей страницы. Цифровой брендбук Клиники ухо, горло, нос им. проф. Е.Н.Оленевой", |
||||||
|
}; |
||||||
|
|
||||||
|
export default function PreviewPage() { |
||||||
|
return <PreviewClient />; |
||||||
|
} |
||||||
@ -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: "#d4f6f8" }} |
||||||
|
> |
||||||
|
<div className="text-center"> |
||||||
|
<h2 className="font-bold mb-1" style={{ color: "#000000", fontSize: 36, lineHeight: "38px" }}> |
||||||
|
Будьте здоровы! |
||||||
|
</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: "#d4f6f8" }} |
||||||
|
> |
||||||
|
<div className="text-center"> |
||||||
|
<h2 className="font-bold mb-1" style={{ color: "#000000", fontSize: 36, lineHeight: "38px" }}> |
||||||
|
Узнайте стоимость операции |
||||||
|
</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> |
||||||
|
); |
||||||
|
} |
||||||
@ -0,0 +1,95 @@ |
|||||||
|
export const STATS = [ |
||||||
|
{ num: "27", label: "ЛОР врачей работает в клинике", prefix: "Ежедневно" }, |
||||||
|
{ num: "6", label: "кандидатов медицинских наук", prefix: "В том числе" }, |
||||||
|
{ num: "12 000+", label: "успешно проведённых операций", prefix: "Свыше" }, |
||||||
|
]; |
||||||
|
|
||||||
|
export const DOCTORS = [ |
||||||
|
{ |
||||||
|
name: "Макарова Людмила Германовна", |
||||||
|
spec: "ЛОР врач, сурдолог", |
||||||
|
photo: "/doctors/makarova.jpg", |
||||||
|
}, |
||||||
|
{ |
||||||
|
name: "Семерикова Наталия Александровна", |
||||||
|
spec: "ЛОР врач, сурдолог, хирург. К.М.Н. Завед. Центром сурдологии", |
||||||
|
photo: "/doctors/semerikova.png", |
||||||
|
}, |
||||||
|
{ |
||||||
|
name: "Ворончихина Наталия Валерьевна", |
||||||
|
spec: "Отоневролог, хирург. К.М.Н., доцент кафедры ПГМУ", |
||||||
|
photo: "/doctors/voronchikhina.png", |
||||||
|
}, |
||||||
|
{ |
||||||
|
name: "Лобанова Ирина Юрьевна", |
||||||
|
spec: "ЛОР врач, сурдолог", |
||||||
|
photo: "/doctors/lobanova.jpg", |
||||||
|
}, |
||||||
|
{ |
||||||
|
name: "Торсунова Наталья Сергеевна", |
||||||
|
spec: "Специалист по слухопротезированию (сурдоакустик)", |
||||||
|
photo: "/doctors/torsunova.jpg", |
||||||
|
}, |
||||||
|
{ |
||||||
|
name: "Суворова Светлана Викторовна", |
||||||
|
spec: "ЛОР врач, сурдолог", |
||||||
|
photo: "/doctors/suvorova.jpg", |
||||||
|
}, |
||||||
|
]; |
||||||
|
|
||||||
|
export function DoctorsBlock() { |
||||||
|
return ( |
||||||
|
<div className="space-y-8"> |
||||||
|
{/* Заголовок + описание */} |
||||||
|
<div> |
||||||
|
<h2 className="font-bold mb-3" style={{ color: "#000000", fontSize: 36, lineHeight: "38px" }}> |
||||||
|
Приём ведут опытные ЛОР врачи |
||||||
|
</h2> |
||||||
|
<p className="text-sm" style={{ color: "#374151", lineHeight: 1.7 }}> |
||||||
|
Фундаментальная теоретическая подготовка и большой практический опыт в сочетании |
||||||
|
с внимательным индивидуальным подходом являются причиной успеха лечения тысяч наших пациентов |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
|
||||||
|
{/* Статистика — без фона, border-bottom #60959c */} |
||||||
|
<div className="grid grid-cols-3 gap-6"> |
||||||
|
{STATS.map((s) => ( |
||||||
|
<div key={s.num} className="pb-3" style={{ borderBottom: "3px solid #60959c" }}> |
||||||
|
<p className="text-lg font-bold leading-snug" style={{ color: "#60959c" }}> |
||||||
|
{s.prefix} {s.num} {s.label} |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
))} |
||||||
|
</div> |
||||||
|
|
||||||
|
{/* Сетка врачей — 6 колонок */} |
||||||
|
<div className="grid grid-cols-6 gap-3"> |
||||||
|
{DOCTORS.map((doc) => ( |
||||||
|
<div key={doc.name} className="flex flex-col items-center text-center gap-1.5"> |
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */} |
||||||
|
<img |
||||||
|
src={doc.photo} |
||||||
|
alt={doc.name} |
||||||
|
style={{ |
||||||
|
width: 110, |
||||||
|
height: 150, |
||||||
|
objectFit: "cover", |
||||||
|
objectPosition: "center top", |
||||||
|
borderRadius: 4, |
||||||
|
display: "block", |
||||||
|
}} |
||||||
|
/> |
||||||
|
<div> |
||||||
|
<p className="text-xs font-medium leading-snug" style={{ color: "#60959c" }}> |
||||||
|
{doc.name} |
||||||
|
</p> |
||||||
|
<p className="text-[11px] mt-0.5 leading-snug" style={{ color: "#374151" }}> |
||||||
|
{doc.spec} |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
))} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
@ -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:00–21:00</p> |
||||||
|
<p>Сб: 9:00–18:00</p> |
||||||
|
<p>Вс: выходной</p> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
@ -0,0 +1,101 @@ |
|||||||
|
export const HERO_CHECKS = [ |
||||||
|
{ key: "БЕЗОПАСНО", desc: "оперируют хирурги с 15-летним опытом работы" }, |
||||||
|
{ key: "БЕЗ ВНЕШНИХ РАЗРЕЗОВ", desc: "хирургия сверхмалых размеров" }, |
||||||
|
{ key: "БЫСТРО", desc: "под наблюдением врача пациент находится 1 сутки" }, |
||||||
|
]; |
||||||
|
|
||||||
|
export function HeroBlock() { |
||||||
|
return ( |
||||||
|
<div className="space-y-3"> |
||||||
|
{/* H1 страницы */} |
||||||
|
<h2 |
||||||
|
className="font-bold" |
||||||
|
style={{ color: "#cb9768", fontSize: 36, lineHeight: "51px" }} |
||||||
|
> |
||||||
|
ЛОР Клиника ухо, горло, нос - медицинский центр лечения ЛОР заболеваний у детей и взрослых |
||||||
|
</h2> |
||||||
|
|
||||||
|
{/* Баннер — единый светло-кремовый фон */} |
||||||
|
<div |
||||||
|
className="rounded-xl overflow-hidden flex" |
||||||
|
style={{ background: "#f9f4e7", minHeight: 280 }} |
||||||
|
> |
||||||
|
{/* Левая часть — контент */} |
||||||
|
<div |
||||||
|
className="flex flex-col justify-center gap-5 p-8" |
||||||
|
style={{ width: "50%", flexShrink: 0 }} |
||||||
|
> |
||||||
|
<p |
||||||
|
className="font-bold uppercase leading-snug" |
||||||
|
style={{ color: "#333333", fontSize: 22 }} |
||||||
|
> |
||||||
|
Эндоскопическое хирургическое лечение ЛОР органов |
||||||
|
</p> |
||||||
|
<ul className="space-y-3"> |
||||||
|
{HERO_CHECKS.map((c) => ( |
||||||
|
<li key={c.key} className="flex items-start gap-2 text-sm"> |
||||||
|
<span |
||||||
|
className="shrink-0 font-bold" |
||||||
|
style={{ color: "#bf9975", marginTop: 1 }} |
||||||
|
> |
||||||
|
✓ |
||||||
|
</span> |
||||||
|
<span> |
||||||
|
<span className="font-bold uppercase" style={{ color: "#111827" }}> |
||||||
|
{c.key} |
||||||
|
</span>{" "} |
||||||
|
<span style={{ color: "#374151" }}>– {c.desc}</span> |
||||||
|
</span> |
||||||
|
</li> |
||||||
|
))} |
||||||
|
</ul> |
||||||
|
<div> |
||||||
|
<button className="bb-btn bb-btn-md bb-btn-pill"> |
||||||
|
Узнать стоимость операции |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
{/* Правая часть — фото врача */} |
||||||
|
<div className="flex-1 relative overflow-hidden" style={{ minHeight: 280 }}> |
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */} |
||||||
|
<img |
||||||
|
src="/hero-doctor.jpg" |
||||||
|
alt="Врач на приёме с пациентом" |
||||||
|
style={{ |
||||||
|
width: "100%", |
||||||
|
height: "100%", |
||||||
|
objectFit: "cover", |
||||||
|
objectPosition: "center top", |
||||||
|
position: "absolute", |
||||||
|
inset: 0, |
||||||
|
}} |
||||||
|
/> |
||||||
|
</div> |
||||||
|
</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> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
@ -0,0 +1,83 @@ |
|||||||
|
export const NAV_ITEMS = [ |
||||||
|
"Клиника", |
||||||
|
"ЛОР врачи", |
||||||
|
"Заболевания", |
||||||
|
"Вопрос-ответ", |
||||||
|
"ЛОР-операции", |
||||||
|
"Сурдология", |
||||||
|
"Цены", |
||||||
|
"Контакты", |
||||||
|
]; |
||||||
|
|
||||||
|
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" }}> |
||||||
|
Клиника ухо горло нос и аллергия |
||||||
|
</a> |
||||||
|
<a href="#" style={{ color: "#0089c3" }}> |
||||||
|
Центр аллергологии и пульмонологии |
||||||
|
</a> |
||||||
|
</div> |
||||||
|
<div className="flex items-center gap-4"> |
||||||
|
<span className="font-bold text-sm" style={{ color: "#111827" }}> |
||||||
|
/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> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
{/* Главное меню */} |
||||||
|
<nav className="flex border-t bg-white" style={{ borderColor: "#e5e7eb" }}> |
||||||
|
{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> |
||||||
|
))} |
||||||
|
</nav> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
@ -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: "#f2fee6" }} |
||||||
|
> |
||||||
|
<h2 className="font-bold" style={{ color: "#000000", fontSize: 36, lineHeight: "38px" }}> |
||||||
|
Новости |
||||||
|
</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="font-bold mb-2" style={{ color: "#000000", fontSize: 36, lineHeight: "38px" }}> |
||||||
|
Отзывы о нас |
||||||
|
</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> |
||||||
|
); |
||||||
|
} |
||||||
@ -0,0 +1,41 @@ |
|||||||
|
"use client"; |
||||||
|
|
||||||
|
export interface ChangelogEntry { |
||||||
|
version: string; |
||||||
|
date: string; |
||||||
|
changes: string[]; |
||||||
|
} |
||||||
|
|
||||||
|
interface BlockChangelogProps { |
||||||
|
changelog: ChangelogEntry[]; |
||||||
|
} |
||||||
|
|
||||||
|
export function BlockChangelog({ changelog }: BlockChangelogProps) { |
||||||
|
if (!changelog || changelog.length === 0) return null; |
||||||
|
|
||||||
|
return ( |
||||||
|
<section className="space-y-3"> |
||||||
|
<h2 className="font-semibold text-base" style={{ color: "var(--bb-text)" }}> |
||||||
|
История версий |
||||||
|
</h2> |
||||||
|
<div className="space-y-2 text-sm" style={{ color: "var(--bb-text-muted)" }}> |
||||||
|
{changelog.map((entry) => ( |
||||||
|
<div |
||||||
|
key={entry.version} |
||||||
|
className="p-3 rounded-lg" |
||||||
|
style={{ background: "var(--bb-sidebar-bg)", border: "1px solid var(--bb-border)" }} |
||||||
|
> |
||||||
|
<p className="font-semibold text-xs mb-1" style={{ color: "var(--bb-text)" }}> |
||||||
|
{entry.version} — {entry.date} |
||||||
|
</p> |
||||||
|
<ul className="list-disc list-inside space-y-0.5 text-xs"> |
||||||
|
{entry.changes.map((change, i) => ( |
||||||
|
<li key={i}>{change}</li> |
||||||
|
))} |
||||||
|
</ul> |
||||||
|
</div> |
||||||
|
))} |
||||||
|
</div> |
||||||
|
</section> |
||||||
|
); |
||||||
|
} |
||||||
@ -0,0 +1,197 @@ |
|||||||
|
"use client"; |
||||||
|
|
||||||
|
import { useState, useEffect } from "react"; |
||||||
|
import { BlockChangelog, type ChangelogEntry } from "./BlockChangelog"; |
||||||
|
|
||||||
|
const LS_PREFIX = "bb-block-preview:"; |
||||||
|
|
||||||
|
function readLocalPreview(path: string, fallback: boolean): boolean { |
||||||
|
if (typeof window === "undefined") return fallback; |
||||||
|
const v = localStorage.getItem(LS_PREFIX + path); |
||||||
|
if (v === null) return fallback; |
||||||
|
return v === "true"; |
||||||
|
} |
||||||
|
|
||||||
|
function writeLocalPreview(path: string, value: boolean) { |
||||||
|
localStorage.setItem(LS_PREFIX + path, String(value)); |
||||||
|
window.dispatchEvent(new Event("bb-preview-change")); |
||||||
|
} |
||||||
|
|
||||||
|
interface BlockMeta { |
||||||
|
path: string; |
||||||
|
name: string; |
||||||
|
version: string; |
||||||
|
isInPreview: boolean; |
||||||
|
changelog: ChangelogEntry[]; |
||||||
|
} |
||||||
|
|
||||||
|
interface BlockMetaBarProps { |
||||||
|
path: string; |
||||||
|
defaultVersion: string; |
||||||
|
defaultIsInPreview: boolean; |
||||||
|
defaultChangelog?: ChangelogEntry[]; |
||||||
|
} |
||||||
|
|
||||||
|
export function BlockMetaBar({ path, defaultVersion, defaultIsInPreview, defaultChangelog = [] }: 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 [localPreview, setLocalPreview] = useState(defaultIsInPreview); |
||||||
|
|
||||||
|
const apiUrl = process.env.NEXT_PUBLIC_API_URL; |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
setLocalPreview(readLocalPreview(path, defaultIsInPreview)); |
||||||
|
}, [path, defaultIsInPreview]); |
||||||
|
|
||||||
|
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; changelog?: ChangelogEntry[] }) { |
||||||
|
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 (apiDown) { |
||||||
|
const newVal = !localPreview; |
||||||
|
setLocalPreview(newVal); |
||||||
|
writeLocalPreview(path, newVal); |
||||||
|
return; |
||||||
|
} |
||||||
|
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 = apiDown ? localPreview : (meta?.isInPreview ?? localPreview); |
||||||
|
const changelog: ChangelogEntry[] = (meta?.changelog && meta.changelog.length > 0) ? meta.changelog : defaultChangelog; |
||||||
|
|
||||||
|
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 */} |
||||||
|
<button |
||||||
|
onClick={togglePreview} |
||||||
|
disabled={togglingPreview || (!apiDown && !meta)} |
||||||
|
title={isInPreview ? "Убрать из превью страницы" : "Включить в превью страницы"} |
||||||
|
className="flex items-center gap-1.5 px-2.5 py-1 rounded font-medium transition-colors cursor-pointer" |
||||||
|
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> |
||||||
|
|
||||||
|
{/* Subtle offline dot instead of "API офлайн" text */} |
||||||
|
{apiDown && ( |
||||||
|
<span |
||||||
|
className="inline-block w-1.5 h-1.5 rounded-full" |
||||||
|
title="API недоступен — данные из кода" |
||||||
|
style={{ background: "#d1d5db" }} |
||||||
|
/> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
|
||||||
|
{/* Changelog rendered from API or fallback */} |
||||||
|
{changelog.length > 0 && <BlockChangelog changelog={changelog} />} |
||||||
|
</> |
||||||
|
); |
||||||
|
} |
||||||
Loading…
Reference in new issue