- 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.
267 lines
11 KiB
TypeScript
267 lines
11 KiB
TypeScript
import React, { useEffect, useRef, useState } from 'react';
|
|
import { ChevronRightIcon, ChevronLeftIcon } from '@heroicons/react/24/outline';
|
|
import { todayIso } from '../../lib/utils';
|
|
|
|
interface Props {
|
|
value: string; // YYYY-MM-DD Gregorian
|
|
onChange: (v: string) => void;
|
|
onClose: () => void;
|
|
/** انتخاب سال→ماه→روز را فعال میکند (پیشفرض خاموش — مناسب تاریخ تولد). */
|
|
enableYearPicker?: boolean;
|
|
}
|
|
|
|
const WEEK_DAYS = ['ش', 'ی', 'د', 'س', 'چ', 'پ', 'ج'];
|
|
|
|
const pf = new Intl.DateTimeFormat('fa-IR-u-ca-persian', { calendar: 'persian' });
|
|
|
|
function toJalali(d: Date): { year: number; month: number; day: number } {
|
|
const parts = new Intl.DateTimeFormat('en-u-ca-persian', {
|
|
year: 'numeric', month: 'numeric', day: 'numeric',
|
|
}).formatToParts(d);
|
|
const get = (t: string) => parseInt(parts.find(p => p.type === t)?.value ?? '0', 10);
|
|
return { year: get('year'), month: get('month'), day: get('day') };
|
|
}
|
|
|
|
function jalaliFirstWeekday(year: number, month: number): number {
|
|
// Build a Gregorian date for the 1st of this Jalali month by iterating
|
|
// Use the Intl API: find Gregorian date whose Jalali = year/month/1
|
|
// Approximate: use known offset
|
|
const approxGreg = jalaliToGregorian(year, month, 1);
|
|
const d = new Date(approxGreg + 'T12:00:00');
|
|
// JS getDay(): 0=Sun,1=Mon,...,6=Sat → convert to Sat=0
|
|
return (d.getDay() + 1) % 7; // Sat=0, Sun=1, ..., Fri=6
|
|
}
|
|
|
|
function jalaliToGregorian(jy: number, jm: number, jd: number): string {
|
|
const jy2 = jy - 979;
|
|
const jm2 = jm - 1;
|
|
let jDay = 365 * jy2 + Math.floor(jy2 / 33) * 8 + Math.floor(((jy2 % 33) + 3) / 4);
|
|
for (let i = 0; i < jm2; i++) jDay += (i < 6 ? 31 : 30);
|
|
jDay += jd - 1;
|
|
let gDay = jDay + 79;
|
|
let gy2 = 1600 + 400 * Math.floor(gDay / 146097);
|
|
gDay %= 146097;
|
|
let leap = true;
|
|
if (gDay >= 36525) {
|
|
gDay--;
|
|
const gi = Math.floor(gDay / 36524);
|
|
gDay %= 36524;
|
|
gy2 += gi * 100;
|
|
if (gDay >= 365) { gDay++; leap = false; }
|
|
}
|
|
gy2 += Math.floor(gDay / 1461) * 4;
|
|
gDay %= 1461;
|
|
if (gDay >= 366) {
|
|
leap = false;
|
|
gDay--;
|
|
gy2 += Math.floor(gDay / 365);
|
|
gDay %= 365;
|
|
}
|
|
const gMonthDays = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
let gm = 0;
|
|
for (; gm < 12; gm++) {
|
|
if (gDay < gMonthDays[gm]) break;
|
|
gDay -= gMonthDays[gm];
|
|
}
|
|
return `${gy2}-${String(gm + 1).padStart(2, '0')}-${String(gDay + 1).padStart(2, '0')}`;
|
|
}
|
|
|
|
const JALALI_MONTHS = ['فروردین','اردیبهشت','خرداد','تیر','مرداد','شهریور','مهر','آبان','آذر','دی','بهمن','اسفند'];
|
|
|
|
export default function PersianCalendar({ value, onChange, onClose, enableYearPicker = false }: Props) {
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
|
|
// «امروز» به وقت ایران، نه UTC: `toISOString` بعد از ۲۰:۳۰ فردا را امروز نشان میداد.
|
|
const todayGreg = todayIso();
|
|
const todayJ = toJalali(new Date(todayGreg + 'T12:00:00'));
|
|
const valueJ = value ? toJalali(new Date(value + 'T12:00:00')) : todayJ;
|
|
|
|
const [viewYear, setViewYear] = useState(valueJ.year);
|
|
const [viewMonth, setViewMonth] = useState(valueJ.month);
|
|
const [mode, setMode] = useState<'day' | 'month' | 'year'>('day');
|
|
// ابتدای بازهی ۱۲تایی سال در نمای انتخاب سال
|
|
const [yearRangeStart, setYearRangeStart] = useState(valueJ.year - (valueJ.year % 12));
|
|
|
|
const faYear = (y: number) => y.toLocaleString('fa-IR', { useGrouping: false });
|
|
|
|
useEffect(() => {
|
|
function handler(e: MouseEvent) {
|
|
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
|
|
}
|
|
document.addEventListener('mousedown', handler);
|
|
return () => document.removeEventListener('mousedown', handler);
|
|
}, [onClose]);
|
|
|
|
const daysCount = viewMonth <= 6 ? 31 : viewMonth <= 11 ? 30 : 29;
|
|
const firstWd = jalaliFirstWeekday(viewYear, viewMonth);
|
|
|
|
function prevMonth() {
|
|
if (viewMonth === 1) { setViewYear(y => y - 1); setViewMonth(12); }
|
|
else setViewMonth(m => m - 1);
|
|
}
|
|
function nextMonth() {
|
|
if (viewMonth === 12) { setViewYear(y => y + 1); setViewMonth(1); }
|
|
else setViewMonth(m => m + 1);
|
|
}
|
|
|
|
function selectDay(day: number) {
|
|
const greg = jalaliToGregorian(viewYear, viewMonth, day);
|
|
onChange(greg);
|
|
onClose();
|
|
}
|
|
|
|
const isToday = (day: number) =>
|
|
todayJ.year === viewYear && todayJ.month === viewMonth && todayJ.day === day;
|
|
|
|
const isSelected = (day: number) =>
|
|
value !== '' && valueJ.year === viewYear && valueJ.month === viewMonth && valueJ.day === day;
|
|
|
|
const cells: (number | null)[] = [...Array(firstWd).fill(null), ...Array.from({ length: daysCount }, (_, i) => i + 1)];
|
|
while (cells.length % 7 !== 0) cells.push(null);
|
|
|
|
return (
|
|
<div ref={ref} style={{
|
|
position: 'absolute', top: '100%', right: 0, zIndex: 999, marginTop: 4,
|
|
background: 'var(--surface)', border: '1px solid var(--border)',
|
|
borderRadius: 'var(--r)', boxShadow: 'var(--shadow-lg)',
|
|
padding: '12px', width: 268, direction: 'rtl',
|
|
}}>
|
|
{/* Header */}
|
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 10 }}>
|
|
<button
|
|
type="button"
|
|
onClick={mode === 'year' ? () => setYearRangeStart(s => s - 12) : nextMonth}
|
|
style={{ ...navBtnStyle, visibility: mode === 'month' ? 'hidden' : 'visible' }}
|
|
>
|
|
<ChevronRightIcon style={{ width: 16, height: 16 }} />
|
|
</button>
|
|
|
|
{mode === 'day' && (
|
|
<span style={{ fontWeight: 700, fontSize: 14, display: 'flex', gap: 6 }}>
|
|
<span
|
|
onClick={enableYearPicker ? () => setMode('month') : undefined}
|
|
style={{ cursor: enableYearPicker ? 'pointer' : 'default', color: enableYearPicker ? 'var(--primary)' : 'inherit' }}
|
|
>
|
|
{JALALI_MONTHS[viewMonth - 1]}
|
|
</span>
|
|
<span
|
|
onClick={enableYearPicker ? () => { setYearRangeStart(viewYear - (viewYear % 12)); setMode('year'); } : undefined}
|
|
style={{ cursor: enableYearPicker ? 'pointer' : 'default', color: enableYearPicker ? 'var(--primary)' : 'inherit' }}
|
|
>
|
|
{faYear(viewYear)}
|
|
</span>
|
|
</span>
|
|
)}
|
|
{mode === 'month' && <span style={{ fontWeight: 700, fontSize: 14 }}>انتخاب ماه {faYear(viewYear)}</span>}
|
|
{mode === 'year' && (
|
|
<span style={{ fontWeight: 700, fontSize: 14 }}>
|
|
{faYear(yearRangeStart)} - {faYear(yearRangeStart + 11)}
|
|
</span>
|
|
)}
|
|
|
|
<button
|
|
type="button"
|
|
onClick={mode === 'year' ? () => setYearRangeStart(s => s + 12) : prevMonth}
|
|
style={{ ...navBtnStyle, visibility: mode === 'month' ? 'hidden' : 'visible' }}
|
|
>
|
|
<ChevronLeftIcon style={{ width: 16, height: 16 }} />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Day view */}
|
|
{mode === 'day' && (
|
|
<>
|
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 2, marginBottom: 4 }}>
|
|
{WEEK_DAYS.map(d => (
|
|
<div key={d} style={{ textAlign: 'center', fontSize: 11, color: 'var(--text-3)', fontWeight: 600, padding: '2px 0' }}>
|
|
{d}
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 2 }}>
|
|
{cells.map((day, i) => {
|
|
if (!day) return <div key={i} />;
|
|
const sel = isSelected(day);
|
|
const tod = isToday(day) && !sel;
|
|
return (
|
|
<button
|
|
key={i}
|
|
type="button"
|
|
onClick={() => selectDay(day)}
|
|
style={{
|
|
width: 32, height: 32, borderRadius: '50%', fontSize: 12,
|
|
border: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
fontWeight: sel || tod ? 700 : 400,
|
|
background: sel ? 'var(--primary)' : tod ? 'var(--surface-2)' : 'transparent',
|
|
color: sel ? 'var(--on-primary)' : tod ? 'var(--text)' : 'var(--text)',
|
|
transition: 'background 0.1s',
|
|
}}
|
|
onMouseEnter={e => { if (!sel && !tod) (e.currentTarget as HTMLButtonElement).style.background = 'var(--surface-2)'; }}
|
|
onMouseLeave={e => { if (!sel && !tod) (e.currentTarget as HTMLButtonElement).style.background = 'transparent'; }}
|
|
>
|
|
{day.toLocaleString('fa-IR')}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* Month view */}
|
|
{mode === 'month' && (
|
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 6 }}>
|
|
{JALALI_MONTHS.map((mName, idx) => {
|
|
const m = idx + 1;
|
|
const active = m === viewMonth;
|
|
return (
|
|
<button
|
|
key={m}
|
|
type="button"
|
|
onClick={() => { setViewMonth(m); setMode('day'); }}
|
|
style={{ ...gridItemStyle, background: active ? 'var(--primary)' : 'transparent', color: active ? 'var(--on-primary)' : 'var(--text)' }}
|
|
onMouseEnter={e => { if (!active) (e.currentTarget as HTMLButtonElement).style.background = 'var(--surface-2)'; }}
|
|
onMouseLeave={e => { if (!active) (e.currentTarget as HTMLButtonElement).style.background = 'transparent'; }}
|
|
>
|
|
{mName}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{/* Year view */}
|
|
{mode === 'year' && (
|
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 6 }}>
|
|
{Array.from({ length: 12 }, (_, i) => yearRangeStart + i).map(y => {
|
|
const active = y === viewYear;
|
|
return (
|
|
<button
|
|
key={y}
|
|
type="button"
|
|
onClick={() => { setViewYear(y); setMode('month'); }}
|
|
style={{ ...gridItemStyle, background: active ? 'var(--primary)' : 'transparent', color: active ? 'var(--on-primary)' : 'var(--text)' }}
|
|
onMouseEnter={e => { if (!active) (e.currentTarget as HTMLButtonElement).style.background = 'var(--surface-2)'; }}
|
|
onMouseLeave={e => { if (!active) (e.currentTarget as HTMLButtonElement).style.background = 'transparent'; }}
|
|
>
|
|
{faYear(y)}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const navBtnStyle: React.CSSProperties = {
|
|
background: 'transparent', border: 'none', cursor: 'pointer',
|
|
color: 'var(--text-2)', padding: 4, borderRadius: 'var(--r-sm)',
|
|
display: 'flex', alignItems: 'center',
|
|
};
|
|
|
|
const gridItemStyle: React.CSSProperties = {
|
|
height: 40, borderRadius: 'var(--r-sm)', fontSize: 13, fontWeight: 500,
|
|
border: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
transition: 'background 0.1s',
|
|
};
|