Compare commits

..

No commits in common. 'main' and 'sprint/5' have entirely different histories.

  1. 15
      .dockerignore
  2. 10
      .env.example
  3. 1
      .gitignore
  4. 9
      apps/api/package.json
  5. 1
      apps/api/prisma.config.ts
  6. 53
      apps/api/prisma/migrations/20260323120441_add_block_model/migration.sql
  7. 2
      apps/api/prisma/migrations/20260324141120_add_changelog_field/migration.sql
  8. 18
      apps/api/prisma/migrations/20260324185731_add_block_snapshot/migration.sql
  9. 3
      apps/api/prisma/migrations/migration_lock.toml
  10. 27
      apps/api/prisma/schema.prisma
  11. 169
      apps/api/prisma/seed.ts
  12. 3
      apps/api/src/app.module.ts
  13. 43
      apps/api/src/blocks/blocks.controller.ts
  14. 10
      apps/api/src/blocks/blocks.module.ts
  15. 43
      apps/api/src/blocks/blocks.service.ts
  16. 4
      apps/api/src/main.ts
  17. 19
      apps/api/src/prisma/prisma.service.ts
  18. 224
      apps/web/app/blocks/ceo/CeoPageClient.tsx
  19. 218
      apps/web/app/blocks/ceo/page.tsx
  20. 190
      apps/web/app/blocks/contact-forms/ContactFormsPageClient.tsx
  21. 230
      apps/web/app/blocks/contact-forms/page.tsx
  22. 204
      apps/web/app/blocks/contact/ContactPageClient.tsx
  23. 251
      apps/web/app/blocks/contact/page.tsx
  24. 187
      apps/web/app/blocks/doctors/DoctorsPageClient.tsx
  25. 232
      apps/web/app/blocks/doctors/page.tsx
  26. 231
      apps/web/app/blocks/hero/HeroPageClient.tsx
  27. 280
      apps/web/app/blocks/hero/page.tsx
  28. 291
      apps/web/app/blocks/navigation/NavigationPageClient.tsx
  29. 10
      apps/web/app/blocks/navigation/page.tsx
  30. 195
      apps/web/app/blocks/news/NewsPageClient.tsx
  31. 192
      apps/web/app/blocks/news/page.tsx
  32. 226
      apps/web/app/blocks/reviews/ReviewsPageClient.tsx
  33. 258
      apps/web/app/blocks/reviews/page.tsx
  34. 2
      apps/web/app/components/buttons/page.tsx
  35. 2
      apps/web/app/components/cards/page.tsx
  36. 2
      apps/web/app/components/forms/page.tsx
  37. 300
      apps/web/app/components/navigation/page.tsx
  38. 6
      apps/web/app/foundation/typography/page.tsx
  39. 18
      apps/web/app/globals.css
  40. 270
      apps/web/app/pages/preview/PreviewClient.tsx
  41. 11
      apps/web/app/pages/preview/page.tsx
  42. 45
      apps/web/components/blocks/CeoBlock.tsx
  43. 84
      apps/web/components/blocks/ContactFormsBlock.tsx
  44. 95
      apps/web/components/blocks/DoctorsBlock.tsx
  45. 129
      apps/web/components/blocks/FooterBlock.tsx
  46. 92
      apps/web/components/blocks/HeroBlock.tsx
  47. 95
      apps/web/components/blocks/NavigationBlock.tsx
  48. 63
      apps/web/components/blocks/NewsBlock.tsx
  49. 86
      apps/web/components/blocks/ReviewsBlock.tsx
  50. 34
      apps/web/components/blocks/navData.ts
  51. 5
      apps/web/components/layout/Sidebar.tsx
  52. 41
      apps/web/components/ui/BlockChangelog.tsx
  53. 364
      apps/web/components/ui/BlockMetaBar.tsx
  54. 2
      apps/web/next.config.ts
  55. 51
      docker-compose.yml
  56. 29
      docker/Dockerfile.api
  57. 50
      docker/Dockerfile.web
  58. 73
      docs/LLM_CONTEXT.md
  59. 139
      docs/SPRINTS.md
  60. 492
      pnpm-lock.yaml
  61. 5
      vercel.json

15
.dockerignore

@ -1,15 +0,0 @@
node_modules
**/node_modules
**/.next
**/dist
.git
.gitignore
*.md
.env
.env.*
!.env.example
coverage
**/.turbo
**/coverage
.DS_Store
apps/web/pnpm-lock.yaml

10
.env.example

@ -1,8 +1,8 @@
# База данных (локально: порт как в docker-compose.yml, маппинг 5434 -> 5432 в контейнере)
DATABASE_URL="postgresql://brandbook:brandbook@localhost:5434/brandbook"
# База данных
DATABASE_URL="postgresql://brandbook:brandbook@localhost:5433/brandbook"
# API (NestJS) — в коде используется переменная PORT
PORT=3001
# API (NestJS)
API_PORT=3001
# Web (Next.js) — URL API в браузере (при docker compose: http://localhost:3001)
# Web (Next.js)
NEXT_PUBLIC_API_URL=http://localhost:3001

1
.gitignore vendored

@ -29,4 +29,3 @@ prisma/migrations/*.sql.bak
# Claude
.claude/
.vercel

9
apps/api/package.json

@ -12,7 +12,7 @@
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/src/main.js",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
@ -24,11 +24,8 @@
"@nestjs/common": "^11.0.1",
"@nestjs/core": "^11.0.1",
"@nestjs/platform-express": "^11.0.1",
"@prisma/adapter-pg": "^7.5.0",
"@prisma/client": "^7.5.0",
"@types/pg": "^8.20.0",
"dotenv": "^17.3.1",
"pg": "^8.20.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
},
@ -55,13 +52,9 @@
"ts-loader": "^9.5.2",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"tsx": "^4.21.0",
"typescript": "^5.7.3",
"typescript-eslint": "^8.20.0"
},
"prisma": {
"seed": "ts-node prisma/seed.ts"
},
"jest": {
"moduleFileExtensions": [
"js",

1
apps/api/prisma.config.ts

@ -7,7 +7,6 @@ export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
seed: "tsx prisma/seed.ts",
},
datasource: {
url: process.env["DATABASE_URL"],

53
apps/api/prisma/migrations/20260323120441_add_block_model/migration.sql

@ -1,53 +0,0 @@
-- 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;

2
apps/api/prisma/migrations/20260324141120_add_changelog_field/migration.sql

@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "Block" ADD COLUMN "changelog" JSONB NOT NULL DEFAULT '[]';

18
apps/api/prisma/migrations/20260324185731_add_block_snapshot/migration.sql

@ -1,18 +0,0 @@
-- CreateTable
CREATE TABLE "BlockSnapshot" (
"id" TEXT NOT NULL,
"blockPath" TEXT NOT NULL,
"version" TEXT NOT NULL,
"changelog" JSONB NOT NULL DEFAULT '[]',
"html" TEXT NOT NULL,
"css" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "BlockSnapshot_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "BlockSnapshot_blockPath_idx" ON "BlockSnapshot"("blockPath");
-- CreateIndex
CREATE UNIQUE INDEX "BlockSnapshot_blockPath_version_key" ON "BlockSnapshot"("blockPath", "version");

3
apps/api/prisma/migrations/migration_lock.toml

@ -1,3 +0,0 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

27
apps/api/prisma/schema.prisma

@ -1,5 +1,6 @@
generator client {
provider = "prisma-client-js"
provider = "prisma-client"
output = "../generated/prisma"
}
datasource db {
@ -39,27 +40,3 @@ model ExperimentalComponent {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Block {
id String @id @default(uuid())
path String @unique
name String
version String
isInPreview Boolean @default(false)
changelog Json @default("[]")
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
}
model BlockSnapshot {
id String @id @default(uuid())
blockPath String
version String
changelog Json @default("[]")
html String @db.Text
css String @db.Text
createdAt DateTime @default(now())
@@unique([blockPath, version])
@@index([blockPath])
}

169
apps/api/prisma/seed.ts

@ -1,169 +0,0 @@
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.3',
isInPreview: true,
changelog: [
{ version: 'v1.3', date: '24.03.2026', changes: [
'Подменю: выпадающие списки при hover',
'Hover-эффект: бежевый фон #f5f0e6',
'Пункты подчёркнуты, без разделителей',
'Все пункты чёрного цвета #000',
]},
{ version: 'v1.2', date: '24.03.2026', changes: [
'Убрана рамка и тень вокруг шапки',
'3 столбца: логотип | ссылки | телефон+кнопка',
'Реальный логотип logo-main.png',
]},
{ version: 'v1.1', date: '24.03.2026', changes: [
'Адрес: «К. Цеткин, 9», ссылки, телефон 25px, меню 18px',
]},
{ version: 'v1.0', date: '23.03.2026', changes: ['Топ-бар, логотип, главное меню из 8 пунктов'] },
],
},
{
path: '/blocks/hero',
name: 'Hero-баннер',
version: 'v1.3',
isInPreview: true,
changelog: [
{ version: 'v1.3', date: '24.03.2026', changes: [
'Счётчик: «Поделиться ✉ 98572» (было «👁 98 573 просмотра»)',
'Убраны кнопки VK/FB/TW',
]},
{ 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.1',
isInPreview: false,
changelog: [
{ version: 'v1.1', date: '24.03.2026', changes: [
'Адрес: «ул. Цитная, 9» → «ул. Клары Цеткин, 9»',
'Цвет ссылок: #52b4bd (было #0089c3)',
]},
{ 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.2',
isInPreview: true,
changelog: [
{ version: 'v1.2', date: '24.03.2026', changes: [
'Кнопка: bb-btn-lg 18px bold (было bb-btn-md 14px)',
'border-radius кнопки: 4px (было 7px)',
]},
{ 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.1',
isInPreview: false,
changelog: [
{ version: 'v1.1', date: '24.03.2026', changes: [
'Колонка «О клинике»: 13 ссылок по реальному сайту (было 7)',
'Колонка «Заболевания»: 5 категорий (было 6 конкретных диагнозов)',
'Колонка «Вопрос-ответ»: 6 пунктов по реальному сайту',
'Колонка «Операции»: 11 операций (было 6)',
'Два адреса: Клары Цеткин 9 + Газеты Звезда 31А',
'Два графика работы по филиалам',
'Соцсети: добавлен Дзен',
]},
{ 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());

3
apps/api/src/app.module.ts

@ -1,10 +1,9 @@
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { BlocksModule } from './blocks/blocks.module';
@Module({
imports: [BlocksModule],
imports: [],
controllers: [AppController],
providers: [AppService],
})

43
apps/api/src/blocks/blocks.controller.ts

@ -1,43 +0,0 @@
import { Controller, Get, Post, Patch, Param, 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);
}
@Post('snapshots')
createSnapshot(
@Query('path') path: string,
@Body() body: { version: string; changelog: object[]; html: string; css: string },
) {
return this.blocks.createSnapshot(path, body);
}
@Get('snapshots')
listSnapshots(@Query('path') path: string) {
return this.blocks.listSnapshots(path);
}
@Get('snapshots/:id')
getSnapshot(@Param('id') id: string) {
return this.blocks.getSnapshot(id);
}
}

10
apps/api/src/blocks/blocks.module.ts

@ -1,10 +0,0 @@
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 {}

43
apps/api/src/blocks/blocks.service.ts

@ -1,43 +0,0 @@
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 });
}
createSnapshot(blockPath: string, data: { version: string; changelog: object[]; html: string; css: string }) {
return this.prisma.blockSnapshot.upsert({
where: { blockPath_version: { blockPath, version: data.version } },
update: { html: data.html, css: data.css, changelog: data.changelog },
create: { blockPath, ...data },
});
}
listSnapshots(blockPath: string) {
return this.prisma.blockSnapshot.findMany({
where: { blockPath },
select: { id: true, version: true, createdAt: true },
orderBy: { createdAt: 'desc' },
});
}
getSnapshot(id: string) {
return this.prisma.blockSnapshot.findUniqueOrThrow({ where: { id } });
}
}

4
apps/api/src/main.ts

@ -1,12 +1,8 @@
import 'dotenv/config';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.enableCors({
origin: [/^http:\/\/localhost:\d+$/, /^http:\/\/127\.0\.0\.1:\d+$/],
});
await app.listen(process.env.PORT ?? 3000);
}
bootstrap();

19
apps/api/src/prisma/prisma.service.ts

@ -1,19 +0,0 @@
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();
}
}

224
apps/web/app/blocks/ceo/CeoPageClient.tsx

@ -1,224 +0,0 @@
"use client";
import { useState } from "react";
import { LlmBlock, LlmSection, LlmTable, LlmRules } from "@/components/llm/LlmBlock";
import { BlockMetaBar, type SnapshotData } from "@/components/ui/BlockMetaBar";
import { type ChangelogEntry } from "@/components/ui/BlockChangelog";
import { CeoBlock, CEO_QUESTIONS } from "@/components/blocks/CeoBlock";
const BLOCK_PATH = "/blocks/ceo";
const LLM_CEO_TEXT = `
БЛОК: Вводный текст клиники (CEO-блок)
Источник: perm.oclinica.ru/lor секция после баннера
Версия: v1.1
НАЗНАЧЕНИЕ:
Информационный блок под hero-баннером. Рассказывает о специализации клиники
и вовлекает пациента через вопросы-стимулы.
СТРУКТУРА:
1. Вводный абзац
«Клиника ухо, нос специализируется на оториноларингологии лечении взрослых и детей
с ЛОР заболеваниями. ЛОР клиника представлена на двух адресах: ул. Цитная, 9, ул. Г. Звезда, 31а...»
2. Вопросы-стимулы (формат: « Вопрос?»)
Каждый вопрос отдельный абзац с тире, связанный с симптомами пациентов:
У вас болит ухо, заложен нос, першит в горле, и вы не можете понять причину?
Вам срочно нужен платный ЛОР в Перми или, как ещё говорят, «ухогорлонос»?
Заболел ребёнок?
Срочно ищете частные ЛОР-клиники Перми для детей 0+ и взрослых...
Вам назначили проведение эндоскопической операции на ухе, горле или носе?
3. Заключительный абзац
«Обращайтесь в ЛОР центр ухо, горло, нос в Перми...»
«Будьте здоровы!»
ТИПОГРАФИКА:
Шрифт: Fira Sans, 14px, line-height 1.61.8
Цвет текста: #374151 (#bb-text)
Вопросы: тот же стиль, начинаются с «»
Ключевые слова в тексте обычно ссылки синего цвета (#0089c3)
Фон блока: #ffffff, отступы 4060px сверху и снизу
ПРАВИЛА:
Вопросы начинаются с «» (тире)
Ключевые медицинские термины ссылки #0089c3
Текст без H2 заголовка просто связный параграф
Не добавлять маркированные списки (только тире)
Не менять стиль вопросов на другой формат
`.trim();
const CHANGELOG: ChangelogEntry[] = [
{
version: "v1.2",
date: "25.03.2026",
changes: [
"Убраны горизонтальные отступы: padding 40px 48px → 40px 0 (как на реальном сайте)",
],
},
{
version: "v1.1",
date: "24.03.2026",
changes: [
"Адрес: «ул. Цитная, 9» заменён на «ул. Клары Цеткин, 9»",
"Цвет ссылок: #52b4bd (было #0089c3)",
],
},
];
export default function CeoPageClient() {
const [snapshot, setSnapshot] = useState<SnapshotData | null>(null);
return (
<div className="p-8 max-w-5xl mx-auto space-y-10">
{/* Заголовок страницы */}
<div>
<p
className="text-xs font-semibold uppercase tracking-widest mb-1"
style={{ color: "var(--brand-053m)" }}
>
Блоки
</p>
<h1 className="text-2xl font-bold mb-2" style={{ color: "var(--bb-text)" }}>
Вводный текст (CEO-блок)
</h1>
<BlockMetaBar
path={BLOCK_PATH}
defaultVersion="v1.2"
defaultIsInPreview={false}
defaultChangelog={CHANGELOG}
onSnapshotSelect={setSnapshot}
/>
<p className="text-sm" style={{ color: "var(--bb-text-muted)" }}>
Блок после hero-баннера на perm.oclinica.ru/lor. Описание специализации клиники
+ вопросы-стимулы для пациентов.
</p>
</div>
{/* Живой пример */}
<section className="space-y-3">
<h2 className="font-semibold text-base" style={{ color: "var(--bb-text)" }}>
Живой пример
</h2>
{snapshot ? (
<div className="rounded-xl overflow-hidden" style={{ border: "1px solid var(--bb-border)" }}>
<style dangerouslySetInnerHTML={{ __html: snapshot.css }} />
<div dangerouslySetInnerHTML={{ __html: snapshot.html }} />
</div>
) : (
<div data-block-capture={BLOCK_PATH} className="rounded-xl overflow-hidden" style={{ border: "1px solid var(--bb-border)" }}>
<CeoBlock />
</div>
)}
</section>
{/* Анатомия */}
<section className="space-y-4">
<h2 className="font-semibold text-base" style={{ color: "var(--bb-text)" }}>
Структура блока
</h2>
<div className="space-y-3">
{[
{
num: "1",
title: "Вводный абзац",
bg: "#f0f9ff",
desc: 'Специализация клиники, адреса. Ключевые слова — ссылки синего цвета (#0089c3).',
},
{
num: "2",
title: "Вопросы-стимулы",
bg: "#fefce8",
desc: 'Каждый вопрос — отдельный абзац, начинается с «—». Адресован пациенту с конкретным симптомом.',
},
{
num: "3",
title: "Заключительный абзац",
bg: "#f0fdf4",
desc: 'Призыв обращаться. «Будьте здоровы!» — фирменная подпись клиники.',
},
].map((s) => (
<div
key={s.num}
className="flex gap-4 p-4 rounded-xl"
style={{ background: s.bg, border: "1px solid var(--bb-border)" }}
>
<div
className="w-7 h-7 rounded-full flex items-center justify-center font-bold text-sm shrink-0"
style={{ background: "var(--brand-053m)", color: "#fff" }}
>
{s.num}
</div>
<div>
<p className="font-semibold text-sm mb-1" style={{ color: "var(--bb-text)" }}>
{s.title}
</p>
<p className="text-xs" style={{ color: "var(--bb-text-muted)" }}>
{s.desc}
</p>
</div>
</div>
))}
</div>
</section>
{/* Вопросы-стимулы */}
<section className="space-y-3">
<h2 className="font-semibold text-base" style={{ color: "var(--bb-text)" }}>
Вопросы-стимулы (5 штук)
</h2>
<div className="space-y-2">
{CEO_QUESTIONS.map((q, i) => (
<div
key={i}
className="flex items-start gap-3 p-3 rounded-lg text-sm"
style={{ background: "var(--bb-sidebar-bg)", border: "1px solid var(--bb-border)" }}
>
<span className="font-bold shrink-0 mt-0.5" style={{ color: "var(--brand-053m)" }}>
</span>
<span style={{ color: "var(--bb-text)" }}>{q}</span>
</div>
))}
</div>
</section>
{/* LLM блок */}
<LlmBlock path="/blocks/ceo" version="v1.2" specText={LLM_CEO_TEXT}>
<LlmSection title="Структура блока" />
<LlmTable
headers={["Часть", "Содержимое", "Примечание"]}
rows={[
["Вводный абзац", "Специализация клиники, адреса", "Ключевые слова = ссылки #0089c3"],
["Вопросы-стимулы", "5 вопросов от лица пациента, начинаются с «—»", "Без маркированных списков"],
["Заключение", 'Призыв обращаться + «Будьте здоровы!»', "Фирменная подпись клиники"],
]}
/>
<LlmSection title="Типографика" />
<LlmTable
headers={["Параметр", "Значение"]}
rows={[
["Шрифт", "Fira Sans"],
["Размер", "14px"],
["Line-height", "1.6–1.8"],
["Цвет текста", "#374151 (--bb-text)"],
["Цвет ссылок в тексте", "#0089c3 (--brand-053m)"],
["Фон блока", "#ffffff"],
]}
/>
<LlmSection title="Правила" />
<LlmRules
rules={[
{ ok: true, text: "Вопросы начинаются с «—» (длинное тире)" },
{ ok: true, text: "Ключевые медицинские термины — ссылки #0089c3" },
{ ok: true, text: "Фирменная подпись «Будьте здоровы!» в конце" },
{ ok: false, text: "Не добавлять маркированные списки (•)" },
{ ok: false, text: "Не добавлять H2 заголовок внутри блока" },
]}
/>
</LlmBlock>
</div>
);
}

218
apps/web/app/blocks/ceo/page.tsx

@ -1,10 +1,224 @@
import type { Metadata } from "next";
import CeoPageClient from "./CeoPageClient";
import { LlmBlock, LlmSection, LlmTable, LlmRules } from "@/components/llm/LlmBlock";
export const metadata: Metadata = {
title: "Вводный текст (CEO-блок). Цифровой брендбук Клиники ухо, горло, нос им. проф. Е.Н.Оленевой",
};
const LLM_CEO_TEXT = `
БЛОК: Вводный текст клиники (CEO-блок)
Источник: perm.oclinica.ru/lor секция после баннера
Версия: v1.0
НАЗНАЧЕНИЕ:
Информационный блок под hero-баннером. Рассказывает о специализации клиники
и вовлекает пациента через вопросы-стимулы.
СТРУКТУРА:
1. Вводный абзац
«Клиника ухо, нос специализируется на оториноларингологии лечении взрослых и детей
с ЛОР заболеваниями. ЛОР клиника представлена на двух адресах: ул. Цитная, 9, ул. Г. Звезда, 31а...»
2. Вопросы-стимулы (формат: « Вопрос?»)
Каждый вопрос отдельный абзац с тире, связанный с симптомами пациентов:
У вас болит ухо, заложен нос, першит в горле, и вы не можете понять причину?
Вам срочно нужен платный ЛОР в Перми или, как ещё говорят, «ухогорлонос»?
Заболел ребёнок?
Срочно ищете частные ЛОР-клиники Перми для детей 0+ и взрослых...
Вам назначили проведение эндоскопической операции на ухе, горле или носе?
3. Заключительный абзац
«Обращайтесь в ЛОР центр ухо, горло, нос в Перми...»
«Будьте здоровы!»
ТИПОГРАФИКА:
Шрифт: Fira Sans, 14px, line-height 1.61.8
Цвет текста: #374151 (#bb-text)
Вопросы: тот же стиль, начинаются с «»
Ключевые слова в тексте обычно ссылки синего цвета (#0089c3)
Фон блока: #ffffff, отступы 4060px сверху и снизу
ПРАВИЛА:
Вопросы начинаются с «» (тире)
Ключевые медицинские термины ссылки #0089c3
Текст без H2 заголовка просто связный параграф
Не добавлять маркированные списки (только тире)
Не менять стиль вопросов на другой формат
`.trim();
const CEO_QUESTIONS = [
"У вас болит ухо, заложен нос, першит в горле, и вы не можете понять причину?",
"Вам срочно нужен платный ЛОР в Перми или, как ещё говорят, «ухогорлонос»?",
"Заболел ребёнок?",
"Срочно ищете частные ЛОР-клиники Перми для детей 0+ и взрослых с удобным режимом работы с 9:00 до 21:00 по будням?",
"Вам назначили проведение эндоскопической операции на ухе, горле или носе?",
];
export default function CeoPage() {
return <CeoPageClient />;
return (
<div className="p-8 max-w-5xl mx-auto space-y-10">
{/* Заголовок страницы */}
<div>
<p
className="text-xs font-semibold uppercase tracking-widest mb-1"
style={{ color: "var(--brand-053m)" }}
>
Блоки
</p>
<h1 className="text-2xl font-bold mb-2" style={{ color: "var(--bb-text)" }}>
Вводный текст (CEO-блок)
</h1>
<p className="text-sm" style={{ color: "var(--bb-text-muted)" }}>
Блок после hero-баннера на perm.oclinica.ru/lor. Описание специализации клиники
+ вопросы-стимулы для пациентов.
</p>
</div>
{/* Живой пример */}
<section className="space-y-3">
<h2 className="font-semibold text-base" style={{ color: "var(--bb-text)" }}>
Живой пример
</h2>
<div
className="rounded-xl p-8"
style={{
background: "#fff",
border: "1px solid var(--bb-border)",
fontFamily: "var(--font-web)",
fontSize: 14,
lineHeight: 1.75,
color: "#374151",
}}
>
<p>
Клиника ухо, нос специализируется на оториноларингологии лечении взрослых и детей
с ЛОР заболеваниями. ЛОР клиника представлена на двух адресах:{" "}
<a href="#" style={{ color: "#0089c3" }}>ул. Цитная, 9</a>, <a href="#" style={{ color: "#0089c3" }}>ул. Г. Звезда, 31а</a>.{" "}
Это <a href="#" style={{ color: "#0089c3" }}>Клиника лечения кашля и аллергии</a>.
</p>
<div className="mt-4 space-y-2">
{CEO_QUESTIONS.map((q) => (
<p key={q}> {q}</p>
))}
</div>
<p className="mt-4">
Обращайтесь в ЛОР центр ухо, горло, нос в Перми, наши врачи оториноларингологи обязательно Вам помогут!
</p>
<p className="mt-2">
Клиника ЛОР болезней ухо, горло, нос это наиболее современный центр оториноларингологии в Перми,
благодаря эндоскопическому оборудованию, высокому профессионализму оториноларингологов.
</p>
<p className="mt-4 font-medium">Будьте здоровы!</p>
</div>
</section>
{/* Анатомия */}
<section className="space-y-4">
<h2 className="font-semibold text-base" style={{ color: "var(--bb-text)" }}>
Структура блока
</h2>
<div className="space-y-3">
{[
{
num: "1",
title: "Вводный абзац",
bg: "#f0f9ff",
desc: 'Специализация клиники, адреса. Ключевые слова — ссылки синего цвета (#0089c3).',
},
{
num: "2",
title: "Вопросы-стимулы",
bg: "#fefce8",
desc: 'Каждый вопрос — отдельный абзац, начинается с «—». Адресован пациенту с конкретным симптомом.',
},
{
num: "3",
title: "Заключительный абзац",
bg: "#f0fdf4",
desc: 'Призыв обращаться. «Будьте здоровы!» — фирменная подпись клиники.',
},
].map((s) => (
<div
key={s.num}
className="flex gap-4 p-4 rounded-xl"
style={{ background: s.bg, border: "1px solid var(--bb-border)" }}
>
<div
className="w-7 h-7 rounded-full flex items-center justify-center font-bold text-sm shrink-0"
style={{ background: "var(--brand-053m)", color: "#fff" }}
>
{s.num}
</div>
<div>
<p className="font-semibold text-sm mb-1" style={{ color: "var(--bb-text)" }}>
{s.title}
</p>
<p className="text-xs" style={{ color: "var(--bb-text-muted)" }}>
{s.desc}
</p>
</div>
</div>
))}
</div>
</section>
{/* Вопросы-стимулы */}
<section className="space-y-3">
<h2 className="font-semibold text-base" style={{ color: "var(--bb-text)" }}>
Вопросы-стимулы (5 штук)
</h2>
<div className="space-y-2">
{CEO_QUESTIONS.map((q, i) => (
<div
key={i}
className="flex items-start gap-3 p-3 rounded-lg text-sm"
style={{ background: "var(--bb-sidebar-bg)", border: "1px solid var(--bb-border)" }}
>
<span className="font-bold shrink-0 mt-0.5" style={{ color: "var(--brand-053m)" }}>
</span>
<span style={{ color: "var(--bb-text)" }}>{q}</span>
</div>
))}
</div>
</section>
{/* LLM блок */}
<LlmBlock path="/blocks/ceo" version="v1.0" specText={LLM_CEO_TEXT}>
<LlmSection title="Структура блока" />
<LlmTable
headers={["Часть", "Содержимое", "Примечание"]}
rows={[
["Вводный абзац", "Специализация клиники, адреса", "Ключевые слова = ссылки #0089c3"],
["Вопросы-стимулы", "5 вопросов от лица пациента, начинаются с «—»", "Без маркированных списков"],
["Заключение", 'Призыв обращаться + «Будьте здоровы!»', "Фирменная подпись клиники"],
]}
/>
<LlmSection title="Типографика" />
<LlmTable
headers={["Параметр", "Значение"]}
rows={[
["Шрифт", "Fira Sans"],
["Размер", "14px"],
["Line-height", "1.6–1.8"],
["Цвет текста", "#374151 (--bb-text)"],
["Цвет ссылок в тексте", "#0089c3 (--brand-053m)"],
["Фон блока", "#ffffff"],
]}
/>
<LlmSection title="Правила" />
<LlmRules
rules={[
{ ok: true, text: "Вопросы начинаются с «—» (длинное тире)" },
{ ok: true, text: "Ключевые медицинские термины — ссылки #0089c3" },
{ ok: true, text: "Фирменная подпись «Будьте здоровы!» в конце" },
{ ok: false, text: "Не добавлять маркированные списки (•)" },
{ ok: false, text: "Не добавлять H2 заголовок внутри блока" },
]}
/>
</LlmBlock>
</div>
);
}

