mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Update altenative dates
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user