Initial release of Moniki paid services site and CMS (v1.0.0).
Co-authored-by: Cursor <cursoragent@cursor.com>
@@ -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=МОНИКИ
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export const ADMIN_GROUP = {
|
||||
catalog: 'Каталог',
|
||||
booking: 'Запись',
|
||||
content: 'Контент',
|
||||
cabinet: 'Кабинет',
|
||||
system: 'Система',
|
||||
} as const
|
||||
@@ -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} <${settings.fromEmail}></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)
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export default function Home() {
|
||||
redirect('/admin')
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return children
|
||||
}
|
||||
@@ -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: 'Опубликован' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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: 'Основной филиал',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -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 },
|
||||
],
|
||||
}
|
||||
@@ -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: 'Дата' },
|
||||
],
|
||||
}
|
||||
@@ -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' },
|
||||
],
|
||||
}
|
||||
@@ -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 },
|
||||
],
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -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' },
|
||||
],
|
||||
}
|
||||
@@ -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: 'Ответ' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -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 },
|
||||
],
|
||||
}
|
||||
@@ -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: 'Опубликована' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -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: 'Опубликован' },
|
||||
],
|
||||
}
|
||||
@@ -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 },
|
||||
],
|
||||
}
|
||||
@@ -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 },
|
||||
],
|
||||
}
|
||||
@@ -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 },
|
||||
],
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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:
|
||||
'Для отладки: все исходящие письма уйдут только сюда, реальные адресаты не получат ничего.',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -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)
|
||||
},
|
||||
})
|
||||
@@ -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)`)
|
||||
}
|
||||
@@ -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
|
||||
`)
|
||||
}
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.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}. При необходимости перенесите или отмените запись в личном кабинете.`,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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']
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
NEXT_PUBLIC_CMS_URL=http://localhost:3001
|
||||
@@ -0,0 +1,8 @@
|
||||
.node_modules
|
||||
.next
|
||||
out
|
||||
dist
|
||||
.env*.local
|
||||
!.env.example
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { NextConfig } from "next";
|
||||
import createNextIntlPlugin from "next-intl/plugin";
|
||||
|
||||
const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts");
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
devIndicators: false,
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{ protocol: "https", hostname: "images.unsplash.com" },
|
||||
{ protocol: "http", hostname: "localhost", port: "3001" },
|
||||
{ protocol: "http", hostname: "193.124.91.99", port: "3001" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default withNextIntl(nextConfig);
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "@moniki/web",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3000",
|
||||
"build": "next build",
|
||||
"start": "next start -p 3000",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@untitledui/icons": "^0.0.19",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"next": "15.3.9",
|
||||
"next-intl": "^4.1.0",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"tailwind-merge": "^3.3.0",
|
||||
"zod": "^3.25.28"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.1.7",
|
||||
"@types/node": "^22.15.21",
|
||||
"@types/react": "^19.1.5",
|
||||
"@types/react-dom": "^19.1.5",
|
||||
"tailwindcss": "^4.1.7",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 127 KiB |
|
After Width: | Height: | Size: 325 KiB |
|
After Width: | Height: | Size: 141 KiB |
|
After Width: | Height: | Size: 158 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 127 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 234 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 135 KiB |
|
After Width: | Height: | Size: 141 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 149 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 121 KiB |