190
apps/web/app/blocks/contact-forms/ContactFormsPageClient.tsx

@ -1,190 +0,0 @@
"use client";
import { useState } from "react";
import { LlmBlock, LlmSection, LlmTable, LlmRules } from "@/components/llm/LlmBlock";
import { BlockMetaBar, type SnapshotData } from "@/components/ui/BlockMetaBar";
import { type ChangelogEntry } from "@/components/ui/BlockChangelog";
import { ContactFormsBlock } from "@/components/blocks/ContactFormsBlock";
const BLOCK_PATH = "/blocks/contact-forms";
const LLM_FORMS_TEXT = `
БЛОК: Формы записи и обратной связи
Источник: perm.oclinica.ru/lor два блока форм на одной странице
Версия: v1.2
ФОРМА 1: «Будьте здоровы!» (форма записи на приём)
Позиция: после блока отзывов
Заголовок H2: «Будьте здоровы!» 36px, bold, #000000, line-height 38px
Подзаголовок: «Запишитесь на приём к врачу!»
Фон секции: #d4f6f8 (светло-бирюзовый)
Поля формы:
1. Текстовый input: placeholder «Введите ваше имя» (height 50px)
2. Телефонный input: placeholder «Введите ваш телефон» (height 50px)
3. Select: «Выберите ЛОР врача» (height 50px)
4. Checkbox: «Отправляя данные, я даю согласие на обработку персональных данных»
Кнопка: «Запишите меня!» стиль bb-btn-primary (#FFA39C)
Ширина формы: ~400px центрирована или в колонку
ФОРМА 2: «Узнайте стоимость операции» (консультация)
Позиция: после блока новостей
Заголовок H2: «Узнайте стоимость операции» 36px, bold, #000000, line-height 38px
Подзаголовок: «Проконсультируйтесь с ассистентом хирурга»
Фон секции: #d4f6f8 (тот же что и форма 1)
Поля формы:
1. Текстовый input: placeholder «каша» / «Введите ваше имя» (height 50px)
2. Телефонный input: placeholder «Введите ваш телефон» (height 50px)
3. Checkbox: «Отправляя данные, я даю согласие на обработку персональных данных»
Кнопка: «Перезвоните мне» стиль bb-btn-primary (#FFA39C)
ОБЩИЕ ПРАВИЛА:
Оба поля input/select: bb-input / bb-select (height 50px, border 1px solid #ccc)
Чекбокс обязателен в обеих формах
Кнопка отправки: всегда bb-btn-primary (#FFA39C)
Обе формы на бирюзовом фоне (#d4f6f8)
ПРАВИЛА:
Чекбокс согласия обязателен в каждой форме
Кнопки отправки: bb-btn-primary (#FFA39C)
Обе формы на фоне #d4f6f8
Не менять порядок полей
Не убирать чекбокс согласия
`.trim();
const CHANGELOG: ChangelogEntry[] = [
{
version: "v1.2",
date: "24.03.2026",
changes: [
"Кнопка: bb-btn-md заменена на bb-btn-lg (18px, bold, как на реальном сайте)",
"border-radius кнопки: 7px исправлен на 4px (исправлено в globals.css)",
],
},
{
version: "v1.1",
date: "24.03.2026",
changes: [
"H2: размер на 36px, цвет на #000000, line-height 38px",
"Фон формы 1: с #b8e6ed на #d4f6f8",
"Фон формы 2: с #ffffff на #d4f6f8 (обе формы на одном фоне)",
],
},
];
export default function ContactFormsPageClient() {
const [snapshot, setSnapshot] = useState<SnapshotData | null>(null);
return (
<div className="p-8 max-w-5xl mx-auto space-y-10">
{/* Заголовок страницы */}
<div>
<p
className="text-xs font-semibold uppercase tracking-widest mb-1"
style={{ color: "var(--brand-053m)" }}
>
Блоки
</p>
<h1 className="text-2xl font-bold mb-2" style={{ color: "var(--bb-text)" }}>
Формы записи
</h1>
<BlockMetaBar
path={BLOCK_PATH}
defaultVersion="v1.2"
defaultIsInPreview={false}
defaultChangelog={CHANGELOG}
onSnapshotSelect={setSnapshot}
/>
<p className="text-sm" style={{ color: "var(--bb-text-muted)" }}>
Два блока форм с perm.oclinica.ru/lor запись на приём и запрос стоимости операции.
</p>
</div>
{/* Живой пример */}
<section className="space-y-3">
<h2 className="font-semibold text-base" style={{ color: "var(--bb-text)" }}>
Живой пример
</h2>
{snapshot ? (
<>
<style dangerouslySetInnerHTML={{ __html: snapshot.css }} />
<div dangerouslySetInnerHTML={{ __html: snapshot.html }} />
</>
) : (
<div data-block-capture={BLOCK_PATH}>
<ContactFormsBlock />
</div>
)}
</section>
{/* Сравнение двух форм */}
<section className="space-y-3">
<h2 className="font-semibold text-base" style={{ color: "var(--bb-text)" }}>
Сравнение двух форм
</h2>
<div className="overflow-x-auto rounded-xl" style={{ border: "1px solid var(--bb-border)" }}>
<table className="w-full text-sm border-collapse">
<thead>
<tr style={{ background: "var(--bb-sidebar-bg)" }}>
<th className="text-left px-4 py-3 text-xs font-semibold" style={{ color: "var(--bb-text-muted)", borderBottom: "1px solid var(--bb-border)" }}>Параметр</th>
<th className="text-left px-4 py-3 text-xs font-semibold" style={{ color: "var(--bb-text-muted)", borderBottom: "1px solid var(--bb-border)" }}>Форма 1 «Будьте здоровы!»</th>
<th className="text-left px-4 py-3 text-xs font-semibold" style={{ color: "var(--bb-text-muted)", borderBottom: "1px solid var(--bb-border)" }}>Форма 2 «Стоимость операции»</th>
</tr>
</thead>
<tbody>
{[
["Заголовок", "«Будьте здоровы!»", "«Узнайте стоимость операции»"],
["Подзаголовок", "«Запишитесь на приём к врачу!»", "«Проконсультируйтесь с ассистентом хирурга»"],
["Фон секции", "#d4f6f8", "#d4f6f8"],
["Поля", "Имя + Телефон + Select врача + Checkbox", "Имя + Телефон + Checkbox"],
["Кнопка", "«Запишите меня!»", "«Перезвоните мне»"],
["Стиль кнопки", "bb-btn-primary (#FFA39C)", "bb-btn-primary (#FFA39C)"],
].map(([param, f1, f2]) => (
<tr key={param} style={{ borderTop: "1px solid var(--bb-border)" }}>
<td className="px-4 py-2 font-medium text-xs" style={{ color: "var(--bb-text)" }}>{param}</td>
<td className="px-4 py-2 text-xs" style={{ color: "var(--bb-text-muted)" }}>{f1}</td>
<td className="px-4 py-2 text-xs" style={{ color: "var(--bb-text-muted)" }}>{f2}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
{/* LLM блок */}
<LlmBlock path="/blocks/contact-forms" version="v1.2" specText={LLM_FORMS_TEXT}>
<LlmSection title="Форма 1 — Запись на приём" />
<LlmTable
headers={["Поле", "Тип", "Placeholder"]}
rows={[
["Имя", "input[type=text]", "«Введите ваше имя»"],
["Телефон", "input[type=tel]", "«Введите ваш телефон»"],
["Врач", "select", "«Выберите ЛОР врача»"],
["Согласие", "checkbox", "«Отправляя данные, я даю согласие...»"],
["Кнопка", "button bb-btn-primary", "«Запишите меня!»"],
]}
/>
<LlmSection title="Форма 2 — Запрос стоимости" />
<LlmTable
headers={["Поле", "Тип", "Placeholder"]}
rows={[
["Имя", "input[type=text]", "«Введите ваше имя»"],
["Телефон", "input[type=tel]", "«Введите ваш телефон»"],
["Согласие", "checkbox", "«Отправляя данные, я даю согласие...»"],
["Кнопка", "button bb-btn-primary", "«Перезвоните мне»"],
]}
/>
<LlmSection title="Правила" />
<LlmRules
rules={[
{ ok: true, text: "Обе формы: фон #d4f6f8" },
{ ok: true, text: "Чекбокс согласия обязателен в каждой форме" },
{ ok: true, text: "Кнопки: bb-btn-primary (#FFA39C)" },
{ ok: true, text: "Все input/select: height 50px (bb-input, bb-select)" },
{ ok: false, text: "Не убирать чекбокс согласия на обработку данных" },
{ ok: false, text: "Не менять стиль кнопки на outline или teal" },
]}
/>
</LlmBlock>
</div>
);
}

230
apps/web/app/blocks/contact-forms/page.tsx

@ -1,10 +1,236 @@
import type { Metadata } from "next";
import ContactFormsPageClient from "./ContactFormsPageClient";
import { LlmBlock, LlmSection, LlmTable, LlmRules } from "@/components/llm/LlmBlock";
export const metadata: Metadata = {
title: "Формы записи. Цифровой брендбук Клиники ухо, горло, нос им. проф. Е.Н.Оленевой",
};
const LLM_FORMS_TEXT = `
БЛОК: Формы записи и обратной связи
Источник: perm.oclinica.ru/lor два блока форм на одной странице
Версия: v1.0
ФОРМА 1: «Будьте здоровы!» (форма записи на приём)
Позиция: после блока отзывов
Заголовок H2: «Будьте здоровы!»
Подзаголовок: «Запишитесь на приём к врачу!»
Фон секции: #b8e6ed (светло-бирюзовый)
Поля формы:
1. Текстовый input: placeholder «Введите ваше имя» (height 50px)
2. Телефонный input: placeholder «Введите ваш телефон» (height 50px)
3. Select: «Выберите ЛОР врача» (height 50px)
4. Checkbox: «Отправляя данные, я даю согласие на обработку персональных данных»
Кнопка: «Запишите меня!» стиль bb-btn-primary (#FFA39C)
Ширина формы: ~400px центрирована или в колонку
ФОРМА 2: «Узнайте стоимость операции» (консультация)
Позиция: после блока новостей
Заголовок H2: «Узнайте стоимость операции»
Подзаголовок: «Проконсультируйтесь с ассистентом хирурга»
Фон секции: #ffffff
Поля формы:
1. Текстовый input: placeholder «каша» / «Введите ваше имя» (height 50px)
2. Телефонный input: placeholder «Введите ваш телефон» (height 50px)
3. Checkbox: «Отправляя данные, я даю согласие на обработку персональных данных»
Кнопка: «Перезвоните мне» стиль bb-btn-primary (#FFA39C)
ОБЩИЕ ПРАВИЛА:
Оба поля input/select: bb-input / bb-select (height 50px, border 1px solid #ccc)
Чекбокс обязателен в обеих формах
Кнопка отправки: всегда bb-btn-primary (#FFA39C)
Форма 1 на бирюзовом фоне (#b8e6ed), форма 2 на белом
ПРАВИЛА:
Чекбокс согласия обязателен в каждой форме
Кнопки отправки: bb-btn-primary (#FFA39C)
Форма 1 на фоне #b8e6ed
Не менять порядок полей
Не убирать чекбокс согласия
`.trim();
export default function ContactFormsPage() {
return <ContactFormsPageClient />;
return (
<div className="p-8 max-w-5xl mx-auto space-y-10">
{/* Заголовок страницы */}
<div>
<p
className="text-xs font-semibold uppercase tracking-widest mb-1"
style={{ color: "var(--brand-053m)" }}
>
Блоки
</p>
<h1 className="text-2xl font-bold mb-2" style={{ color: "var(--bb-text)" }}>
Формы записи
</h1>
<p className="text-sm" style={{ color: "var(--bb-text-muted)" }}>
Два блока форм с perm.oclinica.ru/lor запись на приём и запрос стоимости операции.
</p>
</div>
{/* Форма 1: Будьте здоровы */}
<section className="space-y-3">
<h2 className="font-semibold text-base" style={{ color: "var(--bb-text)" }}>
Форма 1 «Будьте здоровы!»
</h2>
<p className="text-sm" style={{ color: "var(--bb-text-muted)" }}>
Фон секции: #b8e6ed. Запись на приём к врачу.
</p>
<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>
</section>
{/* Форма 2: Узнайте стоимость */}
<section className="space-y-3">
<h2 className="font-semibold text-base" style={{ color: "var(--bb-text)" }}>
Форма 2 «Узнайте стоимость операции»
</h2>
<p className="text-sm" style={{ color: "var(--bb-text-muted)" }}>
Фон секции: #ffffff. Запрос консультации по стоимости.
</p>
<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>
</section>
{/* Сравнение двух форм */}
<section className="space-y-3">
<h2 className="font-semibold text-base" style={{ color: "var(--bb-text)" }}>
Сравнение двух форм
</h2>
<div className="overflow-x-auto rounded-xl" style={{ border: "1px solid var(--bb-border)" }}>
<table className="w-full text-sm border-collapse">
<thead>
<tr style={{ background: "var(--bb-sidebar-bg)" }}>
<th className="text-left px-4 py-3 text-xs font-semibold" style={{ color: "var(--bb-text-muted)", borderBottom: "1px solid var(--bb-border)" }}>Параметр</th>
<th className="text-left px-4 py-3 text-xs font-semibold" style={{ color: "var(--bb-text-muted)", borderBottom: "1px solid var(--bb-border)" }}>Форма 1 «Будьте здоровы!»</th>
<th className="text-left px-4 py-3 text-xs font-semibold" style={{ color: "var(--bb-text-muted)", borderBottom: "1px solid var(--bb-border)" }}>Форма 2 «Стоимость операции»</th>
</tr>
</thead>
<tbody>
{[
["Заголовок", "«Будьте здоровы!»", "«Узнайте стоимость операции»"],
["Подзаголовок", "«Запишитесь на приём к врачу!»", "«Проконсультируйтесь с ассистентом хирурга»"],
["Фон секции", "#b8e6ed", "#ffffff"],
["Поля", "Имя + Телефон + Select врача + Checkbox", "Имя + Телефон + Checkbox"],
["Кнопка", "«Запишите меня!»", "«Перезвоните мне»"],
["Стиль кнопки", "bb-btn-primary (#FFA39C)", "bb-btn-primary (#FFA39C)"],
].map(([param, f1, f2]) => (
<tr key={param} style={{ borderTop: "1px solid var(--bb-border)" }}>
<td className="px-4 py-2 font-medium text-xs" style={{ color: "var(--bb-text)" }}>{param}</td>
<td className="px-4 py-2 text-xs" style={{ color: "var(--bb-text-muted)" }}>{f1}</td>
<td className="px-4 py-2 text-xs" style={{ color: "var(--bb-text-muted)" }}>{f2}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
{/* LLM блок */}
<LlmBlock path="/blocks/contact-forms" version="v1.0" specText={LLM_FORMS_TEXT}>
<LlmSection title="Форма 1 — Запись на приём" />
<LlmTable
headers={["Поле", "Тип", "Placeholder"]}
rows={[
["Имя", "input[type=text]", "«Введите ваше имя»"],
["Телефон", "input[type=tel]", "«Введите ваш телефон»"],
["Врач", "select", "«Выберите ЛОР врача»"],
["Согласие", "checkbox", "«Отправляя данные, я даю согласие...»"],
["Кнопка", "button bb-btn-primary", "«Запишите меня!»"],
]}
/>
<LlmSection title="Форма 2 — Запрос стоимости" />
<LlmTable
headers={["Поле", "Тип", "Placeholder"]}
rows={[
["Имя", "input[type=text]", "«Введите ваше имя»"],
["Телефон", "input[type=tel]", "«Введите ваш телефон»"],
["Согласие", "checkbox", "«Отправляя данные, я даю согласие...»"],
["Кнопка", "button bb-btn-primary", "«Перезвоните мне»"],
]}
/>
<LlmSection title="Правила" />
<LlmRules
rules={[
{ ok: true, text: "Форма 1: фон #b8e6ed, форма 2: фон #fff" },
{ ok: true, text: "Чекбокс согласия обязателен в каждой форме" },
{ ok: true, text: "Кнопки: bb-btn-primary (#FFA39C)" },
{ ok: true, text: "Все input/select: height 50px (bb-input, bb-select)" },
{ ok: false, text: "Не убирать чекбокс согласия на обработку данных" },
{ ok: false, text: "Не менять стиль кнопки на outline или teal" },
]}
/>
</LlmBlock>
</div>
);
}

204
apps/web/app/blocks/contact/ContactPageClient.tsx

@ -1,204 +0,0 @@
"use client";
import { useState } from "react";
import { LlmBlock, LlmSection, LlmTable, LlmRules } from "@/components/llm/LlmBlock";
import { BlockMetaBar, type SnapshotData } from "@/components/ui/BlockMetaBar";
import { type ChangelogEntry } from "@/components/ui/BlockChangelog";
import { FooterBlock } from "@/components/blocks/FooterBlock";
const BLOCK_PATH = "/blocks/contact";
const FOOTER_COLUMNS = [
{
title: "О клинике",
links: ["Лицензия", "Миссия", "Врачи", "Вакансии", "История", "Образовательная деятельность", "При инфо"],
},
{
title: "Заболевания",
links: ["Ринит", "Отит", "Гайморит", "Тонзиллит", "Полипы носа", "Искривление перегородки"],
},
{
title: "Вопрос-ответ",
links: [
"Что нужно знать до операции на ухо",
"Что нужно знать до операции на нос",
"Отзывы до и после лечения у детей",
"Что нужно знать при лечении у детей",
],
},
{
title: "Операции",
links: ["Септопластика", "Турбинопластика", "Тонзиллэктомия", "Аденотомия", "Тимпанопластика", "Мирингопластика"],
},
];
const LLM_FOOTER_TEXT = `
БЛОК: Подвал сайта (Footer)
Источник: perm.oclinica.ru/lor нижняя часть страницы
Версия: v1.1
СТРУКТУРА ПОДВАЛА:
1. ОСНОВНАЯ ЧАСТЬ 4 колонки ссылок:
Колонка 1 «О клинике»: Лицензия, Миссия, Врачи, Вакансии, История, Образовательная деятельность
Колонка 2 «Заболевания»: Ринит, Отит, Гайморит, Тонзиллит, Полипы носа, Искривление перегородки
Колонка 3 «Вопрос-ответ»: 4 вопроса об операциях и лечении
Колонка 4 «Операции»: Септопластика, Турбинопластика, Тонзиллэктомия, Аденотомия и др.
2. НИЖНЯЯ ПОЛОСА:
Левая: Логотип «КЛИНИКА УХО ГОРЛО НОС ИМ. ПРОФ. Е.Н.ОЛЕНЕВОЙ»
Центр:
«Мы находимся по адресу: Пермь, ул. Г. Звезда...»
Иконки соцсетей: VK, OK, YouTube, Telegram и другие
Правая: Часы работы:
Пн-пт: 9:0021:00
Сб: 9:0018:00
Вс: выходной
Вторая клиника часы отдельно
ЦВЕТА:
Фон подвала: #fff или светло-серый (#f8f9fa)
Заголовки колонок: #111827, font-weight 600
Ссылки: #53514e (--brand-073m), hover: #0089c3
Разделитель: border-top 1px solid #e5e7eb
Часы работы: #374151
ПРАВИЛА:
4 колонки ссылок в основной части
Логотип в нижней полосе слева
Адрес + соцсети в центре нижней полосы
Часы работы справа
Не менять структуру 4 колонок
`.trim();
const CHANGELOG: ChangelogEntry[] = [
{
version: "v1.1",
date: "24.03.2026",
changes: [
"Колонка «О клинике»: обновлены все ссылки по реальному сайту (13 ссылок)",
"Колонка «Заболевания»: обновлены ссылки (5 категорий)",
"Колонка «Вопрос-ответ»: обновлены ссылки (6 пунктов)",
"Колонка «Операции»: обновлены ссылки (11 операций, было 6)",
"Два адреса: Клары Цеткин, 9 + Газеты Звезда, 31А",
"Два графика работы по филиалам",
"Соцсети: добавлен Дзен",
],
},
];
export default function ContactPageClient() {
const [snapshot, setSnapshot] = useState<SnapshotData | null>(null);
return (
<div className="p-8 max-w-5xl mx-auto space-y-10">
{/* Заголовок страницы */}
<div>
<p
className="text-xs font-semibold uppercase tracking-widest mb-1"
style={{ color: "var(--brand-053m)" }}
>
Блоки
</p>
<h1 className="text-2xl font-bold mb-2" style={{ color: "var(--bb-text)" }}>
Подвал (Footer)
</h1>
<BlockMetaBar
path={BLOCK_PATH}
defaultVersion="v1.1"
defaultIsInPreview={false}
defaultChangelog={CHANGELOG}
onSnapshotSelect={setSnapshot}
/>
<p className="text-sm" style={{ color: "var(--bb-text-muted)" }}>
Подвал сайта с perm.oclinica.ru 4 колонки ссылок, логотип, адрес, часы работы, соцсети.
</p>
</div>
{/* Живой пример */}
<section className="space-y-3">
<h2 className="font-semibold text-base" style={{ color: "var(--bb-text)" }}>
Живой пример
</h2>
{snapshot ? (
<>
<style dangerouslySetInnerHTML={{ __html: snapshot.css }} />
<div dangerouslySetInnerHTML={{ __html: snapshot.html }} />
</>
) : (
<div data-block-capture={BLOCK_PATH}>
<FooterBlock />
</div>
)}
</section>
{/* Колонки */}
<section className="space-y-3">
<h2 className="font-semibold text-base" style={{ color: "var(--bb-text)" }}>
Четыре колонки ссылок
</h2>
<div className="grid grid-cols-4 gap-3">
{FOOTER_COLUMNS.map((col) => (
<div
key={col.title}
className="rounded-xl p-3 space-y-1"
style={{ background: "var(--bb-sidebar-bg)", border: "1px solid var(--bb-border)" }}
>
<p className="font-semibold text-xs" style={{ color: "var(--bb-text)" }}>
{col.title}
</p>
<p className="text-[10px]" style={{ color: "var(--bb-text-muted)" }}>
{col.links.length} ссылок
</p>
</div>
))}
</div>
</section>
{/* LLM блок */}
<LlmBlock path="/blocks/contact" version="v1.1" specText={LLM_FOOTER_TEXT}>
<LlmSection title="Структура подвала" />
<LlmTable
headers={["Зона", "Содержимое", "Фон"]}
rows={[
["4 колонки ссылок", "О клинике / Заболевания / Вопрос-ответ / Операции", "#f8f9fa"],
["Нижняя полоса — лево", "Логотип клиники (иконка + текст)", "#fff"],
["Нижняя полоса — центр", "Адрес + иконки соцсетей (VK, OK, YT, TG)", "#fff"],
["Нижняя полоса — право", "Часы работы Пн–пт / Сб / Вс", "#fff"],
]}
/>
<LlmSection title="Часы работы" />
<LlmTable
headers={["День", "Часы"]}
rows={[
["Пн–пт", "9:00–21:00"],
["Сб", "9:00–18:00"],
["Вс", "выходной"],
]}
/>
<LlmSection title="Цвета" />
<LlmTable
headers={["Элемент", "Цвет", "Токен"]}
rows={[
["Заголовки колонок", "#111827, font-weight 600", "—"],
["Ссылки (default)", "#53514e", "--brand-073m"],
["Ссылки (hover)", "#0089c3", "--brand-053m"],
["Фон основной части", "#f8f9fa", "—"],
["Фон нижней полосы", "#ffffff", "—"],
["Разделитель", "1px solid #e5e7eb", "—"],
]}
/>
<LlmSection title="Правила" />
<LlmRules
rules={[
{ ok: true, text: "4 колонки: О клинике / Заболевания / Вопрос-ответ / Операции" },
{ ok: true, text: "Логотип в нижней полосе слева" },
{ ok: true, text: "Адрес и соцсети в центре" },
{ ok: true, text: "Часы работы справа" },
{ ok: false, text: "Не менять структуру и порядок 4 колонок" },
]}
/>
</LlmBlock>
</div>
);
}

251
apps/web/app/blocks/contact/page.tsx

@ -1,10 +1,257 @@
import type { Metadata } from "next";
import ContactPageClient from "./ContactPageClient";
import { LlmBlock, LlmSection, LlmTable, LlmRules } from "@/components/llm/LlmBlock";
export const metadata: Metadata = {
title: "Подвал (Footer). Цифровой брендбук Клиники ухо, горло, нос им. проф. Е.Н.Оленевой",
};
const FOOTER_COLUMNS = [
{
title: "О клинике",
links: ["Лицензия", "Миссия", "Врачи", "Вакансии", "История", "Образовательная деятельность", "При инфо"],
},
{
title: "Заболевания",
links: ["Ринит", "Отит", "Гайморит", "Тонзиллит", "Полипы носа", "Искривление перегородки"],
},
{
title: "Вопрос-ответ",
links: [
"Что нужно знать до операции на ухо",
"Что нужно знать до операции на нос",
"Отзывы до и после лечения у детей",
"Что нужно знать при лечении у детей",
],
},
{
title: "Операции",
links: ["Септопластика", "Турбинопластика", "Тонзиллэктомия", "Аденотомия", "Тимпанопластика", "Мирингопластика"],
},
];
const LLM_FOOTER_TEXT = `
БЛОК: Подвал сайта (Footer)
Источник: perm.oclinica.ru/lor нижняя часть страницы
Версия: v1.0
СТРУКТУРА ПОДВАЛА:
1. ОСНОВНАЯ ЧАСТЬ 4 колонки ссылок:
Колонка 1 «О клинике»: Лицензия, Миссия, Врачи, Вакансии, История, Образовательная деятельность
Колонка 2 «Заболевания»: Ринит, Отит, Гайморит, Тонзиллит, Полипы носа, Искривление перегородки
Колонка 3 «Вопрос-ответ»: 4 вопроса об операциях и лечении
Колонка 4 «Операции»: Септопластика, Турбинопластика, Тонзиллэктомия, Аденотомия и др.
2. НИЖНЯЯ ПОЛОСА:
Левая: Логотип «КЛИНИКА УХО ГОРЛО НОС ИМ. ПРОФ. Е.Н.ОЛЕНЕВОЙ»
Центр:
«Мы находимся по адресу: Пермь, ул. Г. Звезда...»
Иконки соцсетей: VK, OK, YouTube, Telegram и другие
Правая: Часы работы:
Пн-пт: 9:0021:00
Сб: 9:0018:00
Вс: выходной
Вторая клиника часы отдельно
ЦВЕТА:
Фон подвала: #fff или светло-серый (#f8f9fa)
Заголовки колонок: #111827, font-weight 600
Ссылки: #53514e (--brand-073m), hover: #0089c3
Разделитель: border-top 1px solid #e5e7eb
Часы работы: #374151
ПРАВИЛА:
4 колонки ссылок в основной части
Логотип в нижней полосе слева
Адрес + соцсети в центре нижней полосы
Часы работы справа
Не менять структуру 4 колонок
`.trim();
export default function ContactFooterPage() {
return <ContactPageClient />;
return (
<div className="p-8 max-w-5xl mx-auto space-y-10">
{/* Заголовок страницы */}
<div>
<p
className="text-xs font-semibold uppercase tracking-widest mb-1"
style={{ color: "var(--brand-053m)" }}
>
Блоки
</p>
<h1 className="text-2xl font-bold mb-2" style={{ color: "var(--bb-text)" }}>
Подвал (Footer)
</h1>
<p className="text-sm" style={{ color: "var(--bb-text-muted)" }}>
Подвал сайта с perm.oclinica.ru 4 колонки ссылок, логотип, адрес, часы работы, соцсети.
</p>
</div>
{/* Живой пример */}
<section className="space-y-3">
<h2 className="font-semibold text-base" style={{ color: "var(--bb-text)" }}>
Живой пример
</h2>
<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>
</section>
{/* Колонки */}
<section className="space-y-3">
<h2 className="font-semibold text-base" style={{ color: "var(--bb-text)" }}>
Четыре колонки ссылок
</h2>
<div className="grid grid-cols-4 gap-3">
{FOOTER_COLUMNS.map((col) => (
<div
key={col.title}
className="rounded-xl p-3 space-y-1"
style={{ background: "var(--bb-sidebar-bg)", border: "1px solid var(--bb-border)" }}
>
<p className="font-semibold text-xs" style={{ color: "var(--bb-text)" }}>
{col.title}
</p>
<p className="text-[10px]" style={{ color: "var(--bb-text-muted)" }}>
{col.links.length} ссылок
</p>
</div>
))}
</div>
</section>
{/* LLM блок */}
<LlmBlock path="/blocks/contact" version="v1.0" specText={LLM_FOOTER_TEXT}>
<LlmSection title="Структура подвала" />
<LlmTable
headers={["Зона", "Содержимое", "Фон"]}
rows={[
["4 колонки ссылок", "О клинике / Заболевания / Вопрос-ответ / Операции", "#f8f9fa"],
["Нижняя полоса — лево", "Логотип клиники (иконка + текст)", "#fff"],
["Нижняя полоса — центр", "Адрес + иконки соцсетей (VK, OK, YT, TG)", "#fff"],
["Нижняя полоса — право", "Часы работы Пн–пт / Сб / Вс", "#fff"],
]}
/>
<LlmSection title="Часы работы" />
<LlmTable
headers={["День", "Часы"]}
rows={[
["Пн–пт", "9:00–21:00"],
["Сб", "9:00–18:00"],
["Вс", "выходной"],
]}
/>
<LlmSection title="Цвета" />
<LlmTable
headers={["Элемент", "Цвет", "Токен"]}
rows={[
["Заголовки колонок", "#111827, font-weight 600", "—"],
["Ссылки (default)", "#53514e", "--brand-073m"],
["Ссылки (hover)", "#0089c3", "--brand-053m"],
["Фон основной части", "#f8f9fa", "—"],
["Фон нижней полосы", "#ffffff", "—"],
["Разделитель", "1px solid #e5e7eb", "—"],
]}
/>
<LlmSection title="Правила" />
<LlmRules
rules={[
{ ok: true, text: "4 колонки: О клинике / Заболевания / Вопрос-ответ / Операции" },
{ ok: true, text: "Логотип в нижней полосе слева" },
{ ok: true, text: "Адрес и соцсети в центре" },
{ ok: true, text: "Часы работы справа" },
{ ok: false, text: "Не менять структуру и порядок 4 колонок" },
]}
/>
</LlmBlock>
</div>
);
}

187
apps/web/app/blocks/doctors/DoctorsPageClient.tsx

@ -1,187 +0,0 @@
"use client";
import { useState } from "react";
import { LlmBlock, LlmSection, LlmTable, LlmRules } from "@/components/llm/LlmBlock";
import { DoctorsBlock, STATS, DOCTORS } from "@/components/blocks/DoctorsBlock";
import { BlockMetaBar, type SnapshotData } from "@/components/ui/BlockMetaBar";
import { type ChangelogEntry } from "@/components/ui/BlockChangelog";
const BLOCK_PATH = "/blocks/doctors";
const LLM_DOCTORS_TEXT = `
БЛОК: Наши врачи
Источник: perm.oclinica.ru/lor блок под CEO-текстом
Версия: v1.2
СТРУКТУРА БЛОКА:
1. ЗАГОЛОВОК H2: «Приём ведут опытные ЛОР врачи»
Подзаголовок: описание принципа работы врачей клиники
Размер: 36px, font-bold, #000000, line-height: 38px
2. БЛОК СТАТИСТИКИ (3 показателя в ряд):
«Ежедневно 27 ЛОР врачей работают в клинике»
«В том числе 6 кандидатов медицинских наук»
«Свыше 12 000 успешно проведённых операций»
Стиль: весь текст #60959c, bold, ~18px. Фона НЕТ. Снизу border-bottom 3px solid #60959c
3. СЕТКА КАРТОЧЕК ВРАЧЕЙ (6 штук, 6 в ряд):
Каждая карточка:
Фото врача 110×150px, object-fit: cover, object-position: center top, border-radius 4px
Имя (12px, font-weight 500, цвет #60959c)
Специализация (11px, #374151)
Карточки без рамки, gap минимальный
ЦВЕТА:
Заголовок H2: #000000, 36px, line-height 38px
Статистика текст: #60959c (серо-бирюзовый)
Статистика черта: border-bottom 3px solid #60959c
Имя врача: #60959c
Специализация: #374151
ПРАВИЛА:
Заголовок H2 + описание обязательны
3 stat-блока в ряд, без фоновых блоков
Сетка 6 колонок (6 карточек в ряд)
Не отображать более 6 врачей в основном блоке
Не убирать статистику
`.trim();
const CHANGELOG: ChangelogEntry[] = [
{
version: "v1.2",
date: "24.03.2026",
changes: [
"H2: размер с ~30px на 36px, цвет с #111827 на #000000",
"H2 line-height: 38px",
],
},
{
version: "v1.1",
date: "23.03.2026",
changes: [
"6 реальных фото врачей с сайта",
"Статистика без фона, только border-bottom #60959c",
],
},
];
export default function DoctorsPageClient() {
const [snapshot, setSnapshot] = useState<SnapshotData | null>(null);
return (
<div className="p-8 max-w-5xl mx-auto space-y-10">
{/* Заголовок страницы */}
<div>
<p
className="text-xs font-semibold uppercase tracking-widest mb-1"
style={{ color: "var(--brand-053m)" }}
>
Блоки
</p>
<h1 className="text-2xl font-bold mb-2" style={{ color: "var(--bb-text)" }}>
Блок «Наши врачи»
</h1>
<BlockMetaBar
path={BLOCK_PATH}
defaultVersion="v1.2"
defaultIsInPreview={true}
defaultChangelog={CHANGELOG}
onSnapshotSelect={setSnapshot}
/>
<p className="text-sm" style={{ color: "var(--bb-text-muted)" }}>
Блок на странице perm.oclinica.ru/lor заголовок, 3 стат-блока, сетка из 6 карточек врачей.
</p>
</div>
{/* Живой пример */}
<section className="space-y-4">
<h2 className="font-semibold text-base" style={{ color: "var(--bb-text)" }}>
Живой пример
</h2>
{snapshot ? (
<div
className="rounded-xl p-8"
style={{ background: "#fff", border: "1px solid var(--bb-border)" }}
>
<style dangerouslySetInnerHTML={{ __html: snapshot.css }} />
<div dangerouslySetInnerHTML={{ __html: snapshot.html }} />
</div>
) : (
<div
data-block-capture={BLOCK_PATH}
className="rounded-xl p-8"
style={{ background: "#fff", border: "1px solid var(--bb-border)" }}
>
<DoctorsBlock />
</div>
)}
</section>
{/* Стат-блоки — разбор */}
<section className="space-y-3">
<h2 className="font-semibold text-base" style={{ color: "var(--bb-text)" }}>
Блок статистики
</h2>
<div className="grid grid-cols-3 gap-6">
{STATS.map((s) => (
<div
key={s.num}
className="pb-3 space-y-1"
style={{ borderBottom: "3px solid #60959c" }}
>
<p className="text-base font-bold leading-snug" style={{ color: "#60959c" }}>
{s.prefix} {s.num} {s.label}
</p>
<p className="text-[10px] font-mono" style={{ color: "var(--bb-text-muted)" }}>
цвет текста: #60959c · черта: 3px solid #60959c
</p>
</div>
))}
</div>
</section>
{/* LLM блок */}
<LlmBlock path="/blocks/doctors" version="v1.2" specText={LLM_DOCTORS_TEXT}>
<LlmSection title="Структура блока" />
<LlmTable
headers={["Элемент", "Содержимое", "Стиль"]}
rows={[
["H2", "«Приём ведут опытные ЛОР врачи»", "36px, font-bold, #000000, line-height 38px"],
["Подзаголовок", "Описание принципа работы", "14px, #374151, line-height 1.7"],
["Статистика", "3 блока в ряд, без фона", "#60959c bold + border-bottom 3px solid #60959c"],
["Сетка врачей", "6 карточек в 1 ряд", "фото 110×150px + имя #60959c + специализация #374151"],
]}
/>
<LlmSection title="Три показателя" />
<LlmTable
headers={["Число", "Описание"]}
rows={STATS.map((s) => [`${s.prefix} ${s.num}`, s.label])}
/>
<LlmSection title="6 врачей слайдера (perm.oclinica.ru/lor)" />
<LlmTable
headers={["Имя", "Специализация"]}
rows={DOCTORS.map((d) => [d.name, d.spec])}
/>
<LlmSection title="Карточка врача" />
<LlmTable
headers={["Поле", "Размер / Стиль"]}
rows={[
["Фото", "110×150px, object-fit: cover, object-position: center top, border-radius 4px"],
["Имя", "12px, font-weight 500, #60959c"],
["Специализация", "11px, #374151"],
]}
/>
<LlmSection title="Правила" />
<LlmRules
rules={[
{ ok: true, text: "H2 + описание обязательны" },
{ ok: true, text: "3 stat-блока в ряд, без фоновых рамок" },
{ ok: true, text: "Сетка 6 карточек в 1 ряд (6 колонок)" },
{ ok: false, text: "Не показывать более 6 врачей в основном блоке" },
{ ok: false, text: "Не убирать статистику" },
]}
/>
</LlmBlock>
</div>
);
}

232
apps/web/app/blocks/doctors/page.tsx

@ -1,10 +1,238 @@
import type { Metadata } from "next";
import DoctorsPageClient from "./DoctorsPageClient";
import { LlmBlock, LlmSection, LlmTable, LlmRules } from "@/components/llm/LlmBlock";
export const metadata: Metadata = {
title: "Блок «Наши врачи». Цифровой брендбук Клиники ухо, горло, нос им. проф. Е.Н.Оленевой",
};
const STATS = [
{ num: "27", label: "ЛОР врачей работает в клинике", prefix: "Ежедневно" },
{ num: "6", label: "кандидатов медицинских наук", prefix: "В том числе" },
{ num: "12 000+", label: "успешно проведённых операций", prefix: "Свыше" },
];
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",
},
];
const LLM_DOCTORS_TEXT = `
БЛОК: Наши врачи
Источник: perm.oclinica.ru/lor блок под CEO-текстом
Версия: v1.0
СТРУКТУРА БЛОКА:
1. ЗАГОЛОВОК H2: «Приём ведут опытные ЛОР врачи»
Подзаголовок: описание принципа работы врачей клиники
2. БЛОК СТАТИСТИКИ (3 показателя в ряд):
«Ежедневно 27 ЛОР врачей работают в клинике»
«В том числе 6 кандидатов медицинских наук»
«Свыше 12 000 успешно проведённых операций»
Стиль: весь текст #60959c, bold, ~18px. Фона НЕТ. Снизу border-bottom 3px solid #60959c
3. СЕТКА КАРТОЧЕК ВРАЧЕЙ (6 штук, 6 в ряд):
Каждая карточка:
Фото врача 110×150px, object-fit: cover, object-position: center top, border-radius 4px
Имя (12px, font-weight 500, цвет #60959c)
Специализация (11px, #374151)
Карточки без рамки, gap минимальный
ЦВЕТА:
Заголовок H2: #111827, ~30px (text-3xl)
Статистика текст: #60959c (серо-бирюзовый)
Статистика черта: border-bottom 3px solid #60959c
Имя врача: #60959c
Специализация: #374151
ПРАВИЛА:
Заголовок H2 + описание обязательны
3 stat-блока в ряд
Сетка 3 колонки, 2 ряда (6 карточек)
Не отображать более 6 врачей в основном блоке
Не убирать статистику
`.trim();
export default function DoctorsBlockPage() {
return <DoctorsPageClient />;
return (
<div className="p-8 max-w-5xl mx-auto space-y-10">
{/* Заголовок страницы */}
<div>
<p
className="text-xs font-semibold uppercase tracking-widest mb-1"
style={{ color: "var(--brand-053m)" }}
>
Блоки
</p>
<h1 className="text-2xl font-bold mb-2" style={{ color: "var(--bb-text)" }}>
Блок «Наши врачи»
</h1>
<p className="text-sm" style={{ color: "var(--bb-text-muted)" }}>
Блок на странице perm.oclinica.ru/lor заголовок, 3 стат-блока, сетка из 6 карточек врачей.
</p>
</div>
{/* Живой пример */}
<section className="space-y-4">
<h2 className="font-semibold text-base" style={{ color: "var(--bb-text)" }}>
Живой пример
</h2>
<div
className="rounded-xl p-8 space-y-8"
style={{ background: "#fff", border: "1px solid var(--bb-border)" }}
>
{/* Заголовок */}
<div>
<h2 className="text-3xl font-bold mb-3" style={{ color: "#111827" }}>
Приём ведут опытные ЛОР врачи
</h2>
<p className="text-sm" style={{ color: "#374151", lineHeight: 1.7 }}>
Фундаментальная теоретическая подготовка и большой практический опыт в сочетании
с внимательным индивидуальным подходом являются причиной успеха лечения тысяч наших пациентов
</p>
</div>
{/* Статистика — текст тёмно-бирюзовым, без фоновых блоков */}
<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>
{/* Сетка врачей — плотнее, имена тёмно-бирюзовым */}
<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>
</section>
{/* Стат-блоки */}
<section className="space-y-3">
<h2 className="font-semibold text-base" style={{ color: "var(--bb-text)" }}>
Блок статистики
</h2>
<div className="grid grid-cols-3 gap-6">
{STATS.map((s) => (
<div
key={s.num}
className="pb-3 space-y-1"
style={{ borderBottom: "3px solid #60959c" }}
>
<p className="text-base font-bold leading-snug" style={{ color: "#60959c" }}>
{s.prefix} {s.num} {s.label}
</p>
<p className="text-[10px] font-mono" style={{ color: "var(--bb-text-muted)" }}>
цвет текста: #60959c · черта: 3px solid #60959c
</p>
</div>
))}
</div>
</section>
{/* LLM блок */}
<LlmBlock path="/blocks/doctors" version="v1.1" specText={LLM_DOCTORS_TEXT}>
<LlmSection title="Структура блока" />
<LlmTable
headers={["Элемент", "Содержимое", "Стиль"]}
rows={[
["H2", "«Приём ведут опытные ЛОР врачи»", "~30px (text-3xl), font-bold, #111827"],
["Подзаголовок", "Описание принципа работы", "14px, #374151, line-height 1.7"],
["Статистика", "3 блока в ряд, без фона", "#60959c bold + border-bottom 3px solid #60959c"],
["Сетка врачей", "6 карточек в 1 ряд", "фото 110×150px + имя #60959c + специализация #374151"],
]}
/>
<LlmSection title="Три показателя" />
<LlmTable
headers={["Число", "Описание"]}
rows={STATS.map((s) => [`${s.prefix} ${s.num}`, s.label])}
/>
<LlmSection title="6 врачей слайдера (perm.oclinica.ru/lor)" />
<LlmTable
headers={["Имя", "Специализация"]}
rows={DOCTORS.map((d) => [d.name, d.spec])}
/>
<LlmSection title="Карточка врача" />
<LlmTable
headers={["Поле", "Размер / Стиль"]}
rows={[
["Фото", "88×120px (в блоке) или 110×160px (на странице врачей), object-fit: cover"],
["Имя", "12–14px, font-weight 500, #111827"],
["Специализация", "11–12px, #6b7280"],
]}
/>
<LlmSection title="Правила" />
<LlmRules
rules={[
{ ok: true, text: "H2 + описание обязательны" },
{ ok: true, text: "3 stat-блока в ряд, фон #dff0fa" },
{ ok: true, text: "Сетка 6 карточек (3 колонки)" },
{ ok: false, text: "Не показывать более 6 врачей в основном блоке" },
{ ok: false, text: "Не убирать статистику" },
]}
/>
</LlmBlock>
</div>
);
}

231
apps/web/app/blocks/hero/HeroPageClient.tsx

@ -1,231 +0,0 @@
"use client";
import { useState } from "react";
import { LlmBlock, LlmSection, LlmTable, LlmRules } from "@/components/llm/LlmBlock";
import { HeroBlock, HERO_CHECKS } from "@/components/blocks/HeroBlock";
import { BlockMetaBar, type SnapshotData } from "@/components/ui/BlockMetaBar";
import { type ChangelogEntry } from "@/components/ui/BlockChangelog";
const BLOCK_PATH = "/blocks/hero";
const LLM_HERO_TEXT = `
БЛОК: Hero-баннер (главный баннер страницы)
Источник: perm.oclinica.ru/lor реальный баннер раздела ЛОР
Версия: v1.3
ЗАГОЛОВОК СТРАНИЦЫ (H1, над баннером):
«ЛОР Клиника ухо, горло, нос - медицинский центр лечения ЛОР заболеваний у детей и взрослых»
Шрифт: Fira Sans, 36px, weight 700, цвет: #cb9768
СТРУКТУРА БАННЕРА (двухколоночная, единый фон #f9f4e7):
Левая колонка (~50%):
Заголовок: «ЭНДОСКОПИЧЕСКОЕ ХИРУРГИЧЕСКОЕ ЛЕЧЕНИЕ ЛОР ОРГАНОВ»
Шрифт: Fira Sans, 22px, weight 700, uppercase, цвет #333333
3 пункта с галочками ( бежевый #bf9975):
1. «БЕЗОПАСНО оперируют хирурги с 15-летним опытом работы»
2. «БЕЗ ВНЕШНИХ РАЗРЕЗОВ хирургия сверхмалых размеров»
3. «БЫСТРО под наблюдением врача пациент находится 1 сутки»
Ключевое слово: uppercase bold; описание: обычный текст, ~13px
Кнопка «Узнать стоимость операции» стиль: bb-btn-pill (кремовый фон #E9E4D4, radius 25px)
Правая колонка (~50%):
Фото врача с пациентом
Изображение занимает всю высоту блока
ПОД БАННЕРОМ:
Кнопки соцсетей (VK, FB, TW), цвет #9ca3af
Счётчик просмотров
ЦВЕТА:
Фон баннера: #f9f4e7 (светло-кремовый, единый для всего блока)
Кнопка CTA: pill-стиль (кремовый #E9E4D4, border #D5CFBD, radius 25px)
Заголовок блока: #333333
Пункты: ключевое слово #111827 bold, описание #374151
Галочка: #bf9975 (бежевый)
ПРАВИЛА:
Фон баннера всегда #f9f4e7 (светло-кремовый) единый, без разделения на зоны
Заголовок блока uppercase, жирный
Три пункта с галочками обязательны
Не менять фон баннера на другой цвет
Не разбивать фон на два разных цвета
Не убирать три пункта с галочками
`.trim();
const CHANGELOG: ChangelogEntry[] = [
{
version: "v1.3",
date: "24.03.2026",
changes: [
"Счётчик: «98 573 просмотра» заменён на «Поделиться / 98572» (как на реальном сайте)",
"Убраны кнопки VK/FB/TW",
],
},
{
version: "v1.2",
date: "24.03.2026",
changes: [
"H1: цвет исправлен с #53514e на #cb9768, размер с ~20px на 36px",
"Заголовок баннера: размер с ~16px на 22px, цвет с #111827 на #333333",
"CTA-кнопка: стиль изменён с outline на pill (фон #E9E4D4, radius 25px)",
"Дефис в H1: длинное тире «–» заменено на простой дефис «-» (как на сайте)",
],
},
{
version: "v1.1",
date: "23.03.2026",
changes: [
"Единый фон #f9f4e7 (ранее был разбит на две зоны)",
"Реальное фото врача с пациентом",
],
},
];
export default function HeroPageClient() {
const [snapshot, setSnapshot] = useState<SnapshotData | null>(null);
return (
<div className="p-8 max-w-5xl mx-auto space-y-10">
{/* Заголовок страницы */}
<div>
<p
className="text-xs font-semibold uppercase tracking-widest mb-1"
style={{ color: "var(--brand-053m)" }}
>
Блоки
</p>
<h1 className="text-2xl font-bold mb-2" style={{ color: "var(--bb-text)" }}>
Hero-баннер
</h1>
<BlockMetaBar
path={BLOCK_PATH}
defaultVersion="v1.3"
defaultIsInPreview={true}
defaultChangelog={CHANGELOG}
onSnapshotSelect={setSnapshot}
/>
<p className="text-sm" style={{ color: "var(--bb-text-muted)" }}>
Главный баннер страницы раздела ЛОР perm.oclinica.ru/lor. Двухколоночный блок, единый светло-кремовый фон{" "}
<strong>#f9f4e7</strong>.
</p>
</div>
{/* Живой пример */}
<section className="space-y-3">
<h2 className="font-semibold text-base" style={{ color: "var(--bb-text)" }}>
Живой пример
</h2>
{snapshot ? (
<>
<style dangerouslySetInnerHTML={{ __html: snapshot.css }} />
<div dangerouslySetInnerHTML={{ __html: snapshot.html }} />
</>
) : (
<div data-block-capture={BLOCK_PATH}>
<HeroBlock />
</div>
)}
</section>
{/* Анатомия */}
<section className="space-y-4">
<h2 className="font-semibold text-base" style={{ color: "var(--bb-text)" }}>
Анатомия баннера
</h2>
<div
className="rounded-xl p-5 space-y-2"
style={{ background: "#f5f0e8", border: "1px solid #d5cfbd" }}
>
<p className="font-semibold text-sm" style={{ color: "var(--bb-text)" }}>
Весь баннер единый фон <span className="font-mono">#f9f4e7</span>
</p>
<ul className="space-y-1">
{[
"Фон: #f9f4e7 (светло-кремовый) — одинаковый для всего блока",
"Левая половина (~50%): заголовок uppercase bold + 3 галочки + кнопка outline",
"Правая половина (~50%): фото врача с пациентом",
"Минимальная высота: ~280px",
].map((item) => (
<li key={item} className="text-xs flex items-start gap-1.5">
<span style={{ color: "var(--brand-053m)" }}>·</span>
<span style={{ color: "var(--bb-text-muted)" }}>{item}</span>
</li>
))}
</ul>
</div>
</section>
{/* Три пункта с галочками */}
<section className="space-y-3">
<h2 className="font-semibold text-base" style={{ color: "var(--bb-text)" }}>
Три пункта баннера
</h2>
<div className="space-y-2">
{HERO_CHECKS.map((c) => (
<div
key={c.key}
className="flex items-start gap-3 p-3 rounded-lg"
style={{ background: "var(--bb-sidebar-bg)", border: "1px solid var(--bb-border)" }}
>
<span className="font-bold text-lg shrink-0" style={{ color: "#bf9975" }}>
</span>
<div>
<span className="text-sm font-bold uppercase" style={{ color: "#111827" }}>
{c.key}
</span>
<span className="text-sm" style={{ color: "#374151" }}>
{" "} {c.desc}
</span>
</div>
</div>
))}
</div>
<p className="text-xs" style={{ color: "var(--bb-text-muted)" }}>
Ключевое слово: uppercase + bold. Описание: обычный текст. Галочка: #bf9975 (бежевый).
</p>
</section>
{/* LLM блок */}
<LlmBlock path="/blocks/hero" version="v1.3" specText={LLM_HERO_TEXT}>
<LlmSection title="Структура баннера" />
<LlmTable
headers={["Зона", "Ширина", "Фон", "Содержимое"]}
rows={[
["Весь баннер", "100%", "#f9f4e7", "Единый светло-кремовый фон"],
["Левый блок", "~50%", "#f9f4e7 (общий)", "Заголовок uppercase + 3 галочки + кнопка pill"],
["Правый блок", "~50%", "#f9f4e7 (общий)", "Фото врача с пациентом"],
["Под баннером", "100%", "#fff", "Кнопки соцсетей + счётчик просмотров"],
]}
/>
<LlmSection title="Три пункта баннера" />
<LlmTable
headers={["Ключевое слово", "Описание"]}
rows={HERO_CHECKS.map((c) => [c.key, c.desc])}
/>
<LlmSection title="Цвета" />
<LlmTable
headers={["Элемент", "Цвет", "Токен"]}
rows={[
["Фон баннера (единый)", "#f9f4e7", "Светло-кремовый фон"],
["Кнопка CTA", "pill-стиль (#E9E4D4, 25px)", "bb-btn-pill"],
["Заголовок блока", "#333333", "—"],
["H1 страницы", "#cb9768", "36px, bold"],
["Галочка ✓", "#bf9975", "Бежевый"],
]}
/>
<LlmSection title="Правила" />
<LlmRules
rules={[
{ ok: true, text: "Фон баннера: #f9f4e7 (светло-кремовый) — единый для всего блока" },
{ ok: true, text: "Кнопка CTA: bb-btn-pill (кремовый #E9E4D4, radius 25px)" },
{ ok: true, text: "Заголовок: uppercase, bold" },
{ ok: true, text: "Три пункта с галочками ✓ (#bf9975)" },
{ ok: false, text: "Не менять фон баннера на другой цвет" },
{ ok: false, text: "Не разбивать баннер на два разных цвета фона" },
{ ok: false, text: "Не убирать три пункта с галочками" },
]}
/>
</LlmBlock>
</div>
);
}

280
apps/web/app/blocks/hero/page.tsx

@ -1,10 +1,286 @@
import type { Metadata } from "next";
import HeroPageClient from "./HeroPageClient";
import { LlmBlock, LlmSection, LlmTable, LlmRules } from "@/components/llm/LlmBlock";
export const metadata: Metadata = {
title: "Hero-баннер. Цифровой брендбук Клиники ухо, горло, нос им. проф. Е.Н.Оленевой",
};
const LLM_HERO_TEXT = `
БЛОК: Hero-баннер (главный баннер страницы)
Источник: perm.oclinica.ru/lor реальный баннер раздела ЛОР
Версия: v1.1
ЗАГОЛОВОК СТРАНИЦЫ (H1, над баннером):
«ЛОР Клиника ухо, горло, нос медицинский центр лечения ЛОР заболеваний у детей и взрослых»
Шрифт: Fira Sans, ~2628px, weight 700, цвет: #53514e (--brand-073m)
СТРУКТУРА БАННЕРА (двухколоночная, единый фон #f9f4e7):
Левая колонка (~50%):
Заголовок: «ЭНДОСКОПИЧЕСКОЕ ХИРУРГИЧЕСКОЕ ЛЕЧЕНИЕ ЛОР ОРГАНОВ»
Шрифт: Fira Sans, ~18px, weight 700, uppercase, цвет #111827
3 пункта с галочками ( зелёный):
1. «БЕЗОПАСНО оперируют хирурги с 15-летним опытом работы»
2. «БЕЗ ВНЕШНИХ РАЗРЕЗОВ хирургия сверхмалых размеров»
3. «БЫСТРО под наблюдением врача пациент находится 1 сутки»
Ключевое слово: uppercase bold; описание: обычный текст, ~13px
Кнопка «Узнать стоимость операции» стиль: bb-btn-outline (светлая, без заливки)
Правая колонка (~50%):
Фото врача с пациентом
Изображение занимает всю высоту блока
ПОД БАННЕРОМ:
Кнопки соцсетей (Facebook, VK, Twitter/X), цвет #6b7280
Счётчик просмотров
ЦВЕТА:
Фон баннера: #f9f4e7 (светло-кремовый, единый для всего блока)
Кнопка CTA: outline-стиль (светлая), не коралловая
Заголовок блока: #111827
Пункты: ключевое слово #111827 bold, описание #374151
Галочка: цвет бренда (бежевый/золотой ~#bf9975)
ПРАВИЛА:
Фон баннера всегда #f9f4e7 (светло-кремовый) единый, без разделения на зоны
Заголовок блока uppercase, жирный
Три пункта с галочками обязательны
Не менять фон баннера на другой цвет
Не разбивать фон на два разных цвета
Не убирать три пункта с галочками
`.trim();
const HERO_CHECKS = [
{
key: "БЕЗОПАСНО",
desc: "оперируют хирурги с 15-летним опытом работы",
},
{
key: "БЕЗ ВНЕШНИХ РАЗРЕЗОВ",
desc: "хирургия смяткими размерами",
},
{
key: "БЫСТРО",
desc: "под наблюдением врача пациент находится 1 сутки",
},
];
export default function HeroPage() {
return <HeroPageClient />;
return (
<div className="p-8 max-w-5xl mx-auto space-y-10">
{/* Заголовок страницы */}
<div>
<p
className="text-xs font-semibold uppercase tracking-widest mb-1"
style={{ color: "var(--brand-053m)" }}
>
Блоки
</p>
<h1 className="text-2xl font-bold mb-2" style={{ color: "var(--bb-text)" }}>
Hero-баннер
</h1>
<p className="text-sm" style={{ color: "var(--bb-text-muted)" }}>
Главный баннер страницы раздела ЛОР perm.oclinica.ru/lor. Двухколоночный блок, единый светло-кремовый фон <strong>#f9f4e7</strong>.
</p>
</div>
{/* Живой пример */}
<section className="space-y-3">
<h2 className="font-semibold text-base" style={{ color: "var(--bb-text)" }}>
Живой пример
</h2>
{/* H1 страницы */}
<h2
className="text-xl font-bold leading-snug"
style={{ color: "#53514e" }}
>
ЛОР Клиника ухо, горло, нос медицинский центр лечения ЛОР заболеваний у детей и взрослых
</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="text-base font-bold uppercase leading-snug"
style={{ color: "#111827" }}
>
Эндоскопическое хирургическое лечение ЛОР органов
</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-outline">
Узнать стоимость операции
</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: "var(--bb-text-muted)" }}>
Поделиться:
</span>
{["VK", "FB", "TW"].map((s) => (
<button
key={s}
className="text-xs px-2 py-1 rounded"
style={{
background: "var(--bb-sidebar-bg)",
border: "1px solid var(--bb-border)",
color: "var(--bb-text-muted)",
}}
>
{s}
</button>
))}
<span className="text-xs ml-2" style={{ color: "var(--bb-text-muted)" }}>
👁 98 573 просмотра
</span>
</div>
</section>
{/* Анатомия */}
<section className="space-y-4">
<h2 className="font-semibold text-base" style={{ color: "var(--bb-text)" }}>
Анатомия баннера
</h2>
<div
className="rounded-xl p-5 space-y-2"
style={{ background: "#f5f0e8", border: "1px solid #d5cfbd" }}
>
<p className="font-semibold text-sm" style={{ color: "var(--bb-text)" }}>
Весь баннер единый фон <span className="font-mono">#f9f4e7</span>
</p>
<ul className="space-y-1">
{[
"Фон: #f9f4e7 (светло-кремовый) — одинаковый для всего блока",
"Левая половина (~50%): заголовок uppercase bold + 3 галочки + кнопка outline",
"Правая половина (~50%): фото врача с пациентом",
"Минимальная высота: ~280px",
].map((item) => (
<li key={item} className="text-xs flex items-start gap-1.5">
<span style={{ color: "var(--brand-053m)" }}>·</span>
<span style={{ color: "var(--bb-text-muted)" }}>{item}</span>
</li>
))}
</ul>
</div>
</section>
{/* Пункты с галочками */}
<section className="space-y-3">
<h2 className="font-semibold text-base" style={{ color: "var(--bb-text)" }}>
Три пункта баннера
</h2>
<div className="space-y-2">
{HERO_CHECKS.map((c) => (
<div
key={c.key}
className="flex items-start gap-3 p-3 rounded-lg"
style={{ background: "var(--bb-sidebar-bg)", border: "1px solid var(--bb-border)" }}
>
<span className="font-bold text-lg shrink-0" style={{ color: "#22c55e" }}>
</span>
<div>
<span className="text-sm font-bold uppercase" style={{ color: "#111827" }}>
{c.key}
</span>
<span className="text-sm" style={{ color: "#374151" }}>
{" "} {c.desc}
</span>
</div>
</div>
))}
</div>
<p className="text-xs" style={{ color: "var(--bb-text-muted)" }}>
Ключевое слово: uppercase + bold. Описание: обычный текст. Галочка: #22c55e.
</p>
</section>
{/* LLM блок */}
<LlmBlock path="/blocks/hero" version="v1.1" specText={LLM_HERO_TEXT}>
<LlmSection title="Структура баннера" />
<LlmTable
headers={["Зона", "Ширина", "Фон", "Содержимое"]}
rows={[
["Весь баннер", "100%", "#f9f4e7", "Единый светло-кремовый фон"],
["Левый блок", "~50%", "#f9f4e7 (общий)", "Заголовок uppercase + 3 галочки + кнопка outline"],
["Правый блок", "~50%", "#f9f4e7 (общий)", "Фото врача с пациентом"],
["Под баннером", "100%", "#fff", "Кнопки соцсетей + счётчик просмотров"],
]}
/>
<LlmSection title="Три пункта баннера" />
<LlmTable
headers={["Ключевое слово", "Описание"]}
rows={HERO_CHECKS.map((c) => [c.key, c.desc])}
/>
<LlmSection title="Цвета" />
<LlmTable
headers={["Элемент", "Цвет", "Токен"]}
rows={[
["Фон баннера (единый)", "#f9f4e7", "Светло-кремовый фон"],
["Кнопка CTA", "outline-стиль", "bb-btn-outline"],
["Заголовок блока", "#111827", "—"],
["Галочка ✓", "#bf9975", "Бежевый (--brand-081m approx.)"],
]}
/>
<LlmSection title="Правила" />
<LlmRules
rules={[
{ ok: true, text: "Фон баннера: #f9f4e7 (светло-кремовый) — единый для всего блока" },
{ ok: true, text: "Кнопка CTA: bb-btn-outline (светлая)" },
{ ok: true, text: "Заголовок: uppercase, bold" },
{ ok: true, text: "Три пункта с галочками ✓ (#bf9975)" },
{ ok: false, text: "Не менять фон баннера на другой цвет" },
{ ok: false, text: "Не разбивать баннер на два разных цвета фона" },
{ ok: false, text: "Не убирать три пункта с галочками" },
]}
/>
</LlmBlock>
</div>
);
}

291
apps/web/app/blocks/navigation/NavigationPageClient.tsx

@ -1,291 +0,0 @@
"use client";
import { useState } from "react";
import { LlmBlock, LlmSection, LlmTable, LlmRules } from "@/components/llm/LlmBlock";
import { NavigationBlock } from "@/components/blocks/NavigationBlock";
import { NAV_ITEMS } from "@/components/blocks/navData";
import { BlockMetaBar, type SnapshotData } from "@/components/ui/BlockMetaBar";
import { type ChangelogEntry } from "@/components/ui/BlockChangelog";
const BLOCK_PATH = "/blocks/navigation";
const LLM_NAV_TEXT = `
БЛОК: Шапка и навигация сайта (Header)
Источник: perm.oclinica.ru/lor реальная шапка сайта
Версия: v1.3
СТРУКТУРА ШАПКИ (2 зоны сверху вниз):
1. ВЕРХНЯЯ ПАНЕЛЬ (фон #fff, без рамок)
Три столбца:
Столбец 1 (лево): Логотип клиники (logo-main.png, h-20)
Столбец 2 (центр): Три ссылки вертикально с иконкой 📍:
· К. Цеткин, 9 (цвет #52b4bd, подчёркнутая)
· Клиника лечения кашля и аллергии (цвет #52b4bd, подчёркнутая)
· Центр диагностики и реабилитации (цвет #52b4bd, подчёркнутая)
Столбец 3 (право): Телефон «/342/ 255-53-84» (25px, bold, #000) + кнопка «Заказать звонок» (bb-btn-md bb-btn-pill)
2. ГЛАВНОЕ МЕНЮ (~46px, фон #fff, border-top 1px solid #e5e7eb)
Пункты (8 штук): Клиника | ЛОР врачи | Заболевания | Вопрос-ответ | ЛОР операции | Сурдология | Цены | Контакты
Шрифт: 18px, weight 400
Цвет ссылок: #000
Активный / Hover: #0089c3
Разделители: border-right 1px solid #f3f4f6 между пунктами
ПРАВИЛА:
Логотип всегда кликабелен ведёт на главную страницу раздела
Кнопка «Заказать звонок» всегда видна, pill-стиль
Телефон кликабелен (tel: ссылка)
Активный пункт меню цвет #0089c3, остальные #000
Не добавлять пункты меню, которых нет на сайте
Не менять порядок пунктов меню
`.trim();
const CHANGELOG: ChangelogEntry[] = [
{
version: "v1.3",
date: "24.03.2026",
changes: [
"Подменю: выпадающие списки при hover для Клиника, Заболевания, Вопрос-ответ, ЛОР операции",
"Hover-эффект: бежевый фон #f5f0e6 при наведении на пункт меню",
"Пункты меню подчёркнуты (underline), без разделителей между ними",
"Убрана рамка между шапкой и меню (border-top)",
"Все пункты меню чёрного цвета #000 (было: первый #0089c3)",
"Уменьшен padding пунктов (px-2.5 вместо px-4) — все помещаются в ширину экрана",
],
},
{
version: "v1.2",
date: "24.03.2026",
changes: [
"Убрана рамка и тень вокруг шапки (как на реальном сайте)",
"Структура: 3 столбца (логотип | ссылки вертикально | телефон+кнопка) вместо горизонтального ряда",
"Логотип: реальный logo-main.png вместо кружка, размер h-20 (было h-12)",
"Кнопка: bb-btn-md (было bb-btn-sm)",
"Анатомия шапки: обновлена — 2 зоны вместо 3",
],
},
{
version: "v1.1",
date: "24.03.2026",
changes: [
"Адрес: «Б. Цитная, 3» заменён на «К. Цеткин, 9»",
"Ссылки: обновлены на «Клиника лечения кашля и аллергии» и «Центр диагностики и реабилитации»",
"Телефон: размер 25px (было ~14px)",
"Меню: font-size 18px (было 14px), цвет ссылок #000 (было #53514e)",
"Цвет ссылки адреса: #52b4bd (было #0089c3)",
"«ЛОР операции» без дефиса (было «ЛОР-операции»)",
],
},
];
export default function NavigationPageClient() {
const [snapshot, setSnapshot] = useState<SnapshotData | null>(null);
return (
<div className="p-8 max-w-5xl mx-auto space-y-10">
{/* Заголовок страницы */}
<div>
<p
className="text-xs font-semibold uppercase tracking-widest mb-1"
style={{ color: "var(--brand-053m)" }}
>
Блоки
</p>
<h1 className="text-2xl font-bold mb-2" style={{ color: "var(--bb-text)" }}>
Шапка и навигация
</h1>
<BlockMetaBar
path={BLOCK_PATH}
defaultVersion="v1.3"
defaultIsInPreview={true}
defaultChangelog={CHANGELOG}
onSnapshotSelect={setSnapshot}
/>
<p className="text-sm" style={{ color: "var(--bb-text-muted)" }}>
Точное воспроизведение шапки с perm.oclinica.ru/lor. Три зоны: топ-бар, логотип, главное меню.
</p>
</div>
{/* Живой пример */}
<section className="space-y-3">
<h2 className="font-semibold text-base" style={{ color: "var(--bb-text)" }}>
Живой пример
</h2>
{snapshot ? (
<div className="rounded-xl" style={{ paddingBottom: 40 }}>
<style dangerouslySetInnerHTML={{ __html: snapshot.css }} />
<div dangerouslySetInnerHTML={{ __html: snapshot.html }} />
</div>
) : (
<div data-block-capture={BLOCK_PATH} className="rounded-xl" style={{ paddingBottom: 40 }}>
<NavigationBlock />
</div>
)}
</section>
{/* Анатомия */}
<section className="space-y-4">
<h2 className="font-semibold text-base" style={{ color: "var(--bb-text)" }}>
Анатомия шапки
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{[
{
zone: "1. Верхняя панель",
bg: "#f0f9ff",
desc: "Три столбца: логотип (logo-main.png) | три ссылки вертикально с 📍 (К. Цеткин 9, Клиника кашля, Центр диагностики) | телефон 25px + кнопка «Заказать звонок» (pill)",
details: "Фон: #fff · Без рамок · Ссылки: #52b4bd, подчёркнутые",
},
{
zone: "2. Главное меню",
bg: "#fefce8",
desc: "8 горизонтальных пунктов: Клиника | ЛОР врачи | Заболевания | Вопрос-ответ | ЛОР операции | Сурдология | Цены | Контакты",
details: "Фон: #fff · border-top · 18px · Активный: #0089c3, остальные: #000",
},
].map((z) => (
<div
key={z.zone}
className="rounded-xl p-4 space-y-2"
style={{ background: z.bg, border: "1px solid var(--bb-border)" }}
>
<p className="font-semibold text-sm" style={{ color: "var(--bb-text)" }}>
{z.zone}
</p>
<p className="text-xs" style={{ color: "var(--bb-text-muted)" }}>
{z.desc}
</p>
<p className="text-[11px] font-mono mt-1" style={{ color: "var(--bb-text-muted)" }}>
{z.details}
</p>
</div>
))}
</div>
</section>
{/* Пункты меню */}
<section className="space-y-3">
<h2 className="font-semibold text-base" style={{ color: "var(--bb-text)" }}>
Пункты главного меню
</h2>
<div className="rounded-xl border overflow-visible" style={{ borderColor: "var(--bb-border)", background: "#fff" }}>
<div className="flex items-center justify-center flex-wrap" style={{ borderTop: "1px solid #e5e7eb" }}>
{NAV_ITEMS.map((item) => (
<div key={item.label} className="bb-nav-item-wrap relative group">
<a
href="#"
className="bb-nav-item px-2.5 py-3 whitespace-nowrap inline-flex items-center gap-1"
style={{
fontSize: 18,
color: "#000",
fontWeight: 400,
textDecoration: "underline",
textUnderlineOffset: "4px",
}}
>
{item.label}
{item.submenu.length > 0 && (
<span className="text-xs" style={{ color: "#9ca3af", textDecoration: "none", display: "inline-block" }}></span>
)}
</a>
{item.submenu.length > 0 && (
<div
className="absolute left-0 top-full z-50 hidden group-hover:block min-w-[220px] py-1 rounded shadow-lg"
style={{ background: "#fff", border: "1px solid #e5e7eb" }}
>
{item.submenu.map((sub) => (
<a
key={sub}
href="#"
className="block px-4 py-2 text-sm bb-nav-sub-item"
style={{ color: "#333", textDecoration: "none" }}
>
{sub}
</a>
))}
</div>
)}
</div>
))}
</div>
</div>
<p className="text-xs" style={{ color: "var(--bb-text-muted)" }}>
Порядок фиксирован. Все пункты чёрные (#000), подчёркнуты. Hover бежевый фон #f5f0e6. выпадающее подменю при наведении.
</p>
</section>
{/* Токены */}
<section className="space-y-3">
<h2 className="font-semibold text-base" style={{ color: "var(--bb-text)" }}>
Цвета и токены
</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{[
{ label: "Фон шапки", value: "#ffffff", token: "—" },
{ label: "Пункты меню", value: "#000000", token: "—" },
{ label: "Hover фон меню", value: "#f5f0e6", token: "bb-nav-item:hover" },
{ label: "Ссылки адресов", value: "#52b4bd", token: "—" },
{ label: "Кнопка «Заказать»", value: "#e9e4d4", token: "bb-btn-pill" },
{ label: "Подменю фон", value: "#ffffff", token: "—" },
{ label: "Подменю hover", value: "#f5f0e6", token: "bb-nav-sub-item:hover" },
{ label: "Телефон", value: "#000000", token: "25px, bold" },
].map((t) => (
<div
key={t.label}
className="p-3 rounded-xl space-y-2"
style={{ background: "var(--bb-sidebar-bg)", border: "1px solid var(--bb-border)" }}
>
<div
className="w-8 h-8 rounded border"
style={{ background: t.value, borderColor: "var(--bb-border)" }}
/>
<p className="text-xs font-medium" style={{ color: "var(--bb-text)" }}>
{t.label}
</p>
<p className="text-[10px] font-mono" style={{ color: "var(--bb-text-muted)" }}>
{t.value}
</p>
<p className="text-[10px]" style={{ color: "var(--bb-text-muted)" }}>
{t.token}
</p>
</div>
))}
</div>
</section>
{/* LLM блок */}
<LlmBlock path="/blocks/navigation" version="v1.3" specText={LLM_NAV_TEXT}>
<LlmSection title="Зоны шапки" />
<LlmTable
headers={["Зона", "Фон", "Содержимое"]}
rows={[
["Верхняя панель", "#fff, без рамок", "Логотип (logo-main.png) | 3 ссылки вертикально с 📍 (#52b4bd) | Телефон 25px + кнопка pill"],
["Главное меню", "#fff, border-top", "8 пунктов 18px: Клиника / ЛОР врачи / Заболевания / Вопрос-ответ / ЛОР операции / Сурдология / Цены / Контакты"],
]}
/>
<LlmSection title="Цвета" />
<LlmTable
headers={["Элемент", "Цвет", "Токен"]}
rows={[
["Фон шапки", "#ffffff", "—"],
["Ссылки меню (default)", "#000000", "—"],
["Активный пункт / hover", "#0089c3", "--brand-053m"],
["Кнопка «Заказать звонок»", "#e9e4d4 / border #d5cfbd", "bb-btn-pill"],
["Телефон", "#111827, font-weight bold", "—"],
]}
/>
<LlmSection title="Правила" />
<LlmRules
rules={[
{ ok: true, text: "Логотип ведёт на главную страницу раздела" },
{ ok: true, text: "Кнопка «Заказать звонок» всегда видна, pill-стиль" },
{ ok: true, text: "Телефон — кликабельная ссылка tel:" },
{ ok: true, text: "Активный пункт меню — цвет #0089c3" },
{ ok: false, text: "Не добавлять пункты меню, которых нет на сайте" },
{ ok: false, text: "Не менять порядок 8 пунктов меню" },
]}
/>
</LlmBlock>
</div>
);
}

10
apps/web/app/blocks/navigation/page.tsx

@ -1,10 +0,0 @@
import type { Metadata } from "next";
import NavigationPageClient from "./NavigationPageClient";
export const metadata: Metadata = {
title: "Шапка и навигация. Цифровой брендбук Клиники ухо, горло, нос им. проф. Е.Н.Оленевой",
};
export default function NavigationPage() {
return <NavigationPageClient />;
}

195
apps/web/app/blocks/news/NewsPageClient.tsx

@ -1,195 +0,0 @@
"use client";
import { useState } from "react";
import { LlmBlock, LlmSection, LlmTable, LlmRules } from "@/components/llm/LlmBlock";
import { BlockMetaBar, type SnapshotData } from "@/components/ui/BlockMetaBar";
import { type ChangelogEntry } from "@/components/ui/BlockChangelog";
import { NewsBlock } from "@/components/blocks/NewsBlock";
const BLOCK_PATH = "/blocks/news";
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: "#",
},
];
const LLM_NEWS_TEXT = `
БЛОК: Новости
Источник: perm.oclinica.ru/lor блок новостей внизу страницы
Версия: v1.1
СТРУКТУРА БЛОКА:
1. ЗАГОЛОВОК H2: «Новости»
Выравнивание: по центру или слева
2. СЕТКА КАРТОЧЕК НОВОСТЕЙ (4 штуки в ряд):
Каждая карточка:
Дата: малый текст сверху (формат DD.MM.YYYY), цвет #6b7280
Заголовок новости: синяя ссылка (#0089c3), 14px, font-weight 500, hover underline
Без превью-изображения
Без описания (только дата + заголовок-ссылка)
Hover на карточке: bg #eef4d1, box-shadow 0 0 16px 0 #9e9e9a (bb-news-card)
3. КНОПКА «Все новости»:
Стиль: outline (#BF9975) или teal (#60959c), выровнена по центру под сеткой
На сайте: ссылка «Все новости»
ЦВЕТА:
Дата: #6b7280
Заголовок-ссылка: #0089c3 (--brand-053m)
Hover фон карточки: #eef4d1
Hover тень: 0 0 16px 0 #9e9e9a
ПРАВИЛА:
4 карточки в ряд
Дата сверху карточки
Заголовок = ссылка #0089c3
Hover: bg #eef4d1 (класс bb-news-card)
Кнопка «Все новости» под сеткой
Не добавлять изображения в карточки новостей главной страницы
Не добавлять описание/анонс в карточку
`.trim();
const CHANGELOG: ChangelogEntry[] = [
{
version: "v1.1",
date: "24.03.2026",
changes: [
"H2: размер на 36px, цвет на #000000, line-height 38px",
"Фон секции: с белого на #f2fee6 (светло-зелёный, как на реальном сайте)",
],
},
];
export default function NewsPageClient() {
const [snapshot, setSnapshot] = useState<SnapshotData | null>(null);
return (
<div className="p-8 max-w-5xl mx-auto space-y-10">
{/* Заголовок страницы */}
<div>
<p
className="text-xs font-semibold uppercase tracking-widest mb-1"
style={{ color: "var(--brand-053m)" }}
>
Блоки
</p>
<h1 className="text-2xl font-bold mb-2" style={{ color: "var(--bb-text)" }}>
Блок «Новости»
</h1>
<BlockMetaBar
path={BLOCK_PATH}
defaultVersion="v1.1"
defaultIsInPreview={false}
defaultChangelog={CHANGELOG}
onSnapshotSelect={setSnapshot}
/>
<p className="text-sm" style={{ color: "var(--bb-text-muted)" }}>
Блок новостей с perm.oclinica.ru/lor 4 карточки в ряд (дата + заголовок-ссылка),
кнопка «Все новости».
</p>
</div>
{/* Живой пример */}
<section className="space-y-4">
<h2 className="font-semibold text-base" style={{ color: "var(--bb-text)" }}>
Живой пример
</h2>
{snapshot ? (
<>
<style dangerouslySetInnerHTML={{ __html: snapshot.css }} />
<div dangerouslySetInnerHTML={{ __html: snapshot.html }} />
</>
) : (
<div data-block-capture={BLOCK_PATH}>
<NewsBlock />
</div>
)}
</section>
{/* Анатомия карточки */}
<section className="space-y-3">
<h2 className="font-semibold text-base" style={{ color: "var(--bb-text)" }}>
Анатомия карточки новости
</h2>
<div
className="rounded-xl p-6 max-w-xs"
style={{ background: "var(--bb-sidebar-bg)", border: "1px solid var(--bb-border)" }}
>
<div className="space-y-3">
<div
className="flex items-center gap-2 px-2 py-1 rounded text-xs font-mono"
style={{ background: "#f0f9ff", color: "var(--bb-text-muted)" }}
>
Дата: DD.MM.YYYY · цвет #6b7280
</div>
<div
className="flex items-center gap-2 px-2 py-1 rounded text-xs font-mono"
style={{ background: "#f0f9ff", color: "var(--bb-text-muted)" }}
>
Заголовок-ссылка · #0089c3 · font-weight 500
</div>
<div
className="flex items-center gap-2 px-2 py-1 rounded text-xs font-mono"
style={{ background: "#fefce8", color: "var(--bb-text-muted)" }}
>
Hover: bg #eef4d1 · shadow 0 0 16px #9e9e9a
</div>
</div>
</div>
<p className="text-xs" style={{ color: "var(--bb-text-muted)" }}>
Нет изображений, нет описания только дата и заголовок.
CSS-класс <code className="font-mono">.bb-news-card</code> обрабатывает hover.
</p>
</section>
{/* LLM блок */}
<LlmBlock path="/blocks/news" version="v1.1" specText={LLM_NEWS_TEXT}>
<LlmSection title="Структура карточки новости" />
<LlmTable
headers={["Поле", "Стиль", "Значение"]}
rows={[
["Дата", "text-xs, #6b7280", "Формат DD.MM.YYYY"],
["Заголовок", "text-sm, font-weight 500, #0089c3", "Ссылка на новость"],
["Hover фон", ".bb-news-card:hover", "#eef4d1"],
["Hover тень", ".bb-news-card:hover", "0 0 16px 0 #9e9e9a"],
]}
/>
<LlmSection title="Mock-данные (4 реальные новости с сайта)" />
<LlmTable
headers={["Дата", "Заголовок"]}
rows={MOCK_NEWS.map((n) => [n.date, n.title])}
/>
<LlmSection title="Правила" />
<LlmRules
rules={[
{ ok: true, text: "4 карточки в ряд (grid-cols-4)" },
{ ok: true, text: "Дата сверху, заголовок-ссылка ниже" },
{ ok: true, text: "Hover: класс bb-news-card (bg #eef4d1)" },
{ ok: true, text: "Кнопка «Все новости» под сеткой (bb-btn-outline)" },
{ ok: false, text: "Не добавлять изображения в карточки" },
{ ok: false, text: "Не добавлять текст-анонс в карточку" },
]}
/>
</LlmBlock>
</div>
);
}

192
apps/web/app/blocks/news/page.tsx

@ -1,10 +1,198 @@
import type { Metadata } from "next";
import NewsPageClient from "./NewsPageClient";
import { LlmBlock, LlmSection, LlmTable, LlmRules } from "@/components/llm/LlmBlock";
export const metadata: Metadata = {
title: "Блок «Новости». Цифровой брендбук Клиники ухо, горло, нос им. проф. Е.Н.Оленевой",
};
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: "#",
},
];
const LLM_NEWS_TEXT = `
БЛОК: Новости
Источник: perm.oclinica.ru/lor блок новостей внизу страницы
Версия: v1.0
СТРУКТУРА БЛОКА:
1. ЗАГОЛОВОК H2: «Новости»
Выравнивание: по центру или слева
2. СЕТКА КАРТОЧЕК НОВОСТЕЙ (4 штуки в ряд):
Каждая карточка:
Дата: малый текст сверху (формат DD.MM.YYYY), цвет #6b7280
Заголовок новости: синяя ссылка (#0089c3), 14px, font-weight 500, hover underline
Без превью-изображения
Без описания (только дата + заголовок-ссылка)
Hover на карточке: bg #eef4d1, box-shadow 0 0 16px 0 #9e9e9a (bb-news-card)
3. КНОПКА «Все новости»:
Стиль: outline (#BF9975) или teal (#60959c), выровнена по центру под сеткой
На сайте: ссылка «Все новости»
ЦВЕТА:
Дата: #6b7280
Заголовок-ссылка: #0089c3 (--brand-053m)
Hover фон карточки: #eef4d1
Hover тень: 0 0 16px 0 #9e9e9a
ПРАВИЛА:
4 карточки в ряд
Дата сверху карточки
Заголовок = ссылка #0089c3
Hover: bg #eef4d1 (класс bb-news-card)
Кнопка «Все новости» под сеткой
Не добавлять изображения в карточки новостей главной страницы
Не добавлять описание/анонс в карточку
`.trim();
export default function NewsBlockPage() {
return <NewsPageClient />;
return (
<div className="p-8 max-w-5xl mx-auto space-y-10">
{/* Заголовок страницы */}
<div>
<p
className="text-xs font-semibold uppercase tracking-widest mb-1"
style={{ color: "var(--brand-053m)" }}
>
Блоки
</p>
<h1 className="text-2xl font-bold mb-2" style={{ color: "var(--bb-text)" }}>
Блок «Новости»
</h1>
<p className="text-sm" style={{ color: "var(--bb-text-muted)" }}>
Блок новостей с perm.oclinica.ru/lor 4 карточки в ряд (дата + заголовок-ссылка),
кнопка «Все новости».
</p>
</div>
{/* Живой пример */}
<section className="space-y-4">
<h2 className="font-semibold text-base" style={{ color: "var(--bb-text)" }}>
Живой пример
</h2>
<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>
</section>
{/* Анатомия карточки */}
<section className="space-y-3">
<h2 className="font-semibold text-base" style={{ color: "var(--bb-text)" }}>
Анатомия карточки новости
</h2>
<div
className="rounded-xl p-6 max-w-xs"
style={{ background: "var(--bb-sidebar-bg)", border: "1px solid var(--bb-border)" }}
>
<div className="space-y-3">
<div
className="flex items-center gap-2 px-2 py-1 rounded text-xs font-mono"
style={{ background: "#f0f9ff", color: "var(--bb-text-muted)" }}
>
Дата: DD.MM.YYYY · цвет #6b7280
</div>
<div
className="flex items-center gap-2 px-2 py-1 rounded text-xs font-mono"
style={{ background: "#f0f9ff", color: "var(--bb-text-muted)" }}
>
Заголовок-ссылка · #0089c3 · font-weight 500
</div>
<div
className="flex items-center gap-2 px-2 py-1 rounded text-xs font-mono"
style={{ background: "#fefce8", color: "var(--bb-text-muted)" }}
>
Hover: bg #eef4d1 · shadow 0 0 16px #9e9e9a
</div>
</div>
</div>
<p className="text-xs" style={{ color: "var(--bb-text-muted)" }}>
Нет изображений, нет описания только дата и заголовок.
CSS-класс <code className="font-mono">.bb-news-card</code> обрабатывает hover.
</p>
</section>
{/* LLM блок */}
<LlmBlock path="/blocks/news" version="v1.0" specText={LLM_NEWS_TEXT}>
<LlmSection title="Структура карточки новости" />
<LlmTable
headers={["Поле", "Стиль", "Значение"]}
rows={[
["Дата", "text-xs, #6b7280", "Формат DD.MM.YYYY"],
["Заголовок", "text-sm, font-weight 500, #0089c3", "Ссылка на новость"],
["Hover фон", ".bb-news-card:hover", "#eef4d1"],
["Hover тень", ".bb-news-card:hover", "0 0 16px 0 #9e9e9a"],
]}
/>
<LlmSection title="Mock-данные (4 реальные новости с сайта)" />
<LlmTable
headers={["Дата", "Заголовок"]}
rows={MOCK_NEWS.map((n) => [n.date, n.title])}
/>
<LlmSection title="Правила" />
<LlmRules
rules={[
{ ok: true, text: "4 карточки в ряд (grid-cols-4)" },
{ ok: true, text: "Дата сверху, заголовок-ссылка ниже" },
{ ok: true, text: "Hover: класс bb-news-card (bg #eef4d1)" },
{ ok: true, text: "Кнопка «Все новости» под сеткой (bb-btn-outline)" },
{ ok: false, text: "Не добавлять изображения в карточки" },
{ ok: false, text: "Не добавлять текст-анонс в карточку" },
]}
/>
</LlmBlock>
</div>
);
}

