From 745d58ab3a6617334bc672b655c1b45527a7a9ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=B2=D0=B0=D0=BD?= Date: Mon, 15 Dec 2025 00:59:03 +0300 Subject: [PATCH] Add all possible bullshit --- src/api/types.ts | 88 +- .../catalog/[id]/components/BookingWidget.tsx | 174 ++-- .../catalog/[id]/components/ContactInfo.tsx | 102 +- .../[id]/components/YachtAvailability.tsx | 701 +++++++------ .../[id]/components/YachtCharacteristics.tsx | 85 +- .../catalog/[id]/components/YachtGallery.tsx | 179 ++-- src/app/catalog/[id]/const.ts | 37 - src/app/catalog/[id]/page.tsx | 532 +++++----- src/app/components/FeaturedYacht.tsx | 329 +++---- src/app/components/YachtGrid.tsx | 287 +++--- src/app/confirm/page.tsx | 926 ++++++++++-------- src/components/form/guest-date-picker.tsx | 543 +++++----- src/components/ui/date-picker.tsx | 353 +++---- src/hooks/useApiClient.ts | 2 +- 14 files changed, 2241 insertions(+), 2097 deletions(-) delete mode 100644 src/app/catalog/[id]/const.ts diff --git a/src/api/types.ts b/src/api/types.ts index 72ace3e..34a55c3 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -1,23 +1,77 @@ -interface CatalogItemDto { - id?: number; - name: string; - length: number; - speed: number; - minCost: number; - mainImageUrl: string; - galleryUrls: string[]; - hasQuickRent: boolean; - isFeatured: boolean; - topText?: string; - isBestOffer?: boolean; +interface CatalogItemShortDto { + id?: number; + name: string; + length: number; + speed: number; + minCost: number; + mainImageUrl: string; + galleryUrls: string[]; + hasQuickRent: boolean; + isFeatured: boolean; + topText?: string; + isBestOffer?: boolean; } interface MainPageCatalogResponseDto { - featuredYacht: CatalogItemDto; - restYachts: CatalogItemDto[]; + featuredYacht: CatalogItemShortDto; + restYachts: CatalogItemShortDto[]; } interface CatalogFilteredResponseDto { - items: CatalogItemDto[]; - total: number; -} \ No newline at end of file + items: CatalogItemShortDto[]; + total: number; +} + +interface Reservation { + id: number; + yachtId: number; + reservatorId: number; + startUtc: number; + endUtc: number; +} + +interface Review { + id: number; + reviewerId: number; + yachtId: number; + starsCount: number; + description: string; +} + +interface User { + userId?: number; + firstName?: string; + lastName?: string; + phone?: string; + email?: string; + password?: string; + yachts?: Yacht[]; + companyName?: string; + inn?: number; + ogrn?: number; +} + +interface CatalogItemLongDto extends CatalogItemShortDto { + year: number; + comfortCapacity: number; + maxCapacity: number; + width: number; + cabinsCount: number; + matherial: string; + power: number; + description: string; + owner: User; + reviews: Review[]; + reservations: Reservation[]; +} + +interface Yacht { + yachtId: number; + name: string; + model: string; + year: number; + length: number; + userId: number; + createdAt: Date; + updatedAt: Date; +} diff --git a/src/app/catalog/[id]/components/BookingWidget.tsx b/src/app/catalog/[id]/components/BookingWidget.tsx index 8c823e4..22bce86 100644 --- a/src/app/catalog/[id]/components/BookingWidget.tsx +++ b/src/app/catalog/[id]/components/BookingWidget.tsx @@ -5,97 +5,107 @@ 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"; interface BookingWidgetProps { - price: string; + price: string; + yacht: CatalogItemLongDto; } -export function BookingWidget({ price }: BookingWidgetProps) { - const router = useRouter(); - const [departureDate] = useState(); - const [arrivalDate] = useState(); - const [guests, setGuests] = useState({ adults: 1, children: 0 }); - const [total] = useState(0); +export function BookingWidget({ price, yacht }: BookingWidgetProps) { + const router = useRouter(); + const [departureDate, setDepartureDate] = useState(); + const [arrivalDate, setArrivalDate] = useState(); + const [guests, setGuests] = useState({ adults: 1, children: 0 }); + const [total] = useState(0); - const handleGuestsChange = (adults: number, children: number) => { - setGuests({ adults, children }); - }; + const handleGuestsChange = (adults: number, children: number) => { + setGuests({ adults, children }); + }; - const handleBook = () => { - // Логика бронирования - console.log("Booking:", { - departureDate, - arrivalDate, - guests, - }); - router.push("/confirm"); - }; + const handleBook = () => { + if (!departureDate || !arrivalDate || !yacht || !yacht.id) return; - return ( -
-
-

- от {price} ₽{" "} - - /час - -

