This commit is contained in:
Иван 2025-12-19 14:22:38 +03:00
parent 089f5064a3
commit da29133989
16 changed files with 631 additions and 471 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

2
package-lock.json generated
View File

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

View File

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

View File

@ -1,77 +1,86 @@
interface CatalogItemShortDto {
id?: number;
name: string;
length: number;
speed: number;
minCost: number;
mainImageUrl: string;
galleryUrls: string[];
hasQuickRent: boolean;
isFeatured: boolean;
topText?: string;
isBestOffer?: boolean;
export interface CatalogItemShortDto {
id?: number;
name: string;
length: number;
speed: number;
minCost: number;
mainImageUrl: string;
galleryUrls: string[];
hasQuickRent: boolean;
isFeatured: boolean;
topText?: string;
isBestOffer?: boolean;
}
interface MainPageCatalogResponseDto {
featuredYacht: CatalogItemShortDto;
restYachts: CatalogItemShortDto[];
export interface MainPageCatalogResponseDto {
featuredYacht: CatalogItemShortDto;
restYachts: CatalogItemShortDto[];
}
interface CatalogFilteredResponseDto {
items: CatalogItemShortDto[];
total: number;
export interface CatalogFilteredResponseDto {
items: CatalogItemShortDto[];
total: number;
}
interface Reservation {
id: number;
yachtId: number;
reservatorId: number;
startUtc: number;
endUtc: number;
id: number;
yachtId: number;
reservatorId: number;
startUtc: number;
endUtc: number;
}
interface Review {
id: number;
reviewerId: number;
yachtId: number;
starsCount: number;
description: string;
id: number;
reviewerId: number;
yachtId: number;
starsCount: number;
description: string;
}
interface User {
userId?: number;
firstName?: string;
lastName?: string;
phone?: string;
email?: string;
password?: string;
yachts?: Yacht[];
companyName?: string;
inn?: number;
ogrn?: number;
export interface User {
userId?: number;
firstName?: string;
lastName?: string;
phone?: string;
email?: string;
password?: string;
yachts?: Yacht[];
companyName?: string;
inn?: number;
ogrn?: number;
}
interface CatalogItemLongDto extends CatalogItemShortDto {
year: number;
comfortCapacity: number;
maxCapacity: number;
width: number;
cabinsCount: number;
matherial: string;
power: number;
description: string;
owner: User;
reviews: Review[];
reservations: Reservation[];
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;
yachtId: number;
name: string;
model: string;
year: number;
length: number;
userId: number;
createdAt: Date;
updatedAt: Date;
}
export interface ReservationDto {
yachtId: number;
reservatorId: number;
startUtc: number;
endUtc: number;
id: number;
yacht: CatalogItemLongDto;
}

View File

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

View File

@ -7,6 +7,7 @@ import { DatePicker } from "@/components/ui/date-picker";
import { GuestPicker } from "@/components/form/guest-picker";
import { format } from "date-fns";
import { calculateTotalPrice, formatPrice } from "@/lib/utils";
import { CatalogItemLongDto } from "@/api/types";
interface BookingWidgetProps {
price: string;

View File

@ -1,6 +1,7 @@
"use client";
import Image from "next/image";
import { User } from "@/api/types";
export function ContactInfo({ firstName, companyName, inn, ogrn }: User) {
return (

View File

@ -1,5 +1,7 @@
"use client";
import { CatalogItemLongDto } from "@/api/types";
interface YachtCharacteristicsProps {
yacht: CatalogItemLongDto;
}

View File

@ -14,6 +14,7 @@ import { GuestPicker } from "@/components/form/guest-picker";
import useApiClient from "@/hooks/useApiClient";
import { formatSpeed } from "@/lib/utils";
import { format } from "date-fns";
import { CatalogItemLongDto } from "@/api/types";
export default function YachtDetailPage() {
const { id } = useParams();

View File

@ -18,6 +18,7 @@ import { Button } from "@/components/ui/button";
import useApiClient from "@/hooks/useApiClient";
import { formatMinCost, formatSpeed, formatWidth, getImageUrl } from "@/lib/utils";
import { useSearchParams, useRouter, usePathname } from "next/navigation";
import { CatalogFilteredResponseDto } from "@/api/types";
export interface CatalogFilters {
search: string;

View File

@ -3,247 +3,270 @@
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import {
Carousel,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious,
Carousel,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious,
} from "@/components/ui/carousel";
import Image from "next/image";
import Icon from "@/components/ui/icon";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { GuestDatePicker, GuestDatePickerValue } from "@/components/form/guest-date-picker";
import { formatMinCost, formatWidth, getImageUrl, calculateTotalPrice, formatPrice } 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,
}: {
yacht: CatalogItemShortDto;
yacht: CatalogItemShortDto;
}) {
const router = useRouter();
const [selectedImage, setSelectedImage] = useState(yacht.mainImageUrl);
const [bookingData, setBookingData] = useState<GuestDatePickerValue>({
date: undefined,
departureTime: "12:00",
arrivalTime: "13:00",
adults: 1,
children: 0,
});
const handleThumbnailClick = (imageSrc: string) => {
setSelectedImage(imageSrc);
};
// Расчет итоговой стоимости
const getTotalPrice = () => {
if (!bookingData.date || !bookingData.departureTime || !bookingData.arrivalTime) {
return 0;
}
// Форматируем дату в ISO строку для calculateTotalPrice
const dateString = format(bookingData.date, "yyyy-MM-dd");
const { totalPrice } = calculateTotalPrice(
dateString,
bookingData.departureTime,
dateString, // Используем ту же дату для arrival
bookingData.arrivalTime,
yacht.minCost
);
return totalPrice;
};
// Обработчик нажатия на кнопку "Забронировать"
const handleBookClick = () => {
if (!bookingData.date || !yacht.id) {
return;
}
// Форматируем дату в формат yyyy-MM-dd
const dateString = format(bookingData.date, "yyyy-MM-dd");
// Кодируем время для URL (00:00 -> 00%3A00)
const encodedDepartureTime = encodeURIComponent(bookingData.departureTime);
const encodedArrivalTime = encodeURIComponent(bookingData.arrivalTime);
// Вычисляем общее количество гостей
const totalGuests = bookingData.adults + bookingData.children;
// Формируем URL с параметрами
const params = new URLSearchParams({
yachtId: yacht.id.toString(),
departureDate: dateString,
departureTime: encodedDepartureTime,
arrivalDate: dateString, // Используем ту же дату для arrival
arrivalTime: encodedArrivalTime,
guests: totalGuests.toString(),
const router = useRouter();
const [selectedImage, setSelectedImage] = useState(yacht.mainImageUrl);
const [bookingData, setBookingData] = useState<GuestDatePickerValue>({
date: undefined,
departureTime: "12:00",
arrivalTime: "13:00",
adults: 1,
children: 0,
});
// Переходим на страницу подтверждения
router.push(`/confirm?${params.toString()}`);
};
return (
<div className="mb-10">
<Card className="overflow-hidden bg-white text-gray-900">
<CardContent className="p-0">
<div className="flex flex-col lg:flex-row gap-11 px-6 py-10">
{/* Left side - Yacht details and images */}
<div className="flex-1">
{/* Promoted banner - Mobile only */}
<div
className="text-white flex items-center justify-center py-2 rounded-full text-center mb-6 relative lg:hidden"
style={{
backgroundImage: "url(/images/badge-bg.jpg)",
backgroundSize: "cover",
backgroundPosition: "center",
backgroundRepeat: "no-repeat",
}}
>
<span className="text-xs font-medium relative z-10">
Заметнее других бронируют быстрее
</span>
</div>
const handleThumbnailClick = (imageSrc: string) => {
setSelectedImage(imageSrc);
};
{/* Header with yacht name and length */}
<div className="flex items-center justify-between mb-6">
<h2 className="text-3xl font-bold">{yacht.name}</h2>
<div className="flex items-center gap-2 text-gray-600">
<Icon size={16} name="width" />
<span className="text-lg">{formatWidth(yacht.length)}</span>
</div>
</div>
// Расчет итоговой стоимости
const getTotalPrice = () => {
if (
!bookingData.date ||
!bookingData.departureTime ||
!bookingData.arrivalTime
) {
return 0;
}
{/* Main yacht image */}
<div className="relative mb-6">
<Image
src={getImageUrl(selectedImage)}
alt={yacht.name}
width={600}
height={400}
className="w-full h-80 object-cover rounded-[24px]"
unoptimized
/>
</div>
// Форматируем дату в ISO строку для calculateTotalPrice
const dateString = format(bookingData.date, "yyyy-MM-dd");
{/* Thumbnail images carousel */}
<div className="relative mb-6">
<Carousel
opts={{
align: "start",
loop: false,
slidesToScroll: 2,
}}
className="w-full"
>
<CarouselContent className="-ml-2 md:-ml-4">
{yacht.galleryUrls.map((thumb, idx) => (
<CarouselItem
key={idx}
className="pl-2 md:pl-4 basis-auto"
>
<div className="relative">
<Image
src={getImageUrl(thumb)}
alt={`${yacht.name} view ${idx + 1}`}
width={80}
height={60}
className={`w-20 h-16 object-cover rounded-[8px] cursor-pointer border-2 transition-all ${
selectedImage === thumb
? "border-[#008299]"
: "border-gray-200 hover:border-gray-400"
}`}
onClick={() => handleThumbnailClick(thumb)}
unoptimized
/>
const { totalPrice } = calculateTotalPrice(
dateString,
bookingData.departureTime,
dateString, // Используем ту же дату для arrival
bookingData.arrivalTime,
yacht.minCost
);
return totalPrice;
};
// Обработчик нажатия на кнопку "Забронировать"
const handleBookClick = () => {
if (!bookingData.date || !yacht.id) {
return;
}
// Формируем URL с параметрами
const params = new URLSearchParams({
yachtId: yacht.id.toString(),
departureDate: format(bookingData.date, "yyyy-MM-dd"),
departureTime: bookingData.departureTime,
arrivalDate: format(bookingData.date, "yyyy-MM-dd"),
arrivalTime: bookingData.arrivalTime,
guests: (bookingData.adults + bookingData.children).toString(),
});
// Переходим на страницу подтверждения
router.push(`/confirm?${params.toString()}`);
};
return (
<div className="mb-10">
<Card className="overflow-hidden bg-white text-gray-900">
<CardContent className="p-0">
<div className="flex flex-col lg:flex-row gap-11 px-6 py-10">
{/* Left side - Yacht details and images */}
<div className="flex-1">
{/* Promoted banner - Mobile only */}
<div
className="text-white flex items-center justify-center py-2 rounded-full text-center mb-6 relative lg:hidden"
style={{
backgroundImage:
"url(/images/badge-bg.jpg)",
backgroundSize: "cover",
backgroundPosition: "center",
backgroundRepeat: "no-repeat",
}}
>
<span className="text-xs font-medium relative z-10">
Заметнее других бронируют быстрее
</span>
</div>
{/* Header with yacht name and length */}
<div className="flex items-center justify-between mb-6">
<h2 className="text-3xl font-bold">
{yacht.name}
</h2>
<div className="flex items-center gap-2 text-gray-600">
<Icon size={16} name="width" />
<span className="text-lg">
{formatWidth(yacht.length)}
</span>
</div>
</div>
{/* Main yacht image */}
<div className="relative mb-6">
<Image
src={getImageUrl(selectedImage)}
alt={yacht.name}
width={600}
height={400}
className="w-full h-80 object-cover rounded-[24px]"
unoptimized
/>
</div>
{/* Thumbnail images carousel */}
<div className="relative mb-6">
<Carousel
opts={{
align: "start",
loop: false,
slidesToScroll: 2,
}}
className="w-full"
>
<CarouselContent className="-ml-2 md:-ml-4">
{yacht.galleryUrls.map((thumb, idx) => (
<CarouselItem
key={idx}
className="pl-2 md:pl-4 basis-auto"
>
<div className="relative">
<Image
src={getImageUrl(thumb)}
alt={`${
yacht.name
} view ${idx + 1}`}
width={80}
height={60}
className={`w-20 h-16 object-cover rounded-[8px] cursor-pointer border-2 transition-all ${
selectedImage ===
thumb
? "border-[#008299]"
: "border-gray-200 hover:border-gray-400"
}`}
onClick={() =>
handleThumbnailClick(
thumb
)
}
unoptimized
/>
</div>
</CarouselItem>
))}
</CarouselContent>
<CarouselPrevious className="absolute left-2 top-1/2 -translate-y-1/2 bg-white hover:bg-gray-50 shadow-lg z-10" />
<CarouselNext className="absolute right-2 top-1/2 -translate-y-1/2 bg-white hover:bg-gray-50 shadow-lg z-10" />
</Carousel>
</div>
{/* Promoted badge */}
{yacht.isFeatured && (
<div className="flex items-center gap-2 text-sm text-gray-400">
<Icon
className="min-w-[21px] min-h-[21px]"
size={21}
name="ad"
/>
<span>
Это объявление продвигается.{" "}
<span className="underline cursor-pointer">
Хотите так же?
</span>
</span>
</div>
)}
</div>
</CarouselItem>
))}
</CarouselContent>
<CarouselPrevious className="absolute left-2 top-1/2 -translate-y-1/2 bg-white hover:bg-gray-50 shadow-lg z-10" />
<CarouselNext className="absolute right-2 top-1/2 -translate-y-1/2 bg-white hover:bg-gray-50 shadow-lg z-10" />
</Carousel>
</div>
{/* Promoted badge */}
{yacht.isFeatured && (
<div className="flex items-center gap-2 text-sm text-gray-400">
<Icon
className="min-w-[21px] min-h-[21px]"
size={21}
name="ad"
/>
<span>
Это объявление продвигается.{" "}
<span className="underline cursor-pointer">
Хотите так же?
</span>
</span>
</div>
)}
</div>
{/* Right side - Booking form */}
<div className="min-w-[296px] flex-0 flex flex-col justify-between">
<div>
{/* Promoted banner - Desktop only */}
<div
className="text-white flex items-center justify-center py-2 rounded-full text-center mb-6 relative hidden lg:flex"
style={{
backgroundImage:
"url(/images/badge-bg.jpg)",
backgroundSize: "cover",
backgroundPosition: "center",
backgroundRepeat: "no-repeat",
}}
>
<span className="text-xs font-medium relative z-10">
Заметнее других бронируют быстрее
</span>
</div>
{/* Right side - Booking form */}
<div className="min-w-[296px] flex-0 flex flex-col justify-between">
<div>
{/* Promoted banner - Desktop only */}
<div
className="text-white flex items-center justify-center py-2 rounded-full text-center mb-6 relative hidden lg:flex"
style={{
backgroundImage: "url(/images/badge-bg.jpg)",
backgroundSize: "cover",
backgroundPosition: "center",
backgroundRepeat: "no-repeat",
}}
>
<span className="text-xs font-medium relative z-10">
Заметнее других бронируют быстрее
</span>
</div>
<div className="border rounded-[16px] p-6 pb-8 border-gray-200 pt-6">
{/* Price */}
<div className="mb-6">
<p className="text-3xl font-bold whitespace-nowrap">
{formatMinCost(yacht.minCost)}
<span className="text-sm font-normal text-gray-500">
/ час
</span>
</p>
</div>
<div className="border rounded-[16px] p-6 pb-8 border-gray-200 pt-6">
{/* Price */}
<div className="mb-6">
<p className="text-3xl font-bold whitespace-nowrap">
{formatMinCost(yacht.minCost)}
<span className="text-sm font-normal text-gray-500">
/ час
</span>
</p>
</div>
{/* Booking form */}
<div className="mb-8">
<GuestDatePicker
value={bookingData}
onChange={setBookingData}
/>
</div>
{/* Booking form */}
<div className="mb-8">
<GuestDatePicker
value={bookingData}
onChange={setBookingData}
/>
</div>
{/* Book button */}
<Button
variant="gradient"
className="font-bold text-white h-[64px] w-full px-8"
onClick={handleBookClick}
disabled={
!bookingData.date || !yacht.id
}
>
Забронировать
</Button>
{/* Book button */}
<Button
variant="gradient"
className="font-bold text-white h-[64px] w-full px-8"
onClick={handleBookClick}
disabled={!bookingData.date || !yacht.id}
>
Забронировать
</Button>
{/* Total price */}
<div className="flex justify-between items-center text-l mt-6 font-bold text-gray-800">
<span className="font-normal">Итого:</span>
<span>{formatPrice(getTotalPrice())} </span>
</div>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
);
{/* Total price */}
<div className="flex justify-between items-center text-l mt-6 font-bold text-gray-800">
<span className="font-normal">
Итого:
</span>
<span>
{formatPrice(getTotalPrice())}
</span>
</div>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@ -14,6 +14,7 @@ import {
formatWidth,
getImageUrl,
} from "@/lib/utils";
import { CatalogItemShortDto, MainPageCatalogResponseDto } from "@/api/types";
export default function YachtGrid() {
const client = useApiClient();

View File

@ -3,12 +3,14 @@
import { Button } from "@/components/ui/button";
import Image from "next/image";
import Link from "next/link";
import { useMutation } from "@tanstack/react-query";
import { User, ArrowUpRight, Map, ArrowLeft, Heart } from "lucide-react";
import { useEffect, useState, Suspense } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import useApiClient from "@/hooks/useApiClient";
import { getImageUrl, formatPrice, calculateTotalPrice } from "@/lib/utils";
import { parseISO } from "date-fns";
import { CatalogItemLongDto } from "@/api/types";
function ConfirmPageContent() {
const [yacht, setYacht] = useState<CatalogItemLongDto | null>(null);
@ -154,6 +156,40 @@ function ConfirmPageContent() {
: `${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 />;
}
@ -304,7 +340,11 @@ function ConfirmPageContent() {
Скидка (DISCOUNT50):
</span>
<span className="text-[#2D908D] font-bold">
-{formatPrice(totalPrice * 0.5)} Р
-
{formatPrice(
totalPrice * 0.5
)}{" "}
Р
</span>
</div>
)}
@ -358,7 +398,7 @@ function ConfirmPageContent() {
variant="default"
className="w-full h-[56px] bg-[#2D908D] hover:bg-[#007088] text-white font-bold rounded-full transition-colors duration-200"
disabled={totalHours === 0}
onClick={() => router.push("/profile/reservations")}
onClick={() => mutate()}
>
Отправить заявку
</Button>
@ -454,10 +494,12 @@ function ConfirmPageContent() {
{isPromocodeApplied && (
<div className="flex justify-between items-center mb-4">
<span className="text-[#333333]">
Скидка (DISCOUNT50):
Скидка
(DISCOUNT50):
</span>
<span className="text-[#2D908D] font-bold">
-{formatPrice(
-
{formatPrice(
totalPrice *
0.5
)}{" "}
@ -591,7 +633,7 @@ function ConfirmPageContent() {
size="lg"
className="flex-shrink-0 h-[64px] w-[270px] bg-[#2D908D] hover:bg-[#007088] text-white font-bold rounded-full p-0 transition-colors duration-200 hover:shadow-lg"
disabled={totalHours === 0}
onClick={() => router.push("/profile/reservations")}
onClick={() => mutate()}
>
Отправить заявку
</Button>

View File

@ -1,76 +1,48 @@
"use client";
import { useState } from "react";
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";
interface Reservation {
id: string;
yachtName: string;
yachtImage: string;
ownerName: string;
ownerAvatar?: string;
length: number;
capacity: number;
departureDate: string;
departureTime: string;
arrivalDate: string;
arrivalTime: string;
guests: number;
paymentType: string;
totalPrice: number;
paymentStatus: "pending" | "paid" | "confirmed";
}
// Моковые данные для демонстрации
const mockReservations: Record<string, Reservation[]> = {
new: [
{
id: "1",
yachtName: "KALLISTE",
yachtImage: "/images/yachts/yacht1.jpg",
ownerName: "Денис",
length: 14,
capacity: 10,
departureDate: "9 Авг, 2025",
departureTime: "00:00",
arrivalDate: "9 Авг, 2025",
arrivalTime: "02:00",
guests: 1,
paymentType: "Полная оплата",
totalPrice: 52800,
paymentStatus: "pending",
},
{
id: "2",
yachtName: "Señorita",
yachtImage: "/images/yachts/yacht2.jpg",
ownerName: "Денис",
length: 14,
capacity: 10,
departureDate: "17 Авг, 2025",
departureTime: "00:00",
arrivalDate: "17 Авг, 2025",
arrivalTime: "03:00",
guests: 1,
paymentType: "Полная оплата",
totalPrice: 75240,
paymentStatus: "pending",
},
],
active: [],
confirmed: [],
archive: [],
};
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 reservations = mockReservations[activeTab];
const [activeTab, setActiveTab] = useState<
"new" | "active" | "confirmed" | "archive"
>("new");
const [reservationsData, setReservationsData] = useState<ReservationDto[]>(
[]
);
const apiClient = useApiClient();
const { getUserId } = useAuthStore();
const userId = getUserId();
const formatPrice = (price: number): string => {
return new Intl.NumberFormat("ru-RU").format(price) + " Р";
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 (
@ -103,37 +75,41 @@ export default function ReservationsPage() {
<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]"
}`}
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]"
}`}
>
Новые брони ({mockReservations.new.length})
Новые брони ({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]"
}`}
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]"
}`}
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]"
}`}
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>
@ -141,23 +117,34 @@ export default function ReservationsPage() {
{/* Reservations List */}
<div className="space-y-8">
{reservations.length === 0 ? (
{reservationsData.length === 0 ? (
<div className="text-center py-12 text-[#999999]">
Нет бронирований в этой категории
</div>
) : (
reservations.map((reservation, index) => (
reservationsData.map((reservation, index) => (
<div
key={reservation.id}
className={`overflow-hidden bg-white ${index !== reservations.length - 1 ? 'pb-8 border-b border-gray-200' : ''}`}
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.yachtImage}
alt={reservation.yachtName}
src={
reservation.yacht
.mainImageUrl
}
alt={
reservation.yacht
.name
}
fill
className="object-cover"
/>
@ -173,7 +160,12 @@ export default function ReservationsPage() {
Владелец
</span>
<span className="text-sm text-[#333333] font-bold">
{reservation.ownerName}
{
reservation
.yacht
.owner
.firstName
}
</span>
</div>
</div>
@ -187,7 +179,11 @@ export default function ReservationsPage() {
className="text-white"
/>
<span>
{reservation.length} метров
{formatWidth(
reservation
.yacht
.length
)}
</span>
</div>
</div>
@ -198,7 +194,13 @@ export default function ReservationsPage() {
size={16}
className="text-white"
/>
<span>{reservation.capacity}</span>
<span>
{
reservation
.yacht
.maxCapacity
}
</span>
</div>
</div>
</div>
@ -214,8 +216,9 @@ export default function ReservationsPage() {
Выход:
</div>
<div>
{reservation.departureDate} -{" "}
{reservation.departureTime}
{formatUtcDate(
reservation.startUtc
)}
</div>
</div>
<div className="text-[#333333] w-full flex justify-between">
@ -223,8 +226,9 @@ export default function ReservationsPage() {
Заход:
</div>
<div>
{reservation.arrivalDate} -{" "}
{reservation.arrivalTime}
{formatUtcDate(
reservation.endUtc
)}
</div>
</div>
<div className="text-[#333333] w-full flex justify-between">
@ -232,24 +236,38 @@ export default function ReservationsPage() {
Гости:
</div>
<div>
{reservation.guests}
{/* @TODO: Добавить количество гостей */}
{/* {
reservation.guests
} */}
-
</div>
</div>
<div className="text-[#333333] w-full flex justify-between">
<div>
Тип оплаты:
Тип
оплаты:
</div>
<div>
{reservation.paymentType}
{/* @TODO: Добавить тип оплаты */}
{/* {
reservation.paymentType
} */}
-
</div>
</div>
<div className="flex items-center gap-2 text-sm text-[#333333]">
<Clock
size={16}
size={
16
}
className="text-[#999999]"
/>
<span>
По местному времени яхты
По
местному
времени
яхты
</span>
</div>
</div>
@ -257,17 +275,26 @@ export default function ReservationsPage() {
<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 ===
)}{" "}
{reservation.paymentStatus ===
"pending" && (
<span className="text-base font-bold text-red-500">
(в ожидании оплаты)
</span>
)}
<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>

View File

@ -1,44 +1,34 @@
"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";
interface Yacht {
id: string;
name: string;
image: string;
length: number;
capacity: number;
minCost: number;
status: "active" | "moderation" | "archive";
}
// Моковые данные для демонстрации
const mockYachts: Yacht[] = [
{
id: "1",
name: "KALLISTE",
image: "/images/yachts/yacht1.jpg",
length: 14,
capacity: 10,
minCost: 26400,
status: "active",
},
{
id: "2",
name: "Señorita",
image: "/images/yachts/yacht2.jpg",
length: 14,
capacity: 10,
minCost: 37620,
status: "active",
},
];
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 = mockYachts;
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]">
@ -68,7 +58,9 @@ export default function YachtsPage() {
<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>
<h2 className="text-2xl font-bold text-[#333333]">
Мои яхты
</h2>
<Link href="/profile/yachts/add">
<Button variant="gradient" size="default">
Добавить
@ -98,7 +90,7 @@ export default function YachtsPage() {
<div className="relative rounded-[12px] overflow-hidden w-90 h-90 flex-shrink-0">
<Image
src={getImageUrl(
yacht.image
yacht.mainImageUrl
)}
alt={yacht.name}
fill
@ -129,9 +121,7 @@ export default function YachtsPage() {
className="text-white"
/>
<span>
{
yacht.capacity
}
-
</span>
</div>
</div>
@ -162,10 +152,7 @@ export default function YachtsPage() {
Вместимость:
</div>
<div>
{
yacht.capacity
}{" "}
человек
-
</div>
</div>
<div className="text-[#333333] w-full flex justify-between">
@ -183,19 +170,23 @@ export default function YachtsPage() {
</div>
<div className="pt-3 border-t border-[#DFDFDF]">
<div className="flex items-center justify-between">
<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>
{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>

View File

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