Update altenative dates

This commit is contained in:
Roba Boru
2026-07-16 13:39:44 +03:00
parent 50d5ce5ad9
commit e316e59eda
2 changed files with 611 additions and 78 deletions

View File

@@ -24,6 +24,7 @@ import { format } from "date-fns";
import { formatTime, getTimePeriod, toZonedDate } from "@/utils/format";
import { formatFare } from "@/utils/fare-utils";
import { useState, useEffect } from "react";
import AlternativeDatesCalendar from "@/components/AlternativeDatesCalendar";
// Shared by the compact schedule card's coach-type badges and the "Choose Your Coach"
// modal, so both pick the same icon for a given coach type name.
@@ -47,6 +48,11 @@ export default function ResultsPage() {
);
const [effectiveDepartureDate, setEffectiveDepartureDate] = useState<string>('');
const [effectiveReturnDate, setEffectiveReturnDate] = useState<string>('');
// Round-trip "both legs empty" dual-calendar: holds picks until both outbound
// and return dates are chosen, then a single search fires for the pair — see
// the effect below, right after searchData/pushResultsWithDates are defined.
const [pendingOutboundDate, setPendingOutboundDate] = useState<Date | undefined>();
const [pendingInboundDate, setPendingInboundDate] = useState<Date | undefined>();
const [classModal, setClassModal] = useState<Schedule | null>(null);
const [promoData, setPromoData] = useState<{
code: string;
@@ -96,11 +102,14 @@ export default function ResultsPage() {
promoCode: searchParams.get("promoCode") || searchCriteria?.promoCode || "",
};
// Initialise effective dates from URL/store once searchData is stable
// Keep the displayed effective dates in sync with the URL-driven search
// criteria — not just on first load, but every time searchData.date/returnDate
// actually changes (e.g. picking a new date on the alternative-dates calendar
// re-runs the search with a different date; the heading must follow it, not
// stay frozen on whatever was first requested).
useEffect(() => {
if (searchData.date && !effectiveDepartureDate) setEffectiveDepartureDate(searchData.date);
if (searchData.returnDate && !effectiveReturnDate) setEffectiveReturnDate(searchData.returnDate);
// eslint-disable-next-line react-hooks/exhaustive-deps
if (searchData.date) setEffectiveDepartureDate(searchData.date);
if (searchData.returnDate) setEffectiveReturnDate(searchData.returnDate);
}, [searchData.date, searchData.returnDate]);
const nat = (searchData.nationality ?? '').toUpperCase();
@@ -161,6 +170,46 @@ export default function ResultsPage() {
return `/booking/search?${params}`;
};
// Pushes a new date onto the CURRENT results route (not back to the search form,
// unlike buildSearchUrl) — the query's queryKey is derived from these URL params
// (see searchData/useQuery below), so this alone re-triggers a search with the
// new date(s) while preserving route/passengers/nationality/promo unchanged.
const pushResultsWithDates = (overrides: { date?: string; returnDate?: string }) => {
const params = new URLSearchParams({
tripType: searchData.journeyType,
origin: searchData.originStationId,
destination: searchData.destinationStationId,
date: overrides.date ?? searchData.date,
adults: searchData.adultCount.toString(),
children: searchData.childCount.toString(),
nationality: searchData.nationality,
...(searchData.promoCode && { promoCode: searchData.promoCode }),
});
const returnDate = overrides.returnDate ?? searchData.returnDate;
if (returnDate) params.set("returnDate", returnDate);
router.push(`/booking/results?${params}`);
};
// Round-trip dual calendar: fire the search only once both legs have a pick —
// picking outbound alone must not trigger a search on its own.
useEffect(() => {
if (pendingOutboundDate && pendingInboundDate) {
pushResultsWithDates({
date: format(pendingOutboundDate, "yyyy-MM-dd"),
returnDate: format(pendingInboundDate, "yyyy-MM-dd"),
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pendingOutboundDate, pendingInboundDate]);
// Clear stale picks once the URL-driven search criteria actually changes (i.e.
// after a real navigation completes), so a previous failed attempt's picks don't
// leak into the next "still no results" render.
useEffect(() => {
setPendingOutboundDate(undefined);
setPendingInboundDate(undefined);
}, [searchData.date, searchData.returnDate]);
const {
data: results,
isLoading,
@@ -1064,6 +1113,90 @@ export default function ResultsPage() {
);
}
// Round trip, both legs empty, but at least one leg has alternative dates to
// offer — show both date pickers together instead of forcing the user through
// the outbound-then-return step wizard for a case we already know is doubly empty.
const isRoundTripBothLegsEmpty =
isRoundTrip && outboundSchedules.length === 0 && inboundSchedules.length === 0;
if (isRoundTripBothLegsEmpty) {
const bothCalendarsAvailable = alternativeOutbound.length > 0 && alternativeInbound.length > 0;
const outboundValue = pendingOutboundDate ?? (searchData.date ? new Date(`${searchData.date}T00:00:00`) : undefined);
const inboundValue = pendingInboundDate ?? (searchData.returnDate ? new Date(`${searchData.returnDate}T00:00:00`) : undefined);
return (
<div className="booking-page">
<div className="container mx-auto px-4">
<div className="max-w-6xl mx-auto">
<div className="card max-w-lg mx-auto text-center py-10 px-6">
<div className="w-16 h-16 bg-red-100 dark:bg-red-900/30 rounded-full flex items-center justify-center mx-auto mb-5">
<Calendar className="w-8 h-8 text-red-500 dark:text-red-400" />
</div>
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-2">
No trains available
</h2>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-6">
No trains available on your selected dates. Please choose another
date below.
</p>
<div className="space-y-4 text-left">
{alternativeOutbound.length > 0 ? (
<AlternativeDatesCalendar
label="Outbound date"
alternatives={alternativeOutbound}
value={outboundValue}
minDate={new Date()}
onChange={(date) => {
if (!bothCalendarsAvailable) {
pushResultsWithDates({ date: format(date, "yyyy-MM-dd") });
return;
}
setPendingOutboundDate(date);
if (pendingInboundDate && pendingInboundDate < date) setPendingInboundDate(undefined);
}}
/>
) : (
<p className="text-xs text-gray-500 dark:text-gray-400">
No alternative outbound dates found nearby.
</p>
)}
{alternativeInbound.length > 0 ? (
<AlternativeDatesCalendar
label="Return date"
alternatives={alternativeInbound}
value={inboundValue}
minDate={pendingOutboundDate ?? (searchData.date ? new Date(`${searchData.date}T00:00:00`) : new Date())}
onChange={(date) => {
if (!bothCalendarsAvailable) {
pushResultsWithDates({ returnDate: format(date, "yyyy-MM-dd") });
return;
}
setPendingInboundDate(date);
}}
/>
) : (
<p className="text-xs text-gray-500 dark:text-gray-400">
No alternative return dates found nearby.
</p>
)}
</div>
{bothCalendarsAvailable && pendingOutboundDate && !pendingInboundDate && (
<p className="text-xs text-gray-500 dark:text-gray-400 mt-4">
Now choose a return date to search.
</p>
)}
<button
onClick={() => router.push(buildSearchUrl())}
className="text-sm font-semibold text-primary hover:underline mt-6"
>
Modify search instead
</button>
</div>
</div>
</div>
</div>
);
}
if (isOneWayNoOutbound) {
return (
<div className="booking-page">
@@ -1078,42 +1211,26 @@ export default function ResultsPage() {
No trains available
</h2>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-6">
There are no trains scheduled on{" "}
<span className="font-semibold text-gray-900 dark:text-gray-100">
{searchData.date ? format(new Date(`${searchData.date}T00:00:00`), "EEEE, MMMM d, yyyy") : "your selected date"}
</span>
. Try a different date to see available trains.
No trains available on your selected date. Please choose another
date below.
</p>
<button
onClick={() => router.push(buildSearchUrl())}
className="btn-primary inline-flex items-center gap-2"
>
<Calendar className="w-4 h-4" />
Change Date
</button>
{alternativeOutbound.length > 0 ? (
<AlternativeDatesCalendar
alternatives={alternativeOutbound}
value={searchData.date ? new Date(`${searchData.date}T00:00:00`) : undefined}
minDate={new Date()}
onChange={(date) => pushResultsWithDates({ date: format(date, "yyyy-MM-dd") })}
/>
) : (
<button
onClick={() => router.push(buildSearchUrl())}
className="btn-primary inline-flex items-center gap-2"
>
<Calendar className="w-4 h-4" />
Change Date
</button>
)}
</div>
{/* Alternative Travel Options — commented out for the time being;
only the "No trains available" banner above is shown.
{hasAlternatives && (
<div>
<div className="mb-4">
<h3 className="text-lg font-bold text-gray-900 dark:text-white">
Alternative Travel Options
</h3>
<p className="text-sm text-amber-600 dark:text-amber-400 mt-1">
These trains run on different dates than requested —
adjust your travel date to book one of them.
</p>
</div>
<div className="space-y-4">
{alternativeOutbound.map((schedule) =>
renderScheduleCard(schedule, true, true),
)}
</div>
</div>
)}
*/}
</div>
</div>
</div>
@@ -1249,28 +1366,23 @@ export default function ResultsPage() {
</div>
{outboundSchedules.length === 0 && (
<div className="mt-6">
<div className="flex items-center justify-between gap-4 px-4 py-3 rounded-xl bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800">
<div className="flex items-center gap-2.5 text-sm text-red-800 dark:text-red-300">
<Calendar className="w-4 h-4 flex-shrink-0" />
<span>No trains on <span className="font-semibold">{searchData.date ? format(new Date(`${searchData.date}T00:00:00`), "EEEE, MMMM d") : "your selected date"}</span>.</span>
</div>
<button onClick={() => router.push(buildSearchUrl())} className="text-sm font-semibold text-primary hover:underline flex-shrink-0">
<div className="flex items-center gap-2.5 text-sm text-red-800 dark:text-red-300 px-4 py-3 rounded-xl bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 mb-4">
<Calendar className="w-4 h-4 flex-shrink-0" />
<span>No trains on <span className="font-semibold">{searchData.date ? format(new Date(`${searchData.date}T00:00:00`), "EEEE, MMMM d") : "your selected date"}</span>.</span>
</div>
{alternativeOutbound.length > 0 ? (
<AlternativeDatesCalendar
alternatives={alternativeOutbound}
value={searchData.date ? new Date(`${searchData.date}T00:00:00`) : undefined}
minDate={new Date()}
onChange={(date) => pushResultsWithDates({ date: format(date, "yyyy-MM-dd") })}
/>
) : (
<button onClick={() => router.push(buildSearchUrl())} className="btn-primary inline-flex items-center gap-2">
<Calendar className="w-4 h-4" />
Change dates
</button>
</div>
{/* Alternative Outbound Options — commented out for the time being;
only the "No trains" banner above is shown.
<div className="mb-3">
<h3 className="text-base font-bold text-gray-900 dark:text-white">
Alternative Outbound Options
</h3>
</div>
<div className="space-y-4">
{alternativeOutbound.map((schedule: Schedule) =>
renderScheduleCard(schedule, true, true),
)}
</div>
*/}
)}
</div>
)}
</div>
@@ -1335,28 +1447,23 @@ export default function ResultsPage() {
</div>
{inboundSchedules.length === 0 && (
<div className="mt-6">
<div className="flex items-center justify-between gap-4 px-4 py-3 rounded-xl bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800">
<div className="flex items-center gap-2.5 text-sm text-red-800 dark:text-red-300">
<Calendar className="w-4 h-4 flex-shrink-0" />
<span>No trains on <span className="font-semibold">{searchData.returnDate ? format(new Date(`${searchData.returnDate}T00:00:00`), "EEEE, MMMM d") : "your selected return date"}</span>.</span>
</div>
<button onClick={() => router.push(buildSearchUrl())} className="text-sm font-semibold text-primary hover:underline flex-shrink-0">
<div className="flex items-center gap-2.5 text-sm text-red-800 dark:text-red-300 px-4 py-3 rounded-xl bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 mb-4">
<Calendar className="w-4 h-4 flex-shrink-0" />
<span>No trains on <span className="font-semibold">{searchData.returnDate ? format(new Date(`${searchData.returnDate}T00:00:00`), "EEEE, MMMM d") : "your selected return date"}</span>.</span>
</div>
{alternativeInbound.length > 0 ? (
<AlternativeDatesCalendar
alternatives={alternativeInbound}
value={searchData.returnDate ? new Date(`${searchData.returnDate}T00:00:00`) : undefined}
minDate={new Date(`${searchData.date}T00:00:00`)}
onChange={(date) => pushResultsWithDates({ returnDate: format(date, "yyyy-MM-dd") })}
/>
) : (
<button onClick={() => router.push(buildSearchUrl())} className="btn-primary inline-flex items-center gap-2">
<Calendar className="w-4 h-4" />
Change dates
</button>
</div>
{/* Alternative Return Options — commented out for the time being;
only the "No trains" banner above is shown.
<div className="mb-3">
<h3 className="text-base font-bold text-gray-900 dark:text-white">
Alternative Return Options
</h3>
</div>
<div className="space-y-4">
{alternativeInbound.map((schedule: Schedule) =>
renderScheduleCard(schedule, false, true),
)}
</div>
*/}
)}
</div>
)}
</div>

View File

@@ -0,0 +1,426 @@
'use client';
import { useState, useMemo, useRef, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { ChevronLeft, ChevronRight, Calendar as CalendarIcon, Globe, X, Star } from 'lucide-react';
import {
gregorianToEthiopian,
ethiopianToGregorian,
formatEthiopianDate,
getDaysInEthiopianMonth,
ETHIOPIAN_MONTHS,
type EthiopianDate,
} from '@/lib/ethiopian-calendar';
import { format } from 'date-fns';
import { formatFare } from '@/utils/fare-utils';
import { Schedule } from '@/types';
interface AlternativeDatesCalendarProps {
// alternativeOutbound / alternativeInbound, as returned by /search — no fetching
// of its own, this component is purely presentational over data the results
// page already has in hand.
alternatives: Schedule[];
value?: Date;
minDate?: Date;
onChange: (date: Date) => void;
label?: string;
}
interface DayInfo {
hasAvailability: boolean;
lowestFareMinor: number | null;
displayCurrency: string | null;
}
const toDateKey = (date: Date) => {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
};
export default function AlternativeDatesCalendar({
value,
minDate,
onChange,
label,
alternatives,
}: AlternativeDatesCalendarProps) {
const [isOpen, setIsOpen] = useState(false);
const [isMobileView, setIsMobileView] = useState(false);
const [calendarType, setCalendarType] = useState<'gregorian' | 'ethiopian'>('gregorian');
const containerRef = useRef<HTMLDivElement>(null);
// Day-level availability + cheapest fare, derived once from the alternatives
// list — same "cheapest across faresByClass" logic used by the schedule cards
// on the results page.
const dayMap = useMemo(() => {
const map = new Map<string, DayInfo>();
for (const schedule of alternatives) {
if (!schedule.departureAt) continue;
const key = toDateKey(new Date(schedule.departureAt));
const fares = (schedule.faresByClass ?? [])
.map((f) => f.displayAmountMinor ?? f.baseFareMinor)
.filter((n): n is number => !!n && n > 0);
const lowestFareMinor = fares.length ? Math.min(...fares) : null;
const displayCurrency = schedule.faresByClass?.[0]?.displayCurrency ?? null;
const hasAvailability = !!schedule.hasAvailability;
const existing = map.get(key);
if (!existing) {
map.set(key, { hasAvailability, lowestFareMinor: hasAvailability ? lowestFareMinor : null, displayCurrency: hasAvailability ? displayCurrency : null });
} else {
existing.hasAvailability = existing.hasAvailability || hasAvailability;
if (hasAvailability && lowestFareMinor !== null && (existing.lowestFareMinor === null || lowestFareMinor < existing.lowestFareMinor)) {
existing.lowestFareMinor = lowestFareMinor;
existing.displayCurrency = displayCurrency;
}
}
}
return map;
}, [alternatives]);
const availableDateKeys = useMemo(
() => Array.from(dayMap.entries()).filter(([, info]) => info.hasAvailability).map(([key]) => key),
[dayMap],
);
const bestPriceDateKeys = useMemo(() => {
const fares = availableDateKeys
.map((key) => dayMap.get(key)!.lowestFareMinor)
.filter((n): n is number => n !== null);
if (fares.length === 0) return new Set<string>();
const min = Math.min(...fares);
return new Set(availableDateKeys.filter((key) => dayMap.get(key)!.lowestFareMinor === min));
}, [availableDateKeys, dayMap]);
// Pick the initial month to show: the requested date's month if it actually has
// data, otherwise the month of whichever available date is closest to it — the
// alternatives list has no day-window bound, so it can easily land in a
// different month than the one originally searched.
const initialFocusDate = useMemo(() => {
const base = value ?? new Date();
const baseMonthKey = `${base.getFullYear()}-${String(base.getMonth() + 1).padStart(2, '0')}`;
if (availableDateKeys.some((key) => key.startsWith(baseMonthKey))) return base;
let nearest: string | null = null;
let nearestDiff = Infinity;
for (const key of availableDateKeys) {
const diff = Math.abs(new Date(`${key}T00:00:00`).getTime() - base.getTime());
if (diff < nearestDiff) { nearestDiff = diff; nearest = key; }
}
return nearest ? new Date(`${nearest}T00:00:00`) : base;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const [viewMonth, setViewMonth] = useState(initialFocusDate.getMonth());
const [viewYear, setViewYear] = useState(initialFocusDate.getFullYear());
const initialEthDate = gregorianToEthiopian(initialFocusDate);
const [ethViewMonth, setEthViewMonth] = useState(initialEthDate.month);
const [ethViewYear, setEthViewYear] = useState(initialEthDate.year);
useEffect(() => {
const check = () => setIsMobileView(window.innerWidth < 768);
check();
window.addEventListener('resize', check);
return () => window.removeEventListener('resize', check);
}, []);
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 isBeforeMin = (date: Date) =>
!!minDate && date < new Date(minDate.getFullYear(), minDate.getMonth(), minDate.getDate());
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 key = toDateKey(date);
const dayInfo = dayMap.get(key);
const isSelected = value && date.getDate() === value.getDate() && date.getMonth() === value.getMonth() && date.getFullYear() === value.getFullYear();
const isToday = date.toDateString() === new Date().toDateString();
const isDisabled = isBeforeMin(date) || !dayInfo?.hasAvailability;
const isBestPrice = bestPriceDateKeys.has(key);
return (
<button key={day} type="button" onClick={() => !isDisabled && handleDateSelect(date)} disabled={isDisabled}
className={`relative aspect-square flex flex-col 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-green-50 dark:hover:bg-green-900/20 text-gray-700 dark:text-gray-300' : ''}
${isDisabled ? 'text-gray-300 dark:text-gray-600 cursor-not-allowed' : ''}`}>
{isBestPrice && !isSelected && (
<Star className="w-2.5 h-2.5 absolute top-0.5 right-0.5 text-amber-500 fill-amber-500" />
)}
<span>{day}</span>
{dayInfo?.hasAvailability && dayInfo.lowestFareMinor != null && (
<span className={`text-[8px] leading-none mt-0.5 ${isSelected ? 'text-white/90' : 'text-green-600 dark:text-green-400'}`}>
{formatFare(dayInfo.lowestFareMinor, dayInfo.displayCurrency ?? 'ETB').replace(/\.00$/, '')}
</span>
)}
</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 key = toDateKey(gregDate);
const dayInfo = dayMap.get(key);
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;
const isDisabled = isBeforeMin(gregDate) || !dayInfo?.hasAvailability;
const isBestPrice = bestPriceDateKeys.has(key);
return (
<button key={day} type="button" onClick={() => !isDisabled && handleEthiopianDateSelect(ethDate)} disabled={isDisabled}
className={`relative aspect-square flex flex-col 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-green-50 dark:hover:bg-green-900/20 text-gray-700 dark:text-gray-300' : ''}
${isDisabled ? 'text-gray-300 dark:text-gray-600 cursor-not-allowed' : ''}`}>
{isBestPrice && !isSelected && (
<Star className="w-2.5 h-2.5 absolute top-0.5 right-0.5 text-amber-500 fill-amber-500" />
)}
<span>{day}</span>
{dayInfo?.hasAvailability && dayInfo.lowestFareMinor != null && (
<span className={`text-[8px] leading-none mt-0.5 ${isSelected ? 'text-white/90' : 'text-green-600 dark:text-green-400'}`}>
{formatFare(dayInfo.lowestFareMinor, dayInfo.displayCurrency ?? 'ETB').replace(/\.00$/, '')}
</span>
)}
</button>
);
})}
</div>
</div>
);
};
const legend = (
<div className="flex items-center gap-4 px-4 pb-3 text-xs text-gray-500 dark:text-gray-400">
<span className="flex items-center gap-1.5">
<span className="w-2.5 h-2.5 rounded-full bg-green-500" /> Available
</span>
<span className="flex items-center gap-1.5">
<span className="w-2.5 h-2.5 rounded-full bg-gray-300 dark:bg-gray-600" /> Unavailable
</span>
<span className="flex items-center gap-1.5">
<Star className="w-3 h-3 text-amber-500 fill-amber-500" /> Best price
</span>
</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>
);
const modalContent = (
<div className="flex flex-col h-full">
<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">{label ? `Select ${label}` : 'Select a 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>
{legend}
<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>
<div className="flex-1 overflow-y-auto">
{calendarType === 'gregorian' ? renderGregorianCalendar() : renderEthiopianCalendar()}
{calendarFooter}
</div>
{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}>
<button
type="button"
onClick={() => setIsOpen(true)}
className="w-full min-w-0 px-4 py-3.5 border-2 border-dashed border-primary/40 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary text-center flex items-center justify-center gap-2 bg-primary/5 hover:bg-primary/10 dark:bg-primary/10 dark:hover:bg-primary/15 transition-all group"
>
<CalendarIcon className="w-4 h-4 text-primary flex-shrink-0" />
<span className="text-sm font-semibold text-primary">
{`Click here to see available ${label ? label.toLowerCase() + " " : ""}dates`}
</span>
</button>
{isOpen && createPortal(
<>
{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>
) : (
<>
<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>
);
}