Update paiments
All checks were successful
Deploy / deploy (push) Successful in 17s

This commit is contained in:
2026-03-12 10:18:08 +00:00
parent d7e1fe31ff
commit eaf0891905
2 changed files with 45 additions and 24 deletions

View File

@@ -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 ───────────────────────────────────────────────────────────

View File

@@ -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) {