Compare commits

..

10 Commits

Author SHA1 Message Date
Иван ee31b639b2 make my shit work 2025-12-19 20:41:46 +03:00
Иван da29133989 fixes 2025-12-19 14:22:38 +03:00
Sergey Bolshakov 089f5064a3 Страницы профиля 2025-12-15 15:12:31 +03:00
Sergey Bolshakov 63725ff710 Доработка флоу 2025-12-15 14:53:50 +03:00
Sergey Bolshakov b249ab597b unoptimized images 2025-12-15 01:19:19 +03:00
Sergey Bolshakov b63bc78ec7 build fix 2025-12-15 01:08:28 +03:00
Sergey Bolshakov 040ee2dd05 header button logic 2025-12-15 01:00:36 +03:00
Иван 745d58ab3a Add all possible bullshit 2025-12-15 00:59:15 +03:00
Sergey Bolshakov d177eee970 страницы профиля 2025-12-15 00:44:44 +03:00
Sergey Bolshakov b45f9885ab Фильтры в каталоге, интеграция 2025-12-14 23:24:18 +03:00
29 changed files with 3250 additions and 1312 deletions

View File

@ -1,22 +0,0 @@
stages:
- build
- deploy
workflow:
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
build:
stage: build
script:
- echo "Building Docker image..."
- docker build -t travelmarine-frontend:latest .
deploy:
stage: deploy
needs: ["build"]
script:
- echo "Restarting container..."
- docker ps -a --filter 'name=^/travelmarine-frontend$' --format '{{.Names}}' | grep -q '^travelmarine-frontend$' && docker rm -f travelmarine-frontend || true
- docker run -d --name travelmarine-frontend --restart unless-stopped -p 127.0.0.1:3000:3000 travelmarine-frontend:latest
when: on_success

View File

