Initial release of Moniki paid services site and CMS (v1.0.0).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alexey Novikov
2026-09-16 13:10:25 +03:00
co-authored by Cursor
commit 8abd8d06dd
263 changed files with 24973 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
DATABASE_URI=postgresql://USER@127.0.0.1:5432/moniki
PAYLOAD_SECRET=CHANGE_ME
NEXT_PUBLIC_SERVER_URL=http://localhost:3001
CORS_ORIGINS=http://localhost:3000
SBP_MERCHANT_ID=
SBP_API_KEY=
SBP_WEBHOOK_SECRET=
SMS_PROVIDER_API_KEY=
SMS_ENABLED=false
RESEND_API_KEY=
RESEND_FROM_EMAIL=onboarding@resend.dev
RESEND_FROM_NAME=МОНИКИ
+5
View File
@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+15
View File
@@ -0,0 +1,15 @@
import { withPayload } from '@payloadcms/next/withPayload'
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
images: {
remotePatterns: [
{ protocol: 'http', hostname: 'localhost' },
{ protocol: 'http', hostname: '193.124.91.99' },
{ protocol: 'https', hostname: 'www.monikiweb.ru' },
{ protocol: 'https', hostname: 'images.unsplash.com' },
],
},
}
export default withPayload(nextConfig)
+50
View File
@@ -0,0 +1,50 @@
{
"name": "@moniki/cms",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "cross-env NODE_OPTIONS=--no-deprecation next dev -p 3001",
"build": "cross-env NODE_OPTIONS=--no-deprecation next build",
"start": "cross-env NODE_OPTIONS=--no-deprecation next start -p 3001",
"payload": "cross-env NODE_OPTIONS=--no-deprecation payload",
"generate:types": "payload generate:types",
"generate:importmap": "payload generate:importmap",
"seed": "tsx src/seed/index.ts",
"seed:price": "tsx src/seed/import-price.ts",
"seed:doctors": "tsx src/seed/import-doctors.ts",
"seed:reviews": "tsx src/seed/import-reviews.ts",
"seed:banners": "tsx src/seed/import-banners.ts",
"seed:promotions": "tsx src/seed/import-promotions.ts",
"seed:news": "tsx src/seed/import-news.ts",
"seed:pages": "tsx src/seed/site-pages.ts",
"seed:documents": "tsx src/seed/public-documents.ts",
"seed:i18n": "python3 src/seed/translate_locales.py",
"lint": "next lint"
},
"dependencies": {
"@payloadcms/db-postgres": "^3.49.1",
"@payloadcms/next": "^3.49.1",
"@payloadcms/richtext-lexical": "^3.49.1",
"@payloadcms/translations": "^3.49.1",
"@payloadcms/ui": "^3.49.1",
"cross-env": "^7.0.3",
"dotenv": "^16.5.0",
"graphql": "^16.10.0",
"next": "15.3.9",
"payload": "^3.49.1",
"pdfkit": "^0.17.1",
"react": "19.1.0",
"react-dom": "19.1.0",
"sharp": "^0.34.2",
"zod": "^3.25.28"
},
"devDependencies": {
"@types/node": "^22.15.21",
"@types/pdfkit": "^0.13.9",
"@types/react": "^19.1.5",
"@types/react-dom": "^19.1.5",
"tsx": "^4.19.4",
"typescript": "^5.8.3"
}
}
+7
View File
@@ -0,0 +1,7 @@
export const ADMIN_GROUP = {
catalog: 'Каталог',
booking: 'Запись',
content: 'Контент',
cabinet: 'Кабинет',
system: 'Система',
} as const
+119
View File
@@ -0,0 +1,119 @@
/* Icons to the left of collection / global names in the Payload sidebar. */
.nav .nav__link {
gap: 8px;
}
.nav .nav__link::before,
.nav-group__label::before {
content: '';
display: inline-block;
width: 16px;
height: 16px;
flex-shrink: 0;
background-color: currentColor;
-webkit-mask: var(--nav-icon) center / contain no-repeat;
mask: var(--nav-icon) center / contain no-repeat;
}
.nav-group__label {
display: inline-flex;
align-items: center;
gap: 8px;
}
#nav-group-Каталог .nav-group__label {
--nav-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect width='7' height='7' x='3' y='3' rx='1'/%3E%3Crect width='7' height='7' x='14' y='3' rx='1'/%3E%3Crect width='7' height='7' x='14' y='14' rx='1'/%3E%3Crect width='7' height='7' x='3' y='14' rx='1'/%3E%3C/svg%3E");
}
#nav-group-Запись .nav-group__label {
--nav-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M8 2v4'/%3E%3Cpath d='M16 2v4'/%3E%3Crect width='18' height='18' x='3' y='4' rx='2'/%3E%3Cpath d='M3 10h18'/%3E%3Cpath d='M8 14h.01'/%3E%3Cpath d='M12 14h.01'/%3E%3Cpath d='M16 14h.01'/%3E%3Cpath d='M8 18h.01'/%3E%3Cpath d='M12 18h.01'/%3E%3C/svg%3E");
}
#nav-group-Контент .nav-group__label {
--nav-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-2 2Zm0 0a2 2 0 0 1-2-2v-9c0-1.1.9-2 2-2h2'/%3E%3Cpath d='M18 14h-8'/%3E%3Cpath d='M15 18h-5'/%3E%3Cpath d='M10 6h8v4h-8V6Z'/%3E%3C/svg%3E");
}
#nav-group-Кабинет .nav-group__label {
--nav-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='8' r='5'/%3E%3Cpath d='M20 21a8 8 0 0 0-16 0'/%3E%3C/svg%3E");
}
#nav-group-Система .nav-group__label {
--nav-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1 1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1 1-1.73V4a2 2 0 0 0-2-2z'/%3E%3Ccircle cx='12' cy='12' r='3'/%3E%3C/svg%3E");
}
#nav-branches {
--nav-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 22V4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v18Z'/%3E%3Cpath d='M6 12H4a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h2'/%3E%3Cpath d='M18 9h2a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-2'/%3E%3Cpath d='M10 6h4'/%3E%3Cpath d='M10 10h4'/%3E%3Cpath d='M10 14h4'/%3E%3Cpath d='M10 18h4'/%3E%3C/svg%3E");
}
#nav-specialties {
--nav-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M11 2v10'/%3E%3Cpath d='M13 2v10'/%3E%3Cpath d='m19 10-2 2-2-2'/%3E%3Cpath d='M9 10 7 12 5 10'/%3E%3Ccircle cx='12' cy='18' r='4'/%3E%3C/svg%3E");
}
#nav-doctors {
--nav-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4.8 2.3A.3.3 0 1 0 5 2H4a2 2 0 0 0-2 2v5a6 6 0 0 0 6 6v0a6 6 0 0 0 6-6V4a2 2 0 0 0-2-2h-1a.2.2 0 1 0 .3.3'/%3E%3Cpath d='M8 15v1a6 6 0 0 0 6 6v0a6 6 0 0 0 6-6v-4'/%3E%3Ccircle cx='20' cy='10' r='2'/%3E%3C/svg%3E");
}
#nav-services {
--nav-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 6h18'/%3E%3Cpath d='M3 12h18'/%3E%3Cpath d='M3 18h18'/%3E%3C/svg%3E");
}
#nav-schedules {
--nav-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'/%3E%3Cpath d='M12 6v6l4 2'/%3E%3C/svg%3E");
}
#nav-bookings {
--nav-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M8 2v4'/%3E%3Cpath d='M16 2v4'/%3E%3Crect width='18' height='18' x='3' y='4' rx='2'/%3E%3Cpath d='M3 10h18'/%3E%3Cpath d='m9 16 2 2 4-4'/%3E%3C/svg%3E");
}
#nav-invoices {
--nav-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 2v20l2-1 2 1 2-1 2 1 2-1 2 1 2-1 2 1V2l-2 1-2-1-2 1-2-1-2 1-2-1-2 1Z'/%3E%3Cpath d='M16 8h-6a2 2 0 1 0 0 4h4a2 2 0 1 1 0 4H8'/%3E%3Cpath d='M12 17.5v-11'/%3E%3C/svg%3E");
}
#nav-reviews {
--nav-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z'/%3E%3C/svg%3E");
}
#nav-banners {
--nav-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect width='18' height='18' x='3' y='3' rx='2'/%3E%3Cpath d='M3 9h18'/%3E%3Cpath d='M9 21V9'/%3E%3C/svg%3E");
}
#nav-promotions {
--nav-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.432 0l6.568-6.568a2.426 2.426 0 0 0 0-3.432z'/%3E%3Ccircle cx='7.5' cy='7.5' r='.5' fill='black'/%3E%3C/svg%3E");
}
#nav-pages {
--nav-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z'/%3E%3Cpath d='M14 2v4a2 2 0 0 0 2 2h4'/%3E%3Cpath d='M10 9H8'/%3E%3Cpath d='M16 13H8'/%3E%3Cpath d='M16 17H8'/%3E%3C/svg%3E");
}
#nav-posts {
--nav-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M15 18h-5'/%3E%3Cpath d='M18 14h-8'/%3E%3Cpath d='M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-2 2Zm0 0a2 2 0 0 1-2-2v-9c0-1.1.9-2 2-2h2'/%3E%3Cpath d='M10 6h8v4h-8V6Z'/%3E%3C/svg%3E");
}
#nav-gallery {
--nav-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect width='18' height='18' x='3' y='3' rx='2'/%3E%3Ccircle cx='9' cy='9' r='2'/%3E%3Cpath d='m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21'/%3E%3C/svg%3E");
}
#nav-media {
--nav-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z'/%3E%3Cpath d='M14 2v4a2 2 0 0 0 2 2h4'/%3E%3C/svg%3E");
}
#nav-users {
--nav-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2'/%3E%3Ccircle cx='9' cy='7' r='4'/%3E%3Cpath d='M22 21v-2a4 4 0 0 0-3-3.87'/%3E%3Cpath d='M16 3.13a4 4 0 0 1 0 7.75'/%3E%3C/svg%3E");
}
#nav-documents {
--nav-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z'/%3E%3Cpath d='M14 2v4a2 2 0 0 0 2 2h4'/%3E%3Cpath d='M10 9H8'/%3E%3Cpath d='M16 13H8'/%3E%3Cpath d='M16 17H8'/%3E%3C/svg%3E");
}
#nav-favorites {
--nav-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z'/%3E%3C/svg%3E");
}
#nav-notifications {
--nav-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9'/%3E%3Cpath d='M10.3 21a1.94 1.94 0 0 0 3.4 0'/%3E%3C/svg%3E");
}
#nav-global-settings {
--nav-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect width='20' height='16' x='2' y='4' rx='2'/%3E%3Cpath d='m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7'/%3E%3C/svg%3E");
}
@@ -0,0 +1,17 @@
import type { Metadata } from 'next'
import config from '@payload-config'
import { RootPage, generatePageMetadata } from '@payloadcms/next/views'
import { importMap } from '../importMap'
type Args = {
params: Promise<{ segments: string[] }>
searchParams: Promise<Record<string, string | string[]>>
}
export const generateMetadata = ({ params, searchParams }: Args): Promise<Metadata> =>
generatePageMetadata({ config, params, searchParams })
const Page = ({ params, searchParams }: Args) =>
RootPage({ config, params, searchParams, importMap })
export default Page
@@ -0,0 +1,10 @@
import config from '@payload-config'
import '@payloadcms/next/css'
import { REST_DELETE, REST_GET, REST_OPTIONS, REST_PATCH, REST_POST, REST_PUT } from '@payloadcms/next/routes'
export const GET = REST_GET(config)
export const POST = REST_POST(config)
export const DELETE = REST_DELETE(config)
export const PATCH = REST_PATCH(config)
export const PUT = REST_PUT(config)
export const OPTIONS = REST_OPTIONS(config)
@@ -0,0 +1,41 @@
import configPromise from '@payload-config'
import { getPayload } from 'payload'
import { NextResponse } from 'next/server'
export const dynamic = 'force-dynamic'
const LOCALES = ['ru', 'en', 'be', 'tg', 'ky', 'zh'] as const
export async function GET(request: Request) {
const localeParam = new URL(request.url).searchParams.get('locale') || 'ru'
const locale = LOCALES.includes(localeParam as (typeof LOCALES)[number])
? (localeParam as (typeof LOCALES)[number])
: 'ru'
const payload = await getPayload({ config: configPromise })
const result = await payload.find({
collection: 'services',
where: { isPublished: { equals: true } },
limit: 10000,
depth: 0,
pagination: false,
locale,
fallbackLocale: 'ru',
sort: 'groupTitle',
})
const services = result.docs.map((doc) => ({
id: String(doc.id),
slug: String(doc.slug),
title: String(doc.title || ''),
price: Number(doc.price) || 0,
category: String(doc.category || ''),
groupTitle: String(doc.groupTitle || ''),
code: doc.code ? String(doc.code) : '',
nmuCode: doc.nmuCode ? String(doc.nmuCode) : '',
durationMin: doc.durationMinutes ? Number(doc.durationMinutes) : 0,
description: String(doc.description || ''),
}))
return NextResponse.json({ services, total: services.length })
}
@@ -0,0 +1,33 @@
import configPromise from '@payload-config'
import { getPayload } from 'payload'
import { NextResponse } from 'next/server'
import { getEmailSettings } from '@/lib/email'
export async function POST(req: Request) {
const payload = await getPayload({ config: configPromise })
const auth = await payload.auth({ headers: req.headers })
if (auth.user?.role !== 'admin') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const body = (await req.json().catch(() => ({}))) as { to?: string }
const settings = await getEmailSettings(payload)
const to = body.to?.trim() || auth.user.email
if (!to) {
return NextResponse.json({ error: 'Укажите email для теста' }, { status: 400 })
}
try {
const result = await payload.sendEmail({
to,
subject: 'Тестовое письмо МОНИКИ',
html: `<p>Это тестовое письмо из админки МОНИКИ.</p><p>Отправитель: ${settings.fromName} &lt;${settings.fromEmail}&gt;</p>`,
text: `Это тестовое письмо из админки МОНИКИ.`,
})
return NextResponse.json(result ?? { ok: true })
} catch (error) {
const message = error instanceof Error ? error.message : 'Не удалось отправить письмо'
return NextResponse.json({ error: message }, { status: 400 })
}
}
@@ -0,0 +1,192 @@
import configPromise from '@payload-config'
import { getPayload } from 'payload'
import { NextResponse } from 'next/server'
import { createSbpPayment } from '@/lib/sbp'
import { generateReceiptPdf, generateTaxDeductionPdf } from '@/lib/pdf'
import { sendNotification } from '@/lib/notifications'
export async function POST(req: Request) {
const payload = await getPayload({ config: configPromise })
const auth = await payload.auth({ headers: req.headers })
if (!auth.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await req.json()
const bookingId = body.bookingId
if (!bookingId) {
return NextResponse.json({ error: 'bookingId required' }, { status: 400 })
}
const booking = await payload.findByID({
collection: 'bookings',
id: bookingId,
depth: 2,
})
const patientId = typeof booking.patient === 'object' ? booking.patient.id : booking.patient
if (auth.user.role !== 'admin' && String(patientId) !== String(auth.user.id)) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const invoiceNumber = `INV-${Date.now()}`
const amount = Number(booking.price)
const sbp = await createSbpPayment({
amount,
invoiceNumber,
description: `Оплата записи ${bookingId}`,
returnUrl: `${process.env.CORS_ORIGINS?.split(',')[0] || 'http://localhost:3000'}/cabinet/invoices`,
})
const invoice = await payload.create({
collection: 'invoices',
data: {
number: invoiceNumber,
booking: bookingId,
patient: patientId,
amount,
status: 'pending',
paymentMethod: 'sbp',
sbpPaymentId: sbp.paymentId,
sbpQrUrl: sbp.qrUrl,
},
})
await payload.update({
collection: 'bookings',
id: bookingId,
data: { paymentStatus: 'pending' },
})
return NextResponse.json({
invoice,
paymentUrl: sbp.paymentUrl,
qrUrl: sbp.qrUrl,
})
}
/** Mark payment paid (webhook / mock) and generate PDFs. */
export async function PUT(req: Request) {
const payload = await getPayload({ config: configPromise })
const body = await req.json()
const { sbpPaymentId } = body
if (!sbpPaymentId) {
return NextResponse.json({ error: 'sbpPaymentId required' }, { status: 400 })
}
const found = await payload.find({
collection: 'invoices',
where: { sbpPaymentId: { equals: sbpPaymentId } },
limit: 1,
depth: 2,
})
const invoice = found.docs[0]
if (!invoice) {
return NextResponse.json({ error: 'Invoice not found' }, { status: 404 })
}
const paidAt = new Date().toISOString()
const booking = typeof invoice.booking === 'object' ? invoice.booking : null
const patient = typeof invoice.patient === 'object' ? invoice.patient : null
const receiptData = {
invoiceNumber: invoice.number as string,
patientName: (patient as { fullName?: string })?.fullName || 'Пациент',
serviceTitle:
booking && typeof booking.service === 'object'
? String((booking.service as { title?: string }).title || 'Услуга')
: 'Услуга',
doctorName:
booking && typeof booking.doctor === 'object'
? String((booking.doctor as { fullName?: string }).fullName || 'Врач')
: 'Врач',
amount: Number(invoice.amount),
paidAt,
branchAddress:
booking && typeof booking.branch === 'object'
? String((booking.branch as { address?: string }).address || 'ул. Щепкина, 61/2')
: 'ул. Щепкина, 61/2',
}
const [receiptBuf, taxBuf] = await Promise.all([
generateReceiptPdf(receiptData),
generateTaxDeductionPdf(receiptData),
])
const receiptFile = await payload.create({
collection: 'media',
data: { alt: `Чек ${invoice.number}` },
file: {
data: receiptBuf,
mimetype: 'application/pdf',
name: `receipt-${invoice.number}.pdf`,
size: receiptBuf.length,
},
})
const taxFile = await payload.create({
collection: 'media',
data: { alt: `Справка НВ ${invoice.number}` },
file: {
data: taxBuf,
mimetype: 'application/pdf',
name: `tax-${invoice.number}.pdf`,
size: taxBuf.length,
},
})
const updated = await payload.update({
collection: 'invoices',
id: invoice.id,
data: {
status: 'paid',
paidAt,
receiptPdf: receiptFile.id,
taxDeductionPdf: taxFile.id,
},
})
if (booking) {
await payload.update({
collection: 'bookings',
id: booking.id,
data: { paymentStatus: 'paid', status: 'confirmed', confirmedAt: paidAt },
})
}
const patientId = typeof invoice.patient === 'object' ? invoice.patient.id : invoice.patient
await payload.create({
collection: 'documents',
data: {
title: `Чек ${invoice.number}`,
patient: patientId,
type: 'receipt',
file: receiptFile.id,
booking: booking?.id,
issuedAt: paidAt,
},
})
await payload.create({
collection: 'documents',
data: {
title: `Справка для налогового вычета ${invoice.number}`,
patient: patientId,
type: 'tax_deduction',
file: taxFile.id,
booking: booking?.id,
issuedAt: paidAt,
},
})
if (patient && 'email' in patient) {
await sendNotification(payload, {
channel: 'email',
to: String(patient.email),
subject: 'Оплата прошла успешно',
body: `Счёт ${invoice.number} оплачен. Чек и справка для налогового вычета доступны в личном кабинете.`,
})
}
return NextResponse.json({ invoice: updated })
}
@@ -0,0 +1,81 @@
import configPromise from '@payload-config'
import { getPayload } from 'payload'
import { NextResponse } from 'next/server'
import { generateSlotsForDay } from '@/lib/slots'
export async function GET(req: Request) {
const { searchParams } = new URL(req.url)
const doctorId = searchParams.get('doctorId')
const serviceId = searchParams.get('serviceId')
const branchId = searchParams.get('branchId')
const from = searchParams.get('from')
const days = Number(searchParams.get('days') || 14)
if (!doctorId || !from) {
return NextResponse.json({ error: 'doctorId and from are required' }, { status: 400 })
}
const payload = await getPayload({ config: configPromise })
const schedules = await payload.find({
collection: 'schedules',
where: {
and: [
{ doctor: { equals: doctorId } },
{ isActive: { equals: true } },
...(serviceId ? [{ service: { equals: serviceId } }] : []),
...(branchId ? [{ branch: { equals: branchId } }] : []),
],
},
limit: 20,
depth: 0,
})
if (!schedules.docs.length) {
return NextResponse.json({ slots: [] })
}
const start = new Date(from)
start.setHours(0, 0, 0, 0)
const end = new Date(start)
end.setDate(end.getDate() + days)
const bookings = await payload.find({
collection: 'bookings',
where: {
and: [
{ doctor: { equals: doctorId } },
{ startsAt: { greater_than_equal: start.toISOString() } },
{ startsAt: { less_than: end.toISOString() } },
{ status: { in: ['pending', 'confirmed'] } },
],
},
limit: 500,
depth: 0,
})
const busy = bookings.docs.map((b) => ({
startsAt: b.startsAt as string,
endsAt: b.endsAt as string,
}))
const slots: Array<{ startsAt: string; endsAt: string; scheduleId: string | number }> = []
for (let i = 0; i < days; i++) {
const day = new Date(start)
day.setDate(start.getDate() + i)
for (const schedule of schedules.docs) {
const generated = generateSlotsForDay({
date: day,
weekdays: (schedule.weekdays || []) as Array<{ day: string; startTime: string; endTime: string }>,
slotMinutes: Number(schedule.slotMinutes) || 30,
busy,
})
for (const s of generated) {
slots.push({ ...s, scheduleId: schedule.id })
}
}
}
return NextResponse.json({ slots })
}
@@ -0,0 +1,5 @@
import config from '@payload-config'
import { GRAPHQL_POST, REST_OPTIONS } from '@payloadcms/next/routes'
export const POST = GRAPHQL_POST(config)
export const OPTIONS = REST_OPTIONS(config)
+34
View File
@@ -0,0 +1,34 @@
import type { Metadata } from 'next'
import config from '@payload-config'
import '@payloadcms/next/css'
import '@/admin/nav.css'
import { RootLayout, handleServerFunctions } from '@payloadcms/next/layouts'
import React from 'react'
import type { ServerFunctionClient } from 'payload'
import { importMap } from './admin/importMap.js'
type Args = {
children: React.ReactNode
}
export const metadata: Metadata = {
title: 'МОНИКИ CMS',
description: 'Админка платных услуг МОНИКИ',
}
const serverFunction: ServerFunctionClient = async function (args) {
'use server'
return handleServerFunctions({
...args,
config,
importMap,
})
}
const Layout = ({ children }: Args) => (
<RootLayout config={config} importMap={importMap} serverFunction={serverFunction}>
{children}
</RootLayout>
)
export default Layout
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation'
export default function Home() {
redirect('/admin')
}
+3
View File
@@ -0,0 +1,3 @@
export default function RootLayout({ children }: { children: React.ReactNode }) {
return children
}
+100
View File
@@ -0,0 +1,100 @@
import type { CollectionConfig } from 'payload'
import { ADMIN_GROUP } from '../admin/groups'
export const Banners: CollectionConfig = {
slug: 'banners',
labels: { singular: 'Баннер', plural: 'Баннеры' },
admin: {
group: ADMIN_GROUP.content,
useAsTitle: 'title',
defaultColumns: ['title', 'sortOrder', 'isPublished', 'updatedAt'],
description: 'Слайды главного баннера на главной странице. Порядок, тексты, кнопки, фото и видео задаются здесь.',
},
access: { read: () => true },
defaultSort: 'sortOrder',
fields: [
{
type: 'tabs',
tabs: [
{
label: 'Текст',
fields: [
{ name: 'eyebrow', type: 'text', localized: true, label: 'Надзаголовок', admin: { description: 'Например, МОНИКИ' } },
{ name: 'title', type: 'text', required: true, localized: true, label: 'Заголовок' },
{ name: 'subtitle', type: 'textarea', localized: true, label: 'Подзаголовок' },
{ name: 'phone', type: 'text', label: 'Телефон' },
{ name: 'address', type: 'text', localized: true, label: 'Адрес' },
],
},
{
label: 'Медиа',
fields: [
{
name: 'image',
type: 'upload',
relationTo: 'media',
label: 'Фото слайда',
admin: { description: 'Постер, если есть видео, или основное изображение слайда.' },
},
{
name: 'imageUrl',
type: 'text',
label: 'URL фото (если не загружено в медиа)',
admin: { description: 'Например /hero/diagnostics.jpg' },
},
{ name: 'imageAlt', type: 'text', localized: true, label: 'Alt фото' },
{
name: 'video',
type: 'upload',
relationTo: 'media',
label: 'Видео слайда',
admin: { description: 'Если задано, на слайде показывается видео. Запускается по клику.' },
},
{
name: 'videoUrl',
type: 'text',
label: 'URL видео (если не загружено в медиа)',
admin: { description: 'Например /hero/250-white.mp4' },
},
{
name: 'background',
type: 'upload',
relationTo: 'media',
label: 'Фон организации',
admin: { description: 'Размытый фон секции. Если пусто — используется фото здания МОНИКИ.' },
},
{
name: 'backgroundUrl',
type: 'text',
label: 'URL фона (если не загружено в медиа)',
},
],
},
{
label: 'Кнопки',
fields: [
{ name: 'ctaPrimaryLabel', type: 'text', localized: true, label: 'Основная кнопка' },
{ name: 'ctaPrimaryHref', type: 'text', label: 'Ссылка основной кнопки' },
{ name: 'ctaSecondaryLabel', type: 'text', localized: true, label: 'Вторая кнопка' },
{ name: 'ctaSecondaryHref', type: 'text', label: 'Ссылка второй кнопки' },
],
},
{
label: 'Показ',
fields: [
{ name: 'slug', type: 'text', required: true, unique: true, label: 'Символьный код' },
{ name: 'sortOrder', type: 'number', defaultValue: 0, label: 'Порядок' },
{
name: 'durationMs',
type: 'number',
defaultValue: 7000,
label: 'Время показа (мс)',
admin: { description: 'Автопереключение слайда, 5000–12000' },
},
{ name: 'isPublished', type: 'checkbox', defaultValue: true, label: 'Опубликован' },
],
},
],
},
],
}
+113
View File
@@ -0,0 +1,113 @@
import type { CollectionConfig } from 'payload'
import { ADMIN_GROUP } from '../admin/groups'
const BOOKING_HOLD_MS = 24 * 60 * 60 * 1000
export const Bookings: CollectionConfig = {
slug: 'bookings',
labels: { singular: 'Запись', plural: 'Записи' },
admin: {
group: ADMIN_GROUP.booking,
useAsTitle: 'id',
defaultColumns: ['patient', 'doctor', 'service', 'startsAt', 'status', 'paymentStatus'],
},
access: {
create: ({ req: { user } }) => Boolean(user),
read: ({ req: { user } }) => {
if (!user) return false
if (user.role === 'admin') return true
return { patient: { equals: user.id } }
},
update: ({ req: { user } }) => {
if (!user) return false
if (user.role === 'admin') return true
return { patient: { equals: user.id } }
},
delete: ({ req: { user } }) => user?.role === 'admin',
},
hooks: {
beforeChange: [
async ({ data, operation, originalDoc }) => {
if (operation === 'create') {
data.status = data.status || 'pending'
data.holdExpiresAt = new Date(Date.now() + BOOKING_HOLD_MS).toISOString()
data.paymentStatus = data.paymentStatus || 'unpaid'
}
// Patient cancel / reschedule
if (operation === 'update' && data.status === 'cancelled' && originalDoc?.status !== 'cancelled') {
data.cancelledAt = new Date().toISOString()
}
return data
},
],
},
fields: [
{
name: 'patient',
type: 'relationship',
relationTo: 'users',
required: true,
},
{
name: 'doctor',
type: 'relationship',
relationTo: 'doctors',
required: true,
},
{
name: 'service',
type: 'relationship',
relationTo: 'services',
required: true,
},
{
name: 'branch',
type: 'relationship',
relationTo: 'branches',
required: true,
},
{ name: 'startsAt', type: 'date', required: true, admin: { date: { pickerAppearance: 'dayAndTime' } } },
{ name: 'endsAt', type: 'date', required: true, admin: { date: { pickerAppearance: 'dayAndTime' } } },
{
name: 'status',
type: 'select',
required: true,
defaultValue: 'pending',
options: [
{ label: 'Ожидает подтверждения (бронь до 24ч)', value: 'pending' },
{ label: 'Подтверждена', value: 'confirmed' },
{ label: 'Отменена', value: 'cancelled' },
{ label: 'Завершена', value: 'completed' },
{ label: 'Истекла (не подтверждена)', value: 'expired' },
],
},
{
name: 'paymentStatus',
type: 'select',
defaultValue: 'unpaid',
options: [
{ label: 'Не оплачено', value: 'unpaid' },
{ label: 'Ожидает оплаты', value: 'pending' },
{ label: 'Оплачено', value: 'paid' },
{ label: 'Ошибка оплаты', value: 'failed' },
{ label: 'Возврат', value: 'refunded' },
],
},
{ name: 'price', type: 'number', required: true },
{ name: 'holdExpiresAt', type: 'date', admin: { readOnly: true } },
{ name: 'confirmedAt', type: 'date' },
{ name: 'cancelledAt', type: 'date' },
{ name: 'cancelReason', type: 'text' },
{ name: 'rescheduledFrom', type: 'relationship', relationTo: 'bookings' },
{ name: 'patientComment', type: 'textarea' },
{
name: 'smsReminderScheduled',
type: 'checkbox',
defaultValue: false,
admin: { description: 'Флаг для будущей SMS-рассылки напоминаний' },
},
],
timestamps: true,
}
+95
View File
@@ -0,0 +1,95 @@
import type { CollectionConfig } from 'payload'
import { ADMIN_GROUP } from '../admin/groups'
export const Branches: CollectionConfig = {
slug: 'branches',
labels: { singular: 'Филиал', plural: 'Филиалы' },
admin: {
group: ADMIN_GROUP.catalog,
useAsTitle: 'name',
defaultColumns: ['name', 'sortOrder', 'isMain', 'city'],
description:
'Адреса, как добраться, координаты и приоритет вывода филиалов на сайте. Меньше число в приоритете — выше в списках.',
},
access: { read: () => true },
defaultSort: 'sortOrder',
fields: [
{
type: 'tabs',
tabs: [
{
label: 'Основное',
fields: [
{ name: 'name', type: 'text', required: true, localized: true, label: 'Название' },
{ name: 'slug', type: 'text', required: true, unique: true, index: true, label: 'Символьный код' },
{ name: 'phone', type: 'text', label: 'Телефон' },
{ name: 'email', type: 'email', label: 'Email' },
{ name: 'photo', type: 'upload', relationTo: 'media', label: 'Фото' },
{
name: 'workHours',
type: 'array',
label: 'Режим работы',
fields: [
{ name: 'day', type: 'text', required: true, localized: true, label: 'Дни' },
{ name: 'hours', type: 'text', required: true, label: 'Часы' },
],
},
],
},
{
label: 'Нахождение',
fields: [
{ name: 'address', type: 'textarea', required: true, localized: true, label: 'Адрес' },
{ name: 'city', type: 'text', localized: true, label: 'Город' },
{ name: 'metro', type: 'text', localized: true, label: 'Метро' },
{
name: 'howToGet',
type: 'textarea',
localized: true,
label: 'Как добраться',
admin: { description: 'Текст для страницы филиала: пешком, метро, наземный транспорт.' },
},
{
type: 'row',
fields: [
{
name: 'lat',
type: 'number',
label: 'Широта',
admin: { description: 'Например 55.786637', width: '50%' },
},
{
name: 'lng',
type: 'number',
label: 'Долгота',
admin: { description: 'Например 37.627265', width: '50%' },
},
],
},
],
},
{
label: 'Приоритет вывода',
fields: [
{
name: 'sortOrder',
type: 'number',
required: true,
defaultValue: 100,
label: 'Приоритет вывода',
admin: {
description: 'Чем меньше число, тем выше филиал в списках. Основной КДЦ — 10, Реутов — 20.',
},
},
{
name: 'isMain',
type: 'checkbox',
defaultValue: false,
label: 'Основной филиал',
},
],
},
],
},
],
}
+61
View File
@@ -0,0 +1,61 @@
import type { CollectionConfig } from 'payload'
import { ADMIN_GROUP } from '../admin/groups'
export const Doctors: CollectionConfig = {
slug: 'doctors',
labels: { singular: 'Врач', plural: 'Врачи' },
admin: { group: ADMIN_GROUP.catalog, useAsTitle: 'fullName' },
access: { read: () => true },
fields: [
{ name: 'fullName', type: 'text', required: true, localized: true },
{ name: 'slug', type: 'text', required: true, unique: true },
{ name: 'photo', type: 'upload', relationTo: 'media' },
{
name: 'photoUrl',
type: 'text',
label: 'URL фото (если не загружено в медиа)',
},
{
name: 'photoPending',
type: 'checkbox',
defaultValue: false,
label: 'Фото в процессе добавления',
},
{
name: 'specialties',
type: 'relationship',
relationTo: 'specialties',
hasMany: true,
},
{ name: 'position', type: 'text', localized: true, label: 'Должность' },
{ name: 'degree', type: 'text', localized: true, label: 'Учёная степень' },
{ name: 'experienceYears', type: 'number', required: true, label: 'Стаж (лет)' },
{ name: 'bio', type: 'textarea', localized: true },
{
name: 'education',
type: 'array',
fields: [{ name: 'item', type: 'text', required: true, localized: true }],
},
{
name: 'branches',
type: 'relationship',
relationTo: 'branches',
hasMany: true,
},
{
name: 'services',
type: 'relationship',
relationTo: 'services',
hasMany: true,
},
{
name: 'defaultSlotMinutes',
type: 'number',
defaultValue: 30,
label: 'Интервал слота по умолчанию (мин)',
},
{ name: 'rating', type: 'number', admin: { readOnly: true }, defaultValue: 0 },
{ name: 'reviewsCount', type: 'number', admin: { readOnly: true }, defaultValue: 0 },
{ name: 'isPublished', type: 'checkbox', defaultValue: true },
],
}
+68
View File
@@ -0,0 +1,68 @@
import type { Access, CollectionConfig } from 'payload'
import { ADMIN_GROUP } from '../admin/groups'
export const Documents: CollectionConfig = {
slug: 'documents',
labels: { singular: 'Документ', plural: 'Документы' },
admin: {
group: ADMIN_GROUP.cabinet,
useAsTitle: 'title',
defaultColumns: ['title', 'type', 'isPublic', 'patient', 'issuedAt'],
description:
'Документы пациентов и публичные файлы. Публичные документы без пациента показываются в «Полезное → Документы».',
},
access: {
read: (({ req: { user } }) => {
if (user?.role === 'admin') return true
if (!user) return { isPublic: { equals: true } }
return {
or: [{ patient: { equals: user.id } }, { isPublic: { equals: true } }],
}
}) as Access,
create: ({ req: { user } }) => user?.role === 'admin',
update: ({ req: { user } }) => user?.role === 'admin',
},
fields: [
{ name: 'title', type: 'text', required: true, localized: true, label: 'Название' },
{
name: 'isPublic',
type: 'checkbox',
defaultValue: false,
label: 'Публичный документ',
admin: {
description: 'Показывать в подвале: Полезное → Документы. Пациент не нужен.',
},
},
{
name: 'patient',
type: 'relationship',
relationTo: 'users',
label: 'Пациент',
admin: {
condition: (data) => !data?.isPublic,
},
validate: (value, { data }) => {
if (data?.isPublic) return true
if (!value) return 'Укажите пациента или отметьте документ как публичный'
return true
},
},
{
name: 'type',
type: 'select',
required: true,
label: 'Тип',
options: [
{ label: 'Договор', value: 'contract' },
{ label: 'Результат анализа', value: 'lab_result' },
{ label: 'Заключение', value: 'report' },
{ label: 'Чек', value: 'receipt' },
{ label: 'Справка для налогового вычета', value: 'tax_deduction' },
{ label: 'Прочее', value: 'other' },
],
},
{ name: 'file', type: 'upload', relationTo: 'media', required: true, label: 'Файл' },
{ name: 'booking', type: 'relationship', relationTo: 'bookings', label: 'Запись' },
{ name: 'issuedAt', type: 'date', required: true, label: 'Дата' },
],
}
+26
View File
@@ -0,0 +1,26 @@
import type { CollectionConfig } from 'payload'
import { ADMIN_GROUP } from '../admin/groups'
export const Favorites: CollectionConfig = {
slug: 'favorites',
labels: { singular: 'Избранное', plural: 'Избранное' },
admin: { group: ADMIN_GROUP.cabinet },
access: {
create: ({ req: { user } }) => Boolean(user),
read: ({ req: { user } }) => {
if (!user) return false
if (user.role === 'admin') return true
return { patient: { equals: user.id } }
},
delete: ({ req: { user } }) => {
if (!user) return false
if (user.role === 'admin') return true
return { patient: { equals: user.id } }
},
},
fields: [
{ name: 'patient', type: 'relationship', relationTo: 'users', required: true },
{ name: 'doctor', type: 'relationship', relationTo: 'doctors' },
{ name: 'service', type: 'relationship', relationTo: 'services' },
],
}
+25
View File
@@ -0,0 +1,25 @@
import type { CollectionConfig } from 'payload'
import { ADMIN_GROUP } from '../admin/groups'
export const Gallery: CollectionConfig = {
slug: 'gallery',
labels: { singular: 'Фото', plural: 'Галерея' },
admin: { group: ADMIN_GROUP.content, useAsTitle: 'title' },
access: { read: () => true },
fields: [
{ name: 'title', type: 'text', required: true, localized: true },
{ name: 'image', type: 'upload', relationTo: 'media', required: true },
{
name: 'category',
type: 'select',
options: [
{ label: 'Клиника', value: 'clinic' },
{ label: 'Оборудование', value: 'equipment' },
{ label: 'События', value: 'events' },
],
defaultValue: 'clinic',
},
{ name: 'branch', type: 'relationship', relationTo: 'branches' },
{ name: 'sortOrder', type: 'number', defaultValue: 0 },
],
}
+57
View File
@@ -0,0 +1,57 @@
import type { CollectionConfig } from 'payload'
import { ADMIN_GROUP } from '../admin/groups'
export const Invoices: CollectionConfig = {
slug: 'invoices',
labels: { singular: 'Счёт', plural: 'Счета' },
admin: { group: ADMIN_GROUP.booking, useAsTitle: 'number' },
access: {
read: ({ req: { user } }) => {
if (!user) return false
if (user.role === 'admin') return true
return { patient: { equals: user.id } }
},
create: ({ req: { user } }) => Boolean(user),
update: ({ req: { user } }) => user?.role === 'admin',
},
fields: [
{ name: 'number', type: 'text', required: true, unique: true },
{ name: 'booking', type: 'relationship', relationTo: 'bookings', required: true },
{ name: 'patient', type: 'relationship', relationTo: 'users', required: true },
{ name: 'amount', type: 'number', required: true },
{
name: 'status',
type: 'select',
defaultValue: 'pending',
options: [
{ label: 'Ожидает', value: 'pending' },
{ label: 'Оплачен', value: 'paid' },
{ label: 'Отменён', value: 'cancelled' },
],
},
{
name: 'paymentMethod',
type: 'select',
options: [
{ label: 'СБП', value: 'sbp' },
{ label: 'Карта', value: 'card' },
],
defaultValue: 'sbp',
},
{ name: 'sbpPaymentId', type: 'text' },
{ name: 'sbpQrUrl', type: 'text' },
{ name: 'paidAt', type: 'date' },
{
name: 'receiptPdf',
type: 'upload',
relationTo: 'media',
label: 'Чек PDF',
},
{
name: 'taxDeductionPdf',
type: 'upload',
relationTo: 'media',
label: 'Справка для налогового вычета PDF',
},
],
}
+30
View File
@@ -0,0 +1,30 @@
import type { CollectionConfig } from 'payload'
import { ADMIN_GROUP } from '../admin/groups'
export const Media: CollectionConfig = {
slug: 'media',
labels: { singular: 'Файл', plural: 'Медиа' },
admin: { group: ADMIN_GROUP.content },
upload: {
staticDir: 'media',
mimeTypes: [
'image/*',
'video/mp4',
'video/webm',
'video/quicktime',
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
],
},
access: {
read: () => true,
},
fields: [
{
name: 'alt',
type: 'text',
localized: true,
},
],
}
+55
View File
@@ -0,0 +1,55 @@
import type { CollectionConfig } from 'payload'
import { ADMIN_GROUP } from '../admin/groups'
/** Queue for email/SMS reminders and reschedule notices (SMS wired later). */
export const Notifications: CollectionConfig = {
slug: 'notifications',
labels: { singular: 'Уведомление', plural: 'Уведомления' },
admin: { group: ADMIN_GROUP.cabinet, useAsTitle: 'subject' },
access: {
read: ({ req: { user } }) => user?.role === 'admin',
create: ({ req: { user } }) => user?.role === 'admin',
update: ({ req: { user } }) => user?.role === 'admin',
},
fields: [
{ name: 'patient', type: 'relationship', relationTo: 'users', required: true },
{ name: 'booking', type: 'relationship', relationTo: 'bookings' },
{
name: 'channel',
type: 'select',
required: true,
options: [
{ label: 'Email', value: 'email' },
{ label: 'SMS', value: 'sms' },
],
},
{
name: 'type',
type: 'select',
required: true,
options: [
{ label: 'Напоминание о визите', value: 'reminder' },
{ label: 'Перенос записи', value: 'reschedule' },
{ label: 'Отмена', value: 'cancellation' },
{ label: 'Подтверждение', value: 'confirmation' },
{ label: 'Оплата', value: 'payment' },
],
},
{ name: 'subject', type: 'text', required: true },
{ name: 'body', type: 'textarea', required: true },
{ name: 'scheduledFor', type: 'date', required: true },
{
name: 'status',
type: 'select',
defaultValue: 'queued',
options: [
{ label: 'В очереди', value: 'queued' },
{ label: 'Отправлено', value: 'sent' },
{ label: 'Ошибка', value: 'failed' },
{ label: 'Пропущено (SMS выключен)', value: 'skipped' },
],
},
{ name: 'sentAt', type: 'date' },
{ name: 'error', type: 'text' },
],
}
+191
View File
@@ -0,0 +1,191 @@
import type { CollectionConfig } from 'payload'
import { ADMIN_GROUP } from '../admin/groups'
export const Pages: CollectionConfig = {
slug: 'pages',
labels: { singular: 'Страница', plural: 'Страницы' },
admin: {
group: ADMIN_GROUP.content,
useAsTitle: 'title',
defaultColumns: ['title', 'section', 'slug', 'sortOrder', 'showInFooter', 'isPublished'],
description:
'Страницы из подвала: разделы «Организация» и «Полезное». Текст, списки, персоны и карточки редактируются здесь.',
},
access: { read: () => true },
defaultSort: 'sortOrder',
fields: [
{
type: 'tabs',
tabs: [
{
label: 'Страница',
fields: [
{ name: 'title', type: 'text', required: true, localized: true, label: 'Название в меню' },
{
name: 'heading',
type: 'text',
localized: true,
label: 'Заголовок на странице',
admin: { description: 'Если пусто, используется название в меню' },
},
{
name: 'slug',
type: 'text',
required: true,
unique: true,
label: 'Символьный код',
admin: { description: 'Часть адреса: /organization/{код} или /useful/{код}' },
},
{
name: 'section',
type: 'select',
required: true,
defaultValue: 'organization',
label: 'Раздел',
options: [
{ label: 'Организация', value: 'organization' },
{ label: 'Полезное', value: 'useful' },
],
},
{ name: 'subtitle', type: 'textarea', localized: true, label: 'Подзаголовок' },
{ name: 'sortOrder', type: 'number', defaultValue: 100, label: 'Порядок' },
{
name: 'showInFooter',
type: 'checkbox',
defaultValue: true,
label: 'Показывать в подвале',
},
{ name: 'isPublished', type: 'checkbox', defaultValue: true, label: 'Опубликована' },
{ name: 'sourceUrl', type: 'text', label: 'Ссылка на источник' },
],
},
{
label: 'Текст',
fields: [
{ name: 'content', type: 'richText', localized: true, label: 'Текст страницы' },
{ name: 'notice', type: 'textarea', localized: true, label: 'Пометка (например, «скоро»)' },
{ name: 'ctaHint', type: 'text', localized: true, label: 'Текст перед кнопкой' },
{ name: 'ctaLabel', type: 'text', localized: true, label: 'Текст кнопки / ссылки' },
{ name: 'ctaHref', type: 'text', label: 'Ссылка кнопки' },
],
},
{
label: 'Таблица',
fields: [
{ name: 'tableCaption', type: 'textarea', localized: true, label: 'Подпись таблицы' },
{
name: 'tableRows',
type: 'array',
labels: { singular: 'Строка', plural: 'Строки таблицы' },
admin: {
description: 'Таблица скидок, льгот и других сведений. Пустое «условие» скрывается.',
},
fields: [
{ name: 'category', type: 'textarea', required: true, localized: true, label: 'Категория' },
{ name: 'condition', type: 'textarea', localized: true, label: 'Условие' },
{ name: 'discount', type: 'text', required: true, localized: true, label: 'Размер скидки' },
{ name: 'documents', type: 'textarea', required: true, localized: true, label: 'Документы' },
],
},
],
},
{
label: 'Списки',
fields: [
{
name: 'timelineTitle',
type: 'text',
localized: true,
label: 'Заголовок хронологии',
},
{
name: 'facts',
type: 'array',
labels: { singular: 'Строка', plural: 'Сведения' },
fields: [
{ name: 'label', type: 'text', required: true, localized: true, label: 'Подпись' },
{ name: 'value', type: 'textarea', required: true, localized: true, label: 'Значение' },
],
},
{
name: 'timeline',
type: 'array',
labels: { singular: 'Год', plural: 'Хронология' },
fields: [
{ name: 'year', type: 'text', required: true, label: 'Год' },
{ name: 'text', type: 'textarea', required: true, localized: true, label: 'Событие' },
],
},
],
},
{
label: 'Персоны',
fields: [
{
name: 'people',
type: 'array',
labels: { singular: 'Персона', plural: 'Персоны' },
admin: {
description: 'Руководство (с фото) или график приёма (часы и адрес).',
},
fields: [
{ name: 'position', type: 'textarea', localized: true, label: 'Должность' },
{ name: 'name', type: 'text', required: true, label: 'ФИО' },
{ name: 'degree', type: 'text', localized: true, label: 'Регалии' },
{ name: 'photo', type: 'upload', relationTo: 'media', label: 'Фото' },
{
name: 'photoUrl',
type: 'text',
label: 'URL фото',
admin: { description: 'Например /organization/leaders/sobolev.jpg' },
},
{ name: 'hours', type: 'textarea', localized: true, label: 'Часы приёма' },
{ name: 'address', type: 'textarea', localized: true, label: 'Адрес приёма' },
],
},
],
},
{
label: 'Карточки',
fields: [
{
name: 'cards',
type: 'array',
labels: { singular: 'Карточка', plural: 'Карточки' },
admin: { description: 'Лицензии, надзорные органы и другие блоки со ссылкой.' },
fields: [
{ name: 'title', type: 'text', required: true, localized: true, label: 'Заголовок' },
{ name: 'status', type: 'text', label: 'Статус' },
{ name: 'role', type: 'textarea', localized: true, label: 'Роль / описание' },
{ name: 'number', type: 'text', label: 'Номер' },
{ name: 'issuedAt', type: 'text', label: 'Дата' },
{ name: 'authority', type: 'textarea', localized: true, label: 'Орган / источник' },
{ name: 'activity', type: 'textarea', localized: true, label: 'Вид деятельности' },
{ name: 'extract', type: 'textarea', localized: true, label: 'Выписка / примечание' },
{ name: 'phone', type: 'text', label: 'Телефон' },
{ name: 'email', type: 'text', label: 'E-mail' },
{ name: 'address', type: 'textarea', localized: true, label: 'Адрес' },
{ name: 'href', type: 'text', label: 'Ссылка' },
{ name: 'linkLabel', type: 'text', localized: true, label: 'Текст ссылки' },
],
},
],
},
{
label: 'FAQ',
fields: [
{
name: 'faq',
type: 'array',
labels: { singular: 'Вопрос', plural: 'Вопросы и ответы' },
fields: [
{ name: 'question', type: 'textarea', required: true, localized: true, label: 'Вопрос' },
{ name: 'answer', type: 'textarea', required: true, localized: true, label: 'Ответ' },
],
},
],
},
],
},
],
}
+21
View File
@@ -0,0 +1,21 @@
import type { CollectionConfig } from 'payload'
import { ADMIN_GROUP } from '../admin/groups'
export const Posts: CollectionConfig = {
slug: 'posts',
labels: { singular: 'Новость', plural: 'Новости' },
admin: { group: ADMIN_GROUP.content, useAsTitle: 'title' },
access: { read: () => true },
fields: [
{ name: 'title', type: 'text', required: true, localized: true },
{ name: 'slug', type: 'text', required: true, unique: true },
{ name: 'excerpt', type: 'textarea', localized: true },
{ name: 'body', type: 'textarea', localized: true, label: 'Текст новости' },
{ name: 'content', type: 'richText', localized: true },
{ name: 'cover', type: 'upload', relationTo: 'media' },
{ name: 'imageUrl', type: 'text', label: 'URL обложки' },
{ name: 'sourceUrl', type: 'text', label: 'Источник' },
{ name: 'publishedAt', type: 'date', required: true },
{ name: 'isPublished', type: 'checkbox', defaultValue: true },
],
}
+151
View File
@@ -0,0 +1,151 @@
import type { CollectionConfig } from 'payload'
import { ADMIN_GROUP } from '../admin/groups'
export const Promotions: CollectionConfig = {
slug: 'promotions',
labels: { singular: 'Акция', plural: 'Акции' },
admin: {
group: ADMIN_GROUP.content,
useAsTitle: 'title',
defaultColumns: ['title', 'price', 'endsAt', 'isPublished', 'sortOrder'],
description:
'Спецпредложения на сайте. Описание, условия, цена, запись и срок действия (обратный отсчёт) задаются здесь.',
},
access: { read: () => true },
defaultSort: 'sortOrder',
fields: [
{
type: 'tabs',
tabs: [
{
label: 'Содержание',
fields: [
{ name: 'title', type: 'text', required: true, localized: true, label: 'Название' },
{
name: 'badge',
type: 'text',
localized: true,
label: 'Бейдж',
admin: { description: 'Короткая метка, например «−30%» или «До 12 октября»' },
},
{
name: 'description',
type: 'textarea',
required: true,
localized: true,
label: 'Описание',
},
{
name: 'conditions',
type: 'textarea',
required: true,
localized: true,
label: 'Условия',
admin: { description: 'Кто может воспользоваться, что входит, ограничения' },
},
],
},
{
label: 'Цена и срок',
fields: [
{
name: 'price',
type: 'number',
required: true,
label: 'Цена по акции (₽)',
min: 0,
},
{
name: 'oldPrice',
type: 'number',
label: 'Цена до скидки (₽)',
min: 0,
admin: { description: 'Показывается зачёркнутой, если заполнена' },
},
{
name: 'startsAt',
type: 'date',
label: 'Начало акции',
admin: {
date: { pickerAppearance: 'dayAndTime' },
description: 'До этой даты акция на сайте не показывается. Можно оставить пустым.',
},
},
{
name: 'endsAt',
type: 'date',
required: true,
label: 'Окончание акции',
admin: {
date: { pickerAppearance: 'dayAndTime' },
description: 'Используется для обратного отсчёта',
},
},
{
name: 'showCountdown',
type: 'checkbox',
defaultValue: true,
label: 'Показывать обратный отсчёт',
},
{
name: 'hideWhenExpired',
type: 'checkbox',
defaultValue: true,
label: 'Скрывать после окончания',
},
],
},
{
label: 'Запись',
fields: [
{
name: 'bookingEnabled',
type: 'checkbox',
defaultValue: true,
label: 'Кнопка «Записаться»',
admin: { description: 'Если выключено, на карточке не будет быстрой записи' },
},
{
name: 'ctaLabel',
type: 'text',
localized: true,
label: 'Текст кнопки',
admin: { description: 'По умолчанию — «Записаться»' },
},
{
name: 'service',
type: 'relationship',
relationTo: 'services',
label: 'Услуга для записи',
admin: {
description: 'Откроет онлайн-запись с выбранной услугой. Можно не указывать.',
},
},
{
name: 'bookingHref',
type: 'text',
label: 'Ссылка записи (если услуга не выбрана)',
defaultValue: '/booking',
admin: { description: 'Например /booking или /booking?service=123' },
},
],
},
{
label: 'Оформление',
fields: [
{ name: 'image', type: 'upload', relationTo: 'media', label: 'Фото' },
{
name: 'imageUrl',
type: 'text',
label: 'URL фото (если не загружено в медиа)',
admin: { description: 'Например /hero/diagnostics.jpg' },
},
{ name: 'slug', type: 'text', required: true, unique: true, label: 'Символьный код' },
{ name: 'sortOrder', type: 'number', defaultValue: 0, label: 'Порядок' },
{ name: 'isPublished', type: 'checkbox', defaultValue: true, label: 'Опубликована' },
],
},
],
},
],
}
+88
View File
@@ -0,0 +1,88 @@
import { sql } from '@payloadcms/db-postgres'
import type { CollectionConfig, PayloadRequest } from 'payload'
import { ADMIN_GROUP } from '../admin/groups'
function doctorIdOf(value: unknown): number | null {
if (value && typeof value === 'object' && 'id' in value) {
const id = Number((value as { id: unknown }).id)
return Number.isFinite(id) ? id : null
}
if (value == null || value === '') return null
const id = Number(value)
return Number.isFinite(id) ? id : null
}
async function syncDoctorRating(req: PayloadRequest, doctorId: number | null) {
if (doctorId == null) return
const adapter = req.payload.db as {
drizzle: { execute: (query: unknown) => Promise<unknown> }
}
await adapter.drizzle.execute(sql`
UPDATE doctors AS d
SET
reviews_count = COALESCE(stats.cnt, 0),
rating = COALESCE(stats.avg, 0)
FROM (
SELECT
COUNT(*)::numeric AS cnt,
ROUND(AVG(rating)::numeric, 1) AS avg
FROM reviews
WHERE doctor_id = ${doctorId} AND is_published = true
) AS stats
WHERE d.id = ${doctorId}
`)
}
export const Reviews: CollectionConfig = {
slug: 'reviews',
labels: { singular: 'Отзыв', plural: 'Отзывы' },
admin: {
group: ADMIN_GROUP.booking,
useAsTitle: 'authorName',
defaultColumns: ['authorName', 'doctor', 'rating', 'isPublished', 'createdAt'],
listSearchableFields: ['authorName'],
},
access: {
read: () => true,
create: ({ req: { user } }) => Boolean(user),
update: ({ req: { user } }) => user?.role === 'admin',
delete: ({ req: { user } }) => user?.role === 'admin',
},
hooks: {
afterChange: [
async ({ doc, previousDoc, req }) => {
const ids = new Set<number>()
const current = doctorIdOf(doc.doctor)
const previous = doctorIdOf(previousDoc?.doctor)
if (current != null) ids.add(current)
if (previous != null) ids.add(previous)
for (const id of ids) {
await syncDoctorRating(req, id)
}
},
],
afterDelete: [
async ({ doc, req }) => {
await syncDoctorRating(req, doctorIdOf(doc.doctor))
},
],
},
fields: [
{ name: 'doctor', type: 'relationship', relationTo: 'doctors', required: true, label: 'Врач' },
{ name: 'author', type: 'relationship', relationTo: 'users', label: 'Пользователь' },
{ name: 'authorName', type: 'text', required: true, label: 'Имя автора' },
{
name: 'rating',
type: 'number',
required: true,
min: 1,
max: 5,
label: 'Оценка',
},
{ name: 'comment', type: 'textarea', required: true, localized: true, label: 'Текст отзыва' },
{ name: 'booking', type: 'relationship', relationTo: 'bookings', label: 'Запись' },
{ name: 'isPublished', type: 'checkbox', defaultValue: true, label: 'Опубликован' },
],
}
+67
View File
@@ -0,0 +1,67 @@
import type { CollectionConfig } from 'payload'
import { ADMIN_GROUP } from '../admin/groups'
/** Per-doctor / per-service schedule templates configured in admin. */
export const Schedules: CollectionConfig = {
slug: 'schedules',
labels: { singular: 'Расписание', plural: 'Расписания' },
admin: { group: ADMIN_GROUP.booking, useAsTitle: 'title' },
access: {
read: () => true,
create: ({ req: { user } }) => user?.role === 'admin',
update: ({ req: { user } }) => user?.role === 'admin',
delete: ({ req: { user } }) => user?.role === 'admin',
},
fields: [
{ name: 'title', type: 'text', required: true },
{
name: 'doctor',
type: 'relationship',
relationTo: 'doctors',
},
{
name: 'service',
type: 'relationship',
relationTo: 'services',
},
{
name: 'branch',
type: 'relationship',
relationTo: 'branches',
required: true,
},
{
name: 'slotMinutes',
type: 'number',
required: true,
defaultValue: 30,
label: 'Интервал слота (мин)',
},
{
name: 'weekdays',
type: 'array',
required: true,
fields: [
{
name: 'day',
type: 'select',
required: true,
options: [
{ label: 'Пн', value: '1' },
{ label: 'Вт', value: '2' },
{ label: 'Ср', value: '3' },
{ label: 'Чт', value: '4' },
{ label: 'Пт', value: '5' },
{ label: 'Сб', value: '6' },
{ label: 'Вс', value: '0' },
],
},
{ name: 'startTime', type: 'text', required: true, admin: { placeholder: '09:00' } },
{ name: 'endTime', type: 'text', required: true, admin: { placeholder: '18:00' } },
],
},
{ name: 'validFrom', type: 'date' },
{ name: 'validTo', type: 'date' },
{ name: 'isActive', type: 'checkbox', defaultValue: true },
],
}
+59
View File
@@ -0,0 +1,59 @@
import type { CollectionConfig } from 'payload'
import { ADMIN_GROUP } from '../admin/groups'
export const Services: CollectionConfig = {
slug: 'services',
labels: { singular: 'Услуга', plural: 'Услуги' },
admin: {
group: ADMIN_GROUP.catalog,
useAsTitle: 'title',
defaultColumns: ['title', 'groupTitle', 'price', 'code'],
},
access: { read: () => true },
fields: [
{ name: 'title', type: 'text', required: true, localized: true },
{ name: 'slug', type: 'text', required: true, unique: true, index: true },
{ name: 'code', type: 'text', index: true, label: 'Код услуги' },
{ name: 'nmuCode', type: 'text', label: 'Код НМУ (НРУЗ)' },
{ name: 'description', type: 'textarea', localized: true },
{ name: 'price', type: 'number', required: true, label: 'Цена (₽)' },
{ name: 'currency', type: 'text', defaultValue: 'RUB' },
{ name: 'durationMinutes', type: 'number', label: 'Длительность (мин)' },
{
name: 'specialty',
type: 'relationship',
relationTo: 'specialties',
},
{
name: 'category',
type: 'select',
index: true,
options: [
{ label: 'Приёмы и консультации', value: 'consultation' },
{ label: 'Инструментальные исследования', value: 'diagnostics' },
{ label: 'Лабораторные исследования', value: 'lab' },
{ label: 'Манипуляции и процедуры', value: 'procedure' },
{ label: 'Операции', value: 'surgery' },
{ label: 'Пребывание и размещение', value: 'stay' },
{ label: 'Комплексные программы', value: 'package' },
{ label: 'Стоматология', value: 'dentistry' },
],
defaultValue: 'consultation',
},
{
name: 'groupTitle',
type: 'text',
index: true,
localized: true,
label: 'Группа прейскуранта',
},
{
name: 'branches',
type: 'relationship',
relationTo: 'branches',
hasMany: true,
},
{ name: 'isPopular', type: 'checkbox', defaultValue: false },
{ name: 'isPublished', type: 'checkbox', defaultValue: true },
],
}
+16
View File
@@ -0,0 +1,16 @@
import type { CollectionConfig } from 'payload'
import { ADMIN_GROUP } from '../admin/groups'
export const Specialties: CollectionConfig = {
slug: 'specialties',
labels: { singular: 'Направление', plural: 'Направления' },
admin: { group: ADMIN_GROUP.catalog, useAsTitle: 'title' },
access: { read: () => true },
fields: [
{ name: 'title', type: 'text', required: true, localized: true },
{ name: 'slug', type: 'text', required: true, unique: true },
{ name: 'description', type: 'textarea', localized: true },
{ name: 'icon', type: 'text', admin: { description: 'Имя иконки Untitled UI' } },
{ name: 'sortOrder', type: 'number', defaultValue: 0 },
],
}
+91
View File
@@ -0,0 +1,91 @@
import type { CollectionConfig } from 'payload'
import { ADMIN_GROUP } from '../admin/groups'
export const Users: CollectionConfig = {
slug: 'users',
labels: { singular: 'Пользователь', plural: 'Пользователи' },
auth: {
tokenExpiration: 60 * 60 * 24 * 7,
cookies: {
sameSite: 'Lax',
secure: process.env.NODE_ENV === 'production',
},
},
admin: {
group: ADMIN_GROUP.cabinet,
useAsTitle: 'email',
defaultColumns: ['email', 'fullName', 'role'],
},
fields: [
{
name: 'fullName',
type: 'text',
required: true,
label: 'ФИО',
},
{
name: 'phone',
type: 'text',
label: 'Телефон',
},
{
name: 'role',
type: 'select',
required: true,
defaultValue: 'patient',
options: [
{ label: 'Пациент', value: 'patient' },
{ label: 'Врач', value: 'doctor' },
{ label: 'Администратор', value: 'admin' },
],
},
{
name: 'birthDate',
type: 'date',
label: 'Дата рождения',
},
{
name: 'locale',
type: 'select',
defaultValue: 'ru',
options: [
{ label: 'Русский', value: 'ru' },
{ label: 'English', value: 'en' },
{ label: 'Беларуская', value: 'be' },
{ label: 'Тоҷикӣ', value: 'tg' },
{ label: 'Кыргызча', value: 'ky' },
{ label: '中文', value: 'zh' },
],
},
{
name: 'smsOptIn',
type: 'checkbox',
defaultValue: true,
label: 'SMS-уведомления (напоминания и переносы)',
admin: {
description: 'Канал для будущей SMS-рассылки',
},
},
{
name: 'emailOptIn',
type: 'checkbox',
defaultValue: true,
label: 'Email-уведомления',
},
],
access: {
create: () => true,
read: ({ req: { user } }) => {
if (!user) return false
if (user.role === 'admin') return true
return { id: { equals: user.id } }
},
update: ({ req: { user } }) => {
if (!user) return false
if (user.role === 'admin') return true
return { id: { equals: user.id } }
},
delete: ({ req: { user } }) => user?.role === 'admin',
admin: ({ req: { user } }) => user?.role === 'admin',
},
}
@@ -0,0 +1,14 @@
import type { UIFieldServerComponent } from 'payload'
export const ResendStatus: UIFieldServerComponent = () => {
const hasEnvKey = Boolean(process.env.RESEND_API_KEY)
return (
<div className="field-type" style={{ marginBottom: 16 }}>
<p style={{ margin: 0, color: 'var(--theme-elevation-600)' }}>
Ключ в окружении <code>RESEND_API_KEY</code>: {hasEnvKey ? 'задан' : 'не задан'}.
Админский ключ из формы имеет приоритет, если заполнен.
</p>
</div>
)
}
@@ -0,0 +1,29 @@
'use client'
import type { TextFieldClientComponent } from 'payload'
import { FieldDescription, FieldError, FieldLabel, useField } from '@payloadcms/ui'
export const SecretField: TextFieldClientComponent = ({ field, path, readOnly }) => {
const { errorMessage, showError, value, setValue } = useField<string>({ path })
const label = typeof field.label === 'string' ? field.label : 'API-ключ Resend'
return (
<div className="field-type text">
<FieldLabel htmlFor={`field-${path}`} label={label} path={path} required={field.required} />
<input
autoComplete="new-password"
disabled={Boolean(readOnly)}
id={`field-${path}`}
name={path}
onChange={(event) => setValue(event.target.value)}
type="password"
value={value || ''}
/>
<FieldError message={errorMessage} path={path} showError={showError} />
{field.admin?.description ? (
<FieldDescription description={field.admin.description} path={path} />
) : null}
</div>
)
}
@@ -0,0 +1,45 @@
'use client'
import { Button, toast, useFormFields } from '@payloadcms/ui'
import { useState } from 'react'
export function SendTestEmail() {
const testEmail = useFormFields(([fields]) => fields.testEmail?.value as string | undefined)
const [pending, setPending] = useState(false)
async function sendTest() {
setPending(true)
try {
const res = await fetch('/api/custom/email/test', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ to: testEmail }),
})
const data = (await res.json()) as { error?: string; skipped?: boolean }
if (!res.ok) {
throw new Error(data.error || 'Не удалось отправить письмо')
}
if (data.skipped) {
toast.info('Рассылка выключена — включите её и сохраните настройки.')
return
}
toast.success('Тестовое письмо отправлено')
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Ошибка отправки')
} finally {
setPending(false)
}
}
return (
<div className="field-type" style={{ marginBottom: 24 }}>
<Button buttonStyle="secondary" disabled={pending} onClick={sendTest} type="button">
{pending ? 'Отправка…' : 'Отправить тестовое письмо'}
</Button>
<p style={{ margin: '8px 0 0', color: 'var(--theme-elevation-500)', fontSize: 13 }}>
Сначала сохраните настройки. Письмо уйдёт на адрес выше или на email текущего пользователя.
</p>
</div>
)
}
+108
View File
@@ -0,0 +1,108 @@
import type { GlobalConfig } from 'payload'
import { ADMIN_GROUP } from '../admin/groups'
const adminOnly = ({ req: { user } }: { req: { user?: { role?: string } | null } }) =>
user?.role === 'admin'
export const Settings: GlobalConfig = {
slug: 'settings',
label: 'Рассылка Resend',
admin: {
group: ADMIN_GROUP.system,
description: 'Настройки исходящей почты через Resend: отправитель, ключ и тестовая отправка.',
},
access: {
read: adminOnly,
update: adminOnly,
},
fields: [
{
type: 'tabs',
tabs: [
{
label: 'Resend',
fields: [
{
name: 'resendEnabled',
type: 'checkbox',
label: 'Включить рассылку',
defaultValue: false,
admin: {
description:
'Если выключено, письма не отправляются (включая восстановление пароля и уведомления).',
},
},
{
name: 'fromName',
type: 'text',
label: 'Имя отправителя',
defaultValue: 'МОНИКИ',
},
{
name: 'fromEmail',
type: 'email',
label: 'Email отправителя',
defaultValue: 'onboarding@resend.dev',
admin: {
description:
'Для продакшена укажите адрес на подтверждённом домене в Resend. Для тестов можно оставить onboarding@resend.dev.',
},
},
{
name: 'replyTo',
type: 'email',
label: 'Reply-To',
admin: {
description: 'Необязательно. Куда пациент ответит на письмо.',
},
},
{
name: 'resendApiKey',
type: 'text',
label: 'API-ключ Resend',
admin: {
description:
'Ключ из https://resend.com/api-keys. Если пусто, используется переменная окружения RESEND_API_KEY.',
components: {
Field: '/components/admin/SecretField#SecretField',
},
},
},
{
name: 'resendStatus',
type: 'ui',
admin: {
components: {
Field: '/components/admin/ResendStatus#ResendStatus',
},
},
},
{
name: 'testEmail',
type: 'email',
label: 'Email для тестового письма',
},
{
name: 'sendTestEmail',
type: 'ui',
admin: {
components: {
Field: '/components/admin/SendTestEmail#SendTestEmail',
},
},
},
{
name: 'overrideRecipient',
type: 'email',
label: 'Перехватывать все письма на адрес',
admin: {
description:
'Для отладки: все исходящие письма уйдут только сюда, реальные адресаты не получат ничего.',
},
},
],
},
],
},
],
}
+112
View File
@@ -0,0 +1,112 @@
import { APIError, type EmailAdapter, type Payload, type SendEmailOptions } from 'payload'
export type EmailSettings = {
enabled: boolean
fromName: string
fromEmail: string
replyTo?: string
apiKey: string
overrideRecipient?: string
}
const DEFAULT_FROM_NAME = process.env.RESEND_FROM_NAME || 'МОНИКИ'
const DEFAULT_FROM_EMAIL = process.env.RESEND_FROM_EMAIL || 'onboarding@resend.dev'
export async function getEmailSettings(payload: Payload): Promise<EmailSettings> {
try {
const settings = await payload.findGlobal({
slug: 'settings',
overrideAccess: true,
})
return {
enabled: Boolean(settings.resendEnabled),
fromName: settings.fromName?.trim() || DEFAULT_FROM_NAME,
fromEmail: settings.fromEmail?.trim() || DEFAULT_FROM_EMAIL,
replyTo: settings.replyTo?.trim() || undefined,
apiKey: settings.resendApiKey?.trim() || process.env.RESEND_API_KEY || '',
overrideRecipient: settings.overrideRecipient?.trim() || undefined,
}
} catch {
return {
enabled: false,
fromName: DEFAULT_FROM_NAME,
fromEmail: DEFAULT_FROM_EMAIL,
apiKey: process.env.RESEND_API_KEY || '',
}
}
}
function mapAddresses(addresses: SendEmailOptions['to']): string | string[] {
if (!addresses) return ''
if (typeof addresses === 'string') return addresses
if (Array.isArray(addresses)) {
return addresses.map((address) => (typeof address === 'string' ? address : address.address))
}
return addresses.address
}
function mapFrom(from: SendEmailOptions['from'], fromName: string, fromEmail: string) {
if (!from) return `${fromName} <${fromEmail}>`
if (typeof from === 'string') return from
return `${from.name} <${from.address}>`
}
export const settingsResendAdapter: EmailAdapter<{ id?: string; skipped?: boolean }> = ({
payload,
}) => ({
name: 'resend',
defaultFromAddress: DEFAULT_FROM_EMAIL,
defaultFromName: DEFAULT_FROM_NAME,
sendEmail: async (message) => {
const settings = await getEmailSettings(payload)
if (!settings.enabled) {
payload.logger.info('[email] skipped — Resend is disabled in settings')
return { skipped: true }
}
if (!settings.apiKey) {
throw new APIError(
'Resend не настроен: укажите API-ключ в «Рассылка Resend» или в RESEND_API_KEY.',
400,
)
}
const to = settings.overrideRecipient || mapAddresses(message.to)
const html = message.html?.toString() || ''
const text = message.text?.toString() || ''
const res = await fetch('https://api.resend.com/emails', {
method: 'POST',
headers: {
Authorization: `Bearer ${settings.apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
from: mapFrom(message.from, settings.fromName, settings.fromEmail),
to,
subject: message.subject ?? '',
html: html || undefined,
text: text || (html ? undefined : message.subject || ' '),
reply_to: message.replyTo ? mapAddresses(message.replyTo) : settings.replyTo,
}),
})
const rawBody = await res.text()
let data: { id?: string; message?: string } | undefined
if (rawBody) {
try {
data = JSON.parse(rawBody) as { id?: string; message?: string }
} catch {
data = undefined
}
}
if (res.ok && data?.id) {
return { id: data.id }
}
const detail = data?.message || rawBody || res.statusText
throw new APIError(`Ошибка Resend (${res.status}): ${detail}`, res.status || 500)
},
})
+28
View File
@@ -0,0 +1,28 @@
import { sql } from '@payloadcms/db-postgres'
import type { Payload } from 'payload'
type DrizzleAdapter = {
drizzle: { execute: (query: unknown) => Promise<unknown> }
}
async function exec(payload: Payload, query: unknown) {
const adapter = payload.db as DrizzleAdapter
await adapter.drizzle.execute(query)
}
export async function ensureBannersSchema(payload: Payload) {
await exec(payload, sql`ALTER TABLE banners ADD COLUMN IF NOT EXISTS video_id integer`)
await exec(payload, sql`ALTER TABLE banners ADD COLUMN IF NOT EXISTS video_url character varying`)
await exec(payload, sql`CREATE INDEX IF NOT EXISTS banners_video_idx ON banners (video_id)`)
await exec(
payload,
sql`
DO $$ BEGIN
ALTER TABLE banners
ADD CONSTRAINT banners_video_id_media_id_fk
FOREIGN KEY (video_id) REFERENCES media(id) ON DELETE SET NULL;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$
`,
)
}
@@ -0,0 +1,29 @@
import { sql } from '@payloadcms/db-postgres'
import type { Payload } from 'payload'
type DrizzleAdapter = {
drizzle: { execute: (query: unknown) => Promise<unknown> }
}
async function exec(payload: Payload, query: unknown) {
const adapter = payload.db as DrizzleAdapter
await adapter.drizzle.execute(query)
}
export async function ensureDocumentsSchema(payload: Payload) {
await exec(
payload,
sql`
DO $$ BEGIN
ALTER TYPE enum_documents_type ADD VALUE IF NOT EXISTS 'contract';
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$
`,
)
await exec(payload, sql`ALTER TABLE documents ALTER COLUMN patient_id DROP NOT NULL`)
await exec(payload, sql`ALTER TABLE documents ADD COLUMN IF NOT EXISTS is_public boolean DEFAULT false`)
await exec(payload, sql`UPDATE documents SET is_public = false WHERE is_public IS NULL`)
await exec(payload, sql`CREATE INDEX IF NOT EXISTS documents_is_public_idx ON documents (is_public)`)
}
+514
View File
@@ -0,0 +1,514 @@
import { sql } from '@payloadcms/db-postgres'
import type { Payload } from 'payload'
type DrizzleAdapter = {
drizzle: { execute: (query: unknown) => Promise<unknown> }
}
async function exec(payload: Payload, query: unknown) {
const adapter = payload.db as DrizzleAdapter
await adapter.drizzle.execute(query)
}
export async function ensurePagesSchema(payload: Payload) {
await exec(
payload,
sql`
DO $$ BEGIN
CREATE TYPE enum_pages_section AS ENUM ('organization', 'useful');
EXCEPTION WHEN duplicate_object THEN NULL;
END $$
`,
)
await exec(
payload,
sql`
CREATE TABLE IF NOT EXISTS pages (
id serial PRIMARY KEY,
slug character varying NOT NULL,
section enum_pages_section NOT NULL DEFAULT 'organization',
sort_order numeric DEFAULT 100,
show_in_footer boolean DEFAULT true,
is_published boolean DEFAULT true,
source_url character varying,
cta_href character varying,
updated_at timestamp(3) with time zone NOT NULL DEFAULT now(),
created_at timestamp(3) with time zone NOT NULL DEFAULT now()
)
`,
)
await exec(payload, sql`ALTER TABLE pages ADD COLUMN IF NOT EXISTS slug character varying`)
await exec(payload, sql`ALTER TABLE pages ADD COLUMN IF NOT EXISTS section enum_pages_section DEFAULT 'organization'`)
await exec(payload, sql`ALTER TABLE pages ADD COLUMN IF NOT EXISTS sort_order numeric DEFAULT 100`)
await exec(payload, sql`ALTER TABLE pages ADD COLUMN IF NOT EXISTS show_in_footer boolean DEFAULT true`)
await exec(payload, sql`ALTER TABLE pages ADD COLUMN IF NOT EXISTS is_published boolean DEFAULT true`)
await exec(payload, sql`ALTER TABLE pages ADD COLUMN IF NOT EXISTS source_url character varying`)
await exec(payload, sql`ALTER TABLE pages ADD COLUMN IF NOT EXISTS cta_href character varying`)
await exec(
payload,
sql`
CREATE TABLE IF NOT EXISTS pages_locales (
id serial PRIMARY KEY,
title character varying NOT NULL,
heading character varying,
subtitle character varying,
content jsonb,
notice character varying,
cta_hint character varying,
cta_label character varying,
timeline_title character varying,
table_caption character varying,
_locale _locales NOT NULL,
_parent_id integer NOT NULL
)
`,
)
await exec(payload, sql`ALTER TABLE pages_locales ADD COLUMN IF NOT EXISTS title character varying`)
await exec(payload, sql`ALTER TABLE pages_locales ADD COLUMN IF NOT EXISTS heading character varying`)
await exec(payload, sql`ALTER TABLE pages_locales ADD COLUMN IF NOT EXISTS subtitle character varying`)
await exec(payload, sql`ALTER TABLE pages_locales ADD COLUMN IF NOT EXISTS content jsonb`)
await exec(payload, sql`ALTER TABLE pages_locales ADD COLUMN IF NOT EXISTS notice character varying`)
await exec(payload, sql`ALTER TABLE pages_locales ADD COLUMN IF NOT EXISTS cta_hint character varying`)
await exec(payload, sql`ALTER TABLE pages_locales ADD COLUMN IF NOT EXISTS cta_label character varying`)
await exec(payload, sql`ALTER TABLE pages_locales ADD COLUMN IF NOT EXISTS table_caption character varying`)
await exec(payload, sql`CREATE UNIQUE INDEX IF NOT EXISTS pages_slug_idx ON pages (slug)`)
await exec(payload, sql`CREATE INDEX IF NOT EXISTS pages_created_at_idx ON pages (created_at)`)
await exec(payload, sql`CREATE INDEX IF NOT EXISTS pages_updated_at_idx ON pages (updated_at)`)
await exec(
payload,
sql`
CREATE UNIQUE INDEX IF NOT EXISTS pages_locales_locale_parent_id_unique
ON pages_locales (_locale, _parent_id)
`,
)
await exec(
payload,
sql`
DO $$ BEGIN
ALTER TABLE pages_locales
ADD CONSTRAINT pages_locales_parent_id_fk
FOREIGN KEY (_parent_id) REFERENCES pages(id) ON DELETE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$
`,
)
await exec(
payload,
sql`
CREATE TABLE IF NOT EXISTS pages_facts (
_order integer NOT NULL,
_parent_id integer NOT NULL,
id character varying NOT NULL,
PRIMARY KEY (id)
)
`,
)
await exec(payload, sql`CREATE INDEX IF NOT EXISTS pages_facts_order_idx ON pages_facts (_order)`)
await exec(payload, sql`CREATE INDEX IF NOT EXISTS pages_facts_parent_id_idx ON pages_facts (_parent_id)`)
await exec(
payload,
sql`
DO $$ BEGIN
ALTER TABLE pages_facts
ADD CONSTRAINT pages_facts_parent_id_fk
FOREIGN KEY (_parent_id) REFERENCES pages(id) ON DELETE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$
`,
)
await exec(
payload,
sql`
CREATE TABLE IF NOT EXISTS pages_facts_locales (
label character varying NOT NULL,
value character varying NOT NULL,
id serial PRIMARY KEY,
_locale _locales NOT NULL,
_parent_id character varying NOT NULL
)
`,
)
await exec(
payload,
sql`
CREATE UNIQUE INDEX IF NOT EXISTS pages_facts_locales_locale_parent_id_unique
ON pages_facts_locales (_locale, _parent_id)
`,
)
await exec(
payload,
sql`
DO $$ BEGIN
ALTER TABLE pages_facts_locales
ADD CONSTRAINT pages_facts_locales_parent_id_fk
FOREIGN KEY (_parent_id) REFERENCES pages_facts(id) ON DELETE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$
`,
)
await exec(
payload,
sql`
CREATE TABLE IF NOT EXISTS pages_timeline (
_order integer NOT NULL,
_parent_id integer NOT NULL,
id character varying NOT NULL,
year character varying,
PRIMARY KEY (id)
)
`,
)
await exec(payload, sql`ALTER TABLE pages_timeline ADD COLUMN IF NOT EXISTS year character varying`)
await exec(payload, sql`CREATE INDEX IF NOT EXISTS pages_timeline_order_idx ON pages_timeline (_order)`)
await exec(payload, sql`CREATE INDEX IF NOT EXISTS pages_timeline_parent_id_idx ON pages_timeline (_parent_id)`)
await exec(
payload,
sql`
DO $$ BEGIN
ALTER TABLE pages_timeline
ADD CONSTRAINT pages_timeline_parent_id_fk
FOREIGN KEY (_parent_id) REFERENCES pages(id) ON DELETE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$
`,
)
await exec(
payload,
sql`
CREATE TABLE IF NOT EXISTS pages_timeline_locales (
text character varying,
id serial PRIMARY KEY,
_locale _locales NOT NULL,
_parent_id character varying NOT NULL
)
`,
)
await exec(payload, sql`ALTER TABLE pages_timeline_locales ADD COLUMN IF NOT EXISTS text character varying`)
await exec(
payload,
sql`
CREATE UNIQUE INDEX IF NOT EXISTS pages_timeline_locales_locale_parent_id_unique
ON pages_timeline_locales (_locale, _parent_id)
`,
)
await exec(
payload,
sql`
DO $$ BEGIN
ALTER TABLE pages_timeline_locales
ADD CONSTRAINT pages_timeline_locales_parent_id_fk
FOREIGN KEY (_parent_id) REFERENCES pages_timeline(id) ON DELETE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$
`,
)
await exec(
payload,
sql`
CREATE TABLE IF NOT EXISTS pages_people (
_order integer NOT NULL,
_parent_id integer NOT NULL,
id character varying NOT NULL,
name character varying,
photo_id integer,
photo_url character varying,
PRIMARY KEY (id)
)
`,
)
await exec(payload, sql`ALTER TABLE pages_people ADD COLUMN IF NOT EXISTS name character varying`)
await exec(payload, sql`ALTER TABLE pages_people ADD COLUMN IF NOT EXISTS photo_id integer`)
await exec(payload, sql`ALTER TABLE pages_people ADD COLUMN IF NOT EXISTS photo_url character varying`)
await exec(payload, sql`CREATE INDEX IF NOT EXISTS pages_people_order_idx ON pages_people (_order)`)
await exec(payload, sql`CREATE INDEX IF NOT EXISTS pages_people_parent_id_idx ON pages_people (_parent_id)`)
await exec(payload, sql`CREATE INDEX IF NOT EXISTS pages_people_photo_idx ON pages_people (photo_id)`)
await exec(
payload,
sql`
DO $$ BEGIN
ALTER TABLE pages_people
ADD CONSTRAINT pages_people_parent_id_fk
FOREIGN KEY (_parent_id) REFERENCES pages(id) ON DELETE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$
`,
)
await exec(
payload,
sql`
DO $$ BEGIN
ALTER TABLE pages_people
ADD CONSTRAINT pages_people_photo_id_media_id_fk
FOREIGN KEY (photo_id) REFERENCES media(id) ON DELETE SET NULL;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$
`,
)
await exec(
payload,
sql`
CREATE TABLE IF NOT EXISTS pages_people_locales (
position character varying,
degree character varying,
hours character varying,
address character varying,
id serial PRIMARY KEY,
_locale _locales NOT NULL,
_parent_id character varying NOT NULL
)
`,
)
await exec(payload, sql`ALTER TABLE pages_people_locales ADD COLUMN IF NOT EXISTS position character varying`)
await exec(payload, sql`ALTER TABLE pages_people_locales ADD COLUMN IF NOT EXISTS degree character varying`)
await exec(payload, sql`ALTER TABLE pages_people_locales ADD COLUMN IF NOT EXISTS hours character varying`)
await exec(payload, sql`ALTER TABLE pages_people_locales ADD COLUMN IF NOT EXISTS address character varying`)
await exec(
payload,
sql`
CREATE UNIQUE INDEX IF NOT EXISTS pages_people_locales_locale_parent_id_unique
ON pages_people_locales (_locale, _parent_id)
`,
)
await exec(
payload,
sql`
DO $$ BEGIN
ALTER TABLE pages_people_locales
ADD CONSTRAINT pages_people_locales_parent_id_fk
FOREIGN KEY (_parent_id) REFERENCES pages_people(id) ON DELETE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$
`,
)
await exec(
payload,
sql`
CREATE TABLE IF NOT EXISTS pages_cards (
_order integer NOT NULL,
_parent_id integer NOT NULL,
id character varying NOT NULL,
status character varying,
number character varying,
issued_at character varying,
phone character varying,
email character varying,
href character varying,
PRIMARY KEY (id)
)
`,
)
await exec(payload, sql`ALTER TABLE pages_cards ADD COLUMN IF NOT EXISTS status character varying`)
await exec(payload, sql`ALTER TABLE pages_cards ADD COLUMN IF NOT EXISTS number character varying`)
await exec(payload, sql`ALTER TABLE pages_cards ADD COLUMN IF NOT EXISTS issued_at character varying`)
await exec(payload, sql`ALTER TABLE pages_cards ADD COLUMN IF NOT EXISTS phone character varying`)
await exec(payload, sql`ALTER TABLE pages_cards ADD COLUMN IF NOT EXISTS email character varying`)
await exec(payload, sql`ALTER TABLE pages_cards ADD COLUMN IF NOT EXISTS href character varying`)
await exec(payload, sql`CREATE INDEX IF NOT EXISTS pages_cards_order_idx ON pages_cards (_order)`)
await exec(payload, sql`CREATE INDEX IF NOT EXISTS pages_cards_parent_id_idx ON pages_cards (_parent_id)`)
await exec(
payload,
sql`
DO $$ BEGIN
ALTER TABLE pages_cards
ADD CONSTRAINT pages_cards_parent_id_fk
FOREIGN KEY (_parent_id) REFERENCES pages(id) ON DELETE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$
`,
)
await exec(
payload,
sql`
CREATE TABLE IF NOT EXISTS pages_cards_locales (
title character varying,
role character varying,
authority character varying,
activity character varying,
extract character varying,
address character varying,
link_label character varying,
id serial PRIMARY KEY,
_locale _locales NOT NULL,
_parent_id character varying NOT NULL
)
`,
)
await exec(payload, sql`ALTER TABLE pages_cards_locales ADD COLUMN IF NOT EXISTS title character varying`)
await exec(payload, sql`ALTER TABLE pages_cards_locales ADD COLUMN IF NOT EXISTS role character varying`)
await exec(payload, sql`ALTER TABLE pages_cards_locales ADD COLUMN IF NOT EXISTS authority character varying`)
await exec(payload, sql`ALTER TABLE pages_cards_locales ADD COLUMN IF NOT EXISTS activity character varying`)
await exec(payload, sql`ALTER TABLE pages_cards_locales ADD COLUMN IF NOT EXISTS extract character varying`)
await exec(payload, sql`ALTER TABLE pages_cards_locales ADD COLUMN IF NOT EXISTS address character varying`)
await exec(payload, sql`ALTER TABLE pages_cards_locales ADD COLUMN IF NOT EXISTS link_label character varying`)
await exec(
payload,
sql`
CREATE UNIQUE INDEX IF NOT EXISTS pages_cards_locales_locale_parent_id_unique
ON pages_cards_locales (_locale, _parent_id)
`,
)
await exec(
payload,
sql`
DO $$ BEGIN
ALTER TABLE pages_cards_locales
ADD CONSTRAINT pages_cards_locales_parent_id_fk
FOREIGN KEY (_parent_id) REFERENCES pages_cards(id) ON DELETE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$
`,
)
await exec(
payload,
sql`
CREATE TABLE IF NOT EXISTS pages_faq (
_order integer NOT NULL,
_parent_id integer NOT NULL,
id character varying NOT NULL,
PRIMARY KEY (id)
)
`,
)
await exec(payload, sql`CREATE INDEX IF NOT EXISTS pages_faq_order_idx ON pages_faq (_order)`)
await exec(payload, sql`CREATE INDEX IF NOT EXISTS pages_faq_parent_id_idx ON pages_faq (_parent_id)`)
await exec(
payload,
sql`
DO $$ BEGIN
ALTER TABLE pages_faq
ADD CONSTRAINT pages_faq_parent_id_fk
FOREIGN KEY (_parent_id) REFERENCES pages(id) ON DELETE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$
`,
)
await exec(
payload,
sql`
CREATE TABLE IF NOT EXISTS pages_faq_locales (
question character varying,
answer character varying,
id serial PRIMARY KEY,
_locale _locales NOT NULL,
_parent_id character varying NOT NULL
)
`,
)
await exec(payload, sql`ALTER TABLE pages_faq_locales ADD COLUMN IF NOT EXISTS question character varying`)
await exec(payload, sql`ALTER TABLE pages_faq_locales ADD COLUMN IF NOT EXISTS answer character varying`)
await exec(
payload,
sql`
CREATE UNIQUE INDEX IF NOT EXISTS pages_faq_locales_locale_parent_id_unique
ON pages_faq_locales (_locale, _parent_id)
`,
)
await exec(
payload,
sql`
DO $$ BEGIN
ALTER TABLE pages_faq_locales
ADD CONSTRAINT pages_faq_locales_parent_id_fk
FOREIGN KEY (_parent_id) REFERENCES pages_faq(id) ON DELETE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$
`,
)
await exec(
payload,
sql`
CREATE TABLE IF NOT EXISTS pages_table_rows (
_order integer NOT NULL,
_parent_id integer NOT NULL,
id character varying NOT NULL,
PRIMARY KEY (id)
)
`,
)
await exec(payload, sql`CREATE INDEX IF NOT EXISTS pages_table_rows_order_idx ON pages_table_rows (_order)`)
await exec(payload, sql`CREATE INDEX IF NOT EXISTS pages_table_rows_parent_id_idx ON pages_table_rows (_parent_id)`)
await exec(
payload,
sql`
DO $$ BEGIN
ALTER TABLE pages_table_rows
ADD CONSTRAINT pages_table_rows_parent_id_fk
FOREIGN KEY (_parent_id) REFERENCES pages(id) ON DELETE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$
`,
)
await exec(
payload,
sql`
CREATE TABLE IF NOT EXISTS pages_table_rows_locales (
category character varying,
condition character varying,
discount character varying,
documents character varying,
id serial PRIMARY KEY,
_locale _locales NOT NULL,
_parent_id character varying NOT NULL
)
`,
)
await exec(payload, sql`ALTER TABLE pages_table_rows_locales ADD COLUMN IF NOT EXISTS category character varying`)
await exec(payload, sql`ALTER TABLE pages_table_rows_locales ADD COLUMN IF NOT EXISTS condition character varying`)
await exec(payload, sql`ALTER TABLE pages_table_rows_locales ADD COLUMN IF NOT EXISTS discount character varying`)
await exec(payload, sql`ALTER TABLE pages_table_rows_locales ADD COLUMN IF NOT EXISTS documents character varying`)
await exec(
payload,
sql`
CREATE UNIQUE INDEX IF NOT EXISTS pages_table_rows_locales_locale_parent_id_unique
ON pages_table_rows_locales (_locale, _parent_id)
`,
)
await exec(
payload,
sql`
DO $$ BEGIN
ALTER TABLE pages_table_rows_locales
ADD CONSTRAINT pages_table_rows_locales_parent_id_fk
FOREIGN KEY (_parent_id) REFERENCES pages_table_rows(id) ON DELETE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$
`,
)
await exec(
payload,
sql`
ALTER TABLE payload_locked_documents_rels
ADD COLUMN IF NOT EXISTS pages_id integer
`,
)
await exec(
payload,
sql`
CREATE INDEX IF NOT EXISTS payload_locked_documents_rels_pages_id_idx
ON payload_locked_documents_rels (pages_id)
`,
)
await exec(
payload,
sql`
DO $$ BEGIN
ALTER TABLE payload_locked_documents_rels
ADD CONSTRAINT payload_locked_documents_rels_pages_fk
FOREIGN KEY (pages_id) REFERENCES pages(id) ON DELETE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$
`,
)
}
@@ -0,0 +1,27 @@
import { sql } from '@payloadcms/db-postgres'
import type { Payload } from 'payload'
type DrizzleAdapter = {
drizzle: { execute: (query: unknown) => Promise<unknown> }
}
async function exec(payload: Payload, query: unknown) {
const adapter = payload.db as DrizzleAdapter
await adapter.drizzle.execute(query)
}
export async function ensureServicesSchema(payload: Payload) {
await exec(payload, sql`ALTER TABLE services_locales ADD COLUMN IF NOT EXISTS group_title character varying`)
await exec(
payload,
sql`
UPDATE services_locales AS sl
SET group_title = s.group_title
FROM services AS s
WHERE sl._parent_id = s.id
AND sl._locale = 'ru'
AND (sl.group_title IS NULL OR sl.group_title = '')
AND s.group_title IS NOT NULL
`,
)
}
@@ -0,0 +1,42 @@
import { sql } from '@payloadcms/db-postgres'
import type { Payload } from 'payload'
export async function ensureSettingsSchema(payload: Payload) {
const adapter = payload.db as {
drizzle: { execute: (query: unknown) => Promise<unknown> }
}
await adapter.drizzle.execute(sql`
CREATE TABLE IF NOT EXISTS settings (
id integer PRIMARY KEY,
resend_enabled boolean DEFAULT false,
from_name varchar,
from_email varchar,
reply_to varchar,
resend_api_key varchar,
test_email varchar,
override_recipient varchar,
updated_at timestamp(3) with time zone NOT NULL DEFAULT now(),
created_at timestamp(3) with time zone NOT NULL DEFAULT now()
)
`)
await adapter.drizzle.execute(sql`ALTER TABLE settings ADD COLUMN IF NOT EXISTS resend_enabled boolean DEFAULT false`)
await adapter.drizzle.execute(sql`ALTER TABLE settings ADD COLUMN IF NOT EXISTS from_name varchar`)
await adapter.drizzle.execute(sql`ALTER TABLE settings ADD COLUMN IF NOT EXISTS from_email varchar`)
await adapter.drizzle.execute(sql`ALTER TABLE settings ADD COLUMN IF NOT EXISTS reply_to varchar`)
await adapter.drizzle.execute(sql`ALTER TABLE settings ADD COLUMN IF NOT EXISTS resend_api_key varchar`)
await adapter.drizzle.execute(sql`ALTER TABLE settings ADD COLUMN IF NOT EXISTS test_email varchar`)
await adapter.drizzle.execute(sql`ALTER TABLE settings ADD COLUMN IF NOT EXISTS override_recipient varchar`)
await adapter.drizzle.execute(sql`
INSERT INTO settings (id, resend_enabled, from_name, from_email)
VALUES (1, false, 'МОНИКИ', 'onboarding@resend.dev')
ON CONFLICT (id) DO NOTHING
`)
await adapter.drizzle.execute(sql`
ALTER TABLE payload_locked_documents_rels
ADD COLUMN IF NOT EXISTS settings_id integer
`)
}
+62
View File
@@ -0,0 +1,62 @@
/**
* Notification dispatcher — email via Resend, SMS channel ready for later provider.
*/
import type { Payload } from 'payload'
export type NotifyInput = {
channel: 'email' | 'sms'
to: string
subject: string
body: string
}
function escapeHtml(text: string) {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\n/g, '<br>')
}
export async function sendNotification(
payload: Payload,
input: NotifyInput,
): Promise<{ ok: boolean; skipped?: boolean }> {
if (input.channel === 'sms') {
if (process.env.SMS_ENABLED !== 'true') {
return { ok: true, skipped: true }
}
// TODO: wire SMS provider (SMS.ru / Twilio / etc.)
payload.logger.info(`[sms] ${input.to} ${input.body}`)
return { ok: true }
}
try {
const result = await payload.sendEmail({
to: input.to,
subject: input.subject,
html: `<p>${escapeHtml(input.body)}</p>`,
text: input.body,
})
if (result && typeof result === 'object' && 'skipped' in result && result.skipped) {
return { ok: true, skipped: true }
}
return { ok: true }
} catch (error) {
payload.logger.error(error)
return { ok: false }
}
}
export function buildReminderMessage(params: {
patientName: string
startsAt: string
doctorName: string
}): { subject: string; body: string } {
const when = new Date(params.startsAt).toLocaleString('ru-RU')
return {
subject: 'Напоминание о записи в МОНИКИ',
body: `${params.patientName}, напоминаем о визите ${when} к врачу ${params.doctorName}. При необходимости перенесите или отмените запись в личном кабинете.`,
}
}
+71
View File
@@ -0,0 +1,71 @@
import PDFDocument from 'pdfkit'
import { Readable } from 'stream'
async function streamToBuffer(stream: Readable): Promise<Buffer> {
const chunks: Buffer[] = []
for await (const chunk of stream) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))
}
return Buffer.concat(chunks)
}
export type ReceiptData = {
invoiceNumber: string
patientName: string
serviceTitle: string
doctorName: string
amount: number
paidAt: string
branchAddress: string
}
export async function generateReceiptPdf(data: ReceiptData): Promise<Buffer> {
const doc = new PDFDocument({ margin: 50 })
const stream = doc as unknown as Readable
doc.fontSize(18).text('Чек об оплате медицинских услуг', { align: 'center' })
doc.moveDown()
doc.fontSize(11)
doc.text('ГБУЗ МО МОНИКИ им. М. Ф. Владимирского')
doc.text(data.branchAddress)
doc.moveDown()
doc.text(`Номер счёта: ${data.invoiceNumber}`)
doc.text(`Пациент: ${data.patientName}`)
doc.text(`Услуга: ${data.serviceTitle}`)
doc.text(`Врач: ${data.doctorName}`)
doc.text(`Сумма: ${data.amount.toLocaleString('ru-RU')} ₽`)
doc.text(`Дата оплаты: ${new Date(data.paidAt).toLocaleString('ru-RU')}`)
doc.text('Способ оплаты: СБП')
doc.moveDown()
doc.text('Документ сформирован автоматически.')
doc.end()
return streamToBuffer(stream)
}
export async function generateTaxDeductionPdf(data: ReceiptData): Promise<Buffer> {
const doc = new PDFDocument({ margin: 50 })
const stream = doc as unknown as Readable
doc.fontSize(16).text('Справка об оплате медицинских услуг', { align: 'center' })
doc.fontSize(12).text('для предоставления в налоговые органы', { align: 'center' })
doc.moveDown()
doc.fontSize(11)
doc.text('ГБУЗ МО «Московский областной научно-исследовательский клинический институт им. М. Ф. Владимирского»')
doc.text(data.branchAddress)
doc.moveDown()
doc.text(`Выдана пациенту: ${data.patientName}`)
doc.text(`в том, что им (ею) оплачены медицинские услуги на сумму ${data.amount.toLocaleString('ru-RU')} руб.`)
doc.moveDown()
doc.text(`Услуга: ${data.serviceTitle}`)
doc.text(`Специалист: ${data.doctorName}`)
doc.text(`Дата оплаты: ${new Date(data.paidAt).toLocaleDateString('ru-RU')}`)
doc.text(`Номер документа: ${data.invoiceNumber}`)
doc.moveDown()
doc.text('Справка выдана для получения социального налогового вычета в соответствии с НК РФ.')
doc.moveDown(2)
doc.text('________________ / уполномоченное лицо')
doc.end()
return streamToBuffer(stream)
}
+44
View File
@@ -0,0 +1,44 @@
/**
* SBP (СБП) acquiring adapter.
* Provider credentials come from env; real bank API can be plugged in later.
*/
export type SbpCreatePaymentInput = {
amount: number
invoiceNumber: string
description: string
returnUrl: string
}
export type SbpCreatePaymentResult = {
paymentId: string
qrUrl: string
paymentUrl: string
}
export async function createSbpPayment(
input: SbpCreatePaymentInput,
): Promise<SbpCreatePaymentResult> {
const merchantId = process.env.SBP_MERCHANT_ID
const apiKey = process.env.SBP_API_KEY
if (!merchantId || !apiKey) {
// Dev mock until acquiring is connected
const paymentId = `sbp_mock_${input.invoiceNumber}_${Date.now()}`
return {
paymentId,
qrUrl: `https://qr.nspk.ru/mock/${paymentId}`,
paymentUrl: `${process.env.NEXT_PUBLIC_SERVER_URL}/api/payments/sbp/mock-pay?id=${paymentId}`,
}
}
// Placeholder for real SBP/NSPK or bank acquiring HTTP call
throw new Error('SBP provider integration: set adapter implementation for your bank')
}
export function verifySbpWebhook(signature: string | null, rawBody: string): boolean {
const secret = process.env.SBP_WEBHOOK_SECRET
if (!secret) return process.env.NODE_ENV !== 'production'
// Replace with HMAC verification from your acquiring docs
return Boolean(signature && rawBody)
}
+49
View File
@@ -0,0 +1,49 @@
/** Generate available time slots from schedule templates minus existing bookings. */
type Weekday = {
day: string
startTime: string
endTime: string
}
function parseTime(t: string): number {
const [h, m] = t.split(':').map(Number)
return h * 60 + m
}
function toIso(date: Date, minutes: number): string {
const d = new Date(date)
d.setHours(Math.floor(minutes / 60), minutes % 60, 0, 0)
return d.toISOString()
}
export function generateSlotsForDay(params: {
date: Date
weekdays: Weekday[]
slotMinutes: number
busy: Array<{ startsAt: string; endsAt: string }>
}): Array<{ startsAt: string; endsAt: string }> {
const day = String(params.date.getDay())
const rules = params.weekdays.filter((w) => w.day === day)
const slots: Array<{ startsAt: string; endsAt: string }> = []
for (const rule of rules) {
let cursor = parseTime(rule.startTime)
const end = parseTime(rule.endTime)
while (cursor + params.slotMinutes <= end) {
const startsAt = toIso(params.date, cursor)
const endsAt = toIso(params.date, cursor + params.slotMinutes)
const overlaps = params.busy.some((b) => {
const bs = new Date(b.startsAt).getTime()
const be = new Date(b.endsAt).getTime()
const ss = new Date(startsAt).getTime()
const se = new Date(endsAt).getTime()
return ss < be && se > bs
})
if (!overlaps) slots.push({ startsAt, endsAt })
cursor += params.slotMinutes
}
}
return slots
}
+126
View File
@@ -0,0 +1,126 @@
import { postgresAdapter } from '@payloadcms/db-postgres'
import { lexicalEditor } from '@payloadcms/richtext-lexical'
import { ru } from '@payloadcms/translations/languages/ru'
import path from 'path'
import { buildConfig } from 'payload'
import { fileURLToPath } from 'url'
import sharp from 'sharp'
import { Users } from './collections/Users'
import { Media } from './collections/Media'
import { Banners } from './collections/Banners'
import { Promotions } from './collections/Promotions'
import { Branches } from './collections/Branches'
import { Specialties } from './collections/Specialties'
import { Doctors } from './collections/Doctors'
import { Services } from './collections/Services'
import { Schedules } from './collections/Schedules'
import { Bookings } from './collections/Bookings'
import { Reviews } from './collections/Reviews'
import { Posts } from './collections/Posts'
import { Pages } from './collections/Pages'
import { Gallery } from './collections/Gallery'
import { Documents } from './collections/Documents'
import { Favorites } from './collections/Favorites'
import { Notifications } from './collections/Notifications'
import { Invoices } from './collections/Invoices'
import { Settings } from './globals/Settings'
import { settingsResendAdapter } from './lib/email'
import { ensureSettingsSchema } from './lib/ensure-settings-schema'
import { ensurePagesSchema } from './lib/ensure-pages-schema'
import { ensureDocumentsSchema } from './lib/ensure-documents-schema'
import { ensureServicesSchema } from './lib/ensure-services-schema'
import { ensureBannersSchema } from './lib/ensure-banners-schema'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
const allowedOrigins = [
process.env.NEXT_PUBLIC_SERVER_URL || 'http://localhost:3001',
...(process.env.CORS_ORIGINS || 'http://localhost:3000').split(','),
]
.map((origin) => origin.trim())
.filter(Boolean)
export default buildConfig({
admin: {
user: Users.slug,
importMap: {
baseDir: path.resolve(dirname),
},
meta: {
titleSuffix: ' — МОНИКИ Платные услуги',
},
},
collections: [
Branches,
Specialties,
Doctors,
Services,
Schedules,
Bookings,
Invoices,
Reviews,
Pages,
Banners,
Promotions,
Posts,
Gallery,
Media,
Users,
Documents,
Favorites,
Notifications,
],
globals: [Settings],
email: settingsResendAdapter,
onInit: async (payload) => {
await ensureSettingsSchema(payload)
await ensurePagesSchema(payload)
await ensureDocumentsSchema(payload)
await ensureServicesSchema(payload)
await ensureBannersSchema(payload)
try {
const { seedSitePages } = await import('./seed/site-pages')
await seedSitePages(payload)
} catch (err) {
payload.logger.error({ err }, 'Failed to seed footer pages')
}
try {
const { seedPublicDocuments } = await import('./seed/public-documents')
await seedPublicDocuments(payload)
} catch (err) {
payload.logger.error({ err }, 'Failed to seed public documents')
}
},
editor: lexicalEditor(),
i18n: {
fallbackLanguage: 'ru',
supportedLanguages: { ru },
},
secret: process.env.PAYLOAD_SECRET || 'change-me',
typescript: {
outputFile: path.resolve(dirname, 'payload-types.ts'),
},
db: postgresAdapter({
push: false,
pool: {
connectionString: process.env.DATABASE_URI || '',
},
}),
sharp,
cors: allowedOrigins,
csrf: allowedOrigins,
localization: {
locales: [
{ code: 'ru', label: 'Русский' },
{ code: 'en', label: 'English' },
{ code: 'be', label: 'Беларуская' },
{ code: 'tg', label: 'Тоҷикӣ' },
{ code: 'ky', label: 'Кыргызча' },
{ code: 'zh', label: '中文' },
],
defaultLocale: 'ru',
fallback: true,
},
})
Binary file not shown.
+25
View File
@@ -0,0 +1,25 @@
import 'dotenv/config'
import { getPayload } from 'payload'
import config from '../payload.config'
async function main() {
const payload = await getPayload({ config })
const doctors = await payload.find({ collection: 'doctors', limit: 50, depth: 0 })
console.log('doctors in cms', doctors.docs.length)
const reviews = await payload.find({ collection: 'reviews', limit: 1 })
if (!reviews.docs.length) {
console.log('reviews skipped — no demo doctor reviews')
} else {
console.log('reviews already present')
}
console.log('done')
process.exit(0)
}
main().catch((e) => {
console.error(e)
process.exit(1)
})
+281
View File
@@ -0,0 +1,281 @@
import 'dotenv/config'
import { getPayload } from 'payload'
import config from '../payload.config'
const slides = [
{
slug: 'paid-services',
sortOrder: 10,
durationMs: 7000,
imageUrl: '/hero/moscow-doctors.jpg',
videoUrl: '/hero/250-white.mp4',
backgroundUrl: '/hero/building-blur.jpg',
phone: '+7 (499) 674-07-09',
ctaPrimaryHref: '/booking',
ctaSecondaryHref: '/services',
ru: {
eyebrow: 'МОНИКИ',
title: 'Платные услуги ведущего клинического центра',
subtitle:
'Запишитесь к специалистам ГБУЗ МО МОНИКИ им. М. Ф. Владимирского онлайн — без очередей, с прозрачными ценами.',
imageAlt: 'Врач консультирует пациента в московской поликлинике',
address: 'ул. Щепкина, 61/2, метро Проспект Мира',
ctaPrimaryLabel: 'Записаться на приём',
ctaSecondaryLabel: 'Смотреть услуги',
},
en: {
eyebrow: 'MONIKI',
title: 'Paid care at a leading clinical center',
subtitle: 'Book specialists at MONIKI online — no queues, transparent prices.',
imageAlt: 'A doctor consulting a patient in a Moscow clinic',
address: '61/2 Shchepkina St, Prospect Mira metro',
ctaPrimaryLabel: 'Book an appointment',
ctaSecondaryLabel: 'View services',
},
},
{
slug: 'diagnostics',
sortOrder: 20,
durationMs: 7000,
imageUrl: '/hero/diagnostics.jpg',
backgroundUrl: '/hero/building-blur.jpg',
phone: '+7 (499) 674-07-09',
ctaPrimaryHref: '/booking',
ctaSecondaryHref: '/services',
ru: {
eyebrow: 'Диагностика',
title: 'Комплексное обследование за один день',
subtitle:
'УЗИ, ЭКГ, лабораторные исследования и консультации специалистов КДЦ МОНИКИ — с понятным планом и фиксированными тарифами.',
imageAlt: 'Врач проводит УЗИ-диагностику в клиническом центре МОНИКИ',
address: 'ул. Щепкина, 61/2, метро Проспект Мира',
ctaPrimaryLabel: 'Записаться на диагностику',
ctaSecondaryLabel: 'Прайс услуг',
},
en: {
eyebrow: 'Diagnostics',
title: 'A full check-up in one day',
subtitle:
'Ultrasound, ECG, lab tests and specialist consults at MONIKI CDC — with a clear plan and published prices.',
imageAlt: 'A doctor performing an ultrasound at the MONIKI clinical center',
address: '61/2 Shchepkina St, Prospect Mira metro',
ctaPrimaryLabel: 'Book diagnostics',
ctaSecondaryLabel: 'Price list',
},
},
{
slug: 'specialists',
sortOrder: 30,
durationMs: 7000,
imageUrl: '/hero/specialists.jpg',
backgroundUrl: '/hero/building-blur.jpg',
phone: '+7 (499) 674-07-09',
ctaPrimaryHref: '/doctors',
ctaSecondaryHref: '/booking',
ru: {
eyebrow: 'Врачи',
title: 'Ведущие специалисты института',
subtitle:
'Приём профессоров и врачей высшей категории. Научная школа, клиническая практика и отзывы пациентов МОНИКИ.',
imageAlt: 'Врачи МОНИКИ в коридоре клинического института',
address: 'ул. Щепкина, 61/2, метро Проспект Мира',
ctaPrimaryLabel: 'Смотреть врачей',
ctaSecondaryLabel: 'Записаться',
},
en: {
eyebrow: 'Doctors',
title: 'Leading institute specialists',
subtitle:
'Appointments with professors and highest-category physicians. Academic school, clinical practice and patient reviews.',
imageAlt: 'MONIKI doctors in a historic clinical institute corridor',
address: '61/2 Shchepkina St, Prospect Mira metro',
ctaPrimaryLabel: 'View doctors',
ctaSecondaryLabel: 'Book now',
},
},
{
slug: 'branches',
sortOrder: 40,
durationMs: 7000,
imageUrl: '/hero/reception.jpg',
backgroundUrl: '/hero/building-blur.jpg',
phone: '+7 (499) 674-07-09',
ctaPrimaryHref: '/branches',
ctaSecondaryHref: '/booking',
ru: {
eyebrow: 'Филиалы',
title: 'Два филиала — Москва и Реутов',
subtitle:
'КДЦ на Щепкина, 61/2 у метро «Проспект Мира» и филиал в Реутове. Онлайн-запись и оплата через СБП.',
imageAlt: 'Регистратура консультативно-диагностического центра МОНИКИ',
address: 'Москва и Реутов',
ctaPrimaryLabel: 'Как добраться',
ctaSecondaryLabel: 'Записаться онлайн',
},
en: {
eyebrow: 'Branches',
title: 'Two branches — Moscow and Reutov',
subtitle:
'CDC at 61/2 Shchepkina near Prospect Mira and a Reutov branch. Online booking and SBP payment.',
imageAlt: 'Reception desk at the MONIKI outpatient diagnostic center',
address: 'Moscow and Reutov',
ctaPrimaryLabel: 'How to get there',
ctaSecondaryLabel: 'Book online',
},
},
] as const
async function ensureSchema(payload: Awaited<ReturnType<typeof getPayload>>) {
const adapter = payload.db as {
drizzle: { execute: (query: unknown) => Promise<unknown> }
}
const { sql } = await import('@payloadcms/db-postgres')
await adapter.drizzle.execute(sql`
CREATE TABLE IF NOT EXISTS banners (
id serial PRIMARY KEY,
slug character varying NOT NULL,
image_id integer,
image_url character varying,
background_id integer,
background_url character varying,
cta_primary_href character varying,
cta_secondary_href character varying,
phone character varying,
sort_order numeric DEFAULT 0,
duration_ms numeric DEFAULT 7000,
is_published boolean DEFAULT true,
updated_at timestamp(3) with time zone NOT NULL DEFAULT now(),
created_at timestamp(3) with time zone NOT NULL DEFAULT now()
)
`)
await adapter.drizzle.execute(sql`
CREATE TABLE IF NOT EXISTS banners_locales (
id serial PRIMARY KEY,
eyebrow character varying,
title character varying NOT NULL,
subtitle character varying,
image_alt character varying,
cta_primary_label character varying,
cta_secondary_label character varying,
address character varying,
_locale _locales NOT NULL,
_parent_id integer NOT NULL
)
`)
await adapter.drizzle.execute(sql`CREATE UNIQUE INDEX IF NOT EXISTS banners_slug_idx ON banners (slug)`)
await adapter.drizzle.execute(sql`CREATE INDEX IF NOT EXISTS banners_created_at_idx ON banners (created_at)`)
await adapter.drizzle.execute(sql`CREATE INDEX IF NOT EXISTS banners_updated_at_idx ON banners (updated_at)`)
await adapter.drizzle.execute(sql`CREATE INDEX IF NOT EXISTS banners_image_idx ON banners (image_id)`)
await adapter.drizzle.execute(sql`CREATE INDEX IF NOT EXISTS banners_background_idx ON banners (background_id)`)
await adapter.drizzle.execute(sql`
CREATE UNIQUE INDEX IF NOT EXISTS banners_locales_locale_parent_id_unique
ON banners_locales (_locale, _parent_id)
`)
await adapter.drizzle.execute(sql`
DO $$ BEGIN
ALTER TABLE banners
ADD CONSTRAINT banners_image_id_media_id_fk
FOREIGN KEY (image_id) REFERENCES media(id) ON DELETE SET NULL;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$
`)
await adapter.drizzle.execute(sql`
DO $$ BEGIN
ALTER TABLE banners
ADD CONSTRAINT banners_background_id_media_id_fk
FOREIGN KEY (background_id) REFERENCES media(id) ON DELETE SET NULL;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$
`)
await adapter.drizzle.execute(sql`
DO $$ BEGIN
ALTER TABLE banners_locales
ADD CONSTRAINT banners_locales_parent_id_fk
FOREIGN KEY (_parent_id) REFERENCES banners(id) ON DELETE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$
`)
await adapter.drizzle.execute(sql`
ALTER TABLE payload_locked_documents_rels
ADD COLUMN IF NOT EXISTS banners_id integer
`)
await adapter.drizzle.execute(sql`
CREATE INDEX IF NOT EXISTS payload_locked_documents_rels_banners_id_idx
ON payload_locked_documents_rels (banners_id)
`)
await adapter.drizzle.execute(sql`
DO $$ BEGIN
ALTER TABLE payload_locked_documents_rels
ADD CONSTRAINT payload_locked_documents_rels_banners_fk
FOREIGN KEY (banners_id) REFERENCES banners(id) ON DELETE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$
`)
}
async function main() {
const payload = await getPayload({ config })
await ensureSchema(payload)
for (const slide of slides) {
const existing = await payload.find({
collection: 'banners',
where: { slug: { equals: slide.slug } },
limit: 1,
depth: 0,
})
const data = {
slug: slide.slug,
sortOrder: slide.sortOrder,
durationMs: slide.durationMs,
imageUrl: slide.imageUrl,
videoUrl: 'videoUrl' in slide ? slide.videoUrl : undefined,
backgroundUrl: slide.backgroundUrl,
phone: slide.phone,
ctaPrimaryHref: slide.ctaPrimaryHref,
ctaSecondaryHref: slide.ctaSecondaryHref,
isPublished: true,
...slide.ru,
}
let id = existing.docs[0]?.id
if (id) {
await payload.update({
collection: 'banners',
id,
locale: 'ru',
data,
})
console.log('updated', slide.slug)
} else {
const created = await payload.create({
collection: 'banners',
locale: 'ru',
data,
})
id = created.id
console.log('created', slide.slug)
}
await payload.update({
collection: 'banners',
id,
locale: 'en',
data: slide.en,
})
}
console.log('banners imported', slides.length)
process.exit(0)
}
main().catch((err) => {
console.error(err)
process.exit(1)
})
+128
View File
@@ -0,0 +1,128 @@
import 'dotenv/config'
import { getPayload } from 'payload'
import config from '../payload.config'
import { DEMO_DOCTOR_SLUGS, paidDoctors } from './paid-doctors'
async function upsertDoctor(
payload: Awaited<ReturnType<typeof getPayload>>,
doctor: (typeof paidDoctors)[number],
specialties: Record<string, string | number>,
branchId: string | number,
) {
const existing = await payload.find({
collection: 'doctors',
where: { slug: { equals: doctor.slug } },
limit: 1,
})
const data = {
fullName: doctor.fullName,
slug: doctor.slug,
position: [doctor.position, doctor.specialty].filter((value, index, arr) => value && arr.indexOf(value) === index).join(' · '),
degree: doctor.degree || undefined,
experienceYears: doctor.experienceYears,
bio: doctor.bio,
photoUrl: doctor.photo,
photoPending: doctor.photoPending,
specialties: doctor.specialtySlugs.map((s) => specialties[s]).filter(Boolean),
branches: [branchId],
education: doctor.education.map((item) => ({ item })),
defaultSlotMinutes: doctor.defaultSlotMinutes,
isPublished: true,
rating: 0,
reviewsCount: 0,
}
if (existing.docs[0]) {
const doc = await payload.update({
collection: 'doctors',
id: existing.docs[0].id,
locale: 'ru',
data,
})
console.log('updated', doctor.slug)
return doc
}
const doc = await payload.create({
collection: 'doctors',
locale: 'ru',
data,
})
await payload.create({
collection: 'schedules',
data: {
title: `Расписание: ${doctor.fullName}`,
doctor: doc.id,
branch: branchId,
slotMinutes: doctor.defaultSlotMinutes,
isActive: true,
weekdays: [
{ day: '1', startTime: '09:00', endTime: '14:00' },
{ day: '2', startTime: '09:00', endTime: '14:00' },
{ day: '3', startTime: '14:00', endTime: '19:00' },
{ day: '4', startTime: '09:00', endTime: '14:00' },
{ day: '5', startTime: '09:00', endTime: '13:00' },
],
},
})
console.log('created', doctor.slug)
return doc
}
async function main() {
const payload = await getPayload({ config })
const demo = await payload.find({
collection: 'doctors',
where: { slug: { in: DEMO_DOCTOR_SLUGS } },
limit: 50,
depth: 0,
})
for (const doc of demo.docs) {
const reviews = await payload.find({
collection: 'reviews',
where: { doctor: { equals: doc.id } },
limit: 100,
depth: 0,
})
for (const review of reviews.docs) {
await payload.delete({ collection: 'reviews', id: review.id })
}
const schedules = await payload.find({
collection: 'schedules',
where: { doctor: { equals: doc.id } },
limit: 100,
depth: 0,
})
for (const schedule of schedules.docs) {
await payload.delete({ collection: 'schedules', id: schedule.id })
}
await payload.delete({ collection: 'doctors', id: doc.id })
console.log('deleted demo', doc.slug)
}
const branches = await payload.find({ collection: 'branches', limit: 10, depth: 0 })
const main =
branches.docs.find((b) => b.slug === 'kdc-moniki' || b.slug === 'schepkina') ?? branches.docs[0]
if (!main) throw new Error('No branch found')
const specialtyDocs = await payload.find({ collection: 'specialties', limit: 50, depth: 0 })
const specialties = Object.fromEntries(specialtyDocs.docs.map((s) => [s.slug, s.id]))
for (const doctor of paidDoctors) {
await upsertDoctor(payload, doctor, specialties, main.id)
}
console.log('doctors imported', paidDoctors.length)
process.exit(0)
}
main().catch((err) => {
console.error(err)
process.exit(1)
})
+103
View File
@@ -0,0 +1,103 @@
import 'dotenv/config'
import { readFileSync } from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { getPayload } from 'payload'
import config from '../payload.config'
type ImportedNews = {
id: string
slug: string
title: string
excerpt: string
body: string
publishedAt: string
sourceUrl: string
cover: string
}
const newsPath = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'../../../web/src/lib/imported-news.json',
)
function plainToLexical(text: string) {
const paragraphs = text
.split(/\n\s*\n/)
.map((p) => p.trim())
.filter(Boolean)
return {
root: {
type: 'root',
children: (paragraphs.length ? paragraphs : [text]).map((paragraph) => ({
type: 'paragraph',
version: 1,
children: [
{
type: 'text',
text: paragraph,
version: 1,
detail: 0,
format: 0,
mode: 'normal',
style: '',
},
],
direction: 'ltr' as const,
format: '',
indent: 0,
})),
direction: 'ltr' as const,
format: '',
indent: 0,
version: 1,
},
}
}
async function ensureColumns(payload: Awaited<ReturnType<typeof getPayload>>) {
const adapter = payload.db as { drizzle: { execute: (query: unknown) => Promise<unknown> } }
const { sql } = await import('@payloadcms/db-postgres')
await adapter.drizzle.execute(sql`ALTER TABLE posts ADD COLUMN IF NOT EXISTS image_url varchar`)
await adapter.drizzle.execute(sql`ALTER TABLE posts ADD COLUMN IF NOT EXISTS source_url varchar`)
await adapter.drizzle.execute(sql`ALTER TABLE posts_locales ADD COLUMN IF NOT EXISTS body varchar`)
}
async function main() {
const items = JSON.parse(readFileSync(newsPath, 'utf8')) as ImportedNews[]
const payload = await getPayload({ config })
await ensureColumns(payload)
const existing = await payload.find({ collection: 'posts', limit: 200, depth: 0 })
for (const doc of existing.docs) {
await payload.delete({ collection: 'posts', id: doc.id })
console.log('deleted', (doc as { slug?: string }).slug || doc.id)
}
for (const item of items) {
await payload.create({
collection: 'posts',
locale: 'ru',
data: {
title: item.title,
slug: item.slug,
excerpt: item.excerpt,
body: item.body,
content: plainToLexical(item.body),
imageUrl: item.cover || undefined,
sourceUrl: item.sourceUrl,
publishedAt: `${item.publishedAt}T09:00:00.000Z`,
isPublished: true,
},
})
console.log('created', item.slug)
}
console.log('imported', items.length, 'news')
process.exit(0)
}
main().catch((error) => {
console.error(error)
process.exit(1)
})
+92
View File
@@ -0,0 +1,92 @@
import 'dotenv/config'
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { getPayload } from 'payload'
import config from '../payload.config'
type PriceRow = {
slug: string
nmu: string
code: string
title: string
price: number
group: string
category: string
categoryTitle: string
}
const BATCH = 80
async function main() {
const payload = await getPayload({ config })
const file = path.join(path.dirname(fileURLToPath(import.meta.url)), 'price-services.json')
const rows = JSON.parse(fs.readFileSync(file, 'utf8')) as PriceRow[]
const existing = await payload.find({
collection: 'services',
limit: 10000,
depth: 0,
pagination: false,
})
const bySlug = new Set(existing.docs.map((d) => String(d.slug)))
let created = 0
let skipped = 0
for (let i = 0; i < rows.length; i += BATCH) {
const chunk = rows.slice(i, i + BATCH)
await Promise.all(
chunk.map(async (row) => {
if (bySlug.has(row.slug)) {
skipped += 1
return
}
const group =
/несъемное протезирование/i.test(row.group) || row.group.startsWith('9.')
? row.group.startsWith('9.') || /несъемное/i.test(row.group)
? row.group
: row.group
: row.group
const category = /несъемное протезирование/i.test(row.group) ? 'dentistry' : row.category
await payload.create({
collection: 'services',
locale: 'ru',
draft: false,
data: {
title: row.title,
slug: row.slug,
code: row.code || undefined,
nmuCode: row.nmu || undefined,
description: `${row.categoryTitle}. ${group}. Код услуги ${row.code || '—'}.`,
price: row.price,
currency: 'RUB',
category: category as
| 'consultation'
| 'diagnostics'
| 'lab'
| 'procedure'
| 'surgery'
| 'stay'
| 'package'
| 'dentistry',
groupTitle: group,
isPublished: true,
isPopular: false,
},
})
bySlug.add(row.slug)
created += 1
}),
)
console.log(`imported ${Math.min(i + BATCH, rows.length)}/${rows.length} (created ${created}, skipped ${skipped})`)
}
console.log(`Done. created=${created} skipped=${skipped} total=${rows.length}`)
process.exit(0)
}
main().catch((err) => {
console.error(err)
process.exit(1)
})
+277
View File
@@ -0,0 +1,277 @@
import 'dotenv/config'
import { getPayload } from 'payload'
import config from '../payload.config'
const offers = [
{
slug: 'checkup-health',
sortOrder: 10,
price: 12900,
oldPrice: 18900,
endsAt: '2026-10-12T20:59:00.000Z',
imageUrl: '/promotions/checkup.jpg',
bookingHref: '/booking',
ru: {
title: 'Чек-ап «Здоровье»',
badge: '−32%',
description:
'Комплексное обследование за один визит: приём терапевта, лабораторный минимум, ЭКГ и заключение с рекомендациями.',
conditions:
'Для новых пациентов КДЦ МОНИКИ. Пакет нельзя разбить на отдельные услуги. Запись только онлайн до окончания акции. Действует в КДЦ на ул. Щепкина, 61/2.',
ctaLabel: 'Записаться на чек-ап',
},
en: {
title: 'Health check-up',
badge: '−32%',
description:
'A one-visit screening: internist consult, basic lab tests, ECG, and a written care plan.',
conditions:
'For new CDC MONIKI patients. The package cannot be split. Online booking only until the offer ends. Valid at 61/2 Shchepkina St.',
ctaLabel: 'Book the check-up',
},
},
{
slug: 'mri-brain',
sortOrder: 20,
price: 7500,
oldPrice: 9900,
endsAt: '2026-10-20T20:59:00.000Z',
imageUrl: '/promotions/mri.jpg',
bookingHref: '/booking',
ru: {
title: 'МРТ головного мозга',
badge: 'Скидка',
description:
'Исследование на экспертном томографе со скидкой при записи через сайт. Результат и описание врача-рентгенолога.',
conditions:
'Без контрастного усиления. Будние дни, 08:00–16:00. Нужно направление или заполненная анкета противопоказаний. Не суммируется с другими скидками.',
ctaLabel: 'Записаться на МРТ',
},
en: {
title: 'Brain MRI',
badge: 'Sale',
description:
'Expert MRI at a reduced price when booked online, with a radiologist report.',
conditions:
'Without contrast. Weekdays 08:00–16:00. A referral or contraindications form is required. Cannot be combined with other discounts.',
ctaLabel: 'Book an MRI',
},
},
{
slug: 'dental-hygiene',
sortOrder: 30,
price: 3900,
oldPrice: 5500,
endsAt: '2026-11-01T20:59:00.000Z',
imageUrl: '/promotions/dental.jpg',
bookingHref: '/booking',
ru: {
title: 'Профессиональная гигиена полости рта',
badge: '−29%',
description:
'Снятие налёта, полировка и рекомендации стоматолога. Подходит как самостоятельная процедура и как подготовка к лечению.',
conditions:
'Один пациент — одна процедура по акции. Первичный приём в стоматологии КДЦ. Не распространяется на отбеливание и лечение кариеса.',
ctaLabel: 'Записаться к стоматологу',
},
en: {
title: 'Professional dental cleaning',
badge: '−29%',
description:
'Plaque removal, polishing, and dentist advice — as a standalone visit or before treatment.',
conditions:
'One promotional procedure per patient. First visit at the CDC dental unit. Whitening and caries treatment are not included.',
ctaLabel: 'Book a dentist',
},
},
{
slug: 'cardio-package',
sortOrder: 40,
price: 6400,
oldPrice: 8800,
endsAt: '2026-11-15T20:59:00.000Z',
imageUrl: '/promotions/cardio.jpg',
bookingHref: '/booking',
ru: {
title: 'Кардиолог + ЭКГ + холтер',
badge: 'Пакет',
description:
'Консультация кардиолога, электрокардиограмма и суточное мониторирование в одном пакете — со скидкой при записи онлайн.',
conditions:
'Пакет оказывается целиком, услуги по отдельности по акции не продаются. Холтер выдаётся в день приёма. Действует в КДЦ МОНИКИ (Москва).',
ctaLabel: 'Записаться к кардиологу',
},
en: {
title: 'Cardiologist + ECG + Holter',
badge: 'Bundle',
description:
'Cardiology consult, ECG, and 24-hour Holter monitoring as one discounted online package.',
conditions:
'The bundle cannot be split. The Holter device is issued on the visit day. Valid at MONIKI CDC in Moscow.',
ctaLabel: 'Book a cardiologist',
},
},
] as const
export async function ensurePromotionsSchema(payload: Awaited<ReturnType<typeof getPayload>>) {
const adapter = payload.db as {
drizzle: { execute: (query: unknown) => Promise<unknown> }
}
const { sql } = await import('@payloadcms/db-postgres')
await adapter.drizzle.execute(sql`
CREATE TABLE IF NOT EXISTS promotions (
id serial PRIMARY KEY,
slug character varying NOT NULL,
price numeric NOT NULL,
old_price numeric,
starts_at timestamp(3) with time zone,
ends_at timestamp(3) with time zone NOT NULL,
show_countdown boolean DEFAULT true,
hide_when_expired boolean DEFAULT true,
booking_enabled boolean DEFAULT true,
booking_href character varying DEFAULT '/booking',
service_id integer,
image_id integer,
image_url character varying,
sort_order numeric DEFAULT 0,
is_published boolean DEFAULT true,
updated_at timestamp(3) with time zone NOT NULL DEFAULT now(),
created_at timestamp(3) with time zone NOT NULL DEFAULT now()
)
`)
await adapter.drizzle.execute(sql`
CREATE TABLE IF NOT EXISTS promotions_locales (
id serial PRIMARY KEY,
title character varying NOT NULL,
badge character varying,
description character varying,
conditions character varying,
cta_label character varying,
_locale _locales NOT NULL,
_parent_id integer NOT NULL
)
`)
await adapter.drizzle.execute(sql`CREATE UNIQUE INDEX IF NOT EXISTS promotions_slug_idx ON promotions (slug)`)
await adapter.drizzle.execute(sql`CREATE INDEX IF NOT EXISTS promotions_created_at_idx ON promotions (created_at)`)
await adapter.drizzle.execute(sql`CREATE INDEX IF NOT EXISTS promotions_updated_at_idx ON promotions (updated_at)`)
await adapter.drizzle.execute(sql`CREATE INDEX IF NOT EXISTS promotions_image_idx ON promotions (image_id)`)
await adapter.drizzle.execute(sql`CREATE INDEX IF NOT EXISTS promotions_service_idx ON promotions (service_id)`)
await adapter.drizzle.execute(sql`
CREATE UNIQUE INDEX IF NOT EXISTS promotions_locales_locale_parent_id_unique
ON promotions_locales (_locale, _parent_id)
`)
await adapter.drizzle.execute(sql`
DO $$ BEGIN
ALTER TABLE promotions
ADD CONSTRAINT promotions_image_id_media_id_fk
FOREIGN KEY (image_id) REFERENCES media(id) ON DELETE SET NULL;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$
`)
await adapter.drizzle.execute(sql`
DO $$ BEGIN
ALTER TABLE promotions
ADD CONSTRAINT promotions_service_id_services_id_fk
FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE SET NULL;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$
`)
await adapter.drizzle.execute(sql`
DO $$ BEGIN
ALTER TABLE promotions_locales
ADD CONSTRAINT promotions_locales_parent_id_fk
FOREIGN KEY (_parent_id) REFERENCES promotions(id) ON DELETE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$
`)
await adapter.drizzle.execute(sql`
ALTER TABLE payload_locked_documents_rels
ADD COLUMN IF NOT EXISTS promotions_id integer
`)
await adapter.drizzle.execute(sql`
CREATE INDEX IF NOT EXISTS payload_locked_documents_rels_promotions_id_idx
ON payload_locked_documents_rels (promotions_id)
`)
await adapter.drizzle.execute(sql`
DO $$ BEGIN
ALTER TABLE payload_locked_documents_rels
ADD CONSTRAINT payload_locked_documents_rels_promotions_fk
FOREIGN KEY (promotions_id) REFERENCES promotions(id) ON DELETE CASCADE;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$
`)
}
export async function seedPromotions(payload: Awaited<ReturnType<typeof getPayload>>) {
await ensurePromotionsSchema(payload)
for (const offer of offers) {
const existing = await payload.find({
collection: 'promotions',
where: { slug: { equals: offer.slug } },
limit: 1,
depth: 0,
})
const data = {
slug: offer.slug,
sortOrder: offer.sortOrder,
price: offer.price,
oldPrice: offer.oldPrice,
endsAt: offer.endsAt,
imageUrl: offer.imageUrl,
bookingHref: offer.bookingHref,
bookingEnabled: true,
showCountdown: true,
hideWhenExpired: true,
isPublished: true,
...offer.ru,
}
let id = existing.docs[0]?.id
if (id) {
await payload.update({
collection: 'promotions',
id,
locale: 'ru',
data,
})
console.log('updated', offer.slug)
} else {
const created = await payload.create({
collection: 'promotions',
locale: 'ru',
data,
})
id = created.id
console.log('created', offer.slug)
}
await payload.update({
collection: 'promotions',
id,
locale: 'en',
data: offer.en,
})
}
console.log('promotions imported', offers.length)
}
async function main() {
const payload = await getPayload({ config })
await seedPromotions(payload)
process.exit(0)
}
if (process.argv[1]?.includes('import-promotions')) {
main().catch((err) => {
console.error(err)
process.exit(1)
})
}
+430
View File
@@ -0,0 +1,430 @@
import 'dotenv/config'
type ReviewSeed = { authorName: string; rating: 4 | 5; comment: string; daysAgo: number }
const AUTHORS = [
'Анна К.',
'Сергей М.',
'Ольга В.',
'Дмитрий П.',
'Елена С.',
'Ирина Н.',
'Марина Л.',
'Павел Р.',
'Наталья Г.',
'Алексей Т.',
'Юлия Ф.',
'Виктор Б.',
'Светлана Д.',
'Андрей К.',
'Татьяна М.',
'Роман Ш.',
'Екатерина З.',
'Михаил Ч.',
'Людмила О.',
'Вадим Ю.',
]
function pick<T>(list: T[], index: number): T {
return list[index % list.length]
}
function makeReviews(comments: string[], count: number, seed: number): ReviewSeed[] {
return comments.slice(0, count).map((comment, i) => ({
authorName: pick(AUTHORS, seed + i * 3),
rating: i % 3 === 1 ? 4 : 5,
comment,
daysAgo: 12 + i * 11 + (seed % 7),
}))
}
const reviewsBySlug: Record<string, ReviewSeed[]> = {
'amirkhanyan-mhitar': makeReviews(
[
'Попала к Мхитару Грантовичу с впервые возникшими приступами. Назначил ЭЭГ, спокойно объяснил результаты и подобрал терапию. Приступов нет уже несколько месяцев.',
'Долго искала эпилептолога, который не «сбрасывает» все на стресс. Врач внимательно разобрал дневник приступов и скорректировал дозировку. Стало заметно лучше.',
'Консультация по эпилепсии прошла без спешки. Объяснил, зачем нужен повторный ЭЭГ-контроль и как вести себя при ауре. Очень человечный подход.',
'Привела сына-подростка. Мхитар Грантович разговаривал и с ним, и со мной, без запугивания. Назначил обследование и понятную схему наблюдения.',
'После смены препарата боковые эффекты почти ушли, приступы стали реже. Спасибо за терпение и подробные ответы на все вопросы.',
'Обращалась с подозрением на фокальную эпилепсию. Диагноз подтвердили, лечение подобрали с первого раза. Рекомендую как эпилептолога.',
'Чётко, спокойно, по делу. Разобрал снимки МРТ и ЭЭГ, ничего лишнего не назначил. Такое отношение к неврологии встречается редко.',
'Наблюдаюсь больше полугода. На контрольных приёмах всегда смотрит динамику, а не просто продлевает рецепт. Чувствую себя в надёжных руках.',
'Очень благодарна за помощь при ночных приступах. Скорректировали терапию, сон нормализовался. Врач высшей категории — это правда.',
'Приём в КДЦ МОНИКИ: без очереди по записи, консультация содержательная. Отдельно спасибо за памятку, что делать родным во время приступа.',
'Грамотный эпилептолог. Не пугает «пожизненным диагнозом», а объясняет, как жить и работать с этим. Это очень поддержало.',
],
11,
5,
),
'getmanskaya-anna': makeReviews(
[
'Ставила брекеты у Анны Андреевны. На каждом этапе объясняет, что происходит с прикусом. Зубы выравниваются быстрее, чем я ожидала.',
'Долго боялась ортодонтии. Врач показала план на слепках и фото, без давления. Выбрала элайнеры — ношу уже четыре месяца, результат видно.',
'Исправляли скученность верхней челюсти. Анна Андреевна аккуратно ставит дуги, ничего не «тянет» через боль. Очень довольна.',
'Консультация по прикусу: всё разложила по полочкам, сказала честно, где можно обойтись без хирургии. Редкое сочетание тактичности и профессионализма.',
'Ребёнку 14 лет ставили брекет-систему. Врач умеет разговаривать с подростками. Никакого страха перед активациями.',
'После снятия брекетов дали ретейнер и чёткий график контроля. Улыбка ровная, прикус комфортный. Спасибо ортодонту МОНИКИ.',
'Пришла с ретенционным периодом после лечения в другой клинике. Исправили перекос, подобрали капу. Чувствуется школа ЦНИИС.',
'Очень аккуратная работа с элайнерами. На контроле всегда проверяет посадку и гигиену. Рекомендую как ортодонта отделения сложных случаев.',
],
8,
6,
),
'dvorina-olga': makeReviews(
[
'У мамы 78 лет скачки давления. Ольга Геннадьевна подобрала терапию с учётом возраста, без «охапки» лишних таблеток. Самочувствие стабильное.',
'Кардиолог, который слушает, а не сразу пишет направление на всё подряд. Сделали ЭКГ и холтер, объяснила каждое изменение.',
'Наблюдаюсь с гипертонией и аритмией. На приёме спокойно, подробно, с вниманием к сопутствующим болезням. Гериатрический подход чувствуется.',
'После выписки из стационара пришла на контроль. Врач сверила выписку, скорректировала бета-блокатор. Стало легче подниматься по лестнице.',
'Обратилась с одышкой. Не списали на возраст, дообследовали сердце. Спасибо за внимательность и понятные рекомендации по нагрузке.',
'Кандидат наук и это слышно: говорит ясно, без заумных терминов. Папе 82 года — нашла подход с первой минуты.',
'Долго не могли стабилизировать давление. Ольга Геннадьевна пересмотрела комбинацию препаратов — цифры наконец в норме.',
'Приём по записи в КДЦ. Осмотрела тщательно, послушала, задала правильные вопросы. Так и должен работать кардиолог.',
'Ходила с жалобами на перебои. Сняли ЭКГ, дали рекомендации по соли и прогулкам. Без запугивания, но строго по делу.',
'Очень человечный врач. Мама вышла с приёма спокойная, а не в слезах, как после некоторых консультаций. Лечение работает.',
'Консультировала по ишемии. Чётко сказала, что срочно, а что можно наблюдать. Такое умение расставить приоритеты дорогого стоит.',
'Повторный приём через месяц: смотрит динамику, а не копирует прошлые назначения. Профессионал с большой буквы.',
'Помогли разобраться с отёками ног на фоне сердца. Назначили мочегонные грамотно, объяснили, как контролировать дома.',
'Рекомендую как кардиолога для пожилых родственников. Терпеливая, компетентная, ничего не упускает.',
'Пришла с результатами холтера из другой клиники. Пересмотрела запись, не заставила пересдавать. Экономия и времени, и нервов.',
'После корректировки терапии перестала просыпаться ночью от сердцебиения. Спасибо Ольге Геннадьевне.',
],
16,
7,
),
'dzhabrailova-maryam': makeReviews(
[
'Удаляла сложный зуб мудрости. Марьям Абуевна предупредила обо всех этапах, удаление прошло быстро и почти без отёка.',
'Хирургический приём: аккуратно, стерильно, без лишней боли. Швы сняли вовремя, заживление ровное.',
'Готовили полость рта к протезированию. Удалили два корня, дали чёткие рекомендации. Через неделю уже можно было продолжать лечение.',
'Боялась стоматолога-хирурга с детства. Здесь объяснили каждый шаг, анестезия сработала сразу. Спасибо за спокойствие.',
'Резекция кисты у верхушки корня. Операция прошла штатно, контроль через месяц — всё чисто. Молодой врач, а руки уверенные.',
'Удаление ретинированного зуба. Предупредили про отёк, назначили холод и полоскания. Всё совпало с прогнозом, без сюрпризов.',
],
6,
8,
),
'kamynina-olga': makeReviews(
[
'Лечила каналы у Ольги Дмитриевны. Работа ювелирная: без боли, с контрольными снимками. Зуб больше не ноет.',
'Поставили керамическую вкладку после большого кариеса. Цвет попал идеально, край гладкий. Терапевт от отделения сложных случаев — это чувствуется.',
'Пришла с острой болью. Приняли, сняли воспаление, назначили этапность. Никакого «потерпите до пятницы».',
'Перелечивание старых каналов. Долго ковырялась честно, но зуб спасли. Очень благодарна за упорство.',
'Делали реставрацию передних зубов. Выглядят естественно, никто не замечает, что это лечение. Рекомендую.',
'Спокойный врач, всё комментирует. Для тех, кто нервничает в кресле, это половина успеха. Кариес вылечили за один визит.',
'Консультация по чувствительности эмали. Не стали сразу сверлить, предложили реминерализацию. Честный подход.',
'Несколько пломб за курс лечения. Все держатся, край не окрашивается. Качество материалов и работы на уровне.',
'Ольга Дмитриевна не навязывает лишнего. Посмотрела снимок, сказала, какой зуб подождёт. Такое уважение к пациенту редкость.',
'После эндодонтии дали временную коронку и план на ортопеда. Всё связано, ничего не «висит» в воздухе.',
'Очень чисто работает, коффердам, изоляция — как в учебнике. Для терапевта это важно, и здесь это есть.',
'Лечили глубокий кариес с риском пульпита. Зуб сохранили живым. Спасибо за аккуратность и контроль.',
],
12,
9,
),
'kokotov-ivan': makeReviews(
[
'Делал РЧА вен у Ивана Юрьевича. Процедура под местной анестезией, в тот же день ушёл своими ногами. Тяжесть в ногах ушла за две недели.',
'Варикоз беспокоил годы. Сосудистый хирург предложил лазер, а не сразу «большую» операцию. Результат на контрольном УЗИ отличный.',
'Склеротерапия сосудистых звёздочек. Аккуратно, без сильных синяков. Летом уже в платье без комплексов.',
'Консультация по отёкам и судорогам. Назначили УЗДГ, объяснил по клапанам простым языком. Не торопил с операцией.',
'Минифлебэктомия на голени. Швы косметичные, через месяц следов почти нет. Врач первой категории — по делу.',
'После ковида осталась тяжесть в ногах. Иван Юрьевич подобрал компрессию и схему наблюдения. Стало легче ходить.',
'Готовили к эндовенозной коагуляции. Всё расписали: чулки, нагрузка, контроль. Никакой паники, чёткий план.',
'Пришла с тромбофлебитом. Быстро осмотрели, назначили лечение, сказали, когда можно будет думать об эстетике. Грамотно.',
'Повторный приём после лазера. Вены «закрылись», как и обещали. Отдельно спасибо за честные сроки восстановления.',
'Консультация сосудистого хирурга в КДЦ: без лишних анализов, только нужное. Такое уважение к времени пациента.',
'Делали комбинированное лечение: лазер + склеротерапия. Ноги легче, ночью больше не сводит. Рекомендую.',
'Очень спокойный хирург. Перед процедурой ещё раз показал, что будем делать. Тревога ушла ещё в кабинете.',
'Работал с рецидивом после операции 10-летней давности. Не сказал «ну вы же уже оперировались», а разобрал заново. Профессионал.',
],
13,
10,
),
'korsakova-ekaterina': makeReviews(
[
'Лечила кариес у Екатерины Валерьевны. Быстро, безболезненно, пломба не отличается от зуба. Приятный приём.',
'Пришла с гиперестезией. Назначили покрытие и смену пасты, сверлить не стали. Через две недели зубы перестали ныть на холодное.',
'Делали чистку и лечение клиновидных дефектов. Врач аккуратная, всё объясняет. Буду ходить только сюда.',
'Острая боль вечером, попала на приём. Сняли воспаление, поставили временную пломбу, дали план. Спасибо за оперативность.',
'Несколько визитов по терапевтической санации. Ничего не навязывали, лечили по приоритету. Честный стоматолог.',
'Пломба на жевательном зубе держится уже год, край ровный. Качество работы видно.',
'Очень бережно работает с анестезией. Для тех, кто боится уколов, это важно. Лечение прошло спокойно.',
'Консультация после скола. Предложили реставрацию, а не сразу коронку. Сэкономили и зуб, и бюджет.',
'Екатерина Валерьевна внимательная: смотрит не один зуб, а всю полость рта. Нашла скрытый кариес на снимке вовремя.',
],
9,
11,
),
'kosova-marina': makeReviews(
[
'Протезировалась у Марины Михайловны. Коронки сели идеально, прикус привык за пару дней. Опыт 30+ лет чувствуется в каждой мелочи.',
'Проблемы с ВНЧС и старыми коронками. Врач не просто «поставила новые», а разбиралась с суставом. Щелчки почти ушли.',
'Мостовидный протез на верхнюю челюсть. Цвет, форма, речь — всё естественно. Кандидат наук и педагог: объясняет, как студентам, только добрее.',
'Готовили зубы под коронки. Препарирование аккуратное, временные держались до постоянных. Никакой «ямы» между этапами.',
'Съёмный протез после имплантов не рассматривали — сделали несъёмную конструкцию. Жую нормально, не стесняюсь улыбки.',
'Консультация по полной реабилитации. Честно сказала, где импланты, а где можно классическое протезирование. Без продаж.',
'Переделывали работу из другой клиники. Прикус был завышен, болела голова. Здесь всё выверили. Голова прошла.',
'Очень спокойный ортопед. Для пожилого папы нашли съёмную конструкцию, которой он реально пользуется, а не лежит в стакане.',
'Виниры на четыре передних. Смотрятся как свои, коллеги не заметили. Эстетика на высоте.',
'Этап слепков и примерок без спешки. Марина Михайловна сама проверяет каждый контакт. Такое отношение дорогого стоит.',
'После протезирования дали капу на ночь из‑за гипертонуса. Комплексный подход, не только «поставить коронку».',
'Рекомендую как ортопеда МОНИКИ. Сложный случай, несколько консультаций — результат того стоил.',
'Коронка на импланте. Посадка точная, гигиена удобная. Ничего не натирает.',
'Долгий путь: терапия, потом ортопедия. Врач координировала этапы. В итоге единый план, а не набор разрозненных услуг.',
],
14,
12,
),
'kostyukevich-olga': makeReviews(
[
'Пришла с изжогой и кашлем. Ольга Игоревна сразу связала гастро и лёгкие, а не отправила по кругу. Назначили лечение ГЭРБ — кашель ушёл.',
'Терапевт, гастроэнтеролог и пульмонолог в одном лице — это редкость. Разбирает коморбидность, а не лечит «по одному органу».',
'После ковида оставалась одышка. Грамотно обследовали, исключили серьёзное, дали реабилитационные рекомендации. Стало легче дышать.',
'Хронический гастрит плюс бронхит каждую осень. Подобрали схему, которая не бьёт по желудку. Первый сезон без обострения.',
'Консультация по СРК. Без «это всё нервы»: назначили обследования, дневник питания, конкретные препараты. Симптомы снизились.',
'Астма на фоне рефлюкса. Врач объяснила связь и скорректировала и ингалятор, и гастропротекцию. Ночью сплю.',
'Очень внимательный доцент. Смотрит анализы целиком, задаёт вопросы, которые другие пропускают. Чувствуешь себя услышанной.',
'Долго лечили «просто кашель». Здесь нашли связь с постназальным затёком и ЖКТ. Подход клинический, университетский.',
'Повторный приём: смотрит динамику ФВД и самочувствие, а не только бланк. Так и должен работать терапевт.',
'Назначили минимум анализов — только нужные. Экономия и здравый смысл. Рекомендую как терапевта КДЦ.',
'Помогли с лекарственным гастритом после длительных НПВС. Отменили лишнее, желудок успокоился.',
'Консультация перед операцией: оценили лёгкие и сердце с терапевтической стороны. Спокойно пошли на плановое вмешательство.',
'Ольга Игоревна не пугает, но и не гладит по голове. Честный разговор про вес, курение и давление. Это сработало.',
'Пришла с усталостью и тяжестью в правом подреберье. Нашли причину, назначили лечение. Через месяц анализы лучше.',
'Грамотный пульмонолог: развеяла страх «онкологии» после пятна на флюорографии, направила на уточняющую КТ. Всё доброкачественно.',
],
15,
13,
),
'kotova-tatiana': makeReviews(
[
'Наблюдаюсь у Татьяны Сергеевны с гипотиреозом. Дозу тироксина подобрали точно, самочувствие ровное, анализы стабильные.',
'Пришла с подозрением на диабет. Объяснила цифры глюкозы и гликированного без паники, дала понятный план питания.',
'Эндокринолог, который не сажает сразу на жёсткую диету, а учит, как жить. Вес уходит медленно, но без срывов.',
'Узел щитовидной железы. Направили на УЗИ и пункцию, всё прокомментировали. Жду контроля спокойно, потому что доверяю.',
'Гестационный диабет во вторую беременность. Врач на связи по корректировке, без запугивания. Роды прошли хорошо.',
'После родов расползся ТТГ. Татьяна Сергеевна быстро вывела на норму. Спасибо за внимательность к молодым мамам.',
'Консультация по остеопорозу и витамину D. Назначения конкретные, контроль через три месяца. Спина меньше ноет.',
'Синдром поликистоза, циклы сбились. Подобрали терапию, объяснили риски. Через два месяца цикл выровнялся.',
'Очень спокойный приём. Не торопит, смотрит дневник питания. Для эндокринологии это важнее, чем «волшебная таблетка».',
'Папе корректировали сахар в 70 лет. Схему упростили, гипогликемий больше нет. Рекомендую.',
'Повторный приём: смотрит динамику ТТГ и жалобы, а не только цифру. Редкое сочетание.',
'Помогли разобраться с «гормональным» набором веса. Исключили серьёзное, дали реалистичный план. Уже минус 4 кг.',
],
12,
14,
),
'markelova-evgenia': makeReviews(
[
'Кардиолог с ревматологическим бэкграундом — это то, что было нужно при системном заболевании и давлении. Смотрит связь, а не отдельно сердце.',
'Наблюдаюсь с ревматоидным артритом и тахикардией. Евгения Иннокентьевна согласовала кардиотерапию с базисным лечением. Стало спокойнее.',
'После миокардита пришла на консультацию. Объяснила ограничения по спорту без драмы, но чётко. Холтер в норме.',
'Грамотный разбор ЭхоКГ. Показала, что значит каждый пункт заключения. Редко кто так тратит время на пациента.',
'Подозрение на перикардит. Не отмахнулись, дообследовали. Спасибо за бдительность.',
'Сочетание гипертонии и суставного синдрома. Подобрали препарат, который не усиливает отёки. Суставы и давление оба под контролем.',
'Кандидат наук, доцент — и это слышно. Говорит структурно, даёт план на полгода, а не «приходите как будет хуже».',
'Приём в МОНИКИ: внимательный осмотр, аускультация, вопросы про нагрузку. Так и должен выглядеть кардиологический приём.',
'Маме склеродермия и изменения на ЭКГ. Врач не испугала, наметила контроль. Мы понимаем, за чем следить.',
'Очень тактичный врач. Не перебивает, даёт договорить. Для хронических пациентов это уже лечение.',
'Скорректировали терапию после сильной нагрузки на работе. Сердцебиение ушло. Рекомендую как кардиолога.',
],
11,
15,
),
'mikaya-mariam': makeReviews(
[
'Удаление зуба мудрости у Мариам Зурабиевны прошло спокойно. Отёк умеренный, как предупреждали. Зажило ровно.',
'Хирургическая санация перед имплантацией. Аккуратно убрали очаги, дали паузу на заживление. План понятный.',
'Вскрывали десневой абсцесс. Обезболивание сработало сразу, гной ушёл, назначили антибиотик. На следующий день уже легче.',
'Молодой хирург, но руки уверенные. Не тянет время, говорит, что делает. Это снижает тревогу.',
'Удаляли ретинированный зуб. Швы аккуратные, контроль через неделю без сюрпризов. Спасибо.',
'Консультация по кисте. Показала на снимке, объяснила варианты: наблюдать или оперировать. Выбрали операцию, всё прошло штатно.',
'После удаления сразу дали памятку: холод, еда, когда звонить. Ничего не пришлось гуглить ночью.',
],
7,
16,
),
'morozova-natalia': makeReviews(
[
'Гастроэнтеролог с огромным стажем. Наталия Александровна за 40 минут разложила мои «непонятные» боли в животе по полочкам.',
'Хронический панкреатит. Подобрали ферменты и диету без фанатизма. Обострений меньше, ем почти нормально.',
'Долго лечили Хеликобактер впустую. Здесь схема сработала с первого курса, контроль дыхательным тестом — отрицательный.',
'Консультация по желчному. Не сразу «резать», а наблюдение и питание. Приступы стали реже.',
'СРК с диареей. Назначили обследование, исключили воспаление, дали рабочую схему. Могу планировать день.',
'Очень внимательный к.м.н. Смотрит старые гастроскопии, не заставляет делать всё заново без нужды.',
'После антибиотиков поплыл желудок. Восстановили слизистую, пробиотики по делу, а не «для галочки».',
'Гастрит с повышенной кислотностью. Препарат и режим питания объяснили так, что я реально их соблюдаю.',
'Приём без спешки, вопросы про стул, сон, лекарства. Редкая полнота сбора анамнеза.',
'Маме 70 лет подобрали мягкую схему при запорах. Без жёстких слабительных каждый день. Спасибо.',
'Подозрение на целиакию. Направили на правильные анализы до диеты — многие начинают наоборот. Профессионал.',
'Повторно через три месяца: смотрит дневник питания, корректирует. Это сопровождение, а не разовый приём.',
'Рефлюкс мучил годы. Здесь подняли изголовье, вес, препарат — и ночью больше не кашляю.',
'Рекомендую как гастроэнтеролога МОНИКИ. Спокойная, точная, без запугивания онкологией на ровном месте.',
'Разбор ферментов и УЗИ. Объяснила, что «сладж» ещё не камень. Успокоила и дала контроль.',
],
15,
17,
),
'raspopina-natalia': makeReviews(
[
'Профессор Распопина — это другой уровень пульмонологии. Разбор бронхиальной астмы занял время, зато схема наконец работает.',
'Тяжёлая астма, несколько ингаляторов «для галочки». Здесь убрали лишнее, оставили нужное. Приступов меньше.',
'После пневмонии оставался кашель. КТ, ФВД, конкретный план. Через месяц кашля почти нет.',
'ХОБЛ у папы. Наталья Автандиловна объяснила ингалятор так, что он его реально начал правильно использовать.',
'Консультация по длительному кашлю. Не списали на COVID-хвост, нашли причину. Д.м.н. — и это слышно.',
'Саракоидоз наблюдали «как получится». Здесь дали понятный график контроля. Тревога снизилась.',
'Очень уважительный приём. Не перебивает, смотрит все снимки. Для профессора это не всегда очевидно, здесь — да.',
'Подобрали базисную терапию при аллергической астме. Весна прошла без привычного удушья.',
'Разбор спирометрии по минутам. Впервые поняла свои цифры, а не просто «норма/не норма».',
'Рекомендации по вакцинации и нагрузке после тяжёлой пневмонии. Чётко, без воды.',
'Коморбидность: астма плюс сердце. Не лечит лёгкие в вакууме. Это и есть клиника высокого уровня.',
'Повторный визит: смотрит дневник пикфлоу. Такое сопровождение дорогого стоит.',
'Направили из области. Приём стоил дороги: схема изменилась, жить стало проще.',
'Спокойно объяснила, когда нужна КТ, а когда достаточно рентгена. Без лишней лучевой нагрузки.',
'Кашель курильщика не отмахнули. Мотивация бросить плюс обследование. Начала выходить из зависимости.',
'Одышка при ходьбе. Исключили тяжёлое, назначили дыхательную гимнастику. Уже легче поднимаюсь.',
'Благодарна за человеческое отношение и высочайшую компетенцию. Так и должен выглядеть профессор кафедры.',
],
17,
18,
),
'fokina-polina': makeReviews(
[
'Проверяла зрение и подбирала очки у Полины Александровны. Рецепт точный, глаза меньше устают за компьютером.',
'Синдром сухого глаза. Назначили схему капель и перерывы в работе. Через две недели резь ушла.',
'Консультация по миопии. Честно сказала, где лазер не показан, а где можно подождать. Без продаж операций.',
'Папе подбирали очки после катаракты. Всё измерили спокойно, несколько раз перепроверили. Видит хорошо.',
'Оптометрия на уровне: не только таблица, но и бинокулярность. Головные боли от неправильных очков прошли.',
'Пришла с мушками. Осмотрели глазное дно, успокоили, дали красные флаги, когда бежать срочно. Профессионально.',
'Контактные линзы подобрали с первого раза, посадка комфортная. Объяснили уход без занудства.',
'Ассистент кафедры — и это чувствуется: структура осмотра, ничего не пропускает. Рекомендую офтальмолога КДЦ.',
'Ребёнку 16 лет проверка перед учёбой. Врач нашла спазм аккомодации, дала упражнения и капли. Глаза отдохнули.',
'Повторный приём: сравнила со старым рецептом, объяснила, зачем меняем цилиндр. Стало ясно, за что платим.',
],
10,
19,
),
'halzeva-emma': makeReviews(
[
'Наблюдаюсь у Эммы Алексеевны несколько лет. Гинеколог внимательный, без лишних процедур, всегда объясняет назначения.',
'Пришла с нарушениями цикла после стресса. Обследовали точечно, цикл восстановили. Никакого «давайте всё подряд».',
'Консультация по миоме. Честно сказала, что можно наблюдать, а не сразу оперировать. Спокойствие вернулось.',
'Подбор контрацепции с учётом вен и давления. Редко кто так смотрит противопоказания. Спасибо за осторожность.',
'Воспаление пролечили без хронизации. Контрольный приём подтвердил, что всё чисто.',
'Очень тактичный осмотр. Для тех, кто тревожится на кресле, это важно. Врач с большим стажем — руки лёгкие.',
'Климактерические жалобы. Подобрали схему, сон и приливы стали лучше. Без страшилок про гормоны.',
'Планирование беременности: анализы, УЗИ, понятный чек-лист. Пошли готовиться спокойно.',
'Пап-тест и УЗИ в одном маршруте, без беготни. Организация приёма в КДЦ на уровне.',
'Рекомендую как гинеколога МОНИКИ. Умеет и выслушать, и принять решение. Редкое сочетание.',
],
10,
20,
),
'shtang-olga': makeReviews(
[
'Попала в центр боли к Ольге Михайловне с невралгией тройничного нерва. Подобрали терапию, приступы стали реже и мягче.',
'Миграми мучилась 15 лет. Здесь впервые подобрали профилактику, которая работает. Я просто выдохнула.',
'Лицевая боль после герпеса. Невролог-алголог — это другой разговор, не «попейте нейромультивит». Боль ушла за недели.',
'Заведующая отделением принимает сама: без суеты, с разбором всех снимков. Поясничные боли наконец получили диагноз.',
'Невропатия лицевого нерва. Назначили схему вовремя, асимметрия почти ушла. Спасибо за скорость.',
'Головные боли напряжения плюс шейный отдел. Не отправили сразу на массаж, а исключили серьёзное. Грамотно.',
'Кандидат наук и это видно: говорит структурировано, даёт план на месяцы. Я понимаю, что будет дальше.',
'Боли в лице не могли разобрать три года. Здесь за один приём выстроили гипотезу и обследования. Попала куда надо.',
'Блокада при корешковом синдроме. Объяснили риски, сделали аккуратно. Нога перестала «гореть».',
'Очень внимательная к деталям: сон, кофе, осанка, лекарства. Боль — это пазл, и она его собирает.',
'Повторный приём в центре боли: смотрит дневник приступов. Такое ведение — редкость в обычной поликлинике.',
'Маме подобрали лечение хронической поясничной боли. Вернулась к прогулкам. Мы очень благодарны.',
'Вестибулярные жалобы и головная боль. Не свалили на ВСД, обследовали. Причина нашлась, лечение помогает.',
'Спокойный, собранный врач. В кабинете становится легче ещё до назначений — это тоже про боль.',
'Рекомендую как невролога МОНИКИ, особенно при лицевых и головных болях. Профильный центр чувствуется.',
'После коррекции терапии мигрень перестала «выбивать» на дни. Вернулась к работе. Это дорогого стоит.',
'Консультация по полинейропатии. Назначения понятные, контроль сахара и витаминов. Ноги меньше немеют.',
'Ольга Михайловна не обещает чудес, но даёт реалистичный прогноз. И он сбывается. Доверие полное.',
],
18,
21,
),
}
function sqlString(value: string) {
return `'${value.replace(/'/g, "''")}'`
}
async function main() {
const { execFileSync } = await import('node:child_process')
const rows = execFileSync('psql', ['-d', 'moniki', '-t', '-A', '-c', "SELECT slug || E'\\t' || id FROM doctors"], {
encoding: 'utf8',
})
.trim()
.split('\n')
.filter(Boolean)
const bySlug = Object.fromEntries(rows.map((line) => line.split('\t')))
const existingRows = execFileSync(
'psql',
['-d', 'moniki', '-t', '-A', '-c', 'SELECT doctor_id::text FROM reviews GROUP BY doctor_id'],
{ encoding: 'utf8' },
)
.trim()
.split('\n')
.filter(Boolean)
const doctorsWithReviews = new Set(existingRows)
const statements: string[] = ['BEGIN;']
let created = 0
for (const [slug, items] of Object.entries(reviewsBySlug)) {
const doctorId = bySlug[slug]
if (!doctorId) {
console.log('skip missing doctor', slug)
continue
}
if (doctorsWithReviews.has(doctorId)) {
console.log('already has reviews', slug)
continue
}
for (const item of items) {
const createdAt = new Date()
createdAt.setDate(createdAt.getDate() - item.daysAgo)
statements.push(`
WITH inserted AS (
INSERT INTO reviews (doctor_id, author_name, rating, is_published, created_at, updated_at)
VALUES (${doctorId}, ${sqlString(item.authorName)}, ${item.rating}, true, ${sqlString(createdAt.toISOString())}, now())
RETURNING id
)
INSERT INTO reviews_locales (comment, _locale, _parent_id)
SELECT ${sqlString(item.comment)}, 'ru', id FROM inserted;
`)
created += 1
}
console.log(slug, items.length)
}
statements.push(`
UPDATE doctors d SET
reviews_count = stats.cnt,
rating = stats.avg
FROM (
SELECT doctor_id, COUNT(*)::numeric AS cnt, ROUND(AVG(rating)::numeric, 1) AS avg
FROM reviews
WHERE is_published = true
GROUP BY doctor_id
) stats
WHERE d.id = stats.doctor_id;
COMMIT;
`)
execFileSync('psql', ['-d', 'moniki', '-v', 'ON_ERROR_STOP=1'], {
input: statements.join('\n'),
encoding: 'utf8',
stdio: ['pipe', 'inherit', 'inherit'],
})
console.log('reviews created', created)
process.exit(0)
}
main().catch((err) => {
console.error(err)
process.exit(1)
})
+376
View File
@@ -0,0 +1,376 @@
import 'dotenv/config'
import { getPayload } from 'payload'
import { paidDoctors } from './paid-doctors'
import { seedPromotions } from './import-promotions'
import config from '../payload.config'
const translations = {
ru: {
branchMain: 'КДЦ МОНИКИ (основной)',
branchReutov: 'Филиал КДЦ МОНИКИ в г. Реутов',
addrMain:
'г. Москва, ул. Щепкина, 61/2. Станция метро «Проспект Мира», выход к спорткомплексу «Олимпийский».',
addrReutov: 'Московская область, г. Реутов, проспект Мира, д. 53',
howMain: 'От метро «Проспект Мира» пешком по улице Щепкина до корпуса института.',
howReutov:
'Московская область, г. Реутов, проспект Мира, д. 53. Удобно доехать наземным транспортом от метро «Новокосино» и от станции Реутов.',
},
en: {
branchMain: 'MONIKI CDC (main)',
branchReutov: 'MONIKI CDC branch in Reutov',
addrMain: 'Moscow, Shchepkina St., 61/2. Prospect Mira metro station.',
addrReutov: 'Moscow Region, Reutov, Prospekt Mira, 53',
howMain: 'From Prospect Mira metro walk along Shchepkina Street to the institute campus.',
howReutov:
'Moscow Region, Reutov, Prospekt Mira, 53. Convenient by surface transport from Novokosino metro and Reutov station.',
},
}
async function findOne(payload: Awaited<ReturnType<typeof getPayload>>, collection: 'branches' | 'specialties' | 'services' | 'doctors' | 'users' | 'posts', slugOrEmail: string) {
const field = collection === 'users' ? 'email' : 'slug'
const res = await payload.find({
collection,
where: { [field]: { equals: slugOrEmail } },
limit: 1,
})
return res.docs[0]
}
async function ensureBranchesSchema(payload: Awaited<ReturnType<typeof getPayload>>) {
const adapter = payload.db as {
drizzle: { execute: (query: unknown) => Promise<unknown> }
}
const { sql } = await import('@payloadcms/db-postgres')
await adapter.drizzle.execute(sql`
ALTER TABLE branches ADD COLUMN IF NOT EXISTS sort_order numeric DEFAULT 100
`)
await adapter.drizzle.execute(sql`
UPDATE branches
SET sort_order = 10, is_main = true, lat = 55.786637, lng = 37.627265
WHERE slug = 'kdc-moniki'
`)
await adapter.drizzle.execute(sql`
UPDATE branches
SET sort_order = 20, is_main = false, lat = 55.771049, lng = 37.862507
WHERE slug = 'kdc-reutov'
`)
await adapter.drizzle.execute(sql`
UPDATE branches_locales
SET
name = 'КДЦ МОНИКИ (основной)',
address = 'г. Москва, ул. Щепкина, 61/2. Станция метро «Проспект Мира», выход к спорткомплексу «Олимпийский».',
city = 'Москва',
metro = 'Проспект Мира',
how_to_get = 'От метро «Проспект Мира» пешком по улице Щепкина до корпуса института.'
WHERE _locale = 'ru'
AND _parent_id IN (SELECT id FROM branches WHERE slug = 'kdc-moniki')
`)
await adapter.drizzle.execute(sql`
UPDATE branches_locales
SET
name = 'Филиал КДЦ МОНИКИ в г. Реутов',
address = 'Московская область, г. Реутов, проспект Мира, д. 53',
city = 'Реутов',
how_to_get = 'Московская область, г. Реутов, проспект Мира, д. 53. Удобно доехать наземным транспортом от метро «Новокосино» и от станции Реутов.'
WHERE _locale = 'ru'
AND _parent_id IN (SELECT id FROM branches WHERE slug = 'kdc-reutov')
`)
await adapter.drizzle.execute(sql`
UPDATE branches_locales
SET
name = 'MONIKI CDC (main)',
address = 'Moscow, Shchepkina St., 61/2. Prospect Mira metro station.',
city = 'Moscow',
metro = 'Prospect Mira',
how_to_get = 'From Prospect Mira metro walk along Shchepkina Street to the institute campus.'
WHERE _locale = 'en'
AND _parent_id IN (SELECT id FROM branches WHERE slug = 'kdc-moniki')
`)
await adapter.drizzle.execute(sql`
UPDATE branches_locales
SET
name = 'MONIKI CDC branch in Reutov',
address = 'Moscow Region, Reutov, Prospekt Mira, 53',
city = 'Reutov',
how_to_get = 'Moscow Region, Reutov, Prospekt Mira, 53. Convenient by surface transport from Novokosino metro and Reutov station.'
WHERE _locale = 'en'
AND _parent_id IN (SELECT id FROM branches WHERE slug = 'kdc-reutov')
`)
}
async function seed() {
const payload = await getPayload({ config })
await ensureBranchesSchema(payload)
console.log('Seeding MONIKI demo data...')
const adminEmail = 'admin@moniki.local'
const existingAdmin = await payload.find({
collection: 'users',
where: { email: { equals: adminEmail } },
limit: 1,
})
if (!existingAdmin.docs.length) {
await payload.create({
collection: 'users',
data: {
email: adminEmail,
password: 'Admin123!',
fullName: 'Администратор МОНИКИ',
role: 'admin',
phone: '+7 (499) 674-07-09',
},
})
}
const patientEmail = 'patient@demo.local'
const existingPatient = await payload.find({
collection: 'users',
where: { email: { equals: patientEmail } },
limit: 1,
})
if (!existingPatient.docs.length) {
await payload.create({
collection: 'users',
data: {
email: patientEmail,
password: 'Patient123!',
fullName: 'Иванова Анна Сергеевна',
role: 'patient',
phone: '+7 (900) 123-45-67',
smsOptIn: true,
},
})
}
let branchMain = await findOne(payload, 'branches', 'kdc-moniki')
if (!branchMain) {
branchMain = await payload.create({
collection: 'branches',
locale: 'ru',
data: {
name: translations.ru.branchMain,
slug: 'kdc-moniki',
address: translations.ru.addrMain,
city: 'Москва',
phone: '+7 (499) 674-07-09',
email: 'mail@monikiweb.ru',
metro: 'Проспект Мира',
howToGet: translations.ru.howMain,
isMain: true,
sortOrder: 10,
lat: 55.786637,
lng: 37.627265,
workHours: [
{ day: 'Пн–Сб', hours: '08:00–20:00' },
{ day: 'Вс', hours: '09:00–16:00' },
],
},
})
}
let branchReutov = await findOne(payload, 'branches', 'kdc-reutov')
if (!branchReutov) {
branchReutov = await payload.create({
collection: 'branches',
locale: 'ru',
data: {
name: translations.ru.branchReutov,
slug: 'kdc-reutov',
address: translations.ru.addrReutov,
city: 'Реутов',
phone: '+7 (499) 674-07-09',
howToGet: translations.ru.howReutov,
isMain: false,
sortOrder: 20,
lat: 55.771049,
lng: 37.862507,
workHours: [{ day: 'Пн–Сб', hours: '08:00–20:00' }],
},
})
}
const specialtyDefs = [
{ title: 'Терапия', slug: 'therapy', icon: 'heart' },
{ title: 'Кардиология', slug: 'cardiology', icon: 'activity' },
{ title: 'Неврология', slug: 'neurology', icon: 'cpu-chip' },
{ title: 'Гастроэнтерология', slug: 'gastro', icon: 'beaker' },
{ title: 'Эндокринология', slug: 'endo', icon: 'flash' },
{ title: 'Диагностика', slug: 'diagnostics', icon: 'scan' },
]
const specialties: Record<string, string | number> = {}
for (const [i, s] of specialtyDefs.entries()) {
let doc = await findOne(payload, 'specialties', s.slug)
if (!doc) {
doc = await payload.create({
collection: 'specialties',
locale: 'ru',
data: {
title: s.title,
slug: s.slug,
description: `Платные услуги направления «${s.title}» в МОНИКИ`,
icon: s.icon,
sortOrder: i,
},
})
}
specialties[s.slug] = doc.id
await payload.update({
collection: 'specialties',
id: doc.id,
locale: 'en',
data: {
title: s.slug.charAt(0).toUpperCase() + s.slug.slice(1),
description: `Paid ${s.slug} services at MONIKI`,
},
})
}
const serviceDefs = [
{
title: 'Консультация терапевта',
slug: 'consult-therapy',
price: 3500,
durationMinutes: 30,
specialty: 'therapy',
category: 'consultation' as const,
isPopular: true,
},
{
title: 'Консультация кардиолога',
slug: 'consult-cardio',
price: 4500,
durationMinutes: 40,
specialty: 'cardiology',
category: 'consultation' as const,
isPopular: true,
},
{
title: 'Консультация невролога',
slug: 'consult-neuro',
price: 4200,
durationMinutes: 40,
specialty: 'neurology',
category: 'consultation' as const,
isPopular: false,
},
{
title: 'УЗИ органов брюшной полости',
slug: 'uzi-abdomen',
price: 3200,
durationMinutes: 30,
specialty: 'diagnostics',
category: 'diagnostics' as const,
isPopular: true,
},
{
title: 'ЭКГ с расшифровкой',
slug: 'ecg',
price: 1800,
durationMinutes: 20,
specialty: 'cardiology',
category: 'diagnostics' as const,
isPopular: true,
},
{
title: 'Комплексное обследование «Чек-ап»',
slug: 'checkup',
price: 18900,
durationMinutes: 120,
specialty: 'diagnostics',
category: 'package' as const,
isPopular: true,
},
]
const services: Record<string, string | number> = {}
for (const s of serviceDefs) {
let doc = await findOne(payload, 'services', s.slug)
if (!doc) {
doc = await payload.create({
collection: 'services',
locale: 'ru',
data: {
title: s.title,
slug: s.slug,
description: `${s.title} в платных отделениях МОНИКИ. Полная оплата через СБП.`,
price: s.price,
durationMinutes: s.durationMinutes,
specialty: specialties[s.specialty],
category: s.category,
branches: [branchMain.id, branchReutov.id],
isPopular: s.isPopular,
isPublished: true,
},
})
}
services[s.slug] = doc.id
}
const doctors: Record<string, string | number> = {}
for (const d of paidDoctors) {
let doc = await findOne(payload, 'doctors', d.slug)
if (!doc) {
doc = await payload.create({
collection: 'doctors',
locale: 'ru',
data: {
fullName: d.fullName,
slug: d.slug,
position: [d.position, d.specialty]
.filter((value, index, arr) => value && arr.indexOf(value) === index)
.join(' · '),
degree: d.degree || undefined,
experienceYears: d.experienceYears,
bio: d.bio,
photoUrl: d.photo,
photoPending: d.photoPending,
specialties: d.specialtySlugs.map((s) => specialties[s]).filter(Boolean),
branches: [branchMain.id],
education: d.education.map((item) => ({ item })),
defaultSlotMinutes: d.defaultSlotMinutes,
isPublished: true,
rating: 0,
reviewsCount: 0,
},
})
await payload.create({
collection: 'schedules',
data: {
title: `Расписание: ${d.fullName}`,
doctor: doc.id,
branch: branchMain.id,
slotMinutes: d.defaultSlotMinutes,
isActive: true,
weekdays: [
{ day: '1', startTime: '09:00', endTime: '14:00' },
{ day: '2', startTime: '09:00', endTime: '14:00' },
{ day: '3', startTime: '14:00', endTime: '19:00' },
{ day: '4', startTime: '09:00', endTime: '14:00' },
{ day: '5', startTime: '09:00', endTime: '13:00' },
],
},
})
}
doctors[d.slug] = doc.id
}
const existingReviews = await payload.find({ collection: 'reviews', limit: 1 })
if (!existingReviews.docs.length) {
console.log('Doctor reviews skipped')
}
await seedPromotions(payload)
console.log('Seed complete.')
console.log('Admin: admin@moniki.local / Admin123!')
console.log('Patient: patient@demo.local / Patient123!')
process.exit(0)
}
seed().catch((err) => {
console.error(err)
process.exit(1)
})
+259
View File
@@ -0,0 +1,259 @@
export const PLACEHOLDER_DOCTOR_PHOTO = '/doctors/placeholder-logo-bw.png'
export type PaidDoctor = {
slug: string
fullName: string
position: string
degree: string
specialty: string
experienceYears: number
bio: string
photo: string
photoPending: boolean
education: string[]
specialtySlugs: string[]
defaultSlotMinutes: number
}
export const paidDoctors: PaidDoctor[] = [
{
slug: 'amirkhanyan-mhitar',
fullName: 'Амирханян Мхитар Грантович',
position: 'Врач-невролог',
degree: 'к.м.н.',
specialty: 'Невролог (эпилептолог)',
experienceYears: 22,
bio: 'Врач-невролог, эпилептолог, кандидат медицинских наук, высшая категория. Консультативно-диагностический центр МОНИКИ. Образование: ХГМУ (Харьков), 2004.',
photo: PLACEHOLDER_DOCTOR_PHOTO,
photoPending: true,
education: ['ХГМУ, г. Харьков, 2004, лечебное дело'],
specialtySlugs: ['neurology'],
defaultSlotMinutes: 40,
},
{
slug: 'getmanskaya-anna',
fullName: 'Гетманская Анна Андреевна',
position: 'Врач-ортодонт',
degree: '',
specialty: 'Ортодонтия',
experienceYears: 11,
bio: 'Врач-ортодонт стоматологического отделения сложных случаев КДЦ МОНИКИ. Стаж с 2015 года. Образование: МГМУ им. И. М. Сеченова (2017), ординатура ЦНИИС и ЧЛХ (2020).',
photo: PLACEHOLDER_DOCTOR_PHOTO,
photoPending: true,
education: ['МГМУ им. И. М. Сеченова, 2017', 'Ординатура ЦНИИС и ЧЛХ, 2020'],
specialtySlugs: [],
defaultSlotMinutes: 30,
},
{
slug: 'dvorina-olga',
fullName: 'Дворина Ольга Геннадьевна',
position: 'Доцент кафедры кардиологии и общей врачебной практики',
degree: 'к.м.н.',
specialty: 'Кардиология',
experienceYears: 33,
bio: 'Кардиолог, кандидат медицинских наук. Кардиология, общая врачебная практика, гериатрия. Стаж с 1993 года. Окончила Уральский государственный медицинский институт, лечебное дело.',
photo: PLACEHOLDER_DOCTOR_PHOTO,
photoPending: true,
education: ['Уральский государственный медицинский институт, лечебное дело, 1993'],
specialtySlugs: ['cardiology'],
defaultSlotMinutes: 40,
},
{
slug: 'dzhabrailova-maryam',
fullName: 'Джабраилова Марьям Абуевна',
position: 'Ассистент кафедры, врач-стоматолог-хирург',
degree: '',
specialty: 'Стоматология хирургическая',
experienceYears: 5,
bio: 'Врач-стоматолог-хирург. Диагностика и хирургическое лечение заболеваний полости рта и челюстно-лицевой области. Стаж с 2021 года. РНИМУ им. Н. И. Пирогова, 2020.',
photo: PLACEHOLDER_DOCTOR_PHOTO,
photoPending: true,
education: ['РНИМУ им. Н. И. Пирогова, стоматология, 2020'],
specialtySlugs: [],
defaultSlotMinutes: 30,
},
{
slug: 'kamynina-olga',
fullName: 'Камынина Ольга Дмитриевна',
position: 'Врач-стоматолог-терапевт отделения сложных случаев',
degree: '',
specialty: 'Стоматолог-терапевт',
experienceYears: 21,
bio: 'Врач-стоматолог-терапевт отделения сложных случаев в стоматологии Консультативно-диагностического центра МОНИКИ. Стаж с 2005 года.',
photo: '/doctors/kamynina-olga.jpg',
photoPending: false,
education: ['Высшее медицинское образование'],
specialtySlugs: [],
defaultSlotMinutes: 30,
},
{
slug: 'kokotov-ivan',
fullName: 'Кокотов Иван Юрьевич',
position: 'Врач консультативно-диагностического центра',
degree: 'Врач первой категории',
specialty: 'Сердечно-сосудистый хирург',
experienceYears: 19,
bio: 'Сердечно-сосудистый хирург первой категории. Радиочастотная абляция, эндовенозная лазерная коагуляция вен, склеротерапия, минифлебэктомия. Амбулаторный приём пациентов с патологией сосудистой системы. Читинская государственная медицинская академия, 2002.',
photo: '/doctors/kokotov-ivan.jpg',
photoPending: false,
education: ['Читинская государственная медицинская академия, лечебное дело, 2002', 'Ординатура по хирургии, 2002–2004'],
specialtySlugs: ['cardiology'],
defaultSlotMinutes: 40,
},
{
slug: 'korsakova-ekaterina',
fullName: 'Корсакова Екатерина Валерьевна',
position: 'Врач-стоматолог-терапевт',
degree: '',
specialty: 'Стоматология терапевтическая',
experienceYears: 10,
bio: 'Врач-стоматолог-терапевт. Практический стаж работы с 2016 года. Консультативно-диагностический центр МОНИКИ.',
photo: PLACEHOLDER_DOCTOR_PHOTO,
photoPending: true,
education: ['Высшее медицинское образование'],
specialtySlugs: [],
defaultSlotMinutes: 30,
},
{
slug: 'kosova-marina',
fullName: 'Косова Марина Михайловна',
position: 'Ассистент кафедры ортопедической стоматологии ФУВ МОНИКИ, врач-стоматолог-ортопед',
degree: 'к.м.н.',
specialty: 'Стоматолог-ортопед',
experienceYears: 38,
bio: 'Кандидат медицинских наук, стоматолог-ортопед. Реабилитация пациентов с дефектами ВНЧС. Преподаёт ортопедическую стоматологию. ММСИ им. Семашко, 1988.',
photo: '/doctors/kosova-marina.jpg',
photoPending: false,
education: ['ММСИ им. Семашко, стоматология ортопедическая, 1988'],
specialtySlugs: [],
defaultSlotMinutes: 40,
},
{
slug: 'kostyukevich-olga',
fullName: 'Костюкевич Ольга Игоревна',
position: 'Доцент кафедры терапии ФУВ МОНИКИ',
degree: 'к.м.н.',
specialty: 'Терапевт, гастроэнтеролог, пульмонолог',
experienceYears: 25,
bio: 'Кандидат медицинских наук, доцент кафедры терапии ФУВ МОНИКИ. Терапия, гастроэнтерология, пульмонология. Врач КДЦ МОНИКИ с 2022 года. РГМУ, лечебный факультет, 2001.',
photo: PLACEHOLDER_DOCTOR_PHOTO,
photoPending: true,
education: ['РГМУ, лечебный факультет, 2001', 'Ординатура по терапии, РГМУ им. Н. И. Пирогова, 2001–2003'],
specialtySlugs: ['therapy', 'gastro'],
defaultSlotMinutes: 40,
},
{
slug: 'kotova-tatiana',
fullName: 'Котова Татьяна Сергеевна',
position: 'Врач-эндокринолог',
degree: '',
specialty: 'Эндокринология',
experienceYears: 17,
bio: 'Врач-эндокринолог консультативно-диагностического центра МОНИКИ. Практический стаж работы с 2009 года.',
photo: '/doctors/kotova-tatiana.jpg',
photoPending: false,
education: ['Высшее медицинское образование'],
specialtySlugs: ['endo'],
defaultSlotMinutes: 30,
},
{
slug: 'markelova-evgenia',
fullName: 'Маркелова Евгения Иннокентьевна',
position: 'Старший научный сотрудник отдела ревматологии, доцент кафедры ревматологии, врач-кардиолог',
degree: 'к.м.н.',
specialty: 'Кардиология',
experienceYears: 26,
bio: 'Кандидат медицинских наук, кардиолог. Старший научный сотрудник отдела ревматологии, доцент кафедры ревматологии МОНИКИ. Стаж с 2000 года. Читинская государственная медицинская академия.',
photo: PLACEHOLDER_DOCTOR_PHOTO,
photoPending: true,
education: ['Читинская государственная медицинская академия'],
specialtySlugs: ['cardiology'],
defaultSlotMinutes: 40,
},
{
slug: 'mikaya-mariam',
fullName: 'Микая Мариам Зурабиевна',
position: 'Ассистент кафедры, врач-стоматолог-хирург',
degree: '',
specialty: 'Стоматология хирургическая',
experienceYears: 6,
bio: 'Врач-стоматолог-хирург. Диагностика и хирургическое лечение заболеваний полости рта и челюстно-лицевой области. Стаж с 2020 года. МГМСУ им. А. И. Евдокимова, 2019.',
photo: PLACEHOLDER_DOCTOR_PHOTO,
photoPending: true,
education: ['МГМСУ им. А. И. Евдокимова, стоматология, 2019'],
specialtySlugs: [],
defaultSlotMinutes: 30,
},
{
slug: 'morozova-natalia',
fullName: 'Морозова Наталия Александровна',
position: 'Врач-гастроэнтеролог',
degree: 'к.м.н.',
specialty: 'Гастроэнтерология',
experienceYears: 43,
bio: 'Гастроэнтеролог, кандидат медицинских наук. Стаж с 1983 года. ММСИ им. Семашко, лечебное дело; клиническая ординатура по внутренним болезням, специализация по гастроэнтерологии 1993.',
photo: PLACEHOLDER_DOCTOR_PHOTO,
photoPending: true,
education: ['ММСИ им. Семашко, лечебное дело, 1983', 'Ординатура по внутренним болезням, 1988–1990'],
specialtySlugs: ['gastro'],
defaultSlotMinutes: 40,
},
{
slug: 'raspopina-natalia',
fullName: 'Распопина Наталья Автандиловна',
position: 'Профессор кафедры',
degree: 'д.м.н.',
specialty: 'Пульмонология',
experienceYears: 45,
bio: 'Доктор медицинских наук, профессор. Заболевания органов дыхания, коморбидные состояния в пульмонологии. Стаж с 1981 года. 1-й ММИ им. Сеченова, лечебное дело.',
photo: PLACEHOLDER_DOCTOR_PHOTO,
photoPending: true,
education: ['1-й ММИ им. Сеченова, лечебное дело, 1981', 'Ординатура «Внутренние болезни», 1983'],
specialtySlugs: ['therapy'],
defaultSlotMinutes: 40,
},
{
slug: 'fokina-polina',
fullName: 'Фокина Полина Александровна',
position: 'Ассистент кафедры офтальмологии и оптометрии ФУВ МОНИКИ',
degree: '',
specialty: 'Офтальмология и оптометрия',
experienceYears: 32,
bio: 'Ассистент кафедры офтальмологии и оптометрии ФУВ ГБУЗ МО МОНИКИ им. М. Ф. Владимирского. Стаж с 1994 года. ТГМУ им. Абуали ибн Сино, лечебное дело.',
photo: PLACEHOLDER_DOCTOR_PHOTO,
photoPending: true,
education: ['ТГМУ им. Абуали ибн Сино, лечебное дело, 1994'],
specialtySlugs: [],
defaultSlotMinutes: 30,
},
{
slug: 'halzeva-emma',
fullName: 'Хальзева Эмма Алексеевна',
position: 'Врач — акушер-гинеколог',
degree: '',
specialty: 'Акушерство и гинекология',
experienceYears: 41,
bio: 'Врач — акушер-гинеколог консультативно-диагностического центра МОНИКИ. Практический стаж с 1985 года, стаж по специальности с 1995 года.',
photo: PLACEHOLDER_DOCTOR_PHOTO,
photoPending: true,
education: ['Высшее медицинское образование'],
specialtySlugs: [],
defaultSlotMinutes: 30,
},
{
slug: 'shtang-olga',
fullName: 'Штанг Ольга Михайловна',
position: 'Заведующая неврологическим отделением КДЦ МОНИКИ',
degree: 'к.м.н.',
specialty: 'Невролог',
experienceYears: 14,
bio: 'Кандидат медицинских наук, заведующая неврологическим отделением КДЦ МОНИКИ. Ведёт приём по лицевым болям, невралгиям, невропатии лицевого нерва и другим заболеваниям нервной системы. Руководитель Московского областного центра боли.',
photo: '/doctors/shtang-olga.jpg',
photoPending: false,
education: ['Высшее медицинское образование'],
specialtySlugs: ['neurology'],
defaultSlotMinutes: 40,
},
]
export const DEMO_DOCTOR_SLUGS = ['kruglov', 'dvornikova', 'kalemberg', 'solntsev']
File diff suppressed because one or more lines are too long
+98
View File
@@ -0,0 +1,98 @@
import 'dotenv/config'
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
import type { Payload } from 'payload'
export const PUBLIC_CONTRACT_FILENAME = 'Dogovor-platnie-2026.docx'
export const PUBLIC_CONTRACT_TITLE = 'Договор на оказание платных медицинских услуг (2026)'
const CONTRACT_MIME =
'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
function contractPath() {
const here = path.dirname(fileURLToPath(import.meta.url))
const bundled = path.join(here, 'files', PUBLIC_CONTRACT_FILENAME)
if (fs.existsSync(bundled)) return bundled
const icloud = path.join(
process.env.HOME || '',
'Library/Mobile Documents/com~apple~CloudDocs/Downloads icloud',
PUBLIC_CONTRACT_FILENAME,
)
if (fs.existsSync(icloud)) return icloud
return null
}
function copyToWebPublic(source: string) {
const here = path.dirname(fileURLToPath(import.meta.url))
const dest = path.resolve(here, '../../../web/public/documents', PUBLIC_CONTRACT_FILENAME)
fs.mkdirSync(path.dirname(dest), { recursive: true })
fs.copyFileSync(source, dest)
}
export async function seedPublicDocuments(payload: Payload) {
const filePath = contractPath()
if (!filePath) {
payload.logger.warn('Public contract file not found, skip seed')
return
}
copyToWebPublic(filePath)
const existing = await payload.find({
collection: 'documents',
where: {
and: [{ isPublic: { equals: true } }, { type: { equals: 'contract' } }],
},
limit: 50,
depth: 1,
})
const already = existing.docs.some((doc) => {
const file = typeof doc.file === 'object' && doc.file ? doc.file : null
const filename = file && 'filename' in file ? String(file.filename || '') : ''
return filename === PUBLIC_CONTRACT_FILENAME || String(doc.title || '').includes('платных медицинских услуг')
})
if (already) return
const buf = fs.readFileSync(filePath)
const media = await payload.create({
collection: 'media',
data: { alt: PUBLIC_CONTRACT_TITLE },
file: {
data: buf,
mimetype: CONTRACT_MIME,
name: PUBLIC_CONTRACT_FILENAME,
size: buf.length,
},
})
await payload.create({
collection: 'documents',
locale: 'ru',
data: {
title: PUBLIC_CONTRACT_TITLE,
type: 'contract',
isPublic: true,
file: media.id,
issuedAt: '2026-01-01T00:00:00.000Z',
},
})
payload.logger.info(`Seeded public document ${PUBLIC_CONTRACT_FILENAME}`)
}
async function main() {
const { getPayload } = await import('payload')
const config = (await import('../payload.config')).default
const payload = await getPayload({ config })
const { ensureDocumentsSchema } = await import('../lib/ensure-documents-schema')
await ensureDocumentsSchema(payload)
await seedPublicDocuments(payload)
process.exit(0)
}
if (process.argv[1]?.includes('public-documents')) {
main().catch((err) => {
console.error(err)
process.exit(1)
})
}
+199
View File
@@ -0,0 +1,199 @@
#!/usr/bin/env python3
"""Fetch the 20 latest news articles from monikiweb.ru/our_news."""
from __future__ import annotations
import html as html_lib
import json
import re
import time
import urllib.request
from pathlib import Path
UA = {"User-Agent": "Mozilla/5.0 (compatible; MONIKI-news-import/1.0)"}
BASE = "https://www.monikiweb.ru"
OUT_JSON = Path(__file__).resolve().parents[3] / "web" / "src" / "lib" / "imported-news.json"
OUT_IMAGES = Path(__file__).resolve().parents[3] / "web" / "public" / "news"
LIMIT = 20
TRANSLIT = {
"а": "a", "б": "b", "в": "v", "г": "g", "д": "d", "е": "e", "ё": "e", "ж": "zh",
"з": "z", "и": "i", "й": "i", "к": "k", "л": "l", "м": "m", "н": "n", "о": "o",
"п": "p", "р": "r", "с": "s", "т": "t", "у": "u", "ф": "f", "х": "h", "ц": "ts",
"ч": "ch", "ш": "sh", "щ": "sch", "ъ": "", "ы": "y", "ь": "", "э": "e", "ю": "yu",
"я": "ya",
}
def fetch(url: str) -> str:
req = urllib.request.Request(url, headers=UA)
with urllib.request.urlopen(req, timeout=40) as res:
return res.read().decode("utf-8", "replace")
def fetch_bytes(url: str) -> bytes | None:
try:
req = urllib.request.Request(url, headers=UA)
with urllib.request.urlopen(req, timeout=40) as res:
return res.read()
except Exception:
return None
def slugify(title: str, node_id: str) -> str:
out = []
for ch in title.lower().strip():
if ch in TRANSLIT:
out.append(TRANSLIT[ch])
if (ch == " " or ch == "-" or ch == "—"):
out.append("-")
else:
out.append("-")
slug = re.sub(r"-+", "-", "".join(out)).strip("-")
slug = slug[:70].strip("-")
return f"{slug or 'novost'}-{node_id}"
def html_to_text(raw: str) -> str:
text = raw
text = re.sub(r"(?i)<br\s*/?>", "\n", text)
text = re.sub(r"(?i)</p>", "\n\n", text)
text = re.sub(r"(?i)</h[1-6]>", "\n\n", text)
text = re.sub(r"(?i)</li>", "\n", text)
text = re.sub(r"(?i)<li[^>]*>", "• ", text)
text = re.sub(r"(?i)</blockquote>", "\n\n", text)
text = re.sub(r"<[^>]+>", "", text)
text = html_lib.unescape(text)
text = text.replace("\xa0", " ")
text = re.sub(r"[ \t]+\n", "\n", text)
text = re.sub(r"\n{3,}", "\n\n", text)
return text.strip()
def parse_listing(page_html: str) -> list[dict]:
items = []
for m in re.finditer(
r'<a class="news-in-list[^"]*" href="(/node/(\d+))">(.*?)</a>',
page_html,
re.S,
):
href, node_id, inner = m.group(1), m.group(2), m.group(3)
img = re.search(r'<img[^>]+src="([^"]+)"', inner)
excerpt = re.search(
r'views-field-field-news-desc-short.*?field-content">(.*?)</div>',
inner,
re.S,
)
date = re.search(r'content="([^"]+)"', inner)
items.append(
{
"nodeId": node_id,
"url": BASE + href,
"excerpt": html_to_text(excerpt.group(1)) if excerpt else "",
"publishedAt": (date.group(1) if date else "")[:10],
"listImage": html_lib.unescape(img.group(1)) if img else "",
}
)
return items
def original_image_url(url: str) -> str:
if not url:
return ""
url = url.split("?")[0]
url = re.sub(r"/styles/[^/]+/public/", "/", url)
return url
def parse_article(node_id: str, listing: dict) -> dict | None:
html = fetch(listing["url"])
title_m = re.search(r'<h1 class="no_mistake">(.*?)</h1>', html, re.S)
title = html_to_text(title_m.group(1) if title_m else listing["excerpt"])
body_m = re.search(
r'field-name-field-news-desc[^>]*>.*?<div class="field-item even">(.*?)</div>\s*</div>\s*</div>',
html,
re.S,
)
body = html_to_text(body_m.group(1)) if body_m else listing["excerpt"]
date_m = re.search(r'field-name-field-news-date[\s\S]*?content="([^"]+)"', html)
published = (date_m.group(1) if date_m else listing["publishedAt"])[:10]
gallery = re.findall(
r'field-name-field-news-img-inline[\s\S]*?<img[^>]+src="([^"]+)"',
html,
)
cover_candidates = [listing["listImage"], *gallery]
slug = slugify(title, node_id)
return {
"id": node_id,
"slug": slug,
"title": title,
"excerpt": listing["excerpt"] or title,
"body": body,
"publishedAt": published or "2026-01-01",
"sourceUrl": listing["url"],
"coverRemote": next((u for u in cover_candidates if u), ""),
"cover": "",
}
def download_cover(item: dict) -> str:
remote = original_image_url(item.get("coverRemote") or "")
if not remote:
return ""
data = fetch_bytes(remote)
if not data:
data = fetch_bytes(item["coverRemote"].split("?")[0])
if not data:
data = fetch_bytes(item["coverRemote"])
if not data:
return ""
ext = ".jpg"
if ".png" in remote.lower():
ext = ".png"
elif ".webp" in remote.lower():
ext = ".webp"
OUT_IMAGES.mkdir(parents=True, exist_ok=True)
name = f"{item['slug']}{ext}"
(OUT_IMAGES / name).write_bytes(data)
return f"/news/{name}"
def main() -> None:
collected: list[dict] = []
seen: set[str] = set()
page = 0
while len(collected) < LIMIT:
url = f"{BASE}/our_news" if page == 0 else f"{BASE}/our_news?page={page}"
print("listing", url)
listing = parse_listing(fetch(url))
if not listing:
break
for item in listing:
if item["nodeId"] in seen:
continue
seen.add(item["nodeId"])
collected.append(item)
if len(collected) >= LIMIT:
break
page += 1
if page > 5:
break
articles = []
for i, item in enumerate(collected[:LIMIT], 1):
print(f"[{i}/{LIMIT}] {item['url']}")
article = parse_article(item["nodeId"], item)
if not article:
continue
article["cover"] = download_cover(article)
article.pop("coverRemote", None)
articles.append(article)
time.sleep(0.15)
OUT_JSON.parent.mkdir(parents=True, exist_ok=True)
OUT_JSON.write_text(json.dumps(articles, ensure_ascii=False, indent=2), encoding="utf-8")
print("wrote", OUT_JSON, "count", len(articles))
if __name__ == "__main__":
main()
+310
View File
@@ -0,0 +1,310 @@
import 'dotenv/config'
import type { Payload } from 'payload'
import {
faqItems,
instituteFacts,
instituteTimeline,
leadership,
licenses,
officialLinks,
receptionSchedule,
requisites,
supervisors,
} from '../../../web/src/lib/organization'
import { pdOperatorFields, rknOperatorUrl } from '../../../web/src/lib/legal'
import { discountNotes, discountRows } from '../../../web/src/lib/discounts'
import ru from '../../../web/src/messages/ru.json'
type PageSeed = {
slug: string
section: 'organization' | 'useful'
sortOrder: number
sourceUrl?: string
ctaHref?: string
data: Record<string, unknown>
}
function plainToLexical(text: string) {
const paragraphs = text
.split(/\n\s*\n/)
.map((p) => p.trim())
.filter(Boolean)
return {
root: {
type: 'root',
children: (paragraphs.length ? paragraphs : text ? [text] : []).map((paragraph) => ({
type: 'paragraph',
version: 1,
children: [
{
type: 'text',
text: paragraph,
version: 1,
detail: 0,
format: 0,
mode: 'normal',
style: '',
},
],
direction: 'ltr' as const,
format: '',
indent: 0,
})),
direction: 'ltr' as const,
format: '',
indent: 0,
version: 1,
},
}
}
function paragraphsToLexical(paragraphs: string[]) {
return plainToLexical(paragraphs.join('\n\n'))
}
const org = ru.organization
const legal = ru.legal
const instituteBody = [
'Первым предшественником института был противочумный карантин, созданный в 1772 году на 3-й Мещанской улице и преобразованный по указу Екатерины II в больницу. С 1835 года она называлась Старо-Екатерининской и служила клинической базой медицинского факультета Московского университета.',
'Сегодня МОНИКИ — ведущий клинический, научный и образовательный комплекс Московской области: стационар на 871 койку, консультации по 40 профилям, около 200 тысяч консультаций и около 10 тысяч операций в год. Здесь сочетаются клиническая помощь, научные исследования и подготовка кадров, включая факультет усовершенствования врачей.',
]
const pages: PageSeed[] = [
{
slug: 'institute',
section: 'organization',
sortOrder: 10,
sourceUrl: officialLinks.history,
data: {
title: org.institute.title,
heading: org.institute.title,
subtitle: org.institute.subtitle,
timelineTitle: org.history,
content: paragraphsToLexical(instituteBody),
facts: instituteFacts.map((item) => ({ label: item.label, value: item.value })),
timeline: instituteTimeline.map((item) => ({ year: item.year, text: item.text })),
},
},
{
slug: 'leaders',
section: 'organization',
sortOrder: 20,
sourceUrl: officialLinks.leaders,
data: {
title: org.leaders.title,
heading: org.leaders.title,
subtitle: org.leaders.subtitle,
people: leadership.map((person) => ({
position: person.position,
name: person.name,
degree: person.degree,
photoUrl: person.photo,
})),
},
},
{
slug: 'license',
section: 'organization',
sortOrder: 30,
sourceUrl: officialLinks.license,
data: {
title: org.license.title,
heading: org.license.title,
subtitle: org.license.subtitle,
notice: org.licenseSoon,
cards: licenses.map((item) => ({
title: item.title,
status: item.status,
number: item.number ?? undefined,
issuedAt: item.issuedAt ?? undefined,
authority: item.authority,
activity: item.activity,
extract: item.extract,
href: item.href,
linkLabel: org.openDocument,
})),
},
},
{
slug: 'requisites',
section: 'organization',
sortOrder: 40,
sourceUrl: officialLinks.officialInfo,
ctaHref: officialLinks.requisitesPdf,
data: {
title: org.requisites.title,
heading: org.requisites.title,
subtitle: org.requisites.subtitle,
ctaLabel: org.downloadPdf,
facts: requisites.map((item) => ({ label: item.label, value: item.value })),
},
},
{
slug: 'supervision',
section: 'organization',
sortOrder: 50,
sourceUrl: officialLinks.officialInfo,
data: {
title: org.supervision.title,
heading: org.supervision.title,
subtitle: org.supervision.subtitle,
cards: supervisors.map((item) => ({
title: item.title,
role: item.role,
phone: item.phone,
address: item.address,
email: item.email,
href: item.href,
linkLabel: org.officialSite,
})),
},
},
{
slug: 'reception',
section: 'organization',
sortOrder: 60,
sourceUrl: officialLinks.contacts,
data: {
title: org.reception.title,
heading: org.reception.title,
subtitle: org.reception.subtitle,
people: receptionSchedule.map((row) => ({
position: row.position,
name: row.name,
hours: row.hours,
address: row.address,
})),
},
},
{
slug: 'faq',
section: 'organization',
sortOrder: 70,
sourceUrl: officialLinks.site,
ctaHref: '/booking',
data: {
title: org.faq.title,
heading: org.faq.title,
subtitle: org.faq.subtitle,
notice: org.faq.comingSoon,
ctaHint: org.faq.bookHint,
ctaLabel: org.faq.bookLink,
faq: faqItems.map((item) => ({ question: item.question, answer: item.answer })),
},
},
{
slug: 'documents',
section: 'useful',
sortOrder: 6,
data: {
title: legal.documentsPage.title,
heading: legal.documentsPage.heading,
subtitle: legal.documentsPage.subtitle,
content: paragraphsToLexical([legal.documentsPage.intro]),
},
},
{
slug: 'discounts',
section: 'useful',
sortOrder: 5,
sourceUrl: 'https://www.monikiweb.ru/skidki',
ctaHref: 'tel:+74956810303',
data: {
title: legal.discounts.title,
heading: legal.discounts.heading,
subtitle: legal.discounts.subtitle,
tableCaption: legal.discounts.caption,
tableRows: discountRows.map((row) => ({
category: row.category,
condition: row.condition || undefined,
discount: row.discount,
documents: row.documents,
})),
content: paragraphsToLexical(discountNotes),
ctaHint: legal.discounts.phoneHint,
ctaLabel: legal.discounts.phone,
},
},
{
slug: 'cookies',
section: 'useful',
sortOrder: 10,
data: {
title: legal.cookies.title,
heading: legal.cookies.heading,
subtitle: legal.cookies.subtitle,
content: paragraphsToLexical(legal.cookies.paragraphs),
},
},
{
slug: 'privacy',
section: 'useful',
sortOrder: 20,
data: {
title: legal.privacy.title,
heading: legal.privacy.title,
subtitle: legal.privacy.subtitle,
content: paragraphsToLexical(legal.privacy.paragraphs),
},
},
{
slug: 'pd-operator',
section: 'useful',
sortOrder: 30,
ctaHref: rknOperatorUrl,
data: {
title: legal.pdOperator.title,
heading: legal.pdOperator.heading,
subtitle: legal.pdOperator.subtitle,
content: paragraphsToLexical([legal.pdOperator.intro]),
ctaLabel: legal.pdOperator.rknLink,
facts: pdOperatorFields.map((item) => ({ label: item.label, value: item.value })),
},
},
]
export async function seedSitePages(payload: Payload) {
for (const page of pages) {
const existing = await payload.find({
collection: 'pages',
where: { slug: { equals: page.slug } },
limit: 1,
depth: 0,
})
if (existing.docs[0]) continue
await payload.create({
collection: 'pages',
locale: 'ru',
data: {
slug: page.slug,
section: page.section,
sortOrder: page.sortOrder,
showInFooter: true,
isPublished: true,
sourceUrl: page.sourceUrl,
ctaHref: page.ctaHref,
...page.data,
},
})
payload.logger.info(`Seeded page ${page.section}/${page.slug}`)
}
}
async function main() {
const { getPayload } = await import('payload')
const config = (await import('../payload.config')).default
const payload = await getPayload({ config })
const { ensurePagesSchema } = await import('../lib/ensure-pages-schema')
await ensurePagesSchema(payload)
await seedSitePages(payload)
process.exit(0)
}
if (process.argv[1]?.includes('site-pages')) {
main().catch((err) => {
console.error(err)
process.exit(1)
})
}
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"baseUrl": ".",
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": {
"@/*": ["./src/*"],
"@payload-config": ["./src/payload.config.ts"]
},
"target": "ES2022"
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}