From 15f1c8d1ebfbb3b8b47d25490f1ee0f0cc9459df Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Fri, 17 Jul 2026 10:58:55 +0330 Subject: [PATCH] feat(admin): force Latin digits in numeric fields globally - Add a numeric prop to the base Input component that sets inputMode, dir=ltr, lang=en and normalizes Persian/Arabic digits to Latin via the shared toEnglishDigits on every change. - Dedupe digit conversion: PriceInput now uses toEnglishDigits instead of its local map; AppointmentsPage mobile handler uses sanitizeMobileInput. - Fix raw national-code / mobile inputs in NewAppointmentModal and NewAppointmentDrawer that stripped Persian digits without converting. - Cover the numeric Input behaviour with unit tests. Co-Authored-By: Claude Fable 5 --- .../admin/components/NewAppointmentDrawer.tsx | 8 ++--- assets/admin/components/ui/Input.tsx | 18 +++++++++++- .../admin/components/ui/InputField.test.tsx | 29 +++++++++++++++++-- assets/admin/components/ui/PriceInput.tsx | 14 ++------- assets/admin/pages/AppointmentsPage.tsx | 12 +++----- 5 files changed, 54 insertions(+), 27 deletions(-) diff --git a/assets/admin/components/NewAppointmentDrawer.tsx b/assets/admin/components/NewAppointmentDrawer.tsx index 2efc0ab6..9dcf0664 100644 --- a/assets/admin/components/NewAppointmentDrawer.tsx +++ b/assets/admin/components/NewAppointmentDrawer.tsx @@ -9,7 +9,7 @@ import PersianDateInput from './ui/PersianDateInput'; import PriceInput from './ui/PriceInput'; import SearchableSelect from './ui/SearchableSelect'; import { WalletChargeLink } from './AppointmentActions'; -import { tehranWallClockToUnix, tomanToRial } from '../lib/utils'; +import { tehranWallClockToUnix, tomanToRial, toEnglishDigits, sanitizeMobileInput } from '../lib/utils'; interface Option { uuid: string; name?: string; full_name?: string } interface PatientRow { uuid: string; user_name?: string; user_mobile?: string; user_national_code?: string } @@ -191,12 +191,12 @@ export default function NewAppointmentDrawer({ doctorUuid, defaultDate, queryKey
- setMobile(e.target.value)} placeholder="شماره تماس مراجعه کننده را وارد نمایید" dir="ltr" /> + setMobile(sanitizeMobileInput(e.target.value))} placeholder="شماره تماس مراجعه کننده را وارد نمایید" dir="ltr" inputMode="numeric" lang="en" maxLength={11} />
- setNationalCode(e.target.value.replace(/\D/g, '').slice(0, 10))} - placeholder="کد ملی مراجعه کننده را وارد نمایید" dir="ltr" inputMode="numeric" maxLength={10} /> + setNationalCode(toEnglishDigits(e.target.value).replace(/\D/g, '').slice(0, 10))} + placeholder="کد ملی مراجعه کننده را وارد نمایید" dir="ltr" inputMode="numeric" lang="en" maxLength={10} />
)} diff --git a/assets/admin/components/ui/Input.tsx b/assets/admin/components/ui/Input.tsx index 246996d8..91a9a71a 100644 --- a/assets/admin/components/ui/Input.tsx +++ b/assets/admin/components/ui/Input.tsx @@ -1,21 +1,37 @@ import React from 'react'; +import { toEnglishDigits } from '../../lib/utils'; type Props = React.InputHTMLAttributes & { hasError?: boolean; + /** فیلد فقط‌عددی: کیبورد عددی، جهت LTR، و تبدیل زندهٔ ارقام فارسی/عربی به لاتین. */ + numeric?: boolean; }; /** * Input متنی پایهٔ design system: کلاس `cp-input`، حالت خطا (قاب قرمز)، * و forwardRef برای سازگاری با react-hook-form `register`. + * با `numeric`، ورودی به لاتین نرمال می‌شود تا از ثبت ارقام فارسی جلوگیری شود. */ export default React.forwardRef(function Input( - { hasError, className, style, ...rest }, + { hasError, numeric, className, style, onChange, inputMode, dir, lang, ...rest }, ref, ) { + const handleChange = numeric + ? (e: React.ChangeEvent) => { + const latin = toEnglishDigits(e.target.value); + if (latin !== e.target.value) e.target.value = latin; + onChange?.(e); + } + : onChange; + return ( diff --git a/assets/admin/components/ui/InputField.test.tsx b/assets/admin/components/ui/InputField.test.tsx index 18ae62c4..7cd880d7 100644 --- a/assets/admin/components/ui/InputField.test.tsx +++ b/assets/admin/components/ui/InputField.test.tsx @@ -1,5 +1,5 @@ -import { describe, it, expect } from 'vitest'; -import { render, screen } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; import Input from '@/components/ui/Input'; import Field from '@/components/ui/Field'; @@ -16,6 +16,31 @@ describe('Input', () => { const el = screen.getByPlaceholderText('کدملی') as HTMLInputElement; expect(el.style.borderColor).toBe('var(--danger)'); }); + + it('با numeric کیبورد عددی و جهت LTR می‌گیرد', () => { + render(); + const el = screen.getByPlaceholderText('مبلغ') as HTMLInputElement; + expect(el.inputMode).toBe('numeric'); + expect(el.getAttribute('dir')).toBe('ltr'); + expect(el.getAttribute('lang')).toBe('en'); + }); + + it('با numeric ارقام فارسی را قبل از onChange به لاتین تبدیل می‌کند', () => { + const onChange = vi.fn(); + render(); + const el = screen.getByPlaceholderText('مبلغ') as HTMLInputElement; + fireEvent.change(el, { target: { value: '۱۲۳۴' } }); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange.mock.calls[0][0].target.value).toBe('1234'); + }); + + it('بدون numeric ورودی را دست‌نخورده می‌گذارد', () => { + const onChange = vi.fn(); + render(); + const el = screen.getByPlaceholderText('نام') as HTMLInputElement; + fireEvent.change(el, { target: { value: 'علی۱۲' } }); + expect(onChange.mock.calls[0][0].target.value).toBe('علی۱۲'); + }); }); describe('Field', () => { diff --git a/assets/admin/components/ui/PriceInput.tsx b/assets/admin/components/ui/PriceInput.tsx index 0f55f785..5d3ab30b 100644 --- a/assets/admin/components/ui/PriceInput.tsx +++ b/assets/admin/components/ui/PriceInput.tsx @@ -1,4 +1,5 @@ import React, { useEffect, useState } from 'react'; +import { toEnglishDigits } from '../../lib/utils'; interface PriceInputProps { value: number | ''; @@ -10,17 +11,6 @@ interface PriceInputProps { min?: number; } -const PERSIAN_TO_LATIN: Record = { - '۰': '0', '۱': '1', '۲': '2', '۳': '3', '۴': '4', - '۵': '5', '۶': '6', '۷': '7', '۸': '8', '۹': '9', - '٠': '0', '١': '1', '٢': '2', '٣': '3', '٤': '4', - '٥': '5', '٦': '6', '٧': '7', '٨': '8', '٩': '9', -}; - -function toLatinDigits(str: string): string { - return str.replace(/[۰-۹٠-٩]/g, (ch) => PERSIAN_TO_LATIN[ch] ?? ch); -} - function formatDisplay(num: number): string { if (num === 0) return ''; return new Intl.NumberFormat('fa-IR').format(num); @@ -42,7 +32,7 @@ export default function PriceInput({ }, [value]); const handleChange = (e: React.ChangeEvent) => { - const raw = toLatinDigits(e.target.value).replace(/[^0-9]/g, ''); + const raw = toEnglishDigits(e.target.value).replace(/[^0-9]/g, ''); const num = raw === '' ? 0 : Math.max(min, parseInt(raw, 10)); onChange(num); setDisplay(num > 0 ? formatDisplay(num) : ''); diff --git a/assets/admin/pages/AppointmentsPage.tsx b/assets/admin/pages/AppointmentsPage.tsx index b21ddb11..9f5b224b 100644 --- a/assets/admin/pages/AppointmentsPage.tsx +++ b/assets/admin/pages/AppointmentsPage.tsx @@ -9,7 +9,7 @@ import { toast } from 'sonner'; import { api } from '../lib/api'; import type { PaginatedResponse, ApiResponse } from '../lib/api'; import type { Appointment } from '../types'; -import { formatDate, toGregorianDate, formatTime } from '../lib/utils'; +import { formatDate, toGregorianDate, formatTime, toEnglishDigits, sanitizeMobileInput } from '../lib/utils'; import { useAuthStore } from '../stores/authStore'; import Pagination from '../components/ui/Pagination'; import AppointmentFiltersModal, { applyAppointmentFilters, EMPTY_FILTERS } from '../components/AppointmentFiltersModal'; @@ -173,12 +173,7 @@ export function NewAppointmentModal({ // تغییر موبایل نتیجه‌ی جستجوی قبلی را باطل می‌کند تا کاربر دوباره جستجو کند. function onMobileChange(v: string) { // ارقام فارسی/عربی → انگلیسی، فقط رقم، حداکثر ۱۱ رقم (کیبورد فارسی هم پذیرفته می‌شود). - const normalized = v - .replace(/[۰-۹]/g, d => String('۰۱۲۳۴۵۶۷۸۹'.indexOf(d))) - .replace(/[٠-٩]/g, d => String('٠١٢٣٤٥٦٧٨٩'.indexOf(d))) - .replace(/\D/g, '') - .slice(0, 11); - setMobile(normalized); + setMobile(sanitizeMobileInput(v)); if (lookup !== null) { setLookup(null); setPatientName(''); setNationalCode(''); } } @@ -265,9 +260,10 @@ export function NewAppointmentModal({ setNationalCode(e.target.value.replace(/\D/g, '').slice(0, 10))} + onChange={e => setNationalCode(toEnglishDigits(e.target.value).replace(/\D/g, '').slice(0, 10))} placeholder="کد ملی ۱۰ رقمی" style={{ ...inputSx, direction: 'ltr' }} />