Browse Source

feat(sprint-5.5): add NestJS API, BlockMetaBar, block components + fix Vercel build

- Add vercel.json to build only apps/web (fix Vercel build failure)
- NestJS API: BlocksModule, BlocksController, BlocksService with Prisma 7
- PostgreSQL migration: Block model (path, version, isInPreview)
- BlockMetaBar component: inline version edit, API fetch with offline fallback
- New block components: CeoBlock, ContactFormsBlock, FooterBlock, NewsBlock, ReviewsBlock
- PreviewClient: fetch isInPreview from API, block visibility toggle
- Pages updated: hero, doctors, ceo, contact-forms, contact, news, reviews
- docker-compose: PostgreSQL on port 5434

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
main
AR 15 M4 7 days ago
parent
commit
2ed7eee63d
  1. 7
      apps/api/package.json
  2. 1
      apps/api/prisma.config.ts
  3. 53
      apps/api/prisma/migrations/20260323120441_add_block_model/migration.sql
  4. 3
      apps/api/prisma/migrations/migration_lock.toml
  5. 13
      apps/api/prisma/schema.prisma
  6. 35
      apps/api/prisma/seed.ts
  7. 3
      apps/api/src/app.module.ts
  8. 25
      apps/api/src/blocks/blocks.controller.ts
  9. 10
      apps/api/src/blocks/blocks.module.ts
  10. 23
      apps/api/src/blocks/blocks.service.ts
  11. 2
      apps/api/src/main.ts
  12. 19
      apps/api/src/prisma/prisma.service.ts
  13. 44
      apps/web/app/blocks/ceo/page.tsx
  14. 96
      apps/web/app/blocks/contact-forms/page.tsx
  15. 95
      apps/web/app/blocks/contact/page.tsx
  16. 2
      apps/web/app/blocks/doctors/page.tsx
  17. 2
      apps/web/app/blocks/hero/page.tsx
  18. 40
      apps/web/app/blocks/news/page.tsx
  19. 74
      apps/web/app/blocks/reviews/page.tsx
  20. 2
      apps/web/app/components/buttons/page.tsx
  21. 2
      apps/web/app/components/cards/page.tsx
  22. 2
      apps/web/app/components/forms/page.tsx
  23. 2
      apps/web/app/components/navigation/page.tsx
  24. 84
      apps/web/app/pages/preview/PreviewClient.tsx
  25. 45
      apps/web/components/blocks/CeoBlock.tsx
  26. 84
      apps/web/components/blocks/ContactFormsBlock.tsx
  27. 119
      apps/web/components/blocks/FooterBlock.tsx
  28. 63
      apps/web/components/blocks/NewsBlock.tsx
  29. 86
      apps/web/components/blocks/ReviewsBlock.tsx
  30. 174
      apps/web/components/ui/BlockMetaBar.tsx
  31. 2
      docker-compose.yml
  32. 492
      pnpm-lock.yaml
  33. 5
      vercel.json

7
apps/api/package.json

