- Updated date handling in BlogSeoFields and ScheduleSection to use Tehran timezone utilities for consistency. - Introduced `toTehranClockTime`, `tehranWallClockToUnix`, and `todayIso` functions for accurate date representation. - Modified various components to utilize these new utilities, ensuring that date strings are correctly formatted and timestamps are accurately converted. - Enhanced API documentation to clarify the handling of date fields, emphasizing the importance of server-local midnight. - Added tests to verify that date overrides and holidays maintain the correct day without shifting due to timezone discrepancies.
212 lines
10 KiB
TypeScript
212 lines
10 KiB
TypeScript
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);
|
||
}
|
||
|
||
// سال شمسی: رقم فارسی بدون جداکنندهی هزارگان («۱۴۰۴» نه «۱٬۴۰۴»).
|
||
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<Record<string, string>>((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» با رقم لاتین — برای مقداردهی <input type="time">
|
||
// (بر خلاف 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 = <T extends z.ZodTypeAny>(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), 'کد ملی باید ۱۰ رقم باشد');
|