import { z } from 'zod'; // واحد ذخیره/API ریال است؛ نمایش تومان (÷۱۰) و ورودی ×۱۰. export const RIAL_PER_TOMAN = 10; export const rialToToman = (rial: number): number => Math.round((Number(rial) || 0) / RIAL_PER_TOMAN); export const tomanToRial = (toman: number): number => Math.round((Number(toman) || 0) * RIAL_PER_TOMAN); // ورودی مقدار ریال است (سازگاری با همهٔ فراخوانی‌ها) ولی خروجی به تومان نمایش داده می‌شود. export function formatRial(rial: number): string { return new Intl.NumberFormat('fa-IR').format(rialToToman(rial)) + ' تومان'; } export function formatNumber(n: number): string { return new Intl.NumberFormat('fa-IR').format(n); } /** * سقف منابعِ یک پلن اشتراک به زبان آدم — `-1` یعنی نامحدود. * * سه صفحه همین عدد را نشان می‌دهند (منابع، اشتراک، اشتراکِ ادمین)؛ یک جا نوشته می‌شود * تا «نامحدود» در یکی از آن‌ها به «-۱» تبدیل نشود. */ export function formatResourceLimit(max: number): string { return max < 0 ? 'نامحدود' : formatNumber(max); } // سال شمسی: رقم فارسی بدون جداکننده‌ی هزارگان («۱۴۰۴» نه «۱٬۴۰۴»). export function formatYear(year: number): string { return new Intl.NumberFormat('fa-IR', { useGrouping: false }).format(year); } // تایم‌زون رسمی سراسری برنامه = ایران. همهٔ نمایش/تبدیل تاریخ باید با این tz باشد، // مستقل از تایم‌زون مرورگرِ کاربر (اجباری). export const APP_TZ = 'Asia/Tehran'; export function toDate(val: string | number | null | undefined): Date | null { if (val == null || val === '') return null; if (typeof val === 'number') return new Date(val * 1000); // Y-m-d → treat as local noon to avoid UTC-off-by-one if (/^\d{4}-\d{2}-\d{2}$/.test(val)) return new Date(`${val}T12:00:00`); return new Date(val); } export function formatDate(val: string | number | null | undefined): string { const d = toDate(val); if (!d || isNaN(d.getTime())) return '—'; return new Intl.DateTimeFormat('fa-IR-u-ca-persian', { timeZone: APP_TZ, year: 'numeric', month: '2-digit', day: '2-digit', }).format(d); } export function formatDateTime(val: string | number | null | undefined): string { const d = toDate(val); if (!d || isNaN(d.getTime())) return '—'; return new Intl.DateTimeFormat('fa-IR-u-ca-persian', { timeZone: APP_TZ, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', }).format(d); } // ساعت (HH:MM) یک timestamp ثانیه‌ای به وقت ایران — برای تایم‌لاین/جدول نوبت‌ها. export function formatTime(tsSeconds: number): string { return new Intl.DateTimeFormat('fa-IR', { timeZone: APP_TZ, hour: '2-digit', minute: '2-digit', }).format(new Date(tsSeconds * 1000)); } // اختلاف دقیقه‌ایِ یک تایم‌زون با UTC در لحظهٔ مشخص (ایران ثابت +03:30 است ولی این // روش عمومی و درست است). function tzOffsetMinutes(date: Date, tz: string): number { const parts = new Intl.DateTimeFormat('en-US', { timeZone: tz, hour12: false, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', }).formatToParts(date).reduce>((a, p) => (a[p.type] = p.value, a), {}); const asUTC = Date.UTC(+parts.year, +parts.month - 1, +parts.day, +parts.hour, +parts.minute, +parts.second); return Math.round((asUTC - date.getTime()) / 60000); } // «ساعت دیواریِ ایران» (تاریخ Y-m-d + HH:MM) → Unix ثانیه. مستقل از tz مرورگر، تا // ثبت نوبت با ساعت واردشده دقیقاً همان لحظه به وقت ایران باشد. export function tehranWallClockToUnix(isoDate: string, time: string): number { const [Y, M, D] = isoDate.split('-').map(Number); const [h, m] = (time || '00:00').split(':').map(Number); const asUtc = Date.UTC(Y, (M || 1) - 1, D || 1, h || 0, m || 0, 0); const offset = tzOffsetMinutes(new Date(asUtc), APP_TZ); return Math.floor((asUtc - offset * 60000) / 1000); } // تاریخ تقویمیِ میلادی (Y-m-d) یک لحظه، به وقت ایران. export function toGregorianDate(d: Date): string { // en-CA → قالب YYYY-MM-DD return new Intl.DateTimeFormat('en-CA', { timeZone: APP_TZ, year: 'numeric', month: '2-digit', day: '2-digit', }).format(d); } // ساعت دیواریِ ایران به صورت «HH:MM» با رقم لاتین — برای مقداردهی // (بر خلاف formatTime که رقم فارسی می‌دهد و در input معتبر نیست). export function toTehranClockTime(d: Date): string { return new Intl.DateTimeFormat('en-GB', { timeZone: APP_TZ, hour12: false, hour: '2-digit', minute: '2-digit', }).format(d); } // API stores contract dates as Unix seconds; the date input speaks Y-m-d strings. export function isoToUnix(iso: string): number | null { const d = toDate(iso); return d && !isNaN(d.getTime()) ? Math.floor(d.getTime() / 1000) : null; } export function unixToIso(ts: number | null | undefined): string { if (ts == null) return ''; return toGregorianDate(new Date(ts * 1000)); } // «امروز» به وقت ایران. هرگز از `new Date().toISOString().slice(0,10)` استفاده نکن؛ // آن تاریخِ UTC است و بعد از ۲۰:۳۰ به‌وقت تهران یک روز جلو می‌افتد. export function todayIso(): string { return toGregorianDate(new Date()); } // عنوان «دکتر» فقط در نمایش افزوده می‌شود و هرگز در فیلد name دیتابیس ذخیره نمی‌شود // (backend با stripDoctorTitle حذف می‌کند). این helper تنها نقطهٔ افزودن عنوان است تا // در کل پنل یکسان باشد و از «دکتر دکتر …» یا نمایش بدون عنوان جلوگیری شود. export function displayDoctorName(name?: string | null): string { const n = (name ?? '').trim(); if (!n) return ''; return n.startsWith('دکتر') ? n : `دکتر ${n}`; } export function maskMobile(mobile: string): string { if (mobile.length < 7) return mobile; return mobile.slice(0, 4) + '***' + mobile.slice(-3); } 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 digitsOnly(input: string, maxLen?: number): string { const digits = toEnglishDigits(input).replace(/\D/g, ''); return maxLen ? digits.slice(0, maxLen) : digits; } // فقط ارقام انگلیسی، حداکثر ۱۱ رقم (برای فیلد موبایل). export function sanitizeMobileInput(input: string): string { return digitsOnly(input, 11); } /** * رشته‌ی ورودی کاربر (ارقام فارسی/عربی، جداکننده‌ی هزارگان، فاصله) را به عدد امن تبدیل می‌کند. * هرگز NaN برنمی‌گرداند؛ ورودی نامعتبر یا خالی ⇒ null. */ export function parseUserNumber(raw: string | number | null | undefined): number | null { if (raw == null || raw === '') return null; const s = toEnglishDigits(String(raw)).replace(/[,\s٫٬]/g, ''); if (!/^-?(\d+\.?\d*|\.\d+)$/.test(s)) return null; const n = Number(s); return Number.isFinite(n) ? n : null; } /** همان parseUserNumber با محدودکردن بازه — برای درصد (۰..۱۰۰) و مقادیر غیرمنفی. */ export function parseUserNumberClamped( raw: string | number | null | undefined, min: number, max: number, ): number | null { const n = parseUserNumber(raw); return n == null ? null : Math.min(max, Math.max(min, n)); } // z.coerce.number() روی رشته‌ی فارسی NaN می‌دهد. فیلدهای عددی پنل در مبدأ (numericField // در lib/forms.ts) نرمال می‌شوند، پس این wrapper فقط برای مصرف‌کننده‌های خارج از آن مسیر است. // روی resolverهای React Hook Form استفاده نکن — z.preprocess تایپ ورودی را unknown می‌کند. export const persianSafeNumber = (schema: T) => z.preprocess((v) => (typeof v === 'string' ? toEnglishDigits(v) : v), schema); // 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), 'شماره موبایل نامعتبر است'); // regex کد ملی ایران (۱۰ رقم؛ صحت رقم کنترلی اینجا بررسی نمی‌شود) export const IRAN_NATIONAL_CODE_RE = /^\d{10}$/; export const iranNationalCodeSchema = z .string() .transform((v) => digitsOnly(v, 10)) .refine((v) => IRAN_NATIONAL_CODE_RE.test(v), 'کد ملی باید ۱۰ رقم باشد'); export const iranNationalCodeOptionalSchema = z .string() .transform((v) => digitsOnly(v, 10)) .refine((v) => v === '' || IRAN_NATIONAL_CODE_RE.test(v), 'کد ملی باید ۱۰ رقم باشد'); /** * سالِ شمسی جاری از فرمت‌کنندهٔ شمسیِ مرورگر، نه با محاسبهٔ دستی. * * دو مصرف‌کننده دارد (صفحهٔ تعطیلات و کارت تعطیلات رسمی)؛ دو کپی یعنی دو رفتار در * مرز سال. */ export function currentJalaliYear(): number { const formatted = new Intl.DateTimeFormat('en-u-ca-persian', { year: 'numeric' }).format(new Date()); return Number(formatted.replace(/\D/g, '')) || 1405; }