2 Commits
21 changed files with 264 additions and 33 deletions
+18
View File
@@ -2,6 +2,22 @@
Формат основан на [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.9] — 2026-09-17
### Сайт
- В заявке на запись: обязательные ФИО и e-mail, маска телефона РФ `+7` только из цифр, проверка e-mail (`@` и домен), серые примеры в полях
## [1.0.8] — 2026-09-17
### CMS
- Исправлена сессия входа в админку по HTTP: cookie больше не отбрасывается из‑за Origin/CSRF
### Сайт
- Cookie входа в личный кабинет сохраняется на HTTP без флага Secure
## [1.0.7] — 2026-09-17 ## [1.0.7] — 2026-09-17
### CMS ### CMS
@@ -70,6 +86,8 @@
- Docker Compose для локальной БД - Docker Compose для локальной БД
- Секреты и логины в git не входят - Секреты и логины в git не входят
[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.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.0.5]: https://git.myunion.pro/Alex/Moniki_uslugi/src/tag/v1.0.5 [1.0.5]: https://git.myunion.pro/Alex/Moniki_uslugi/src/tag/v1.0.5
+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.7** (см. [CHANGELOG.md](CHANGELOG.md)). Текущая версия: **v1.0.9** (см. [CHANGELOG.md](CHANGELOG.md)).
## Что внутри ## Что внутри
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@moniki/cms", "name": "@moniki/cms",
"version": "1.0.7", "version": "1.0.9",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -1,6 +1,7 @@
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'
function asText(value: unknown) { function asText(value: unknown) {
return typeof value === "string" ? value.trim() : "" return typeof value === "string" ? value.trim() : ""
@@ -17,14 +18,14 @@ 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 })
} }
+39 -3
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',
@@ -16,10 +17,45 @@ export const Inquiries: CollectionConfig = {
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: 'Филиал' },
+1 -2
View File
@@ -8,8 +8,7 @@ export const Users: CollectionConfig = {
tokenExpiration: 60 * 60 * 24 * 7, tokenExpiration: 60 * 60 * 24 * 7,
cookies: { cookies: {
sameSite: 'Lax', sameSite: 'Lax',
// HTTP admin (IP:3001) cannot store Secure cookies; enable only on HTTPS. secure: false,
secure: (process.env.NEXT_PUBLIC_SERVER_URL || '').startsWith('https://'),
}, },
}, },
admin: { admin: {
+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))
}
+2 -1
View File
@@ -114,7 +114,8 @@ export default buildConfig({
}), }),
sharp, sharp,
cors: allowedOrigins, cors: allowedOrigins,
csrf: allowedOrigins, // Empty list: do not drop the session cookie when Origin is missing or atypical (HTTP IP:port admin).
csrf: [],
localization: { localization: {
locales: [ locales: [
{ code: 'ru', label: 'Русский' }, { code: 'ru', label: 'Русский' },
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@moniki/web", "name": "@moniki/web",
"version": "1.0.7", "version": "1.0.9",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev -p 3000", "dev": "next dev -p 3000",
+1 -1
View File
@@ -19,7 +19,7 @@ export async function POST(request: Request) {
httpOnly: true, httpOnly: true,
sameSite: "lax", sameSite: "lax",
path: "/", path: "/",
secure: process.env.NODE_ENV === "production", secure: false,
maxAge: 60 * 60 * 24 * 7, maxAge: 60 * 60 * 24 * 7,
}); });
return response; return response;
+1 -1
View File
@@ -28,7 +28,7 @@ export async function POST(request: Request) {
httpOnly: true, httpOnly: true,
sameSite: "lax", sameSite: "lax",
path: "/", path: "/",
secure: process.env.NODE_ENV === "production", secure: false,
maxAge: 60 * 60 * 24 * 7, maxAge: 60 * 60 * 24 * 7,
}); });
return response; 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}
+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))
}
+9 -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": "Філіялы",
+9 -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",
+9 -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": "Филиалдар",
+9 -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": "Филиалы",
+9 -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": "Филиалҳо",
+9 -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": "分院",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "moniki-platnye-uslugi", "name": "moniki-platnye-uslugi",
"version": "1.0.7", "version": "1.0.9",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "pnpm -r --parallel dev", "dev": "pnpm -r --parallel dev",