diff --git a/CHANGELOG.md b/CHANGELOG.md index 90d68e3..9f1c57a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ Формат основан на [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 @@ -80,6 +86,7 @@ - Docker Compose для локальной БД - Секреты и логины в 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.6]: https://git.myunion.pro/Alex/Moniki_uslugi/src/tag/v1.0.6 diff --git a/README.md b/README.md index cf0a160..ca8d796 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Репозиторий: [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.9** (см. [CHANGELOG.md](CHANGELOG.md)). ## Что внутри diff --git a/apps/cms/package.json b/apps/cms/package.json index 238dd77..a116470 100644 --- a/apps/cms/package.json +++ b/apps/cms/package.json @@ -1,6 +1,6 @@ { "name": "@moniki/cms", - "version": "1.0.8", + "version": "1.0.9", "private": true, "type": "module", "scripts": { diff --git a/apps/cms/src/app/(payload)/api/custom/inquiries/route.ts b/apps/cms/src/app/(payload)/api/custom/inquiries/route.ts index f36e1cc..b7c0c70 100644 --- a/apps/cms/src/app/(payload)/api/custom/inquiries/route.ts +++ b/apps/cms/src/app/(payload)/api/custom/inquiries/route.ts @@ -1,6 +1,7 @@ import configPromise from '@payload-config' import { getPayload } from 'payload' import { NextResponse } from 'next/server' +import { isValidEmailAddress, isValidFullName, isValidRuPhone, normalizeRuPhone } from '@/lib/contact-fields' function asText(value: unknown) { return typeof value === "string" ? value.trim() : "" @@ -17,14 +18,14 @@ function asId(value: unknown) { export async function POST(req: Request) { const body = (await req.json().catch(() => ({}))) as Record - const name = asText(body.name) + const name = asText(body.name).replace(/\s+/g, " ") const email = asText(body.email) - const phone = asText(body.phone) + const phone = normalizeRuPhone(asText(body.phone)) const branch = asId(body.branch) const service = asId(body.service) 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 }) } diff --git a/apps/cms/src/collections/Inquiries.ts b/apps/cms/src/collections/Inquiries.ts index c35df0f..d99c660 100644 --- a/apps/cms/src/collections/Inquiries.ts +++ b/apps/cms/src/collections/Inquiries.ts @@ -1,5 +1,6 @@ import type { CollectionConfig } from 'payload' import { ADMIN_GROUP } from '../admin/groups' +import { isValidEmailAddress, isValidFullName, isValidRuPhone, normalizeRuPhone } from '../lib/contact-fields' export const Inquiries: CollectionConfig = { slug: 'inquiries', @@ -16,10 +17,45 @@ export const Inquiries: CollectionConfig = { update: ({ 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: [ - { name: 'name', type: 'text', required: true, label: 'ФИО' }, - { name: 'email', type: 'email', required: true, label: 'E-mail' }, - { name: 'phone', type: 'text', required: true, label: 'Телефон' }, + { + name: 'name', + 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: 'doctor', type: 'relationship', relationTo: 'doctors', label: 'Врач' }, { name: 'branch', type: 'relationship', relationTo: 'branches', required: true, label: 'Филиал' }, diff --git a/apps/cms/src/lib/contact-fields.ts b/apps/cms/src/lib/contact-fields.ts new file mode 100644 index 0000000..5ea6423 --- /dev/null +++ b/apps/cms/src/lib/contact-fields.ts @@ -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)) +} diff --git a/apps/web/package.json b/apps/web/package.json index d2e786d..db516e2 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@moniki/web", - "version": "1.0.8", + "version": "1.0.9", "private": true, "scripts": { "dev": "next dev -p 3000", diff --git a/apps/web/src/app/api/inquiries/route.ts b/apps/web/src/app/api/inquiries/route.ts index caa236c..a66431d 100644 --- a/apps/web/src/app/api/inquiries/route.ts +++ b/apps/web/src/app/api/inquiries/route.ts @@ -1,4 +1,10 @@ import { createInquiry } from "@/lib/api"; +import { + isValidEmailAddress, + isValidFullName, + isValidRuPhone, + normalizeRuPhone, +} from "@/lib/contact-fields"; import { NextResponse } from "next/server"; function asText(value: unknown) { @@ -7,19 +13,16 @@ function asText(value: unknown) { export async function POST(request: Request) { const body = (await request.json()) as Record; - const name = asText(body.name ?? body.patientName); + const name = asText(body.name ?? body.patientName).replace(/\s+/g, " "); const email = asText(body.email); - const phone = asText(body.phone); + const phone = normalizeRuPhone(asText(body.phone)); const branchId = asText(body.branchId); const serviceId = asText(body.serviceId) || 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 }); } - if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { - return NextResponse.json({ error: "email" }, { status: 400 }); - } try { const inquiry = await createInquiry({ diff --git a/apps/web/src/components/booking/BookingWizard.tsx b/apps/web/src/components/booking/BookingWizard.tsx index 05243b9..b6a22e6 100644 --- a/apps/web/src/components/booking/BookingWizard.tsx +++ b/apps/web/src/components/booking/BookingWizard.tsx @@ -5,6 +5,13 @@ import { Card } from "@/components/ui/Card"; import { Input } from "@/components/ui/Input"; import { Select } from "@/components/ui/Select"; 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 { cn, formatPrice, stripPriceListIndex } from "@/lib/utils"; import { useTranslations, useLocale } from "next-intl"; @@ -42,6 +49,7 @@ export function BookingWizard({ const [name, setName] = useState(""); const [email, setEmail] = useState(""); const [phone, setPhone] = useState(""); + const [fieldErrors, setFieldErrors] = useState<{ name?: string; email?: string; phone?: string }>({}); const [done, setDone] = useState(false); const [error, setError] = useState(""); const [pending, startTransition] = useTransition(); @@ -77,10 +85,20 @@ export function BookingWizard({ return doctors.filter((d) => d.services.includes(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() { if (step === 0) return Boolean(serviceId || doctorId); if (step === 1) return Boolean(branchId); - return Boolean(name.trim() && email.trim() && phone.trim()); + return true; } async function onNext() { @@ -89,6 +107,9 @@ export function BookingWizard({ setStep((s) => s + 1); return; } + const nextErrors = contactErrors(); + setFieldErrors(nextErrors); + if (Object.keys(nextErrors).length) return; startTransition(async () => { try { const res = await fetch("/api/inquiries", { @@ -98,9 +119,9 @@ export function BookingWizard({ serviceId: serviceId || undefined, doctorId: doctorId || undefined, branchId, - name: name.trim(), + name: name.trim().replace(/\s+/g, " "), email: email.trim(), - phone: phone.trim(), + phone: normalizeRuPhone(phone), }), }); if (!res.ok) throw new Error("fail"); @@ -220,15 +241,53 @@ export function BookingWizard({ {branch ?
  • {branch.name}
  • : null} - setName(e.target.value)} required /> { + setName(e.target.value); + setFieldErrors((prev) => ({ ...prev, name: undefined })); + }} + onBlur={() => setFieldErrors((prev) => ({ ...prev, name: contactErrors().name }))} + required + /> + 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 + /> + { + 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 /> - setPhone(e.target.value)} required /> ) : null} diff --git a/apps/web/src/lib/contact-fields.ts b/apps/web/src/lib/contact-fields.ts new file mode 100644 index 0000000..480424a --- /dev/null +++ b/apps/web/src/lib/contact-fields.ts @@ -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)) +} diff --git a/apps/web/src/messages/be.json b/apps/web/src/messages/be.json index f5d9703..9167ff4 100644 --- a/apps/web/src/messages/be.json +++ b/apps/web/src/messages/be.json @@ -140,7 +140,15 @@ "email": "E-mail", "phone": "Тэлефон", "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": { "title": "Філіялы", diff --git a/apps/web/src/messages/en.json b/apps/web/src/messages/en.json index 54cf49a..075b6da 100644 --- a/apps/web/src/messages/en.json +++ b/apps/web/src/messages/en.json @@ -140,7 +140,15 @@ "email": "E-mail", "phone": "Phone", "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": { "title": "Branches", diff --git a/apps/web/src/messages/ky.json b/apps/web/src/messages/ky.json index 8487527..2f8526c 100644 --- a/apps/web/src/messages/ky.json +++ b/apps/web/src/messages/ky.json @@ -140,7 +140,15 @@ "email": "E-mail", "phone": "Телефон", "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": { "title": "Филиалдар", diff --git a/apps/web/src/messages/ru.json b/apps/web/src/messages/ru.json index 3cb0c22..16c0aab 100644 --- a/apps/web/src/messages/ru.json +++ b/apps/web/src/messages/ru.json @@ -140,7 +140,15 @@ "patientName": "ФИО", "email": "E-mail", "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": { "title": "Филиалы", diff --git a/apps/web/src/messages/tg.json b/apps/web/src/messages/tg.json index 4efe0c1..fb1f4de 100644 --- a/apps/web/src/messages/tg.json +++ b/apps/web/src/messages/tg.json @@ -140,7 +140,15 @@ "email": "E-mail", "phone": "Телефон", "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": { "title": "Филиалҳо", diff --git a/apps/web/src/messages/zh.json b/apps/web/src/messages/zh.json index 4282d89..5d0f668 100644 --- a/apps/web/src/messages/zh.json +++ b/apps/web/src/messages/zh.json @@ -140,7 +140,15 @@ "email": "E-mail", "phone": "电话", "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": { "title": "分院", diff --git a/package.json b/package.json index f35ef72..3d5b952 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "moniki-platnye-uslugi", - "version": "1.0.8", + "version": "1.0.9", "private": true, "scripts": { "dev": "pnpm -r --parallel dev",