feat: implement MobileInput component for standardized mobile number input and update related forms to use it

This commit is contained in:
hamed
2026-06-19 15:28:40 +03:30
parent d910a30a7e
commit da57c554c1
11 changed files with 108 additions and 42 deletions
@@ -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
<div className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div>
<label style={{ fontSize: 13, fontWeight: 600, display: 'block', marginBottom: 6 }}>شماره موبایل پزشک *</label>
<input className="input" dir="ltr" placeholder="09xxxxxxxxx" {...register('mobile')} />
<MobileInput className="input" hasError={!!errors.mobile} {...register('mobile')} />
{errors.mobile && <div className="err-text">{errors.mobile.message}</div>}
</div>
<div>
@@ -0,0 +1,36 @@
import React from 'react';
import { sanitizeMobileInput } from '../../lib/utils';
type Props = React.InputHTMLAttributes<HTMLInputElement> & {
hasError?: boolean;
};
/**
* فیلد شماره موبایل ایران: ارقام فارسی/عربی را به انگلیسی تبدیل می‌کند، فقط رقم می‌پذیرد و حداکثر ۱۱ رقم.
* سازگار با react-hook-form `register` (event-based onChange) و حالت controlled.
* قبل از فراخوانی onChange، مقدار DOM پاک‌سازی می‌شود تا value در state هم تمیز ذخیره شود.
*/
export default React.forwardRef<HTMLInputElement, Props>(function MobileInput(
{ hasError, className, placeholder, style, onChange, ...rest },
ref,
) {
const handle = (e: React.ChangeEvent<HTMLInputElement>) => {
e.target.value = sanitizeMobileInput(e.target.value);
onChange?.(e);
};
return (
<input
{...rest}
ref={ref}
type="tel"
inputMode="numeric"
dir="ltr"
maxLength={11}
placeholder={placeholder ?? '09xxxxxxxxx'}
className={className ?? 'cp-input'}
style={hasError ? { borderColor: 'var(--danger)', ...(style || {}) } : style}
onChange={handle}
/>
);
});
@@ -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) {
<input
type="tel"
value={newMobile}
onChange={e => 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)',
+34
View File
@@ -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), 'شماره موبایل نامعتبر است');
+4 -2
View File
@@ -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() {
<form onSubmit={handleSubmit((v) => mutation.mutate(v))} style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<label style={{ fontSize: 13, fontWeight: 600 }}>شماره موبایل صاحب کلینیک <span style={{ color: 'var(--red)' }}>*</span></label>
<input className="field" placeholder="09xxxxxxxxx" dir="ltr" {...register('owner_mobile')} />
<MobileInput className="field" hasError={!!errors.owner_mobile} {...register('owner_mobile')} />
{errors.owner_mobile && <span style={{ color: 'var(--red)', fontSize: 12 }}>{errors.owner_mobile.message}</span>}
<span style={{ fontSize: 11, color: 'var(--text-3)' }}>اگر این موبایل در سیستم نباشد، کاربر جدید ساخته میشود</span>
</div>
+4 -3
View File
@@ -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() {
<label style={{ fontSize: 13, fontWeight: 600, marginBottom: 6, display: 'block' }}>
شماره موبایل صاحب کلینیک <span style={{ color: 'var(--red)' }}>*</span>
</label>
<input className="input" placeholder="09xxxxxxxxx" dir="ltr" {...addForm.register('owner_mobile')} />
<MobileInput className="input" hasError={!!addForm.formState.errors.owner_mobile} {...addForm.register('owner_mobile')} />
{addForm.formState.errors.owner_mobile && (
<div className="err-text">{addForm.formState.errors.owner_mobile.message}</div>
)}
+4 -3
View File
@@ -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
<input type="text" className="cp-input" placeholder="دکتر ..." {...register('name')} />
</EditField>
<EditField label="شماره موبایل مطب">
<input type="text" dir="ltr" className="cp-input" placeholder="09xxxxxxxxx" {...register('mobile_number')} />
<MobileInput className="cp-input" {...register('mobile_number')} />
</EditField>
</div>
</div>
+4 -3
View File
@@ -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() {
<SectionHeader icon={<PhoneIcon style={{ width: 16, height: 16 }} />} title="اطلاعات حساب" />
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14 }}>
<Field label="شماره موبایل" required error={errors.mobile?.message} hint="اگر کاربر با این شماره وجود دارد پروفایل به او متصل می‌شود">
<input type="tel" dir="ltr" className="cp-input" placeholder="09xxxxxxxxx" {...register('mobile')}
style={errors.mobile ? { borderColor: 'var(--danger)' } : {}} />
<MobileInput hasError={!!errors.mobile} {...register('mobile')} />
</Field>
<Field label="نام کامل" required error={errors.name?.message}>
<input type="text" className="cp-input" placeholder="دکتر ..." {...register('name')}
+4 -3
View File
@@ -4,6 +4,7 @@ import { toast } from 'sonner';
import { EyeIcon, EyeSlashIcon } from '@heroicons/react/24/outline';
import { useAuthStore } from '../stores/authStore';
import PwaLoginCard from '../components/ui/PwaLoginCard';
import { sanitizeMobileInput } from '../lib/utils';
type Mode = 'password' | 'sms' | 'forgot';
type SmsStep = 1 | 2;
@@ -220,7 +221,7 @@ export default function LoginPage() {
<label>شماره موبایل</label>
<input className="input" type="tel" dir="ltr" placeholder="09xxxxxxxxx"
autoComplete="username" style={{ textAlign: 'right' }}
value={pwMobile} onChange={(e) => setPwMobile(e.target.value)} />
value={pwMobile} onChange={(e) => setPwMobile(sanitizeMobileInput(e.target.value))} />
</div>
<div className="form-row">
<label>رمز عبور</label>
@@ -257,7 +258,7 @@ export default function LoginPage() {
<label>شماره موبایل</label>
<input className="input" type="tel" dir="ltr" placeholder="09xxxxxxxxx"
style={{ textAlign: 'right' }}
value={smsMobile} onChange={(e) => setSmsMobile(e.target.value)}
value={smsMobile} onChange={(e) => setSmsMobile(sanitizeMobileInput(e.target.value))}
onKeyDown={(e) => e.key === 'Enter' && handleSmsSend()} />
</div>
<button className="btn primary block" disabled={smsLoading} onClick={handleSmsSend}
@@ -305,7 +306,7 @@ export default function LoginPage() {
<label>شماره موبایل</label>
<input className="input" type="tel" dir="ltr" placeholder="09xxxxxxxxx"
style={{ textAlign: 'right' }}
value={forgotMobile} onChange={(e) => setForgotMobile(e.target.value)}
value={forgotMobile} onChange={(e) => setForgotMobile(sanitizeMobileInput(e.target.value))}
onKeyDown={(e) => e.key === 'Enter' && handleForgotSend()} />
</div>
<button className="btn primary block" disabled={forgotLoading} onClick={handleForgotSend}
+5 -22
View File
@@ -21,7 +21,8 @@ import { ActiveBadge } from "../components/ui/StatusBadge";
import { useSubscription } from "../hooks/useSubscription";
import type { ApiResponse } from "../lib/api";
import { api } from "../lib/api";
import { formatDate, maskMobile } from "../lib/utils";
import { formatDate, maskMobile, iranMobileSchema } from "../lib/utils";
import MobileInput from "../components/ui/MobileInput";
import { useAuthStore } from "../stores/authStore";
import type { Secretary, SecretaryPermissions } from "../types";
@@ -244,7 +245,7 @@ function PermissionsMatrix({
const createSchema = z.object({
name: z.string().min(2, "نام الزامی است"),
mobile_number: z.string().regex(/^09[0-9]{9}$/, "شماره موبایل معتبر نیست"),
mobile_number: iranMobileSchema,
});
type CreateForm = z.infer<typeof createSchema>;
@@ -694,28 +695,10 @@ export default function MySecretariesPage() {
</div>
<div className="field" style={{ marginTop: 16 }}>
<label>شماره موبایل *</label>
<input
<MobileInput
{...createForm.register("mobile_number")}
placeholder="09123456789"
dir="ltr"
inputMode="numeric"
maxLength={11}
autoComplete="off"
onChange={(e) => {
const digits = e.target.value.replace(
/\D/g,
"",
);
createForm.setValue("mobile_number", digits, {
shouldValidate:
createForm.formState.isSubmitted,
});
}}
style={
createForm.formState.errors.mobile_number
? { borderColor: "var(--danger)" }
: undefined
}
hasError={!!createForm.formState.errors.mobile_number}
/>
{createForm.formState.errors.mobile_number && (
<span className="field-error">
+4 -2
View File
@@ -8,6 +8,8 @@ import { z } from 'zod';
import { toast } from 'sonner';
import { api } from '../lib/api';
import type { ApiResponse, PaginatedResponse } from '../lib/api';
import MobileInput from '../components/ui/MobileInput';
import { iranMobileSchema } from '../lib/utils';
import type { Representation, City } from '../types';
import { formatDate, formatRial, formatNumber } from '../lib/utils';
import DataTable, { Column } from '../components/ui/DataTable';
@@ -19,7 +21,7 @@ import SearchableSelect from '../components/ui/SearchableSelect';
const schema = z.object({
full_name: z.string().min(2, 'نام الزامی است'),
mobile_number: z.string().min(10, 'شماره موبایل معتبر نیست'),
mobile_number: iranMobileSchema,
city_id: z.number().nullable().optional(),
commission_percent: z.coerce.number().min(0).max(100),
});
@@ -177,7 +179,7 @@ export default function RepresentationsPage() {
</div>
<div className="form-row" style={{ marginTop: 12 }}>
<label>شماره موبایل</label>
<input {...register('mobile_number')} dir="ltr" placeholder="09xxxxxxxxx" className="input" />
<MobileInput className="input" hasError={!!errors.mobile_number} {...register('mobile_number')} />
{errors.mobile_number && <p className="err-text">{errors.mobile_number.message}</p>}
</div>
<div className="form-row" style={{ marginTop: 12 }}>