@ -24,8 +24,11 @@
"@nestjs/common": "^11.0.1", "@nestjs/common": "^11.0.1",
"@nestjs/core": "^11.0.1", "@nestjs/core": "^11.0.1",
"@nestjs/platform-express": "^11.0.1", "@nestjs/platform-express": "^11.0.1",
"@prisma/adapter-pg": "^7.5.0",
"@prisma/client": "^7.5.0", "@prisma/client": "^7.5.0",
"@types/pg": "^8.20.0",
"dotenv": "^17.3.1", "dotenv": "^17.3.1",
"pg": "^8.20.0",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1" "rxjs": "^7.8.1"
}, },
@ -52,9 +55,13 @@
"ts-loader": "^9.5.2", "ts-loader": "^9.5.2",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0", "tsconfig-paths": "^4.2.0",
"tsx": "^4.21.0",
"typescript": "^5.7.3", "typescript": "^5.7.3",
"typescript-eslint": "^8.20.0" "typescript-eslint": "^8.20.0"
}, },
"prisma": {
"seed": "ts-node prisma/seed.ts"
},
"jest": { "jest": {
"moduleFileExtensions": [ "moduleFileExtensions": [
"js", "js",

1
apps/api/prisma.config.ts

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

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

@ -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;

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

@ -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"

13
apps/api/prisma/schema.prisma

@ -1,6 +1,5 @@
generator client { generator client {
provider = "prisma-client" provider = "prisma-client-js"
output = "../generated/prisma"
} }
datasource db { datasource db {
@ -40,3 +39,13 @@ model ExperimentalComponent {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
model Block {
id String @id @default(uuid())
path String @unique
name String
version String
isInPreview Boolean @default(false)
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
}

35
apps/api/prisma/seed.ts

@ -0,0 +1,35 @@
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: '/components/navigation', name: 'Шапка / Навигация', version: 'v1.0', isInPreview: true },
{ path: '/blocks/hero', name: 'Hero-баннер', version: 'v1.1', isInPreview: true },
{ path: '/blocks/ceo', name: 'Вводный текст (CEO-блок)', version: 'v0.1', isInPreview: false },
{ path: '/blocks/doctors', name: 'Наши врачи', version: 'v1.1', isInPreview: true },
{ path: '/blocks/reviews', name: 'Отзывы', version: 'v0.1', isInPreview: false },
{ path: '/blocks/contact-forms', name: 'Формы записи', version: 'v0.1', isInPreview: false },
{ path: '/blocks/news', name: 'Новости', version: 'v0.1', isInPreview: false },
{ path: '/blocks/contact', name: 'Подвал / Контакт', version: 'v0.1', isInPreview: false },
];
async function main() {
for (const block of BLOCKS) {
await prisma.block.upsert({
where: { path: block.path },
update: {},
create: block,
});
}
console.log(`Seeded ${BLOCKS.length} blocks`);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(() => prisma.$disconnect());

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

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

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

@ -0,0 +1,25 @@
import { Controller, Get, Patch, Query, Body } from '@nestjs/common';
import { BlocksService } from './blocks.service';
@Controller('blocks')
export class BlocksController {
constructor(private blocks: BlocksService) {}
@Get()
findAll() {
return this.blocks.findAll();
}
@Get('by-path')
findByPath(@Query('path') path: string) {
return this.blocks.findByPath(path);
}
@Patch('by-path')
update(
@Query('path') path: string,
@Body() body: { version?: string; isInPreview?: boolean },
) {
return this.blocks.update(path, body);
}
}

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

@ -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 {}

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

@ -0,0 +1,23 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class BlocksService {
constructor(private prisma: PrismaService) {}
findAll() {
return this.prisma.block.findMany({ orderBy: { createdAt: 'asc' } });
}
async findByPath(path: string) {
const existing = await this.prisma.block.findUnique({ where: { path } });
if (existing) return existing;
return this.prisma.block.create({
data: { path, name: path, version: 'v0.1', isInPreview: false },
});
}
update(path: string, data: { version?: string; isInPreview?: boolean }) {
return this.prisma.block.update({ where: { path }, data });
}
}

2
apps/api/src/main.ts

@ -1,8 +1,10 @@
import 'dotenv/config';
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create(AppModule); const app = await NestFactory.create(AppModule);
app.enableCors({ origin: 'http://localhost:3001' });
await app.listen(process.env.PORT ?? 3000); await app.listen(process.env.PORT ?? 3000);
} }
bootstrap(); bootstrap();

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

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

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

@ -1,5 +1,7 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { LlmBlock, LlmSection, LlmTable, LlmRules } from "@/components/llm/LlmBlock"; import { LlmBlock, LlmSection, LlmTable, LlmRules } from "@/components/llm/LlmBlock";
import { BlockMetaBar } from "@/components/ui/BlockMetaBar";
import { CeoBlock, CEO_QUESTIONS } from "@/components/blocks/CeoBlock";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Вводный текст (CEO-блок). Цифровой брендбук Клиники ухо, горло, нос им. проф. Е.Н.Оленевой", title: "Вводный текст (CEO-блок). Цифровой брендбук Клиники ухо, горло, нос им. проф. Е.Н.Оленевой",
@ -46,13 +48,6 @@ const LLM_CEO_TEXT = `
Не менять стиль вопросов на другой формат Не менять стиль вопросов на другой формат
`.trim(); `.trim();
const CEO_QUESTIONS = [
"У вас болит ухо, заложен нос, першит в горле, и вы не можете понять причину?",
"Вам срочно нужен платный ЛОР в Перми или, как ещё говорят, «ухогорлонос»?",
"Заболел ребёнок?",
"Срочно ищете частные ЛОР-клиники Перми для детей 0+ и взрослых с удобным режимом работы с 9:00 до 21:00 по будням?",
"Вам назначили проведение эндоскопической операции на ухе, горле или носе?",
];
export default function CeoPage() { export default function CeoPage() {
return ( return (
@ -68,6 +63,7 @@ export default function CeoPage() {
<h1 className="text-2xl font-bold mb-2" style={{ color: "var(--bb-text)" }}> <h1 className="text-2xl font-bold mb-2" style={{ color: "var(--bb-text)" }}>
Вводный текст (CEO-блок) Вводный текст (CEO-блок)
</h1> </h1>
<BlockMetaBar path="/blocks/ceo" defaultVersion="v1.0" defaultIsInPreview={false} />
<p className="text-sm" style={{ color: "var(--bb-text-muted)" }}> <p className="text-sm" style={{ color: "var(--bb-text-muted)" }}>
Блок после hero-баннера на perm.oclinica.ru/lor. Описание специализации клиники Блок после hero-баннера на perm.oclinica.ru/lor. Описание специализации клиники
+ вопросы-стимулы для пациентов. + вопросы-стимулы для пациентов.
@ -79,38 +75,8 @@ export default function CeoPage() {
<h2 className="font-semibold text-base" style={{ color: "var(--bb-text)" }}> <h2 className="font-semibold text-base" style={{ color: "var(--bb-text)" }}>
Живой пример Живой пример
</h2> </h2>
<div <div className="rounded-xl overflow-hidden" style={{ border: "1px solid var(--bb-border)" }}>
className="rounded-xl p-8" <CeoBlock />
style={{
background: "#fff",
border: "1px solid var(--bb-border)",
fontFamily: "var(--font-web)",
fontSize: 14,
lineHeight: 1.75,
color: "#374151",
}}
>
<p>
Клиника ухо, нос специализируется на оториноларингологии лечении взрослых и детей
с ЛОР заболеваниями. ЛОР клиника представлена на двух адресах:{" "}
<a href="#" style={{ color: "#0089c3" }}>ул. Цитная, 9</a>, <a href="#" style={{ color: "#0089c3" }}>ул. Г. Звезда, 31а</a>.{" "}
Это <a href="#" style={{ color: "#0089c3" }}>Клиника лечения кашля и аллергии</a>.
</p>
<div className="mt-4 space-y-2">
{CEO_QUESTIONS.map((q) => (
<p key={q}> {q}</p>
))}
</div>
<p className="mt-4">
Обращайтесь в ЛОР центр ухо, горло, нос в Перми, наши врачи оториноларингологи обязательно Вам помогут!
</p>
<p className="mt-2">
Клиника ЛОР болезней ухо, горло, нос это наиболее современный центр оториноларингологии в Перми,
благодаря эндоскопическому оборудованию, высокому профессионализму оториноларингологов.
</p>
<p className="mt-4 font-medium">Будьте здоровы!</p>
</div> </div>
</section> </section>

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

@ -1,5 +1,7 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { LlmBlock, LlmSection, LlmTable, LlmRules } from "@/components/llm/LlmBlock"; import { LlmBlock, LlmSection, LlmTable, LlmRules } from "@/components/llm/LlmBlock";
import { BlockMetaBar } from "@/components/ui/BlockMetaBar";
import { ContactFormsBlock } from "@/components/blocks/ContactFormsBlock";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Формы записи. Цифровой брендбук Клиники ухо, горло, нос им. проф. Е.Н.Оленевой", title: "Формы записи. Цифровой брендбук Клиники ухо, горло, нос им. проф. Е.Н.Оленевой",
@ -62,104 +64,18 @@ export default function ContactFormsPage() {
<h1 className="text-2xl font-bold mb-2" style={{ color: "var(--bb-text)" }}> <h1 className="text-2xl font-bold mb-2" style={{ color: "var(--bb-text)" }}>
Формы записи Формы записи
</h1> </h1>
<BlockMetaBar path="/blocks/contact-forms" defaultVersion="v1.0" defaultIsInPreview={false} />
<p className="text-sm" style={{ color: "var(--bb-text-muted)" }}> <p className="text-sm" style={{ color: "var(--bb-text-muted)" }}>
Два блока форм с perm.oclinica.ru/lor запись на приём и запрос стоимости операции. Два блока форм с perm.oclinica.ru/lor запись на приём и запрос стоимости операции.
</p> </p>
</div> </div>
{/* Форма 1: Будьте здоровы */} {/* Живой пример */}
<section className="space-y-3"> <section className="space-y-3">
<h2 className="font-semibold text-base" style={{ color: "var(--bb-text)" }}> <h2 className="font-semibold text-base" style={{ color: "var(--bb-text)" }}>
Форма 1 «Будьте здоровы!» Живой пример
</h2> </h2>
<p className="text-sm" style={{ color: "var(--bb-text-muted)" }}> <ContactFormsBlock />
Фон секции: #b8e6ed. Запись на приём к врачу.
</p>
<div
className="rounded-xl py-10 px-8 flex flex-col items-center gap-6"
style={{ background: "#b8e6ed" }}
>
<div className="text-center">
<h2 className="text-2xl font-bold mb-1" style={{ color: "#111827" }}>
Будьте здоровы!
</h2>
<p className="text-sm" style={{ color: "#374151" }}>
Запишитесь на приём к врачу!
</p>
</div>
<div className="w-full max-w-sm space-y-3">
<input
className="bb-input"
type="text"
placeholder="Введите ваше имя"
readOnly
/>
<input
className="bb-input"
type="tel"
placeholder="Введите ваш телефон"
readOnly
/>
<select className="bb-select" disabled>
<option>Выберите ЛОР врача</option>
</select>
<label className="flex items-start gap-2 text-xs cursor-pointer" style={{ color: "#374151" }}>
<input type="checkbox" className="bb-checkbox mt-0.5" readOnly />
<span>
Отправляя данные, я даю согласие на обработку персональных данных
</span>
</label>
<button className="bb-btn bb-btn-md bb-btn-primary w-full">
Запишите меня!
</button>
</div>
</div>
</section>
{/* Форма 2: Узнайте стоимость */}
<section className="space-y-3">
<h2 className="font-semibold text-base" style={{ color: "var(--bb-text)" }}>
Форма 2 «Узнайте стоимость операции»
</h2>
<p className="text-sm" style={{ color: "var(--bb-text-muted)" }}>
Фон секции: #ffffff. Запрос консультации по стоимости.
</p>
<div
className="rounded-xl py-10 px-8 flex flex-col items-center gap-6"
style={{ background: "#fff", border: "1px solid var(--bb-border)" }}
>
<div className="text-center">
<h2 className="text-2xl font-bold mb-1" style={{ color: "#111827" }}>
Узнайте стоимость операции
</h2>
<p className="text-sm" style={{ color: "#374151" }}>
Проконсультируйтесь с ассистентом хирурга
</p>
</div>
<div className="w-full max-w-sm space-y-3">
<input
className="bb-input"
type="text"
placeholder="Введите ваше имя"
readOnly
/>
<input
className="bb-input"
type="tel"
placeholder="Введите ваш телефон"
readOnly
/>
<label className="flex items-start gap-2 text-xs cursor-pointer" style={{ color: "#374151" }}>
<input type="checkbox" className="bb-checkbox mt-0.5" readOnly />
<span>
Отправляя данные, я даю согласие на обработку персональных данных
</span>
</label>
<button className="bb-btn bb-btn-md bb-btn-primary w-full">
Перезвоните мне
</button>
</div>
</div>
</section> </section>
{/* Сравнение двух форм */} {/* Сравнение двух форм */}

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

@ -1,5 +1,7 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { LlmBlock, LlmSection, LlmTable, LlmRules } from "@/components/llm/LlmBlock"; import { LlmBlock, LlmSection, LlmTable, LlmRules } from "@/components/llm/LlmBlock";
import { BlockMetaBar } from "@/components/ui/BlockMetaBar";
import { FooterBlock } from "@/components/blocks/FooterBlock";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Подвал (Footer). Цифровой брендбук Клиники ухо, горло, нос им. проф. Е.Н.Оленевой", title: "Подвал (Footer). Цифровой брендбук Клиники ухо, горло, нос им. проф. Е.Н.Оленевой",
@ -82,6 +84,7 @@ export default function ContactFooterPage() {
<h1 className="text-2xl font-bold mb-2" style={{ color: "var(--bb-text)" }}> <h1 className="text-2xl font-bold mb-2" style={{ color: "var(--bb-text)" }}>
Подвал (Footer) Подвал (Footer)
</h1> </h1>
<BlockMetaBar path="/blocks/contact" defaultVersion="v1.0" defaultIsInPreview={false} />
<p className="text-sm" style={{ color: "var(--bb-text-muted)" }}> <p className="text-sm" style={{ color: "var(--bb-text-muted)" }}>
Подвал сайта с perm.oclinica.ru 4 колонки ссылок, логотип, адрес, часы работы, соцсети. Подвал сайта с perm.oclinica.ru 4 колонки ссылок, логотип, адрес, часы работы, соцсети.
</p> </p>
@ -92,97 +95,7 @@ export default function ContactFooterPage() {
<h2 className="font-semibold text-base" style={{ color: "var(--bb-text)" }}> <h2 className="font-semibold text-base" style={{ color: "var(--bb-text)" }}>
Живой пример Живой пример
</h2> </h2>
<div <FooterBlock />
className="rounded-xl overflow-hidden"
style={{ border: "1px solid var(--bb-border)" }}
>
{/* 4 колонки ссылок */}
<div
className="grid grid-cols-4 gap-6 p-8"
style={{ background: "#f8f9fa", borderBottom: "1px solid #e5e7eb" }}
>
{FOOTER_COLUMNS.map((col) => (
<div key={col.title}>
<p className="font-semibold text-sm mb-3" style={{ color: "#111827" }}>
{col.title}
</p>
<ul className="space-y-1.5">
{col.links.map((link) => (
<li key={link}>
<a
href="#"
className="text-xs bb-nav-link"
style={{ color: "#53514e", textDecoration: "none" }}
>
{link}
</a>
</li>
))}
</ul>
</div>
))}
</div>
{/* Нижняя полоса */}
<div
className="flex items-start justify-between gap-6 px-8 py-5"
style={{ background: "#fff" }}
>
{/* Логотип */}
<div className="flex items-center gap-2 shrink-0">
<div
className="w-10 h-10 rounded-full flex items-center justify-center text-white font-bold"
style={{ background: "#0089c3" }}
>
</div>
<div>
<div
className="text-[10px] font-bold uppercase leading-tight"
style={{ color: "#53514e" }}
>
Клиника<br />ухо, горло, нос
</div>
<div className="text-[8px] leading-tight" style={{ color: "#9ca3af" }}>
им. проф. Е.Н.Оленевой
</div>
</div>
</div>
{/* Адрес и соцсети */}
<div className="text-center space-y-2">
<p className="text-xs" style={{ color: "#374151" }}>
Мы находимся по адресу: Пермь, ул. Г. Звезда, 31а
</p>
<div className="flex items-center justify-center gap-2">
{["VK", "OK", "YT", "TG"].map((s) => (
<a
key={s}
href="#"
className="text-[11px] px-2 py-1 rounded"
style={{
background: "var(--bb-sidebar-bg)",
border: "1px solid var(--bb-border)",
color: "var(--bb-text-muted)",
}}
>
{s}
</a>
))}
</div>
</div>
{/* Часы работы */}
<div className="text-right text-xs space-y-1" style={{ color: "#374151" }}>
<p className="font-semibold text-xs mb-1" style={{ color: "#111827" }}>
Часы работы:
</p>
<p>Пнпт: 9:0021:00</p>
<p>Сб: 9:0018:00</p>
<p>Вс: выходной</p>
</div>
</div>
</div>
</section> </section>
{/* Колонки */} {/* Колонки */}

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

@ -1,6 +1,7 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { LlmBlock, LlmSection, LlmTable, LlmRules } from "@/components/llm/LlmBlock"; import { LlmBlock, LlmSection, LlmTable, LlmRules } from "@/components/llm/LlmBlock";
import { DoctorsBlock, STATS, DOCTORS } from "@/components/blocks/DoctorsBlock"; import { DoctorsBlock, STATS, DOCTORS } from "@/components/blocks/DoctorsBlock";
import { BlockMetaBar } from "@/components/ui/BlockMetaBar";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Блок «Наши врачи». Цифровой брендбук Клиники ухо, горло, нос им. проф. Е.Н.Оленевой", title: "Блок «Наши врачи». Цифровой брендбук Клиники ухо, горло, нос им. проф. Е.Н.Оленевой",
@ -58,6 +59,7 @@ export default function DoctorsBlockPage() {
<h1 className="text-2xl font-bold mb-2" style={{ color: "var(--bb-text)" }}> <h1 className="text-2xl font-bold mb-2" style={{ color: "var(--bb-text)" }}>
Блок «Наши врачи» Блок «Наши врачи»
</h1> </h1>
<BlockMetaBar path="/blocks/doctors" defaultVersion="v1.1" defaultIsInPreview={true} />
<p className="text-sm" style={{ color: "var(--bb-text-muted)" }}> <p className="text-sm" style={{ color: "var(--bb-text-muted)" }}>
Блок на странице perm.oclinica.ru/lor заголовок, 3 стат-блока, сетка из 6 карточек врачей. Блок на странице perm.oclinica.ru/lor заголовок, 3 стат-блока, сетка из 6 карточек врачей.
</p> </p>

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

@ -1,6 +1,7 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { LlmBlock, LlmSection, LlmTable, LlmRules } from "@/components/llm/LlmBlock"; import { LlmBlock, LlmSection, LlmTable, LlmRules } from "@/components/llm/LlmBlock";
import { HeroBlock, HERO_CHECKS } from "@/components/blocks/HeroBlock"; import { HeroBlock, HERO_CHECKS } from "@/components/blocks/HeroBlock";
import { BlockMetaBar } from "@/components/ui/BlockMetaBar";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Hero-баннер. Цифровой брендбук Клиники ухо, горло, нос им. проф. Е.Н.Оленевой", title: "Hero-баннер. Цифровой брендбук Клиники ухо, горло, нос им. проф. Е.Н.Оленевой",
@ -63,6 +64,7 @@ export default function HeroPage() {
<h1 className="text-2xl font-bold mb-2" style={{ color: "var(--bb-text)" }}> <h1 className="text-2xl font-bold mb-2" style={{ color: "var(--bb-text)" }}>
Hero-баннер Hero-баннер
</h1> </h1>
<BlockMetaBar path="/blocks/hero" defaultVersion="v1.1" defaultIsInPreview={true} />
<p className="text-sm" style={{ color: "var(--bb-text-muted)" }}> <p className="text-sm" style={{ color: "var(--bb-text-muted)" }}>
Главный баннер страницы раздела ЛОР perm.oclinica.ru/lor. Двухколоночный блок, единый светло-кремовый фон{" "} Главный баннер страницы раздела ЛОР perm.oclinica.ru/lor. Двухколоночный блок, единый светло-кремовый фон{" "}
<strong>#f9f4e7</strong>. <strong>#f9f4e7</strong>.

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

@ -1,5 +1,7 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { LlmBlock, LlmSection, LlmTable, LlmRules } from "@/components/llm/LlmBlock"; import { LlmBlock, LlmSection, LlmTable, LlmRules } from "@/components/llm/LlmBlock";
import { BlockMetaBar } from "@/components/ui/BlockMetaBar";
import { NewsBlock } from "@/components/blocks/NewsBlock";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Блок «Новости». Цифровой брендбук Клиники ухо, горло, нос им. проф. Е.Н.Оленевой", title: "Блок «Новости». Цифровой брендбук Клиники ухо, горло, нос им. проф. Е.Н.Оленевой",
@ -79,6 +81,7 @@ export default function NewsBlockPage() {
<h1 className="text-2xl font-bold mb-2" style={{ color: "var(--bb-text)" }}> <h1 className="text-2xl font-bold mb-2" style={{ color: "var(--bb-text)" }}>
Блок «Новости» Блок «Новости»
</h1> </h1>
<BlockMetaBar path="/blocks/news" defaultVersion="v1.0" defaultIsInPreview={false} />
<p className="text-sm" style={{ color: "var(--bb-text-muted)" }}> <p className="text-sm" style={{ color: "var(--bb-text-muted)" }}>
Блок новостей с perm.oclinica.ru/lor 4 карточки в ряд (дата + заголовок-ссылка), Блок новостей с perm.oclinica.ru/lor 4 карточки в ряд (дата + заголовок-ссылка),
кнопка «Все новости». кнопка «Все новости».
@ -90,42 +93,7 @@ export default function NewsBlockPage() {
<h2 className="font-semibold text-base" style={{ color: "var(--bb-text)" }}> <h2 className="font-semibold text-base" style={{ color: "var(--bb-text)" }}>
Живой пример Живой пример
</h2> </h2>
<div <NewsBlock />
className="rounded-xl p-8 space-y-6"
style={{ background: "#fff", border: "1px solid var(--bb-border)" }}
>
<h2 className="text-xl font-bold" style={{ color: "#111827" }}>
Новости
</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{MOCK_NEWS.map((n) => (
<div
key={n.date}
className="bb-news-card rounded-lg p-3 cursor-pointer"
style={{
border: "1px solid var(--bb-border)",
transition: "background 0.15s, box-shadow 0.15s",
}}
>
<p className="text-xs mb-2" style={{ color: "#6b7280" }}>
{n.date}
</p>
<a
href={n.href}
className="text-sm font-medium leading-snug block"
style={{ color: "#0089c3", textDecoration: "none" }}
>
{n.title}
</a>
</div>
))}
</div>
<div className="text-center">
<button className="bb-btn bb-btn-md bb-btn-outline">
Все новости
</button>
</div>
</div>
</section> </section>
{/* Анатомия карточки */} {/* Анатомия карточки */}

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

@ -1,5 +1,7 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { LlmBlock, LlmSection, LlmTable, LlmRules } from "@/components/llm/LlmBlock"; import { LlmBlock, LlmSection, LlmTable, LlmRules } from "@/components/llm/LlmBlock";
import { BlockMetaBar } from "@/components/ui/BlockMetaBar";
import { ReviewsBlock } from "@/components/blocks/ReviewsBlock";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Блок «Отзывы». Цифровой брендбук Клиники ухо, горло, нос им. проф. Е.Н.Оленевой", title: "Блок «Отзывы». Цифровой брендбук Клиники ухо, горло, нос им. проф. Е.Н.Оленевой",
@ -64,6 +66,7 @@ export default function ReviewsBlockPage() {
<h1 className="text-2xl font-bold mb-2" style={{ color: "var(--bb-text)" }}> <h1 className="text-2xl font-bold mb-2" style={{ color: "var(--bb-text)" }}>
Блок «Отзывы о нас» Блок «Отзывы о нас»
</h1> </h1>
<BlockMetaBar path="/blocks/reviews" defaultVersion="v1.0" defaultIsInPreview={false} />
<p className="text-sm" style={{ color: "var(--bb-text-muted)" }}> <p className="text-sm" style={{ color: "var(--bb-text-muted)" }}>
Карусель отзывов с perm.oclinica.ru/lor большая кавычка, текст, «Читать полностью», стрелки. Карусель отзывов с perm.oclinica.ru/lor большая кавычка, текст, «Читать полностью», стрелки.
</p> </p>
@ -74,76 +77,7 @@ export default function ReviewsBlockPage() {
<h2 className="font-semibold text-base" style={{ color: "var(--bb-text)" }}> <h2 className="font-semibold text-base" style={{ color: "var(--bb-text)" }}>
Живой пример Живой пример
</h2> </h2>
<div <ReviewsBlock />
className="rounded-xl p-8 space-y-6"
style={{ background: "#fff", border: "1px solid var(--bb-border)" }}
>
{/* Заголовок */}
<div>
<h2 className="text-xl font-bold mb-2" style={{ color: "#111827" }}>
Отзывы о нас
</h2>
<p className="text-sm" style={{ color: "#374151", lineHeight: 1.7 }}>
За 12 лет работы наши врачи оториноларингологи избавили от болезней ухо, горло, носа
более 50 000 пациентов. Но даже сейчас мы высоко ценим каждый положительный отзыв
и искренние слова благодарности.
</p>
</div>
{/* Карусель (статичная) */}
<div className="relative flex items-center gap-4">
{/* Стрелка влево */}
<button
className="shrink-0 w-10 h-10 rounded-full flex items-center justify-center font-bold text-lg"
style={{
background: "var(--bb-sidebar-bg)",
border: "1px solid var(--bb-border)",
color: "var(--bb-text-muted)",
}}
>
</button>
{/* Карточка отзыва */}
<div
className="flex-1 rounded-xl p-6 relative"
style={{ background: "#eef4d1" }}
>
{/* Декоративная кавычка */}
<div
className="text-8xl leading-none mb-2 font-serif"
style={{ color: "#b8e6ed", lineHeight: 0.8 }}
>
«
</div>
<p
className="text-sm italic leading-relaxed"
style={{ color: "#374151" }}
>
{MOCK_REVIEWS[0].text}
</p>
<a
href="#"
className="inline-block mt-3 text-sm"
style={{ color: "#0089c3" }}
>
Читать отзыв полностью
</a>
</div>
{/* Стрелка вправо */}
<button
className="shrink-0 w-10 h-10 rounded-full flex items-center justify-center font-bold text-lg"
style={{
background: "var(--bb-sidebar-bg)",
border: "1px solid var(--bb-border)",
color: "var(--bb-text-muted)",
}}
>
</button>
</div>
</div>
</section> </section>
{/* Несколько примеров */} {/* Несколько примеров */}

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

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

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

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

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

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

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

@ -1,6 +1,7 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { LlmBlock, LlmSection, LlmTable, LlmRules } from "@/components/llm/LlmBlock"; import { LlmBlock, LlmSection, LlmTable, LlmRules } from "@/components/llm/LlmBlock";
import { NavigationBlock, NAV_ITEMS } from "@/components/blocks/NavigationBlock"; import { NavigationBlock, NAV_ITEMS } from "@/components/blocks/NavigationBlock";
import { BlockMetaBar } from "@/components/ui/BlockMetaBar";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Шапка и навигация. Цифровой брендбук Клиники ухо, горло, нос им. проф. Е.Н.Оленевой", title: "Шапка и навигация. Цифровой брендбук Клиники ухо, горло, нос им. проф. Е.Н.Оленевой",
@ -57,6 +58,7 @@ export default function NavigationPage() {
<h1 className="text-2xl font-bold mb-2" style={{ color: "var(--bb-text)" }}> <h1 className="text-2xl font-bold mb-2" style={{ color: "var(--bb-text)" }}>
Шапка и навигация Шапка и навигация
</h1> </h1>
<BlockMetaBar path="/components/navigation" defaultVersion="v1.0" defaultIsInPreview={true} />
<p className="text-sm" style={{ color: "var(--bb-text-muted)" }}> <p className="text-sm" style={{ color: "var(--bb-text-muted)" }}>
Точное воспроизведение шапки с perm.oclinica.ru/lor. Три зоны: топ-бар, логотип, главное меню. Точное воспроизведение шапки с perm.oclinica.ru/lor. Три зоны: топ-бар, логотип, главное меню.
</p> </p>

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

@ -4,6 +4,11 @@ import { useState, useEffect } from "react";
import { NavigationBlock } from "@/components/blocks/NavigationBlock"; import { NavigationBlock } from "@/components/blocks/NavigationBlock";
import { HeroBlock } from "@/components/blocks/HeroBlock"; import { HeroBlock } from "@/components/blocks/HeroBlock";
import { DoctorsBlock } from "@/components/blocks/DoctorsBlock"; 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 STORAGE_KEY = "bb-preview-created";
@ -30,71 +35,89 @@ function BlockPlaceholder({ name, href }: { name: string; href: string }) {
); );
} }
const BLOCKS: Array<{ interface BlockDef {
id: string; id: string;
name: string; name: string;
href: string; href: string;
ready: boolean; path: string;
defaultReady: boolean;
component?: React.ReactNode; component?: React.ReactNode;
}> = [ }
const BLOCKS: BlockDef[] = [
{ {
id: "navigation", id: "navigation",
name: "Шапка / Навигация", name: "Шапка / Навигация",
href: "/components/navigation", href: "/components/navigation",
ready: true, path: "/components/navigation",
defaultReady: true,
component: <NavigationBlock />, component: <NavigationBlock />,
}, },
{ {
id: "hero", id: "hero",
name: "Hero-баннер", name: "Hero-баннер",
href: "/blocks/hero", href: "/blocks/hero",
ready: true, path: "/blocks/hero",
defaultReady: true,
component: <HeroBlock />, component: <HeroBlock />,
}, },
{ {
id: "ceo", id: "ceo",
name: "Вводный текст (CEO-блок)", name: "Вводный текст (CEO-блок)",
href: "/blocks/ceo", href: "/blocks/ceo",
ready: false, path: "/blocks/ceo",
defaultReady: false,
component: <CeoBlock />,
}, },
{ {
id: "doctors", id: "doctors",
name: "Наши врачи", name: "Наши врачи",
href: "/blocks/doctors", href: "/blocks/doctors",
ready: true, path: "/blocks/doctors",
defaultReady: true,
component: <DoctorsBlock />, component: <DoctorsBlock />,
}, },
{ {
id: "reviews", id: "reviews",
name: "Отзывы", name: "Отзывы",
href: "/blocks/reviews", href: "/blocks/reviews",
ready: false, path: "/blocks/reviews",
defaultReady: false,
component: <ReviewsBlock />,
}, },
{ {
id: "contact-forms", id: "contact-forms",
name: "Формы записи", name: "Формы записи",
href: "/blocks/contact-forms", href: "/blocks/contact-forms",
ready: false, path: "/blocks/contact-forms",
defaultReady: false,
component: <ContactFormsBlock />,
}, },
{ {
id: "news", id: "news",
name: "Новости", name: "Новости",
href: "/blocks/news", href: "/blocks/news",
ready: false, path: "/blocks/news",
defaultReady: false,
component: <NewsBlock />,
}, },
{ {
id: "footer", id: "footer",
name: "Подвал / Контакт", name: "Подвал / Контакт",
href: "/blocks/contact", href: "/blocks/contact",
ready: false, path: "/blocks/contact",
defaultReady: false,
component: <FooterBlock />,
}, },
]; ];
const READY_COUNT = BLOCKS.filter((b) => b.ready).length;
export function PreviewClient() { export function PreviewClient() {
const [created, setCreated] = useState(false); const [created, setCreated] = useState(false);
const [mounted, setMounted] = 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;
useEffect(() => { useEffect(() => {
setMounted(true); setMounted(true);
@ -103,6 +126,27 @@ export function PreviewClient() {
} }
}, []); }, []);
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 {
if (apiMeta !== null && block.path in apiMeta) {
return apiMeta[block.path] && !!block.component;
}
return block.defaultReady && !!block.component;
}
function handleCreate() { function handleCreate() {
localStorage.setItem(STORAGE_KEY, "true"); localStorage.setItem(STORAGE_KEY, "true");
setCreated(true); setCreated(true);
@ -113,9 +157,10 @@ export function PreviewClient() {
setCreated(false); setCreated(false);
} }
// Avoid hydration mismatch — render nothing until mounted
if (!mounted) return null; if (!mounted) return null;
const readyCount = BLOCKS.filter(isReady).length;
/* ── ПУСТОЕ СОСТОЯНИЕ ── */ /* ── ПУСТОЕ СОСТОЯНИЕ ── */
if (!created) { if (!created) {
return ( return (
@ -147,11 +192,11 @@ export function PreviewClient() {
style={{ background: "#22c55e" }} style={{ background: "#22c55e" }}
/> />
<span style={{ color: "var(--bb-text-muted)" }}> <span style={{ color: "var(--bb-text-muted)" }}>
Готово блоков: {READY_COUNT} из {BLOCKS.length} Готово блоков: {readyCount} из {BLOCKS.length}
</span> </span>
<span style={{ color: "var(--bb-text-muted)" }}>·</span> <span style={{ color: "var(--bb-text-muted)" }}>·</span>
<span style={{ color: "var(--bb-text-muted)" }}> <span style={{ color: "var(--bb-text-muted)" }}>
{BLOCKS.length - READY_COUNT} плейсхолдеров {BLOCKS.length - readyCount} плейсхолдеров
</span> </span>
</div> </div>
<div> <div>
@ -183,7 +228,10 @@ export function PreviewClient() {
Просмотр текущей страницы Просмотр текущей страницы
</p> </p>
<p className="text-xs mt-0.5" style={{ color: "var(--bb-text-muted)" }}> <p className="text-xs mt-0.5" style={{ color: "var(--bb-text-muted)" }}>
perm.oclinica.ru/lor · {READY_COUNT}/{BLOCKS.length} блоков готово perm.oclinica.ru/lor · {readyCount}/{BLOCKS.length} блоков готово
{apiMeta === null && apiUrl && (
<span style={{ color: "#f59e0b" }}> · API загружается...</span>
)}
</p> </p>
</div> </div>
<button onClick={handleRebuild} className="bb-btn bb-btn-sm bb-btn-outline"> <button onClick={handleRebuild} className="bb-btn bb-btn-sm bb-btn-outline">
@ -194,7 +242,7 @@ export function PreviewClient() {
{/* Собранная страница */} {/* Собранная страница */}
<div className="max-w-5xl mx-auto px-8 py-8 space-y-12"> <div className="max-w-5xl mx-auto px-8 py-8 space-y-12">
{BLOCKS.map((block) => {BLOCKS.map((block) =>
block.ready && block.component ? ( isReady(block) ? (
<section key={block.id}>{block.component}</section> <section key={block.id}>{block.component}</section>
) : ( ) : (
<section key={block.id}> <section key={block.id}>

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

@ -0,0 +1,45 @@
export const CEO_QUESTIONS = [
"У вас болит ухо, заложен нос, першит в горле, и вы не можете понять причину?",
"Вам срочно нужен платный ЛОР в Перми или, как ещё говорят, «ухогорлонос»?",
"Заболел ребёнок?",
"Срочно ищете частные ЛОР-клиники Перми для детей 0+ и взрослых с удобным режимом работы с 9:00 до 21:00 по будням?",
"Вам назначили проведение эндоскопической операции на ухе, горле или носе?",
];
export function CeoBlock() {
return (
<div
style={{
background: "#fff",
fontFamily: "var(--font-web, 'Fira Sans', sans-serif)",
fontSize: 14,
lineHeight: 1.75,
color: "#374151",
padding: "40px 48px",
}}
>
<p>
Клиника ухо, нос специализируется на оториноларингологии лечении взрослых и детей
с ЛОР заболеваниями. ЛОР клиника представлена на двух адресах:{" "}
<a href="#" style={{ color: "#0089c3" }}>ул. Цитная, 9</a>,{" "}
<a href="#" style={{ color: "#0089c3" }}>ул. Г. Звезда, 31а</a>.{" "}
Это <a href="#" style={{ color: "#0089c3" }}>Клиника лечения кашля и аллергии</a>.
</p>
<div style={{ marginTop: 16 }}>
{CEO_QUESTIONS.map((q) => (
<p key={q} style={{ marginBottom: 8 }}> {q}</p>
))}
</div>
<p style={{ marginTop: 16 }}>
Обращайтесь в ЛОР центр ухо, горло, нос в Перми, наши врачи оториноларингологи обязательно Вам помогут!
</p>
<p style={{ marginTop: 8 }}>
Клиника ЛОР болезней ухо, горло, нос это наиболее современный центр оториноларингологии в Перми,
благодаря эндоскопическому оборудованию, высокому профессионализму оториноларингологов.
</p>
<p style={{ marginTop: 16, fontWeight: 500 }}>Будьте здоровы!</p>
</div>
);
}

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

@ -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: "#b8e6ed" }}
>
<div className="text-center">
<h2 className="text-2xl font-bold mb-1" style={{ color: "#111827" }}>
Будьте здоровы!
</h2>
<p className="text-sm" style={{ color: "#374151" }}>
Запишитесь на приём к врачу!
</p>
</div>
<div className="w-full max-w-sm space-y-3">
<input
className="bb-input"
type="text"
placeholder="Введите ваше имя"
readOnly
/>
<input
className="bb-input"
type="tel"
placeholder="Введите ваш телефон"
readOnly
/>
<select className="bb-select" disabled>
<option>Выберите ЛОР врача</option>
</select>
<label className="flex items-start gap-2 text-xs cursor-pointer" style={{ color: "#374151" }}>
<input type="checkbox" className="bb-checkbox mt-0.5" readOnly />
<span>
Отправляя данные, я даю согласие на обработку персональных данных
</span>
</label>
<button className="bb-btn bb-btn-md bb-btn-primary w-full">
Запишите меня!
</button>
</div>
</div>
{/* Форма 2: Узнайте стоимость */}
<div
className="rounded-xl py-10 px-8 flex flex-col items-center gap-6"
style={{ background: "#fff", border: "1px solid var(--bb-border)" }}
>
<div className="text-center">
<h2 className="text-2xl font-bold mb-1" style={{ color: "#111827" }}>
Узнайте стоимость операции
</h2>
<p className="text-sm" style={{ color: "#374151" }}>
Проконсультируйтесь с ассистентом хирурга
</p>
</div>
<div className="w-full max-w-sm space-y-3">
<input
className="bb-input"
type="text"
placeholder="Введите ваше имя"
readOnly
/>
<input
className="bb-input"
type="tel"
placeholder="Введите ваш телефон"
readOnly
/>
<label className="flex items-start gap-2 text-xs cursor-pointer" style={{ color: "#374151" }}>
<input type="checkbox" className="bb-checkbox mt-0.5" readOnly />
<span>
Отправляя данные, я даю согласие на обработку персональных данных
</span>
</label>
<button className="bb-btn bb-btn-md bb-btn-primary w-full">
Перезвоните мне
</button>
</div>
</div>
</div>
);
}

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

@ -0,0 +1,119 @@
const FOOTER_COLUMNS = [
{
title: "О клинике",
links: ["Лицензия", "Миссия", "Врачи", "Вакансии", "История", "Образовательная деятельность", "При инфо"],
},
{
title: "Заболевания",
links: ["Ринит", "Отит", "Гайморит", "Тонзиллит", "Полипы носа", "Искривление перегородки"],
},
{
title: "Вопрос-ответ",
links: [
"Что нужно знать до операции на ухо",
"Что нужно знать до операции на нос",
"Отзывы до и после лечения у детей",
"Что нужно знать при лечении у детей",
],
},
{
title: "Операции",
links: ["Септопластика", "Турбинопластика", "Тонзиллэктомия", "Аденотомия", "Тимпанопластика", "Мирингопластика"],
},
];
export function FooterBlock() {
return (
<div
className="rounded-xl overflow-hidden"
style={{ border: "1px solid var(--bb-border)" }}
>
{/* 4 колонки ссылок */}
<div
className="grid grid-cols-4 gap-6 p-8"
style={{ background: "#f8f9fa", borderBottom: "1px solid #e5e7eb" }}
>
{FOOTER_COLUMNS.map((col) => (
<div key={col.title}>
<p className="font-semibold text-sm mb-3" style={{ color: "#111827" }}>
{col.title}
</p>
<ul className="space-y-1.5">
{col.links.map((link) => (
<li key={link}>
<a
href="#"
className="text-xs bb-nav-link"
style={{ color: "#53514e", textDecoration: "none" }}
>
{link}
</a>
</li>
))}
</ul>
</div>
))}
</div>
{/* Нижняя полоса */}
<div
className="flex items-start justify-between gap-6 px-8 py-5"
style={{ background: "#fff" }}
>
{/* Логотип */}
<div className="flex items-center gap-2 shrink-0">
<div
className="w-10 h-10 rounded-full flex items-center justify-center text-white font-bold"
style={{ background: "#0089c3" }}
>
</div>
<div>
<div
className="text-[10px] font-bold uppercase leading-tight"
style={{ color: "#53514e" }}
>
Клиника<br />ухо, горло, нос
</div>
<div className="text-[8px] leading-tight" style={{ color: "#9ca3af" }}>
им. проф. Е.Н.Оленевой
</div>
</div>
</div>
{/* Адрес и соцсети */}
<div className="text-center space-y-2">
<p className="text-xs" style={{ color: "#374151" }}>
Мы находимся по адресу: Пермь, ул. Г. Звезда, 31а
</p>
<div className="flex items-center justify-center gap-2">
{["VK", "OK", "YT", "TG"].map((s) => (
<a
key={s}
href="#"
className="text-[11px] px-2 py-1 rounded"
style={{
background: "var(--bb-sidebar-bg)",
border: "1px solid var(--bb-border)",
color: "var(--bb-text-muted)",
}}
>
{s}
</a>
))}
</div>
</div>
{/* Часы работы */}
<div className="text-right text-xs space-y-1" style={{ color: "#374151" }}>
<p className="font-semibold text-xs mb-1" style={{ color: "#111827" }}>
Часы работы:
</p>
<p>Пнпт: 9:0021:00</p>
<p>Сб: 9:0018:00</p>
<p>Вс: выходной</p>
</div>
</div>
</div>
);
}

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

@ -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: "#fff", border: "1px solid var(--bb-border)" }}
>
<h2 className="text-xl font-bold" style={{ color: "#111827" }}>
Новости
</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{MOCK_NEWS.map((n) => (
<div
key={n.date}
className="bb-news-card rounded-lg p-3 cursor-pointer"
style={{
border: "1px solid var(--bb-border)",
transition: "background 0.15s, box-shadow 0.15s",
}}
>
<p className="text-xs mb-2" style={{ color: "#6b7280" }}>
{n.date}
</p>
<a
href={n.href}
className="text-sm font-medium leading-snug block"
style={{ color: "#0089c3", textDecoration: "none" }}
>
{n.title}
</a>
</div>
))}
</div>
<div className="text-center">
<button className="bb-btn bb-btn-md bb-btn-outline">
Все новости
</button>
</div>
</div>
);
}

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

@ -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="text-xl font-bold mb-2" style={{ color: "#111827" }}>
Отзывы о нас
</h2>
<p className="text-sm" style={{ color: "#374151", lineHeight: 1.7 }}>
За 12 лет работы наши врачи оториноларингологи избавили от болезней ухо, горло, носа
более 50 000 пациентов. Но даже сейчас мы высоко ценим каждый положительный отзыв
и искренние слова благодарности.
</p>
</div>
{/* Карусель */}
<div className="relative flex items-center gap-4">
{/* Стрелка влево */}
<button
className="shrink-0 w-10 h-10 rounded-full flex items-center justify-center font-bold text-lg"
style={{
background: "var(--bb-sidebar-bg)",
border: "1px solid var(--bb-border)",
color: "var(--bb-text-muted)",
}}
>
</button>
{/* Карточка отзыва */}
<div
className="flex-1 rounded-xl p-6"
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>
);
}

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

@ -0,0 +1,174 @@
"use client";
import { useState, useEffect } from "react";
interface BlockMeta {
path: string;
name: string;
version: string;
isInPreview: boolean;
}
interface BlockMetaBarProps {
path: string;
defaultVersion: string;
defaultIsInPreview: boolean;
}
export function BlockMetaBar({ path, defaultVersion, defaultIsInPreview }: BlockMetaBarProps) {
const [meta, setMeta] = useState<BlockMeta | null>(null);
const [editing, setEditing] = useState(false);
const [versionInput, setVersionInput] = useState(defaultVersion);
const [saving, setSaving] = useState(false);
const [togglingPreview, setTogglingPreview] = useState(false);
const [apiDown, setApiDown] = useState(false);
const apiUrl = process.env.NEXT_PUBLIC_API_URL;
useEffect(() => {
if (!apiUrl) { setApiDown(true); return; }
fetch(`${apiUrl}/blocks/by-path?path=${encodeURIComponent(path)}`)
.then((r) => r.json())
.then((data: BlockMeta) => {
setMeta(data);
setVersionInput(data.version);
})
.catch(() => setApiDown(true));
}, [apiUrl, path]);
async function patch(data: { version?: string; isInPreview?: boolean }) {
if (!apiUrl) return null;
const r = await fetch(`${apiUrl}/blocks/by-path?path=${encodeURIComponent(path)}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
return r.json() as Promise<BlockMeta>;
}
async function saveVersion() {
if (!meta) return;
setSaving(true);
try {
const updated = await patch({ version: versionInput });
if (updated) { setMeta(updated); setEditing(false); }
} finally {
setSaving(false);
}
}
async function togglePreview() {
if (!meta) return;
setTogglingPreview(true);
try {
const updated = await patch({ isInPreview: !meta.isInPreview });
if (updated) setMeta(updated);
} finally {
setTogglingPreview(false);
}
}
const version = meta?.version ?? defaultVersion;
const isInPreview = meta?.isInPreview ?? defaultIsInPreview;
return (
<div
className="flex items-center flex-wrap gap-3 px-4 py-2 rounded-lg text-xs mb-6"
style={{ background: "var(--bb-sidebar-bg)", border: "1px solid var(--bb-border)" }}
>
{/* Version badge */}
<span className="font-semibold" style={{ color: "var(--bb-text-muted)" }}>
Версия:
</span>
{editing ? (
<span className="flex items-center gap-1.5">
<input
className="px-2 py-0.5 rounded border text-xs font-mono w-20"
style={{ borderColor: "var(--bb-border)", color: "var(--bb-text)" }}
value={versionInput}
onChange={(e) => setVersionInput(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') saveVersion(); if (e.key === 'Escape') setEditing(false); }}
autoFocus
/>
<button
onClick={saveVersion}
disabled={saving}
className="px-2 py-0.5 rounded text-xs font-medium"
style={{ background: "var(--brand-053m)", color: "#fff" }}
>
{saving ? '...' : 'Сохранить'}
</button>
<button
onClick={() => { setEditing(false); setVersionInput(version); }}
className="px-2 py-0.5 rounded text-xs"
style={{ color: "var(--bb-text-muted)" }}
>
Отмена
</button>
</span>
) : (
<button
onClick={() => !apiDown && setEditing(true)}
title={apiDown ? 'API недоступен' : 'Изменить версию'}
className="font-mono px-2 py-0.5 rounded"
style={{
background: "var(--bb-sidebar-active-bg, #dff0fa)",
color: "var(--brand-053m)",
cursor: apiDown ? 'default' : 'pointer',
}}
>
{version}
</button>
)}
<span style={{ color: "var(--bb-border)" }}>·</span>
{/* Preview toggle */}
{apiDown ? (
<span
className="flex items-center gap-1"
style={{ color: isInPreview ? "#16a34a" : "var(--bb-text-muted)" }}
>
<span
className="inline-block w-1.5 h-1.5 rounded-full"
style={{ background: isInPreview ? "#22c55e" : "#d1d5db" }}
/>
{isInPreview ? "В превью" : "Не в превью"}
</span>
) : (
<button
onClick={togglePreview}
disabled={togglingPreview || !meta}
title={isInPreview ? "Убрать из превью страницы" : "Включить в превью страницы"}
className="flex items-center gap-1.5 px-2.5 py-1 rounded font-medium transition-colors"
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>
)}
{apiDown && (
<>
<span style={{ color: "var(--bb-border)" }}>·</span>
<span style={{ color: "#f59e0b" }}>API офлайн</span>
</>
)}
</div>
);
}

2
docker-compose.yml

@ -8,7 +8,7 @@ services:
POSTGRES_PASSWORD: brandbook POSTGRES_PASSWORD: brandbook
POSTGRES_DB: brandbook POSTGRES_DB: brandbook
ports: ports:
- "5433:5432" - "5434:5432"
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data

492
pnpm-lock.yaml

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

5
vercel.json

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