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