Compare commits

..

No commits in common. 'main' and 'sprint/1' 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. 10
      apps/web/app/blocks/ceo/page.tsx
  20. 190
      apps/web/app/blocks/contact-forms/ContactFormsPageClient.tsx
  21. 10
      apps/web/app/blocks/contact-forms/page.tsx
  22. 204
      apps/web/app/blocks/contact/ContactPageClient.tsx
  23. 10
      apps/web/app/blocks/contact/page.tsx
  24. 187
      apps/web/app/blocks/doctors/DoctorsPageClient.tsx
  25. 10
      apps/web/app/blocks/doctors/page.tsx
  26. 231
      apps/web/app/blocks/hero/HeroPageClient.tsx
  27. 10
      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. 10
      apps/web/app/blocks/news/page.tsx
  32. 226
      apps/web/app/blocks/reviews/ReviewsPageClient.tsx
  33. 10
      apps/web/app/blocks/reviews/page.tsx
  34. 575
      apps/web/app/components/buttons/page.tsx
  35. 631
      apps/web/app/components/cards/page.tsx
  36. 725
      apps/web/app/components/forms/page.tsx
  37. 821
      apps/web/app/foundation/colors/page.tsx
  38. 219
      apps/web/app/foundation/logo/page.tsx
  39. 560
      apps/web/app/foundation/typography/page.tsx
  40. 180
      apps/web/app/globals.css
  41. 205
      apps/web/app/offline/badges/page.tsx
  42. 243
      apps/web/app/offline/navigation/page.tsx
  43. 331
      apps/web/app/offline/print/page.tsx
  44. 178
      apps/web/app/offline/transport/page.tsx
  45. 228
      apps/web/app/offline/uniform/page.tsx
  46. 270
      apps/web/app/pages/preview/PreviewClient.tsx
  47. 11
      apps/web/app/pages/preview/page.tsx
  48. 45
      apps/web/components/blocks/CeoBlock.tsx
  49. 84
      apps/web/components/blocks/ContactFormsBlock.tsx
  50. 95
      apps/web/components/blocks/DoctorsBlock.tsx
  51. 129
      apps/web/components/blocks/FooterBlock.tsx
  52. 92
      apps/web/components/blocks/HeroBlock.tsx
  53. 95
      apps/web/components/blocks/NavigationBlock.tsx
  54. 63
      apps/web/components/blocks/NewsBlock.tsx
  55. 86
      apps/web/components/blocks/ReviewsBlock.tsx
  56. 34
      apps/web/components/blocks/navData.ts
  57. 46
      apps/web/components/layout/Sidebar.tsx
  58. 175
      apps/web/components/llm/LlmBlock.tsx
  59. 41
      apps/web/components/ui/BlockChangelog.tsx
  60. 364
      apps/web/components/ui/BlockMetaBar.tsx
  61. 45
      apps/web/components/ui/Button.tsx
  62. 60
      apps/web/components/ui/CodeCopy.tsx
  63. 59
      apps/web/components/ui/Toggle.tsx
  64. 12
      apps/web/next.config.ts
  65. BIN
      apps/web/public/doctors/lobanova.jpg
  66. BIN
      apps/web/public/doctors/makarova.jpg
  67. BIN
      apps/web/public/doctors/semerikova.png
  68. BIN
      apps/web/public/doctors/suvorova.jpg
  69. BIN
      apps/web/public/doctors/torsunova.jpg
  70. BIN
      apps/web/public/doctors/voronchikhina.png
  71. BIN
      apps/web/public/hero-doctor.jpg
  72. BIN
      apps/web/public/offline/badges/badge-1.jpeg
  73. BIN
      apps/web/public/offline/badges/badge-2.jpeg
  74. BIN
      apps/web/public/offline/navigation/nav-1.jpeg
  75. BIN
      apps/web/public/offline/navigation/nav-2.jpeg
  76. BIN
      apps/web/public/offline/navigation/nav-3.jpeg
  77. BIN
      apps/web/public/offline/navigation/nav-directory.jpeg
  78. BIN
      apps/web/public/offline/navigation/nav-door-31.png
  79. BIN
      apps/web/public/offline/navigation/nav-mockup-kabinet.jpeg
  80. BIN
      apps/web/public/offline/navigation/nav-mockup-signs.jpeg
  81. BIN
      apps/web/public/offline/navigation/nav-p14-1.jpeg
  82. BIN
      apps/web/public/offline/navigation/nav-p14-2.jpeg
  83. BIN
      apps/web/public/offline/navigation/nav-p14-3.jpeg
  84. BIN
      apps/web/public/offline/navigation/nav-render-p13.jpeg
  85. BIN
      apps/web/public/offline/navigation/nav-render-p14.jpeg
  86. BIN
      apps/web/public/offline/transport/tram-1.png
  87. BIN
      apps/web/public/offline/transport/tram-10.jpeg
  88. BIN
      apps/web/public/offline/transport/tram-11.png
  89. BIN
      apps/web/public/offline/transport/tram-12.jpeg
  90. BIN
      apps/web/public/offline/transport/tram-13.png
  91. BIN
      apps/web/public/offline/transport/tram-14.jpeg
  92. BIN
      apps/web/public/offline/transport/tram-15.png
  93. BIN
      apps/web/public/offline/transport/tram-16.jpeg
  94. BIN
      apps/web/public/offline/transport/tram-17.png
  95. BIN
      apps/web/public/offline/transport/tram-18.jpeg
  96. BIN
      apps/web/public/offline/transport/tram-2.png
  97. BIN
      apps/web/public/offline/transport/tram-3.png
  98. BIN
      apps/web/public/offline/transport/tram-4.png
  99. BIN
      apps/web/public/offline/transport/tram-5.png
  100. BIN
      apps/web/public/offline/transport/tram-6.png
  101. Some files were not shown because too many files have changed in this diff Show More

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>
);
}

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

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

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>
);
}

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

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

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>
);
}

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

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

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>
);
}

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

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

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>
);
}

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

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

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>
);
}

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

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

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>
);
}

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

@ -1,10 +0,0 @@
import type { Metadata } from "next";
import ReviewsPageClient from "./ReviewsPageClient";
export const metadata: Metadata = {
title: "Блок «Отзывы». Цифровой брендбук Клиники ухо, горло, нос им. проф. Е.Н.Оленевой",
};
export default function ReviewsBlockPage() {
return <ReviewsPageClient />;
}

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

