Validate booking contacts: required FIO and email, RF +7 phone mask, and email domain.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alexey Novikov
2026-09-17 01:27:38 +03:00
co-authored by Cursor
parent a6a748281f
commit 2ab7c29576
17 changed files with 248 additions and 28 deletions
+7
View File
@@ -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
+1 -1
View File
@@ -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)).
## Что внутри
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@moniki/cms",
"version": "1.0.8",
"version": "1.0.9",
"private": true,
"type": "module",
"scripts": {
@@ -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<string, unknown>
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 })
}
+39 -3
View File
@@ -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: 'Филиал' },
+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))
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@moniki/web",
"version": "1.0.8",
"version": "1.0.9",
"private": true,
"scripts": {
"dev": "next dev -p 3000",
+9 -6
View File
@@ -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<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 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({
@@ -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 ? <li>{branch.name}</li> : null}
</ul>
</div>
<Input label={t("patientName")} value={name} onChange={(e) => setName(e.target.value)} required />
<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"
inputMode="email"
label={t("email")}
placeholder={t("emailPlaceholder")}
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
/>
<Input label={t("phone")} value={phone} onChange={(e) => setPhone(e.target.value)} required />
</>
) : 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",
"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": "Філіялы",
+9 -1
View File
@@ -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",
+9 -1
View File
@@ -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": "Филиалдар",
+9 -1
View File
@@ -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": "Филиалы",
+9 -1
View File
@@ -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": "Филиалҳо",
+9 -1
View File
@@ -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": "分院",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "moniki-platnye-uslugi",
"version": "1.0.8",
"version": "1.0.9",
"private": true,
"scripts": {
"dev": "pnpm -r --parallel dev",