Доработка флоу

This commit is contained in:
Sergey Bolshakov 2025-12-15 14:53:50 +03:00
parent b249ab597b
commit 63725ff710
9 changed files with 1401 additions and 964 deletions

View File

@ -1,11 +1,12 @@
"use client"; "use client";
import { useState } from "react"; import { useState, useMemo } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { DatePicker } from "@/components/ui/date-picker"; import { DatePicker } from "@/components/ui/date-picker";
import { GuestPicker } from "@/components/form/guest-picker"; import { GuestPicker } from "@/components/form/guest-picker";
import { format } from "date-fns"; import { format } from "date-fns";
import { calculateTotalPrice, formatPrice } from "@/lib/utils";
interface BookingWidgetProps { interface BookingWidgetProps {
price: string; price: string;
@ -16,22 +17,43 @@ export function BookingWidget({ price, yacht }: BookingWidgetProps) {
const router = useRouter(); const router = useRouter();
const [departureDate, setDepartureDate] = useState<Date | undefined>(); const [departureDate, setDepartureDate] = useState<Date | undefined>();
const [arrivalDate, setArrivalDate] = useState<Date | undefined>(); const [arrivalDate, setArrivalDate] = useState<Date | undefined>();
const [departureTime, setDepartureTime] = useState<string>("12:00");
const [arrivalTime, setArrivalTime] = useState<string>("13:00");
const [guests, setGuests] = useState({ adults: 1, children: 0 }); const [guests, setGuests] = useState({ adults: 1, children: 0 });
const [total] = useState(0);
// Расчет итоговой стоимости
const total = useMemo(() => {
if (!departureDate || !arrivalDate || !departureTime || !arrivalTime || !yacht?.minCost) {
return 0;
}
const departureDateStr = format(departureDate, "yyyy-MM-dd");
const arrivalDateStr = format(arrivalDate, "yyyy-MM-dd");
const { totalPrice } = calculateTotalPrice(
departureDateStr,
departureTime,
arrivalDateStr,
arrivalTime,
yacht.minCost
);
return totalPrice;
}, [departureDate, arrivalDate, departureTime, arrivalTime, yacht?.minCost]);
const handleGuestsChange = (adults: number, children: number) => { const handleGuestsChange = (adults: number, children: number) => {
setGuests({ adults, children }); setGuests({ adults, children });
}; };
const handleBook = () => { const handleBook = () => {
if (!departureDate || !arrivalDate || !yacht || !yacht.id) return; if (!departureDate || !arrivalDate || !departureTime || !arrivalTime || !yacht || !yacht.id) return;
const params = new URLSearchParams({ const params = new URLSearchParams({
yachtId: yacht.id.toString(), yachtId: yacht.id.toString(),
departureDate: format(departureDate, "yyyy-MM-dd"), departureDate: format(departureDate, "yyyy-MM-dd"),
departureTime: format(departureDate, "HH:mm"), departureTime: departureTime,
arrivalDate: format(arrivalDate, "yyyy-MM-dd"), arrivalDate: format(arrivalDate, "yyyy-MM-dd"),
arrivalTime: format(arrivalDate, "HH:mm"), arrivalTime: arrivalTime,
guests: (guests.adults + guests.children).toString(), guests: (guests.adults + guests.children).toString(),
}); });
@ -58,6 +80,8 @@ export function BookingWidget({ price, yacht }: BookingWidgetProps) {
showIcon={false} showIcon={false}
onDateChange={setDepartureDate} onDateChange={setDepartureDate}
value={departureDate} value={departureDate}
departureTime={departureTime}
onDepartureTimeChange={setDepartureTime}
onlyDeparture onlyDeparture
/> />
</div> </div>
@ -72,6 +96,8 @@ export function BookingWidget({ price, yacht }: BookingWidgetProps) {
showIcon={false} showIcon={false}
onDateChange={setArrivalDate} onDateChange={setArrivalDate}
value={arrivalDate} value={arrivalDate}
arrivalTime={arrivalTime}
onArrivalTimeChange={setArrivalTime}
onlyArrival onlyArrival
/> />
</div> </div>
@ -95,7 +121,7 @@ export function BookingWidget({ price, yacht }: BookingWidgetProps) {
onClick={handleBook} onClick={handleBook}
variant="gradient" variant="gradient"
className="w-full h-12 font-bold text-white mb-4" className="w-full h-12 font-bold text-white mb-4"
disabled={!departureDate || !arrivalDate} disabled={!departureDate || !arrivalDate || !departureTime || !arrivalTime}
> >
Забронировать Забронировать
</Button> </Button>
@ -103,7 +129,7 @@ export function BookingWidget({ price, yacht }: BookingWidgetProps) {
<div className="pt-4 border-t border-gray-200"> <div className="pt-4 border-t border-gray-200">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<span className="text-base text-[#333333]">Итого:</span> <span className="text-base text-[#333333]">Итого:</span>
<span className="text-base font-bold text-[#333333]">{total} </span> <span className="text-base font-bold text-[#333333]">{formatPrice(total)} </span>
</div> </div>
</div> </div>
</div> </div>

View File

@ -26,19 +26,53 @@ interface YachtAvailabilityProps {
price: string; price: string;
mobile?: boolean; mobile?: boolean;
reservations?: Reservation[]; reservations?: Reservation[];
// Controlled props для мобильной версии
selectedDate?: Date;
startTime?: string;
endTime?: string;
onDateChange?: (date: Date | undefined) => void;
onStartTimeChange?: (time: string) => void;
onEndTimeChange?: (time: string) => void;
} }
export function YachtAvailability({ export function YachtAvailability({
price, price,
mobile = false, mobile = false,
reservations = [], reservations = [],
selectedDate,
startTime: controlledStartTime,
endTime: controlledEndTime,
onDateChange,
onStartTimeChange,
onEndTimeChange,
}: YachtAvailabilityProps) { }: YachtAvailabilityProps) {
const today = startOfDay(new Date()); const today = startOfDay(new Date());
const [currentMonth, setCurrentMonth] = useState( const [currentMonth, setCurrentMonth] = useState(
new Date(today.getFullYear(), today.getMonth(), 1) new Date(today.getFullYear(), today.getMonth(), 1)
); );
const [startTime, setStartTime] = useState<string>("");
const [endTime, setEndTime] = useState<string>(""); // Используем контролируемые значения или внутреннее состояние
const [internalStartTime, setInternalStartTime] = useState<string>("");
const [internalEndTime, setInternalEndTime] = useState<string>("");
const startTime = mobile && controlledStartTime !== undefined ? controlledStartTime : internalStartTime;
const endTime = mobile && controlledEndTime !== undefined ? controlledEndTime : internalEndTime;
const handleStartTimeChange = (time: string) => {
if (mobile && onStartTimeChange) {
onStartTimeChange(time);
} else {
setInternalStartTime(time);
}
};
const handleEndTimeChange = (time: string) => {
if (mobile && onEndTimeChange) {
onEndTimeChange(time);
} else {
setInternalEndTime(time);
}
};
const unavailableDates = Array.from({ length: 26 }, (_, i) => { const unavailableDates = Array.from({ length: 26 }, (_, i) => {
return new Date(2025, 3, i + 1); return new Date(2025, 3, i + 1);
@ -214,6 +248,12 @@ export function YachtAvailability({
<div style={{ flexShrink: 0 }}> <div style={{ flexShrink: 0 }}>
<Calendar <Calendar
mode="single" mode="single"
selected={selectedDate}
onSelect={(date) => {
if (onDateChange) {
onDateChange(date);
}
}}
month={currentMonth} month={currentMonth}
onMonthChange={setCurrentMonth} onMonthChange={setCurrentMonth}
showOutsideDays={false} showOutsideDays={false}
@ -248,6 +288,10 @@ export function YachtAvailability({
const isCrossedOut = shouldBeCrossedOut(day.date); const isCrossedOut = shouldBeCrossedOut(day.date);
const hasRes = hasReservationsOnDate(day.date); const hasRes = hasReservationsOnDate(day.date);
const isSelected = selectedDate &&
selectedDate.getDate() === day.date.getDate() &&
selectedDate.getMonth() === day.date.getMonth() &&
selectedDate.getFullYear() === day.date.getFullYear();
return ( return (
<button <button
@ -255,6 +299,8 @@ export function YachtAvailability({
className={`relative w-full flex items-center justify-center text-sm font-medium transition-colors ${ className={`relative w-full flex items-center justify-center text-sm font-medium transition-colors ${
isCrossedOut isCrossedOut
? "text-[#CCCCCC] line-through" ? "text-[#CCCCCC] line-through"
: isSelected
? "bg-[#008299] text-white rounded-full"
: "text-[#333333] hover:bg-gray-100" : "text-[#333333] hover:bg-gray-100"
}`} }`}
style={ style={
@ -266,7 +312,7 @@ export function YachtAvailability({
disabled={isCrossedOut} disabled={isCrossedOut}
> >
{day.date.getDate()} {day.date.getDate()}
{hasRes && !isCrossedOut && ( {hasRes && !isCrossedOut && !isSelected && (
<div className="absolute bottom-1 right-1 w-1.5 h-1.5 bg-[#2F5CD0] rounded-full"></div> <div className="absolute bottom-1 right-1 w-1.5 h-1.5 bg-[#2F5CD0] rounded-full"></div>
)} )}
</button> </button>
@ -282,7 +328,7 @@ export function YachtAvailability({
<div className="flex-1"> <div className="flex-1">
<select <select
value={startTime} value={startTime}
onChange={(e) => setStartTime(e.target.value)} onChange={(e) => handleStartTimeChange(e.target.value)}
className="w-full px-4 py-3 border border-[#DFDFDF] rounded-lg text-base text-[#333333] bg-white appearance-none" className="w-full px-4 py-3 border border-[#DFDFDF] rounded-lg text-base text-[#333333] bg-white appearance-none"
> >
<option value="">--:--</option> <option value="">--:--</option>
@ -296,7 +342,7 @@ export function YachtAvailability({
<div className="flex-1"> <div className="flex-1">
<select <select
value={endTime} value={endTime}
onChange={(e) => setEndTime(e.target.value)} onChange={(e) => handleEndTimeChange(e.target.value)}
className="w-full px-4 py-3 border border-[#DFDFDF] rounded-lg text-base text-[#333333] bg-white appearance-none" className="w-full px-4 py-3 border border-[#DFDFDF] rounded-lg text-base text-[#333333] bg-white appearance-none"
> >
<option value="">--:--</option> <option value="">--:--</option>

View File

@ -10,287 +10,359 @@ import { YachtAvailability } from "./components/YachtAvailability";
import { BookingWidget } from "./components/BookingWidget"; import { BookingWidget } from "./components/BookingWidget";
import { YachtCharacteristics } from "./components/YachtCharacteristics"; import { YachtCharacteristics } from "./components/YachtCharacteristics";
import { ContactInfo } from "./components/ContactInfo"; import { ContactInfo } from "./components/ContactInfo";
import { GuestPicker } from "@/components/form/guest-picker";
import useApiClient from "@/hooks/useApiClient"; import useApiClient from "@/hooks/useApiClient";
import { formatSpeed } from "@/lib/utils"; import { formatSpeed } from "@/lib/utils";
import { format } from "date-fns";
export default function YachtDetailPage() { export default function YachtDetailPage() {
const { id } = useParams(); const { id } = useParams();
const [yacht, setYacht] = useState<CatalogItemLongDto | null>(null); const [yacht, setYacht] = useState<CatalogItemLongDto | null>(null);
const client = useApiClient(); const client = useApiClient();
useEffect(() => { useEffect(() => {
(async () => { (async () => {
const response = await client.get<CatalogItemLongDto>(`/catalog/${id}/`); const response = await client.get<CatalogItemLongDto>(
`/catalog/${id}/`
);
setYacht(response.data); setYacht(response.data);
})(); })();
}, [id]); }, [id]);
// const params = useParams(); // const params = useParams();
const router = useRouter(); const router = useRouter();
const [activeTab, setActiveTab] = useState< const [activeTab, setActiveTab] = useState<
| "availability" | "availability"
| "description" | "description"
| "characteristics" | "characteristics"
| "contact" | "contact"
| "requisites" | "requisites"
| "reviews" | "reviews"
>("availability"); >("availability");
if (!yacht) { // Состояние для мобильного бронирования
return <div />; const [selectedDate, setSelectedDate] = useState<Date | undefined>();
} const [startTime, setStartTime] = useState<string>("");
const [endTime, setEndTime] = useState<string>("");
const [guests, setGuests] = useState({ adults: 1, children: 0 });
return ( const handleGuestsChange = (adults: number, children: number) => {
<main className="bg-[#f4f4f4] min-h-screen "> setGuests({ adults, children });
{/* Мобильная фиксированная верхняя панель навигации */} };
<div className="lg:hidden fixed top-[73px] left-0 right-0 z-[50] bg-white border-b border-gray-200">
<div className="flex items-center justify-between px-4 h-14">
<button
onClick={() => router.back()}
className="flex items-center justify-center"
>
<ArrowLeft size={24} className="text-[#333333]" />
</button>
<h2 className="text-base font-medium text-[#333333]">Яхта</h2>
<button className="flex items-center justify-center">
<Heart size={24} className="text-[#333333]" />
</button>
</div>
</div>
{/* Десктопная версия - Breadcrumbs */} const handleBookMobile = () => {
<div className="hidden lg:block container max-w-6xl mx-auto px-4 py-4"> if (!selectedDate || !startTime || !endTime || !yacht || !yacht.id)
<div className="text-sm text-[#999999] flex items-center gap-4"> return;
<Link href="/">
<span className="cursor-pointer hover:text-[#333333] transition-colors">
Аренда яхты
</span>
</Link>
<span>&gt;</span>
<Link href="/catalog">
<span className="cursor-pointer hover:text-[#333333] transition-colors">
Моторные яхты
</span>
</Link>
<span>&gt;</span>
<span className="text-[#333333]">{yacht.name}</span>
</div>
</div>
{/* Main Content Container */} // Используем выбранную дату как дату отправления и прибытия (можно изменить логику при необходимости)
<div className="lg:container lg:max-w-6xl lg:mx-auto lg:px-4 lg:pb-6"> const departureDate = format(selectedDate, "yyyy-MM-dd");
<div className="bg-white lg:rounded-[16px] lg:p-6"> const arrivalDate = format(selectedDate, "yyyy-MM-dd");
{/* Мобильная версия - без отступов сверху, с отступом для фиксированной панели */}
<div className="lg:hidden pt-[50px]">
{/* Gallery */}
<YachtGallery
images={yacht.galleryUrls || []}
badge={!yacht.hasQuickRent ? "По запросу" : ""}
/>
{/* Yacht Title */} const params = new URLSearchParams({
<div className="px-4 pt-4"> yachtId: yacht.id.toString(),
<h1 className="text-xl font-bold text-[#333333] mb-4"> departureDate: departureDate,
{yacht.name} departureTime: startTime,
</h1> arrivalDate: arrivalDate,
</div> arrivalTime: endTime,
guests: (guests.adults + guests.children).toString(),
});
{/* Tabs */} router.push(`/confirm?${params.toString()}`);
<div className="px-4 border-b border-gray-200 overflow-x-auto"> };
<div className="flex gap-6 min-w-max">
<button
onClick={() => setActiveTab("availability")}
className={`pb-3 text-base font-medium transition-colors whitespace-nowrap ${
activeTab === "availability"
? "text-[#008299] border-b-2 border-[#008299]"
: "text-[#999999]"
}`}
>
Свободные даты
</button>
<button
onClick={() => setActiveTab("description")}
className={`pb-3 text-base font-medium transition-colors whitespace-nowrap ${
activeTab === "description"
? "text-[#008299] border-b-2 border-[#008299]"
: "text-[#999999]"
}`}
>
Описание
</button>
<button
onClick={() => setActiveTab("characteristics")}
className={`pb-3 text-base font-medium transition-colors whitespace-nowrap ${
activeTab === "characteristics"
? "text-[#008299] border-b-2 border-[#008299]"
: "text-[#999999]"
}`}
>
Характеристики
</button>
<button
onClick={() => setActiveTab("contact")}
className={`pb-3 text-base font-medium transition-colors whitespace-nowrap ${
activeTab === "contact"
? "text-[#008299] border-b-2 border-[#008299]"
: "text-[#999999]"
}`}
>
Контактное лицо и реквизиты
</button>
<button
onClick={() => setActiveTab("reviews")}
className={`pb-3 text-base font-medium transition-colors whitespace-nowrap ${
activeTab === "reviews"
? "text-[#008299] border-b-2 border-[#008299]"
: "text-[#999999]"
}`}
>
Отзывы
</button>
</div>
</div>
{/* Tab Content */} if (!yacht) {
<div className="px-4 py-6"> return <div />;
{activeTab === "availability" && ( }
<YachtAvailability
price={String(yacht.minCost)} return (
mobile={true} <main className="bg-[#f4f4f4] min-h-screen ">
reservations={yacht.reservations} {/* Мобильная фиксированная верхняя панель навигации */}
/> <div className="lg:hidden fixed top-[73px] left-0 right-0 z-[50] bg-white border-b border-gray-200">
)} <div className="flex items-center justify-between px-4 h-14">
{activeTab === "description" && ( <button
<div> onClick={() => router.back()}
<p className="text-base text-[#666666] leading-relaxed"> className="flex items-center justify-center"
{yacht.description} >
</p> <ArrowLeft size={24} className="text-[#333333]" />
</div> </button>
)} <h2 className="text-base font-medium text-[#333333]">
{activeTab === "characteristics" && ( Яхта
<YachtCharacteristics yacht={yacht} />
)}
{activeTab === "contact" && <ContactInfo {...yacht.owner} />}
{activeTab === "reviews" && (
<div>
<div className="flex items-center gap-2 mb-4">
<Icon name="reviewStar" size={16} />
<h2 className="text-base font-bold text-[#333333]">
Отзывы
</h2> </h2>
</div> <button className="flex items-center justify-center">
<div className="border border-[#DFDFDF] rounded-[12px] flex items-center justify-center py-18"> <Heart size={24} className="text-[#333333]" />
<p className="text-lg text-[#999999]"> </button>
У этой яхты пока нет отзывов
</p>
</div>
</div> </div>
)}
</div>
</div>
{/* Десктопная версия */}
<div className="hidden lg:block">
{/* Yacht Title and Actions */}
<div className="mb-6 flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<h1 className="text-xl md:text-2xl font-bold text-[#333333]">
{yacht.name}
</h1>
<div className="flex items-center gap-6">
<div className="flex items-center gap-2 text-[#333333]">
<Icon name="pin" size={32} />
<span className="text-base">{formatSpeed(yacht.speed)}</span>
</div>
<button className="flex items-center gap-2 cursor-pointer text-[#333333] hover:text-[#008299] transition-colors">
<Icon name="share" size={32} />
<span className="text-base">Поделиться</span>
</button>
<button className="flex items-center gap-2 cursor-pointer text-[#333333] hover:text-[#008299] transition-colors">
<Icon name="heart" size={32} />
<span className="text-base">Избранное</span>
</button>
</div>
</div> </div>
{/* Main Content */} {/* Десктопная версия - Breadcrumbs */}
<div className="space-y-6"> <div className="hidden lg:block container max-w-6xl mx-auto px-4 py-4">
{/* Gallery */} <div className="text-sm text-[#999999] flex items-center gap-4">
<YachtGallery <Link href="/">
images={yacht.galleryUrls || []} <span className="cursor-pointer hover:text-[#333333] transition-colors">
badge={!yacht.hasQuickRent ? "По запросу" : ""} Аренда яхты
/> </span>
</Link>
<span>&gt;</span>
<Link href="/catalog">
<span className="cursor-pointer hover:text-[#333333] transition-colors">
Моторные яхты
</span>
</Link>
<span>&gt;</span>
<span className="text-[#333333]">{yacht.name}</span>
</div>
</div>
{/* Content with Booking Widget on the right */} {/* Main Content Container */}
<div className="flex flex-col lg:flex-row gap-6 items-start"> <div className="lg:container lg:max-w-6xl lg:mx-auto lg:px-4 lg:pb-6">
{/* Left column - all content below gallery */} <div className="bg-white lg:rounded-[16px] lg:p-6">
<div className="flex-1 space-y-6"> {/* Мобильная версия - без отступов сверху, с отступом для фиксированной панели */}
{/* Availability */} <div className="lg:hidden pt-[50px]">
<YachtAvailability {/* Gallery */}
price={String(yacht.minCost)} <YachtGallery
reservations={yacht.reservations} images={yacht.galleryUrls || []}
/> badge={!yacht.hasQuickRent ? "По запросу" : ""}
/>
{/* Characteristics */} {/* Yacht Title */}
<YachtCharacteristics yacht={yacht} /> <div className="px-4 pt-4">
<h1 className="text-xl font-bold text-[#333333] mb-4">
{yacht.name}
</h1>
</div>
{/* Description */} {/* Tabs */}
<div> <div className="px-4 border-b border-gray-200 overflow-x-auto">
<h2 className="text-base font-bold text-[#333333] mb-4"> <div className="flex gap-6 min-w-max">
Описание <button
</h2> onClick={() => setActiveTab("availability")}
<p className="text-base text-[#666666] leading-relaxed"> className={`pb-3 text-base font-medium transition-colors whitespace-nowrap ${
{yacht.description} activeTab === "availability"
</p> ? "text-[#008299] border-b-2 border-[#008299]"
</div> : "text-[#999999]"
}`}
>
Свободные даты
</button>
<button
onClick={() => setActiveTab("description")}
className={`pb-3 text-base font-medium transition-colors whitespace-nowrap ${
activeTab === "description"
? "text-[#008299] border-b-2 border-[#008299]"
: "text-[#999999]"
}`}
>
Описание
</button>
<button
onClick={() =>
setActiveTab("characteristics")
}
className={`pb-3 text-base font-medium transition-colors whitespace-nowrap ${
activeTab === "characteristics"
? "text-[#008299] border-b-2 border-[#008299]"
: "text-[#999999]"
}`}
>
Характеристики
</button>
<button
onClick={() => setActiveTab("contact")}
className={`pb-3 text-base font-medium transition-colors whitespace-nowrap ${
activeTab === "contact"
? "text-[#008299] border-b-2 border-[#008299]"
: "text-[#999999]"
}`}
>
Контактное лицо и реквизиты
</button>
<button
onClick={() => setActiveTab("reviews")}
className={`pb-3 text-base font-medium transition-colors whitespace-nowrap ${
activeTab === "reviews"
? "text-[#008299] border-b-2 border-[#008299]"
: "text-[#999999]"
}`}
>
Отзывы
</button>
</div>
</div>
{/* Contact and Requisites */} {/* Tab Content */}
<ContactInfo {...yacht.owner} /> <div className="px-4 py-6">
{activeTab === "availability" && (
{/* Reviews */} <>
<div> <YachtAvailability
<div className="flex items-center gap-2 mb-4"> price={String(yacht.minCost)}
<Icon name="reviewStar" size={16} /> mobile={true}
<h2 className="text-base font-bold text-[#333333]"> reservations={yacht.reservations}
Отзывы selectedDate={selectedDate}
</h2> startTime={startTime}
endTime={endTime}
onDateChange={setSelectedDate}
onStartTimeChange={setStartTime}
onEndTimeChange={setEndTime}
/>
{/* Выбор гостей для мобильной версии */}
<div className="mt-6">
<label className="block text-sm font-medium text-[#333333] mb-2">
Гостей
</label>
<GuestPicker
adults={guests.adults}
childrenCount={guests.children}
onChange={handleGuestsChange}
variant="small"
showIcon={false}
placeholder="1 гость"
/>
</div>
</>
)}
{activeTab === "description" && (
<div>
<p className="text-base text-[#666666] leading-relaxed">
{yacht.description}
</p>
</div>
)}
{activeTab === "characteristics" && (
<YachtCharacteristics yacht={yacht} />
)}
{activeTab === "contact" && (
<ContactInfo {...yacht.owner} />
)}
{activeTab === "reviews" && (
<div>
<div className="flex items-center gap-2 mb-4">
<Icon name="reviewStar" size={16} />
<h2 className="text-base font-bold text-[#333333]">
Отзывы
</h2>
</div>
<div className="border border-[#DFDFDF] rounded-[12px] flex items-center justify-center py-18">
<p className="text-lg text-[#999999]">
У этой яхты пока нет отзывов
</p>
</div>
</div>
)}
</div>
</div> </div>
<div className="border border-[#DFDFDF] rounded-[12px] flex items-center justify-center py-18">
<p className="text-lg text-[#999999]"> {/* Десктопная версия */}
У этой яхты пока нет отзывов <div className="hidden lg:block">
</p> {/* Yacht Title and Actions */}
<div className="mb-6 flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<h1 className="text-xl md:text-2xl font-bold text-[#333333]">
{yacht.name}
</h1>
<div className="flex items-center gap-6">
<div className="flex items-center gap-2 text-[#333333]">
<Icon name="pin" size={32} />
<span className="text-base">
{formatSpeed(yacht.speed)}
</span>
</div>
<button className="flex items-center gap-2 cursor-pointer text-[#333333] hover:text-[#008299] transition-colors">
<Icon name="share" size={32} />
<span className="text-base">
Поделиться
</span>
</button>
<button className="flex items-center gap-2 cursor-pointer text-[#333333] hover:text-[#008299] transition-colors">
<Icon name="heart" size={32} />
<span className="text-base">Избранное</span>
</button>
</div>
</div>
{/* Main Content */}
<div className="space-y-6">
{/* Gallery */}
<YachtGallery
images={yacht.galleryUrls || []}
badge={!yacht.hasQuickRent ? "По запросу" : ""}
/>
{/* Content with Booking Widget on the right */}
<div className="flex flex-col lg:flex-row gap-6 items-start">
{/* Left column - all content below gallery */}
<div className="flex-1 space-y-6">
{/* Availability */}
<YachtAvailability
price={String(yacht.minCost)}
reservations={yacht.reservations}
/>
{/* Characteristics */}
<YachtCharacteristics yacht={yacht} />
{/* Description */}
<div>
<h2 className="text-base font-bold text-[#333333] mb-4">
Описание
</h2>
<p className="text-base text-[#666666] leading-relaxed">
{yacht.description}
</p>
</div>
{/* Contact and Requisites */}
<ContactInfo {...yacht.owner} />
{/* Reviews */}
<div>
<div className="flex items-center gap-2 mb-4">
<Icon name="reviewStar" size={16} />
<h2 className="text-base font-bold text-[#333333]">
Отзывы
</h2>
</div>
<div className="border border-[#DFDFDF] rounded-[12px] flex items-center justify-center py-18">
<p className="text-lg text-[#999999]">
У этой яхты пока нет отзывов
</p>
</div>
</div>
</div>
{/* Right column - Booking Widget (sticky) */}
<div className="lg:w-74 flex-shrink-0 lg:sticky lg:top-24 self-start">
<BookingWidget
price={String(yacht.minCost)}
yacht={yacht}
/>
</div>
</div>
</div>
</div> </div>
</div>
</div> </div>
{/* Right column - Booking Widget (sticky) */}
<div className="lg:w-74 flex-shrink-0 lg:sticky lg:top-24 self-start">
<BookingWidget price={String(yacht.minCost)} yacht={yacht} />
</div>
</div>
</div> </div>
</div>
</div>
</div>
{/* Мобильная фиксированная нижняя панель бронирования */} {/* Мобильная фиксированная нижняя панель бронирования */}
<div className="lg:hidden sticky bottom-0 left-0 right-0 z-[10] bg-white border-t border-gray-200 px-4 py-3"> <div className="lg:hidden sticky bottom-0 left-0 right-0 z-[10] bg-white border-t border-gray-200 px-4 py-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<span className="text-lg font-bold text-[#333333]"> <span className="text-lg font-bold text-[#333333]">
{yacht.minCost} {yacht.minCost}
</span> </span>
<span className="text-sm text-[#999999] ml-1">/ час</span> <span className="text-sm text-[#999999] ml-1">
</div> / час
<button </span>
onClick={() => router.push(`/confirm?yachtId=${yacht.id}`)} </div>
className="bg-[#008299] text-white px-6 py-3 rounded-lg font-bold text-base hover:bg-[#006d7a] transition-colors" <button
> onClick={handleBookMobile}
Забронировать disabled={!selectedDate || !startTime || !endTime}
</button> className="bg-[#008299] text-white px-6 py-3 rounded-lg font-bold text-base hover:bg-[#006d7a] transition-colors disabled:bg-gray-300 disabled:cursor-not-allowed"
</div> >
</div> Забронировать
</main> </button>
); </div>
</div>
</main>
);
} }

View File

@ -12,20 +12,80 @@ import {
import Image from "next/image"; import Image from "next/image";
import Icon from "@/components/ui/icon"; import Icon from "@/components/ui/icon";
import { useState } from "react"; import { useState } from "react";
import { GuestDatePicker } from "@/components/form/guest-date-picker"; import { useRouter } from "next/navigation";
import { formatMinCost, formatWidth, getImageUrl } from "@/lib/utils"; import { GuestDatePicker, GuestDatePickerValue } from "@/components/form/guest-date-picker";
import { formatMinCost, formatWidth, getImageUrl, calculateTotalPrice, formatPrice } from "@/lib/utils";
import { format } from "date-fns";
export default function FeaturedYacht({ export default function FeaturedYacht({
yacht, yacht,
}: { }: {
yacht: CatalogItemShortDto; yacht: CatalogItemShortDto;
}) { }) {
const router = useRouter();
const [selectedImage, setSelectedImage] = useState(yacht.mainImageUrl); const [selectedImage, setSelectedImage] = useState(yacht.mainImageUrl);
const [bookingData, setBookingData] = useState<GuestDatePickerValue>({
date: undefined,
departureTime: "12:00",
arrivalTime: "13:00",
adults: 1,
children: 0,
});
const handleThumbnailClick = (imageSrc: string) => { const handleThumbnailClick = (imageSrc: string) => {
setSelectedImage(imageSrc); setSelectedImage(imageSrc);
}; };
// Расчет итоговой стоимости
const getTotalPrice = () => {
if (!bookingData.date || !bookingData.departureTime || !bookingData.arrivalTime) {
return 0;
}
// Форматируем дату в ISO строку для calculateTotalPrice
const dateString = format(bookingData.date, "yyyy-MM-dd");
const { totalPrice } = calculateTotalPrice(
dateString,
bookingData.departureTime,
dateString, // Используем ту же дату для arrival
bookingData.arrivalTime,
yacht.minCost
);
return totalPrice;
};
// Обработчик нажатия на кнопку "Забронировать"
const handleBookClick = () => {
if (!bookingData.date || !yacht.id) {
return;
}
// Форматируем дату в формат yyyy-MM-dd
const dateString = format(bookingData.date, "yyyy-MM-dd");
// Кодируем время для URL (00:00 -> 00%3A00)
const encodedDepartureTime = encodeURIComponent(bookingData.departureTime);
const encodedArrivalTime = encodeURIComponent(bookingData.arrivalTime);
// Вычисляем общее количество гостей
const totalGuests = bookingData.adults + bookingData.children;
// Формируем URL с параметрами
const params = new URLSearchParams({
yachtId: yacht.id.toString(),
departureDate: dateString,
departureTime: encodedDepartureTime,
arrivalDate: dateString, // Используем ту же дату для arrival
arrivalTime: encodedArrivalTime,
guests: totalGuests.toString(),
});
// Переходим на страницу подтверждения
router.push(`/confirm?${params.toString()}`);
};
return ( return (
<div className="mb-10"> <div className="mb-10">
<Card className="overflow-hidden bg-white text-gray-900"> <Card className="overflow-hidden bg-white text-gray-900">
@ -157,13 +217,18 @@ export default function FeaturedYacht({
{/* Booking form */} {/* Booking form */}
<div className="mb-8"> <div className="mb-8">
<GuestDatePicker /> <GuestDatePicker
value={bookingData}
onChange={setBookingData}
/>
</div> </div>
{/* Book button */} {/* Book button */}
<Button <Button
variant="gradient" variant="gradient"
className="font-bold text-white h-[64px] w-full px-8" className="font-bold text-white h-[64px] w-full px-8"
onClick={handleBookClick}
disabled={!bookingData.date || !yacht.id}
> >
Забронировать Забронировать
</Button> </Button>
@ -171,7 +236,7 @@ export default function FeaturedYacht({
{/* Total price */} {/* Total price */}
<div className="flex justify-between items-center text-l mt-6 font-bold text-gray-800"> <div className="flex justify-between items-center text-l mt-6 font-bold text-gray-800">
<span className="font-normal">Итого:</span> <span className="font-normal">Итого:</span>
<span>0 </span> <span>{formatPrice(getTotalPrice())} </span>
</div> </div>
</div> </div>
</div> </div>

File diff suppressed because it is too large Load Diff

View File

@ -14,14 +14,18 @@ import {
PopoverTrigger, PopoverTrigger,
} from "@/components/ui/popover"; } from "@/components/ui/popover";
export interface GuestDatePickerValue {
date: Date | undefined;
departureTime: string;
arrivalTime: string;
adults: number;
children: number;
}
interface GuestDatePickerProps { interface GuestDatePickerProps {
onApply?: (data: { value?: GuestDatePickerValue;
date: Date | undefined; onChange?: (value: GuestDatePickerValue) => void;
departureTime: string; onApply?: (data: GuestDatePickerValue) => void;
arrivalTime: string;
adults: number;
children: number;
}) => void;
className?: string; className?: string;
} }
@ -149,26 +153,94 @@ const CommonPopoverContent: React.FC<CommonPopoverContentProps> = ({
}; };
export const GuestDatePicker: React.FC<GuestDatePickerProps> = ({ export const GuestDatePicker: React.FC<GuestDatePickerProps> = ({
value,
onChange,
onApply, onApply,
className, className,
}) => { }) => {
const [date, setDate] = useState<Date>(); // Используем controlled значения, если они переданы, иначе используем внутреннее состояние
const [departureTime, setDepartureTime] = useState("12:00"); const isControlled = value !== undefined;
const [arrivalTime, setArrivalTime] = useState("13:00");
const [adults, setAdults] = useState(1); const [internalDate, setInternalDate] = useState<Date>();
const [children, setChildren] = useState(0); const [internalDepartureTime, setInternalDepartureTime] = useState("12:00");
const [internalArrivalTime, setInternalArrivalTime] = useState("13:00");
const [internalAdults, setInternalAdults] = useState(1);
const [internalChildren, setInternalChildren] = useState(0);
const date = isControlled ? value.date : internalDate;
const departureTime = isControlled ? value.departureTime : internalDepartureTime;
const arrivalTime = isControlled ? value.arrivalTime : internalArrivalTime;
const adults = isControlled ? value.adults : internalAdults;
const children = isControlled ? value.children : internalChildren;
const setDate = (newDate: Date | undefined) => {
if (isControlled) {
onChange?.({
...value,
date: newDate,
});
} else {
setInternalDate(newDate);
}
};
const setDepartureTime = (newTime: string) => {
if (isControlled) {
onChange?.({
...value,
departureTime: newTime,
});
} else {
setInternalDepartureTime(newTime);
}
};
const setArrivalTime = (newTime: string) => {
if (isControlled) {
onChange?.({
...value,
arrivalTime: newTime,
});
} else {
setInternalArrivalTime(newTime);
}
};
const setAdults = (newAdults: number) => {
if (isControlled) {
onChange?.({
...value,
adults: newAdults,
});
} else {
setInternalAdults(newAdults);
}
};
const setChildren = (newChildren: number) => {
if (isControlled) {
onChange?.({
...value,
children: newChildren,
});
} else {
setInternalChildren(newChildren);
}
};
const [isDepartureOpen, setIsDepartureOpen] = useState(false); const [isDepartureOpen, setIsDepartureOpen] = useState(false);
const [isArrivalOpen, setIsArrivalOpen] = useState(false); const [isArrivalOpen, setIsArrivalOpen] = useState(false);
const [isGuestOpen, setIsGuestOpen] = useState(false); const [isGuestOpen, setIsGuestOpen] = useState(false);
const handleApply = () => { const handleApply = () => {
onApply?.({ const currentValue = {
date, date,
departureTime, departureTime,
arrivalTime, arrivalTime,
adults, adults,
children, children,
}); };
onApply?.(currentValue);
setIsDepartureOpen(false); setIsDepartureOpen(false);
setIsArrivalOpen(false); setIsArrivalOpen(false);
setIsGuestOpen(false); setIsGuestOpen(false);

View File

@ -8,202 +8,224 @@ import { ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar"; import { Calendar } from "@/components/ui/calendar";
import { import {
Popover, Popover,
PopoverContent, PopoverContent,
PopoverTrigger, PopoverTrigger,
} from "@/components/ui/popover"; } from "@/components/ui/popover";
import Icon from "./icon"; import Icon from "./icon";
interface DatePickerProps { interface DatePickerProps {
showIcon?: boolean; showIcon?: boolean;
variant?: "default" | "small"; variant?: "default" | "small";
placeholder?: string; placeholder?: string;
value?: Date | null; value?: Date | null;
departureTime?: string; departureTime?: string;
arrivalTime?: string; arrivalTime?: string;
onDateChange?: (date: Date | undefined) => void; onDateChange?: (date: Date | undefined) => void;
onDepartureTimeChange?: (time: string) => void; onDepartureTimeChange?: (time: string) => void;
onArrivalTimeChange?: (time: string) => void; onArrivalTimeChange?: (time: string) => void;
onlyDeparture?: boolean; onlyDeparture?: boolean;
onlyArrival?: boolean; onlyArrival?: boolean;
} }
export function DatePicker({ export function DatePicker({
showIcon = true, showIcon = true,
variant = "default", variant = "default",
placeholder = "Выберите дату и время", placeholder = "Выберите дату и время",
value, value,
departureTime: externalDepartureTime, departureTime: externalDepartureTime,
arrivalTime: externalArrivalTime, arrivalTime: externalArrivalTime,
onDateChange, onDateChange,
onDepartureTimeChange, onDepartureTimeChange,
onArrivalTimeChange, onArrivalTimeChange,
onlyDeparture, onlyDeparture,
onlyArrival, onlyArrival,
}: DatePickerProps) { }: DatePickerProps) {
const [internalDate, setInternalDate] = React.useState<Date>(); const [internalDate, setInternalDate] = React.useState<Date>();
const [internalDepartureTime, setInternalDepartureTime] = const [internalDepartureTime, setInternalDepartureTime] =
React.useState("12:00"); React.useState("12:00");
const [internalArrivalTime, setInternalArrivalTime] = React.useState("13:00"); const [internalArrivalTime, setInternalArrivalTime] =
const [open, setOpen] = React.useState(false); React.useState("13:00");
const [open, setOpen] = React.useState(false);
// Определяем, является ли компонент контролируемым // Определяем, является ли компонент контролируемым
const isControlled = const isControlled =
value !== undefined || value !== undefined ||
externalDepartureTime !== undefined || externalDepartureTime !== undefined ||
externalArrivalTime !== undefined; externalArrivalTime !== undefined;
// Используем внешние значения, если они предоставлены, иначе внутренние // Используем внешние значения, если они предоставлены, иначе внутренние
const date = value !== undefined ? value || undefined : internalDate; const date = value !== undefined ? value || undefined : internalDate;
const departureTime = const departureTime =
externalDepartureTime !== undefined externalDepartureTime !== undefined
? externalDepartureTime ? externalDepartureTime
: internalDepartureTime; : internalDepartureTime;
const arrivalTime = const arrivalTime =
externalArrivalTime !== undefined externalArrivalTime !== undefined
? externalArrivalTime ? externalArrivalTime
: internalArrivalTime; : internalArrivalTime;
const handleDateChange = (newDate: Date | undefined) => { const handleDateChange = (newDate: Date | undefined) => {
if (onDateChange) { if (onDateChange) {
onDateChange(newDate); onDateChange(newDate);
} else if (!isControlled) { } else if (!isControlled) {
setInternalDate(newDate); setInternalDate(newDate);
} }
}; };
const handleDepartureTimeChange = (time: string) => { const handleDepartureTimeChange = (time: string) => {
if (onDepartureTimeChange) { if (onDepartureTimeChange) {
onDepartureTimeChange(time); onDepartureTimeChange(time);
} else if (!isControlled) { } else if (!isControlled) {
setInternalDepartureTime(time); setInternalDepartureTime(time);
} }
}; };
const handleArrivalTimeChange = (time: string) => { const handleArrivalTimeChange = (time: string) => {
if (onArrivalTimeChange) { if (onArrivalTimeChange) {
onArrivalTimeChange(time); onArrivalTimeChange(time);
} else if (!isControlled) { } else if (!isControlled) {
setInternalArrivalTime(time); setInternalArrivalTime(time);
} }
}; };
const handleApply = () => { const handleApply = () => {
// Закрываем popover после применения // Закрываем popover после применения
setOpen(false); setOpen(false);
}; };
const heightClass = variant === "small" ? "h-[48px]" : "h-[64px]"; const heightClass = variant === "small" ? "h-[48px]" : "h-[64px]";
return ( return (
<Popover open={open} onOpenChange={setOpen}> <Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
variant="outline" variant="outline"
data-empty={!date} data-empty={!date}
className={`w-full ${heightClass} justify-between text-left font-normal`} className={`w-full ${heightClass} justify-between text-left font-normal`}
> >
<div className="flex items-center"> <div className="flex items-center">
{showIcon && ( {showIcon && (
<Icon name="calendar" className="w-4 h-4 text-brand mr-2" /> <Icon
)} name="calendar"
{date ? ( className="w-4 h-4 text-brand mr-2"
format(date, `d MMMM, ${departureTime} - ${arrivalTime}`, { />
locale: ru, )}
}) {date ? (
) : ( (() => {
<span>{placeholder}</span> let timeFormat = "";
)} if (onlyDeparture) {
</div> timeFormat = `d MMMM, ${departureTime}`;
{open ? ( } else if (onlyArrival) {
<ChevronUpIcon className="w-4 h-4" /> timeFormat = `d MMMM, ${arrivalTime}`;
) : ( } else {
<ChevronDownIcon className="w-4 h-4" /> timeFormat = `d MMMM, ${departureTime} - ${arrivalTime}`;
)} }
</Button> return format(date, timeFormat, {
</PopoverTrigger> locale: ru,
<PopoverContent className="p-0 bg-white rounded-[20px] shadow-lg"> });
<div className="p-4 w-full"> })()
{/* Календарь */} ) : (
<Calendar <span>{placeholder}</span>
mode="single" )}
selected={date} </div>
onSelect={handleDateChange} {open ? (
className="mb-4 " <ChevronUpIcon className="w-4 h-4" />
locale={ru} ) : (
disabled={(date) => <ChevronDownIcon className="w-4 h-4" />
date < new Date(new Date().setHours(0, 0, 0, 0)) )}
} </Button>
classNames={{ </PopoverTrigger>
root: "w-full", <PopoverContent className="p-0 bg-white rounded-[20px] shadow-lg">
month: "flex w-full flex-col gap-4", <div className="p-4 w-full">
button_previous: {/* Календарь */}
"h-8 w-8 flex items-center justify-center hover:bg-gray-100 rounded-md", <Calendar
button_next: mode="single"
"h-8 w-8 flex items-center justify-center hover:bg-gray-100 rounded-md", selected={date}
month_caption: onSelect={handleDateChange}
"flex h-8 w-full items-center justify-center px-8 text-gray-700 font-semibold", className="mb-4 "
table: "w-full border-collapse", locale={ru}
weekdays: "flex", disabled={(date) =>
weekday: date < new Date(new Date().setHours(0, 0, 0, 0))
"flex-1 text-gray-500 text-xs font-normal p-2 text-center", }
day_button: "font-bold ring-0 focus:ring-0", classNames={{
week: "mt-2 flex w-full", root: "w-full",
today: "bg-gray-100 text-gray-900 rounded-full", month: "flex w-full flex-col gap-4",
outside: "text-gray-300", button_previous:
disabled: "text-gray-400 cursor-not-allowed", "h-8 w-8 flex items-center justify-center hover:bg-gray-100 rounded-md",
selected: button_next:
"rounded-full border-none outline-none !bg-brand text-white", "h-8 w-8 flex items-center justify-center hover:bg-gray-100 rounded-md",
}} month_caption:
/> "flex h-8 w-full items-center justify-center px-8 text-gray-700 font-semibold",
table: "w-full border-collapse",
weekdays: "flex",
weekday:
"flex-1 text-gray-500 text-xs font-normal p-2 text-center",
day_button: "font-bold ring-0 focus:ring-0",
week: "mt-2 flex w-full",
today: "bg-gray-100 text-gray-900 rounded-full",
outside: "text-gray-300",
disabled: "text-gray-400 cursor-not-allowed",
selected:
"rounded-full border-none outline-none !bg-brand text-white",
}}
/>
{/* Поля времени */} {/* Поля времени */}
<div className="flex gap-3 mb-4"> <div className="flex gap-3 mb-4">
{!onlyDeparture ? ( {onlyDeparture && (
<div className="relative w-full h-12 px-3 border border-gray-300 rounded-full text-gray-700 font-medium text-center"> <div className="relative w-full h-12 px-3 border border-gray-300 rounded-full text-gray-700 font-medium text-center">
<label className="absolute left-[24px] top-0 transform -translate-y-1/2 text-xs text-gray-500 pointer-events-none transition-all duration-200 bg-white px-1"> <label className="absolute left-[24px] top-0 transform -translate-y-1/2 text-xs text-gray-500 pointer-events-none transition-all duration-200 bg-white px-1">
Выход Выход
</label> </label>
<div className="relative h-full flex align-center"> <div className="relative h-full flex align-center">
<ChevronDownIcon className="absolute right-0 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-500 pointer-events-none" /> <ChevronDownIcon className="absolute right-0 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-500 pointer-events-none" />
<input <input
type="time" type="time"
value={departureTime} value={departureTime}
onChange={(e) => handleDepartureTimeChange(e.target.value)} onChange={(e) =>
className="w-full focus:outline-none focus:border-transparent" handleDepartureTimeChange(
/> e.target.value
)
}
className="w-full focus:outline-none focus:border-transparent"
/>
</div>
</div>
)}
{onlyArrival && (
<div className="relative w-full h-12 px-3 border border-gray-300 rounded-full text-gray-700 font-medium text-center">
<label className="absolute left-[24px] top-0 transform -translate-y-1/2 text-xs text-gray-500 pointer-events-none transition-all duration-200 bg-white px-1">
Заход
</label>
<div className="relative h-full flex align-center">
<ChevronDownIcon className="absolute right-0 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-500 pointer-events-none" />
<input
type="time"
value={arrivalTime}
onChange={(e) =>
handleArrivalTimeChange(
e.target.value
)
}
className="w-full focus:outline-none focus:border-transparent"
/>
</div>
</div>
)}
</div>
{/* Кнопка Применить */}
<Button
onClick={handleApply}
variant="gradient"
className="font-bold text-white h-[44px] w-full px-8"
>
Применить
</Button>
</div> </div>
</div> </PopoverContent>
) : null} </Popover>
);
{!onlyArrival ? (
<div className="relative w-full h-12 px-3 border border-gray-300 rounded-full text-gray-700 font-medium text-center">
<label className="absolute left-[24px] top-0 transform -translate-y-1/2 text-xs text-gray-500 pointer-events-none transition-all duration-200 bg-white px-1">
Заход
</label>
<div className="relative h-full flex align-center">
<ChevronDownIcon className="absolute right-0 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-500 pointer-events-none" />
<input
type="time"
value={arrivalTime}
onChange={(e) => handleArrivalTimeChange(e.target.value)}
className="w-full focus:outline-none focus:border-transparent"
/>
</div>
</div>
) : null}
</div>
{/* Кнопка Применить */}
<Button
onClick={handleApply}
variant="gradient"
className="font-bold text-white h-[44px] w-full px-8"
>
Применить
</Button>
</div>
</PopoverContent>
</Popover>
);
} }

View File

@ -7,7 +7,7 @@ const useApiClient = () => {
const authPopup = useAuthPopup(); const authPopup = useAuthPopup();
const apiClient = axios.create({ const apiClient = axios.create({
baseURL: "/api", baseURL: "http://89.169.188.2/api",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },

View File

@ -1,5 +1,6 @@
import { clsx, type ClassValue } from "clsx" import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge" import { twMerge } from "tailwind-merge"
import { differenceInHours, parseISO } from "date-fns";
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) return twMerge(clsx(inputs))
@ -28,4 +29,56 @@ export const formatSpeed = (speed: number): string => {
export const formatMinCost = (minCost: number): string => { export const formatMinCost = (minCost: number): string => {
return "от " + minCost + " ₽"; return "от " + minCost + " ₽";
};
export const formatPrice = (price: number): string => {
return new Intl.NumberFormat("ru-RU").format(price);
};
export interface TotalPriceResult {
totalHours: number;
totalPrice: number;
}
export const calculateTotalPrice = (
departureDate: string | null,
departureTime: string | null,
arrivalDate: string | null,
arrivalTime: string | null,
pricePerHour: number
): TotalPriceResult => {
if (
!departureDate ||
!departureTime ||
!arrivalDate ||
!arrivalTime ||
!pricePerHour
) {
return { totalHours: 0, totalPrice: 0 };
}
try {
// Создаем полные даты
const departureDateTime = parseISO(`${departureDate}T${departureTime}`);
const arrivalDateTime = parseISO(`${arrivalDate}T${arrivalTime}`);
// Рассчитываем разницу в часах (с округлением до 0.5 часа)
let hoursDiff = differenceInHours(arrivalDateTime, departureDateTime);
// Добавляем разницу в минутах
const minutesDiff =
(arrivalDateTime.getMinutes() - departureDateTime.getMinutes()) / 60;
hoursDiff += minutesDiff;
// Округляем до ближайших 0.5 часа
const roundedHours = Math.ceil(hoursDiff * 2) / 2;
// Рассчитываем стоимость
const total = pricePerHour * roundedHours;
return { totalHours: roundedHours, totalPrice: total };
} catch (error) {
console.error("Error calculating price:", error);
return { totalHours: 0, totalPrice: 0 };
}
}; };