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
+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 }}>