-
+ const params = new URLSearchParams({ + yachtId: yacht.id.toString(), + departureDate: format(departureDate, "yyyy-MM-dd"), + departureTime: format(departureDate, "HH:mm"), + arrivalDate: format(arrivalDate, "yyyy-MM-dd"), + arrivalTime: format(arrivalDate, "HH:mm"), + guests: (guests.adults + guests.children).toString(), + }); -
-
- - -
+ router.push(`/confirm?${params.toString()}`); + }; -
- - -
+ return ( +
+
+

+ от {price} ₽{" "} + /час +

+
-
- - -
-
- - - -
-
- Итого: - - {total} ₽ - -
-
+
+
+ +
- ); + +
+ + +
+ +
+ + +
+
+ + + +
+
+ Итого: + {total} ₽ +
+
+
+ ); } diff --git a/src/app/catalog/[id]/components/ContactInfo.tsx b/src/app/catalog/[id]/components/ContactInfo.tsx index 6104b67..b4d6915 100644 --- a/src/app/catalog/[id]/components/ContactInfo.tsx +++ b/src/app/catalog/[id]/components/ContactInfo.tsx @@ -2,69 +2,43 @@ import Image from "next/image"; -interface ContactInfoProps { - contactPerson: { - name: string; - avatar: string; - }; - requisites: { - ip: string; - inn: string; - ogrn: string; - }; -} - -export function ContactInfo({ contactPerson, requisites }: ContactInfoProps) { - return ( -
-
-
-
- {contactPerson.name} -
-
-

- {contactPerson.name} -

-

- Контактное лицо -

-
-
-
- -
-

- Реквизиты -

-
-
- ИП - - {requisites.ip} - -
-
- ИНН - - {requisites.inn} - -
-
- - ОГРН/ОГРНИП - - - {requisites.ogrn} - -
-
-
+export function ContactInfo({ firstName, companyName, inn, ogrn }: User) { + return ( +
+
+
+
+ {firstName +
+
+

{firstName}

+

Контактное лицо

+
- ); +
+ +
+

Реквизиты

+
+
+ ИП + {companyName} +
+
+ ИНН + {inn} +
+
+ ОГРН/ОГРНИП + {ogrn} +
+
+
+
+ ); } diff --git a/src/app/catalog/[id]/components/YachtAvailability.tsx b/src/app/catalog/[id]/components/YachtAvailability.tsx index 768e297..97e0221 100644 --- a/src/app/catalog/[id]/components/YachtAvailability.tsx +++ b/src/app/catalog/[id]/components/YachtAvailability.tsx @@ -1,339 +1,424 @@ "use client"; -import { useState } from "react"; +import { useState, useMemo } from "react"; import { Calendar } from "@/components/ui/calendar"; import { - isSameMonth, - isBefore, - startOfDay, - format, - eachDayOfInterval, - startOfMonth, - endOfMonth, + 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; + price: string; + mobile?: boolean; + reservations?: Reservation[]; } export function YachtAvailability({ - price, - mobile = false, + 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(""); - const [endTime, setEndTime] = useState(""); + 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 unavailableDates = Array.from({ length: 26 }, (_, i) => { - return new Date(2025, 3, i + 1); - }); + const unavailableDates = Array.from({ length: 26 }, (_, i) => { + return new Date(2025, 3, i + 1); + }); - const isDateUnavailable = (date: Date) => { - return unavailableDates.some( - (d) => - d.getDate() === date.getDate() && - d.getMonth() === date.getMonth() && - d.getFullYear() === date.getFullYear() - ); - }; + // 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"); + }; - const isDateInPast = (date: Date) => { - return isBefore(startOfDay(date), today); - }; + // 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 + }; - const shouldBeCrossedOut = (date: Date) => { - // Перечеркиваем если день занят или находится до текущего дня - return isDateUnavailable(date) || isDateInPast(date); - }; + // 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 isDateAvailable = (date: Date) => { - return !shouldBeCrossedOut(date) && isSameMonth(date, currentMonth); - }; + const dayReservations: Array<{ + id: number; + startTime: string; + endTime: string; + }> = []; - const getAvailableDaysCount = () => { - const monthStart = startOfMonth(currentMonth); - const monthEnd = endOfMonth(currentMonth); - const daysInMonth = eachDayOfInterval({ - start: monthStart, - end: monthEnd, + 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 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 }; + } }); - if (mobile) { - return ( -
- {/* Навигация по месяцам */} -
- -
- - {format(currentMonth, "LLLL", { locale: ru })} - - - Свободных дней: {getAvailableDaysCount()} - -
- -
+ return dayReservations; + }; - {/* Календарь */} -
- { - 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
; - } + // 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; - const isCrossedOut = shouldBeCrossedOut( - day.date - ); + return reservations.some((reservation) => { + return reservation.startUtc < dayEnd && reservation.endUtc > dayStart; + }); + }; - return ( - - ); - }, - }} - /> -
+ 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 ( +
+
+ 08:00—20:00 +
+
+ ); } + // Show all reservations for this day return ( -
-
-

- Доступность яхты -

-
- -
-
-
- - - {format(currentMonth, "LLLL yyyy", { locale: ru })} - - -
-
- { - // Показываем только дни текущего месяца - if (!isSameMonth(day.date, currentMonth)) { - return
; - } - - const isCrossedOut = shouldBeCrossedOut(day.date); - - return ( - - ); - }, - }} - /> -
-
+
+ {dateReservations.map((res) => ( +
+ {res.startTime}—{res.endTime} +
+ ))} +
); + }; + + if (mobile) { + return ( +
+ {/* Навигация по месяцам */} +
+ +
+ + {format(currentMonth, "LLLL", { locale: ru })} + + + Свободных дней: {getAvailableDaysCount()} + +
+ +
+ + {/* Календарь */} +
+ { + 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
; + } + + const isCrossedOut = shouldBeCrossedOut(day.date); + const hasRes = hasReservationsOnDate(day.date); + + return ( + + ); + }, + }} + /> +
+ + {/* Выбор времени */} +
+
+
+ +
+
+ +
+
+
+ + По местному времени яхты +
+
+
+ ); + } + + return ( +
+
+

Доступность яхты

+
+ +
+
+
+ + + {format(currentMonth, "LLLL yyyy", { locale: ru })} + + +
+
+ { + // Показываем только дни текущего месяца + if (!isSameMonth(day.date, currentMonth)) { + return
; + } + + const isCrossedOut = shouldBeCrossedOut(day.date); + + return ( + + ); + }, + }} + /> +
+
+ ); } diff --git a/src/app/catalog/[id]/components/YachtCharacteristics.tsx b/src/app/catalog/[id]/components/YachtCharacteristics.tsx index 7c2822f..a82b57f 100644 --- a/src/app/catalog/[id]/components/YachtCharacteristics.tsx +++ b/src/app/catalog/[id]/components/YachtCharacteristics.tsx @@ -1,56 +1,45 @@ "use client"; interface YachtCharacteristicsProps { - yacht: { - year: number; - maxCapacity: number; - comfortableCapacity: number; - length: number; - width: number; - cabins: number; - material: string; - power: number; - }; + yacht: CatalogItemLongDto; } export function YachtCharacteristics({ yacht }: YachtCharacteristicsProps) { - const characteristics = [ - { label: "Год", value: yacht.year }, - { - label: "Максимальная вместимость", - value: `${yacht.maxCapacity} человек`, - }, - { - label: "Комфортная вместимость", - value: `${yacht.comfortableCapacity} человек`, - }, - { label: "Длина", value: `${yacht.length} м` }, - { label: "Ширина", value: `${yacht.width} м` }, - { label: "Каюты", value: yacht.cabins }, - { label: "Материал", value: yacht.material }, - { label: "Мощность", value: `${yacht.power} л/с` }, - ]; + const characteristics = [ + { label: "Год", value: yacht.year }, + { + label: "Максимальная вместимость", + value: `${yacht.maxCapacity} человек`, + }, + { + label: "Комфортная вместимость", + value: `${yacht.comfortCapacity} человек`, + }, + { label: "Длина", value: `${yacht.length} м` }, + { label: "Ширина", value: `${yacht.width} м` }, + { label: "Каюты", value: yacht.cabinsCount }, + { label: "Материал", value: yacht.matherial }, + { label: "Мощность", value: `${yacht.power} л/с` }, + ]; - return ( -
-

- Характеристики -

-
- {characteristics.map((char, index) => ( -
- - {char.label} - - - {char.value} - -
- ))} -
-
- ); + return ( +
+

+ Характеристики +

+
+ {characteristics.map((char, index) => ( +
+ {char.label} + + {char.value} + +
+ ))} +
+
+ ); } diff --git a/src/app/catalog/[id]/components/YachtGallery.tsx b/src/app/catalog/[id]/components/YachtGallery.tsx index 60f5b10..8233183 100644 --- a/src/app/catalog/[id]/components/YachtGallery.tsx +++ b/src/app/catalog/[id]/components/YachtGallery.tsx @@ -4,107 +4,108 @@ import { useState, useEffect } from "react"; import Image from "next/image"; import Icon from "@/components/ui/icon"; import { - Carousel, - CarouselContent, - CarouselItem, - CarouselNext, - CarouselPrevious, - type CarouselApi, + Carousel, + CarouselContent, + CarouselItem, + CarouselNext, + CarouselPrevious, + type CarouselApi, } from "@/components/ui/carousel"; +import { getImageUrl } from "@/lib/utils"; interface YachtGalleryProps { - images: string[]; - badge?: string; + images: string[]; + badge?: string; } export function YachtGallery({ images, badge }: YachtGalleryProps) { - const [api, setApi] = useState(); - const [current, setCurrent] = useState(0); + const [api, setApi] = useState(); + const [current, setCurrent] = useState(0); - useEffect(() => { - if (!api) { - return; - } + useEffect(() => { + if (!api) { + return; + } - setCurrent(api.selectedScrollSnap()); + setCurrent(api.selectedScrollSnap()); - api.on("select", () => { - setCurrent(api.selectedScrollSnap()); - }); - }, [api]); + api.on("select", () => { + setCurrent(api.selectedScrollSnap()); + }); + }, [api]); - const scrollTo = (index: number) => { - api?.scrollTo(index); - }; + const scrollTo = (index: number) => { + api?.scrollTo(index); + }; - return ( -
- {/* Main Image Carousel */} -
- - - {images.map((img, index) => ( - -
- {`Yacht -
-
- ))} -
- - -
- {/* Badge - поверх слайдера, не скроллится */} - {badge && ( -
-
- - {badge} -
-
- )} - {/* Photo counter - поверх слайдера, не скроллится */} -
-
- {current + 1}/{images.length} -
+ return ( +
+ {/* Main Image Carousel */} +
+ + + {images.map((img, index) => ( + +
+ {`Yacht
+
+ ))} +
+ + +
+ {/* Badge - поверх слайдера, не скроллится */} + {badge && ( +
+
+ + {badge}
- - {/* Thumbnails - скрыты на мобильных */} -
- {images.map((img, index) => ( - - ))} -
+
+ )} + {/* Photo counter - поверх слайдера, не скроллится */} +
+
+ {current + 1}/{images.length} +
- ); +
+ + {/* Thumbnails - скрыты на мобильных */} +
+ {images.map((img, index) => ( + + ))} +
+
+ ); } diff --git a/src/app/catalog/[id]/const.ts b/src/app/catalog/[id]/const.ts deleted file mode 100644 index b12641a..0000000 --- a/src/app/catalog/[id]/const.ts +++ /dev/null @@ -1,37 +0,0 @@ -export const YACHT = { - id: 1, - name: "Яхта Название", - location: "7 Футов", - price: "18 000", - images: [ - "/images/yachts/yacht1.jpg", - "/images/yachts/yacht2.jpg", - "/images/yachts/yacht3.jpg", - "/images/yachts/yacht4.jpg", - "/images/yachts/yacht5.jpg", - "/images/yachts/yacht6.jpg", - ], - badge: "По запросу", - year: 2000, - maxCapacity: 11, - comfortableCapacity: 11, - length: 13, - width: 4, - cabins: 2, - material: "Стеклопластик", - power: 740, - description: `Яхта Яхта Яхта Яхта Яхта Яхта Яхта Яхта Яхта Яхта - Яхта Яхта Яхта Яхта Яхта Яхта Яхта Яхта Яхта Яхта - Яхта Яхта Яхта Яхта Яхта Яхта Яхта Яхта Яхта Яхта - Яхта Яхта Яхта Яхта Яхта Яхта Яхта Яхта Яхта Яхта - Яхта Яхта Яхта Яхта Яхта Яхта Яхта Яхта Яхта Яхта`, - contactPerson: { - name: "Денис", - avatar: "/images/logo.svg", - }, - requisites: { - ip: "Иванов Иван Иванович", - inn: "23000000000", - ogrn: "310000000000001", - }, -}; diff --git a/src/app/catalog/[id]/page.tsx b/src/app/catalog/[id]/page.tsx index 75353d2..8316adf 100644 --- a/src/app/catalog/[id]/page.tsx +++ b/src/app/catalog/[id]/page.tsx @@ -1,8 +1,8 @@ "use client"; -import { useRouter } from "next/navigation"; +import { useRouter, useParams } from "next/navigation"; import Link from "next/link"; -import { useState } from "react"; +import { useState, useEffect } from "react"; import { ArrowLeft, Heart } from "lucide-react"; import Icon from "@/components/ui/icon"; import { YachtGallery } from "./components/YachtGallery"; @@ -10,283 +10,287 @@ import { YachtAvailability } from "./components/YachtAvailability"; import { BookingWidget } from "./components/BookingWidget"; import { YachtCharacteristics } from "./components/YachtCharacteristics"; import { ContactInfo } from "./components/ContactInfo"; -import { YACHT } from "./const"; +import useApiClient from "@/hooks/useApiClient"; +import { formatSpeed } from "@/lib/utils"; export default function YachtDetailPage() { - // const params = useParams(); - const router = useRouter(); - const [activeTab, setActiveTab] = useState< - | "availability" - | "description" - | "characteristics" - | "contact" - | "requisites" - | "reviews" - >("availability"); + const { id } = useParams(); + const [yacht, setYacht] = useState(null); - return ( -
- {/* Мобильная фиксированная верхняя панель навигации */} -
-
- -

- Яхта + const client = useApiClient(); + + useEffect(() => { + (async () => { + const response = await client.get(`/catalog/${id}/`); + + setYacht(response.data); + })(); + }, [id]); + + // const params = useParams(); + const router = useRouter(); + const [activeTab, setActiveTab] = useState< + | "availability" + | "description" + | "characteristics" + | "contact" + | "requisites" + | "reviews" + >("availability"); + + if (!yacht) { + return
; + } + + return ( +
+ {/* Мобильная фиксированная верхняя панель навигации */} +
+
+ +

Яхта

+ +
+
+ + {/* Десктопная версия - Breadcrumbs */} +
+
+ + + Аренда яхты + + + > + + + Моторные яхты + + + > + {yacht.name} +
+
+ + {/* Main Content Container */} +
+
+ {/* Мобильная версия - без отступов сверху, с отступом для фиксированной панели */} +
+ {/* Gallery */} + + + {/* Yacht Title */} +
+

+ {yacht.name} +

+
+ + {/* Tabs */} +
+
+ + + + + +
+
+ + {/* Tab Content */} +
+ {activeTab === "availability" && ( + + )} + {activeTab === "description" && ( +
+

+ {yacht.description} +

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

+ Отзывы

- +
+
+

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

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

+ {yacht.name} +

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

- {YACHT.name} -

-
+ {/* Characteristics */} + - {/* Tabs */} -
-
- - - - - -
-
+ {/* Description */} +
+

+ Описание +

+

+ {yacht.description} +

+
- {/* Tab Content */} -
- {activeTab === "availability" && ( - - )} - {activeTab === "description" && ( -
-

- {YACHT.description} -

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

- Отзывы -

-
-
-

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

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

+ Отзывы +

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

- {YACHT.name} -

-
-
- - - {YACHT.location} - -
- - -
-
- - {/* 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) */} -
- -
-
-
+
+

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

+
-
- {/* Мобильная фиксированная нижняя панель бронирования */} -
-
-
- - {YACHT.price} ₽ - - - / час - -
- + {/* Right column - Booking Widget (sticky) */} +
+
+
-
- ); +
+

+
+ + {/* Мобильная фиксированная нижняя панель бронирования */} +
+
+
+ + {yacht.minCost} ₽ + + / час +
+ +
+
+
+ ); } diff --git a/src/app/components/FeaturedYacht.tsx b/src/app/components/FeaturedYacht.tsx index 15ac879..5d9dc73 100644 --- a/src/app/components/FeaturedYacht.tsx +++ b/src/app/components/FeaturedYacht.tsx @@ -3,11 +3,11 @@ import { Card, CardContent } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { - Carousel, - CarouselContent, - CarouselItem, - CarouselNext, - CarouselPrevious, + Carousel, + CarouselContent, + CarouselItem, + CarouselNext, + CarouselPrevious, } from "@/components/ui/carousel"; import Image from "next/image"; import Icon from "@/components/ui/icon"; @@ -15,179 +15,170 @@ import { useState } from "react"; import { GuestDatePicker } from "@/components/form/guest-date-picker"; import { formatMinCost, formatWidth, getImageUrl } from "@/lib/utils"; -export default function FeaturedYacht({ yacht }: { yacht: CatalogItemDto }) { - const [selectedImage, setSelectedImage] = useState(yacht.mainImageUrl); +export default function FeaturedYacht({ + yacht, +}: { + yacht: CatalogItemShortDto; +}) { + const [selectedImage, setSelectedImage] = useState(yacht.mainImageUrl); - const handleThumbnailClick = (imageSrc: string) => { - setSelectedImage(imageSrc); - }; + const handleThumbnailClick = (imageSrc: string) => { + setSelectedImage(imageSrc); + }; - return ( -
- - -
- {/* Left side - Yacht details and images */} -
- {/* Promoted banner - Mobile only */} -
- - Заметнее других — бронируют быстрее - -
+ return ( +
+ + +
+ {/* Left side - Yacht details and images */} +
+ {/* Promoted banner - Mobile only */} +
+ + Заметнее других — бронируют быстрее + +
- {/* Header with yacht name and length */} -
-

- {yacht.name} -

-
- - - {formatWidth(yacht.length)} - -
-
+ {/* Header with yacht name and length */} +
+

{yacht.name}

+
+ + {formatWidth(yacht.length)} +
+
- {/* Main yacht image */} -
- {yacht.name} -
+ {/* Main yacht image */} +
+ {yacht.name} +
- {/* Thumbnail images carousel */} -
- - - {yacht.galleryUrls.map((thumb, idx) => ( - -
- {`${yacht.name - handleThumbnailClick( - thumb - ) - } - unoptimized - /> -
-
- ))} -
- - -
-
- - {/* Promoted badge */} - {yacht.isFeatured && ( -
- - - Это объявление продвигается.{" "} - - Хотите так же? - - -
- )} + {/* Thumbnail images carousel */} +
+ + + {yacht.galleryUrls.map((thumb, idx) => ( + +
+ {`${yacht.name} handleThumbnailClick(thumb)} + unoptimized + />
+
+ ))} +
+ + +
+
- {/* Right side - Booking form */} -
-
- {/* Promoted banner - Desktop only */} -
- - Заметнее других — бронируют быстрее - -
+ {/* Promoted badge */} + {yacht.isFeatured && ( +
+ + + Это объявление продвигается.{" "} + + Хотите так же? + + +
+ )} +
-
- {/* Price */} -
-

- {formatMinCost(yacht.minCost)} - - / час - -

-
+ {/* Right side - Booking form */} +
+
+ {/* Promoted banner - Desktop only */} +
+ + Заметнее других — бронируют быстрее + +
- {/* Booking form */} -
- -
+
+ {/* Price */} +
+

+ {formatMinCost(yacht.minCost)} + + / час + +

+
- {/* Book button */} - + {/* Booking form */} +
+ +
- {/* Total price */} -
- - Итого: - - 0 ₽ -
-
-
-
-
- - -
- ); + {/* Book button */} + + + {/* Total price */} +
+ Итого: + 0 ₽ +
+
+
+
+
+ + +
+ ); } diff --git a/src/app/components/YachtGrid.tsx b/src/app/components/YachtGrid.tsx index ef28d02..ebe893c 100644 --- a/src/app/components/YachtGrid.tsx +++ b/src/app/components/YachtGrid.tsx @@ -8,157 +8,156 @@ import Link from "next/link"; import FeaturedYacht from "./FeaturedYacht"; import useApiClient from "@/hooks/useApiClient"; import { useEffect, useState } from "react"; -import { formatMinCost, formatSpeed, formatWidth, getImageUrl } from "@/lib/utils"; +import { + formatMinCost, + formatSpeed, + formatWidth, + getImageUrl, +} from "@/lib/utils"; export default function YachtGrid() { - const client = useApiClient(); + const client = useApiClient(); - const [featuredYacht, setFeaturedYacht] = useState(null); - const [yachtCatalog, setYachtCatalog] = useState(null); + const [featuredYacht, setFeaturedYacht] = + useState(null); + const [yachtCatalog, setYachtCatalog] = useState< + CatalogItemShortDto[] | null + >(null); - useEffect(() => { - (async () => { - const response = await client.get("/catalog/main-page/"); - setFeaturedYacht(response.data.featuredYacht); - setYachtCatalog(response.data.restYachts); - })(); - }, []) + useEffect(() => { + (async () => { + const response = await client.get( + "/catalog/main-page/" + ); + setFeaturedYacht(response.data.featuredYacht); + setYachtCatalog(response.data.restYachts); + })(); + }, []); - return ( -
-
- {/* Header Section */} -
-

- Яхты и катера в аренду -

-

- Онлайн бронирование яхт и катеров -

-

- Каталог лучших яхт Балаклавы разных ценовых сегментах. -

-

- Проверенные лодки с лицензией на перевозки, опытные - капитаны. Выбирайте удобную дату, время и бронируйте. -

-
+ return ( +
+
+ {/* Header Section */} +
+

+ Яхты и катера в аренду +

+

+ Онлайн бронирование яхт и катеров +

+

+ Каталог лучших яхт Балаклавы разных ценовых сегментах. +

+

+ Проверенные лодки с лицензией на перевозки, опытные капитаны. + Выбирайте удобную дату, время и бронируйте. +

+
- {/* Featured Yacht Block */} - {featuredYacht && ( - - )} + {/* Featured Yacht Block */} + {featuredYacht && } - {/* Yacht Grid */} - {yachtCatalog && ( -
- {yachtCatalog.map((yacht) => ( - - - -
- {/* Best Offer Badge - над карточкой */} - {yacht.topText && ( -
-
- - {yacht.topText} - -
-
- )} - {yacht.name} - {/* Badge Overlay */} - {!yacht.hasQuickRent && !yacht.topText && ( - <> -
-
- - - По запросу - -
-
- - )} -
-
- -
- {/* Левая колонка - название и длина */} -
-

- {yacht.name} -

-
- - {formatWidth(yacht.length)} -
-
- - {/* Правая колонка - цена и футы */} -
-
- {yacht.isBestOffer ? ( -

- {formatMinCost(yacht.minCost)} / час -

- ) : ( -

- {formatMinCost(yacht.minCost)} / час -

- )} -
-
- - {formatSpeed(yacht.speed)} -
-
-
-
-
- - ))} + {/* Yacht Grid */} + {yachtCatalog && ( +
+ {yachtCatalog.map((yacht) => ( + + + +
+ {/* Best Offer Badge - над карточкой */} + {yacht.topText && ( +
+
+ {yacht.topText} +
+
+ )} + {yacht.name} + {/* Badge Overlay */} + {!yacht.hasQuickRent && !yacht.topText && ( + <> +
+
+ + По запросу +
+
+ + )}
- )} +
+ +
+ {/* Левая колонка - название и длина */} +
+

{yacht.name}

+
+ + {formatWidth(yacht.length)} +
+
- {/* Call to Action Button */} -
- - - -
-
-
- ); + {/* Правая колонка - цена и футы */} +
+
+ {yacht.isBestOffer ? ( +

+ {formatMinCost(yacht.minCost)} / час +

+ ) : ( +

+ {formatMinCost(yacht.minCost)} / час +

+ )} +
+
+ + {formatSpeed(yacht.speed)} +
+
+
+ + + + ))} +
+ )} + + {/* Call to Action Button */} +
+ + + +
+
+ + ); } diff --git a/src/app/confirm/page.tsx b/src/app/confirm/page.tsx index 0b45f0a..800c3ac 100644 --- a/src/app/confirm/page.tsx +++ b/src/app/confirm/page.tsx @@ -4,456 +4,528 @@ import { Button } from "@/components/ui/button"; import Image from "next/image"; import Link from "next/link"; import { User, ArrowUpRight, Map, ArrowLeft, Heart } from "lucide-react"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; +import useApiClient from "@/hooks/useApiClient"; +import { getImageUrl } from "@/lib/utils"; +import { differenceInHours, parseISO } from "date-fns"; export default function ConfirmPage() { - const [promocode, setPromocode] = useState(""); - const router = useRouter(); - const searchParams = useSearchParams(); + const [yacht, setYacht] = useState(null); + const [totalHours, setTotalHours] = useState(0); + const [totalPrice, setTotalPrice] = useState(0); - // Извлекаем параметры из URL - const yachtId = searchParams.get("yachtId"); - const guestCount = searchParams.get("guestCount"); - const departureDate = searchParams.get("departureDate"); - const departureTime = searchParams.get("departureTime"); - const arrivalDate = searchParams.get("arrivalDate"); - const arrivalTime = searchParams.get("arrivalTime"); + const client = useApiClient(); + const [promocode, setPromocode] = useState(""); + const router = useRouter(); + const searchParams = useSearchParams(); - // Функция для форматирования даты (краткий формат) - const formatDate = (dateString: string | null) => { - if (!dateString) return null; - try { - const date = new Date(dateString); - const months = [ - "янв", "фев", "мар", "апр", "май", "июн", - "июл", "авг", "сен", "окт", "ноя", "дек" - ]; - const day = date.getDate(); - const month = months[date.getMonth()]; - return `${day} ${month}`; - } catch { - return null; - } - }; + // Извлекаем параметры из 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"); - // Функция для форматирования даты (полный формат для десктопа) - const formatDateFull = (dateString: string | null) => { - if (!dateString) return null; - try { - const date = new Date(dateString); - const months = [ - "января", "февраля", "марта", "апреля", "мая", "июня", - "июля", "августа", "сентября", "октября", "ноября", "декабря" - ]; - const day = date.getDate(); - const month = months[date.getMonth()]; - return `${day} ${month}`; - } catch { - return null; - } - }; + useEffect(() => { + (async () => { + const response = await client.get( + `/catalog/${yachtId}/` + ); + setYacht(response.data); + })(); + }, [yachtId]); - // Функция для форматирования времени - const formatTime = (timeString: string | null) => { - if (!timeString) return null; - // Предполагаем формат HH:mm или HH:mm:ss - return timeString.split(":").slice(0, 2).join(":"); - }; + // Расчет стоимости при изменении дат + useEffect(() => { + if ( + departureDate && + departureTime && + arrivalDate && + arrivalTime && + yacht?.minCost + ) { + try { + // Создаем полные даты + const departureDateTime = parseISO(`${departureDate}T${departureTime}`); + const arrivalDateTime = parseISO(`${arrivalDate}T${arrivalTime}`); - // Форматируем данные для отображения - const departureDateFormatted = formatDate(departureDate); - const departureTimeFormatted = formatTime(departureTime); - const arrivalDateFormatted = formatDate(arrivalDate); - const arrivalTimeFormatted = formatTime(arrivalTime); + // Рассчитываем разницу в часах (с округлением до 0.5 часа) + let hoursDiff = differenceInHours(arrivalDateTime, departureDateTime); - // Полный формат для десктопной версии - const departureDateFormattedFull = formatDateFull(departureDate); - const arrivalDateFormattedFull = formatDateFull(arrivalDate); + // Добавляем разницу в минутах + const minutesDiff = + (arrivalDateTime.getMinutes() - departureDateTime.getMinutes()) / 60; + hoursDiff += minutesDiff; - // Формируем строки для отображения - const departureDisplay = departureDateFormatted && departureTimeFormatted - ? `${departureDateFormatted} ${departureTimeFormatted}` - : "Не выбрано"; - - const arrivalDisplay = arrivalDateFormatted && arrivalTimeFormatted - ? `${arrivalDateFormatted} ${arrivalTimeFormatted}` - : "Не выбрано"; + // Округляем до ближайших 0.5 часа + const roundedHours = Math.ceil(hoursDiff * 2) / 2; - const datesDisplay = departureDateFormattedFull && departureTimeFormatted && arrivalDateFormattedFull && arrivalTimeFormatted - ? `${departureDateFormattedFull} в ${departureTimeFormatted} — ${arrivalDateFormattedFull} в ${arrivalTimeFormatted}` - : "Не выбрано"; + // Рассчитываем стоимость + const pricePerHour = yacht.minCost; + const total = pricePerHour * roundedHours; - const guestsDisplay = guestCount - ? guestCount === "1" ? "1 гость" : `${guestCount} гостей` - : "Не выбрано"; + setTotalHours(roundedHours); + setTotalPrice(total); + } catch (error) { + console.error("Error calculating price:", error); + setTotalHours(0); + setTotalPrice(0); + } + } else { + setTotalHours(0); + setTotalPrice(0); + } + }, [departureDate, departureTime, arrivalDate, arrivalTime, yacht]); - return ( -
- {/* Мобильная версия */} -
- {/* Верхний блок с навигацией */} -
-
-
- {/* Кнопка назад */} - + // Функция для форматирования даты (краткий формат) + 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; + } + }; - {/* Центральный блок с информацией */} -
-

- Яхта Сеньорита -

-
- {departureDateFormatted || "Не выбрано"} - Гостей: {guestCount || "Не выбрано"} -
-
+ // Функция для форматирования даты (полный формат для десктопа) + 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; + } + }; - {/* Кнопка избранного */} - -
-
+ // Функция для форматирования времени + 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 мая вы получите частичный - возврат.{" "} - - Подробнее - -

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

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

-
-
- - 26 400₽ x 2ч - - - 52 800 ₽ - -
-
- - Услуги - - 0 Р -
-
- - Итого: - - - 52 800 Р - -
-
-
- - {/* Промокод */} -
-
- - 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 - скрыты на мобильных */} -
- - - Аренда яхты + {/* Поля Выход и Заход */} +
+
+ +
+
{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}ч - - > - - Ваше бронирование - -
- -
- {/* Левая колонка - Информация о яхте и ценах - скрыта на мобильных */} -
-
-
- {/* Изображение яхты */} -
- Яхта - {/* Плашка владельца */} -
-
- -
- - Владелец - - - Денис - -
-
-
-
- {/* Название яхты */} -

- Яхта -

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

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

-
-
- - 26 400₽ x 2ч - - - 52 800 ₽ - -
-
- - Услуги - - - 0 Р - -
-
- - Итого: - - - 52 800 Р - -
-
-
-
-
- -
-
- {/* Промокод */} -
- - 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" - /> - -
-
-
-
- - {/* Правая колонка - Подтверждение бронирования */} -
-
- {/* Заголовок */} -

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

-

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

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

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

- -

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

- - Подробнее - - - {/* Указание времени и кнопка отправки */} -
-
- - - По местному времени яхты - -
- -
-
+ + {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" + /> + +
+
+
-
- ); + + {/* Правая колонка - Подтверждение бронирования */} +
+
+ {/* Заголовок */} +

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

+

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

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

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

+ +

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

+ + Подробнее + + + {/* Указание времени и кнопка отправки */} +
+
+ + + По местному времени яхты + +
+ +
+
+
+
+
+
+
+ ); } diff --git a/src/components/form/guest-date-picker.tsx b/src/components/form/guest-date-picker.tsx index a9a15f1..ecc9e86 100644 --- a/src/components/form/guest-date-picker.tsx +++ b/src/components/form/guest-date-picker.tsx @@ -9,302 +9,295 @@ import { Button } from "@/components/ui/button"; import { Calendar } from "@/components/ui/calendar"; import { Counter } from "@/components/ui/counter"; import { - Popover, - PopoverContent, - PopoverTrigger, + Popover, + PopoverContent, + PopoverTrigger, } from "@/components/ui/popover"; interface GuestDatePickerProps { - onApply?: (data: { - date: Date | undefined; - departureTime: string; - arrivalTime: string; - adults: number; - children: number; - }) => void; - className?: string; + onApply?: (data: { + date: Date | undefined; + departureTime: string; + arrivalTime: string; + adults: number; + children: number; + }) => void; + className?: string; } interface CommonPopoverContentProps { - date: Date | undefined; - setDate: (date: Date | undefined) => void; - departureTime: string; - setDepartureTime: (time: string) => void; - arrivalTime: string; - setArrivalTime: (time: string) => void; - adults: number; - setAdults: (count: number) => void; - childrenCount: number; - setChildrenCount: (count: number) => void; - handleApply: () => void; + date: Date | undefined; + setDate: (date: Date | undefined) => void; + departureTime: string; + setDepartureTime: (time: string) => void; + arrivalTime: string; + setArrivalTime: (time: string) => void; + adults: number; + setAdults: (count: number) => void; + childrenCount: number; + setChildrenCount: (count: number) => void; + handleApply: () => void; } const CommonPopoverContent: React.FC = ({ - date, - setDate, - departureTime, - setDepartureTime, - arrivalTime, - setArrivalTime, - adults, - setAdults, - childrenCount, - setChildrenCount, - handleApply, + date, + setDate, + departureTime, + setDepartureTime, + arrivalTime, + setArrivalTime, + adults, + setAdults, + childrenCount, + setChildrenCount, + handleApply, }) => { - return ( - - {/* Календарь */} + 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", - }} + 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", + }} + /> + + {/* Счетчики гостей */} +
+ + +
+ + {/* Поля времени */} +
+
+ +
+ + setDepartureTime(e.target.value)} + className="w-full focus:outline-none focus:border-transparent" /> +
+
+
+ +
+ + setArrivalTime(e.target.value)} + className="w-full focus:outline-none focus:border-transparent" + /> +
+
+
- {/* Счетчики гостей */} -
- - -
- - {/* Поля времени */} -
-
- -
- - setDepartureTime(e.target.value)} - className="w-full focus:outline-none focus:border-transparent" - /> -
-
- -
- -
- - setArrivalTime(e.target.value)} - className="w-full focus:outline-none focus:border-transparent" - /> -
-
-
- - {/* Кнопка Применить */} - -
- ); + {/* Кнопка Применить */} + +
+ ); }; export const GuestDatePicker: React.FC = ({ - onApply, - className, + 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); - const [isDepartureOpen, setIsDepartureOpen] = useState(false); - const [isArrivalOpen, setIsArrivalOpen] = useState(false); - const [isGuestOpen, setIsGuestOpen] = useState(false); + 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); + const [isDepartureOpen, setIsDepartureOpen] = useState(false); + const [isArrivalOpen, setIsArrivalOpen] = useState(false); + const [isGuestOpen, setIsGuestOpen] = useState(false); - const handleApply = () => { - onApply?.({ - date, - departureTime, - arrivalTime, - adults, - children, - }); - setIsDepartureOpen(false); - setIsArrivalOpen(false); - setIsGuestOpen(false); - }; + const handleApply = () => { + onApply?.({ + date, + departureTime, + arrivalTime, + adults, + children, + }); + setIsDepartureOpen(false); + setIsArrivalOpen(false); + setIsGuestOpen(false); + }; - const getDepartureDisplayText = () => { - if (!date || !departureTime) return "Выход"; - - return ( - <> - {format(date, "d MMMM", { - locale: ru, - })} - , {departureTime} - - ); - }; - - const getArrivalDisplayText = () => { - if (!date || !arrivalTime) return "Заход"; - - return ( - <> - {format(date, "d MMMM", { - locale: ru, - })} - , {arrivalTime} - - ); - }; - - const getGuestDisplayText = () => { - if (adults === 1 && children === 0) return "1 гость"; - return ( - - Взрослых: {adults}, Детей: {children} - - ); - }; + const getDepartureDisplayText = () => { + if (!date || !departureTime) return "Выход"; return ( -
-
- {/* Кнопка Выход */} - - - - - - - - {/* Кнопка Заход */} - - - - - - - - {/* Кнопка Гости */} - - - - - - -
-
+ <> + {format(date, "d MMMM", { + locale: ru, + })} + , {departureTime} + ); + }; + + const getArrivalDisplayText = () => { + if (!date || !arrivalTime) return "Заход"; + + return ( + <> + {format(date, "d MMMM", { + locale: ru, + })} + , {arrivalTime} + + ); + }; + + const getGuestDisplayText = () => { + if (adults === 1 && children === 0) return "1 гость"; + return ( + + Взрослых: {adults}, Детей: {children} + + ); + }; + + return ( +
+
+ {/* Кнопка Выход */} + + + + + + + + {/* Кнопка Заход */} + + + + + + + + {/* Кнопка Гости */} + + + + + + +
+
+ ); }; diff --git a/src/components/ui/date-picker.tsx b/src/components/ui/date-picker.tsx index 0632a52..18100af 100644 --- a/src/components/ui/date-picker.tsx +++ b/src/components/ui/date-picker.tsx @@ -8,193 +8,202 @@ 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; + 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, +export function DatePicker({ + 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", + }} + /> - {/* Поля времени */} -
-
- -
- - - handleDepartureTimeChange(e.target.value) - } - className="w-full focus:outline-none focus:border-transparent" - /> -
-
- -
- -
- - - handleArrivalTimeChange(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" + />
- - - ); +
+ ) : 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 74559f1..51d387e 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: "http://192.168.1.5:4000/", + baseURL: "/api", headers: { "Content-Type": "application/json", },