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>
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
import type { Metadata } from "next";
|
||||
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 = {
|
||||
title: "Вводный текст (CEO-блок). Цифровой брендбук Клиники ухо, горло, нос им. проф. Е.Н.Оленевой",
|
||||
@@ -46,13 +48,6 @@ const LLM_CEO_TEXT = `
|
||||
✕ Не менять стиль вопросов на другой формат
|
||||
`.trim();
|
||||
|
||||
const CEO_QUESTIONS = [
|
||||
"У вас болит ухо, заложен нос, першит в горле, и вы не можете понять причину?",
|
||||
"Вам срочно нужен платный ЛОР в Перми или, как ещё говорят, «ухогорлонос»?",
|
||||
"Заболел ребёнок?",
|
||||
"Срочно ищете частные ЛОР-клиники Перми для детей 0+ и взрослых с удобным режимом работы с 9:00 до 21:00 по будням?",
|
||||
"Вам назначили проведение эндоскопической операции на ухе, горле или носе?",
|
||||
];
|
||||
|
||||
export default function CeoPage() {
|
||||
return (
|
||||
@@ -68,6 +63,7 @@ export default function CeoPage() {
|
||||
<h1 className="text-2xl font-bold mb-2" style={{ color: "var(--bb-text)" }}>
|
||||
Вводный текст (CEO-блок)
|
||||
</h1>
|
||||
<BlockMetaBar path="/blocks/ceo" defaultVersion="v1.0" defaultIsInPreview={false} />
|
||||
<p className="text-sm" style={{ color: "var(--bb-text-muted)" }}>
|
||||
Блок после 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>
|
||||
<div
|
||||
className="rounded-xl p-8"
|
||||
style={{
|
||||
background: "#fff",
|
||||
border: "1px solid var(--bb-border)",
|
||||
fontFamily: "var(--font-web)",
|
||||
fontSize: 14,
|
||||
lineHeight: 1.75,
|
||||
color: "#374151",
|
||||
}}
|
||||
>
|
||||
<p>
|
||||
Клиника ухо, нос специализируется на оториноларингологии – лечении взрослых и детей
|
||||
с ЛОР заболеваниями. ЛОР клиника представлена на двух адресах:{" "}
|
||||
<a href="#" style={{ color: "#0089c3" }}>ул. Цитная, 9</a>, <a href="#" style={{ color: "#0089c3" }}>ул. Г. Звезда, 31а</a>.{" "}
|
||||
Это <a href="#" style={{ color: "#0089c3" }}>Клиника лечения кашля и аллергии</a>.
|
||||
</p>
|
||||
|
||||
<div className="mt-4 space-y-2">
|
||||
{CEO_QUESTIONS.map((q) => (
|
||||
<p key={q}>— {q}</p>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="mt-4">
|
||||
Обращайтесь в ЛОР центр ухо, горло, нос в Перми, наши врачи оториноларингологи обязательно Вам помогут!
|
||||
</p>
|
||||
<p className="mt-2">
|
||||
Клиника ЛОР болезней ухо, горло, нос – это наиболее современный центр оториноларингологии в Перми,
|
||||
благодаря эндоскопическому оборудованию, высокому профессионализму оториноларингологов.
|
||||
</p>
|
||||
<p className="mt-4 font-medium">Будьте здоровы!</p>
|
||||
<div className="rounded-xl overflow-hidden" style={{ border: "1px solid var(--bb-border)" }}>
|
||||
<CeoBlock />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { Metadata } from "next";
|
||||
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 = {
|
||||
title: "Формы записи. Цифровой брендбук Клиники ухо, горло, нос им. проф. Е.Н.Оленевой",
|
||||
@@ -62,104 +64,18 @@ export default function ContactFormsPage() {
|
||||
<h1 className="text-2xl font-bold mb-2" style={{ color: "var(--bb-text)" }}>
|
||||
Формы записи
|
||||
</h1>
|
||||
<BlockMetaBar path="/blocks/contact-forms" defaultVersion="v1.0" defaultIsInPreview={false} />
|
||||
<p className="text-sm" style={{ color: "var(--bb-text-muted)" }}>
|
||||
Два блока форм с perm.oclinica.ru/lor — запись на приём и запрос стоимости операции.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Форма 1: Будьте здоровы */}
|
||||
{/* Живой пример */}
|
||||
<section className="space-y-3">
|
||||
<h2 className="font-semibold text-base" style={{ color: "var(--bb-text)" }}>
|
||||
Форма 1 — «Будьте здоровы!»
|
||||
Живой пример
|
||||
</h2>
|
||||
<p className="text-sm" style={{ color: "var(--bb-text-muted)" }}>
|
||||
Фон секции: #b8e6ed. Запись на приём к врачу.
|
||||
</p>
|
||||
<div
|
||||
className="rounded-xl py-10 px-8 flex flex-col items-center gap-6"
|
||||
style={{ background: "#b8e6ed" }}
|
||||
>
|
||||
<div className="text-center">
|
||||
<h2 className="text-2xl font-bold mb-1" style={{ color: "#111827" }}>
|
||||
Будьте здоровы!
|
||||
</h2>
|
||||
<p className="text-sm" style={{ color: "#374151" }}>
|
||||
Запишитесь на приём к врачу!
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-full max-w-sm space-y-3">
|
||||
<input
|
||||
className="bb-input"
|
||||
type="text"
|
||||
placeholder="Введите ваше имя"
|
||||
readOnly
|
||||
/>
|
||||
<input
|
||||
className="bb-input"
|
||||
type="tel"
|
||||
placeholder="Введите ваш телефон"
|
||||
readOnly
|
||||
/>
|
||||
<select className="bb-select" disabled>
|
||||
<option>Выберите ЛОР врача</option>
|
||||
</select>
|
||||
<label className="flex items-start gap-2 text-xs cursor-pointer" style={{ color: "#374151" }}>
|
||||
<input type="checkbox" className="bb-checkbox mt-0.5" readOnly />
|
||||
<span>
|
||||
Отправляя данные, я даю согласие на обработку персональных данных
|
||||
</span>
|
||||
</label>
|
||||
<button className="bb-btn bb-btn-md bb-btn-primary w-full">
|
||||
Запишите меня!
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Форма 2: Узнайте стоимость */}
|
||||
<section className="space-y-3">
|
||||
<h2 className="font-semibold text-base" style={{ color: "var(--bb-text)" }}>
|
||||
Форма 2 — «Узнайте стоимость операции»
|
||||
</h2>
|
||||
<p className="text-sm" style={{ color: "var(--bb-text-muted)" }}>
|
||||
Фон секции: #ffffff. Запрос консультации по стоимости.
|
||||
</p>
|
||||
<div
|
||||
className="rounded-xl py-10 px-8 flex flex-col items-center gap-6"
|
||||
style={{ background: "#fff", border: "1px solid var(--bb-border)" }}
|
||||
>
|
||||
<div className="text-center">
|
||||
<h2 className="text-2xl font-bold mb-1" style={{ color: "#111827" }}>
|
||||
Узнайте стоимость операции
|
||||
</h2>
|
||||
<p className="text-sm" style={{ color: "#374151" }}>
|
||||
Проконсультируйтесь с ассистентом хирурга
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-full max-w-sm space-y-3">
|
||||
<input
|
||||
className="bb-input"
|
||||
type="text"
|
||||
placeholder="Введите ваше имя"
|
||||
readOnly
|
||||
/>
|
||||
<input
|
||||
className="bb-input"
|
||||
type="tel"
|
||||
placeholder="Введите ваш телефон"
|
||||
readOnly
|
||||
/>
|
||||
<label className="flex items-start gap-2 text-xs cursor-pointer" style={{ color: "#374151" }}>
|
||||
<input type="checkbox" className="bb-checkbox mt-0.5" readOnly />
|
||||
<span>
|
||||
Отправляя данные, я даю согласие на обработку персональных данных
|
||||
</span>
|
||||
</label>
|
||||
<button className="bb-btn bb-btn-md bb-btn-primary w-full">
|
||||
Перезвоните мне
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<ContactFormsBlock />
|
||||
</section>
|
||||
|
||||
{/* Сравнение двух форм */}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { Metadata } from "next";
|
||||
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 = {
|
||||
title: "Подвал (Footer). Цифровой брендбук Клиники ухо, горло, нос им. проф. Е.Н.Оленевой",
|
||||
@@ -82,6 +84,7 @@ export default function ContactFooterPage() {
|
||||
<h1 className="text-2xl font-bold mb-2" style={{ color: "var(--bb-text)" }}>
|
||||
Подвал (Footer)
|
||||
</h1>
|
||||
<BlockMetaBar path="/blocks/contact" defaultVersion="v1.0" defaultIsInPreview={false} />
|
||||
<p className="text-sm" style={{ color: "var(--bb-text-muted)" }}>
|
||||
Подвал сайта с perm.oclinica.ru — 4 колонки ссылок, логотип, адрес, часы работы, соцсети.
|
||||
</p>
|
||||
@@ -92,97 +95,7 @@ export default function ContactFooterPage() {
|
||||
<h2 className="font-semibold text-base" style={{ color: "var(--bb-text)" }}>
|
||||
Живой пример
|
||||
</h2>
|
||||
<div
|
||||
className="rounded-xl overflow-hidden"
|
||||
style={{ border: "1px solid var(--bb-border)" }}
|
||||
>
|
||||
{/* 4 колонки ссылок */}
|
||||
<div
|
||||
className="grid grid-cols-4 gap-6 p-8"
|
||||
style={{ background: "#f8f9fa", borderBottom: "1px solid #e5e7eb" }}
|
||||
>
|
||||
{FOOTER_COLUMNS.map((col) => (
|
||||
<div key={col.title}>
|
||||
<p className="font-semibold text-sm mb-3" style={{ color: "#111827" }}>
|
||||
{col.title}
|
||||
</p>
|
||||
<ul className="space-y-1.5">
|
||||
{col.links.map((link) => (
|
||||
<li key={link}>
|
||||
<a
|
||||
href="#"
|
||||
className="text-xs bb-nav-link"
|
||||
style={{ color: "#53514e", textDecoration: "none" }}
|
||||
>
|
||||
{link}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Нижняя полоса */}
|
||||
<div
|
||||
className="flex items-start justify-between gap-6 px-8 py-5"
|
||||
style={{ background: "#fff" }}
|
||||
>
|
||||
{/* Логотип */}
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<div
|
||||
className="w-10 h-10 rounded-full flex items-center justify-center text-white font-bold"
|
||||
style={{ background: "#0089c3" }}
|
||||
>
|
||||
✚
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
className="text-[10px] font-bold uppercase leading-tight"
|
||||
style={{ color: "#53514e" }}
|
||||
>
|
||||
Клиника<br />ухо, горло, нос
|
||||
</div>
|
||||
<div className="text-[8px] leading-tight" style={{ color: "#9ca3af" }}>
|
||||
им. проф. Е.Н.Оленевой
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Адрес и соцсети */}
|
||||
<div className="text-center space-y-2">
|
||||
<p className="text-xs" style={{ color: "#374151" }}>
|
||||
Мы находимся по адресу: Пермь, ул. Г. Звезда, 31а
|
||||
</p>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
{["VK", "OK", "YT", "TG"].map((s) => (
|
||||
<a
|
||||
key={s}
|
||||
href="#"
|
||||
className="text-[11px] px-2 py-1 rounded"
|
||||
style={{
|
||||
background: "var(--bb-sidebar-bg)",
|
||||
border: "1px solid var(--bb-border)",
|
||||
color: "var(--bb-text-muted)",
|
||||
}}
|
||||
>
|
||||
{s}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Часы работы */}
|
||||
<div className="text-right text-xs space-y-1" style={{ color: "#374151" }}>
|
||||
<p className="font-semibold text-xs mb-1" style={{ color: "#111827" }}>
|
||||
Часы работы:
|
||||
</p>
|
||||
<p>Пн–пт: 9:00–21:00</p>
|
||||
<p>Сб: 9:00–18:00</p>
|
||||
<p>Вс: выходной</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<FooterBlock />
|
||||
</section>
|
||||
|
||||
{/* Колонки */}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Metadata } from "next";
|
||||
import { LlmBlock, LlmSection, LlmTable, LlmRules } from "@/components/llm/LlmBlock";
|
||||
import { DoctorsBlock, STATS, DOCTORS } from "@/components/blocks/DoctorsBlock";
|
||||
import { BlockMetaBar } from "@/components/ui/BlockMetaBar";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Блок «Наши врачи». Цифровой брендбук Клиники ухо, горло, нос им. проф. Е.Н.Оленевой",
|
||||
@@ -58,6 +59,7 @@ export default function DoctorsBlockPage() {
|
||||
<h1 className="text-2xl font-bold mb-2" style={{ color: "var(--bb-text)" }}>
|
||||
Блок «Наши врачи»
|
||||
</h1>
|
||||
<BlockMetaBar path="/blocks/doctors" defaultVersion="v1.1" defaultIsInPreview={true} />
|
||||
<p className="text-sm" style={{ color: "var(--bb-text-muted)" }}>
|
||||
Блок на странице perm.oclinica.ru/lor — заголовок, 3 стат-блока, сетка из 6 карточек врачей.
|
||||
</p>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Metadata } from "next";
|
||||
import { LlmBlock, LlmSection, LlmTable, LlmRules } from "@/components/llm/LlmBlock";
|
||||
import { HeroBlock, HERO_CHECKS } from "@/components/blocks/HeroBlock";
|
||||
import { BlockMetaBar } from "@/components/ui/BlockMetaBar";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Hero-баннер. Цифровой брендбук Клиники ухо, горло, нос им. проф. Е.Н.Оленевой",
|
||||
@@ -63,6 +64,7 @@ export default function HeroPage() {
|
||||
<h1 className="text-2xl font-bold mb-2" style={{ color: "var(--bb-text)" }}>
|
||||
Hero-баннер
|
||||
</h1>
|
||||
<BlockMetaBar path="/blocks/hero" defaultVersion="v1.1" defaultIsInPreview={true} />
|
||||
<p className="text-sm" style={{ color: "var(--bb-text-muted)" }}>
|
||||
Главный баннер страницы раздела ЛОР — perm.oclinica.ru/lor. Двухколоночный блок, единый светло-кремовый фон{" "}
|
||||
<strong>#f9f4e7</strong>.
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { Metadata } from "next";
|
||||
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 = {
|
||||
title: "Блок «Новости». Цифровой брендбук Клиники ухо, горло, нос им. проф. Е.Н.Оленевой",
|
||||
@@ -79,6 +81,7 @@ export default function NewsBlockPage() {
|
||||
<h1 className="text-2xl font-bold mb-2" style={{ color: "var(--bb-text)" }}>
|
||||
Блок «Новости»
|
||||
</h1>
|
||||
<BlockMetaBar path="/blocks/news" defaultVersion="v1.0" defaultIsInPreview={false} />
|
||||
<p className="text-sm" style={{ color: "var(--bb-text-muted)" }}>
|
||||
Блок новостей с 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>
|
||||
<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>
|
||||
<NewsBlock />
|
||||
</section>
|
||||
|
||||
{/* Анатомия карточки */}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { Metadata } from "next";
|
||||
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 = {
|
||||
title: "Блок «Отзывы». Цифровой брендбук Клиники ухо, горло, нос им. проф. Е.Н.Оленевой",
|
||||
@@ -64,6 +66,7 @@ export default function ReviewsBlockPage() {
|
||||
<h1 className="text-2xl font-bold mb-2" style={{ color: "var(--bb-text)" }}>
|
||||
Блок «Отзывы о нас»
|
||||
</h1>
|
||||
<BlockMetaBar path="/blocks/reviews" defaultVersion="v1.0" defaultIsInPreview={false} />
|
||||
<p className="text-sm" style={{ color: "var(--bb-text-muted)" }}>
|
||||
Карусель отзывов с perm.oclinica.ru/lor — большая кавычка, текст, «Читать полностью», стрелки.
|
||||
</p>
|
||||
@@ -74,76 +77,7 @@ export default function ReviewsBlockPage() {
|
||||
<h2 className="font-semibold text-base" style={{ color: "var(--bb-text)" }}>
|
||||
Живой пример
|
||||
</h2>
|
||||
<div
|
||||
className="rounded-xl p-8 space-y-6"
|
||||
style={{ background: "#fff", border: "1px solid var(--bb-border)" }}
|
||||
>
|
||||
{/* Заголовок */}
|
||||
<div>
|
||||
<h2 className="text-xl font-bold mb-2" style={{ color: "#111827" }}>
|
||||
Отзывы о нас
|
||||
</h2>
|
||||
<p className="text-sm" style={{ color: "#374151", lineHeight: 1.7 }}>
|
||||
За 12 лет работы наши врачи оториноларингологи избавили от болезней ухо, горло, носа
|
||||
более 50 000 пациентов. Но даже сейчас мы высоко ценим каждый положительный отзыв
|
||||
и искренние слова благодарности.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Карусель (статичная) */}
|
||||
<div className="relative flex items-center gap-4">
|
||||
{/* Стрелка влево */}
|
||||
<button
|
||||
className="shrink-0 w-10 h-10 rounded-full flex items-center justify-center font-bold text-lg"
|
||||
style={{
|
||||
background: "var(--bb-sidebar-bg)",
|
||||
border: "1px solid var(--bb-border)",
|
||||
color: "var(--bb-text-muted)",
|
||||
}}
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
|
||||
{/* Карточка отзыва */}
|
||||
<div
|
||||
className="flex-1 rounded-xl p-6 relative"
|
||||
style={{ background: "#eef4d1" }}
|
||||
>
|
||||
{/* Декоративная кавычка */}
|
||||
<div
|
||||
className="text-8xl leading-none mb-2 font-serif"
|
||||
style={{ color: "#b8e6ed", lineHeight: 0.8 }}
|
||||
>
|
||||
«
|
||||
</div>
|
||||
<p
|
||||
className="text-sm italic leading-relaxed"
|
||||
style={{ color: "#374151" }}
|
||||
>
|
||||
{MOCK_REVIEWS[0].text}
|
||||
</p>
|
||||
<a
|
||||
href="#"
|
||||
className="inline-block mt-3 text-sm"
|
||||
style={{ color: "#0089c3" }}
|
||||
>
|
||||
Читать отзыв полностью
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Стрелка вправо */}
|
||||
<button
|
||||
className="shrink-0 w-10 h-10 rounded-full flex items-center justify-center font-bold text-lg"
|
||||
style={{
|
||||
background: "var(--bb-sidebar-bg)",
|
||||
border: "1px solid var(--bb-border)",
|
||||
color: "var(--bb-text-muted)",
|
||||
}}
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<ReviewsBlock />
|
||||
</section>
|
||||
|
||||
{/* Несколько примеров */}
|
||||
|
||||
@@ -2,6 +2,7 @@ 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: "Кнопки. Цифровой брендбук Клиники ухо, горло, нос им. проф. Е.Н.Оленевой",
|
||||
@@ -227,6 +228,7 @@ a.callback_url {
|
||||
<h1 className="text-3xl font-semibold mb-3" style={{ color: "var(--bb-text)" }}>
|
||||
Кнопки
|
||||
</h1>
|
||||
<BlockMetaBar path="/components/buttons" defaultVersion="v2.0" defaultIsInPreview={false} />
|
||||
<p className="text-base max-w-2xl" style={{ color: "var(--bb-text-muted)" }}>
|
||||
Кнопки скопированы с реального сайта{" "}
|
||||
<span className="font-mono text-sm" style={{ color: "var(--bb-text)" }}>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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: "Карточки. Цифровой брендбук Клиники ухо, горло, нос им. проф. Е.Н.Оленевой",
|
||||
@@ -417,6 +418,7 @@ export default function CardsPage() {
|
||||
<h1 className="text-3xl font-semibold mb-3" style={{ color: "var(--bb-text)" }}>
|
||||
Карточки
|
||||
</h1>
|
||||
<BlockMetaBar path="/components/cards" defaultVersion="v1.0" defaultIsInPreview={false} />
|
||||
<p className="text-base max-w-2xl" style={{ color: "var(--bb-text-muted)" }}>
|
||||
Карточки врача, новости, отзыва, цены и услуги — основные блоки контента сайта.
|
||||
Бейджи, теги и алерты — вспомогательные элементы.
|
||||
|
||||
@@ -2,6 +2,7 @@ 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: "Форм-контролы. Цифровой брендбук Клиники ухо, горло, нос им. проф. Е.Н.Оленевой",
|
||||
@@ -264,6 +265,7 @@ input[type=email] {
|
||||
<h1 className="text-3xl font-semibold mb-3" style={{ color: "var(--bb-text)" }}>
|
||||
Форм-контролы
|
||||
</h1>
|
||||
<BlockMetaBar path="/components/forms" defaultVersion="v2.0" defaultIsInPreview={false} />
|
||||
<p className="text-base max-w-2xl" style={{ color: "var(--bb-text-muted)" }}>
|
||||
Элементы ввода данных: текстовые поля, выпадающие списки, флажки, переключатели.
|
||||
Применяются в формах записи, фильтрах и настройках.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Metadata } from "next";
|
||||
import { LlmBlock, LlmSection, LlmTable, LlmRules } from "@/components/llm/LlmBlock";
|
||||
import { NavigationBlock, NAV_ITEMS } from "@/components/blocks/NavigationBlock";
|
||||
import { BlockMetaBar } from "@/components/ui/BlockMetaBar";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Шапка и навигация. Цифровой брендбук Клиники ухо, горло, нос им. проф. Е.Н.Оленевой",
|
||||
@@ -57,6 +58,7 @@ export default function NavigationPage() {
|
||||
<h1 className="text-2xl font-bold mb-2" style={{ color: "var(--bb-text)" }}>
|
||||
Шапка и навигация
|
||||
</h1>
|
||||
<BlockMetaBar path="/components/navigation" defaultVersion="v1.0" defaultIsInPreview={true} />
|
||||
<p className="text-sm" style={{ color: "var(--bb-text-muted)" }}>
|
||||
Точное воспроизведение шапки с perm.oclinica.ru/lor. Три зоны: топ-бар, логотип, главное меню.
|
||||
</p>
|
||||
|
||||
@@ -4,6 +4,11 @@ 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";
|
||||
|
||||
@@ -30,71 +35,89 @@ function BlockPlaceholder({ name, href }: { name: string; href: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
const BLOCKS: Array<{
|
||||
interface BlockDef {
|
||||
id: string;
|
||||
name: string;
|
||||
href: string;
|
||||
ready: boolean;
|
||||
path: string;
|
||||
defaultReady: boolean;
|
||||
component?: React.ReactNode;
|
||||
}> = [
|
||||
}
|
||||
|
||||
const BLOCKS: BlockDef[] = [
|
||||
{
|
||||
id: "navigation",
|
||||
name: "Шапка / Навигация",
|
||||
href: "/components/navigation",
|
||||
ready: true,
|
||||
path: "/components/navigation",
|
||||
defaultReady: true,
|
||||
component: <NavigationBlock />,
|
||||
},
|
||||
{
|
||||
id: "hero",
|
||||
name: "Hero-баннер",
|
||||
href: "/blocks/hero",
|
||||
ready: true,
|
||||
path: "/blocks/hero",
|
||||
defaultReady: true,
|
||||
component: <HeroBlock />,
|
||||
},
|
||||
{
|
||||
id: "ceo",
|
||||
name: "Вводный текст (CEO-блок)",
|
||||
href: "/blocks/ceo",
|
||||
ready: false,
|
||||
path: "/blocks/ceo",
|
||||
defaultReady: false,
|
||||
component: <CeoBlock />,
|
||||
},
|
||||
{
|
||||
id: "doctors",
|
||||
name: "Наши врачи",
|
||||
href: "/blocks/doctors",
|
||||
ready: true,
|
||||
path: "/blocks/doctors",
|
||||
defaultReady: true,
|
||||
component: <DoctorsBlock />,
|
||||
},
|
||||
{
|
||||
id: "reviews",
|
||||
name: "Отзывы",
|
||||
href: "/blocks/reviews",
|
||||
ready: false,
|
||||
path: "/blocks/reviews",
|
||||
defaultReady: false,
|
||||
component: <ReviewsBlock />,
|
||||
},
|
||||
{
|
||||
id: "contact-forms",
|
||||
name: "Формы записи",
|
||||
href: "/blocks/contact-forms",
|
||||
ready: false,
|
||||
path: "/blocks/contact-forms",
|
||||
defaultReady: false,
|
||||
component: <ContactFormsBlock />,
|
||||
},
|
||||
{
|
||||
id: "news",
|
||||
name: "Новости",
|
||||
href: "/blocks/news",
|
||||
ready: false,
|
||||
path: "/blocks/news",
|
||||
defaultReady: false,
|
||||
component: <NewsBlock />,
|
||||
},
|
||||
{
|
||||
id: "footer",
|
||||
name: "Подвал / Контакт",
|
||||
href: "/blocks/contact",
|
||||
ready: false,
|
||||
path: "/blocks/contact",
|
||||
defaultReady: false,
|
||||
component: <FooterBlock />,
|
||||
},
|
||||
];
|
||||
|
||||
const READY_COUNT = BLOCKS.filter((b) => b.ready).length;
|
||||
|
||||
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;
|
||||
|
||||
useEffect(() => {
|
||||
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() {
|
||||
localStorage.setItem(STORAGE_KEY, "true");
|
||||
setCreated(true);
|
||||
@@ -113,9 +157,10 @@ export function PreviewClient() {
|
||||
setCreated(false);
|
||||
}
|
||||
|
||||
// Avoid hydration mismatch — render nothing until mounted
|
||||
if (!mounted) return null;
|
||||
|
||||
const readyCount = BLOCKS.filter(isReady).length;
|
||||
|
||||
/* ── ПУСТОЕ СОСТОЯНИЕ ── */
|
||||
if (!created) {
|
||||
return (
|
||||
@@ -147,11 +192,11 @@ export function PreviewClient() {
|
||||
style={{ background: "#22c55e" }}
|
||||
/>
|
||||
<span style={{ color: "var(--bb-text-muted)" }}>
|
||||
Готово блоков: {READY_COUNT} из {BLOCKS.length}
|
||||
Готово блоков: {readyCount} из {BLOCKS.length}
|
||||
</span>
|
||||
<span style={{ color: "var(--bb-text-muted)" }}>·</span>
|
||||
<span style={{ color: "var(--bb-text-muted)" }}>
|
||||
{BLOCKS.length - READY_COUNT} плейсхолдеров
|
||||
{BLOCKS.length - readyCount} плейсхолдеров
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
@@ -183,7 +228,10 @@ export function PreviewClient() {
|
||||
Просмотр текущей страницы
|
||||
</p>
|
||||
<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>
|
||||
</div>
|
||||
<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">
|
||||
{BLOCKS.map((block) =>
|
||||
block.ready && block.component ? (
|
||||
isReady(block) ? (
|
||||
<section key={block.id}>{block.component}</section>
|
||||
) : (
|
||||
<section key={block.id}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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:00–21:00</p>
|
||||
<p>Сб: 9:00–18:00</p>
|
||||
<p>Вс: выходной</p>
|
||||
</div>
|
||||
</div>
|
||||
</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: "#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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user