226
apps/web/app/blocks/reviews/ReviewsPageClient.tsx

@ -1,226 +0,0 @@
"use client";
import { useState } from "react";
import { LlmBlock, LlmSection, LlmTable, LlmRules } from "@/components/llm/LlmBlock";
import { BlockMetaBar, type SnapshotData } from "@/components/ui/BlockMetaBar";
import { type ChangelogEntry } from "@/components/ui/BlockChangelog";
import { ReviewsBlock } from "@/components/blocks/ReviewsBlock";
const BLOCK_PATH = "/blocks/reviews";
const MOCK_REVIEWS = [
{
text: "Спасибо за приём, мне всё понравилось, спасибо за приём, мне всё понравилось. Врач очень внимательный и профессиональный.",
author: "Пациент клиники",
doctor: "Тимофеева Наталья Александровна",
},
{
text: "Очень довольна лечением! Прошла курс процедур, нос дышит отлично. Рекомендую клинику всем.",
author: "Наталья К.",
doctor: "Макарова Людмила Тимофеевна",
},
];
const LLM_REVIEWS_TEXT = `
БЛОК: Отзывы о нас
Источник: perm.oclinica.ru/lor блок отзывов
Версия: v1.1
СТРУКТУРА БЛОКА:
1. ЗАГОЛОВОК H2: «Отзывы о нас»
Подзаголовок: «За 12 лет работы наши врачи оториноларингологи избавили от болезней ухо, горло, носа
более 50000 пациентов. Но дня сейчас мы высоко ценим каждый положительный отзыв и искренние слова благодарности»
2. КАРУСЕЛЬ ОТЗЫВОВ:
Большая открывающая кавычка (« «) в цвете #b8e6ed, font-size 80100px
Текст отзыва: курсив, 1516px, #374151, background #eef4d1
Ссылка «Читать отзыв полностью» в цвете #0089c3
Стрелки навигации < > по бокам (round buttons)
Карусель показывает 1 отзыв за раз
ЦВЕТА:
Фон карточки отзыва: #eef4d1 (светло-жёлто-зелёный)
Кавычка: #b8e6ed (светло-бирюзовый)
Текст отзыва: #374151
Ссылка «Читать полностью»: #0089c3
ПРАВИЛА:
Фон карточки отзыва: #eef4d1 (тот же что у ReviewCard)
Большая декоративная кавычка
Ссылка «Читать отзыв полностью» обязательна
Навигация карусели (стрелки)
Не показывать более 1 отзыва за раз в карусели
Не убирать навигационные стрелки
`.trim();
const CHANGELOG: ChangelogEntry[] = [
{
version: "v1.1",
date: "24.03.2026",
changes: [
"H2: размер с ~20px на 36px, цвет с #111827 на #000000, line-height 38px",
],
},
];
export default function ReviewsPageClient() {
const [snapshot, setSnapshot] = useState<SnapshotData | null>(null);
return (
<div className="p-8 max-w-5xl mx-auto space-y-10">
{/* Заголовок страницы */}
<div>
<p
className="text-xs font-semibold uppercase tracking-widest mb-1"
style={{ color: "var(--brand-053m)" }}
>
Блоки
</p>
<h1 className="text-2xl font-bold mb-2" style={{ color: "var(--bb-text)" }}>
Блок «Отзывы о нас»
</h1>
<BlockMetaBar
path={BLOCK_PATH}
defaultVersion="v1.1"
defaultIsInPreview={false}
defaultChangelog={CHANGELOG}
onSnapshotSelect={setSnapshot}
/>
<p className="text-sm" style={{ color: "var(--bb-text-muted)" }}>
Карусель отзывов с perm.oclinica.ru/lor большая кавычка, текст, «Читать полностью», стрелки.
</p>
</div>
{/* Живой пример */}
<section className="space-y-4">
<h2 className="font-semibold text-base" style={{ color: "var(--bb-text)" }}>
Живой пример
</h2>
{snapshot ? (
<>
<style dangerouslySetInnerHTML={{ __html: snapshot.css }} />
<div dangerouslySetInnerHTML={{ __html: snapshot.html }} />
</>
) : (
<div data-block-capture={BLOCK_PATH}>
<ReviewsBlock />
</div>
)}
</section>
{/* Несколько примеров */}
<section className="space-y-3">
<h2 className="font-semibold text-base" style={{ color: "var(--bb-text)" }}>
Примеры отзывов (mock)
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{MOCK_REVIEWS.map((r, i) => (
<div
key={i}
className="rounded-xl p-5 space-y-3"
style={{ background: "#eef4d1", border: "1px solid #d4e6a0" }}
>
<div
className="text-5xl leading-none font-serif"
style={{ color: "#b8e6ed" }}
>
«
</div>
<p
className="text-sm italic leading-relaxed"
style={{ color: "#374151" }}
>
{r.text}
</p>
<div className="pt-1">
<a href="#" className="text-sm" style={{ color: "#0089c3" }}>
Читать отзыв полностью
</a>
</div>
<div className="border-t pt-2" style={{ borderColor: "#c8d8a0" }}>
<p className="text-xs font-medium" style={{ color: "#374151" }}>
{r.author}
</p>
<p className="text-[11px]" style={{ color: "#6b7280" }}>
о враче: {r.doctor}
</p>
</div>
</div>
))}
</div>
</section>
{/* Анатомия */}
<section className="space-y-3">
<h2 className="font-semibold text-base" style={{ color: "var(--bb-text)" }}>
Анатомия карточки отзыва
</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{[
{ label: "Фон карточки", value: "#eef4d1", note: "светло-жёлто-зелёный" },
{ label: "Кавычка декоративная", value: "#b8e6ed", note: "80–100px, font-serif" },
{ label: "Текст отзыва", value: "#374151", note: "14px, italic" },
{ label: "Ссылка", value: "#0089c3", note: "--brand-053m" },
].map((t) => (
<div
key={t.label}
className="p-3 rounded-xl space-y-1"
style={{ background: "var(--bb-sidebar-bg)", border: "1px solid var(--bb-border)" }}
>
<div
className="w-8 h-8 rounded border"
style={{ background: t.value, borderColor: "var(--bb-border)" }}
/>
<p className="text-xs font-medium" style={{ color: "var(--bb-text)" }}>
{t.label}
</p>
<p className="text-[10px] font-mono" style={{ color: "var(--bb-text-muted)" }}>
{t.value}
</p>
<p className="text-[10px]" style={{ color: "var(--bb-text-muted)" }}>
{t.note}
</p>
</div>
))}
</div>
</section>
{/* LLM блок */}
<LlmBlock path="/blocks/reviews" version="v1.1" specText={LLM_REVIEWS_TEXT}>
<LlmSection title="Структура блока" />
<LlmTable
headers={["Элемент", "Содержимое", "Стиль"]}
rows={[
["H2", "«Отзывы о нас»", "36px, font-bold, #000000, line-height 38px"],
["Подзаголовок", "Описание достижений клиники за 12 лет", "14px, #374151"],
["Кавычка", "Декоративная «", "80–100px, #b8e6ed, font-serif"],
["Текст отзыва", "Полный текст отзыва пациента", "14px, italic, #374151"],
["Ссылка", "«Читать отзыв полностью»", "#0089c3"],
["Стрелки карусели", "‹ ›", "Round buttons, фон var(--bb-sidebar-bg)"],
]}
/>
<LlmSection title="Цвета" />
<LlmTable
headers={["Элемент", "Цвет", "Токен"]}
rows={[
["Фон карточки отзыва", "#eef4d1", "—"],
["Декоративная кавычка", "#b8e6ed", "—"],
["Текст отзыва", "#374151", "—"],
["Ссылка", "#0089c3", "--brand-053m"],
]}
/>
<LlmSection title="Правила" />
<LlmRules
rules={[
{ ok: true, text: "Фон карточки отзыва: #eef4d1" },
{ ok: true, text: "Декоративная кавычка цвет #b8e6ed" },
{ ok: true, text: "Ссылка «Читать отзыв полностью» обязательна" },
{ ok: true, text: "Навигация карусели: стрелки ‹ ›" },
{ ok: false, text: "Не показывать несколько отзывов одновременно" },
{ ok: false, text: "Не убирать навигационные стрелки" },
]}
/>
</LlmBlock>
</div>
);
}

