feat(appointment): inline dual-month Jalali calendar for day selection

The day picker used a Popover-based JalaliDatePicker that only showed two
empty text inputs on the booking page — the calendar was hidden until you
clicked the input, so it never matched the design. Replace it with an
always-visible inline two-month calendar (InlineJalaliMonth) rendered side
by side: current month on the right, next on the left (RTL), weekday
headers, Fridays in red, disabled days greyed, selected day in orange,
shared month navigation on the outer edges. Preserves the existing
contract: setDate(unix timestamp), disabledDates[], auto-select nearest
available day.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-06-15 15:58:23 +03:30
co-authored by Claude Opus 4.8
parent 1196aa64c6
commit cf705a894e
2 changed files with 166 additions and 106 deletions
+48 -106
View File
@@ -1,128 +1,70 @@
"use client";
import { useEffect, useRef, useState } from "react";
import FirstDatePicker from "./date/FirstDatePicker";
import SecondDatePicker from "./date/SecondDatePicker";
import { dateToTimestamp } from "@/helper";
import { useEffect, useState } from "react";
import moment from "moment-jalaali";
import "moment-timezone";
import { dateToTimestamp } from "@/helper";
import InlineJalaliMonth from "@/components/common/InlineJalaliMonth";
function DatePicker({ setDate, disabledDates = [] }) {
const nextIconDatePickerF = useRef();
const prevIconDatePickerF = useRef();
const nextIconDatePickerS = useRef();
const prevIconDatePickerS = useRef();
const [startDate, setStartDate] = useState(null);
const [endDate, setEndDate] = useState();
const [loading, setLoading] = useState(true);
const [baseMonth, setBaseMonth] = useState(moment().tz("Asia/Tehran"));
const [selectedDate, setSelectedDate] = useState(null);
const [autoSelected, setAutoSelected] = useState(false);
const handleStartDateChange = (date, e) => {
if (date) {
setDate(dateToTimestamp(date));
setEndDate(null);
setStartDate(date);
}
const isDisabled = (date) => {
const day = moment(date).startOf("day");
if (day.isBefore(moment().startOf("day"))) return true;
return disabledDates.some((ts) => day.isSame(moment.unix(ts).startOf("day")));
};
const handleEndDateChange = (date) => {
if (date) {
setDate(dateToTimestamp(date));
setEndDate(date);
setStartDate(null);
}
};
const handleDisableDate = (e) => {
const date = moment(e).startOf("day");
const today = moment().startOf("day");
if (date.isBefore(today)) return true;
if (
disabledDates.some((ts) => date.isSame(moment.unix(ts).startOf("day")))
) {
return true;
}
return false;
const selectDay = (date) => {
if (isDisabled(date)) return;
setSelectedDate(date);
setDate(dateToTimestamp(date));
};
useEffect(() => {
// تنظیم loading به false بعد از یک تاخیر کوتاه
setTimeout(() => {
setLoading(false);
}, 1000);
if (autoSelected) return;
if (nextIconDatePickerS.current) {
nextIconDatePickerS.current.click();
}
}, [nextIconDatePickerS, disabledDates]);
// انتخاب خودکار امروز یا نزدیک‌ترین روز قابل انتخاب
useEffect(() => {
if (!loading && !autoSelected && disabledDates) {
const today = moment().tz("Asia/Tehran");
const todayTimestamp = moment().tz("Asia/Tehran").startOf("day").unix();
// بررسی اینکه امروز قابل انتخاب است یا نه
const isTodayDisabled = disabledDates.some((ts) =>
moment.unix(ts).startOf("day").isSame(today.startOf("day"))
);
if (!isTodayDisabled) {
// امروز قابل انتخاب است
setStartDate(today);
setDate(todayTimestamp);
const today = moment().tz("Asia/Tehran");
for (let i = 0; i <= 30; i++) {
const candidate = today.clone().add(i, "days");
if (!isDisabled(candidate)) {
setSelectedDate(candidate);
setBaseMonth(candidate.clone());
setDate(candidate.clone().startOf("day").unix());
setAutoSelected(true);
} else {
// پیدا کردن نزدیک‌ترین روز قابل انتخاب
let nearestDate = null;
let daysToCheck = 30; // بررسی 30 روز آینده
for (let i = 1; i <= daysToCheck; i++) {
const futureDate = moment().tz("Asia/Tehran").add(i, "days");
const futureDateTimestamp = futureDate.clone().startOf("day").unix();
const isFutureDateDisabled = disabledDates.some((ts) =>
moment.unix(ts).startOf("day").isSame(futureDate.startOf("day"))
);
if (!isFutureDateDisabled) {
nearestDate = futureDate;
setStartDate(futureDate);
setDate(futureDateTimestamp);
setAutoSelected(true);
break;
}
}
break;
}
}
}, [loading, disabledDates, autoSelected, setDate]);
}, [disabledDates, autoSelected, setDate]);
const secondMonth = moment(baseMonth).add(1, "jMonth");
return (
<div className="datePickerApt flex items-start custom-datepicker justify-evenly w-full">
<FirstDatePicker
loading={loading}
startDate={startDate}
disableDates={disabledDates}
handleDisableDate={handleDisableDate}
nextIconDatePickerF={nextIconDatePickerF}
prevIconDatePickerF={prevIconDatePickerF}
prevIconDatePickerS={prevIconDatePickerS}
handleStartDateChange={handleStartDateChange}
/>
<SecondDatePicker
loading={loading}
endDate={endDate}
handleDisableDate={handleDisableDate}
nextIconDatePickerF={nextIconDatePickerF}
nextIconDatePickerS={nextIconDatePickerS}
prevIconDatePickerS={prevIconDatePickerS}
handleEndDateChange={handleEndDateChange}
/>
<div className="datePickerApt flex items-start justify-evenly gap-[24px] w-full">
<div className="hidden md:block lg:hidden xl:block w-1/2">
<InlineJalaliMonth
displayDate={secondMonth}
selectedDate={selectedDate}
onSelectDay={selectDay}
isDisabled={isDisabled}
showPrev={false}
onNext={() => setBaseMonth(moment(baseMonth).add(1, "jMonth"))}
onPrev={() => setBaseMonth(moment(baseMonth).subtract(1, "jMonth"))}
/>
</div>
<div className="w-full lg:w-1/2">
<InlineJalaliMonth
displayDate={baseMonth}
selectedDate={selectedDate}
onSelectDay={selectDay}
isDisabled={isDisabled}
showNext={false}
onPrev={() => setBaseMonth(moment(baseMonth).subtract(1, "jMonth"))}
onNext={() => setBaseMonth(moment(baseMonth).add(1, "jMonth"))}
/>
</div>
</div>
);
}
+118
View File
@@ -0,0 +1,118 @@
"use client";
import moment from "moment-jalaali";
moment.loadPersian({ dialect: "persian-modern", usePersianDigits: false });
const WEEKDAYS = ["شنبه", "۱ شنبه", "۲ شنبه", "۳ شنبه", "۴ شنبه", "۵ شنبه", "جمعه"];
const MONTHS = [
"فروردین",
"اردیبهشت",
"خرداد",
"تیر",
"مرداد",
"شهریور",
"مهر",
"آبان",
"آذر",
"دی",
"بهمن",
"اسفند",
];
function buildDays(displayDate) {
const year = displayDate.jYear();
const month = displayDate.jMonth();
const daysInMonth = moment.jDaysInMonth(year, month);
const firstDayOfMonth = moment(`${year}/${month + 1}/1`, "jYYYY/jM/jD").day();
const startOffset = (firstDayOfMonth + 1) % 7;
const days = [];
for (let i = 0; i < startOffset; i++) days.push(null);
for (let day = 1; day <= daysInMonth; day++) days.push(day);
return days;
}
function InlineJalaliMonth({
displayDate,
selectedDate,
onSelectDay,
isDisabled,
onPrev,
onNext,
showPrev = true,
showNext = true,
}) {
const days = buildDays(displayDate);
return (
<div className="w-full">
<div className="flex items-center justify-between mb-[16px] px-1">
<button
type="button"
onClick={onNext}
className={`p-1 text-[#525252] ${showNext ? "" : "invisible"}`}
aria-label="ماه بعد"
>
</button>
<p className="text-[#3B3B3B] text-[14px] md:text-[16px] font-bold">
{MONTHS[displayDate.jMonth()]} {displayDate.jYear()}
</p>
<button
type="button"
onClick={onPrev}
className={`p-1 text-[#525252] ${showPrev ? "" : "invisible"}`}
aria-label="ماه قبل"
>
</button>
</div>
<div className="grid grid-cols-7 gap-y-[12px] mb-[8px]">
{WEEKDAYS.map((label, idx) => (
<span
key={idx}
className="text-center text-[#9B9B9B] text-[11px] md:text-[12px] font-normal"
>
{label}
</span>
))}
</div>
<div className="grid grid-cols-7 gap-y-[12px]">
{days.map((day, index) => {
if (!day) return <span key={index} />;
const dayDate = moment(displayDate).jDate(day);
const isFriday = dayDate.day() === 5;
const disabled = isDisabled(dayDate);
const selected =
selectedDate &&
dayDate.jYear() === selectedDate.jYear() &&
dayDate.jMonth() === selectedDate.jMonth() &&
dayDate.jDate() === selectedDate.jDate();
return (
<button
type="button"
key={index}
disabled={disabled}
onClick={() => onSelectDay(dayDate)}
className={`
mx-auto flex items-center justify-center w-[34px] h-[34px] rounded-full
text-[14px] md:text-[16px] font-normal transition-colors
${selected ? "!bg-[#F17732] !text-[#FFF]" : ""}
${disabled ? "text-[#D7D7D7] cursor-not-allowed" : isFriday ? "text-[#E0383B]" : "text-[#525252] hover:bg-[#F5F5F5]"}
`}
>
{day}
</button>
);
})}
</div>
</div>
);
}
export default InlineJalaliMonth;