Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ab7c29576 | ||
|
|
a6a748281f | ||
|
|
4dbfe782f5 |
@@ -2,6 +2,28 @@
|
|||||||
|
|
||||||
Формат основан на [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
|
||||||
|
|
||||||
|
### CMS
|
||||||
|
|
||||||
|
- Вход в админку по HTTP снова сохраняет сессию: Secure-cookie больше не ставится без HTTPS
|
||||||
|
|
||||||
## [1.0.6] — 2026-09-17
|
## [1.0.6] — 2026-09-17
|
||||||
|
|
||||||
### Сайт
|
### Сайт
|
||||||
@@ -64,6 +86,9 @@
|
|||||||
- 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.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.0.4]: https://git.myunion.pro/Alex/Moniki_uslugi/src/tag/v1.0.4
|
[1.0.4]: https://git.myunion.pro/Alex/Moniki_uslugi/src/tag/v1.0.4
|
||||||
|
|||||||
@@ -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.6** (см. [CHANGELOG.md](CHANGELOG.md)).
|
Текущая версия: **v1.0.9** (см. [CHANGELOG.md](CHANGELOG.md)).
|
||||||
|
|
||||||
## Что внутри
|
## Что внутри
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@moniki/cms",
|
"name": "@moniki/cms",
|
||||||
"version": "1.0.6",
|
"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 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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: 'Филиал' },
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export const Users: CollectionConfig = {
|
|||||||
tokenExpiration: 60 * 60 * 24 * 7,
|
tokenExpiration: 60 * 60 * 24 * 7,
|
||||||
cookies: {
|
cookies: {
|
||||||
sameSite: 'Lax',
|
sameSite: 'Lax',
|
||||||
secure: process.env.NODE_ENV === 'production',
|
secure: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
admin: {
|
admin: {
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@moniki/web",
|
"name": "@moniki/web",
|
||||||
"version": "1.0.6",
|
"version": "1.0.9",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -p 3000",
|
"dev": "next dev -p 3000",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
"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": "Філіялы",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "Филиалдар",
|
||||||
|
|||||||
@@ -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": "Филиалы",
|
||||||
|
|||||||
@@ -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": "Филиалҳо",
|
||||||
|
|||||||
@@ -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
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "moniki-platnye-uslugi",
|
"name": "moniki-platnye-uslugi",
|
||||||
"version": "1.0.6",
|
"version": "1.0.9",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "pnpm -r --parallel dev",
|
"dev": "pnpm -r --parallel dev",
|
||||||
|
|||||||
Reference in New Issue
Block a user