2 Commits
37 changed files with 959 additions and 106 deletions
+16
View File
@@ -2,6 +2,20 @@
Формат основан на [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/), версии — [SemVer](https://semver.org/lang/ru/). Формат основан на [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/), версии — [SemVer](https://semver.org/lang/ru/).
## [1.0.10] — 2026-09-17
### Сайт
- Личный кабинет больше не показывает чужие демо-записи: без входа разделы пустые, у каждого пациента только свои заявки, документы и счета
- После заявки на запись создаётся кабинет (если пациента ещё не было) и на почту уходит письмо со сводкой услуги, стоимостью и полезной информацией
- В галерее четыре фото КДЦ МОНИКИ как загруженные файлы, не ссылки
## [1.0.9] — 2026-09-17
### Сайт
- В заявке на запись: обязательные ФИО и e-mail, маска телефона РФ `+7` только из цифр, проверка e-mail (`@` и домен), серые примеры в полях
## [1.0.8] — 2026-09-17 ## [1.0.8] — 2026-09-17
### CMS ### CMS
@@ -80,6 +94,8 @@
- Docker Compose для локальной БД - Docker Compose для локальной БД
- Секреты и логины в git не входят - Секреты и логины в git не входят
[1.0.10]: https://git.myunion.pro/Alex/Moniki_uslugi/src/tag/v1.0.10
[1.0.9]: https://git.myunion.pro/Alex/Moniki_uslugi/src/tag/v1.0.9
[1.0.8]: https://git.myunion.pro/Alex/Moniki_uslugi/src/tag/v1.0.8 [1.0.8]: https://git.myunion.pro/Alex/Moniki_uslugi/src/tag/v1.0.8
[1.0.7]: https://git.myunion.pro/Alex/Moniki_uslugi/src/tag/v1.0.7 [1.0.7]: https://git.myunion.pro/Alex/Moniki_uslugi/src/tag/v1.0.7
[1.0.6]: https://git.myunion.pro/Alex/Moniki_uslugi/src/tag/v1.0.6 [1.0.6]: https://git.myunion.pro/Alex/Moniki_uslugi/src/tag/v1.0.6
+1 -1
View File
@@ -4,7 +4,7 @@
Репозиторий: [Moniki_uslugi](https://git.myunion.pro/Alex/Moniki_uslugi) на [git.myunion.pro](https://git.myunion.pro/). Репозиторий: [Moniki_uslugi](https://git.myunion.pro/Alex/Moniki_uslugi) на [git.myunion.pro](https://git.myunion.pro/).
Текущая версия: **v1.0.8** (см. [CHANGELOG.md](CHANGELOG.md)). Текущая версия: **v1.0.10** (см. [CHANGELOG.md](CHANGELOG.md)).
## Что внутри ## Что внутри
+2 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@moniki/cms", "name": "@moniki/cms",
"version": "1.0.8", "version": "1.0.10",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -19,6 +19,7 @@
"seed:news": "tsx src/seed/import-news.ts", "seed:news": "tsx src/seed/import-news.ts",
"seed:pages": "tsx src/seed/site-pages.ts", "seed:pages": "tsx src/seed/site-pages.ts",
"seed:documents": "tsx src/seed/public-documents.ts", "seed:documents": "tsx src/seed/public-documents.ts",
"seed:gallery": "tsx src/seed/gallery-photos.ts",
"seed:i18n": "python3 src/seed/translate_locales.py", "seed:i18n": "python3 src/seed/translate_locales.py",
"lint": "next lint" "lint": "next lint"
}, },
@@ -1,6 +1,8 @@
import configPromise from '@payload-config' import configPromise from '@payload-config'
import { getPayload } from 'payload' import { getPayload } from 'payload'
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { isValidEmailAddress, isValidFullName, isValidRuPhone, normalizeRuPhone } from '@/lib/contact-fields'
import { findOrCreatePatient, notifyInquiry } from '@/lib/inquiry-account'
function asText(value: unknown) { function asText(value: unknown) {
return typeof value === "string" ? value.trim() : "" return typeof value === "string" ? value.trim() : ""
@@ -17,19 +19,31 @@ function asId(value: unknown) {
export async function POST(req: Request) { export async function POST(req: Request) {
const body = (await req.json().catch(() => ({}))) as Record<string, unknown> const body = (await req.json().catch(() => ({}))) as Record<string, unknown>
const name = asText(body.name) const name = asText(body.name).replace(/\s+/g, " ")
const email = asText(body.email) const email = asText(body.email)
const phone = asText(body.phone) const phone = normalizeRuPhone(asText(body.phone))
const branch = asId(body.branch) const branch = asId(body.branch)
const service = asId(body.service) const service = asId(body.service)
const doctor = asId(body.doctor) const doctor = asId(body.doctor)
if (!name || !email || !phone || branch == null || (service == null && doctor == null)) { if (!isValidFullName(name) || !isValidEmailAddress(email) || !isValidRuPhone(phone) || branch == null || (service == null && doctor == null)) {
return NextResponse.json({ error: "invalid" }, { status: 400 }) return NextResponse.json({ error: "invalid" }, { status: 400 })
} }
try { try {
const payload = await getPayload({ config: configPromise }) const payload = await getPayload({ config: configPromise })
let patientId: string | number | undefined
let cabinetCreated = false
let password: string | undefined
try {
const account = await findOrCreatePatient(payload, { email, name, phone })
patientId = account.id
cabinetCreated = account.created
password = account.password
} catch (error) {
payload.logger.error({ err: error }, 'Failed to create patient cabinet from inquiry')
}
const doc = await payload.create({ const doc = await payload.create({
collection: "inquiries", collection: "inquiries",
overrideAccess: true, overrideAccess: true,
@@ -40,10 +54,23 @@ export async function POST(req: Request) {
branch, branch,
...(service != null ? { service } : {}), ...(service != null ? { service } : {}),
...(doctor != null ? { doctor } : {}), ...(doctor != null ? { doctor } : {}),
...(patientId != null ? { patient: patientId } : {}),
status: "new", status: "new",
}, },
}) })
return NextResponse.json({ id: String(doc.id) })
notifyInquiry(payload, {
inquiryId: doc.id,
patientName: name,
email,
phone,
cabinetCreated,
password,
}).catch((error) => {
payload.logger.error({ err: error }, 'Failed to send inquiry email')
})
return NextResponse.json({ id: String(doc.id), cabinetCreated })
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "failed" const message = error instanceof Error ? error.message : "failed"
return NextResponse.json({ error: message }, { status: 400 }) return NextResponse.json({ error: message }, { status: 400 })
+45 -4
View File
@@ -1,5 +1,6 @@
import type { CollectionConfig } from 'payload' import type { CollectionConfig } from 'payload'
import { ADMIN_GROUP } from '../admin/groups' import { ADMIN_GROUP } from '../admin/groups'
import { isValidEmailAddress, isValidFullName, isValidRuPhone, normalizeRuPhone } from '../lib/contact-fields'
export const Inquiries: CollectionConfig = { export const Inquiries: CollectionConfig = {
slug: 'inquiries', slug: 'inquiries',
@@ -12,17 +13,57 @@ export const Inquiries: CollectionConfig = {
}, },
access: { access: {
create: () => true, create: () => true,
read: ({ req: { user } }) => user?.role === 'admin', read: ({ req: { user } }) => {
if (!user) return false
if (user.role === 'admin') return true
return { patient: { equals: user.id } }
},
update: ({ req: { user } }) => user?.role === 'admin', update: ({ req: { user } }) => user?.role === 'admin',
delete: ({ req: { user } }) => user?.role === 'admin', delete: ({ req: { user } }) => user?.role === 'admin',
}, },
hooks: {
beforeValidate: [
({ data }) => {
if (!data) return data
if (typeof data.name === 'string') data.name = data.name.trim().replace(/\s+/g, ' ')
if (typeof data.email === 'string') data.email = data.email.trim()
if (typeof data.phone === 'string') {
const phone = normalizeRuPhone(data.phone)
if (phone) data.phone = phone
}
return data
},
],
},
fields: [ fields: [
{ name: 'name', type: 'text', required: true, label: 'ФИО' }, {
{ name: 'email', type: 'email', required: true, label: 'E-mail' }, name: 'name',
{ name: 'phone', type: 'text', required: true, label: 'Телефон' }, type: 'text',
required: true,
label: 'ФИО',
validate: (value: string | null | undefined) =>
isValidFullName(String(value ?? '')) ? true : 'Укажите фамилию и имя буквами',
},
{
name: 'email',
type: 'email',
required: true,
label: 'E-mail',
validate: (value: string | null | undefined) =>
isValidEmailAddress(String(value ?? '')) ? true : 'Укажите e-mail в формате name@domain.ru',
},
{
name: 'phone',
type: 'text',
required: true,
label: 'Телефон',
validate: (value: string | null | undefined) =>
isValidRuPhone(String(value ?? '')) ? true : 'Укажите телефон в формате +7 и 10 цифр',
},
{ name: 'service', type: 'relationship', relationTo: 'services', label: 'Услуга' }, { name: 'service', type: 'relationship', relationTo: 'services', label: 'Услуга' },
{ name: 'doctor', type: 'relationship', relationTo: 'doctors', label: 'Врач' }, { name: 'doctor', type: 'relationship', relationTo: 'doctors', label: 'Врач' },
{ name: 'branch', type: 'relationship', relationTo: 'branches', required: true, label: 'Филиал' }, { name: 'branch', type: 'relationship', relationTo: 'branches', required: true, label: 'Филиал' },
{ name: 'patient', type: 'relationship', relationTo: 'users', label: 'Пациент' },
{ {
name: 'status', name: 'status',
type: 'select', type: 'select',
+25
View File
@@ -0,0 +1,25 @@
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[A-Za-z0-9-]{2,}$/
export function isValidFullName(value: string) {
const name = value.trim().replace(/\s+/g, " ")
if (name.length < 3) return false
const parts = name.split(" ").filter(Boolean)
if (parts.length < 2) return false
return /^[\p{L}][\p{L}\s'-]*$/u.test(name)
}
export function isValidEmailAddress(value: string) {
return EMAIL_RE.test(value.trim())
}
export function normalizeRuPhone(value: string) {
let digits = value.replace(/\D/g, "")
if (digits.startsWith("8")) digits = `7${digits.slice(1)}`
if (!digits.startsWith("7")) digits = `7${digits}`
digits = digits.slice(0, 11)
return digits.length === 11 ? `+${digits}` : ""
}
export function isValidRuPhone(value: string) {
return /^\+7\d{10}$/.test(normalizeRuPhone(value))
}
@@ -40,6 +40,20 @@ export async function ensureInquiriesSchema(payload: Payload) {
`, `,
) )
await exec(
payload,
sql`
ALTER TABLE inquiries
ADD COLUMN IF NOT EXISTS patient_id integer
`,
)
await exec(
payload,
sql`
CREATE INDEX IF NOT EXISTS inquiries_patient_idx ON inquiries (patient_id)
`,
)
await exec( await exec(
payload, payload,
sql` sql`
+105
View File
@@ -0,0 +1,105 @@
import { randomBytes } from 'crypto'
import type { Payload } from 'payload'
import { buildInquiryEmail } from './inquiry-email'
import { sendNotification } from './notifications'
function siteUrl() {
return (process.env.CORS_ORIGINS || 'http://localhost:3000').split(',')[0]?.trim() || 'http://localhost:3000'
}
function generateCabinetPassword() {
const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789'
const bytes = randomBytes(10)
let out = 'Mk-'
for (const byte of bytes) out += alphabet[byte % alphabet.length]
return out
}
function relRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === 'object' ? (value as Record<string, unknown>) : null
}
function relText(value: unknown, key: string) {
const rec = relRecord(value)
const raw = rec?.[key]
return typeof raw === 'string' ? raw.trim() : ''
}
export async function findOrCreatePatient(
payload: Payload,
input: { email: string; name: string; phone: string },
): Promise<{ id: string | number; created: boolean; password?: string }> {
const email = input.email.trim().toLowerCase()
const existing = await payload.find({
collection: 'users',
where: { email: { equals: email } },
limit: 1,
overrideAccess: true,
})
if (existing.docs[0]) {
const user = existing.docs[0]
if (!user.phone && input.phone) {
await payload.update({
collection: 'users',
id: user.id,
overrideAccess: true,
data: { phone: input.phone },
})
}
return { id: user.id, created: false }
}
const password = generateCabinetPassword()
const created = await payload.create({
collection: 'users',
overrideAccess: true,
data: {
email,
password,
fullName: input.name,
phone: input.phone,
role: 'patient',
emailOptIn: true,
},
})
return { id: created.id, created: true, password }
}
export async function notifyInquiry(
payload: Payload,
input: {
inquiryId: string | number
patientName: string
email: string
phone: string
cabinetCreated: boolean
password?: string
},
) {
const inquiry = await payload.findByID({
collection: 'inquiries',
id: input.inquiryId,
depth: 2,
overrideAccess: true,
})
const message = buildInquiryEmail({
patientName: input.patientName,
email: input.email,
phone: input.phone,
serviceTitle: relText(inquiry.service, 'title').replace(/^\d+(?:\.\d+)*\.?\s+/, '').trim(),
servicePrice: Number(relRecord(inquiry.service)?.price) || undefined,
doctorName: relText(inquiry.doctor, 'fullName'),
branchName: relText(inquiry.branch, 'name'),
branchAddress: relText(inquiry.branch, 'address'),
cabinetCreated: input.cabinetCreated,
password: input.password,
loginUrl: `${siteUrl()}/ru/login`,
})
await sendNotification(payload, {
channel: 'email',
to: input.email,
subject: message.subject,
body: message.text,
html: message.html,
})
}
+149
View File
@@ -0,0 +1,149 @@
function escapeHtml(text: string) {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
function row(label: string, value: string) {
if (!value.trim()) return ''
return `<tr>
<td style="padding:8px 0;color:#64748b;font-size:14px;width:40%;vertical-align:top;">${escapeHtml(label)}</td>
<td style="padding:8px 0;color:#0f172a;font-size:14px;font-weight:600;">${escapeHtml(value)}</td>
</tr>`
}
export type InquiryEmailInput = {
patientName: string
email: string
phone: string
serviceTitle?: string
servicePrice?: number
doctorName?: string
branchName?: string
branchAddress?: string
cabinetCreated: boolean
password?: string
loginUrl: string
}
function formatPrice(amount?: number) {
if (amount == null || !Number.isFinite(amount) || amount <= 0) return ''
return new Intl.NumberFormat('ru-RU', {
style: 'currency',
currency: 'RUB',
maximumFractionDigits: 0,
}).format(amount)
}
export function buildInquiryEmail(input: InquiryEmailInput) {
const firstName = input.patientName.trim().split(/\s+/)[1] || input.patientName.trim()
const price = formatPrice(input.servicePrice)
const subject = input.cabinetCreated
? 'Личный кабинет и заявка на запись — МОНИКИ'
: 'Ваша заявка на запись принята — МОНИКИ'
const cabinetBlock = input.cabinetCreated
? `
<div style="margin:24px 0;padding:20px;border-radius:16px;background:#eef6fb;border:1px solid #cfe3f2;">
<p style="margin:0 0 8px;font-size:16px;font-weight:700;color:#163a56;">Для вас создан личный кабинет</p>
<p style="margin:0 0 16px;font-size:14px;line-height:1.55;color:#334155;">
В кабинете будут ваши заявки, документы и счета. Войти можно по почте, которую вы указали в заявке.
</p>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0">
${row('Логин', input.email)}
${input.password ? row('Пароль', input.password) : ''}
</table>
<p style="margin:16px 0 0;">
<a href="${escapeHtml(input.loginUrl)}" style="display:inline-block;background:#1b4b73;color:#ffffff;text-decoration:none;padding:12px 20px;border-radius:999px;font-size:14px;font-weight:700;">
Войти в личный кабинет
</a>
</p>
</div>`
: `
<p style="margin:0 0 20px;font-size:14px;line-height:1.55;color:#334155;">
Заявка сохранена в вашем личном кабинете. Войти можно по адресу
<a href="${escapeHtml(input.loginUrl)}" style="color:#1b4b73;font-weight:700;">${escapeHtml(input.loginUrl)}</a>.
</p>`
const html = `<!doctype html>
<html lang="ru">
<body style="margin:0;padding:0;background:#f4f7fb;font-family:'Segoe UI',Arial,sans-serif;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#f4f7fb;padding:24px 12px;">
<tr>
<td align="center">
<table role="presentation" width="640" cellspacing="0" cellpadding="0" style="max-width:640px;width:100%;background:#ffffff;border-radius:20px;overflow:hidden;box-shadow:0 10px 30px rgba(15,23,42,0.06);">
<tr>
<td style="background:linear-gradient(135deg,#163a56,#1b4b73);padding:28px 32px;color:#ffffff;">
<p style="margin:0;font-size:12px;letter-spacing:.12em;text-transform:uppercase;opacity:.8;">КДЦ МОНИКИ</p>
<h1 style="margin:8px 0 0;font-size:24px;line-height:1.3;">Заявка принята</h1>
</td>
</tr>
<tr>
<td style="padding:32px;">
<p style="margin:0 0 16px;font-size:16px;color:#0f172a;">Здравствуйте, ${escapeHtml(firstName)}!</p>
<p style="margin:0 0 20px;font-size:15px;line-height:1.6;color:#334155;">
Спасибо, что выбрали платные медицинские услуги КДЦ МОНИКИ им. М. Ф. Владимирского.
Мы получили вашу заявку и свяжемся с вами, чтобы уточнить удобную дату и время.
</p>
<div style="padding:4px 20px 8px;border-radius:16px;background:#f8fafc;border:1px solid #e2e8f0;">
<p style="margin:16px 0 4px;font-size:13px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:#1b4b73;">Сводка заявки</p>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0">
${row('Пациент', input.patientName)}
${row('Услуга', input.serviceTitle || '')}
${row('Стоимость', price)}
${row('Врач', input.doctorName || '')}
${row('Филиал', input.branchName || '')}
${row('Адрес', input.branchAddress || '')}
${row('Телефон для связи', input.phone)}
${row('E-mail', input.email)}
</table>
</div>
${cabinetBlock}
<div style="margin-top:8px;padding:20px;border-radius:16px;background:#fff8ef;border:1px solid #f1e0c3;">
<p style="margin:0 0 10px;font-size:16px;font-weight:700;color:#9a6700;">Полезная информация</p>
<ul style="margin:0;padding-left:18px;color:#57534e;font-size:14px;line-height:1.65;">
<li>Возьмите паспорт. Если есть полис ОМС или результаты обследований — их тоже полезно иметь при себе.</li>
<li>Приходите за 10–15 минут до приёма, чтобы оформить документы на стойке регистрации.</li>
<li>Точное время визита подтвердит администратор: +7 (499) 674-07-09 или 122.</li>
<li>Основной филиал: ул. Щепкина, 61/2, метро «Проспект Мира».</li>
<li>Если планы изменятся, напишите нам или позвоните — заявку можно уточнить или перенести.</li>
</ul>
</div>
<p style="margin:24px 0 0;font-size:13px;line-height:1.6;color:#64748b;">
С заботой о вашем здоровье,<br>
<strong style="color:#163a56;">КДЦ МОНИКИ</strong><br>
Платные медицинские услуги
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`
const textLines = [
`Здравствуйте, ${firstName}!`,
'',
'Мы получили вашу заявку на запись в КДЦ МОНИКИ и свяжемся с вами, чтобы уточнить дату и время.',
'',
input.serviceTitle ? `Услуга: ${input.serviceTitle}` : '',
price ? `Стоимость: ${price}` : '',
input.doctorName ? `Врач: ${input.doctorName}` : '',
input.branchName ? `Филиал: ${input.branchName}` : '',
input.branchAddress ? `Адрес: ${input.branchAddress}` : '',
`Телефон: ${input.phone}`,
`E-mail: ${input.email}`,
'',
input.cabinetCreated
? `Для вас создан личный кабинет.\nЛогин: ${input.email}\nПароль: ${input.password || ''}\nВход: ${input.loginUrl}`
: `Личный кабинет: ${input.loginUrl}`,
'',
'Возьмите паспорт, приходите за 10–15 минут. Телефон: +7 (499) 674-07-09, 122.',
].filter(Boolean)
return { subject, html, text: textLines.join('\n') }
}
+2 -1
View File
@@ -9,6 +9,7 @@ export type NotifyInput = {
to: string to: string
subject: string subject: string
body: string body: string
html?: string
} }
function escapeHtml(text: string) { function escapeHtml(text: string) {
@@ -36,7 +37,7 @@ export async function sendNotification(
const result = await payload.sendEmail({ const result = await payload.sendEmail({
to: input.to, to: input.to,
subject: input.subject, subject: input.subject,
html: `<p>${escapeHtml(input.body)}</p>`, html: input.html || `<p>${escapeHtml(input.body)}</p>`,
text: input.body, text: input.body,
}) })
if (result && typeof result === 'object' && 'skipped' in result && result.skipped) { if (result && typeof result === 'object' && 'skipped' in result && result.skipped) {
+6
View File
@@ -96,6 +96,12 @@ export default buildConfig({
} catch (err) { } catch (err) {
payload.logger.error({ err }, 'Failed to seed public documents') payload.logger.error({ err }, 'Failed to seed public documents')
} }
try {
const { seedGalleryPhotos } = await import('./seed/gallery-photos')
await seedGalleryPhotos(payload)
} catch (err) {
payload.logger.error({ err }, 'Failed to seed gallery photos')
}
}, },
editor: lexicalEditor(), editor: lexicalEditor(),
i18n: { i18n: {
Binary file not shown.

After

Width:  |  Height:  |  Size: 493 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 397 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 535 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

+110
View File
@@ -0,0 +1,110 @@
import 'dotenv/config'
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
import type { Payload } from 'payload'
export const GALLERY_PHOTOS = [
{
filename: 'photo-6.jpg',
mime: 'image/jpeg',
title: 'Ресепшен КДЦ МОНИКИ',
alt: 'Стойка регистрации КДЦ МОНИКИ',
sortOrder: 10,
},
{
filename: 'photo69.jpeg',
mime: 'image/jpeg',
title: 'Администратор на ресепшене',
alt: 'Администратор КДЦ МОНИКИ за стойкой регистрации',
sortOrder: 20,
},
{
filename: 'photo68.jpg',
mime: 'image/jpeg',
title: 'Зона ожидания',
alt: 'Зал ожидания отделения платных медицинских услуг КДЦ МОНИКИ',
sortOrder: 30,
},
{
filename: 'photo-9.jpg',
mime: 'image/jpeg',
title: 'Стойка регистрации',
alt: 'Интерьер стойки регистрации КДЦ МОНИКИ',
sortOrder: 40,
},
]
function filePath(filename: string) {
const here = path.dirname(fileURLToPath(import.meta.url))
const bundled = path.join(here, 'files', 'gallery', filename)
return fs.existsSync(bundled) ? bundled : null
}
export async function seedGalleryPhotos(payload: Payload) {
for (const item of GALLERY_PHOTOS) {
const source = filePath(item.filename)
if (!source) {
payload.logger.warn(`Gallery photo not found: ${item.filename}`)
continue
}
const already = await payload.find({
collection: 'gallery',
where: { title: { equals: item.title } },
locale: 'ru',
limit: 1,
})
if (already.docs.length) continue
const existingMedia = await payload.find({
collection: 'media',
where: { filename: { equals: item.filename } },
limit: 1,
})
let mediaId = existingMedia.docs[0]?.id
if (!mediaId) {
const buf = fs.readFileSync(source)
const media = await payload.create({
collection: 'media',
locale: 'ru',
data: { alt: item.alt },
file: {
data: buf,
mimetype: item.mime,
name: item.filename,
size: buf.length,
},
})
mediaId = media.id
}
await payload.create({
collection: 'gallery',
locale: 'ru',
data: {
title: item.title,
image: mediaId,
category: 'clinic',
sortOrder: item.sortOrder,
},
})
payload.logger.info(`Seeded gallery photo ${item.filename}`)
}
}
async function main() {
const { getPayload } = await import('payload')
const config = (await import('../payload.config')).default
const payload = await getPayload({ config })
await seedGalleryPhotos(payload)
process.exit(0)
}
if (process.argv[1]?.includes('gallery-photos')) {
main().catch((err) => {
console.error(err)
process.exit(1)
})
}
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@moniki/web", "name": "@moniki/web",
"version": "1.0.8", "version": "1.0.10",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev -p 3000", "dev": "next dev -p 3000",
@@ -1,3 +1,5 @@
import { getMyDocuments } from "@/lib/api";
import { formatDate } from "@/lib/utils";
import { getTranslations, setRequestLocale } from "next-intl/server"; import { getTranslations, setRequestLocale } from "next-intl/server";
export default async function CabinetDocumentsPage({ export default async function CabinetDocumentsPage({
@@ -8,11 +10,27 @@ export default async function CabinetDocumentsPage({
const { locale } = await params; const { locale } = await params;
setRequestLocale(locale); setRequestLocale(locale);
const t = await getTranslations("cabinet"); const t = await getTranslations("cabinet");
const documents = await getMyDocuments();
return ( return (
<div> <div>
<h2 className="mb-4 text-xl font-semibold">{t("documents")}</h2> <h2 className="mb-4 text-xl font-semibold">{t("documents")}</h2>
{documents.length === 0 ? (
<p className="text-[var(--text-muted)]">{t("emptyDocuments")}</p> <p className="text-[var(--text-muted)]">{t("emptyDocuments")}</p>
) : (
<ul className="space-y-3">
{documents.map((doc) => (
<li key={doc.id} className="rounded-[12px] border border-[var(--border)] bg-white px-4 py-3">
<a href={doc.href} className="font-semibold text-[var(--brand-700)]" target="_blank" rel="noreferrer">
{doc.title}
</a>
{doc.issuedAt ? (
<p className="mt-1 text-sm text-[var(--text-muted)]">{formatDate(doc.issuedAt, locale)}</p>
) : null}
</li>
))}
</ul>
)}
</div> </div>
); );
} }
@@ -1,6 +1,6 @@
import { Card } from "@/components/ui/Card"; import { Card } from "@/components/ui/Card";
import { getMyBookings } from "@/lib/api"; import { getMyInvoices } from "@/lib/api";
import { formatPrice } from "@/lib/utils"; import { formatDate, formatPrice } from "@/lib/utils";
import { getTranslations, setRequestLocale } from "next-intl/server"; import { getTranslations, setRequestLocale } from "next-intl/server";
export default async function CabinetInvoicesPage({ export default async function CabinetInvoicesPage({
@@ -11,24 +11,27 @@ export default async function CabinetInvoicesPage({
const { locale } = await params; const { locale } = await params;
setRequestLocale(locale); setRequestLocale(locale);
const t = await getTranslations("cabinet"); const t = await getTranslations("cabinet");
const bookings = await getMyBookings(); const invoices = await getMyInvoices();
return ( return (
<div> <div>
<h2 className="mb-4 text-xl font-semibold">{t("invoices")}</h2> <h2 className="mb-4 text-xl font-semibold">{t("invoices")}</h2>
{bookings.length === 0 ? ( {invoices.length === 0 ? (
<p className="text-[var(--text-muted)]">{t("emptyInvoices")}</p> <p className="text-[var(--text-muted)]">{t("emptyInvoices")}</p>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
{bookings.map((b) => ( {invoices.map((invoice) => (
<Card key={b.id} className="flex items-center justify-between gap-4"> <Card key={invoice.id} className="flex items-center justify-between gap-4">
<div> <div>
<p className="font-semibold">{b.serviceTitle}</p> <p className="font-semibold">{invoice.title}</p>
<p className="text-sm text-[var(--text-muted)]">{b.datetime}</p> <p className="text-sm text-[var(--text-muted)]">
{invoice.number}
{invoice.datetime ? ` · ${formatDate(invoice.datetime, locale)}` : ""}
</p>
</div> </div>
<div className="text-right"> <div className="text-right">
<p className="font-bold text-[var(--brand-700)]">{formatPrice(b.price, locale)}</p> <p className="font-bold text-[var(--brand-700)]">{formatPrice(invoice.amount, locale)}</p>
<p className="text-xs text-[var(--neutral-500)]">{b.paid ? t("paid") : t("unpaid")}</p> <p className="text-xs text-[var(--neutral-500)]">{invoice.paid ? t("paid") : t("unpaid")}</p>
</div> </div>
</Card> </Card>
))} ))}
@@ -1,5 +1,7 @@
import { CabinetNav } from "@/components/cabinet/CabinetNav"; import { CabinetNav } from "@/components/cabinet/CabinetNav";
import { namedPageMetadata } from "@/lib/page-metadata"; import { namedPageMetadata } from "@/lib/page-metadata";
import { me } from "@/lib/api";
import { Link } from "@/i18n/routing";
import { getTranslations, setRequestLocale } from "next-intl/server"; import { getTranslations, setRequestLocale } from "next-intl/server";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
@@ -22,6 +24,20 @@ export default async function CabinetLayout({
const { locale } = await params; const { locale } = await params;
setRequestLocale(locale); setRequestLocale(locale);
const t = await getTranslations("cabinet"); const t = await getTranslations("cabinet");
const auth = await getTranslations("auth");
const user = await me();
if (!user) {
return (
<div className="mx-auto max-w-xl px-4 py-12 sm:px-6">
<h1 className="text-3xl font-bold text-[var(--neutral-900)]">{t("title")}</h1>
<p className="mt-4 text-[var(--text-muted)]">{t("loginHint")}</p>
<Link href="/login" className="mt-4 inline-block font-semibold text-[var(--brand-700)]">
{auth("login")}
</Link>
</div>
);
}
return ( return (
<div className="mx-auto max-w-6xl px-4 py-12 sm:px-6"> <div className="mx-auto max-w-6xl px-4 py-12 sm:px-6">
+1 -1
View File
@@ -35,7 +35,7 @@ export default async function GalleryPage({
{/* eslint-disable-next-line @next/next/no-img-element */} {/* eslint-disable-next-line @next/next/no-img-element */}
<img src={item.image} alt={item.title} className="mx-auto block w-full object-cover object-center" /> <img src={item.image} alt={item.title} className="mx-auto block w-full object-cover object-center" />
<figcaption className="bg-white px-3 py-2 text-sm text-[var(--neutral-600)]"> <figcaption className="bg-white px-3 py-2 text-sm text-[var(--neutral-600)]">
{item.title} · {item.category} {item.title}
</figcaption> </figcaption>
</figure> </figure>
))} ))}
-13
View File
@@ -24,19 +24,6 @@ export async function POST(request: Request) {
}); });
return response; return response;
} catch { } catch {
// Demo login fallback when CMS is down
if (body.email && body.password) {
const response = NextResponse.json({
user: { id: "demo", email: body.email, name: "Пациент" },
});
response.cookies.set(AUTH_COOKIE, "demo-token", {
httpOnly: true,
sameSite: "lax",
path: "/",
maxAge: 60 * 60 * 24 * 7,
});
return response;
}
return NextResponse.json({ message: "Login failed" }, { status: 500 }); return NextResponse.json({ message: "Login failed" }, { status: 500 });
} }
} }
+10 -16
View File
@@ -5,13 +5,7 @@ import { NextResponse } from "next/server";
export async function GET() { export async function GET() {
const store = await cookies(); const store = await cookies();
const token = store.get(AUTH_COOKIE)?.value; const token = store.get(AUTH_COOKIE)?.value;
if (!token) return NextResponse.json({ user: null }, { status: 401 }); if (!token || token === "demo-token") return NextResponse.json({ user: null }, { status: 401 });
if (token === "demo-token") {
return NextResponse.json({
user: { id: "demo", email: "patient@example.com", name: "Пациент", phone: "+7" },
});
}
try { try {
const res = await fetch(`${CMS_URL}/api/users/me`, { const res = await fetch(`${CMS_URL}/api/users/me`, {
@@ -21,21 +15,21 @@ export async function GET() {
const data = await res.json(); const data = await res.json();
return NextResponse.json(data, { status: res.status }); return NextResponse.json(data, { status: res.status });
} catch { } catch {
return NextResponse.json({ return NextResponse.json({ user: null }, { status: 401 });
user: { id: "demo", email: "patient@example.com", name: "Пациент" },
});
} }
} }
export async function PATCH(request: Request) { export async function PATCH(request: Request) {
const store = await cookies(); const store = await cookies();
const token = store.get(AUTH_COOKIE)?.value; const token = store.get(AUTH_COOKIE)?.value;
if (!token) return NextResponse.json({ message: "Unauthorized" }, { status: 401 }); if (!token || token === "demo-token") return NextResponse.json({ message: "Unauthorized" }, { status: 401 });
const body = await request.json(); const body = await request.json();
if (token === "demo-token") { const payload = {
return NextResponse.json({ user: { id: "demo", ...body } }); ...body,
} fullName: body.fullName || body.name,
};
delete payload.name;
try { try {
const res = await fetch(`${CMS_URL}/api/users/me`, { const res = await fetch(`${CMS_URL}/api/users/me`, {
@@ -44,11 +38,11 @@ export async function PATCH(request: Request) {
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify(body), body: JSON.stringify(payload),
}); });
const data = await res.json(); const data = await res.json();
return NextResponse.json(data, { status: res.status }); return NextResponse.json(data, { status: res.status });
} catch { } catch {
return NextResponse.json({ user: { id: "demo", ...body } }); return NextResponse.json({ message: "Update failed" }, { status: 500 });
} }
} }
+13 -13
View File
@@ -4,24 +4,33 @@ import { NextResponse } from "next/server";
export async function POST(request: Request) { export async function POST(request: Request) {
const body = await request.json(); const body = await request.json();
try { try {
const payload = {
email: body.email,
password: body.password,
fullName: body.fullName || body.name,
phone: body.phone,
role: "patient",
};
const res = await fetch(`${CMS_URL}/api/users`, { const res = await fetch(`${CMS_URL}/api/users`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(body), body: JSON.stringify(payload),
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok) { if (!res.ok) {
return NextResponse.json(data, { status: res.status }); return NextResponse.json(data, { status: res.status });
} }
// Auto-login after register
const loginRes = await fetch(`${CMS_URL}/api/users/login`, { const loginRes = await fetch(`${CMS_URL}/api/users/login`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: body.email, password: body.password }), body: JSON.stringify({ email: body.email, password: body.password }),
}); });
const loginData = await loginRes.json().catch(() => ({})); const loginData = await loginRes.json().catch(() => ({}));
const token = loginData.token || data.token || "demo-token"; const token = loginData.token || data.token;
if (!token) {
return NextResponse.json({ message: "Register succeeded but login failed" }, { status: 500 });
}
const response = NextResponse.json({ user: data.doc || data.user || { email: body.email } }); const response = NextResponse.json({ user: data.doc || data.user || { email: body.email } });
response.cookies.set(AUTH_COOKIE, token, { response.cookies.set(AUTH_COOKIE, token, {
@@ -33,15 +42,6 @@ export async function POST(request: Request) {
}); });
return response; return response;
} catch { } catch {
const response = NextResponse.json({ return NextResponse.json({ message: "Register failed" }, { status: 500 });
user: { id: "demo", email: body.email, name: body.name },
});
response.cookies.set(AUTH_COOKIE, "demo-token", {
httpOnly: true,
sameSite: "lax",
path: "/",
maxAge: 60 * 60 * 24 * 7,
});
return response;
} }
} }
+9 -6
View File
@@ -1,4 +1,10 @@
import { createInquiry } from "@/lib/api"; import { createInquiry } from "@/lib/api";
import {
isValidEmailAddress,
isValidFullName,
isValidRuPhone,
normalizeRuPhone,
} from "@/lib/contact-fields";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
function asText(value: unknown) { function asText(value: unknown) {
@@ -7,19 +13,16 @@ function asText(value: unknown) {
export async function POST(request: Request) { export async function POST(request: Request) {
const body = (await request.json()) as Record<string, unknown>; const body = (await request.json()) as Record<string, unknown>;
const name = asText(body.name ?? body.patientName); const name = asText(body.name ?? body.patientName).replace(/\s+/g, " ");
const email = asText(body.email); const email = asText(body.email);
const phone = asText(body.phone); const phone = normalizeRuPhone(asText(body.phone));
const branchId = asText(body.branchId); const branchId = asText(body.branchId);
const serviceId = asText(body.serviceId) || undefined; const serviceId = asText(body.serviceId) || undefined;
const doctorId = asText(body.doctorId) || undefined; const doctorId = asText(body.doctorId) || undefined;
if (!name || !email || !phone || !branchId || (!serviceId && !doctorId)) { if (!isValidFullName(name) || !isValidEmailAddress(email) || !isValidRuPhone(phone) || !branchId || (!serviceId && !doctorId)) {
return NextResponse.json({ error: "invalid" }, { status: 400 }); return NextResponse.json({ error: "invalid" }, { status: 400 });
} }
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
return NextResponse.json({ error: "email" }, { status: 400 });
}
try { try {
const inquiry = await createInquiry({ const inquiry = await createInquiry({
@@ -5,6 +5,13 @@ import { Card } from "@/components/ui/Card";
import { Input } from "@/components/ui/Input"; import { Input } from "@/components/ui/Input";
import { Select } from "@/components/ui/Select"; import { Select } from "@/components/ui/Select";
import type { Branch, Doctor, Service } from "@/lib/demo-data"; import type { Branch, Doctor, Service } from "@/lib/demo-data";
import {
formatRuPhone,
isValidEmailAddress,
isValidFullName,
isValidRuPhone,
normalizeRuPhone,
} from "@/lib/contact-fields";
import { matchesQuery } from "@/lib/search"; import { matchesQuery } from "@/lib/search";
import { cn, formatPrice, stripPriceListIndex } from "@/lib/utils"; import { cn, formatPrice, stripPriceListIndex } from "@/lib/utils";
import { useTranslations, useLocale } from "next-intl"; import { useTranslations, useLocale } from "next-intl";
@@ -42,6 +49,7 @@ export function BookingWizard({
const [name, setName] = useState(""); const [name, setName] = useState("");
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [phone, setPhone] = useState(""); const [phone, setPhone] = useState("");
const [fieldErrors, setFieldErrors] = useState<{ name?: string; email?: string; phone?: string }>({});
const [done, setDone] = useState(false); const [done, setDone] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [pending, startTransition] = useTransition(); const [pending, startTransition] = useTransition();
@@ -77,10 +85,20 @@ export function BookingWizard({
return doctors.filter((d) => d.services.includes(serviceId)); return doctors.filter((d) => d.services.includes(serviceId));
}, [doctors, serviceId]); }, [doctors, serviceId]);
function contactErrors() {
const next: { name?: string; email?: string; phone?: string } = {};
if (!name.trim()) next.name = t("nameRequired");
else if (!isValidFullName(name)) next.name = t("nameInvalid");
if (!email.trim()) next.email = t("emailRequired");
else if (!isValidEmailAddress(email)) next.email = t("emailInvalid");
if (!isValidRuPhone(phone)) next.phone = t("phoneInvalid");
return next;
}
function canNext() { function canNext() {
if (step === 0) return Boolean(serviceId || doctorId); if (step === 0) return Boolean(serviceId || doctorId);
if (step === 1) return Boolean(branchId); if (step === 1) return Boolean(branchId);
return Boolean(name.trim() && email.trim() && phone.trim()); return true;
} }
async function onNext() { async function onNext() {
@@ -89,6 +107,9 @@ export function BookingWizard({
setStep((s) => s + 1); setStep((s) => s + 1);
return; return;
} }
const nextErrors = contactErrors();
setFieldErrors(nextErrors);
if (Object.keys(nextErrors).length) return;
startTransition(async () => { startTransition(async () => {
try { try {
const res = await fetch("/api/inquiries", { const res = await fetch("/api/inquiries", {
@@ -98,9 +119,9 @@ export function BookingWizard({
serviceId: serviceId || undefined, serviceId: serviceId || undefined,
doctorId: doctorId || undefined, doctorId: doctorId || undefined,
branchId, branchId,
name: name.trim(), name: name.trim().replace(/\s+/g, " "),
email: email.trim(), email: email.trim(),
phone: phone.trim(), phone: normalizeRuPhone(phone),
}), }),
}); });
if (!res.ok) throw new Error("fail"); if (!res.ok) throw new Error("fail");
@@ -220,15 +241,53 @@ export function BookingWizard({
{branch ? <li>{branch.name}</li> : null} {branch ? <li>{branch.name}</li> : null}
</ul> </ul>
</div> </div>
<Input label={t("patientName")} value={name} onChange={(e) => setName(e.target.value)} required />
<Input <Input
label={t("patientName")}
placeholder={t("namePlaceholder")}
value={name}
autoComplete="name"
error={fieldErrors.name}
onChange={(e) => {
setName(e.target.value);
setFieldErrors((prev) => ({ ...prev, name: undefined }));
}}
onBlur={() => setFieldErrors((prev) => ({ ...prev, name: contactErrors().name }))}
required
/>
<Input
type="email" type="email"
inputMode="email"
label={t("email")} label={t("email")}
placeholder={t("emailPlaceholder")}
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} autoComplete="email"
error={fieldErrors.email}
onChange={(e) => {
setEmail(e.target.value);
setFieldErrors((prev) => ({ ...prev, email: undefined }));
}}
onBlur={() => setFieldErrors((prev) => ({ ...prev, email: contactErrors().email }))}
required
/>
<Input
type="tel"
inputMode="numeric"
label={t("phone")}
placeholder={t("phonePlaceholder")}
value={phone}
autoComplete="tel"
error={fieldErrors.phone}
onChange={(e) => {
setPhone(formatRuPhone(e.target.value));
setFieldErrors((prev) => ({ ...prev, phone: undefined }));
}}
onBlur={() => setFieldErrors((prev) => ({ ...prev, phone: contactErrors().phone }))}
onFocus={() => {
if (!phone.trim() || phone.trim() === "+") setPhone("+7");
}}
maxLength={16}
required required
/> />
<Input label={t("phone")} value={phone} onChange={(e) => setPhone(e.target.value)} required />
</> </>
) : null} ) : null}
@@ -95,15 +95,20 @@ export function BookingActions({ booking }: { booking: Booking }) {
<p className="mt-1 text-sm text-[var(--text-muted)]">{booking.doctorName}</p> <p className="mt-1 text-sm text-[var(--text-muted)]">{booking.doctorName}</p>
<p className="text-sm text-[var(--text-muted)]">{booking.branchName}</p> <p className="text-sm text-[var(--text-muted)]">{booking.branchName}</p>
<p className="mt-2 text-sm font-medium">{formatDate(booking.datetime, locale)}</p> <p className="mt-2 text-sm font-medium">{formatDate(booking.datetime, locale)}</p>
{booking.source === "inquiry" ? (
<p className="mt-2 text-sm text-[var(--text-muted)]">{t("requestPending")}</p>
) : null}
</div> </div>
<div className="text-right"> <div className="text-right">
<p className="text-sm font-semibold text-[var(--brand-700)]">{statusLabel}</p> <p className="text-sm font-semibold text-[var(--brand-700)]">{statusLabel}</p>
<p className="mt-1 text-sm">{formatPrice(booking.price, locale)}</p> {booking.price ? <p className="mt-1 text-sm">{formatPrice(booking.price, locale)}</p> : null}
{booking.source === "inquiry" ? null : (
<p className="text-xs text-[var(--neutral-500)]">{paid ? t("paid") : t("unpaid")}</p> <p className="text-xs text-[var(--neutral-500)]">{paid ? t("paid") : t("unpaid")}</p>
)}
</div> </div>
</div> </div>
{status === "upcoming" ? ( {status === "upcoming" && booking.source !== "inquiry" ? (
<div className="mt-4 flex flex-wrap gap-2"> <div className="mt-4 flex flex-wrap gap-2">
{!paid ? ( {!paid ? (
<Button size="sm" onClick={pay} disabled={pending}> <Button size="sm" onClick={pay} disabled={pending}>
@@ -161,7 +166,7 @@ export function BookingActions({ booking }: { booking: Booking }) {
{reviewDone ? <p className="mt-3 text-sm text-[var(--success)]">{tr("thanks")}</p> : null} {reviewDone ? <p className="mt-3 text-sm text-[var(--success)]">{tr("thanks")}</p> : null}
{status === "upcoming" ? ( {status === "upcoming" && booking.source !== "inquiry" ? (
<p className="mt-3 text-xs text-[var(--neutral-500)]"> <p className="mt-3 text-xs text-[var(--neutral-500)]">
<Link href="/booking" className="underline"> <Link href="/booking" className="underline">
{t("reschedule")} {t("reschedule")}
+186 -15
View File
@@ -1,7 +1,6 @@
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { import {
demoBanners, demoBanners,
demoBookings,
demoBranches, demoBranches,
demoDoctors, demoDoctors,
demoGallery, demoGallery,
@@ -170,6 +169,83 @@ function asText(value: unknown): string {
return ""; return "";
} }
function isPatientToken(token: string | null): token is string {
return Boolean(token) && token !== "demo-token";
}
function relRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" ? (value as Record<string, unknown>) : null;
}
function mapInquiryBooking(raw: Record<string, unknown>): Booking {
const service = relRecord(raw.service);
const doctor = relRecord(raw.doctor);
const branch = relRecord(raw.branch);
return {
id: `inquiry-${raw.id}`,
serviceTitle: stripPriceListIndex(asText(service?.title)) || (doctor ? "Приём врача" : "Заявка на запись"),
doctorName: asText(doctor?.fullName) || "—",
branchName: asText(branch?.name),
datetime: asText(raw.createdAt),
status: asText(raw.status) === "done" ? "completed" : "upcoming",
price: Number(service?.price) || 0,
paid: false,
source: "inquiry",
};
}
function mapCmsBooking(raw: Record<string, unknown>): Booking {
const service = relRecord(raw.service);
const doctor = relRecord(raw.doctor);
const branch = relRecord(raw.branch);
const statusRaw = asText(raw.status);
const status: Booking["status"] =
statusRaw === "completed" ? "completed" : statusRaw === "cancelled" || statusRaw === "expired" ? "cancelled" : "upcoming";
return {
id: String(raw.id ?? ""),
serviceTitle: stripPriceListIndex(asText(service?.title)) || "Услуга",
doctorName: asText(doctor?.fullName) || "—",
branchName: asText(branch?.name),
datetime: asText(raw.startsAt) || asText(raw.createdAt),
status,
price: Number(raw.price) || Number(service?.price) || 0,
paid: asText(raw.paymentStatus) === "paid",
source: "booking",
};
}
function mapFavorite(raw: Record<string, unknown>): Service | Doctor | null {
const doctor = relRecord(raw.doctor);
if (doctor) {
return {
id: String(doctor.id ?? raw.id ?? ""),
slug: asText(doctor.slug),
name: asText(doctor.fullName) || asText(doctor.name),
specialty: asText(doctor.specialty) || asText(doctor.position),
experienceYears: Number(doctor.experienceYears) || 0,
photo: mediaSrc(doctor.photo),
bio: asText(doctor.bio),
rating: Number(doctor.rating) || 0,
reviewsCount: Number(doctor.reviewsCount) || 0,
services: [],
branchIds: [],
};
}
const service = relRecord(raw.service);
if (service) {
return {
id: String(service.id ?? raw.id ?? ""),
slug: asText(service.slug),
title: asText(service.title),
category: asText(service.groupTitle) || asText(service.category),
price: Number(service.price) || 0,
durationMin: Number(service.durationMin) || 0,
description: asText(service.description),
};
}
return null;
}
function mapIds(value: unknown): string[] { function mapIds(value: unknown): string[] {
if (!Array.isArray(value)) return []; if (!Array.isArray(value)) return [];
return value.map((item) => { return value.map((item) => {
@@ -664,10 +740,24 @@ export async function getNewsBySlug(slug: string, locale = "ru"): Promise<NewsIt
} }
export async function getGallery(locale = "ru"): Promise<GalleryItem[]> { export async function getGallery(locale = "ru"): Promise<GalleryItem[]> {
const query = withLocale(new URLSearchParams({ limit: "100", depth: "1" }), locale); const query = withLocale(new URLSearchParams({ limit: "100", depth: "1", sort: "sortOrder" }), locale);
return cmsFetch<GalleryItem[]>(`/api/gallery?${query}`, { fallback: demoGallery }).then((data) => try {
unwrapList(data as PayloadList<GalleryItem> | GalleryItem[]), const data = await cmsFetch<PayloadList<Record<string, unknown>>>(`/api/gallery?${query}`, {
); cache: "no-store",
});
const items = unwrapList(data)
.map((raw) => ({
id: String(raw.id ?? ""),
title: asText(raw.title),
image: mediaSrc(raw.image),
category: asText(raw.category),
}))
.filter((item) => item.id && item.image);
if (items.length) return items;
} catch {
// fall through to demo
}
return demoGallery;
} }
export async function getReviews( export async function getReviews(
@@ -788,7 +878,7 @@ export async function createInquiry(payload: {
method: "POST", method: "POST",
body: JSON.stringify(data), body: JSON.stringify(data),
cache: "no-store", cache: "no-store",
timeoutMs: 20000, timeoutMs: 30000,
}); });
} }
@@ -852,11 +942,16 @@ export async function createReview(payload: {
export async function getFavorites(): Promise<(Service | Doctor)[]> { export async function getFavorites(): Promise<(Service | Doctor)[]> {
const token = await getAuthToken(); const token = await getAuthToken();
return cmsFetch(`/api/favorites`, { if (!isPatientToken(token)) return [];
return cmsFetch(`/api/favorites?depth=1&limit=50`, {
token, token,
cache: "no-store", cache: "no-store",
fallback: [], fallback: { docs: [] },
}).then((data) => unwrapList(data as PayloadList<Service | Doctor> | (Service | Doctor)[])); }).then((data) =>
unwrapList(data as PayloadList<Record<string, unknown>> | Record<string, unknown>[])
.map(mapFavorite)
.filter((item): item is Service | Doctor => Boolean(item)),
);
} }
export async function addFavorite(payload: { export async function addFavorite(payload: {
@@ -885,11 +980,78 @@ export async function removeFavorite(id: string): Promise<void> {
export async function getMyBookings(): Promise<Booking[]> { export async function getMyBookings(): Promise<Booking[]> {
const token = await getAuthToken(); const token = await getAuthToken();
return cmsFetch<Booking[]>(`/api/bookings?mine=true`, { if (!isPatientToken(token)) return [];
const [inquiries, bookings] = await Promise.all([
cmsFetch<PayloadList<Record<string, unknown>>>(`/api/inquiries?limit=50&depth=2&sort=-createdAt`, {
token, token,
cache: "no-store", cache: "no-store",
fallback: demoBookings, fallback: { docs: [] },
}).then((data) => unwrapList(data as PayloadList<Booking> | Booking[])); }).catch(() => ({ docs: [] })),
cmsFetch<PayloadList<Record<string, unknown>>>(`/api/bookings?limit=50&depth=2&sort=-startsAt`, {
token,
cache: "no-store",
fallback: { docs: [] },
}).catch(() => ({ docs: [] })),
]);
return [
...unwrapList(inquiries).map(mapInquiryBooking),
...unwrapList(bookings).map(mapCmsBooking),
].sort((a, b) => Date.parse(b.datetime) - Date.parse(a.datetime));
}
export type CabinetDocument = {
id: string;
title: string;
href: string;
issuedAt?: string;
};
export async function getMyDocuments(): Promise<CabinetDocument[]> {
const token = await getAuthToken();
if (!isPatientToken(token)) return [];
const data = await cmsFetch<PayloadList<Record<string, unknown>>>(
`/api/documents?depth=1&limit=50&sort=-issuedAt&where[isPublic][equals]=false`,
{ token, cache: "no-store", fallback: { docs: [] } },
).catch(() => ({ docs: [] as Record<string, unknown>[] }));
return unwrapList(data)
.map((raw) => {
const file = raw.file && typeof raw.file === "object" ? (raw.file as Record<string, unknown>) : null;
const href = mediaSrc(file);
const title = asText(raw.title);
if (!title || !href) return null;
return { id: String(raw.id ?? ""), title, href, issuedAt: asText(raw.issuedAt) || undefined };
})
.filter((item): item is CabinetDocument => Boolean(item));
}
export type CabinetInvoice = {
id: string;
number: string;
title: string;
amount: number;
paid: boolean;
datetime: string;
};
export async function getMyInvoices(): Promise<CabinetInvoice[]> {
const token = await getAuthToken();
if (!isPatientToken(token)) return [];
const data = await cmsFetch<PayloadList<Record<string, unknown>>>(
`/api/invoices?depth=2&limit=50&sort=-createdAt`,
{ token, cache: "no-store", fallback: { docs: [] } },
).catch(() => ({ docs: [] as Record<string, unknown>[] }));
return unwrapList(data).map((raw) => {
const booking = relRecord(raw.booking);
const service = booking ? relRecord(booking.service) : null;
return {
id: String(raw.id ?? ""),
number: asText(raw.number) || String(raw.id ?? ""),
title: asText(service?.title) || asText(raw.number) || "Счёт",
amount: Number(raw.amount) || 0,
paid: asText(raw.status) === "paid",
datetime: asText(raw.paidAt) || asText(raw.createdAt),
};
});
} }
export async function me(): Promise<{ export async function me(): Promise<{
@@ -899,13 +1061,22 @@ export async function me(): Promise<{
phone?: string; phone?: string;
} | null> { } | null> {
const token = await getAuthToken(); const token = await getAuthToken();
if (!token) return null; if (!isPatientToken(token)) return null;
try { try {
return await cmsFetch(`/api/users/me`, { const data = await cmsFetch<Record<string, unknown>>(`/api/users/me`, {
token, token,
cache: "no-store", cache: "no-store",
fallback: { id: "demo", email: "patient@example.com", name: "Пациент", phone: "+7" },
}); });
const user = relRecord(data.user) || data;
const email = asText(user.email);
const id = user.id != null ? String(user.id) : "";
if (!email || !id || id === "demo") return null;
return {
id,
email,
name: asText(user.fullName) || asText(user.name) || undefined,
phone: asText(user.phone) || undefined,
};
} catch { } catch {
return null; return null;
} }
+41
View File
@@ -0,0 +1,41 @@
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[A-Za-z0-9-]{2,}$/
export function isValidFullName(value: string) {
const name = value.trim().replace(/\s+/g, " ")
if (name.length < 3) return false
const parts = name.split(" ").filter(Boolean)
if (parts.length < 2) return false
return /^[\p{L}][\p{L}\s'-]*$/u.test(name)
}
export function isValidEmailAddress(value: string) {
const email = value.trim()
return EMAIL_RE.test(email)
}
export function ruPhoneDigits(value: string) {
let digits = value.replace(/\D/g, "")
if (digits.startsWith("8")) digits = `7${digits.slice(1)}`
if (!digits.startsWith("7")) digits = `7${digits}`
return digits.slice(0, 11)
}
export function formatRuPhone(value: string) {
const digits = ruPhoneDigits(value)
const local = digits.slice(1)
let formatted = "+7"
if (local.length > 0) formatted += ` ${local.slice(0, 3)}`
if (local.length > 3) formatted += ` ${local.slice(3, 6)}`
if (local.length > 6) formatted += ` ${local.slice(6, 8)}`
if (local.length > 8) formatted += ` ${local.slice(8, 10)}`
return formatted
}
export function normalizeRuPhone(value: string) {
const digits = ruPhoneDigits(value)
return digits.length === 11 ? `+${digits}` : ""
}
export function isValidRuPhone(value: string) {
return /^\+7\d{10}$/.test(normalizeRuPhone(value))
}
+1
View File
@@ -131,6 +131,7 @@ export type Booking = {
status: "upcoming" | "completed" | "cancelled"; status: "upcoming" | "completed" | "cancelled";
price: number; price: number;
paid: boolean; paid: boolean;
source?: "inquiry" | "booking";
}; };
export const demoServices: Service[] = [ export const demoServices: Service[] = [
+11 -1
View File
@@ -140,7 +140,15 @@
"email": "E-mail", "email": "E-mail",
"phone": "Тэлефон", "phone": "Тэлефон",
"summary": "Зводка заяўкі", "summary": "Зводка заяўкі",
"searchService": "Знайдзіце паслугу па назве або кодзе" "searchService": "Знайдзіце паслугу па назве або кодзе",
"namePlaceholder": "Іваноў Іван Іванавіч",
"emailPlaceholder": "ivan.ivanov@mail.ru",
"phonePlaceholder": "+7 900 123 45 67",
"nameRequired": "Укажыце ПІБ",
"nameInvalid": "Укажыце прозвішча і імя літарамі",
"emailRequired": "Укажыце e-mail",
"emailInvalid": "Укажыце e-mail у фармаце name@domain.ru",
"phoneInvalid": "Укажыце тэлефон у фармаце +7 і 10 лічбаў"
}, },
"branches": { "branches": {
"title": "Філіялы", "title": "Філіялы",
@@ -228,6 +236,8 @@
"emptyFavorites": "Абранае пустое", "emptyFavorites": "Абранае пустое",
"emptyDocuments": "Дакументаў пакуль няма", "emptyDocuments": "Дакументаў пакуль няма",
"emptyInvoices": "Рахункаў пакуль няма", "emptyInvoices": "Рахункаў пакуль няма",
"loginHint": "Увайдзіце ў асабісты кабінет, каб убачыць свае запісы, дакументы і рахункі.",
"requestPending": "Заяўка прынята. Мы звяжамся з вамі, каб удакладніць дату і час.",
"save": "Захаваць", "save": "Захаваць",
"saved": "Захавана" "saved": "Захавана"
}, },
+11 -1
View File
@@ -140,7 +140,15 @@
"email": "E-mail", "email": "E-mail",
"phone": "Phone", "phone": "Phone",
"summary": "Request summary", "summary": "Request summary",
"searchService": "Find a service by name or code" "searchService": "Find a service by name or code",
"namePlaceholder": "Ivanov Ivan Ivanovich",
"emailPlaceholder": "ivan.ivanov@mail.ru",
"phonePlaceholder": "+7 900 123 45 67",
"nameRequired": "Enter your full name",
"nameInvalid": "Enter last name and first name in letters",
"emailRequired": "Enter your e-mail",
"emailInvalid": "Enter an e-mail like name@domain.ru",
"phoneInvalid": "Enter a phone number as +7 and 10 digits"
}, },
"branches": { "branches": {
"title": "Branches", "title": "Branches",
@@ -228,6 +236,8 @@
"emptyFavorites": "No favorites yet", "emptyFavorites": "No favorites yet",
"emptyDocuments": "No documents yet", "emptyDocuments": "No documents yet",
"emptyInvoices": "No invoices yet", "emptyInvoices": "No invoices yet",
"loginHint": "Sign in to see your bookings, documents and invoices. Each patient has a personal account.",
"requestPending": "Your request was received. We will contact you to confirm the date and time.",
"save": "Save", "save": "Save",
"saved": "Saved" "saved": "Saved"
}, },
+11 -1
View File
@@ -140,7 +140,15 @@
"email": "E-mail", "email": "E-mail",
"phone": "Телефон", "phone": "Телефон",
"summary": "Өтүнмөнүн жыйынтыгы", "summary": "Өтүнмөнүн жыйынтыгы",
"searchService": "Кызматты аталышы же коду боюнча табыңыз" "searchService": "Кызматты аталышы же коду боюнча табыңыз",
"namePlaceholder": "Иванов Иван Иванович",
"emailPlaceholder": "ivan.ivanov@mail.ru",
"phonePlaceholder": "+7 900 123 45 67",
"nameRequired": "Аты-жөнүңүздү жазыңыз",
"nameInvalid": "Фамилия жана атын тамгалар менен жазыңыз",
"emailRequired": "E-mail жазыңыз",
"emailInvalid": "E-mailди name@domain.ru форматында жазыңыз",
"phoneInvalid": "Телефонду +7 жана 10 сан форматында жазыңыз"
}, },
"branches": { "branches": {
"title": "Филиалдар", "title": "Филиалдар",
@@ -228,6 +236,8 @@
"emptyFavorites": "Тандалмалар бош", "emptyFavorites": "Тандалмалар бош",
"emptyDocuments": "Азырынча документ жок", "emptyDocuments": "Азырынча документ жок",
"emptyInvoices": "Азырынча эсеп жок", "emptyInvoices": "Азырынча эсеп жок",
"loginHint": "Жазууларыңызды, документтерди жана эсептерди көрүү үчүн жеке кабинетке кириңиз.",
"requestPending": "Өтүнмө кабыл алынды. Биз дата менен убакытты тактоо үчүн байланышабыз.",
"save": "Сактоо", "save": "Сактоо",
"saved": "Сакталды" "saved": "Сакталды"
}, },
+11 -1
View File
@@ -140,7 +140,15 @@
"patientName": "ФИО", "patientName": "ФИО",
"email": "E-mail", "email": "E-mail",
"phone": "Телефон", "phone": "Телефон",
"summary": "Сводка заявки" "summary": "Сводка заявки",
"namePlaceholder": "Иванов Иван Иванович",
"emailPlaceholder": "ivan.ivanov@mail.ru",
"phonePlaceholder": "+7 900 123 45 67",
"nameRequired": "Укажите ФИО",
"nameInvalid": "Укажите фамилию и имя буквами",
"emailRequired": "Укажите e-mail",
"emailInvalid": "Укажите e-mail в формате name@domain.ru",
"phoneInvalid": "Укажите телефон в формате +7 и 10 цифр"
}, },
"branches": { "branches": {
"title": "Филиалы", "title": "Филиалы",
@@ -228,6 +236,8 @@
"emptyFavorites": "Избранное пусто", "emptyFavorites": "Избранное пусто",
"emptyDocuments": "Документов пока нет", "emptyDocuments": "Документов пока нет",
"emptyInvoices": "Счетов пока нет", "emptyInvoices": "Счетов пока нет",
"loginHint": "Войдите в личный кабинет, чтобы увидеть свои записи, документы и счета. У каждого пациента свой кабинет.",
"requestPending": "Заявка принята. Мы свяжемся с вами, чтобы уточнить дату и время визита.",
"save": "Сохранить", "save": "Сохранить",
"saved": "Сохранено" "saved": "Сохранено"
}, },
+11 -1
View File
@@ -140,7 +140,15 @@
"email": "E-mail", "email": "E-mail",
"phone": "Телефон", "phone": "Телефон",
"summary": "Хулосаи дархост", "summary": "Хулосаи дархост",
"searchService": "Хизматро бо ном ё рамз ҷустуҷӯ кунед" "searchService": "Хизматро бо ном ё рамз ҷустуҷӯ кунед",
"namePlaceholder": "Иванов Иван Иванович",
"emailPlaceholder": "ivan.ivanov@mail.ru",
"phonePlaceholder": "+7 900 123 45 67",
"nameRequired": "Ному насабро нависед",
"nameInvalid": "Насаб ва номро бо ҳарфҳо нависед",
"emailRequired": "E-mail-ро нависед",
"emailInvalid": "E-mail-ро дар шакли name@domain.ru нависед",
"phoneInvalid": "Телефонро дар шакли +7 ва 10 рақам нависед"
}, },
"branches": { "branches": {
"title": "Филиалҳо", "title": "Филиалҳо",
@@ -228,6 +236,8 @@
"emptyFavorites": "Дӯстдоштаҳо холӣ", "emptyFavorites": "Дӯстдоштаҳо холӣ",
"emptyDocuments": "Ҳанӯз ҳуҷҷат нест", "emptyDocuments": "Ҳанӯз ҳуҷҷат нест",
"emptyInvoices": "Ҳанӯз ҳисоб нест", "emptyInvoices": "Ҳанӯз ҳисоб нест",
"loginHint": "Ба кабинети шахсӣ ворид шавед, то дархостҳо, ҳуҷҷатҳо ва ҳисобҳои худро бубинед.",
"requestPending": "Дархост қабул шуд. Мо барои дақиқ кардани сана ва вақт бо шумо тамос мегирем.",
"save": "Нигоҳ доштан", "save": "Нигоҳ доштан",
"saved": "Нигоҳ дошта шуд" "saved": "Нигоҳ дошта шуд"
}, },
+11 -1
View File
@@ -140,7 +140,15 @@
"email": "E-mail", "email": "E-mail",
"phone": "电话", "phone": "电话",
"summary": "申请摘要", "summary": "申请摘要",
"searchService": "按名称或编码查找服务" "searchService": "按名称或编码查找服务",
"namePlaceholder": "Ivanov Ivan Ivanovich",
"emailPlaceholder": "ivan.ivanov@mail.ru",
"phonePlaceholder": "+7 900 123 45 67",
"nameRequired": "请填写姓名",
"nameInvalid": "请用字母填写姓和名",
"emailRequired": "请填写电子邮箱",
"emailInvalid": "请填写 name@domain.ru 格式的邮箱",
"phoneInvalid": "请填写 +7 加 10 位数字的电话"
}, },
"branches": { "branches": {
"title": "分院", "title": "分院",
@@ -228,6 +236,8 @@
"emptyFavorites": "暂无收藏", "emptyFavorites": "暂无收藏",
"emptyDocuments": "暂无文件", "emptyDocuments": "暂无文件",
"emptyInvoices": "暂无账单", "emptyInvoices": "暂无账单",
"loginHint": "请登录个人账户查看您的预约、文件和账单。每位患者都有独立账户。",
"requestPending": "申请已受理。我们会联系您确认就诊日期和时间。",
"save": "保存", "save": "保存",
"saved": "已保存" "saved": "已保存"
}, },
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "moniki-platnye-uslugi", "name": "moniki-platnye-uslugi",
"version": "1.0.8", "version": "1.0.10",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "pnpm -r --parallel dev", "dev": "pnpm -r --parallel dev",