travelmarine-frontend/src/app/catalog/[id]/components/YachtAvailability.tsx

425 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useState, useMemo } from "react";
import { Calendar } from "@/components/ui/calendar";
import {
isSameMonth,
isBefore,
startOfDay,
format,
eachDayOfInterval,
startOfMonth,
endOfMonth,
} from "date-fns";
import { ru } from "date-fns/locale";
import { ChevronLeftIcon, ChevronRightIcon, Clock } from "lucide-react";
interface Reservation {
id: number;
reservatorId: number;
yachtId: number;
startUtc: number;
endUtc: number;
}
interface YachtAvailabilityProps {
price: string;
mobile?: boolean;
reservations?: Reservation[];
}
export function YachtAvailability({
price,
mobile = false,
reservations = [],
}: YachtAvailabilityProps) {
const today = startOfDay(new Date());
const [currentMonth, setCurrentMonth] = useState(
new Date(today.getFullYear(), today.getMonth(), 1)
);
const [startTime, setStartTime] = useState<string>("");
const [endTime, setEndTime] = useState<string>("");
const unavailableDates = Array.from({ length: 26 }, (_, i) => {
return new Date(2025, 3, i + 1);
});
// Format time from Unix timestamp to HH:mm in UTC
const formatTimeFromUnix = (unixTimestamp: number) => {
const date = new Date(unixTimestamp * 1000);
// Format in UTC to avoid timezone conversion
return format(date, "HH:mm");
};
// Get time portion of a UTC timestamp
const getUTCTime = (unixTimestamp: number) => {
const date = new Date(unixTimestamp * 1000);
return date.getUTCHours() * 60 + date.getUTCMinutes(); // minutes since midnight UTC
};
// Get reservations for a specific date with proper time splitting
const getReservationsForDate = (date: Date) => {
const dayStart = Math.floor(startOfDay(date).getTime() / 1000);
const dayEnd = dayStart + 24 * 60 * 60;
const dayReservations: Array<{
id: number;
startTime: string;
endTime: string;
}> = [];
reservations.forEach((reservation) => {
// Check if reservation overlaps with this day
if (reservation.startUtc < dayEnd && reservation.endUtc > dayStart) {
// Calculate the actual time range for this specific day
const dayReservationStart = Math.max(reservation.startUtc, dayStart);
const dayReservationEnd = Math.min(reservation.endUtc, dayEnd);
// Format times in UTC to avoid timezone issues
const startTime = formatTimeFromUnix(dayReservationStart);
const endTime = formatTimeFromUnix(dayReservationEnd);
dayReservations.push({
id: reservation.id,
startTime,
endTime,
});
}
});
return dayReservations;
};
// Check if a date has any reservations
const hasReservationsOnDate = (date: Date) => {
const dayStart = Math.floor(startOfDay(date).getTime() / 1000);
const dayEnd = dayStart + 24 * 60 * 60;
return reservations.some((reservation) => {
return reservation.startUtc < dayEnd && reservation.endUtc > dayStart;
});
};
const isDateUnavailable = (date: Date) => {
return unavailableDates.some(
(d) =>
d.getDate() === date.getDate() &&
d.getMonth() === date.getMonth() &&
d.getFullYear() === date.getFullYear()
);
};
const isDateInPast = (date: Date) => {
return isBefore(startOfDay(date), today);
};
const shouldBeCrossedOut = (date: Date) => {
return isDateUnavailable(date) || isDateInPast(date);
};
const isDateAvailable = (date: Date) => {
return !shouldBeCrossedOut(date) && isSameMonth(date, currentMonth);
};
const getAvailableDaysCount = () => {
const monthStart = startOfMonth(currentMonth);
const monthEnd = endOfMonth(currentMonth);
const daysInMonth = eachDayOfInterval({
start: monthStart,
end: monthEnd,
});
return daysInMonth.filter((day) => isDateAvailable(day)).length;
};
const goToPreviousMonth = () => {
setCurrentMonth(
new Date(currentMonth.getFullYear(), currentMonth.getMonth() - 1, 1)
);
};
const goToNextMonth = () => {
setCurrentMonth(
new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1, 1)
);
};
// Генерация времени для селекта
const timeOptions = Array.from({ length: 24 * 2 }, (_, i) => {
const hours = Math.floor(i / 2);
const minutes = (i % 2) * 30;
const timeString = `${String(hours).padStart(2, "0")}:${String(
minutes
).padStart(2, "0")}`;
return { value: timeString, label: timeString };
});
// Helper function to render time slots for desktop view
const renderTimeSlots = (date: Date) => {
const dateReservations = getReservationsForDate(date);
if (dateReservations.length === 0) {
// No reservations, show free time slot
return (
<div className="flex flex-col gap-1.5 w-full mt-1">
<div className="w-fit bg-[#F6BD4D] text-white text-[10px] font-medium px-1 py-0 rounded-full inline-block">
08:0020:00
</div>
</div>
);
}
// Show all reservations for this day
return (
<div className="flex flex-col gap-1 w-full mt-1">
{dateReservations.map((res) => (
<div
key={`${res.id}-${res.startTime}`}
className="w-fit bg-[#2F5CD0] text-white text-[10px] font-medium px-1 py-0 rounded-full inline-block"
>
{res.startTime}{res.endTime}
</div>
))}
</div>
);
};
if (mobile) {
return (
<div className="w-full">
{/* Навигация по месяцам */}
<div className="flex items-center justify-between mb-4">
<button
onClick={goToPreviousMonth}
className="w-10 h-10 rounded-full border border-[#dfdfdf] flex items-center justify-center hover:bg-gray-50 transition-colors"
>
<ChevronLeftIcon className="size-4 text-[#333333]" />
</button>
<div className="flex flex-col items-center">
<span className="text-lg font-medium text-[#333333] capitalize">
{format(currentMonth, "LLLL", { locale: ru })}
</span>
<span className="text-sm text-[#999999]">
Свободных дней: {getAvailableDaysCount()}
</span>
</div>
<button
onClick={goToNextMonth}
className="w-10 h-10 rounded-full border border-[#dfdfdf] flex items-center justify-center hover:bg-gray-50 transition-colors"
>
<ChevronRightIcon className="size-4 text-[#333333]" />
</button>
</div>
{/* Календарь */}
<div style={{ flexShrink: 0 }}>
<Calendar
mode="single"
month={currentMonth}
onMonthChange={setCurrentMonth}
showOutsideDays={false}
className="w-full p-0"
locale={ru}
formatters={{
formatWeekdayName: (date) => {
const weekdays = ["ВС", "ПН", "ВТ", "СР", "ЧТ", "ПТ", "СБ"];
return weekdays[date.getDay()];
},
}}
classNames={{
root: "w-full",
month: "flex w-full flex-col gap-2",
nav: "hidden",
month_caption: "hidden",
caption_label: "hidden",
button_previous: "hidden",
button_next: "hidden",
table: "w-full border-collapse table-fixed",
weekdays: "flex w-full mb-2",
weekday:
"flex-1 text-[#999999] text-xs font-normal p-2 text-center",
week: "flex w-full min-h-[50px]",
day: "relative flex-1 min-w-0 flex-shrink-0",
}}
components={{
DayButton: ({ day, ...props }) => {
if (!isSameMonth(day.date, currentMonth)) {
return <div className="hidden" />;
}
const isCrossedOut = shouldBeCrossedOut(day.date);
const hasRes = hasReservationsOnDate(day.date);
return (
<button
{...props}
className={`relative w-full flex items-center justify-center text-sm font-medium transition-colors ${
isCrossedOut
? "text-[#CCCCCC] line-through"
: "text-[#333333] hover:bg-gray-100"
}`}
style={
{
aspectRatio: "1 / 1",
minHeight: "44px",
} as React.CSSProperties
}
disabled={isCrossedOut}
>
{day.date.getDate()}
{hasRes && !isCrossedOut && (
<div className="absolute bottom-1 right-1 w-1.5 h-1.5 bg-[#2F5CD0] rounded-full"></div>
)}
</button>
);
},
}}
/>
</div>
{/* Выбор времени */}
<div className="space-y-4 mb-4" style={{ marginTop: "24px" }}>
<div className="flex gap-3">
<div className="flex-1">
<select
value={startTime}
onChange={(e) => setStartTime(e.target.value)}
className="w-full px-4 py-3 border border-[#DFDFDF] rounded-lg text-base text-[#333333] bg-white appearance-none"
>
<option value="">--:--</option>
{timeOptions.map((time) => (
<option key={time.value} value={time.value}>
{time.label}
</option>
))}
</select>
</div>
<div className="flex-1">
<select
value={endTime}
onChange={(e) => setEndTime(e.target.value)}
className="w-full px-4 py-3 border border-[#DFDFDF] rounded-lg text-base text-[#333333] bg-white appearance-none"
>
<option value="">--:--</option>
{timeOptions.map((time) => (
<option key={time.value} value={time.value}>
{time.label}
</option>
))}
</select>
</div>
</div>
<div className="flex items-center gap-2 text-sm text-[#999999]">
<Clock size={16} />
<span>По местному времени яхты</span>
</div>
</div>
</div>
);
}
return (
<div className="space-y-4 w-full">
<div className="flex items-center justify-between">
<h2 className="text-base font-bold text-[#333333]">Доступность яхты</h2>
</div>
<div className="bg-white w-full">
<div className="w-full flex justify-end mb-8">
<div className="flex items-center gap-5">
<button
onClick={goToPreviousMonth}
className="cursor-pointer rounded-full border border-[#dfdfdf] h-12 w-12 flex items-center justify-center"
>
<ChevronLeftIcon className="size-4" />
</button>
<span className="text-2xl text-[#333333]">
{format(currentMonth, "LLLL yyyy", { locale: ru })}
</span>
<button
onClick={goToNextMonth}
className="cursor-pointer rounded-full border border-[#dfdfdf] h-12 w-12 flex items-center justify-center"
>
<ChevronRightIcon className="size-4" />
</button>
</div>
</div>
<Calendar
mode="single"
month={currentMonth}
onMonthChange={setCurrentMonth}
showOutsideDays={false}
className="w-full p-0"
locale={ru}
classNames={{
root: "w-full",
month: "flex w-full flex-col gap-4",
nav: "absolute inset-x-0 top-0 flex w-full items-center justify-between gap-2",
month_caption: "hidden",
caption_label: "text-2xl",
button_previous: "hidden",
button_next: "hidden",
table: "w-full border-collapse",
weekdays: "hidden",
weekday: "flex-1 text-gray-500 text-xs font-normal p-2 text-center",
week: "flex w-full",
day: "relative flex-1",
}}
components={{
DayButton: ({ day, ...props }) => {
// Показываем только дни текущего месяца
if (!isSameMonth(day.date, currentMonth)) {
return <div className="hidden" />;
}
const isCrossedOut = shouldBeCrossedOut(day.date);
return (
<button
{...props}
className="relative w-full h-20 flex flex-col items-start justify-start px-2 py-[2px] border border-gray-200"
disabled={isCrossedOut}
>
{isCrossedOut ? (
// Перечеркнутая ячейка для недоступных дней
<>
<span className="text-sm font-medium text-[#333333] self-end">
{day.date.getDate()}
</span>
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-gray-300 text-4xl font-light leading-none">
</span>
</div>
</>
) : (
// Доступный день с информацией
<>
{/* Дата и "Доступно:" в одной строке */}
<div className="flex items-center justify-between w-full">
<span className="text-xs text-gray-400">
{hasReservationsOnDate(day.date)
? "Бронь:"
: "Доступно:"}
</span>
<span className="text-sm font-medium text-[#333333]">
{day.date.getDate()}
</span>
</div>
{/* Time slots - reservations first */}
{renderTimeSlots(day.date)}
{/* Цена в нижнем правом углу */}
<span className="absolute bottom-[2px] right-[4px] text-xs text-[#333333] font-medium">
{price} / час
</span>
</>
)}
</button>
);
},
}}
/>
</div>
</div>
);
}