258
apps/web/app/blocks/reviews/page.tsx

@ -1,10 +1,264 @@
import type { Metadata } from "next";
import ReviewsPageClient from "./ReviewsPageClient";
import { LlmBlock, LlmSection, LlmTable, LlmRules } from "@/components/llm/LlmBlock";
export const metadata: Metadata = {
title: "Блок «Отзывы». Цифровой брендбук Клиники ухо, горло, нос им. проф. Е.Н.Оленевой",
};
const MOCK_REVIEWS = [
{
text: "Спасибо за приём, мне всё понравилось, спасибо за приём, мне всё понравилось. Врач очень внимательный и профессиональный.",
author: "Пациент клиники",
doctor: "Тимофеева Наталья Александровна",
},
{
text: "Очень довольна лечением! Прошла курс процедур, нос дышит отлично. Рекомендую клинику всем.",
author: "Наталья К.",
doctor: "Макарова Людмила Тимофеевна",
},
];
const LLM_REVIEWS_TEXT = `
БЛОК: Отзывы о нас
Источник: perm.oclinica.ru/lor блок отзывов
Версия: v1.0
СТРУКТУРА БЛОКА:
1. ЗАГОЛОВОК H2: «Отзывы о нас»
Подзаголовок: «За 12 лет работы наши врачи оториноларингологи избавили от болезней ухо, горло, носа
более 50000 пациентов. Но дня сейчас мы высоко ценим каждый положительный отзыв и искренние слова благодарности»
2. КАРУСЕЛЬ ОТЗЫВОВ:
Большая открывающая кавычка (« «) в цвете #b8e6ed, font-size 80100px
Текст отзыва: курсив, 1516px, #374151, background #eef4d1
Ссылка «Читать отзыв полностью» в цвете #0089c3
Стрелки навигации < > по бокам (round buttons)
Карусель показывает 1 отзыв за раз
ЦВЕТА:
Фон карточки отзыва: #eef4d1 (светло-жёлто-зелёный)
Кавычка: #b8e6ed (светло-бирюзовый)
Текст отзыва: #374151
Ссылка «Читать полностью»: #0089c3
ПРАВИЛА:
Фон карточки отзыва: #eef4d1 (тот же что у ReviewCard)
Большая декоративная кавычка
Ссылка «Читать отзыв полностью» обязательна
Навигация карусели (стрелки)
Не показывать более 1 отзыва за раз в карусели
Не убирать навигационные стрелки
`.trim();
export default function ReviewsBlockPage() {
return <ReviewsPageClient />;
return (
<div className="p-8 max-w-5xl mx-auto space-y-10">
{/* Заголовок страницы */}
<div>
<p
className="text-xs font-semibold uppercase tracking-widest mb-1"
style={{ color: "var(--brand-053m)" }}
>
Блоки
</p>
<h1 className="text-2xl font-bold mb-2" style={{ color: "var(--bb-text)" }}>
Блок «Отзывы о нас»
</h1>
<p className="text-sm" style={{ color: "var(--bb-text-muted)" }}>
Карусель отзывов с perm.oclinica.ru/lor большая кавычка, текст, «Читать полностью», стрелки.
</p>
</div>
{/* Живой пример */}
<section className="space-y-4">
<h2 className="font-semibold text-base" style={{ color: "var(--bb-text)" }}>
Живой пример
</h2>
<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 relative"
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>
</section>
{/* Несколько примеров */}
<section className="space-y-3">
<h2 className="font-semibold text-base" style={{ color: "var(--bb-text)" }}>
Примеры отзывов (mock)
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{MOCK_REVIEWS.map((r, i) => (
<div
key={i}
className="rounded-xl p-5 space-y-3"
style={{ background: "#eef4d1", border: "1px solid #d4e6a0" }}
>
<div
className="text-5xl leading-none font-serif"
style={{ color: "#b8e6ed" }}
>
«
</div>
<p
className="text-sm italic leading-relaxed"
style={{ color: "#374151" }}
>
{r.text}
</p>
<div className="pt-1">
<a href="#" className="text-sm" style={{ color: "#0089c3" }}>
Читать отзыв полностью
</a>
</div>
<div className="border-t pt-2" style={{ borderColor: "#c8d8a0" }}>
<p className="text-xs font-medium" style={{ color: "#374151" }}>
{r.author}
</p>
<p className="text-[11px]" style={{ color: "#6b7280" }}>
о враче: {r.doctor}
</p>
</div>
</div>
))}
</div>
</section>
{/* Анатомия */}
<section className="space-y-3">
<h2 className="font-semibold text-base" style={{ color: "var(--bb-text)" }}>
Анатомия карточки отзыва
</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{[
{ label: "Фон карточки", value: "#eef4d1", note: "светло-жёлто-зелёный" },
{ label: "Кавычка декоративная", value: "#b8e6ed", note: "80–100px, font-serif" },
{ label: "Текст отзыва", value: "#374151", note: "14px, italic" },
{ label: "Ссылка", value: "#0089c3", note: "--brand-053m" },
].map((t) => (
<div
key={t.label}
className="p-3 rounded-xl space-y-1"
style={{ background: "var(--bb-sidebar-bg)", border: "1px solid var(--bb-border)" }}
>
<div
className="w-8 h-8 rounded border"
style={{ background: t.value, borderColor: "var(--bb-border)" }}
/>
<p className="text-xs font-medium" style={{ color: "var(--bb-text)" }}>
{t.label}
</p>
<p className="text-[10px] font-mono" style={{ color: "var(--bb-text-muted)" }}>
{t.value}
</p>
<p className="text-[10px]" style={{ color: "var(--bb-text-muted)" }}>
{t.note}
</p>
</div>
))}
</div>
</section>
{/* LLM блок */}
<LlmBlock path="/blocks/reviews" version="v1.0" specText={LLM_REVIEWS_TEXT}>
<LlmSection title="Структура блока" />
<LlmTable
headers={["Элемент", "Содержимое", "Стиль"]}
rows={[
["H2", "«Отзывы о нас»", "text-xl, font-bold, #111827"],
["Подзаголовок", "Описание достижений клиники за 12 лет", "14px, #374151"],
["Кавычка", "Декоративная «", "80–100px, #b8e6ed, font-serif"],
["Текст отзыва", "Полный текст отзыва пациента", "14px, italic, #374151"],
["Ссылка", "«Читать отзыв полностью»", "#0089c3"],
["Стрелки карусели", "‹ ›", "Round buttons, фон var(--bb-sidebar-bg)"],
]}
/>
<LlmSection title="Цвета" />
<LlmTable
headers={["Элемент", "Цвет", "Токен"]}
rows={[
["Фон карточки отзыва", "#eef4d1", "—"],
["Декоративная кавычка", "#b8e6ed", "—"],
["Текст отзыва", "#374151", "—"],
["Ссылка", "#0089c3", "--brand-053m"],
]}
/>
<LlmSection title="Правила" />
<LlmRules
rules={[
{ ok: true, text: "Фон карточки отзыва: #eef4d1" },
{ ok: true, text: "Декоративная кавычка цвет #b8e6ed" },
{ ok: true, text: "Ссылка «Читать отзыв полностью» обязательна" },
{ ok: true, text: "Навигация карусели: стрелки ‹ ›" },
{ ok: false, text: "Не показывать несколько отзывов одновременно" },
{ ok: false, text: "Не убирать навигационные стрелки" },
]}
/>
</LlmBlock>
</div>
);
}

