diff --git a/src/app/catalog/[id]/components/BookingWidget.tsx b/src/app/catalog/[id]/components/BookingWidget.tsx index 22bce86..af4a12c 100644 --- a/src/app/catalog/[id]/components/BookingWidget.tsx +++ b/src/app/catalog/[id]/components/BookingWidget.tsx @@ -1,11 +1,12 @@ "use client"; -import { useState } from "react"; +import { useState, useMemo } from "react"; import { useRouter } from "next/navigation"; import { Button } from "@/components/ui/button"; import { DatePicker } from "@/components/ui/date-picker"; import { GuestPicker } from "@/components/form/guest-picker"; import { format } from "date-fns"; +import { calculateTotalPrice, formatPrice } from "@/lib/utils"; interface BookingWidgetProps { price: string; @@ -16,22 +17,43 @@ export function BookingWidget({ price, yacht }: BookingWidgetProps) { const router = useRouter(); const [departureDate, setDepartureDate] = useState(); const [arrivalDate, setArrivalDate] = useState(); + const [departureTime, setDepartureTime] = useState("12:00"); + const [arrivalTime, setArrivalTime] = useState("13:00"); 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) => { setGuests({ adults, children }); }; const handleBook = () => { - if (!departureDate || !arrivalDate || !yacht || !yacht.id) return; + if (!departureDate || !arrivalDate || !departureTime || !arrivalTime || !yacht || !yacht.id) return; const params = new URLSearchParams({ yachtId: yacht.id.toString(), departureDate: format(departureDate, "yyyy-MM-dd"), - departureTime: format(departureDate, "HH:mm"), + departureTime: departureTime, arrivalDate: format(arrivalDate, "yyyy-MM-dd"), - arrivalTime: format(arrivalDate, "HH:mm"), + arrivalTime: arrivalTime, guests: (guests.adults + guests.children).toString(), }); @@ -58,6 +80,8 @@ export function BookingWidget({ price, yacht }: BookingWidgetProps) { showIcon={false} onDateChange={setDepartureDate} value={departureDate} + departureTime={departureTime} + onDepartureTimeChange={setDepartureTime} onlyDeparture /> @@ -72,6 +96,8 @@ export function BookingWidget({ price, yacht }: BookingWidgetProps) { showIcon={false} onDateChange={setArrivalDate} value={arrivalDate} + arrivalTime={arrivalTime} + onArrivalTimeChange={setArrivalTime} onlyArrival /> @@ -95,7 +121,7 @@ export function BookingWidget({ price, yacht }: BookingWidgetProps) { onClick={handleBook} variant="gradient" className="w-full h-12 font-bold text-white mb-4" - disabled={!departureDate || !arrivalDate} + disabled={!departureDate || !arrivalDate || !departureTime || !arrivalTime} > Забронировать @@ -103,7 +129,7 @@ export function BookingWidget({ price, yacht }: BookingWidgetProps) {
Итого: - {total} ₽ + {formatPrice(total)} ₽
diff --git a/src/app/catalog/[id]/components/YachtAvailability.tsx b/src/app/catalog/[id]/components/YachtAvailability.tsx index 97e0221..726d920 100644 --- a/src/app/catalog/[id]/components/YachtAvailability.tsx +++ b/src/app/catalog/[id]/components/YachtAvailability.tsx @@ -26,19 +26,53 @@ interface YachtAvailabilityProps { price: string; mobile?: boolean; 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({ price, mobile = false, reservations = [], + selectedDate, + startTime: controlledStartTime, + endTime: controlledEndTime, + onDateChange, + onStartTimeChange, + onEndTimeChange, }: YachtAvailabilityProps) { const today = startOfDay(new Date()); const [currentMonth, setCurrentMonth] = useState( new Date(today.getFullYear(), today.getMonth(), 1) ); - const [startTime, setStartTime] = useState(""); - const [endTime, setEndTime] = useState(""); + + // Используем контролируемые значения или внутреннее состояние + const [internalStartTime, setInternalStartTime] = useState(""); + const [internalEndTime, setInternalEndTime] = useState(""); + + 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) => { return new Date(2025, 3, i + 1); @@ -214,6 +248,12 @@ export function YachtAvailability({
{ + if (onDateChange) { + onDateChange(date); + } + }} month={currentMonth} onMonthChange={setCurrentMonth} showOutsideDays={false} @@ -248,6 +288,10 @@ export function YachtAvailability({ const isCrossedOut = shouldBeCrossedOut(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 ( @@ -282,7 +328,7 @@ export function YachtAvailability({
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" > diff --git a/src/app/catalog/[id]/page.tsx b/src/app/catalog/[id]/page.tsx index 8316adf..078a942 100644 --- a/src/app/catalog/[id]/page.tsx +++ b/src/app/catalog/[id]/page.tsx @@ -10,287 +10,359 @@ import { YachtAvailability } from "./components/YachtAvailability"; import { BookingWidget } from "./components/BookingWidget"; import { YachtCharacteristics } from "./components/YachtCharacteristics"; import { ContactInfo } from "./components/ContactInfo"; +import { GuestPicker } from "@/components/form/guest-picker"; import useApiClient from "@/hooks/useApiClient"; import { formatSpeed } from "@/lib/utils"; +import { format } from "date-fns"; export default function YachtDetailPage() { - const { id } = useParams(); - const [yacht, setYacht] = useState(null); + const { id } = useParams(); + const [yacht, setYacht] = useState(null); - const client = useApiClient(); + const client = useApiClient(); - useEffect(() => { - (async () => { - const response = await client.get(`/catalog/${id}/`); + useEffect(() => { + (async () => { + const response = await client.get( + `/catalog/${id}/` + ); - setYacht(response.data); - })(); - }, [id]); + setYacht(response.data); + })(); + }, [id]); - // const params = useParams(); - const router = useRouter(); - const [activeTab, setActiveTab] = useState< - | "availability" - | "description" - | "characteristics" - | "contact" - | "requisites" - | "reviews" - >("availability"); + // const params = useParams(); + const router = useRouter(); + const [activeTab, setActiveTab] = useState< + | "availability" + | "description" + | "characteristics" + | "contact" + | "requisites" + | "reviews" + >("availability"); - if (!yacht) { - return
; - } + // Состояние для мобильного бронирования + const [selectedDate, setSelectedDate] = useState(); + const [startTime, setStartTime] = useState(""); + const [endTime, setEndTime] = useState(""); + const [guests, setGuests] = useState({ adults: 1, children: 0 }); - return ( -
- {/* Мобильная фиксированная верхняя панель навигации */} -
-
- -

Яхта

- -
-
+ const handleGuestsChange = (adults: number, children: number) => { + setGuests({ adults, children }); + }; - {/* Десктопная версия - Breadcrumbs */} -
-
- - - Аренда яхты - - - > - - - Моторные яхты - - - > - {yacht.name} -
-
+ const handleBookMobile = () => { + if (!selectedDate || !startTime || !endTime || !yacht || !yacht.id) + return; - {/* Main Content Container */} -
-
- {/* Мобильная версия - без отступов сверху, с отступом для фиксированной панели */} -
- {/* Gallery */} - + // Используем выбранную дату как дату отправления и прибытия (можно изменить логику при необходимости) + const departureDate = format(selectedDate, "yyyy-MM-dd"); + const arrivalDate = format(selectedDate, "yyyy-MM-dd"); - {/* Yacht Title */} -
-

- {yacht.name} -

-
+ const params = new URLSearchParams({ + yachtId: yacht.id.toString(), + departureDate: departureDate, + departureTime: startTime, + arrivalDate: arrivalDate, + arrivalTime: endTime, + guests: (guests.adults + guests.children).toString(), + }); - {/* Tabs */} -
-
- - - - - -
-
+ router.push(`/confirm?${params.toString()}`); + }; - {/* Tab Content */} -
- {activeTab === "availability" && ( - - )} - {activeTab === "description" && ( -
-

- {yacht.description} -

-
- )} - {activeTab === "characteristics" && ( - - )} - {activeTab === "contact" && } - {activeTab === "reviews" && ( -
-
- -

- Отзывы + if (!yacht) { + return
; + } + + return ( +
+ {/* Мобильная фиксированная верхняя панель навигации */} +
+
+ +

+ Яхта

-
-
-

- У этой яхты пока нет отзывов -

-
+
- )} -
-

- - {/* Десктопная версия */} -
- {/* Yacht Title and Actions */} -
-

- {yacht.name} -

-
-
- - {formatSpeed(yacht.speed)} -
- - -
- {/* Main Content */} -
- {/* Gallery */} - + {/* Десктопная версия - Breadcrumbs */} +
+
+ + + Аренда яхты + + + > + + + Моторные яхты + + + > + {yacht.name} +
+
- {/* Content with Booking Widget on the right */} -
- {/* Left column - all content below gallery */} -
- {/* Availability */} - + {/* Main Content Container */} +
+
+ {/* Мобильная версия - без отступов сверху, с отступом для фиксированной панели */} +
+ {/* Gallery */} + - {/* Characteristics */} - + {/* Yacht Title */} +
+

+ {yacht.name} +

+
- {/* Description */} -
-

- Описание -

-

- {yacht.description} -

-
+ {/* Tabs */} +
+
+ + + + + +
+
- {/* Contact and Requisites */} - - - {/* Reviews */} -
-
- -

- Отзывы -

+ {/* Tab Content */} +
+ {activeTab === "availability" && ( + <> + + {/* Выбор гостей для мобильной версии */} +
+ + +
+ + )} + {activeTab === "description" && ( +
+

+ {yacht.description} +

+
+ )} + {activeTab === "characteristics" && ( + + )} + {activeTab === "contact" && ( + + )} + {activeTab === "reviews" && ( +
+
+ +

+ Отзывы +

+
+
+

+ У этой яхты пока нет отзывов +

+
+
+ )} +
-
-

- У этой яхты пока нет отзывов -

+ + {/* Десктопная версия */} +
+ {/* Yacht Title and Actions */} +
+

+ {yacht.name} +

+
+
+ + + {formatSpeed(yacht.speed)} + +
+ + +
+
+ + {/* Main Content */} +
+ {/* Gallery */} + + + {/* Content with Booking Widget on the right */} +
+ {/* Left column - all content below gallery */} +
+ {/* Availability */} + + + {/* Characteristics */} + + + {/* Description */} +
+

+ Описание +

+

+ {yacht.description} +

+
+ + {/* Contact and Requisites */} + + + {/* Reviews */} +
+
+ +

+ Отзывы +

+
+
+

+ У этой яхты пока нет отзывов +

+
+
+
+ + {/* Right column - Booking Widget (sticky) */} +
+ +
+
+
-
- - {/* Right column - Booking Widget (sticky) */} -
- -
-
-
-
-
- {/* Мобильная фиксированная нижняя панель бронирования */} -
-
-
- - {yacht.minCost} ₽ - - / час -
- -
-
-
- ); + {/* Мобильная фиксированная нижняя панель бронирования */} +
+
+
+ + {yacht.minCost} ₽ + + + / час + +
+ +
+
+ + ); } diff --git a/src/app/components/FeaturedYacht.tsx b/src/app/components/FeaturedYacht.tsx index 5d9dc73..5edac8f 100644 --- a/src/app/components/FeaturedYacht.tsx +++ b/src/app/components/FeaturedYacht.tsx @@ -12,20 +12,80 @@ import { import Image from "next/image"; import Icon from "@/components/ui/icon"; import { useState } from "react"; -import { GuestDatePicker } from "@/components/form/guest-date-picker"; -import { formatMinCost, formatWidth, getImageUrl } from "@/lib/utils"; +import { useRouter } from "next/navigation"; +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({ yacht, }: { yacht: CatalogItemShortDto; }) { + const router = useRouter(); const [selectedImage, setSelectedImage] = useState(yacht.mainImageUrl); + const [bookingData, setBookingData] = useState({ + date: undefined, + departureTime: "12:00", + arrivalTime: "13:00", + adults: 1, + children: 0, + }); const handleThumbnailClick = (imageSrc: string) => { 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 (
@@ -157,13 +217,18 @@ export default function FeaturedYacht({ {/* Booking form */}
- +
{/* Book button */} @@ -171,7 +236,7 @@ export default function FeaturedYacht({ {/* Total price */}
Итого: - 0 ₽ + {formatPrice(getTotalPrice())} ₽
diff --git a/src/app/confirm/page.tsx b/src/app/confirm/page.tsx index c71fbe1..e048e8d 100644 --- a/src/app/confirm/page.tsx +++ b/src/app/confirm/page.tsx @@ -7,533 +7,614 @@ import { User, ArrowUpRight, Map, ArrowLeft, Heart } from "lucide-react"; import { useEffect, useState, Suspense } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import useApiClient from "@/hooks/useApiClient"; -import { getImageUrl } from "@/lib/utils"; -import { differenceInHours, parseISO } from "date-fns"; +import { getImageUrl, formatPrice, calculateTotalPrice } from "@/lib/utils"; +import { parseISO } from "date-fns"; function ConfirmPageContent() { - const [yacht, setYacht] = useState(null); - const [totalHours, setTotalHours] = useState(0); - const [totalPrice, setTotalPrice] = useState(0); + const [yacht, setYacht] = useState(null); - const client = useApiClient(); - const [promocode, setPromocode] = useState(""); - const router = useRouter(); - const searchParams = useSearchParams(); + const client = useApiClient(); + const [promocode, setPromocode] = useState(""); + const [isPromocodeApplied, setIsPromocodeApplied] = useState(false); + const router = useRouter(); + const searchParams = useSearchParams(); - // Извлекаем параметры из URL - const yachtId = searchParams.get("yachtId"); - const guestCount = searchParams.get("guests"); - const departureDate = searchParams.get("departureDate"); - const departureTime = searchParams.get("departureTime"); - const arrivalDate = searchParams.get("arrivalDate"); - const arrivalTime = searchParams.get("arrivalTime"); + // Извлекаем параметры из URL + const yachtId = searchParams.get("yachtId"); + const guestCount = searchParams.get("guests"); + const departureDate = searchParams.get("departureDate"); + const departureTime = searchParams.get("departureTime"); + const arrivalDate = searchParams.get("arrivalDate"); + const arrivalTime = searchParams.get("arrivalTime"); - useEffect(() => { - (async () => { - const response = await client.get( - `/catalog/${yachtId}/` - ); - setYacht(response.data); - })(); - }, [yachtId]); + useEffect(() => { + (async () => { + const response = await client.get( + `/catalog/${yachtId}/` + ); + setYacht(response.data); + })(); + }, [yachtId]); - // Расчет стоимости при изменении дат - useEffect(() => { - if ( - departureDate && - departureTime && - arrivalDate && - arrivalTime && - yacht?.minCost - ) { - try { - // Создаем полные даты - const departureDateTime = parseISO(`${departureDate}T${departureTime}`); - const arrivalDateTime = parseISO(`${arrivalDate}T${arrivalTime}`); + // Расчет стоимости через функцию + const { totalHours, totalPrice } = calculateTotalPrice( + departureDate, + departureTime, + arrivalDate, + arrivalTime, + yacht?.minCost || 0 + ); - // Рассчитываем разницу в часах (с округлением до 0.5 часа) - let hoursDiff = differenceInHours(arrivalDateTime, departureDateTime); + // Обработчик применения промокода + const handlePromocodeApply = () => { + if (promocode.trim().toUpperCase() === "DISCOUNT50") { + setIsPromocodeApplied(true); + } else { + setIsPromocodeApplied(false); + } + }; - // Добавляем разницу в минутах - const minutesDiff = - (arrivalDateTime.getMinutes() - departureDateTime.getMinutes()) / 60; - hoursDiff += minutesDiff; + // Финальная цена с учетом скидки + const finalPrice = isPromocodeApplied ? totalPrice * 0.5 : totalPrice; - // Округляем до ближайших 0.5 часа - const roundedHours = Math.ceil(hoursDiff * 2) / 2; + // Функция для форматирования даты (краткий формат) + const formatDate = (dateString: string | null) => { + if (!dateString) return null; + try { + const date = parseISO(dateString); + const months = [ + "янв", + "фев", + "мар", + "апр", + "май", + "июн", + "июл", + "авг", + "сен", + "окт", + "ноя", + "дек", + ]; + const day = date.getDate(); + const month = months[date.getMonth()]; + return `${day} ${month}`; + } catch { + return null; + } + }; - // Рассчитываем стоимость - const pricePerHour = yacht.minCost; - const total = pricePerHour * roundedHours; + // Функция для форматирования даты (полный формат для десктопа) + const formatDateFull = (dateString: string | null) => { + if (!dateString) return null; + try { + const date = parseISO(dateString); + const months = [ + "января", + "февраля", + "марта", + "апреля", + "мая", + "июня", + "июля", + "августа", + "сентября", + "октября", + "ноября", + "декабря", + ]; + const day = date.getDate(); + const month = months[date.getMonth()]; + return `${day} ${month}`; + } catch { + return null; + } + }; - setTotalHours(roundedHours); - setTotalPrice(total); - } catch (error) { - console.error("Error calculating price:", error); - setTotalHours(0); - setTotalPrice(0); - } - } else { - setTotalHours(0); - setTotalPrice(0); + // Функция для форматирования времени + const formatTime = (timeString: string | null) => { + if (!timeString) return null; + // Декодируем URL-encoded строку (например, 00%3A00 -> 00:00) + const decoded = decodeURIComponent(timeString); + return decoded.split(":").slice(0, 2).join(":"); + }; + + // Форматируем данные для отображения + const departureDateFormatted = formatDate(departureDate); + const departureTimeFormatted = formatTime(departureTime); + const arrivalDateFormatted = formatDate(arrivalDate); + const arrivalTimeFormatted = formatTime(arrivalTime); + + // Полный формат для десктопной версии + const departureDateFormattedFull = formatDateFull(departureDate); + const arrivalDateFormattedFull = formatDateFull(arrivalDate); + + // Формируем строки для отображения + const departureDisplay = + departureDateFormatted && departureTimeFormatted + ? `${departureDateFormatted} ${departureTimeFormatted}` + : "Не выбрано"; + + const arrivalDisplay = + arrivalDateFormatted && arrivalTimeFormatted + ? `${arrivalDateFormatted} ${arrivalTimeFormatted}` + : "Не выбрано"; + + const datesDisplay = + departureDateFormattedFull && + departureTimeFormatted && + arrivalDateFormattedFull && + arrivalTimeFormatted + ? `${departureDateFormattedFull} в ${departureTimeFormatted} — ${arrivalDateFormattedFull} в ${arrivalTimeFormatted}` + : "Не выбрано"; + + const guestsDisplay = guestCount + ? guestCount === "1" + ? "1 гость" + : `${guestCount} гостей` + : "Не выбрано"; + + if (!yacht) { + return
; } - }, [departureDate, departureTime, arrivalDate, arrivalTime, yacht]); - // Функция для форматирования даты (краткий формат) - const formatDate = (dateString: string | null) => { - if (!dateString) return null; - try { - const date = parseISO(dateString); - const months = [ - "янв", - "фев", - "мар", - "апр", - "май", - "июн", - "июл", - "авг", - "сен", - "окт", - "ноя", - "дек", - ]; - const day = date.getDate(); - const month = months[date.getMonth()]; - return `${day} ${month}`; - } catch { - return null; - } - }; + return ( +
+ {/* Мобильная версия */} +
+ {/* Верхний блок с навигацией */} +
+
+
+ {/* Кнопка назад */} + - // Функция для форматирования даты (полный формат для десктопа) - const formatDateFull = (dateString: string | null) => { - if (!dateString) return null; - try { - const date = parseISO(dateString); - const months = [ - "января", - "февраля", - "марта", - "апреля", - "мая", - "июня", - "июля", - "августа", - "сентября", - "октября", - "ноября", - "декабря", - ]; - const day = date.getDate(); - const month = months[date.getMonth()]; - return `${day} ${month}`; - } catch { - return null; - } - }; + {/* Центральный блок с информацией */} +
+

+ Яхта {yacht.name} +

+
+ + {departureDateFormatted || "Не выбрано"} + + + Гостей: {guestCount || "Не выбрано"} + +
+
- // Функция для форматирования времени - const formatTime = (timeString: string | null) => { - if (!timeString) return null; - return timeString.split(":").slice(0, 2).join(":"); - }; - - // Форматируем данные для отображения - const departureDateFormatted = formatDate(departureDate); - const departureTimeFormatted = formatTime(departureTime); - const arrivalDateFormatted = formatDate(arrivalDate); - const arrivalTimeFormatted = formatTime(arrivalTime); - - // Полный формат для десктопной версии - const departureDateFormattedFull = formatDateFull(departureDate); - const arrivalDateFormattedFull = formatDateFull(arrivalDate); - - // Формируем строки для отображения - const departureDisplay = - departureDateFormatted && departureTimeFormatted - ? `${departureDateFormatted} ${departureTimeFormatted}` - : "Не выбрано"; - - const arrivalDisplay = - arrivalDateFormatted && arrivalTimeFormatted - ? `${arrivalDateFormatted} ${arrivalTimeFormatted}` - : "Не выбрано"; - - const datesDisplay = - departureDateFormattedFull && - departureTimeFormatted && - arrivalDateFormattedFull && - arrivalTimeFormatted - ? `${departureDateFormattedFull} в ${departureTimeFormatted} — ${arrivalDateFormattedFull} в ${arrivalTimeFormatted}` - : "Не выбрано"; - - const guestsDisplay = guestCount - ? guestCount === "1" - ? "1 гость" - : `${guestCount} гостей` - : "Не выбрано"; - - // Форматирование цены с разделителями тысяч - const formatPrice = (price: number) => { - return new Intl.NumberFormat("ru-RU").format(price); - }; - - if (!yacht) { - return
; - } - - return ( -
- {/* Мобильная версия */} -
- {/* Верхний блок с навигацией */} -
-
-
- {/* Кнопка назад */} - - - {/* Центральный блок с информацией */} -
-

- Яхта {yacht.name} -

-
- {departureDateFormatted || "Не выбрано"} - Гостей: {guestCount || "Не выбрано"} -
-
- - {/* Кнопка избранного */} - -
-
-
- -
-
- {/* Заголовок с иконкой */} -
-

Ваше бронирование 🛥️

-
- - {/* Поля Выход и Заход */} -
-
- -
-
{departureDisplay}
-
-
-
- -
-
{arrivalDisplay}
-
-
-
- - {/* По местному времени яхты */} -
- - По местному времени яхты -
- - {/* Гости */} -
-
- -
- {guestsDisplay} -
-
-
- - {/* Правила отмены */} -
-

- Правила отмены -

-

- При отмене до 10 мая вы получите частичный возврат.{" "} - - Подробнее - -

-
- - {/* Детализация цены */} -
-

- Детализация цены -

-
- {totalHours > 0 && yacht.minCost ? ( - <> -
- - {formatPrice(yacht.minCost)}₽ × {totalHours}ч - - - {formatPrice(totalPrice)} ₽ - -
-
- Услуги - 0 Р -
-
- Итого: - - {formatPrice(totalPrice)} Р - -
- - ) : ( -
- Укажите даты для расчета стоимости -
- )} -
-
- - {/* Промокод */} -
-
- setPromocode(e.target.value)} - className="flex-1 min-w-0 px-4 py-3 h-[64px] border border-[#DFDFDF] rounded-full text-sm text-[#757575] focus:outline-none focus:ring-2 focus:ring-[#008299] focus:border-transparent" - /> - -
-
- - {/* Кнопка отправки заявки */} - -
-
-
- - {/* Десктопная версия */} -
-
- {/* Breadcrumbs - скрыты на мобильных */} -
- - - Аренда яхты - - - > - Ваше бронирование -
- -
- {/* Левая колонка - Информация о яхте и ценах - скрыта на мобильных */} -
-
-
- {/* Изображение яхты */} -
- Яхта - {/* Плашка владельца */} -
-
- -
- Владелец - - {yacht.owner.firstName} - + {/* Кнопка избранного */} +
-
-
- {/* Название яхты */} -

- Яхта {yacht.name} -

+
- {/* Детализация цены */} -
-

- Детализация цены -

-
- {totalHours > 0 && yacht.minCost ? ( - <> -
- - {formatPrice(yacht.minCost)}₽ × {totalHours}ч - - - {formatPrice(totalPrice)} ₽ - -
-
- Услуги - 0 Р -
-
- Итого: - - {formatPrice(totalPrice)} Р - -
- - ) : ( -
- Укажите даты для расчета стоимости +
+
+ {/* Заголовок с иконкой */} +
+

+ Ваше бронирование 🛥️ +

- )} -
-
-
-
-
-
- {/* Промокод */} -
- setPromocode(e.target.value)} - className="flex-1 min-w-0 px-4 sm:px-8 py-5 h-[64px] border border-[#DFDFDF] rounded-full text-base text-[#757575] focus:outline-none focus:ring-2 focus:ring-[#008299] focus:border-transparent" - /> - -
+ {/* Поля Выход и Заход */} +
+
+ +
+
+ {departureDisplay} +
+
+
+
+ +
+
+ {arrivalDisplay} +
+
+
+
+ + {/* По местному времени яхты */} +
+ + По местному времени яхты +
+ + {/* Гости */} +
+
+ +
+ + {guestsDisplay} + +
+
+
+ + {/* Правила отмены */} +
+

+ Правила отмены +

+

+ При отмене до 10 мая вы получите частичный + возврат.{" "} + + Подробнее + +

+
+ + {/* Детализация цены */} +
+

+ Детализация цены +

+
+ {totalHours > 0 && yacht.minCost ? ( + <> +
+ + {formatPrice(yacht.minCost)}₽ ×{" "} + {totalHours}ч + + + {formatPrice(totalPrice)} ₽ + +
+
+ + Услуги + + + 0 Р + +
+ {isPromocodeApplied && ( +
+ + Скидка (DISCOUNT50): + + + -{formatPrice(totalPrice * 0.5)} Р + +
+ )} +
+ + Итого: + + + {formatPrice(finalPrice)} Р + +
+ + ) : ( +
+ Укажите даты для расчета стоимости +
+ )} +
+
+ + {/* Промокод */} +
+
+ { + setPromocode(e.target.value); + setIsPromocodeApplied(false); + }} + onKeyDown={(e) => { + if (e.key === "Enter") { + handlePromocodeApply(); + } + }} + className="flex-1 min-w-0 px-4 py-3 h-[64px] border border-[#DFDFDF] rounded-full text-sm text-[#757575] focus:outline-none focus:ring-2 focus:ring-[#008299] focus:border-transparent" + /> + +
+
+ + {/* Кнопка отправки заявки */} + +
-
- {/* Правая колонка - Подтверждение бронирования */} -
-
- {/* Заголовок */} -

- Проверьте данные -

-

- Ваше бронирование -

- - {/* Сведения о бронирования */} -
- {/* Даты */} -
-
Даты
-
- {datesDisplay} + {/* Десктопная версия */} +
+
+ {/* Breadcrumbs - скрыты на мобильных */} +
+ + + Аренда яхты + + + > + + Ваше бронирование +
-
- {/* Гости */} -
-
-
Гости
-
- {guestsDisplay} -
+
+ {/* Левая колонка - Информация о яхте и ценах - скрыта на мобильных */} +
+
+
+ {/* Изображение яхты */} +
+ Яхта + {/* Плашка владельца */} +
+
+ +
+ + Владелец + + + {yacht.owner.firstName} + +
+
+
+
+ {/* Название яхты */} +

+ Яхта {yacht.name} +

+ + {/* Детализация цены */} +
+

+ Детализация цены +

+
+ {totalHours > 0 && yacht.minCost ? ( + <> +
+ + {formatPrice( + yacht.minCost + )} + ₽ × {totalHours}ч + + + {formatPrice( + totalPrice + )}{" "} + ₽ + +
+
+ + Услуги + + + 0 Р + +
+ {isPromocodeApplied && ( +
+ + Скидка (DISCOUNT50): + + + -{formatPrice( + totalPrice * + 0.5 + )}{" "} + Р + +
+ )} +
+ + Итого: + + + {formatPrice( + finalPrice + )}{" "} + Р + +
+ + ) : ( +
+ Укажите даты для расчета + стоимости +
+ )} +
+
+
+
+ +
+
+ {/* Промокод */} +
+ { + setPromocode(e.target.value); + setIsPromocodeApplied(false); + }} + onKeyDown={(e) => { + if (e.key === "Enter") { + handlePromocodeApply(); + } + }} + className="flex-1 min-w-0 px-4 sm:px-8 py-5 h-[64px] border border-[#DFDFDF] rounded-full text-base text-[#757575] focus:outline-none focus:ring-2 focus:ring-[#008299] focus:border-transparent" + /> + +
+
+
+
+ + {/* Правая колонка - Подтверждение бронирования */} +
+
+ {/* Заголовок */} +

+ Проверьте данные +

+

+ Ваше бронирование +

+ + {/* Сведения о бронирования */} +
+ {/* Даты */} +
+
+ Даты +
+
+ {datesDisplay} +
+
+ + {/* Гости */} +
+
+
+ Гости +
+
+ {guestsDisplay} +
+
+
+
+ + {/* Дополнительные услуги */} +
+
+ Нет дополнительных услуг +
+
+ + {/* Правила отмены */} +

+ Правила отмены +

+ +

+ При отмене до 10 мая вы получите частичный + возврат. +

+ + Подробнее + + + {/* Указание времени и кнопка отправки */} +
+
+ + + По местному времени яхты + +
+ +
+
+
-
- - {/* Дополнительные услуги */} -
-
- Нет дополнительных услуг -
-
- - {/* Правила отмены */} -

- Правила отмены -

- -

- При отмене до 10 мая вы получите частичный возврат. -

- - Подробнее - - - {/* Указание времени и кнопка отправки */} -
-
- - - По местному времени яхты - -
- -
-
-
-
-
-
- ); +
+ ); } export default function ConfirmPage() { - return ( - Загрузка...
}> - - - ); + return ( + + Загрузка... +
+ } + > + + + ); } diff --git a/src/components/form/guest-date-picker.tsx b/src/components/form/guest-date-picker.tsx index ecc9e86..1c64117 100644 --- a/src/components/form/guest-date-picker.tsx +++ b/src/components/form/guest-date-picker.tsx @@ -14,14 +14,18 @@ import { PopoverTrigger, } from "@/components/ui/popover"; +export interface GuestDatePickerValue { + date: Date | undefined; + departureTime: string; + arrivalTime: string; + adults: number; + children: number; +} + interface GuestDatePickerProps { - onApply?: (data: { - date: Date | undefined; - departureTime: string; - arrivalTime: string; - adults: number; - children: number; - }) => void; + value?: GuestDatePickerValue; + onChange?: (value: GuestDatePickerValue) => void; + onApply?: (data: GuestDatePickerValue) => void; className?: string; } @@ -149,26 +153,94 @@ const CommonPopoverContent: React.FC = ({ }; export const GuestDatePicker: React.FC = ({ + value, + onChange, onApply, className, }) => { - const [date, setDate] = useState(); - const [departureTime, setDepartureTime] = useState("12:00"); - const [arrivalTime, setArrivalTime] = useState("13:00"); - const [adults, setAdults] = useState(1); - const [children, setChildren] = useState(0); + // Используем controlled значения, если они переданы, иначе используем внутреннее состояние + const isControlled = value !== undefined; + + const [internalDate, setInternalDate] = useState(); + 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 [isArrivalOpen, setIsArrivalOpen] = useState(false); const [isGuestOpen, setIsGuestOpen] = useState(false); const handleApply = () => { - onApply?.({ + const currentValue = { date, departureTime, arrivalTime, adults, children, - }); + }; + onApply?.(currentValue); setIsDepartureOpen(false); setIsArrivalOpen(false); setIsGuestOpen(false); diff --git a/src/components/ui/date-picker.tsx b/src/components/ui/date-picker.tsx index 18100af..2607d84 100644 --- a/src/components/ui/date-picker.tsx +++ b/src/components/ui/date-picker.tsx @@ -8,202 +8,224 @@ import { ChevronDownIcon, ChevronUpIcon } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Calendar } from "@/components/ui/calendar"; import { - Popover, - PopoverContent, - PopoverTrigger, + Popover, + PopoverContent, + PopoverTrigger, } from "@/components/ui/popover"; import Icon from "./icon"; interface DatePickerProps { - showIcon?: boolean; - variant?: "default" | "small"; - placeholder?: string; - value?: Date | null; - departureTime?: string; - arrivalTime?: string; - onDateChange?: (date: Date | undefined) => void; - onDepartureTimeChange?: (time: string) => void; - onArrivalTimeChange?: (time: string) => void; - onlyDeparture?: boolean; - onlyArrival?: boolean; + showIcon?: boolean; + variant?: "default" | "small"; + placeholder?: string; + value?: Date | null; + departureTime?: string; + arrivalTime?: string; + onDateChange?: (date: Date | undefined) => void; + onDepartureTimeChange?: (time: string) => void; + onArrivalTimeChange?: (time: string) => void; + onlyDeparture?: boolean; + onlyArrival?: boolean; } export function DatePicker({ - showIcon = true, - variant = "default", - placeholder = "Выберите дату и время", - value, - departureTime: externalDepartureTime, - arrivalTime: externalArrivalTime, - onDateChange, - onDepartureTimeChange, - onArrivalTimeChange, - onlyDeparture, - onlyArrival, + showIcon = true, + variant = "default", + placeholder = "Выберите дату и время", + value, + departureTime: externalDepartureTime, + arrivalTime: externalArrivalTime, + onDateChange, + onDepartureTimeChange, + onArrivalTimeChange, + onlyDeparture, + onlyArrival, }: DatePickerProps) { - const [internalDate, setInternalDate] = React.useState(); - const [internalDepartureTime, setInternalDepartureTime] = - React.useState("12:00"); - const [internalArrivalTime, setInternalArrivalTime] = React.useState("13:00"); - const [open, setOpen] = React.useState(false); + const [internalDate, setInternalDate] = React.useState(); + const [internalDepartureTime, setInternalDepartureTime] = + React.useState("12:00"); + const [internalArrivalTime, setInternalArrivalTime] = + React.useState("13:00"); + const [open, setOpen] = React.useState(false); - // Определяем, является ли компонент контролируемым - const isControlled = - value !== undefined || - externalDepartureTime !== undefined || - externalArrivalTime !== undefined; + // Определяем, является ли компонент контролируемым + const isControlled = + value !== undefined || + externalDepartureTime !== undefined || + externalArrivalTime !== undefined; - // Используем внешние значения, если они предоставлены, иначе внутренние - const date = value !== undefined ? value || undefined : internalDate; - const departureTime = - externalDepartureTime !== undefined - ? externalDepartureTime - : internalDepartureTime; - const arrivalTime = - externalArrivalTime !== undefined - ? externalArrivalTime - : internalArrivalTime; + // Используем внешние значения, если они предоставлены, иначе внутренние + const date = value !== undefined ? value || undefined : internalDate; + const departureTime = + externalDepartureTime !== undefined + ? externalDepartureTime + : internalDepartureTime; + const arrivalTime = + externalArrivalTime !== undefined + ? externalArrivalTime + : internalArrivalTime; - const handleDateChange = (newDate: Date | undefined) => { - if (onDateChange) { - onDateChange(newDate); - } else if (!isControlled) { - setInternalDate(newDate); - } - }; + const handleDateChange = (newDate: Date | undefined) => { + if (onDateChange) { + onDateChange(newDate); + } else if (!isControlled) { + setInternalDate(newDate); + } + }; - const handleDepartureTimeChange = (time: string) => { - if (onDepartureTimeChange) { - onDepartureTimeChange(time); - } else if (!isControlled) { - setInternalDepartureTime(time); - } - }; + const handleDepartureTimeChange = (time: string) => { + if (onDepartureTimeChange) { + onDepartureTimeChange(time); + } else if (!isControlled) { + setInternalDepartureTime(time); + } + }; - const handleArrivalTimeChange = (time: string) => { - if (onArrivalTimeChange) { - onArrivalTimeChange(time); - } else if (!isControlled) { - setInternalArrivalTime(time); - } - }; + const handleArrivalTimeChange = (time: string) => { + if (onArrivalTimeChange) { + onArrivalTimeChange(time); + } else if (!isControlled) { + setInternalArrivalTime(time); + } + }; - const handleApply = () => { - // Закрываем popover после применения - setOpen(false); - }; + const handleApply = () => { + // Закрываем popover после применения + setOpen(false); + }; - const heightClass = variant === "small" ? "h-[48px]" : "h-[64px]"; + const heightClass = variant === "small" ? "h-[48px]" : "h-[64px]"; - return ( - - - - - -
- {/* Календарь */} - - date < new Date(new Date().setHours(0, 0, 0, 0)) - } - classNames={{ - root: "w-full", - month: "flex w-full flex-col gap-4", - button_previous: - "h-8 w-8 flex items-center justify-center hover:bg-gray-100 rounded-md", - button_next: - "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", - }} - /> + return ( + + + + + +
+ {/* Календарь */} + + date < new Date(new Date().setHours(0, 0, 0, 0)) + } + classNames={{ + root: "w-full", + month: "flex w-full flex-col gap-4", + button_previous: + "h-8 w-8 flex items-center justify-center hover:bg-gray-100 rounded-md", + button_next: + "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", + }} + /> - {/* Поля времени */} -
- {!onlyDeparture ? ( -
- -
- - handleDepartureTimeChange(e.target.value)} - className="w-full focus:outline-none focus:border-transparent" - /> + {/* Поля времени */} +
+ {onlyDeparture && ( +
+ +
+ + + handleDepartureTimeChange( + e.target.value + ) + } + className="w-full focus:outline-none focus:border-transparent" + /> +
+
+ )} + + {onlyArrival && ( +
+ +
+ + + handleArrivalTimeChange( + e.target.value + ) + } + className="w-full focus:outline-none focus:border-transparent" + /> +
+
+ )} +
+ + {/* Кнопка Применить */} +
-
- ) : null} - - {!onlyArrival ? ( -
- -
- - handleArrivalTimeChange(e.target.value)} - className="w-full focus:outline-none focus:border-transparent" - /> -
-
- ) : null} -
- - {/* Кнопка Применить */} - -
-
-
- ); + + + ); } diff --git a/src/hooks/useApiClient.ts b/src/hooks/useApiClient.ts index 51d387e..d35171e 100644 --- a/src/hooks/useApiClient.ts +++ b/src/hooks/useApiClient.ts @@ -7,7 +7,7 @@ const useApiClient = () => { const authPopup = useAuthPopup(); const apiClient = axios.create({ - baseURL: "/api", + baseURL: "http://89.169.188.2/api", headers: { "Content-Type": "application/json", }, diff --git a/src/lib/utils.ts b/src/lib/utils.ts index a7f1dd5..adab797 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,5 +1,6 @@ import { clsx, type ClassValue } from "clsx" import { twMerge } from "tailwind-merge" +import { differenceInHours, parseISO } from "date-fns"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) @@ -28,4 +29,56 @@ export const formatSpeed = (speed: number): string => { export const formatMinCost = (minCost: number): string => { 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 }; + } }; \ No newline at end of file