Compare commits
10 Commits
7f6c6d1107
...
ee31b639b2
| Author | SHA1 | Date |
|---|---|---|
|
|
ee31b639b2 | |
|
|
da29133989 | |
|
|
089f5064a3 | |
|
|
63725ff710 | |
|
|
b249ab597b | |
|
|
b63bc78ec7 | |
|
|
040ee2dd05 | |
|
|
745d58ab3a | |
|
|
d177eee970 | |
|
|
b45f9885ab |
|
|
@ -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
|
|
||||||
|
|
@ -4,12 +4,12 @@ const nextConfig: NextConfig = {
|
||||||
images: {
|
images: {
|
||||||
remotePatterns: [
|
remotePatterns: [
|
||||||
{
|
{
|
||||||
protocol: "http",
|
protocol: "https",
|
||||||
hostname: "89.169.188.2",
|
hostname: "api.travelmarine.ru",
|
||||||
pathname: '/**'
|
pathname: "/**",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
unoptimized: false,
|
unoptimized: true,
|
||||||
},
|
},
|
||||||
webpack(config) {
|
webpack(config) {
|
||||||
config.module.rules.push({
|
config.module.rules.push({
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@
|
||||||
"eslint-config-next": "15.5.5",
|
"eslint-config-next": "15.5.5",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"turbo": "^2.6.3",
|
"turbo": "^2.6.3",
|
||||||
"typescript": "^5"
|
"typescript": "5.9.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@alloc/quick-lru": {
|
"node_modules/@alloc/quick-lru": {
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,6 @@
|
||||||
"eslint-config-next": "15.5.5",
|
"eslint-config-next": "15.5.5",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"turbo": "^2.6.3",
|
"turbo": "^2.6.3",
|
||||||
"typescript": "^5"
|
"typescript": "5.9.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
interface CatalogItemDto {
|
export interface CatalogItemShortDto {
|
||||||
id?: number;
|
id?: number;
|
||||||
name: string;
|
name: string;
|
||||||
length: number;
|
length: number;
|
||||||
|
|
@ -12,12 +12,75 @@ interface CatalogItemDto {
|
||||||
isBestOffer?: boolean;
|
isBestOffer?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MainPageCatalogResponseDto {
|
export interface MainPageCatalogResponseDto {
|
||||||
featuredYacht: CatalogItemDto;
|
featuredYacht: CatalogItemShortDto;
|
||||||
restYachts: CatalogItemDto[];
|
restYachts: CatalogItemShortDto[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CatalogFilteredResponseDto {
|
export interface CatalogFilteredResponseDto {
|
||||||
items: CatalogItemDto[];
|
items: CatalogItemShortDto[];
|
||||||
total: number;
|
total: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface Reservation {
|
||||||
|
id: number;
|
||||||
|
yachtId: number;
|
||||||
|
reservatorId: number;
|
||||||
|
startUtc: number;
|
||||||
|
endUtc: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Review {
|
||||||
|
id: number;
|
||||||
|
reviewerId: number;
|
||||||
|
yachtId: number;
|
||||||
|
starsCount: number;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
userId?: number;
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
phone?: string;
|
||||||
|
email?: string;
|
||||||
|
password?: string;
|
||||||
|
yachts?: Yacht[];
|
||||||
|
companyName?: string;
|
||||||
|
inn?: number;
|
||||||
|
ogrn?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CatalogItemLongDto extends CatalogItemShortDto {
|
||||||
|
year: number;
|
||||||
|
comfortCapacity: number;
|
||||||
|
maxCapacity: number;
|
||||||
|
width: number;
|
||||||
|
cabinsCount: number;
|
||||||
|
matherial: string;
|
||||||
|
power: number;
|
||||||
|
description: string;
|
||||||
|
owner: User;
|
||||||
|
reviews: Review[];
|
||||||
|
reservations: Reservation[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Yacht {
|
||||||
|
yachtId: number;
|
||||||
|
name: string;
|
||||||
|
model: string;
|
||||||
|
year: number;
|
||||||
|
length: number;
|
||||||
|
userId: number;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReservationDto {
|
||||||
|
yachtId: number;
|
||||||
|
reservatorId: number;
|
||||||
|
startUtc: number;
|
||||||
|
endUtc: number;
|
||||||
|
id: number;
|
||||||
|
yacht: CatalogItemLongDto;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,40 @@ interface AuthDataType {
|
||||||
rememberMe: boolean;
|
rememberMe: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface JWTPayload {
|
||||||
|
user_id?: number;
|
||||||
|
userId?: number;
|
||||||
|
sub?: string;
|
||||||
|
id?: number;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseJWT = (token: string): JWTPayload | null => {
|
||||||
|
try {
|
||||||
|
const base64Url = token.split(".")[1];
|
||||||
|
if (!base64Url) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/");
|
||||||
|
const jsonPayload = decodeURIComponent(
|
||||||
|
atob(base64)
|
||||||
|
.split("")
|
||||||
|
.map((c) => "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2))
|
||||||
|
.join("")
|
||||||
|
);
|
||||||
|
|
||||||
|
return JSON.parse(jsonPayload);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Ошибка при парсинге JWT токена:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const useAuthentificate = () => {
|
const useAuthentificate = () => {
|
||||||
const client = useApiClient();
|
const client = useApiClient();
|
||||||
const setToken = useAuthStore((state) => state.setToken);
|
const setToken = useAuthStore((state) => state.setToken);
|
||||||
|
const setUserId = useAuthStore((state) => state.setUserId);
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationKey: ["auth"],
|
mutationKey: ["auth"],
|
||||||
|
|
@ -25,6 +56,21 @@ const useAuthentificate = () => {
|
||||||
|
|
||||||
setToken(access_token, authData.rememberMe);
|
setToken(access_token, authData.rememberMe);
|
||||||
|
|
||||||
|
// Парсим JWT токен и извлекаем userId
|
||||||
|
const payload = parseJWT(access_token);
|
||||||
|
if (payload) {
|
||||||
|
const userId =
|
||||||
|
payload.user_id ||
|
||||||
|
payload.userId ||
|
||||||
|
payload.sub ||
|
||||||
|
payload.id ||
|
||||||
|
null;
|
||||||
|
|
||||||
|
if (userId) {
|
||||||
|
setUserId(userId, authData.rememberMe);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return access_token;
|
return access_token;
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
|
|
|
||||||
|
|
@ -1,101 +1,138 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState, useMemo } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { DatePicker } from "@/components/ui/date-picker";
|
import { DatePicker } from "@/components/ui/date-picker";
|
||||||
import { GuestPicker } from "@/components/form/guest-picker";
|
import { GuestPicker } from "@/components/form/guest-picker";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import { calculateTotalPrice, formatPrice } from "@/lib/utils";
|
||||||
|
import { CatalogItemLongDto } from "@/api/types";
|
||||||
|
|
||||||
interface BookingWidgetProps {
|
interface BookingWidgetProps {
|
||||||
price: string;
|
price: string;
|
||||||
|
yacht: CatalogItemLongDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BookingWidget({ price }: BookingWidgetProps) {
|
export function BookingWidget({ price, yacht }: BookingWidgetProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [departureDate] = useState<Date | undefined>();
|
const [departureDate, setDepartureDate] = useState<Date | undefined>();
|
||||||
const [arrivalDate] = useState<Date | undefined>();
|
const [arrivalDate, setArrivalDate] = useState<Date | undefined>();
|
||||||
const [guests, setGuests] = useState({ adults: 1, children: 0 });
|
const [departureTime, setDepartureTime] = useState<string>("12:00");
|
||||||
const [total] = useState(0);
|
const [arrivalTime, setArrivalTime] = useState<string>("13:00");
|
||||||
|
const [guests, setGuests] = useState({ adults: 1, children: 0 });
|
||||||
|
|
||||||
const handleGuestsChange = (adults: number, children: number) => {
|
// Расчет итоговой стоимости
|
||||||
setGuests({ adults, children });
|
const total = useMemo(() => {
|
||||||
};
|
if (!departureDate || !arrivalDate || !departureTime || !arrivalTime || !yacht?.minCost) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
const handleBook = () => {
|
const departureDateStr = format(departureDate, "yyyy-MM-dd");
|
||||||
// Логика бронирования
|
const arrivalDateStr = format(arrivalDate, "yyyy-MM-dd");
|
||||||
console.log("Booking:", {
|
|
||||||
departureDate,
|
|
||||||
arrivalDate,
|
|
||||||
guests,
|
|
||||||
});
|
|
||||||
router.push("/confirm");
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
const { totalPrice } = calculateTotalPrice(
|
||||||
<div className="bg-white border border-gray-200 rounded-lg p-6">
|
departureDateStr,
|
||||||
<div className="mb-6">
|
departureTime,
|
||||||
<p className="text-2xl font-bold text-[#333333] mb-2">
|
arrivalDateStr,
|
||||||
от {price} ₽{" "}
|
arrivalTime,
|
||||||
<span className="text-base font-normal text-[#999999]">
|
yacht.minCost
|
||||||
/час
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4 mb-6">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-[#333333] mb-2">
|
|
||||||
Выход
|
|
||||||
</label>
|
|
||||||
<DatePicker
|
|
||||||
variant="small"
|
|
||||||
placeholder="Выберите дату и время"
|
|
||||||
showIcon={false}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-[#333333] mb-2">
|
|
||||||
Заход
|
|
||||||
</label>
|
|
||||||
<DatePicker
|
|
||||||
variant="small"
|
|
||||||
placeholder="Выберите дату и время"
|
|
||||||
showIcon={false}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-[#333333] mb-2">
|
|
||||||
Гостей
|
|
||||||
</label>
|
|
||||||
<GuestPicker
|
|
||||||
adults={guests.adults}
|
|
||||||
childrenCount={guests.children}
|
|
||||||
onChange={handleGuestsChange}
|
|
||||||
variant="small"
|
|
||||||
showIcon={false}
|
|
||||||
placeholder="1 гость"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={handleBook}
|
|
||||||
variant="gradient"
|
|
||||||
className="w-full h-12 font-bold text-white mb-4"
|
|
||||||
>
|
|
||||||
Забронировать
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<div className="pt-4 border-t border-gray-200">
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<span className="text-base text-[#333333]">Итого:</span>
|
|
||||||
<span className="text-base font-bold text-[#333333]">
|
|
||||||
{total} ₽
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return totalPrice;
|
||||||
|
}, [departureDate, arrivalDate, departureTime, arrivalTime, yacht?.minCost]);
|
||||||
|
|
||||||
|
const handleGuestsChange = (adults: number, children: number) => {
|
||||||
|
setGuests({ adults, children });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBook = () => {
|
||||||
|
if (!departureDate || !arrivalDate || !departureTime || !arrivalTime || !yacht || !yacht.id) return;
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
yachtId: yacht.id.toString(),
|
||||||
|
departureDate: format(departureDate, "yyyy-MM-dd"),
|
||||||
|
departureTime: departureTime,
|
||||||
|
arrivalDate: format(arrivalDate, "yyyy-MM-dd"),
|
||||||
|
arrivalTime: arrivalTime,
|
||||||
|
guests: (guests.adults + guests.children).toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
router.push(`/confirm?${params.toString()}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white border border-gray-200 rounded-lg p-6">
|
||||||
|
<div className="mb-6">
|
||||||
|
<p className="text-2xl font-bold text-[#333333] mb-2">
|
||||||
|
от {price} ₽{" "}
|
||||||
|
<span className="text-base font-normal text-[#999999]">/час</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4 mb-6">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-[#333333] mb-2">
|
||||||
|
Выход
|
||||||
|
</label>
|
||||||
|
<DatePicker
|
||||||
|
variant="small"
|
||||||
|
placeholder="Выберите дату и время"
|
||||||
|
showIcon={false}
|
||||||
|
onDateChange={setDepartureDate}
|
||||||
|
value={departureDate}
|
||||||
|
departureTime={departureTime}
|
||||||
|
onDepartureTimeChange={setDepartureTime}
|
||||||
|
onlyDeparture
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-[#333333] mb-2">
|
||||||
|
Заход
|
||||||
|
</label>
|
||||||
|
<DatePicker
|
||||||
|
variant="small"
|
||||||
|
placeholder="Выберите дату и время"
|
||||||
|
showIcon={false}
|
||||||
|
onDateChange={setArrivalDate}
|
||||||
|
value={arrivalDate}
|
||||||
|
arrivalTime={arrivalTime}
|
||||||
|
onArrivalTimeChange={setArrivalTime}
|
||||||
|
onlyArrival
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-[#333333] mb-2">
|
||||||
|
Гостей
|
||||||
|
</label>
|
||||||
|
<GuestPicker
|
||||||
|
adults={guests.adults}
|
||||||
|
childrenCount={guests.children}
|
||||||
|
onChange={handleGuestsChange}
|
||||||
|
variant="small"
|
||||||
|
showIcon={false}
|
||||||
|
placeholder="1 гость"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={handleBook}
|
||||||
|
variant="gradient"
|
||||||
|
className="w-full h-12 font-bold text-white mb-4"
|
||||||
|
disabled={!departureDate || !arrivalDate || !departureTime || !arrivalTime}
|
||||||
|
>
|
||||||
|
Забронировать
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="pt-4 border-t border-gray-200">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-base text-[#333333]">Итого:</span>
|
||||||
|
<span className="text-base font-bold text-[#333333]">{formatPrice(total)} ₽</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,70 +1,45 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
import { User } from "@/api/types";
|
||||||
|
|
||||||
interface ContactInfoProps {
|
export function ContactInfo({ firstName, companyName, inn, ogrn }: User) {
|
||||||
contactPerson: {
|
return (
|
||||||
name: string;
|
<div className="flex flex-col sm:flex-row gap-5">
|
||||||
avatar: string;
|
<div className="flex-1 rounded-[24px] px-6 py-5 bg-[#f4f4f4]">
|
||||||
};
|
<div className="flex items-center gap-4 h-full">
|
||||||
requisites: {
|
<div className="relative rounded-full overflow-hidden bg-gray-200 flex items-center justify-center">
|
||||||
ip: string;
|
<Image
|
||||||
inn: string;
|
src="/images/avatar.png"
|
||||||
ogrn: string;
|
alt={firstName || "avatar"}
|
||||||
};
|
width={124}
|
||||||
}
|
height={124}
|
||||||
|
/>
|
||||||
export function ContactInfo({ contactPerson, requisites }: ContactInfoProps) {
|
</div>
|
||||||
return (
|
<div className="flex flex-col justify-between h-full">
|
||||||
<div className="flex flex-col sm:flex-row gap-5">
|
<h3 className="text-base font-bold text-[#333333]">{firstName}</h3>
|
||||||
<div className="flex-1 rounded-[24px] px-6 py-5 bg-[#f4f4f4]">
|
<p className="text-base text-[#333333]">Контактное лицо</p>
|
||||||
<div className="flex items-center gap-4 h-full">
|
</div>
|
||||||
<div className="relative rounded-full overflow-hidden bg-gray-200 flex items-center justify-center">
|
|
||||||
<Image
|
|
||||||
src="/images/avatar.png"
|
|
||||||
alt={contactPerson.name}
|
|
||||||
width={124}
|
|
||||||
height={124}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col justify-between h-full">
|
|
||||||
<h3 className="text-base font-bold text-[#333333]">
|
|
||||||
{contactPerson.name}
|
|
||||||
</h3>
|
|
||||||
<p className="text-base text-[#333333]">
|
|
||||||
Контактное лицо
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 rounded-[24px] px-6 py-5 bg-[#f4f4f4]">
|
|
||||||
<h3 className="text-base font-bold text-[#333333] mb-3">
|
|
||||||
Реквизиты
|
|
||||||
</h3>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex flex-col sm:flex-row sm:justify-between gap-1">
|
|
||||||
<span className="text-base text-[#333333]">ИП</span>
|
|
||||||
<span className="text-base text-[#999999]">
|
|
||||||
{requisites.ip}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col sm:flex-row sm:justify-between gap-1">
|
|
||||||
<span className="text-base text-[#333333]">ИНН</span>
|
|
||||||
<span className="text-base text-[#999999]">
|
|
||||||
{requisites.inn}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col sm:flex-row sm:justify-between gap-1">
|
|
||||||
<span className="text-base text-[#333333]">
|
|
||||||
ОГРН/ОГРНИП
|
|
||||||
</span>
|
|
||||||
<span className="text-base text-[#999999]">
|
|
||||||
{requisites.ogrn}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 rounded-[24px] px-6 py-5 bg-[#f4f4f4]">
|
||||||
|
<h3 className="text-base font-bold text-[#333333] mb-3">Реквизиты</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:justify-between gap-1">
|
||||||
|
<span className="text-base text-[#333333]">ИП</span>
|
||||||
|
<span className="text-base text-[#999999]">{companyName}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col sm:flex-row sm:justify-between gap-1">
|
||||||
|
<span className="text-base text-[#333333]">ИНН</span>
|
||||||
|
<span className="text-base text-[#999999]">{inn}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col sm:flex-row sm:justify-between gap-1">
|
||||||
|
<span className="text-base text-[#333333]">ОГРН/ОГРНИП</span>
|
||||||
|
<span className="text-base text-[#999999]">{ogrn}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,339 +1,470 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState, useMemo } from "react";
|
||||||
import { Calendar } from "@/components/ui/calendar";
|
import { Calendar } from "@/components/ui/calendar";
|
||||||
import {
|
import {
|
||||||
isSameMonth,
|
isSameMonth,
|
||||||
isBefore,
|
isBefore,
|
||||||
startOfDay,
|
startOfDay,
|
||||||
format,
|
format,
|
||||||
eachDayOfInterval,
|
eachDayOfInterval,
|
||||||
startOfMonth,
|
startOfMonth,
|
||||||
endOfMonth,
|
endOfMonth,
|
||||||
} from "date-fns";
|
} from "date-fns";
|
||||||
import { ru } from "date-fns/locale";
|
import { ru } from "date-fns/locale";
|
||||||
import { ChevronLeftIcon, ChevronRightIcon, Clock } from "lucide-react";
|
import { ChevronLeftIcon, ChevronRightIcon, Clock } from "lucide-react";
|
||||||
|
|
||||||
|
interface Reservation {
|
||||||
|
id: number;
|
||||||
|
reservatorId: number;
|
||||||
|
yachtId: number;
|
||||||
|
startUtc: number;
|
||||||
|
endUtc: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface YachtAvailabilityProps {
|
interface YachtAvailabilityProps {
|
||||||
price: string;
|
price: string;
|
||||||
mobile?: boolean;
|
mobile?: boolean;
|
||||||
|
reservations?: Reservation[];
|
||||||
|
// Controlled props для мобильной версии
|
||||||
|
selectedDate?: Date;
|
||||||
|
startTime?: string;
|
||||||
|
endTime?: string;
|
||||||
|
onDateChange?: (date: Date | undefined) => void;
|
||||||
|
onStartTimeChange?: (time: string) => void;
|
||||||
|
onEndTimeChange?: (time: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function YachtAvailability({
|
export function YachtAvailability({
|
||||||
price,
|
price,
|
||||||
mobile = false,
|
mobile = false,
|
||||||
|
reservations = [],
|
||||||
|
selectedDate,
|
||||||
|
startTime: controlledStartTime,
|
||||||
|
endTime: controlledEndTime,
|
||||||
|
onDateChange,
|
||||||
|
onStartTimeChange,
|
||||||
|
onEndTimeChange,
|
||||||
}: YachtAvailabilityProps) {
|
}: YachtAvailabilityProps) {
|
||||||
const today = startOfDay(new Date());
|
const today = startOfDay(new Date());
|
||||||
const [currentMonth, setCurrentMonth] = useState(
|
const [currentMonth, setCurrentMonth] = useState(
|
||||||
new Date(today.getFullYear(), today.getMonth(), 1)
|
new Date(today.getFullYear(), today.getMonth(), 1)
|
||||||
);
|
);
|
||||||
const [startTime, setStartTime] = useState<string>("");
|
|
||||||
const [endTime, setEndTime] = useState<string>("");
|
|
||||||
|
|
||||||
const unavailableDates = Array.from({ length: 26 }, (_, i) => {
|
// Используем контролируемые значения или внутреннее состояние
|
||||||
return new Date(2025, 3, i + 1);
|
const [internalStartTime, setInternalStartTime] = useState<string>("");
|
||||||
});
|
const [internalEndTime, setInternalEndTime] = useState<string>("");
|
||||||
|
|
||||||
const isDateUnavailable = (date: Date) => {
|
const startTime = mobile && controlledStartTime !== undefined ? controlledStartTime : internalStartTime;
|
||||||
return unavailableDates.some(
|
const endTime = mobile && controlledEndTime !== undefined ? controlledEndTime : internalEndTime;
|
||||||
(d) =>
|
|
||||||
d.getDate() === date.getDate() &&
|
|
||||||
d.getMonth() === date.getMonth() &&
|
|
||||||
d.getFullYear() === date.getFullYear()
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const isDateInPast = (date: Date) => {
|
const handleStartTimeChange = (time: string) => {
|
||||||
return isBefore(startOfDay(date), today);
|
if (mobile && onStartTimeChange) {
|
||||||
};
|
onStartTimeChange(time);
|
||||||
|
} else {
|
||||||
|
setInternalStartTime(time);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const shouldBeCrossedOut = (date: Date) => {
|
const handleEndTimeChange = (time: string) => {
|
||||||
// Перечеркиваем если день занят или находится до текущего дня
|
if (mobile && onEndTimeChange) {
|
||||||
return isDateUnavailable(date) || isDateInPast(date);
|
onEndTimeChange(time);
|
||||||
};
|
} else {
|
||||||
|
setInternalEndTime(time);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const isDateAvailable = (date: Date) => {
|
const unavailableDates = Array.from({ length: 26 }, (_, i) => {
|
||||||
return !shouldBeCrossedOut(date) && isSameMonth(date, currentMonth);
|
return new Date(2025, 3, i + 1);
|
||||||
};
|
});
|
||||||
|
|
||||||
const getAvailableDaysCount = () => {
|
// Format time from Unix timestamp to HH:mm in UTC
|
||||||
const monthStart = startOfMonth(currentMonth);
|
const formatTimeFromUnix = (unixTimestamp: number) => {
|
||||||
const monthEnd = endOfMonth(currentMonth);
|
const date = new Date(unixTimestamp * 1000);
|
||||||
const daysInMonth = eachDayOfInterval({
|
// Format in UTC to avoid timezone conversion
|
||||||
start: monthStart,
|
return format(date, "HH:mm");
|
||||||
end: monthEnd,
|
};
|
||||||
|
|
||||||
|
// Get time portion of a UTC timestamp
|
||||||
|
const getUTCTime = (unixTimestamp: number) => {
|
||||||
|
const date = new Date(unixTimestamp * 1000);
|
||||||
|
return date.getUTCHours() * 60 + date.getUTCMinutes(); // minutes since midnight UTC
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get reservations for a specific date with proper time splitting
|
||||||
|
const getReservationsForDate = (date: Date) => {
|
||||||
|
const dayStart = Math.floor(startOfDay(date).getTime() / 1000);
|
||||||
|
const dayEnd = dayStart + 24 * 60 * 60;
|
||||||
|
|
||||||
|
const dayReservations: Array<{
|
||||||
|
id: number;
|
||||||
|
startTime: string;
|
||||||
|
endTime: string;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
reservations.forEach((reservation) => {
|
||||||
|
// Check if reservation overlaps with this day
|
||||||
|
if (reservation.startUtc < dayEnd && reservation.endUtc > dayStart) {
|
||||||
|
// Calculate the actual time range for this specific day
|
||||||
|
const dayReservationStart = Math.max(reservation.startUtc, dayStart);
|
||||||
|
const dayReservationEnd = Math.min(reservation.endUtc, dayEnd);
|
||||||
|
|
||||||
|
// Format times in UTC to avoid timezone issues
|
||||||
|
const startTime = formatTimeFromUnix(dayReservationStart);
|
||||||
|
const endTime = formatTimeFromUnix(dayReservationEnd);
|
||||||
|
|
||||||
|
dayReservations.push({
|
||||||
|
id: reservation.id,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
});
|
});
|
||||||
return daysInMonth.filter((day) => isDateAvailable(day)).length;
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const goToPreviousMonth = () => {
|
|
||||||
setCurrentMonth(
|
|
||||||
new Date(currentMonth.getFullYear(), currentMonth.getMonth() - 1, 1)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const goToNextMonth = () => {
|
|
||||||
setCurrentMonth(
|
|
||||||
new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1, 1)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Генерация времени для селекта
|
|
||||||
const timeOptions = Array.from({ length: 24 * 2 }, (_, i) => {
|
|
||||||
const hours = Math.floor(i / 2);
|
|
||||||
const minutes = (i % 2) * 30;
|
|
||||||
const timeString = `${String(hours).padStart(2, "0")}:${String(
|
|
||||||
minutes
|
|
||||||
).padStart(2, "0")}`;
|
|
||||||
return { value: timeString, label: timeString };
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (mobile) {
|
return dayReservations;
|
||||||
return (
|
};
|
||||||
<div className="w-full">
|
|
||||||
{/* Навигация по месяцам */}
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<button
|
|
||||||
onClick={goToPreviousMonth}
|
|
||||||
className="w-10 h-10 rounded-full border border-[#dfdfdf] flex items-center justify-center hover:bg-gray-50 transition-colors"
|
|
||||||
>
|
|
||||||
<ChevronLeftIcon className="size-4 text-[#333333]" />
|
|
||||||
</button>
|
|
||||||
<div className="flex flex-col items-center">
|
|
||||||
<span className="text-lg font-medium text-[#333333] capitalize">
|
|
||||||
{format(currentMonth, "LLLL", { locale: ru })}
|
|
||||||
</span>
|
|
||||||
<span className="text-sm text-[#999999]">
|
|
||||||
Свободных дней: {getAvailableDaysCount()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={goToNextMonth}
|
|
||||||
className="w-10 h-10 rounded-full border border-[#dfdfdf] flex items-center justify-center hover:bg-gray-50 transition-colors"
|
|
||||||
>
|
|
||||||
<ChevronRightIcon className="size-4 text-[#333333]" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Календарь */}
|
// Check if a date has any reservations
|
||||||
<div style={{ flexShrink: 0 }}>
|
const hasReservationsOnDate = (date: Date) => {
|
||||||
<Calendar
|
const dayStart = Math.floor(startOfDay(date).getTime() / 1000);
|
||||||
mode="single"
|
const dayEnd = dayStart + 24 * 60 * 60;
|
||||||
month={currentMonth}
|
|
||||||
onMonthChange={setCurrentMonth}
|
|
||||||
showOutsideDays={false}
|
|
||||||
className="w-full p-0"
|
|
||||||
locale={ru}
|
|
||||||
formatters={{
|
|
||||||
formatWeekdayName: (date) => {
|
|
||||||
const weekdays = [
|
|
||||||
"ВС",
|
|
||||||
"ПН",
|
|
||||||
"ВТ",
|
|
||||||
"СР",
|
|
||||||
"ЧТ",
|
|
||||||
"ПТ",
|
|
||||||
"СБ",
|
|
||||||
];
|
|
||||||
return weekdays[date.getDay()];
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
classNames={{
|
|
||||||
root: "w-full",
|
|
||||||
month: "flex w-full flex-col gap-2",
|
|
||||||
nav: "hidden",
|
|
||||||
month_caption: "hidden",
|
|
||||||
caption_label: "hidden",
|
|
||||||
button_previous: "hidden",
|
|
||||||
button_next: "hidden",
|
|
||||||
table: "w-full border-collapse table-fixed",
|
|
||||||
weekdays: "flex w-full mb-2",
|
|
||||||
weekday:
|
|
||||||
"flex-1 text-[#999999] text-xs font-normal p-2 text-center",
|
|
||||||
week: "flex w-full min-h-[50px]",
|
|
||||||
day: "relative flex-1 min-w-0 flex-shrink-0",
|
|
||||||
}}
|
|
||||||
components={{
|
|
||||||
DayButton: ({ day, ...props }) => {
|
|
||||||
if (!isSameMonth(day.date, currentMonth)) {
|
|
||||||
return <div className="hidden" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isCrossedOut = shouldBeCrossedOut(
|
return reservations.some((reservation) => {
|
||||||
day.date
|
return reservation.startUtc < dayEnd && reservation.endUtc > dayStart;
|
||||||
);
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
const isDateUnavailable = (date: Date) => {
|
||||||
<button
|
return unavailableDates.some(
|
||||||
{...props}
|
(d) =>
|
||||||
className={`relative w-full flex items-center justify-center text-sm font-medium transition-colors ${
|
d.getDate() === date.getDate() &&
|
||||||
isCrossedOut
|
d.getMonth() === date.getMonth() &&
|
||||||
? "text-[#CCCCCC] line-through"
|
d.getFullYear() === date.getFullYear()
|
||||||
: "text-[#333333] hover:bg-gray-100"
|
);
|
||||||
}`}
|
};
|
||||||
style={
|
|
||||||
{
|
|
||||||
aspectRatio: "1 / 1",
|
|
||||||
minHeight: "44px",
|
|
||||||
} as React.CSSProperties
|
|
||||||
}
|
|
||||||
disabled={isCrossedOut}
|
|
||||||
>
|
|
||||||
{day.date.getDate()}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Выбор времени */}
|
const isDateInPast = (date: Date) => {
|
||||||
<div className="space-y-4 mb-4" style={{ marginTop: "24px" }}>
|
return isBefore(startOfDay(date), today);
|
||||||
<div className="flex gap-3">
|
};
|
||||||
<div className="flex-1">
|
|
||||||
<select
|
const shouldBeCrossedOut = (date: Date) => {
|
||||||
value={startTime}
|
return isDateUnavailable(date) || isDateInPast(date);
|
||||||
onChange={(e) => setStartTime(e.target.value)}
|
};
|
||||||
className="w-full px-4 py-3 border border-[#DFDFDF] rounded-lg text-base text-[#333333] bg-white appearance-none"
|
|
||||||
>
|
const isDateAvailable = (date: Date) => {
|
||||||
<option value="">--:--</option>
|
return !shouldBeCrossedOut(date) && isSameMonth(date, currentMonth);
|
||||||
{timeOptions.map((time) => (
|
};
|
||||||
<option key={time.value} value={time.value}>
|
|
||||||
{time.label}
|
const getAvailableDaysCount = () => {
|
||||||
</option>
|
const monthStart = startOfMonth(currentMonth);
|
||||||
))}
|
const monthEnd = endOfMonth(currentMonth);
|
||||||
</select>
|
const daysInMonth = eachDayOfInterval({
|
||||||
</div>
|
start: monthStart,
|
||||||
<div className="flex-1">
|
end: monthEnd,
|
||||||
<select
|
});
|
||||||
value={endTime}
|
return daysInMonth.filter((day) => isDateAvailable(day)).length;
|
||||||
onChange={(e) => setEndTime(e.target.value)}
|
};
|
||||||
className="w-full px-4 py-3 border border-[#DFDFDF] rounded-lg text-base text-[#333333] bg-white appearance-none"
|
|
||||||
>
|
const goToPreviousMonth = () => {
|
||||||
<option value="">--:--</option>
|
setCurrentMonth(
|
||||||
{timeOptions.map((time) => (
|
new Date(currentMonth.getFullYear(), currentMonth.getMonth() - 1, 1)
|
||||||
<option key={time.value} value={time.value}>
|
);
|
||||||
{time.label}
|
};
|
||||||
</option>
|
|
||||||
))}
|
const goToNextMonth = () => {
|
||||||
</select>
|
setCurrentMonth(
|
||||||
</div>
|
new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1, 1)
|
||||||
</div>
|
);
|
||||||
<div className="flex items-center gap-2 text-sm text-[#999999]">
|
};
|
||||||
<Clock size={16} />
|
|
||||||
<span>По местному времени яхты</span>
|
// Генерация времени для селекта
|
||||||
</div>
|
const timeOptions = Array.from({ length: 24 * 2 }, (_, i) => {
|
||||||
</div>
|
const hours = Math.floor(i / 2);
|
||||||
</div>
|
const minutes = (i % 2) * 30;
|
||||||
);
|
const timeString = `${String(hours).padStart(2, "0")}:${String(
|
||||||
|
minutes
|
||||||
|
).padStart(2, "0")}`;
|
||||||
|
return { value: timeString, label: timeString };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper function to render time slots for desktop view
|
||||||
|
const renderTimeSlots = (date: Date) => {
|
||||||
|
const dateReservations = getReservationsForDate(date);
|
||||||
|
|
||||||
|
if (dateReservations.length === 0) {
|
||||||
|
// No reservations, show free time slot
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1.5 w-full mt-1">
|
||||||
|
<div className="w-fit bg-[#F6BD4D] text-white text-[10px] font-medium px-1 py-0 rounded-full inline-block">
|
||||||
|
08:00—20:00
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show all reservations for this day
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 w-full">
|
<div className="flex flex-col gap-1 w-full mt-1">
|
||||||
<div className="flex items-center justify-between">
|
{dateReservations.map((res) => (
|
||||||
<h2 className="text-base font-bold text-[#333333]">
|
<div
|
||||||
Доступность яхты
|
key={`${res.id}-${res.startTime}`}
|
||||||
</h2>
|
className="w-fit bg-[#2F5CD0] text-white text-[10px] font-medium px-1 py-0 rounded-full inline-block"
|
||||||
</div>
|
>
|
||||||
|
{res.startTime}—{res.endTime}
|
||||||
<div className="bg-white w-full">
|
</div>
|
||||||
<div className="w-full flex justify-end mb-8">
|
))}
|
||||||
<div className="flex items-center gap-5">
|
</div>
|
||||||
<button
|
|
||||||
onClick={goToPreviousMonth}
|
|
||||||
className="cursor-pointer rounded-full border border-[#dfdfdf] h-12 w-12 flex items-center justify-center"
|
|
||||||
>
|
|
||||||
<ChevronLeftIcon className="size-4" />
|
|
||||||
</button>
|
|
||||||
<span className="text-2xl text-[#333333]">
|
|
||||||
{format(currentMonth, "LLLL yyyy", { locale: ru })}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={goToNextMonth}
|
|
||||||
className="cursor-pointer rounded-full border border-[#dfdfdf] h-12 w-12 flex items-center justify-center"
|
|
||||||
>
|
|
||||||
<ChevronRightIcon className="size-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Calendar
|
|
||||||
mode="single"
|
|
||||||
month={currentMonth}
|
|
||||||
onMonthChange={setCurrentMonth}
|
|
||||||
showOutsideDays={false}
|
|
||||||
className="w-full p-0"
|
|
||||||
locale={ru}
|
|
||||||
classNames={{
|
|
||||||
root: "w-full",
|
|
||||||
month: "flex w-full flex-col gap-4",
|
|
||||||
nav: "absolute inset-x-0 top-0 flex w-full items-center justify-between gap-2",
|
|
||||||
month_caption: "hidden",
|
|
||||||
caption_label: "text-2xl",
|
|
||||||
button_previous: "hidden",
|
|
||||||
button_next: "hidden",
|
|
||||||
table: "w-full border-collapse",
|
|
||||||
weekdays: "hidden",
|
|
||||||
weekday:
|
|
||||||
"flex-1 text-gray-500 text-xs font-normal p-2 text-center",
|
|
||||||
week: "flex w-full",
|
|
||||||
day: "relative flex-1",
|
|
||||||
}}
|
|
||||||
components={{
|
|
||||||
DayButton: ({ day, ...props }) => {
|
|
||||||
// Показываем только дни текущего месяца
|
|
||||||
if (!isSameMonth(day.date, currentMonth)) {
|
|
||||||
return <div className="hidden" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isCrossedOut = shouldBeCrossedOut(day.date);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
{...props}
|
|
||||||
className="relative w-full h-20 flex flex-col items-start justify-start px-2 py-[2px] border border-gray-200"
|
|
||||||
disabled={isCrossedOut}
|
|
||||||
>
|
|
||||||
{isCrossedOut ? (
|
|
||||||
// Перечеркнутая ячейка для недоступных дней
|
|
||||||
<>
|
|
||||||
<span className="text-sm font-medium text-[#333333] self-end">
|
|
||||||
{day.date.getDate()}
|
|
||||||
</span>
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center">
|
|
||||||
<span className="text-gray-300 text-4xl font-light leading-none">
|
|
||||||
✕
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
// Доступный день с информацией
|
|
||||||
<>
|
|
||||||
{/* Дата и "Доступно:" в одной строке */}
|
|
||||||
<div className="flex items-center justify-between w-full">
|
|
||||||
<span className="text-xs text-gray-400">
|
|
||||||
Доступно:
|
|
||||||
</span>
|
|
||||||
<span className="text-sm font-medium text-[#333333]">
|
|
||||||
{day.date.getDate()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1.5 w-full mt-1">
|
|
||||||
<div className="w-fit bg-[#F6BD4D] text-white text-[10px] font-medium px-1 py-0 rounded-full inline-block">
|
|
||||||
08:00—20:00
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* Цена в нижнем правом углу */}
|
|
||||||
<span className="absolute bottom-[2px] right-[4px] text-xs text-[#333333] font-medium">
|
|
||||||
{price} / час
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (mobile) {
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
{/* Навигация по месяцам */}
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<button
|
||||||
|
onClick={goToPreviousMonth}
|
||||||
|
className="w-10 h-10 rounded-full border border-[#dfdfdf] flex items-center justify-center hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronLeftIcon className="size-4 text-[#333333]" />
|
||||||
|
</button>
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<span className="text-lg font-medium text-[#333333] capitalize">
|
||||||
|
{format(currentMonth, "LLLL", { locale: ru })}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-[#999999]">
|
||||||
|
Свободных дней: {getAvailableDaysCount()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={goToNextMonth}
|
||||||
|
className="w-10 h-10 rounded-full border border-[#dfdfdf] flex items-center justify-center hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronRightIcon className="size-4 text-[#333333]" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Календарь */}
|
||||||
|
<div style={{ flexShrink: 0 }}>
|
||||||
|
<Calendar
|
||||||
|
mode="single"
|
||||||
|
selected={selectedDate}
|
||||||
|
onSelect={(date) => {
|
||||||
|
if (onDateChange) {
|
||||||
|
onDateChange(date);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
month={currentMonth}
|
||||||
|
onMonthChange={setCurrentMonth}
|
||||||
|
showOutsideDays={false}
|
||||||
|
className="w-full p-0"
|
||||||
|
locale={ru}
|
||||||
|
formatters={{
|
||||||
|
formatWeekdayName: (date) => {
|
||||||
|
const weekdays = ["ВС", "ПН", "ВТ", "СР", "ЧТ", "ПТ", "СБ"];
|
||||||
|
return weekdays[date.getDay()];
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
classNames={{
|
||||||
|
root: "w-full",
|
||||||
|
month: "flex w-full flex-col gap-2",
|
||||||
|
nav: "hidden",
|
||||||
|
month_caption: "hidden",
|
||||||
|
caption_label: "hidden",
|
||||||
|
button_previous: "hidden",
|
||||||
|
button_next: "hidden",
|
||||||
|
table: "w-full border-collapse table-fixed",
|
||||||
|
weekdays: "flex w-full mb-2",
|
||||||
|
weekday:
|
||||||
|
"flex-1 text-[#999999] text-xs font-normal p-2 text-center",
|
||||||
|
week: "flex w-full min-h-[50px]",
|
||||||
|
day: "relative flex-1 min-w-0 flex-shrink-0",
|
||||||
|
}}
|
||||||
|
components={{
|
||||||
|
DayButton: ({ day, ...props }) => {
|
||||||
|
if (!isSameMonth(day.date, currentMonth)) {
|
||||||
|
return <div className="hidden" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isCrossedOut = shouldBeCrossedOut(day.date);
|
||||||
|
const hasRes = hasReservationsOnDate(day.date);
|
||||||
|
const isSelected = selectedDate &&
|
||||||
|
selectedDate.getDate() === day.date.getDate() &&
|
||||||
|
selectedDate.getMonth() === day.date.getMonth() &&
|
||||||
|
selectedDate.getFullYear() === day.date.getFullYear();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
{...props}
|
||||||
|
className={`relative w-full flex items-center justify-center text-sm font-medium transition-colors ${
|
||||||
|
isCrossedOut
|
||||||
|
? "text-[#CCCCCC] line-through"
|
||||||
|
: isSelected
|
||||||
|
? "bg-[#008299] text-white rounded-full"
|
||||||
|
: "text-[#333333] hover:bg-gray-100"
|
||||||
|
}`}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
aspectRatio: "1 / 1",
|
||||||
|
minHeight: "44px",
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
disabled={isCrossedOut}
|
||||||
|
>
|
||||||
|
{day.date.getDate()}
|
||||||
|
{hasRes && !isCrossedOut && !isSelected && (
|
||||||
|
<div className="absolute bottom-1 right-1 w-1.5 h-1.5 bg-[#2F5CD0] rounded-full"></div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Выбор времени */}
|
||||||
|
<div className="space-y-4 mb-4" style={{ marginTop: "24px" }}>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<div className="flex-1">
|
||||||
|
<select
|
||||||
|
value={startTime}
|
||||||
|
onChange={(e) => handleStartTimeChange(e.target.value)}
|
||||||
|
className="w-full px-4 py-3 border border-[#DFDFDF] rounded-lg text-base text-[#333333] bg-white appearance-none"
|
||||||
|
>
|
||||||
|
<option value="">--:--</option>
|
||||||
|
{timeOptions.map((time) => (
|
||||||
|
<option key={time.value} value={time.value}>
|
||||||
|
{time.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<select
|
||||||
|
value={endTime}
|
||||||
|
onChange={(e) => handleEndTimeChange(e.target.value)}
|
||||||
|
className="w-full px-4 py-3 border border-[#DFDFDF] rounded-lg text-base text-[#333333] bg-white appearance-none"
|
||||||
|
>
|
||||||
|
<option value="">--:--</option>
|
||||||
|
{timeOptions.map((time) => (
|
||||||
|
<option key={time.value} value={time.value}>
|
||||||
|
{time.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm text-[#999999]">
|
||||||
|
<Clock size={16} />
|
||||||
|
<span>По местному времени яхты</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 w-full">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-base font-bold text-[#333333]">Доступность яхты</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white w-full">
|
||||||
|
<div className="w-full flex justify-end mb-8">
|
||||||
|
<div className="flex items-center gap-5">
|
||||||
|
<button
|
||||||
|
onClick={goToPreviousMonth}
|
||||||
|
className="cursor-pointer rounded-full border border-[#dfdfdf] h-12 w-12 flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<ChevronLeftIcon className="size-4" />
|
||||||
|
</button>
|
||||||
|
<span className="text-2xl text-[#333333]">
|
||||||
|
{format(currentMonth, "LLLL yyyy", { locale: ru })}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={goToNextMonth}
|
||||||
|
className="cursor-pointer rounded-full border border-[#dfdfdf] h-12 w-12 flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<ChevronRightIcon className="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Calendar
|
||||||
|
mode="single"
|
||||||
|
month={currentMonth}
|
||||||
|
onMonthChange={setCurrentMonth}
|
||||||
|
showOutsideDays={false}
|
||||||
|
className="w-full p-0"
|
||||||
|
locale={ru}
|
||||||
|
classNames={{
|
||||||
|
root: "w-full",
|
||||||
|
month: "flex w-full flex-col gap-4",
|
||||||
|
nav: "absolute inset-x-0 top-0 flex w-full items-center justify-between gap-2",
|
||||||
|
month_caption: "hidden",
|
||||||
|
caption_label: "text-2xl",
|
||||||
|
button_previous: "hidden",
|
||||||
|
button_next: "hidden",
|
||||||
|
table: "w-full border-collapse",
|
||||||
|
weekdays: "hidden",
|
||||||
|
weekday: "flex-1 text-gray-500 text-xs font-normal p-2 text-center",
|
||||||
|
week: "flex w-full",
|
||||||
|
day: "relative flex-1",
|
||||||
|
}}
|
||||||
|
components={{
|
||||||
|
DayButton: ({ day, ...props }) => {
|
||||||
|
// Показываем только дни текущего месяца
|
||||||
|
if (!isSameMonth(day.date, currentMonth)) {
|
||||||
|
return <div className="hidden" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isCrossedOut = shouldBeCrossedOut(day.date);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
{...props}
|
||||||
|
className="relative w-full h-20 flex flex-col items-start justify-start px-2 py-[2px] border border-gray-200"
|
||||||
|
disabled={isCrossedOut}
|
||||||
|
>
|
||||||
|
{isCrossedOut ? (
|
||||||
|
// Перечеркнутая ячейка для недоступных дней
|
||||||
|
<>
|
||||||
|
<span className="text-sm font-medium text-[#333333] self-end">
|
||||||
|
{day.date.getDate()}
|
||||||
|
</span>
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<span className="text-gray-300 text-4xl font-light leading-none">
|
||||||
|
✕
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
// Доступный день с информацией
|
||||||
|
<>
|
||||||
|
{/* Дата и "Доступно:" в одной строке */}
|
||||||
|
<div className="flex items-center justify-between w-full">
|
||||||
|
<span className="text-xs text-gray-400">
|
||||||
|
{hasReservationsOnDate(day.date)
|
||||||
|
? "Бронь:"
|
||||||
|
: "Доступно:"}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-medium text-[#333333]">
|
||||||
|
{day.date.getDate()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/* Time slots - reservations first */}
|
||||||
|
{renderTimeSlots(day.date)}
|
||||||
|
{/* Цена в нижнем правом углу */}
|
||||||
|
<span className="absolute bottom-[2px] right-[4px] text-xs text-[#333333] font-medium">
|
||||||
|
{price} / час
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,56 +1,47 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { CatalogItemLongDto } from "@/api/types";
|
||||||
|
|
||||||
interface YachtCharacteristicsProps {
|
interface YachtCharacteristicsProps {
|
||||||
yacht: {
|
yacht: CatalogItemLongDto;
|
||||||
year: number;
|
|
||||||
maxCapacity: number;
|
|
||||||
comfortableCapacity: number;
|
|
||||||
length: number;
|
|
||||||
width: number;
|
|
||||||
cabins: number;
|
|
||||||
material: string;
|
|
||||||
power: number;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function YachtCharacteristics({ yacht }: YachtCharacteristicsProps) {
|
export function YachtCharacteristics({ yacht }: YachtCharacteristicsProps) {
|
||||||
const characteristics = [
|
const characteristics = [
|
||||||
{ label: "Год", value: yacht.year },
|
{ label: "Год", value: yacht.year },
|
||||||
{
|
{
|
||||||
label: "Максимальная вместимость",
|
label: "Максимальная вместимость",
|
||||||
value: `${yacht.maxCapacity} человек`,
|
value: `${yacht.maxCapacity} человек`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Комфортная вместимость",
|
label: "Комфортная вместимость",
|
||||||
value: `${yacht.comfortableCapacity} человек`,
|
value: `${yacht.comfortCapacity} человек`,
|
||||||
},
|
},
|
||||||
{ label: "Длина", value: `${yacht.length} м` },
|
{ label: "Длина", value: `${yacht.length} м` },
|
||||||
{ label: "Ширина", value: `${yacht.width} м` },
|
{ label: "Ширина", value: `${yacht.width} м` },
|
||||||
{ label: "Каюты", value: yacht.cabins },
|
{ label: "Каюты", value: yacht.cabinsCount },
|
||||||
{ label: "Материал", value: yacht.material },
|
{ label: "Материал", value: yacht.matherial },
|
||||||
{ label: "Мощность", value: `${yacht.power} л/с` },
|
{ label: "Мощность", value: `${yacht.power} л/с` },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-base font-bold text-[#333333] mb-4">
|
<h2 className="text-base font-bold text-[#333333] mb-4">
|
||||||
Характеристики
|
Характеристики
|
||||||
</h2>
|
</h2>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-6">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-6">
|
||||||
{characteristics.map((char, index) => (
|
{characteristics.map((char, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
className="flex justify-between items-center py-4 border-b border-gray-200"
|
className="flex justify-between items-center py-4 border-b border-gray-200"
|
||||||
>
|
>
|
||||||
<span className="text-base text-[#999999]">
|
<span className="text-base text-[#999999]">{char.label}</span>
|
||||||
{char.label}
|
<span className="text-base font-regular text-[#333333]">
|
||||||
</span>
|
{char.value}
|
||||||
<span className="text-base font-regular text-[#333333]">
|
</span>
|
||||||
{char.value}
|
</div>
|
||||||
</span>
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
</div>
|
||||||
</div>
|
);
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,107 +4,108 @@ import { useState, useEffect } from "react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Icon from "@/components/ui/icon";
|
import Icon from "@/components/ui/icon";
|
||||||
import {
|
import {
|
||||||
Carousel,
|
Carousel,
|
||||||
CarouselContent,
|
CarouselContent,
|
||||||
CarouselItem,
|
CarouselItem,
|
||||||
CarouselNext,
|
CarouselNext,
|
||||||
CarouselPrevious,
|
CarouselPrevious,
|
||||||
type CarouselApi,
|
type CarouselApi,
|
||||||
} from "@/components/ui/carousel";
|
} from "@/components/ui/carousel";
|
||||||
|
import { getImageUrl } from "@/lib/utils";
|
||||||
|
|
||||||
interface YachtGalleryProps {
|
interface YachtGalleryProps {
|
||||||
images: string[];
|
images: string[];
|
||||||
badge?: string;
|
badge?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function YachtGallery({ images, badge }: YachtGalleryProps) {
|
export function YachtGallery({ images, badge }: YachtGalleryProps) {
|
||||||
const [api, setApi] = useState<CarouselApi>();
|
const [api, setApi] = useState<CarouselApi>();
|
||||||
const [current, setCurrent] = useState(0);
|
const [current, setCurrent] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!api) {
|
if (!api) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setCurrent(api.selectedScrollSnap());
|
setCurrent(api.selectedScrollSnap());
|
||||||
|
|
||||||
api.on("select", () => {
|
api.on("select", () => {
|
||||||
setCurrent(api.selectedScrollSnap());
|
setCurrent(api.selectedScrollSnap());
|
||||||
});
|
});
|
||||||
}, [api]);
|
}, [api]);
|
||||||
|
|
||||||
const scrollTo = (index: number) => {
|
const scrollTo = (index: number) => {
|
||||||
api?.scrollTo(index);
|
api?.scrollTo(index);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Main Image Carousel */}
|
{/* Main Image Carousel */}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Carousel
|
<Carousel
|
||||||
setApi={setApi}
|
setApi={setApi}
|
||||||
opts={{
|
opts={{
|
||||||
align: "start",
|
align: "start",
|
||||||
loop: false,
|
loop: false,
|
||||||
}}
|
}}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
>
|
>
|
||||||
<CarouselContent>
|
<CarouselContent>
|
||||||
{images.map((img, index) => (
|
{images.map((img, index) => (
|
||||||
<CarouselItem key={index}>
|
<CarouselItem key={index}>
|
||||||
<div className="relative w-full h-[60vh] lg:h-[592px] rounded-0 lg:rounded-[24px] overflow-hidden">
|
<div className="relative w-full h-[60vh] lg:h-[592px] rounded-0 lg:rounded-[24px] overflow-hidden">
|
||||||
<Image
|
<Image
|
||||||
src={img}
|
src={getImageUrl(img)}
|
||||||
alt={`Yacht image ${index + 1}`}
|
alt={`Yacht image ${index + 1}`}
|
||||||
fill
|
fill
|
||||||
className="object-cover"
|
className="object-cover"
|
||||||
priority={index === 0}
|
priority={index === 0}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</CarouselItem>
|
|
||||||
))}
|
|
||||||
</CarouselContent>
|
|
||||||
<CarouselPrevious className="left-2 lg:left-4 bg-white/80 hover:bg-white border-gray-300 w-10 h-10 lg:w-12 lg:h-12 rounded-full" />
|
|
||||||
<CarouselNext className="right-2 lg:right-4 bg-white/80 hover:bg-white border-gray-300 w-10 h-10 lg:w-12 lg:h-12 rounded-full" />
|
|
||||||
</Carousel>
|
|
||||||
{/* Badge - поверх слайдера, не скроллится */}
|
|
||||||
{badge && (
|
|
||||||
<div className="absolute bottom-4 left-4 z-20 pointer-events-none">
|
|
||||||
<div className="flex items-center justify-center bg-black/40 text-white px-3 py-1 rounded-lg text-sm gap-1">
|
|
||||||
<Icon size={16} name="restart" />
|
|
||||||
<span>{badge}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{/* Photo counter - поверх слайдера, не скроллится */}
|
|
||||||
<div className="absolute bottom-4 right-4 z-20 pointer-events-none">
|
|
||||||
<div className="bg-black/40 text-white px-3 py-1 rounded-lg text-sm">
|
|
||||||
{current + 1}/{images.length}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</CarouselItem>
|
||||||
|
))}
|
||||||
|
</CarouselContent>
|
||||||
|
<CarouselPrevious className="left-2 lg:left-4 bg-white/80 hover:bg-white border-gray-300 w-10 h-10 lg:w-12 lg:h-12 rounded-full" />
|
||||||
|
<CarouselNext className="right-2 lg:right-4 bg-white/80 hover:bg-white border-gray-300 w-10 h-10 lg:w-12 lg:h-12 rounded-full" />
|
||||||
|
</Carousel>
|
||||||
|
{/* Badge - поверх слайдера, не скроллится */}
|
||||||
|
{badge && (
|
||||||
|
<div className="absolute bottom-4 left-4 z-20 pointer-events-none">
|
||||||
|
<div className="flex items-center justify-center bg-black/40 text-white px-3 py-1 rounded-lg text-sm gap-1">
|
||||||
|
<Icon size={16} name="restart" />
|
||||||
|
<span>{badge}</span>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{/* Thumbnails - скрыты на мобильных */}
|
)}
|
||||||
<div className="hidden lg:flex gap-2 overflow-x-auto pb-2">
|
{/* Photo counter - поверх слайдера, не скроллится */}
|
||||||
{images.map((img, index) => (
|
<div className="absolute bottom-4 right-4 z-20 pointer-events-none">
|
||||||
<button
|
<div className="bg-black/40 text-white px-3 py-1 rounded-lg text-sm">
|
||||||
key={index}
|
{current + 1}/{images.length}
|
||||||
onClick={() => scrollTo(index)}
|
</div>
|
||||||
className={`relative flex-shrink-0 w-17 h-14 rounded-lg overflow-hidden border-2 transition-all ${
|
|
||||||
current === index
|
|
||||||
? "border-[#008299] opacity-100"
|
|
||||||
: "border-transparent opacity-60 hover:opacity-100"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
src={img}
|
|
||||||
alt={`Thumbnail ${index + 1}`}
|
|
||||||
fill
|
|
||||||
className="object-cover"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
</div>
|
||||||
|
|
||||||
|
{/* Thumbnails - скрыты на мобильных */}
|
||||||
|
<div className="hidden lg:flex gap-2 overflow-x-auto pb-2">
|
||||||
|
{images.map((img, index) => (
|
||||||
|
<button
|
||||||
|
key={index}
|
||||||
|
onClick={() => scrollTo(index)}
|
||||||
|
className={`relative flex-shrink-0 w-17 h-14 rounded-lg overflow-hidden border-2 transition-all ${
|
||||||
|
current === index
|
||||||
|
? "border-[#008299] opacity-100"
|
||||||
|
: "border-transparent opacity-60 hover:opacity-100"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={getImageUrl(img)}
|
||||||
|
alt={`Thumbnail ${index + 1}`}
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter, useParams } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { ArrowLeft, Heart } from "lucide-react";
|
import { ArrowLeft, Heart } from "lucide-react";
|
||||||
import Icon from "@/components/ui/icon";
|
import Icon from "@/components/ui/icon";
|
||||||
import { YachtGallery } from "./components/YachtGallery";
|
import { YachtGallery } from "./components/YachtGallery";
|
||||||
|
|
@ -10,9 +10,28 @@ import { YachtAvailability } from "./components/YachtAvailability";
|
||||||
import { BookingWidget } from "./components/BookingWidget";
|
import { BookingWidget } from "./components/BookingWidget";
|
||||||
import { YachtCharacteristics } from "./components/YachtCharacteristics";
|
import { YachtCharacteristics } from "./components/YachtCharacteristics";
|
||||||
import { ContactInfo } from "./components/ContactInfo";
|
import { ContactInfo } from "./components/ContactInfo";
|
||||||
import { YACHT } from "./const";
|
import { GuestPicker } from "@/components/form/guest-picker";
|
||||||
|
import useApiClient from "@/hooks/useApiClient";
|
||||||
|
import { formatSpeed } from "@/lib/utils";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import { CatalogItemLongDto } from "@/api/types";
|
||||||
|
|
||||||
export default function YachtDetailPage() {
|
export default function YachtDetailPage() {
|
||||||
|
const { id } = useParams();
|
||||||
|
const [yacht, setYacht] = useState<CatalogItemLongDto | null>(null);
|
||||||
|
|
||||||
|
const client = useApiClient();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
const response = await client.get<CatalogItemLongDto>(
|
||||||
|
`/catalog/${id}/`
|
||||||
|
);
|
||||||
|
|
||||||
|
setYacht(response.data);
|
||||||
|
})();
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
// const params = useParams();
|
// const params = useParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [activeTab, setActiveTab] = useState<
|
const [activeTab, setActiveTab] = useState<
|
||||||
|
|
@ -24,6 +43,40 @@ export default function YachtDetailPage() {
|
||||||
| "reviews"
|
| "reviews"
|
||||||
>("availability");
|
>("availability");
|
||||||
|
|
||||||
|
// Состояние для мобильного бронирования
|
||||||
|
const [selectedDate, setSelectedDate] = useState<Date | undefined>();
|
||||||
|
const [startTime, setStartTime] = useState<string>("");
|
||||||
|
const [endTime, setEndTime] = useState<string>("");
|
||||||
|
const [guests, setGuests] = useState({ adults: 1, children: 0 });
|
||||||
|
|
||||||
|
const handleGuestsChange = (adults: number, children: number) => {
|
||||||
|
setGuests({ adults, children });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBookMobile = () => {
|
||||||
|
if (!selectedDate || !startTime || !endTime || !yacht || !yacht.id)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Используем выбранную дату как дату отправления и прибытия (можно изменить логику при необходимости)
|
||||||
|
const departureDate = format(selectedDate, "yyyy-MM-dd");
|
||||||
|
const arrivalDate = format(selectedDate, "yyyy-MM-dd");
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
yachtId: yacht.id.toString(),
|
||||||
|
departureDate: departureDate,
|
||||||
|
departureTime: startTime,
|
||||||
|
arrivalDate: arrivalDate,
|
||||||
|
arrivalTime: endTime,
|
||||||
|
guests: (guests.adults + guests.children).toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
router.push(`/confirm?${params.toString()}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!yacht) {
|
||||||
|
return <div />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="bg-[#f4f4f4] min-h-screen ">
|
<main className="bg-[#f4f4f4] min-h-screen ">
|
||||||
{/* Мобильная фиксированная верхняя панель навигации */}
|
{/* Мобильная фиксированная верхняя панель навигации */}
|
||||||
|
|
@ -59,7 +112,7 @@ export default function YachtDetailPage() {
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
<span>></span>
|
<span>></span>
|
||||||
<span className="text-[#333333]">{YACHT.name}</span>
|
<span className="text-[#333333]">{yacht.name}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -70,14 +123,14 @@ export default function YachtDetailPage() {
|
||||||
<div className="lg:hidden pt-[50px]">
|
<div className="lg:hidden pt-[50px]">
|
||||||
{/* Gallery */}
|
{/* Gallery */}
|
||||||
<YachtGallery
|
<YachtGallery
|
||||||
images={YACHT.images}
|
images={yacht.galleryUrls || []}
|
||||||
badge={YACHT.badge}
|
badge={!yacht.hasQuickRent ? "По запросу" : ""}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Yacht Title */}
|
{/* Yacht Title */}
|
||||||
<div className="px-4 pt-4">
|
<div className="px-4 pt-4">
|
||||||
<h1 className="text-xl font-bold text-[#333333] mb-4">
|
<h1 className="text-xl font-bold text-[#333333] mb-4">
|
||||||
{YACHT.name}
|
{yacht.name}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -142,26 +195,46 @@ export default function YachtDetailPage() {
|
||||||
{/* Tab Content */}
|
{/* Tab Content */}
|
||||||
<div className="px-4 py-6">
|
<div className="px-4 py-6">
|
||||||
{activeTab === "availability" && (
|
{activeTab === "availability" && (
|
||||||
<YachtAvailability
|
<>
|
||||||
price={YACHT.price}
|
<YachtAvailability
|
||||||
mobile={true}
|
price={String(yacht.minCost)}
|
||||||
/>
|
mobile={true}
|
||||||
|
reservations={yacht.reservations}
|
||||||
|
selectedDate={selectedDate}
|
||||||
|
startTime={startTime}
|
||||||
|
endTime={endTime}
|
||||||
|
onDateChange={setSelectedDate}
|
||||||
|
onStartTimeChange={setStartTime}
|
||||||
|
onEndTimeChange={setEndTime}
|
||||||
|
/>
|
||||||
|
{/* Выбор гостей для мобильной версии */}
|
||||||
|
<div className="mt-6">
|
||||||
|
<label className="block text-sm font-medium text-[#333333] mb-2">
|
||||||
|
Гостей
|
||||||
|
</label>
|
||||||
|
<GuestPicker
|
||||||
|
adults={guests.adults}
|
||||||
|
childrenCount={guests.children}
|
||||||
|
onChange={handleGuestsChange}
|
||||||
|
variant="small"
|
||||||
|
showIcon={false}
|
||||||
|
placeholder="1 гость"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{activeTab === "description" && (
|
{activeTab === "description" && (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-base text-[#666666] leading-relaxed">
|
<p className="text-base text-[#666666] leading-relaxed">
|
||||||
{YACHT.description}
|
{yacht.description}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{activeTab === "characteristics" && (
|
{activeTab === "characteristics" && (
|
||||||
<YachtCharacteristics yacht={YACHT} />
|
<YachtCharacteristics yacht={yacht} />
|
||||||
)}
|
)}
|
||||||
{activeTab === "contact" && (
|
{activeTab === "contact" && (
|
||||||
<ContactInfo
|
<ContactInfo {...yacht.owner} />
|
||||||
contactPerson={YACHT.contactPerson}
|
|
||||||
requisites={YACHT.requisites}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
{activeTab === "reviews" && (
|
{activeTab === "reviews" && (
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -186,13 +259,13 @@ export default function YachtDetailPage() {
|
||||||
{/* Yacht Title and Actions */}
|
{/* Yacht Title and Actions */}
|
||||||
<div className="mb-6 flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
<div className="mb-6 flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||||
<h1 className="text-xl md:text-2xl font-bold text-[#333333]">
|
<h1 className="text-xl md:text-2xl font-bold text-[#333333]">
|
||||||
{YACHT.name}
|
{yacht.name}
|
||||||
</h1>
|
</h1>
|
||||||
<div className="flex items-center gap-6">
|
<div className="flex items-center gap-6">
|
||||||
<div className="flex items-center gap-2 text-[#333333]">
|
<div className="flex items-center gap-2 text-[#333333]">
|
||||||
<Icon name="pin" size={32} />
|
<Icon name="pin" size={32} />
|
||||||
<span className="text-base">
|
<span className="text-base">
|
||||||
{YACHT.location}
|
{formatSpeed(yacht.speed)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button className="flex items-center gap-2 cursor-pointer text-[#333333] hover:text-[#008299] transition-colors">
|
<button className="flex items-center gap-2 cursor-pointer text-[#333333] hover:text-[#008299] transition-colors">
|
||||||
|
|
@ -212,8 +285,8 @@ export default function YachtDetailPage() {
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Gallery */}
|
{/* Gallery */}
|
||||||
<YachtGallery
|
<YachtGallery
|
||||||
images={YACHT.images}
|
images={yacht.galleryUrls || []}
|
||||||
badge={YACHT.badge}
|
badge={!yacht.hasQuickRent ? "По запросу" : ""}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Content with Booking Widget on the right */}
|
{/* Content with Booking Widget on the right */}
|
||||||
|
|
@ -221,10 +294,13 @@ export default function YachtDetailPage() {
|
||||||
{/* Left column - all content below gallery */}
|
{/* Left column - all content below gallery */}
|
||||||
<div className="flex-1 space-y-6">
|
<div className="flex-1 space-y-6">
|
||||||
{/* Availability */}
|
{/* Availability */}
|
||||||
<YachtAvailability price={YACHT.price} />
|
<YachtAvailability
|
||||||
|
price={String(yacht.minCost)}
|
||||||
|
reservations={yacht.reservations}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Characteristics */}
|
{/* Characteristics */}
|
||||||
<YachtCharacteristics yacht={YACHT} />
|
<YachtCharacteristics yacht={yacht} />
|
||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -232,15 +308,12 @@ export default function YachtDetailPage() {
|
||||||
Описание
|
Описание
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-base text-[#666666] leading-relaxed">
|
<p className="text-base text-[#666666] leading-relaxed">
|
||||||
{YACHT.description}
|
{yacht.description}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Contact and Requisites */}
|
{/* Contact and Requisites */}
|
||||||
<ContactInfo
|
<ContactInfo {...yacht.owner} />
|
||||||
contactPerson={YACHT.contactPerson}
|
|
||||||
requisites={YACHT.requisites}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Reviews */}
|
{/* Reviews */}
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -260,7 +333,10 @@ export default function YachtDetailPage() {
|
||||||
|
|
||||||
{/* Right column - Booking Widget (sticky) */}
|
{/* Right column - Booking Widget (sticky) */}
|
||||||
<div className="lg:w-74 flex-shrink-0 lg:sticky lg:top-24 self-start">
|
<div className="lg:w-74 flex-shrink-0 lg:sticky lg:top-24 self-start">
|
||||||
<BookingWidget price={YACHT.price} />
|
<BookingWidget
|
||||||
|
price={String(yacht.minCost)}
|
||||||
|
yacht={yacht}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -273,15 +349,16 @@ export default function YachtDetailPage() {
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-lg font-bold text-[#333333]">
|
<span className="text-lg font-bold text-[#333333]">
|
||||||
{YACHT.price} ₽
|
{yacht.minCost} ₽
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm text-[#999999] ml-1">
|
<span className="text-sm text-[#999999] ml-1">
|
||||||
/ час
|
/ час
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => router.push("/confirm")}
|
onClick={handleBookMobile}
|
||||||
className="bg-[#008299] text-white px-6 py-3 rounded-lg font-bold text-base hover:bg-[#006d7a] transition-colors"
|
disabled={!selectedDate || !startTime || !endTime}
|
||||||
|
className="bg-[#008299] text-white px-6 py-3 rounded-lg font-bold text-base hover:bg-[#006d7a] transition-colors disabled:bg-gray-300 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
Забронировать
|
Забронировать
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState, useCallback, useRef } from "react";
|
import { useEffect, useState, useCallback, useRef, Suspense } from "react";
|
||||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Icon from "@/components/ui/icon";
|
import Icon from "@/components/ui/icon";
|
||||||
|
|
@ -18,6 +18,7 @@ import { Button } from "@/components/ui/button";
|
||||||
import useApiClient from "@/hooks/useApiClient";
|
import useApiClient from "@/hooks/useApiClient";
|
||||||
import { formatMinCost, formatSpeed, formatWidth, getImageUrl } from "@/lib/utils";
|
import { formatMinCost, formatSpeed, formatWidth, getImageUrl } from "@/lib/utils";
|
||||||
import { useSearchParams, useRouter, usePathname } from "next/navigation";
|
import { useSearchParams, useRouter, usePathname } from "next/navigation";
|
||||||
|
import { CatalogFilteredResponseDto } from "@/api/types";
|
||||||
|
|
||||||
export interface CatalogFilters {
|
export interface CatalogFilters {
|
||||||
search: string;
|
search: string;
|
||||||
|
|
@ -49,7 +50,7 @@ export const defaultFilters: CatalogFilters = {
|
||||||
arrivalTime: "13:00",
|
arrivalTime: "13:00",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function CatalogPage() {
|
function CatalogPageContent() {
|
||||||
const [isFiltersOpen, setIsFiltersOpen] = useState(false);
|
const [isFiltersOpen, setIsFiltersOpen] = useState(false);
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
@ -203,6 +204,12 @@ export default function CatalogPage() {
|
||||||
params.set("arrivalTime", filters.arrivalTime);
|
params.set("arrivalTime", filters.arrivalTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Сохраняем сортировку, если она установлена
|
||||||
|
const sortByPrice = searchParams.get("sortByPrice");
|
||||||
|
if (sortByPrice) {
|
||||||
|
params.set("sortByPrice", sortByPrice);
|
||||||
|
}
|
||||||
|
|
||||||
// Обновляем URL без прокрутки страницы
|
// Обновляем URL без прокрутки страницы
|
||||||
const newUrl = params.toString()
|
const newUrl = params.toString()
|
||||||
? `${pathname}?${params.toString()}`
|
? `${pathname}?${params.toString()}`
|
||||||
|
|
@ -219,11 +226,29 @@ export default function CatalogPage() {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
const params: Record<string, string> = {
|
const allParams: Record<string, string> = {
|
||||||
search: searchParams.get("search") ?? "",
|
search: searchParams.get("search") ?? "",
|
||||||
|
minLength: searchParams.get("lengthMin") ?? "",
|
||||||
|
maxLength: searchParams.get("lengthMax") ?? "",
|
||||||
|
minPrice: searchParams.get("priceMin") ?? "",
|
||||||
|
maxPrice: searchParams.get("priceMax") ?? "",
|
||||||
|
minYear: searchParams.get("yearMin") ?? "",
|
||||||
|
maxYear: searchParams.get("yearMax") ?? "",
|
||||||
|
guests: searchParams.get("adults") && searchParams.get("children") ? `${Number(searchParams.get("adults")) + Number(searchParams.get("children"))}` : "",
|
||||||
|
paymentType: searchParams.get("paymentType") ?? "",
|
||||||
|
quickBooking: searchParams.get("quickBooking") ?? "",
|
||||||
|
hasToilet: searchParams.get("hasToilet") ?? "",
|
||||||
|
date: searchParams.get("date") ?? "",
|
||||||
|
departureTime: searchParams.get("departureTime") ?? "",
|
||||||
|
arrivalTime: searchParams.get("arrivalTime") ?? "",
|
||||||
|
sortByPrice: searchParams.get("sortByPrice") ?? "",
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await client.get<CatalogFilteredResponseDto>("/catalog/filtered/", {
|
const params = Object.fromEntries(
|
||||||
|
Object.entries(allParams).filter(([_, value]) => value !== "")
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await client.get<CatalogFilteredResponseDto>("/catalog/filter/", {
|
||||||
params,
|
params,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -346,7 +371,21 @@ export default function CatalogPage() {
|
||||||
<div className="text-base text-[#999999]">
|
<div className="text-base text-[#999999]">
|
||||||
Сортировка:
|
Сортировка:
|
||||||
</div>
|
</div>
|
||||||
<Select defaultValue="default">
|
<Select
|
||||||
|
value={searchParams.get("sortByPrice") || "default"}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
|
if (value === "default") {
|
||||||
|
params.delete("sortByPrice");
|
||||||
|
} else {
|
||||||
|
params.set("sortByPrice", value);
|
||||||
|
}
|
||||||
|
const newUrl = params.toString()
|
||||||
|
? `${pathname}?${params.toString()}`
|
||||||
|
: pathname;
|
||||||
|
router.replace(newUrl, { scroll: false });
|
||||||
|
}}
|
||||||
|
>
|
||||||
<SelectTrigger
|
<SelectTrigger
|
||||||
className="w-full"
|
className="w-full"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|
@ -357,18 +396,12 @@ export default function CatalogPage() {
|
||||||
<SelectItem value="default">
|
<SelectItem value="default">
|
||||||
По умолчанию
|
По умолчанию
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="price-asc">
|
<SelectItem value="asc">
|
||||||
Цена: по возрастанию
|
Цена: по возрастанию
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="price-desc">
|
<SelectItem value="desc">
|
||||||
Цена: по убыванию
|
Цена: по убыванию
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="length-asc">
|
|
||||||
Длина: по возрастанию
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="length-desc">
|
|
||||||
Длина: по убыванию
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -455,3 +488,11 @@ export default function CatalogPage() {
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function CatalogPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<div className="bg-[#f4f4f4] grow flex items-center justify-center">Загрузка...</div>}>
|
||||||
|
<CatalogPageContent />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,16 +12,84 @@ import {
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Icon from "@/components/ui/icon";
|
import Icon from "@/components/ui/icon";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { GuestDatePicker } from "@/components/form/guest-date-picker";
|
import { useRouter } from "next/navigation";
|
||||||
import { formatMinCost, formatWidth, getImageUrl } from "@/lib/utils";
|
import {
|
||||||
|
GuestDatePicker,
|
||||||
|
GuestDatePickerValue,
|
||||||
|
} from "@/components/form/guest-date-picker";
|
||||||
|
import {
|
||||||
|
formatMinCost,
|
||||||
|
formatWidth,
|
||||||
|
getImageUrl,
|
||||||
|
calculateTotalPrice,
|
||||||
|
formatPrice,
|
||||||
|
} from "@/lib/utils";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import { CatalogItemShortDto } from "@/api/types";
|
||||||
|
|
||||||
export default function FeaturedYacht({ yacht }: { yacht: CatalogItemDto }) {
|
export default function FeaturedYacht({
|
||||||
|
yacht,
|
||||||
|
}: {
|
||||||
|
yacht: CatalogItemShortDto;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
const [selectedImage, setSelectedImage] = useState(yacht.mainImageUrl);
|
const [selectedImage, setSelectedImage] = useState(yacht.mainImageUrl);
|
||||||
|
const [bookingData, setBookingData] = useState<GuestDatePickerValue>({
|
||||||
|
date: undefined,
|
||||||
|
departureTime: "12:00",
|
||||||
|
arrivalTime: "13:00",
|
||||||
|
adults: 1,
|
||||||
|
children: 0,
|
||||||
|
});
|
||||||
|
|
||||||
const handleThumbnailClick = (imageSrc: string) => {
|
const handleThumbnailClick = (imageSrc: string) => {
|
||||||
setSelectedImage(imageSrc);
|
setSelectedImage(imageSrc);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Расчет итоговой стоимости
|
||||||
|
const getTotalPrice = () => {
|
||||||
|
if (
|
||||||
|
!bookingData.date ||
|
||||||
|
!bookingData.departureTime ||
|
||||||
|
!bookingData.arrivalTime
|
||||||
|
) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Форматируем дату в ISO строку для calculateTotalPrice
|
||||||
|
const dateString = format(bookingData.date, "yyyy-MM-dd");
|
||||||
|
|
||||||
|
const { totalPrice } = calculateTotalPrice(
|
||||||
|
dateString,
|
||||||
|
bookingData.departureTime,
|
||||||
|
dateString, // Используем ту же дату для arrival
|
||||||
|
bookingData.arrivalTime,
|
||||||
|
yacht.minCost
|
||||||
|
);
|
||||||
|
|
||||||
|
return totalPrice;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Обработчик нажатия на кнопку "Забронировать"
|
||||||
|
const handleBookClick = () => {
|
||||||
|
if (!bookingData.date || !yacht.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Формируем URL с параметрами
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
yachtId: yacht.id.toString(),
|
||||||
|
departureDate: format(bookingData.date, "yyyy-MM-dd"),
|
||||||
|
departureTime: bookingData.departureTime,
|
||||||
|
arrivalDate: format(bookingData.date, "yyyy-MM-dd"),
|
||||||
|
arrivalTime: bookingData.arrivalTime,
|
||||||
|
guests: (bookingData.adults + bookingData.children).toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Переходим на страницу подтверждения
|
||||||
|
router.push(`/confirm?${params.toString()}`);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-10">
|
<div className="mb-10">
|
||||||
<Card className="overflow-hidden bg-white text-gray-900">
|
<Card className="overflow-hidden bg-white text-gray-900">
|
||||||
|
|
@ -89,15 +157,17 @@ export default function FeaturedYacht({ yacht }: { yacht: CatalogItemDto }) {
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Image
|
<Image
|
||||||
src={getImageUrl(thumb)}
|
src={getImageUrl(thumb)}
|
||||||
alt={`${yacht.name
|
alt={`${
|
||||||
} view ${idx + 1}`}
|
yacht.name
|
||||||
|
} view ${idx + 1}`}
|
||||||
width={80}
|
width={80}
|
||||||
height={60}
|
height={60}
|
||||||
className={`w-20 h-16 object-cover rounded-[8px] cursor-pointer border-2 transition-all ${selectedImage ===
|
className={`w-20 h-16 object-cover rounded-[8px] cursor-pointer border-2 transition-all ${
|
||||||
|
selectedImage ===
|
||||||
thumb
|
thumb
|
||||||
? "border-[#008299]"
|
? "border-[#008299]"
|
||||||
: "border-gray-200 hover:border-gray-400"
|
: "border-gray-200 hover:border-gray-400"
|
||||||
}`}
|
}`}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
handleThumbnailClick(
|
handleThumbnailClick(
|
||||||
thumb
|
thumb
|
||||||
|
|
@ -164,13 +234,20 @@ export default function FeaturedYacht({ yacht }: { yacht: CatalogItemDto }) {
|
||||||
|
|
||||||
{/* Booking form */}
|
{/* Booking form */}
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<GuestDatePicker />
|
<GuestDatePicker
|
||||||
|
value={bookingData}
|
||||||
|
onChange={setBookingData}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Book button */}
|
{/* Book button */}
|
||||||
<Button
|
<Button
|
||||||
variant="gradient"
|
variant="gradient"
|
||||||
className="font-bold text-white h-[64px] w-full px-8"
|
className="font-bold text-white h-[64px] w-full px-8"
|
||||||
|
onClick={handleBookClick}
|
||||||
|
disabled={
|
||||||
|
!bookingData.date || !yacht.id
|
||||||
|
}
|
||||||
>
|
>
|
||||||
Забронировать
|
Забронировать
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -180,7 +257,9 @@ export default function FeaturedYacht({ yacht }: { yacht: CatalogItemDto }) {
|
||||||
<span className="font-normal">
|
<span className="font-normal">
|
||||||
Итого:
|
Итого:
|
||||||
</span>
|
</span>
|
||||||
<span>0 ₽</span>
|
<span>
|
||||||
|
{formatPrice(getTotalPrice())} ₽
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,9 @@ import Link from "next/link";
|
||||||
export default function Hero() {
|
export default function Hero() {
|
||||||
const [adults, setAdults] = useState<number>(0);
|
const [adults, setAdults] = useState<number>(0);
|
||||||
const [children, setChildren] = useState<number>(0);
|
const [children, setChildren] = useState<number>(0);
|
||||||
|
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
|
||||||
|
const [departureTime, setDepartureTime] = useState<string>("12:00");
|
||||||
|
const [arrivalTime, setArrivalTime] = useState<string>("13:00");
|
||||||
return (
|
return (
|
||||||
<section className="relative h-[600px] rounded-[24px] mx-[16px] overflow-hidden flex text-white">
|
<section className="relative h-[600px] rounded-[24px] mx-[16px] overflow-hidden flex text-white">
|
||||||
<Image
|
<Image
|
||||||
|
|
@ -63,7 +66,14 @@ export default function Hero() {
|
||||||
|
|
||||||
{/* Дата и время */}
|
{/* Дата и время */}
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<DatePicker />
|
<DatePicker
|
||||||
|
value={selectedDate || undefined}
|
||||||
|
departureTime={departureTime}
|
||||||
|
arrivalTime={arrivalTime}
|
||||||
|
onDateChange={(date) => setSelectedDate(date || null)}
|
||||||
|
onDepartureTimeChange={setDepartureTime}
|
||||||
|
onArrivalTimeChange={setArrivalTime}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Количество гостей */}
|
{/* Количество гостей */}
|
||||||
|
|
@ -79,9 +89,20 @@ export default function Hero() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Кнопка поиска */}
|
{/* Кнопка поиска */}
|
||||||
<Button variant="gradient" className="font-bold text-white h-[64px] w-[176px] px-8">
|
<Link href={(() => {
|
||||||
Найти
|
const params = new URLSearchParams();
|
||||||
</Button>
|
if (adults > 0) params.append('adults', adults.toString());
|
||||||
|
if (children > 0) params.append('children', children.toString());
|
||||||
|
if (selectedDate) params.append('date', selectedDate.toString());
|
||||||
|
if (departureTime && departureTime !== "12:00") params.append('departureTime', departureTime);
|
||||||
|
if (arrivalTime && arrivalTime !== "13:00") params.append('arrivalTime', arrivalTime);
|
||||||
|
const queryString = params.toString();
|
||||||
|
return queryString ? `/catalog?${queryString}` : '/catalog';
|
||||||
|
})()}>
|
||||||
|
<Button variant="gradient" className="font-bold text-white h-[64px] w-[176px] px-8">
|
||||||
|
Найти
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
|
||||||
|
|
@ -8,157 +8,157 @@ import Link from "next/link";
|
||||||
import FeaturedYacht from "./FeaturedYacht";
|
import FeaturedYacht from "./FeaturedYacht";
|
||||||
import useApiClient from "@/hooks/useApiClient";
|
import useApiClient from "@/hooks/useApiClient";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { formatMinCost, formatSpeed, formatWidth, getImageUrl } from "@/lib/utils";
|
import {
|
||||||
|
formatMinCost,
|
||||||
|
formatSpeed,
|
||||||
|
formatWidth,
|
||||||
|
getImageUrl,
|
||||||
|
} from "@/lib/utils";
|
||||||
|
import { CatalogItemShortDto, MainPageCatalogResponseDto } from "@/api/types";
|
||||||
|
|
||||||
export default function YachtGrid() {
|
export default function YachtGrid() {
|
||||||
const client = useApiClient();
|
const client = useApiClient();
|
||||||
|
|
||||||
const [featuredYacht, setFeaturedYacht] = useState<CatalogItemDto | null>(null);
|
const [featuredYacht, setFeaturedYacht] =
|
||||||
const [yachtCatalog, setYachtCatalog] = useState<CatalogItemDto[] | null>(null);
|
useState<CatalogItemShortDto | null>(null);
|
||||||
|
const [yachtCatalog, setYachtCatalog] = useState<
|
||||||
|
CatalogItemShortDto[] | null
|
||||||
|
>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
const response = await client.get<MainPageCatalogResponseDto>("/catalog/main-page/");
|
const response = await client.get<MainPageCatalogResponseDto>(
|
||||||
setFeaturedYacht(response.data.featuredYacht);
|
"/catalog/main-page/"
|
||||||
setYachtCatalog(response.data.restYachts);
|
);
|
||||||
})();
|
setFeaturedYacht(response.data.featuredYacht);
|
||||||
}, [])
|
setYachtCatalog(response.data.restYachts);
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="text-white">
|
<section className="text-white">
|
||||||
<div className="container max-w-6xl mx-auto px-4 mt-6 md:mt-12">
|
<div className="container max-w-6xl mx-auto px-4 mt-6 md:mt-12">
|
||||||
{/* Header Section */}
|
{/* Header Section */}
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<h1 className="text-3xl md:text-4xl font-bold mb-4 text-black">
|
<h1 className="text-3xl md:text-4xl font-bold mb-4 text-black">
|
||||||
Яхты и катера в аренду
|
Яхты и катера в аренду
|
||||||
</h1>
|
</h1>
|
||||||
<h2 className="text-l text-black font-bold mb-2">
|
<h2 className="text-l text-black font-bold mb-2">
|
||||||
Онлайн бронирование яхт и катеров
|
Онлайн бронирование яхт и катеров
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-gray-700 max-w-3xl leading-relaxed">
|
<p className="text-gray-700 max-w-3xl leading-relaxed">
|
||||||
Каталог лучших яхт Балаклавы разных ценовых сегментах.
|
Каталог лучших яхт Балаклавы разных ценовых сегментах.
|
||||||
</p>
|
</p>
|
||||||
<p className="text-gray-700 leading-relaxed">
|
<p className="text-gray-700 leading-relaxed">
|
||||||
Проверенные лодки с лицензией на перевозки, опытные
|
Проверенные лодки с лицензией на перевозки, опытные капитаны.
|
||||||
капитаны. Выбирайте удобную дату, время и бронируйте.
|
Выбирайте удобную дату, время и бронируйте.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Featured Yacht Block */}
|
{/* Featured Yacht Block */}
|
||||||
{featuredYacht && (
|
{featuredYacht && <FeaturedYacht yacht={featuredYacht} />}
|
||||||
<FeaturedYacht yacht={featuredYacht} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Yacht Grid */}
|
{/* Yacht Grid */}
|
||||||
{yachtCatalog && (
|
{yachtCatalog && (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-6">
|
||||||
{yachtCatalog.map((yacht) => (
|
{yachtCatalog.map((yacht) => (
|
||||||
<Link
|
<Link
|
||||||
key={yacht.id}
|
key={yacht.id}
|
||||||
href={`/catalog/${yacht.id ?? 0}}`}
|
href={`/catalog/${yacht.id ?? 0}`}
|
||||||
className="block"
|
className="block"
|
||||||
>
|
>
|
||||||
<Card className="overflow-hidden bg-white text-gray-900 cursor-pointer transition-all duration-200 hover:shadow-lg">
|
<Card className="overflow-hidden bg-white text-gray-900 cursor-pointer transition-all duration-200 hover:shadow-lg">
|
||||||
<CardHeader className="p-0 relative">
|
<CardHeader className="p-0 relative">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
{/* Best Offer Badge - над карточкой */}
|
{/* Best Offer Badge - над карточкой */}
|
||||||
{yacht.topText && (
|
{yacht.topText && (
|
||||||
<div className="w-full flex justify-center">
|
<div className="w-full flex justify-center">
|
||||||
<div
|
<div
|
||||||
className="flex w-full items-center justify-center text-white px-4 py-2 text-sm font-medium bg-cover bg-center bg-no-repeat"
|
className="flex w-full items-center justify-center text-white px-4 py-2 text-sm font-medium bg-cover bg-center bg-no-repeat"
|
||||||
style={{
|
style={{
|
||||||
backgroundImage:
|
backgroundImage:
|
||||||
"url('/images/best-yacht-bg.jpg')",
|
"url('/images/best-yacht-bg.jpg')",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span>
|
<span>{yacht.topText}</span>
|
||||||
{yacht.topText}
|
</div>
|
||||||
</span>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
<Image
|
||||||
)}
|
src={getImageUrl(yacht.mainImageUrl)}
|
||||||
<Image
|
alt={yacht.name}
|
||||||
src={getImageUrl(yacht.mainImageUrl)}
|
width={400}
|
||||||
alt={yacht.name}
|
height={250}
|
||||||
width={400}
|
className="w-full h-48 object-cover"
|
||||||
height={250}
|
/>
|
||||||
className="w-full h-48 object-cover"
|
{/* Badge Overlay */}
|
||||||
/>
|
{!yacht.hasQuickRent && !yacht.topText && (
|
||||||
{/* Badge Overlay */}
|
<>
|
||||||
{!yacht.hasQuickRent && !yacht.topText && (
|
<div className="absolute top-3 left-3">
|
||||||
<>
|
<div className="flex items-center justify-center bg-black/40 text-white px-3 py-1 rounded-lg text-sm flex items-center gap-1">
|
||||||
<div className="absolute top-3 left-3">
|
<Icon size={16} name="restart" />
|
||||||
<div className="flex items-center justify-center bg-black/40 text-white px-3 py-1 rounded-lg text-sm flex items-center gap-1">
|
<span>По запросу</span>
|
||||||
<Icon
|
</div>
|
||||||
size={16}
|
</div>
|
||||||
name="restart"
|
</>
|
||||||
/>
|
)}
|
||||||
<span>
|
|
||||||
По запросу
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<div className="flex justify-between gap-4">
|
|
||||||
{/* Левая колонка - название и длина */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<h3 className="font-bold text-l">
|
|
||||||
{yacht.name}
|
|
||||||
</h3>
|
|
||||||
<div className="flex items-center gap-1 text-sm">
|
|
||||||
<Icon size={16} name="width" />
|
|
||||||
<span>{formatWidth(yacht.length)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Правая колонка - цена и футы */}
|
|
||||||
<div className="flex flex-col justify-between">
|
|
||||||
<div className="w-fit">
|
|
||||||
{yacht.isBestOffer ? (
|
|
||||||
<p
|
|
||||||
style={{
|
|
||||||
background:
|
|
||||||
"linear-gradient(90deg, #008299 0%, #7E8FFF 100%)",
|
|
||||||
}}
|
|
||||||
className="text-l font-bold text-white pl-2 pr-3 rounded-t-[5px] rounded-bl-[10px] rounded-br-[30px] whitespace-nowrap"
|
|
||||||
>
|
|
||||||
{formatMinCost(yacht.minCost)} / час
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<p className="w-fit text-l whitespace-nowrap">
|
|
||||||
{formatMinCost(yacht.minCost)} / час
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1 text-sm">
|
|
||||||
<Icon size={16} name="anchor" />
|
|
||||||
<span>{formatSpeed(yacht.speed)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</CardHeader>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex justify-between gap-4">
|
||||||
|
{/* Левая колонка - название и длина */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="font-bold text-l">{yacht.name}</h3>
|
||||||
|
<div className="flex items-center gap-1 text-sm">
|
||||||
|
<Icon size={16} name="width" />
|
||||||
|
<span>{formatWidth(yacht.length)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Call to Action Button */}
|
{/* Правая колонка - цена и футы */}
|
||||||
<div className="text-center">
|
<div className="flex flex-col justify-between">
|
||||||
<Link href="/catalog">
|
<div className="w-fit">
|
||||||
<Button
|
{yacht.isBestOffer ? (
|
||||||
size="lg"
|
<p
|
||||||
className="bg-white text-gray-900 hover:bg-gray-100 px-8 py-3 h-[56px] w-[336px] text-lg font-bold"
|
style={{
|
||||||
>
|
background:
|
||||||
Каталог яхт
|
"linear-gradient(90deg, #008299 0%, #7E8FFF 100%)",
|
||||||
</Button>
|
}}
|
||||||
</Link>
|
className="text-l font-bold text-white pl-2 pr-3 rounded-t-[5px] rounded-bl-[10px] rounded-br-[30px] whitespace-nowrap"
|
||||||
</div>
|
>
|
||||||
</div>
|
{formatMinCost(yacht.minCost)} / час
|
||||||
</section>
|
</p>
|
||||||
);
|
) : (
|
||||||
|
<p className="w-fit text-l whitespace-nowrap">
|
||||||
|
{formatMinCost(yacht.minCost)} / час
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 text-sm">
|
||||||
|
<Icon size={16} name="anchor" />
|
||||||
|
<span>{formatSpeed(yacht.speed)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Call to Action Button */}
|
||||||
|
<div className="text-center">
|
||||||
|
<Link href="/catalog">
|
||||||
|
<Button
|
||||||
|
size="lg"
|
||||||
|
className="bg-white text-gray-900 hover:bg-gray-100 px-8 py-3 h-[56px] w-[336px] text-lg font-bold"
|
||||||
|
>
|
||||||
|
Каталог яхт
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,31 +3,80 @@
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { User, ArrowUpRight, Map, ArrowLeft, Heart } from "lucide-react";
|
import { User, ArrowUpRight, Map, ArrowLeft, Heart } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useEffect, useState, Suspense } from "react";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import useApiClient from "@/hooks/useApiClient";
|
||||||
|
import { getImageUrl, formatPrice, calculateTotalPrice } from "@/lib/utils";
|
||||||
|
import { parseISO } from "date-fns";
|
||||||
|
import { CatalogItemLongDto } from "@/api/types";
|
||||||
|
|
||||||
export default function ConfirmPage() {
|
function ConfirmPageContent() {
|
||||||
|
const [yacht, setYacht] = useState<CatalogItemLongDto | null>(null);
|
||||||
|
|
||||||
|
const client = useApiClient();
|
||||||
const [promocode, setPromocode] = useState("");
|
const [promocode, setPromocode] = useState("");
|
||||||
|
const [isPromocodeApplied, setIsPromocodeApplied] = useState(false);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
// Извлекаем параметры из URL
|
// Извлекаем параметры из URL
|
||||||
const yachtId = searchParams.get("yachtId");
|
const yachtId = searchParams.get("yachtId");
|
||||||
const guestCount = searchParams.get("guestCount");
|
const guestCount = searchParams.get("guests");
|
||||||
const departureDate = searchParams.get("departureDate");
|
const departureDate = searchParams.get("departureDate");
|
||||||
const departureTime = searchParams.get("departureTime");
|
const departureTime = searchParams.get("departureTime");
|
||||||
const arrivalDate = searchParams.get("arrivalDate");
|
const arrivalDate = searchParams.get("arrivalDate");
|
||||||
const arrivalTime = searchParams.get("arrivalTime");
|
const arrivalTime = searchParams.get("arrivalTime");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
const response = await client.get<CatalogItemLongDto>(
|
||||||
|
`/catalog/${yachtId}/`
|
||||||
|
);
|
||||||
|
setYacht(response.data);
|
||||||
|
})();
|
||||||
|
}, [yachtId]);
|
||||||
|
|
||||||
|
// Расчет стоимости через функцию
|
||||||
|
const { totalHours, totalPrice } = calculateTotalPrice(
|
||||||
|
departureDate,
|
||||||
|
departureTime,
|
||||||
|
arrivalDate,
|
||||||
|
arrivalTime,
|
||||||
|
yacht?.minCost || 0
|
||||||
|
);
|
||||||
|
|
||||||
|
// Обработчик применения промокода
|
||||||
|
const handlePromocodeApply = () => {
|
||||||
|
if (promocode.trim().toUpperCase() === "DISCOUNT50") {
|
||||||
|
setIsPromocodeApplied(true);
|
||||||
|
} else {
|
||||||
|
setIsPromocodeApplied(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Финальная цена с учетом скидки
|
||||||
|
const finalPrice = isPromocodeApplied ? totalPrice * 0.5 : totalPrice;
|
||||||
|
|
||||||
// Функция для форматирования даты (краткий формат)
|
// Функция для форматирования даты (краткий формат)
|
||||||
const formatDate = (dateString: string | null) => {
|
const formatDate = (dateString: string | null) => {
|
||||||
if (!dateString) return null;
|
if (!dateString) return null;
|
||||||
try {
|
try {
|
||||||
const date = new Date(dateString);
|
const date = parseISO(dateString);
|
||||||
const months = [
|
const months = [
|
||||||
"янв", "фев", "мар", "апр", "май", "июн",
|
"янв",
|
||||||
"июл", "авг", "сен", "окт", "ноя", "дек"
|
"фев",
|
||||||
|
"мар",
|
||||||
|
"апр",
|
||||||
|
"май",
|
||||||
|
"июн",
|
||||||
|
"июл",
|
||||||
|
"авг",
|
||||||
|
"сен",
|
||||||
|
"окт",
|
||||||
|
"ноя",
|
||||||
|
"дек",
|
||||||
];
|
];
|
||||||
const day = date.getDate();
|
const day = date.getDate();
|
||||||
const month = months[date.getMonth()];
|
const month = months[date.getMonth()];
|
||||||
|
|
@ -41,10 +90,20 @@ export default function ConfirmPage() {
|
||||||
const formatDateFull = (dateString: string | null) => {
|
const formatDateFull = (dateString: string | null) => {
|
||||||
if (!dateString) return null;
|
if (!dateString) return null;
|
||||||
try {
|
try {
|
||||||
const date = new Date(dateString);
|
const date = parseISO(dateString);
|
||||||
const months = [
|
const months = [
|
||||||
"января", "февраля", "марта", "апреля", "мая", "июня",
|
"января",
|
||||||
"июля", "августа", "сентября", "октября", "ноября", "декабря"
|
"февраля",
|
||||||
|
"марта",
|
||||||
|
"апреля",
|
||||||
|
"мая",
|
||||||
|
"июня",
|
||||||
|
"июля",
|
||||||
|
"августа",
|
||||||
|
"сентября",
|
||||||
|
"октября",
|
||||||
|
"ноября",
|
||||||
|
"декабря",
|
||||||
];
|
];
|
||||||
const day = date.getDate();
|
const day = date.getDate();
|
||||||
const month = months[date.getMonth()];
|
const month = months[date.getMonth()];
|
||||||
|
|
@ -57,8 +116,9 @@ export default function ConfirmPage() {
|
||||||
// Функция для форматирования времени
|
// Функция для форматирования времени
|
||||||
const formatTime = (timeString: string | null) => {
|
const formatTime = (timeString: string | null) => {
|
||||||
if (!timeString) return null;
|
if (!timeString) return null;
|
||||||
// Предполагаем формат HH:mm или HH:mm:ss
|
// Декодируем URL-encoded строку (например, 00%3A00 -> 00:00)
|
||||||
return timeString.split(":").slice(0, 2).join(":");
|
const decoded = decodeURIComponent(timeString);
|
||||||
|
return decoded.split(":").slice(0, 2).join(":");
|
||||||
};
|
};
|
||||||
|
|
||||||
// Форматируем данные для отображения
|
// Форматируем данные для отображения
|
||||||
|
|
@ -72,22 +132,68 @@ export default function ConfirmPage() {
|
||||||
const arrivalDateFormattedFull = formatDateFull(arrivalDate);
|
const arrivalDateFormattedFull = formatDateFull(arrivalDate);
|
||||||
|
|
||||||
// Формируем строки для отображения
|
// Формируем строки для отображения
|
||||||
const departureDisplay = departureDateFormatted && departureTimeFormatted
|
const departureDisplay =
|
||||||
? `${departureDateFormatted} ${departureTimeFormatted}`
|
departureDateFormatted && departureTimeFormatted
|
||||||
: "Не выбрано";
|
? `${departureDateFormatted} ${departureTimeFormatted}`
|
||||||
|
: "Не выбрано";
|
||||||
|
|
||||||
const arrivalDisplay = arrivalDateFormatted && arrivalTimeFormatted
|
const arrivalDisplay =
|
||||||
? `${arrivalDateFormatted} ${arrivalTimeFormatted}`
|
arrivalDateFormatted && arrivalTimeFormatted
|
||||||
: "Не выбрано";
|
? `${arrivalDateFormatted} ${arrivalTimeFormatted}`
|
||||||
|
: "Не выбрано";
|
||||||
|
|
||||||
const datesDisplay = departureDateFormattedFull && departureTimeFormatted && arrivalDateFormattedFull && arrivalTimeFormatted
|
const datesDisplay =
|
||||||
? `${departureDateFormattedFull} в ${departureTimeFormatted} — ${arrivalDateFormattedFull} в ${arrivalTimeFormatted}`
|
departureDateFormattedFull &&
|
||||||
: "Не выбрано";
|
departureTimeFormatted &&
|
||||||
|
arrivalDateFormattedFull &&
|
||||||
|
arrivalTimeFormatted
|
||||||
|
? `${departureDateFormattedFull} в ${departureTimeFormatted} — ${arrivalDateFormattedFull} в ${arrivalTimeFormatted}`
|
||||||
|
: "Не выбрано";
|
||||||
|
|
||||||
const guestsDisplay = guestCount
|
const guestsDisplay = guestCount
|
||||||
? guestCount === "1" ? "1 гость" : `${guestCount} гостей`
|
? guestCount === "1"
|
||||||
|
? "1 гость"
|
||||||
|
: `${guestCount} гостей`
|
||||||
: "Не выбрано";
|
: "Не выбрано";
|
||||||
|
|
||||||
|
const { mutate } = useMutation({
|
||||||
|
mutationKey: ["create-reservation", yachtId],
|
||||||
|
mutationFn: async () => {
|
||||||
|
if (
|
||||||
|
!departureDate ||
|
||||||
|
!departureTime ||
|
||||||
|
!yachtId ||
|
||||||
|
!arrivalDate ||
|
||||||
|
!arrivalTime
|
||||||
|
) {
|
||||||
|
throw new Error("Ошибка получения данных бронирования");
|
||||||
|
}
|
||||||
|
|
||||||
|
const departureDateTime = new Date(
|
||||||
|
`${departureDate}T${departureTime}`
|
||||||
|
);
|
||||||
|
const arrivalDateTime = new Date(`${arrivalDate}T${arrivalTime}`);
|
||||||
|
|
||||||
|
const startUtc = Math.floor(departureDateTime.getTime() / 1000);
|
||||||
|
const endUtc = Math.floor(arrivalDateTime.getTime() / 1000);
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
startUtc,
|
||||||
|
endUtc,
|
||||||
|
yachtId: Number(yachtId),
|
||||||
|
reservatorId: Number("userId"), // TODO
|
||||||
|
};
|
||||||
|
|
||||||
|
await client.post("/reservations", body);
|
||||||
|
|
||||||
|
router.push("/profile/reservations");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!yacht) {
|
||||||
|
return <div />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="bg-[#f4f4f4] grow">
|
<main className="bg-[#f4f4f4] grow">
|
||||||
{/* Мобильная версия */}
|
{/* Мобильная версия */}
|
||||||
|
|
@ -110,11 +216,15 @@ export default function ConfirmPage() {
|
||||||
{/* Центральный блок с информацией */}
|
{/* Центральный блок с информацией */}
|
||||||
<div className="flex-1 min-w-0 text-center">
|
<div className="flex-1 min-w-0 text-center">
|
||||||
<h2 className="text-base font-bold text-[#333333] mb-1">
|
<h2 className="text-base font-bold text-[#333333] mb-1">
|
||||||
Яхта Сеньорита
|
Яхта {yacht.name}
|
||||||
</h2>
|
</h2>
|
||||||
<div className="flex justify-center gap-10 text-xs text-[#666666]">
|
<div className="flex justify-center gap-10 text-xs text-[#666666]">
|
||||||
<span>{departureDateFormatted || "Не выбрано"}</span>
|
<span>
|
||||||
<span>Гостей: {guestCount || "Не выбрано"}</span>
|
{departureDateFormatted || "Не выбрано"}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
Гостей: {guestCount || "Не выбрано"}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -205,28 +315,53 @@ export default function ConfirmPage() {
|
||||||
Детализация цены
|
Детализация цены
|
||||||
</h3>
|
</h3>
|
||||||
<div>
|
<div>
|
||||||
<div className="flex justify-between items-center mb-4">
|
{totalHours > 0 && yacht.minCost ? (
|
||||||
<span className="text-[#333333]">
|
<>
|
||||||
26 400₽ x 2ч
|
<div className="flex justify-between items-center mb-4">
|
||||||
</span>
|
<span className="text-[#333333]">
|
||||||
<span className="text-[#333333]">
|
{formatPrice(yacht.minCost)}₽ ×{" "}
|
||||||
52 800 ₽
|
{totalHours}ч
|
||||||
</span>
|
</span>
|
||||||
</div>
|
<span className="text-[#333333]">
|
||||||
<div className="flex justify-between items-center mb-4 pb-4 border-b border-[#DFDFDF]">
|
{formatPrice(totalPrice)} ₽
|
||||||
<span className="text-[#333333]">
|
</span>
|
||||||
Услуги
|
</div>
|
||||||
</span>
|
<div className="flex justify-between items-center mb-4 pb-4 border-b border-[#DFDFDF]">
|
||||||
<span className="text-[#333333]">0 Р</span>
|
<span className="text-[#333333]">
|
||||||
</div>
|
Услуги
|
||||||
<div className="flex justify-between items-center">
|
</span>
|
||||||
<span className="text-[#333333]">
|
<span className="text-[#333333]">
|
||||||
Итого:
|
0 Р
|
||||||
</span>
|
</span>
|
||||||
<span className="font-bold text-[#333333]">
|
</div>
|
||||||
52 800 Р
|
{isPromocodeApplied && (
|
||||||
</span>
|
<div className="flex justify-between items-center mb-4">
|
||||||
</div>
|
<span className="text-[#333333]">
|
||||||
|
Скидка (DISCOUNT50):
|
||||||
|
</span>
|
||||||
|
<span className="text-[#2D908D] font-bold">
|
||||||
|
-
|
||||||
|
{formatPrice(
|
||||||
|
totalPrice * 0.5
|
||||||
|
)}{" "}
|
||||||
|
Р
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-[#333333]">
|
||||||
|
Итого:
|
||||||
|
</span>
|
||||||
|
<span className="font-bold text-[#333333]">
|
||||||
|
{formatPrice(finalPrice)} Р
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="text-[#999999] text-center py-4">
|
||||||
|
Укажите даты для расчета стоимости
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -237,13 +372,20 @@ export default function ConfirmPage() {
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Промокод"
|
placeholder="Промокод"
|
||||||
value={promocode}
|
value={promocode}
|
||||||
onChange={(e) =>
|
onChange={(e) => {
|
||||||
setPromocode(e.target.value)
|
setPromocode(e.target.value);
|
||||||
}
|
setIsPromocodeApplied(false);
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
handlePromocodeApply();
|
||||||
|
}
|
||||||
|
}}
|
||||||
className="flex-1 min-w-0 px-4 py-3 h-[64px] border border-[#DFDFDF] rounded-full text-sm text-[#757575] focus:outline-none focus:ring-2 focus:ring-[#008299] focus:border-transparent"
|
className="flex-1 min-w-0 px-4 py-3 h-[64px] border border-[#DFDFDF] rounded-full text-sm text-[#757575] focus:outline-none focus:ring-2 focus:ring-[#008299] focus:border-transparent"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
|
onClick={handlePromocodeApply}
|
||||||
className="flex-shrink-0 h-[64px] w-[64px] bg-[#2D908D] hover:bg-[#007088] text-white rounded-full p-0 transition-colors duration-200"
|
className="flex-shrink-0 h-[64px] w-[64px] bg-[#2D908D] hover:bg-[#007088] text-white rounded-full p-0 transition-colors duration-200"
|
||||||
>
|
>
|
||||||
<ArrowUpRight size={14} />
|
<ArrowUpRight size={14} />
|
||||||
|
|
@ -255,6 +397,8 @@ export default function ConfirmPage() {
|
||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
className="w-full h-[56px] bg-[#2D908D] hover:bg-[#007088] text-white font-bold rounded-full transition-colors duration-200"
|
className="w-full h-[56px] bg-[#2D908D] hover:bg-[#007088] text-white font-bold rounded-full transition-colors duration-200"
|
||||||
|
disabled={totalHours === 0}
|
||||||
|
onClick={() => mutate()}
|
||||||
>
|
>
|
||||||
Отправить заявку
|
Отправить заявку
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -286,7 +430,9 @@ export default function ConfirmPage() {
|
||||||
{/* Изображение яхты */}
|
{/* Изображение яхты */}
|
||||||
<div className="relative mb-5">
|
<div className="relative mb-5">
|
||||||
<Image
|
<Image
|
||||||
src="/images/yachts/yacht1.jpg"
|
src={getImageUrl(
|
||||||
|
yacht.mainImageUrl
|
||||||
|
)}
|
||||||
alt="Яхта"
|
alt="Яхта"
|
||||||
width={400}
|
width={400}
|
||||||
height={250}
|
height={250}
|
||||||
|
|
@ -304,7 +450,7 @@ export default function ConfirmPage() {
|
||||||
Владелец
|
Владелец
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[#333333] font-bold">
|
<span className="text-[#333333] font-bold">
|
||||||
Денис
|
{yacht.owner.firstName}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -312,7 +458,7 @@ export default function ConfirmPage() {
|
||||||
</div>
|
</div>
|
||||||
{/* Название яхты */}
|
{/* Название яхты */}
|
||||||
<h3 className="text-base text-[#333333] pb-3 border-b border-[#DFDFDF] mb-4">
|
<h3 className="text-base text-[#333333] pb-3 border-b border-[#DFDFDF] mb-4">
|
||||||
Яхта
|
Яхта {yacht.name}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
{/* Детализация цены */}
|
{/* Детализация цены */}
|
||||||
|
|
@ -321,30 +467,64 @@ export default function ConfirmPage() {
|
||||||
Детализация цены
|
Детализация цены
|
||||||
</h4>
|
</h4>
|
||||||
<div>
|
<div>
|
||||||
<div className="flex justify-between items-center mb-4">
|
{totalHours > 0 && yacht.minCost ? (
|
||||||
<span className="text-[#333333]">
|
<>
|
||||||
26 400₽ x 2ч
|
<div className="flex justify-between items-center mb-4">
|
||||||
</span>
|
<span className="text-[#333333]">
|
||||||
<span className="text-[#333333]">
|
{formatPrice(
|
||||||
52 800 ₽
|
yacht.minCost
|
||||||
</span>
|
)}
|
||||||
</div>
|
₽ × {totalHours}ч
|
||||||
<div className="flex justify-between items-center border-b border-[#DFDFDF] pb-4 mb-4">
|
</span>
|
||||||
<span className="text-[#333333]">
|
<span className="text-[#333333]">
|
||||||
Услуги
|
{formatPrice(
|
||||||
</span>
|
totalPrice
|
||||||
<span className="text-[#333333]">
|
)}{" "}
|
||||||
0 Р
|
₽
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center border-b border-[#DFDFDF] pb-4 mb-4">
|
||||||
<span className="text-[#333333]">
|
<span className="text-[#333333]">
|
||||||
Итого:
|
Услуги
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[#333333] font-bold">
|
<span className="text-[#333333]">
|
||||||
52 800 Р
|
0 Р
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{isPromocodeApplied && (
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<span className="text-[#333333]">
|
||||||
|
Скидка
|
||||||
|
(DISCOUNT50):
|
||||||
|
</span>
|
||||||
|
<span className="text-[#2D908D] font-bold">
|
||||||
|
-
|
||||||
|
{formatPrice(
|
||||||
|
totalPrice *
|
||||||
|
0.5
|
||||||
|
)}{" "}
|
||||||
|
Р
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-[#333333]">
|
||||||
|
Итого:
|
||||||
|
</span>
|
||||||
|
<span className="text-[#333333] font-bold">
|
||||||
|
{formatPrice(
|
||||||
|
finalPrice
|
||||||
|
)}{" "}
|
||||||
|
Р
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="text-[#999999] text-center py-4">
|
||||||
|
Укажите даты для расчета
|
||||||
|
стоимости
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -358,13 +538,20 @@ export default function ConfirmPage() {
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Промокод"
|
placeholder="Промокод"
|
||||||
value={promocode}
|
value={promocode}
|
||||||
onChange={(e) =>
|
onChange={(e) => {
|
||||||
setPromocode(e.target.value)
|
setPromocode(e.target.value);
|
||||||
}
|
setIsPromocodeApplied(false);
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
handlePromocodeApply();
|
||||||
|
}
|
||||||
|
}}
|
||||||
className="flex-1 min-w-0 px-4 sm:px-8 py-5 h-[64px] border border-[#DFDFDF] rounded-full text-base text-[#757575] focus:outline-none focus:ring-2 focus:ring-[#008299] focus:border-transparent"
|
className="flex-1 min-w-0 px-4 sm:px-8 py-5 h-[64px] border border-[#DFDFDF] rounded-full text-base text-[#757575] focus:outline-none focus:ring-2 focus:ring-[#008299] focus:border-transparent"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
|
onClick={handlePromocodeApply}
|
||||||
className="flex-shrink-0 h-[64px] w-[64px] bg-[#2D908D] hover:bg-[#007088] text-white rounded-full p-0 transition-colors duration-200"
|
className="flex-shrink-0 h-[64px] w-[64px] bg-[#2D908D] hover:bg-[#007088] text-white rounded-full p-0 transition-colors duration-200"
|
||||||
>
|
>
|
||||||
<ArrowUpRight size={12} />
|
<ArrowUpRight size={12} />
|
||||||
|
|
@ -385,7 +572,7 @@ export default function ConfirmPage() {
|
||||||
Ваше бронирование
|
Ваше бронирование
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
{/* Сведения о бронировании */}
|
{/* Сведения о бронирования */}
|
||||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||||
{/* Даты */}
|
{/* Даты */}
|
||||||
<div className="grow-1 border border-[#DFDFDF] rounded-[8px] p-4">
|
<div className="grow-1 border border-[#DFDFDF] rounded-[8px] p-4">
|
||||||
|
|
@ -445,6 +632,8 @@ export default function ConfirmPage() {
|
||||||
variant="default"
|
variant="default"
|
||||||
size="lg"
|
size="lg"
|
||||||
className="flex-shrink-0 h-[64px] w-[270px] bg-[#2D908D] hover:bg-[#007088] text-white font-bold rounded-full p-0 transition-colors duration-200 hover:shadow-lg"
|
className="flex-shrink-0 h-[64px] w-[270px] bg-[#2D908D] hover:bg-[#007088] text-white font-bold rounded-full p-0 transition-colors duration-200 hover:shadow-lg"
|
||||||
|
disabled={totalHours === 0}
|
||||||
|
onClick={() => mutate()}
|
||||||
>
|
>
|
||||||
Отправить заявку
|
Отправить заявку
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -457,3 +646,17 @@ export default function ConfirmPage() {
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function ConfirmPage() {
|
||||||
|
return (
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<div className="bg-[#f4f4f4] grow flex items-center justify-center">
|
||||||
|
Загрузка...
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ConfirmPageContent />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>></span>
|
||||||
|
<Link href="/profile">
|
||||||
|
<span className="cursor-pointer hover:text-[#333333] transition-colors">
|
||||||
|
Личный кабинет
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
<span>></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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>></span>
|
||||||
|
<Link href="/profile">
|
||||||
|
<span className="cursor-pointer hover:text-[#333333] transition-colors">
|
||||||
|
Личный кабинет
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
<span>></span>
|
||||||
|
<Link href="/profile/yachts">
|
||||||
|
<span className="cursor-pointer hover:text-[#333333] transition-colors">
|
||||||
|
Мои яхты
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
<span>></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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>></span>
|
||||||
|
<Link href="/profile">
|
||||||
|
<span className="cursor-pointer hover:text-[#333333] transition-colors">
|
||||||
|
Личный кабинет
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
<span>></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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -9,302 +9,367 @@ import { Button } from "@/components/ui/button";
|
||||||
import { Calendar } from "@/components/ui/calendar";
|
import { Calendar } from "@/components/ui/calendar";
|
||||||
import { Counter } from "@/components/ui/counter";
|
import { Counter } from "@/components/ui/counter";
|
||||||
import {
|
import {
|
||||||
Popover,
|
Popover,
|
||||||
PopoverContent,
|
PopoverContent,
|
||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
} from "@/components/ui/popover";
|
} from "@/components/ui/popover";
|
||||||
|
|
||||||
|
export interface GuestDatePickerValue {
|
||||||
|
date: Date | undefined;
|
||||||
|
departureTime: string;
|
||||||
|
arrivalTime: string;
|
||||||
|
adults: number;
|
||||||
|
children: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface GuestDatePickerProps {
|
interface GuestDatePickerProps {
|
||||||
onApply?: (data: {
|
value?: GuestDatePickerValue;
|
||||||
date: Date | undefined;
|
onChange?: (value: GuestDatePickerValue) => void;
|
||||||
departureTime: string;
|
onApply?: (data: GuestDatePickerValue) => void;
|
||||||
arrivalTime: string;
|
className?: string;
|
||||||
adults: number;
|
|
||||||
children: number;
|
|
||||||
}) => void;
|
|
||||||
className?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CommonPopoverContentProps {
|
interface CommonPopoverContentProps {
|
||||||
date: Date | undefined;
|
date: Date | undefined;
|
||||||
setDate: (date: Date | undefined) => void;
|
setDate: (date: Date | undefined) => void;
|
||||||
departureTime: string;
|
departureTime: string;
|
||||||
setDepartureTime: (time: string) => void;
|
setDepartureTime: (time: string) => void;
|
||||||
arrivalTime: string;
|
arrivalTime: string;
|
||||||
setArrivalTime: (time: string) => void;
|
setArrivalTime: (time: string) => void;
|
||||||
adults: number;
|
adults: number;
|
||||||
setAdults: (count: number) => void;
|
setAdults: (count: number) => void;
|
||||||
childrenCount: number;
|
childrenCount: number;
|
||||||
setChildrenCount: (count: number) => void;
|
setChildrenCount: (count: number) => void;
|
||||||
handleApply: () => void;
|
handleApply: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CommonPopoverContent: React.FC<CommonPopoverContentProps> = ({
|
const CommonPopoverContent: React.FC<CommonPopoverContentProps> = ({
|
||||||
date,
|
date,
|
||||||
setDate,
|
setDate,
|
||||||
departureTime,
|
departureTime,
|
||||||
setDepartureTime,
|
setDepartureTime,
|
||||||
arrivalTime,
|
arrivalTime,
|
||||||
setArrivalTime,
|
setArrivalTime,
|
||||||
adults,
|
adults,
|
||||||
setAdults,
|
setAdults,
|
||||||
childrenCount,
|
childrenCount,
|
||||||
setChildrenCount,
|
setChildrenCount,
|
||||||
handleApply,
|
handleApply,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<PopoverContent className="rounded-[20px] p-6 pb-4 w-[324px]">
|
<PopoverContent className="rounded-[20px] p-6 pb-4 w-[324px]">
|
||||||
{/* Календарь */}
|
{/* Календарь */}
|
||||||
|
|
||||||
<Calendar
|
<Calendar
|
||||||
mode="single"
|
mode="single"
|
||||||
selected={date}
|
selected={date}
|
||||||
onSelect={setDate}
|
onSelect={setDate}
|
||||||
className="mb-[24px]"
|
className="mb-[24px]"
|
||||||
locale={ru}
|
locale={ru}
|
||||||
disabled={(date) =>
|
disabled={(date) => date < new Date(new Date().setHours(0, 0, 0, 0))}
|
||||||
date < new Date(new Date().setHours(0, 0, 0, 0))
|
classNames={{
|
||||||
}
|
root: "w-full",
|
||||||
classNames={{
|
month: "flex w-full flex-col gap-4",
|
||||||
root: "w-full",
|
button_previous:
|
||||||
month: "flex w-full flex-col gap-4",
|
"h-8 w-8 flex items-center justify-center hover:bg-gray-100 rounded-md",
|
||||||
button_previous:
|
button_next:
|
||||||
"h-8 w-8 flex items-center justify-center hover:bg-gray-100 rounded-md",
|
"h-8 w-8 flex items-center justify-center hover:bg-gray-100 rounded-md",
|
||||||
button_next:
|
month_caption:
|
||||||
"h-8 w-8 flex items-center justify-center hover:bg-gray-100 rounded-md",
|
"flex h-8 w-full items-center justify-center px-8 text-gray-700 font-semibold",
|
||||||
month_caption:
|
table: "w-full border-collapse",
|
||||||
"flex h-8 w-full items-center justify-center px-8 text-gray-700 font-semibold",
|
weekdays: "flex",
|
||||||
table: "w-full border-collapse",
|
weekday: "flex-1 text-gray-500 text-xs font-normal p-2 text-center",
|
||||||
weekdays: "flex",
|
day_button: "font-bold ring-0 focus:ring-0",
|
||||||
weekday:
|
week: "mt-2 flex w-full",
|
||||||
"flex-1 text-gray-500 text-xs font-normal p-2 text-center",
|
today: "bg-gray-100 text-gray-900 rounded-full",
|
||||||
day_button: "font-bold ring-0 focus:ring-0",
|
outside: "text-gray-300",
|
||||||
week: "mt-2 flex w-full",
|
disabled: "text-gray-400 cursor-not-allowed",
|
||||||
today: "bg-gray-100 text-gray-900 rounded-full",
|
selected:
|
||||||
outside: "text-gray-300",
|
"rounded-full border-none outline-none !bg-brand text-white",
|
||||||
disabled: "text-gray-400 cursor-not-allowed",
|
}}
|
||||||
selected:
|
/>
|
||||||
"rounded-full border-none outline-none !bg-brand text-white",
|
|
||||||
}}
|
{/* Счетчики гостей */}
|
||||||
|
<div className="mb-[24px] flex gap-3">
|
||||||
|
<Counter
|
||||||
|
label="Взрослые"
|
||||||
|
value={adults}
|
||||||
|
onChange={setAdults}
|
||||||
|
min={0}
|
||||||
|
max={10}
|
||||||
|
/>
|
||||||
|
<Counter
|
||||||
|
label="Дети"
|
||||||
|
value={childrenCount}
|
||||||
|
onChange={setChildrenCount}
|
||||||
|
min={0}
|
||||||
|
max={10}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Поля времени */}
|
||||||
|
<div className="flex gap-3 mb-6">
|
||||||
|
<div className="relative w-full h-12 px-3 border border-gray-300 rounded-full text-gray-700 font-medium text-center">
|
||||||
|
<label className="absolute left-[24px] top-0 transform -translate-y-1/2 text-xs text-gray-500 pointer-events-none transition-all duration-200 bg-white px-1">
|
||||||
|
Выход
|
||||||
|
</label>
|
||||||
|
<div className="relative h-full flex align-center">
|
||||||
|
<ChevronDownIcon className="absolute right-0 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-500 pointer-events-none" />
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={departureTime}
|
||||||
|
onChange={(e) => setDepartureTime(e.target.value)}
|
||||||
|
className="w-full focus:outline-none focus:border-transparent"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative w-full h-12 px-3 border border-gray-300 rounded-full text-gray-700 font-medium text-center">
|
||||||
|
<label className="absolute left-[24px] top-0 transform -translate-y-1/2 text-xs text-gray-500 pointer-events-none transition-all duration-200 bg-white px-1">
|
||||||
|
Заход
|
||||||
|
</label>
|
||||||
|
<div className="relative h-full flex align-center">
|
||||||
|
<ChevronDownIcon className="absolute right-0 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-500 pointer-events-none" />
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={arrivalTime}
|
||||||
|
onChange={(e) => setArrivalTime(e.target.value)}
|
||||||
|
className="w-full focus:outline-none focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Счетчики гостей */}
|
{/* Кнопка Применить */}
|
||||||
<div className="mb-[24px] flex gap-3">
|
<Button
|
||||||
<Counter
|
onClick={handleApply}
|
||||||
label="Взрослые"
|
variant="gradient"
|
||||||
value={adults}
|
className="font-bold text-white h-[44px] w-full px-8"
|
||||||
onChange={setAdults}
|
>
|
||||||
min={0}
|
Применить
|
||||||
max={10}
|
</Button>
|
||||||
/>
|
</PopoverContent>
|
||||||
<Counter
|
);
|
||||||
label="Дети"
|
|
||||||
value={childrenCount}
|
|
||||||
onChange={setChildrenCount}
|
|
||||||
min={0}
|
|
||||||
max={10}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Поля времени */}
|
|
||||||
<div className="flex gap-3 mb-6">
|
|
||||||
<div className="relative w-full h-12 px-3 border border-gray-300 rounded-full text-gray-700 font-medium text-center">
|
|
||||||
<label className="absolute left-[24px] top-0 transform -translate-y-1/2 text-xs text-gray-500 pointer-events-none transition-all duration-200 bg-white px-1">
|
|
||||||
Выход
|
|
||||||
</label>
|
|
||||||
<div className="relative h-full flex align-center">
|
|
||||||
<ChevronDownIcon className="absolute right-0 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-500 pointer-events-none" />
|
|
||||||
<input
|
|
||||||
type="time"
|
|
||||||
value={departureTime}
|
|
||||||
onChange={(e) => setDepartureTime(e.target.value)}
|
|
||||||
className="w-full focus:outline-none focus:border-transparent"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative w-full h-12 px-3 border border-gray-300 rounded-full text-gray-700 font-medium text-center">
|
|
||||||
<label className="absolute left-[24px] top-0 transform -translate-y-1/2 text-xs text-gray-500 pointer-events-none transition-all duration-200 bg-white px-1">
|
|
||||||
Заход
|
|
||||||
</label>
|
|
||||||
<div className="relative h-full flex align-center">
|
|
||||||
<ChevronDownIcon className="absolute right-0 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-500 pointer-events-none" />
|
|
||||||
<input
|
|
||||||
type="time"
|
|
||||||
value={arrivalTime}
|
|
||||||
onChange={(e) => setArrivalTime(e.target.value)}
|
|
||||||
className="w-full focus:outline-none focus:border-transparent"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Кнопка Применить */}
|
|
||||||
<Button
|
|
||||||
onClick={handleApply}
|
|
||||||
variant="gradient"
|
|
||||||
className="font-bold text-white h-[44px] w-full px-8"
|
|
||||||
>
|
|
||||||
Применить
|
|
||||||
</Button>
|
|
||||||
</PopoverContent>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const GuestDatePicker: React.FC<GuestDatePickerProps> = ({
|
export const GuestDatePicker: React.FC<GuestDatePickerProps> = ({
|
||||||
onApply,
|
value,
|
||||||
className,
|
onChange,
|
||||||
|
onApply,
|
||||||
|
className,
|
||||||
}) => {
|
}) => {
|
||||||
const [date, setDate] = useState<Date>();
|
// Используем controlled значения, если они переданы, иначе используем внутреннее состояние
|
||||||
const [departureTime, setDepartureTime] = useState("12:00");
|
const isControlled = value !== undefined;
|
||||||
const [arrivalTime, setArrivalTime] = useState("13:00");
|
|
||||||
const [adults, setAdults] = useState(1);
|
|
||||||
const [children, setChildren] = useState(0);
|
|
||||||
const [isDepartureOpen, setIsDepartureOpen] = useState(false);
|
|
||||||
const [isArrivalOpen, setIsArrivalOpen] = useState(false);
|
|
||||||
const [isGuestOpen, setIsGuestOpen] = useState(false);
|
|
||||||
|
|
||||||
const handleApply = () => {
|
const [internalDate, setInternalDate] = useState<Date>();
|
||||||
onApply?.({
|
const [internalDepartureTime, setInternalDepartureTime] = useState("12:00");
|
||||||
date,
|
const [internalArrivalTime, setInternalArrivalTime] = useState("13:00");
|
||||||
departureTime,
|
const [internalAdults, setInternalAdults] = useState(1);
|
||||||
arrivalTime,
|
const [internalChildren, setInternalChildren] = useState(0);
|
||||||
adults,
|
|
||||||
children,
|
const date = isControlled ? value.date : internalDate;
|
||||||
});
|
const departureTime = isControlled ? value.departureTime : internalDepartureTime;
|
||||||
setIsDepartureOpen(false);
|
const arrivalTime = isControlled ? value.arrivalTime : internalArrivalTime;
|
||||||
setIsArrivalOpen(false);
|
const adults = isControlled ? value.adults : internalAdults;
|
||||||
setIsGuestOpen(false);
|
const children = isControlled ? value.children : internalChildren;
|
||||||
|
|
||||||
|
const setDate = (newDate: Date | undefined) => {
|
||||||
|
if (isControlled) {
|
||||||
|
onChange?.({
|
||||||
|
...value,
|
||||||
|
date: newDate,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setInternalDate(newDate);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setDepartureTime = (newTime: string) => {
|
||||||
|
if (isControlled) {
|
||||||
|
onChange?.({
|
||||||
|
...value,
|
||||||
|
departureTime: newTime,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setInternalDepartureTime(newTime);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setArrivalTime = (newTime: string) => {
|
||||||
|
if (isControlled) {
|
||||||
|
onChange?.({
|
||||||
|
...value,
|
||||||
|
arrivalTime: newTime,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setInternalArrivalTime(newTime);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setAdults = (newAdults: number) => {
|
||||||
|
if (isControlled) {
|
||||||
|
onChange?.({
|
||||||
|
...value,
|
||||||
|
adults: newAdults,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setInternalAdults(newAdults);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setChildren = (newChildren: number) => {
|
||||||
|
if (isControlled) {
|
||||||
|
onChange?.({
|
||||||
|
...value,
|
||||||
|
children: newChildren,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setInternalChildren(newChildren);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const [isDepartureOpen, setIsDepartureOpen] = useState(false);
|
||||||
|
const [isArrivalOpen, setIsArrivalOpen] = useState(false);
|
||||||
|
const [isGuestOpen, setIsGuestOpen] = useState(false);
|
||||||
|
|
||||||
|
const handleApply = () => {
|
||||||
|
const currentValue = {
|
||||||
|
date,
|
||||||
|
departureTime,
|
||||||
|
arrivalTime,
|
||||||
|
adults,
|
||||||
|
children,
|
||||||
};
|
};
|
||||||
|
onApply?.(currentValue);
|
||||||
|
setIsDepartureOpen(false);
|
||||||
|
setIsArrivalOpen(false);
|
||||||
|
setIsGuestOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
const getDepartureDisplayText = () => {
|
const getDepartureDisplayText = () => {
|
||||||
if (!date || !departureTime) return "Выход";
|
if (!date || !departureTime) return "Выход";
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{format(date, "d MMMM", {
|
|
||||||
locale: ru,
|
|
||||||
})}
|
|
||||||
, <span className="font-bold">{departureTime}</span>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getArrivalDisplayText = () => {
|
|
||||||
if (!date || !arrivalTime) return "Заход";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{format(date, "d MMMM", {
|
|
||||||
locale: ru,
|
|
||||||
})}
|
|
||||||
, <span className="font-bold">{arrivalTime}</span>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getGuestDisplayText = () => {
|
|
||||||
if (adults === 1 && children === 0) return "1 гость";
|
|
||||||
return (
|
|
||||||
<span className="font-bold">
|
|
||||||
Взрослых: {adults}, Детей: {children}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={className}>
|
<>
|
||||||
<div className="space-y-5">
|
{format(date, "d MMMM", {
|
||||||
{/* Кнопка Выход */}
|
locale: ru,
|
||||||
<Popover
|
})}
|
||||||
open={isDepartureOpen}
|
, <span className="font-bold">{departureTime}</span>
|
||||||
onOpenChange={setIsDepartureOpen}
|
</>
|
||||||
>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="h-[64px] px-4 w-full justify-between font-normal"
|
|
||||||
>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<span>{getDepartureDisplayText()}</span>
|
|
||||||
</div>
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<CommonPopoverContent
|
|
||||||
date={date}
|
|
||||||
setDate={setDate}
|
|
||||||
departureTime={departureTime}
|
|
||||||
setDepartureTime={setDepartureTime}
|
|
||||||
arrivalTime={arrivalTime}
|
|
||||||
setArrivalTime={setArrivalTime}
|
|
||||||
adults={adults}
|
|
||||||
setAdults={setAdults}
|
|
||||||
childrenCount={children}
|
|
||||||
setChildrenCount={setChildren}
|
|
||||||
handleApply={handleApply}
|
|
||||||
/>
|
|
||||||
</Popover>
|
|
||||||
|
|
||||||
{/* Кнопка Заход */}
|
|
||||||
<Popover open={isArrivalOpen} onOpenChange={setIsArrivalOpen}>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="h-[64px] px-4 w-full justify-between font-normal"
|
|
||||||
>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<span>{getArrivalDisplayText()}</span>
|
|
||||||
</div>
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<CommonPopoverContent
|
|
||||||
date={date}
|
|
||||||
setDate={setDate}
|
|
||||||
departureTime={departureTime}
|
|
||||||
setDepartureTime={setDepartureTime}
|
|
||||||
arrivalTime={arrivalTime}
|
|
||||||
setArrivalTime={setArrivalTime}
|
|
||||||
adults={adults}
|
|
||||||
setAdults={setAdults}
|
|
||||||
childrenCount={children}
|
|
||||||
setChildrenCount={setChildren}
|
|
||||||
handleApply={handleApply}
|
|
||||||
/>
|
|
||||||
</Popover>
|
|
||||||
|
|
||||||
{/* Кнопка Гости */}
|
|
||||||
<Popover open={isGuestOpen} onOpenChange={setIsGuestOpen}>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="h-[64px] px-4 w-full justify-between font-normal"
|
|
||||||
>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<span>{getGuestDisplayText()}</span>
|
|
||||||
</div>
|
|
||||||
{isGuestOpen ? (
|
|
||||||
<ChevronUpIcon className="h-4 w-4" />
|
|
||||||
) : (
|
|
||||||
<ChevronDownIcon className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<CommonPopoverContent
|
|
||||||
date={date}
|
|
||||||
setDate={setDate}
|
|
||||||
departureTime={departureTime}
|
|
||||||
setDepartureTime={setDepartureTime}
|
|
||||||
arrivalTime={arrivalTime}
|
|
||||||
setArrivalTime={setArrivalTime}
|
|
||||||
adults={adults}
|
|
||||||
setAdults={setAdults}
|
|
||||||
childrenCount={children}
|
|
||||||
setChildrenCount={setChildren}
|
|
||||||
handleApply={handleApply}
|
|
||||||
/>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getArrivalDisplayText = () => {
|
||||||
|
if (!date || !arrivalTime) return "Заход";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{format(date, "d MMMM", {
|
||||||
|
locale: ru,
|
||||||
|
})}
|
||||||
|
, <span className="font-bold">{arrivalTime}</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getGuestDisplayText = () => {
|
||||||
|
if (adults === 1 && children === 0) return "1 гость";
|
||||||
|
return (
|
||||||
|
<span className="font-bold">
|
||||||
|
Взрослых: {adults}, Детей: {children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
<div className="space-y-5">
|
||||||
|
{/* Кнопка Выход */}
|
||||||
|
<Popover open={isDepartureOpen} onOpenChange={setIsDepartureOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="h-[64px] px-4 w-full justify-between font-normal"
|
||||||
|
>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<span>{getDepartureDisplayText()}</span>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<CommonPopoverContent
|
||||||
|
date={date}
|
||||||
|
setDate={setDate}
|
||||||
|
departureTime={departureTime}
|
||||||
|
setDepartureTime={setDepartureTime}
|
||||||
|
arrivalTime={arrivalTime}
|
||||||
|
setArrivalTime={setArrivalTime}
|
||||||
|
adults={adults}
|
||||||
|
setAdults={setAdults}
|
||||||
|
childrenCount={children}
|
||||||
|
setChildrenCount={setChildren}
|
||||||
|
handleApply={handleApply}
|
||||||
|
/>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
|
{/* Кнопка Заход */}
|
||||||
|
<Popover open={isArrivalOpen} onOpenChange={setIsArrivalOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="h-[64px] px-4 w-full justify-between font-normal"
|
||||||
|
>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<span>{getArrivalDisplayText()}</span>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<CommonPopoverContent
|
||||||
|
date={date}
|
||||||
|
setDate={setDate}
|
||||||
|
departureTime={departureTime}
|
||||||
|
setDepartureTime={setDepartureTime}
|
||||||
|
arrivalTime={arrivalTime}
|
||||||
|
setArrivalTime={setArrivalTime}
|
||||||
|
adults={adults}
|
||||||
|
setAdults={setAdults}
|
||||||
|
childrenCount={children}
|
||||||
|
setChildrenCount={setChildren}
|
||||||
|
handleApply={handleApply}
|
||||||
|
/>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
|
{/* Кнопка Гости */}
|
||||||
|
<Popover open={isGuestOpen} onOpenChange={setIsGuestOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="h-[64px] px-4 w-full justify-between font-normal"
|
||||||
|
>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<span>{getGuestDisplayText()}</span>
|
||||||
|
</div>
|
||||||
|
{isGuestOpen ? (
|
||||||
|
<ChevronUpIcon className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<ChevronDownIcon className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<CommonPopoverContent
|
||||||
|
date={date}
|
||||||
|
setDate={setDate}
|
||||||
|
departureTime={departureTime}
|
||||||
|
setDepartureTime={setDepartureTime}
|
||||||
|
arrivalTime={arrivalTime}
|
||||||
|
setArrivalTime={setArrivalTime}
|
||||||
|
adults={adults}
|
||||||
|
setAdults={setAdults}
|
||||||
|
childrenCount={children}
|
||||||
|
setChildrenCount={setChildren}
|
||||||
|
handleApply={handleApply}
|
||||||
|
/>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,24 @@ import { Button } from "@/components/ui/button";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { User, Menu } from "lucide-react";
|
import { User, Menu } from "lucide-react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import AuthDialog from "@/components/layout/AuthDialog";
|
import AuthDialog from "@/components/layout/AuthDialog";
|
||||||
import useAuthPopup from "@/stores/useAuthPopup";
|
import useAuthPopup from "@/stores/useAuthPopup";
|
||||||
|
import useAuthStore from "@/stores/useAuthStore";
|
||||||
|
|
||||||
export default function Header() {
|
export default function Header() {
|
||||||
const authPopup = useAuthPopup();
|
const authPopup = useAuthPopup();
|
||||||
|
const router = useRouter();
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
|
||||||
|
const handleProfileClick = () => {
|
||||||
|
const token = authStore.getToken();
|
||||||
|
if (token) {
|
||||||
|
router.push("/profile/reservations");
|
||||||
|
} else {
|
||||||
|
authPopup.open();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-50 bg-white/95 backdrop-blur-sm border-b border-gray-100">
|
<header className="sticky top-0 z-50 bg-white/95 backdrop-blur-sm border-b border-gray-100">
|
||||||
|
|
@ -36,7 +49,7 @@ export default function Header() {
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={authPopup.open}
|
onClick={handleProfileClick}
|
||||||
className="text-gray-700 w-[100px] h-[48px] border"
|
className="text-gray-700 w-[100px] h-[48px] border"
|
||||||
>
|
>
|
||||||
<Menu className="!h-[24px] !w-[24px]" />
|
<Menu className="!h-[24px] !w-[24px]" />
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,8 @@ interface DatePickerProps {
|
||||||
onDateChange?: (date: Date | undefined) => void;
|
onDateChange?: (date: Date | undefined) => void;
|
||||||
onDepartureTimeChange?: (time: string) => void;
|
onDepartureTimeChange?: (time: string) => void;
|
||||||
onArrivalTimeChange?: (time: string) => void;
|
onArrivalTimeChange?: (time: string) => void;
|
||||||
|
onlyDeparture?: boolean;
|
||||||
|
onlyArrival?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DatePicker({
|
export function DatePicker({
|
||||||
|
|
@ -36,21 +38,37 @@ export function DatePicker({
|
||||||
onDateChange,
|
onDateChange,
|
||||||
onDepartureTimeChange,
|
onDepartureTimeChange,
|
||||||
onArrivalTimeChange,
|
onArrivalTimeChange,
|
||||||
|
onlyDeparture,
|
||||||
|
onlyArrival,
|
||||||
}: DatePickerProps) {
|
}: DatePickerProps) {
|
||||||
const [internalDate, setInternalDate] = React.useState<Date>();
|
const [internalDate, setInternalDate] = React.useState<Date>();
|
||||||
const [internalDepartureTime, setInternalDepartureTime] = React.useState("12:00");
|
const [internalDepartureTime, setInternalDepartureTime] =
|
||||||
const [internalArrivalTime, setInternalArrivalTime] = React.useState("13:00");
|
React.useState("12:00");
|
||||||
|
const [internalArrivalTime, setInternalArrivalTime] =
|
||||||
|
React.useState("13:00");
|
||||||
const [open, setOpen] = React.useState(false);
|
const [open, setOpen] = React.useState(false);
|
||||||
|
|
||||||
|
// Определяем, является ли компонент контролируемым
|
||||||
|
const isControlled =
|
||||||
|
value !== undefined ||
|
||||||
|
externalDepartureTime !== undefined ||
|
||||||
|
externalArrivalTime !== undefined;
|
||||||
|
|
||||||
// Используем внешние значения, если они предоставлены, иначе внутренние
|
// Используем внешние значения, если они предоставлены, иначе внутренние
|
||||||
const date = value !== undefined ? (value || undefined) : internalDate;
|
const date = value !== undefined ? value || undefined : internalDate;
|
||||||
const departureTime = externalDepartureTime !== undefined ? externalDepartureTime : internalDepartureTime;
|
const departureTime =
|
||||||
const arrivalTime = externalArrivalTime !== undefined ? externalArrivalTime : internalArrivalTime;
|
externalDepartureTime !== undefined
|
||||||
|
? externalDepartureTime
|
||||||
|
: internalDepartureTime;
|
||||||
|
const arrivalTime =
|
||||||
|
externalArrivalTime !== undefined
|
||||||
|
? externalArrivalTime
|
||||||
|
: internalArrivalTime;
|
||||||
|
|
||||||
const handleDateChange = (newDate: Date | undefined) => {
|
const handleDateChange = (newDate: Date | undefined) => {
|
||||||
if (onDateChange) {
|
if (onDateChange) {
|
||||||
onDateChange(newDate);
|
onDateChange(newDate);
|
||||||
} else {
|
} else if (!isControlled) {
|
||||||
setInternalDate(newDate);
|
setInternalDate(newDate);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -58,7 +76,7 @@ export function DatePicker({
|
||||||
const handleDepartureTimeChange = (time: string) => {
|
const handleDepartureTimeChange = (time: string) => {
|
||||||
if (onDepartureTimeChange) {
|
if (onDepartureTimeChange) {
|
||||||
onDepartureTimeChange(time);
|
onDepartureTimeChange(time);
|
||||||
} else {
|
} else if (!isControlled) {
|
||||||
setInternalDepartureTime(time);
|
setInternalDepartureTime(time);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -66,7 +84,7 @@ export function DatePicker({
|
||||||
const handleArrivalTimeChange = (time: string) => {
|
const handleArrivalTimeChange = (time: string) => {
|
||||||
if (onArrivalTimeChange) {
|
if (onArrivalTimeChange) {
|
||||||
onArrivalTimeChange(time);
|
onArrivalTimeChange(time);
|
||||||
} else {
|
} else if (!isControlled) {
|
||||||
setInternalArrivalTime(time);
|
setInternalArrivalTime(time);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -94,11 +112,19 @@ export function DatePicker({
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{date ? (
|
{date ? (
|
||||||
format(
|
(() => {
|
||||||
date,
|
let timeFormat = "";
|
||||||
`d MMMM, ${departureTime} - ${arrivalTime}`,
|
if (onlyDeparture) {
|
||||||
{ locale: ru }
|
timeFormat = `d MMMM, ${departureTime}`;
|
||||||
)
|
} else if (onlyArrival) {
|
||||||
|
timeFormat = `d MMMM, ${arrivalTime}`;
|
||||||
|
} else {
|
||||||
|
timeFormat = `d MMMM, ${departureTime} - ${arrivalTime}`;
|
||||||
|
}
|
||||||
|
return format(date, timeFormat, {
|
||||||
|
locale: ru,
|
||||||
|
});
|
||||||
|
})()
|
||||||
) : (
|
) : (
|
||||||
<span>{placeholder}</span>
|
<span>{placeholder}</span>
|
||||||
)}
|
)}
|
||||||
|
|
@ -147,39 +173,47 @@ export function DatePicker({
|
||||||
|
|
||||||
{/* Поля времени */}
|
{/* Поля времени */}
|
||||||
<div className="flex gap-3 mb-4">
|
<div className="flex gap-3 mb-4">
|
||||||
<div className="relative w-full h-12 px-3 border border-gray-300 rounded-full text-gray-700 font-medium text-center">
|
{onlyDeparture && (
|
||||||
<label className="absolute left-[24px] top-0 transform -translate-y-1/2 text-xs text-gray-500 pointer-events-none transition-all duration-200 bg-white px-1">
|
<div className="relative w-full h-12 px-3 border border-gray-300 rounded-full text-gray-700 font-medium text-center">
|
||||||
Выход
|
<label className="absolute left-[24px] top-0 transform -translate-y-1/2 text-xs text-gray-500 pointer-events-none transition-all duration-200 bg-white px-1">
|
||||||
</label>
|
Выход
|
||||||
<div className="relative h-full flex align-center">
|
</label>
|
||||||
<ChevronDownIcon className="absolute right-0 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-500 pointer-events-none" />
|
<div className="relative h-full flex align-center">
|
||||||
<input
|
<ChevronDownIcon className="absolute right-0 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-500 pointer-events-none" />
|
||||||
type="time"
|
<input
|
||||||
value={departureTime}
|
type="time"
|
||||||
onChange={(e) =>
|
value={departureTime}
|
||||||
handleDepartureTimeChange(e.target.value)
|
onChange={(e) =>
|
||||||
}
|
handleDepartureTimeChange(
|
||||||
className="w-full focus:outline-none focus:border-transparent"
|
e.target.value
|
||||||
/>
|
)
|
||||||
|
}
|
||||||
|
className="w-full focus:outline-none focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
<div className="relative w-full h-12 px-3 border border-gray-300 rounded-full text-gray-700 font-medium text-center">
|
{onlyArrival && (
|
||||||
<label className="absolute left-[24px] top-0 transform -translate-y-1/2 text-xs text-gray-500 pointer-events-none transition-all duration-200 bg-white px-1">
|
<div className="relative w-full h-12 px-3 border border-gray-300 rounded-full text-gray-700 font-medium text-center">
|
||||||
Заход
|
<label className="absolute left-[24px] top-0 transform -translate-y-1/2 text-xs text-gray-500 pointer-events-none transition-all duration-200 bg-white px-1">
|
||||||
</label>
|
Заход
|
||||||
<div className="relative h-full flex align-center">
|
</label>
|
||||||
<ChevronDownIcon className="absolute right-0 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-500 pointer-events-none" />
|
<div className="relative h-full flex align-center">
|
||||||
<input
|
<ChevronDownIcon className="absolute right-0 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-500 pointer-events-none" />
|
||||||
type="time"
|
<input
|
||||||
value={arrivalTime}
|
type="time"
|
||||||
onChange={(e) =>
|
value={arrivalTime}
|
||||||
handleArrivalTimeChange(e.target.value)
|
onChange={(e) =>
|
||||||
}
|
handleArrivalTimeChange(
|
||||||
className="w-full focus:outline-none focus:border-transparent"
|
e.target.value
|
||||||
/>
|
)
|
||||||
|
}
|
||||||
|
className="w-full focus:outline-none focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Кнопка Применить */}
|
{/* Кнопка Применить */}
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
|
@ -3,43 +3,43 @@ import useAuthStore from "@/stores/useAuthStore";
|
||||||
import useAuthPopup from "@/stores/useAuthPopup";
|
import useAuthPopup from "@/stores/useAuthPopup";
|
||||||
|
|
||||||
const useApiClient = () => {
|
const useApiClient = () => {
|
||||||
const { getToken } = useAuthStore();
|
const { getToken } = useAuthStore();
|
||||||
const authPopup = useAuthPopup();
|
const authPopup = useAuthPopup();
|
||||||
|
|
||||||
const apiClient = axios.create({
|
const apiClient = axios.create({
|
||||||
baseURL: "http://192.168.1.5:4000/",
|
baseURL: "https://api.travelmarine.ru",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
apiClient.interceptors.request.use(
|
apiClient.interceptors.request.use(
|
||||||
(config) => {
|
(config) => {
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
|
|
||||||
if (token) {
|
if (token) {
|
||||||
config.headers.Authorization = `Token ${token}`;
|
config.headers.Authorization = `Token ${token}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return config;
|
return config;
|
||||||
},
|
},
|
||||||
(error) => {
|
(error) => {
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
apiClient.interceptors.response.use(
|
apiClient.interceptors.response.use(
|
||||||
(response) => response,
|
(response) => response,
|
||||||
(error) => {
|
(error) => {
|
||||||
if (error.response?.status === 401) {
|
if (error.response?.status === 401) {
|
||||||
console.error("Authentication error:", error);
|
console.error("Authentication error:", error);
|
||||||
authPopup.open();
|
authPopup.open();
|
||||||
}
|
}
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
return apiClient;
|
return apiClient;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default useApiClient;
|
export default useApiClient;
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,26 @@
|
||||||
import { clsx, type ClassValue } from "clsx"
|
import { clsx, type ClassValue } from "clsx";
|
||||||
import { twMerge } from "tailwind-merge"
|
import { twMerge } from "tailwind-merge";
|
||||||
|
import { differenceInHours, parseISO } from "date-fns";
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs))
|
return twMerge(clsx(inputs));
|
||||||
}
|
}
|
||||||
|
|
||||||
const API_BASE_URL = "http://89.169.188.2";
|
const API_BASE_URL = "https://api.travelmarine.ru";
|
||||||
|
|
||||||
export const getImageUrl = (relativePath: string): string => {
|
export const getImageUrl = (relativePath: string): string => {
|
||||||
if (!relativePath) return "";
|
if (!relativePath) return "";
|
||||||
// Если путь уже абсолютный, возвращаем как есть
|
// Если путь уже абсолютный, возвращаем как есть
|
||||||
if (relativePath.startsWith("http://") || relativePath.startsWith("https://")) {
|
if (
|
||||||
|
relativePath.startsWith("http://") ||
|
||||||
|
relativePath.startsWith("https://")
|
||||||
|
) {
|
||||||
return relativePath;
|
return relativePath;
|
||||||
}
|
}
|
||||||
// Убираем начальный слеш, если есть, и формируем абсолютный URL
|
// Убираем начальный слеш, если есть, и формируем абсолютный URL
|
||||||
const cleanPath = relativePath.startsWith("/") ? relativePath.slice(1) : relativePath;
|
const cleanPath = relativePath.startsWith("/")
|
||||||
|
? relativePath.slice(1)
|
||||||
|
: relativePath;
|
||||||
return `${API_BASE_URL}/${cleanPath}`;
|
return `${API_BASE_URL}/${cleanPath}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -29,3 +35,56 @@ export const formatSpeed = (speed: number): string => {
|
||||||
export const formatMinCost = (minCost: number): string => {
|
export const formatMinCost = (minCost: number): string => {
|
||||||
return "от " + minCost + " ₽";
|
return "от " + minCost + " ₽";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const formatPrice = (price: number): string => {
|
||||||
|
return new Intl.NumberFormat("ru-RU").format(price);
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface TotalPriceResult {
|
||||||
|
totalHours: number;
|
||||||
|
totalPrice: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const calculateTotalPrice = (
|
||||||
|
departureDate: string | null,
|
||||||
|
departureTime: string | null,
|
||||||
|
arrivalDate: string | null,
|
||||||
|
arrivalTime: string | null,
|
||||||
|
pricePerHour: number
|
||||||
|
): TotalPriceResult => {
|
||||||
|
if (
|
||||||
|
!departureDate ||
|
||||||
|
!departureTime ||
|
||||||
|
!arrivalDate ||
|
||||||
|
!arrivalTime ||
|
||||||
|
!pricePerHour
|
||||||
|
) {
|
||||||
|
return { totalHours: 0, totalPrice: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Создаем полные даты
|
||||||
|
const departureDateTime = parseISO(`${departureDate}T${departureTime}`);
|
||||||
|
const arrivalDateTime = parseISO(`${arrivalDate}T${arrivalTime}`);
|
||||||
|
|
||||||
|
// Рассчитываем разницу в часах (с округлением до 0.5 часа)
|
||||||
|
let hoursDiff = differenceInHours(arrivalDateTime, departureDateTime);
|
||||||
|
|
||||||
|
// Добавляем разницу в минутах
|
||||||
|
const minutesDiff =
|
||||||
|
(arrivalDateTime.getMinutes() - departureDateTime.getMinutes()) /
|
||||||
|
60;
|
||||||
|
hoursDiff += minutesDiff;
|
||||||
|
|
||||||
|
// Округляем до ближайших 0.5 часа
|
||||||
|
const roundedHours = Math.ceil(hoursDiff * 2) / 2;
|
||||||
|
|
||||||
|
// Рассчитываем стоимость
|
||||||
|
const total = pricePerHour * roundedHours;
|
||||||
|
|
||||||
|
return { totalHours: roundedHours, totalPrice: total };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error calculating price:", error);
|
||||||
|
return { totalHours: 0, totalPrice: 0 };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,14 @@ interface AuthStore {
|
||||||
setToken: (token: string, rememberMe?: boolean) => void;
|
setToken: (token: string, rememberMe?: boolean) => void;
|
||||||
getToken: () => string | null;
|
getToken: () => string | null;
|
||||||
clearToken: () => void;
|
clearToken: () => void;
|
||||||
|
setUserId: (userId: string | number, rememberMe?: boolean) => void;
|
||||||
|
getUserId: () => string | null;
|
||||||
|
clearUserId: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const useAuthStore = create<AuthStore>((set) => ({
|
const useAuthStore = create<AuthStore>(() => ({
|
||||||
setToken: (token: string, rememberMe: boolean = false) => {
|
setToken: (token: string, rememberMe: boolean = false) => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
if (rememberMe) {
|
if (rememberMe) {
|
||||||
localStorage.setItem("token", token);
|
localStorage.setItem("token", token);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -16,6 +20,7 @@ const useAuthStore = create<AuthStore>((set) => ({
|
||||||
},
|
},
|
||||||
|
|
||||||
getToken: (): string | null => {
|
getToken: (): string | null => {
|
||||||
|
if (typeof window === "undefined") return null;
|
||||||
const sessionToken = sessionStorage.getItem("token");
|
const sessionToken = sessionStorage.getItem("token");
|
||||||
if (sessionToken) {
|
if (sessionToken) {
|
||||||
return sessionToken;
|
return sessionToken;
|
||||||
|
|
@ -30,9 +35,41 @@ const useAuthStore = create<AuthStore>((set) => ({
|
||||||
},
|
},
|
||||||
|
|
||||||
clearToken: () => {
|
clearToken: () => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
sessionStorage.removeItem("token");
|
sessionStorage.removeItem("token");
|
||||||
localStorage.removeItem("token");
|
localStorage.removeItem("token");
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setUserId: (userId: string | number, rememberMe: boolean = false) => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
const userIdString = String(userId);
|
||||||
|
if (rememberMe) {
|
||||||
|
localStorage.setItem("userId", userIdString);
|
||||||
|
} else {
|
||||||
|
sessionStorage.setItem("userId", userIdString);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getUserId: (): string | null => {
|
||||||
|
if (typeof window === "undefined") return null;
|
||||||
|
const sessionUserId = sessionStorage.getItem("userId");
|
||||||
|
if (sessionUserId) {
|
||||||
|
return sessionUserId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const localUserId = localStorage.getItem("userId");
|
||||||
|
if (localUserId) {
|
||||||
|
return localUserId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
|
||||||
|
clearUserId: () => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
sessionStorage.removeItem("userId");
|
||||||
|
localStorage.removeItem("userId");
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export default useAuthStore;
|
export default useAuthStore;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue