From da57c554c12b51f60213a1e303b7037b93826f82 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Fri, 19 Jun 2026 15:28:40 +0330 Subject: [PATCH] feat: implement MobileInput component for standardized mobile number input and update related forms to use it --- .../admin/components/ui/InviteDoctorModal.tsx | 6 ++-- assets/admin/components/ui/MobileInput.tsx | 36 +++++++++++++++++++ .../components/ui/NotificationMobileCard.tsx | 7 ++-- assets/admin/lib/utils.ts | 34 ++++++++++++++++++ assets/admin/pages/ClinicFormPage.tsx | 6 ++-- assets/admin/pages/ClinicsPage.tsx | 7 ++-- assets/admin/pages/DoctorDetailPage.tsx | 7 ++-- assets/admin/pages/DoctorFormPage.tsx | 7 ++-- assets/admin/pages/LoginPage.tsx | 7 ++-- assets/admin/pages/MySecretariesPage.tsx | 27 +++----------- assets/admin/pages/RepresentationsPage.tsx | 6 ++-- 11 files changed, 108 insertions(+), 42 deletions(-) create mode 100644 assets/admin/components/ui/MobileInput.tsx diff --git a/assets/admin/components/ui/InviteDoctorModal.tsx b/assets/admin/components/ui/InviteDoctorModal.tsx index 6b92aa13..97e87feb 100644 --- a/assets/admin/components/ui/InviteDoctorModal.tsx +++ b/assets/admin/components/ui/InviteDoctorModal.tsx @@ -6,9 +6,11 @@ import { XMarkIcon } from '@heroicons/react/24/outline'; import { toast } from 'sonner'; import { api } from '../../lib/api'; import type { ApiResponse } from '../../lib/api'; +import MobileInput from './MobileInput'; +import { iranMobileSchema } from '../../lib/utils'; const inviteSchema = z.object({ - mobile: z.string().regex(/^09\d{9}$/, 'شماره موبایل ۱۱ رقمی با 09 شروع می‌شود'), + mobile: iranMobileSchema, name: z.string().optional(), specialty: z.string().optional(), }); @@ -47,7 +49,7 @@ export default function InviteDoctorModal({ clinicUuid, onClose, onInvited }: Pr
- + {errors.mobile &&
{errors.mobile.message}
}
diff --git a/assets/admin/components/ui/MobileInput.tsx b/assets/admin/components/ui/MobileInput.tsx new file mode 100644 index 00000000..39e3a48d --- /dev/null +++ b/assets/admin/components/ui/MobileInput.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { sanitizeMobileInput } from '../../lib/utils'; + +type Props = React.InputHTMLAttributes & { + hasError?: boolean; +}; + +/** + * فیلد شماره موبایل ایران: ارقام فارسی/عربی را به انگلیسی تبدیل می‌کند، فقط رقم می‌پذیرد و حداکثر ۱۱ رقم. + * سازگار با react-hook-form `register` (event-based onChange) و حالت controlled. + * قبل از فراخوانی onChange، مقدار DOM پاک‌سازی می‌شود تا value در state هم تمیز ذخیره شود. + */ +export default React.forwardRef(function MobileInput( + { hasError, className, placeholder, style, onChange, ...rest }, + ref, +) { + const handle = (e: React.ChangeEvent) => { + e.target.value = sanitizeMobileInput(e.target.value); + onChange?.(e); + }; + + return ( + + ); +}); diff --git a/assets/admin/components/ui/NotificationMobileCard.tsx b/assets/admin/components/ui/NotificationMobileCard.tsx index 1fb19ad7..b7e9e94a 100644 --- a/assets/admin/components/ui/NotificationMobileCard.tsx +++ b/assets/admin/components/ui/NotificationMobileCard.tsx @@ -3,6 +3,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { DevicePhoneMobileIcon, CheckCircleIcon, XMarkIcon } from '@heroicons/react/24/outline'; import { api } from '../../lib/api'; import type { ApiResponse } from '../../lib/api'; +import { sanitizeMobileInput } from '../../lib/utils'; interface NotificationMobileData { notification_mobile: string | null; @@ -126,9 +127,11 @@ export default function NotificationMobileCard({ target }: Props) { setNewMobile(e.target.value)} - placeholder="09XXXXXXXXX" dir="ltr" + inputMode="numeric" + maxLength={11} + onChange={e => setNewMobile(sanitizeMobileInput(e.target.value))} + placeholder="09XXXXXXXXX" style={{ width: '100%', maxWidth: 220, height: 38, padding: '0 12px', borderRadius: 'var(--r-sm)', border: '1px solid var(--border)', diff --git a/assets/admin/lib/utils.ts b/assets/admin/lib/utils.ts index e683b11a..ee23e552 100644 --- a/assets/admin/lib/utils.ts +++ b/assets/admin/lib/utils.ts @@ -1,3 +1,5 @@ +import { z } from 'zod'; + export function formatRial(amount: number): string { return new Intl.NumberFormat('fa-IR').format(amount) + ' تومان'; } @@ -46,3 +48,35 @@ export function maskMobile(mobile: string): string { export function cn(...classes: (string | undefined | null | false)[]): string { return classes.filter(Boolean).join(' '); } + +// تبدیل ارقام فارسی/عربی به انگلیسی + حذف هر کاراکتر غیرعددی. +export function toEnglishDigits(input: string): string { + if (!input) return ''; + return input + .replace(/[۰-۹]/g, (d) => String(d.charCodeAt(0) - 0x06f0)) + .replace(/[٠-٩]/g, (d) => String(d.charCodeAt(0) - 0x0660)); +} + +// فقط ارقام انگلیسی، حداکثر ۱۱ رقم (برای فیلد موبایل). +export function sanitizeMobileInput(input: string): string { + return toEnglishDigits(input).replace(/\D/g, '').slice(0, 11); +} + +// regex شماره موبایل ایران +export const IRAN_MOBILE_RE = /^09\d{9}$/; + +export function isValidIranMobile(input: string): boolean { + return IRAN_MOBILE_RE.test(toEnglishDigits(input || '')); +} + +// schema قابل‌استفاده‌ی مشترک برای شماره موبایل ایران (ارقام فارسی/عربی را هم می‌پذیرد و نرمال می‌کند). +export const iranMobileSchema = z + .string() + .transform((v) => toEnglishDigits(v).replace(/\D/g, '')) + .refine((v) => IRAN_MOBILE_RE.test(v), 'شماره موبایل باید ۱۱ رقم و با 09 شروع شود'); + +// نسخه‌ی اختیاری (خالی یا معتبر) برای فیلدهای غیرالزامی. +export const iranMobileOptionalSchema = z + .string() + .transform((v) => toEnglishDigits(v).replace(/\D/g, '')) + .refine((v) => v === '' || IRAN_MOBILE_RE.test(v), 'شماره موبایل نامعتبر است'); diff --git a/assets/admin/pages/ClinicFormPage.tsx b/assets/admin/pages/ClinicFormPage.tsx index a2db580d..baf41f64 100644 --- a/assets/admin/pages/ClinicFormPage.tsx +++ b/assets/admin/pages/ClinicFormPage.tsx @@ -8,9 +8,11 @@ import { ArrowRightIcon, BuildingOffice2Icon } from '@heroicons/react/24/outline import { toast } from 'sonner'; import { api } from '../lib/api'; import type { ApiResponse } from '../lib/api'; +import MobileInput from '../components/ui/MobileInput'; +import { iranMobileSchema } from '../lib/utils'; const schema = z.object({ - owner_mobile: z.string().min(10, 'شماره موبایل معتبر نیست').max(15), + owner_mobile: iranMobileSchema, name: z.string().min(2, 'نام کلینیک حداقل ۲ کاراکتر'), telephone: z.string().max(20).optional().or(z.literal('')), address: z.string().max(500).optional().or(z.literal('')), @@ -49,7 +51,7 @@ export default function ClinicFormPage() {
mutation.mutate(v))} style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
- + {errors.owner_mobile && {errors.owner_mobile.message}} اگر این موبایل در سیستم نباشد، کاربر جدید ساخته می‌شود
diff --git a/assets/admin/pages/ClinicsPage.tsx b/assets/admin/pages/ClinicsPage.tsx index 610f8511..0e787bb5 100644 --- a/assets/admin/pages/ClinicsPage.tsx +++ b/assets/admin/pages/ClinicsPage.tsx @@ -16,14 +16,15 @@ import { api } from '../lib/api'; import type { ApiResponse, PaginatedResponse } from '../lib/api'; import type { Clinic } from '../types'; import { useAuthStore } from '../stores/authStore'; -import { formatDate, formatNumber } from '../lib/utils'; +import MobileInput from '../components/ui/MobileInput'; +import { formatDate, formatNumber, iranMobileSchema } from '../lib/utils'; import Pagination from '../components/ui/Pagination'; import ConfirmDialog from '../components/ui/ConfirmDialog'; const HUES_LIST = [256, 205, 162, 295, 272]; const addSchema = z.object({ - owner_mobile: z.string().min(10, 'شماره موبایل معتبر نیست'), + owner_mobile: iranMobileSchema, name: z.string().min(2, 'نام الزامی است'), telephone: z.string().optional(), }); @@ -254,7 +255,7 @@ export default function ClinicsPage() { - + {addForm.formState.errors.owner_mobile && (
{addForm.formState.errors.owner_mobile.message}
)} diff --git a/assets/admin/pages/DoctorDetailPage.tsx b/assets/admin/pages/DoctorDetailPage.tsx index 54206b70..d1f60872 100644 --- a/assets/admin/pages/DoctorDetailPage.tsx +++ b/assets/admin/pages/DoctorDetailPage.tsx @@ -22,7 +22,8 @@ import 'leaflet/dist/leaflet.css'; import { api, ApiError } from '../lib/api'; import { useAuthStore } from '../stores/authStore'; import type { ApiResponse } from '../lib/api'; -import { formatNumber } from '../lib/utils'; +import { formatNumber, iranMobileOptionalSchema } from '../lib/utils'; +import MobileInput from '../components/ui/MobileInput'; import Modal from '../components/ui/Modal'; import ConfirmDialog from '../components/ui/ConfirmDialog'; import NotificationMobileCard from '../components/ui/NotificationMobileCard'; @@ -2105,7 +2106,7 @@ const editSchema = z.object({ gender: z.enum(['man', 'woman']).optional().or(z.literal('')), degree: z.string().optional().or(z.literal('')), medical_system_code: z.string().max(30).optional().or(z.literal('')), - mobile_number: z.string().max(15).optional().or(z.literal('')), + mobile_number: iranMobileOptionalSchema.optional(), info: z.string().max(2000).optional().or(z.literal('')), specialties: z.array(z.number()).optional(), services: z.array(z.number()).optional(), @@ -2715,7 +2716,7 @@ export default function DoctorDetailPage({ isOwnProfile = false }: { isOwnProfil - +
diff --git a/assets/admin/pages/DoctorFormPage.tsx b/assets/admin/pages/DoctorFormPage.tsx index ab605c7c..990110ec 100644 --- a/assets/admin/pages/DoctorFormPage.tsx +++ b/assets/admin/pages/DoctorFormPage.tsx @@ -13,6 +13,8 @@ import { toast } from 'sonner'; import { api } from '../lib/api'; import type { ApiResponse } from '../lib/api'; import SearchableSelect from '../components/ui/SearchableSelect'; +import MobileInput from '../components/ui/MobileInput'; +import { iranMobileSchema } from '../lib/utils'; import { useAuthStore } from '../stores/authStore'; // ── Types ──────────────────────────────────────────────────────────────────── @@ -22,7 +24,7 @@ interface SpecialtyOption { id: number; uuid: string; name: string; parent_id: n // ── Schema ─────────────────────────────────────────────────────────────────── const schema = z.object({ - mobile: z.string().min(10, 'شماره موبایل معتبر نیست').max(15), + mobile: iranMobileSchema, name: z.string().min(2, 'نام حداقل ۲ کاراکتر'), gender: z.enum(['man', 'woman']).optional().or(z.literal('')), degree: z.string().optional().or(z.literal('')), @@ -318,8 +320,7 @@ export default function DoctorFormPage() { } title="اطلاعات حساب" />
- + شماره موبایل setPwMobile(e.target.value)} /> + value={pwMobile} onChange={(e) => setPwMobile(sanitizeMobileInput(e.target.value))} />
@@ -257,7 +258,7 @@ export default function LoginPage() { setSmsMobile(e.target.value)} + value={smsMobile} onChange={(e) => setSmsMobile(sanitizeMobileInput(e.target.value))} onKeyDown={(e) => e.key === 'Enter' && handleSmsSend()} />