@ -4,12 +4,12 @@ const nextConfig: NextConfig = {
images: {
remotePatterns: [
{
protocol: "http",
hostname: "89.169.188.2",
pathname: '/**'
protocol: "https",
hostname: "api.travelmarine.ru",
pathname: "/**",
},
],
unoptimized: false,
unoptimized: true,
},
webpack(config) {
config.module.rules.push({

2
package-lock.json generated
View File

@ -44,7 +44,7 @@
"eslint-config-next": "15.5.5",
"tailwindcss": "^4",
"turbo": "^2.6.3",
"typescript": "^5"
"typescript": "5.9.3"
}
},
"node_modules/@alloc/quick-lru": {

View File

@ -45,6 +45,6 @@
"eslint-config-next": "15.5.5",
"tailwindcss": "^4",
"turbo": "^2.6.3",
"typescript": "^5"
"typescript": "5.9.3"
}
}

View File

@ -1,4 +1,4 @@
interface CatalogItemDto {
export interface CatalogItemShortDto {
id?: number;
name: string;
length: number;
@ -12,12 +12,75 @@ interface CatalogItemDto {
isBestOffer?: boolean;
}
interface MainPageCatalogResponseDto {
featuredYacht: CatalogItemDto;
restYachts: CatalogItemDto[];
export interface MainPageCatalogResponseDto {
featuredYacht: CatalogItemShortDto;
restYachts: CatalogItemShortDto[];
}
interface CatalogFilteredResponseDto {
items: CatalogItemDto[];
export interface CatalogFilteredResponseDto {
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;
}
export interface User {
userId?: number;
firstName?: string;
lastName?: string;
phone?: string;
email?: string;
password?: string;
yachts?: Yacht[];
companyName?: string;
inn?: number;
ogrn?: number;
}
export 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;
}
export interface ReservationDto {
yachtId: number;
reservatorId: number;
startUtc: number;
endUtc: number;
id: number;
yacht: CatalogItemLongDto;
}

View File

@ -8,9 +8,40 @@ interface AuthDataType {
rememberMe: boolean;
}
interface JWTPayload {
user_id?: number;
userId?: number;
sub?: string;
id?: number;
[key: string]: unknown;
}
const parseJWT = (token: string): JWTPayload | null => {
try {
const base64Url = token.split(".")[1];
if (!base64Url) {
return null;
}
const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/");
const jsonPayload = decodeURIComponent(
atob(base64)
.split("")
.map((c) => "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2))
.join("")
);
return JSON.parse(jsonPayload);
} catch (error) {
console.error("Ошибка при парсинге JWT токена:", error);
return null;
}
};
const useAuthentificate = () => {
const client = useApiClient();
const setToken = useAuthStore((state) => state.setToken);
const setUserId = useAuthStore((state) => state.setUserId);
return useMutation({
mutationKey: ["auth"],
@ -25,6 +56,21 @@ const useAuthentificate = () => {
setToken(access_token, authData.rememberMe);
// Парсим JWT токен и извлекаем userId
const payload = parseJWT(access_token);
if (payload) {
const userId =
payload.user_id ||
payload.userId ||
payload.sub ||
payload.id ||
null;
if (userId) {
setUserId(userId, authData.rememberMe);
}
}
return access_token;
},
onError: () => {

View File

@ -1,101 +1,138 @@
"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";
import { CatalogItemLongDto } from "@/api/types";
interface BookingWidgetProps {
price: string;
price: string;
yacht: CatalogItemLongDto;
}
export function BookingWidget({ price }: BookingWidgetProps) {
const router = useRouter();
const [departureDate] = useState<Date | undefined>();
const [arrivalDate] = useState<Date | undefined>();
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<Date | undefined>();
const [arrivalDate, setArrivalDate] = useState<Date | undefined>();
const [departureTime, setDepartureTime] = useState<string>("12:00");
const [arrivalTime, setArrivalTime] = useState<string>("13:00");
const [guests, setGuests] = useState({ adults: 1, children: 0 });
const handleGuestsChange = (adults: number, children: number) => {
setGuests({ adults, children });
};
// Расчет итоговой стоимости
const total = useMemo(() => {
if (!departureDate || !arrivalDate || !departureTime || !arrivalTime || !yacht?.minCost) {
return 0;
}
const handleBook = () => {
// Логика бронирования
console.log("Booking:", {
departureDate,
arrivalDate,
guests,
});
router.push("/confirm");
};
const departureDateStr = format(departureDate, "yyyy-MM-dd");
const arrivalDateStr = format(arrivalDate, "yyyy-MM-dd");
return (
<div className="bg-white border border-gray-200 rounded-lg p-6">
<div className="mb-6">
<p className="text-2xl font-bold text-[#333333] mb-2">
от {price} {" "}
<span className="text-base font-normal text-[#999999]">
/час
</span>
</p>
</div>
<div className="space-y-4 mb-6">
<div>
<label className="block text-sm font-medium text-[#333333] mb-2">
Выход
</label>
<DatePicker
variant="small"
placeholder="Выберите дату и время"
showIcon={false}
/>
</div>
<div>
<label className="block text-sm font-medium text-[#333333] mb-2">
Заход
</label>
<DatePicker
variant="small"
placeholder="Выберите дату и время"
showIcon={false}
/>
</div>
<div>
<label className="block text-sm font-medium text-[#333333] mb-2">
Гостей
</label>
<GuestPicker
adults={guests.adults}
childrenCount={guests.children}
onChange={handleGuestsChange}
variant="small"
showIcon={false}
placeholder="1 гость"
/>
</div>
</div>
<Button
onClick={handleBook}
variant="gradient"
className="w-full h-12 font-bold text-white mb-4"
>
Забронировать
</Button>
<div className="pt-4 border-t border-gray-200">
<div className="flex justify-between items-center">
<span className="text-base text-[#333333]">Итого:</span>
<span className="text-base font-bold text-[#333333]">
{total}
</span>
</div>
</div>
</div>
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 || !departureTime || !arrivalTime || !yacht || !yacht.id) return;
const params = new URLSearchParams({
yachtId: yacht.id.toString(),
departureDate: format(departureDate, "yyyy-MM-dd"),
departureTime: departureTime,
arrivalDate: format(arrivalDate, "yyyy-MM-dd"),
arrivalTime: arrivalTime,
guests: (guests.adults + guests.children).toString(),
});
router.push(`/confirm?${params.toString()}`);
};
return (
<div className="bg-white border border-gray-200 rounded-lg p-6">
<div className="mb-6">
<p className="text-2xl font-bold text-[#333333] mb-2">
от {price} {" "}
<span className="text-base font-normal text-[#999999]">/час</span>
</p>
</div>
<div className="space-y-4 mb-6">
<div>
<label className="block text-sm font-medium text-[#333333] mb-2">
Выход
</label>
<DatePicker
variant="small"
placeholder="Выберите дату и время"
showIcon={false}
onDateChange={setDepartureDate}
value={departureDate}
departureTime={departureTime}
onDepartureTimeChange={setDepartureTime}
onlyDeparture
/>
</div>
<div>
<label className="block text-sm font-medium text-[#333333] mb-2">
Заход
</label>
<DatePicker
variant="small"
placeholder="Выберите дату и время"
showIcon={false}
onDateChange={setArrivalDate}
value={arrivalDate}
arrivalTime={arrivalTime}
onArrivalTimeChange={setArrivalTime}
onlyArrival
/>
</div>
<div>
<label className="block text-sm font-medium text-[#333333] mb-2">
Гостей
</label>
<GuestPicker
adults={guests.adults}
childrenCount={guests.children}
onChange={handleGuestsChange}
variant="small"
showIcon={false}
placeholder="1 гость"
/>
</div>
</div>
<Button
onClick={handleBook}
variant="gradient"
className="w-full h-12 font-bold text-white mb-4"
disabled={!departureDate || !arrivalDate || !departureTime || !arrivalTime}
>
Забронировать
</Button>
<div className="pt-4 border-t border-gray-200">
<div className="flex justify-between items-center">
<span className="text-base text-[#333333]">Итого:</span>
<span className="text-base font-bold text-[#333333]">{formatPrice(total)} </span>
</div>
</div>
</div>
);
}

View File

@ -1,70 +1,45 @@
"use client";
import Image from "next/image";
import { User } from "@/api/types";
interface ContactInfoProps {
contactPerson: {
name: string;
avatar: string;
};
requisites: {
ip: string;
inn: string;
ogrn: string;
};
}
export function ContactInfo({ contactPerson, requisites }: ContactInfoProps) {
return (
<div className="flex flex-col sm:flex-row gap-5">
<div className="flex-1 rounded-[24px] px-6 py-5 bg-[#f4f4f4]">
<div className="flex items-center gap-4 h-full">
<div className="relative rounded-full overflow-hidden bg-gray-200 flex items-center justify-center">
<Image
src="/images/avatar.png"
alt={contactPerson.name}
width={124}
height={124}
/>
</div>
<div className="flex flex-col justify-between h-full">
<h3 className="text-base font-bold text-[#333333]">
{contactPerson.name}
</h3>
<p className="text-base text-[#333333]">
Контактное лицо
</p>
</div>
</div>
</div>
<div className="flex-1 rounded-[24px] px-6 py-5 bg-[#f4f4f4]">
<h3 className="text-base font-bold text-[#333333] mb-3">
Реквизиты
</h3>
<div className="space-y-2">
<div className="flex flex-col sm:flex-row sm:justify-between gap-1">
<span className="text-base text-[#333333]">ИП</span>
<span className="text-base text-[#999999]">
{requisites.ip}
</span>
</div>
<div className="flex flex-col sm:flex-row sm:justify-between gap-1">
<span className="text-base text-[#333333]">ИНН</span>
<span className="text-base text-[#999999]">
{requisites.inn}
</span>
</div>
<div className="flex flex-col sm:flex-row sm:justify-between gap-1">
<span className="text-base text-[#333333]">
ОГРН/ОГРНИП
</span>
<span className="text-base text-[#999999]">
{requisites.ogrn}
</span>
</div>
</div>
</div>
export function ContactInfo({ firstName, companyName, inn, ogrn }: User) {
return (
<div className="flex flex-col sm:flex-row gap-5">
<div className="flex-1 rounded-[24px] px-6 py-5 bg-[#f4f4f4]">
<div className="flex items-center gap-4 h-full">
<div className="relative rounded-full overflow-hidden bg-gray-200 flex items-center justify-center">
<Image
src="/images/avatar.png"
alt={firstName || "avatar"}
width={124}
height={124}
/>
</div>
<div className="flex flex-col justify-between h-full">
<h3 className="text-base font-bold text-[#333333]">{firstName}</h3>
<p className="text-base text-[#333333]">Контактное лицо</p>
</div>
</div>
);
</div>
<div className="flex-1 rounded-[24px] px-6 py-5 bg-[#f4f4f4]">
<h3 className="text-base font-bold text-[#333333] mb-3">Реквизиты</h3>
<div className="space-y-2">
<div className="flex flex-col sm:flex-row sm:justify-between gap-1">
<span className="text-base text-[#333333]">ИП</span>
<span className="text-base text-[#999999]">{companyName}</span>
</div>
<div className="flex flex-col sm:flex-row sm:justify-between gap-1">
<span className="text-base text-[#333333]">ИНН</span>
<span className="text-base text-[#999999]">{inn}</span>
</div>
<div className="flex flex-col sm:flex-row sm:justify-between gap-1">
<span className="text-base text-[#333333]">ОГРН/ОГРНИП</span>
<span className="text-base text-[#999999]">{ogrn}</span>
</div>
</div>
</div>
</div>
);
}

View File

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

View File

@ -1,56 +1,47 @@
"use client";
import { CatalogItemLongDto } from "@/api/types";
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 (
<div>
<h2 className="text-base font-bold text-[#333333] mb-4">
Характеристики
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-6">
{characteristics.map((char, index) => (
<div
key={index}
className="flex justify-between items-center py-4 border-b border-gray-200"
>
<span className="text-base text-[#999999]">
{char.label}
</span>
<span className="text-base font-regular text-[#333333]">
{char.value}
</span>
</div>
))}
</div>
</div>
);
return (
<div>
<h2 className="text-base font-bold text-[#333333] mb-4">
Характеристики
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-6">
{characteristics.map((char, index) => (
<div
key={index}
className="flex justify-between items-center py-4 border-b border-gray-200"
>
<span className="text-base text-[#999999]">{char.label}</span>
<span className="text-base font-regular text-[#333333]">
{char.value}
</span>
</div>
))}
</div>
</div>
);
}

View File

@ -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<CarouselApi>();
const [current, setCurrent] = useState(0);
const [api, setApi] = useState<CarouselApi>();
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 (
<div className="space-y-4">
{/* Main Image Carousel */}
<div className="relative">
<Carousel
setApi={setApi}
opts={{
align: "start",
loop: false,
}}
className="w-full"
>
<CarouselContent>
{images.map((img, index) => (
<CarouselItem key={index}>
<div className="relative w-full h-[60vh] lg:h-[592px] rounded-0 lg:rounded-[24px] overflow-hidden">
<Image
src={img}
alt={`Yacht image ${index + 1}`}
fill
className="object-cover"
priority={index === 0}
/>
</div>
</CarouselItem>
))}
</CarouselContent>
<CarouselPrevious className="left-2 lg:left-4 bg-white/80 hover:bg-white border-gray-300 w-10 h-10 lg:w-12 lg:h-12 rounded-full" />
<CarouselNext className="right-2 lg:right-4 bg-white/80 hover:bg-white border-gray-300 w-10 h-10 lg:w-12 lg:h-12 rounded-full" />
</Carousel>
{/* Badge - поверх слайдера, не скроллится */}
{badge && (
<div className="absolute bottom-4 left-4 z-20 pointer-events-none">
<div className="flex items-center justify-center bg-black/40 text-white px-3 py-1 rounded-lg text-sm gap-1">
<Icon size={16} name="restart" />
<span>{badge}</span>
</div>
</div>
)}
{/* Photo counter - поверх слайдера, не скроллится */}
<div className="absolute bottom-4 right-4 z-20 pointer-events-none">
<div className="bg-black/40 text-white px-3 py-1 rounded-lg text-sm">
{current + 1}/{images.length}
</div>
return (
<div className="space-y-4">
{/* Main Image Carousel */}
<div className="relative">
<Carousel
setApi={setApi}
opts={{
align: "start",
loop: false,
}}
className="w-full"
>
<CarouselContent>
{images.map((img, index) => (
<CarouselItem key={index}>
<div className="relative w-full h-[60vh] lg:h-[592px] rounded-0 lg:rounded-[24px] overflow-hidden">
<Image
src={getImageUrl(img)}
alt={`Yacht image ${index + 1}`}
fill
className="object-cover"
priority={index === 0}
/>
</div>
</CarouselItem>
))}
</CarouselContent>
<CarouselPrevious className="left-2 lg:left-4 bg-white/80 hover:bg-white border-gray-300 w-10 h-10 lg:w-12 lg:h-12 rounded-full" />
<CarouselNext className="right-2 lg:right-4 bg-white/80 hover:bg-white border-gray-300 w-10 h-10 lg:w-12 lg:h-12 rounded-full" />
</Carousel>
{/* Badge - поверх слайдера, не скроллится */}
{badge && (
<div className="absolute bottom-4 left-4 z-20 pointer-events-none">
<div className="flex items-center justify-center bg-black/40 text-white px-3 py-1 rounded-lg text-sm gap-1">
<Icon size={16} name="restart" />
<span>{badge}</span>
</div>
{/* Thumbnails - скрыты на мобильных */}
<div className="hidden lg:flex gap-2 overflow-x-auto pb-2">
{images.map((img, index) => (
<button
key={index}
onClick={() => scrollTo(index)}
className={`relative flex-shrink-0 w-17 h-14 rounded-lg overflow-hidden border-2 transition-all ${
current === index
? "border-[#008299] opacity-100"
: "border-transparent opacity-60 hover:opacity-100"
}`}
>
<Image
src={img}
alt={`Thumbnail ${index + 1}`}
fill
className="object-cover"
/>
</button>
))}
</div>
</div>
)}
{/* Photo counter - поверх слайдера, не скроллится */}
<div className="absolute bottom-4 right-4 z-20 pointer-events-none">
<div className="bg-black/40 text-white px-3 py-1 rounded-lg text-sm">
{current + 1}/{images.length}
</div>
</div>
);
</div>
{/* Thumbnails - скрыты на мобильных */}
<div className="hidden lg:flex gap-2 overflow-x-auto pb-2">
{images.map((img, index) => (
<button
key={index}
onClick={() => scrollTo(index)}
className={`relative flex-shrink-0 w-17 h-14 rounded-lg overflow-hidden border-2 transition-all ${
current === index
? "border-[#008299] opacity-100"
: "border-transparent opacity-60 hover:opacity-100"
}`}
>
<Image
src={getImageUrl(img)}
alt={`Thumbnail ${index + 1}`}
fill
className="object-cover"
/>
</button>
))}
</div>
</div>
);
}

View File

@ -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",
},
};

View File

@ -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,9 +10,28 @@ 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 { GuestPicker } from "@/components/form/guest-picker";
import useApiClient from "@/hooks/useApiClient";
import { formatSpeed } from "@/lib/utils";
import { format } from "date-fns";
import { CatalogItemLongDto } from "@/api/types";
export default function YachtDetailPage() {
const { id } = useParams();
const [yacht, setYacht] = useState<CatalogItemLongDto | null>(null);
const client = useApiClient();
useEffect(() => {
(async () => {
const response = await client.get<CatalogItemLongDto>(
`/catalog/${id}/`
);
setYacht(response.data);
})();
}, [id]);
// const params = useParams();
const router = useRouter();
const [activeTab, setActiveTab] = useState<
@ -24,6 +43,40 @@ export default function YachtDetailPage() {
| "reviews"
>("availability");
// Состояние для мобильного бронирования
const [selectedDate, setSelectedDate] = useState<Date | undefined>();
const [startTime, setStartTime] = useState<string>("");
const [endTime, setEndTime] = useState<string>("");
const [guests, setGuests] = useState({ adults: 1, children: 0 });
const handleGuestsChange = (adults: number, children: number) => {
setGuests({ adults, children });
};
const handleBookMobile = () => {
if (!selectedDate || !startTime || !endTime || !yacht || !yacht.id)
return;
// Используем выбранную дату как дату отправления и прибытия (можно изменить логику при необходимости)
const departureDate = format(selectedDate, "yyyy-MM-dd");
const arrivalDate = format(selectedDate, "yyyy-MM-dd");
const params = new URLSearchParams({
yachtId: yacht.id.toString(),
departureDate: departureDate,
departureTime: startTime,
arrivalDate: arrivalDate,
arrivalTime: endTime,
guests: (guests.adults + guests.children).toString(),
});
router.push(`/confirm?${params.toString()}`);
};
if (!yacht) {
return <div />;
}
return (
<main className="bg-[#f4f4f4] min-h-screen ">
{/* Мобильная фиксированная верхняя панель навигации */}
@ -59,7 +112,7 @@ export default function YachtDetailPage() {
</span>
</Link>
<span>&gt;</span>
<span className="text-[#333333]">{YACHT.name}</span>
<span className="text-[#333333]">{yacht.name}</span>
</div>
</div>
@ -70,14 +123,14 @@ export default function YachtDetailPage() {
<div className="lg:hidden pt-[50px]">
{/* Gallery */}
<YachtGallery
images={YACHT.images}
badge={YACHT.badge}
images={yacht.galleryUrls || []}
badge={!yacht.hasQuickRent ? "По запросу" : ""}
/>
{/* Yacht Title */}
<div className="px-4 pt-4">
<h1 className="text-xl font-bold text-[#333333] mb-4">
{YACHT.name}
{yacht.name}
</h1>
</div>
@ -142,26 +195,46 @@ export default function YachtDetailPage() {
{/* Tab Content */}
<div className="px-4 py-6">
{activeTab === "availability" && (
<YachtAvailability
price={YACHT.price}
mobile={true}
/>
<>
<YachtAvailability
price={String(yacht.minCost)}
mobile={true}
reservations={yacht.reservations}
selectedDate={selectedDate}
startTime={startTime}
endTime={endTime}
onDateChange={setSelectedDate}
onStartTimeChange={setStartTime}
onEndTimeChange={setEndTime}
/>
{/* Выбор гостей для мобильной версии */}
<div className="mt-6">
<label className="block text-sm font-medium text-[#333333] mb-2">
Гостей
</label>
<GuestPicker
adults={guests.adults}
childrenCount={guests.children}
onChange={handleGuestsChange}
variant="small"
showIcon={false}
placeholder="1 гость"
/>
</div>
</>
)}
{activeTab === "description" && (
<div>
<p className="text-base text-[#666666] leading-relaxed">
{YACHT.description}
{yacht.description}
</p>
</div>
)}
{activeTab === "characteristics" && (
<YachtCharacteristics yacht={YACHT} />
<YachtCharacteristics yacht={yacht} />
)}
{activeTab === "contact" && (
<ContactInfo
contactPerson={YACHT.contactPerson}
requisites={YACHT.requisites}
/>
<ContactInfo {...yacht.owner} />
)}
{activeTab === "reviews" && (
<div>
@ -186,13 +259,13 @@ export default function YachtDetailPage() {
{/* Yacht Title and Actions */}
<div className="mb-6 flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<h1 className="text-xl md:text-2xl font-bold text-[#333333]">
{YACHT.name}
{yacht.name}
</h1>
<div className="flex items-center gap-6">
<div className="flex items-center gap-2 text-[#333333]">
<Icon name="pin" size={32} />
<span className="text-base">
{YACHT.location}
{formatSpeed(yacht.speed)}
</span>
</div>
<button className="flex items-center gap-2 cursor-pointer text-[#333333] hover:text-[#008299] transition-colors">
@ -212,8 +285,8 @@ export default function YachtDetailPage() {
<div className="space-y-6">
{/* Gallery */}
<YachtGallery
images={YACHT.images}
badge={YACHT.badge}
images={yacht.galleryUrls || []}
badge={!yacht.hasQuickRent ? "По запросу" : ""}
/>
{/* Content with Booking Widget on the right */}
@ -221,10 +294,13 @@ export default function YachtDetailPage() {
{/* Left column - all content below gallery */}
<div className="flex-1 space-y-6">
{/* Availability */}
<YachtAvailability price={YACHT.price} />
<YachtAvailability
price={String(yacht.minCost)}
reservations={yacht.reservations}
/>
{/* Characteristics */}
<YachtCharacteristics yacht={YACHT} />
<YachtCharacteristics yacht={yacht} />
{/* Description */}
<div>
@ -232,15 +308,12 @@ export default function YachtDetailPage() {
Описание
</h2>
<p className="text-base text-[#666666] leading-relaxed">
{YACHT.description}
{yacht.description}
</p>
</div>
{/* Contact and Requisites */}
<ContactInfo
contactPerson={YACHT.contactPerson}
requisites={YACHT.requisites}
/>
<ContactInfo {...yacht.owner} />
{/* Reviews */}
<div>
@ -260,7 +333,10 @@ export default function YachtDetailPage() {
{/* Right column - Booking Widget (sticky) */}
<div className="lg:w-74 flex-shrink-0 lg:sticky lg:top-24 self-start">
<BookingWidget price={YACHT.price} />
<BookingWidget
price={String(yacht.minCost)}
yacht={yacht}
/>
</div>
</div>
</div>
@ -273,15 +349,16 @@ export default function YachtDetailPage() {
<div className="flex items-center justify-between">
<div>
<span className="text-lg font-bold text-[#333333]">
{YACHT.price}
{yacht.minCost}
</span>
<span className="text-sm text-[#999999] ml-1">
/ час
</span>
</div>
<button
onClick={() => router.push("/confirm")}
className="bg-[#008299] text-white px-6 py-3 rounded-lg font-bold text-base hover:bg-[#006d7a] transition-colors"
onClick={handleBookMobile}
disabled={!selectedDate || !startTime || !endTime}
className="bg-[#008299] text-white px-6 py-3 rounded-lg font-bold text-base hover:bg-[#006d7a] transition-colors disabled:bg-gray-300 disabled:cursor-not-allowed"
>
Забронировать
</button>

View File

@ -1,6 +1,6 @@
"use client";
import { useEffect, useState, useCallback, useRef } from "react";
import { useEffect, useState, useCallback, useRef, Suspense } from "react";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import Image from "next/image";
import Icon from "@/components/ui/icon";
@ -18,6 +18,7 @@ import { Button } from "@/components/ui/button";
import useApiClient from "@/hooks/useApiClient";
import { formatMinCost, formatSpeed, formatWidth, getImageUrl } from "@/lib/utils";
import { useSearchParams, useRouter, usePathname } from "next/navigation";
import { CatalogFilteredResponseDto } from "@/api/types";
export interface CatalogFilters {
search: string;
@ -49,7 +50,7 @@ export const defaultFilters: CatalogFilters = {
arrivalTime: "13:00",
};
export default function CatalogPage() {
function CatalogPageContent() {
const [isFiltersOpen, setIsFiltersOpen] = useState(false);
const searchParams = useSearchParams();
const router = useRouter();
@ -203,6 +204,12 @@ export default function CatalogPage() {
params.set("arrivalTime", filters.arrivalTime);
}
// Сохраняем сортировку, если она установлена
const sortByPrice = searchParams.get("sortByPrice");
if (sortByPrice) {
params.set("sortByPrice", sortByPrice);
}
// Обновляем URL без прокрутки страницы
const newUrl = params.toString()
? `${pathname}?${params.toString()}`
@ -219,11 +226,29 @@ export default function CatalogPage() {
useEffect(() => {
(async () => {
const params: Record<string, string> = {
const allParams: Record<string, string> = {
search: searchParams.get("search") ?? "",
minLength: searchParams.get("lengthMin") ?? "",
maxLength: searchParams.get("lengthMax") ?? "",
minPrice: searchParams.get("priceMin") ?? "",
maxPrice: searchParams.get("priceMax") ?? "",
minYear: searchParams.get("yearMin") ?? "",
maxYear: searchParams.get("yearMax") ?? "",
guests: searchParams.get("adults") && searchParams.get("children") ? `${Number(searchParams.get("adults")) + Number(searchParams.get("children"))}` : "",
paymentType: searchParams.get("paymentType") ?? "",
quickBooking: searchParams.get("quickBooking") ?? "",
hasToilet: searchParams.get("hasToilet") ?? "",
date: searchParams.get("date") ?? "",
departureTime: searchParams.get("departureTime") ?? "",
arrivalTime: searchParams.get("arrivalTime") ?? "",
sortByPrice: searchParams.get("sortByPrice") ?? "",
};
const response = await client.get<CatalogFilteredResponseDto>("/catalog/filtered/", {
const params = Object.fromEntries(
Object.entries(allParams).filter(([_, value]) => value !== "")
);
const response = await client.get<CatalogFilteredResponseDto>("/catalog/filter/", {
params,
});
@ -346,7 +371,21 @@ export default function CatalogPage() {
<div className="text-base text-[#999999]">
Сортировка:
</div>
<Select defaultValue="default">
<Select
value={searchParams.get("sortByPrice") || "default"}
onValueChange={(value) => {
const params = new URLSearchParams(searchParams.toString());
if (value === "default") {
params.delete("sortByPrice");
} else {
params.set("sortByPrice", value);
}
const newUrl = params.toString()
? `${pathname}?${params.toString()}`
: pathname;
router.replace(newUrl, { scroll: false });
}}
>
<SelectTrigger
className="w-full"
variant="ghost"
@ -357,18 +396,12 @@ export default function CatalogPage() {
<SelectItem value="default">
По умолчанию
</SelectItem>
<SelectItem value="price-asc">
<SelectItem value="asc">
Цена: по возрастанию
</SelectItem>
<SelectItem value="price-desc">
<SelectItem value="desc">
Цена: по убыванию
</SelectItem>
<SelectItem value="length-asc">
Длина: по возрастанию
</SelectItem>
<SelectItem value="length-desc">
Длина: по убыванию
</SelectItem>
</SelectContent>
</Select>
</div>
@ -455,3 +488,11 @@ export default function CatalogPage() {
</main>
);
}
export default function CatalogPage() {
return (
<Suspense fallback={<div className="bg-[#f4f4f4] grow flex items-center justify-center">Загрузка...</div>}>
<CatalogPageContent />
</Suspense>
);
}

View File

@ -12,16 +12,84 @@ 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";
import { CatalogItemShortDto } from "@/api/types";
export default function FeaturedYacht({ yacht }: { yacht: CatalogItemDto }) {
export default function FeaturedYacht({
yacht,
}: {
yacht: CatalogItemShortDto;
}) {
const router = useRouter();
const [selectedImage, setSelectedImage] = useState(yacht.mainImageUrl);
const [bookingData, setBookingData] = useState<GuestDatePickerValue>({
date: undefined,
departureTime: "12:00",
arrivalTime: "13:00",
adults: 1,
children: 0,
});
const handleThumbnailClick = (imageSrc: string) => {
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;
}
// Формируем URL с параметрами
const params = new URLSearchParams({
yachtId: yacht.id.toString(),
departureDate: format(bookingData.date, "yyyy-MM-dd"),
departureTime: bookingData.departureTime,
arrivalDate: format(bookingData.date, "yyyy-MM-dd"),
arrivalTime: bookingData.arrivalTime,
guests: (bookingData.adults + bookingData.children).toString(),
});
// Переходим на страницу подтверждения
router.push(`/confirm?${params.toString()}`);
};
return (
<div className="mb-10">
<Card className="overflow-hidden bg-white text-gray-900">
@ -89,15 +157,17 @@ export default function FeaturedYacht({ yacht }: { yacht: CatalogItemDto }) {
<div className="relative">
<Image
src={getImageUrl(thumb)}
alt={`${yacht.name
} view ${idx + 1}`}
alt={`${
yacht.name
} view ${idx + 1}`}
width={80}
height={60}
className={`w-20 h-16 object-cover rounded-[8px] cursor-pointer border-2 transition-all ${selectedImage ===
className={`w-20 h-16 object-cover rounded-[8px] cursor-pointer border-2 transition-all ${
selectedImage ===
thumb
? "border-[#008299]"
: "border-gray-200 hover:border-gray-400"
}`}
? "border-[#008299]"
: "border-gray-200 hover:border-gray-400"
}`}
onClick={() =>
handleThumbnailClick(
thumb
@ -164,13 +234,20 @@ export default function FeaturedYacht({ yacht }: { yacht: CatalogItemDto }) {
{/* Booking form */}
<div className="mb-8">
<GuestDatePicker />
<GuestDatePicker
value={bookingData}
onChange={setBookingData}
/>
</div>
{/* Book button */}
<Button
variant="gradient"
className="font-bold text-white h-[64px] w-full px-8"
onClick={handleBookClick}
disabled={
!bookingData.date || !yacht.id
}
>
Забронировать
</Button>
@ -180,7 +257,9 @@ export default function FeaturedYacht({ yacht }: { yacht: CatalogItemDto }) {
<span className="font-normal">
Итого:
</span>
<span>0 </span>
<span>
{formatPrice(getTotalPrice())}
</span>
</div>
</div>
</div>

View File

@ -12,6 +12,9 @@ import Link from "next/link";
export default function Hero() {
const [adults, setAdults] = useState<number>(0);
const [children, setChildren] = useState<number>(0);
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
const [departureTime, setDepartureTime] = useState<string>("12:00");
const [arrivalTime, setArrivalTime] = useState<string>("13:00");
return (
<section className="relative h-[600px] rounded-[24px] mx-[16px] overflow-hidden flex text-white">
<Image
@ -63,7 +66,14 @@ export default function Hero() {
{/* Дата и время */}
<div className="flex-1">
<DatePicker />
<DatePicker
value={selectedDate || undefined}
departureTime={departureTime}
arrivalTime={arrivalTime}
onDateChange={(date) => setSelectedDate(date || null)}
onDepartureTimeChange={setDepartureTime}
onArrivalTimeChange={setArrivalTime}
/>
</div>
{/* Количество гостей */}
@ -79,9 +89,20 @@ export default function Hero() {
</div>
{/* Кнопка поиска */}
<Button variant="gradient" className="font-bold text-white h-[64px] w-[176px] px-8">
Найти
</Button>
<Link href={(() => {
const params = new URLSearchParams();
if (adults > 0) params.append('adults', adults.toString());
if (children > 0) params.append('children', children.toString());
if (selectedDate) params.append('date', selectedDate.toString());
if (departureTime && departureTime !== "12:00") params.append('departureTime', departureTime);
if (arrivalTime && arrivalTime !== "13:00") params.append('arrivalTime', arrivalTime);
const queryString = params.toString();
return queryString ? `/catalog?${queryString}` : '/catalog';
})()}>
<Button variant="gradient" className="font-bold text-white h-[64px] w-[176px] px-8">
Найти
</Button>
</Link>
</div>
</CardContent>
</Card>

View File

@ -8,157 +8,157 @@ 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";
import { CatalogItemShortDto, MainPageCatalogResponseDto } from "@/api/types";
export default function YachtGrid() {
const client = useApiClient();
const client = useApiClient();
const [featuredYacht, setFeaturedYacht] = useState<CatalogItemDto | null>(null);
const [yachtCatalog, setYachtCatalog] = useState<CatalogItemDto[] | null>(null);
const [featuredYacht, setFeaturedYacht] =
useState<CatalogItemShortDto | null>(null);
const [yachtCatalog, setYachtCatalog] = useState<
CatalogItemShortDto[] | null
>(null);
useEffect(() => {
(async () => {
const response = await client.get<MainPageCatalogResponseDto>("/catalog/main-page/");
setFeaturedYacht(response.data.featuredYacht);
setYachtCatalog(response.data.restYachts);
})();
}, [])
useEffect(() => {
(async () => {
const response = await client.get<MainPageCatalogResponseDto>(
"/catalog/main-page/"
);
setFeaturedYacht(response.data.featuredYacht);
setYachtCatalog(response.data.restYachts);
})();
}, []);
return (
<section className="text-white">
<div className="container max-w-6xl mx-auto px-4 mt-6 md:mt-12">
{/* Header Section */}
<div className="mb-6">
<h1 className="text-3xl md:text-4xl font-bold mb-4 text-black">
Яхты и катера в аренду
</h1>
<h2 className="text-l text-black font-bold mb-2">
Онлайн бронирование яхт и катеров
</h2>
<p className="text-gray-700 max-w-3xl leading-relaxed">
Каталог лучших яхт Балаклавы разных ценовых сегментах.
</p>
<p className="text-gray-700 leading-relaxed">
Проверенные лодки с лицензией на перевозки, опытные
капитаны. Выбирайте удобную дату, время и бронируйте.
</p>
</div>
return (
<section className="text-white">
<div className="container max-w-6xl mx-auto px-4 mt-6 md:mt-12">
{/* Header Section */}
<div className="mb-6">
<h1 className="text-3xl md:text-4xl font-bold mb-4 text-black">
Яхты и катера в аренду
</h1>
<h2 className="text-l text-black font-bold mb-2">
Онлайн бронирование яхт и катеров
</h2>
<p className="text-gray-700 max-w-3xl leading-relaxed">
Каталог лучших яхт Балаклавы разных ценовых сегментах.
</p>
<p className="text-gray-700 leading-relaxed">
Проверенные лодки с лицензией на перевозки, опытные капитаны.
Выбирайте удобную дату, время и бронируйте.
</p>
</div>
{/* Featured Yacht Block */}
{featuredYacht && (
<FeaturedYacht yacht={featuredYacht} />
)}
{/* Featured Yacht Block */}
{featuredYacht && <FeaturedYacht yacht={featuredYacht} />}
{/* Yacht Grid */}
{yachtCatalog && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-6">
{yachtCatalog.map((yacht) => (
<Link
key={yacht.id}
href={`/catalog/${yacht.id ?? 0}}`}
className="block"
>
<Card className="overflow-hidden bg-white text-gray-900 cursor-pointer transition-all duration-200 hover:shadow-lg">
<CardHeader className="p-0 relative">
<div className="relative">
{/* Best Offer Badge - над карточкой */}
{yacht.topText && (
<div className="w-full flex justify-center">
<div
className="flex w-full items-center justify-center text-white px-4 py-2 text-sm font-medium bg-cover bg-center bg-no-repeat"
style={{
backgroundImage:
"url('/images/best-yacht-bg.jpg')",
}}
>
<span>
{yacht.topText}
</span>
</div>
</div>
)}
<Image
src={getImageUrl(yacht.mainImageUrl)}
alt={yacht.name}
width={400}
height={250}
className="w-full h-48 object-cover"
/>
{/* Badge Overlay */}
{!yacht.hasQuickRent && !yacht.topText && (
<>
<div className="absolute top-3 left-3">
<div className="flex items-center justify-center bg-black/40 text-white px-3 py-1 rounded-lg text-sm flex items-center gap-1">
<Icon
size={16}
name="restart"
/>
<span>
По запросу
</span>
</div>
</div>
</>
)}
</div>
</CardHeader>
<CardContent className="p-4">
<div className="flex justify-between gap-4">
{/* Левая колонка - название и длина */}
<div className="space-y-2">
<h3 className="font-bold text-l">
{yacht.name}
</h3>
<div className="flex items-center gap-1 text-sm">
<Icon size={16} name="width" />
<span>{formatWidth(yacht.length)}</span>
</div>
</div>
{/* Правая колонка - цена и футы */}
<div className="flex flex-col justify-between">
<div className="w-fit">
{yacht.isBestOffer ? (
<p
style={{
background:
"linear-gradient(90deg, #008299 0%, #7E8FFF 100%)",
}}
className="text-l font-bold text-white pl-2 pr-3 rounded-t-[5px] rounded-bl-[10px] rounded-br-[30px] whitespace-nowrap"
>
{formatMinCost(yacht.minCost)} / час
</p>
) : (
<p className="w-fit text-l whitespace-nowrap">
{formatMinCost(yacht.minCost)} / час
</p>
)}
</div>
<div className="flex items-center gap-1 text-sm">
<Icon size={16} name="anchor" />
<span>{formatSpeed(yacht.speed)}</span>
</div>
</div>
</div>
</CardContent>
</Card>
</Link>
))}
{/* Yacht Grid */}
{yachtCatalog && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-6">
{yachtCatalog.map((yacht) => (
<Link
key={yacht.id}
href={`/catalog/${yacht.id ?? 0}`}
className="block"
>
<Card className="overflow-hidden bg-white text-gray-900 cursor-pointer transition-all duration-200 hover:shadow-lg">
<CardHeader className="p-0 relative">
<div className="relative">
{/* Best Offer Badge - над карточкой */}
{yacht.topText && (
<div className="w-full flex justify-center">
<div
className="flex w-full items-center justify-center text-white px-4 py-2 text-sm font-medium bg-cover bg-center bg-no-repeat"
style={{
backgroundImage:
"url('/images/best-yacht-bg.jpg')",
}}
>
<span>{yacht.topText}</span>
</div>
</div>
)}
<Image
src={getImageUrl(yacht.mainImageUrl)}
alt={yacht.name}
width={400}
height={250}
className="w-full h-48 object-cover"
/>
{/* Badge Overlay */}
{!yacht.hasQuickRent && !yacht.topText && (
<>
<div className="absolute top-3 left-3">
<div className="flex items-center justify-center bg-black/40 text-white px-3 py-1 rounded-lg text-sm flex items-center gap-1">
<Icon size={16} name="restart" />
<span>По запросу</span>
</div>
</div>
</>
)}
</div>
)}
</CardHeader>
<CardContent className="p-4">
<div className="flex justify-between gap-4">
{/* Левая колонка - название и длина */}
<div className="space-y-2">
<h3 className="font-bold text-l">{yacht.name}</h3>
<div className="flex items-center gap-1 text-sm">
<Icon size={16} name="width" />
<span>{formatWidth(yacht.length)}</span>
</div>
</div>
{/* Call to Action Button */}
<div className="text-center">
<Link href="/catalog">
<Button
size="lg"
className="bg-white text-gray-900 hover:bg-gray-100 px-8 py-3 h-[56px] w-[336px] text-lg font-bold"
>
Каталог яхт
</Button>
</Link>
</div>
</div>
</section>
);
{/* Правая колонка - цена и футы */}
<div className="flex flex-col justify-between">
<div className="w-fit">
{yacht.isBestOffer ? (
<p
style={{
background:
"linear-gradient(90deg, #008299 0%, #7E8FFF 100%)",
}}
className="text-l font-bold text-white pl-2 pr-3 rounded-t-[5px] rounded-bl-[10px] rounded-br-[30px] whitespace-nowrap"
>
{formatMinCost(yacht.minCost)} / час
</p>
) : (
<p className="w-fit text-l whitespace-nowrap">
{formatMinCost(yacht.minCost)} / час
</p>
)}
</div>
<div className="flex items-center gap-1 text-sm">
<Icon size={16} name="anchor" />
<span>{formatSpeed(yacht.speed)}</span>
</div>
</div>
</div>
</CardContent>
</Card>
</Link>
))}
</div>
)}
{/* Call to Action Button */}
<div className="text-center">
<Link href="/catalog">
<Button
size="lg"
className="bg-white text-gray-900 hover:bg-gray-100 px-8 py-3 h-[56px] w-[336px] text-lg font-bold"
>
Каталог яхт
</Button>
</Link>
</div>
</div>
</section>
);
}

View File

@ -3,31 +3,80 @@
import { Button } from "@/components/ui/button";
import Image from "next/image";
import Link from "next/link";
import { useMutation } from "@tanstack/react-query";
import { User, ArrowUpRight, Map, ArrowLeft, Heart } from "lucide-react";
import { useState } from "react";
import { useEffect, useState, Suspense } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import useApiClient from "@/hooks/useApiClient";
import { getImageUrl, formatPrice, calculateTotalPrice } from "@/lib/utils";
import { parseISO } from "date-fns";
import { CatalogItemLongDto } from "@/api/types";
export default function ConfirmPage() {
function ConfirmPageContent() {
const [yacht, setYacht] = useState<CatalogItemLongDto | null>(null);
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("guestCount");
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<CatalogItemLongDto>(
`/catalog/${yachtId}/`
);
setYacht(response.data);
})();
}, [yachtId]);
// Расчет стоимости через функцию
const { totalHours, totalPrice } = calculateTotalPrice(
departureDate,
departureTime,
arrivalDate,
arrivalTime,
yacht?.minCost || 0
);
// Обработчик применения промокода
const handlePromocodeApply = () => {
if (promocode.trim().toUpperCase() === "DISCOUNT50") {
setIsPromocodeApplied(true);
} else {
setIsPromocodeApplied(false);
}
};
// Финальная цена с учетом скидки
const finalPrice = isPromocodeApplied ? totalPrice * 0.5 : totalPrice;
// Функция для форматирования даты (краткий формат)
const formatDate = (dateString: string | null) => {
if (!dateString) return null;
try {
const date = new Date(dateString);
const date = parseISO(dateString);
const months = [
"янв", "фев", "мар", "апр", "май", "июн",
"июл", "авг", "сен", "окт", "ноя", "дек"
"янв",
"фев",
"мар",
"апр",
"май",
"июн",
"июл",
"авг",
"сен",
"окт",
"ноя",
"дек",
];
const day = date.getDate();
const month = months[date.getMonth()];
@ -41,10 +90,20 @@ export default function ConfirmPage() {
const formatDateFull = (dateString: string | null) => {
if (!dateString) return null;
try {
const date = new Date(dateString);
const date = parseISO(dateString);
const months = [
"января", "февраля", "марта", "апреля", "мая", "июня",
"июля", "августа", "сентября", "октября", "ноября", "декабря"
"января",
"февраля",
"марта",
"апреля",
"мая",
"июня",
"июля",
"августа",
"сентября",
"октября",
"ноября",
"декабря",
];
const day = date.getDate();
const month = months[date.getMonth()];
@ -57,8 +116,9 @@ export default function ConfirmPage() {
// Функция для форматирования времени
const formatTime = (timeString: string | null) => {
if (!timeString) return null;
// Предполагаем формат HH:mm или HH:mm:ss
return timeString.split(":").slice(0, 2).join(":");
// Декодируем URL-encoded строку (например, 00%3A00 -> 00:00)
const decoded = decodeURIComponent(timeString);
return decoded.split(":").slice(0, 2).join(":");
};
// Форматируем данные для отображения
@ -72,22 +132,68 @@ export default function ConfirmPage() {
const arrivalDateFormattedFull = formatDateFull(arrivalDate);
// Формируем строки для отображения
const departureDisplay = departureDateFormatted && departureTimeFormatted
? `${departureDateFormatted} ${departureTimeFormatted}`
: "Не выбрано";
const departureDisplay =
departureDateFormatted && departureTimeFormatted
? `${departureDateFormatted} ${departureTimeFormatted}`
: "Не выбрано";
const arrivalDisplay = arrivalDateFormatted && arrivalTimeFormatted
? `${arrivalDateFormatted} ${arrivalTimeFormatted}`
: "Не выбрано";
const arrivalDisplay =
arrivalDateFormatted && arrivalTimeFormatted
? `${arrivalDateFormatted} ${arrivalTimeFormatted}`
: "Не выбрано";
const datesDisplay = departureDateFormattedFull && departureTimeFormatted && arrivalDateFormattedFull && arrivalTimeFormatted
? `${departureDateFormattedFull} в ${departureTimeFormatted}${arrivalDateFormattedFull} в ${arrivalTimeFormatted}`
: "Не выбрано";
const datesDisplay =
departureDateFormattedFull &&
departureTimeFormatted &&
arrivalDateFormattedFull &&
arrivalTimeFormatted
? `${departureDateFormattedFull} в ${departureTimeFormatted}${arrivalDateFormattedFull} в ${arrivalTimeFormatted}`
: "Не выбрано";
const guestsDisplay = guestCount
? guestCount === "1" ? "1 гость" : `${guestCount} гостей`
? guestCount === "1"
? "1 гость"
: `${guestCount} гостей`
: "Не выбрано";
const { mutate } = useMutation({
mutationKey: ["create-reservation", yachtId],
mutationFn: async () => {
if (
!departureDate ||
!departureTime ||
!yachtId ||
!arrivalDate ||
!arrivalTime
) {
throw new Error("Ошибка получения данных бронирования");
}
const departureDateTime = new Date(
`${departureDate}T${departureTime}`
);
const arrivalDateTime = new Date(`${arrivalDate}T${arrivalTime}`);
const startUtc = Math.floor(departureDateTime.getTime() / 1000);
const endUtc = Math.floor(arrivalDateTime.getTime() / 1000);
const body = {
startUtc,
endUtc,
yachtId: Number(yachtId),
reservatorId: Number("userId"), // TODO
};
await client.post("/reservations", body);
router.push("/profile/reservations");
},
});
if (!yacht) {
return <div />;
}
return (
<main className="bg-[#f4f4f4] grow">
{/* Мобильная версия */}
@ -110,11 +216,15 @@ export default function ConfirmPage() {
{/* Центральный блок с информацией */}
<div className="flex-1 min-w-0 text-center">
<h2 className="text-base font-bold text-[#333333] mb-1">
Яхта Сеньорита
Яхта {yacht.name}
</h2>
<div className="flex justify-center gap-10 text-xs text-[#666666]">
<span>{departureDateFormatted || "Не выбрано"}</span>
<span>Гостей: {guestCount || "Не выбрано"}</span>
<span>
{departureDateFormatted || "Не выбрано"}
</span>
<span>
Гостей: {guestCount || "Не выбрано"}
</span>
</div>
</div>
@ -205,28 +315,53 @@ export default function ConfirmPage() {
Детализация цены
</h3>
<div>
<div className="flex justify-between items-center mb-4">
<span className="text-[#333333]">
26 400 x 2ч
</span>
<span className="text-[#333333]">
52 800
</span>
</div>
<div className="flex justify-between items-center mb-4 pb-4 border-b border-[#DFDFDF]">
<span className="text-[#333333]">
Услуги
</span>
<span className="text-[#333333]">0 Р</span>
</div>
<div className="flex justify-between items-center">
<span className="text-[#333333]">
Итого:
</span>
<span className="font-bold text-[#333333]">
52 800 Р
</span>
</div>
{totalHours > 0 && yacht.minCost ? (
<>
<div className="flex justify-between items-center mb-4">
<span className="text-[#333333]">
{formatPrice(yacht.minCost)} ×{" "}
{totalHours}ч
</span>
<span className="text-[#333333]">
{formatPrice(totalPrice)}
</span>
</div>
<div className="flex justify-between items-center mb-4 pb-4 border-b border-[#DFDFDF]">
<span className="text-[#333333]">
Услуги
</span>
<span className="text-[#333333]">
0 Р
</span>
</div>
{isPromocodeApplied && (
<div className="flex justify-between items-center mb-4">
<span className="text-[#333333]">
Скидка (DISCOUNT50):
</span>
<span className="text-[#2D908D] font-bold">
-
{formatPrice(
totalPrice * 0.5
)}{" "}
Р
</span>
</div>
)}
<div className="flex justify-between items-center">
<span className="text-[#333333]">
Итого:
</span>
<span className="font-bold text-[#333333]">
{formatPrice(finalPrice)} Р
</span>
</div>
</>
) : (
<div className="text-[#999999] text-center py-4">
Укажите даты для расчета стоимости
</div>
)}
</div>
</div>
@ -237,13 +372,20 @@ export default function ConfirmPage() {
type="text"
placeholder="Промокод"
value={promocode}
onChange={(e) =>
setPromocode(e.target.value)
}
onChange={(e) => {
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"
/>
<Button
variant="default"
onClick={handlePromocodeApply}
className="flex-shrink-0 h-[64px] w-[64px] bg-[#2D908D] hover:bg-[#007088] text-white rounded-full p-0 transition-colors duration-200"
>
<ArrowUpRight size={14} />
@ -255,6 +397,8 @@ export default function ConfirmPage() {
<Button
variant="default"
className="w-full h-[56px] bg-[#2D908D] hover:bg-[#007088] text-white font-bold rounded-full transition-colors duration-200"
disabled={totalHours === 0}
onClick={() => mutate()}
>
Отправить заявку
</Button>
@ -286,7 +430,9 @@ export default function ConfirmPage() {
{/* Изображение яхты */}
<div className="relative mb-5">
<Image
src="/images/yachts/yacht1.jpg"
src={getImageUrl(
yacht.mainImageUrl
)}
alt="Яхта"
width={400}
height={250}
@ -304,7 +450,7 @@ export default function ConfirmPage() {
Владелец
</span>
<span className="text-[#333333] font-bold">
Денис
{yacht.owner.firstName}
</span>
</div>
</div>
@ -312,7 +458,7 @@ export default function ConfirmPage() {
</div>
{/* Название яхты */}
<h3 className="text-base text-[#333333] pb-3 border-b border-[#DFDFDF] mb-4">
Яхта
Яхта {yacht.name}
</h3>
{/* Детализация цены */}
@ -321,30 +467,64 @@ export default function ConfirmPage() {
Детализация цены
</h4>
<div>
<div className="flex justify-between items-center mb-4">
<span className="text-[#333333]">
26 400 x 2ч
</span>
<span className="text-[#333333]">
52 800
</span>
</div>
<div className="flex justify-between items-center border-b border-[#DFDFDF] pb-4 mb-4">
<span className="text-[#333333]">
Услуги
</span>
<span className="text-[#333333]">
0 Р
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-[#333333]">
Итого:
</span>
<span className="text-[#333333] font-bold">
52 800 Р
</span>
</div>
{totalHours > 0 && yacht.minCost ? (
<>
<div className="flex justify-between items-center mb-4">
<span className="text-[#333333]">
{formatPrice(
yacht.minCost
)}
× {totalHours}ч
</span>
<span className="text-[#333333]">
{formatPrice(
totalPrice
)}{" "}
</span>
</div>
<div className="flex justify-between items-center border-b border-[#DFDFDF] pb-4 mb-4">
<span className="text-[#333333]">
Услуги
</span>
<span className="text-[#333333]">
0 Р
</span>
</div>
{isPromocodeApplied && (
<div className="flex justify-between items-center mb-4">
<span className="text-[#333333]">
Скидка
(DISCOUNT50):
</span>
<span className="text-[#2D908D] font-bold">
-
{formatPrice(
totalPrice *
0.5
)}{" "}
Р
</span>
</div>
)}
<div className="flex justify-between items-center">
<span className="text-[#333333]">
Итого:
</span>
<span className="text-[#333333] font-bold">
{formatPrice(
finalPrice
)}{" "}
Р
</span>
</div>
</>
) : (
<div className="text-[#999999] text-center py-4">
Укажите даты для расчета
стоимости
</div>
)}
</div>
</div>
</div>
@ -358,13 +538,20 @@ export default function ConfirmPage() {
type="text"
placeholder="Промокод"
value={promocode}
onChange={(e) =>
setPromocode(e.target.value)
}
onChange={(e) => {
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"
/>
<Button
variant="default"
onClick={handlePromocodeApply}
className="flex-shrink-0 h-[64px] w-[64px] bg-[#2D908D] hover:bg-[#007088] text-white rounded-full p-0 transition-colors duration-200"
>
<ArrowUpRight size={12} />
@ -385,7 +572,7 @@ export default function ConfirmPage() {
Ваше бронирование
</h2>
{/* Сведения о бронировании */}
{/* Сведения о бронирования */}
<div className="grid grid-cols-2 gap-4 mb-4">
{/* Даты */}
<div className="grow-1 border border-[#DFDFDF] rounded-[8px] p-4">
@ -445,6 +632,8 @@ export default function ConfirmPage() {
variant="default"
size="lg"
className="flex-shrink-0 h-[64px] w-[270px] bg-[#2D908D] hover:bg-[#007088] text-white font-bold rounded-full p-0 transition-colors duration-200 hover:shadow-lg"
disabled={totalHours === 0}
onClick={() => mutate()}
>
Отправить заявку
</Button>
@ -457,3 +646,17 @@ export default function ConfirmPage() {
</main>
);
}
export default function ConfirmPage() {
return (
<Suspense
fallback={
<div className="bg-[#f4f4f4] grow flex items-center justify-center">
Загрузка...
</div>
}
>
<ConfirmPageContent />
</Suspense>
);
}

View File

@ -0,0 +1,55 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
interface MenuItem {
label: string;
href: string;
icon?: string;
}
const menuItems: MenuItem[] = [
{ label: "Дашборд", href: "/profile/dashboard" },
{ label: "Мои яхты", href: "/profile/yachts" },
{ label: "Мои брони", href: "/profile/reservations" },
{ label: "Заказы", href: "/profile/orders" },
{ label: "Календарь", href: "/profile/calendar" },
{ label: "Избранное", href: "/profile/favorites" },
{ label: "Аккаунт", href: "/profile/account" },
{ label: "Выйти", href: "/profile/logout" },
];
export default function ProfileSidebar() {
const pathname = usePathname();
return (
<aside className="w-[292px] bg-white h-min rounded-[16px] flex-shrink-0 bg-[#f4f4f4]">
<nav>
<ul>
{menuItems.map((item) => {
const isActive = pathname === item.href ||
(item.href === "/profile/yachts" && pathname?.startsWith("/profile/yachts"));
return (
<li key={item.href}>
<Link
href={item.href}
className={cn(
"block p-6 border-b border-[#EDEDED] font-regular text-sm",
isActive
? "text-[#2D908D]"
: "text-[#333333]"
)}
>
{item.label}
</Link>
</li>
);
})}
</ul>
</nav>
</aside>
);
}

View File

@ -0,0 +1,313 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import Image from "next/image";
import ProfileSidebar from "@/app/profile/components/ProfileSidebar";
import { User, Clock, MoveHorizontal, Users } from "lucide-react";
import useApiClient from "@/hooks/useApiClient";
import useAuthStore from "@/stores/useAuthStore";
import { ReservationDto } from "@/api/types";
import { formatWidth } from "@/lib/utils";
export default function ReservationsPage() {
const [activeTab, setActiveTab] = useState<
"new" | "active" | "confirmed" | "archive"
>("new");
const [reservationsData, setReservationsData] = useState<ReservationDto[]>(
[]
);
const apiClient = useApiClient();
const { getUserId } = useAuthStore();
const userId = getUserId();
useEffect(() => {
if (userId) {
apiClient
.get<ReservationDto[]>(`/reservations/user/${userId}`)
.then((response) => {
setReservationsData(response.data);
})
.catch((error) => {
console.error("Ошибка при загрузке бронирований:", error);
});
}
}, [userId]);
// @TODO: Залупа с годом, надо скачать dayjs
const formatUtcDate = (timestamp: number): string => {
const date = new Date(timestamp);
const day = String(date.getUTCDate()).padStart(2, "0");
const month = String(date.getUTCMonth() + 1).padStart(2, "0");
const year = date.getUTCFullYear();
const hours = String(date.getUTCHours()).padStart(2, "0");
const minutes = String(date.getUTCMinutes()).padStart(2, "0");
return `${day}.${month}.${year} - ${hours}:${minutes}`;
};
return (
<main className="bg-[#f4f4f4]">
<div className="container max-w-6xl mx-auto px-4 py-6">
{/* Breadcrumbs */}
<div className="hidden lg:flex mb-4 text-sm text-[#999999] items-center gap-[16px]">
<Link href="/">
<span className="cursor-pointer hover:text-[#333333] transition-colors">
Аренда яхты
</span>
</Link>
<span>&gt;</span>
<Link href="/profile">
<span className="cursor-pointer hover:text-[#333333] transition-colors">
Личный кабинет
</span>
</Link>
<span>&gt;</span>
<span className="text-[#333333]">Мои брони</span>
</div>
<div className="flex gap-6">
{/* Sidebar */}
<ProfileSidebar />
{/* Main Content */}
<div className="flex-1 bg-white rounded-[16px] p-8">
{/* Tabs */}
<div className="flex gap-2 mb-6">
<button
onClick={() => setActiveTab("new")}
className={`px-4 py-2 text-sm font-medium rounded-full border transition-colors ${
activeTab === "new"
? "border-[#333333] bg-white text-[#333333] font-bold"
: "border-transparent text-[#999999] hover:text-[#333333]"
}`}
>
Новые брони ({reservationsData.length})
</button>
<button
onClick={() => setActiveTab("active")}
className={`px-4 py-2 text-sm font-medium rounded-full border transition-colors ${
activeTab === "active"
? "border-[#333333] bg-white text-[#333333] font-bold"
: "border-transparent text-[#999999] hover:text-[#333333]"
}`}
>
Активные
</button>
<button
onClick={() => setActiveTab("confirmed")}
className={`px-4 py-2 text-sm font-medium rounded-full border transition-colors ${
activeTab === "confirmed"
? "border-[#333333] bg-white text-[#333333] font-bold"
: "border-transparent text-[#999999] hover:text-[#333333]"
}`}
>
Подтвержденные
</button>
<button
onClick={() => setActiveTab("archive")}
className={`px-4 py-2 text-sm font-medium rounded-full border transition-colors ${
activeTab === "archive"
? "border-[#333333] bg-white text-[#333333] font-bold"
: "border-transparent text-[#999999] hover:text-[#333333]"
}`}
>
Архив
</button>
</div>
{/* Reservations List */}
<div className="space-y-8">
{reservationsData.length === 0 ? (
<div className="text-center py-12 text-[#999999]">
Нет бронирований в этой категории
</div>
) : (
reservationsData.map((reservation, index) => (
<div
key={reservation.id}
className={`overflow-hidden bg-white ${
index !==
reservationsData.length - 1
? "pb-8 border-b border-gray-200"
: ""
}`}
>
<div>
<div className="flex flex-col lg:flex-row">
{/* Image Section */}
<div className="relative rounded-[12px] overflow-hidden w-90 h-90 flex-shrink-0">
<Image
src={
reservation.yacht
.mainImageUrl
}
alt={
reservation.yacht
.name
}
fill
className="object-cover"
/>
{/* Owner Info Overlay */}
<div className="absolute top-2 left-2">
<div className="bg-white p-2 rounded-[8px] flex items-center gap-2">
<User
size={20}
className="text-[#999999]"
/>
<div className="flex flex-col">
<span className="text-xs text-[#999999]">
Владелец
</span>
<span className="text-sm text-[#333333] font-bold">
{
reservation
.yacht
.owner
.firstName
}
</span>
</div>
</div>
</div>
{/* Yacht Details Overlay */}
<div className="absolute bottom-2 left-2 flex gap-2">
<div className="bg-black/50 text-white px-3 py-1.5 rounded-lg flex items-center gap-3 text-sm">
<div className="flex items-center gap-1">
<MoveHorizontal
size={16}
className="text-white"
/>
<span>
{formatWidth(
reservation
.yacht
.length
)}
</span>
</div>
</div>
<div className="bg-black/50 text-white px-3 py-1.5 rounded-lg flex items-center gap-3 text-sm">
<div className="flex items-center gap-1">
<Users
size={16}
className="text-white"
/>
<span>
{
reservation
.yacht
.maxCapacity
}
</span>
</div>
</div>
</div>
</div>
{/* Details Section */}
<div className="flex-1 px-6">
<div className="space-y-3">
<div className="flex items-start justify-between">
<div className="space-y-3 w-full">
<div className="text-[#333333] w-full flex justify-between">
<div>
Выход:
</div>
<div>
{formatUtcDate(
reservation.startUtc
)}
</div>
</div>
<div className="text-[#333333] w-full flex justify-between">
<div>
Заход:
</div>
<div>
{formatUtcDate(
reservation.endUtc
)}
</div>
</div>
<div className="text-[#333333] w-full flex justify-between">
<div>
Гости:
</div>
<div>
{/* @TODO: Добавить количество гостей */}
{/* {
reservation.guests
} */}
-
</div>
</div>
<div className="text-[#333333] w-full flex justify-between">
<div>
Тип
оплаты:
</div>
<div>
{/* @TODO: Добавить тип оплаты */}
{/* {
reservation.paymentType
} */}
-
</div>
</div>
<div className="flex items-center gap-2 text-sm text-[#333333]">
<Clock
size={
16
}
className="text-[#999999]"
/>
<span>
По
местному
времени
яхты
</span>
</div>
</div>
</div>
<div className="pt-3 border-t border-[#DFDFDF]">
<div className="flex items-center justify-between">
<span className="text-base font-bold text-[#333333]">
{/* @TODO: Добавить итоговую стоимость */}
{/* Итого:{" "}
{formatPrice(
reservation.totalPrice
)}{" "}
{reservation.paymentStatus ===
"pending" && (
<span className="text-base font-bold text-red-500">
(в
ожидании
оплаты)
</span>
)} */}
Итого: 78000{" "}
<span className="text-base font-bold text-red-500">
(в
ожидании
оплаты)
</span>
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
))
)}
</div>
</div>
</div>
</div>
</main>
);
}

View File

@ -0,0 +1,525 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import ProfileSidebar from "@/app/profile/components/ProfileSidebar";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { Info, X, Plus, Minus } from "lucide-react";
interface Cabin {
id: string;
name: string;
count: number;
type: string;
}
export default function AddYachtPage() {
const [cabins, setCabins] = useState<Cabin[]>([
{ id: "1", name: "Мастер Каюта", count: 1, type: "Односпальная" },
{ id: "2", name: "Гостевая каюта 1", count: 1, type: "" },
]);
const addCabin = () => {
const newCabin: Cabin = {
id: Date.now().toString(),
name: `Гостевая каюта ${cabins.length}`,
count: 1,
type: "",
};
setCabins([...cabins, newCabin]);
};
const removeCabin = (id: string) => {
setCabins(cabins.filter((cabin) => cabin.id !== id));
};
const updateCabinCount = (id: string, delta: number) => {
setCabins(
cabins.map((cabin) =>
cabin.id === id
? { ...cabin, count: Math.max(1, cabin.count + delta) }
: cabin
)
);
};
const updateCabinType = (id: string, type: string) => {
setCabins(
cabins.map((cabin) =>
cabin.id === id ? { ...cabin, type } : cabin
)
);
};
return (
<main className="bg-[#f4f4f4] min-h-screen">
<div className="container max-w-6xl mx-auto px-4 py-6">
{/* Breadcrumbs */}
<div className="hidden lg:flex mb-4 text-sm text-[#999999] items-center gap-[16px]">
<Link href="/">
<span className="cursor-pointer hover:text-[#333333] transition-colors">
Аренда яхты
</span>
</Link>
<span>&gt;</span>
<Link href="/profile">
<span className="cursor-pointer hover:text-[#333333] transition-colors">
Личный кабинет
</span>
</Link>
<span>&gt;</span>
<Link href="/profile/yachts">
<span className="cursor-pointer hover:text-[#333333] transition-colors">
Мои яхты
</span>
</Link>
<span>&gt;</span>
<span className="text-[#333333]">Добавление яхты</span>
</div>
<div className="flex gap-6">
{/* Sidebar */}
<ProfileSidebar />
{/* Main Content */}
<div className="flex-1 bg-white rounded-lg p-8">
<h1 className="text-2xl font-bold text-[#333333] mb-8">
Добавление яхты
</h1>
{/* Выберите тип судна */}
<div className="mb-6">
<Label className="text-sm font-medium text-[#333333] mb-2 block">
Выберите тип судна*
</Label>
<Select>
<SelectTrigger className="w-full h-12 rounded-lg">
<SelectValue placeholder="Выберите тип судна" />
</SelectTrigger>
<SelectContent>
<SelectItem value="motor">Моторная яхта</SelectItem>
<SelectItem value="sail">Парусная яхта</SelectItem>
<SelectItem value="catamaran">Катамаран</SelectItem>
</SelectContent>
</Select>
</div>
{/* Основная информация */}
<div className="mb-8">
<h2 className="text-lg font-bold text-[#333333] mb-4">
Основная информация*
</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<div>
<Label className="text-sm font-medium text-[#333333] mb-2 block">
Название судна*
</Label>
<Input
placeholder="Название судна"
className="h-12"
/>
</div>
<div>
<Label className="text-sm font-medium text-[#333333] mb-2 block">
Верфь*
</Label>
<Input placeholder="Верфь" className="h-12" />
</div>
<div>
<Label className="text-sm font-medium text-[#333333] mb-2 block">
Модель*
</Label>
<Select>
<SelectTrigger className="w-full h-12 rounded-lg">
<SelectValue placeholder="Модель" />
</SelectTrigger>
<SelectContent>
<SelectItem value="model1">Модель 1</SelectItem>
<SelectItem value="model2">Модель 2</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div>
<Label className="text-sm font-medium text-[#333333] mb-2 block">
Выберите марину*
</Label>
<Select>
<SelectTrigger className="w-full h-12 rounded-lg">
<SelectValue placeholder="Выберите марину" />
</SelectTrigger>
<SelectContent>
<SelectItem value="marina1">Марина 1</SelectItem>
<SelectItem value="marina2">Марина 2</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Прибыль и время аренды */}
<div className="mb-8">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label className="text-sm font-medium text-[#333333] mb-2 block">
Прибыль (за час)*
</Label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-[#333333]">
$
</span>
<Input
placeholder="Прибыль (за час)"
className="h-12 pl-8 pr-10"
/>
<Info className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-[#999999]" />
</div>
</div>
<div>
<Label className="text-sm font-medium text-[#333333] mb-2 block">
Минимальное время аренды (в часах)*
</Label>
<Input
placeholder="Минимальное время (в часах)"
className="h-12"
/>
</div>
</div>
</div>
{/* Тип оплаты */}
<div className="mb-8">
<h2 className="text-lg font-bold text-[#333333] mb-4">
Тип оплаты
</h2>
<div className="space-y-3">
<div className="flex items-center gap-3">
<Checkbox id="yookassa" />
<Label
htmlFor="yookassa"
className="flex-1 cursor-pointer flex items-center justify-between"
>
<span>Оплата через Yookassa</span>
<Info className="h-4 w-4 text-[#999999]" />
</Label>
</div>
<div className="flex items-center gap-3">
<Checkbox id="prepayment" defaultChecked />
<Label
htmlFor="prepayment"
className="flex-1 cursor-pointer flex items-center justify-between"
>
<span>Предоплата</span>
<Info className="h-4 w-4 text-[#999999]" />
</Label>
</div>
</div>
</div>
{/* Промоцены */}
<div className="mb-8">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-bold text-[#333333]">
Промоцены
</h2>
<Button variant="outline" size="sm">
Добавить промоцену
</Button>
</div>
</div>
{/* Синхронизация Google Календаря */}
<div className="mb-8">
<Label className="text-sm font-medium text-[#333333] mb-2 block">
ID Google Календаря для синхронизации
</Label>
<Input
placeholder="ID Google Календаря"
className="h-12"
/>
</div>
{/* Загрузка изображений */}
<div className="mb-8">
<div className="flex items-center justify-between mb-4">
<Label className="text-sm font-medium text-[#333333]">
Загрузите изображения судна (в высоком разрешении)*
</Label>
<Button variant="outline" size="sm">
Загрузить
</Button>
</div>
<div className="border-2 border-dashed border-[#999999] rounded-lg p-12 text-center">
<div className="flex flex-col items-center gap-4">
<div className="w-16 h-16 bg-[#f4f4f4] rounded-lg flex items-center justify-center">
<svg
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
</div>
<p className="text-sm text-[#333333]">
Загрузите изображения судна (в высоком разрешении)*
</p>
</div>
</div>
</div>
{/* Характеристики */}
<div className="mb-8">
<h2 className="text-lg font-bold text-[#333333] mb-4">
Характеристики*
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label className="text-sm font-medium text-[#333333] mb-2 block">
Год*
</Label>
<Input placeholder="Год" className="h-12" />
</div>
<div>
<Label className="text-sm font-medium text-[#333333] mb-2 block">
Мощность (л/с)
</Label>
<Input placeholder="Мощность (л/с)" className="h-12" />
</div>
<div>
<Label className="text-sm font-medium text-[#333333] mb-2 block">
Длина (м)*
</Label>
<Input placeholder="Длина (м)" className="h-12" />
</div>
<div>
<Label className="text-sm font-medium text-[#333333] mb-2 block">
Ширина (м)
</Label>
<Input placeholder="Ширина (м)" className="h-12" />
</div>
<div>
<Label className="text-sm font-medium text-[#333333] mb-2 block">
Макс. вместимость (без экипажа)*
</Label>
<Input
placeholder="Макс. вместимость (без экипажа)"
className="h-12"
/>
</div>
<div>
<Label className="text-sm font-medium text-[#333333] mb-2 block">
Комфортная вместимость (человек)*
</Label>
<Input
placeholder="Комфортная вместимость (человек)"
className="h-12"
/>
</div>
<div>
<Label className="text-sm font-medium text-[#333333] mb-2 block">
Каюты
</Label>
<Input placeholder="Каюты" className="h-12" />
</div>
<div>
<Label className="text-sm font-medium text-[#333333] mb-2 block">
Материал
</Label>
<Select>
<SelectTrigger className="w-full h-12 rounded-lg">
<SelectValue placeholder="Материал" />
</SelectTrigger>
<SelectContent>
<SelectItem value="fiberglass">Стеклопластик</SelectItem>
<SelectItem value="aluminum">Алюминий</SelectItem>
<SelectItem value="steel">Сталь</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
{/* Удобства */}
<div className="mb-8">
<h2 className="text-lg font-bold text-[#333333] mb-4">
Удобства
</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{/* Здесь будут чекбоксы для удобств */}
<div className="flex items-center gap-2">
<Checkbox id="wifi" />
<Label htmlFor="wifi" className="cursor-pointer">
Wi-Fi
</Label>
</div>
<div className="flex items-center gap-2">
<Checkbox id="aircon" />
<Label htmlFor="aircon" className="cursor-pointer">
Кондиционер
</Label>
</div>
<div className="flex items-center gap-2">
<Checkbox id="kitchen" />
<Label htmlFor="kitchen" className="cursor-pointer">
Кухня
</Label>
</div>
<div className="flex items-center gap-2">
<Checkbox id="toilet" />
<Label htmlFor="toilet" className="cursor-pointer">
Туалет
</Label>
</div>
</div>
</div>
{/* Описание */}
<div className="mb-8">
<div className="flex items-center gap-2 mb-4">
<h2 className="text-lg font-bold text-[#333333]">
Описание (5000)
</h2>
<Info className="h-4 w-4 text-[#999999]" />
</div>
<textarea
placeholder="Введите описание"
className="w-full h-32 p-4 border border-gray-300 rounded-lg resize-none focus:outline-none focus:ring-2 focus:ring-[#008299]"
/>
</div>
{/* Добавить каюты */}
<div className="mb-8">
<h2 className="text-lg font-bold text-[#333333] mb-4">
Добавить каюты
</h2>
<div className="space-y-3 mb-4">
{cabins.map((cabin) => (
<div
key={cabin.id}
className="flex items-center gap-4 p-4 border border-gray-200 rounded-lg"
>
<button
onClick={() => removeCabin(cabin.id)}
className="text-[#999999] hover:text-[#333333]"
>
<X className="h-5 w-5" />
</button>
<div className="flex items-center gap-2">
<button
onClick={() =>
updateCabinCount(cabin.id, -1)
}
className="w-8 h-8 flex items-center justify-center border border-gray-300 rounded"
>
<Minus className="h-4 w-4" />
</button>
<span className="w-8 text-center">
{cabin.count}
</span>
<button
onClick={() =>
updateCabinCount(cabin.id, 1)
}
className="w-8 h-8 flex items-center justify-center border border-gray-300 rounded"
>
<Plus className="h-4 w-4" />
</button>
</div>
<span className="flex-1 font-medium">
{cabin.name}
</span>
<Select
value={cabin.type}
onValueChange={(value) =>
updateCabinType(cabin.id, value)
}
>
<SelectTrigger className="w-48 rounded-lg">
<SelectValue
placeholder="Выберите..."
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="Односпальная">
Односпальная
</SelectItem>
<SelectItem value="Двуспальная">
Двуспальная
</SelectItem>
<SelectItem value="Двухъярусная">
Двухъярусная
</SelectItem>
</SelectContent>
</Select>
</div>
))}
</div>
<Button
variant="outline"
onClick={addCabin}
className="bg-[#333333] text-white hover:bg-[#444444] border-[#333333]"
>
Добавить каюту
</Button>
</div>
{/* Услуги на яхте */}
<div className="mb-8">
<h2 className="text-lg font-bold text-[#333333] mb-4">
Какие есть услуги на вашей яхте?
</h2>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
<div className="flex items-center gap-2">
<Checkbox id="service1" />
<Label htmlFor="service1" className="cursor-pointer">
Капитан
</Label>
</div>
<div className="flex items-center gap-2">
<Checkbox id="service2" />
<Label htmlFor="service2" className="cursor-pointer">
Повар
</Label>
</div>
<div className="flex items-center gap-2">
<Checkbox id="service3" />
<Label htmlFor="service3" className="cursor-pointer">
Стюард
</Label>
</div>
</div>
</div>
{/* Кнопки внизу */}
<div className="flex items-center justify-between pt-6 border-t border-gray-200">
<Button variant="outline" size="lg">
Режим предпросмотра
</Button>
<Button
variant="gradient"
size="lg"
className="bg-[#008299] hover:bg-[#006d7a] text-white"
>
Добавить судно
</Button>
</div>
</div>
</div>
</div>
</main>
);
}

View File

@ -0,0 +1,205 @@
"use client";
import Link from "next/link";
import Image from "next/image";
import ProfileSidebar from "@/app/profile/components/ProfileSidebar";
import { MoveHorizontal, Users } from "lucide-react";
import { getImageUrl, formatMinCost } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import useAuthStore from "@/stores/useAuthStore";
import { useEffect, useState } from "react";
import useApiClient from "@/hooks/useApiClient";
import { CatalogItemShortDto } from "@/api/types";
export default function YachtsPage() {
const [yachts, setYachts] = useState<CatalogItemShortDto[]>([]);
const apiClient = useApiClient();
const { getUserId } = useAuthStore();
const userId = getUserId();
useEffect(() => {
if (userId) {
apiClient
.get<CatalogItemShortDto[]>(`/catalog/user/${userId}`)
.then((response) => {
setYachts(response.data);
})
.catch((error) => {
console.error("Ошибка при загрузке яхт:", error);
});
}
}, [userId]);
return (
<main className="bg-[#f4f4f4]">
<div className="container max-w-6xl mx-auto px-4 py-6">
{/* Breadcrumbs */}
<div className="hidden lg:flex mb-4 text-sm text-[#999999] items-center gap-[16px]">
<Link href="/">
<span className="cursor-pointer hover:text-[#333333] transition-colors">
Аренда яхты
</span>
</Link>
<span>&gt;</span>
<Link href="/profile">
<span className="cursor-pointer hover:text-[#333333] transition-colors">
Личный кабинет
</span>
</Link>
<span>&gt;</span>
<span className="text-[#333333]">Мои яхты</span>
</div>
<div className="flex gap-6">
{/* Sidebar */}
<ProfileSidebar />
{/* Main Content */}
<div className="flex-1 bg-white rounded-[16px] p-8">
{/* Header with Add Button */}
<div className="flex items-center justify-between mb-6">
<h2 className="text-2xl font-bold text-[#333333]">
Мои яхты
</h2>
<Link href="/profile/yachts/add">
<Button variant="gradient" size="default">
Добавить
</Button>
</Link>
</div>
{/* Yachts List */}
<div className="space-y-8">
{yachts.length === 0 ? (
<div className="text-center py-12 text-[#999999]">
Нет яхт в этой категории
</div>
) : (
yachts.map((yacht, index) => (
<div
key={yacht.id}
className={`overflow-hidden bg-white ${
index !== yachts.length - 1
? "pb-8 border-b border-gray-200"
: ""
}`}
>
<div>
<div className="flex flex-col lg:flex-row">
{/* Image Section */}
<div className="relative rounded-[12px] overflow-hidden w-90 h-90 flex-shrink-0">
<Image
src={getImageUrl(
yacht.mainImageUrl
)}
alt={yacht.name}
fill
className="object-cover"
unoptimized
/>
{/* Yacht Details Overlay */}
<div className="absolute bottom-2 left-2 flex gap-2">
<div className="bg-black/50 text-white px-3 py-1.5 rounded-lg flex items-center gap-3 text-sm">
<div className="flex items-center gap-1">
<MoveHorizontal
size={16}
className="text-white"
/>
<span>
{
yacht.length
}{" "}
метров
</span>
</div>
</div>
<div className="bg-black/50 text-white px-3 py-1.5 rounded-lg flex items-center gap-3 text-sm">
<div className="flex items-center gap-1">
<Users
size={16}
className="text-white"
/>
<span>
-
</span>
</div>
</div>
</div>
</div>
{/* Details Section */}
<div className="flex-1 px-6">
<div className="space-y-3">
<div className="flex items-start justify-between">
<div className="space-y-3 w-full">
<h3 className="text-xl font-bold text-[#333333]">
{yacht.name}
</h3>
<div className="text-[#333333] w-full flex justify-between">
<div>
Длина:
</div>
<div>
{
yacht.length
}{" "}
метров
</div>
</div>
<div className="text-[#333333] w-full flex justify-between">
<div>
Вместимость:
</div>
<div>
-
</div>
</div>
<div className="text-[#333333] w-full flex justify-between">
<div>
Стоимость:
</div>
<div className="font-bold">
{formatMinCost(
yacht.minCost
)}{" "}
/ час
</div>
</div>
</div>
</div>
<div className="pt-3 border-t border-[#DFDFDF]">
<div className="flex items-center justify-between">
{yacht.id && (
<>
<Link
href={`/catalog/${yacht.id}`}
className="text-sm text-[#2D908D] hover:underline"
>
Посмотреть
объявление
</Link>
<Link
href={`/profile/yachts/${yacht.id}/edit`}
className="text-sm text-[#2D908D] hover:underline"
>
Редактировать
</Link>
</>
)}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
))
)}
</div>
</div>
</div>
</div>
</main>
);
}

View File

@ -9,302 +9,367 @@ 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";
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;
className?: string;
value?: GuestDatePickerValue;
onChange?: (value: GuestDatePickerValue) => void;
onApply?: (data: GuestDatePickerValue) => 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<CommonPopoverContentProps> = ({
date,
setDate,
departureTime,
setDepartureTime,
arrivalTime,
setArrivalTime,
adults,
setAdults,
childrenCount,
setChildrenCount,
handleApply,
date,
setDate,
departureTime,
setDepartureTime,
arrivalTime,
setArrivalTime,
adults,
setAdults,
childrenCount,
setChildrenCount,
handleApply,
}) => {
return (
<PopoverContent className="rounded-[20px] p-6 pb-4 w-[324px]">
{/* Календарь */}
return (
<PopoverContent className="rounded-[20px] p-6 pb-4 w-[324px]">
{/* Календарь */}
<Calendar
mode="single"
selected={date}
onSelect={setDate}
className="mb-[24px]"
locale={ru}
disabled={(date) =>
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",
}}
<Calendar
mode="single"
selected={date}
onSelect={setDate}
className="mb-[24px]"
locale={ru}
disabled={(date) => 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",
}}
/>
{/* Счетчики гостей */}
<div className="mb-[24px] flex gap-3">
<Counter
label="Взрослые"
value={adults}
onChange={setAdults}
min={0}
max={10}
/>
<Counter
label="Дети"
value={childrenCount}
onChange={setChildrenCount}
min={0}
max={10}
/>
</div>
{/* Поля времени */}
<div className="flex gap-3 mb-6">
<div className="relative w-full h-12 px-3 border border-gray-300 rounded-full text-gray-700 font-medium text-center">
<label className="absolute left-[24px] top-0 transform -translate-y-1/2 text-xs text-gray-500 pointer-events-none transition-all duration-200 bg-white px-1">
Выход
</label>
<div className="relative h-full flex align-center">
<ChevronDownIcon className="absolute right-0 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-500 pointer-events-none" />
<input
type="time"
value={departureTime}
onChange={(e) => setDepartureTime(e.target.value)}
className="w-full focus:outline-none focus:border-transparent"
/>
</div>
</div>
<div className="relative w-full h-12 px-3 border border-gray-300 rounded-full text-gray-700 font-medium text-center">
<label className="absolute left-[24px] top-0 transform -translate-y-1/2 text-xs text-gray-500 pointer-events-none transition-all duration-200 bg-white px-1">
Заход
</label>
<div className="relative h-full flex align-center">
<ChevronDownIcon className="absolute right-0 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-500 pointer-events-none" />
<input
type="time"
value={arrivalTime}
onChange={(e) => setArrivalTime(e.target.value)}
className="w-full focus:outline-none focus:border-transparent"
/>
</div>
</div>
</div>
{/* Счетчики гостей */}
<div className="mb-[24px] flex gap-3">
<Counter
label="Взрослые"
value={adults}
onChange={setAdults}
min={0}
max={10}
/>
<Counter
label="Дети"
value={childrenCount}
onChange={setChildrenCount}
min={0}
max={10}
/>
</div>
{/* Поля времени */}
<div className="flex gap-3 mb-6">
<div className="relative w-full h-12 px-3 border border-gray-300 rounded-full text-gray-700 font-medium text-center">
<label className="absolute left-[24px] top-0 transform -translate-y-1/2 text-xs text-gray-500 pointer-events-none transition-all duration-200 bg-white px-1">
Выход
</label>
<div className="relative h-full flex align-center">
<ChevronDownIcon className="absolute right-0 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-500 pointer-events-none" />
<input
type="time"
value={departureTime}
onChange={(e) => setDepartureTime(e.target.value)}
className="w-full focus:outline-none focus:border-transparent"
/>
</div>
</div>
<div className="relative w-full h-12 px-3 border border-gray-300 rounded-full text-gray-700 font-medium text-center">
<label className="absolute left-[24px] top-0 transform -translate-y-1/2 text-xs text-gray-500 pointer-events-none transition-all duration-200 bg-white px-1">
Заход
</label>
<div className="relative h-full flex align-center">
<ChevronDownIcon className="absolute right-0 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-500 pointer-events-none" />
<input
type="time"
value={arrivalTime}
onChange={(e) => setArrivalTime(e.target.value)}
className="w-full focus:outline-none focus:border-transparent"
/>
</div>
</div>
</div>
{/* Кнопка Применить */}
<Button
onClick={handleApply}
variant="gradient"
className="font-bold text-white h-[44px] w-full px-8"
>
Применить
</Button>
</PopoverContent>
);
{/* Кнопка Применить */}
<Button
onClick={handleApply}
variant="gradient"
className="font-bold text-white h-[44px] w-full px-8"
>
Применить
</Button>
</PopoverContent>
);
};
export const GuestDatePicker: React.FC<GuestDatePickerProps> = ({
onApply,
className,
value,
onChange,
onApply,
className,
}) => {
const [date, setDate] = useState<Date>();
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);
// Используем controlled значения, если они переданы, иначе используем внутреннее состояние
const isControlled = value !== undefined;
const handleApply = () => {
onApply?.({
date,
departureTime,
arrivalTime,
adults,
children,
});
setIsDepartureOpen(false);
setIsArrivalOpen(false);
setIsGuestOpen(false);
const [internalDate, setInternalDate] = useState<Date>();
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 = () => {
const currentValue = {
date,
departureTime,
arrivalTime,
adults,
children,
};
onApply?.(currentValue);
setIsDepartureOpen(false);
setIsArrivalOpen(false);
setIsGuestOpen(false);
};
const getDepartureDisplayText = () => {
if (!date || !departureTime) return "Выход";
return (
<>
{format(date, "d MMMM", {
locale: ru,
})}
, <span className="font-bold">{departureTime}</span>
</>
);
};
const getArrivalDisplayText = () => {
if (!date || !arrivalTime) return "Заход";
return (
<>
{format(date, "d MMMM", {
locale: ru,
})}
, <span className="font-bold">{arrivalTime}</span>
</>
);
};
const getGuestDisplayText = () => {
if (adults === 1 && children === 0) return "1 гость";
return (
<span className="font-bold">
Взрослых: {adults}, Детей: {children}
</span>
);
};
const getDepartureDisplayText = () => {
if (!date || !departureTime) return "Выход";
return (
<div className={className}>
<div className="space-y-5">
{/* Кнопка Выход */}
<Popover
open={isDepartureOpen}
onOpenChange={setIsDepartureOpen}
>
<PopoverTrigger asChild>
<Button
variant="outline"
className="h-[64px] px-4 w-full justify-between font-normal"
>
<div className="flex items-center">
<span>{getDepartureDisplayText()}</span>
</div>
</Button>
</PopoverTrigger>
<CommonPopoverContent
date={date}
setDate={setDate}
departureTime={departureTime}
setDepartureTime={setDepartureTime}
arrivalTime={arrivalTime}
setArrivalTime={setArrivalTime}
adults={adults}
setAdults={setAdults}
childrenCount={children}
setChildrenCount={setChildren}
handleApply={handleApply}
/>
</Popover>
{/* Кнопка Заход */}
<Popover open={isArrivalOpen} onOpenChange={setIsArrivalOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
className="h-[64px] px-4 w-full justify-between font-normal"
>
<div className="flex items-center">
<span>{getArrivalDisplayText()}</span>
</div>
</Button>
</PopoverTrigger>
<CommonPopoverContent
date={date}
setDate={setDate}
departureTime={departureTime}
setDepartureTime={setDepartureTime}
arrivalTime={arrivalTime}
setArrivalTime={setArrivalTime}
adults={adults}
setAdults={setAdults}
childrenCount={children}
setChildrenCount={setChildren}
handleApply={handleApply}
/>
</Popover>
{/* Кнопка Гости */}
<Popover open={isGuestOpen} onOpenChange={setIsGuestOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
className="h-[64px] px-4 w-full justify-between font-normal"
>
<div className="flex items-center">
<span>{getGuestDisplayText()}</span>
</div>
{isGuestOpen ? (
<ChevronUpIcon className="h-4 w-4" />
) : (
<ChevronDownIcon className="h-4 w-4" />
)}
</Button>
</PopoverTrigger>
<CommonPopoverContent
date={date}
setDate={setDate}
departureTime={departureTime}
setDepartureTime={setDepartureTime}
arrivalTime={arrivalTime}
setArrivalTime={setArrivalTime}
adults={adults}
setAdults={setAdults}
childrenCount={children}
setChildrenCount={setChildren}
handleApply={handleApply}
/>
</Popover>
</div>
</div>
<>
{format(date, "d MMMM", {
locale: ru,
})}
, <span className="font-bold">{departureTime}</span>
</>
);
};
const getArrivalDisplayText = () => {
if (!date || !arrivalTime) return "Заход";
return (
<>
{format(date, "d MMMM", {
locale: ru,
})}
, <span className="font-bold">{arrivalTime}</span>
</>
);
};
const getGuestDisplayText = () => {
if (adults === 1 && children === 0) return "1 гость";
return (
<span className="font-bold">
Взрослых: {adults}, Детей: {children}
</span>
);
};
return (
<div className={className}>
<div className="space-y-5">
{/* Кнопка Выход */}
<Popover open={isDepartureOpen} onOpenChange={setIsDepartureOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
className="h-[64px] px-4 w-full justify-between font-normal"
>
<div className="flex items-center">
<span>{getDepartureDisplayText()}</span>
</div>
</Button>
</PopoverTrigger>
<CommonPopoverContent
date={date}
setDate={setDate}
departureTime={departureTime}
setDepartureTime={setDepartureTime}
arrivalTime={arrivalTime}
setArrivalTime={setArrivalTime}
adults={adults}
setAdults={setAdults}
childrenCount={children}
setChildrenCount={setChildren}
handleApply={handleApply}
/>
</Popover>
{/* Кнопка Заход */}
<Popover open={isArrivalOpen} onOpenChange={setIsArrivalOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
className="h-[64px] px-4 w-full justify-between font-normal"
>
<div className="flex items-center">
<span>{getArrivalDisplayText()}</span>
</div>
</Button>
</PopoverTrigger>
<CommonPopoverContent
date={date}
setDate={setDate}
departureTime={departureTime}
setDepartureTime={setDepartureTime}
arrivalTime={arrivalTime}
setArrivalTime={setArrivalTime}
adults={adults}
setAdults={setAdults}
childrenCount={children}
setChildrenCount={setChildren}
handleApply={handleApply}
/>
</Popover>
{/* Кнопка Гости */}
<Popover open={isGuestOpen} onOpenChange={setIsGuestOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
className="h-[64px] px-4 w-full justify-between font-normal"
>
<div className="flex items-center">
<span>{getGuestDisplayText()}</span>
</div>
{isGuestOpen ? (
<ChevronUpIcon className="h-4 w-4" />
) : (
<ChevronDownIcon className="h-4 w-4" />
)}
</Button>
</PopoverTrigger>
<CommonPopoverContent
date={date}
setDate={setDate}
departureTime={departureTime}
setDepartureTime={setDepartureTime}
arrivalTime={arrivalTime}
setArrivalTime={setArrivalTime}
adults={adults}
setAdults={setAdults}
childrenCount={children}
setChildrenCount={setChildren}
handleApply={handleApply}
/>
</Popover>
</div>
</div>
);
};

View File

@ -4,11 +4,24 @@ import { Button } from "@/components/ui/button";
import Image from "next/image";
import Link from "next/link";
import { User, Menu } from "lucide-react";
import { useRouter } from "next/navigation";
import AuthDialog from "@/components/layout/AuthDialog";
import useAuthPopup from "@/stores/useAuthPopup";
import useAuthStore from "@/stores/useAuthStore";
export default function Header() {
const authPopup = useAuthPopup();
const router = useRouter();
const authStore = useAuthStore();
const handleProfileClick = () => {
const token = authStore.getToken();
if (token) {
router.push("/profile/reservations");
} else {
authPopup.open();
}
};
return (
<header className="sticky top-0 z-50 bg-white/95 backdrop-blur-sm border-b border-gray-100">
@ -36,7 +49,7 @@ export default function Header() {
<Button
variant="ghost"
size="icon"
onClick={authPopup.open}
onClick={handleProfileClick}
className="text-gray-700 w-[100px] h-[48px] border"
>
<Menu className="!h-[24px] !w-[24px]" />

View File

@ -24,6 +24,8 @@ interface DatePickerProps {
onDateChange?: (date: Date | undefined) => void;
onDepartureTimeChange?: (time: string) => void;
onArrivalTimeChange?: (time: string) => void;
onlyDeparture?: boolean;
onlyArrival?: boolean;
}
export function DatePicker({
@ -36,21 +38,37 @@ export function DatePicker({
onDateChange,
onDepartureTimeChange,
onArrivalTimeChange,
onlyDeparture,
onlyArrival,
}: DatePickerProps) {
const [internalDate, setInternalDate] = React.useState<Date>();
const [internalDepartureTime, setInternalDepartureTime] = React.useState("12:00");
const [internalArrivalTime, setInternalArrivalTime] = React.useState("13:00");
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 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 {
} else if (!isControlled) {
setInternalDate(newDate);
}
};
@ -58,7 +76,7 @@ export function DatePicker({
const handleDepartureTimeChange = (time: string) => {
if (onDepartureTimeChange) {
onDepartureTimeChange(time);
} else {
} else if (!isControlled) {
setInternalDepartureTime(time);
}
};
@ -66,7 +84,7 @@ export function DatePicker({
const handleArrivalTimeChange = (time: string) => {
if (onArrivalTimeChange) {
onArrivalTimeChange(time);
} else {
} else if (!isControlled) {
setInternalArrivalTime(time);
}
};
@ -94,11 +112,19 @@ export function DatePicker({
/>
)}
{date ? (
format(
date,
`d MMMM, ${departureTime} - ${arrivalTime}`,
{ locale: ru }
)
(() => {
let timeFormat = "";
if (onlyDeparture) {
timeFormat = `d MMMM, ${departureTime}`;
} else if (onlyArrival) {
timeFormat = `d MMMM, ${arrivalTime}`;
} else {
timeFormat = `d MMMM, ${departureTime} - ${arrivalTime}`;
}
return format(date, timeFormat, {
locale: ru,
});
})()
) : (
<span>{placeholder}</span>
)}
@ -147,39 +173,47 @@ export function DatePicker({
{/* Поля времени */}
<div className="flex gap-3 mb-4">
<div className="relative w-full h-12 px-3 border border-gray-300 rounded-full text-gray-700 font-medium text-center">
<label className="absolute left-[24px] top-0 transform -translate-y-1/2 text-xs text-gray-500 pointer-events-none transition-all duration-200 bg-white px-1">
Выход
</label>
<div className="relative h-full flex align-center">
<ChevronDownIcon className="absolute right-0 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-500 pointer-events-none" />
<input
type="time"
value={departureTime}
onChange={(e) =>
handleDepartureTimeChange(e.target.value)
}
className="w-full focus:outline-none focus:border-transparent"
/>
{onlyDeparture && (
<div className="relative w-full h-12 px-3 border border-gray-300 rounded-full text-gray-700 font-medium text-center">
<label className="absolute left-[24px] top-0 transform -translate-y-1/2 text-xs text-gray-500 pointer-events-none transition-all duration-200 bg-white px-1">
Выход
</label>
<div className="relative h-full flex align-center">
<ChevronDownIcon className="absolute right-0 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-500 pointer-events-none" />
<input
type="time"
value={departureTime}
onChange={(e) =>
handleDepartureTimeChange(
e.target.value
)
}
className="w-full focus:outline-none focus:border-transparent"
/>
</div>
</div>
</div>
)}
<div className="relative w-full h-12 px-3 border border-gray-300 rounded-full text-gray-700 font-medium text-center">
<label className="absolute left-[24px] top-0 transform -translate-y-1/2 text-xs text-gray-500 pointer-events-none transition-all duration-200 bg-white px-1">
Заход
</label>
<div className="relative h-full flex align-center">
<ChevronDownIcon className="absolute right-0 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-500 pointer-events-none" />
<input
type="time"
value={arrivalTime}
onChange={(e) =>
handleArrivalTimeChange(e.target.value)
}
className="w-full focus:outline-none focus:border-transparent"
/>
{onlyArrival && (
<div className="relative w-full h-12 px-3 border border-gray-300 rounded-full text-gray-700 font-medium text-center">
<label className="absolute left-[24px] top-0 transform -translate-y-1/2 text-xs text-gray-500 pointer-events-none transition-all duration-200 bg-white px-1">
Заход
</label>
<div className="relative h-full flex align-center">
<ChevronDownIcon className="absolute right-0 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-500 pointer-events-none" />
<input
type="time"
value={arrivalTime}
onChange={(e) =>
handleArrivalTimeChange(
e.target.value
)
}
className="w-full focus:outline-none focus:border-transparent"
/>
</div>
</div>
</div>
)}
</div>
{/* Кнопка Применить */}

View File

@ -0,0 +1,26 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {
pisun?: string
}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#008299] focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
);
}
);
Input.displayName = "Input";
export { Input };

View File

@ -3,43 +3,43 @@ import useAuthStore from "@/stores/useAuthStore";
import useAuthPopup from "@/stores/useAuthPopup";
const useApiClient = () => {
const { getToken } = useAuthStore();
const authPopup = useAuthPopup();
const { getToken } = useAuthStore();
const authPopup = useAuthPopup();
const apiClient = axios.create({
baseURL: "http://192.168.1.5:4000/",
headers: {
"Content-Type": "application/json",
},
});
const apiClient = axios.create({
baseURL: "https://api.travelmarine.ru",
headers: {
"Content-Type": "application/json",
},
});
apiClient.interceptors.request.use(
(config) => {
const token = getToken();
apiClient.interceptors.request.use(
(config) => {
const token = getToken();
if (token) {
config.headers.Authorization = `Token ${token}`;
}
if (token) {
config.headers.Authorization = `Token ${token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
return config;
},
(error) => {
return Promise.reject(error);
}
);
apiClient.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
console.error("Authentication error:", error);
authPopup.open();
}
return Promise.reject(error);
}
);
apiClient.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
console.error("Authentication error:", error);
authPopup.open();
}
return Promise.reject(error);
}
);
return apiClient;
return apiClient;
};
export default useApiClient;

View File

@ -1,20 +1,26 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
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))
return twMerge(clsx(inputs));
}
const API_BASE_URL = "http://89.169.188.2";
const API_BASE_URL = "https://api.travelmarine.ru";
export const getImageUrl = (relativePath: string): string => {
if (!relativePath) return "";
// Если путь уже абсолютный, возвращаем как есть
if (relativePath.startsWith("http://") || relativePath.startsWith("https://")) {
if (
relativePath.startsWith("http://") ||
relativePath.startsWith("https://")
) {
return relativePath;
}
// Убираем начальный слеш, если есть, и формируем абсолютный URL
const cleanPath = relativePath.startsWith("/") ? relativePath.slice(1) : relativePath;
const cleanPath = relativePath.startsWith("/")
? relativePath.slice(1)
: relativePath;
return `${API_BASE_URL}/${cleanPath}`;
};
@ -29,3 +35,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 };
}
};

View File

@ -4,10 +4,14 @@ interface AuthStore {
setToken: (token: string, rememberMe?: boolean) => void;
getToken: () => string | null;
clearToken: () => void;
setUserId: (userId: string | number, rememberMe?: boolean) => void;
getUserId: () => string | null;
clearUserId: () => void;
}
const useAuthStore = create<AuthStore>((set) => ({
const useAuthStore = create<AuthStore>(() => ({
setToken: (token: string, rememberMe: boolean = false) => {
if (typeof window === "undefined") return;
if (rememberMe) {
localStorage.setItem("token", token);
} else {
@ -16,6 +20,7 @@ const useAuthStore = create<AuthStore>((set) => ({
},
getToken: (): string | null => {
if (typeof window === "undefined") return null;
const sessionToken = sessionStorage.getItem("token");
if (sessionToken) {
return sessionToken;
@ -30,9 +35,41 @@ const useAuthStore = create<AuthStore>((set) => ({
},
clearToken: () => {
if (typeof window === "undefined") return;
sessionStorage.removeItem("token");
localStorage.removeItem("token");
},
setUserId: (userId: string | number, rememberMe: boolean = false) => {
if (typeof window === "undefined") return;
const userIdString = String(userId);
if (rememberMe) {
localStorage.setItem("userId", userIdString);
} else {
sessionStorage.setItem("userId", userIdString);
}
},
getUserId: (): string | null => {
if (typeof window === "undefined") return null;
const sessionUserId = sessionStorage.getItem("userId");
if (sessionUserId) {
return sessionUserId;
}
const localUserId = localStorage.getItem("userId");
if (localUserId) {
return localUserId;
}
return null;
},
clearUserId: () => {
if (typeof window === "undefined") return;
sessionStorage.removeItem("userId");
localStorage.removeItem("userId");
},
}));
export default useAuthStore;