Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 36 additions & 14 deletions src/features/booking/components/PaymentModal.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { useState } from "react";
import type { FormEvent } from "react";
import { PaymentForm } from "./payment-parts/PaymentForm";
import { PaymentProcessing, PaymentSuccess } from "./payment-parts/PaymentStatus";

Expand All @@ -9,35 +8,58 @@ interface PaymentModalProps {
totalPrice: number;
movieTitle: string;
seats: string[];
onSuccess: () => void;
onSuccess: () => Promise<void>;
}

export const PaymentModal = ({ isOpen, onClose, totalPrice, movieTitle, seats, onSuccess }: PaymentModalProps) => {
const [status, setStatus] = useState<'form' | 'processing' | 'success'>('form');

const currentData = { totalPrice, movieTitle, seats };

if (!isOpen) return null;

const handlePay = (e: FormEvent) => {
const handlePay = async (e: React.FormEvent) => {
e.preventDefault();
setStatus('processing');

setTimeout(() => {
try {
await onSuccess();
setStatus('success');
onSuccess();
setTimeout(() => onClose(), 3000);
}, 2000);

setTimeout(() => {
onClose();
}, 4000);
} catch {
setStatus('form');
alert("Оплата не пройшла. Спробуйте ще раз.");
}
};

return (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm animate-fade-in">
<div className="w-full max-w-md bg-white dark:bg-[#121217] border border-gray-200 dark:border-gray-800 rounded-2xl p-6 shadow-2xl relative overflow-hidden transition-colors duration-300">
<button onClick={onClose} className="absolute top-4 right-4 text-gray-400 hover:text-gray-600 dark:hover:text-white transition-colors">
<span className="text-xl font-bold">✕</span>
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/70 backdrop-blur-md animate-fade-in">
<div className="w-full max-w-md bg-white dark:bg-[#121217] border border-gray-200 dark:border-gray-800 rounded-3xl p-8 shadow-2xl relative transition-all duration-300">
<button onClick={onClose} className="absolute top-6 right-6 text-gray-400 hover:text-white transition-colors">
<span className="text-2xl font-light">✕</span>
</button>

{status === 'form' && <PaymentForm onSubmit={handlePay} totalPrice={totalPrice} movieTitle={movieTitle} seats={seats} />}
{status === 'processing' && <PaymentProcessing totalPrice={totalPrice} movieTitle={movieTitle} seats={seats} />}
{status === 'success' && <PaymentSuccess totalPrice={totalPrice} movieTitle={movieTitle} seats={seats} />}
<div className="mt-2">
{status === 'form' && (
<PaymentForm onSubmit={handlePay} totalPrice={currentData.totalPrice} movieTitle={currentData.movieTitle} seats={currentData.seats} />
)}

{status === 'processing' && (
<PaymentProcessing totalPrice={currentData.totalPrice} movieTitle={currentData.movieTitle} seats={currentData.seats} />
)}

{status === 'success' && (
<div className="animate-in zoom-in-95 duration-500">
<PaymentSuccess totalPrice={currentData.totalPrice} movieTitle={currentData.movieTitle} seats={currentData.seats} />
<div className="mt-6 p-3 bg-green-500/10 border border-green-500/20 rounded-xl text-center">
<p className="text-green-400 text-sm font-medium">Квиток додано в особистий кабінет!</p>
</div>
</div>
)}
</div>
</div>
</div>
);
Expand Down
173 changes: 90 additions & 83 deletions src/features/booking/pages/BookingPage.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,34 @@
import { useState, useEffect } from "react";
import { useState, useEffect, useMemo } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { api } from "@/shared/api/Axios";
import { CinemaHall } from "../components/CinemaHall";
import { rowsConfig } from "../constants";
import { BookingSummary } from "../components/BookingSummary";
import { PaymentModal } from "../components/PaymentModal";

interface SessionInfo {
title: string;
interface SessionDTO {
id: string;
movieTitle: string;
hallName: string;
hallId: string;
startTime: string;
}

interface BackendSeat {
id: string;
rowNumber: number;
seatNumber: number;
}

interface UserTicket {
id: string;
movieTitle: string;
date: string;
time: string;
hall: string;
seats: string;
totalPrice: number;
status: string;
}

const BookingPage = () => {
Expand All @@ -18,115 +37,103 @@ const BookingPage = () => {

const [selectedSeats, setSelectedSeats] = useState<string[]>([]);
const [occupiedSeats, setOccupiedSeats] = useState<string[]>([]);
const [sessionInfo, setSessionInfo] = useState<SessionInfo | null>(null);
const [sessionInfo, setSessionInfo] = useState<{ title: string, time: string, hall: string } | null>(null);
const [realSeatsMap, setRealSeatsMap] = useState<BackendSeat[]>([]);

const [isPaymentOpen, setPaymentOpen] = useState(false);
const [isLoading, setIsLoading] = useState(true);

const STORAGE_KEY = `cached-bookings-${id}`;

useEffect(() => {
const loadPageData = async () => {
try {
setIsLoading(true);
const [seatsRes, sessionRes] = await Promise.all([
api.get<string[]>(`/bookings/occupied-seats/${id}`),
api.get<SessionInfo>(`/sessions/${id}`)
]);
const { data: s } = await api.get<SessionDTO>(`/v1/sessions/${id}`);
setSessionInfo({ title: s.movieTitle, time: s.startTime, hall: s.hallName });

if (s.hallId) {
try {
const { data: seats } = await api.get<BackendSeat[]>(`/v1/seat/hall/${s.hallId}`);
setRealSeatsMap(seats);
} catch { /* Тиха помилка */ }
}

const { data: serverOcc } = await api.get<string[]>(`/v1/bookings/occupied-seats/${id}`).catch(() => ({ data: [] }));
const localOcc = JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]");
setOccupiedSeats(Array.from(new Set([...serverOcc, ...localOcc])));

setOccupiedSeats(seatsRes.data);
setSessionInfo(sessionRes.data);
} catch (error) {
console.error("Помилка завантаження:", error);
} finally {
setIsLoading(false);
}
};
if (id) void loadPageData();
}, [id, STORAGE_KEY]);

if (id) loadPageData();
}, [id]);
const totalPrice = useMemo(() => {
return selectedSeats.reduce((sum, seatId) => {
const row = rowsConfig.find((r) => r.id === seatId.split("-")[0]);
return sum + (row ? row.price : 0);
}, 0);
}, [selectedSeats]);

const handleSeatClick = (seatId: string) => {
setSelectedSeats(prev => prev.includes(seatId)
? prev.filter(id => id !== seatId)
: [...prev, seatId]
);
};
const handleBookingConfirm = async (): Promise<void> => {
try {
const seatsToBook = selectedSeats.map(visualSeat => {
const [rowLetter, seatNumStr] = visualSeat.split('-');
const rowNum = rowLetter.charCodeAt(0) - 64;
const seatNum = parseInt(seatNumStr);
return realSeatsMap.find(s => s.rowNumber === rowNum && s.seatNumber === seatNum)?.id || null;
}).filter((sid): sid is string => sid !== null);

if (seatsToBook.length > 0) {
await api.post("/v1/bookings/reserve", { showtimeId: id, seats: seatsToBook }).catch(() => null);
}

const totalPrice = selectedSeats.reduce((sum, seatId) => {
const rowId = seatId.split("-")[0];
const row = rowsConfig.find((r) => r.id === rowId);
return sum + (row ? row.price : 0);
}, 0);
const ticket: UserTicket = {
id: Math.random().toString(36).substring(2, 10).toUpperCase(),
movieTitle: sessionInfo?.title || "Фільм",
date: new Date().toLocaleDateString('uk-UA'),
time: sessionInfo?.time || "19:00",
hall: sessionInfo?.hall || "Зал 1",
seats: selectedSeats.join(", "),
totalPrice: totalPrice,
status: "Активний"
};

const handleBookingConfirm = async () => {
try {
await api.post("/bookings/reserve", {
showtimeId: id,
seats: selectedSeats
});

setOccupiedSeats(prev => [...prev, ...selectedSeats]);
setSelectedSeats([]);
setPaymentOpen(false);
alert("Місця успішно заброньовано!");
} catch (error) {
console.error("Помилка бронювання:", error);
alert("Сталася помилка. Можливо, ці місця вже хтось забронював.");
const allTickets = JSON.parse(localStorage.getItem("user_tickets") || "[]");
localStorage.setItem("user_tickets", JSON.stringify([ticket, ...allTickets]));

const newOcc = Array.from(new Set([...occupiedSeats, ...selectedSeats]));
setOccupiedSeats(newOcc);
localStorage.setItem(STORAGE_KEY, JSON.stringify(newOcc));

setTimeout(() => setSelectedSeats([]), 3000);

} catch (err) {
console.error("Помилка обробки успіху:", err);
}
};

if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-white dark:bg-[#0A0A0F] dark:text-white font-montserrat">
<p className="animate-pulse">Синхронізація з сервером...</p>
</div>
);
}

if (!sessionInfo) {
return (
<div className="min-h-screen flex flex-col items-center justify-center bg-white dark:bg-[#0A0A0F] dark:text-white">
<p className="text-xl mb-4">Сеанс не знайдено </p>
<button onClick={() => navigate(-1)} className="text-red-600 underline">Повернутися назад</button>
</div>
);
}
if (isLoading) return <div className="min-h-screen flex items-center justify-center dark:text-white">Завантаження...</div>;

return (
<div className="min-h-screen bg-white dark:bg-[#0A0A0F] text-gray-900 dark:text-white flex flex-col relative pb-32 transition-colors duration-300 font-montserrat">
{}
<div className="min-h-screen bg-white dark:bg-[#0A0A0F] text-gray-900 dark:text-white flex flex-col pb-32 font-montserrat">
<div className="p-6">
<button
onClick={() => navigate(-1)}
className="text-gray-500 hover:text-gray-900 dark:hover:text-white flex items-center gap-2 transition-colors"
>
<span>‹ Назад до розкладу</span>
</button>
<button onClick={() => navigate(-1)} className="text-gray-500 hover:text-white transition-colors">‹ Назад</button>
</div>

{}
<div className="text-center mt-4 mb-2">
<h1 className="text-2xl font-bold mb-2">{sessionInfo.title}</h1>
<p className="text-gray-500 text-sm">
{sessionInfo.time} · 2D · {sessionInfo.hall}
</p>
<div className="text-center mb-4">
<h1 className="text-2xl font-bold">{sessionInfo?.title}</h1>
<p className="text-gray-500">{sessionInfo?.time} • {sessionInfo?.hall}</p>
</div>

{}
<CinemaHall
selectedSeats={selectedSeats}
occupiedSeats={occupiedSeats}
onSeatClick={handleSeatClick}
/>

{}
<BookingSummary
selectedSeats={selectedSeats}
totalPrice={totalPrice}
onBuyClick={() => setPaymentOpen(true)}
/>

{}
{isPaymentOpen && (
<CinemaHall selectedSeats={selectedSeats} occupiedSeats={occupiedSeats} onSeatClick={(s) => setSelectedSeats(prev => prev.includes(s) ? prev.filter(i => i !== s) : [...prev, s])} />
<BookingSummary selectedSeats={selectedSeats} totalPrice={totalPrice} onBuyClick={() => setPaymentOpen(true)} />

{isPaymentOpen && sessionInfo && (
<PaymentModal
isOpen={isPaymentOpen}
onClose={() => setPaymentOpen(false)}
Expand Down