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: { images: {
remotePatterns: [ remotePatterns: [
{ {
protocol: "http", protocol: "https",
hostname: "89.169.188.2", hostname: "api.travelmarine.ru",
pathname: '/**' pathname: "/**",
}, },
], ],
unoptimized: false, unoptimized: true,
}, },
webpack(config) { webpack(config) {
config.module.rules.push({ config.module.rules.push({

2
package-lock.json generated
View File

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

View File

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

View File

@ -1,4 +1,4 @@
interface CatalogItemDto { export interface CatalogItemShortDto {
id?: number; id?: number;
name: string; name: string;
length: number; length: number;
@ -12,12 +12,75 @@ interface CatalogItemDto {
isBestOffer?: boolean; isBestOffer?: boolean;
} }
interface MainPageCatalogResponseDto { export interface MainPageCatalogResponseDto {
featuredYacht: CatalogItemDto; featuredYacht: CatalogItemShortDto;
restYachts: CatalogItemDto[]; restYachts: CatalogItemShortDto[];
} }
interface CatalogFilteredResponseDto { export interface CatalogFilteredResponseDto {
items: CatalogItemDto[]; items: CatalogItemShortDto[];
total: number; 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; 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 useAuthentificate = () => {
const client = useApiClient(); const client = useApiClient();
const setToken = useAuthStore((state) => state.setToken); const setToken = useAuthStore((state) => state.setToken);
const setUserId = useAuthStore((state) => state.setUserId);
return useMutation({ return useMutation({
mutationKey: ["auth"], mutationKey: ["auth"],
@ -25,6 +56,21 @@ const useAuthentificate = () => {
setToken(access_token, authData.rememberMe); 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; return access_token;
}, },
onError: () => { onError: () => {

View File

@ -1,101 +1,138 @@
"use client"; "use client";
import { useState } from "react"; import { useState, useMemo } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { DatePicker } from "@/components/ui/date-picker"; import { DatePicker } from "@/components/ui/date-picker";
import { GuestPicker } from "@/components/form/guest-picker"; import { GuestPicker } from "@/components/form/guest-picker";
import { format } from "date-fns";
import { calculateTotalPrice, formatPrice } from "@/lib/utils";
import { CatalogItemLongDto } from "@/api/types";
interface BookingWidgetProps { interface BookingWidgetProps {
price: string; price: string;
yacht: CatalogItemLongDto;
} }
export function BookingWidget({ price }: BookingWidgetProps) { export function BookingWidget({ price, yacht }: BookingWidgetProps) {
const router = useRouter(); const router = useRouter();
const [departureDate] = useState<Date | undefined>(); const [departureDate, setDepartureDate] = useState<Date | undefined>();
const [arrivalDate] = useState<Date | undefined>(); const [arrivalDate, setArrivalDate] = useState<Date | undefined>();
const [guests, setGuests] = useState({ adults: 1, children: 0 }); const [departureTime, setDepartureTime] = useState<string>("12:00");
const [total] = useState(0); 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 = () => { const departureDateStr = format(departureDate, "yyyy-MM-dd");
// Логика бронирования const arrivalDateStr = format(arrivalDate, "yyyy-MM-dd");
console.log("Booking:", {
departureDate,
arrivalDate,
guests,
});
router.push("/confirm");
};
return ( const { totalPrice } = calculateTotalPrice(
<div className="bg-white border border-gray-200 rounded-lg p-6"> departureDateStr,
<div className="mb-6"> departureTime,
<p className="text-2xl font-bold text-[#333333] mb-2"> arrivalDateStr,
от {price} {" "} arrivalTime,
<span className="text-base font-normal text-[#999999]"> yacht.minCost
/час
</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>
); );
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"; "use client";
import Image from "next/image"; import Image from "next/image";
import { User } from "@/api/types";
interface ContactInfoProps { export function ContactInfo({ firstName, companyName, inn, ogrn }: User) {
contactPerson: { return (
name: string; <div className="flex flex-col sm:flex-row gap-5">
avatar: string; <div className="flex-1 rounded-[24px] px-6 py-5 bg-[#f4f4f4]">
}; <div className="flex items-center gap-4 h-full">
requisites: { <div className="relative rounded-full overflow-hidden bg-gray-200 flex items-center justify-center">
ip: string; <Image
inn: string; src="/images/avatar.png"
ogrn: string; alt={firstName || "avatar"}
}; width={124}
} height={124}
/>
export function ContactInfo({ contactPerson, requisites }: ContactInfoProps) { </div>
return ( <div className="flex flex-col justify-between h-full">
<div className="flex flex-col sm:flex-row gap-5"> <h3 className="text-base font-bold text-[#333333]">{firstName}</h3>
<div className="flex-1 rounded-[24px] px-6 py-5 bg-[#f4f4f4]"> <p className="text-base text-[#333333]">Контактное лицо</p>
<div className="flex items-center gap-4 h-full"> </div>
<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>
</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"; "use client";
import { useState } from "react"; import { useState, useMemo } from "react";
import { Calendar } from "@/components/ui/calendar"; import { Calendar } from "@/components/ui/calendar";
import { import {
isSameMonth, isSameMonth,
isBefore, isBefore,
startOfDay, startOfDay,
format, format,
eachDayOfInterval, eachDayOfInterval,
startOfMonth, startOfMonth,
endOfMonth, endOfMonth,
} from "date-fns"; } from "date-fns";
import { ru } from "date-fns/locale"; import { ru } from "date-fns/locale";
import { ChevronLeftIcon, ChevronRightIcon, Clock } from "lucide-react"; import { ChevronLeftIcon, ChevronRightIcon, Clock } from "lucide-react";
interface Reservation {
id: number;
reservatorId: number;
yachtId: number;
startUtc: number;
endUtc: number;
}
interface YachtAvailabilityProps { interface YachtAvailabilityProps {
price: string; price: string;
mobile?: boolean; 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({ export function YachtAvailability({
price, price,
mobile = false, mobile = false,
reservations = [],
selectedDate,
startTime: controlledStartTime,
endTime: controlledEndTime,
onDateChange,
onStartTimeChange,
onEndTimeChange,
}: YachtAvailabilityProps) { }: YachtAvailabilityProps) {
const today = startOfDay(new Date()); const today = startOfDay(new Date());
const [currentMonth, setCurrentMonth] = useState( const [currentMonth, setCurrentMonth] = useState(
new Date(today.getFullYear(), today.getMonth(), 1) new Date(today.getFullYear(), today.getMonth(), 1)
); );
const [startTime, setStartTime] = useState<string>("");
const [endTime, setEndTime] = useState<string>("");
const 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) => { const startTime = mobile && controlledStartTime !== undefined ? controlledStartTime : internalStartTime;
return unavailableDates.some( const endTime = mobile && controlledEndTime !== undefined ? controlledEndTime : internalEndTime;
(d) =>
d.getDate() === date.getDate() &&
d.getMonth() === date.getMonth() &&
d.getFullYear() === date.getFullYear()
);
};
const isDateInPast = (date: Date) => { const handleStartTimeChange = (time: string) => {
return isBefore(startOfDay(date), today); if (mobile && onStartTimeChange) {
}; onStartTimeChange(time);
} else {
setInternalStartTime(time);
}
};
const shouldBeCrossedOut = (date: Date) => { const handleEndTimeChange = (time: string) => {
// Перечеркиваем если день занят или находится до текущего дня if (mobile && onEndTimeChange) {
return isDateUnavailable(date) || isDateInPast(date); onEndTimeChange(time);
}; } else {
setInternalEndTime(time);
}
};
const isDateAvailable = (date: Date) => { const unavailableDates = Array.from({ length: 26 }, (_, i) => {
return !shouldBeCrossedOut(date) && isSameMonth(date, currentMonth); return new Date(2025, 3, i + 1);
}; });
const getAvailableDaysCount = () => { // Format time from Unix timestamp to HH:mm in UTC
const monthStart = startOfMonth(currentMonth); const formatTimeFromUnix = (unixTimestamp: number) => {
const monthEnd = endOfMonth(currentMonth); const date = new Date(unixTimestamp * 1000);
const daysInMonth = eachDayOfInterval({ // Format in UTC to avoid timezone conversion
start: monthStart, return format(date, "HH:mm");
end: monthEnd, };
// 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 dayReservations;
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>
{/* Календарь */} // Check if a date has any reservations
<div style={{ flexShrink: 0 }}> const hasReservationsOnDate = (date: Date) => {
<Calendar const dayStart = Math.floor(startOfDay(date).getTime() / 1000);
mode="single" const dayEnd = dayStart + 24 * 60 * 60;
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( return reservations.some((reservation) => {
day.date return reservation.startUtc < dayEnd && reservation.endUtc > dayStart;
); });
};
return ( const isDateUnavailable = (date: Date) => {
<button return unavailableDates.some(
{...props} (d) =>
className={`relative w-full flex items-center justify-center text-sm font-medium transition-colors ${ d.getDate() === date.getDate() &&
isCrossedOut d.getMonth() === date.getMonth() &&
? "text-[#CCCCCC] line-through" d.getFullYear() === date.getFullYear()
: "text-[#333333] hover:bg-gray-100" );
}`} };
style={
{
aspectRatio: "1 / 1",
minHeight: "44px",
} as React.CSSProperties
}
disabled={isCrossedOut}
>
{day.date.getDate()}
</button>
);
},
}}
/>
</div>
{/* Выбор времени */} const isDateInPast = (date: Date) => {
<div className="space-y-4 mb-4" style={{ marginTop: "24px" }}> return isBefore(startOfDay(date), today);
<div className="flex gap-3"> };
<div className="flex-1">
<select const shouldBeCrossedOut = (date: Date) => {
value={startTime} return isDateUnavailable(date) || isDateInPast(date);
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"
> const isDateAvailable = (date: Date) => {
<option value="">--:--</option> return !shouldBeCrossedOut(date) && isSameMonth(date, currentMonth);
{timeOptions.map((time) => ( };
<option key={time.value} value={time.value}>
{time.label} const getAvailableDaysCount = () => {
</option> const monthStart = startOfMonth(currentMonth);
))} const monthEnd = endOfMonth(currentMonth);
</select> const daysInMonth = eachDayOfInterval({
</div> start: monthStart,
<div className="flex-1"> end: monthEnd,
<select });
value={endTime} return daysInMonth.filter((day) => isDateAvailable(day)).length;
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"
> const goToPreviousMonth = () => {
<option value="">--:--</option> setCurrentMonth(
{timeOptions.map((time) => ( new Date(currentMonth.getFullYear(), currentMonth.getMonth() - 1, 1)
<option key={time.value} value={time.value}> );
{time.label} };
</option>
))} const goToNextMonth = () => {
</select> setCurrentMonth(
</div> new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1, 1)
</div> );
<div className="flex items-center gap-2 text-sm text-[#999999]"> };
<Clock size={16} />
<span>По местному времени яхты</span> // Генерация времени для селекта
</div> const timeOptions = Array.from({ length: 24 * 2 }, (_, i) => {
</div> const hours = Math.floor(i / 2);
</div> 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 ( return (
<div className="space-y-4 w-full"> <div className="flex flex-col gap-1 w-full mt-1">
<div className="flex items-center justify-between"> {dateReservations.map((res) => (
<h2 className="text-base font-bold text-[#333333]"> <div
Доступность яхты key={`${res.id}-${res.startTime}`}
</h2> className="w-fit bg-[#2F5CD0] text-white text-[10px] font-medium px-1 py-0 rounded-full inline-block"
</div> >
{res.startTime}{res.endTime}
<div className="bg-white w-full"> </div>
<div className="w-full flex justify-end mb-8"> ))}
<div className="flex items-center gap-5"> </div>
<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>
); );
};
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"; "use client";
import { CatalogItemLongDto } from "@/api/types";
interface YachtCharacteristicsProps { interface YachtCharacteristicsProps {
yacht: { yacht: CatalogItemLongDto;
year: number;
maxCapacity: number;
comfortableCapacity: number;
length: number;
width: number;
cabins: number;
material: string;
power: number;
};
} }
export function YachtCharacteristics({ yacht }: YachtCharacteristicsProps) { export function YachtCharacteristics({ yacht }: YachtCharacteristicsProps) {
const characteristics = [ const characteristics = [
{ label: "Год", value: yacht.year }, { label: "Год", value: yacht.year },
{ {
label: "Максимальная вместимость", label: "Максимальная вместимость",
value: `${yacht.maxCapacity} человек`, value: `${yacht.maxCapacity} человек`,
}, },
{ {
label: "Комфортная вместимость", label: "Комфортная вместимость",
value: `${yacht.comfortableCapacity} человек`, value: `${yacht.comfortCapacity} человек`,
}, },
{ label: "Длина", value: `${yacht.length} м` }, { label: "Длина", value: `${yacht.length} м` },
{ label: "Ширина", value: `${yacht.width} м` }, { label: "Ширина", value: `${yacht.width} м` },
{ label: "Каюты", value: yacht.cabins }, { label: "Каюты", value: yacht.cabinsCount },
{ label: "Материал", value: yacht.material }, { label: "Материал", value: yacht.matherial },
{ label: "Мощность", value: `${yacht.power} л/с` }, { label: "Мощность", value: `${yacht.power} л/с` },
]; ];
return ( return (
<div> <div>
<h2 className="text-base font-bold text-[#333333] mb-4"> <h2 className="text-base font-bold text-[#333333] mb-4">
Характеристики Характеристики
</h2> </h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-6"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-x-6">
{characteristics.map((char, index) => ( {characteristics.map((char, index) => (
<div <div
key={index} key={index}
className="flex justify-between items-center py-4 border-b border-gray-200" className="flex justify-between items-center py-4 border-b border-gray-200"
> >
<span className="text-base text-[#999999]"> <span className="text-base text-[#999999]">{char.label}</span>
{char.label} <span className="text-base font-regular text-[#333333]">
</span> {char.value}
<span className="text-base font-regular text-[#333333]"> </span>
{char.value} </div>
</span> ))}
</div> </div>
))} </div>
</div> );
</div>
);
} }

View File

@ -4,107 +4,108 @@ import { useState, useEffect } from "react";
import Image from "next/image"; import Image from "next/image";
import Icon from "@/components/ui/icon"; import Icon from "@/components/ui/icon";
import { import {
Carousel, Carousel,
CarouselContent, CarouselContent,
CarouselItem, CarouselItem,
CarouselNext, CarouselNext,
CarouselPrevious, CarouselPrevious,
type CarouselApi, type CarouselApi,
} from "@/components/ui/carousel"; } from "@/components/ui/carousel";
import { getImageUrl } from "@/lib/utils";
interface YachtGalleryProps { interface YachtGalleryProps {
images: string[]; images: string[];
badge?: string; badge?: string;
} }
export function YachtGallery({ images, badge }: YachtGalleryProps) { export function YachtGallery({ images, badge }: YachtGalleryProps) {
const [api, setApi] = useState<CarouselApi>(); const [api, setApi] = useState<CarouselApi>();
const [current, setCurrent] = useState(0); const [current, setCurrent] = useState(0);
useEffect(() => { useEffect(() => {
if (!api) { if (!api) {
return; return;
} }
setCurrent(api.selectedScrollSnap()); setCurrent(api.selectedScrollSnap());
api.on("select", () => { api.on("select", () => {
setCurrent(api.selectedScrollSnap()); setCurrent(api.selectedScrollSnap());
}); });
}, [api]); }, [api]);
const scrollTo = (index: number) => { const scrollTo = (index: number) => {
api?.scrollTo(index); api?.scrollTo(index);
}; };
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* Main Image Carousel */} {/* Main Image Carousel */}
<div className="relative"> <div className="relative">
<Carousel <Carousel
setApi={setApi} setApi={setApi}
opts={{ opts={{
align: "start", align: "start",
loop: false, loop: false,
}} }}
className="w-full" className="w-full"
> >
<CarouselContent> <CarouselContent>
{images.map((img, index) => ( {images.map((img, index) => (
<CarouselItem key={index}> <CarouselItem key={index}>
<div className="relative w-full h-[60vh] lg:h-[592px] rounded-0 lg:rounded-[24px] overflow-hidden"> <div className="relative w-full h-[60vh] lg:h-[592px] rounded-0 lg:rounded-[24px] overflow-hidden">
<Image <Image
src={img} src={getImageUrl(img)}
alt={`Yacht image ${index + 1}`} alt={`Yacht image ${index + 1}`}
fill fill
className="object-cover" className="object-cover"
priority={index === 0} 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>
</div> </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>
</div>
{/* Thumbnails - скрыты на мобильных */} )}
<div className="hidden lg:flex gap-2 overflow-x-auto pb-2"> {/* Photo counter - поверх слайдера, не скроллится */}
{images.map((img, index) => ( <div className="absolute bottom-4 right-4 z-20 pointer-events-none">
<button <div className="bg-black/40 text-white px-3 py-1 rounded-lg text-sm">
key={index} {current + 1}/{images.length}
onClick={() => scrollTo(index)} </div>
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> </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"; "use client";
import { useRouter } from "next/navigation"; import { useRouter, useParams } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { useState } from "react"; import { useState, useEffect } from "react";
import { ArrowLeft, Heart } from "lucide-react"; import { ArrowLeft, Heart } from "lucide-react";
import Icon from "@/components/ui/icon"; import Icon from "@/components/ui/icon";
import { YachtGallery } from "./components/YachtGallery"; import { YachtGallery } from "./components/YachtGallery";
@ -10,9 +10,28 @@ import { YachtAvailability } from "./components/YachtAvailability";
import { BookingWidget } from "./components/BookingWidget"; import { BookingWidget } from "./components/BookingWidget";
import { YachtCharacteristics } from "./components/YachtCharacteristics"; import { YachtCharacteristics } from "./components/YachtCharacteristics";
import { ContactInfo } from "./components/ContactInfo"; import { ContactInfo } from "./components/ContactInfo";
import { 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() { 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 params = useParams();
const router = useRouter(); const router = useRouter();
const [activeTab, setActiveTab] = useState< const [activeTab, setActiveTab] = useState<
@ -24,6 +43,40 @@ export default function YachtDetailPage() {
| "reviews" | "reviews"
>("availability"); >("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 ( return (
<main className="bg-[#f4f4f4] min-h-screen "> <main className="bg-[#f4f4f4] min-h-screen ">
{/* Мобильная фиксированная верхняя панель навигации */} {/* Мобильная фиксированная верхняя панель навигации */}
@ -59,7 +112,7 @@ export default function YachtDetailPage() {
</span> </span>
</Link> </Link>
<span>&gt;</span> <span>&gt;</span>
<span className="text-[#333333]">{YACHT.name}</span> <span className="text-[#333333]">{yacht.name}</span>
</div> </div>
</div> </div>
@ -70,14 +123,14 @@ export default function YachtDetailPage() {
<div className="lg:hidden pt-[50px]"> <div className="lg:hidden pt-[50px]">
{/* Gallery */} {/* Gallery */}
<YachtGallery <YachtGallery
images={YACHT.images} images={yacht.galleryUrls || []}
badge={YACHT.badge} badge={!yacht.hasQuickRent ? "По запросу" : ""}
/> />
{/* Yacht Title */} {/* Yacht Title */}
<div className="px-4 pt-4"> <div className="px-4 pt-4">
<h1 className="text-xl font-bold text-[#333333] mb-4"> <h1 className="text-xl font-bold text-[#333333] mb-4">
{YACHT.name} {yacht.name}
</h1> </h1>
</div> </div>
@ -142,26 +195,46 @@ export default function YachtDetailPage() {
{/* Tab Content */} {/* Tab Content */}
<div className="px-4 py-6"> <div className="px-4 py-6">
{activeTab === "availability" && ( {activeTab === "availability" && (
<YachtAvailability <>
price={YACHT.price} <YachtAvailability
mobile={true} 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" && ( {activeTab === "description" && (
<div> <div>
<p className="text-base text-[#666666] leading-relaxed"> <p className="text-base text-[#666666] leading-relaxed">
{YACHT.description} {yacht.description}
</p> </p>
</div> </div>
)} )}
{activeTab === "characteristics" && ( {activeTab === "characteristics" && (
<YachtCharacteristics yacht={YACHT} /> <YachtCharacteristics yacht={yacht} />
)} )}
{activeTab === "contact" && ( {activeTab === "contact" && (
<ContactInfo <ContactInfo {...yacht.owner} />
contactPerson={YACHT.contactPerson}
requisites={YACHT.requisites}
/>
)} )}
{activeTab === "reviews" && ( {activeTab === "reviews" && (
<div> <div>
@ -186,13 +259,13 @@ export default function YachtDetailPage() {
{/* Yacht Title and Actions */} {/* Yacht Title and Actions */}
<div className="mb-6 flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4"> <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]"> <h1 className="text-xl md:text-2xl font-bold text-[#333333]">
{YACHT.name} {yacht.name}
</h1> </h1>
<div className="flex items-center gap-6"> <div className="flex items-center gap-6">
<div className="flex items-center gap-2 text-[#333333]"> <div className="flex items-center gap-2 text-[#333333]">
<Icon name="pin" size={32} /> <Icon name="pin" size={32} />
<span className="text-base"> <span className="text-base">
{YACHT.location} {formatSpeed(yacht.speed)}
</span> </span>
</div> </div>
<button className="flex items-center gap-2 cursor-pointer text-[#333333] hover:text-[#008299] transition-colors"> <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"> <div className="space-y-6">
{/* Gallery */} {/* Gallery */}
<YachtGallery <YachtGallery
images={YACHT.images} images={yacht.galleryUrls || []}
badge={YACHT.badge} badge={!yacht.hasQuickRent ? "По запросу" : ""}
/> />
{/* Content with Booking Widget on the right */} {/* Content with Booking Widget on the right */}
@ -221,10 +294,13 @@ export default function YachtDetailPage() {
{/* Left column - all content below gallery */} {/* Left column - all content below gallery */}
<div className="flex-1 space-y-6"> <div className="flex-1 space-y-6">
{/* Availability */} {/* Availability */}
<YachtAvailability price={YACHT.price} /> <YachtAvailability
price={String(yacht.minCost)}
reservations={yacht.reservations}
/>
{/* Characteristics */} {/* Characteristics */}
<YachtCharacteristics yacht={YACHT} /> <YachtCharacteristics yacht={yacht} />
{/* Description */} {/* Description */}
<div> <div>
@ -232,15 +308,12 @@ export default function YachtDetailPage() {
Описание Описание
</h2> </h2>
<p className="text-base text-[#666666] leading-relaxed"> <p className="text-base text-[#666666] leading-relaxed">
{YACHT.description} {yacht.description}
</p> </p>
</div> </div>
{/* Contact and Requisites */} {/* Contact and Requisites */}
<ContactInfo <ContactInfo {...yacht.owner} />
contactPerson={YACHT.contactPerson}
requisites={YACHT.requisites}
/>
{/* Reviews */} {/* Reviews */}
<div> <div>
@ -260,7 +333,10 @@ export default function YachtDetailPage() {
{/* Right column - Booking Widget (sticky) */} {/* Right column - Booking Widget (sticky) */}
<div className="lg:w-74 flex-shrink-0 lg:sticky lg:top-24 self-start"> <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> </div>
</div> </div>
@ -273,15 +349,16 @@ export default function YachtDetailPage() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<span className="text-lg font-bold text-[#333333]"> <span className="text-lg font-bold text-[#333333]">
{YACHT.price} {yacht.minCost}
</span> </span>
<span className="text-sm text-[#999999] ml-1"> <span className="text-sm text-[#999999] ml-1">
/ час / час
</span> </span>
</div> </div>
<button <button
onClick={() => router.push("/confirm")} onClick={handleBookMobile}
className="bg-[#008299] text-white px-6 py-3 rounded-lg font-bold text-base hover:bg-[#006d7a] transition-colors" 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> </button>

View File

@ -1,6 +1,6 @@
"use client"; "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 { Card, CardContent, CardHeader } from "@/components/ui/card";
import Image from "next/image"; import Image from "next/image";
import Icon from "@/components/ui/icon"; import Icon from "@/components/ui/icon";
@ -18,6 +18,7 @@ import { Button } from "@/components/ui/button";
import useApiClient from "@/hooks/useApiClient"; import useApiClient from "@/hooks/useApiClient";
import { formatMinCost, formatSpeed, formatWidth, getImageUrl } from "@/lib/utils"; import { formatMinCost, formatSpeed, formatWidth, getImageUrl } from "@/lib/utils";
import { useSearchParams, useRouter, usePathname } from "next/navigation"; import { useSearchParams, useRouter, usePathname } from "next/navigation";
import { CatalogFilteredResponseDto } from "@/api/types";
export interface CatalogFilters { export interface CatalogFilters {
search: string; search: string;
@ -49,7 +50,7 @@ export const defaultFilters: CatalogFilters = {
arrivalTime: "13:00", arrivalTime: "13:00",
}; };
export default function CatalogPage() { function CatalogPageContent() {
const [isFiltersOpen, setIsFiltersOpen] = useState(false); const [isFiltersOpen, setIsFiltersOpen] = useState(false);
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const router = useRouter(); const router = useRouter();
@ -203,6 +204,12 @@ export default function CatalogPage() {
params.set("arrivalTime", filters.arrivalTime); params.set("arrivalTime", filters.arrivalTime);
} }
// Сохраняем сортировку, если она установлена
const sortByPrice = searchParams.get("sortByPrice");
if (sortByPrice) {
params.set("sortByPrice", sortByPrice);
}
// Обновляем URL без прокрутки страницы // Обновляем URL без прокрутки страницы
const newUrl = params.toString() const newUrl = params.toString()
? `${pathname}?${params.toString()}` ? `${pathname}?${params.toString()}`
@ -219,11 +226,29 @@ export default function CatalogPage() {
useEffect(() => { useEffect(() => {
(async () => { (async () => {
const params: Record<string, string> = { const allParams: Record<string, string> = {
search: searchParams.get("search") ?? "", 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, params,
}); });
@ -346,7 +371,21 @@ export default function CatalogPage() {
<div className="text-base text-[#999999]"> <div className="text-base text-[#999999]">
Сортировка: Сортировка:
</div> </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 <SelectTrigger
className="w-full" className="w-full"
variant="ghost" variant="ghost"
@ -357,18 +396,12 @@ export default function CatalogPage() {
<SelectItem value="default"> <SelectItem value="default">
По умолчанию По умолчанию
</SelectItem> </SelectItem>
<SelectItem value="price-asc"> <SelectItem value="asc">
Цена: по возрастанию Цена: по возрастанию
</SelectItem> </SelectItem>
<SelectItem value="price-desc"> <SelectItem value="desc">
Цена: по убыванию Цена: по убыванию
</SelectItem> </SelectItem>
<SelectItem value="length-asc">
Длина: по возрастанию
</SelectItem>
<SelectItem value="length-desc">
Длина: по убыванию
</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@ -455,3 +488,11 @@ export default function CatalogPage() {
</main> </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 Image from "next/image";
import Icon from "@/components/ui/icon"; import Icon from "@/components/ui/icon";
import { useState } from "react"; import { useState } from "react";
import { GuestDatePicker } from "@/components/form/guest-date-picker"; import { useRouter } from "next/navigation";
import { formatMinCost, formatWidth, getImageUrl } from "@/lib/utils"; import {
GuestDatePicker,
GuestDatePickerValue,
} from "@/components/form/guest-date-picker";
import {
formatMinCost,
formatWidth,
getImageUrl,
calculateTotalPrice,
formatPrice,
} from "@/lib/utils";
import { format } from "date-fns";
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 [selectedImage, setSelectedImage] = useState(yacht.mainImageUrl);
const [bookingData, setBookingData] = useState<GuestDatePickerValue>({
date: undefined,
departureTime: "12:00",
arrivalTime: "13:00",
adults: 1,
children: 0,
});
const handleThumbnailClick = (imageSrc: string) => { const handleThumbnailClick = (imageSrc: string) => {
setSelectedImage(imageSrc); setSelectedImage(imageSrc);
}; };
// Расчет итоговой стоимости
const getTotalPrice = () => {
if (
!bookingData.date ||
!bookingData.departureTime ||
!bookingData.arrivalTime
) {
return 0;
}
// Форматируем дату в ISO строку для calculateTotalPrice
const dateString = format(bookingData.date, "yyyy-MM-dd");
const { totalPrice } = calculateTotalPrice(
dateString,
bookingData.departureTime,
dateString, // Используем ту же дату для arrival
bookingData.arrivalTime,
yacht.minCost
);
return totalPrice;
};
// Обработчик нажатия на кнопку "Забронировать"
const handleBookClick = () => {
if (!bookingData.date || !yacht.id) {
return;
}
// Формируем 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 ( return (
<div className="mb-10"> <div className="mb-10">
<Card className="overflow-hidden bg-white text-gray-900"> <Card className="overflow-hidden bg-white text-gray-900">
@ -89,15 +157,17 @@ export default function FeaturedYacht({ yacht }: { yacht: CatalogItemDto }) {
<div className="relative"> <div className="relative">
<Image <Image
src={getImageUrl(thumb)} src={getImageUrl(thumb)}
alt={`${yacht.name alt={`${
} view ${idx + 1}`} yacht.name
} view ${idx + 1}`}
width={80} width={80}
height={60} 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 thumb
? "border-[#008299]" ? "border-[#008299]"
: "border-gray-200 hover:border-gray-400" : "border-gray-200 hover:border-gray-400"
}`} }`}
onClick={() => onClick={() =>
handleThumbnailClick( handleThumbnailClick(
thumb thumb
@ -164,13 +234,20 @@ export default function FeaturedYacht({ yacht }: { yacht: CatalogItemDto }) {
{/* Booking form */} {/* Booking form */}
<div className="mb-8"> <div className="mb-8">
<GuestDatePicker /> <GuestDatePicker
value={bookingData}
onChange={setBookingData}
/>
</div> </div>
{/* Book button */} {/* Book button */}
<Button <Button
variant="gradient" variant="gradient"
className="font-bold text-white h-[64px] w-full px-8" className="font-bold text-white h-[64px] w-full px-8"
onClick={handleBookClick}
disabled={
!bookingData.date || !yacht.id
}
> >
Забронировать Забронировать
</Button> </Button>
@ -180,7 +257,9 @@ export default function FeaturedYacht({ yacht }: { yacht: CatalogItemDto }) {
<span className="font-normal"> <span className="font-normal">
Итого: Итого:
</span> </span>
<span>0 </span> <span>
{formatPrice(getTotalPrice())}
</span>
</div> </div>
</div> </div>
</div> </div>

View File

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

View File

@ -8,157 +8,157 @@ import Link from "next/link";
import FeaturedYacht from "./FeaturedYacht"; import FeaturedYacht from "./FeaturedYacht";
import useApiClient from "@/hooks/useApiClient"; import useApiClient from "@/hooks/useApiClient";
import { useEffect, useState } from "react"; 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() { export default function YachtGrid() {
const client = useApiClient(); const client = useApiClient();
const [featuredYacht, setFeaturedYacht] = useState<CatalogItemDto | null>(null); const [featuredYacht, setFeaturedYacht] =
const [yachtCatalog, setYachtCatalog] = useState<CatalogItemDto[] | null>(null); useState<CatalogItemShortDto | null>(null);
const [yachtCatalog, setYachtCatalog] = useState<
CatalogItemShortDto[] | null
>(null);
useEffect(() => { useEffect(() => {
(async () => { (async () => {
const response = await client.get<MainPageCatalogResponseDto>("/catalog/main-page/"); const response = await client.get<MainPageCatalogResponseDto>(
setFeaturedYacht(response.data.featuredYacht); "/catalog/main-page/"
setYachtCatalog(response.data.restYachts); );
})(); setFeaturedYacht(response.data.featuredYacht);
}, []) setYachtCatalog(response.data.restYachts);
})();
}, []);
return ( return (
<section className="text-white"> <section className="text-white">
<div className="container max-w-6xl mx-auto px-4 mt-6 md:mt-12"> <div className="container max-w-6xl mx-auto px-4 mt-6 md:mt-12">
{/* Header Section */} {/* Header Section */}
<div className="mb-6"> <div className="mb-6">
<h1 className="text-3xl md:text-4xl font-bold mb-4 text-black"> <h1 className="text-3xl md:text-4xl font-bold mb-4 text-black">
Яхты и катера в аренду Яхты и катера в аренду
</h1> </h1>
<h2 className="text-l text-black font-bold mb-2"> <h2 className="text-l text-black font-bold mb-2">
Онлайн бронирование яхт и катеров Онлайн бронирование яхт и катеров
</h2> </h2>
<p className="text-gray-700 max-w-3xl leading-relaxed"> <p className="text-gray-700 max-w-3xl leading-relaxed">
Каталог лучших яхт Балаклавы разных ценовых сегментах. Каталог лучших яхт Балаклавы разных ценовых сегментах.
</p> </p>
<p className="text-gray-700 leading-relaxed"> <p className="text-gray-700 leading-relaxed">
Проверенные лодки с лицензией на перевозки, опытные Проверенные лодки с лицензией на перевозки, опытные капитаны.
капитаны. Выбирайте удобную дату, время и бронируйте. Выбирайте удобную дату, время и бронируйте.
</p> </p>
</div> </div>
{/* Featured Yacht Block */} {/* Featured Yacht Block */}
{featuredYacht && ( {featuredYacht && <FeaturedYacht yacht={featuredYacht} />}
<FeaturedYacht yacht={featuredYacht} />
)}
{/* Yacht Grid */} {/* Yacht Grid */}
{yachtCatalog && ( {yachtCatalog && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-6">
{yachtCatalog.map((yacht) => ( {yachtCatalog.map((yacht) => (
<Link <Link
key={yacht.id} key={yacht.id}
href={`/catalog/${yacht.id ?? 0}}`} href={`/catalog/${yacht.id ?? 0}`}
className="block" className="block"
> >
<Card className="overflow-hidden bg-white text-gray-900 cursor-pointer transition-all duration-200 hover:shadow-lg"> <Card className="overflow-hidden bg-white text-gray-900 cursor-pointer transition-all duration-200 hover:shadow-lg">
<CardHeader className="p-0 relative"> <CardHeader className="p-0 relative">
<div className="relative"> <div className="relative">
{/* Best Offer Badge - над карточкой */} {/* Best Offer Badge - над карточкой */}
{yacht.topText && ( {yacht.topText && (
<div className="w-full flex justify-center"> <div className="w-full flex justify-center">
<div <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" 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={{ style={{
backgroundImage: backgroundImage:
"url('/images/best-yacht-bg.jpg')", "url('/images/best-yacht-bg.jpg')",
}} }}
> >
<span> <span>{yacht.topText}</span>
{yacht.topText} </div>
</span> </div>
</div> )}
</div> <Image
)} src={getImageUrl(yacht.mainImageUrl)}
<Image alt={yacht.name}
src={getImageUrl(yacht.mainImageUrl)} width={400}
alt={yacht.name} height={250}
width={400} className="w-full h-48 object-cover"
height={250} />
className="w-full h-48 object-cover" {/* Badge Overlay */}
/> {!yacht.hasQuickRent && !yacht.topText && (
{/* 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">
<div className="absolute top-3 left-3"> <Icon size={16} name="restart" />
<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"> <span>По запросу</span>
<Icon </div>
size={16} </div>
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>
))}
</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"> <div className="flex flex-col justify-between">
<Link href="/catalog"> <div className="w-fit">
<Button {yacht.isBestOffer ? (
size="lg" <p
className="bg-white text-gray-900 hover:bg-gray-100 px-8 py-3 h-[56px] w-[336px] text-lg font-bold" style={{
> background:
Каталог яхт "linear-gradient(90deg, #008299 0%, #7E8FFF 100%)",
</Button> }}
</Link> className="text-l font-bold text-white pl-2 pr-3 rounded-t-[5px] rounded-bl-[10px] rounded-br-[30px] whitespace-nowrap"
</div> >
</div> {formatMinCost(yacht.minCost)} / час
</section> </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 { Button } from "@/components/ui/button";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { useMutation } from "@tanstack/react-query";
import { User, ArrowUpRight, Map, ArrowLeft, Heart } from "lucide-react"; 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 { 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 [promocode, setPromocode] = useState("");
const [isPromocodeApplied, setIsPromocodeApplied] = useState(false);
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
// Извлекаем параметры из URL // Извлекаем параметры из URL
const yachtId = searchParams.get("yachtId"); const yachtId = searchParams.get("yachtId");
const guestCount = searchParams.get("guestCount"); const guestCount = searchParams.get("guests");
const departureDate = searchParams.get("departureDate"); const departureDate = searchParams.get("departureDate");
const departureTime = searchParams.get("departureTime"); const departureTime = searchParams.get("departureTime");
const arrivalDate = searchParams.get("arrivalDate"); const arrivalDate = searchParams.get("arrivalDate");
const arrivalTime = searchParams.get("arrivalTime"); 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) => { const formatDate = (dateString: string | null) => {
if (!dateString) return null; if (!dateString) return null;
try { try {
const date = new Date(dateString); const date = parseISO(dateString);
const months = [ const months = [
"янв", "фев", "мар", "апр", "май", "июн", "янв",
"июл", "авг", "сен", "окт", "ноя", "дек" "фев",
"мар",
"апр",
"май",
"июн",
"июл",
"авг",
"сен",
"окт",
"ноя",
"дек",
]; ];
const day = date.getDate(); const day = date.getDate();
const month = months[date.getMonth()]; const month = months[date.getMonth()];
@ -41,10 +90,20 @@ export default function ConfirmPage() {
const formatDateFull = (dateString: string | null) => { const formatDateFull = (dateString: string | null) => {
if (!dateString) return null; if (!dateString) return null;
try { try {
const date = new Date(dateString); const date = parseISO(dateString);
const months = [ const months = [
"января", "февраля", "марта", "апреля", "мая", "июня", "января",
"июля", "августа", "сентября", "октября", "ноября", "декабря" "февраля",
"марта",
"апреля",
"мая",
"июня",
"июля",
"августа",
"сентября",
"октября",
"ноября",
"декабря",
]; ];
const day = date.getDate(); const day = date.getDate();
const month = months[date.getMonth()]; const month = months[date.getMonth()];
@ -57,8 +116,9 @@ export default function ConfirmPage() {
// Функция для форматирования времени // Функция для форматирования времени
const formatTime = (timeString: string | null) => { const formatTime = (timeString: string | null) => {
if (!timeString) return null; if (!timeString) return null;
// Предполагаем формат HH:mm или HH:mm:ss // Декодируем URL-encoded строку (например, 00%3A00 -> 00:00)
return timeString.split(":").slice(0, 2).join(":"); const decoded = decodeURIComponent(timeString);
return decoded.split(":").slice(0, 2).join(":");
}; };
// Форматируем данные для отображения // Форматируем данные для отображения
@ -72,22 +132,68 @@ export default function ConfirmPage() {
const arrivalDateFormattedFull = formatDateFull(arrivalDate); const arrivalDateFormattedFull = formatDateFull(arrivalDate);
// Формируем строки для отображения // Формируем строки для отображения
const departureDisplay = departureDateFormatted && departureTimeFormatted const departureDisplay =
? `${departureDateFormatted} ${departureTimeFormatted}` departureDateFormatted && departureTimeFormatted
: "Не выбрано"; ? `${departureDateFormatted} ${departureTimeFormatted}`
: "Не выбрано";
const arrivalDisplay = arrivalDateFormatted && arrivalTimeFormatted const arrivalDisplay =
? `${arrivalDateFormatted} ${arrivalTimeFormatted}` arrivalDateFormatted && arrivalTimeFormatted
: "Не выбрано"; ? `${arrivalDateFormatted} ${arrivalTimeFormatted}`
: "Не выбрано";
const datesDisplay = departureDateFormattedFull && departureTimeFormatted && arrivalDateFormattedFull && arrivalTimeFormatted const datesDisplay =
? `${departureDateFormattedFull} в ${departureTimeFormatted}${arrivalDateFormattedFull} в ${arrivalTimeFormatted}` departureDateFormattedFull &&
: "Не выбрано"; departureTimeFormatted &&
arrivalDateFormattedFull &&
arrivalTimeFormatted
? `${departureDateFormattedFull} в ${departureTimeFormatted}${arrivalDateFormattedFull} в ${arrivalTimeFormatted}`
: "Не выбрано";
const guestsDisplay = guestCount 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 ( return (
<main className="bg-[#f4f4f4] grow"> <main className="bg-[#f4f4f4] grow">
{/* Мобильная версия */} {/* Мобильная версия */}
@ -110,11 +216,15 @@ export default function ConfirmPage() {
{/* Центральный блок с информацией */} {/* Центральный блок с информацией */}
<div className="flex-1 min-w-0 text-center"> <div className="flex-1 min-w-0 text-center">
<h2 className="text-base font-bold text-[#333333] mb-1"> <h2 className="text-base font-bold text-[#333333] mb-1">
Яхта Сеньорита Яхта {yacht.name}
</h2> </h2>
<div className="flex justify-center gap-10 text-xs text-[#666666]"> <div className="flex justify-center gap-10 text-xs text-[#666666]">
<span>{departureDateFormatted || "Не выбрано"}</span> <span>
<span>Гостей: {guestCount || "Не выбрано"}</span> {departureDateFormatted || "Не выбрано"}
</span>
<span>
Гостей: {guestCount || "Не выбрано"}
</span>
</div> </div>
</div> </div>
@ -205,28 +315,53 @@ export default function ConfirmPage() {
Детализация цены Детализация цены
</h3> </h3>
<div> <div>
<div className="flex justify-between items-center mb-4"> {totalHours > 0 && yacht.minCost ? (
<span className="text-[#333333]"> <>
26 400 x 2ч <div className="flex justify-between items-center mb-4">
</span> <span className="text-[#333333]">
<span className="text-[#333333]"> {formatPrice(yacht.minCost)} ×{" "}
52 800 {totalHours}ч
</span> </span>
</div> <span className="text-[#333333]">
<div className="flex justify-between items-center mb-4 pb-4 border-b border-[#DFDFDF]"> {formatPrice(totalPrice)}
<span className="text-[#333333]"> </span>
Услуги </div>
</span> <div className="flex justify-between items-center mb-4 pb-4 border-b border-[#DFDFDF]">
<span className="text-[#333333]">0 Р</span> <span className="text-[#333333]">
</div> Услуги
<div className="flex justify-between items-center"> </span>
<span className="text-[#333333]"> <span className="text-[#333333]">
Итого: 0 Р
</span> </span>
<span className="font-bold text-[#333333]"> </div>
52 800 Р {isPromocodeApplied && (
</span> <div className="flex justify-between items-center mb-4">
</div> <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>
</div> </div>
@ -237,13 +372,20 @@ export default function ConfirmPage() {
type="text" type="text"
placeholder="Промокод" placeholder="Промокод"
value={promocode} value={promocode}
onChange={(e) => onChange={(e) => {
setPromocode(e.target.value) 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" 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 <Button
variant="default" 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" 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} /> <ArrowUpRight size={14} />
@ -255,6 +397,8 @@ export default function ConfirmPage() {
<Button <Button
variant="default" variant="default"
className="w-full h-[56px] bg-[#2D908D] hover:bg-[#007088] text-white font-bold rounded-full transition-colors duration-200" 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> </Button>
@ -286,7 +430,9 @@ export default function ConfirmPage() {
{/* Изображение яхты */} {/* Изображение яхты */}
<div className="relative mb-5"> <div className="relative mb-5">
<Image <Image
src="/images/yachts/yacht1.jpg" src={getImageUrl(
yacht.mainImageUrl
)}
alt="Яхта" alt="Яхта"
width={400} width={400}
height={250} height={250}
@ -304,7 +450,7 @@ export default function ConfirmPage() {
Владелец Владелец
</span> </span>
<span className="text-[#333333] font-bold"> <span className="text-[#333333] font-bold">
Денис {yacht.owner.firstName}
</span> </span>
</div> </div>
</div> </div>
@ -312,7 +458,7 @@ export default function ConfirmPage() {
</div> </div>
{/* Название яхты */} {/* Название яхты */}
<h3 className="text-base text-[#333333] pb-3 border-b border-[#DFDFDF] mb-4"> <h3 className="text-base text-[#333333] pb-3 border-b border-[#DFDFDF] mb-4">
Яхта Яхта {yacht.name}
</h3> </h3>
{/* Детализация цены */} {/* Детализация цены */}
@ -321,30 +467,64 @@ export default function ConfirmPage() {
Детализация цены Детализация цены
</h4> </h4>
<div> <div>
<div className="flex justify-between items-center mb-4"> {totalHours > 0 && yacht.minCost ? (
<span className="text-[#333333]"> <>
26 400 x 2ч <div className="flex justify-between items-center mb-4">
</span> <span className="text-[#333333]">
<span className="text-[#333333]"> {formatPrice(
52 800 yacht.minCost
</span> )}
</div> × {totalHours}ч
<div className="flex justify-between items-center border-b border-[#DFDFDF] pb-4 mb-4"> </span>
<span className="text-[#333333]"> <span className="text-[#333333]">
Услуги {formatPrice(
</span> totalPrice
<span className="text-[#333333]"> )}{" "}
0 Р
</span> </span>
</div> </div>
<div className="flex justify-between items-center"> <div className="flex justify-between items-center border-b border-[#DFDFDF] pb-4 mb-4">
<span className="text-[#333333]"> <span className="text-[#333333]">
Итого: Услуги
</span> </span>
<span className="text-[#333333] font-bold"> <span className="text-[#333333]">
52 800 Р 0 Р
</span> </span>
</div> </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> </div>
</div> </div>
@ -358,13 +538,20 @@ export default function ConfirmPage() {
type="text" type="text"
placeholder="Промокод" placeholder="Промокод"
value={promocode} value={promocode}
onChange={(e) => onChange={(e) => {
setPromocode(e.target.value) 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" 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 <Button
variant="default" 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" 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} /> <ArrowUpRight size={12} />
@ -385,7 +572,7 @@ export default function ConfirmPage() {
Ваше бронирование Ваше бронирование
</h2> </h2>
{/* Сведения о бронировании */} {/* Сведения о бронирования */}
<div className="grid grid-cols-2 gap-4 mb-4"> <div className="grid grid-cols-2 gap-4 mb-4">
{/* Даты */} {/* Даты */}
<div className="grow-1 border border-[#DFDFDF] rounded-[8px] p-4"> <div className="grow-1 border border-[#DFDFDF] rounded-[8px] p-4">
@ -445,6 +632,8 @@ export default function ConfirmPage() {
variant="default" variant="default"
size="lg" 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" 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> </Button>
@ -457,3 +646,17 @@ export default function ConfirmPage() {
</main> </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 { Calendar } from "@/components/ui/calendar";
import { Counter } from "@/components/ui/counter"; import { Counter } from "@/components/ui/counter";
import { import {
Popover, Popover,
PopoverContent, PopoverContent,
PopoverTrigger, PopoverTrigger,
} from "@/components/ui/popover"; } from "@/components/ui/popover";
export interface GuestDatePickerValue {
date: Date | undefined;
departureTime: string;
arrivalTime: string;
adults: number;
children: number;
}
interface GuestDatePickerProps { interface GuestDatePickerProps {
onApply?: (data: { value?: GuestDatePickerValue;
date: Date | undefined; onChange?: (value: GuestDatePickerValue) => void;
departureTime: string; onApply?: (data: GuestDatePickerValue) => void;
arrivalTime: string; className?: string;
adults: number;
children: number;
}) => void;
className?: string;
} }
interface CommonPopoverContentProps { interface CommonPopoverContentProps {
date: Date | undefined; date: Date | undefined;
setDate: (date: Date | undefined) => void; setDate: (date: Date | undefined) => void;
departureTime: string; departureTime: string;
setDepartureTime: (time: string) => void; setDepartureTime: (time: string) => void;
arrivalTime: string; arrivalTime: string;
setArrivalTime: (time: string) => void; setArrivalTime: (time: string) => void;
adults: number; adults: number;
setAdults: (count: number) => void; setAdults: (count: number) => void;
childrenCount: number; childrenCount: number;
setChildrenCount: (count: number) => void; setChildrenCount: (count: number) => void;
handleApply: () => void; handleApply: () => void;
} }
const CommonPopoverContent: React.FC<CommonPopoverContentProps> = ({ const CommonPopoverContent: React.FC<CommonPopoverContentProps> = ({
date, date,
setDate, setDate,
departureTime, departureTime,
setDepartureTime, setDepartureTime,
arrivalTime, arrivalTime,
setArrivalTime, setArrivalTime,
adults, adults,
setAdults, setAdults,
childrenCount, childrenCount,
setChildrenCount, setChildrenCount,
handleApply, handleApply,
}) => { }) => {
return ( return (
<PopoverContent className="rounded-[20px] p-6 pb-4 w-[324px]"> <PopoverContent className="rounded-[20px] p-6 pb-4 w-[324px]">
{/* Календарь */} {/* Календарь */}
<Calendar <Calendar
mode="single" mode="single"
selected={date} selected={date}
onSelect={setDate} onSelect={setDate}
className="mb-[24px]" className="mb-[24px]"
locale={ru} locale={ru}
disabled={(date) => disabled={(date) => date < new Date(new Date().setHours(0, 0, 0, 0))}
date < new Date(new Date().setHours(0, 0, 0, 0)) classNames={{
} root: "w-full",
classNames={{ month: "flex w-full flex-col gap-4",
root: "w-full", button_previous:
month: "flex w-full flex-col gap-4", "h-8 w-8 flex items-center justify-center hover:bg-gray-100 rounded-md",
button_previous: button_next:
"h-8 w-8 flex items-center justify-center hover:bg-gray-100 rounded-md", "h-8 w-8 flex items-center justify-center hover:bg-gray-100 rounded-md",
button_next: month_caption:
"h-8 w-8 flex items-center justify-center hover:bg-gray-100 rounded-md", "flex h-8 w-full items-center justify-center px-8 text-gray-700 font-semibold",
month_caption: table: "w-full border-collapse",
"flex h-8 w-full items-center justify-center px-8 text-gray-700 font-semibold", weekdays: "flex",
table: "w-full border-collapse", weekday: "flex-1 text-gray-500 text-xs font-normal p-2 text-center",
weekdays: "flex", day_button: "font-bold ring-0 focus:ring-0",
weekday: week: "mt-2 flex w-full",
"flex-1 text-gray-500 text-xs font-normal p-2 text-center", today: "bg-gray-100 text-gray-900 rounded-full",
day_button: "font-bold ring-0 focus:ring-0", outside: "text-gray-300",
week: "mt-2 flex w-full", disabled: "text-gray-400 cursor-not-allowed",
today: "bg-gray-100 text-gray-900 rounded-full", selected:
outside: "text-gray-300", "rounded-full border-none outline-none !bg-brand text-white",
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"> <Button
<Counter onClick={handleApply}
label="Взрослые" variant="gradient"
value={adults} className="font-bold text-white h-[44px] w-full px-8"
onChange={setAdults} >
min={0} Применить
max={10} </Button>
/> </PopoverContent>
<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>
);
}; };
export const GuestDatePicker: React.FC<GuestDatePickerProps> = ({ export const GuestDatePicker: React.FC<GuestDatePickerProps> = ({
onApply, value,
className, onChange,
onApply,
className,
}) => { }) => {
const [date, setDate] = useState<Date>(); // Используем controlled значения, если они переданы, иначе используем внутреннее состояние
const [departureTime, setDepartureTime] = useState("12:00"); const isControlled = value !== undefined;
const [arrivalTime, setArrivalTime] = useState("13:00");
const [adults, setAdults] = useState(1);
const [children, setChildren] = useState(0);
const [isDepartureOpen, setIsDepartureOpen] = useState(false);
const [isArrivalOpen, setIsArrivalOpen] = useState(false);
const [isGuestOpen, setIsGuestOpen] = useState(false);
const handleApply = () => { const [internalDate, setInternalDate] = useState<Date>();
onApply?.({ const [internalDepartureTime, setInternalDepartureTime] = useState("12:00");
date, const [internalArrivalTime, setInternalArrivalTime] = useState("13:00");
departureTime, const [internalAdults, setInternalAdults] = useState(1);
arrivalTime, const [internalChildren, setInternalChildren] = useState(0);
adults,
children, const date = isControlled ? value.date : internalDate;
}); const departureTime = isControlled ? value.departureTime : internalDepartureTime;
setIsDepartureOpen(false); const arrivalTime = isControlled ? value.arrivalTime : internalArrivalTime;
setIsArrivalOpen(false); const adults = isControlled ? value.adults : internalAdults;
setIsGuestOpen(false); 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 = () => { const getDepartureDisplayText = () => {
if (!date || !departureTime) return "Выход"; 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>
);
};
return ( return (
<div className={className}> <>
<div className="space-y-5"> {format(date, "d MMMM", {
{/* Кнопка Выход */} locale: ru,
<Popover })}
open={isDepartureOpen} , <span className="font-bold">{departureTime}</span>
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>
); );
};
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 Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { User, Menu } from "lucide-react"; import { User, Menu } from "lucide-react";
import { useRouter } from "next/navigation";
import AuthDialog from "@/components/layout/AuthDialog"; import AuthDialog from "@/components/layout/AuthDialog";
import useAuthPopup from "@/stores/useAuthPopup"; import useAuthPopup from "@/stores/useAuthPopup";
import useAuthStore from "@/stores/useAuthStore";
export default function Header() { export default function Header() {
const authPopup = useAuthPopup(); 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 ( return (
<header className="sticky top-0 z-50 bg-white/95 backdrop-blur-sm border-b border-gray-100"> <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 <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={authPopup.open} onClick={handleProfileClick}
className="text-gray-700 w-[100px] h-[48px] border" className="text-gray-700 w-[100px] h-[48px] border"
> >
<Menu className="!h-[24px] !w-[24px]" /> <Menu className="!h-[24px] !w-[24px]" />

View File

@ -24,6 +24,8 @@ interface DatePickerProps {
onDateChange?: (date: Date | undefined) => void; onDateChange?: (date: Date | undefined) => void;
onDepartureTimeChange?: (time: string) => void; onDepartureTimeChange?: (time: string) => void;
onArrivalTimeChange?: (time: string) => void; onArrivalTimeChange?: (time: string) => void;
onlyDeparture?: boolean;
onlyArrival?: boolean;
} }
export function DatePicker({ export function DatePicker({
@ -36,21 +38,37 @@ export function DatePicker({
onDateChange, onDateChange,
onDepartureTimeChange, onDepartureTimeChange,
onArrivalTimeChange, onArrivalTimeChange,
onlyDeparture,
onlyArrival,
}: DatePickerProps) { }: DatePickerProps) {
const [internalDate, setInternalDate] = React.useState<Date>(); const [internalDate, setInternalDate] = React.useState<Date>();
const [internalDepartureTime, setInternalDepartureTime] = React.useState("12:00"); const [internalDepartureTime, setInternalDepartureTime] =
const [internalArrivalTime, setInternalArrivalTime] = React.useState("13:00"); React.useState("12:00");
const [internalArrivalTime, setInternalArrivalTime] =
React.useState("13:00");
const [open, setOpen] = React.useState(false); const [open, setOpen] = React.useState(false);
// Определяем, является ли компонент контролируемым
const isControlled =
value !== undefined ||
externalDepartureTime !== undefined ||
externalArrivalTime !== undefined;
// Используем внешние значения, если они предоставлены, иначе внутренние // Используем внешние значения, если они предоставлены, иначе внутренние
const date = value !== undefined ? (value || undefined) : internalDate; const date = value !== undefined ? value || undefined : internalDate;
const departureTime = externalDepartureTime !== undefined ? externalDepartureTime : internalDepartureTime; const departureTime =
const arrivalTime = externalArrivalTime !== undefined ? externalArrivalTime : internalArrivalTime; externalDepartureTime !== undefined
? externalDepartureTime
: internalDepartureTime;
const arrivalTime =
externalArrivalTime !== undefined
? externalArrivalTime
: internalArrivalTime;
const handleDateChange = (newDate: Date | undefined) => { const handleDateChange = (newDate: Date | undefined) => {
if (onDateChange) { if (onDateChange) {
onDateChange(newDate); onDateChange(newDate);
} else { } else if (!isControlled) {
setInternalDate(newDate); setInternalDate(newDate);
} }
}; };
@ -58,7 +76,7 @@ export function DatePicker({
const handleDepartureTimeChange = (time: string) => { const handleDepartureTimeChange = (time: string) => {
if (onDepartureTimeChange) { if (onDepartureTimeChange) {
onDepartureTimeChange(time); onDepartureTimeChange(time);
} else { } else if (!isControlled) {
setInternalDepartureTime(time); setInternalDepartureTime(time);
} }
}; };
@ -66,7 +84,7 @@ export function DatePicker({
const handleArrivalTimeChange = (time: string) => { const handleArrivalTimeChange = (time: string) => {
if (onArrivalTimeChange) { if (onArrivalTimeChange) {
onArrivalTimeChange(time); onArrivalTimeChange(time);
} else { } else if (!isControlled) {
setInternalArrivalTime(time); setInternalArrivalTime(time);
} }
}; };
@ -94,11 +112,19 @@ export function DatePicker({
/> />
)} )}
{date ? ( {date ? (
format( (() => {
date, let timeFormat = "";
`d MMMM, ${departureTime} - ${arrivalTime}`, if (onlyDeparture) {
{ locale: ru } 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> <span>{placeholder}</span>
)} )}
@ -147,39 +173,47 @@ export function DatePicker({
{/* Поля времени */} {/* Поля времени */}
<div className="flex gap-3 mb-4"> <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"> {onlyDeparture && (
<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"> <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"> </label>
<ChevronDownIcon className="absolute right-0 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-500 pointer-events-none" /> <div className="relative h-full flex align-center">
<input <ChevronDownIcon className="absolute right-0 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-500 pointer-events-none" />
type="time" <input
value={departureTime} type="time"
onChange={(e) => value={departureTime}
handleDepartureTimeChange(e.target.value) onChange={(e) =>
} handleDepartureTimeChange(
className="w-full focus:outline-none focus:border-transparent" e.target.value
/> )
}
className="w-full focus:outline-none focus:border-transparent"
/>
</div>
</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"> {onlyArrival && (
<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"> <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"> </label>
<ChevronDownIcon className="absolute right-0 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-500 pointer-events-none" /> <div className="relative h-full flex align-center">
<input <ChevronDownIcon className="absolute right-0 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-500 pointer-events-none" />
type="time" <input
value={arrivalTime} type="time"
onChange={(e) => value={arrivalTime}
handleArrivalTimeChange(e.target.value) onChange={(e) =>
} handleArrivalTimeChange(
className="w-full focus:outline-none focus:border-transparent" e.target.value
/> )
}
className="w-full focus:outline-none focus:border-transparent"
/>
</div>
</div> </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"; import useAuthPopup from "@/stores/useAuthPopup";
const useApiClient = () => { const useApiClient = () => {
const { getToken } = useAuthStore(); const { getToken } = useAuthStore();
const authPopup = useAuthPopup(); const authPopup = useAuthPopup();
const apiClient = axios.create({ const apiClient = axios.create({
baseURL: "http://192.168.1.5:4000/", baseURL: "https://api.travelmarine.ru",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
}); });
apiClient.interceptors.request.use( apiClient.interceptors.request.use(
(config) => { (config) => {
const token = getToken(); const token = getToken();
if (token) { if (token) {
config.headers.Authorization = `Token ${token}`; config.headers.Authorization = `Token ${token}`;
} }
return config; return config;
}, },
(error) => { (error) => {
return Promise.reject(error); return Promise.reject(error);
} }
); );
apiClient.interceptors.response.use( apiClient.interceptors.response.use(
(response) => response, (response) => response,
(error) => { (error) => {
if (error.response?.status === 401) { if (error.response?.status === 401) {
console.error("Authentication error:", error); console.error("Authentication error:", error);
authPopup.open(); authPopup.open();
} }
return Promise.reject(error); return Promise.reject(error);
} }
); );
return apiClient; return apiClient;
}; };
export default useApiClient; export default useApiClient;

View File

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

View File

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