Files
edr-platform/apps/edr-passenger-web/portal/src/components/ModernDatePicker.tsx
2026-07-11 00:45:14 +03:00

345 lines
16 KiB
TypeScript

'use client';
import { useState, useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import { ChevronLeft, ChevronRight, Calendar as CalendarIcon, Globe, X } from 'lucide-react';
import {
gregorianToEthiopian,
ethiopianToGregorian,
formatEthiopianDate,
getDaysInEthiopianMonth,
ETHIOPIAN_MONTHS,
type EthiopianDate,
} from '@/lib/ethiopian-calendar';
import { format } from 'date-fns';
interface ModernDatePickerProps {
value?: Date;
onChange: (date: Date) => void;
minDate?: Date;
maxDate?: Date;
placeholder?: string;
error?: boolean;
}
export default function ModernDatePicker({
value,
onChange,
minDate,
maxDate,
placeholder = 'Select date',
error = false,
}: ModernDatePickerProps) {
const [isOpen, setIsOpen] = useState(false);
const [isMobileView, setIsMobileView] = useState(false);
const [calendarType, setCalendarType] = useState<'gregorian' | 'ethiopian'>('gregorian');
const [viewMonth, setViewMonth] = useState(value?.getMonth() ?? new Date().getMonth());
const [viewYear, setViewYear] = useState(value?.getFullYear() ?? new Date().getFullYear());
const initialEthDate = gregorianToEthiopian(value || new Date());
const [ethViewMonth, setEthViewMonth] = useState(initialEthDate.month);
const [ethViewYear, setEthViewYear] = useState(initialEthDate.year);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const check = () => setIsMobileView(window.innerWidth < 768);
check();
window.addEventListener('resize', check);
return () => window.removeEventListener('resize', check);
}, []);
useEffect(() => {
if (value) {
setViewMonth(value.getMonth());
setViewYear(value.getFullYear());
const ethDate = gregorianToEthiopian(value);
setEthViewMonth(ethDate.month);
setEthViewYear(ethDate.year);
}
}, [value]);
// Lock body scroll when modal is open (both mobile and desktop modal)
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
return () => { document.body.style.overflow = ''; };
}, [isOpen]);
const toggleCalendarType = () => {
const newType = calendarType === 'gregorian' ? 'ethiopian' : 'gregorian';
const ref = value || new Date();
if (newType === 'ethiopian') {
const ethDate = gregorianToEthiopian(ref);
setEthViewMonth(ethDate.month);
setEthViewYear(ethDate.year);
} else {
setViewMonth(ref.getMonth());
setViewYear(ref.getFullYear());
}
setCalendarType(newType);
};
const handleDateSelect = (date: Date) => {
onChange(date);
setIsOpen(false);
};
const handleEthiopianDateSelect = (ethDate: EthiopianDate) => {
handleDateSelect(ethiopianToGregorian(ethDate));
};
const renderGregorianCalendar = () => {
const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
const firstDay = new Date(viewYear, viewMonth, 1).getDay();
const days: (number | null)[] = Array(firstDay).fill(null);
for (let d = 1; d <= daysInMonth; d++) days.push(d);
const monthNames = ['January','February','March','April','May','June','July','August','September','October','November','December'];
return (
<div className="p-4">
<div className="flex items-center justify-between mb-4">
<button type="button" onClick={() => { if (viewMonth === 0) { setViewMonth(11); setViewYear(y => y - 1); } else setViewMonth(m => m - 1); }} className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors">
<ChevronLeft className="w-5 h-5 text-gray-600 dark:text-gray-400" />
</button>
<span className="font-semibold text-base text-gray-900 dark:text-gray-100">{monthNames[viewMonth]} {viewYear}</span>
<button type="button" onClick={() => { if (viewMonth === 11) { setViewMonth(0); setViewYear(y => y + 1); } else setViewMonth(m => m + 1); }} className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors">
<ChevronRight className="w-5 h-5 text-gray-600 dark:text-gray-400" />
</button>
</div>
<div className="grid grid-cols-7 gap-1 mb-2">
{['Su','Mo','Tu','We','Th','Fr','Sa'].map(d => (
<div key={d} className="text-center text-xs font-semibold text-gray-400 py-2">{d}</div>
))}
</div>
<div className="grid grid-cols-7 gap-1">
{days.map((day, i) => {
if (day === null) return <div key={`e-${i}`} />;
const date = new Date(viewYear, viewMonth, day);
const isSelected = value && date.getDate() === value.getDate() && date.getMonth() === value.getMonth() && date.getFullYear() === value.getFullYear();
const isToday = date.toDateString() === new Date().toDateString();
const isDisabled = (minDate && date < new Date(minDate.getFullYear(), minDate.getMonth(), minDate.getDate())) || (maxDate && date > new Date(maxDate.getFullYear(), maxDate.getMonth(), maxDate.getDate()));
return (
<button key={day} type="button" onClick={() => !isDisabled && handleDateSelect(date)} disabled={!!isDisabled}
className={`aspect-square flex items-center justify-center text-sm rounded-lg transition-all
${isSelected ? 'bg-primary text-white font-semibold shadow-md scale-105' : ''}
${isToday && !isSelected ? 'border-2 border-primary text-primary font-semibold' : ''}
${!isSelected && !isToday && !isDisabled ? 'hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300' : ''}
${isDisabled ? 'text-gray-300 dark:text-gray-600 cursor-not-allowed' : ''}`}>
{day}
</button>
);
})}
</div>
</div>
);
};
const renderEthiopianCalendar = () => {
const daysInMonth = getDaysInEthiopianMonth(ethViewYear, ethViewMonth);
const firstDate = ethiopianToGregorian({ year: ethViewYear, month: ethViewMonth, day: 1 });
const firstDayOfWeek = firstDate.getDay();
const daysWithOffset: (number | null)[] = Array(firstDayOfWeek).fill(null);
for (let d = 1; d <= daysInMonth; d++) daysWithOffset.push(d);
const monthName = ETHIOPIAN_MONTHS[ethViewMonth - 1] || `Month ${ethViewMonth}`;
return (
<div className="p-4">
<div className="flex items-center justify-between mb-4">
<button type="button" onClick={() => { if (ethViewMonth === 1) { setEthViewMonth(13); setEthViewYear(y => y - 1); } else setEthViewMonth(m => m - 1); }} className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors">
<ChevronLeft className="w-5 h-5 text-gray-600 dark:text-gray-400" />
</button>
<span className="font-semibold text-base text-gray-900 dark:text-gray-100">{monthName} {ethViewYear}</span>
<button type="button" onClick={() => { if (ethViewMonth === 13) { setEthViewMonth(1); setEthViewYear(y => y + 1); } else setEthViewMonth(m => m + 1); }} className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors">
<ChevronRight className="w-5 h-5 text-gray-600 dark:text-gray-400" />
</button>
</div>
<div className="grid grid-cols-7 gap-1 mb-2">
{['Su','Mo','Tu','We','Th','Fr','Sa'].map(d => (
<div key={d} className="text-center text-xs font-semibold text-gray-400 py-2">{d}</div>
))}
</div>
<div className="grid grid-cols-7 gap-1">
{daysWithOffset.map((day, i) => {
if (day === null) return <div key={`e-${i}`} />;
const ethDate: EthiopianDate = { year: ethViewYear, month: ethViewMonth, day };
const gregDate = ethiopianToGregorian(ethDate);
const isSelected = value && gregDate.getDate() === value.getDate() && gregDate.getMonth() === value.getMonth() && gregDate.getFullYear() === value.getFullYear();
const todayEth = gregorianToEthiopian(new Date());
const isToday = ethDate.day === todayEth.day && ethDate.month === todayEth.month && ethDate.year === todayEth.year;
let isDisabled = false;
if (minDate) {
const [mY, mM, mD] = [minDate.getFullYear(), minDate.getMonth(), minDate.getDate()];
const [gY, gM, gD] = [gregDate.getFullYear(), gregDate.getMonth(), gregDate.getDate()];
if (gY < mY || (gY === mY && gM < mM) || (gY === mY && gM === mM && gD < mD)) isDisabled = true;
}
if (maxDate && !isDisabled) {
const [mY, mM, mD] = [maxDate.getFullYear(), maxDate.getMonth(), maxDate.getDate()];
const [gY, gM, gD] = [gregDate.getFullYear(), gregDate.getMonth(), gregDate.getDate()];
if (gY > mY || (gY === mY && gM > mM) || (gY === mY && gM === mM && gD > mD)) isDisabled = true;
}
return (
<button key={day} type="button" onClick={() => !isDisabled && handleEthiopianDateSelect(ethDate)} disabled={isDisabled}
className={`aspect-square flex items-center justify-center text-sm rounded-lg transition-all
${isSelected ? 'bg-primary text-white font-semibold shadow-md scale-105' : ''}
${isToday && !isSelected ? 'border-2 border-primary text-primary font-semibold' : ''}
${!isSelected && !isToday && !isDisabled ? 'hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300' : ''}
${isDisabled ? 'text-gray-300 dark:text-gray-600 cursor-not-allowed' : ''}`}>
{day}
</button>
);
})}
</div>
</div>
);
};
const calendarFooter = value && (
<div className="border-t border-gray-200 dark:border-gray-700 p-3 bg-gray-50 dark:bg-gray-800/60 space-y-1.5">
<div className="flex items-center justify-between text-xs">
<span className="text-gray-500 dark:text-gray-400">Gregorian:</span>
<span className="font-medium text-gray-700 dark:text-gray-300">{format(value, 'MMMM d, yyyy')}</span>
</div>
<div className="flex items-center justify-between text-xs">
<span className="text-gray-500 dark:text-gray-400">Ethiopian:</span>
<span className="font-medium text-gray-700 dark:text-gray-300">{formatEthiopianDate(gregorianToEthiopian(value))}</span>
</div>
</div>
);
// Shared modal content (used by both mobile and desktop)
const modalContent = (
<div className="flex flex-col h-full">
{/* Header row: title + close */}
<div className="flex items-center justify-between px-4 py-4 border-b border-gray-100 dark:border-gray-800 flex-shrink-0">
<div className="flex items-center gap-2">
<CalendarIcon className="w-4 h-4 text-primary" />
<h2 className="text-base font-bold text-gray-900 dark:text-white">Select Date</h2>
</div>
<button
type="button"
onClick={() => setIsOpen(false)}
className="w-9 h-9 flex items-center justify-center rounded-full hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
>
<X className="w-5 h-5 text-gray-500 dark:text-gray-400" />
</button>
</div>
{/* Toggle row: always full-width, clearly visible */}
<div className="px-4 py-2.5 border-b border-gray-100 dark:border-gray-800 flex-shrink-0">
<div className="flex rounded-lg overflow-hidden border-2 border-gray-200 dark:border-gray-700">
<button
type="button"
onClick={() => calendarType !== 'gregorian' && toggleCalendarType()}
className={`flex-1 flex items-center justify-center gap-1.5 py-2 text-xs font-semibold transition-colors ${
calendarType === 'gregorian'
? 'bg-primary text-white'
: 'bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700'
}`}
>
<Globe className="w-3.5 h-3.5" />
Gregorian
</button>
<button
type="button"
onClick={() => calendarType !== 'ethiopian' && toggleCalendarType()}
className={`flex-1 flex items-center justify-center gap-1.5 py-2 text-xs font-semibold transition-colors ${
calendarType === 'ethiopian'
? 'bg-primary text-white'
: 'bg-white dark:bg-gray-800 text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700'
}`}
>
<Globe className="w-3.5 h-3.5" />
Ethiopian
</button>
</div>
</div>
{/* Calendar body */}
<div className="flex-1 overflow-y-auto">
{calendarType === 'gregorian' ? renderGregorianCalendar() : renderEthiopianCalendar()}
{calendarFooter}
</div>
{/* Mobile-only: a large, unmistakable Cancel action in addition to the header
X — the mobile view is a full-screen takeover with no backdrop to tap, so
this is the fallback way out without having to pick a date. */}
{isMobileView && (
<div className="flex-shrink-0 border-t border-gray-200 dark:border-gray-700 p-3 bg-white dark:bg-gray-900">
<button
type="button"
onClick={() => setIsOpen(false)}
className="w-full py-3 rounded-xl border-2 border-gray-200 dark:border-gray-700 text-sm font-semibold text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
Cancel
</button>
</div>
)}
</div>
);
return (
<div className="relative" ref={containerRef}>
{/* Trigger button */}
<button
type="button"
onClick={() => setIsOpen(true)}
className={`w-full min-w-0 px-2.5 sm:px-3.5 py-3.5 border-2 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary text-left flex items-center justify-between gap-1.5 bg-white dark:bg-gray-800 transition-all group ${
error
? 'border-red-400 hover:border-red-400'
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
}`}
>
<span className={`text-sm whitespace-nowrap truncate ${value ? 'text-gray-900 dark:text-gray-100 font-medium' : 'text-gray-400'}`}>
{value ? format(value, 'MMM d, yyyy') : placeholder}
</span>
<CalendarIcon className="w-4 h-4 text-gray-400 group-hover:text-primary transition-colors flex-shrink-0" />
</button>
{isOpen && createPortal(
<>
{/* Mobile: full-screen takeover */}
{isMobileView ? (
<div
className="fixed inset-0 z-[100] bg-white dark:bg-gray-900 flex flex-col"
style={{ animation: 'mdp-slide-up 0.25s cubic-bezier(0.32,0.72,0,1)' }}
>
{modalContent}
</div>
) : (
/* Desktop: centred modal with backdrop */
<>
<div className="fixed inset-0 z-[99] bg-black/40 backdrop-blur-sm" onClick={() => setIsOpen(false)} />
<div
className="fixed inset-0 z-[100] flex items-center justify-center p-4 pointer-events-none"
>
<div
className="bg-white dark:bg-gray-900 rounded-2xl shadow-2xl border border-gray-200 dark:border-gray-700 w-full max-w-sm pointer-events-auto"
style={{ animation: 'mdp-scale-in 0.2s cubic-bezier(0.34,1.56,0.64,1)' }}
>
{modalContent}
</div>
</div>
</>
)}
<style>{`
@keyframes mdp-slide-up {
from { transform: translateY(100%); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
@keyframes mdp-scale-in {
from { transform: scale(0.92); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
`}</style>
</>,
document.body
)}
</div>
);
}