From 5b54ad5c2369226da31bce938c12016e28821982 Mon Sep 17 00:00:00 2001 From: AR 15 M4 Date: Wed, 25 Mar 2026 00:17:25 +0500 Subject: [PATCH] feat(sprint-5.5): add block version snapshots with switching between versions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add BlockSnapshot Prisma model (html, css, version, changelog) + migration - Add API endpoints: POST/GET /blocks/snapshots, GET /blocks/snapshots/:id - BlockMetaBar: version dropdown, HTML capture on save, onSnapshotSelect prop - "Сохранить версию" now captures innerHTML snapshot + CSS and stores in DB - Selecting archived version shows stored HTML snapshot instead of live component - Yellow banner "Архивная версия" with link to return to current - Split all 8 block pages into Server Component (metadata) + Client Component - Add data-block-capture attribute for snapshot capture targeting Co-Authored-By: Claude Opus 4.6 (1M context) --- .../migration.sql | 18 ++ apps/api/prisma/schema.prisma | 13 + apps/api/src/blocks/blocks.controller.ts | 20 +- apps/api/src/blocks/blocks.service.ts | 20 ++ apps/web/app/blocks/ceo/CeoPageClient.tsx | 217 +++++++++++++ apps/web/app/blocks/ceo/page.tsx | 196 +----------- .../contact-forms/ContactFormsPageClient.tsx | 190 ++++++++++++ apps/web/app/blocks/contact-forms/page.tsx | 167 +--------- .../app/blocks/contact/ContactPageClient.tsx | 204 ++++++++++++ apps/web/app/blocks/contact/page.tsx | 181 +---------- .../app/blocks/doctors/DoctorsPageClient.tsx | 187 +++++++++++ apps/web/app/blocks/doctors/page.tsx | 162 +--------- apps/web/app/blocks/hero/HeroPageClient.tsx | 231 ++++++++++++++ apps/web/app/blocks/hero/page.tsx | 208 +------------ .../navigation/NavigationPageClient.tsx | 291 ++++++++++++++++++ apps/web/app/blocks/navigation/page.tsx | 270 +--------------- apps/web/app/blocks/news/NewsPageClient.tsx | 195 ++++++++++++ apps/web/app/blocks/news/page.tsx | 172 +---------- .../app/blocks/reviews/ReviewsPageClient.tsx | 226 ++++++++++++++ apps/web/app/blocks/reviews/page.tsx | 203 +----------- apps/web/components/ui/BlockMetaBar.tsx | 145 ++++++++- 21 files changed, 1962 insertions(+), 1554 deletions(-) create mode 100644 apps/api/prisma/migrations/20260324185731_add_block_snapshot/migration.sql create mode 100644 apps/web/app/blocks/ceo/CeoPageClient.tsx create mode 100644 apps/web/app/blocks/contact-forms/ContactFormsPageClient.tsx create mode 100644 apps/web/app/blocks/contact/ContactPageClient.tsx create mode 100644 apps/web/app/blocks/doctors/DoctorsPageClient.tsx create mode 100644 apps/web/app/blocks/hero/HeroPageClient.tsx create mode 100644 apps/web/app/blocks/navigation/NavigationPageClient.tsx create mode 100644 apps/web/app/blocks/news/NewsPageClient.tsx create mode 100644 apps/web/app/blocks/reviews/ReviewsPageClient.tsx diff --git a/apps/api/prisma/migrations/20260324185731_add_block_snapshot/migration.sql b/apps/api/prisma/migrations/20260324185731_add_block_snapshot/migration.sql new file mode 100644 index 0000000..527f945 --- /dev/null +++ b/apps/api/prisma/migrations/20260324185731_add_block_snapshot/migration.sql @@ -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"); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index daa377e..60ac3cf 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -50,3 +50,16 @@ model Block { 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]) +} diff --git a/apps/api/src/blocks/blocks.controller.ts b/apps/api/src/blocks/blocks.controller.ts index f3b59bb..858bb01 100644 --- a/apps/api/src/blocks/blocks.controller.ts +++ b/apps/api/src/blocks/blocks.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, Patch, Query, Body } from '@nestjs/common'; +import { Controller, Get, Post, Patch, Param, Query, Body } from '@nestjs/common'; import { BlocksService } from './blocks.service'; @Controller('blocks') @@ -22,4 +22,22 @@ export class BlocksController { ) { 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); + } } diff --git a/apps/api/src/blocks/blocks.service.ts b/apps/api/src/blocks/blocks.service.ts index 01ab3ba..a926a92 100644 --- a/apps/api/src/blocks/blocks.service.ts +++ b/apps/api/src/blocks/blocks.service.ts @@ -20,4 +20,24 @@ export class BlocksService { 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 } }); + } } diff --git a/apps/web/app/blocks/ceo/CeoPageClient.tsx b/apps/web/app/blocks/ceo/CeoPageClient.tsx new file mode 100644 index 0000000..16aa473 --- /dev/null +++ b/apps/web/app/blocks/ceo/CeoPageClient.tsx @@ -0,0 +1,217 @@ +"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.1", + date: "24.03.2026", + changes: [ + "Адрес: «ул. Цитная, 9» заменён на «ул. Клары Цеткин, 9»", + "Цвет ссылок: #52b4bd (было #0089c3)", + ], + }, +]; + +export default function CeoPageClient() { + const [snapshot, setSnapshot] = useState(null); + + return ( +
+ {/* Заголовок страницы */} +
+

+ Блоки +

+

+ Вводный текст (CEO-блок) +

+ +

+ Блок после hero-баннера на perm.oclinica.ru/lor. Описание специализации клиники + + вопросы-стимулы для пациентов. +

+
+ + {/* Живой пример */} +
+

+ Живой пример +

+ {snapshot ? ( +
+