2
apps/web/app/components/buttons/page.tsx

@ -2,7 +2,6 @@ import type { Metadata } from "next";
import { Button } from "@/components/ui/Button";
import { CodeCopy } from "@/components/ui/CodeCopy";
import { LlmBlock, LlmSection, LlmTable, LlmRules } from "@/components/llm/LlmBlock";
import { BlockMetaBar } from "@/components/ui/BlockMetaBar";
export const metadata: Metadata = {
title: "Кнопки. Цифровой брендбук Клиники ухо, горло, нос им. проф. Е.Н.Оленевой",
@ -228,7 +227,6 @@ a.callback_url {
<h1 className="text-3xl font-semibold mb-3" style={{ color: "var(--bb-text)" }}>
Кнопки
</h1>
<BlockMetaBar path="/components/buttons" defaultVersion="v2.0" defaultIsInPreview={false} />
<p className="text-base max-w-2xl" style={{ color: "var(--bb-text-muted)" }}>
Кнопки скопированы с реального сайта{" "}
<span className="font-mono text-sm" style={{ color: "var(--bb-text)" }}>

2
apps/web/app/components/cards/page.tsx

@ -1,7 +1,6 @@
import type { Metadata } from "next";
import { CodeCopy } from "@/components/ui/CodeCopy";
import { LlmBlock, LlmSection, LlmTable, LlmRules } from "@/components/llm/LlmBlock";
import { BlockMetaBar } from "@/components/ui/BlockMetaBar";
export const metadata: Metadata = {
title: "Карточки. Цифровой брендбук Клиники ухо, горло, нос им. проф. Е.Н.Оленевой",
@ -418,7 +417,6 @@ export default function CardsPage() {
<h1 className="text-3xl font-semibold mb-3" style={{ color: "var(--bb-text)" }}>
Карточки
</h1>
<BlockMetaBar path="/components/cards" defaultVersion="v1.0" defaultIsInPreview={false} />
<p className="text-base max-w-2xl" style={{ color: "var(--bb-text-muted)" }}>
Карточки врача, новости, отзыва, цены и услуги основные блоки контента сайта.
Бейджи, теги и алерты вспомогательные элементы.

2
apps/web/app/components/forms/page.tsx

@ -2,7 +2,6 @@ import type { Metadata } from "next";
import { Toggle } from "@/components/ui/Toggle";
import { CodeCopy } from "@/components/ui/CodeCopy";
import { LlmBlock, LlmSection, LlmTable, LlmRules } from "@/components/llm/LlmBlock";
import { BlockMetaBar } from "@/components/ui/BlockMetaBar";
export const metadata: Metadata = {
title: "Форм-контролы. Цифровой брендбук Клиники ухо, горло, нос им. проф. Е.Н.Оленевой",
@ -265,7 +264,6 @@ input[type=email] {
<h1 className="text-3xl font-semibold mb-3" style={{ color: "var(--bb-text)" }}>
Форм-контролы
</h1>
<BlockMetaBar path="/components/forms" defaultVersion="v2.0" defaultIsInPreview={false} />
<p className="text-base max-w-2xl" style={{ color: "var(--bb-text-muted)" }}>
Элементы ввода данных: текстовые поля, выпадающие списки, флажки, переключатели.
Применяются в формах записи, фильтрах и настройках.

300
apps/web/app/components/navigation/page.tsx

@ -0,0 +1,300 @@
import type { Metadata } from "next";
import { LlmBlock, LlmSection, LlmTable, LlmRules } from "@/components/llm/LlmBlock";
export const metadata: Metadata = {
title: "Шапка и навигация. Цифровой брендбук Клиники ухо, горло, нос им. проф. Е.Н.Оленевой",
};
const NAV_ITEMS = [
"Клиника",
"ЛОР врачи",
"Заболевания",
"Вопрос-ответ",
"ЛОР-операции",
"Сурдология",
"Цены",
"Контакты",
];
const LLM_NAV_TEXT = `
БЛОК: Шапка и навигация сайта (Header)
Источник: perm.oclinica.ru/lor реальная шапка сайта
Версия: v1.0
СТРУКТУРА ШАПКИ (3 зоны сверху вниз):
1. ТОП-БАР (~40px высота, фон #fff, border-bottom 1px solid #e5e7eb)
Левая часть:
Адрес: «Б. Цитная, 3» с иконкой 📍
Ссылка: «Клиника ухо горло нос и аллергия»
Ссылка: «Центр аллергологии и пульмонологии»
Правая часть:
Телефон: «/342/ 255-53-84» (font-weight: bold, color: #111827)
Кнопка «Заказать звонок» стиль bb-btn-pill (#e9e4d4, border #d5cfbd, border-radius 25px)
2. ЛОГОТИП (~64px, фон #fff)
Иконка кружок синий (#0089c3) с крестом
Текст «КЛИНИКА УХО ГОРЛО НОС» жирный uppercase, цвет #53514e
Подпись «им. проф. Е.Н.Оленевой» мелкий, цвет #9ca3af
3. ГЛАВНОЕ МЕНЮ (~46px, фон #fff, border-top 1px solid #e5e7eb)
Пункты (8 штук): Клиника | ЛОР врачи | Заболевания | Вопрос-ответ | ЛОР-операции | Сурдология | Цены | Контакты
Шрифт: Fira Sans 14px, weight 400
Цвет ссылок: #53514e (--brand-073m)
Активный / Hover: #0089c3 (--brand-053m)
Разделители: border-right 1px solid #f3f4f6 между пунктами
ПРАВИЛА:
Логотип всегда кликабелен ведёт на главную страницу раздела
Кнопка «Заказать звонок» всегда видна, pill-стиль
Телефон кликабелен (tel: ссылка)
Активный пункт меню цвет #0089c3, остальные #53514e
Не добавлять пункты меню, которых нет на сайте
Не менять порядок пунктов меню
`.trim();
export default function NavigationPage() {
return (
<div className="p-8 max-w-5xl mx-auto space-y-10">
{/* Заголовок страницы */}
<div>
<p
className="text-xs font-semibold uppercase tracking-widest mb-1"
style={{ color: "var(--brand-053m)" }}
>
Компоненты
</p>
<h1 className="text-2xl font-bold mb-2" style={{ color: "var(--bb-text)" }}>
Шапка и навигация
</h1>
<p className="text-sm" style={{ color: "var(--bb-text-muted)" }}>
Точное воспроизведение шапки с perm.oclinica.ru/lor. Три зоны: топ-бар, логотип, главное меню.
</p>
</div>
{/* Живой пример */}
<section className="space-y-3">
<h2 className="font-semibold text-base" style={{ color: "var(--bb-text)" }}>
Живой пример
</h2>
<div
className="rounded-xl overflow-hidden"
style={{ border: "1px solid var(--bb-border)", 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 bb-nav-link whitespace-nowrap"
style={{
color: i === 0 ? "#0089c3" : "#53514e",
borderRight: "1px solid #f3f4f6",
fontWeight: i === 0 ? 500 : 400,
textDecoration: "none",
}}
>
{item}
</a>
))}
</nav>
</div>
</section>
{/* Анатомия */}
<section className="space-y-4">
<h2 className="font-semibold text-base" style={{ color: "var(--bb-text)" }}>
Анатомия шапки
</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{[
{
zone: "1. Топ-бар",
bg: "#f0f9ff",
height: "~40px",
color: "Фон: #fff",
desc: "Адрес, ссылки на разделы клиники, телефон, кнопка «Заказать звонок» (pill)",
},
{
zone: "2. Логотип",
bg: "#f0fdf4",
height: "~64px",
color: "Фон: #fff",
desc: "Иконка-кружок (#0089c3) + название клиники двумя строками + подпись мелким",
},
{
zone: "3. Навигация",
bg: "#fefce8",
height: "~46px",
color: "Фон: #fff, border-top",
desc: "8 горизонтальных пунктов, активный = #0089c3, остальные = #53514e",
},
].map((z) => (
<div
key={z.zone}
className="rounded-xl p-4 space-y-2"
style={{ background: z.bg, border: "1px solid var(--bb-border)" }}
>
<p className="font-semibold text-sm" style={{ color: "var(--bb-text)" }}>
{z.zone}
</p>
<p className="text-xs" style={{ color: "var(--bb-text-muted)" }}>
{z.desc}
</p>
<p className="text-[11px] font-mono mt-1" style={{ color: "var(--bb-text-muted)" }}>
H: {z.height} · {z.color}
</p>
</div>
))}
</div>
</section>
{/* Пункты меню */}
<section className="space-y-3">
<h2 className="font-semibold text-base" style={{ color: "var(--bb-text)" }}>
Пункты главного меню
</h2>
<div className="flex flex-wrap gap-2">
{NAV_ITEMS.map((item, i) => (
<span
key={item}
className="px-3 py-1.5 rounded text-sm"
style={{
background: i === 0 ? "#dff0fa" : "var(--bb-sidebar-bg)",
color: i === 0 ? "var(--brand-053m)" : "var(--bb-text)",
border: "1px solid var(--bb-border)",
fontWeight: i === 0 ? 500 : 400,
}}
>
{item}
{i === 0 && (
<span className="ml-1.5 text-[10px]" style={{ color: "var(--bb-text-muted)" }}>
(активный)
</span>
)}
</span>
))}
</div>
<p className="text-xs" style={{ color: "var(--bb-text-muted)" }}>
Порядок пунктов фиксирован. Hover и активный пункт цвет #0089c3 (--brand-053m).
</p>
</section>
{/* Токены */}
<section className="space-y-3">
<h2 className="font-semibold text-base" style={{ color: "var(--bb-text)" }}>
Цвета и токены
</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{[
{ label: "Фон шапки", value: "#ffffff", token: "—" },
{ label: "Ссылки меню", value: "#53514e", token: "--brand-073m" },
{ label: "Активный / hover", value: "#0089c3", token: "--brand-053m" },
{ label: "Кнопка «Заказать»", value: "#e9e4d4", token: "bb-btn-pill" },
].map((t) => (
<div
key={t.label}
className="p-3 rounded-xl space-y-2"
style={{ background: "var(--bb-sidebar-bg)", border: "1px solid var(--bb-border)" }}
>
<div
className="w-8 h-8 rounded border"
style={{ background: t.value, borderColor: "var(--bb-border)" }}
/>
<p className="text-xs font-medium" style={{ color: "var(--bb-text)" }}>
{t.label}
</p>
<p className="text-[10px] font-mono" style={{ color: "var(--bb-text-muted)" }}>
{t.value}
</p>
<p className="text-[10px]" style={{ color: "var(--bb-text-muted)" }}>
{t.token}
</p>
</div>
))}
</div>
</section>
{/* LLM блок */}
<LlmBlock path="/components/navigation" version="v1.0" specText={LLM_NAV_TEXT}>
<LlmSection title="Зоны шапки" />
<LlmTable
headers={["Зона", "Высота", "Фон", "Содержимое"]}
rows={[
["Топ-бар", "~40px", "#fff", "Адрес, ссылки разделов, телефон /342/ 255-53-84, кнопка «Заказать звонок»"],
["Логотип", "~64px", "#fff", "Кружок #0089c3 + «КЛИНИКА УХО ГОРЛО НОС ИМ. ПРОФ. Е.Н.ОЛЕНЕВОЙ»"],
["Навигация", "~46px", "#fff + border-top", "8 пунктов: Клиника / ЛОР врачи / Заболевания / Вопрос-ответ / ЛОР-операции / Сурдология / Цены / Контакты"],
]}
/>
<LlmSection title="Цвета" />
<LlmTable
headers={["Элемент", "Цвет", "Токен"]}
rows={[
["Фон шапки", "#ffffff", "—"],
["Ссылки меню (default)", "#53514e", "--brand-073m"],
["Активный пункт / hover", "#0089c3", "--brand-053m"],
["Кнопка «Заказать звонок»", "#e9e4d4 / border #d5cfbd", "bb-btn-pill"],
["Телефон", "#111827, font-weight bold", "—"],
]}
/>
<LlmSection title="Правила" />
<LlmRules
rules={[
{ ok: true, text: "Логотип ведёт на главную страницу раздела" },
{ ok: true, text: "Кнопка «Заказать звонок» всегда видна, pill-стиль" },
{ ok: true, text: "Телефон — кликабельная ссылка tel:" },
{ ok: true, text: "Активный пункт меню — цвет #0089c3" },
{ ok: false, text: "Не добавлять пункты меню, которых нет на сайте" },
{ ok: false, text: "Не менять порядок 8 пунктов меню" },
]}
/>
</LlmBlock>
</div>
);
}

6
apps/web/app/foundation/typography/page.tsx

@ -421,9 +421,9 @@ export default function TypographyPage() {
</thead>
<tbody>
{[
["H1 страницы (над Hero)", "Fira Sans", "36px", "700", "#cb9768", "ЛОР Клиника ухо, горло, нос…"],
["H2 секции", "Fira Sans", "36px", "700", "#000000", "Приём ведут опытные ЛОР врачи"],
["Заголовок Hero-баннера", "Fira Sans", "22px", "700", "#333333", "ЭНДОСКОПИЧЕСКОЕ ХИРУРГИЧЕСКОЕ…"],
["H1 страницы (над Hero)", "Fira Sans", "~28–30px", "700", "#bf9975", "ЛОР Клиника ухо, горло, нос…"],
["H2 секции", "Fira Sans", "~30px", "700", "#111827", "Приём ведут опытные ЛОР врачи"],
["Заголовок Hero-баннера", "Fira Sans", "~18px", "700", "#111827", "ЭНДОСКОПИЧЕСКОЕ ХИРУРГИЧЕСКОЕ…"],
["Статистика (цифра+текст)", "Fira Sans", "~18px", "700", "#60959c", "Ежедневно 27 ЛОР врачей…"],
["Подзаголовок секции", "Fira Sans", "~14px", "400", "#374151", "Фундаментальная теоретическая…"],
["Пункт checklist (ключ)", "Fira Sans", "13px", "700", "#111827", "БЕЗОПАСНО"],

18
apps/web/app/globals.css

@ -80,8 +80,8 @@ body {
background: #FFA39C;
color: #fff;
border-color: #FF847B;
border-radius: 4px;
font-weight: 700;
border-radius: 7px;
font-weight: bold;
box-shadow: 0px 0px 5px rgba(0,0,0,0.4), 0px 3px 5px rgba(0,0,0,0.25);
}
@ -109,20 +109,6 @@ body {
border-radius: 25px;
}
/* ─── Навигация сайта ────────────────────────────────────────── */
.bb-nav-item {
transition: background 0.15s;
}
.bb-nav-item:hover {
background: #f5f0e6;
}
.bb-nav-sub-item {
transition: background 0.1s;
}
.bb-nav-sub-item:hover {
background: #f5f0e6;
}
/* ─── Форм-контролы (Sprint 3) ───────────────────────────────── */
.bb-input,
.bb-textarea,

270
apps/web/app/pages/preview/PreviewClient.tsx

@ -1,270 +0,0 @@
"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>
);
}

11
apps/web/app/pages/preview/page.tsx

@ -1,11 +0,0 @@
import type { Metadata } from "next";
import { PreviewClient } from "./PreviewClient";
export const metadata: Metadata = {
title:
"Просмотр текущей страницы. Цифровой брендбук Клиники ухо, горло, нос им. проф. Е.Н.Оленевой",
};
export default function PreviewPage() {
return <PreviewClient />;
}

45
apps/web/components/blocks/CeoBlock.tsx

@ -1,45 +0,0 @@
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 0",
}}
>
<p>
Клиника ухо, нос специализируется на оториноларингологии лечении взрослых и детей
с ЛОР заболеваниями. ЛОР клиника ухо, горло, нос в Перми представлена по двум адресам:{" "}
<a href="#" style={{ color: "#52b4bd" }}>ул. Клары Цеткин, 9</a> |{" "}
<a href="#" style={{ color: "#52b4bd" }}>ул. Г. Звезда, 31а</a>.{" "}
Это <a href="#" style={{ color: "#52b4bd" }}>Клиника лечения кашля и аллергии</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>
);
}

84
apps/web/components/blocks/ContactFormsBlock.tsx

@ -1,84 +0,0 @@
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-lg 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-lg bb-btn-primary w-full">
Перезвоните мне
</button>
</div>
</div>
</div>
);
}

