update
This commit is contained in:
parent
3c843d73e7
commit
a3a0db7dcd
Binary file not shown.
Binary file not shown.
BIN
backend/api/v0/__pycache__/payments.cpython-310.pyc
Normal file
BIN
backend/api/v0/__pycache__/payments.cpython-310.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
212
backend/api/v0/payments.py
Normal file
212
backend/api/v0/payments.py
Normal file
@ -0,0 +1,212 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Body, Header, Request
|
||||
from sqlalchemy.orm import Session
|
||||
from core.db import get_db, Sale, User, Poster
|
||||
from core.pay import YooKassaPayment
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
import json
|
||||
|
||||
class PaymentRequest(BaseModel):
|
||||
amount: float
|
||||
currency: str
|
||||
description: str
|
||||
return_url: str
|
||||
user_id: Optional[int] = Field(None) # Делаем необязательным
|
||||
poster_id: Optional[int] = None
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Конфигурация YooKassa
|
||||
SHOP_ID = "1017909"
|
||||
API_KEY = "test_udCBy7nXqGavVoj3RrzIRXN9UL02_UnF0FBBykxWH60"
|
||||
payment_service = YooKassaPayment(SHOP_ID, API_KEY)
|
||||
|
||||
@router.post("/create-payment/")
|
||||
async def create_payment(
|
||||
payment_data: dict = Body(...), # Принимаем любой JSON
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
try:
|
||||
# Логирование для отладки
|
||||
print(f"Received payment data: {json.dumps(payment_data, ensure_ascii=False)}")
|
||||
|
||||
# Создание платежа через YooKassa
|
||||
payment_response = payment_service.create_payment(
|
||||
amount=float(payment_data.get("amount", 0)),
|
||||
currency=payment_data.get("currency", "RUB"),
|
||||
description=payment_data.get("description", ""),
|
||||
return_url=payment_data.get("return_url", "http://localhost:3000/payments")
|
||||
)
|
||||
|
||||
# Логирование ответа YooKassa
|
||||
print(f"YooKassa response: {json.dumps(payment_response, ensure_ascii=False)}")
|
||||
|
||||
# Сохранение данных о платеже в БД
|
||||
new_sale = Sale(
|
||||
amount=float(payment_data.get("amount", 0)),
|
||||
status="pending",
|
||||
description=payment_data.get("description", ""),
|
||||
poster_id=payment_data.get("poster_id"),
|
||||
user_id=payment_data.get("user_id", 1), # Используем 1 как значение по умолчанию
|
||||
user_email=payment_data.get("user_email", "example@example.com"),
|
||||
payment_id=payment_response["id"],
|
||||
confirmation_url=payment_response["confirmation"]["confirmation_url"]
|
||||
)
|
||||
db.add(new_sale)
|
||||
db.commit()
|
||||
db.refresh(new_sale)
|
||||
|
||||
return {"confirmation_url": new_sale.confirmation_url, "payment_id": payment_response["id"]}
|
||||
except Exception as e:
|
||||
print(f"ERROR creating payment: {str(e)}")
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
@router.get("/payment-status/{payment_id}")
|
||||
async def get_payment_status(payment_id: str, db: Session = Depends(get_db)):
|
||||
try:
|
||||
print(f"Checking payment status for ID: {payment_id}")
|
||||
|
||||
# Получение информации о платеже из БД
|
||||
sale = db.query(Sale).filter(Sale.payment_id == payment_id).first()
|
||||
|
||||
if not sale:
|
||||
raise HTTPException(status_code=404, detail=f"Платеж с ID {payment_id} не найден")
|
||||
|
||||
# Обновление статуса из YooKassa
|
||||
yookassa_status = payment_service.get_payment_status(payment_id)
|
||||
if yookassa_status and "status" in yookassa_status:
|
||||
# Маппинг статусов YooKassa на наши статусы
|
||||
if yookassa_status["status"] == "succeeded" or yookassa_status["paid"] == True:
|
||||
sale.status = "paid"
|
||||
elif yookassa_status["status"] == "canceled":
|
||||
sale.status = "canceled"
|
||||
elif yookassa_status["status"] == "waiting_for_capture":
|
||||
sale.status = "waiting_for_capture"
|
||||
# Сохраняем обновленный статус
|
||||
db.commit()
|
||||
print(f"Updated payment status from YooKassa: {yookassa_status['status']} -> {sale.status}")
|
||||
|
||||
return {"payment_id": sale.payment_id, "status": sale.status}
|
||||
except Exception as e:
|
||||
print(f"ERROR checking payment status: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.post("/notifications/")
|
||||
async def process_notification(request: Request, db: Session = Depends(get_db)):
|
||||
try:
|
||||
# Получаем тело запроса
|
||||
notification_data = await request.json()
|
||||
print(f"Received notification: {json.dumps(notification_data, ensure_ascii=False)}")
|
||||
|
||||
# Проверяем тип события
|
||||
event = notification_data.get("event")
|
||||
payment_id = notification_data.get("object", {}).get("id")
|
||||
|
||||
if not payment_id:
|
||||
raise HTTPException(status_code=400, detail="Missing payment ID")
|
||||
|
||||
# Находим платеж в БД
|
||||
sale = db.query(Sale).filter(Sale.payment_id == payment_id).first()
|
||||
if not sale:
|
||||
raise HTTPException(status_code=404, detail=f"Payment {payment_id} not found")
|
||||
|
||||
# Обновляем статус платежа в зависимости от события
|
||||
if event == "payment.waiting_for_capture":
|
||||
sale.status = "waiting_for_capture"
|
||||
elif event == "payment.succeeded":
|
||||
sale.status = "paid"
|
||||
elif event == "payment.canceled":
|
||||
sale.status = "canceled"
|
||||
else:
|
||||
print(f"Unknown event: {event}")
|
||||
|
||||
db.commit()
|
||||
return {"success": True}
|
||||
except Exception as e:
|
||||
print(f"ERROR processing notification: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.post("/update-payment-status/")
|
||||
async def manual_update_payment_status(payment_id: str, new_status: str, db: Session = Depends(get_db)):
|
||||
try:
|
||||
sale = db.query(Sale).filter(Sale.payment_id == payment_id).first()
|
||||
if not sale:
|
||||
raise HTTPException(status_code=404, detail="Платеж не найден")
|
||||
|
||||
# Обновляем статус
|
||||
sale.status = new_status
|
||||
db.commit()
|
||||
|
||||
return {"message": "Статус платежа обновлен", "payment_id": sale.payment_id, "status": sale.status}
|
||||
except Exception as e:
|
||||
print(f"ERROR updating payment status: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.get("/user-tickets/{user_id}")
|
||||
async def get_user_tickets(user_id: int, db: Session = Depends(get_db)):
|
||||
"""Получение всех оплаченных билетов пользователя"""
|
||||
try:
|
||||
# Проверяем существование пользователя
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="Пользователь не найден")
|
||||
|
||||
# Получаем все успешно оплаченные билеты пользователя
|
||||
sales = db.query(Sale).filter(Sale.user_id == user_id, Sale.status == "paid").all()
|
||||
|
||||
result = []
|
||||
for sale in sales:
|
||||
# Получаем информацию о мероприятии
|
||||
poster = None
|
||||
if sale.poster_id:
|
||||
poster = db.query(Poster).filter(Poster.id == sale.poster_id).first()
|
||||
|
||||
# Формирование даты покупки
|
||||
purchase_date = sale.date if hasattr(sale, 'date') else datetime.now().isoformat()
|
||||
|
||||
ticket_data = {
|
||||
"id": sale.id,
|
||||
"amount": sale.amount,
|
||||
"description": sale.description,
|
||||
"payment_id": sale.payment_id,
|
||||
"purchase_date": purchase_date,
|
||||
"poster": None
|
||||
}
|
||||
|
||||
if poster:
|
||||
# Используем только те поля, которые точно есть в модели Poster
|
||||
ticket_data["poster"] = {
|
||||
"id": poster.id,
|
||||
"title": poster.title,
|
||||
"date": poster.date
|
||||
# Убрали поле img, которого нет в модели
|
||||
}
|
||||
|
||||
result.append(ticket_data)
|
||||
|
||||
return result
|
||||
except HTTPException as he:
|
||||
# Пробрасываем дальше HTTPException
|
||||
raise he
|
||||
except Exception as e:
|
||||
print(f"ERROR getting user tickets: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
# Для тестирования, также добавим временный эндпоинт для обновления статуса билета
|
||||
@router.post("/update-ticket-status/{sale_id}")
|
||||
async def update_ticket_status(sale_id: int, status: str, db: Session = Depends(get_db)):
|
||||
"""Временный эндпоинт для обновления статуса билета (для тестирования)"""
|
||||
try:
|
||||
sale = db.query(Sale).filter(Sale.id == sale_id).first()
|
||||
if not sale:
|
||||
raise HTTPException(status_code=404, detail="Билет не найден")
|
||||
|
||||
sale.status = status
|
||||
db.commit()
|
||||
|
||||
return {"success": True, "message": f"Статус билета с ID {sale_id} обновлен на {status}"}
|
||||
except Exception as e:
|
||||
print(f"ERROR updating ticket status: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
backend/core/__pycache__/pay.cpython-310.pyc
Normal file
BIN
backend/core/__pycache__/pay.cpython-310.pyc
Normal file
Binary file not shown.
@ -3,7 +3,7 @@ from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from datetime import datetime
|
||||
|
||||
DATABASE_URL = "sqlite:///c:/Users/rbiter/Desktop/AboutMe/backend/database.db"
|
||||
DATABASE_URL = "sqlite:///./database.db"
|
||||
|
||||
engine = create_engine(DATABASE_URL)
|
||||
|
||||
|
52
backend/core/pay.py
Normal file
52
backend/core/pay.py
Normal file
@ -0,0 +1,52 @@
|
||||
import uuid
|
||||
import requests
|
||||
import base64
|
||||
|
||||
class YooKassaPayment:
|
||||
def __init__(self, shop_id: str, api_key: str):
|
||||
self.shop_id = shop_id
|
||||
self.api_key = api_key
|
||||
self.auth_token = base64.b64encode(f'{shop_id}:{api_key}'.encode()).decode()
|
||||
|
||||
def create_payment(self, amount: float, currency: str, description: str, return_url: str, order_id: str = None):
|
||||
idempotence_key = str(uuid.uuid4())
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': f'Basic {self.auth_token}',
|
||||
'Idempotence-Key': idempotence_key
|
||||
}
|
||||
data = {
|
||||
"amount": {
|
||||
"value": f"{amount:.2f}",
|
||||
"currency": currency
|
||||
},
|
||||
"confirmation": {
|
||||
"type": "redirect",
|
||||
"return_url": return_url
|
||||
},
|
||||
"capture": True,
|
||||
"description": description,
|
||||
"metadata": {
|
||||
"order_id": order_id or str(uuid.uuid4())
|
||||
}
|
||||
}
|
||||
response = requests.post("https://api.yookassa.ru/v3/payments", json=data, headers=headers)
|
||||
if response.status_code in [200, 201]:
|
||||
return response.json()
|
||||
else:
|
||||
raise Exception(f"Ошибка {response.status_code}: {response.text}")
|
||||
|
||||
def get_payment_status(self, payment_id: str):
|
||||
"""Получение актуального статуса платежа из YooKassa API"""
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': f'Basic {self.auth_token}'
|
||||
}
|
||||
|
||||
response = requests.get(f"https://api.yookassa.ru/v3/payments/{payment_id}", headers=headers)
|
||||
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
else:
|
||||
print(f"Error getting payment status: {response.status_code} {response.text}")
|
||||
return None
|
24
backend/core/payment/payment_schemas.py
Normal file
24
backend/core/payment/payment_schemas.py
Normal file
@ -0,0 +1,24 @@
|
||||
from pydantic import BaseModel, constr, condecimal
|
||||
from typing import Optional
|
||||
|
||||
class PaymentCreateSchema(BaseModel):
|
||||
amount: condecimal(gt=0) # Amount must be greater than 0
|
||||
currency: constr(min_length=3, max_length=3) # Currency code must be 3 characters
|
||||
description: str
|
||||
return_url: str
|
||||
capture: bool = True
|
||||
metadata: Optional[dict] = None
|
||||
|
||||
class PaymentResponseSchema(BaseModel):
|
||||
id: str
|
||||
status: str
|
||||
confirmation_url: str
|
||||
|
||||
class PaymentStatusSchema(BaseModel):
|
||||
id: str
|
||||
status: str
|
||||
amount: condecimal(gt=0)
|
||||
currency: constr(min_length=3, max_length=3) # Currency code must be 3 characters
|
||||
description: str
|
||||
created_at: str # ISO format date string
|
||||
confirmation_url: Optional[str] = None
|
Binary file not shown.
@ -6,6 +6,7 @@ from api.v0.user import router as user_router
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from api.v0.poster import router as poster_router
|
||||
from api.v0.feedback import router as feedback_router
|
||||
from api.v0.payments import router as payment_router
|
||||
|
||||
db.initialize_database()
|
||||
|
||||
@ -15,6 +16,7 @@ app = FastAPI()
|
||||
app.include_router(user_router)
|
||||
app.include_router(poster_router)
|
||||
app.include_router(feedback_router)
|
||||
app.include_router(payment_router)
|
||||
app.mount("/res", StaticFiles(directory="res"), name="res")
|
||||
|
||||
|
||||
|
@ -1,39 +0,0 @@
|
||||
import uuid
|
||||
import requests
|
||||
import base64
|
||||
|
||||
shop_id = '1017909'
|
||||
api_key = 'test_udCBy7nXqGavVoj3RrzIRXN9UL02_UnF0FBBykxWH60'
|
||||
|
||||
auth_token = base64.b64encode(f'{shop_id}:{api_key}'.encode()).decode()
|
||||
|
||||
idempotence_key = str(uuid.uuid4())
|
||||
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': f'Basic {auth_token}',
|
||||
'Idempotence-Key': idempotence_key
|
||||
}
|
||||
|
||||
data = {
|
||||
"amount": {
|
||||
"value": "1500.00",
|
||||
"currency": "RUB"
|
||||
},
|
||||
"confirmation": {
|
||||
"type": "redirect",
|
||||
"return_url": "https://example.com/return"
|
||||
},
|
||||
"capture": True,
|
||||
"description": "Зеленый слоник 2",
|
||||
"metadata": {
|
||||
"order_id": str(uuid.uuid4())
|
||||
}
|
||||
}
|
||||
|
||||
response = requests.post("https://api.yookassa.ru/v3/payments", json=data, headers=headers)
|
||||
|
||||
if response.status_code in [200, 201]:
|
||||
print(response.json()["confirmation"]["confirmation_url"])
|
||||
else:
|
||||
print(f"Ошибка {response.status_code}: {response.text}")
|
7
backend/requirements.txt
Normal file
7
backend/requirements.txt
Normal file
@ -0,0 +1,7 @@
|
||||
fastapi
|
||||
uvicorn
|
||||
sqlalchemy
|
||||
pydantic
|
||||
python-multipart
|
||||
PyJWT
|
||||
requests
|
172
frontend/src/app/payments/page.tsx
Normal file
172
frontend/src/app/payments/page.tsx
Normal file
@ -0,0 +1,172 @@
|
||||
'use client';
|
||||
import React, { useEffect, useState, CSSProperties } from 'react';
|
||||
import Header from '@/components/Header';
|
||||
import Fotter from '@/components/Fotter';
|
||||
import { useSearchParams, useRouter } from 'next/navigation';
|
||||
|
||||
const styles = `
|
||||
.page-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
background: radial-gradient(circle at top right, rgba(30,30,30,0.8) 0%, rgba(15,15,15,0.8) 100%);
|
||||
position: relative;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.page-container::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: url('/noise-texture.png'), linear-gradient(145deg, rgba(255,127,39,0.1) 0%, rgba(0,0,0,0) 70%);
|
||||
opacity: 0.4;
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.content-container {
|
||||
flex: 1;
|
||||
animation: fadeInUp 0.6s ease-out forwards;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
color: #ff7f27;
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
font-weight: bold;
|
||||
background: linear-gradient(to right, #ff7f27, #ff5500);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
animation: glowPulse 3s infinite;
|
||||
text-align: center;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.payment-status {
|
||||
text-align: center;
|
||||
margin: 2rem auto;
|
||||
padding: 2rem;
|
||||
border-radius: 1rem;
|
||||
background: rgba(0,0,0,0.3);
|
||||
border: 1px solid rgba(255,127,39,0.2);
|
||||
color: rgba(255,255,255,0.9);
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.success-message {
|
||||
color: #30d158;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #ff453a;
|
||||
}
|
||||
|
||||
.loading-message {
|
||||
color: rgba(255,255,255,0.7);
|
||||
}
|
||||
`;
|
||||
|
||||
const mainContentStyle: CSSProperties = {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
maxWidth: '90%',
|
||||
margin: '0 auto',
|
||||
flex: 1,
|
||||
paddingTop: '76px',
|
||||
paddingBottom: '30px',
|
||||
};
|
||||
|
||||
export default function PaymentStatusPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const paymentId = searchParams.get('payment_id');
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const checkPaymentStatus = async () => {
|
||||
// Пытаемся получить payment_id из разных источников
|
||||
const urlPaymentId = searchParams.get('payment_id');
|
||||
const returnPaymentId = searchParams.get('return_payment_id');
|
||||
const storedPaymentId = typeof window !== 'undefined' ? localStorage.getItem('current_payment_id') : null;
|
||||
|
||||
// Используем первый доступный ID
|
||||
const finalPaymentId = urlPaymentId || returnPaymentId || storedPaymentId;
|
||||
|
||||
console.log("Available payment IDs:", { urlPaymentId, returnPaymentId, storedPaymentId });
|
||||
|
||||
if (!finalPaymentId) {
|
||||
setStatus('error');
|
||||
setError('ID платежа не найден');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log("Checking payment status for:", finalPaymentId);
|
||||
const response = await fetch(`http://localhost:8000/payment-status/${finalPaymentId}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Ошибка ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log("Payment status response:", data);
|
||||
setStatus(data.status);
|
||||
|
||||
// Если платеж завершен, перенаправляем через 5 секунд
|
||||
if (data.status === 'paid') {
|
||||
setTimeout(() => {
|
||||
router.push('/'); // или на страницу с билетами пользователя
|
||||
}, 5000);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching payment status:", error);
|
||||
setError('Не удалось получить информацию о платеже');
|
||||
setStatus('error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
checkPaymentStatus();
|
||||
|
||||
// Проверяем статус каждые 5 секунд, если платеж ожидает оплаты
|
||||
const intervalId = setInterval(() => {
|
||||
if (status === 'pending' || status === 'waiting_for_capture') {
|
||||
checkPaymentStatus();
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
return () => clearInterval(intervalId);
|
||||
}, [paymentId, router, status]);
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<style>{styles}</style>
|
||||
<Header />
|
||||
<main style={mainContentStyle} className="content-container">
|
||||
<h1 className="section-title">Статус платежа</h1>
|
||||
{loading ? (
|
||||
<div className="payment-status loading-message">Загрузка статуса платежа...</div>
|
||||
) : status === 'paid' ? (
|
||||
<div className="payment-status success-message">Ваш платеж успешно завершен!</div>
|
||||
) : status === 'pending' ? (
|
||||
<div className="payment-status">Ваш платеж находится в ожидании подтверждения.</div>
|
||||
) : status === 'failed' ? (
|
||||
<div className="payment-status error-message">Ваш платеж не удалось завершить.</div>
|
||||
) : (
|
||||
<div className="payment-status error-message">Произошла ошибка. Платеж не найден.</div>
|
||||
)}
|
||||
</main>
|
||||
<Fotter />
|
||||
</div>
|
||||
);
|
||||
}
|
@ -21,6 +21,7 @@ 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
|
||||
@ -124,6 +125,93 @@ export default function PosterModal({ data, isOpen, onClose }: PosterModalProps)
|
||||
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;
|
||||
|
||||
@ -416,22 +504,48 @@ export default function PosterModal({ data, isOpen, onClose }: PosterModalProps)
|
||||
border: 0,
|
||||
borderRadius: 24,
|
||||
fontWeight: 'bold',
|
||||
cursor: 'pointer',
|
||||
cursor: paymentLoading ? 'wait' : 'pointer',
|
||||
color: '#000',
|
||||
transition: 'all 0.3s',
|
||||
boxShadow: '0 2px 10px rgba(255,127,39,0.4)'
|
||||
boxShadow: '0 2px 10px rgba(255,127,39,0.4)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
opacity: paymentLoading ? 0.8 : 1
|
||||
}}
|
||||
onClick={() => alert('Функция покупки билетов будет доступна в ближайшее время!')}
|
||||
onClick={handlePayment}
|
||||
disabled={paymentLoading}
|
||||
onMouseOver={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-2px)';
|
||||
e.currentTarget.style.boxShadow = '0 4px 15px rgba(255,127,39,0.5)';
|
||||
if (!paymentLoading) {
|
||||
e.currentTarget.style.transform = 'translateY(-2px)';
|
||||
e.currentTarget.style.boxShadow = '0 4px 15px rgba(255,127,39,0.5)';
|
||||
}
|
||||
}}
|
||||
onMouseOut={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
e.currentTarget.style.boxShadow = '0 2px 10px rgba(255,127,39,0.4)';
|
||||
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>
|
||||
|
@ -1,7 +1,71 @@
|
||||
'use client';
|
||||
import { CSSProperties, useState, useEffect } from 'react';
|
||||
|
||||
// Улучшенные стили с градиентами и эффектами
|
||||
// Обновите стили CSS для прокрутки
|
||||
const styles = `
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes profileCardAppear {
|
||||
from { opacity: 0; transform: scale(0.95); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Стилизация скроллбара */
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-radius: 10px;
|
||||
margin: 5px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: linear-gradient(145deg, #ff7f27, #e96c00);
|
||||
border-radius: 10px;
|
||||
border: 2px solid rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: linear-gradient(145deg, #ff9540, #ff7f27);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.tab-content-scroll {
|
||||
max-height: calc(65vh - 280px); /* Динамическая высота на основе высоты окна */
|
||||
overflow-y: auto;
|
||||
padding-right: 10px; /* Увеличиваем отступ для скроллбара */
|
||||
padding-bottom: 20px; /* Добавляем отступ снизу */
|
||||
-webkit-overflow-scrolling: touch; /* Плавная прокрутка на iOS */
|
||||
}
|
||||
|
||||
.ticket-card {
|
||||
animation: fadeIn 0.5s forwards;
|
||||
}
|
||||
|
||||
.ticket-card:last-child {
|
||||
margin-bottom: 20px; /* Дополнительный отступ для последнего билета */
|
||||
}
|
||||
`;
|
||||
|
||||
// Существующие стили...
|
||||
const wrapper: CSSProperties = {
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
@ -20,15 +84,18 @@ const modal: CSSProperties = {
|
||||
background: 'linear-gradient(145deg, #0a0a0a, #1a1a1a)',
|
||||
border: '1px solid #ff7f27',
|
||||
borderRadius: 24,
|
||||
padding: 40,
|
||||
minWidth: 700, // Увеличено для более широкого профиля
|
||||
padding: '40px 40px 20px 40px', // Уменьшаем нижний отступ
|
||||
minWidth: 700,
|
||||
maxWidth: '90vw',
|
||||
width: '100%', // Добавлено для лучшего контроля ширины
|
||||
width: '100%',
|
||||
color: '#ff7f27',
|
||||
boxShadow: '0 8px 40px rgba(255,127,39,.25), 0 0 0 1px rgba(255,127,39,0.1)',
|
||||
transformOrigin: 'center',
|
||||
transition: 'transform 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275)',
|
||||
animation: 'profileCardAppear 0.4s forwards',
|
||||
maxHeight: '85vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
};
|
||||
|
||||
const profileHeader: CSSProperties = {
|
||||
@ -125,9 +192,14 @@ const tabActive: CSSProperties = {
|
||||
background: 'rgba(255,127,39,0.1)',
|
||||
};
|
||||
|
||||
// Обновленный стиль для контента вкладок
|
||||
const tabContent: CSSProperties = {
|
||||
minHeight: 200,
|
||||
padding: '20px 0',
|
||||
padding: '20px 0 0 0', // Убираем нижний padding
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
};
|
||||
|
||||
const settingsButton: CSSProperties = {
|
||||
@ -254,6 +326,20 @@ interface UserData {
|
||||
full_name?: string; // Для обратной совместимости
|
||||
}
|
||||
|
||||
interface Ticket {
|
||||
id: number;
|
||||
amount: number;
|
||||
description: string;
|
||||
payment_id: string;
|
||||
purchase_date: string;
|
||||
poster: {
|
||||
id: number;
|
||||
title: string;
|
||||
date: string;
|
||||
img: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export default function ProfileModal({
|
||||
user,
|
||||
onClose,
|
||||
@ -265,22 +351,23 @@ export default function ProfileModal({
|
||||
}) {
|
||||
const [activeTab, setActiveTab] = useState('tickets');
|
||||
const [userData, setUserData] = useState<UserData | null>(null);
|
||||
// Removed unused loading state
|
||||
const [tickets, setTickets] = useState<Ticket[]>([]);
|
||||
const [ticketsLoading, setTicketsLoading] = useState(true);
|
||||
|
||||
// Получение ID пользователя из cookie
|
||||
const userId = document.cookie
|
||||
.split('; ')
|
||||
.find(row => row.startsWith('id='))
|
||||
?.split('=')[1];
|
||||
|
||||
// Получение токена из cookie (removed unused token variable)
|
||||
|
||||
// Загрузка полных данных пользователя при открытии профиля
|
||||
// Загрузка данных пользователя и билетов
|
||||
useEffect(() => {
|
||||
if (userId) {
|
||||
fetchUserData(userId);
|
||||
fetchUserTickets(userId);
|
||||
} else {
|
||||
setLoading(false);
|
||||
setTicketsLoading(false);
|
||||
}
|
||||
}, [userId]);
|
||||
|
||||
@ -309,6 +396,28 @@ export default function ProfileModal({
|
||||
}
|
||||
};
|
||||
|
||||
// Новая функция для загрузки билетов
|
||||
const fetchUserTickets = async (id: string) => {
|
||||
try {
|
||||
const response = await fetch(`http://localhost:8000/user-tickets/${id}`, {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
console.log("Получены билеты пользователя:", data);
|
||||
setTickets(data);
|
||||
} else {
|
||||
console.error("Ошибка при получении билетов");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Ошибка запроса билетов:", error);
|
||||
} finally {
|
||||
setTicketsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Функция форматирования полного имени
|
||||
const formatName = () => {
|
||||
if (userData) {
|
||||
@ -328,6 +437,8 @@ export default function ProfileModal({
|
||||
style={wrapper}
|
||||
onClick={(e) => e.target === e.currentTarget && onClose()}
|
||||
>
|
||||
{/* Добавляем тег style с нашими стилями */}
|
||||
<style>{styles}</style>
|
||||
|
||||
<div style={modal}>
|
||||
<button
|
||||
@ -412,56 +523,84 @@ export default function ProfileModal({
|
||||
</div>
|
||||
|
||||
{/* Содержимое вкладок */}
|
||||
<div style={tabContent} className={`tab-content-active`}>
|
||||
<div style={tabContent}>
|
||||
{activeTab === 'tickets' && (
|
||||
<div>
|
||||
<div style={ticketCard}>
|
||||
<div style={ticketHeader}>
|
||||
<div>
|
||||
<div style={ticketTitle}>Зеленый слоник 2</div>
|
||||
</div>
|
||||
<div style={ticketNumber}>F22SB21</div>
|
||||
<div className="tab-content-scroll">
|
||||
{ticketsLoading ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '30px 0' }}>
|
||||
<svg width="40" height="40" 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>
|
||||
</div>
|
||||
|
||||
<div style={ticketDetails}>
|
||||
<div style={ticketDetail}>
|
||||
<span style={ticketLabel}>ДАТА</span>
|
||||
<span style={ticketValue}>21.05.2025</span>
|
||||
</div>
|
||||
|
||||
<div style={ticketDetail}>
|
||||
<span style={ticketLabel}>ВРЕМЯ</span>
|
||||
<span style={ticketValue}>19:30</span>
|
||||
</div>
|
||||
|
||||
<div style={ticketDetail}>
|
||||
<span style={ticketLabel}>ЗАЛ</span>
|
||||
<span style={ticketValue}>Главный</span>
|
||||
</div>
|
||||
|
||||
<div style={ticketDetail}>
|
||||
<span style={ticketLabel}>МЕСТО</span>
|
||||
<span style={ticketValue}>VIP 12</span>
|
||||
) : tickets.length > 0 ? (
|
||||
tickets.map(ticket => (
|
||||
<div key={ticket.id} style={ticketCard} className="ticket-card">
|
||||
<div style={ticketHeader}>
|
||||
<div>
|
||||
<div style={ticketTitle}>
|
||||
{ticket.poster?.title || ticket.description}
|
||||
</div>
|
||||
</div>
|
||||
<div style={ticketNumber}>
|
||||
{`T${ticket.id}${ticket.payment_id.substring(0, 5).toUpperCase()}`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={ticketDetails}>
|
||||
{ticket.poster?.date && (
|
||||
<div style={ticketDetail}>
|
||||
<span style={ticketLabel}>ДАТА</span>
|
||||
<span style={ticketValue}>
|
||||
{new Date(ticket.poster.date).toLocaleDateString('ru-RU')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={ticketDetail}>
|
||||
<span style={ticketLabel}>ДАТА ПОКУПКИ</span>
|
||||
<span style={ticketValue}>
|
||||
{new Date(ticket.purchase_date).toLocaleDateString('ru-RU')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={ticketDetail}>
|
||||
<span style={ticketLabel}>СТОИМОСТЬ</span>
|
||||
<span style={ticketValue}>
|
||||
{ticket.amount.toLocaleString('ru-RU', { style: 'currency', currency: 'RUB' })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={ticketPaymentId}>
|
||||
<span style={{color: 'rgba(255,127,39,0.8)', marginRight: 5}}>Код платежа:</span>
|
||||
{ticket.payment_id}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div style={emptyState}>
|
||||
<svg width="60" height="60" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ marginBottom: 16, opacity: 0.5 }}>
|
||||
<path d="M20 7V21C20 21.5523 19.5523 22 19 22H5C4.44772 22 4 21.5523 4 21V3C4 2.44772 4.44772 2 5 2H15L20 7Z" stroke="#ff7f27" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M9 17H15M9 13H15M9 9H10" stroke="#ff7f27" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
<h3 style={{ color: 'rgba(255,255,255,0.8)', marginBottom: 8 }}>Билеты отсутствуют</h3>
|
||||
<p style={{ maxWidth: '70%', margin: '0 auto' }}>У вас пока нет приобретенных билетов</p>
|
||||
</div>
|
||||
|
||||
<div style={ticketPaymentId}>
|
||||
<span style={{color: 'rgba(255,127,39,0.8)', marginRight: 5}}>Код платежа:</span>
|
||||
2fbd2cd4-000f-5001-9000-1fb8d8bcd8a9
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'history' && (
|
||||
<div style={emptyState}>
|
||||
<svg width="60" height="60" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ marginBottom: 16, opacity: 0.5 }}>
|
||||
<path d="M12 8V12L15 15M21 12C21 16.9706 16.9706 21 12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12Z" stroke="#ff7f27" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
<h3 style={{ color: 'rgba(255,255,255,0.8)', marginBottom: 8 }}>История заказов пуста</h3>
|
||||
<p style={{ maxWidth: '70%', margin: '0 auto' }}>Здесь появится история ваших прошлых заказов</p>
|
||||
<div className="tab-content-scroll">
|
||||
<div style={emptyState}>
|
||||
<svg width="60" height="60" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ marginBottom: 16, opacity: 0.5 }}>
|
||||
<path d="M12 8V12L15 15M21 12C21 16.9706 16.9706 21 12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12Z" stroke="#ff7f27" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
<h3 style={{ color: 'rgba(255,255,255,0.8)', marginBottom: 8 }}>История заказов пуста</h3>
|
||||
<p style={{ maxWidth: '70%', margin: '0 auto' }}>Здесь появится история ваших прошлых заказов</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
Loading…
x
Reference in New Issue
Block a user