Update project 7 FRONT UI -> API -> DB -> RabbitMQ -> Worker -> MinIO
This commit is contained in:
@@ -2,23 +2,22 @@
|
|||||||
|
|
||||||
## 1. Базовые принципы
|
## 1. Базовые принципы
|
||||||
- **Стек:** Next.js 14 (App Router), React 18, TypeScript, Tailwind CSS.
|
- **Стек:** Next.js 14 (App Router), React 18, TypeScript, Tailwind CSS.
|
||||||
- **UI/UX:** Темная тема (`#121212`), акцентный цвет `#E32636` (красный неон). Иконки — строго `lucide-react`.
|
- **UI/UX:** Темная тема (`#121212`), акцентный цвет `#E32636`. Иконки — строго `lucide-react`.
|
||||||
- **Компоненты:** Никакого кастомного CSS. Используем только утилитные классы Tailwind. Если нужен сложный компонент (модалка, селект) — используем паттерны `shadcn/ui`.
|
- **Компоненты:** Никакого кастомного CSS. Только утилитные классы Tailwind.
|
||||||
|
|
||||||
## 2. Управление состоянием (State Management)
|
## 2. Управление состоянием (State Management)
|
||||||
- **Локальный стейт:** `useState` / `useReducer` для состояния UI (открытые меню, табы).
|
- **Глобальный стейт (Zustand):**
|
||||||
- **Глобальный стейт:** Строго `Zustand` (директория `src/store/`).
|
- `authStore`: Хранит JWT-токен и данные профиля. **Обязательно** использовать persist middleware (хранить в localStorage).
|
||||||
- `authStore`: Хранит JWT-токен и данные профиля (persist in localStorage).
|
- `cartStore`: Хранит выбранные места (`CartSeat` с `ticketId`). **ЗАПРЕЩЕНО** использовать persist. Корзина живет только в памяти страницы, так как лок места на бэкенде временный (TTL в Redis).
|
||||||
- `cartStore`: Хранит выбранные `seat_id` и считает итоговую сумму (totalPrice).
|
|
||||||
|
|
||||||
## 3. Работа с API и Сетью
|
## 3. Работа с API и Сетью (КРИТИЧЕСКИ ВАЖНО)
|
||||||
|
- **Базовый URL:** ЗАПРЕЩЕНО хардкодить `localhost` или прямые IP в исходном коде. Всегда использовать `process.env.NEXT_PUBLIC_API_URL` с фоллбэком.
|
||||||
- **Клиент:** Axios инстанс (`src/api/client.ts`).
|
- **Клиент:** Axios инстанс (`src/api/client.ts`).
|
||||||
- **Авторизация:** Интерсептор автоматически добавляет `Authorization: Bearer <token>` из `authStore`.
|
- **Авторизация:** Интерсептор автоматически добавляет `Authorization: Bearer <token>` из `authStore`.
|
||||||
- **Роутинг к Бэкенду:** Base URL указывает на API-шлюз бэкенда.
|
- **Обработка ошибок:** Все вызовы API оборачивать в `try/catch`. Бизнес-ошибки (например, 409 Conflict) обрабатывать локально с показом Toast-уведомлений.
|
||||||
- **Обработка ошибок:** Все сетевые запросы должны оборачиваться в `try/catch`. 401 ошибка должна разлогинивать юзера и чистить `authStore`. 409 ошибка (Conflict) обрабатывается локально в компонентах (например, при захвате мест).
|
|
||||||
|
|
||||||
## 4. Структура директорий
|
## 4. Структура директорий
|
||||||
- `src/app/` — Роутинг (Pages, Layouts).
|
- `src/app/` — Роутинг (Pages, Layouts).
|
||||||
- `src/components/` — Переиспользуемые UI компоненты (Кнопки, Карточки, SVG-схемы).
|
- `src/components/` — Переиспользуемые UI компоненты.
|
||||||
- `src/api/` — API-клиенты и типизация ответов бэкенда.
|
- `src/api/` — API-клиенты и типизация ответов бэкенда.
|
||||||
- `src/store/` — Zustand-хранилища.
|
- `src/store/` — Zustand-хранилища.
|
||||||
@@ -2,7 +2,7 @@ import axios from "axios";
|
|||||||
import { useAuthStore } from "@/store/authStore";
|
import { useAuthStore } from "@/store/authStore";
|
||||||
|
|
||||||
const apiClient = axios.create({
|
const apiClient = axios.create({
|
||||||
baseURL: "http://192.168.149.101:8000/api",
|
baseURL: process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8081/api",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -14,13 +14,47 @@ apiClient.interceptors.request.use((config) => {
|
|||||||
return config;
|
return config;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── Seat locking ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface LockSeatResponse {
|
||||||
|
message: string;
|
||||||
|
seat_id: number;
|
||||||
|
status: string;
|
||||||
|
/** Returned by backend when the Ticket row is created. Falls back to seat_id. */
|
||||||
|
ticket_id?: number;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/seats/{seat_id}/lock?user_id={user_id}
|
* POST /api/seats/{seat_id}/lock?user_id={user_id}
|
||||||
* Бросает AxiosError со status 409, если место захвачено конкурентом.
|
* Returns the ticketId for the locked seat (falls back to seatId if backend
|
||||||
|
* does not yet expose ticket_id in the response body).
|
||||||
|
* Throws AxiosError with status 409 if the seat is already locked.
|
||||||
*/
|
*/
|
||||||
export async function lockSeatApi(seatId: number, userId: number): Promise<void> {
|
export async function lockSeatApi(
|
||||||
await apiClient.post(`/seats/${seatId}/lock`, null, {
|
seatId: number,
|
||||||
params: { user_id: userId },
|
userId: number
|
||||||
|
): Promise<{ ticketId: number }> {
|
||||||
|
const response = await apiClient.post<LockSeatResponse>(
|
||||||
|
`/seats/${seatId}/lock`,
|
||||||
|
null,
|
||||||
|
{ params: { user_id: userId } }
|
||||||
|
);
|
||||||
|
return { ticketId: response.data.ticket_id ?? seatId };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Payment webhook ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
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",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
346
frontend-client/src/app/checkout/page.tsx
Normal file
346
frontend-client/src/app/checkout/page.tsx
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Shield,
|
||||||
|
Check,
|
||||||
|
Ticket,
|
||||||
|
CreditCard,
|
||||||
|
Loader2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { useCartStore, type CartSeat } from "@/store/cartStore";
|
||||||
|
import { processPaymentWebhook } from "@/api/client";
|
||||||
|
|
||||||
|
// ─── Mock event data (real data would come from route param / API) ─────────────
|
||||||
|
|
||||||
|
const MOCK_EVENT = {
|
||||||
|
title: "Чемпионат по ММА",
|
||||||
|
subtitle: "15 Марта, 19:00 | ВТБ Арена",
|
||||||
|
imageSrc:
|
||||||
|
"https://images.unsplash.com/photo-1549719386-74dfcbf7dbed?w=400&q=80",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Payment method config ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const PAYMENT_METHODS = [
|
||||||
|
{ id: "apple-pay", label: "Apple Pay" },
|
||||||
|
{ id: "card", label: "Кредитная карта" },
|
||||||
|
{ id: "sbp", label: "Скидка на кэшбэк" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type PaymentMethodId = (typeof PAYMENT_METHODS)[number]["id"];
|
||||||
|
|
||||||
|
// ─── Sub-components ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function PaymentMethodIcon({ id }: { id: PaymentMethodId }) {
|
||||||
|
if (id === "apple-pay") {
|
||||||
|
return (
|
||||||
|
<div className="w-10 h-7 bg-white rounded-md flex items-center justify-center flex-shrink-0">
|
||||||
|
<span className="text-black text-[10px] font-extrabold tracking-tight leading-none">
|
||||||
|
Pay
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (id === "card") {
|
||||||
|
return (
|
||||||
|
<div className="w-10 h-7 bg-[#2C2C2E] rounded-md flex items-center justify-center flex-shrink-0">
|
||||||
|
<CreditCard size={15} className="text-[#8E8E93]" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Mastercard-style icon
|
||||||
|
return (
|
||||||
|
<div className="w-10 h-7 bg-[#2C2C2E] rounded-md flex items-center justify-center flex-shrink-0">
|
||||||
|
<div className="flex -space-x-2">
|
||||||
|
<div className="w-4 h-4 rounded-full bg-red-500 opacity-90" />
|
||||||
|
<div className="w-4 h-4 rounded-full bg-yellow-400 opacity-90" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group seats by sector+row for the summary block
|
||||||
|
function groupByRow(seats: CartSeat[]): Map<string, CartSeat[]> {
|
||||||
|
const map = new Map<string, CartSeat[]>();
|
||||||
|
for (const seat of seats) {
|
||||||
|
const key = `${seat.sector}__${seat.row}`;
|
||||||
|
const existing = map.get(key);
|
||||||
|
if (existing) {
|
||||||
|
existing.push(seat);
|
||||||
|
} else {
|
||||||
|
map.set(key, [seat]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Page ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function CheckoutPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { seats, totalPrice, clearCart } = useCartStore();
|
||||||
|
|
||||||
|
const [selectedMethod, setSelectedMethod] = useState<PaymentMethodId>("apple-pay");
|
||||||
|
const [useBonuses, setUseBonuses] = useState(false);
|
||||||
|
const [promoCode, setPromoCode] = useState("");
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [toastMsg, setToastMsg] = useState<string | null>(null);
|
||||||
|
const [toastIsError, setToastIsError] = useState(false);
|
||||||
|
|
||||||
|
const BONUS_BALANCE = 120;
|
||||||
|
const bonusDiscount = useBonuses ? BONUS_BALANCE : 0;
|
||||||
|
const rawTotal = totalPrice();
|
||||||
|
const finalTotal = rawTotal - bonusDiscount;
|
||||||
|
const seatGroups = groupByRow(seats);
|
||||||
|
|
||||||
|
function showToast(message: string, isError = false) {
|
||||||
|
setToastMsg(message);
|
||||||
|
setToastIsError(isError);
|
||||||
|
setTimeout(() => setToastMsg(null), 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePay() {
|
||||||
|
if (seats.length === 0 || isLoading) 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Empty cart guard ──
|
||||||
|
if (seats.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center min-h-screen bg-[#121212]">
|
||||||
|
<div className="w-full max-w-[390px] flex flex-col items-center justify-center gap-5 px-5">
|
||||||
|
<div className="w-16 h-16 rounded-full bg-[#1C1C1E] flex items-center justify-center">
|
||||||
|
<Ticket size={28} className="text-[#8E8E93]" />
|
||||||
|
</div>
|
||||||
|
<p className="text-[15px] text-[#8E8E93] text-center">
|
||||||
|
Корзина пуста. Выберите места.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => router.back()}
|
||||||
|
className="text-[#E32636] text-[14px] font-semibold"
|
||||||
|
>
|
||||||
|
← Назад к выбору мест
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center min-h-screen bg-[#121212]">
|
||||||
|
<div className="relative w-full max-w-[390px] flex flex-col bg-[#121212]">
|
||||||
|
|
||||||
|
{/* ── Toast ── */}
|
||||||
|
<div
|
||||||
|
className={`fixed top-14 left-1/2 -translate-x-1/2 z-50 transition-all duration-300 ${
|
||||||
|
toastMsg ? "opacity-100 translate-y-0 pointer-events-auto" : "opacity-0 -translate-y-2 pointer-events-none"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-2 px-5 py-2.5 rounded-full shadow-xl text-white text-[13px] font-medium whitespace-nowrap ${
|
||||||
|
toastIsError ? "bg-[#E32636]" : "bg-[#16A34A]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{!toastIsError && <Check size={14} strokeWidth={2.5} />}
|
||||||
|
{toastMsg}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Header ── */}
|
||||||
|
<div className="flex items-center gap-3 px-5 pt-12 pb-4">
|
||||||
|
<button
|
||||||
|
onClick={() => router.back()}
|
||||||
|
aria-label="Назад"
|
||||||
|
className="w-9 h-9 flex items-center justify-center rounded-full bg-[#1C1C1E] text-white flex-shrink-0"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={18} strokeWidth={2.5} />
|
||||||
|
</button>
|
||||||
|
<h1 className="text-[18px] font-bold text-white">Оформление заказа</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Scrollable content ── */}
|
||||||
|
<div className="flex-1 overflow-y-auto px-4 pb-36 space-y-3">
|
||||||
|
|
||||||
|
{/* Order summary card */}
|
||||||
|
<div className="bg-[#1C1C1E] rounded-2xl p-4">
|
||||||
|
{/* Event banner */}
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className="relative w-12 h-12 rounded-xl overflow-hidden flex-shrink-0">
|
||||||
|
<Image
|
||||||
|
src={MOCK_EVENT.imageSrc}
|
||||||
|
alt={MOCK_EVENT.title}
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
sizes="48px"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-[15px] font-bold text-white leading-tight truncate">
|
||||||
|
{MOCK_EVENT.title}
|
||||||
|
</p>
|
||||||
|
<p className="text-[12px] text-[#8E8E93] mt-0.5">{MOCK_EVENT.subtitle}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-px bg-[#2C2C2E] mb-4" />
|
||||||
|
|
||||||
|
{/* Grouped seat rows */}
|
||||||
|
{Array.from(seatGroups.entries()).map(([key, groupSeats]) => (
|
||||||
|
<div key={key} className="flex justify-between items-baseline mb-2 last:mb-0">
|
||||||
|
<p className="text-[13px] text-[#8E8E93]">
|
||||||
|
Сектор:{" "}
|
||||||
|
<span className="text-white font-semibold">{groupSeats[0].sector}</span>
|
||||||
|
, Ряд:{" "}
|
||||||
|
<span className="text-white font-semibold">{groupSeats[0].row}</span>
|
||||||
|
</p>
|
||||||
|
<p className="text-[13px] text-[#8E8E93] text-right">
|
||||||
|
Места:{" "}
|
||||||
|
<span className="text-white font-semibold">
|
||||||
|
{groupSeats.map((s) => s.number).join(", ")}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="h-px bg-[#2C2C2E] my-4" />
|
||||||
|
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<p className="text-[15px] font-bold text-white">Итого к оплате:</p>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-[20px] font-bold text-white leading-none">
|
||||||
|
{finalTotal.toLocaleString("ru-RU")} ₽
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-[#8E8E93] mt-0.5">Включая сборы</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Promo code — visual stub */}
|
||||||
|
<div className="bg-[#1C1C1E] rounded-2xl flex items-center gap-2 p-1.5">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={promoCode}
|
||||||
|
onChange={(e) => setPromoCode(e.target.value)}
|
||||||
|
placeholder="Ввести промокод"
|
||||||
|
className="flex-1 bg-transparent text-[14px] text-white placeholder-[#8E8E93] px-3 py-2 outline-none"
|
||||||
|
/>
|
||||||
|
<button className="bg-[#2C2C2E] text-white text-[13px] font-semibold px-4 py-2.5 rounded-xl flex-shrink-0">
|
||||||
|
Применить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bonuses — visual stub with functional toggle */}
|
||||||
|
<div className="bg-[#1C1C1E] rounded-2xl p-4">
|
||||||
|
<div className="flex justify-between items-center mb-3">
|
||||||
|
<p className="text-[15px] font-semibold text-white">Бонусы</p>
|
||||||
|
<p className="text-[13px] text-[#8E8E93]">
|
||||||
|
У вас:{" "}
|
||||||
|
<span className="text-white font-semibold">{BONUS_BALANCE} баллов</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setUseBonuses((v) => !v)}
|
||||||
|
aria-checked={useBonuses}
|
||||||
|
role="switch"
|
||||||
|
className={`relative w-11 h-6 rounded-full transition-colors flex-shrink-0 ${
|
||||||
|
useBonuses ? "bg-[#E32636]" : "bg-[#3A3A3C]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full shadow transition-transform ${
|
||||||
|
useBonuses ? "translate-x-5" : "translate-x-0"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<div>
|
||||||
|
<p className="text-[14px] text-white">Использовать баллы для скидки?</p>
|
||||||
|
<p className="text-[12px] text-[#8E8E93]">
|
||||||
|
Скидка: −{BONUS_BALANCE.toLocaleString("ru-RU")} ₽
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Payment method selector */}
|
||||||
|
<div className="bg-[#1C1C1E] rounded-2xl px-4 pt-4 pb-1">
|
||||||
|
<p className="text-[15px] font-semibold text-white mb-1">Метод оплаты</p>
|
||||||
|
{PAYMENT_METHODS.map((method, idx) => (
|
||||||
|
<div key={method.id}>
|
||||||
|
{idx > 0 && <div className="h-px bg-[#2C2C2E]" />}
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedMethod(method.id)}
|
||||||
|
className="flex items-center justify-between w-full py-3"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<PaymentMethodIcon id={method.id} />
|
||||||
|
<span className="text-[15px] text-white">{method.label}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`w-5 h-5 rounded-full border-2 flex items-center justify-center transition-colors ${
|
||||||
|
selectedMethod === method.id
|
||||||
|
? "border-[#E32636] bg-[#E32636]"
|
||||||
|
: "border-[#4B5563] bg-transparent"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{selectedMethod === method.id && (
|
||||||
|
<Check size={11} className="text-white" strokeWidth={3} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Security notice */}
|
||||||
|
<div className="flex items-center gap-1.5 py-3 border-t border-[#2C2C2E]">
|
||||||
|
<Shield size={12} className="text-[#8E8E93] flex-shrink-0" />
|
||||||
|
<p className="text-[12px] text-[#8E8E93]">Все платежи защищены</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Sticky bottom bar ── */}
|
||||||
|
<div className="fixed bottom-0 left-1/2 -translate-x-1/2 w-full max-w-[390px] bg-[#1C1C1E] border-t border-[#2C2C2E] px-5 pt-4 pb-8">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-[12px] text-[#8E8E93]">Итого к оплате:</p>
|
||||||
|
<p className="text-[22px] font-bold text-white leading-tight truncate">
|
||||||
|
{finalTotal.toLocaleString("ru-RU")} ₽
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handlePay}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="flex-shrink-0 flex items-center gap-2 bg-[#E32636] hover:bg-[#C41E2A] disabled:opacity-60 disabled:cursor-not-allowed active:scale-95 transition-all text-white text-[16px] font-bold px-7 py-3.5 rounded-2xl"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 size={17} className="animate-spin" />
|
||||||
|
Обработка…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Оплатить"
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { ArrowLeft, Share2, MapPin, ShoppingCart, X } from "lucide-react";
|
|||||||
import OctagonSeatMap from "@/components/OctagonSeatMap";
|
import OctagonSeatMap from "@/components/OctagonSeatMap";
|
||||||
import { useCartStore } from "@/store/cartStore";
|
import { useCartStore } from "@/store/cartStore";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
// ─── Mocked event data (real API call would go here) ─────────────────────────
|
// ─── Mocked event data (real API call would go here) ─────────────────────────
|
||||||
|
|
||||||
@@ -38,6 +39,8 @@ export default function SeatsPage() {
|
|||||||
// The first selected seat for the summary line
|
// The first selected seat for the summary line
|
||||||
const firstSeat = cartSeats[0];
|
const firstSeat = cartSeats[0];
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center min-h-screen bg-[#121212]">
|
<div className="flex justify-center min-h-screen bg-[#121212]">
|
||||||
<div className="relative w-full max-w-[390px] flex flex-col min-h-screen bg-[#121212]">
|
<div className="relative w-full max-w-[390px] flex flex-col min-h-screen bg-[#121212]">
|
||||||
@@ -150,7 +153,10 @@ export default function SeatsPage() {
|
|||||||
{total.toLocaleString("ru-RU")} ₽
|
{total.toLocaleString("ru-RU")} ₽
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button className="bg-[#E32636] hover:bg-[#C41E2A] active:scale-95 transition-all text-white text-[15px] font-semibold px-6 py-3.5 rounded-2xl">
|
<button
|
||||||
|
onClick={() => router.push('/checkout')}
|
||||||
|
className="bg-[#E32636] hover:bg-[#C41E2A] active:scale-95 transition-all text-white text-[15px] font-semibold px-6 py-3.5 rounded-2xl"
|
||||||
|
>
|
||||||
Перейти к оплате
|
Перейти к оплате
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -149,11 +149,12 @@ export default function OctagonSeatMap() {
|
|||||||
setPendingId(seat.id);
|
setPendingId(seat.id);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await lockSeatApi(seat.id, userId);
|
const { ticketId } = await lockSeatApi(seat.id, userId);
|
||||||
|
|
||||||
// ✅ 200 OK — seat is ours, add to cart
|
// ✅ 200 OK — seat is ours, add to cart with the resolved ticketId
|
||||||
addSeat({
|
addSeat({
|
||||||
seatId: seat.id,
|
seatId: seat.id,
|
||||||
|
ticketId,
|
||||||
sector: seat.sector,
|
sector: seat.sector,
|
||||||
row: seat.row,
|
row: seat.row,
|
||||||
number: seat.number,
|
number: seat.number,
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
|
|
||||||
interface CartSeat {
|
export interface CartSeat {
|
||||||
seatId: number;
|
seatId: number;
|
||||||
|
ticketId: number;
|
||||||
sector: string;
|
sector: string;
|
||||||
row: number;
|
row: number;
|
||||||
number: number;
|
number: number;
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ services:
|
|||||||
container_name: frontend
|
container_name: frontend
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000" # Пробиваем дыру напрямую для дебага
|
- "3000:3000" # Пробиваем дыру напрямую для дебага
|
||||||
|
environment:
|
||||||
|
- NEXT_PUBLIC_API_URL=http://192.168.149.101:8000/api
|
||||||
volumes:
|
volumes:
|
||||||
- ../frontend-client:/app
|
- ../frontend-client:/app
|
||||||
- /app/node_modules # Изолируем зависимости контейнера от хоста
|
- /app/node_modules # Изолируем зависимости контейнера от хоста
|
||||||
|
|||||||
Reference in New Issue
Block a user