diff --git a/frontend-client/src/api/client.ts b/frontend-client/src/api/client.ts index 50a8623..c0dab99 100644 --- a/frontend-client/src/api/client.ts +++ b/frontend-client/src/api/client.ts @@ -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 { - 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 { + const response = await apiClient.post("/tickets/book", { + seat_id: seatId, }); + return response.data; } // ─── Ticket scanner ─────────────────────────────────────────────────────────── diff --git a/frontend-client/src/app/checkout/page.tsx b/frontend-client/src/app/checkout/page.tsx index 413e3fa..3a205d4 100644 --- a/frontend-client/src/app/checkout/page.tsx +++ b/frontend-client/src/app/checkout/page.tsx @@ -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) {