feat: implement MobileInput component for standardized mobile number input and update related forms to use it
This commit is contained in:
@@ -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)',
|
||||
|
||||
@@ -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), 'شماره موبایل نامعتبر است');
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
Reference in New Issue
Block a user