@ -1,575 +0,0 @@
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: "Кнопки. Цифровой брендбук Клиники ухо, горло, нос им. проф. Е.Н.Оленевой",
};
function Section({
id,
title,
subtitle,
children,
}: {
id?: string;
title: string;
subtitle?: string;
children: React.ReactNode;
}) {
return (
<section id={id} className="mb-12">
<div className="mb-6">
<h2 className="text-xl font-semibold" style={{ color: "var(--bb-text)" }}>
{title}
</h2>
{subtitle && (
<p className="mt-1 text-sm" style={{ color: "var(--bb-text-muted)" }}>
{subtitle}
</p>
)}
</div>
{children}
</section>
);
}
const VARIANTS = [
{
variant: "primary" as const,
name: "Primary",
label: "Запишитесь к нам",
cssClass: ".bb-btn-primary",
bg: "#FFA39C",
border: "#FF847B",
textColor: "#fff",
radius: "7px",
shadow: "да",
where: "Кнопка отправки форм записи",
example: "«Запишите меня!»",
note: "Коралловый — самый заметный акцент на странице. Всегда один в форме.",
},
{
variant: "outline" as const,
name: "Outline",
label: "Записаться на приём",
cssClass: ".bb-btn-outline",
bg: "#fff",
border: "#BF9975",
textColor: "#BF9975",
radius: "7px",
shadow: "нет",
where: "Хедер, навигация, ссылки-кнопки",
example: "«Записаться на прием», «Все новости»",
note: "Бежевая рамка — ненавязчивый вторичный CTA. Не конкурирует с основной формой.",
},
{
variant: "teal" as const,
name: "Teal",
label: "Позвонить",
cssClass: ".bb-btn-teal",
bg: "#60959c",
border: "прозрачный",
textColor: "#fff",
radius: "7px",
shadow: "нет",
where: "Контактные действия — звонок",
example: "«Позвонить»",
note: "Серо-бирюзовый — цвет из реального CSS сайта. Близок к Oracal 066M.",
},
{
variant: "pill" as const,
name: "Pill",
label: "Заказать звонок",
cssClass: ".bb-btn-pill",
bg: "#e9e4d4",
border: "#d5cfbd",
textColor: "#333",
radius: "25px",
shadow: "нет",
where: "Модальные триггеры, мягкий CTA",
example: "«Заказать звонок»",
note: "Кремовый фон + pill-форма — мягкий стиль. Используется для открытия модальных окон.",
},
];
const LLM_BUTTONS_TEXT = `КНОПКИ — LLM-спецификация (с реального сайта oclinica.ru)
Версия: v2.0 · /components/buttons
Источник CSS: perm.oclinica.ru/.../style.css
ВАРИАНТЫ (реальный сайт)
Вариант | CSS класс | Фон | Текст | Граница | Radius | Shadow | Применение
primary | .bb-btn-primary | #FFA39C | #fff | #FF847B | 7px | да | Форм-сабмит «Запишите меня!»
outline | .bb-btn-outline | #fff | #BF9975 | #BF9975 | 7px | нет | Хедер «Записаться на прием», ссылки-кнопки
teal | .bb-btn-teal | #60959c | #fff | нет | 7px | нет | Звонок «Позвонить»
pill | .bb-btn-pill | #e9e4d4 | #333 | #d5cfbd | 25px | нет | Callback «Заказать звонок»
CSS С САЙТА (точные значения)
/* форм-кнопка «Запишите меня!» */
button { background:#FFA39C; color:white; font-weight:bold; border:solid 1px #FF847B;
height:42px; font-size:18px; box-shadow:0px 0px 5px rgba(0,0,0,0.5),0px 4px 5px rgba(0,0,0,0.3); }
/* appointment — «Записаться на прием» */
.appointment { background:#FFF; border:#BF9975 solid 1px; color:#BF9975;
font-size:14px; line-height:38px; padding:3px 12px; border-radius:7px; }
/* show-phone — «Позвонить» */
.show-phone { background:rgb(96,149,156); color:#fff; border-radius:7px;
font-size:14px; line-height:38px; padding:3px 12px; }
/* callback — «Заказать звонок» */
a.callback_url { background:#e9e4d4; border:#d5cfbd solid 1px; color:#000;
border-radius:25px; font-size:16px; padding:6px 18px; }
РАЗМЕРЫ (брендбук-компонент)
Размер | CSS класс | padding | font-size | Применение
sm | .bb-btn-sm | 4px 11px | 13px | Компактные контексты
md | .bb-btn-md | 8px 16px | 14px | Стандарт (appointment, teal, pill)
lg | .bb-btn-lg | 10px 24px | 18px + bold | Форм-сабмит (соответствует реальному сайту)
ПРАВИЛА ПРИМЕНЕНИЯ
primary (коралловый) только для главного CTA в форме записи
outline (бежевый) хедер, навигация, ссылки-кнопки на странице
teal (бирюзовый) контактные действия (звонок, направление)
pill (кремовый) открытие модальных окон, мягкий callback
Не более одного primary на форму
Не менять цвета вне фирменной палитры сайта
Primary не для навигационных ссылок
Не накладывать тень на outline/teal/pill`.trim();
export default function ButtonsPage() {
const codeHtml = `<!-- Primary — форм-кнопка «Запишите меня!» -->
<button class="bb-btn bb-btn-lg bb-btn-primary">Запишите меня!</button>
<!-- Outline appointment «Записаться на прием» -->
<a class="bb-btn bb-btn-md bb-btn-outline" href="#form">Записаться на прием</a>
<!-- Teal «Позвонить» -->
<a class="bb-btn bb-btn-md bb-btn-teal" href="tel:+73422250662">Позвонить</a>
<!-- Pill «Заказать звонок» -->
<a class="bb-btn bb-btn-md bb-btn-pill" href="#callback">Заказать звонок</a>`;
const codeReact = `import { Button } from "@/components/ui/Button";
// Форм-кнопка (главный CTA)
<Button variant="primary" size="lg">Запишите меня!</Button>
// Запись из хедера / навигации
<Button variant="outline" size="md">Записаться на прием</Button>
// Звонок
<Button variant="teal" size="md">Позвонить</Button>
// Заказать звонок (открывает модал)
<Button variant="pill" size="md">Заказать звонок</Button>
// С loading-состоянием
<Button variant="primary" size="lg" loading>Отправляем...</Button>`;
const codeSiteExact = `/* Точный CSS с сайта oclinica.ru (style.css) */
/* Форм-кнопка — кнопка отправки форм записи */
#block-entityform-block-feedback button,
#block-entityform-block-lor-form button {
background: #FFA39C;
color: white;
font-weight: bold;
border: solid 1px #FF847B;
width: 300px;
height: 42px;
font-size: 18px;
box-shadow: 0px 0px 5px rgba(0,0,0,0.5), 0px 4px 5px rgba(0,0,0,0.3);
}
/* Кнопка «Записаться на прием» в хедере */
#block-block-15 .appointment {
background: #FFF;
border: #BF9975 solid 1px;
color: #BF9975;
font-size: 14px;
line-height: 38px;
padding: 3px 12px;
border-radius: 7px;
}
/* Кнопка «Позвонить» */
.show-phone {
background: rgb(96, 149, 156); /* #60959c */
color: #fff;
border-radius: 7px;
font-size: 14px;
line-height: 38px;
padding: 3px 12px;
}
/* Кнопка «Заказать звонок» */
a.callback_url {
background: #e9e4d4;
border: #d5cfbd solid 1px;
color: #000;
border-radius: 25px;
font-size: 16px;
padding: 6px 18px;
}`;
return (
<div className="max-w-4xl mx-auto px-8 py-10">
{/* Заголовок */}
<div className="mb-10 pb-6 border-b" style={{ borderColor: "var(--bb-border)" }}>
<p
className="text-xs font-semibold uppercase tracking-widest mb-2"
style={{ color: "var(--brand-053m)" }}
>
Компоненты 3.1
</p>
<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)" }}>
oclinica.ru
</span>
. Цвета, размеры и тени взяты напрямую из CSS темы{" "}
<span className="font-mono text-sm" style={{ color: "var(--bb-text)" }}>
clinic_bootstrap_mobile/css/style.css
</span>
.
</p>
<div
className="mt-4 px-4 py-3 rounded-lg border text-sm flex items-center gap-2"
style={{ borderColor: "#e0f5f4", background: "#f8fffe", color: "var(--bb-text-muted)" }}
>
<span style={{ color: "var(--brand-053m)", fontWeight: 600 }}>Источник</span>
<span>
CSS сайта проанализирован 2026-03-22 4 типа кнопок с реальными значениями.
</span>
</div>
</div>
{/* 1. Варианты */}
<Section
id="variants"
title="Варианты"
subtitle="Четыре типа кнопок с реального сайта клиники."
>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-6">
{VARIANTS.map(({ variant, name, label, where, example, note, bg, border, textColor, radius, shadow }) => (
<div
key={variant}
className="rounded-xl border p-5 flex flex-col gap-4"
style={{ borderColor: "var(--bb-border)" }}
>
{/* Превью */}
<div
className="flex items-center justify-center py-6 rounded-lg"
style={{ background: "var(--bb-sidebar-bg)" }}
>
<Button variant={variant} size="md">
{label}
</Button>
</div>
{/* Инфо */}
<div>
<p className="font-semibold text-sm mb-1" style={{ color: "var(--bb-text)" }}>
{name}
</p>
<p className="text-xs mb-2 leading-relaxed" style={{ color: "var(--bb-text-muted)" }}>
{note}
</p>
<div className="flex flex-wrap gap-1.5 mb-2">
{[
{ k: "bg", v: bg },
{ k: "text", v: textColor },
{ k: "border", v: border },
{ k: "radius", v: radius },
...(shadow === "да" ? [{ k: "shadow", v: "да" }] : []),
].map(({ k, v }) => (
<span
key={k}
className="text-[10px] font-mono px-1.5 py-0.5 rounded"
style={{ background: "#f3f4f6", color: "var(--bb-text-muted)" }}
>
{k}: {v}
</span>
))}
</div>
<div
className="rounded p-2.5 text-xs"
style={{ background: "#f8f9fa", color: "var(--bb-text-muted)" }}
>
<span className="font-medium" style={{ color: "var(--bb-text)" }}>
Где:
</span>{" "}
{where}
<br />
<span className="font-medium" style={{ color: "var(--bb-text)" }}>
Пример:
</span>{" "}
{example}
</div>
</div>
</div>
))}
</div>
</Section>
{/* 2. Размеры */}
<Section
id="sizes"
title="Размеры"
subtitle="Три размера для разных контекстов. lg соответствует форм-кнопке на реальном сайте (18px, bold)."
>
<div
className="rounded-xl border overflow-hidden"
style={{ borderColor: "var(--bb-border)" }}
>
{(
[
{
size: "sm" as const,
label: "Small",
hint: "4px 11px · 13px",
use: "Компактные контексты, таблицы",
},
{
size: "md" as const,
label: "Medium",
hint: "8px 16px · 14px",
use: "Appointment, Teal, Pill (соответствует сайту)",
},
{
size: "lg" as const,
label: "Large",
hint: "10px 24px · 18px bold",
use: "Primary форм-кнопка (соответствует сайту)",
},
] as const
).map(({ size, label, hint, use }, i) => (
<div
key={size}
className="flex items-center gap-6 px-5 py-4"
style={{ borderTop: i > 0 ? "1px solid var(--bb-border)" : undefined }}
>
<div className="w-40 shrink-0">
<Button variant="primary" size={size}>
Записаться
</Button>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium mb-0.5" style={{ color: "var(--bb-text)" }}>
{label}
</p>
<p className="text-xs font-mono" style={{ color: "var(--bb-text-muted)" }}>
padding: {hint.split("·")[0].trim()} · font-size: {hint.split("·")[1].trim()}
</p>
</div>
<p
className="text-xs hidden lg:block"
style={{ color: "var(--bb-text-muted)", maxWidth: 220 }}
>
{use}
</p>
</div>
))}
</div>
</Section>
{/* 3. Состояния */}
<Section
id="states"
title="Состояния"
subtitle="Базовые состояния кнопки. На реальном сайте hover/transition не определены в CSS."
>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{(
[
{
label: "Default",
node: <Button variant="primary" size="lg">Записаться</Button>,
hint: "Стандартное состояние",
},
{
label: "Hover",
node: (
<Button
variant="primary"
size="lg"
style={{ filter: "brightness(0.93)" }}
>
Записаться
</Button>
),
hint: "filter: brightness(0.93)",
},
{
label: "Loading",
node: <Button variant="primary" size="lg" loading>Отправка...</Button>,
hint: "Спиннер + blocked",
},
{
label: "Disabled",
node: <Button variant="primary" size="lg" disabled>Записаться</Button>,
hint: "opacity: 0.5",
},
] as const
).map(({ label, node, hint }) => (
<div
key={label}
className="rounded-xl border p-4"
style={{ borderColor: "var(--bb-border)" }}
>
<div
className="flex items-center justify-center py-4 mb-3 rounded-lg"
style={{ background: "var(--bb-sidebar-bg)" }}
>
{node}
</div>
<p className="text-sm font-medium mb-0.5" style={{ color: "var(--bb-text)" }}>
{label}
</p>
<p className="text-xs" style={{ color: "var(--bb-text-muted)" }}>
{hint}
</p>
</div>
))}
</div>
</Section>
{/* 4. Контекст применения */}
<Section
id="context"
title="Где применяется"
subtitle="Таблица: тип кнопки → реальное использование на сайте."
>
<div
className="rounded-xl border overflow-hidden"
style={{ borderColor: "var(--bb-border)" }}
>
<table className="w-full text-sm">
<thead>
<tr style={{ background: "var(--bb-sidebar-bg)" }}>
{["Вариант", "Цвет фона", "Реальный класс/контекст", "Текст кнопки на сайте"].map((h) => (
<th
key={h}
className="text-left px-4 py-3 font-medium text-xs"
style={{ color: "var(--bb-text-muted)" }}
>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{[
{
v: <Button variant="primary" size="sm">Primary</Button>,
bg: "#FFA39C",
ctx: "button в entityform-блоках форм записи",
text: "«Запишите меня!»",
},
{
v: <Button variant="outline" size="sm">Outline</Button>,
bg: "#fff / рамка #BF9975",
ctx: ".appointment в хедере (block-block-15, 30, 32, 24)",
text: "«Записаться на прием»",
},
{
v: <Button variant="teal" size="sm">Teal</Button>,
bg: "#60959c",
ctx: ".show-phone (block-block-4, 15)",
text: "«Позвонить»",
},
{
v: <Button variant="pill" size="sm">Pill</Button>,
bg: "#e9e4d4",
ctx: "a.callback_url (modal trigger)",
text: "«Заказать звонок»",
},
].map(({ v, bg, ctx, text }, i) => (
<tr
key={i}
style={{ borderTop: "1px solid var(--bb-border)" }}
>
<td className="px-4 py-3">{v}</td>
<td
className="px-4 py-3 font-mono text-xs"
style={{ color: "var(--bb-text-muted)" }}
>
{bg}
</td>
<td
className="px-4 py-3 font-mono text-xs"
style={{ color: "var(--bb-text-muted)" }}
>
{ctx}
</td>
<td className="px-4 py-3 text-xs" style={{ color: "var(--bb-text)" }}>
{text}
</td>
</tr>
))}
</tbody>
</table>
</div>
</Section>
{/* 5. Примеры кода */}
<Section
id="code"
title="Примеры кода"
subtitle="HTML-классы из globals.css, JSX-компонент, и точный CSS с сайта."
>
<div className="space-y-4">
<CodeCopy lang="HTML (CSS-классы brandbook)" code={codeHtml} />
<CodeCopy lang="JSX (React / Next.js)" code={codeReact} />
<CodeCopy lang="CSS — точно с сайта oclinica.ru" code={codeSiteExact} />
</div>
</Section>
{/* LLM-блок */}
<LlmBlock path="/components/buttons" version="v2.0" specText={LLM_BUTTONS_TEXT}>
<LlmSection title="Варианты (реальный сайт oclinica.ru)" />
<LlmTable
headers={["Вариант", "CSS класс", "Фон", "Текст", "Border", "Radius", "Применение"]}
rows={VARIANTS.map((v) => [
v.variant,
v.cssClass,
v.bg,
v.textColor,
v.border,
v.radius,
v.where,
])}
/>
<LlmSection title="Размеры (брендбук-компонент)" />
<LlmTable
headers={["Размер", "padding", "font-size", "Применение"]}
rows={[
["sm", "4px 11px", "13px", "Компактные контексты"],
["md", "8px 16px", "14px", "Стандарт (outline, teal, pill с сайта)"],
["lg", "10px 24px", "18px bold", "Primary форм-кнопка (соответствует сайту)"],
]}
/>
<LlmSection title="Правила применения" />
<LlmRules
rules={[
{ ok: true, text: "primary (коралловый) — только для submit в формах записи" },
{ ok: true, text: "outline (бежевый) — хедер, навигация, второстепенные ссылки" },
{ ok: true, text: "teal (бирюзовый) — телефонные и контактные действия" },
{ ok: true, text: "pill (кремовый) — открытие модальных окон / callback" },
{ ok: true, text: "Не более одного primary на форму" },
{ ok: false, text: "Не менять цвета вне указанной палитры сайта" },
{ ok: false, text: "Primary — не для навигационных ссылок" },
{ ok: false, text: "Не накладывать тень на outline, teal, pill" },
]}
/>
</LlmBlock>
</div>
);
}

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

@ -1,631 +0,0 @@
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: "Карточки. Цифровой брендбук Клиники ухо, горло, нос им. проф. Е.Н.Оленевой",
};
function Section({
id, title, subtitle, children,
}: {
id?: string; title: string; subtitle?: string; children: React.ReactNode;
}) {
return (
<section id={id} className="mb-14">
<div className="mb-6">
<h2 className="text-xl font-semibold" style={{ color: "var(--bb-text)" }}>{title}</h2>
{subtitle && <p className="mt-1 text-sm" style={{ color: "var(--bb-text-muted)" }}>{subtitle}</p>}
</div>
{children}
</section>
);
}
/* ─── Карточка врача ────────────────────────────────────────────────── */
function DoctorCard({
name, specialty, experience, photo,
}: {
name: string; specialty: string; experience: string; photo?: string;
}) {
return (
<div
className="flex gap-4 p-4 rounded-xl border bg-white transition-shadow"
style={{ borderColor: "var(--bb-border)" }}
>
{/* Фото */}
<div
className="shrink-0 rounded-lg overflow-hidden"
style={{ width: 110, height: 160, background: "#dff0fa" }}
>
{photo ? (
<img src={photo} alt={name} style={{ width: "100%", height: "100%", objectFit: "cover" }} />
) : (
<div
className="w-full h-full flex flex-col items-center justify-center gap-1"
style={{ color: "var(--brand-053m)", opacity: 0.5 }}
>
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
<span className="text-xs">фото</span>
</div>
)}
</div>
{/* Информация */}
<div className="flex flex-col justify-between min-w-0">
<div>
<h3 className="font-semibold text-sm leading-tight mb-1" style={{ color: "var(--bb-text)" }}>
{name}
</h3>
<p className="text-xs mb-2" style={{ color: "var(--bb-text-muted)" }}>{specialty}</p>
<p className="text-xs" style={{ color: "var(--brand-053m)" }}>{experience}</p>
</div>
<button className="bb-btn bb-btn-sm bb-btn-outline mt-3 self-start">
Записаться
</button>
</div>
</div>
);
}
/* ─── Карточка новости ──────────────────────────────────────────────── */
function NewsCard({
date, title, snippet, category,
}: {
date: string; title: string; snippet: string; category?: string;
}) {
return (
<div
className="bb-news-card rounded-xl border overflow-hidden cursor-pointer transition-all"
style={{ borderColor: "var(--bb-border)", background: "#fff" }}
>
{/* Превью */}
<div
className="h-36 flex items-center justify-center"
style={{ background: "#f0f9ff" }}
>
{category && (
<span
className="px-3 py-1 rounded-full text-xs font-semibold"
style={{ background: "var(--brand-053m)", color: "#fff" }}
>
{category}
</span>
)}
</div>
{/* Контент */}
<div className="p-4">
<p className="text-xs mb-2" style={{ color: "var(--bb-text-muted)" }}>{date}</p>
<h3 className="font-semibold text-sm leading-tight mb-2" style={{ color: "var(--bb-text)" }}>
{title}
</h3>
<p className="text-xs leading-relaxed mb-3" style={{ color: "var(--bb-text-muted)" }}>
{snippet}
</p>
<span className="text-xs font-medium" style={{ color: "var(--brand-053m)" }}>
Читать далее
</span>
</div>
</div>
);
}
/* ─── Карточка отзыва ───────────────────────────────────────────────── */
function ReviewCard({
author, date, text, rating, doctor,
}: {
author: string; date: string; text: string; rating: number; doctor?: string;
}) {
return (
<div
className="rounded-xl border p-5"
style={{ borderColor: "var(--bb-border)", background: "#eef4d1" }}
>
{/* Звёзды */}
<div className="flex gap-0.5 mb-3">
{Array.from({ length: 5 }, (_, i) => (
<svg key={i} width="16" height="16" viewBox="0 0 24 24" fill={i < rating ? "#f59e0b" : "none"} stroke="#f59e0b" strokeWidth="1.5">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
</svg>
))}
<span className="text-xs ml-1 font-medium" style={{ color: "#92400e" }}>{rating}/5</span>
</div>
{/* Текст */}
<p
className="text-sm leading-relaxed mb-4"
style={{
color: "var(--bb-text)",
display: "-webkit-box",
WebkitLineClamp: 4,
WebkitBoxOrient: "vertical",
overflow: "hidden",
}}
>
{text}
</p>
{/* Автор */}
<div className="flex items-end justify-between">
<div>
<p className="text-sm font-semibold" style={{ color: "var(--bb-text)" }}>{author}</p>
{doctor && <p className="text-xs mt-0.5" style={{ color: "var(--bb-text-muted)" }}>Врач: {doctor}</p>}
</div>
<p className="text-xs" style={{ color: "var(--bb-text-muted)" }}>{date}</p>
</div>
</div>
);
}
/* ─── Карточка цены ─────────────────────────────────────────────────── */
function PriceCard({
service, price, description, highlighted,
}: {
service: string; price: string; description?: string; highlighted?: boolean;
}) {
return (
<div
className="rounded-xl border p-5 flex flex-col gap-3"
style={{
borderColor: highlighted ? "var(--brand-053m)" : "var(--bb-border)",
background: highlighted ? "#f0f9ff" : "#fff",
}}
>
<div className="flex items-start justify-between gap-3">
<p className="text-sm font-medium flex-1" style={{ color: "var(--bb-text)" }}>{service}</p>
<p className="text-lg font-bold shrink-0" style={{ color: highlighted ? "var(--brand-053m)" : "var(--bb-text)" }}>
{price}
</p>
</div>
{description && (
<p className="text-xs" style={{ color: "var(--bb-text-muted)" }}>{description}</p>
)}
<button className="bb-btn bb-btn-sm bb-btn-outline self-start">
Записаться
</button>
</div>
);
}
/* ─── Карточка услуги ───────────────────────────────────────────────── */
function ServiceCard({
title, description, icon,
}: {
title: string; description: string; icon: string;
}) {
return (
<div
className="bb-service-card rounded-xl border p-5 flex flex-col gap-3 cursor-pointer transition-shadow"
style={{ borderColor: "var(--bb-border)", background: "#fff" }}
>
<div
className="w-12 h-12 rounded-xl flex items-center justify-center text-2xl"
style={{ background: "#dff0fa" }}
>
{icon}
</div>
<div>
<h3 className="font-semibold text-sm mb-1" style={{ color: "var(--bb-text)" }}>{title}</h3>
<p className="text-xs leading-relaxed" style={{ color: "var(--bb-text-muted)" }}>{description}</p>
</div>
<span className="text-xs font-medium" style={{ color: "var(--brand-053m)" }}>
Подробнее
</span>
</div>
);
}
/* ─── LLM текст ─────────────────────────────────────────────────────── */
const LLM_CARDS_TEXT = `КАРТОЧКИ — LLM-спецификация
Версия: v1.0 · /components/cards
КАРТОЧКА ВРАЧА (DoctorCard)
Структура: фото (110×160px) + имя + специализация + опыт + кнопка «Записаться»
Фото: 110px × 160px, object-fit: cover, border-radius: 8px, фон-placeholder: #dff0fa
Кнопка: .bb-btn-outline .bb-btn-sm
Источник: .doctor на perm.oclinica.ru
КАРТОЧКА НОВОСТИ (NewsCard)
Структура: превью (h=144px) + дата + заголовок + анонс (4 строки) + «Читать далее »
Hover: background #eef4d1 + box-shadow 0 0 16px #9e9e9a
Источник: #block-views-last-news-block-1 .views-column на сайте (200×200px)
Без hover: background #fff, border 1px
КАРТОЧКА ОТЗЫВА (ReviewCard)
Структура: рейтинг (звёзды 15) + текст (4 строки, overflow hidden) + автор + дата + врач
Фон: #eef4d1 (светло-жёлтый) из реального CSS сайта
Звёзды: SVG polygon, filled #f59e0b
КАРТОЧКА ЦЕНЫ (PriceCard)
Структура: услуга + цена (bold) + описание + кнопка
highlighted-вариант: border var(--brand-053m), bg #f0f9ff, цена тоже --brand-053m
КАРТОЧКА УСЛУГИ (ServiceCard)
Структура: иконка (emoji, 48×48px, bg #dff0fa) + заголовок + описание + «Подробнее »
Hover: box-shadow (0.5rem smth)
БЕЙДЖИ (Badge)
Варианты: primary (#0089c3), success (#059669), warning (#d97706), danger (#dc2626), neutral (#6b7280)
Размер: text-xs, px-2.5 py-0.5, border-radius: full
CSS: inline-flex, font-weight: 600
ТЕГИ (Tag)
Варианты: default (border, text), filled (bg), removable (с кнопкой ×)
Цвет: --brand-053m или нейтральный (#e5e7eb bg)
Размер: text-xs, px-2 py-1, border-radius: 4px
АЛЕРТЫ (Alert)
Варианты: info (#dff0fa фон, #075985 текст), success (#d1fae5, #065f46), warning (#fef3c7, #92400e), error (#fee2e2, #991b1b)
Структура: иконка (16×16px) + заголовок + описание
Без кнопки закрыть в базовом варианте
ПРАВИЛА
DoctorCard: всегда показывать фото-placeholder если нет фото (не ломать layout)
ReviewCard: обрезать текст после 4 строк (WebkitLineClamp)
PriceCard: highlighted = акционная или рекомендуемая позиция
NewsCard: hover-эффект обязателен (#eef4d1 + box-shadow)
Badge: не более 23 бейджей рядом
Alert: одновременно не более 1 alert одного типа на экране
Не смешивать типы карточек в одной сетке без заголовка секции
Не использовать ReviewCard без рейтинга`.trim();
/* ─── Данные примеров ───────────────────────────────────────────────── */
const DOCTORS = [
{
name: "Иванова Анна Сергеевна",
specialty: "Оториноларинголог (ЛОР), высшая категория",
experience: "Стаж 18 лет",
},
{
name: "Петров Дмитрий Александрович",
specialty: "Сурдолог, аудиолог",
experience: "Стаж 12 лет",
},
{
name: "Соколова Мария Ивановна",
specialty: "Детский ЛОР, ринолог",
experience: "Стаж 9 лет",
},
];
const REVIEWS = [
{
author: "Елена К.",
date: "15 марта 2026",
rating: 5,
doctor: "Иванова А.С.",
text: "Очень довольна визитом. Доктор внимательно выслушала все жалобы, провела осмотр и объяснила причину заболевания. Назначила лечение, которое помогло уже через 3 дня. Рекомендую!",
},
{
author: "Михаил Р.",
date: "10 марта 2026",
rating: 4,
doctor: "Петров Д.А.",
text: "Хороший специалист, всё объяснил понятно. Подождал немного дольше, чем ожидал, но качество приёма на высоте. Буду обращаться снова.",
},
];
const PRICES = [
{ service: "Первичный приём ЛОР-врача", price: "1 500 ₽", description: "Включает осмотр и консультацию" },
{ service: "Повторный приём", price: "1 000 ₽", description: "До 14 дней после первичного" },
{ service: "Промывание миндалин", price: "800 ₽", highlighted: true, description: "Аппаратное — аккустический вакуум" },
{ service: "Аудиометрия", price: "1 200 ₽", description: "Исследование слуха" },
];
const SERVICES = [
{ title: "Лечение ангины и тонзиллита", description: "Консервативное и хирургическое лечение заболеваний миндалин", icon: "🦷" },
{ title: "Аудиология и сурдология", description: "Диагностика нарушений слуха, подбор слуховых аппаратов", icon: "👂" },
{ title: "Детский ЛОР", description: "Специализация на лечении ЛОР-заболеваний у детей от 0 лет", icon: "👶" },
{ title: "Ринология", description: "Лечение заболеваний носа и придаточных пазух", icon: "👃" },
];
/* ─── Коды примеров ─────────────────────────────────────────────────── */
const codeDoctorCard = `<!-- Карточка врача -->
<div class="doctor-card">
<img src="doctor.jpg" width="110" height="160" alt="ФИО" />
<div class="doctor-info">
<h3>Иванова Анна Сергеевна</h3>
<p class="specialty">Оториноларинголог, высшая категория</p>
<p class="experience">Стаж 18 лет</p>
<button class="bb-btn bb-btn-sm bb-btn-outline">Записаться</button>
</div>
</div>
/* CSS с сайта oclinica.ru */
.doctor .image { float:left; margin-right:20px; width:110px; height:160px; }
.doctor .item { float:left; width:170px; }
.doctor h3 { margin-top:0; height:32px; }`;
const codeNewsCard = `<!-- Карточка новости (сайт: 200×200px) -->
<div class="news-card">
<div class="news-preview">...</div>
<div class="news-body">
<time>15 марта 2026</time>
<h3>Заголовок новости</h3>
<p>Краткий анонс...</p>
<a href="#">Читать далее </a>
</div>
</div>
/* CSS с сайта */
#block-views-last-news-block-1 .views-column {
background: #fff; width: 200px; height: 200px;
margin: 15px 8px; padding: 15px;
}
#block-views-last-news-block-1 .views-column:hover {
background: #eef4d1;
box-shadow: 0px 0px 16px 0px #9e9e9a;
}`;
const codeReviewCard = `<!-- Карточка отзыва -->
<div class="review-card">
<div class="stars"></div>
<p class="text">Текст отзыва (4 строки, overflow: hidden)...</p>
<div class="author">
<span>Елена К.</span>
<time>15 марта 2026</time>
</div>
</div>
/* Стиль брендбука (фон из CSS сайта) */
.review-card { background: #eef4d1; border-radius: 12px; padding: 20px; }`;
const codePriceCard = `<!-- Карточка цены -->
<div class="price-card">
<div class="price-row">
<span class="service">Первичный приём ЛОР-врача</span>
<strong class="price">1 500 </strong>
</div>
<p class="description">Включает осмотр и консультацию</p>
<button class="bb-btn bb-btn-sm bb-btn-outline">Записаться</button>
</div>`;
const codeBadges = `<!-- Бейджи -->
<span class="bb-badge bb-badge-primary">ЛОР</span>
<span class="bb-badge bb-badge-success">Принимает</span>
<span class="bb-badge bb-badge-warning">Ожидает</span>
<span class="bb-badge bb-badge-danger">Не принимает</span>
<span class="bb-badge bb-badge-neutral">Высшая категория</span>
<!-- Теги -->
<button class="bb-tag">Ухо</button>
<button class="bb-tag">Горло</button>
<button class="bb-tag bb-tag-active">Нос</button>
<!-- Алерт -->
<div class="bb-alert bb-alert-info">
<span class="bb-alert-icon"></span>
<div>
<strong>Запись открыта</strong>
<p>Вы можете записаться онлайн или по телефону.</p>
</div>
</div>`;
export default function CardsPage() {
return (
<div className="max-w-4xl mx-auto px-8 py-10">
{/* Заголовок */}
<div className="mb-10 pb-6 border-b" style={{ borderColor: "var(--bb-border)" }}>
<p className="text-xs font-semibold uppercase tracking-widest mb-2" style={{ color: "var(--brand-053m)" }}>
Компоненты 3.3
</p>
<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)" }}>
Карточки врача, новости, отзыва, цены и услуги основные блоки контента сайта.
Бейджи, теги и алерты вспомогательные элементы.
</p>
</div>
{/* 1. Карточки врачей */}
<Section
id="doctor"
title="Карточка врача"
subtitle="Фото 110×160px, имя, специализация, стаж, кнопка записи. Соответствует .doctor на сайте."
>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
{DOCTORS.map(d => <DoctorCard key={d.name} {...d} />)}
</div>
</Section>
{/* 2. Карточки новостей */}
<Section
id="news"
title="Карточка новости"
subtitle="Hover: bg #eef4d1 + box-shadow. Источник: #block-views-last-news-block-1 на сайте."
>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<NewsCard
date="15 марта 2026"
category="ЛОР"
title="Как правильно промывать нос при насморке"
snippet="Промывание носа — эффективный метод лечения и профилактики острого ринита. Рассказываем о правильной технике."
/>
<NewsCard
date="10 марта 2026"
category="Аудиология"
title="Новый слуховой аппарат в нашей клинике"
snippet="Мы начали подбор и настройку слуховых аппаратов нового поколения — незаметных и точных."
/>
<NewsCard
date="5 марта 2026"
title="Весенняя профилактика ЛОР-заболеваний"
snippet="Апрель — период обострений. Рекомендации нашего специалиста по укреплению иммунитета и защите."
/>
</div>
<p className="mt-3 text-xs" style={{ color: "var(--bb-text-muted)" }}>
* Наведите на карточку чтобы увидеть hover-эффект
</p>
</Section>
{/* 3. Карточки отзывов */}
<Section
id="review"
title="Карточка отзыва"
subtitle="Рейтинг (1–5 звёзд), текст 4 строки, автор, дата, врач. Фон #eef4d1 — с реального сайта."
>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{REVIEWS.map(r => <ReviewCard key={r.author} {...r} />)}
</div>
</Section>
{/* 4. Карточки цен */}
<Section
id="price"
title="Карточка цены"
subtitle="Услуга + стоимость + описание + кнопка. Highlighted-вариант для акционных позиций."
>
<div className="flex flex-col gap-3">
{PRICES.map(p => <PriceCard key={p.service} {...p} />)}
</div>
</Section>
{/* 5. Карточки услуг */}
<Section
id="service"
title="Карточка услуги"
subtitle="Иконка + заголовок + описание + ссылка. Применяется в блоке «Наши услуги»."
>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
{SERVICES.map(s => <ServiceCard key={s.title} {...s} />)}
</div>
</Section>
{/* 6. Бейджи и теги */}
<Section
id="badges"
title="Бейджи и теги"
subtitle="Статусные бейджи, теги-категории, алерты."
>
{/* Бейджи */}
<div className="mb-8">
<p className="text-sm font-medium mb-3" style={{ color: "var(--bb-text)" }}>Бейджи (статус)</p>
<div className="flex flex-wrap gap-2">
{[
{ label: "Основной", bg: "var(--brand-053m)", color: "#fff" },
{ label: "Принимает", bg: "#059669", color: "#fff" },
{ label: "Высшая категория", bg: "#d97706", color: "#fff" },
{ label: "Не принимает", bg: "#dc2626", color: "#fff" },
{ label: "Нейтральный", bg: "#6b7280", color: "#fff" },
{ label: "Новинка", bg: "#dff0fa", color: "var(--brand-053m)" },
].map(b => (
<span
key={b.label}
className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold"
style={{ background: b.bg, color: b.color }}
>
{b.label}
</span>
))}
</div>
</div>
{/* Теги */}
<div className="mb-8">
<p className="text-sm font-medium mb-3" style={{ color: "var(--bb-text)" }}>Теги (категории)</p>
<div className="flex flex-wrap gap-2">
{["Ухо", "Горло", "Нос", "Аудиология", "Детский ЛОР", "Хирургия"].map((tag, i) => (
<button
key={tag}
className="inline-flex items-center px-3 py-1 rounded text-xs font-medium border transition-colors"
style={
i === 0
? { background: "var(--brand-053m)", color: "#fff", borderColor: "var(--brand-053m)" }
: { background: "#fff", color: "var(--bb-text)", borderColor: "var(--bb-border)" }
}
>
{tag}
</button>
))}
</div>
</div>
{/* Алерты */}
<div>
<p className="text-sm font-medium mb-3" style={{ color: "var(--bb-text)" }}>Алерты</p>
<div className="flex flex-col gap-3">
{[
{ type: "info", bg: "#dff0fa", color: "#075985", icon: "ℹ", title: "Информация", text: "Запись открыта. Вы можете записаться онлайн или по телефону." },
{ type: "success", bg: "#d1fae5", color: "#065f46", icon: "✓", title: "Успешно", text: "Ваша запись подтверждена. Ждём вас 20 марта в 10:00." },
{ type: "warning", bg: "#fef3c7", color: "#92400e", icon: "⚠", title: "Внимание", text: "Не забудьте взять паспорт и полис ОМС на приём." },
{ type: "error", bg: "#fee2e2", color: "#991b1b", icon: "✕", title: "Ошибка", text: "К сожалению, это время уже занято. Выберите другое." },
].map(a => (
<div
key={a.type}
className="flex gap-3 p-4 rounded-xl border"
style={{ background: a.bg, borderColor: a.bg, color: a.color }}
>
<span className="text-base font-bold shrink-0 mt-0.5">{a.icon}</span>
<div>
<p className="text-sm font-semibold mb-0.5">{a.title}</p>
<p className="text-sm opacity-90">{a.text}</p>
</div>
</div>
))}
</div>
</div>
</Section>
{/* 7. Код */}
<Section
id="code"
title="Примеры кода"
subtitle="HTML-структура и CSS-справка."
>
<div className="space-y-4">
<CodeCopy lang="HTML — DoctorCard + CSS с сайта" code={codeDoctorCard} />
<CodeCopy lang="HTML — NewsCard + CSS с сайта" code={codeNewsCard} />
<CodeCopy lang="HTML — ReviewCard" code={codeReviewCard} />
<CodeCopy lang="HTML — PriceCard" code={codePriceCard} />
<CodeCopy lang="HTML — Badges, Tags, Alerts" code={codeBadges} />
</div>
</Section>
{/* LLM-блок */}
<LlmBlock path="/components/cards" version="v1.0" specText={LLM_CARDS_TEXT}>
<LlmSection title="Типы карточек" />
<LlmTable
headers={["Карточка", "Ключевые размеры", "Источник на сайте", "Фон / hover"]}
rows={[
["DoctorCard", "фото 110×160px, layout: flex", ".doctor .image + .doctor .item", "#fff / —"],
["NewsCard", "preview h=144px, grid 3 col", "#block-views-last-news-block-1 .views-column", "#fff / #eef4d1 + shadow"],
["ReviewCard", "4 строки текста, рейтинг", ".node-reviews", "#eef4d1 / —"],
["PriceCard", "flex row: name + price", ".field-name-field-price-priem", "#fff / highlighted: #f0f9ff"],
["ServiceCard", "иконка 48×48, grid 4 col", "—", "#fff / shadow"],
]}
/>
<LlmSection title="Бейджи, теги, алерты" />
<LlmTable
headers={["Элемент", "Варианты", "Размер", "Применение"]}
rows={[
["Badge", "primary / success / warning / danger / neutral", "text-xs, px-2.5, rounded-full", "Статус врача, категория, акция"],
["Tag", "default / active", "text-xs, px-3, rounded-4px", "Фильтры, категории услуг"],
["Alert", "info / success / warning / error", "p-4, border-radius 12px", "Системные сообщения пользователю"],
]}
/>
<LlmSection title="Правила применения" />
<LlmRules
rules={[
{ ok: true, text: "DoctorCard: всегда фото-placeholder если нет фото" },
{ ok: true, text: "NewsCard: hover #eef4d1 + box-shadow (из реального CSS сайта)" },
{ ok: true, text: "ReviewCard: обрезать текст после 4 строк (WebkitLineClamp: 4)" },
{ ok: true, text: "PriceCard highlighted = акционная / рекомендуемая позиция" },
{ ok: true, text: "Alert: один тип одновременно на экране" },
{ ok: false, text: "Не смешивать типы карточек в одной сетке без заголовка" },
{ ok: false, text: "Не использовать ReviewCard без рейтинга" },
{ ok: false, text: "Не ставить более 3 бейджей рядом" },
]}
/>
</LlmBlock>
</div>
);
}

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