95
apps/web/components/blocks/DoctorsBlock.tsx

@ -1,95 +0,0 @@
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>
);
}

129
apps/web/components/blocks/FooterBlock.tsx

@ -1,129 +0,0 @@
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"
style={{ color: "#52b4bd", 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 shrink-0">
<img
src="/logo/logo-main.png"
alt="Клиника ухо, горло, нос"
className="h-12 w-auto"
/>
</div>
{/* Адреса и соцсети */}
<div className="text-center space-y-2">
<div className="text-xs space-y-0.5" style={{ color: "#374151" }}>
<p>Мы находимся по адресам:</p>
<p>г. Пермь, ул. Клары Цеткин, 9</p>
<p>г. Пермь, ул. Газеты Звезда, 31А</p>
</div>
<div className="flex items-center justify-center gap-2">
{["VK", "YT", "TG", "OK", "Дзен"].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-2" style={{ color: "#374151" }}>
<p className="font-semibold text-xs" style={{ color: "#111827" }}>
Время работы клиники:
</p>
<div>
<p className="font-medium" style={{ color: "#111827" }}>Газеты Звезда 31а</p>
<p>пн пт: с 09.00 до 21.00</p>
<p>сб вс: с 09:00 до 19:00</p>
</div>
<div>
<p className="font-medium" style={{ color: "#111827" }}>Клары Цеткин 9</p>
<p>пн сб: с 09.00 до 17.00</p>
<p>вс выходной</p>
</div>
</div>
</div>
</div>
);
}

92
apps/web/components/blocks/HeroBlock.tsx

@ -1,92 +0,0 @@
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-2 pt-1">
<button
className="text-xs px-3 py-1.5 rounded"
style={{
background: "#f9fafb",
border: "1px solid #e5e7eb",
color: "#9ca3af",
}}
>
Поделиться 98572
</button>
</div>
</div>
);
}

95
apps/web/components/blocks/NavigationBlock.tsx

@ -1,95 +0,0 @@
"use client";
import { NAV_ITEMS } from "./navData";
export { NAV_ITEMS };
function NavItem({ item, isActive }: { item: (typeof NAV_ITEMS)[number]; isActive: boolean }) {
const hasSubmenu = item.submenu.length > 0;
return (
<div className="bb-nav-item-wrap relative group">
<a
href="#"
className="bb-nav-item px-2.5 py-3 whitespace-nowrap inline-flex items-center gap-1"
style={{
fontSize: 18,
color: "#000",
fontWeight: 400,
textDecoration: "underline",
textUnderlineOffset: "4px",
}}
>
{item.label}
{hasSubmenu && (
<span className="text-xs" style={{ color: "#9ca3af", textDecoration: "none", display: "inline-block" }}></span>
)}
</a>
{hasSubmenu && (
<div
className="absolute left-0 top-full z-50 hidden group-hover:block min-w-[220px] py-1 rounded shadow-lg"
style={{ background: "#fff", border: "1px solid #e5e7eb" }}
>
{item.submenu.map((sub) => (
<a
key={sub}
href="#"
className="block px-4 py-2 text-sm bb-nav-sub-item"
style={{ color: "#333", textDecoration: "none" }}
>
{sub}
</a>
))}
</div>
)}
</div>
);
}
export function NavigationBlock() {
return (
<div className="bg-white relative">
{/* Верхняя панель: три столбца — логотип | ссылки | телефон+кнопка */}
<div className="flex items-center px-6 py-4">
{/* Столбец 1: Логотип */}
<div className="shrink-0">
<img
src="/logo/logo-main.png"
alt="Клиника ухо, горло, нос"
className="h-20 w-auto"
/>
</div>
{/* Столбец 2: Три ссылки вертикально — прижат к логотипу */}
<div className="flex flex-col gap-1.5 ml-6">
<a href="#" className="flex items-center gap-1.5 text-sm" style={{ color: "#9ca3af", textDecoration: "none" }}>
<span style={{ color: "#9ca3af" }}>📍</span>
<span style={{ color: "#52b4bd", textDecoration: "underline" }}>К. Цеткин, 9</span>
</a>
<a href="#" className="flex items-center gap-1.5 text-sm" style={{ color: "#9ca3af", textDecoration: "none" }}>
<span style={{ color: "#9ca3af" }}>📍</span>
<span style={{ color: "#52b4bd", textDecoration: "underline" }}>Клиника лечения кашля и аллергии</span>
</a>
<a href="#" className="flex items-center gap-1.5 text-sm" style={{ color: "#9ca3af", textDecoration: "none" }}>
<span style={{ color: "#9ca3af" }}>📍</span>
<span style={{ color: "#52b4bd", textDecoration: "underline" }}>Центр диагностики и реабилитации</span>
</a>
</div>
{/* Столбец 3: Телефон + кнопка вертикально — прижат вправо */}
<div className="flex flex-col items-end gap-2 shrink-0 ml-auto">
<span className="font-bold" style={{ color: "#000", fontSize: 25 }}>
/342/ 255-53-84
</span>
<button className="bb-btn bb-btn-md bb-btn-pill">Заказать звонок</button>
</div>
</div>
{/* Главное меню */}
<nav className="flex bg-white">
{NAV_ITEMS.map((item, i) => (
<NavItem key={item.label} item={item} isActive={i === 0} />
))}
</nav>
</div>
);
}

63
apps/web/components/blocks/NewsBlock.tsx

@ -1,63 +0,0 @@
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>
);
}

86
apps/web/components/blocks/ReviewsBlock.tsx

@ -1,86 +0,0 @@
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>
);
}

34
apps/web/components/blocks/navData.ts

