Compare commits
32 Commits
0570b50d9f
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 8af06750c2 | |||
| c32f3e12ec | |||
| eda348deea | |||
| 1b64ccaa0f | |||
| 5b54ad5c23 | |||
| 196526ffc4 | |||
| 3094e9a2b7 | |||
| e20d222183 | |||
| 36d5faf67d | |||
| 950799193a | |||
| 2ed7eee63d | |||
| c8217cfc2f | |||
| be37ae2508 | |||
| 72829b5d46 | |||
| 60619114a5 | |||
| d4d86e6343 | |||
| 5b1ace962a | |||
| 52f04f9f5d | |||
| 754056642d | |||
| 6ce176f970 | |||
| 6c5b571884 | |||
| 2b66fb9cc1 | |||
| 3811c579fd | |||
| 2b9319e70d | |||
| d6610a288b | |||
| 77c9733144 | |||
| c1731615ab | |||
| 0855892643 | |||
| 0198947c4e | |||
| 6648f16bcb | |||
| 761347ed85 | |||
| 52acdc98e2 |
@@ -0,0 +1,15 @@
|
||||
node_modules
|
||||
**/node_modules
|
||||
**/.next
|
||||
**/dist
|
||||
.git
|
||||
.gitignore
|
||||
*.md
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
coverage
|
||||
**/.turbo
|
||||
**/coverage
|
||||
.DS_Store
|
||||
apps/web/pnpm-lock.yaml
|
||||
@@ -1,8 +1,8 @@
|
||||
# База данных
|
||||
DATABASE_URL="postgresql://brandbook:brandbook@localhost:5433/brandbook"
|
||||
# База данных (локально: порт как в docker-compose.yml, маппинг 5434 -> 5432 в контейнере)
|
||||
DATABASE_URL="postgresql://brandbook:brandbook@localhost:5434/brandbook"
|
||||
|
||||
# API (NestJS)
|
||||
API_PORT=3001
|
||||
# API (NestJS) — в коде используется переменная PORT
|
||||
PORT=3001
|
||||
|
||||
# Web (Next.js)
|
||||
# Web (Next.js) — URL API в браузере (при docker compose: http://localhost:3001)
|
||||
NEXT_PUBLIC_API_URL=http://localhost:3001
|
||||
|
||||
@@ -29,3 +29,4 @@ prisma/migrations/*.sql.bak
|
||||
|
||||
# Claude
|
||||
.claude/
|
||||
.vercel
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"start:prod": "node dist/src/main.js",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
@@ -24,8 +24,11 @@
|
||||
"@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"
|
||||
},
|
||||
@@ -52,9 +55,13 @@
|
||||
"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",
|
||||
|
||||
@@ -7,6 +7,7 @@ export default defineConfig({
|
||||
schema: "prisma/schema.prisma",
|
||||
migrations: {
|
||||
path: "prisma/migrations",
|
||||
seed: "tsx prisma/seed.ts",
|
||||
},
|
||||
datasource: {
|
||||
url: process.env["DATABASE_URL"],
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "Role" AS ENUM ('viewer', 'editor');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ComponentStatus" AS ENUM ('draft', 'review', 'approved');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "User" (
|
||||
"id" TEXT NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"passwordHash" TEXT NOT NULL,
|
||||
"role" "Role" NOT NULL DEFAULT 'viewer',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ExperimentalComponent" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"baseComponent" TEXT NOT NULL,
|
||||
"attributes" JSONB NOT NULL,
|
||||
"status" "ComponentStatus" NOT NULL DEFAULT 'draft',
|
||||
"authorId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "ExperimentalComponent_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Block" (
|
||||
"id" TEXT NOT NULL,
|
||||
"path" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"version" TEXT NOT NULL,
|
||||
"isInPreview" BOOLEAN NOT NULL DEFAULT false,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "Block_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Block_path_key" ON "Block"("path");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ExperimentalComponent" ADD CONSTRAINT "ExperimentalComponent_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Block" ADD COLUMN "changelog" JSONB NOT NULL DEFAULT '[]';
|
||||
@@ -0,0 +1,18 @@
|
||||
-- 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");
|
||||
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "postgresql"
|
||||
@@ -1,6 +1,5 @@
|
||||
generator client {
|
||||
provider = "prisma-client"
|
||||
output = "../generated/prisma"
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
@@ -40,3 +39,27 @@ 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])
|
||||
}
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
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());
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
import { BlocksModule } from './blocks/blocks.module';
|
||||
|
||||
@Module({
|
||||
imports: [],
|
||||
imports: [BlocksModule],
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
})
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { BlocksController } from './blocks.controller';
|
||||
import { BlocksService } from './blocks.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@Module({
|
||||
controllers: [BlocksController],
|
||||
providers: [BlocksService, PrismaService],
|
||||
})
|
||||
export class BlocksModule {}
|
||||
@@ -0,0 +1,43 @@
|
||||
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 } });
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
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();
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||
import { PrismaPg } from '@prisma/adapter-pg';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
||||
constructor() {
|
||||
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });
|
||||
super({ adapter });
|
||||
}
|
||||
|
||||
async onModuleInit() {
|
||||
await this.$connect();
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
await this.$disconnect();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
"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.6–1.8
|
||||
Цвет текста: #374151 (#bb-text)
|
||||
Вопросы: тот же стиль, начинаются с «—»
|
||||
Ключевые слова в тексте — обычно ссылки синего цвета (#0089c3)
|
||||
Фон блока: #ffffff, отступы 40–60px сверху и снизу
|
||||
|
||||
ПРАВИЛА:
|
||||
✓ Вопросы начинаются с «—» (тире)
|
||||
✓ Ключевые медицинские термины — ссылки #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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { Metadata } from "next";
|
||||
import CeoPageClient from "./CeoPageClient";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Вводный текст (CEO-блок). Цифровой брендбук Клиники ухо, горло, нос им. проф. Е.Н.Оленевой",
|
||||
};
|
||||
|
||||
export default function CeoPage() {
|
||||
return <CeoPageClient />;
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { Metadata } from "next";
|
||||
import ContactFormsPageClient from "./ContactFormsPageClient";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Формы записи. Цифровой брендбук Клиники ухо, горло, нос им. проф. Е.Н.Оленевой",
|
||||
};
|
||||
|
||||
export default function ContactFormsPage() {
|
||||
return <ContactFormsPageClient />;
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
"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:00–21:00
|
||||
— Сб: 9:00–18: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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { Metadata } from "next";
|
||||
import ContactPageClient from "./ContactPageClient";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Подвал (Footer). Цифровой брендбук Клиники ухо, горло, нос им. проф. Е.Н.Оленевой",
|
||||
};
|
||||
|
||||
export default function ContactFooterPage() {
|
||||
return <ContactPageClient />;
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { Metadata } from "next";
|
||||
import DoctorsPageClient from "./DoctorsPageClient";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Блок «Наши врачи». Цифровой брендбук Клиники ухо, горло, нос им. проф. Е.Н.Оленевой",
|
||||
};
|
||||
|
||||
export default function DoctorsBlockPage() {
|
||||
return <DoctorsPageClient />;
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { Metadata } from "next";
|
||||
import HeroPageClient from "./HeroPageClient";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Hero-баннер. Цифровой брендбук Клиники ухо, горло, нос им. проф. Е.Н.Оленевой",
|
||||
};
|
||||
|
||||
export default function HeroPage() {
|
||||
return <HeroPageClient />;
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { Metadata } from "next";
|
||||
import NavigationPageClient from "./NavigationPageClient";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Шапка и навигация. Цифровой брендбук Клиники ухо, горло, нос им. проф. Е.Н.Оленевой",
|
||||
};
|
||||
|
||||
export default function NavigationPage() {
|
||||
return <NavigationPageClient />;
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { Metadata } from "next";
|
||||
import NewsPageClient from "./NewsPageClient";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Блок «Новости». Цифровой брендбук Клиники ухо, горло, нос им. проф. Е.Н.Оленевой",
|
||||
};
|
||||
|
||||
export default function NewsBlockPage() {
|
||||
return <NewsPageClient />;
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
"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 80–100px
|
||||
— Текст отзыва: курсив, 15–16px, #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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { Metadata } from "next";
|
||||
import ReviewsPageClient from "./ReviewsPageClient";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Блок «Отзывы». Цифровой брендбук Клиники ухо, горло, нос им. проф. Е.Н.Оленевой",
|
||||
};
|
||||
|
||||
export default function ReviewsBlockPage() {
|
||||
return <ReviewsPageClient />;
|
||||
}
|
||||
@@ -0,0 +1,575 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,631 @@
|
||||
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)
|
||||
Структура: рейтинг (звёзды 1–5) + текст (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: не более 2–3 бейджей рядом
|
||||
✓ 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,725 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { LlmBlock, LlmSection, LlmTable, LlmRules } from "@/components/llm/LlmBlock";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
/* ─── Утилиты конвертации ──────────────────────────────────────────── */
|
||||
@@ -54,42 +55,42 @@ const BRAND_COLORS = [
|
||||
{
|
||||
oracal: "053M",
|
||||
name: "Основной бирюзовый",
|
||||
hex: "#7ecfca",
|
||||
hex: "#0089c3",
|
||||
usage: "Акцентный цвет, CTA-кнопки, иконки, активные состояния",
|
||||
cssVar: "--brand-053m",
|
||||
},
|
||||
{
|
||||
oracal: "073M",
|
||||
name: "Тёмный серо-голубой",
|
||||
hex: "#5b7b87",
|
||||
hex: "#53514e",
|
||||
usage: "Тёмный фон, хедер, акценты на тёмных поверхностях",
|
||||
cssVar: "--brand-073m",
|
||||
},
|
||||
{
|
||||
oracal: "066M",
|
||||
name: "Средний бирюзовый",
|
||||
hex: "#5bb5ad",
|
||||
hex: "#00818c",
|
||||
usage: "Вторичные акценты, фоны секций, иллюстрации",
|
||||
cssVar: "--brand-066m",
|
||||
},
|
||||
{
|
||||
oracal: "050M",
|
||||
name: "Тёмно-синий",
|
||||
hex: "#1b4c72",
|
||||
hex: "#1b2e5d",
|
||||
usage: "Наружная реклама, полиграфия, заголовки на светлом фоне",
|
||||
cssVar: "--brand-050m",
|
||||
},
|
||||
{
|
||||
oracal: "081M",
|
||||
name: "Бежевый",
|
||||
hex: "#c4a882",
|
||||
hex: "#a8885c",
|
||||
usage: "Форма сотрудников, оффлайн носители, тёплые акценты",
|
||||
cssVar: "--brand-081m",
|
||||
},
|
||||
{
|
||||
oracal: "080M",
|
||||
name: "Тёмно-коричневый",
|
||||
hex: "#5c2e0e",
|
||||
hex: "#432f1e",
|
||||
usage: "Текст на бежевых поверхностях, логотип на форме",
|
||||
cssVar: "--brand-080m",
|
||||
},
|
||||
@@ -102,14 +103,101 @@ const BRAND_COLORS = [
|
||||
},
|
||||
];
|
||||
|
||||
/* ─── Соответствие цветов ──────────────────────────────────────────── */
|
||||
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: "#5b7b87", label: "Белый на тёмном серо-голубом" },
|
||||
{ fg: "#ffffff", bg: "#1b4c72", label: "Белый на тёмно-синем" },
|
||||
{ fg: "#ffffff", bg: "#5c2e0e", label: "Белый на тёмно-коричневом" },
|
||||
{ fg: "#ffffff", bg: "#5bb5ad", label: "Белый на среднем бирюзовом" },
|
||||
{ fg: "#111827", bg: "#7ecfca", label: "Тёмный текст на основном бирюзовом" },
|
||||
{ fg: "#111827", bg: "#c4a882", label: "Тёмный текст на бежевом" },
|
||||
{ fg: "#5c2e0e", bg: "#c4a882", label: "Тёмно-коричневый на бежевом (форма)" },
|
||||
{ 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)" },
|
||||
];
|
||||
|
||||
/* ─── Компоненты ───────────────────────────────────────────────────── */
|
||||
@@ -245,6 +333,56 @@ function ContrastRow({ pair }: { pair: typeof CONTRAST_PAIRS[0] }) {
|
||||
);
|
||||
}
|
||||
|
||||
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: {} };
|
||||
@@ -268,6 +406,63 @@ function exportTokens() {
|
||||
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 (
|
||||
@@ -292,12 +487,12 @@ export default function ColorsPage() {
|
||||
<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: "#fde68a", background: "#fffbeb", color: "#92400e" }}
|
||||
style={{ borderColor: "#bae6fd", background: "#f0f9ff", color: "#075985" }}
|
||||
>
|
||||
<span>⚠️</span>
|
||||
<span>ℹ</span>
|
||||
<span>
|
||||
HEX-значения приблизительны. Для оффлайн-носителей используйте
|
||||
официальные коды Oracal.
|
||||
HEX-значения рассчитаны по точным RGB из каталога Oracal.
|
||||
Для оффлайн-носителей используйте официальные коды Oracal.
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
@@ -348,8 +543,120 @@ export default function ColorsPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 3. Применение */}
|
||||
{/* 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)" }}>
|
||||
Правила применения
|
||||
@@ -410,6 +717,105 @@ export default function ColorsPage() {
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
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 }) {
|
||||
@@ -102,6 +103,44 @@ 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">
|
||||
@@ -390,6 +429,55 @@ 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { Metadata } from "next";
|
||||
import { LlmBlock, LlmSection, LlmTable, LlmRules } from "@/components/llm/LlmBlock";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Типографика | Брендбук О!Клиника",
|
||||
title: "Типографика. Цифровой брендбук Клиники ухо, горло, нос им. проф. Е.Н.Оленевой",
|
||||
};
|
||||
|
||||
/* ─── Шкала типографики ────────────────────────────────────────────── */
|
||||
@@ -28,6 +29,60 @@ const FIRA_SCALE = [
|
||||
{ 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,
|
||||
@@ -344,6 +399,162 @@ export default function TypographyPage() {
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
/* ─── Бренд-токены О!Клиника ─────────────────────────────────────────── */
|
||||
/* Цвета уточняются в Sprint 2 по таблице Oracal */
|
||||
:root {
|
||||
/* Фирменные цвета (приблизительно — уточнить в 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) */
|
||||
/* Фирменные цвета — точные 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) */
|
||||
--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: #e0f5f4;
|
||||
--bb-sidebar-active-bg: #dff0fa;
|
||||
--bb-sidebar-active-text: var(--brand-053m);
|
||||
--bb-sidebar-section: #9ca3af;
|
||||
--bb-content-bg: #ffffff;
|
||||
@@ -42,3 +42,167 @@ 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;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { Metadata } from "next";
|
||||
import Image from "next/image";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Внутренняя навигация | Брендбук О!Клиника",
|
||||
title: "Внутренняя навигация. Цифровой брендбук Клиники ухо, горло, нос им. проф. Е.Н.Оленевой",
|
||||
};
|
||||
|
||||
function Section({
|
||||
@@ -30,74 +31,6 @@ function Section({
|
||||
);
|
||||
}
|
||||
|
||||
/* Макет настенной таблички */
|
||||
function SignMockup({
|
||||
type,
|
||||
text,
|
||||
subtext,
|
||||
bgColor,
|
||||
textColor,
|
||||
accentColor,
|
||||
size,
|
||||
}: {
|
||||
type: string;
|
||||
text: string;
|
||||
subtext?: string;
|
||||
bgColor: string;
|
||||
textColor: string;
|
||||
accentColor: string;
|
||||
size: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<div
|
||||
className="rounded-lg px-6 py-4 flex items-center gap-4"
|
||||
style={{
|
||||
background: bgColor,
|
||||
width: 260,
|
||||
border: bgColor === "#ffffff" ? "1px solid #e5e7eb" : "none",
|
||||
}}
|
||||
>
|
||||
{/* Цветовая полоса */}
|
||||
<div
|
||||
className="w-1.5 self-stretch rounded-full"
|
||||
style={{ background: accentColor, minHeight: 32 }}
|
||||
/>
|
||||
<div>
|
||||
<p
|
||||
style={{
|
||||
fontFamily: "'DINPro', Arial, sans-serif",
|
||||
fontSize: 14,
|
||||
fontWeight: 700,
|
||||
color: textColor,
|
||||
lineHeight: 1.3,
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</p>
|
||||
{subtext && (
|
||||
<p
|
||||
style={{
|
||||
fontFamily: "'DINPro', Arial, sans-serif",
|
||||
fontSize: 10,
|
||||
color: textColor,
|
||||
opacity: 0.65,
|
||||
marginTop: 3,
|
||||
}}
|
||||
>
|
||||
{subtext}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="pl-1">
|
||||
<p className="text-xs font-medium" style={{ color: "var(--bb-text)" }}>{type}</p>
|
||||
<p className="text-xs" style={{ color: "var(--bb-text-muted)" }}>{size}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function NavigationPage() {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-8 py-10">
|
||||
@@ -115,52 +48,111 @@ export default function NavigationPage() {
|
||||
</h1>
|
||||
<p className="text-base max-w-2xl" style={{ color: "var(--bb-text-muted)" }}>
|
||||
Система навигационных табличек и указателей внутри клиники.
|
||||
Единый стиль с фирменными цветами и шрифтом DINPro.
|
||||
На оргстекле, наклейки из плёнок Оракл 053M и 073M.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Шаблоны табличек */}
|
||||
{/* Макеты таблечек */}
|
||||
<Section
|
||||
title="Типы табличек"
|
||||
subtitle="Четыре базовых шаблона для разных зон клиники."
|
||||
title="Макеты навигационных табличек"
|
||||
subtitle="Два типа: табличка кабинета с бирюзовым заголовком и карточка врача с логотипом и QR-кодом."
|
||||
>
|
||||
<div className="flex flex-wrap gap-6">
|
||||
<SignMockup
|
||||
type="Кабинет врача"
|
||||
text="Кабинет № 101"
|
||||
subtext="Оториноларингология"
|
||||
bgColor="#ffffff"
|
||||
textColor="#1b4c72"
|
||||
accentColor="#7ecfca"
|
||||
size="200 × 80 мм"
|
||||
/>
|
||||
<SignMockup
|
||||
type="Направляющий указатель"
|
||||
text="→ Регистратура"
|
||||
subtext="2-й этаж"
|
||||
bgColor="#5b7b87"
|
||||
textColor="#ffffff"
|
||||
accentColor="#7ecfca"
|
||||
size="300 × 80 мм"
|
||||
/>
|
||||
<SignMockup
|
||||
type="Зона ожидания"
|
||||
text="Зона ожидания"
|
||||
subtext="Пожалуйста, соблюдайте тишину"
|
||||
bgColor="#e0f5f4"
|
||||
textColor="#1b4c72"
|
||||
accentColor="#5bb5ad"
|
||||
size="250 × 80 мм"
|
||||
/>
|
||||
<SignMockup
|
||||
type="Запрещающий"
|
||||
text="Вход только для персонала"
|
||||
bgColor="#1b4c72"
|
||||
textColor="#ffffff"
|
||||
accentColor="#c4a882"
|
||||
size="250 × 60 мм"
|
||||
<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>
|
||||
|
||||
{/* Технические требования */}
|
||||
@@ -188,12 +180,10 @@ export default function NavigationPage() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{[
|
||||
["Основной материал", "ПВХ 3мм / ПС зеркальный / акрил"],
|
||||
["Покрытие фона", "Oracal плёнка (053M / 073M / 050M)"],
|
||||
["Шрифт", "DINPro Bold / Regular (DXF для фрезеровки)"],
|
||||
["Крепление", "Двусторонний скотч / шурупы с дистанционным держателем"],
|
||||
["Толщина букв (фрезеровка)", "3 мм от основы"],
|
||||
["Минимальный размер текста", "10 мм по высоте"],
|
||||
["Основной материал", "Оргстекло"],
|
||||
["Покрытие", "Наклейка из плёнок Oracal"],
|
||||
["Шрифт", "DINPro Bold / Regular"],
|
||||
["Крепление", "Дистанционные держатели"],
|
||||
].map(([param, value]) => (
|
||||
<tr
|
||||
key={param}
|
||||
@@ -218,24 +208,25 @@ export default function NavigationPage() {
|
||||
title="Цвета Oracal для навигации"
|
||||
subtitle="Допустимые цвета плёнки по коду Oracal."
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
<div className="flex gap-6">
|
||||
{[
|
||||
{ code: "053M", hex: "#7ecfca", name: "Акцент / полоса" },
|
||||
{ code: "073M", hex: "#5b7b87", name: "Фон указателей" },
|
||||
{ code: "050M", hex: "#1b4c72", name: "Фон запрещающих" },
|
||||
{ code: "081M", hex: "#c4a882", name: "Акцент на тёмном" },
|
||||
{ code: "053M", hex: "#7ecfca", name: "Заголовок таблички / активный этаж" },
|
||||
{ code: "073M", hex: "#5b7b87", name: "Дополнительный акцент" },
|
||||
].map(c => (
|
||||
<div
|
||||
key={c.code}
|
||||
className="rounded-xl overflow-hidden border"
|
||||
style={{ borderColor: "var(--bb-border)" }}
|
||||
className="flex items-center gap-4 rounded-xl border p-4"
|
||||
style={{ borderColor: "var(--bb-border)", background: "var(--bb-sidebar-bg)" }}
|
||||
>
|
||||
<div className="h-16" style={{ background: c.hex }} />
|
||||
<div className="p-3">
|
||||
<p className="font-medium text-xs" style={{ color: "var(--bb-text)" }}>
|
||||
<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" style={{ color: "var(--bb-text-muted)" }}>
|
||||
<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)" }}>
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Metadata } from "next";
|
||||
import Image from "next/image";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Печатные материалы | Брендбук О!Клиника",
|
||||
title: "Печатные материалы. Цифровой брендбук Клиники ухо, горло, нос им. проф. Е.Н.Оленевой",
|
||||
};
|
||||
|
||||
function Section({
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Metadata } from "next";
|
||||
import Image from "next/image";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Брендирование транспорта | Брендбук О!Клиника",
|
||||
title: "Брендирование транспорта. Цифровой брендбук Клиники ухо, горло, нос им. проф. Е.Н.Оленевой",
|
||||
};
|
||||
|
||||
function Section({
|
||||
@@ -31,101 +31,6 @@ function Section({
|
||||
);
|
||||
}
|
||||
|
||||
/* Макет трамвая (упрощённый силуэт) */
|
||||
function TramMockup() {
|
||||
return (
|
||||
<div
|
||||
className="relative rounded-2xl overflow-hidden"
|
||||
style={{
|
||||
background: "#f8f9fa",
|
||||
border: "1px solid var(--bb-border)",
|
||||
padding: "32px 24px",
|
||||
}}
|
||||
>
|
||||
{/* Кузов трамвая */}
|
||||
<div
|
||||
className="relative mx-auto rounded-xl overflow-hidden"
|
||||
style={{
|
||||
width: "100%",
|
||||
maxWidth: 560,
|
||||
height: 160,
|
||||
background: "#ffffff",
|
||||
border: "2px solid #e5e7eb",
|
||||
}}
|
||||
>
|
||||
{/* Верхняя полоса — бирюзовая */}
|
||||
<div
|
||||
className="absolute top-0 left-0 right-0"
|
||||
style={{ height: 28, background: "#7ecfca" }}
|
||||
/>
|
||||
|
||||
{/* Основная бежевая полоса */}
|
||||
<div
|
||||
className="absolute left-0 right-0"
|
||||
style={{ top: 28, height: 76, background: "#c4a882" }}
|
||||
/>
|
||||
|
||||
{/* Нижняя полоса — серо-голубая */}
|
||||
<div
|
||||
className="absolute left-0 right-0"
|
||||
style={{ top: 104, height: 56, background: "#5b7b87" }}
|
||||
/>
|
||||
|
||||
{/* Логотип по центру бежевой полосы */}
|
||||
<div
|
||||
className="absolute flex items-center justify-center"
|
||||
style={{ top: 28, left: 0, right: 0, height: 76 }}
|
||||
>
|
||||
<Image
|
||||
src="/logo/logo-transparent.png"
|
||||
alt="Логотип на трамвае"
|
||||
width={180}
|
||||
height={64}
|
||||
className="object-contain"
|
||||
style={{
|
||||
filter: "brightness(0) sepia(1) saturate(2) hue-rotate(330deg) brightness(0.45)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Окна (декоративные) */}
|
||||
{[80, 180, 280, 380, 460].map(x => (
|
||||
<div
|
||||
key={x}
|
||||
className="absolute rounded"
|
||||
style={{
|
||||
left: x,
|
||||
top: 40,
|
||||
width: 50,
|
||||
height: 44,
|
||||
background: "rgba(255,255,255,0.6)",
|
||||
border: "1px solid rgba(255,255,255,0.8)",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Колёса (декоративные) */}
|
||||
{[60, 200, 360, 500].map(x => (
|
||||
<div
|
||||
key={x}
|
||||
className="absolute rounded-full"
|
||||
style={{
|
||||
left: x,
|
||||
bottom: -8,
|
||||
width: 24,
|
||||
height: 24,
|
||||
background: "#374151",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="mt-4 text-center text-xs" style={{ color: "var(--bb-text-muted)" }}>
|
||||
Схема цветового решения (превью, не финальный макет)
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TransportPage() {
|
||||
return (
|
||||
@@ -151,9 +56,20 @@ export default function TransportPage() {
|
||||
{/* Макет */}
|
||||
<Section
|
||||
title="Макет трамвая"
|
||||
subtitle="Трёхполосная схема брендирования: верхняя бирюзовая, центральная бежевая с логотипом, нижняя серо-голубая."
|
||||
subtitle="Вид с обеих сторон: бирюзовая полоса, логотип клиники, фотографии пациентов и врачей, контактная информация."
|
||||
>
|
||||
<TramMockup />
|
||||
<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>
|
||||
|
||||
{/* Цветовая схема */}
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { NavigationBlock } from "@/components/blocks/NavigationBlock";
|
||||
import { HeroBlock } from "@/components/blocks/HeroBlock";
|
||||
import { DoctorsBlock } from "@/components/blocks/DoctorsBlock";
|
||||
import { CeoBlock } from "@/components/blocks/CeoBlock";
|
||||
import { ReviewsBlock } from "@/components/blocks/ReviewsBlock";
|
||||
import { NewsBlock } from "@/components/blocks/NewsBlock";
|
||||
import { ContactFormsBlock } from "@/components/blocks/ContactFormsBlock";
|
||||
import { FooterBlock } from "@/components/blocks/FooterBlock";
|
||||
|
||||
const STORAGE_KEY = "bb-preview-created";
|
||||
const LS_PREFIX = "bb-block-preview:";
|
||||
|
||||
function BlockPlaceholder({ name, href }: { name: string; href: string }) {
|
||||
return (
|
||||
<div
|
||||
className="w-full py-14 flex flex-col items-center justify-center rounded-xl gap-2"
|
||||
style={{
|
||||
border: "2px dashed #d1d5db",
|
||||
background: "#f9fafb",
|
||||
}}
|
||||
>
|
||||
<p className="text-sm font-medium" style={{ color: "#6b7280" }}>
|
||||
{name}
|
||||
</p>
|
||||
<a
|
||||
href={href}
|
||||
className="text-xs underline"
|
||||
style={{ color: "var(--brand-053m)" }}
|
||||
>
|
||||
Открыть в брендбуке →
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface BlockDef {
|
||||
id: string;
|
||||
name: string;
|
||||
href: string;
|
||||
path: string;
|
||||
defaultReady: boolean;
|
||||
component?: React.ReactNode;
|
||||
}
|
||||
|
||||
const BLOCKS: BlockDef[] = [
|
||||
{
|
||||
id: "navigation",
|
||||
name: "Шапка / Навигация",
|
||||
href: "/blocks/navigation",
|
||||
path: "/blocks/navigation",
|
||||
defaultReady: true,
|
||||
component: <NavigationBlock />,
|
||||
},
|
||||
{
|
||||
id: "hero",
|
||||
name: "Hero-баннер",
|
||||
href: "/blocks/hero",
|
||||
path: "/blocks/hero",
|
||||
defaultReady: true,
|
||||
component: <HeroBlock />,
|
||||
},
|
||||
{
|
||||
id: "ceo",
|
||||
name: "Вводный текст (CEO-блок)",
|
||||
href: "/blocks/ceo",
|
||||
path: "/blocks/ceo",
|
||||
defaultReady: false,
|
||||
component: <CeoBlock />,
|
||||
},
|
||||
{
|
||||
id: "doctors",
|
||||
name: "Наши врачи",
|
||||
href: "/blocks/doctors",
|
||||
path: "/blocks/doctors",
|
||||
defaultReady: true,
|
||||
component: <DoctorsBlock />,
|
||||
},
|
||||
{
|
||||
id: "reviews",
|
||||
name: "Отзывы",
|
||||
href: "/blocks/reviews",
|
||||
path: "/blocks/reviews",
|
||||
defaultReady: false,
|
||||
component: <ReviewsBlock />,
|
||||
},
|
||||
{
|
||||
id: "contact-forms",
|
||||
name: "Формы записи",
|
||||
href: "/blocks/contact-forms",
|
||||
path: "/blocks/contact-forms",
|
||||
defaultReady: false,
|
||||
component: <ContactFormsBlock />,
|
||||
},
|
||||
{
|
||||
id: "news",
|
||||
name: "Новости",
|
||||
href: "/blocks/news",
|
||||
path: "/blocks/news",
|
||||
defaultReady: false,
|
||||
component: <NewsBlock />,
|
||||
},
|
||||
{
|
||||
id: "footer",
|
||||
name: "Подвал / Контакт",
|
||||
href: "/blocks/contact",
|
||||
path: "/blocks/contact",
|
||||
defaultReady: false,
|
||||
component: <FooterBlock />,
|
||||
},
|
||||
];
|
||||
|
||||
export function PreviewClient() {
|
||||
const [created, setCreated] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
// Map of path → isInPreview from API; null = not loaded yet
|
||||
const [apiMeta, setApiMeta] = useState<Record<string, boolean> | null>(null);
|
||||
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL;
|
||||
|
||||
// Counter to force re-render when localStorage preview toggles change
|
||||
const [, setRefresh] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
if (localStorage.getItem(STORAGE_KEY) === "true") {
|
||||
setCreated(true);
|
||||
}
|
||||
const handler = () => setRefresh((n) => n + 1);
|
||||
window.addEventListener("bb-preview-change", handler);
|
||||
return () => window.removeEventListener("bb-preview-change", handler);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!apiUrl) return;
|
||||
fetch(`${apiUrl}/blocks`)
|
||||
.then((r) => r.json())
|
||||
.then((data: Array<{ path: string; isInPreview: boolean }>) => {
|
||||
const map: Record<string, boolean> = {};
|
||||
for (const b of data) map[b.path] = b.isInPreview;
|
||||
setApiMeta(map);
|
||||
})
|
||||
.catch(() => {
|
||||
// API offline — fall back to defaultReady
|
||||
});
|
||||
}, [apiUrl]);
|
||||
|
||||
function isReady(block: BlockDef): boolean {
|
||||
// 1. API online → use API data
|
||||
if (apiMeta !== null && block.path in apiMeta) {
|
||||
return apiMeta[block.path] && !!block.component;
|
||||
}
|
||||
// 2. Check localStorage (set by BlockMetaBar toggle)
|
||||
const lsVal = typeof window !== "undefined" ? localStorage.getItem(LS_PREFIX + block.path) : null;
|
||||
if (lsVal !== null) {
|
||||
return lsVal === "true" && !!block.component;
|
||||
}
|
||||
// 3. Fallback to defaultReady
|
||||
return block.defaultReady && !!block.component;
|
||||
}
|
||||
|
||||
function handleCreate() {
|
||||
localStorage.setItem(STORAGE_KEY, "true");
|
||||
setCreated(true);
|
||||
}
|
||||
|
||||
function handleRebuild() {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
setCreated(false);
|
||||
}
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
const readyCount = BLOCKS.filter(isReady).length;
|
||||
|
||||
/* ── ПУСТОЕ СОСТОЯНИЕ ── */
|
||||
if (!created) {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col items-center justify-center min-h-screen"
|
||||
style={{ background: "var(--bb-bg)" }}
|
||||
>
|
||||
<div className="text-center max-w-md space-y-5 p-8">
|
||||
<p
|
||||
className="text-xs font-semibold uppercase tracking-widest"
|
||||
style={{ color: "var(--brand-053m)" }}
|
||||
>
|
||||
Страницы
|
||||
</p>
|
||||
<h1 className="text-2xl font-bold" style={{ color: "var(--bb-text)" }}>
|
||||
Просмотр текущей страницы
|
||||
</h1>
|
||||
<p className="text-sm" style={{ color: "var(--bb-text-muted)", lineHeight: 1.7 }}>
|
||||
Нажмите «Создать», чтобы собрать главную страницу{" "}
|
||||
<span className="font-mono text-xs">perm.oclinica.ru/lor</span> из блоков,
|
||||
задокументированных в брендбуке.
|
||||
</p>
|
||||
<div
|
||||
className="flex items-center justify-center gap-2 text-xs px-4 py-2 rounded-lg"
|
||||
style={{ background: "var(--bb-sidebar-bg)", border: "1px solid var(--bb-border)" }}
|
||||
>
|
||||
<span
|
||||
className="inline-block w-2 h-2 rounded-full"
|
||||
style={{ background: "#22c55e" }}
|
||||
/>
|
||||
<span style={{ color: "var(--bb-text-muted)" }}>
|
||||
Готово блоков: {readyCount} из {BLOCKS.length}
|
||||
</span>
|
||||
<span style={{ color: "var(--bb-text-muted)" }}>·</span>
|
||||
<span style={{ color: "var(--bb-text-muted)" }}>
|
||||
{BLOCKS.length - readyCount} плейсхолдеров
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<button onClick={handleCreate} className="bb-btn bb-btn-md bb-btn-primary">
|
||||
Создать
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── СОЗДАННОЕ СОСТОЯНИЕ ── */
|
||||
return (
|
||||
<div>
|
||||
{/* Топ-бар */}
|
||||
<div
|
||||
className="sticky top-0 z-10 flex items-center justify-between px-6 py-3 border-b"
|
||||
style={{
|
||||
background: "var(--bb-sidebar-bg)",
|
||||
borderColor: "var(--bb-border)",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<p
|
||||
className="text-xs font-semibold uppercase tracking-widest"
|
||||
style={{ color: "var(--brand-053m)" }}
|
||||
>
|
||||
Просмотр текущей страницы
|
||||
</p>
|
||||
<p className="text-xs mt-0.5" style={{ color: "var(--bb-text-muted)" }}>
|
||||
perm.oclinica.ru/lor · {readyCount}/{BLOCKS.length} блоков готово
|
||||
{apiMeta === null && apiUrl && (
|
||||
<span style={{ color: "#f59e0b" }}> · API загружается...</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={handleRebuild} className="bb-btn bb-btn-sm bb-btn-outline">
|
||||
Пересобрать
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Собранная страница */}
|
||||
<div className="max-w-5xl mx-auto px-8 py-8 space-y-12">
|
||||
{BLOCKS.map((block) =>
|
||||
isReady(block) ? (
|
||||
<section key={block.id}>{block.component}</section>
|
||||
) : (
|
||||
<section key={block.id}>
|
||||
<BlockPlaceholder name={block.name} href={block.href} />
|
||||
</section>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { Metadata } from "next";
|
||||
import { PreviewClient } from "./PreviewClient";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title:
|
||||
"Просмотр текущей страницы. Цифровой брендбук Клиники ухо, горло, нос им. проф. Е.Н.Оленевой",
|
||||
};
|
||||
|
||||
export default function PreviewPage() {
|
||||
return <PreviewClient />;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
export const CEO_QUESTIONS = [
|
||||
"У вас болит ухо, заложен нос, першит в горле, и вы не можете понять причину?",
|
||||
"Вам срочно нужен платный ЛОР в Перми или, как ещё говорят, «ухогорлонос»?",
|
||||
"Заболел ребёнок?",
|
||||
"Срочно ищете частные ЛОР-клиники Перми для детей 0+ и взрослых с удобным режимом работы с 9:00 до 21:00 по будням?",
|
||||
"Вам назначили проведение эндоскопической операции на ухе, горле или носе?",
|
||||
];
|
||||
|
||||
export function CeoBlock() {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: "#fff",
|
||||
fontFamily: "var(--font-web, 'Fira Sans', sans-serif)",
|
||||
fontSize: 14,
|
||||
lineHeight: 1.75,
|
||||
color: "#374151",
|
||||
padding: "40px 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
export function ContactFormsBlock() {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Форма 1: Будьте здоровы */}
|
||||
<div
|
||||
className="rounded-xl py-10 px-8 flex flex-col items-center gap-6"
|
||||
style={{ background: "#d4f6f8" }}
|
||||
>
|
||||
<div className="text-center">
|
||||
<h2 className="font-bold mb-1" style={{ color: "#000000", fontSize: 36, lineHeight: "38px" }}>
|
||||
Будьте здоровы!
|
||||
</h2>
|
||||
<p className="text-sm" style={{ color: "#374151" }}>
|
||||
Запишитесь на приём к врачу!
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-full max-w-sm space-y-3">
|
||||
<input
|
||||
className="bb-input"
|
||||
type="text"
|
||||
placeholder="Введите ваше имя"
|
||||
readOnly
|
||||
/>
|
||||
<input
|
||||
className="bb-input"
|
||||
type="tel"
|
||||
placeholder="Введите ваш телефон"
|
||||
readOnly
|
||||
/>
|
||||
<select className="bb-select" disabled>
|
||||
<option>Выберите ЛОР врача</option>
|
||||
</select>
|
||||
<label className="flex items-start gap-2 text-xs cursor-pointer" style={{ color: "#374151" }}>
|
||||
<input type="checkbox" className="bb-checkbox mt-0.5" readOnly />
|
||||
<span>
|
||||
Отправляя данные, я даю согласие на обработку персональных данных
|
||||
</span>
|
||||
</label>
|
||||
<button className="bb-btn bb-btn-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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
export const STATS = [
|
||||
{ num: "27", label: "ЛОР врачей работает в клинике", prefix: "Ежедневно" },
|
||||
{ num: "6", label: "кандидатов медицинских наук", prefix: "В том числе" },
|
||||
{ num: "12 000+", label: "успешно проведённых операций", prefix: "Свыше" },
|
||||
];
|
||||
|
||||
export const DOCTORS = [
|
||||
{
|
||||
name: "Макарова Людмила Германовна",
|
||||
spec: "ЛОР врач, сурдолог",
|
||||
photo: "/doctors/makarova.jpg",
|
||||
},
|
||||
{
|
||||
name: "Семерикова Наталия Александровна",
|
||||
spec: "ЛОР врач, сурдолог, хирург. К.М.Н. Завед. Центром сурдологии",
|
||||
photo: "/doctors/semerikova.png",
|
||||
},
|
||||
{
|
||||
name: "Ворончихина Наталия Валерьевна",
|
||||
spec: "Отоневролог, хирург. К.М.Н., доцент кафедры ПГМУ",
|
||||
photo: "/doctors/voronchikhina.png",
|
||||
},
|
||||
{
|
||||
name: "Лобанова Ирина Юрьевна",
|
||||
spec: "ЛОР врач, сурдолог",
|
||||
photo: "/doctors/lobanova.jpg",
|
||||
},
|
||||
{
|
||||
name: "Торсунова Наталья Сергеевна",
|
||||
spec: "Специалист по слухопротезированию (сурдоакустик)",
|
||||
photo: "/doctors/torsunova.jpg",
|
||||
},
|
||||
{
|
||||
name: "Суворова Светлана Викторовна",
|
||||
spec: "ЛОР врач, сурдолог",
|
||||
photo: "/doctors/suvorova.jpg",
|
||||
},
|
||||
];
|
||||
|
||||
export function DoctorsBlock() {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Заголовок + описание */}
|
||||
<div>
|
||||
<h2 className="font-bold mb-3" style={{ color: "#000000", fontSize: 36, lineHeight: "38px" }}>
|
||||
Приём ведут опытные ЛОР врачи
|
||||
</h2>
|
||||
<p className="text-sm" style={{ color: "#374151", lineHeight: 1.7 }}>
|
||||
Фундаментальная теоретическая подготовка и большой практический опыт в сочетании
|
||||
с внимательным индивидуальным подходом являются причиной успеха лечения тысяч наших пациентов
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Статистика — без фона, border-bottom #60959c */}
|
||||
<div className="grid grid-cols-3 gap-6">
|
||||
{STATS.map((s) => (
|
||||
<div key={s.num} className="pb-3" style={{ borderBottom: "3px solid #60959c" }}>
|
||||
<p className="text-lg font-bold leading-snug" style={{ color: "#60959c" }}>
|
||||
{s.prefix} {s.num} {s.label}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Сетка врачей — 6 колонок */}
|
||||
<div className="grid grid-cols-6 gap-3">
|
||||
{DOCTORS.map((doc) => (
|
||||
<div key={doc.name} className="flex flex-col items-center text-center gap-1.5">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={doc.photo}
|
||||
alt={doc.name}
|
||||
style={{
|
||||
width: 110,
|
||||
height: 150,
|
||||
objectFit: "cover",
|
||||
objectPosition: "center top",
|
||||
borderRadius: 4,
|
||||
display: "block",
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<p className="text-xs font-medium leading-snug" style={{ color: "#60959c" }}>
|
||||
{doc.name}
|
||||
</p>
|
||||
<p className="text-[11px] mt-0.5 leading-snug" style={{ color: "#374151" }}>
|
||||
{doc.spec}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
const MOCK_NEWS = [
|
||||
{
|
||||
date: "20.12.2025",
|
||||
title: "Наша работа клиники и новогодние праздники",
|
||||
href: "#",
|
||||
},
|
||||
{
|
||||
date: "11.08.2025",
|
||||
title: "СЕРВИС ОБНОВЛЕНИЕ: Обновление графика работы клиники",
|
||||
href: "#",
|
||||
},
|
||||
{
|
||||
date: "12.06.2025",
|
||||
title: "СЕРВИС ОБНОВЛЕНИЕ: Временное изменение работы клиники 22.06.25 г.",
|
||||
href: "#",
|
||||
},
|
||||
{
|
||||
date: "11.06.2025",
|
||||
title: "График работы клиники в ближайшие праздники",
|
||||
href: "#",
|
||||
},
|
||||
];
|
||||
|
||||
export function NewsBlock() {
|
||||
return (
|
||||
<div
|
||||
className="rounded-xl p-8 space-y-6"
|
||||
style={{ background: "#f2fee6" }}
|
||||
>
|
||||
<h2 className="font-bold" style={{ color: "#000000", fontSize: 36, lineHeight: "38px" }}>
|
||||
Новости
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{MOCK_NEWS.map((n) => (
|
||||
<div
|
||||
key={n.date}
|
||||
className="bb-news-card rounded-lg p-3 cursor-pointer"
|
||||
style={{
|
||||
border: "1px solid var(--bb-border)",
|
||||
transition: "background 0.15s, box-shadow 0.15s",
|
||||
}}
|
||||
>
|
||||
<p className="text-xs mb-2" style={{ color: "#6b7280" }}>
|
||||
{n.date}
|
||||
</p>
|
||||
<a
|
||||
href={n.href}
|
||||
className="text-sm font-medium leading-snug block"
|
||||
style={{ color: "#0089c3", textDecoration: "none" }}
|
||||
>
|
||||
{n.title}
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<button className="bb-btn bb-btn-md bb-btn-outline">
|
||||
Все новости
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
const MOCK_REVIEWS = [
|
||||
{
|
||||
text: "Спасибо за приём, мне всё понравилось, спасибо за приём, мне всё понравилось. Врач очень внимательный и профессиональный.",
|
||||
author: "Пациент клиники",
|
||||
doctor: "Тимофеева Наталья Александровна",
|
||||
},
|
||||
{
|
||||
text: "Очень довольна лечением! Прошла курс процедур, нос дышит отлично. Рекомендую клинику всем.",
|
||||
author: "Наталья К.",
|
||||
doctor: "Макарова Людмила Тимофеевна",
|
||||
},
|
||||
];
|
||||
|
||||
export function ReviewsBlock() {
|
||||
return (
|
||||
<div
|
||||
className="rounded-xl p-8 space-y-6"
|
||||
style={{ background: "#fff", border: "1px solid var(--bb-border)" }}
|
||||
>
|
||||
{/* Заголовок */}
|
||||
<div>
|
||||
<h2 className="font-bold mb-2" style={{ color: "#000000", fontSize: 36, lineHeight: "38px" }}>
|
||||
Отзывы о нас
|
||||
</h2>
|
||||
<p className="text-sm" style={{ color: "#374151", lineHeight: 1.7 }}>
|
||||
За 12 лет работы наши врачи оториноларингологи избавили от болезней ухо, горло, носа
|
||||
более 50 000 пациентов. Но даже сейчас мы высоко ценим каждый положительный отзыв
|
||||
и искренние слова благодарности.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Карусель */}
|
||||
<div className="relative flex items-center gap-4">
|
||||
{/* Стрелка влево */}
|
||||
<button
|
||||
className="shrink-0 w-10 h-10 rounded-full flex items-center justify-center font-bold text-lg"
|
||||
style={{
|
||||
background: "var(--bb-sidebar-bg)",
|
||||
border: "1px solid var(--bb-border)",
|
||||
color: "var(--bb-text-muted)",
|
||||
}}
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
|
||||
{/* Карточка отзыва */}
|
||||
<div
|
||||
className="flex-1 rounded-xl p-6"
|
||||
style={{ background: "#eef4d1" }}
|
||||
>
|
||||
<div
|
||||
className="text-8xl leading-none mb-2 font-serif"
|
||||
style={{ color: "#b8e6ed", lineHeight: 0.8 }}
|
||||
>
|
||||
«
|
||||
</div>
|
||||
<p
|
||||
className="text-sm italic leading-relaxed"
|
||||
style={{ color: "#374151" }}
|
||||
>
|
||||
{MOCK_REVIEWS[0].text}
|
||||
</p>
|
||||
<a
|
||||
href="#"
|
||||
className="inline-block mt-3 text-sm"
|
||||
style={{ color: "#0089c3" }}
|
||||
>
|
||||
Читать отзыв полностью
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Стрелка вправо */}
|
||||
<button
|
||||
className="shrink-0 w-10 h-10 rounded-full flex items-center justify-center font-bold text-lg"
|
||||
style={{
|
||||
background: "var(--bb-sidebar-bg)",
|
||||
border: "1px solid var(--bb-border)",
|
||||
color: "var(--bb-text-muted)",
|
||||
}}
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
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[] },
|
||||
];
|
||||
@@ -27,32 +27,28 @@ const NAV: NavSection[] = [
|
||||
{
|
||||
title: "Компоненты",
|
||||
items: [
|
||||
{ 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 },
|
||||
{ label: "Кнопки", href: "/components/buttons" },
|
||||
{ label: "Форм-контролы", href: "/components/forms" },
|
||||
{ label: "Карточки", href: "/components/cards" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Блоки",
|
||||
items: [
|
||||
{ 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 },
|
||||
{ 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" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Страницы",
|
||||
items: [
|
||||
{ label: "Просмотр страницы", href: "/pages/preview" },
|
||||
{ label: "Главная", href: "/pages/home", soon: true },
|
||||
{ label: "Заболевание", href: "/pages/disease", soon: true },
|
||||
{ label: "Все врачи", href: "/pages/doctors", soon: true },
|
||||
@@ -68,13 +64,13 @@ const NAV: NavSection[] = [
|
||||
{ label: "Бейджи", href: "/offline/badges" },
|
||||
{ label: "Навигация", href: "/offline/navigation" },
|
||||
{ label: "Транспорт", href: "/offline/transport" },
|
||||
{ label: "Печать", href: "/offline/print" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Эксперименты",
|
||||
items: [
|
||||
{ label: "Библиотека", href: "/experiments", soon: true },
|
||||
{ label: "Дубли компонентов", href: "/variants/blocks", soon: true },
|
||||
{ label: "Дубли страниц", href: "/variants/pages", soon: true },
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -171,7 +167,7 @@ export function Sidebar() {
|
||||
color: "var(--bb-sidebar-text-muted)",
|
||||
}}
|
||||
>
|
||||
Sprint 2 · v0.2.0
|
||||
Sprint 6 · v0.6.0
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
"use client";
|
||||
|
||||
export interface ChangelogEntry {
|
||||
version: string;
|
||||
date: string;
|
||||
changes: string[];
|
||||
}
|
||||
|
||||
interface BlockChangelogProps {
|
||||
changelog: ChangelogEntry[];
|
||||
}
|
||||
|
||||
export function BlockChangelog({ changelog }: BlockChangelogProps) {
|
||||
if (!changelog || changelog.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<h2 className="font-semibold text-base" style={{ color: "var(--bb-text)" }}>
|
||||
История версий
|
||||
</h2>
|
||||
<div className="space-y-2 text-sm" style={{ color: "var(--bb-text-muted)" }}>
|
||||
{changelog.map((entry) => (
|
||||
<div
|
||||
key={entry.version}
|
||||
className="p-3 rounded-lg"
|
||||
style={{ background: "var(--bb-sidebar-bg)", border: "1px solid var(--bb-border)" }}
|
||||
>
|
||||
<p className="font-semibold text-xs mb-1" style={{ color: "var(--bb-text)" }}>
|
||||
{entry.version} — {entry.date}
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-0.5 text-xs">
|
||||
{entry.changes.map((change, i) => (
|
||||
<li key={i}>{change}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
"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} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,60 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,8 @@ 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, "../.."),
|
||||
|
||||
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 762 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 871 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 217 KiB |
|
After Width: | Height: | Size: 236 KiB |
|
After Width: | Height: | Size: 132 B |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 740 B |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 767 B |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 145 B |
|
After Width: | Height: | Size: 957 B |
|
After Width: | Height: | Size: 446 B |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 699 B |
|
After Width: | Height: | Size: 153 B |
|
After Width: | Height: | Size: 141 B |
|
After Width: | Height: | Size: 153 B |
|
After Width: | Height: | Size: 141 B |
|
After Width: | Height: | Size: 153 B |
|
After Width: | Height: | Size: 141 B |
|
After Width: | Height: | Size: 477 B |
|
After Width: | Height: | Size: 249 KiB |