Update project 7 FRONT UI -> API -> DB -> RabbitMQ -> Worker -> MinIO

This commit is contained in:
2026-03-06 13:18:32 +00:00
parent a418c53664
commit 8de0a1e7db
7 changed files with 408 additions and 19 deletions

View File

@@ -2,23 +2,22 @@
## 1. Базовые принципы
- **Стек:** Next.js 14 (App Router), React 18, TypeScript, Tailwind CSS.
- **UI/UX:** Темная тема (`#121212`), акцентный цвет `#E32636` (красный неон). Иконки — строго `lucide-react`.
- **Компоненты:** Никакого кастомного CSS. Используем только утилитные классы Tailwind. Если нужен сложный компонент (модалка, селект) — используем паттерны `shadcn/ui`.
- **UI/UX:** Темная тема (`#121212`), акцентный цвет `#E32636`. Иконки — строго `lucide-react`.
- **Компоненты:** Никакого кастомного CSS. Только утилитные классы Tailwind.
## 2. Управление состоянием (State Management)
- **Локальный стейт:** `useState` / `useReducer` для состояния UI (открытые меню, табы).
- **Глобальный стейт:** Строго `Zustand` (директория `src/store/`).
- `authStore`: Хранит JWT-токен и данные профиля (persist in localStorage).
- `cartStore`: Хранит выбранные `seat_id` и считает итоговую сумму (totalPrice).
- **Глобальный стейт (Zustand):**
- `authStore`: Хранит JWT-токен и данные профиля. **Обязательно** использовать persist middleware (хранить в localStorage).
- `cartStore`: Хранит выбранные места (`CartSeat` с `ticketId`). **ЗАПРЕЩЕНО** использовать persist. Корзина живет только в памяти страницы, так как лок места на бэкенде временный (TTL в Redis).
## 3. Работа с API и Сетью
## 3. Работа с API и Сетью (КРИТИЧЕСКИ ВАЖНО)
- **Базовый URL:** ЗАПРЕЩЕНО хардкодить `localhost` или прямые IP в исходном коде. Всегда использовать `process.env.NEXT_PUBLIC_API_URL` с фоллбэком.
- **Клиент:** Axios инстанс (`src/api/client.ts`).
- **Авторизация:** Интерсептор автоматически добавляет `Authorization: Bearer <token>` из `authStore`.
- **Роутинг к Бэкенду:** Base URL указывает на API-шлюз бэкенда.
- **Обработка ошибок:** Все сетевые запросы должны оборачиваться в `try/catch`. 401 ошибка должна разлогинивать юзера и чистить `authStore`. 409 ошибка (Conflict) обрабатывается локально в компонентах (например, при захвате мест).
- **Обработка ошибок:** Все вызовы API оборачивать в `try/catch`. Бизнес-ошибки (например, 409 Conflict) обрабатывать локально с показом Toast-уведомлений.
## 4. Структура директорий
- `src/app/` — Роутинг (Pages, Layouts).
- `src/components/` — Переиспользуемые UI компоненты (Кнопки, Карточки, SVG-схемы).
- `src/components/` — Переиспользуемые UI компоненты.
- `src/api/` — API-клиенты и типизация ответов бэкенда.
- `src/store/` — Zustand-хранилища.

View File

@@ -2,7 +2,7 @@ import axios from "axios";
import { useAuthStore } from "@/store/authStore";
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" },
});
@@ -14,13 +14,47 @@ apiClient.interceptors.request.use((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}
* Бросает 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> {
await apiClient.post(`/seats/${seatId}/lock`, null, {
params: { user_id: userId },
export async function lockSeatApi(
seatId: number,
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",
});
}

View 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>
);
}

View File

@@ -4,6 +4,7 @@ import { ArrowLeft, Share2, MapPin, ShoppingCart, X } from "lucide-react";
import OctagonSeatMap from "@/components/OctagonSeatMap";
import { useCartStore } from "@/store/cartStore";
import Image from "next/image";
import { useRouter } from 'next/navigation';
// ─── 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
const firstSeat = cartSeats[0];
const router = useRouter();
return (
<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]">
@@ -150,7 +153,10 @@ export default function SeatsPage() {
{total.toLocaleString("ru-RU")}
</p>
</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>
</div>

View File

@@ -149,11 +149,12 @@ export default function OctagonSeatMap() {
setPendingId(seat.id);
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({
seatId: seat.id,
ticketId,
sector: seat.sector,
row: seat.row,
number: seat.number,

View File

@@ -1,7 +1,8 @@
import { create } from "zustand";
interface CartSeat {
export interface CartSeat {
seatId: number;
ticketId: number;
sector: string;
row: number;
number: number;