@ -1,34 +0,0 @@
export const NAV_ITEMS = [
{
label: "Клиника",
submenu: [
"О клинике", "Официальная информация", "История", "Оборудование",
"Новости", "Отзывы", "ЛОР конференции", "Вакансии", "Доверенность",
"Клиника лечения кашля и аллергии", "Налоговый вычет",
],
},
{ label: "ЛОР врачи", submenu: [] as string[] },
{
label: "Заболевания",
submenu: ["Горло", "Ухо", "Нос", "У детей", "У беременных", "Диагностика"],
},
{
label: "Вопрос-ответ",
submenu: [
"Вопросы по заболеваниям уха", "Вопросы по заболеваниям горла",
"Вопросы по заболеваниям носа", "Вопросы по детским заболеваниям",
"Вопросы по операциям", "Задать свой вопрос?",
],
},
{
label: "ЛОР операции",
submenu: [
"Аденотомия", "Вазотомия", "Мастоидэктомия", "Тимпанопластика 1 типа",
"Тимпанопластика 2 типа", "Оссикулопластика", "Септопластика",
"Стапедопластика", "Тонзиллотомия", "Полипотомия", "Микрогайморотомия",
],
},
{ label: "Сурдология", submenu: [] as string[] },
{ label: "Цены", submenu: [] as string[] },
{ label: "Контакты", submenu: [] as string[] },
];

5
apps/web/components/layout/Sidebar.tsx

