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:
@@ -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 })
|
||||
}
|
||||
|
||||
|
||||
@@ -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: 'Филиал' },
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@moniki/web",
|
||||
"version": "1.0.8",
|
||||
"version": "1.0.9",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3000",
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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": "Філіялы",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Филиалдар",
|
||||
|
||||
@@ -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": "Филиалы",
|
||||
|
||||
@@ -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": "Филиалҳо",
|
||||
|
||||
@@ -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": "分院",
|
||||
|
||||
Reference in New Issue
Block a user