@ -1,725 +0,0 @@
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: "Форм-контролы. Цифровой брендбук Клиники ухо, горло, нос им. проф. Е.Н.Оленевой",
};
function Section({
id,
title,
subtitle,
children,
}: {
id?: string;
title: string;
subtitle?: string;
children: React.ReactNode;
}) {
return (
<section id={id} className="mb-12">
<div className="mb-6">
<h2 className="text-xl font-semibold" style={{ color: "var(--bb-text)" }}>
{title}
</h2>
{subtitle && (
<p className="mt-1 text-sm" style={{ color: "var(--bb-text-muted)" }}>
{subtitle}
</p>
)}
</div>
{children}
</section>
);
}
function FieldLabel({ text, required }: { text: string; required?: boolean }) {
return (
<label
className="block text-sm font-medium mb-1.5"
style={{ color: "var(--bb-text)" }}
>
{text}
{required && <span style={{ color: "#dc2626", marginLeft: 2 }}>*</span>}
</label>
);
}
function FieldHint({ text }: { text: string }) {
return (
<p className="mt-1.5 text-xs" style={{ color: "var(--bb-text-muted)" }}>
{text}
</p>
);
}
function FieldError({ text }: { text: string }) {
return (
<p className="mt-1.5 text-xs" style={{ color: "#dc2626" }}>
{text}
</p>
);
}
function StateCard({
label,
hint,
children,
}: {
label: string;
hint: string;
children: React.ReactNode;
}) {
return (
<div className="rounded-xl border p-4" style={{ borderColor: "var(--bb-border)" }}>
<div className="mb-3">{children}</div>
<p className="text-sm font-medium mb-0.5" style={{ color: "var(--bb-text)" }}>
{label}
</p>
<p className="text-xs" style={{ color: "var(--bb-text-muted)" }}>
{hint}
</p>
</div>
);
}
const LLM_FORMS_TEXT = `ФОРМ-КОНТРОЛЫ — LLM-спецификация
Версия: v2.0 · /components/forms
ТЕКСТОВОЕ ПОЛЕ (Input)
CSS класс: .bb-input
Высота: 50px · padding: 10px 12px
border: 1px solid #ccc · border-radius: 4px · font: Fira Sans 14px
Источник: entityform input[type=text] на perm.oclinica.ru
Состояния:
default: border #ccc
focus: border #7ecfca + box-shadow 0 0 0 3px rgba(126,207,202,0.2)
error: border #dc2626 + класс .bb-error
disabled: opacity 0.5 + cursor not-allowed + bg #f8f9fa
МНОГОСТРОЧНЫЙ ТЕКСТ (Textarea)
CSS класс: .bb-textarea
Те же состояния что у Input
min-height: 100px · resize: vertical · padding: 10px 12px
ВЫПАДАЮЩИЙ СПИСОК (Select)
CSS класс: .bb-select
Высота: 50px · padding: 10px 36px 10px 10px
Стрелка: SVG background-image (data URI)
Источник: .form-control.form-select entityform на сайте
Те же состояния что у Input
ФЛАЖОК (Checkbox)
CSS класс: .bb-checkbox
size: 16×16px · accent-color: #5b7b87
HTML: <input type="checkbox" class="bb-checkbox" />
Состояния: unchecked / checked / disabled / checked+disabled
ПЕРЕКЛЮЧАТЕЛЬ ВАРИАНТА (Radio)
CSS класс: .bb-radio
size: 16×16px · accent-color: #5b7b87
HTML: <input type="radio" class="bb-radio" name="group" />
Всегда в группе один выбранный из нескольких
ТУМБЛЕР (Toggle/Switch)
Компонент: @/components/ui/Toggle (React, "use client")
Ширина трека: 44px · Высота: 24px · Бегунок: 20×20px
Выкл: track #d1d5db · Вкл: track #5b7b87
CSS: .bb-toggle-track / .bb-toggle-thumb
HTML-аналог: <input type="checkbox" role="switch" />
КОНТЕКСТ ПРИМЕНЕНИЯ НА САЙТЕ
Input/Select используются в entityform-блоках:
#block-entityform-block-lor-form форма «Запишите меня!» (ЛОР)
#block-entityform-block-lor-form-2 форма «Узнайте стоимость операции»
#block-entityform-block-surgery-form форма хирургии
Фон формы: #b8e6ed (светло-бирюзовый)
Ширина полей: 302px (фиксированная), кнопка submit: 300px
ОБЩИЕ ПРАВИЛА
Метка (label) всегда над полем, font-weight: 500
Обязательные поля помечены * красным цветом (#dc2626)
Подсказка (hint) серым текстом под полем font-size: 12px
Сообщение об ошибке красным (#dc2626) под полем вместо hint
Focus outline бирюзовый #7ecfca (--brand-053m)
Группы checkbox/radio вертикальный список с gap: 10px
Toggle для булевых настроек включить/выключить
Не использовать placeholder вместо label
Не скрывать обязательность поля
Не делать поля шире контейнера`.trim();
export default function FormsPage() {
const codeInput = `<!-- HTML -->
<label class="bb-label">Имя пациента <span style="color:#dc2626">*</span></label>
<input class="bb-input" type="text" placeholder="Иван Иванов" />
<p class="bb-hint">Укажите имя как в паспорте</p>
<!-- Error-состояние -->
<input class="bb-input bb-error" type="text" value="ива" />
<p style="color:#dc2626;font-size:12px">Минимум 3 символа</p>
<!-- Password -->
<input class="bb-input" type="password" placeholder="Введите пароль" />`;
const codeTextarea = `<label class="bb-label">Комментарий к записи</label>
<textarea class="bb-textarea" rows="4" placeholder="Опишите симптомы..."></textarea>`;
const codeSelect = `<label class="bb-label">Специализация</label>
<select class="bb-select">
<option value="">Выберите специализацию</option>
<option value="lor">ЛОР ухо, горло, нос</option>
<option value="aud">Аудиология</option>
</select>`;
const codeCheckbox = `<!-- Одиночный -->
<label style="display:flex;align-items:center;gap:8px">
<input class="bb-checkbox" type="checkbox" />
<span>Согласен с условиями</span>
</label>
<!-- Группа -->
<div style="display:flex;flex-direction:column;gap:10px">
<label style="display:flex;align-items:center;gap:8px">
<input class="bb-checkbox" type="checkbox" checked /> ЛОР
</label>
<label style="display:flex;align-items:center;gap:8px">
<input class="bb-checkbox" type="checkbox" /> Аудиология
</label>
</div>`;
const codeRadio = `<div style="display:flex;flex-direction:column;gap:10px" role="radiogroup">
<label style="display:flex;align-items:center;gap:8px">
<input class="bb-radio" type="radio" name="visit" value="first" checked />
Первичный приём
</label>
<label style="display:flex;align-items:center;gap:8px">
<input class="bb-radio" type="radio" name="visit" value="repeat" />
Повторный приём
</label>
<label style="display:flex;align-items:center;gap:8px">
<input class="bb-radio" type="radio" name="visit" value="online" />
Онлайн-консультация
</label>
</div>`;
const codeToggle = `import { Toggle } from "@/components/ui/Toggle";
// Базовый тумблер
<Toggle />
// С меткой
<Toggle label="Получать уведомления" />
// По умолчанию включён
<Toggle defaultChecked label="Email-рассылка" />
// Заблокирован
<Toggle disabled label="Настройка недоступна" />`;
const codeSiteCSS = `/* ── Реальный CSS с perm.oclinica.ru ─────────────────────────── */
/* Базовые стили (Bootstrap override) */
input[type=text],
input[type=email] {
padding: 0;
height: 30px;
border: 1px solid #ccc;
}
/* Entityform-блоки: форма записи на приём / узнать стоимость */
#block-entityform-block-lor-form input[type=text],
#block-entityform-block-lor-form-2 input[type=text],
#block-entityform-block-surgery-form input[type=text] {
height: 50px;
padding: 10px;
}
/* Select в entityform */
.field-name-field-lor-vrach .form-control.form-select {
height: 50px;
padding: 10px 16px;
font-size: .9em;
font-weight: bold;
font-family: 'Fira Sans';
color: #949290;
}
/* Webform (отдельный вид форм) — скруглений нет */
.webform-client-form input[type=text].form-text {
border-radius: 0;
}`;
return (
<div className="max-w-4xl mx-auto px-8 py-10">
{/* Заголовок */}
<div className="mb-10 pb-6 border-b" style={{ borderColor: "var(--bb-border)" }}>
<p
className="text-xs font-semibold uppercase tracking-widest mb-2"
style={{ color: "var(--brand-053m)" }}
>
Компоненты 3.2
</p>
<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)" }}>
Элементы ввода данных: текстовые поля, выпадающие списки, флажки, переключатели.
Применяются в формах записи, фильтрах и настройках.
</p>
</div>
{/* 1. Input */}
<Section
id="input"
title="Текстовое поле"
subtitle="Базовый элемент ввода текста. Класс .bb-input. Высота 50px — как на сайте oclinica.ru."
>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-4">
<StateCard label="Default" hint="border: 1px solid #ccc · border-radius: 4px · height: 50px">
<FieldLabel text="Имя пациента" required />
<input className="bb-input" type="text" placeholder="Иван Иванов" readOnly />
<FieldHint text="Укажите имя как в паспорте" />
</StateCard>
<StateCard label="Focus" hint="border #7ecfca + box-shadow rgba(126,207,202,0.2)">
<FieldLabel text="Имя пациента" required />
<input
className="bb-input"
type="text"
defaultValue="Иван"
readOnly
style={{
borderColor: "var(--brand-053m)",
boxShadow: "0 0 0 3px rgba(126,207,202,0.2)",
}}
/>
<FieldHint text="Укажите имя как в паспорте" />
</StateCard>
<StateCard label="Error" hint="border #dc2626 + .bb-error + сообщение об ошибке">
<FieldLabel text="Имя пациента" required />
<input
className="bb-input bb-error"
type="text"
defaultValue="ив"
readOnly
/>
<FieldError text="Минимум 3 символа" />
</StateCard>
<StateCard label="Disabled" hint="opacity: 0.5 + cursor: not-allowed">
<FieldLabel text="Email (только чтение)" />
<input
className="bb-input"
type="text"
defaultValue="ivan@example.com"
disabled
/>
<FieldHint text="Email нельзя изменить" />
</StateCard>
</div>
{/* Password */}
<div
className="rounded-xl border p-4"
style={{ borderColor: "var(--bb-border)", background: "var(--bb-sidebar-bg)" }}
>
<p className="text-xs font-semibold uppercase tracking-widest mb-3" style={{ color: "var(--bb-text-muted)" }}>
Тип password
</p>
<div className="max-w-sm">
<FieldLabel text="Пароль" required />
<input className="bb-input" type="password" placeholder="Введите пароль" readOnly />
</div>
</div>
</Section>
{/* 2. Textarea */}
<Section
id="textarea"
title="Многострочный текст"
subtitle="Поле для длинного ввода. Класс .bb-textarea. border: 1px solid #ccc · border-radius: 4px."
>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<StateCard label="Default" hint="min-height: 100px · resize: vertical">
<FieldLabel text="Комментарий к записи" />
<textarea
className="bb-textarea"
rows={3}
placeholder="Опишите симптомы или пожелания..."
readOnly
/>
</StateCard>
<StateCard label="Focus" hint="border #7ecfca + box-shadow">
<FieldLabel text="Комментарий к записи" />
<textarea
className="bb-textarea"
rows={3}
defaultValue="Беспокоит боль в горле..."
readOnly
style={{
borderColor: "var(--brand-053m)",
boxShadow: "0 0 0 3px rgba(126,207,202,0.2)",
}}
/>
</StateCard>
</div>
</Section>
{/* 3. Select */}
<Section
id="select"
title="Выпадающий список"
subtitle="Выбор из предопределённых вариантов. Класс .bb-select. Высота 50px — как в entityform на сайте."
>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<StateCard label="Default" hint="height: 50px · кастомная стрелка SVG">
<FieldLabel text="Специализация" />
<select className="bb-select">
<option value="">Выберите специализацию</option>
<option value="lor">ЛОР ухо, горло, нос</option>
<option value="aud">Аудиология</option>
<option value="ped">ЛОР (детская)</option>
</select>
</StateCard>
<StateCard label="Выбрано значение" hint="Нативное поведение браузера">
<FieldLabel text="Специализация" />
<select className="bb-select" defaultValue="lor">
<option value="">Выберите специализацию</option>
<option value="lor">ЛОР ухо, горло, нос</option>
<option value="aud">Аудиология</option>
<option value="ped">ЛОР (детская)</option>
</select>
</StateCard>
</div>
</Section>
{/* 4. Checkbox */}
<Section
id="checkbox"
title="Флажок"
subtitle="Независимый выбор одного или нескольких вариантов. Класс .bb-checkbox."
>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<StateCard label="Одиночный флажок" hint="Согласие с условиями, подтверждение">
<div className="flex flex-col gap-3">
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" className="bb-checkbox" />
<span className="text-sm" style={{ color: "var(--bb-text)" }}>
Согласен с условиями
</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" className="bb-checkbox" defaultChecked />
<span className="text-sm" style={{ color: "var(--bb-text)" }}>
Получать уведомления
</span>
</label>
<label
className="flex items-center gap-2"
style={{ opacity: 0.5, cursor: "not-allowed" }}
>
<input type="checkbox" className="bb-checkbox" disabled />
<span className="text-sm" style={{ color: "var(--bb-text)" }}>
Недоступная опция
</span>
</label>
</div>
</StateCard>
<StateCard label="Группа флажков" hint="Выбор нескольких специализаций">
<FieldLabel text="Интересующие направления" />
<div className="flex flex-col gap-2.5 mt-1">
{["ЛОР — ухо, горло, нос", "Аудиология", "ЛОР (детская)", "Слухопротезирование"].map(
(item, i) => (
<label key={item} className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
className="bb-checkbox"
defaultChecked={i === 0}
/>
<span className="text-sm" style={{ color: "var(--bb-text)" }}>
{item}
</span>
</label>
)
)}
</div>
</StateCard>
</div>
</Section>
{/* 5. Radio */}
<Section
id="radio"
title="Переключатель варианта"
subtitle="Выбор одного из взаимоисключающих вариантов. Класс .bb-radio."
>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<StateCard label="Тип приёма" hint="Один выбранный из нескольких">
<FieldLabel text="Тип приёма" />
<div className="flex flex-col gap-2.5 mt-1" role="radiogroup">
{[
{ value: "first", label: "Первичный приём", checked: true },
{ value: "repeat", label: "Повторный приём", checked: false },
{ value: "online", label: "Онлайн-консультация", checked: false },
].map(({ value, label, checked }) => (
<label key={value} className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
className="bb-radio"
name="visit-type-demo"
defaultChecked={checked}
/>
<span className="text-sm" style={{ color: "var(--bb-text)" }}>
{label}
</span>
</label>
))}
</div>
</StateCard>
<StateCard label="С отключённым вариантом" hint="disabled скрывает недоступный выбор">
<FieldLabel text="Способ контакта" />
<div className="flex flex-col gap-2.5 mt-1" role="radiogroup">
{[
{ value: "phone", label: "Телефон", disabled: false, checked: true },
{ value: "sms", label: "SMS", disabled: false, checked: false },
{ value: "whatsapp", label: "WhatsApp (скоро)", disabled: true, checked: false },
].map(({ value, label, disabled, checked }) => (
<label
key={value}
className="flex items-center gap-2"
style={{ cursor: disabled ? "not-allowed" : "pointer", opacity: disabled ? 0.5 : 1 }}
>
<input
type="radio"
className="bb-radio"
name="contact-demo"
disabled={disabled}
defaultChecked={checked}
/>
<span className="text-sm" style={{ color: "var(--bb-text)" }}>
{label}
</span>
</label>
))}
</div>
</StateCard>
</div>
</Section>
{/* 6. Toggle */}
<Section
id="toggle"
title="Тумблер"
subtitle="Булевый переключатель «включено / выключено». Компонент Toggle."
>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div
className="rounded-xl border p-5"
style={{ borderColor: "var(--bb-border)" }}
>
<p className="text-sm font-medium mb-4" style={{ color: "var(--bb-text)" }}>
Интерактивные примеры
</p>
<div className="flex flex-col gap-4">
<Toggle label="Получать уведомления" />
<Toggle defaultChecked label="Email-рассылка" />
<Toggle defaultChecked label="Push-уведомления" />
<Toggle disabled label="Недоступная настройка" />
</div>
</div>
<div
className="rounded-xl border p-5"
style={{ borderColor: "var(--bb-border)" }}
>
<p className="text-sm font-medium mb-4" style={{ color: "var(--bb-text)" }}>
Состояния
</p>
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<span className="text-sm" style={{ color: "var(--bb-text-muted)" }}>Выкл (default)</span>
<Toggle />
</div>
<div className="flex items-center justify-between">
<span className="text-sm" style={{ color: "var(--bb-text-muted)" }}>Вкл (defaultChecked)</span>
<Toggle defaultChecked />
</div>
<div className="flex items-center justify-between">
<span className="text-sm" style={{ color: "var(--bb-text-muted)" }}>Disabled</span>
<Toggle disabled />
</div>
<div className="flex items-center justify-between">
<span className="text-sm" style={{ color: "var(--bb-text-muted)" }}>Disabled + вкл</span>
<Toggle disabled defaultChecked />
</div>
</div>
</div>
</div>
</Section>
{/* 7. Контекст на сайте */}
<Section
id="context"
title="Контекст применения"
subtitle="Как форм-контролы выглядят на сайте oclinica.ru — в реальных entityform-блоках."
>
<div className="overflow-x-auto mb-6">
<table className="w-full text-sm border-collapse">
<thead>
<tr style={{ background: "var(--bb-sidebar-bg)" }}>
{["Контрол", "CSS класс", "Где на сайте", "CSS-блок на сайте"].map((h) => (
<th
key={h}
className="text-left px-3 py-2 font-semibold text-xs uppercase tracking-wide"
style={{ color: "var(--bb-text-muted)", borderBottom: "1px solid var(--bb-border)" }}
>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{[
["Input (text)", ".bb-input", "Форма записи ЛОР, форма хирургии, «Узнайте стоимость»", "#block-entityform-block-lor-form input[type=text]"],
["Select", ".bb-select", "Выбор врача в форме записи", ".field-name-field-lor-vrach .form-control.form-select"],
["Checkbox", ".bb-checkbox", "Согласие на обработку данных в entityform", ".form-type-checkbox.checkbox label"],
["Textarea", ".bb-textarea", "Комментарии (в ряде форм)", "Без специального CSS на сайте (Bootstrap)"],
["Toggle", ".bb-toggle-track", "Не используется на сайте (UI-компонент брендбука)", "—"],
].map(([ctrl, cls, where, block]) => (
<tr key={ctrl} style={{ borderBottom: "1px solid var(--bb-border)" }}>
<td className="px-3 py-2.5 font-medium" style={{ color: "var(--bb-text)" }}>{ctrl}</td>
<td className="px-3 py-2.5">
<code className="text-xs px-1.5 py-0.5 rounded" style={{ background: "var(--bb-sidebar-bg)", color: "var(--brand-073m)" }}>{cls}</code>
</td>
<td className="px-3 py-2.5 text-xs" style={{ color: "var(--bb-text-muted)" }}>{where}</td>
<td className="px-3 py-2.5">
<code className="text-xs" style={{ color: "var(--bb-text-muted)" }}>{block}</code>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Макет формы как на сайте */}
<div className="rounded-xl overflow-hidden border" style={{ borderColor: "var(--bb-border)" }}>
<div className="px-4 py-2 text-xs font-semibold uppercase tracking-widest" style={{ background: "var(--bb-sidebar-bg)", color: "var(--bb-text-muted)" }}>
Макет entityform «Узнайте стоимость операции» (oclinica.ru/lor)
</div>
<div style={{ background: "#b8e6ed", padding: "32px 24px" }}>
<div
className="mx-auto"
style={{
maxWidth: 340,
background: "#b8e6ed",
textAlign: "center",
}}
>
<p className="text-sm font-semibold mb-4" style={{ color: "#333", fontFamily: "var(--font-web)" }}>
Узнайте стоимость операции
</p>
<div className="flex flex-col gap-3" style={{ alignItems: "center" }}>
<input
className="bb-input"
type="text"
placeholder="Ваше имя"
style={{ width: 302 }}
readOnly
/>
<input
className="bb-input"
type="text"
placeholder="Ваш телефон"
style={{ width: 302 }}
readOnly
/>
<select className="bb-select" style={{ width: 302 }}>
<option>Выберите врача</option>
<option>Иванов И.И.</option>
</select>
<button className="bb-btn bb-btn-lg bb-btn-primary" style={{ width: 300 }}>
Запишите меня!
</button>
</div>
</div>
</div>
</div>
</Section>
{/* 8. Примеры кода */}
<Section
id="code"
title="Примеры кода"
subtitle="Скопируйте HTML или JSX для использования в проекте."
>
<div className="space-y-4">
<CodeCopy lang="HTML — Input" code={codeInput} />
<CodeCopy lang="HTML — Textarea" code={codeTextarea} />
<CodeCopy lang="HTML — Select" code={codeSelect} />
<CodeCopy lang="HTML — Checkbox" code={codeCheckbox} />
<CodeCopy lang="HTML — Radio" code={codeRadio} />
<CodeCopy lang="JSX (React) — Toggle" code={codeToggle} />
<CodeCopy lang="CSS с сайта (perm.oclinica.ru)" code={codeSiteCSS} />
</div>
</Section>
{/* LLM-блок */}
<LlmBlock path="/components/forms" version="v2.0" specText={LLM_FORMS_TEXT}>
<LlmSection title="Элементы ввода" />
<LlmTable
headers={["Элемент", "CSS класс", "Тег", "Высота", "Описание"]}
rows={[
["Input", ".bb-input", "<input>", "50px", "Текстовое поле, email, password · как на сайте"],
["Textarea", ".bb-textarea", "<textarea>", "≥100px", "Многострочный ввод, resize:vertical"],
["Select", ".bb-select", "<select>", "50px", "Выбор из списка, кастомная стрелка · как на сайте"],
["Checkbox", ".bb-checkbox", "<input type=checkbox>", "16×16px", "Независимый выбор"],
["Radio", ".bb-radio", "<input type=radio>", "16×16px", "Выбор одного из группы"],
["Toggle", ".bb-toggle-track", "React-компонент", "24px", "Булев переключатель"],
]}
/>
<LlmSection title="Состояния полей (Input / Textarea / Select)" />
<LlmTable
headers={["Состояние", "Стиль"]}
rows={[
["default", "border: 1px solid #ccc · border-radius: 4px"],
["focus", "border: #7ecfca + box-shadow: 0 0 0 3px rgba(126,207,202,0.2)"],
["error", "border: #dc2626 (+ класс .bb-error)"],
["disabled", "opacity: 0.5 + cursor: not-allowed + bg: #f8f9fa"],
]}
/>
<LlmSection title="Параметры Toggle" />
<LlmTable
headers={["Параметр", "Тип", "По умолч.", "Описание"]}
rows={[
["defaultChecked", "boolean", "false", "Начальное состояние"],
["disabled", "boolean", "false", "Блокирует взаимодействие"],
["label", "string", "—", "Текстовая метка справа от тумблера"],
["onChange", "(checked: boolean) => void", "—", "Колбэк при изменении"],
]}
/>
<LlmSection title="Правила применения" />
<LlmRules
rules={[
{ ok: true, text: "Метка (label) всегда над полем, font-weight: 500" },
{ ok: true, text: "Обязательные поля помечены * красным (#dc2626)" },
{ ok: true, text: "Hint-текст серым под полем (font-size: 12px)" },
{ ok: true, text: "Ошибка — красный текст вместо hint" },
{ ok: true, text: "Checkbox-группы — вертикальный список с gap: 10px" },
{ ok: true, text: "Toggle — для булевых настроек включить/выключить" },
{ ok: false, text: "Не использовать placeholder вместо label" },
{ ok: false, text: "Не скрывать признак обязательности поля" },
]}
/>
</LlmBlock>
</div>
);
}

821
apps/web/app/foundation/colors/page.tsx

@ -1,821 +0,0 @@
"use client";
import { useState, useCallback } from "react";
import { LlmBlock, LlmSection, LlmTable, LlmRules } from "@/components/llm/LlmBlock";
import type { Metadata } from "next";
/* ─── Утилиты конвертации ──────────────────────────────────────────── */
function hexToRgb(hex: string): { r: number; g: number; b: number } {
const m = /^#([0-9a-f]{6})$/i.exec(hex);
if (!m) return { r: 0, g: 0, b: 0 };
return {
r: parseInt(m[1].slice(0, 2), 16),
g: parseInt(m[1].slice(2, 4), 16),
b: parseInt(m[1].slice(4, 6), 16),
};
}
function rgbToHsl(r: number, g: number, b: number): { h: number; s: number; l: number } {
r /= 255; g /= 255; b /= 255;
const max = Math.max(r, g, b), min = Math.min(r, g, b);
let h = 0, s = 0;
const l = (max + min) / 2;
if (max !== min) {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break;
case g: h = ((b - r) / d + 2) / 6; break;
case b: h = ((r - g) / d + 4) / 6; break;
}
}
return { h: Math.round(h * 360), s: Math.round(s * 100), l: Math.round(l * 100) };
}
function luminance(r: number, g: number, b: number): number {
const a = [r, g, b].map(v => {
v /= 255;
return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
});
return a[0] * 0.2126 + a[1] * 0.7152 + a[2] * 0.0722;
}
function contrastRatio(hex1: string, hex2: string): number {
const { r: r1, g: g1, b: b1 } = hexToRgb(hex1);
const { r: r2, g: g2, b: b2 } = hexToRgb(hex2);
const l1 = luminance(r1, g1, b1);
const l2 = luminance(r2, g2, b2);
const lighter = Math.max(l1, l2);
const darker = Math.min(l1, l2);
return Math.round(((lighter + 0.05) / (darker + 0.05)) * 10) / 10;
}
/* ─── Данные цветов ────────────────────────────────────────────────── */
const BRAND_COLORS = [
{
oracal: "053M",
name: "Основной бирюзовый",
hex: "#0089c3",
usage: "Акцентный цвет, CTA-кнопки, иконки, активные состояния",
cssVar: "--brand-053m",
},
{
oracal: "073M",
name: "Тёмный серо-голубой",
hex: "#53514e",
usage: "Тёмный фон, хедер, акценты на тёмных поверхностях",
cssVar: "--brand-073m",
},
{
oracal: "066M",
name: "Средний бирюзовый",
hex: "#00818c",
usage: "Вторичные акценты, фоны секций, иллюстрации",
cssVar: "--brand-066m",
},
{
oracal: "050M",
name: "Тёмно-синий",
hex: "#1b2e5d",
usage: "Наружная реклама, полиграфия, заголовки на светлом фоне",
cssVar: "--brand-050m",
},
{
oracal: "081M",
name: "Бежевый",
hex: "#a8885c",
usage: "Форма сотрудников, оффлайн носители, тёплые акценты",
cssVar: "--brand-081m",
},
{
oracal: "080M",
name: "Тёмно-коричневый",
hex: "#432f1e",
usage: "Текст на бежевых поверхностях, логотип на форме",
cssVar: "--brand-080m",
},
{
oracal: "—",
name: "Белый",
hex: "#ffffff",
usage: "Фон, инвертированный текст, логотип на тёмных фонах",
cssVar: "--brand-white",
},
];
/* ─── Соответствие цветов ──────────────────────────────────────────── */
const COLOR_MAPPING = [
{
brand: { oracal: "053M", name: "Основной бирюзовый", hex: "#0089c3" },
web: { name: "Бирюзовый", hex: "#63bac3", count: 4 },
note: "Oracal — насыщенный синий; сайт — светлее и голубее",
},
{
brand: { oracal: "073M", name: "Тёмный серо-голубой", hex: "#53514e" },
web: { name: "Серо-бирюзовый", hex: "#60959c", count: 7 },
note: "Oracal — тёмно-серый; сайт — серо-бирюзовый (значительное расхождение)",
},
{
brand: { oracal: "066M", name: "Средний бирюзовый", hex: "#00818c" },
web: { name: "Бирюзовый средний", hex: "#52b4bd", count: 4 },
note: "Oracal — тёмный бирюзовый; сайт — светлее",
},
{
brand: { oracal: "081M", name: "Бежевый", hex: "#a8885c" },
web: { name: "Бежевый", hex: "#bf9975", count: 12 },
note: "Oracal — тёплый охристый; сайт — прохладнее и светлее",
},
{
brand: { oracal: "050M", name: "Тёмно-синий", hex: "#1b2e5d" },
web: null,
note: "Не найден в CSS сайта",
},
{
brand: { oracal: "080M", name: "Тёмно-коричневый", hex: "#432f1e" },
web: null,
note: "Не найден в CSS сайта",
},
{
brand: null,
web: { name: "Основной текст", hex: "#464646", count: 3 },
note: "Только на сайте — нет Oracal-аналога",
},
{
brand: null,
web: { name: "Второстепенный текст", hex: "#949290", count: 4 },
note: "Только на сайте — нет Oracal-аналога",
},
{
brand: null,
web: { name: "Светло-кремовый фон (Hero)", hex: "#f9f4e7", count: 1 },
note: "Только на сайте — фон Hero-баннера страниц разделов. Нет Oracal-аналога",
},
{
brand: null,
web: { name: "Кремовый фон", hex: "#e9e4d4", count: 1 },
note: "Только на сайте — тёплые фоны кнопок-пилюль, вторичные секции. Нет Oracal-аналога",
},
{
brand: null,
web: { name: "Коралловый", hex: "#ffa39c", count: 2 },
note: "Только на сайте — CTA-кнопки, нет в оффлайн-палитре",
},
{
brand: null,
web: { name: "Светло-жёлтый фон", hex: "#eef4d1", count: 1 },
note: "Только на сайте — фон карточек отзывов",
},
{
brand: null,
web: { name: "Светло-зелёный фон", hex: "#f2fee6", count: 1 },
note: "Только на сайте — фон секции новостей",
},
];
/* ─── Цвета с сайта ────────────────────────────────────────────────── */
// Источник: https://perm.oclinica.ru/sites/all/themes/clinic_bootstrap_mobile/css/style.css
// Извлечены парсингом CSS: grep + python Counter по property/value, 2026-03-22
const WEB_COLORS = [
{ name: "Бежевый", hex: "#bf9975", usage: "Основной тёплый акцент, фоны, рамки, текст", count: 12, group: "Акценты" },
{ name: "Серо-бирюзовый", hex: "#60959c", usage: "Основной холодный акцент, ссылки", count: 7, group: "Акценты" },
{ name: "Бирюзовый", hex: "#63bac3", usage: "Фоны акцентных блоков, иконки", count: 4, group: "Акценты" },
{ name: "Бирюзовый средний", hex: "#52b4bd", usage: "Вторичные цветовые акценты", count: 4, group: "Акценты" },
{ name: "Основной текст", hex: "#464646", usage: "Цвет основного текста на сайте", count: 3, group: "Текст" },
{ name: "Второстепенный текст", hex: "#949290", usage: "Подписи, второстепенный контент", count: 4, group: "Текст" },
{ name: "Светло-бирюзовый фон", hex: "#b8e6ed", usage: "Фон формы записи («Будьте здоровы!»)", count: 1, group: "Фоны" },
{ name: "Светло-кремовый фон (Hero)", hex: "#f9f4e7", usage: "Фон Hero-баннера страниц разделов", count: 1, group: "Фоны" },
{ name: "Кремовый фон", hex: "#e9e4d4", usage: "Тёплые фоны кнопок-пилюль, вторичные секции", count: 1, group: "Фоны" },
{ name: "Коралловый", hex: "#ffa39c", usage: "CTA-кнопки («Запишите меня!»), акцентные призывы к действию", count: 2, group: "Акценты" },
{ name: "Светло-жёлтый фон", hex: "#eef4d1", usage: "Фон карточек отзывов (секция «Отзывы о нас»)", count: 1, group: "Фоны" },
{ name: "Светло-зелёный фон", hex: "#f2fee6", usage: "Фон секции новостей", count: 1, group: "Фоны" },
];
const CONTRAST_PAIRS = [
{ fg: "#ffffff", bg: "#53514e", label: "Белый на тёмном серо-голубом (073M)" },
{ fg: "#ffffff", bg: "#1b2e5d", label: "Белый на тёмно-синем (050M)" },
{ fg: "#ffffff", bg: "#432f1e", label: "Белый на тёмно-коричневом (080M)" },
{ fg: "#ffffff", bg: "#00818c", label: "Белый на среднем бирюзовом (066M)" },
{ fg: "#111827", bg: "#0089c3", label: "Тёмный текст на основном бирюзовом (053M)" },
{ fg: "#111827", bg: "#a8885c", label: "Тёмный текст на бежевом (081M)" },
{ fg: "#432f1e", bg: "#a8885c", label: "Тёмно-коричневый на бежевом (форма, 080M/081M)" },
];
/* ─── Компоненты ───────────────────────────────────────────────────── */
function CopyBadge({ value, label }: { value: string; label: string }) {
const [copied, setCopied] = useState(false);
const copy = useCallback(() => {
navigator.clipboard.writeText(value);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}, [value]);
return (
<button
onClick={copy}
className="flex items-center gap-1.5 px-2 py-1 rounded text-xs font-mono transition-colors"
style={{
background: copied ? "#d1fae5" : "#f3f4f6",
color: copied ? "#065f46" : "#374151",
border: "1px solid",
borderColor: copied ? "#6ee7b7" : "#e5e7eb",
}}
title={`Скопировать ${label}`}
>
{copied ? "✓" : "⎘"} {value}
</button>
);
}
function ColorCard({ color }: { color: typeof BRAND_COLORS[0] }) {
const { r, g, b } = hexToRgb(color.hex);
const { h, s, l } = rgbToHsl(r, g, b);
const isLight = l > 55;
return (
<div
className="rounded-xl overflow-hidden border"
style={{ borderColor: "var(--bb-border)" }}
>
{/* Свотч */}
<div
className="h-32 flex items-end justify-between px-4 pb-3"
style={{ background: color.hex }}
>
{color.oracal !== "—" && (
<span
className="text-xs font-semibold px-2 py-0.5 rounded"
style={{
background: isLight ? "rgba(0,0,0,0.15)" : "rgba(255,255,255,0.2)",
color: isLight ? "rgba(0,0,0,0.8)" : "rgba(255,255,255,0.9)",
}}
>
Oracal {color.oracal}
</span>
)}
</div>
{/* Информация */}
<div className="p-4" style={{ background: "var(--bb-content-bg)" }}>
<p className="font-medium text-sm mb-1" style={{ color: "var(--bb-text)" }}>
{color.name}
</p>
<p className="text-xs mb-3" style={{ color: "var(--bb-text-muted)" }}>
{color.usage}
</p>
{/* Коды */}
<div className="flex flex-wrap gap-1.5">
<CopyBadge value={color.hex.toUpperCase()} label="HEX" />
<CopyBadge value={`rgb(${r}, ${g}, ${b})`} label="RGB" />
<CopyBadge value={`hsl(${h}, ${s}%, ${l}%)`} label="HSL" />
<CopyBadge value={color.cssVar} label="CSS var" />
</div>
</div>
</div>
);
}
function ContrastRow({ pair }: { pair: typeof CONTRAST_PAIRS[0] }) {
const ratio = contrastRatio(pair.fg, pair.bg);
const aa = ratio >= 4.5;
const aaa = ratio >= 7;
const aaLarge = ratio >= 3;
return (
<div
className="flex items-center gap-4 p-4 rounded-xl border"
style={{ borderColor: "var(--bb-border)" }}
>
{/* Превью */}
<div
className="w-32 shrink-0 h-12 rounded-lg flex items-center justify-center text-sm font-medium"
style={{ background: pair.bg, color: pair.fg }}
>
Aa Бб Вв
</div>
{/* Описание */}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate" style={{ color: "var(--bb-text)" }}>
{pair.label}
</p>
<div className="flex gap-1 mt-1 items-center">
<span className="font-mono text-xs" style={{ color: "var(--bb-text-muted)" }}>
{pair.fg} / {pair.bg}
</span>
</div>
</div>
{/* Ratio и бейджи */}
<div className="shrink-0 flex items-center gap-3">
<span className="font-mono font-semibold text-sm" style={{ color: "var(--bb-text)" }}>
{ratio}:1
</span>
<div className="flex gap-1">
{[
{ label: "AA", pass: aa },
{ label: "AAA", pass: aaa },
{ label: "AA large", pass: aaLarge },
].map(({ label, pass }) => (
<span
key={label}
className="text-[10px] font-semibold px-1.5 py-0.5 rounded"
style={{
background: pass ? "#d1fae5" : "#fee2e2",
color: pass ? "#065f46" : "#991b1b",
}}
>
{pass ? "✓" : "✕"} {label}
</span>
))}
</div>
</div>
</div>
);
}
function WebColorCard({ color }: { color: typeof WEB_COLORS[0] }) {
const { r, g, b } = hexToRgb(color.hex);
const { h, s, l } = rgbToHsl(r, g, b);
const isLight = l > 60;
return (
<div
className="rounded-xl overflow-hidden border"
style={{ borderColor: "var(--bb-border)" }}
>
<div
className="h-24 flex items-end justify-between px-4 pb-3"
style={{ background: color.hex }}
>
<span
className="text-xs font-semibold px-2 py-0.5 rounded"
style={{
background: isLight ? "rgba(0,0,0,0.12)" : "rgba(255,255,255,0.18)",
color: isLight ? "rgba(0,0,0,0.75)" : "rgba(255,255,255,0.9)",
}}
>
{color.group}
</span>
<span
className="text-xs px-2 py-0.5 rounded font-mono"
style={{
background: isLight ? "rgba(0,0,0,0.12)" : "rgba(255,255,255,0.18)",
color: isLight ? "rgba(0,0,0,0.75)" : "rgba(255,255,255,0.9)",
}}
>
×{color.count}
</span>
</div>
<div className="p-4" style={{ background: "var(--bb-content-bg)" }}>
<p className="font-medium text-sm mb-1" style={{ color: "var(--bb-text)" }}>
{color.name}
</p>
<p className="text-xs mb-3" style={{ color: "var(--bb-text-muted)" }}>
{color.usage}
</p>
<div className="flex flex-wrap gap-1.5">
<CopyBadge value={color.hex.toUpperCase()} label="HEX" />
<CopyBadge value={`rgb(${r}, ${g}, ${b})`} label="RGB" />
<CopyBadge value={`hsl(${h}, ${s}%, ${l}%)`} label="HSL" />
</div>
</div>
</div>
);
}
/* ─── Экспорт токенов ──────────────────────────────────────────────── */
function exportTokens() {
const tokens: Record<string, Record<string, unknown>> = { colors: {} };
BRAND_COLORS.forEach(c => {
const key = c.oracal !== "—" ? `brand-${c.oracal.toLowerCase()}` : "brand-white";
const { r, g, b } = hexToRgb(c.hex);
const { h, s, l } = rgbToHsl(r, g, b);
tokens.colors[key] = {
oracal: c.oracal,
name: c.name,
hex: c.hex.toUpperCase(),
rgb: `rgb(${r}, ${g}, ${b})`,
hsl: `hsl(${h}, ${s}%, ${l}%)`,
cssVar: c.cssVar,
};
});
const blob = new Blob([JSON.stringify(tokens, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url; a.download = "oclinica-brand-tokens.json"; a.click();
URL.revokeObjectURL(url);
}
/* ─── LLM spec text ────────────────────────────────────────────────── */
const LLM_COLORS_TEXT = `# ЦВЕТА — LLM-СПЕЦИФИКАЦИЯ
# Клиника ухо, горло, нос им. проф. Е.Н.Оленевой
# docs/LLM_CONTEXT.md · /foundation/colors · v2.2 · 2026-03-22
ФИРМЕННЫЕ ЦВЕТА (Oracal) точные значения из каталога
Oracal | Название | HEX | RGB | CSS-переменная | Применение
053M | Основной бирюзовый | #0089C3 | rgb(0,137,195) | --brand-053m | Акцент, CTA-кнопки, иконки, активные состояния
073M | Тёмный серо-голубой | #53514E | rgb(83,81,78) | --brand-073m | Тёмный фон, хедер, заголовки навигации
066M | Средний бирюзовый | #00818C | rgb(0,129,140) | --brand-066m | Вторичные акценты, фоны секций
050M | Тёмно-синий | #1B2E5D | rgb(27,46,93) | --brand-050m | Наружная реклама, полиграфия, заголовки
081M | Бежевый | #A8885C | rgb(168,136,92) | --brand-081m | Форма сотрудников, тёплые акценты
080M | Тёмно-коричневый | #432F1E | rgb(67,47,30) | --brand-080m | Текст на бежевом, логотип на форме
| Белый | #FFFFFF | rgb(255,255,255) | --brand-white | Фон, инвертированный текст, логотип на тёмных
ЦВЕТА САЙТА oclinica.ru (CSS: clinic_bootstrap_mobile/css/style.css)
Название | HEX | Группа | × | Применение
Бежевый | #BF9975 | Акценты | 12| Тёплый акцент, фоны, рамки, текст
Серо-бирюзовый | #60959C | Акценты | 7 | Холодный акцент, ссылки
Бирюзовый | #63BAC3 | Акценты | 4 | Фоны акцентных блоков, иконки
Бирюзовый средний | #52B4BD | Акценты | 4 | Вторичные цветовые акценты
Коралловый | #FFA39C | Акценты | 2 | CTA-кнопки («Запишите меня!»)
Основной текст | #464646 | Текст | 3 | Цвет основного текста сайта
Второстепенный текст | #949290 | Текст | 4 | Подписи, второстепенный контент
Светло-кремовый Hero | #F9F4E7 | Фоны | 1 | Фон Hero-баннера страниц разделов
Светло-бирюзовый фон | #B8E6ED | Фоны | 1 | Фон формы записи («Будьте здоровы!»)
Кремовый фон | #E9E4D4 | Фоны | 1 | Тёплые фоны кнопок-пилюль, вторичные секции
Светло-жёлтый фон | #EEF4D1 | Фоны | 1 | Фон карточек отзывов
Светло-зелёный фон | #F2FEE6 | Фоны | 1 | Фон секции новостей
СООТВЕТСТВИЕ ORACAL САЙТ (цифровая адаптация плёночных цветов)
053M #0089C3 #63BAC3 (Oracal ярко-синий; сайт светлее и голубее)
073M #53514E #60959C (Oracal тёмно-серый; сайт серо-бирюзовый значительное расхождение)
066M #00818C #52B4BD (Oracal тёмный бирюзовый; сайт светлее)
081M #A8885C #BF9975 (Oracal тёплый охристый; сайт прохладнее и светлее)
050M #1B2E5D не найден в CSS сайта
080M #432F1E не найден в CSS сайта
КОНТРАСТНОСТЬ WCAG 2.1
#FFFFFF / #53514E | 7.9:1 | AA PASS | AAA PASS
#FFFFFF / #1B2E5D | 13.2:1 | AA PASS | AAA PASS
#FFFFFF / #432F1E | 12.6:1 | AA PASS | AAA PASS
#FFFFFF / #00818C | 4.7:1 | AA PASS | AAA FAIL
#111827 / #0089C3 | 4.7:1 | AA PASS | AAA FAIL
#111827 / #A8885C | 5.5:1 | AA PASS | AAA FAIL
#432F1E / #A8885C | 3.8:1 | AA FAIL | AAA FAIL | только крупный текст 18pt
ПРАВИЛА
Только цвета из фирменной палитры
Digital цвета сайта; оффлайн коды Oracal
Текст на цветном фоне: минимум WCAG AA (4.5:1)
Белый текст на: 073M (#53514E), 066M (#00818C), 050M (#1B2E5D), 080M (#432F1E)
Тёмный текст (#111827) на: 053M (#0089C3), 081M (#A8885C)
Произвольные цвета вне фирменной палитры
Изменение насыщенности / оттенка фирменных цветов
Тёплые и холодные акценты рядом без нейтрального разделителя`.trim();
/* ─── Страница ─────────────────────────────────────────────────────── */
export default function ColorsPage() {
return (
<div className="max-w-4xl mx-auto px-8 py-10">
{/* Заголовок */}
<div className="mb-10 pb-6 border-b" style={{ borderColor: "var(--bb-border)" }}>
<p
className="text-xs font-semibold uppercase tracking-widest mb-2"
style={{ color: "var(--brand-053m)" }}
>
Фундамент 1.3
</p>
<h1 className="text-3xl font-semibold mb-3" style={{ color: "var(--bb-text)" }}>
Цвета
</h1>
<p className="text-base max-w-2xl" style={{ color: "var(--bb-text-muted)" }}>
Фирменная цветовая палитра основана на кодах плёнки Oracal. HEX-значения
подобраны как ближайшие эквиваленты. Для точной печати используйте коды Oracal.
</p>
<div className="mt-4 flex items-center justify-between">
<div
className="px-4 py-3 rounded-lg border text-sm flex items-center gap-2"
style={{ borderColor: "#bae6fd", background: "#f0f9ff", color: "#075985" }}
>
<span></span>
<span>
HEX-значения рассчитаны по точным RGB из каталога Oracal.
Для оффлайн-носителей используйте официальные коды Oracal.
</span>
</div>
<button
onClick={exportTokens}
className="ml-4 shrink-0 px-4 py-2 rounded-lg text-sm font-medium transition-colors"
style={{
background: "var(--brand-053m)",
color: "#ffffff",
}}
>
Экспорт JSON
</button>
</div>
</div>
{/* 1. Палитра */}
<section className="mb-12">
<div className="mb-6">
<h2 className="text-xl font-semibold" style={{ color: "var(--bb-text)" }}>
Фирменные цвета
</h2>
<p className="mt-1 text-sm" style={{ color: "var(--bb-text-muted)" }}>
Нажмите на значение, чтобы скопировать его в буфер обмена.
</p>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{BRAND_COLORS.map(c => (
<ColorCard key={c.oracal} color={c} />
))}
</div>
</section>
{/* 2. Контрастность */}
<section className="mb-12">
<div className="mb-6">
<h2 className="text-xl font-semibold" style={{ color: "var(--bb-text)" }}>
Контрастность пар (WCAG)
</h2>
<p className="mt-1 text-sm" style={{ color: "var(--bb-text-muted)" }}>
Проверка соответствия требованиям доступности для основных комбинаций цветов.
AA = 4.5:1 для обычного текста, 3:1 для крупного (&gt;18pt).
</p>
</div>
<div className="flex flex-col gap-3">
{CONTRAST_PAIRS.map(pair => (
<ContrastRow key={pair.label} pair={pair} />
))}
</div>
</section>
{/* 3. Цвета с сайта */}
<section className="mb-12">
<div className="mb-6">
<h2 className="text-xl font-semibold" style={{ color: "var(--bb-text)" }}>
Цвета с сайта
</h2>
<p className="mt-1 text-sm" style={{ color: "var(--bb-text-muted)" }}>
Реальные цвета, используемые на сайте oclinica.ru. Извлечены из CSS темы сайта.
Число применений показано в правом углу карточки.
</p>
<div
className="mt-3 flex items-center gap-2 px-3 py-2 rounded-lg text-xs w-fit font-mono"
style={{ background: "var(--bb-sidebar-bg)", color: "var(--bb-text-muted)", border: "1px solid var(--bb-border)" }}
>
<span style={{ color: "var(--brand-053m)" }}>CSS</span>
perm.oclinica.ru/sites/all/themes/clinic_bootstrap_mobile/css/style.css
</div>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
{WEB_COLORS.map(c => (
<WebColorCard key={c.hex} color={c} />
))}
</div>
</section>
{/* 4. Соответствие */}
<section className="mb-12">
<div className="mb-6">
<h2 className="text-xl font-semibold" style={{ color: "var(--bb-text)" }}>
Соответствие: Oracal Сайт
</h2>
<p className="mt-1 text-sm" style={{ color: "var(--bb-text-muted)" }}>
Сравнение фирменной палитры (Oracal, брендбук) с реальными цветами, применёнными на сайте.
Расхождения ожидаемы цифровая адаптация плёночных цветов под экран.
</p>
</div>
<div className="flex flex-col gap-3">
{COLOR_MAPPING.map((row, i) => (
<div
key={i}
className="flex items-center gap-3 p-4 rounded-xl border"
style={{ borderColor: "var(--bb-border)", background: "var(--bb-content-bg)" }}
>
{/* Левая сторона — Oracal */}
<div className="flex items-center gap-3 w-56 shrink-0">
{row.brand ? (
<>
<div
className="w-10 h-10 rounded-lg shrink-0 border"
style={{ background: row.brand.hex, borderColor: "var(--bb-border)" }}
/>
<div>
<p className="text-xs font-semibold font-mono" style={{ color: "var(--bb-text)" }}>
{row.brand.hex.toUpperCase()}
</p>
<p className="text-xs" style={{ color: "var(--bb-text-muted)" }}>
Oracal {row.brand.oracal}
</p>
</div>
</>
) : (
<div
className="w-10 h-10 rounded-lg shrink-0 flex items-center justify-center text-lg border"
style={{ borderColor: "var(--bb-border)", background: "var(--bb-sidebar-bg)" }}
>
</div>
)}
</div>
{/* Стрелка */}
<div className="shrink-0 text-sm font-bold" style={{ color: "var(--bb-text-muted)" }}></div>
{/* Правая сторона — Сайт */}
<div className="flex items-center gap-3 w-56 shrink-0">
{row.web ? (
<>
<div
className="w-10 h-10 rounded-lg shrink-0 border"
style={{ background: row.web.hex, borderColor: "var(--bb-border)" }}
/>
<div>
<p className="text-xs font-semibold font-mono" style={{ color: "var(--bb-text)" }}>
{row.web.hex.toUpperCase()}
</p>
<p className="text-xs" style={{ color: "var(--bb-text-muted)" }}>
{row.web.name} · ×{row.web.count}
</p>
</div>
</>
) : (
<div className="flex items-center gap-2">
<div
className="w-10 h-10 rounded-lg shrink-0 flex items-center justify-center text-lg border"
style={{ borderColor: "var(--bb-border)", background: "var(--bb-sidebar-bg)" }}
>
</div>
<p className="text-xs" style={{ color: "var(--bb-text-muted)" }}>Нет на сайте</p>
</div>
)}
</div>
{/* Примечание */}
<p className="flex-1 text-xs" style={{ color: "var(--bb-text-muted)" }}>
{row.note}
</p>
</div>
))}
</div>
</section>
{/* 5. Применение */}
<section className="mb-10">
<div className="mb-6">
<h2 className="text-xl font-semibold" style={{ color: "var(--bb-text)" }}>
Правила применения
</h2>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{[
{
icon: "✓",
color: "#d1fae5",
textColor: "#065f46",
title: "Используйте фирменные цвета",
text: "Только цвета из палитры обеспечивают узнаваемость бренда на всех носителях.",
},
{
icon: "✓",
color: "#d1fae5",
textColor: "#065f46",
title: "Соблюдайте контрастность",
text: "Текст на цветном фоне должен соответствовать минимум AA по WCAG 2.1.",
},
{
icon: "✕",
color: "#fee2e2",
textColor: "#991b1b",
title: "Не смешивайте произвольно",
text: "Не используйте фирменные цвета с посторонними цветами без согласования.",
},
{
icon: "✕",
color: "#fee2e2",
textColor: "#991b1b",
title: "Не изменяйте насыщенность",
text: "Осветление, затемнение или изменение оттенка недопустимо без необходимости.",
},
].map(item => (
<div
key={item.title}
className="flex gap-3 p-4 rounded-xl border"
style={{ borderColor: "var(--bb-border)" }}
>
<span
className="shrink-0 w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold"
style={{ background: item.color, color: item.textColor }}
>
{item.icon}
</span>
<div>
<p className="font-medium text-sm mb-1" style={{ color: "var(--bb-text)" }}>
{item.title}
</p>
<p className="text-sm" style={{ color: "var(--bb-text-muted)" }}>
{item.text}
</p>
</div>
</div>
))}
</div>
</section>
{/* 6. LLM-спецификация */}
<section className="mb-8">
<div className="mb-6">
<h2 className="text-xl font-semibold" style={{ color: "var(--bb-text)" }}>
LLM-спецификация
</h2>
<p className="mt-1 text-sm" style={{ color: "var(--bb-text-muted)" }}>
Машиночитаемые данные раздела для использования AI-ассистентами при разработке
дизайна, макетов и кода. Нажмите «Скопировать» чтобы получить полный текст.
</p>
</div>
<LlmBlock
path="/foundation/colors"
version="v2.2"
specText={LLM_COLORS_TEXT}
>
{/* Фирменные цвета */}
<div className="space-y-2">
<LlmSection title="Фирменные цвета (Oracal)" />
<LlmTable
headers={["Oracal", "HEX", "RGB", "CSS-переменная", "Применение"]}
rows={BRAND_COLORS.map(c => {
const { r, g, b } = hexToRgb(c.hex);
return [
<span key="o" className="flex items-center gap-1.5">
<span className="w-3 h-3 rounded-sm shrink-0 border inline-block" style={{ background: c.hex, borderColor: "var(--bb-border)" }} />
{c.oracal}
</span>,
<span key="h" style={{ color: "var(--bb-text)" }}>{c.hex.toUpperCase()}</span>,
`rgb(${r},${g},${b})`,
<span key="v" style={{ color: "var(--brand-073m)" }}>{c.cssVar}</span>,
c.usage,
];
})}
/>
</div>
{/* Цвета сайта */}
<div className="space-y-2">
<LlmSection title="Цвета сайта oclinica.ru" />
<LlmTable
headers={["Название", "HEX", "Группа", "×", "Применение"]}
rows={WEB_COLORS.map(c => [
<span key="n" className="flex items-center gap-1.5">
<span className="w-3 h-3 rounded-sm shrink-0 border inline-block" style={{ background: c.hex, borderColor: "var(--bb-border)" }} />
{c.name}
</span>,
<span key="h" style={{ color: "var(--bb-text)" }}>{c.hex.toUpperCase()}</span>,
c.group,
String(c.count),
c.usage,
])}
/>
</div>
{/* Контрастность */}
<div className="space-y-2">
<LlmSection title="Контрастность WCAG 2.1" />
<LlmTable
headers={["Пара (fg / bg)", "Ratio", "AA (4.5:1)", "AAA (7:1)", "AA large (3:1)"]}
rows={CONTRAST_PAIRS.map(p => {
const ratio = contrastRatio(p.fg, p.bg);
const aa = ratio >= 4.5, aaa = ratio >= 7, aal = ratio >= 3;
const badge = (pass: boolean) => (
<span key={String(pass)} style={{ color: pass ? "#059669" : "#dc2626", fontWeight: 700 }}>
{pass ? "PASS" : "FAIL"}
</span>
);
return [
<span key="pair" className="flex items-center gap-1.5">
<span className="w-3 h-3 rounded-sm border inline-block shrink-0" style={{ background: p.bg, borderColor: "var(--bb-border)" }} />
{p.fg} / {p.bg}
</span>,
`${ratio}:1`,
badge(aa),
badge(aaa),
badge(aal),
];
})}
/>
</div>
{/* Правила */}
<div className="space-y-2">
<LlmSection title="Правила применения" />
<LlmRules rules={[
{ ok: true, text: "Только цвета из фирменной палитры" },
{ ok: true, text: "Digital → цвета сайта; оффлайн → коды Oracal" },
{ ok: true, text: "Текст на цветном фоне: минимум WCAG AA (4.5:1)" },
{ ok: true, text: "Белый текст на: 073M (#53514E), 066M (#00818C), 050M (#1B2E5D), 080M (#432F1E)" },
{ ok: true, text: "Тёмный текст (#111827) на: 053M (#0089C3), 081M (#A8885C)" },
{ ok: false, text: "Произвольные цвета вне фирменной палитры" },
{ ok: false, text: "Изменение насыщенности / оттенка фирменных цветов" },
{ ok: false, text: "Тёплые + холодные акценты рядом без разделителя" },
]} />
</div>
</LlmBlock>
</section>
</div>
);
}

219
apps/web/app/foundation/logo/page.tsx

@ -1,9 +1,8 @@
import type { Metadata } from "next";
import Image from "next/image";
import { LlmBlock, LlmSection, LlmTable, LlmRules } from "@/components/llm/LlmBlock";
export const metadata: Metadata = {
title: "Логотип. Цифровой брендбук Клиники ухо, горло, нос им. проф. Е.Н.Оленевой",
title: "Логотип | Брендбук О!Клиника",
};
function RuleTag({ children }: { children: React.ReactNode }) {
@ -103,44 +102,6 @@ function ProhibitedItem({ label }: { label: string }) {
);
}
const LLM_LOGO_TEXT = `ЛОГОТИП — LLM-спецификация
Версия: v1.0 · /foundation/logo
ФАЙЛЫ
PNG с прозрачным фоном: /public/logo/logo-transparent.png
SVG/AI: ожидается от дизайнера
СИМВОЛИКА ЗНАКА
Три округлых элемента с равной дистанцией от центра.
- Незамкнутая симметрия: символ развития и жизни, а не завершённости
- Три элемента: структура ухо-горло-нос, триада равновесия
- Отсутствие замкнутой формы: открытость, доступность, человечность
ЦВЕТОВЫЕ ВАРИАНТЫ
Вариант | Фон | CSS-фильтр | Применение
Основной | Светлый (#f8f9fa) | нет | Сайт, полиграфия на белом
Инвертированный | #5b7b87 (073M) | brightness(0) invert(1) | Хедер, тёмные секции
На форме (беж) | #c4a882 (081M) | brightness(0) sepia(1) saturate(2) hue-rotate(330deg) brightness(0.45) | Форма сотрудников (бежевый)
На форме (синий)| #1b4c72 (050M) | brightness(0) invert(1) | Форма сотрудников (синий)
ОХРАННАЯ ЗОНА
Минимальный отступ со всех сторон = высота буквы «К» в слове «КЛИНИКА»
РАЗМЕРЫ НА ФОРМЕ СОТРУДНИКОВ
До 46 р. | 70 мм × 25,5 мм | Левая сторона груди
От 48 р. | 90 мм × 32,8 мм | Левая сторона груди
ПРАВИЛА
Применять только одобренные цветовые варианты
Соблюдать охранную зону
Использовать PNG с прозрачным фоном для digital
Белый логотип (invert) на тёмных фонах (073M, 050M)
Не изменять пропорции или искажать логотип
Не изменять цвета элементов логотипа
Не добавлять рядом произвольный текст
Не размещать на фоне без достаточного контраста
Не применять тени, обводки, градиенты`.trim();
export default function LogoPage() {
return (
<div className="max-w-4xl mx-auto px-8 py-10">
@ -171,119 +132,31 @@ export default function LogoPage() {
</div>
</div>
{/* 1. Логотип */}
{/* 1. Иерархия */}
<Section
id="hierarchy"
title="Логотип"
subtitle="Единый логотип клиники. Применяется на всех носителях."
title="Иерархия и версии"
subtitle="Клиника использует два варианта логотипа в зависимости от контекста применения."
>
<LogoCard
src="/logo/logo-transparent.png"
alt="Логотип Клиника ухо, горло, нос им. проф. Е.Н. Оленевой"
label="Логотип клиники"
description="Применяется в точках контакта с клиентами, на лендингах и сайтах направлений. Применяется для онлайн и оффлайн коммуникаций с клиентами, во внутренней документации."
tag="Онлайн · Оффлайн · Документация"
/>
</Section>
{/* 2. Символика знака */}
<Section
id="symbol"
title="Символика знака"
subtitle="О том, что стоит за формой логотипа."
>
{/* Главный тезис */}
<div
className="rounded-xl border p-6 mb-6"
style={{ borderColor: "var(--bb-border)", background: "var(--bb-sidebar-bg)" }}
>
<p className="text-sm leading-relaxed" style={{ color: "var(--bb-text)" }}>
В знаке клиники три округлых элемента, расположенных с равной дистанцией от центра.
Это создаёт ощущение симметрии, порядка и движения, но без завершённой формы (звезды или круга).
</p>
<div className="mt-4 flex flex-col gap-2">
{[
"Сохраняет баланс и лёгкость",
"Намекает на естественность и органику",
"Не замыкает символ — оставляет «дыхание», открытость",
].map(item => (
<div key={item} className="flex items-start gap-2.5">
<div
className="w-1.5 h-1.5 rounded-full mt-1.5 shrink-0"
style={{ background: "var(--brand-053m)" }}
/>
<p className="text-sm" style={{ color: "var(--bb-text-muted)" }}>{item}</p>
</div>
))}
</div>
</div>
{/* Три значения */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3 mb-6">
{[
{
num: "1",
title: "Незамкнутая симметрия",
text: "Это процесс, а не результат. Знак говорит: «мы развиваемся, мы живые, мы не идеальны, но гармоничны».",
},
{
num: "2",
title: "Три элемента",
text: "Классическая структура уха–горла–носа. Триада равновесия, ритм дыхания, символ доверия и открытости.",
},
{
num: "3",
title: "Отсутствие замкнутости",
text: "Нет барьера — есть приглашение. Открытая форма передаёт заботу, доступность и человечность.",
},
].map(item => (
<div
key={item.num}
className="rounded-xl border p-5"
style={{ borderColor: "var(--bb-border)" }}
>
<div
className="w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold mb-3"
style={{ background: "#e0f5f4", color: "var(--brand-073m)" }}
>
{item.num}
</div>
<p className="font-medium text-sm mb-2" style={{ color: "var(--bb-text)" }}>
{item.title}
</p>
<p className="text-sm leading-relaxed" style={{ color: "var(--bb-text-muted)" }}>
{item.text}
</p>
</div>
))}
</div>
{/* Почему не звезда */}
<div
className="rounded-xl border p-5 flex gap-4"
style={{ borderColor: "#e0f5f4", background: "#f8fffe" }}
>
<div
className="w-8 h-8 rounded-full flex items-center justify-center shrink-0 mt-0.5"
style={{ background: "#e0f5f4" }}
>
<span className="text-base leading-none"></span>
</div>
<div>
<p className="font-medium text-sm mb-1.5" style={{ color: "var(--bb-text)" }}>
Почему нет законченной звезды
</p>
<p className="text-sm leading-relaxed" style={{ color: "var(--bb-text-muted)" }}>
Звезда символ завершённости и сакрального порядка. Знак клиники символ жизни
и взаимодействия. Он ближе по духу к живой биоморфной форме (капли, клетки, лепестки),
чем к идеальной математической фигуре. Такой дизайн передаёт не «власть формы»,
а заботу, мягкость и человечность.
</p>
</div>
<div className="grid grid-cols-1 gap-8 md:grid-cols-2">
<LogoCard
src="/logo/logo-transparent.png"
alt="Основной логотип Клиника УХО ГОРЛО НОС им. проф. Е.Н. Оленевой"
label="Основной логотип"
description="Локальные версии по направлениям (ЛОР, аллергология и др.). Применяется в точках контакта с клиентами, на лендингах и сайтах направлений."
tag="Точки контакта с клиентом"
/>
<LogoCard
src="/logo/logo-transparent.png"
alt="Общий логотип сети клиник"
label="Общий логотип"
description="Версия сети клиник. Применяется для онлайн и оффлайн коммуникаций с клиентами, во внутренней документации. Допустимо на общем сайте."
tag="Сеть клиник · Документация · Сайт"
/>
</div>
</Section>
{/* 3. Цветовые варианты */}
{/* 2. Цветовые варианты */}
<Section
id="variants"
title="Цветовые варианты"
@ -364,7 +237,8 @@ export default function LogoPage() {
{/* 4. Минимальные размеры */}
<Section
id="sizes"
title="Размеры логотипа для размещения на форме сотрудников"
title="Минимальные размеры"
subtitle="Размеры логотипа для размещения на форме сотрудников."
>
<div className="overflow-hidden rounded-xl border" style={{ borderColor: "var(--bb-border)" }}>
<table className="w-full text-sm">
@ -429,55 +303,6 @@ export default function LogoPage() {
</div>
</Section>
{/* LLM-блок */}
<LlmBlock
path="/foundation/logo"
version="v1.0"
specText={LLM_LOGO_TEXT}
>
<LlmSection title="Файлы логотипа" />
<LlmTable
headers={["Файл", "Формат", "Путь", "Статус"]}
rows={[
["logo-transparent.png", "PNG с прозрачным фоном", "/public/logo/logo-transparent.png", "✓ Доступен"],
["logo.svg / logo.ai", "Вектор", "—", "Ожидается от дизайнера"],
]}
/>
<LlmSection title="Цветовые варианты" />
<LlmTable
headers={["Вариант", "Фон", "CSS-фильтр", "Применение"]}
rows={[
["Основной", "Светлый (#f8f9fa)", "нет", "Сайт, полиграфия на белом"],
["Инвертированный", "#5b7b87 (073M)", "brightness(0) invert(1)", "Хедер, тёмные секции"],
["На форме (беж)", "#c4a882 (081M)", "brightness(0) sepia(1) saturate(2) hue-rotate(330deg) brightness(0.45)", "Форма сотрудников (бежевый костюм)"],
["На форме (синий)", "#1b4c72 (050M)", "brightness(0) invert(1)", "Форма сотрудников (синий костюм)"],
]}
/>
<LlmSection title="Охранная зона и размеры на форме" />
<LlmTable
headers={["Носитель", "Условие", "Длина", "Высота", "Расположение"]}
rows={[
["Форма сотрудников", "Размер до 46 р.", "70 мм", "25,5 мм", "Левая сторона груди"],
["Форма сотрудников", "Размер от 48 р.", "90 мм", "32,8 мм", "Левая сторона груди"],
["Охранная зона", "Все носители", "≥ высота буквы «К»", "≥ высота буквы «К»", "Вокруг логотипа со всех сторон"],
]}
/>
<LlmSection title="Правила использования" />
<LlmRules
rules={[
{ ok: true, text: "Применять только одобренные цветовые варианты" },
{ ok: true, text: "Соблюдать охранную зону (≥ высота буквы «К»)" },
{ ok: true, text: "Использовать PNG с прозрачным фоном для digital" },
{ ok: true, text: "Белый логотип на тёмных фонах (073M, 050M, 080M)" },
{ ok: false, text: "Не изменять пропорции или искажать логотип" },
{ ok: false, text: "Не изменять цвета элементов логотипа" },
{ ok: false, text: "Не добавлять рядом произвольный текст" },
{ ok: false, text: "Не размещать на фоне без достаточного контраста" },
{ ok: false, text: "Не применять тени, обводки, градиенты" },
]}
/>
</LlmBlock>
</div>
);
}

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

@ -1,560 +0,0 @@
import type { Metadata } from "next";
import { LlmBlock, LlmSection, LlmTable, LlmRules } from "@/components/llm/LlmBlock";
export const metadata: Metadata = {
title: "Типографика. Цифровой брендбук Клиники ухо, горло, нос им. проф. Е.Н.Оленевой",
};
/* ─── Шкала типографики ────────────────────────────────────────────── */
const DIN_SCALE = [
{ token: "h1", size: "40px / 2.5rem", weight: "700", lh: "1.2", sample: "Клиника ухо, горло, нос" },
{ token: "h2", size: "32px / 2rem", weight: "700", lh: "1.25", sample: "Наши специалисты" },
{ token: "h3", size: "24px / 1.5rem", weight: "700", lh: "1.3", sample: "Запись на приём" },
{ token: "h4", size: "20px / 1.25rem",weight: "700", lh: "1.35", sample: "Расписание врачей" },
{ token: "h5", size: "16px / 1rem", weight: "700", lh: "1.4", sample: "Услуги и цены" },
{ token: "h6", size: "14px / 0.875rem",weight:"700", lh: "1.4", sample: "Контакты клиники" },
];
const FIRA_SCALE = [
{ token: "h1", size: "40px / 2.5rem", weight: "600", lh: "1.2", sample: "Клиника ухо, горло, нос" },
{ token: "h2", size: "32px / 2rem", weight: "600", lh: "1.25", sample: "Наши специалисты" },
{ token: "h3", size: "24px / 1.5rem", weight: "600", lh: "1.3", sample: "Запись на приём" },
{ token: "h4", size: "20px / 1.25rem", weight: "500", lh: "1.35", sample: "Расписание врачей" },
{ token: "h5", size: "16px / 1rem", weight: "500", lh: "1.4", sample: "Услуги и цены" },
{ token: "h6", size: "14px / 0.875rem",weight: "500", lh: "1.4", sample: "Контакты клиники" },
{ token: "body", size: "16px / 1rem", weight: "400", lh: "1.6", sample: "ЛОР-клиника предоставляет полный спектр услуг по диагностике и лечению заболеваний уха, горла и носа." },
{ token: "body-sm", size: "14px / 0.875rem",weight: "400", lh: "1.6", sample: "Запись по телефону или через форму на сайте. Работаем без выходных." },
{ token: "caption", size: "12px / 0.75rem", weight: "400", lh: "1.5", sample: "Лицензия № ЛО-77-01-018234 от 12.01.2022" },
{ token: "label", size: "12px / 0.75rem", weight: "500", lh: "1.4", sample: "СПЕЦИАЛИЗАЦИЯ ВРАЧА" },
{ token: "overline", size: "10px / 0.625rem",weight: "600", lh: "1.4", sample: "ФУНДАМЕНТ → 1.4" },
];
/* ─── LLM spec text ────────────────────────────────────────────────── */
const LLM_TYPOGRAPHY_TEXT = `# ТИПОГРАФИКА — LLM-СПЕЦИФИКАЦИЯ
# Клиника ухо, горло, нос им. проф. Е.Н.Оленевой
# docs/LLM_CONTEXT.md · /foundation/typography · v2.0 · 2026-03-22
ШРИФТЫ БРЕНДА
Шрифт | Тип | Применение | CSS
DINPro | Бренд | Оффлайн, физические носители (бейджи, таблички, транспорт, форма) | font-family: 'DINPro', Arial, sans-serif
Fira Sans| Веб | Сайт, цифровые материалы, брендбук | font-family: 'Fira Sans', sans-serif; Google Fonts; weights: 300/400/500/600
ПРАВИЛО ВЫБОРА: носитель цифровой Fira Sans; физический/печатный DINPro
DINPro ШКАЛА (оффлайн)
Стиль | font-size | rem | weight | line-height
h1 | 40px | 2.5rem | 700 | 1.20
h2 | 32px | 2rem | 700 | 1.25
h3 | 24px | 1.5rem | 700 | 1.30
h4 | 20px | 1.25rem | 700 | 1.35
h5 | 16px | 1rem | 700 | 1.40
h6 | 14px | 0.875rem | 700 | 1.40
Fira Sans ШКАЛА (веб)
Стиль | font-size | rem | weight | line-height | letter-spacing
h1 | 40px | 2.5rem | 600 | 1.20 | -0.025em
h2 | 32px | 2rem | 600 | 1.25 | -0.020em
h3 | 24px | 1.5rem | 600 | 1.30 | -0.010em
h4 | 20px | 1.25rem | 500 | 1.35 | 0em
h5 | 16px | 1rem | 500 | 1.40 | 0em
h6 | 14px | 0.875rem | 500 | 1.40 | +0.010em
body | 16px | 1rem | 400 | 1.60 | 0em
body-sm | 14px | 0.875rem | 400 | 1.60 | 0em
caption | 12px | 0.75rem | 400 | 1.50 | +0.020em
label | 12px | 0.75rem | 500 | 1.40 | +0.030em
overline | 10px | 0.625rem | 600 | 1.40 | +0.100em (uppercase)
ПРИМЕНЕНИЕ ПО НОСИТЕЛЮ
Носитель | Шрифт
Сайт, цифровые материалы, брендбук | Fira Sans
Форма сотрудников, бейджи | DINPro
Вывески, таблички, навигация | DINPro
Брендирование транспорта | DINPro
Визитки, листовки, полиграфия | DINPro
Telegram-бот, пуш-уведомления | Fira Sans (системный)
ПРАВИЛА
H1 только один на странице
Не пропускать уровни заголовков (h1 h2 h3)
Минимальный размер текста на экране: 12px
Кириллица Fira Sans (не DINPro)
Fira Sans: доступные веса 300 / 400 / 500 / 600
DINPro на сайте без явного согласования дизайнера
Light (300) для текста < 14px
Смешивать DINPro и Fira Sans на одном носителе`.trim();
/* ─── Компоненты ───────────────────────────────────────────────────── */
function Section({
title,
subtitle,
children,
}: {
title: string;
subtitle?: string;
children: React.ReactNode;
}) {
return (
<section className="mb-14">
<div className="mb-6">
<h2 className="text-xl font-semibold" style={{ color: "var(--bb-text)" }}>
{title}
</h2>
{subtitle && (
<p className="mt-1 text-sm" style={{ color: "var(--bb-text-muted)" }}>
{subtitle}
</p>
)}
</div>
{children}
</section>
);
}
function UsageBadge({ label, color }: { label: string; color: string }) {
return (
<span
className="inline-block px-2 py-0.5 rounded text-xs font-medium"
style={{ background: color === "teal" ? "#e0f5f4" : "#fef3c7", color: color === "teal" ? "var(--brand-073m)" : "#92400e" }}
>
{label}
</span>
);
}
function TypeRow({
token,
size,
weight,
lh,
sample,
fontFamily,
}: {
token: string;
size: string;
weight: string;
lh: string;
sample: string;
fontFamily: string;
}) {
const [sizeVal] = size.split(" / ");
const pxSize = parseInt(sizeVal);
return (
<div
className="border-t py-4 grid gap-4"
style={{
borderColor: "var(--bb-border)",
gridTemplateColumns: "80px 1fr 140px",
alignItems: "start",
}}
>
{/* Токен */}
<div className="pt-1">
<span
className="font-mono text-xs px-2 py-0.5 rounded"
style={{ background: "#f3f4f6", color: "#374151" }}
>
{token}
</span>
</div>
{/* Образец */}
<div
style={{
fontFamily,
fontSize: `${Math.min(pxSize, 28)}px`,
fontWeight: weight,
lineHeight: lh,
color: "var(--bb-text)",
wordBreak: "break-word",
}}
>
{sample}
</div>
{/* Метаданные */}
<div className="pt-1 space-y-1 text-right">
<p className="font-mono text-xs" style={{ color: "var(--bb-text-muted)" }}>
{size}
</p>
<p className="font-mono text-xs" style={{ color: "var(--bb-text-muted)" }}>
w{weight} · lh{lh}
</p>
</div>
</div>
);
}
/* ─── Страница ─────────────────────────────────────────────────────── */
export default function TypographyPage() {
return (
<div className="max-w-4xl mx-auto px-8 py-10">
{/* Заголовок */}
<div className="mb-10 pb-6 border-b" style={{ borderColor: "var(--bb-border)" }}>
<p
className="text-xs font-semibold uppercase tracking-widest mb-2"
style={{ color: "var(--brand-053m)" }}
>
Фундамент 1.4
</p>
<h1 className="text-3xl font-semibold mb-3" style={{ color: "var(--bb-text)" }}>
Типографика
</h1>
<p className="text-base max-w-2xl" style={{ color: "var(--bb-text-muted)" }}>
Бренд использует два шрифта с чётким разделением по контексту применения.
Никогда не смешивайте их в одном носителе без необходимости.
</p>
</div>
{/* Карточки шрифтов */}
<section className="mb-12">
<h2 className="text-xl font-semibold mb-6" style={{ color: "var(--bb-text)" }}>
Шрифты бренда
</h2>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{/* DINPro */}
<div
className="rounded-xl border p-6"
style={{ borderColor: "var(--bb-border)" }}
>
<div className="flex items-start justify-between mb-3">
<div>
<p className="font-semibold text-base" style={{ color: "var(--bb-text)" }}>
DINPro
</p>
<p className="text-xs mt-0.5" style={{ color: "var(--bb-text-muted)" }}>
Бренд-шрифт · не Google Fonts
</p>
</div>
<UsageBadge label="Оффлайн · Бренд" color="yellow" />
</div>
<div
className="text-4xl mb-4 font-bold"
style={{ fontFamily: "'DINPro', Arial, sans-serif", color: "var(--bb-text)" }}
>
Aa Бб Вв
</div>
<p className="text-sm" style={{ color: "var(--bb-text-muted)" }}>
Применяется на: форме сотрудников, бейджах, вывесках, транспорте,
полиграфии. Лицензионный шрифт передаётся дизайнером.
</p>
<p className="text-xs mt-3 font-mono px-2 py-1 rounded inline-block"
style={{ background: "#f3f4f6", color: "#374151" }}>
--font-brand
</p>
</div>
{/* Fira Sans */}
<div
className="rounded-xl border p-6"
style={{ borderColor: "var(--bb-border)" }}
>
<div className="flex items-start justify-between mb-3">
<div>
<p className="font-semibold text-base" style={{ color: "var(--bb-text)" }}>
Fira Sans
</p>
<p className="text-xs mt-0.5" style={{ color: "var(--bb-text-muted)" }}>
Веб-шрифт · Google Fonts
</p>
</div>
<UsageBadge label="Веб · Цифровой" color="teal" />
</div>
<div
className="text-4xl mb-4 font-semibold"
style={{ fontFamily: "var(--font-fira-sans)", color: "var(--bb-text)" }}
>
Aa Бб Вв
</div>
<p className="text-sm" style={{ color: "var(--bb-text-muted)" }}>
Применяется на: сайте oclinica.ru, в цифровом брендбуке, онлайн-материалах.
Подключён через next/font/google (кириллица + латиница).
</p>
<p className="text-xs mt-3 font-mono px-2 py-1 rounded inline-block"
style={{ background: "#f3f4f6", color: "#374151" }}>
--font-web
</p>
</div>
</div>
</section>
{/* Таблица применения */}
<section className="mb-12">
<h2 className="text-xl font-semibold mb-4" style={{ color: "var(--bb-text)" }}>
Правило применения
</h2>
<div
className="overflow-hidden rounded-xl border"
style={{ borderColor: "var(--bb-border)" }}
>
<table className="w-full text-sm">
<thead>
<tr style={{ background: "var(--bb-sidebar-bg)" }}>
{["Носитель", "Шрифт"].map(h => (
<th
key={h}
className="text-left px-5 py-3 font-medium"
style={{ color: "var(--bb-text-muted)" }}
>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{[
["Сайт, цифровые материалы, брендбук", "Fira Sans"],
["Форма сотрудников, бейджи", "DINPro"],
["Вывески, таблички, навигация", "DINPro"],
["Брендирование транспорта", "DINPro"],
["Визитки, листовки, полиграфия", "DINPro"],
["Telegram-бот, пуш-уведомления", "Fira Sans (системный)"],
].map(([media, font]) => (
<tr
key={media}
className="border-t"
style={{ borderColor: "var(--bb-border)" }}
>
<td className="px-5 py-3" style={{ color: "var(--bb-text)" }}>{media}</td>
<td className="px-5 py-3 font-medium" style={{ color: "var(--brand-073m)" }}>
{font}
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
{/* DINPro Scale */}
<Section
title="DINPro — шкала заголовков"
subtitle="Бренд-шрифт. Используется в оффлайн-носителях. Отображается с системным фоллбэком Arial, если шрифт не установлен."
>
<div>
{DIN_SCALE.map(row => (
<TypeRow
key={row.token}
{...row}
fontFamily="'DINPro', 'DIN Pro', Arial, sans-serif"
/>
))}
</div>
</Section>
{/* Fira Sans Scale */}
<Section
title="Fira Sans — полная шкала"
subtitle="Веб-шрифт. Используется на сайте и в цифровых материалах. Загружается через Google Fonts."
>
<div>
{FIRA_SCALE.map(row => (
<TypeRow
key={row.token}
{...row}
fontFamily="var(--font-fira-sans, 'Fira Sans', sans-serif)"
/>
))}
</div>
</Section>
{/* Живой пример */}
<Section
title="Живой пример — блок контента"
subtitle="Типичная комбинация заголовка и тела текста на сайте."
>
<div
className="rounded-xl border p-8"
style={{
borderColor: "var(--bb-border)",
background: "var(--bb-sidebar-bg)",
fontFamily: "var(--font-fira-sans, 'Fira Sans', sans-serif)",
}}
>
<p
className="text-xs font-semibold uppercase tracking-widest mb-3"
style={{ color: "var(--brand-053m)", letterSpacing: "0.1em" }}
>
Наши специалисты
</p>
<h2
className="mb-4"
style={{ fontSize: 28, fontWeight: 600, lineHeight: 1.3, color: "var(--bb-text)" }}
>
Запись к ЛОР-врачу<br />без очереди и ожидания
</h2>
<p
className="mb-4 max-w-xl"
style={{ fontSize: 16, fontWeight: 400, lineHeight: 1.6, color: "var(--bb-text-muted)" }}
>
В нашей клинике работают специалисты высшей категории с опытом от 15 лет.
Диагностика и лечение заболеваний уха, горла, носа и смежных органов.
</p>
<p
style={{ fontSize: 12, fontWeight: 400, lineHeight: 1.5, color: "#9ca3af" }}
>
Лицензия ЛО-77-01-018234 · Москва, ул. Примерная, д. 1
</p>
</div>
</Section>
{/* Типографика сайта — фактическое состояние 23.03.2026 */}
<Section
title="Типографика сайта — факт на 23.03.2026"
subtitle="Реальные размеры и стили, задокументированные по perm.oclinica.ru/lor. Источник: скриншот + CSS темы clinic_bootstrap_mobile."
>
<div
className="overflow-hidden rounded-xl border"
style={{ borderColor: "var(--bb-border)" }}
>
<table className="w-full text-sm">
<thead>
<tr style={{ background: "var(--bb-sidebar-bg)" }}>
{["Элемент / блок", "Шрифт", "Размер", "Weight", "Цвет", "Пример"].map(h => (
<th key={h} className="text-left px-4 py-3 font-medium text-xs"
style={{ color: "var(--bb-text-muted)" }}>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{[
["H1 страницы (над Hero)", "Fira Sans", "36px", "700", "#cb9768", "ЛОР Клиника ухо, горло, нос…"],
["H2 секции", "Fira Sans", "36px", "700", "#000000", "Приём ведут опытные ЛОР врачи"],
["Заголовок Hero-баннера", "Fira Sans", "22px", "700", "#333333", "ЭНДОСКОПИЧЕСКОЕ ХИРУРГИЧЕСКОЕ…"],
["Статистика (цифра+текст)", "Fira Sans", "~18px", "700", "#60959c", "Ежедневно 27 ЛОР врачей…"],
["Подзаголовок секции", "Fira Sans", "~14px", "400", "#374151", "Фундаментальная теоретическая…"],
["Пункт checklist (ключ)", "Fira Sans", "13px", "700", "#111827", "БЕЗОПАСНО"],
["Пункт checklist (описание)", "Fira Sans", "13px", "400", "#374151", "оперируют хирурги с 15-летним…"],
["Имя врача в карточке", "Fira Sans", "12px", "500", "#60959c", "Макарова Людмила Германовна"],
["Специализация врача", "Fira Sans", "11px", "400", "#374151", "ЛОР врач, сурдолог"],
].map(([el, font, size, w, color, example]) => (
<tr key={el} className="border-t" style={{ borderColor: "var(--bb-border)" }}>
<td className="px-4 py-3 font-medium text-xs" style={{ color: "var(--bb-text)" }}>{el}</td>
<td className="px-4 py-3 text-xs font-mono" style={{ color: "var(--brand-073m)" }}>{font}</td>
<td className="px-4 py-3 text-xs font-mono" style={{ color: "var(--bb-text)" }}>{size}</td>
<td className="px-4 py-3 text-xs font-mono" style={{ color: "var(--bb-text-muted)" }}>{w}</td>
<td className="px-4 py-3">
<span className="flex items-center gap-1.5">
<span className="w-3 h-3 rounded-sm inline-block border shrink-0"
style={{ background: color, borderColor: "var(--bb-border)" }} />
<span className="font-mono text-xs" style={{ color: "var(--bb-text-muted)" }}>{color}</span>
</span>
</td>
<td className="px-4 py-3 text-xs" style={{ color: "var(--bb-text-muted)" }}>{example}</td>
</tr>
))}
</tbody>
</table>
</div>
</Section>
{/* LLM-спецификация */}
<section className="mb-8">
<div className="mb-6">
<h2 className="text-xl font-semibold" style={{ color: "var(--bb-text)" }}>
LLM-спецификация
</h2>
<p className="mt-1 text-sm" style={{ color: "var(--bb-text-muted)" }}>
Машиночитаемые данные раздела для использования AI-ассистентами при разработке
дизайна, макетов и кода. Нажмите «Скопировать» чтобы получить полный текст.
</p>
</div>
<LlmBlock
path="/foundation/typography"
version="v2.0"
specText={LLM_TYPOGRAPHY_TEXT}
>
{/* Шрифты */}
<div className="space-y-2">
<LlmSection title="Шрифты бренда" />
<LlmTable
headers={["Шрифт", "Тип", "Применение", "CSS font-family"]}
rows={[
[
<span key="d" style={{ color: "var(--bb-text)", fontWeight: 600 }}>DINPro</span>,
"Бренд",
"Оффлайн, физические носители (бейджи, таблички, транспорт, форма)",
"'DINPro', Arial, sans-serif",
],
[
<span key="f" style={{ color: "var(--bb-text)", fontWeight: 600 }}>Fira Sans</span>,
"Веб",
"Сайт, цифровые материалы, брендбук",
"'Fira Sans', sans-serif · Google Fonts · 300/400/500/600",
],
]}
/>
</div>
{/* DINPro шкала */}
<div className="space-y-2">
<LlmSection title="DINPro — шкала (оффлайн)" />
<LlmTable
headers={["Стиль", "font-size", "rem", "weight", "line-height"]}
rows={DIN_SCALE.map(r => {
const [px, rem] = r.size.split(" / ");
return [r.token, px, rem, r.weight, r.lh];
})}
/>
</div>
{/* Fira Sans шкала */}
<div className="space-y-2">
<LlmSection title="Fira Sans — шкала (веб)" />
<LlmTable
headers={["Стиль", "font-size", "rem", "weight", "line-height", "letter-spacing"]}
rows={[
["h1", "40px", "2.5rem", "600", "1.20", "-0.025em"],
["h2", "32px", "2rem", "600", "1.25", "-0.020em"],
["h3", "24px", "1.5rem", "600", "1.30", "-0.010em"],
["h4", "20px", "1.25rem", "500", "1.35", "0em"],
["h5", "16px", "1rem", "500", "1.40", "0em"],
["h6", "14px", "0.875rem", "500", "1.40", "+0.010em"],
["body", "16px", "1rem", "400", "1.60", "0em"],
["body-sm", "14px", "0.875rem", "400", "1.60", "0em"],
["caption", "12px", "0.75rem", "400", "1.50", "+0.020em"],
["label", "12px", "0.75rem", "500", "1.40", "+0.030em"],
["overline", "10px", "0.625rem", "600", "1.40", "+0.100em"],
]}
/>
</div>
{/* Применение */}
<div className="space-y-2">
<LlmSection title="Применение по носителю" />
<LlmTable
headers={["Носитель", "Шрифт"]}
rows={[
["Сайт, цифровые материалы, брендбук", <span key="fs" style={{ color: "var(--brand-073m)" }}>Fira Sans</span>],
["Форма сотрудников, бейджи", <span key="d1" style={{ color: "var(--brand-073m)" }}>DINPro</span>],
["Вывески, таблички, навигация", <span key="d2" style={{ color: "var(--brand-073m)" }}>DINPro</span>],
["Брендирование транспорта", <span key="d3" style={{ color: "var(--brand-073m)" }}>DINPro</span>],
["Визитки, листовки, полиграфия", <span key="d4" style={{ color: "var(--brand-073m)" }}>DINPro</span>],
["Telegram-бот, пуш-уведомления", <span key="fs2" style={{ color: "var(--brand-073m)" }}>Fira Sans (системный)</span>],
]}
/>
</div>
{/* Правила */}
<div className="space-y-2">
<LlmSection title="Правила применения" />
<LlmRules rules={[
{ ok: true, text: "H1 — только один на странице" },
{ ok: true, text: "Не пропускать уровни заголовков (h1 → h2 → h3)" },
{ ok: true, text: "Минимальный размер текста на экране: 12px" },
{ ok: true, text: "Кириллица → Fira Sans (не DINPro)" },
{ ok: true, text: "Fira Sans: доступные веса 300 / 400 / 500 / 600" },
{ ok: false, text: "DINPro на сайте без явного согласования дизайнера" },
{ ok: false, text: "Light (300) для текста < 14px" },
{ ok: false, text: "Смешивать DINPro и Fira Sans на одном носителе" },
]} />
</div>
</LlmBlock>
</section>
</div>
);
}

180
apps/web/app/globals.css

@ -3,13 +3,13 @@
/* ─── Бренд-токены О!Клиника ─────────────────────────────────────────── */
/* Цвета уточняются в Sprint 2 по таблице Oracal */
:root {
/* Фирменные цвета — точные RGB из каталога Oracal */
--brand-053m: #0089c3; /* Основной бирюзовый (Oracal 053M) · rgb(0,137,195) */
--brand-073m: #53514e; /* Тёмный серо-голубой (Oracal 073M) · rgb(83,81,78) */
--brand-066m: #00818c; /* Средний бирюзовый (Oracal 066M) · rgb(0,129,140) */
--brand-050m: #1b2e5d; /* Тёмно-синий, наружная реклама (Oracal 050M) · rgb(27,46,93) */
--brand-081m: #a8885c; /* Бежевый (Oracal 081M) · rgb(168,136,92) */
--brand-080m: #432f1e; /* Тёмно-коричневый (Oracal 080M) · rgb(67,47,30) */
/* Фирменные цвета (приблизительно — уточнить в Sprint 2) */
--brand-053m: #7ecfca; /* Основной бирюзовый (Oracal 053M) */
--brand-073m: #5b7b87; /* Тёмный серо-голубой (Oracal 073M) */
--brand-066m: #5bb5ad; /* Средний бирюзовый (Oracal 066M) */
--brand-050m: #1b4c72; /* Тёмно-синий, наружная реклама (Oracal 050M) */
--brand-081m: #c4a882; /* Бежевый (Oracal 081M) */
--brand-080m: #5c2e0e; /* Тёмно-коричневый (Oracal 080M) */
--brand-white: #ffffff;
/* UI-цвета брендбука */
@ -17,7 +17,7 @@
--bb-sidebar-border: #e5e7eb;
--bb-sidebar-text: #374151;
--bb-sidebar-text-muted: #6b7280;
--bb-sidebar-active-bg: #dff0fa;
--bb-sidebar-active-bg: #e0f5f4;
--bb-sidebar-active-text: var(--brand-053m);
--bb-sidebar-section: #9ca3af;
--bb-content-bg: #ffffff;
@ -42,167 +42,3 @@ body {
color: var(--bb-text);
-webkit-font-smoothing: antialiased;
}
/* ─── Анимации ───────────────────────────────────────────────── */
@keyframes bb-spin {
to { transform: rotate(360deg); }
}
/* ─── Кнопки (Sprint 3) ──────────────────────────────────────── */
.bb-btn {
font-family: var(--font-web);
font-weight: 500;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
cursor: pointer;
transition: filter 0.15s, opacity 0.15s;
white-space: nowrap;
line-height: 1;
text-decoration: none;
-webkit-font-smoothing: antialiased;
}
.bb-btn:hover:not(:disabled) { filter: brightness(0.9); }
.bb-btn:active:not(:disabled) { filter: brightness(0.82); }
.bb-btn:disabled { cursor: not-allowed; opacity: 0.5; }
.bb-btn:focus-visible { outline: 2px solid var(--brand-053m); outline-offset: 2px; }
/* Размеры — только padding и font-size, radius задаётся вариантом */
.bb-btn-sm { font-size: 13px; padding: 4px 11px; border: 1px solid transparent; }
.bb-btn-md { font-size: 14px; padding: 8px 16px; border: 1px solid transparent; }
.bb-btn-lg { font-size: 18px; padding: 10px 24px; border: 1px solid transparent; font-weight: bold; }
/* Варианты — цвета и радиус по реальному сайту oclinica.ru */
/* primary — коралловая форм-кнопка «Запишите меня!» — #FFA39C */
.bb-btn-primary {
background: #FFA39C;
color: #fff;
border-color: #FF847B;
border-radius: 4px;
font-weight: 700;
box-shadow: 0px 0px 5px rgba(0,0,0,0.4), 0px 3px 5px rgba(0,0,0,0.25);
}
/* outline — белая с бежевой рамкой «Записаться на прием» */
.bb-btn-outline {
background: #fff;
color: #BF9975;
border-color: #BF9975;
border-radius: 7px;
}
/* teal — бирюзовая «Позвонить» */
.bb-btn-teal {
background: #60959c;
color: #fff;
border-color: transparent;
border-radius: 7px;
}
/* pill — кремовая таблетка «Заказать звонок» */
.bb-btn-pill {
background: #e9e4d4;
color: #333;
border-color: #d5cfbd;
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,
.bb-select {
font-family: var(--font-web);
font-size: 14px;
color: var(--bb-text);
background: #fff;
border: 1px solid #ccc;
border-radius: 4px;
padding: 10px 12px;
width: 100%;
transition: border-color 0.15s, box-shadow 0.15s;
outline: none;
-webkit-font-smoothing: antialiased;
}
.bb-input { height: 50px; }
.bb-input:focus,
.bb-textarea:focus,
.bb-select:focus {
border-color: var(--brand-053m);
box-shadow: 0 0 0 3px rgba(0, 137, 195, 0.2);
}
.bb-input.bb-error,
.bb-textarea.bb-error,
.bb-select.bb-error { border-color: #dc2626; }
.bb-input.bb-error:focus,
.bb-textarea.bb-error:focus,
.bb-select.bb-error:focus { box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.15); }
.bb-input:disabled,
.bb-textarea:disabled,
.bb-select:disabled { opacity: 0.5; cursor: not-allowed; background: var(--bb-sidebar-bg); }
.bb-textarea { resize: vertical; min-height: 100px; }
.bb-select {
cursor: pointer;
appearance: none;
height: 50px;
padding: 10px 36px 10px 10px;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%236b7280' stroke-width='2'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 10px center;
}
.bb-checkbox,
.bb-radio {
width: 16px;
height: 16px;
cursor: pointer;
accent-color: var(--brand-073m);
flex-shrink: 0;
}
/* ─── Карточки (Sprint 4) ────────────────────────────────────── */
.bb-news-card:hover {
background: #eef4d1 !important;
box-shadow: 0 0 16px 0 #9e9e9a;
}
.bb-service-card:hover { box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
/* ─── Навигация сайта (Sprint 5) ────────────────────────────── */
.bb-nav-link:hover { color: var(--brand-053m) !important; }
/* ─── Тумблер (Sprint 3) ──────────────────────────────────────── */
.bb-toggle-track {
display: inline-flex;
align-items: center;
width: 44px;
height: 24px;
border-radius: 12px;
padding: 2px;
cursor: pointer;
transition: background 0.2s;
flex-shrink: 0;
}
.bb-toggle-thumb {
width: 20px;
height: 20px;
border-radius: 50%;
background: #fff;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25);
transition: transform 0.2s;
}

205
apps/web/app/offline/badges/page.tsx

@ -1,205 +0,0 @@
import type { Metadata } from "next";
import Image from "next/image";
export const metadata: Metadata = {
title: "Бейджи | Брендбук О!Клиника",
};
function Section({
title,
subtitle,
children,
}: {
title: string;
subtitle?: string;
children: React.ReactNode;
}) {
return (
<section className="mb-12">
<div className="mb-6">
<h2 className="text-xl font-semibold" style={{ color: "var(--bb-text)" }}>
{title}
</h2>
{subtitle && (
<p className="mt-1 text-sm" style={{ color: "var(--bb-text-muted)" }}>
{subtitle}
</p>
)}
</div>
{children}
</section>
);
}
export default function BadgesPage() {
return (
<div className="max-w-4xl mx-auto px-8 py-10">
{/* Заголовок */}
<div className="mb-10 pb-6 border-b" style={{ borderColor: "var(--bb-border)" }}>
<p
className="text-xs font-semibold uppercase tracking-widest mb-2"
style={{ color: "var(--brand-053m)" }}
>
Оффлайн элементы 6.2
</p>
<h1 className="text-3xl font-semibold mb-3" style={{ color: "var(--bb-text)" }}>
Бейджи сотрудников
</h1>
<p className="text-base max-w-2xl" style={{ color: "var(--bb-text-muted)" }}>
Именные бейджи для идентификации сотрудников клиники. Размер 70×30 мм,
магнитное крепление. Белый фон, чёрный текст.
</p>
</div>
{/* Фотографии */}
<Section
title="Образцы бейджей"
subtitle="Фотографии реальных бейджей из брендбука клиники."
>
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
{/* Лицевая сторона */}
<div>
<div
className="rounded-xl overflow-hidden border mb-4"
style={{ borderColor: "var(--bb-border)" }}
>
<Image
src="/offline/badges/badge-2.jpeg"
alt="Лицевая сторона бейджа: Лебединская Елена Александровна, врач оториноларинголог"
width={690}
height={347}
className="w-full object-cover"
/>
</div>
<p className="font-medium text-sm mb-1" style={{ color: "var(--bb-text)" }}>
Лицевая сторона
</p>
<p className="text-sm" style={{ color: "var(--bb-text-muted)" }}>
Белый фон, скруглённые углы. ФИО крупный шрифт,
должности мелкий. Металлическая рамка.
</p>
</div>
{/* Оборотная сторона */}
<div>
<div
className="rounded-xl overflow-hidden border mb-4"
style={{ borderColor: "var(--bb-border)" }}
>
<Image
src="/offline/badges/badge-1.jpeg"
alt="Оборотная сторона бейджа с магнитным креплением"
width={657}
height={369}
className="w-full object-cover"
/>
</div>
<p className="font-medium text-sm mb-1" style={{ color: "var(--bb-text)" }}>
Оборотная сторона
</p>
<p className="text-sm" style={{ color: "var(--bb-text-muted)" }}>
Чёрный пластик, магнитное крепление (CAUTION MAGNETIC).
Не требует проколов в одежде.
</p>
</div>
</div>
</Section>
{/* Технические параметры */}
<Section
title="Технические параметры"
subtitle="Единый стандарт для всех сотрудников клиники."
>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4 mb-6">
{[
{ label: "Ширина", value: "70 мм" },
{ label: "Высота", value: "30 мм" },
{ label: "Материал", value: "Металл / ПВХ" },
{ label: "Крепление", value: "Магнитное" },
].map(({ label, value }) => (
<div
key={label}
className="rounded-xl border p-4 text-center"
style={{ borderColor: "var(--bb-border)", background: "var(--bb-sidebar-bg)" }}
>
<p className="font-mono text-lg font-semibold" style={{ color: "var(--brand-073m)" }}>
{value}
</p>
<p className="text-xs mt-1" style={{ color: "var(--bb-text-muted)" }}>
{label}
</p>
</div>
))}
</div>
</Section>
{/* Состав текста */}
<Section
title="Состав текста на бейдже"
subtitle="Строгий порядок элементов."
>
<div
className="overflow-hidden rounded-xl border"
style={{ borderColor: "var(--bb-border)" }}
>
<table className="w-full text-sm">
<thead>
<tr style={{ background: "var(--bb-sidebar-bg)" }}>
{["Элемент", "Содержание", "Оформление"].map(h => (
<th
key={h}
className="text-left px-5 py-3 font-medium"
style={{ color: "var(--bb-text-muted)" }}
>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{[
["ФИО", "Фамилия Имя Отчество", "Крупный шрифт, первая строка"],
["Должность", "Основная должность", "Мелкий шрифт, вторая строка"],
["Специализация", "Специализация / учёная степень (если есть)", "Мелкий шрифт, третья строка"],
].map(([el, content, style]) => (
<tr
key={el}
className="border-t"
style={{ borderColor: "var(--bb-border)" }}
>
<td className="px-5 py-3 font-medium" style={{ color: "var(--bb-text)" }}>{el}</td>
<td className="px-5 py-3" style={{ color: "var(--bb-text-muted)" }}>{content}</td>
<td className="px-5 py-3" style={{ color: "var(--bb-text-muted)" }}>{style}</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Пример из фото */}
<div
className="mt-4 rounded-xl border p-4 flex items-start gap-3"
style={{ borderColor: "var(--bb-border)", background: "var(--bb-sidebar-bg)" }}
>
<div
className="w-1.5 self-stretch rounded-full shrink-0"
style={{ background: "var(--brand-053m)" }}
/>
<div>
<p className="text-xs font-medium mb-1" style={{ color: "var(--bb-text-muted)" }}>
Пример из брендбука
</p>
<p className="text-sm font-semibold" style={{ color: "var(--bb-text)" }}>
Лебединская Елена Александровна
</p>
<p className="text-xs mt-0.5" style={{ color: "var(--bb-text-muted)" }}>
врач оториноларинголог · ведущий хирург · кандидат медицинских наук
</p>
</div>
</div>
</Section>
</div>
);
}

243
apps/web/app/offline/navigation/page.tsx

@ -1,243 +0,0 @@
import type { Metadata } from "next";
import Image from "next/image";
export const metadata: Metadata = {
title: "Внутренняя навигация. Цифровой брендбук Клиники ухо, горло, нос им. проф. Е.Н.Оленевой",
};
function Section({
title,
subtitle,
children,
}: {
title: string;
subtitle?: string;
children: React.ReactNode;
}) {
return (
<section className="mb-12">
<div className="mb-6">
<h2 className="text-xl font-semibold" style={{ color: "var(--bb-text)" }}>
{title}
</h2>
{subtitle && (
<p className="mt-1 text-sm" style={{ color: "var(--bb-text-muted)" }}>
{subtitle}
</p>
)}
</div>
{children}
</section>
);
}
export default function NavigationPage() {
return (
<div className="max-w-4xl mx-auto px-8 py-10">
{/* Заголовок */}
<div className="mb-10 pb-6 border-b" style={{ borderColor: "var(--bb-border)" }}>
<p
className="text-xs font-semibold uppercase tracking-widest mb-2"
style={{ color: "var(--brand-053m)" }}
>
Оффлайн элементы 6.3
</p>
<h1 className="text-3xl font-semibold mb-3" style={{ color: "var(--bb-text)" }}>
Внутренняя навигация
</h1>
<p className="text-base max-w-2xl" style={{ color: "var(--bb-text-muted)" }}>
Система навигационных табличек и указателей внутри клиники.
На оргстекле, наклейки из плёнок Оракл 053M и 073M.
</p>
</div>
{/* Макеты таблечек */}
<Section
title="Макеты навигационных табличек"
subtitle="Два типа: табличка кабинета с бирюзовым заголовком и карточка врача с логотипом и QR-кодом."
>
<div className="max-w-lg">
<div
className="rounded-xl overflow-hidden border mb-4"
style={{ borderColor: "var(--bb-border)" }}
>
<Image
src="/offline/navigation/nav-mockup-signs.jpeg"
alt="Макеты навигационных табличек: Кабинет 04 с бирюзовым заголовком и карточка врача Лебединской"
width={620}
height={570}
className="w-full object-cover"
/>
</div>
<p className="font-medium text-sm mb-1" style={{ color: "var(--bb-text)" }}>
Макет из брендбука
</p>
<p className="text-sm" style={{ color: "var(--bb-text-muted)" }}>
Табличка «Кабинет 04»: бирюзовый заголовок (Oracal 053M), специализация и ФИО врачей.
Карточка врача: логотип клиники, имя, должности, QR-код на страницу врача.
</p>
</div>
</Section>
{/* Нумерация дверей */}
<Section
title="Нумерация кабинетов на дверях"
subtitle="Номер кабинета размещается непосредственно на двери — крупный шрифт, плёнка Oracal."
>
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
<div>
<div
className="rounded-xl overflow-hidden border mb-4"
style={{ borderColor: "var(--bb-border)" }}
>
<Image
src="/offline/navigation/nav-door-31.png"
alt="Белая дверь кабинета 31 с крупным номером из плёнки"
width={770}
height={963}
className="w-full object-cover"
style={{ maxHeight: 420, objectPosition: "top" }}
/>
</div>
<p className="font-medium text-sm mb-1" style={{ color: "var(--bb-text)" }}>
Кабинет 31 белая дверь
</p>
<p className="text-sm" style={{ color: "var(--bb-text-muted)" }}>
Крупный номер в верхней части двери. Тёмная плёнка на белом фоне.
</p>
</div>
<div>
<div
className="rounded-xl overflow-hidden border mb-4"
style={{ borderColor: "var(--bb-border)" }}
>
<Image
src="/offline/navigation/nav-3.jpeg"
alt="Серая дверь кабинета 13 с крупным номером"
width={800}
height={463}
className="w-full object-cover"
style={{ maxHeight: 420, objectPosition: "left" }}
/>
</div>
<p className="font-medium text-sm mb-1" style={{ color: "var(--bb-text)" }}>
Кабинет 13 серая дверь
</p>
<p className="text-sm" style={{ color: "var(--bb-text-muted)" }}>
Тёмная дверь с крупным номером. Рядом табличка кабинета на стене.
</p>
</div>
</div>
</Section>
{/* Указатели по этажам */}
<Section
title="Указатели по этажам"
subtitle="Навигационные панели в холлах — показывают расположение кабинетов и специализации на каждом этаже."
>
<div
className="rounded-xl overflow-hidden border"
style={{ borderColor: "var(--bb-border)" }}
>
<Image
src="/offline/navigation/nav-render-p13.jpeg"
alt="Навигационные панели по этажам: кабинеты 01-06, 21-25, 31-37, 41-45"
width={2105}
height={1489}
className="w-full"
/>
</div>
<p className="mt-3 text-sm" style={{ color: "var(--bb-text-muted)" }}>
Слева указатель с этажом и направлением, справа панель с полным перечнем кабинетов.
Активный этаж выделяется бирюзовым фоном (Oracal 053M).
</p>
</Section>
{/* Технические требования */}
<Section
title="Технические требования"
subtitle="Материалы и цвета для производства."
>
<div
className="overflow-hidden rounded-xl border"
style={{ borderColor: "var(--bb-border)" }}
>
<table className="w-full text-sm">
<thead>
<tr style={{ background: "var(--bb-sidebar-bg)" }}>
{["Параметр", "Значение"].map(h => (
<th
key={h}
className="text-left px-5 py-3 font-medium"
style={{ color: "var(--bb-text-muted)" }}
>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{[
["Основной материал", "Оргстекло"],
["Покрытие", "Наклейка из плёнок Oracal"],
["Шрифт", "DINPro Bold / Regular"],
["Крепление", "Дистанционные держатели"],
].map(([param, value]) => (
<tr
key={param}
className="border-t"
style={{ borderColor: "var(--bb-border)" }}
>
<td className="px-5 py-3 font-medium" style={{ color: "var(--bb-text)" }}>
{param}
</td>
<td className="px-5 py-3" style={{ color: "var(--bb-text-muted)" }}>
{value}
</td>
</tr>
))}
</tbody>
</table>
</div>
</Section>
{/* Цвета Oracal */}
<Section
title="Цвета Oracal для навигации"
subtitle="Допустимые цвета плёнки по коду Oracal."
>
<div className="flex gap-6">
{[
{ code: "053M", hex: "#7ecfca", name: "Заголовок таблички / активный этаж" },
{ code: "073M", hex: "#5b7b87", name: "Дополнительный акцент" },
].map(c => (
<div
key={c.code}
className="flex items-center gap-4 rounded-xl border p-4"
style={{ borderColor: "var(--bb-border)", background: "var(--bb-sidebar-bg)" }}
>
<div
className="w-12 h-12 rounded-lg shrink-0"
style={{ background: c.hex }}
/>
<div>
<p className="font-medium text-sm" style={{ color: "var(--bb-text)" }}>
Oracal {c.code}
</p>
<p className="text-xs mt-0.5" style={{ color: "var(--bb-text-muted)" }}>
{c.name}
</p>
<p className="font-mono text-xs mt-1" style={{ color: "var(--bb-text-muted)" }}>
{c.hex}
</p>
</div>
</div>
))}
</div>
</Section>
</div>
);
}

331
apps/web/app/offline/print/page.tsx

@ -1,331 +0,0 @@
import type { Metadata } from "next";
import Image from "next/image";
export const metadata: Metadata = {
title: "Печатные материалы. Цифровой брендбук Клиники ухо, горло, нос им. проф. Е.Н.Оленевой",
};
function Section({
title,
subtitle,
children,
}: {
title: string;
subtitle?: string;
children: React.ReactNode;
}) {
return (
<section className="mb-12">
<div className="mb-6">
<h2 className="text-xl font-semibold" style={{ color: "var(--bb-text)" }}>
{title}
</h2>
{subtitle && (
<p className="mt-1 text-sm" style={{ color: "var(--bb-text-muted)" }}>
{subtitle}
</p>
)}
</div>
{children}
</section>
);
}
/* Макет визитки */
function BusinessCardMockup({ side }: { side: "front" | "back" }) {
/* 90×50 мм → пропорция 9:5. Отображаем 288×160px */
if (side === "front") {
return (
<div
className="rounded-xl overflow-hidden shadow-md"
style={{ width: 288, height: 160, background: "#5b7b87", flexShrink: 0 }}
>
{/* Верхняя бирюзовая полоса */}
<div style={{ height: 6, background: "#7ecfca" }} />
<div className="flex h-full">
{/* Левая колонка с логотипом */}
<div
className="flex items-center justify-center px-6"
style={{ width: 110, borderRight: "1px solid rgba(255,255,255,0.15)" }}
>
<Image
src="/logo/logo-transparent.png"
alt="Логотип"
width={80}
height={28}
className="object-contain"
style={{ filter: "brightness(0) invert(1)" }}
/>
</div>
{/* Правая колонка с данными */}
<div className="flex flex-col justify-center px-5 pb-2 gap-1">
<p
style={{
fontFamily: "'DINPro', Arial, sans-serif",
fontSize: 12,
fontWeight: 700,
color: "#ffffff",
}}
>
Иванова Анна Викторовна
</p>
<p
style={{
fontFamily: "'DINPro', Arial, sans-serif",
fontSize: 9,
color: "rgba(255,255,255,0.7)",
}}
>
Врач-оториноларинголог
</p>
<div className="mt-2 space-y-0.5">
{["+7 (495) 000-00-00", "oclinica.ru"].map(line => (
<p
key={line}
style={{
fontFamily: "'DINPro', Arial, sans-serif",
fontSize: 8,
color: "rgba(255,255,255,0.6)",
}}
>
{line}
</p>
))}
</div>
</div>
</div>
</div>
);
}
return (
<div
className="rounded-xl overflow-hidden shadow-md flex items-center justify-center"
style={{ width: 288, height: 160, background: "#c4a882", flexShrink: 0 }}
>
<Image
src="/logo/logo-transparent.png"
alt="Логотип на обороте"
width={140}
height={50}
className="object-contain"
style={{
filter: "brightness(0) sepia(1) saturate(2) hue-rotate(330deg) brightness(0.45)",
}}
/>
</div>
);
}
/* Макет листовки */
function LeafletMockup() {
/* А5 = 148×210 мм → пропорция ≈ 1:1.42. Отображаем 180×256px */
return (
<div
className="rounded-xl overflow-hidden shadow-md"
style={{ width: 180, height: 256, background: "#ffffff", border: "1px solid #e5e7eb", flexShrink: 0 }}
>
{/* Шапка */}
<div
className="px-4 pt-4 pb-3 flex items-end justify-between"
style={{ background: "#5b7b87" }}
>
<Image
src="/logo/logo-transparent.png"
alt="Логотип"
width={90}
height={32}
className="object-contain"
style={{ filter: "brightness(0) invert(1)" }}
/>
</div>
{/* Тело */}
<div className="px-4 pt-3">
{/* Плейсхолдер фото */}
<div
className="w-full rounded mb-3"
style={{ height: 70, background: "#e0f5f4" }}
/>
{/* Заголовок */}
<div
className="h-3 rounded mb-1.5"
style={{ background: "#1b4c72", width: "80%" }}
/>
<div
className="h-3 rounded mb-3"
style={{ background: "#1b4c72", width: "60%" }}
/>
{/* Текст-заглушка */}
{[100, 90, 95, 80].map((w, i) => (
<div
key={i}
className="h-1.5 rounded mb-1"
style={{ background: "#e5e7eb", width: `${w}%` }}
/>
))}
{/* CTA-кнопка */}
<div
className="mt-4 rounded py-2 text-center"
style={{ background: "#7ecfca" }}
>
<p
style={{
fontFamily: "'DINPro', Arial, sans-serif",
fontSize: 9,
fontWeight: 700,
color: "#ffffff",
}}
>
ЗАПИСАТЬСЯ НА ПРИЁМ
</p>
</div>
</div>
</div>
);
}
export default function PrintPage() {
return (
<div className="max-w-4xl mx-auto px-8 py-10">
{/* Заголовок */}
<div className="mb-10 pb-6 border-b" style={{ borderColor: "var(--bb-border)" }}>
<p
className="text-xs font-semibold uppercase tracking-widest mb-2"
style={{ color: "var(--brand-053m)" }}
>
Оффлайн элементы 6.5
</p>
<h1 className="text-3xl font-semibold mb-3" style={{ color: "var(--bb-text)" }}>
Печатные материалы
</h1>
<p className="text-base max-w-2xl" style={{ color: "var(--bb-text-muted)" }}>
Листовки, визитки и digital-каналы клиники. Все материалы следуют
единому фирменному стилю с использованием шрифта DINPro и цветов Oracal.
</p>
</div>
{/* Визитки */}
<Section
title="Визитки"
subtitle="Стандарт 90×50 мм. Двусторонняя печать. Изображены в масштабе."
>
<div className="flex flex-wrap gap-6 items-start">
<div className="flex flex-col items-center gap-2">
<BusinessCardMockup side="front" />
<p className="text-xs" style={{ color: "var(--bb-text-muted)" }}>Лицевая сторона</p>
</div>
<div className="flex flex-col items-center gap-2">
<BusinessCardMockup side="back" />
<p className="text-xs" style={{ color: "var(--bb-text-muted)" }}>Оборотная сторона</p>
</div>
</div>
<div
className="mt-6 overflow-hidden rounded-xl border"
style={{ borderColor: "var(--bb-border)" }}
>
<table className="w-full text-sm">
<thead>
<tr style={{ background: "var(--bb-sidebar-bg)" }}>
{["Элемент", "Содержание"].map(h => (
<th key={h} className="text-left px-5 py-3 font-medium"
style={{ color: "var(--bb-text-muted)" }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{[
["Лицевая — логотип", "Белый инвертированный на тёмно-серо-голубом фоне"],
["Лицевая — ФИО", "DINPro Bold, 10pt, белый"],
["Лицевая — должность", "DINPro Regular, 8pt, белый 70%"],
["Лицевая — контакты", "DINPro Regular, 7pt, белый 60%"],
["Оборотная", "Логотип тёмно-коричневый на бежевом фоне (081M)"],
["Размер", "90 × 50 мм, 4+4 офсет или цифровая печать"],
].map(([el, content]) => (
<tr key={el} className="border-t" style={{ borderColor: "var(--bb-border)" }}>
<td className="px-5 py-3 font-medium" style={{ color: "var(--bb-text)" }}>{el}</td>
<td className="px-5 py-3" style={{ color: "var(--bb-text-muted)" }}>{content}</td>
</tr>
))}
</tbody>
</table>
</div>
</Section>
{/* Листовки */}
<Section
title="Листовки"
subtitle="Формат А5 (148×210 мм). Шаблон для акций и информационных материалов."
>
<div className="flex gap-8 items-start">
<LeafletMockup />
<div className="flex-1">
<p className="font-medium text-sm mb-3" style={{ color: "var(--bb-text)" }}>
Структура листовки
</p>
<div className="space-y-3">
{[
{ zone: "Шапка", desc: "Логотип белый на тёмно-серо-голубом (073M)" },
{ zone: "Фото", desc: "Фото врача или процедуры, 70% ширины" },
{ zone: "Заголовок", desc: "DINPro Bold, тёмно-синий (050M), 18–22pt" },
{ zone: "Текст", desc: "DINPro Regular, 9–10pt, серый #374151" },
{ zone: "CTA-кнопка", desc: "Основной бирюзовый (053M), DINPro Bold белый" },
].map(({ zone, desc }) => (
<div key={zone} className="flex gap-3">
<span
className="shrink-0 text-xs font-semibold px-2 py-0.5 rounded mt-0.5"
style={{ background: "#e0f5f4", color: "var(--brand-073m)" }}
>
{zone}
</span>
<p className="text-sm" style={{ color: "var(--bb-text-muted)" }}>{desc}</p>
</div>
))}
</div>
</div>
</div>
</Section>
{/* Telegram-бот */}
<Section
title="Telegram-бот"
subtitle="Цифровой канал взаимодействия с пациентами."
>
<div
className="rounded-xl border p-6"
style={{ borderColor: "var(--bb-border)", background: "var(--bb-sidebar-bg)" }}
>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
{[
{ icon: "🤖", title: "Запись на приём", desc: "Выбор врача, даты и времени через бот" },
{ icon: "📋", title: "Результаты анализов", desc: "Уведомления и просмотр результатов" },
{ icon: "💬", title: "Напоминания", desc: "Напоминание о предстоящем визите" },
].map(({ icon, title, desc }) => (
<div key={title} className="flex gap-3">
<span className="text-2xl shrink-0">{icon}</span>
<div>
<p className="font-medium text-sm mb-1" style={{ color: "var(--bb-text)" }}>
{title}
</p>
<p className="text-xs" style={{ color: "var(--bb-text-muted)" }}>{desc}</p>
</div>
</div>
))}
</div>
<div
className="mt-4 pt-4 border-t text-xs"
style={{ borderColor: "var(--bb-border)", color: "var(--bb-text-muted)" }}
>
В Telegram-боте используется системный шрифт Telegram. Логотип и цвета бренда
применяются в аватаре, приветственном изображении и кнопках меню.
</div>
</div>
</Section>
</div>
);
}

178
apps/web/app/offline/transport/page.tsx

@ -1,178 +0,0 @@
import type { Metadata } from "next";
import Image from "next/image";
export const metadata: Metadata = {
title: "Брендирование транспорта. Цифровой брендбук Клиники ухо, горло, нос им. проф. Е.Н.Оленевой",
};
function Section({
title,
subtitle,
children,
}: {
title: string;
subtitle?: string;
children: React.ReactNode;
}) {
return (
<section className="mb-12">
<div className="mb-6">
<h2 className="text-xl font-semibold" style={{ color: "var(--bb-text)" }}>
{title}
</h2>
{subtitle && (
<p className="mt-1 text-sm" style={{ color: "var(--bb-text-muted)" }}>
{subtitle}
</p>
)}
</div>
{children}
</section>
);
}
export default function TransportPage() {
return (
<div className="max-w-4xl mx-auto px-8 py-10">
{/* Заголовок */}
<div className="mb-10 pb-6 border-b" style={{ borderColor: "var(--bb-border)" }}>
<p
className="text-xs font-semibold uppercase tracking-widest mb-2"
style={{ color: "var(--brand-053m)" }}
>
Оффлайн элементы 6.4
</p>
<h1 className="text-3xl font-semibold mb-3" style={{ color: "var(--bb-text)" }}>
Брендирование транспорта
</h1>
<p className="text-base max-w-2xl" style={{ color: "var(--bb-text-muted)" }}>
Схема нанесения фирменной символики на трамвай и другой общественный транспорт.
Трёхполосная структура с логотипом клиники по центру.
</p>
</div>
{/* Макет */}
<Section
title="Макет трамвая"
subtitle="Вид с обеих сторон: бирюзовая полоса, логотип клиники, фотографии пациентов и врачей, контактная информация."
>
<div
className="rounded-xl overflow-hidden border"
style={{ borderColor: "var(--bb-border)" }}
>
<Image
src="/offline/transport/tram-mockup.jpeg"
alt="Макет брендирования трамвая: вид спереди и сзади с логотипом Клиники ухо горло нос им. проф. Е.Н.Оленевой"
width={1884}
height={977}
className="w-full"
/>
</div>
</Section>
{/* Цветовая схема */}
<Section
title="Цветовая схема"
subtitle="Порядок и размеры полос."
>
<div
className="overflow-hidden rounded-xl border"
style={{ borderColor: "var(--bb-border)" }}
>
<table className="w-full text-sm">
<thead>
<tr style={{ background: "var(--bb-sidebar-bg)" }}>
{["Зона", "Цвет", "Oracal", "Высота", "Содержимое"].map(h => (
<th
key={h}
className="text-left px-4 py-3 font-medium"
style={{ color: "var(--bb-text-muted)" }}
>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{[
["Верхняя полоса", "#7ecfca", "053M", "~17% высоты", "Декоративная"],
["Центральная полоса", "#c4a882", "081M", "~48% высоты", "Логотип клиники (по центру)"],
["Нижняя полоса", "#5b7b87", "073M", "~35% высоты", "Декоративная / контактная информация"],
].map(([zone, hex, oracal, height, content]) => (
<tr
key={zone}
className="border-t"
style={{ borderColor: "var(--bb-border)" }}
>
<td className="px-4 py-3 font-medium" style={{ color: "var(--bb-text)" }}>
{zone}
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<div
className="w-5 h-5 rounded"
style={{ background: hex as string, border: "1px solid #e5e7eb" }}
/>
<span className="font-mono text-xs" style={{ color: "var(--bb-text-muted)" }}>
{hex}
</span>
</div>
</td>
<td className="px-4 py-3 font-mono text-xs" style={{ color: "var(--bb-text-muted)" }}>
{oracal}
</td>
<td className="px-4 py-3" style={{ color: "var(--bb-text-muted)" }}>{height}</td>
<td className="px-4 py-3" style={{ color: "var(--bb-text-muted)" }}>{content}</td>
</tr>
))}
</tbody>
</table>
</div>
</Section>
{/* Логотип на транспорте */}
<Section
title="Размещение логотипа"
subtitle="На центральной полосе, горизонтально по центру борта."
>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{[
{ label: "Минимальная длина логотипа", value: "600 мм" },
{ label: "Отступ от краёв борта", value: "≥ 400 мм" },
{ label: "Вертикальное выравнивание", value: "По центру полосы" },
{ label: "Цвет логотипа", value: "Тёмно-коричневый (080M)" },
].map(({ label, value }) => (
<div
key={label}
className="flex items-center gap-4 p-4 rounded-xl border"
style={{ borderColor: "var(--bb-border)", background: "var(--bb-sidebar-bg)" }}
>
<div
className="w-2 h-2 rounded-full shrink-0"
style={{ background: "var(--brand-053m)" }}
/>
<div>
<p className="text-xs" style={{ color: "var(--bb-text-muted)" }}>{label}</p>
<p className="font-semibold text-sm" style={{ color: "var(--bb-text)" }}>{value}</p>
</div>
</div>
))}
</div>
</Section>
<div
className="rounded-xl border p-4 text-sm flex items-start gap-3"
style={{ borderColor: "#fde68a", background: "#fffbeb", color: "#92400e" }}
>
<span className="text-base shrink-0"></span>
<p>
Финальные макеты для производства предоставляются дизайнером в форматах AI / PDF с
реальными размерами конкретного транспортного средства.
</p>
</div>
</div>
);
}

228
apps/web/app/offline/uniform/page.tsx

@ -1,228 +0,0 @@
import type { Metadata } from "next";
import Image from "next/image";
export const metadata: Metadata = {
title: "Форма сотрудников | Брендбук О!Клиника",
};
function Section({
title,
subtitle,
children,
}: {
title: string;
subtitle?: string;
children: React.ReactNode;
}) {
return (
<section className="mb-12">
<div className="mb-6">
<h2 className="text-xl font-semibold" style={{ color: "var(--bb-text)" }}>
{title}
</h2>
{subtitle && (
<p className="mt-1 text-sm" style={{ color: "var(--bb-text-muted)" }}>
{subtitle}
</p>
)}
</div>
{children}
</section>
);
}
export default function UniformPage() {
return (
<div className="max-w-4xl mx-auto px-8 py-10">
{/* Заголовок */}
<div className="mb-10 pb-6 border-b" style={{ borderColor: "var(--bb-border)" }}>
<p
className="text-xs font-semibold uppercase tracking-widest mb-2"
style={{ color: "var(--brand-053m)" }}
>
Оффлайн элементы 6.1
</p>
<h1 className="text-3xl font-semibold mb-3" style={{ color: "var(--bb-text)" }}>
Форма сотрудников
</h1>
<p className="text-base max-w-2xl" style={{ color: "var(--bb-text-muted)" }}>
Фирменная медицинская одежда сотрудников клиники. Два цветовых варианта:
бежевый с коричневым логотипом и синий с белым логотипом.
Логотип размещается на левой стороне груди.
</p>
</div>
{/* Фотографии вариантов */}
<Section
title="Варианты формы"
subtitle="Фотографии реальной формы сотрудников с логотипом клиники."
>
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
{/* Бежевый вариант */}
<div>
<div
className="rounded-xl overflow-hidden border mb-4"
style={{ borderColor: "var(--bb-border)" }}
>
<Image
src="/offline/uniform/uniform-1.jpeg"
alt="Бежевая форма сотрудника клиники с коричневым логотипом"
width={742}
height={990}
className="w-full object-cover"
style={{ maxHeight: 480, objectPosition: "top" }}
/>
</div>
<p className="font-medium text-sm mb-1" style={{ color: "var(--bb-text)" }}>
Бежевый вариант
</p>
<p className="text-sm mb-3" style={{ color: "var(--bb-text-muted)" }}>
Основная форма для медицинского персонала. Логотип тёмно-коричневый.
</p>
<div className="flex gap-2">
<div className="flex items-center gap-1.5">
<div
className="w-4 h-4 rounded"
style={{ background: "#c4a882", border: "1px solid #e5e7eb" }}
/>
<span className="text-xs font-mono" style={{ color: "var(--bb-text-muted)" }}>
Oracal 081M
</span>
</div>
<span style={{ color: "var(--bb-text-muted)" }}>·</span>
<div className="flex items-center gap-1.5">
<div
className="w-4 h-4 rounded"
style={{ background: "#5c2e0e", border: "1px solid #e5e7eb" }}
/>
<span className="text-xs font-mono" style={{ color: "var(--bb-text-muted)" }}>
Логотип 080M
</span>
</div>
</div>
</div>
{/* Синий вариант */}
<div>
<div
className="rounded-xl overflow-hidden border mb-4"
style={{ borderColor: "var(--bb-border)" }}
>
<Image
src="/offline/uniform/uniform-2.jpeg"
alt="Синяя форма сотрудника клиники с белым логотипом"
width={580}
height={773}
className="w-full object-cover"
style={{ maxHeight: 480, objectPosition: "top" }}
/>
</div>
<p className="font-medium text-sm mb-1" style={{ color: "var(--bb-text)" }}>
Синий вариант
</p>
<p className="text-sm mb-3" style={{ color: "var(--bb-text-muted)" }}>
Альтернативный вариант. Логотип белый инвертированный.
</p>
<div className="flex gap-2">
<div className="flex items-center gap-1.5">
<div
className="w-4 h-4 rounded"
style={{ background: "#4a90c4", border: "1px solid #e5e7eb" }}
/>
<span className="text-xs font-mono" style={{ color: "var(--bb-text-muted)" }}>
Синий медицинский
</span>
</div>
<span style={{ color: "var(--bb-text-muted)" }}>·</span>
<div className="flex items-center gap-1.5">
<div
className="w-4 h-4 rounded"
style={{ background: "#ffffff", border: "1px solid #e5e7eb" }}
/>
<span className="text-xs font-mono" style={{ color: "var(--bb-text-muted)" }}>
Логотип белый
</span>
</div>
</div>
</div>
</div>
</Section>
{/* Размеры логотипа */}
<Section
title="Размеры логотипа для размещения на форме сотрудников"
subtitle="Логотип располагается на левой стороне груди. Размер зависит от размера одежды."
>
<div
className="overflow-hidden rounded-xl border"
style={{ borderColor: "var(--bb-border)" }}
>
<table className="w-full text-sm">
<thead>
<tr style={{ background: "var(--bb-sidebar-bg)" }}>
{["Размер одежды", "Длина логотипа", "Высота логотипа", "Расположение"].map(h => (
<th
key={h}
className="text-left px-5 py-3 font-medium"
style={{ color: "var(--bb-text-muted)" }}
>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{[
["До 46 р. включительно", "70 мм", "25,5 мм", "Левая сторона груди"],
["От 48 р.", "90 мм", "32,8 мм", "Левая сторона груди"],
].map(([size, w, h, pos]) => (
<tr
key={size}
className="border-t"
style={{ borderColor: "var(--bb-border)" }}
>
<td className="px-5 py-3" style={{ color: "var(--bb-text)" }}>{size}</td>
<td className="px-5 py-3 font-mono text-xs" style={{ color: "var(--bb-text)" }}>{w}</td>
<td className="px-5 py-3 font-mono text-xs" style={{ color: "var(--bb-text)" }}>{h}</td>
<td className="px-5 py-3" style={{ color: "var(--bb-text-muted)" }}>{pos}</td>
</tr>
))}
</tbody>
</table>
</div>
</Section>
{/* Правила */}
<Section title="Правила использования">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{[
{ ok: true, text: "Носить комплект в полном составе" },
{ ok: true, text: "Поддерживать чистоту и опрятность формы" },
{ ok: true, text: "Логотип только в утверждённых цветовых вариантах" },
{ ok: false, text: "Носить форму без логотипа" },
{ ok: false, text: "Изменять цвет или материал формы" },
{ ok: false, text: "Добавлять сторонние нашивки и знаки" },
].map(item => (
<div
key={item.text}
className="flex items-start gap-3 p-4 rounded-lg border"
style={{
borderColor: item.ok ? "#a7f3d0" : "#fecaca",
background: item.ok ? "#f0fdf4" : "#fff5f5",
}}
>
<span style={{ color: item.ok ? "#16a34a" : "#ef4444" }}>
{item.ok ? "✓" : "✕"}
</span>
<p className="text-sm" style={{ color: item.ok ? "#14532d" : "#7f1d1d" }}>
{item.text}
</p>
</div>
))}
</div>
</Section>
</div>
);
}

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[] },
];

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

@ -19,36 +19,40 @@ const NAV: NavSection[] = [
title: "Фундамент",
items: [
{ label: "Логотип", href: "/foundation/logo" },
{ label: "Цвета", href: "/foundation/colors" },
{ label: "Типографика", href: "/foundation/typography" },
{ label: "Цвета", href: "/foundation/colors", soon: true },
{ label: "Типографика", href: "/foundation/typography", soon: true },
{ label: "Иконография", href: "/foundation/icons", soon: true },
],
},
{
title: "Компоненты",
items: [
{ label: "Кнопки", href: "/components/buttons" },
{ label: "Форм-контролы", href: "/components/forms" },
{ label: "Карточки", href: "/components/cards" },
{ label: "Кнопки", href: "/components/buttons", soon: true },
{ label: "Форм-контролы", href: "/components/forms", soon: true },
{ label: "Карточки", href: "/components/cards", soon: true },
{ label: "Бейджи и теги", href: "/components/badges", soon: true },
{ label: "Алерты", href: "/components/alerts", soon: true },
{ label: "Модальные окна", href: "/components/modals", soon: true },
{ label: "Таблицы", href: "/components/tables", soon: true },
{ label: "Навигация", href: "/components/navigation", soon: true },
],
},
{
title: "Блоки",
items: [
{ label: "Шапка / Навигация", href: "/blocks/navigation" },
{ label: "Hero-баннер", href: "/blocks/hero" },
{ label: "Вводный текст", href: "/blocks/ceo" },
{ label: "Наши врачи", href: "/blocks/doctors" },
{ label: "Отзывы", href: "/blocks/reviews" },
{ label: "Формы записи", href: "/blocks/contact-forms" },
{ label: "Новости", href: "/blocks/news" },
{ label: "Подвал / Контакт", href: "/blocks/contact" },
{ label: "Hero", href: "/blocks/hero", soon: true },
{ label: "CEO-текст", href: "/blocks/ceo", soon: true },
{ label: "Наши врачи", href: "/blocks/doctors", soon: true },
{ label: "Отзывы", href: "/blocks/reviews", soon: true },
{ label: "Новости", href: "/blocks/news", soon: true },
{ label: "Формы контакта", href: "/blocks/contact-forms", soon: true },
{ label: "Контакт", href: "/blocks/contact", soon: true },
{ label: "Услуги", href: "/blocks/services", soon: true },
],
},
{
title: "Страницы",
items: [
{ label: "Просмотр страницы", href: "/pages/preview" },
{ label: "Главная", href: "/pages/home", soon: true },
{ label: "Заболевание", href: "/pages/disease", soon: true },
{ label: "Все врачи", href: "/pages/doctors", soon: true },
@ -60,17 +64,17 @@ const NAV: NavSection[] = [
{
title: "Оффлайн элементы",
items: [
{ label: "Форма сотрудников", href: "/offline/uniform" },
{ label: "Бейджи", href: "/offline/badges" },
{ label: "Навигация", href: "/offline/navigation" },
{ label: "Транспорт", href: "/offline/transport" },
{ label: "Форма сотрудников", href: "/offline/uniform", soon: true },
{ label: "Бейджи", href: "/offline/badges", soon: true },
{ label: "Навигация", href: "/offline/navigation", soon: true },
{ label: "Транспорт", href: "/offline/transport", soon: true },
{ label: "Печать", href: "/offline/print", soon: true },
],
},
{
title: "Эксперименты",
items: [
{ label: "Дубли компонентов", href: "/variants/blocks", soon: true },
{ label: "Дубли страниц", href: "/variants/pages", soon: true },
{ label: "Библиотека", href: "/experiments", soon: true },
],
},
];
@ -167,7 +171,7 @@ export function Sidebar() {
color: "var(--bb-sidebar-text-muted)",
}}
>
Sprint 6 · v0.6.0
Sprint 1 · v0.1.0
</div>
</aside>
);

175
apps/web/components/llm/LlmBlock.tsx

@ -1,175 +0,0 @@
"use client";
import { useState } from "react";
interface LlmBlockProps {
/** Путь страницы, например "/foundation/colors" */
path: string;
/** Версия данных, например "v2.1" */
version: string;
/** Плоский текст для копирования */
specText: string;
/** Содержимое блока — таблицы, правила */
children: React.ReactNode;
}
/**
* LlmBlock переиспользуемый блок LLM-спецификации.
* Добавляется в конец каждой страницы брендбука, содержащей дизайн-стандарты.
* Требование: ФТ-03-LLM (TZ.md) · docs/LLM_CONTEXT.md
*/
export function LlmBlock({ path, version, specText, children }: LlmBlockProps) {
const [copied, setCopied] = useState(false);
function handleCopy(e: React.MouseEvent) {
e.preventDefault();
navigator.clipboard.writeText(specText);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
return (
<details
open
className="rounded-xl overflow-hidden"
style={{
border: "2px dashed var(--brand-053m)",
}}
>
{/* Заголовок */}
<summary
className="flex items-center justify-between px-5 py-3 cursor-pointer select-none list-none"
style={{ background: "rgba(126,207,202,0.07)" }}
>
<div className="flex items-center gap-2 min-w-0">
<span
className="shrink-0 text-[10px] font-bold px-1.5 py-0.5 rounded tracking-wider"
style={{ background: "var(--brand-053m)", color: "#fff" }}
>
LLM
</span>
<span className="font-semibold text-sm" style={{ color: "var(--bb-text)" }}>
LLM-спецификация
</span>
<span
className="text-xs truncate hidden sm:inline"
style={{ color: "var(--bb-text-muted)" }}
>
· машиночитаемые данные · docs/LLM_CONTEXT.md
</span>
</div>
<div className="flex items-center gap-3 shrink-0 ml-3">
<span
className="text-[10px] font-mono hidden md:inline"
style={{ color: "var(--bb-text-muted)" }}
>
{path} · {version}
</span>
<button
onClick={handleCopy}
className="text-xs px-3 py-1 rounded font-medium transition-colors shrink-0"
style={{
background: copied ? "#d1fae5" : "var(--brand-053m)",
color: copied ? "#065f46" : "#fff",
}}
>
{copied ? "✓ Скопировано" : "Скопировать"}
</button>
</div>
</summary>
{/* Содержимое */}
<div
className="px-5 py-5 space-y-6 border-t"
style={{
borderColor: "var(--brand-053m)",
borderStyle: "dashed",
background: "var(--bb-content-bg)",
}}
>
{children}
</div>
</details>
);
}
/* ─── Утилиты для содержимого блока ──────────────────────────── */
/** Заголовок подсекции внутри LLM-блока */
export function LlmSection({ title }: { title: string }) {
return (
<p
className="text-[10px] font-semibold uppercase tracking-widest pb-1 border-b"
style={{ color: "var(--bb-text-muted)", borderColor: "var(--bb-border)" }}
>
{title}
</p>
);
}
/** Компактная таблица для LLM-блока */
export function LlmTable({
headers,
rows,
}: {
headers: string[];
rows: (string | React.ReactNode)[][];
}) {
return (
<div className="overflow-x-auto">
<table className="w-full text-xs border-collapse font-mono">
<thead>
<tr style={{ background: "var(--bb-sidebar-bg)" }}>
{headers.map((h) => (
<th
key={h}
className="text-left px-2 py-1.5 font-medium border whitespace-nowrap"
style={{ color: "var(--bb-text-muted)", borderColor: "var(--bb-border)" }}
>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, ri) => (
<tr key={ri} style={{ borderTop: `1px solid var(--bb-border)` }}>
{row.map((cell, ci) => (
<td
key={ci}
className="px-2 py-1 border"
style={{
borderColor: "var(--bb-border)",
color: "var(--bb-text-muted)",
maxWidth: "240px",
}}
>
{cell}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
/** Список правил ✓ / ✕ */
export function LlmRules({ rules }: { rules: { ok: boolean; text: string }[] }) {
return (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-0.5">
{rules.map((r) => (
<div key={r.text} className="flex items-start gap-1.5 py-0.5 text-xs font-mono">
<span
style={{ color: r.ok ? "#059669" : "#dc2626", fontWeight: 700, flexShrink: 0 }}
>
{r.ok ? "✓" : "✕"}
</span>
<span style={{ color: "var(--bb-text-muted)" }}>{r.text}</span>
</div>
))}
</div>
);
}

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} />}
</>
);
}

45
apps/web/components/ui/Button.tsx

@ -1,45 +0,0 @@
"use client";
import { ButtonHTMLAttributes, forwardRef } from "react";
export type ButtonVariant = "primary" | "outline" | "teal" | "pill";
export type ButtonSize = "sm" | "md" | "lg";
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
size?: ButtonSize;
loading?: boolean;
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
function Button(
{ variant = "primary", size = "md", loading = false, disabled, children, className = "", ...props },
ref
) {
const isDisabled = disabled || loading;
return (
<button
ref={ref}
disabled={isDisabled}
className={`bb-btn bb-btn-${size} bb-btn-${variant} ${className}`.trim()}
{...props}
>
{loading && (
<span
style={{
width: 13,
height: 13,
border: "2px solid currentColor",
borderTopColor: "transparent",
borderRadius: "50%",
display: "inline-block",
animation: "bb-spin 0.65s linear infinite",
flexShrink: 0,
}}
/>
)}
{children}
</button>
);
}
);

60
apps/web/components/ui/CodeCopy.tsx

@ -1,60 +0,0 @@
"use client";
import { useState } from "react";
export function CodeCopy({ code, lang = "jsx" }: { code: string; lang?: string }) {
const [copied, setCopied] = useState(false);
return (
<div style={{ borderRadius: 8, overflow: "hidden", border: "1px solid var(--bb-border)" }}>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
background: "var(--bb-sidebar-bg)",
padding: "6px 12px",
borderBottom: "1px solid var(--bb-border)",
}}
>
<span style={{ fontSize: 11, color: "var(--bb-text-muted)", fontFamily: "var(--font-mono)" }}>
{lang}
</span>
<button
onClick={() => {
navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}}
style={{
fontSize: 12,
background: copied ? "#d1fae5" : "var(--brand-053m)",
color: copied ? "#065f46" : "#fff",
border: "none",
borderRadius: 4,
padding: "3px 10px",
cursor: "pointer",
fontWeight: 500,
fontFamily: "var(--font-web)",
}}
>
{copied ? "✓ Скопировано" : "Скопировать"}
</button>
</div>
<pre
style={{
margin: 0,
padding: "12px 16px",
overflowX: "auto",
fontSize: 12,
fontFamily: "var(--font-mono)",
color: "var(--bb-text-muted)",
background: "#fff",
lineHeight: 1.6,
}}
>
<code>{code}</code>
</pre>
</div>
);
}

59
apps/web/components/ui/Toggle.tsx

@ -1,59 +0,0 @@
"use client";
import { useState } from "react";
interface ToggleProps {
defaultChecked?: boolean;
disabled?: boolean;
label?: string;
onChange?: (checked: boolean) => void;
}
export function Toggle({ defaultChecked = false, disabled = false, label, onChange }: ToggleProps) {
const [checked, setChecked] = useState(defaultChecked);
function handleToggle() {
if (disabled) return;
const next = !checked;
setChecked(next);
onChange?.(next);
}
return (
<div
style={{
display: "inline-flex",
alignItems: "center",
gap: 10,
cursor: disabled ? "not-allowed" : "pointer",
opacity: disabled ? 0.5 : 1,
userSelect: "none",
}}
onClick={handleToggle}
role="switch"
aria-checked={checked}
tabIndex={disabled ? -1 : 0}
onKeyDown={(e) => {
if (e.key === " " || e.key === "Enter") {
e.preventDefault();
handleToggle();
}
}}
>
<div
className="bb-toggle-track"
style={{ background: checked ? "var(--brand-073m)" : "#d1d5db" }}
>
<div
className="bb-toggle-thumb"
style={{ transform: checked ? "translateX(20px)" : "translateX(0)" }}
/>
</div>
{label && (
<span style={{ fontSize: 14, color: "var(--bb-text)", fontFamily: "var(--font-web)" }}>
{label}
</span>
)}
</div>
);
}

12
apps/web/next.config.ts

@ -1,16 +1,10 @@
import type { NextConfig } from "next";
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, "../.."),
},
}),
turbopack: {
root: path.resolve(__dirname, "../.."),
},
};
export default nextConfig;

BIN
apps/web/public/doctors/lobanova.jpg

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

BIN
apps/web/public/doctors/makarova.jpg

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

BIN
apps/web/public/doctors/semerikova.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

BIN
apps/web/public/doctors/suvorova.jpg

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

BIN
apps/web/public/doctors/torsunova.jpg

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

BIN
apps/web/public/doctors/voronchikhina.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

BIN
apps/web/public/hero-doctor.jpg

Binary file not shown.

Before

Width:  |  Height:  |  Size: 762 KiB

BIN
apps/web/public/offline/badges/badge-1.jpeg

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

BIN
apps/web/public/offline/badges/badge-2.jpeg

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB

BIN
apps/web/public/offline/navigation/nav-1.jpeg

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

BIN
apps/web/public/offline/navigation/nav-2.jpeg

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

BIN
apps/web/public/offline/navigation/nav-3.jpeg

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

BIN
apps/web/public/offline/navigation/nav-directory.jpeg

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

BIN
apps/web/public/offline/navigation/nav-door-31.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 871 KiB

BIN
apps/web/public/offline/navigation/nav-mockup-kabinet.jpeg

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

BIN
apps/web/public/offline/navigation/nav-mockup-signs.jpeg

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

BIN
apps/web/public/offline/navigation/nav-p14-1.jpeg

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

BIN
apps/web/public/offline/navigation/nav-p14-2.jpeg

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

BIN
apps/web/public/offline/navigation/nav-p14-3.jpeg

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

BIN
apps/web/public/offline/navigation/nav-render-p13.jpeg

Binary file not shown.

Before

Width:  |  Height:  |  Size: 217 KiB

BIN
apps/web/public/offline/navigation/nav-render-p14.jpeg

Binary file not shown.

Before

Width:  |  Height:  |  Size: 236 KiB

BIN
apps/web/public/offline/transport/tram-1.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 B

BIN
apps/web/public/offline/transport/tram-10.jpeg

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

BIN
apps/web/public/offline/transport/tram-11.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 740 B

BIN
apps/web/public/offline/transport/tram-12.jpeg

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

BIN
apps/web/public/offline/transport/tram-13.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 767 B

BIN
apps/web/public/offline/transport/tram-14.jpeg

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

BIN
apps/web/public/offline/transport/tram-15.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 145 B

BIN
apps/web/public/offline/transport/tram-16.jpeg

Binary file not shown.

Before

Width:  |  Height:  |  Size: 957 B

BIN
apps/web/public/offline/transport/tram-17.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 446 B

BIN
apps/web/public/offline/transport/tram-18.jpeg

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

BIN
apps/web/public/offline/transport/tram-2.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 699 B

BIN
apps/web/public/offline/transport/tram-3.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 153 B

BIN
apps/web/public/offline/transport/tram-4.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 B

BIN
apps/web/public/offline/transport/tram-5.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 153 B

BIN
apps/web/public/offline/transport/tram-6.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 B

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save