This commit is contained in:
@@ -106,20 +106,26 @@ export async function lockSeatApi(
|
||||
return { ticketId: response.data.ticket_id ?? seatId };
|
||||
}
|
||||
|
||||
// ─── Payment webhook ──────────────────────────────────────────────────────────
|
||||
// ─── Acquring / booking ───────────────────────────────────────────────────────
|
||||
|
||||
export interface BookTicketResponse {
|
||||
ticket_id: number;
|
||||
/** Redirect URL returned by the payment gateway (YooKassa, etc.) */
|
||||
payment_url: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/webhooks/payment
|
||||
* Simulates a payment gateway callback for the given ticket.
|
||||
* Uses a random idempotency key to satisfy the backend's idempotency check.
|
||||
* POST /api/tickets/book
|
||||
* Books a seat and initiates an acquiring session.
|
||||
* On success, caller must redirect to `payment_url`.
|
||||
* Throws AxiosError 400/404 if the seat is taken, locked, or not found.
|
||||
* Requires a valid Bearer token (injected by the request interceptor).
|
||||
*/
|
||||
export async function processPaymentWebhook(ticketId: number): Promise<void> {
|
||||
const idempotencyKey = "req-" + Math.random().toString(36).substring(2, 9);
|
||||
await apiClient.post("/webhooks/payment", {
|
||||
ticket_id: ticketId,
|
||||
idempotency_key: idempotencyKey,
|
||||
status: "success",
|
||||
export async function bookTicket(seatId: number): Promise<BookTicketResponse> {
|
||||
const response = await apiClient.post<BookTicketResponse>("/tickets/book", {
|
||||
seat_id: seatId,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// ─── Ticket scanner ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
ArrowLeft,
|
||||
@@ -11,8 +11,9 @@ import {
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import type { AxiosError } from "axios";
|
||||
import { useCartStore, type CartSeat } from "@/store/cartStore";
|
||||
import { processPaymentWebhook } from "@/api/client";
|
||||
import { bookTicket } from "@/api/client";
|
||||
|
||||
// ─── Mock event data (real data would come from route param / API) ─────────────
|
||||
|
||||
@@ -97,26 +98,40 @@ export default function CheckoutPage() {
|
||||
const finalTotal = rawTotal - bonusDiscount;
|
||||
const seatGroups = groupByRow(seats);
|
||||
|
||||
function showToast(message: string, isError = false) {
|
||||
const showToast = useCallback((message: string, isError = false) => {
|
||||
setToastMsg(message);
|
||||
setToastIsError(isError);
|
||||
setTimeout(() => setToastMsg(null), 3000);
|
||||
}
|
||||
}, []);
|
||||
|
||||
async function handlePay() {
|
||||
const handlePay = useCallback(async () => {
|
||||
// Для MVP жестко ограничиваем: один заказ = один билет
|
||||
if (seats.length === 0 || isLoading) return;
|
||||
|
||||
if (seats.length > 1) {
|
||||
showToast("В MVP-версии можно оплатить только одно место за раз. Оставьте в корзине один билет.", true);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await Promise.all(seats.map((s: CartSeat) => processPaymentWebhook(s.ticketId)));
|
||||
clearCart();
|
||||
showToast("Оплата прошла успешно!");
|
||||
setTimeout(() => router.push("/tickets"), 1400);
|
||||
} catch {
|
||||
showToast("Ошибка оплаты. Попробуйте снова.", true);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
// Берем строго единственное место из корзины
|
||||
const targetSeat = seats[0];
|
||||
const response = await bookTicket(targetSeat.seatId);
|
||||
|
||||
// Корзину пока НЕ чистим (оставим это на совесть успешного возврата)
|
||||
// Уходим на шлюз. Кнопка остается заблокированной (isLoading: true).
|
||||
window.location.href = response.payment_url;
|
||||
|
||||
} catch (err) {
|
||||
const axiosErr = err as AxiosError<{ detail: string }>;
|
||||
const detail = axiosErr.response?.data?.detail;
|
||||
const msg = typeof detail === "string" ? detail : "Ошибка связи с платежным шлюзом.";
|
||||
|
||||
showToast(msg, true);
|
||||
setIsLoading(false); // Разблокируем кнопку только при ошибке
|
||||
}
|
||||
}
|
||||
}, [seats, isLoading, showToast]);
|
||||
|
||||
// ── Empty cart guard ──
|
||||
if (seats.length === 0) {
|
||||
|
||||
Reference in New Issue
Block a user