Compare commits

...

10 Commits

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

View File

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

View File

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

2
package-lock.json generated
View File

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

View File

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

View File

@ -1,4 +1,4 @@
interface CatalogItemDto {
export interface CatalogItemShortDto {
id?: number;
name: string;
length: number;
@ -12,12 +12,75 @@ interface CatalogItemDto {
isBestOffer?: boolean;
}
interface MainPageCatalogResponseDto {
featuredYacht: CatalogItemDto;
restYachts: CatalogItemDto[];
export interface MainPageCatalogResponseDto {
featuredYacht: CatalogItemShortDto;
restYachts: CatalogItemShortDto[];
}
interface CatalogFilteredResponseDto {
items: CatalogItemDto[];
export interface CatalogFilteredResponseDto {
items: CatalogItemShortDto[];
total: number;
}
interface Reservation {
id: number;
yachtId: number;
reservatorId: number;
startUtc: number;
endUtc: number;
}
interface Review {
id: number;
reviewerId: number;
yachtId: number;
starsCount: number;
description: string;
}
export interface User {
userId?: number;
firstName?: string;
lastName?: string;
phone?: string;
email?: string;
password?: string;
yachts?: Yacht[];
companyName?: string;
inn?: number;
ogrn?: number;
}
export interface CatalogItemLongDto extends CatalogItemShortDto {
year: number;
comfortCapacity: number;
maxCapacity: number;
width: number;
cabinsCount: number;
matherial: string;
power: number;
description: string;
owner: User;
reviews: Review[];
reservations: Reservation[];
}
interface Yacht {
yachtId: number;
name: string;
model: string;
year: number;
length: number;
userId: number;
createdAt: Date;
updatedAt: Date;
}
export interface ReservationDto {
yachtId: number;
reservatorId: number;
startUtc: number;
endUtc: number;
id: number;
yacht: CatalogItemLongDto;
}

View File

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

View File

@ -1,34 +1,64 @@
"use client";
import { useState } from "react";
import { useState, useMemo } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { DatePicker } from "@/components/ui/date-picker";
import { GuestPicker } from "@/components/form/guest-picker";
import { format } from "date-fns";
import { calculateTotalPrice, formatPrice } from "@/lib/utils";
import { CatalogItemLongDto } from "@/api/types";
interface BookingWidgetProps {
price: string;
yacht: CatalogItemLongDto;
}
export function BookingWidget({ price }: BookingWidgetProps) {
export function BookingWidget({ price, yacht }: BookingWidgetProps) {
const router = useRouter();
const [departureDate] = useState<Date | undefined>();
const [arrivalDate] = useState<Date | undefined>();
const [departureDate, setDepartureDate] = useState<Date | undefined>();
const [arrivalDate, setArrivalDate] = useState<Date | undefined>();
const [departureTime, setDepartureTime] = useState<string>("12:00");
const [arrivalTime, setArrivalTime] = useState<string>("13:00");
const [guests, setGuests] = useState({ adults: 1, children: 0 });
const [total] = useState(0);
// Расчет итоговой стоимости
const total = useMemo(() => {
if (!departureDate || !arrivalDate || !departureTime || !arrivalTime || !yacht?.minCost) {
return 0;
}
const departureDateStr = format(departureDate, "yyyy-MM-dd");
const arrivalDateStr = format(arrivalDate, "yyyy-MM-dd");
const { totalPrice } = calculateTotalPrice(
departureDateStr,
departureTime,
arrivalDateStr,
arrivalTime,
yacht.minCost
);
return totalPrice;
}, [departureDate, arrivalDate, departureTime, arrivalTime, yacht?.minCost]);
const handleGuestsChange = (adults: number, children: number) => {
setGuests({ adults, children });
};
const handleBook = () => {
// Логика бронирования
console.log("Booking:", {
departureDate,
arrivalDate,
guests,
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");
router.push(`/confirm?${params.toString()}`);
};
return (
@ -36,9 +66,7 @@ export function BookingWidget({ price }: BookingWidgetProps) {
<div className="mb-6">
<p className="text-2xl font-bold text-[#333333] mb-2">
от {price} {" "}
<span className="text-base font-normal text-[#999999]">
/час
</span>
<span className="text-base font-normal text-[#999999]">/час</span>
</p>
</div>
@ -51,6 +79,11 @@ export function BookingWidget({ price }: BookingWidgetProps) {
variant="small"
placeholder="Выберите дату и время"
showIcon={false}
onDateChange={setDepartureDate}
value={departureDate}
departureTime={departureTime}
onDepartureTimeChange={setDepartureTime}
onlyDeparture
/>
</div>
@ -62,6 +95,11 @@ export function BookingWidget({ price }: BookingWidgetProps) {
variant="small"
placeholder="Выберите дату и время"
showIcon={false}
onDateChange={setArrivalDate}
value={arrivalDate}
arrivalTime={arrivalTime}
onArrivalTimeChange={setArrivalTime}
onlyArrival
/>
</div>
@ -84,6 +122,7 @@ export function BookingWidget({ price }: BookingWidgetProps) {
onClick={handleBook}
variant="gradient"
className="w-full h-12 font-bold text-white mb-4"
disabled={!departureDate || !arrivalDate || !departureTime || !arrivalTime}
>
Забронировать
</Button>
@ -91,9 +130,7 @@ export function BookingWidget({ price }: BookingWidgetProps) {
<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>
<span className="text-base font-bold text-[#333333]">{formatPrice(total)} </span>
</div>
</div>
</div>

View File

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

View File

@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useState, useMemo } from "react";
import { Calendar } from "@/components/ui/calendar";
import {
isSameMonth,
@ -14,26 +14,126 @@ import {
import { ru } from "date-fns/locale";
import { ChevronLeftIcon, ChevronRightIcon, Clock } from "lucide-react";
interface Reservation {
id: number;
reservatorId: number;
yachtId: number;
startUtc: number;
endUtc: number;
}
interface YachtAvailabilityProps {
price: string;
mobile?: boolean;
reservations?: Reservation[];
// Controlled props для мобильной версии
selectedDate?: Date;
startTime?: string;
endTime?: string;
onDateChange?: (date: Date | undefined) => void;
onStartTimeChange?: (time: string) => void;
onEndTimeChange?: (time: string) => void;
}
export function YachtAvailability({
price,
mobile = false,
reservations = [],
selectedDate,
startTime: controlledStartTime,
endTime: controlledEndTime,
onDateChange,
onStartTimeChange,
onEndTimeChange,
}: YachtAvailabilityProps) {
const today = startOfDay(new Date());
const [currentMonth, setCurrentMonth] = useState(
new Date(today.getFullYear(), today.getMonth(), 1)
);
const [startTime, setStartTime] = useState<string>("");
const [endTime, setEndTime] = useState<string>("");
// Используем контролируемые значения или внутреннее состояние
const [internalStartTime, setInternalStartTime] = useState<string>("");
const [internalEndTime, setInternalEndTime] = useState<string>("");
const startTime = mobile && controlledStartTime !== undefined ? controlledStartTime : internalStartTime;
const endTime = mobile && controlledEndTime !== undefined ? controlledEndTime : internalEndTime;
const handleStartTimeChange = (time: string) => {
if (mobile && onStartTimeChange) {
onStartTimeChange(time);
} else {
setInternalStartTime(time);
}
};
const handleEndTimeChange = (time: string) => {
if (mobile && onEndTimeChange) {
onEndTimeChange(time);
} else {
setInternalEndTime(time);
}
};
const unavailableDates = Array.from({ length: 26 }, (_, i) => {
return new Date(2025, 3, i + 1);
});
// Format time from Unix timestamp to HH:mm in UTC
const formatTimeFromUnix = (unixTimestamp: number) => {
const date = new Date(unixTimestamp * 1000);
// Format in UTC to avoid timezone conversion
return format(date, "HH:mm");
};
// Get time portion of a UTC timestamp
const getUTCTime = (unixTimestamp: number) => {
const date = new Date(unixTimestamp * 1000);
return date.getUTCHours() * 60 + date.getUTCMinutes(); // minutes since midnight UTC
};
// Get reservations for a specific date with proper time splitting
const getReservationsForDate = (date: Date) => {
const dayStart = Math.floor(startOfDay(date).getTime() / 1000);
const dayEnd = dayStart + 24 * 60 * 60;
const dayReservations: Array<{
id: number;
startTime: string;
endTime: string;
}> = [];
reservations.forEach((reservation) => {
// Check if reservation overlaps with this day
if (reservation.startUtc < dayEnd && reservation.endUtc > dayStart) {
// Calculate the actual time range for this specific day
const dayReservationStart = Math.max(reservation.startUtc, dayStart);
const dayReservationEnd = Math.min(reservation.endUtc, dayEnd);
// Format times in UTC to avoid timezone issues
const startTime = formatTimeFromUnix(dayReservationStart);
const endTime = formatTimeFromUnix(dayReservationEnd);
dayReservations.push({
id: reservation.id,
startTime,
endTime,
});
}
});
return dayReservations;
};
// Check if a date has any reservations
const hasReservationsOnDate = (date: Date) => {
const dayStart = Math.floor(startOfDay(date).getTime() / 1000);
const dayEnd = dayStart + 24 * 60 * 60;
return reservations.some((reservation) => {
return reservation.startUtc < dayEnd && reservation.endUtc > dayStart;
});
};
const isDateUnavailable = (date: Date) => {
return unavailableDates.some(
(d) =>
@ -48,7 +148,6 @@ export function YachtAvailability({
};
const shouldBeCrossedOut = (date: Date) => {
// Перечеркиваем если день занят или находится до текущего дня
return isDateUnavailable(date) || isDateInPast(date);
};
@ -88,6 +187,36 @@ export function YachtAvailability({
return { value: timeString, label: timeString };
});
// Helper function to render time slots for desktop view
const renderTimeSlots = (date: Date) => {
const dateReservations = getReservationsForDate(date);
if (dateReservations.length === 0) {
// No reservations, show free time slot
return (
<div className="flex flex-col gap-1.5 w-full mt-1">
<div className="w-fit bg-[#F6BD4D] text-white text-[10px] font-medium px-1 py-0 rounded-full inline-block">
08:0020:00
</div>
</div>
);
}
// Show all reservations for this day
return (
<div className="flex flex-col gap-1 w-full mt-1">
{dateReservations.map((res) => (
<div
key={`${res.id}-${res.startTime}`}
className="w-fit bg-[#2F5CD0] text-white text-[10px] font-medium px-1 py-0 rounded-full inline-block"
>
{res.startTime}{res.endTime}
</div>
))}
</div>
);
};
if (mobile) {
return (
<div className="w-full">
@ -119,6 +248,12 @@ export function YachtAvailability({
<div style={{ flexShrink: 0 }}>
<Calendar
mode="single"
selected={selectedDate}
onSelect={(date) => {
if (onDateChange) {
onDateChange(date);
}
}}
month={currentMonth}
onMonthChange={setCurrentMonth}
showOutsideDays={false}
@ -126,15 +261,7 @@ export function YachtAvailability({
locale={ru}
formatters={{
formatWeekdayName: (date) => {
const weekdays = [
"ВС",
"ПН",
"ВТ",
"СР",
"ЧТ",
"ПТ",
"СБ",
];
const weekdays = ["ВС", "ПН", "ВТ", "СР", "ЧТ", "ПТ", "СБ"];
return weekdays[date.getDay()];
},
}}
@ -159,9 +286,12 @@ export function YachtAvailability({
return <div className="hidden" />;
}
const isCrossedOut = shouldBeCrossedOut(
day.date
);
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
@ -169,6 +299,8 @@ export function YachtAvailability({
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={
@ -180,6 +312,9 @@ export function YachtAvailability({
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>
);
},
@ -193,7 +328,7 @@ export function YachtAvailability({
<div className="flex-1">
<select
value={startTime}
onChange={(e) => setStartTime(e.target.value)}
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>
@ -207,7 +342,7 @@ export function YachtAvailability({
<div className="flex-1">
<select
value={endTime}
onChange={(e) => setEndTime(e.target.value)}
onChange={(e) => handleEndTimeChange(e.target.value)}
className="w-full px-4 py-3 border border-[#DFDFDF] rounded-lg text-base text-[#333333] bg-white appearance-none"
>
<option value="">--:--</option>
@ -231,9 +366,7 @@ export function YachtAvailability({
return (
<div className="space-y-4 w-full">
<div className="flex items-center justify-between">
<h2 className="text-base font-bold text-[#333333]">
Доступность яхты
</h2>
<h2 className="text-base font-bold text-[#333333]">Доступность яхты</h2>
</div>
<div className="bg-white w-full">
@ -273,8 +406,7 @@ export function YachtAvailability({
button_next: "hidden",
table: "w-full border-collapse",
weekdays: "hidden",
weekday:
"flex-1 text-gray-500 text-xs font-normal p-2 text-center",
weekday: "flex-1 text-gray-500 text-xs font-normal p-2 text-center",
week: "flex w-full",
day: "relative flex-1",
}}
@ -311,17 +443,16 @@ export function YachtAvailability({
{/* Дата и "Доступно:" в одной строке */}
<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>
<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>
{/* Time slots - reservations first */}
{renderTimeSlots(day.date)}
{/* Цена в нижнем правом углу */}
<span className="absolute bottom-[2px] right-[4px] text-xs text-[#333333] font-medium">
{price} / час

View File

@ -1,16 +1,9 @@
"use client";
import { CatalogItemLongDto } from "@/api/types";
interface YachtCharacteristicsProps {
yacht: {
year: number;
maxCapacity: number;
comfortableCapacity: number;
length: number;
width: number;
cabins: number;
material: string;
power: number;
};
yacht: CatalogItemLongDto;
}
export function YachtCharacteristics({ yacht }: YachtCharacteristicsProps) {
@ -22,12 +15,12 @@ export function YachtCharacteristics({ yacht }: YachtCharacteristicsProps) {
},
{
label: "Комфортная вместимость",
value: `${yacht.comfortableCapacity} человек`,
value: `${yacht.comfortCapacity} человек`,
},
{ label: "Длина", value: `${yacht.length} м` },
{ label: "Ширина", value: `${yacht.width} м` },
{ label: "Каюты", value: yacht.cabins },
{ label: "Материал", value: yacht.material },
{ label: "Каюты", value: yacht.cabinsCount },
{ label: "Материал", value: yacht.matherial },
{ label: "Мощность", value: `${yacht.power} л/с` },
];
@ -42,9 +35,7 @@ export function YachtCharacteristics({ yacht }: YachtCharacteristicsProps) {
key={index}
className="flex justify-between items-center py-4 border-b border-gray-200"
>
<span className="text-base text-[#999999]">
{char.label}
</span>
<span className="text-base text-[#999999]">{char.label}</span>
<span className="text-base font-regular text-[#333333]">
{char.value}
</span>

View File

@ -11,6 +11,7 @@ import {
CarouselPrevious,
type CarouselApi,
} from "@/components/ui/carousel";
import { getImageUrl } from "@/lib/utils";
interface YachtGalleryProps {
images: string[];
@ -54,7 +55,7 @@ export function YachtGallery({ images, badge }: YachtGalleryProps) {
<CarouselItem key={index}>
<div className="relative w-full h-[60vh] lg:h-[592px] rounded-0 lg:rounded-[24px] overflow-hidden">
<Image
src={img}
src={getImageUrl(img)}
alt={`Yacht image ${index + 1}`}
fill
className="object-cover"
@ -97,7 +98,7 @@ export function YachtGallery({ images, badge }: YachtGalleryProps) {
}`}
>
<Image
src={img}
src={getImageUrl(img)}
alt={`Thumbnail ${index + 1}`}
fill
className="object-cover"

View File

@ -1,37 +0,0 @@
export const YACHT = {
id: 1,
name: "Яхта Название",
location: "7 Футов",
price: "18 000",
images: [
"/images/yachts/yacht1.jpg",
"/images/yachts/yacht2.jpg",
"/images/yachts/yacht3.jpg",
"/images/yachts/yacht4.jpg",
"/images/yachts/yacht5.jpg",
"/images/yachts/yacht6.jpg",
],
badge: "По запросу",
year: 2000,
maxCapacity: 11,
comfortableCapacity: 11,
length: 13,
width: 4,
cabins: 2,
material: "Стеклопластик",
power: 740,
description: `Яхта Яхта Яхта Яхта Яхта Яхта Яхта Яхта Яхта Яхта
Яхта Яхта Яхта Яхта Яхта Яхта Яхта Яхта Яхта Яхта
Яхта Яхта Яхта Яхта Яхта Яхта Яхта Яхта Яхта Яхта
Яхта Яхта Яхта Яхта Яхта Яхта Яхта Яхта Яхта Яхта
Яхта Яхта Яхта Яхта Яхта Яхта Яхта Яхта Яхта Яхта`,
contactPerson: {
name: "Денис",
avatar: "/images/logo.svg",
},
requisites: {
ip: "Иванов Иван Иванович",
inn: "23000000000",
ogrn: "310000000000001",
},
};

View File

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

View File

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

View File

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

View File

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

View File

@ -8,21 +8,32 @@ import Link from "next/link";
import FeaturedYacht from "./FeaturedYacht";
import useApiClient from "@/hooks/useApiClient";
import { useEffect, useState } from "react";
import { formatMinCost, formatSpeed, formatWidth, getImageUrl } from "@/lib/utils";
import {
formatMinCost,
formatSpeed,
formatWidth,
getImageUrl,
} from "@/lib/utils";
import { CatalogItemShortDto, MainPageCatalogResponseDto } from "@/api/types";
export default function YachtGrid() {
const client = useApiClient();
const [featuredYacht, setFeaturedYacht] = useState<CatalogItemDto | null>(null);
const [yachtCatalog, setYachtCatalog] = useState<CatalogItemDto[] | null>(null);
const [featuredYacht, setFeaturedYacht] =
useState<CatalogItemShortDto | null>(null);
const [yachtCatalog, setYachtCatalog] = useState<
CatalogItemShortDto[] | null
>(null);
useEffect(() => {
(async () => {
const response = await client.get<MainPageCatalogResponseDto>("/catalog/main-page/");
const response = await client.get<MainPageCatalogResponseDto>(
"/catalog/main-page/"
);
setFeaturedYacht(response.data.featuredYacht);
setYachtCatalog(response.data.restYachts);
})();
}, [])
}, []);
return (
<section className="text-white">
@ -39,15 +50,13 @@ export default function YachtGrid() {
Каталог лучших яхт Балаклавы разных ценовых сегментах.
</p>
<p className="text-gray-700 leading-relaxed">
Проверенные лодки с лицензией на перевозки, опытные
капитаны. Выбирайте удобную дату, время и бронируйте.
Проверенные лодки с лицензией на перевозки, опытные капитаны.
Выбирайте удобную дату, время и бронируйте.
</p>
</div>
{/* Featured Yacht Block */}
{featuredYacht && (
<FeaturedYacht yacht={featuredYacht} />
)}
{featuredYacht && <FeaturedYacht yacht={featuredYacht} />}
{/* Yacht Grid */}
{yachtCatalog && (
@ -55,7 +64,7 @@ export default function YachtGrid() {
{yachtCatalog.map((yacht) => (
<Link
key={yacht.id}
href={`/catalog/${yacht.id ?? 0}}`}
href={`/catalog/${yacht.id ?? 0}`}
className="block"
>
<Card className="overflow-hidden bg-white text-gray-900 cursor-pointer transition-all duration-200 hover:shadow-lg">
@ -71,9 +80,7 @@ export default function YachtGrid() {
"url('/images/best-yacht-bg.jpg')",
}}
>
<span>
{yacht.topText}
</span>
<span>{yacht.topText}</span>
</div>
</div>
)}
@ -89,13 +96,8 @@ export default function YachtGrid() {
<>
<div className="absolute top-3 left-3">
<div className="flex items-center justify-center bg-black/40 text-white px-3 py-1 rounded-lg text-sm flex items-center gap-1">
<Icon
size={16}
name="restart"
/>
<span>
По запросу
</span>
<Icon size={16} name="restart" />
<span>По запросу</span>
</div>
</div>
</>
@ -106,9 +108,7 @@ export default function YachtGrid() {
<div className="flex justify-between gap-4">
{/* Левая колонка - название и длина */}
<div className="space-y-2">
<h3 className="font-bold text-l">
{yacht.name}
</h3>
<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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -14,14 +14,18 @@ import {
PopoverTrigger,
} from "@/components/ui/popover";
interface GuestDatePickerProps {
onApply?: (data: {
export interface GuestDatePickerValue {
date: Date | undefined;
departureTime: string;
arrivalTime: string;
adults: number;
children: number;
}) => void;
}
interface GuestDatePickerProps {
value?: GuestDatePickerValue;
onChange?: (value: GuestDatePickerValue) => void;
onApply?: (data: GuestDatePickerValue) => void;
className?: string;
}
@ -62,9 +66,7 @@ const CommonPopoverContent: React.FC<CommonPopoverContentProps> = ({
onSelect={setDate}
className="mb-[24px]"
locale={ru}
disabled={(date) =>
date < new Date(new Date().setHours(0, 0, 0, 0))
}
disabled={(date) => date < new Date(new Date().setHours(0, 0, 0, 0))}
classNames={{
root: "w-full",
month: "flex w-full flex-col gap-4",
@ -76,8 +78,7 @@ const CommonPopoverContent: React.FC<CommonPopoverContentProps> = ({
"flex h-8 w-full items-center justify-center px-8 text-gray-700 font-semibold",
table: "w-full border-collapse",
weekdays: "flex",
weekday:
"flex-1 text-gray-500 text-xs font-normal p-2 text-center",
weekday: "flex-1 text-gray-500 text-xs font-normal p-2 text-center",
day_button: "font-bold ring-0 focus:ring-0",
week: "mt-2 flex w-full",
today: "bg-gray-100 text-gray-900 rounded-full",
@ -88,7 +89,6 @@ const CommonPopoverContent: React.FC<CommonPopoverContentProps> = ({
}}
/>
{/* Счетчики гостей */}
<div className="mb-[24px] flex gap-3">
<Counter
@ -153,26 +153,94 @@ const CommonPopoverContent: React.FC<CommonPopoverContentProps> = ({
};
export const GuestDatePicker: React.FC<GuestDatePickerProps> = ({
value,
onChange,
onApply,
className,
}) => {
const [date, setDate] = useState<Date>();
const [departureTime, setDepartureTime] = useState("12:00");
const [arrivalTime, setArrivalTime] = useState("13:00");
const [adults, setAdults] = useState(1);
const [children, setChildren] = useState(0);
// Используем controlled значения, если они переданы, иначе используем внутреннее состояние
const isControlled = value !== undefined;
const [internalDate, setInternalDate] = useState<Date>();
const [internalDepartureTime, setInternalDepartureTime] = useState("12:00");
const [internalArrivalTime, setInternalArrivalTime] = useState("13:00");
const [internalAdults, setInternalAdults] = useState(1);
const [internalChildren, setInternalChildren] = useState(0);
const date = isControlled ? value.date : internalDate;
const departureTime = isControlled ? value.departureTime : internalDepartureTime;
const arrivalTime = isControlled ? value.arrivalTime : internalArrivalTime;
const adults = isControlled ? value.adults : internalAdults;
const children = isControlled ? value.children : internalChildren;
const setDate = (newDate: Date | undefined) => {
if (isControlled) {
onChange?.({
...value,
date: newDate,
});
} else {
setInternalDate(newDate);
}
};
const setDepartureTime = (newTime: string) => {
if (isControlled) {
onChange?.({
...value,
departureTime: newTime,
});
} else {
setInternalDepartureTime(newTime);
}
};
const setArrivalTime = (newTime: string) => {
if (isControlled) {
onChange?.({
...value,
arrivalTime: newTime,
});
} else {
setInternalArrivalTime(newTime);
}
};
const setAdults = (newAdults: number) => {
if (isControlled) {
onChange?.({
...value,
adults: newAdults,
});
} else {
setInternalAdults(newAdults);
}
};
const setChildren = (newChildren: number) => {
if (isControlled) {
onChange?.({
...value,
children: newChildren,
});
} else {
setInternalChildren(newChildren);
}
};
const [isDepartureOpen, setIsDepartureOpen] = useState(false);
const [isArrivalOpen, setIsArrivalOpen] = useState(false);
const [isGuestOpen, setIsGuestOpen] = useState(false);
const handleApply = () => {
onApply?.({
const currentValue = {
date,
departureTime,
arrivalTime,
adults,
children,
});
};
onApply?.(currentValue);
setIsDepartureOpen(false);
setIsArrivalOpen(false);
setIsGuestOpen(false);
@ -217,10 +285,7 @@ export const GuestDatePicker: React.FC<GuestDatePickerProps> = ({
<div className={className}>
<div className="space-y-5">
{/* Кнопка Выход */}
<Popover
open={isDepartureOpen}
onOpenChange={setIsDepartureOpen}
>
<Popover open={isDepartureOpen} onOpenChange={setIsDepartureOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"

View File

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

View File

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

@ -7,7 +7,7 @@ const useApiClient = () => {
const authPopup = useAuthPopup();
const apiClient = axios.create({
baseURL: "http://192.168.1.5:4000/",
baseURL: "https://api.travelmarine.ru",
headers: {
"Content-Type": "application/json",
},

View File

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

View File

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