556 lines
20 KiB
TypeScript
556 lines
20 KiB
TypeScript
'use client';
|
||
import { useState, useCallback, useEffect } from 'react';
|
||
import { format } from 'date-fns';
|
||
import { ru } from 'date-fns/locale';
|
||
|
||
type PosterModalProps = {
|
||
data: {
|
||
id: number;
|
||
img: string;
|
||
title: string;
|
||
likes: number;
|
||
desc: string;
|
||
price: number;
|
||
date: string;
|
||
};
|
||
isOpen: boolean;
|
||
onClose: () => void;
|
||
};
|
||
|
||
export default function PosterModal({ data, isOpen, onClose }: PosterModalProps) {
|
||
const [liked, setLiked] = useState(false);
|
||
const [likesCount, setLikesCount] = useState(data.likes);
|
||
const [loading, setLoading] = useState(false);
|
||
const [paymentLoading, setPaymentLoading] = useState(false);
|
||
const [message, setMessage] = useState<{text: string, type: 'success' | 'error' | 'info' | null}>({
|
||
text: '',
|
||
type: null
|
||
});
|
||
|
||
// Блокировка прокрутки страницы при открытом модальном окне
|
||
useEffect(() => {
|
||
if (isOpen) {
|
||
document.body.style.overflow = 'hidden';
|
||
} else {
|
||
document.body.style.overflow = '';
|
||
}
|
||
return () => {
|
||
document.body.style.overflow = '';
|
||
};
|
||
}, [isOpen]);
|
||
|
||
// Обработка клавиши Escape
|
||
useEffect(() => {
|
||
const handleEscKey = (e: KeyboardEvent) => {
|
||
if (e.key === 'Escape' && isOpen) {
|
||
onClose();
|
||
}
|
||
};
|
||
window.addEventListener('keydown', handleEscKey);
|
||
return () => {
|
||
window.removeEventListener('keydown', handleEscKey);
|
||
};
|
||
}, [isOpen, onClose]);
|
||
|
||
// Функция для форматирования даты
|
||
const formatDate = (dateStr: string) => {
|
||
const date = new Date(dateStr);
|
||
return format(date, "d MMMM yyyy 'г. в' HH:mm", { locale: ru });
|
||
};
|
||
|
||
// Функция обработки лайков
|
||
const handleLike = useCallback(async () => {
|
||
if (loading) return;
|
||
|
||
try {
|
||
setLoading(true);
|
||
|
||
// Получаем JWT токен из cookie
|
||
const token = document.cookie
|
||
.split('; ')
|
||
.find(row => row.startsWith('JWT_token='))
|
||
?.split('=')[1];
|
||
|
||
if (!token) {
|
||
setMessage({
|
||
text: 'Необходимо авторизоваться для оценки мероприятия',
|
||
type: 'error'
|
||
});
|
||
setTimeout(() => setMessage({text: '', type: null}), 3000);
|
||
return;
|
||
}
|
||
|
||
const formData = new FormData();
|
||
formData.append('poster_id', String(data.id));
|
||
formData.append('like', liked ? '-1' : '1');
|
||
|
||
const response = await fetch('http://localhost:8000/setlike', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Authorization': `Bearer ${token}`
|
||
},
|
||
body: formData,
|
||
credentials: 'include'
|
||
});
|
||
|
||
const responseData = await response.json();
|
||
|
||
if (response.ok) {
|
||
setLikesCount(responseData.poster.like);
|
||
setMessage({
|
||
text: responseData.message,
|
||
type: 'success'
|
||
});
|
||
|
||
if (responseData.message !== "Вы уже оценили этот постер." &&
|
||
responseData.message !== "Вы еще не ставили лайк этому постеру.") {
|
||
setLiked(!liked);
|
||
}
|
||
} else {
|
||
setMessage({
|
||
text: responseData.detail || 'Произошла ошибка при оценке мероприятия',
|
||
type: 'error'
|
||
});
|
||
}
|
||
|
||
setTimeout(() => setMessage({text: '', type: null}), 3000);
|
||
} catch (error) {
|
||
console.error('Ошибка при отправке лайка:', error);
|
||
setMessage({
|
||
text: 'Не удалось обработать запрос. Попробуйте позже.',
|
||
type: 'error'
|
||
});
|
||
setTimeout(() => setMessage({text: '', type: null}), 3000);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [data.id, liked, loading]);
|
||
|
||
// Функция для обработки платежа
|
||
const handlePayment = useCallback(async () => {
|
||
if (paymentLoading) return;
|
||
|
||
try {
|
||
setPaymentLoading(true);
|
||
|
||
// Получаем JWT токен из cookie
|
||
const token = document.cookie
|
||
.split('; ')
|
||
.find(row => row.startsWith('JWT_token='))
|
||
?.split('=')[1];
|
||
|
||
if (!token) {
|
||
setMessage({
|
||
text: 'Необходимо авторизоваться для покупки билета',
|
||
type: 'error'
|
||
});
|
||
setTimeout(() => setMessage({text: '', type: null}), 3000);
|
||
return;
|
||
}
|
||
|
||
try {
|
||
// Получаем данные пользователя из JWT
|
||
const payload = JSON.parse(atob(token.split('.')[1]));
|
||
console.log("JWT payload:", payload);
|
||
|
||
// ID пользователя может храниться в разных полях JWT
|
||
const userId = payload.sub || payload.user_id || payload.id || 1;
|
||
console.log("User ID:", userId);
|
||
|
||
// Создание запроса на оплату
|
||
const response = await fetch('http://localhost:8000/create-payment/', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Authorization': `Bearer ${token}`
|
||
},
|
||
body: JSON.stringify({
|
||
amount: data.price,
|
||
currency: 'RUB',
|
||
description: `Билет на мероприятие: ${data.title}`,
|
||
return_url: `${window.location.origin}/payments`,
|
||
user_id: userId,
|
||
poster_id: data.id
|
||
})
|
||
});
|
||
|
||
console.log("Payment response status:", response.status);
|
||
const responseData = await response.json();
|
||
console.log("Payment response data:", responseData);
|
||
|
||
if (response.ok && responseData.confirmation_url) {
|
||
// Сохраняем payment_id в localStorage перед перенаправлением
|
||
if (responseData.payment_id) {
|
||
localStorage.setItem('current_payment_id', responseData.payment_id);
|
||
}
|
||
|
||
// Добавляем payment_id в URL вручную
|
||
window.location.href = `${responseData.confirmation_url}&return_payment_id=${responseData.payment_id}`;
|
||
} else {
|
||
setMessage({
|
||
text: responseData.detail || 'Произошла ошибка при создании платежа',
|
||
type: 'error'
|
||
});
|
||
setTimeout(() => setMessage({text: '', type: null}), 3000);
|
||
}
|
||
} catch (parseError) {
|
||
console.error('Ошибка при обработке JWT:', parseError);
|
||
setMessage({
|
||
text: 'Ошибка авторизации. Пожалуйста, войдите заново.',
|
||
type: 'error'
|
||
});
|
||
setTimeout(() => setMessage({text: '', type: null}), 3000);
|
||
}
|
||
} catch (error) {
|
||
console.error('Ошибка при создании платежа:', error);
|
||
setMessage({
|
||
text: 'Не удалось создать платёж. Попробуйте позже.',
|
||
type: 'error'
|
||
});
|
||
setTimeout(() => setMessage({text: '', type: null}), 3000);
|
||
} finally {
|
||
setPaymentLoading(false);
|
||
}
|
||
}, [data.id, data.price, data.title, paymentLoading]);
|
||
|
||
if (!isOpen) return null;
|
||
|
||
// Расширенные стили с усиленными эффектами размытия и поддержкой кросс-браузерности
|
||
const styles = `
|
||
@keyframes fadeIn {
|
||
from { opacity: 0; transform: translateY(20px); }
|
||
to { opacity: 1; transform: translateY(0); }
|
||
}
|
||
|
||
@keyframes heartbeat {
|
||
0% { transform: scale(1); }
|
||
15% { transform: scale(1.25); }
|
||
25% { transform: scale(1.15); }
|
||
35% { transform: scale(1.25); }
|
||
100% { transform: scale(1); }
|
||
}
|
||
|
||
@keyframes spin {
|
||
to { transform: rotate(360deg); }
|
||
}
|
||
|
||
.loading-spinner {
|
||
animation: spin 1s linear infinite;
|
||
}
|
||
|
||
/* Улучшенная поддержка эффекта размытия для разных браузеров */
|
||
@supports (-webkit-backdrop-filter: none) or (backdrop-filter: none) {
|
||
.modal-backdrop {
|
||
-webkit-backdrop-filter: blur(25px) saturate(120%);
|
||
backdrop-filter: blur(25px) saturate(120%);
|
||
background-color: rgba(0,0,0,.65);
|
||
}
|
||
}
|
||
|
||
@supports not ((-webkit-backdrop-filter: none) or (backdrop-filter: none)) {
|
||
.modal-backdrop {
|
||
background-color: rgba(0,0,0,.9);
|
||
}
|
||
}
|
||
`;
|
||
|
||
const modalContainerStyle = {
|
||
position: 'fixed' as const,
|
||
top: 0,
|
||
left: 0,
|
||
right: 0,
|
||
bottom: 0,
|
||
display: 'flex',
|
||
justifyContent: 'center',
|
||
alignItems: 'center',
|
||
zIndex: 2001, // Повышенный z-index
|
||
pointerEvents: 'none' as const,
|
||
};
|
||
|
||
return (
|
||
<>
|
||
<style>{styles}</style>
|
||
<div
|
||
className="modal-backdrop"
|
||
style={{
|
||
position: 'fixed',
|
||
inset: 0,
|
||
background: 'rgba(0,0,0,.65)',
|
||
backdropFilter: 'blur(25px) saturate(120%)',
|
||
WebkitBackdropFilter: 'blur(25px) saturate(120%)',
|
||
zIndex: 2000, // Повышенный z-index
|
||
willChange: 'backdrop-filter',
|
||
pointerEvents: 'auto',
|
||
}}
|
||
onClick={onClose}
|
||
/>
|
||
<div style={modalContainerStyle}>
|
||
<div
|
||
onClick={e => e.stopPropagation()}
|
||
style={{
|
||
display: 'flex',
|
||
background: 'rgba(15,15,15,0.35)',
|
||
border: '1px solid rgba(255,127,39,0.5)',
|
||
borderRadius: 24,
|
||
overflow: 'hidden',
|
||
boxShadow: '0 15px 35px rgba(0,0,0,0.2)',
|
||
maxWidth: '90vw',
|
||
maxHeight: '85vh',
|
||
width: 'auto',
|
||
height: 'auto',
|
||
position: 'relative',
|
||
animation: 'fadeIn 0.3s forwards',
|
||
pointerEvents: 'auto',
|
||
}}
|
||
>
|
||
<button
|
||
onClick={onClose}
|
||
style={{
|
||
position: 'absolute',
|
||
top: 16,
|
||
right: 16,
|
||
background: 'rgba(0,0,0,0.5)',
|
||
border: '1px solid rgba(255,127,39,0.4)',
|
||
color: '#fff',
|
||
width: 32,
|
||
height: 32,
|
||
borderRadius: '50%',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
cursor: 'pointer',
|
||
transition: 'all 0.2s',
|
||
zIndex: 10,
|
||
}}
|
||
>
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||
<path d="M18 6L6 18M6 6L18 18" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||
</svg>
|
||
</button>
|
||
|
||
<div style={{
|
||
width: '40%',
|
||
height: '85vh',
|
||
maxHeight: '85vh',
|
||
flexShrink: 0,
|
||
position: 'relative',
|
||
overflow: 'hidden',
|
||
borderRight: '1px solid rgba(255,127,39,0.3)',
|
||
}}>
|
||
<img
|
||
src={data.img}
|
||
alt={data.title}
|
||
style={{
|
||
width: '100%',
|
||
height: '100%',
|
||
objectFit: 'cover',
|
||
objectPosition: 'center',
|
||
display: 'block',
|
||
}}
|
||
/>
|
||
</div>
|
||
|
||
<div style={{
|
||
width: '60%',
|
||
padding: 40,
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
color: '#fff',
|
||
}}>
|
||
<h1 style={{
|
||
color: '#ff7f27',
|
||
fontSize: 28,
|
||
fontWeight: 'bold',
|
||
marginBottom: 16,
|
||
textShadow: '0 2px 10px rgba(255,127,39,0.3)'
|
||
}}>
|
||
{data.title}
|
||
</h1>
|
||
|
||
<div style={{
|
||
display: 'flex',
|
||
justifyContent: 'space-between',
|
||
alignItems: 'center',
|
||
marginBottom: 20,
|
||
color: 'rgba(255,255,255,0.7)',
|
||
padding: '8px 0',
|
||
borderBottom: '1px solid rgba(255,127,39,0.2)'
|
||
}}>
|
||
<div style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: 6,
|
||
position: 'relative'
|
||
}}>
|
||
<button
|
||
onClick={handleLike}
|
||
disabled={loading}
|
||
style={{
|
||
background: 'transparent',
|
||
border: 'none',
|
||
cursor: 'pointer',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
padding: 5,
|
||
transition: 'all 0.2s'
|
||
}}
|
||
title={liked ? 'Убрать оценку' : 'Оценить мероприятие'}
|
||
>
|
||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||
<path
|
||
d="M7 22H4C3.46957 22 2.96086 21.7893 2.58579 21.4142C2.21071 21.0391 2 20.5304 2 20V13C2 12.4696 2.21071 11.9609 2.58579 11.5858C2.96086 11.2107 3.46957 11 4 11H7M14 9V5C14 4.20435 13.6839 3.44129 13.1213 2.87868C12.5587 2.31607 11.7956 2 11 2L7 11V22H18.28C18.7623 22.0055 19.2304 21.8364 19.5979 21.524C19.9654 21.2116 20.2077 20.7769 20.28 20.3L21.66 11.3C21.7035 11.0134 21.6842 10.7207 21.6033 10.4423C21.5225 10.1638 21.3821 9.90629 21.1919 9.68751C21.0016 9.46873 20.7661 9.29393 20.5016 9.17522C20.2371 9.0565 19.9499 8.99672 19.66 9H14Z"
|
||
stroke={liked ? "#ff7f27" : "rgba(255,255,255,0.7)"}
|
||
strokeWidth="2"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
fill={liked ? "#ff7f27" : "none"}
|
||
/>
|
||
</svg>
|
||
</button>
|
||
<span style={{ color: liked ? '#ff7f27' : 'rgba(255,255,255,0.7)' }}>
|
||
{likesCount}
|
||
</span>
|
||
|
||
{loading && (
|
||
<span style={{ marginLeft: 5 }}>
|
||
<svg
|
||
width="16"
|
||
height="16"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
xmlns="http://www.w3.org/2000/svg"
|
||
className="loading-spinner"
|
||
>
|
||
<circle cx="12" cy="12" r="10" stroke="rgba(255,127,39,0.3)" strokeWidth="2" />
|
||
<path d="M12 2C6.47715 2 2 6.47715 2 12" stroke="#ff7f27" strokeWidth="2" />
|
||
</svg>
|
||
</span>
|
||
)}
|
||
|
||
{message.text && (
|
||
<div style={{
|
||
position: 'absolute',
|
||
left: '100%',
|
||
marginLeft: 16,
|
||
whiteSpace: 'nowrap',
|
||
fontSize: 14,
|
||
padding: '6px 12px',
|
||
borderRadius: 8,
|
||
boxShadow: '0 2px 8px rgba(0,0,0,0.2)',
|
||
zIndex: 5,
|
||
maxWidth: 250,
|
||
animation: 'fadeIn 0.2s forwards',
|
||
backgroundColor: message.type === 'error' ? 'rgba(255,60,60,0.15)' :
|
||
message.type === 'success' ? 'rgba(39,255,127,0.15)' : 'rgba(255,127,39,0.15)',
|
||
color: message.type === 'error' ? '#ff5252' :
|
||
message.type === 'success' ? '#57ff8a' : '#ff7f27',
|
||
border: `1px solid ${message.type === 'error' ? 'rgba(255,60,60,0.3)' :
|
||
message.type === 'success' ? 'rgba(39,255,127,0.3)' : 'rgba(255,127,39,0.3)'}`
|
||
}}>
|
||
{message.text}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: 6,
|
||
fontSize: 14
|
||
}}>
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||
<path d="M19 4H5C3.89543 4 3 4.89543 3 6V20C3 21.1046 3.89543 22 5 22H19C20.1046 22 21 21.1046 21 20V6C21 4.89543 20.1046 4 19 4Z" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||
<path d="M16 2V6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||
<path d="M8 2V6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||
<path d="M3 10H21" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||
</svg>
|
||
{formatDate(data.date)}
|
||
</div>
|
||
</div>
|
||
|
||
<div style={{
|
||
lineHeight: 1.7,
|
||
fontSize: 16,
|
||
color: 'rgba(255,255,255,0.9)',
|
||
margin: '20px 0 30px',
|
||
flex: 1
|
||
}}>
|
||
{data.desc}
|
||
</div>
|
||
|
||
<div style={{
|
||
display: 'flex',
|
||
justifyContent: 'space-between',
|
||
alignItems: 'center',
|
||
marginTop: 'auto',
|
||
paddingTop: 16,
|
||
borderTop: '1px solid rgba(255,127,39,0.2)'
|
||
}}>
|
||
<div style={{
|
||
color: '#ff7f27',
|
||
fontSize: 24,
|
||
fontWeight: 'bold',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: 15
|
||
}}>
|
||
{data.price.toLocaleString('ru-RU', { style: 'currency', currency: 'RUB' })}
|
||
</div>
|
||
|
||
<button
|
||
style={{
|
||
padding: '12px 24px',
|
||
background: 'linear-gradient(145deg, #ff7f27, #ff5500)',
|
||
border: 0,
|
||
borderRadius: 24,
|
||
fontWeight: 'bold',
|
||
cursor: paymentLoading ? 'wait' : 'pointer',
|
||
color: '#000',
|
||
transition: 'all 0.3s',
|
||
boxShadow: '0 2px 10px rgba(255,127,39,0.4)',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: 8,
|
||
opacity: paymentLoading ? 0.8 : 1
|
||
}}
|
||
onClick={handlePayment}
|
||
disabled={paymentLoading}
|
||
onMouseOver={(e) => {
|
||
if (!paymentLoading) {
|
||
e.currentTarget.style.transform = 'translateY(-2px)';
|
||
e.currentTarget.style.boxShadow = '0 4px 15px rgba(255,127,39,0.5)';
|
||
}
|
||
}}
|
||
onMouseOut={(e) => {
|
||
if (!paymentLoading) {
|
||
e.currentTarget.style.transform = 'translateY(0)';
|
||
e.currentTarget.style.boxShadow = '0 2px 10px rgba(255,127,39,0.4)';
|
||
}
|
||
}}
|
||
>
|
||
{paymentLoading ? (
|
||
<>
|
||
<svg
|
||
width="18"
|
||
height="18"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
xmlns="http://www.w3.org/2000/svg"
|
||
className="loading-spinner"
|
||
>
|
||
<circle cx="12" cy="12" r="10" stroke="rgba(0,0,0,0.3)" strokeWidth="2.5" />
|
||
<path d="M12 2C6.47715 2 2 6.47715 2 12" stroke="#000" strokeWidth="2.5" />
|
||
</svg>
|
||
Обработка...
|
||
</>
|
||
) : (
|
||
'Купить билет'
|
||
)}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</>
|
||
);
|
||
} |