@ -30,12 +30,12 @@ const NAV: NavSection[] = [
{ label: "Кнопки", href: "/components/buttons" },
{ label: "Форм-контролы", href: "/components/forms" },
{ label: "Карточки", href: "/components/cards" },
{ label: "Шапка / Навигация", href: "/components/navigation" },
],
},
{
title: "Блоки",
items: [
{ label: "Шапка / Навигация", href: "/blocks/navigation" },
{ label: "Hero-баннер", href: "/blocks/hero" },
{ label: "Вводный текст", href: "/blocks/ceo" },
{ label: "Наши врачи", href: "/blocks/doctors" },
@ -48,7 +48,6 @@ const NAV: NavSection[] = [
{
title: "Страницы",
items: [
{ label: "Просмотр страницы", href: "/pages/preview" },
{ label: "Главная", href: "/pages/home", soon: true },
{ label: "Заболевание", href: "/pages/disease", soon: true },
{ label: "Все врачи", href: "/pages/doctors", soon: true },
@ -167,7 +166,7 @@ export function Sidebar() {
color: "var(--bb-sidebar-text-muted)",
}}
>
Sprint 6 · v0.6.0
Sprint 5 · v0.5.1
</div>
</aside>
);

41
apps/web/components/ui/BlockChangelog.tsx

@ -1,41 +0,0 @@
"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>
);
}

364
apps/web/components/ui/BlockMetaBar.tsx

@ -1,364 +0,0 @@
"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 SnapshotListItem {
id: string;
version: string;
createdAt: string;
}
export interface SnapshotData {
html: string;
css: string;
}
interface BlockMetaBarProps {
path: string;
defaultVersion: string;
defaultIsInPreview: boolean;
defaultChangelog?: ChangelogEntry[];
onSnapshotSelect?: (snapshot: SnapshotData | null) => void;
}
function captureBlockHtml(path: string): { html: string; css: string } {
const container = document.querySelector(`[data-block-capture="${path}"]`);
if (!container) throw new Error("Block container not found");
const html = container.innerHTML;
const cssRules: string[] = [];
for (const sheet of document.styleSheets) {
try {
for (const rule of sheet.cssRules) {
const text = rule.cssText;
if (text.includes("bb-") || text.includes("--brand") || text.includes("--bb-")) {
cssRules.push(text);
}
}
} catch { /* cross-origin sheets */ }
}
return { html, css: cssRules.join("\n") };
}
export function BlockMetaBar({ path, defaultVersion, defaultIsInPreview, defaultChangelog = [], onSnapshotSelect }: BlockMetaBarProps) {
const [meta, setMeta] = useState<BlockMeta | null>(null);
const [editing, setEditing] = useState(false);
const [versionInput, setVersionInput] = useState(defaultVersion);
const [saving, setSaving] = useState(false);
const [savingVersion, setSavingVersion] = useState<false | 'saving' | 'done'>(false);
const [togglingPreview, setTogglingPreview] = useState(false);
const [apiDown, setApiDown] = useState(false);
const [localPreview, setLocalPreview] = useState(defaultIsInPreview);
const [snapshots, setSnapshots] = useState<SnapshotListItem[]>([]);
const [selectedSnapshotId, setSelectedSnapshotId] = useState<string>("current");
const [loadingSnapshot, setLoadingSnapshot] = useState(false);
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]);
useEffect(() => {
if (!apiUrl || apiDown) return;
fetchSnapshots();
}, [apiUrl, apiDown, path]);
async function fetchSnapshots() {
if (!apiUrl) return;
try {
const r = await fetch(`${apiUrl}/blocks/snapshots?path=${encodeURIComponent(path)}`);
const data: SnapshotListItem[] = await r.json();
setSnapshots(data);
} catch { /* ignore */ }
}
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 saveFullVersion() {
if (apiDown || !apiUrl) return;
setSavingVersion('saving');
try {
// 1. Update block metadata (prefer current API version over hardcoded default)
const currentVersion = meta?.version ?? defaultVersion;
const updated = await patch({ version: currentVersion, changelog: defaultChangelog });
if (updated) {
setMeta(updated);
setVersionInput(updated.version);
}
// 2. Capture and save HTML snapshot
try {
const { html, css } = captureBlockHtml(path);
await fetch(`${apiUrl}/blocks/snapshots?path=${encodeURIComponent(path)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ version: currentVersion, changelog: defaultChangelog, html, css }),
});
await fetchSnapshots();
} catch { /* snapshot capture may fail if container not found */ }
setSavingVersion('done');
setTimeout(() => setSavingVersion(false), 1500);
} catch {
setSavingVersion(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);
}
}
async function handleVersionSelect(value: string) {
setSelectedSnapshotId(value);
if (value === "current") {
onSnapshotSelect?.(null);
return;
}
if (!apiUrl) return;
setLoadingSnapshot(true);
try {
const r = await fetch(`${apiUrl}/blocks/snapshots/${value}`);
const data = await r.json();
onSnapshotSelect?.({ html: data.html, css: data.css });
} catch {
onSnapshotSelect?.(null);
setSelectedSnapshotId("current");
} finally {
setLoadingSnapshot(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;
const isViewingSnapshot = selectedSnapshotId !== "current";
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 selector */}
<span className="font-semibold" style={{ color: "var(--bb-text-muted)" }}>
Версия:
</span>
{snapshots.length > 0 ? (
<select
value={selectedSnapshotId}
onChange={(e) => handleVersionSelect(e.target.value)}
disabled={loadingSnapshot}
className="font-mono px-2 py-0.5 rounded text-xs"
style={{
background: "var(--bb-sidebar-active-bg, #dff0fa)",
color: "var(--brand-053m)",
border: "1px solid var(--bb-border)",
cursor: "pointer",
}}
>
<option value="current">{version} (текущая)</option>
{snapshots.map((s) => (
<option key={s.id} value={s.id}>
{s.version} {new Date(s.createdAt).toLocaleDateString("ru")}
</option>
))}
</select>
) : 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>
)}
{loadingSnapshot && (
<span className="text-xs" style={{ color: "var(--bb-text-muted)" }}>загрузка...</span>
)}
<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>
<span style={{ color: "var(--bb-border)" }}>·</span>
{/* Save version button */}
<button
onClick={saveFullVersion}
disabled={apiDown || savingVersion === 'saving' || isViewingSnapshot}
title={apiDown ? 'API недоступен' : isViewingSnapshot ? 'Переключитесь на текущую версию' : 'Сохранить текущую версию и changelog в БД'}
className="px-2.5 py-1 rounded font-medium transition-colors"
style={savingVersion === 'done' ? {
background: "#dcfce7",
color: "#16a34a",
border: "1px solid #86efac",
cursor: "default",
} : {
background: "var(--bb-sidebar-bg)",
color: (apiDown || isViewingSnapshot) ? "var(--bb-text-muted)" : "var(--brand-053m)",
border: "1px solid var(--bb-border)",
cursor: (apiDown || isViewingSnapshot) ? "default" : "pointer",
opacity: (apiDown || isViewingSnapshot) ? 0.5 : 1,
}}
>
{savingVersion === 'saving' ? '...' : savingVersion === 'done' ? 'Сохранено' : 'Сохранить версию'}
</button>
{/* Subtle offline dot */}
{apiDown && (
<span
className="inline-block w-1.5 h-1.5 rounded-full"
title="API недоступен — данные из кода"
style={{ background: "#d1d5db" }}
/>
)}
</div>
{/* Archive notice */}
{isViewingSnapshot && (
<div
className="flex items-center gap-2 px-4 py-2 rounded-lg text-xs mb-4"
style={{ background: "#fef3c7", border: "1px solid #fcd34d", color: "#92400e" }}
>
Архивная версия (только просмотр).
<button
onClick={() => handleVersionSelect("current")}
className="underline font-medium"
style={{ color: "#92400e" }}
>
Вернуться к текущей
</button>
</div>
)}
{/* Changelog rendered from API or fallback */}
{changelog.length > 0 && <BlockChangelog changelog={changelog} />}
</>
);
}

2
apps/web/next.config.ts

@ -4,8 +4,6 @@ import path from "path";
const isDev = process.env.NODE_ENV === "development";
const nextConfig: NextConfig = {
output: "standalone",
outputFileTracingRoot: path.join(__dirname, "../.."),
...(isDev && {
turbopack: {
root: path.resolve(__dirname, "../.."),

51
docker-compose.yml

@ -1,6 +1,3 @@
# Полный стек: docker compose up --build -d → web :3000, api :3001, postgres :5434 (хост)
# Только БД: docker compose up -d postgres
services:
postgres:
image: postgres:16-alpine
@ -11,55 +8,9 @@ services:
POSTGRES_PASSWORD: brandbook
POSTGRES_DB: brandbook
ports:
- "5434:5432"
- "5433:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U brandbook -d brandbook"]
interval: 5s
timeout: 5s
retries: 10
api:
build:
context: .
dockerfile: docker/Dockerfile.api
container_name: brandbook_api
restart: unless-stopped
ports:
- "3001:3001"
environment:
DATABASE_URL: postgresql://brandbook:brandbook@postgres:5432/brandbook
PORT: "3001"
depends_on:
postgres:
condition: service_healthy
healthcheck:
test:
[
"CMD-SHELL",
"wget -qO- http://127.0.0.1:3001/ >/dev/null || exit 1",
]
interval: 10s
timeout: 5s
retries: 5
start_period: 40s
web:
build:
context: .
dockerfile: docker/Dockerfile.web
args:
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:3001}
container_name: brandbook_web
restart: unless-stopped
ports:
- "3000:3000"
environment:
PORT: "3000"
depends_on:
api:
condition: service_healthy
volumes:
postgres_data:

29
docker/Dockerfile.api

@ -1,29 +0,0 @@
FROM node:22-bookworm-slim AS api
WORKDIR /app
RUN apt-get update -y \
&& apt-get install -y --no-install-recommends openssl ca-certificates wget \
&& rm -rf /var/lib/apt/lists/*
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY apps/api/package.json ./apps/api/
COPY apps/web/package.json ./apps/web/
RUN pnpm install --frozen-lockfile
COPY apps/api ./apps/api
RUN pnpm --filter api exec prisma generate \
&& pnpm --filter api build
WORKDIR /app/apps/api
ENV NODE_ENV=production
ENV PORT=3001
EXPOSE 3001
CMD ["sh", "-c", "pnpm exec prisma migrate deploy && node dist/src/main.js"]

50
docker/Dockerfile.web

@ -1,50 +0,0 @@
FROM node:22-bookworm-slim AS deps
WORKDIR /app
RUN apt-get update -y \
&& apt-get install -y --no-install-recommends openssl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY apps/web/package.json ./apps/web/
COPY apps/api/package.json ./apps/api/
RUN pnpm install --frozen-lockfile
FROM deps AS builder
WORKDIR /app
COPY apps/web ./apps/web
ARG NEXT_PUBLIC_API_URL=http://localhost:3001
ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_ENV=production
RUN pnpm --filter web build
FROM node:22-bookworm-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
RUN addgroup --system --gid 1001 nodejs \
&& adduser --system --uid 1001 nextjs
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/public ./apps/web/public
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/static ./apps/web/.next/static
USER nextjs
EXPOSE 3000
CMD ["node", "apps/web/server.js"]

73
docs/LLM_CONTEXT.md

@ -2,9 +2,9 @@
## Клиника ухо, горло, нос им. проф. Е.Н.Оленевой
**Версия контекста:** 4.5
**Дата обновления:** 2026-03-25
**Актуальный спринт:** Sprint 6
**Версия контекста:** 4.2
**Дата обновления:** 2026-03-23
**Актуальный спринт:** Sprint 5
**Сайт клиники:** https://oclinica.ru
**Брендбук (локально):** http://localhost:3001
**Брендбук (production):** https://web-oclinica.vercel.app
@ -67,9 +67,8 @@
| Коралловый | `#ffa39c` | rgb(255,163,156) | Акценты | 2 | CTA-кнопки («Запишите меня!») |
| Основной текст | `#464646` | rgb(70,70,70) | Текст | 3 | Цвет основного текста на сайте |
| Второстепенный текст | `#949290` | rgb(148,146,144) | Текст | 4 | Подписи, второстепенный контент |
| H1 страницы | `#cb9768` | rgb(203,151,104) | Текст | 1 | Цвет H1 на страницах разделов |
| Светло-кремовый Hero | `#f9f4e7` | rgb(249,244,231) | Фоны | 1 | Фон Hero-баннера страниц разделов |
| Светло-бирюзовый фон | `#d4f6f8` | rgb(212,246,248) | Фоны | 2 | Фон обеих форм записи (ранее #b8e6ed) |
| Светло-бирюзовый фон | `#b8e6ed` | rgb(184,230,237) | Фоны | 1 | Фон формы записи («Будьте здоровы!») |
| Кремовый фон | `#e9e4d4` | rgb(233,228,212) | Фоны | 1 | Тёплые фоны кнопок-пилюль, вторичные секции |
| Светло-жёлтый фон | `#eef4d1` | rgb(238,244,209) | Фоны | 1 | Фон карточек отзывов |
| Светло-зелёный фон | `#f2fee6` | rgb(242,254,230) | Фоны | 1 | Фон секции новостей |
@ -291,16 +290,10 @@ SVG-версия ожидается (не получена от клиники).
| `/components/forms` | ✅ Готова | Форм-контролы — Input/Textarea/Select/Checkbox/Radio/Toggle |
| `/components/cards` | ✅ Sprint 4 | Карточки — врач, услуга, новость, отзыв, цена + бейджи/теги/алерты |
| `/components/*` | 🔜 Sprint 4–5 | Модалки, таблицы, навигация |
| `/blocks/navigation` | ✅ Sprint 5 v1.3 | Шапка: топ-бар, логотип, главное меню с подменю |
| `/blocks/hero` | ✅ Sprint 5 v1.3 | Hero-баннер: H1 36px #cb9768, заголовок 22px #333, кнопка pill, фон #f9f4e7 |
| `/blocks/ceo` | ✅ Sprint 5 v1.2 | Вводный текст: специализация клиники, вопросы-стимулы |
| `/blocks/doctors` | ✅ Sprint 5 v1.2 | Врачи: H2 36px #000, статистика #60959c без фона, 6 реальных фото |
| `/blocks/reviews` | ✅ Sprint 5 v1.1 | Отзывы: карусель, кавычка, стрелки навигации |
| `/blocks/contact-forms` | ✅ Sprint 5 v1.2 | Формы записи: «Будьте здоровы!» + «Узнайте стоимость» |
| `/blocks/news` | ✅ Sprint 5 v1.1 | Новости: 4 карточки, фон #f2fee6 |
| `/blocks/contact` | ✅ Sprint 5 v1.1 | Footer: 4 колонки ссылок, адреса, соцсети |
| `/pages/preview` | ✅ Sprint 5.5 | Просмотр страницы: сборка блоков с toggle «В превью» |
| `/pages/*` | 🔜 Sprint 6 | Главная, заболевание, врачи, цены, контакты |
| `/blocks/hero` | ✅ Sprint 5 v1.1 | Hero-баннер: фон #f9f4e7, реальное фото врача, галочки #bf9975, кнопка outline |
| `/blocks/doctors` | ✅ Sprint 5 v1.1 | Врачи: H2 30px, статистика #60959c без фона, 6 реальных фото |
| `/blocks/*` | 🔜 Sprint 5 | Отзывы, формы, новости, footer |
| `/pages/*` | 🔜 Sprint 9–11 | Главная, заболевание, врачи, цены, контакты |
---
@ -422,47 +415,7 @@ CSS-классы в `globals.css`. Компонент: `@/components/ui/Button`
---
## 10. Система версий блоков
Каждый блок поддерживает версионирование с HTML-снимками.
### Модель данных
**Block** — текущее состояние блока:
- `path` (unique), `version`, `isInPreview`, `changelog` (JSON), `name`
**BlockSnapshot** — архивный снимок версии:
- `blockPath`, `version` (unique пара), `html` (Text), `css` (Text), `changelog` (JSON)
### API эндпоинты
| Метод | URL | Назначение |
|-------|-----|------------|
| GET | `/blocks` | Все блоки |
| GET | `/blocks/by-path?path={path}` | Блок по пути (auto-create) |
| PATCH | `/blocks/by-path?path={path}` | Обновить version/isInPreview/changelog |
| POST | `/blocks/snapshots?path={path}` | Создать/обновить HTML-снимок версии |
| GET | `/blocks/snapshots?path={path}` | Список снимков (id, version, createdAt) |
| GET | `/blocks/snapshots/:id` | Полный снимок (html + css) |
### Компоненты
- **BlockMetaBar** (`components/ui/BlockMetaBar.tsx`) — версия dropdown, toggle «В превью», кнопка «Сохранить версию»
- Prop `onSnapshotSelect` — callback при выборе архивной версии
- При сохранении захватывает `innerHTML` из `[data-block-capture="{path}"]` + CSS правила с `bb-` префиксом
- **BlockChangelog** (`components/ui/BlockChangelog.tsx`) — рендер истории версий
### Архитектура страниц блоков
Каждая страница блока разделена на:
- `page.tsx` — Server Component (экспортирует `metadata`)
- `XxxPageClient.tsx` — Client Component (state для snapshot, `data-block-capture` для захвата)
При просмотре архивной версии живой компонент заменяется на `dangerouslySetInnerHTML` со снимком.
---
## 11. Технический стек проекта
## 10. Технический стек проекта
| Слой | Технология | Версия |
|------|-----------|--------|
@ -477,7 +430,7 @@ CSS-классы в `globals.css`. Компонент: `@/components/ui/Button`
---
## 12. История изменений контекста
## 11. История изменений контекста
| Версия | Дата | Что добавлено |
|--------|------|---------------|
@ -487,14 +440,10 @@ CSS-классы в `globals.css`. Компонент: `@/components/ui/Button`
| 3.0 | 2026-03-22 | Sprint 3: кнопки (Button), форм-контролы (Input/Textarea/Select/Checkbox/Radio/Toggle), LLM-блок на логотипе |
| 4.0 | 2026-03-22 | Sprint 4 start: исправлены цвета Oracal (точные RGB из каталога), кнопки/формы по реальному сайту |
| 4.1 | 2026-03-22 | Sprint 4 done: карточки (DoctorCard/NewsCard/ReviewCard/PriceCard/ServiceCard), бейджи/теги/алерты |
| 4.2 | 2026-03-23 | Sprint 5: блоки Hero v1.1, Doctors v1.1 |
| 4.3 | 2026-03-24 | Sprint 5.5: исправлены CSS-стили блоков по реальному сайту (H1 #cb9768 36px, H2 #000 36px, фоны форм #d4f6f8, фон новостей #f2fee6, CTA-кнопка pill) |
| 4.4 | 2026-03-25 | Sprint 5.5: кнопка «Сохранить версию» (snapshot HTML+CSS в PostgreSQL), dropdown переключения между версиями блоков, навигация v1.3 с подменю, блоки обновлены из предыдущей сессии |
| 4.5 | 2026-03-25 | Sprint 5/5.5 завершены: все 8 блоков /lor задокументированы, CEO v1.2, исправлен баг saveFullVersion() (откат версии), таблица блоков обновлена |
---
## 13. Что обновлять в этом файле
## 12. Что обновлять в этом файле
При каждом спринте добавляй:
- Новые компоненты и их спецификации (цвета, размеры, состояния)

139
docs/SPRINTS.md

@ -178,162 +178,54 @@
---
## Sprint 5 — Все блоки текущего сайта ✅ ЗАВЕРШЁН
## Sprint 5 — Все блоки текущего сайта
**Цель:** Задокументировать ВСЕ блоки страницы perm.oclinica.ru/lor за один спринт.
Источник: скриншот страницы + CSS сайта. Только фронтенд, mock-данные.
### Блоки с реального сайта (сверху вниз, по скриншоту /lor)
**Шапка и навигация** → `/blocks/navigation`
- [x] FE: Топ-бар: адрес «К. Цеткин, 9», телефон /342/ 255-53-84 (25px), кнопка «Заказать звонок» (pill)
- [x] FE: Логотип «КЛИНИКА УХО ГОРЛО НОС ИМ. ПРОФ. Е.Н. ОЛЕНЕВОЙ»
- [x] FE: Главное меню 18px: Клиника / ЛОР врачи / Заболевания / Вопрос-ответ / ЛОР операции / Сурдология / Цены / Контакты
- [x] FE: v1.1 — адрес, ссылки, размеры шрифтов, цвета синхронизированы с реальным сайтом
**Шапка и навигация** → `/components/navigation`
- [ ] FE: Топ-бар: адрес «Б. Цитная, 3», телефон /342/ 255-53-84, кнопка «Заказать звонок» (pill)
- [ ] FE: Логотип «КЛИНИКА УХО ГОРЛО НОС ИМ. ПРОФ. Е.Н. ОЛЕНЕВОЙ»
- [ ] FE: Главное меню: Клиника / ЛОР врачи / Заболевания / Вопрос-ответ / ЛОР-операции / Сурдология / Цены / Контакты
**Hero-баннер** → `/blocks/hero`
- [x] FE: Баннер — единый фон #f9f4e7 (светло-кремовый, замерян пикселем), галочки #bf9975
- [x] FE: Правая часть: реальное фото врача с пациентом (спарсено с сайта → public/hero-doctor.jpg)
- [x] LLM: v1.1 — исправлен цвет фона, кнопка outline, галочки бежевые
- [x] FE: v1.2 — H1: 36px/#cb9768, заголовок баннера: 22px/#333, CTA: pill-стиль, дефис в H1
- [x] FE: v1.3 — счётчик «Поделиться ✉ 98572», убраны кнопки VK/FB/TW
**Вводный текст (CEO-блок)** → `/blocks/ceo`
- [x] FE: Текст специализации клиники, Q&A вопросы-стимулы
- [x] FE: v1.1 — адрес «ул. Клары Цеткин, 9» (было «ул. Цитная, 9»), цвет ссылок #52b4bd
- [ ] FE: Текст специализации клиники, Q&A вопросы-стимулы
**Блок врачей** → `/blocks/doctors`
- [x] FE: Заголовок text-3xl + 3 стат-блока (без фона, #60959c + border-bottom) + сетка 6 карточек
- [x] FE: Реальные фото 6 врачей (спарсены с сайта → public/doctors/)
- [x] FE: Реальные имена и специализации врачей из слайдера /lor
- [x] LLM: v1.1
- [x] FE: v1.2 — H2: 36px/#000000, line-height 38px
**Блок отзывов** → `/blocks/reviews`
- [x] FE: Карусель: кавычка, текст отзыва, «Читать полностью», стрелки
- [x] FE: v1.1 — H2: 36px/#000000, line-height 38px
- [ ] FE: Карусель: кавычка, текст отзыва, «Читать полностью», стрелки
**Формы записи** → `/blocks/contact-forms`
- [x] FE: Форма «Будьте здоровы!» (фон #d4f6f8, поля: имя/телефон/врач, кнопка «Запишите меня!»)
- [x] FE: Форма «Узнайте стоимость операции» (фон #d4f6f8, поля: имя/телефон, кнопка «Перезвоните мне»)
- [x] FE: v1.1 — H2: 36px/#000000, фон обеих форм: #d4f6f8 (ранее #b8e6ed и #fff)
- [x] FE: v1.2 — кнопка bb-btn-lg 18px bold (было bb-btn-md 14px), border-radius 4px (было 7px)
- [ ] FE: Форма «Будьте здоровы!» (фон #b8e6ed, поля: имя/телефон/врач, кнопка «Запишите меня!»)
- [ ] FE: Форма «Узнайте стоимость операции» (белый фон, поля: имя/телефон, кнопка «Перезвоните мне»)
**Блок новостей** → `/blocks/news`
- [x] FE: 4 карточки новостей в ряд (дата + заголовок), кнопка «Все новости» (mock)
- [x] FE: v1.1 — H2: 36px/#000000, фон секции: #f2fee6 (ранее белый)
- [ ] FE: 4 карточки новостей в ряд (дата + заголовок), кнопка «Все новости» (mock)
**Footer (подвал)** → `/blocks/contact`
- [x] FE: 4 колонки ссылок по реальному сайту (О клинике 13, Заболевания 5, Вопрос-ответ 6, Операции 11)
- [x] FE: Два адреса: Клары Цеткин, 9 + Газеты Звезда, 31А
- [x] FE: Два графика работы по филиалам
- [x] FE: Соцсети: VK, YT, TG, OK, Дзен
- [x] FE: v1.1 — полное обновление контента по реальному сайту
- [ ] FE: 4 колонки ссылок, логотип, адрес, соцсети, часы работы
### Общее к Sprint 5
- [x] FE: LLM-блоки на hero v1.1 и doctors v1.1
- [x] Docs: Типографика сайта — реальные стили на 23.03.2026 (новый раздел в /foundation/typography)
- [x] Docs: Цвета — исправлен #f9f4e7 (Hero), #b8e6ed (форма), #e9e4d4 (пилюли)
- [x] FE: Убрать `soon` у Hero и Doctors в Sidebar
- [x] FE: CEO-блок v1.1
- [x] FE: Блоки отзывов, форм записи, новостей — компоненты + страницы документации
- [x] FE: Сравнение ВСЕХ блоков с реальным сайтом и синхронизация (24.03.2026)
- [x] Docs: Обновление `docs/LLM_CONTEXT.md` v4.3
- [x] FE: Исправлены CSS-стили ВСЕХ блоков по реальному сайту (24.03.2026):
- H1 страницы: 36px, #cb9768 (ранее ~20px, #53514e)
- H2 секций: 36px, #000000 (ранее ~20-30px, #111827)
- Hero заголовок: 22px, #333 (ранее 16px, #111827)
- CTA-кнопка Hero: pill (ранее outline)
- Фон форм: #d4f6f8 (ранее #b8e6ed и #fff)
- Фон новостей: #f2fee6 (ранее #fff)
- Типографика сайта: обновлена таблица стилей
- [x] FE: Добавлена «История версий» с changelog на каждую страницу блока
- [x] BE: Метаданные блоков (version, isInPreview, changelog) перенесены в PostgreSQL
- Prisma schema: добавлено поле `changelog Json @default("[]")`
- Seed обновлён: 8 блоков с актуальными версиями и историей изменений
- API: PATCH /blocks/by-path принимает changelog
- CORS: открыт для любого localhost порта
- BlockMetaBar: загружает version + changelog из API, fallback на defaultVersion/defaultChangelog
- Компонент BlockChangelog: отображает историю версий из API или из кода
- Надпись «API офлайн» заменена на серую точку
- [x] FE: Footer (блок `/blocks/contact` готов)
- [x] FE: CEO-блок v1.2 — убраны горизонтальные отступы (padding 40px 48px → 40px 0)
- [x] FE: Исправлен баг BlockMetaBar.saveFullVersion() — использовал defaultVersion вместо meta.version, что откатывало версию в БД при сохранении снапшота
**Результат спринта:** Hero v1.3, Doctors v1.2, CEO v1.2, Reviews v1.1, ContactForms v1.2, News v1.1, Navigation v1.3, Contact v1.1 — все блоки /lor задокументированы. Метаданные блоков хранятся в БД. Система снапшотов работает корректно.
- [ ] FE: Убрать `soon` у Hero и Doctors в Sidebar
- [ ] FE: CEO-блок, отзывы, формы, новости, footer
- [ ] Docs: Финальное обновление `docs/LLM_CONTEXT.md` по итогам Sprint 5
---
## Sprint 5.5 — «Просмотр текущей страницы» (внеочередной) ✅ ЗАВЕРШЁН
**Цель:** Добавить интерактивный раздел брендбука, который собирает главную страницу из уже задокументированных блоков.
Показывает живой превью того, как выглядит сайт на основе данных брендбука.
### Концепция UX
**Маршрут:** `/pages/preview`
**Сайдбар:** добавить в раздел «Страницы» с пометкой (если ещё нет блоков — показывает заглушку с кнопкой)
**Два состояния страницы:**
1. **Пустое состояние** (первый вход, или если превью не создавалось):
- Заголовок «Просмотр текущей страницы»
- Описание: «Здесь будет собрана главная страница из задокументированных блоков»
- Активная кнопка «Создать» (`.bb-btn bb-btn-primary`)
- После нажатия → переход в «созданное» состояние
2. **Созданное состояние** (после нажатия «Создать»):
- Превью главной страницы из всех доступных блоков в порядке сверху вниз, как на perm.oclinica.ru/lor
- Кнопка «Пересобрать» в шапке (сбрасывает до исходного состояния)
- Сборка только из блоков, у которых есть готовый компонент (не mock-заглушки)
- Блоки рендерятся как реальные React-компоненты внутри `<section>`
**Порядок блоков в превью** (по perm.oclinica.ru/lor, только готовые):
1. Hero-баннер (`/blocks/hero` → компонент HeroBlock)
2. Блок врачей (`/blocks/doctors` → компонент DoctorsBlock)
3. Блок отзывов (`/blocks/reviews` → когда будет готов)
4. Форма записи (`/blocks/contact-forms` → когда будет готова)
5. Блок новостей (`/blocks/news` → когда будет готов)
6. Footer (`/blocks/contact` → когда будет готов)
**Техническая реализация (FE only, без бэкенда):**
- Состояние сохраняется в `localStorage` (`preview-created: true/false`)
- Каждый задокументированный блок выносится в переиспользуемый React-компонент
- Страница `/pages/preview` импортирует компоненты и рендерит их в нужном порядке
- Блоки, которых ещё нет → показывается placeholder с текстом «Блок в разработке»
### Задачи
- [x] FE: Страница `/pages/preview` — пустое состояние с кнопкой «Создать»
- [x] FE: Логика `localStorage` — сохранение/сброс состояния превью
- [x] FE: Рефактор `/blocks/hero/page.tsx` — вынести баннер в компонент `HeroBlock` (переиспользуемый)
- [x] FE: Рефактор `/blocks/doctors/page.tsx` — вынести в компонент `DoctorsBlock`
- [x] FE: Placeholder-компонент для блоков, которые ещё не готовы (серая рамка с названием блока)
- [x] FE: Сборка превью: рендер всех доступных компонентов в порядке реального сайта
- [x] FE: Sidebar — добавить «Просмотр страницы» в раздел «Страницы»
- [x] FE: Кнопка «Пересобрать» в созданном состоянии
- [x] FE: Toggle «В превью» через localStorage (fallback при API офлайн)
- [x] BE: BlockMetaBar + PreviewClient подключены к NestJS API `/blocks`
- [x] BE: Метаданные блоков (version, changelog, isInPreview) в PostgreSQL
- [x] Docs: Добавить `/pages/preview` v1.0 в LLM_CONTEXT.md
- [x] FE: Кнопка «Сохранить версию» — фиксация version + changelog из кода в БД
- [x] BE: Модель BlockSnapshot (html, css, version, changelog) + миграция
- [x] BE: API эндпоинты: POST/GET `/blocks/snapshots`, GET `/blocks/snapshots/:id`
- [x] FE: BlockMetaBar — dropdown переключения между сохранёнными версиями
- [x] FE: HTML-снимок блока при сохранении версии (innerHTML + CSS capture)
- [x] FE: Все 8 страниц блоков — split на Server/Client Components для snapshot support
- [x] FE: Навигация v1.3 — подменю с hover-dropdown, navData.ts, обновлённые стили блоков
### Зависимости
- Зависит от: Sprint 5 (блоки hero и doctors уже готовы — ✅)
- По мере добавления новых блоков в Sprint 5 — они автоматически подключаются к превью
### Ожидаемый результат
- Раздел «Просмотр текущей страницы» работает в браузере
- Кнопка «Создать» собирает главную страницу из задокументированных блоков
- Отсутствующие блоки отображаются как плейсхолдеры
- Кнопка «Пересобрать» позволяет сбросить и пересоздать
**Результат спринта (в работе):** Hero + Doctors задокументированы с реальными фото и точными цветами.
---
@ -463,7 +355,6 @@
| 3 | Кнопки и форм-контролы | FE | CSS реального сайта |
| 4 | Карточки, бейджи, алерты | FE | CSS реального сайта |
| 5 | ВСЕ блоки сайта | FE | Все блоки /lor, mock-данные |
| 5.5 | Просмотр текущей страницы | FE | Кнопка «Создать», сборка из блоков, localStorage |
| 6 | Все страницы (сборки) | FE | Сборки из блоков, mock-данные |
| 7 | Авторизация (viewer / editor) | BE + FE | JWT, роли, login-страница, шапка с именем |
| 8 | Реальные данные | BE + FE | NestJS прокси → oclinica.ru, кэш 15 мин |

492
pnpm-lock.yaml

@ -19,21 +19,12 @@ importers:
'@nestjs/platform-express':
specifier: ^11.0.1
version: 11.1.17(@nestjs/common@11.1.17(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17)
'@prisma/adapter-pg':
specifier: ^7.5.0
version: 7.5.0
'@prisma/client':
specifier: ^7.5.0
version: 7.5.0(prisma@7.5.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3)
'@types/pg':
specifier: ^8.20.0
version: 8.20.0
dotenv:
specifier: ^17.3.1
version: 17.3.1
pg:
specifier: ^8.20.0
version: 8.20.0
reflect-metadata:
specifier: ^0.2.2
version: 0.2.2
@ -107,9 +98,6 @@ importers:
tsconfig-paths:
specifier: ^4.2.0
version: 4.2.0
tsx:
specifier: ^4.21.0
version: 4.21.0
typescript:
specifier: ^5.7.3
version: 5.9.3
@ -402,162 +390,6 @@ packages:
'@emnapi/wasi-threads@1.2.0':
resolution: {integrity: sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==}
'@esbuild/aix-ppc64@0.27.4':
resolution: {integrity: sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
'@esbuild/android-arm64@0.27.4':
resolution: {integrity: sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
'@esbuild/android-arm@0.27.4':
resolution: {integrity: sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
'@esbuild/android-x64@0.27.4':
resolution: {integrity: sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
'@esbuild/darwin-arm64@0.27.4':
resolution: {integrity: sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-x64@0.27.4':
resolution: {integrity: sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
'@esbuild/freebsd-arm64@0.27.4':
resolution: {integrity: sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-x64@0.27.4':
resolution: {integrity: sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
'@esbuild/linux-arm64@0.27.4':
resolution: {integrity: sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm@0.27.4':
resolution: {integrity: sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
'@esbuild/linux-ia32@0.27.4':
resolution: {integrity: sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
'@esbuild/linux-loong64@0.27.4':
resolution: {integrity: sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
'@esbuild/linux-mips64el@0.27.4':
resolution: {integrity: sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
'@esbuild/linux-ppc64@0.27.4':
resolution: {integrity: sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
'@esbuild/linux-riscv64@0.27.4':
resolution: {integrity: sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
'@esbuild/linux-s390x@0.27.4':
resolution: {integrity: sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
'@esbuild/linux-x64@0.27.4':
resolution: {integrity: sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
'@esbuild/netbsd-arm64@0.27.4':
resolution: {integrity: sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
'@esbuild/netbsd-x64@0.27.4':
resolution: {integrity: sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
'@esbuild/openbsd-arm64@0.27.4':
resolution: {integrity: sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
'@esbuild/openbsd-x64@0.27.4':
resolution: {integrity: sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
'@esbuild/openharmony-arm64@0.27.4':
resolution: {integrity: sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openharmony]
'@esbuild/sunos-x64@0.27.4':
resolution: {integrity: sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
'@esbuild/win32-arm64@0.27.4':
resolution: {integrity: sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
'@esbuild/win32-ia32@0.27.4':
resolution: {integrity: sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
'@esbuild/win32-x64@0.27.4':
resolution: {integrity: sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
'@eslint-community/eslint-utils@4.9.1':
resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
@ -1183,9 +1015,6 @@ packages:
resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==}
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
'@prisma/adapter-pg@7.5.0':
resolution: {integrity: sha512-EJx7OLULahcC3IjJgdx2qRDNCT+ToY2v66UkeETMCLhNOTgqVzRzYvOEphY7Zp0eHyzfkC33Edd/qqeadf9R4A==}
'@prisma/client-runtime-utils@7.5.0':
resolution: {integrity: sha512-KnJ2b4Si/pcWEtK68uM+h0h1oh80CZt2suhLTVuLaSKg4n58Q9jBF/A42Kw6Ma+aThy1yAhfDeTC0JvEmeZnFQ==}
@ -1213,9 +1042,6 @@ packages:
'@prisma/dev@0.20.0':
resolution: {integrity: sha512-ovlBYwWor0OzG+yH4J3Ot+AneD818BttLA+Ii7wjbcLHUrnC4tbUPVGyNd3c/+71KETPKZfjhkTSpdS15dmXNQ==}
'@prisma/driver-adapter-utils@7.5.0':
resolution: {integrity: sha512-B79N/amgV677mFesFDBAdrW0OIaqawap9E0sjgLBtzIz2R3hIMS1QB8mLZuUEiS4q5Y8Oh3I25Kw4SLxMypk9Q==}
'@prisma/engines-version@7.5.0-15.280c870be64f457428992c43c1f6d557fab6e29e':
resolution: {integrity: sha512-E+iRV/vbJLl8iGjVr6g/TEWokA+gjkV/doZkaQN1i/ULVdDwGnPJDfLUIFGS3BVwlG/m6L8T4x1x5isl8hGMxA==}
@ -1436,12 +1262,6 @@ packages:
'@types/node@22.19.15':
resolution: {integrity: sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==}
'@types/pg@8.11.11':
resolution: {integrity: sha512-kGT1qKM8wJQ5qlawUrEkXgvMSXoV213KfMGXcwfDwUIfUHXqXYXOfS1nE1LINRJVVVx5wCm70XnFlMHaIcQAfw==}
'@types/pg@8.20.0':
resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==}
'@types/qs@6.15.0':
resolution: {integrity: sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==}
@ -2321,11 +2141,6 @@ packages:
resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==}
engines: {node: '>= 0.4'}
esbuild@0.27.4:
resolution: {integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==}
engines: {node: '>=18'}
hasBin: true
escalade@3.2.0:
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
engines: {node: '>=6'}
@ -3567,9 +3382,6 @@ packages:
resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==}
engines: {node: '>= 0.4'}
obuf@1.1.2:
resolution: {integrity: sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==}
ohash@2.0.11:
resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==}
@ -3667,48 +3479,6 @@ packages:
perfect-debounce@1.0.0:
resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==}
pg-cloudflare@1.3.0:
resolution: {integrity: sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==}
pg-connection-string@2.12.0:
resolution: {integrity: sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==}
pg-int8@1.0.1:
resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==}
engines: {node: '>=4.0.0'}
pg-numeric@1.0.2:
resolution: {integrity: sha512-BM/Thnrw5jm2kKLE5uJkXqqExRUY/toLHda65XgFTBTFYZyopbKjBe29Ii3RbkvlsMoFwD+tHeGaCjjv0gHlyw==}
engines: {node: '>=4'}
pg-pool@3.13.0:
resolution: {integrity: sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==}
peerDependencies:
pg: '>=8.0'
pg-protocol@1.13.0:
resolution: {integrity: sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==}
pg-types@2.2.0:
resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==}
engines: {node: '>=4'}
pg-types@4.1.0:
resolution: {integrity: sha512-o2XFanIMy/3+mThw69O8d4n1E5zsLhdO+OPqswezu7Z5ekP4hYDqlDjlmOpYMbzY2Br0ufCwJLdDIXeNVwcWFg==}
engines: {node: '>=10'}
pg@8.20.0:
resolution: {integrity: sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==}
engines: {node: '>= 16.0.0'}
peerDependencies:
pg-native: '>=3.0.1'
peerDependenciesMeta:
pg-native:
optional: true
pgpass@1.0.5:
resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==}
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@ -3751,41 +3521,6 @@ packages:
resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==}
engines: {node: ^10 || ^12 || >=14}
postgres-array@2.0.0:
resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==}
engines: {node: '>=4'}
postgres-array@3.0.4:
resolution: {integrity: sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ==}
engines: {node: '>=12'}
postgres-bytea@1.0.1:
resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==}
engines: {node: '>=0.10.0'}
postgres-bytea@3.0.0:
resolution: {integrity: sha512-CNd4jim9RFPkObHSjVHlVrxoVQXz7quwNFpz7RY1okNNme49+sVyiTvTRobiLV548Hx/hb1BG+iE7h9493WzFw==}
engines: {node: '>= 6'}
postgres-date@1.0.7:
resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==}
engines: {node: '>=0.10.0'}
postgres-date@2.1.0:
resolution: {integrity: sha512-K7Juri8gtgXVcDfZttFKVmhglp7epKb1K4pgrkLxehjqkrgPhfG6OO8LHLkfaqkbpjNRnra018XwAr1yQFWGcA==}
engines: {node: '>=12'}
postgres-interval@1.2.0:
resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==}
engines: {node: '>=0.10.0'}
postgres-interval@3.0.0:
resolution: {integrity: sha512-BSNDnbyZCXSxgA+1f5UU2GmwhoI0aU5yMxRGO8CdFEcY2BQF9xm/7MqKnYoM1nJDk8nONNWDk9WeSmePFhQdlw==}
engines: {node: '>=12'}
postgres-range@1.1.4:
resolution: {integrity: sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w==}
postgres@3.4.7:
resolution: {integrity: sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==}
engines: {node: '>=12'}
@ -4081,10 +3816,6 @@ packages:
resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==}
engines: {node: '>= 12'}
split2@4.2.0:
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
engines: {node: '>= 10.x'}
sprintf-js@1.0.3:
resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==}
@ -4344,11 +4075,6 @@ packages:
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
tsx@4.21.0:
resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==}
engines: {node: '>=18.0.0'}
hasBin: true
type-check@0.4.0:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'}
@ -4541,10 +4267,6 @@ packages:
resolution: {integrity: sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==}
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
xtend@4.0.2:
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
engines: {node: '>=0.4'}
y18n@5.0.8:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'}
@ -4881,84 +4603,6 @@ snapshots:
tslib: 2.8.1
optional: true
'@esbuild/aix-ppc64@0.27.4':
optional: true
'@esbuild/android-arm64@0.27.4':
optional: true
'@esbuild/android-arm@0.27.4':
optional: true
'@esbuild/android-x64@0.27.4':
optional: true
'@esbuild/darwin-arm64@0.27.4':
optional: true
'@esbuild/darwin-x64@0.27.4':
optional: true
'@esbuild/freebsd-arm64@0.27.4':
optional: true
'@esbuild/freebsd-x64@0.27.4':
optional: true
'@esbuild/linux-arm64@0.27.4':
optional: true
'@esbuild/linux-arm@0.27.4':
optional: true
'@esbuild/linux-ia32@0.27.4':
optional: true
'@esbuild/linux-loong64@0.27.4':
optional: true
'@esbuild/linux-mips64el@0.27.4':
optional: true
'@esbuild/linux-ppc64@0.27.4':
optional: true
'@esbuild/linux-riscv64@0.27.4':
optional: true
'@esbuild/linux-s390x@0.27.4':
optional: true
'@esbuild/linux-x64@0.27.4':
optional: true
'@esbuild/netbsd-arm64@0.27.4':
optional: true
'@esbuild/netbsd-x64@0.27.4':
optional: true
'@esbuild/openbsd-arm64@0.27.4':
optional: true
'@esbuild/openbsd-x64@0.27.4':
optional: true
'@esbuild/openharmony-arm64@0.27.4':
optional: true
'@esbuild/sunos-x64@0.27.4':
optional: true
'@esbuild/win32-arm64@0.27.4':
optional: true
'@esbuild/win32-ia32@0.27.4':
optional: true
'@esbuild/win32-x64@0.27.4':
optional: true
'@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@2.6.1))':
dependencies:
eslint: 9.39.4(jiti@2.6.1)
@ -5638,15 +5282,6 @@ snapshots:
'@pkgr/core@0.2.9': {}
'@prisma/adapter-pg@7.5.0':
dependencies:
'@prisma/driver-adapter-utils': 7.5.0
'@types/pg': 8.11.11
pg: 8.20.0
postgres-array: 3.0.4
transitivePeerDependencies:
- pg-native
'@prisma/client-runtime-utils@7.5.0': {}
'@prisma/client@7.5.0(prisma@7.5.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3)':
@ -5691,10 +5326,6 @@ snapshots:
transitivePeerDependencies:
- typescript
'@prisma/driver-adapter-utils@7.5.0':
dependencies:
'@prisma/debug': 7.5.0
'@prisma/engines-version@7.5.0-15.280c870be64f457428992c43c1f6d557fab6e29e': {}
'@prisma/engines@7.5.0':
@ -5923,18 +5554,6 @@ snapshots:
dependencies:
undici-types: 6.21.0
'@types/pg@8.11.11':
dependencies:
'@types/node': 22.19.15
pg-protocol: 1.13.0
pg-types: 4.1.0
'@types/pg@8.20.0':
dependencies:
'@types/node': 22.19.15
pg-protocol: 1.13.0
pg-types: 2.2.0
'@types/qs@6.15.0': {}
'@types/range-parser@1.2.7': {}
@ -6911,35 +6530,6 @@ snapshots:
is-date-object: 1.1.0
is-symbol: 1.1.1
esbuild@0.27.4:
optionalDependencies:
'@esbuild/aix-ppc64': 0.27.4
'@esbuild/android-arm': 0.27.4
'@esbuild/android-arm64': 0.27.4
'@esbuild/android-x64': 0.27.4
'@esbuild/darwin-arm64': 0.27.4
'@esbuild/darwin-x64': 0.27.4
'@esbuild/freebsd-arm64': 0.27.4
'@esbuild/freebsd-x64': 0.27.4
'@esbuild/linux-arm': 0.27.4
'@esbuild/linux-arm64': 0.27.4
'@esbuild/linux-ia32': 0.27.4
'@esbuild/linux-loong64': 0.27.4
'@esbuild/linux-mips64el': 0.27.4
'@esbuild/linux-ppc64': 0.27.4
'@esbuild/linux-riscv64': 0.27.4
'@esbuild/linux-s390x': 0.27.4
'@esbuild/linux-x64': 0.27.4
'@esbuild/netbsd-arm64': 0.27.4
'@esbuild/netbsd-x64': 0.27.4
'@esbuild/openbsd-arm64': 0.27.4
'@esbuild/openbsd-x64': 0.27.4
'@esbuild/openharmony-arm64': 0.27.4
'@esbuild/sunos-x64': 0.27.4
'@esbuild/win32-arm64': 0.27.4
'@esbuild/win32-ia32': 0.27.4
'@esbuild/win32-x64': 0.27.4
escalade@3.2.0: {}
escape-html@1.0.3: {}
@ -8443,8 +8033,6 @@ snapshots:
define-properties: 1.2.1
es-object-atoms: 1.1.1
obuf@1.1.2: {}
ohash@2.0.11: {}
on-finished@2.4.1:
@ -8545,53 +8133,6 @@ snapshots:
perfect-debounce@1.0.0: {}
pg-cloudflare@1.3.0:
optional: true
pg-connection-string@2.12.0: {}
pg-int8@1.0.1: {}
pg-numeric@1.0.2: {}
pg-pool@3.13.0(pg@8.20.0):
dependencies:
pg: 8.20.0
pg-protocol@1.13.0: {}
pg-types@2.2.0:
dependencies:
pg-int8: 1.0.1
postgres-array: 2.0.0
postgres-bytea: 1.0.1
postgres-date: 1.0.7
postgres-interval: 1.2.0
pg-types@4.1.0:
dependencies:
pg-int8: 1.0.1
pg-numeric: 1.0.2
postgres-array: 3.0.4
postgres-bytea: 3.0.0
postgres-date: 2.1.0
postgres-interval: 3.0.0
postgres-range: 1.1.4
pg@8.20.0:
dependencies:
pg-connection-string: 2.12.0
pg-pool: 3.13.0(pg@8.20.0)
pg-protocol: 1.13.0
pg-types: 2.2.0
pgpass: 1.0.5
optionalDependencies:
pg-cloudflare: 1.3.0
pgpass@1.0.5:
dependencies:
split2: 4.2.0
picocolors@1.1.1: {}
picomatch@2.3.1: {}
@ -8628,28 +8169,6 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
postgres-array@2.0.0: {}
postgres-array@3.0.4: {}
postgres-bytea@1.0.1: {}
postgres-bytea@3.0.0:
dependencies:
obuf: 1.1.2
postgres-date@1.0.7: {}
postgres-date@2.1.0: {}
postgres-interval@1.2.0:
dependencies:
xtend: 4.0.2
postgres-interval@3.0.0: {}
postgres-range@1.1.4: {}
postgres@3.4.7: {}
prelude-ls@1.2.1: {}
@ -9013,8 +8532,6 @@ snapshots:
source-map@0.7.6: {}
split2@4.2.0: {}
sprintf-js@1.0.3: {}
sqlstring@2.3.3: {}
@ -9292,13 +8809,6 @@ snapshots:
tslib@2.8.1: {}
tsx@4.21.0:
dependencies:
esbuild: 0.27.4
get-tsconfig: 4.13.6
optionalDependencies:
fsevents: 2.3.3
type-check@0.4.0:
dependencies:
prelude-ls: 1.2.1
@ -9563,8 +9073,6 @@ snapshots:
imurmurhash: 0.1.4
signal-exit: 4.1.0
xtend@4.0.2: {}
y18n@5.0.8: {}
yallist@3.1.1: {}

5
vercel.json

@ -1,5 +0,0 @@
{
"buildCommand": "pnpm --filter web build",
"outputDirectory": "apps/web/.next",
"installCommand": "pnpm install"
}
Loading…
Cancel
Save