From 8de0a1e7db7b0784770a59ae1944b498dc0d6dbd Mon Sep 17 00:00:00 2001 From: openit Date: Fri, 6 Mar 2026 13:18:32 +0000 Subject: [PATCH] Update project 7 FRONT UI -> API -> DB -> RabbitMQ -> Worker -> MinIO --- frontend-client/FRONTEND_ARCHITECTURE.md | 19 +- frontend-client/src/api/client.ts | 44 ++- frontend-client/src/app/checkout/page.tsx | 346 ++++++++++++++++++ .../src/app/events/[id]/seats/page.tsx | 8 +- .../src/components/OctagonSeatMap.tsx | 5 +- frontend-client/src/store/cartStore.ts | 3 +- infra/docker-compose.yml | 2 + 7 files changed, 408 insertions(+), 19 deletions(-) create mode 100644 frontend-client/src/app/checkout/page.tsx diff --git a/frontend-client/FRONTEND_ARCHITECTURE.md b/frontend-client/FRONTEND_ARCHITECTURE.md index 052c66b..d217b70 100644 --- a/frontend-client/FRONTEND_ARCHITECTURE.md +++ b/frontend-client/FRONTEND_ARCHITECTURE.md @@ -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 ` из `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-хранилища. \ No newline at end of file diff --git a/frontend-client/src/api/client.ts b/frontend-client/src/api/client.ts index 1bd4a72..ba177a9 100644 --- a/frontend-client/src/api/client.ts +++ b/frontend-client/src/api/client.ts @@ -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 { - 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( + `/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 { + const idempotencyKey = "req-" + Math.random().toString(36).substring(2, 9); + await apiClient.post("/webhooks/payment", { + ticket_id: ticketId, + idempotency_key: idempotencyKey, + status: "success", }); } diff --git a/frontend-client/src/app/checkout/page.tsx b/frontend-client/src/app/checkout/page.tsx new file mode 100644 index 0000000..413e3fa --- /dev/null +++ b/frontend-client/src/app/checkout/page.tsx @@ -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 ( +
+ + Pay + +
+ ); + } + if (id === "card") { + return ( +
+ +
+ ); + } + // Mastercard-style icon + return ( +
+
+
+
+
+
+ ); +} + +// Group seats by sector+row for the summary block +function groupByRow(seats: CartSeat[]): Map { + const map = new Map(); + 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("apple-pay"); + const [useBonuses, setUseBonuses] = useState(false); + const [promoCode, setPromoCode] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [toastMsg, setToastMsg] = useState(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 ( +
+
+
+ +
+

+ Корзина пуста. Выберите места. +

+ +
+
+ ); + } + + return ( +
+
+ + {/* ── Toast ── */} +
+
+ {!toastIsError && } + {toastMsg} +
+
+ + {/* ── Header ── */} +
+ +

Оформление заказа

+
+ + {/* ── Scrollable content ── */} +
+ + {/* Order summary card */} +
+ {/* Event banner */} +
+
+ {MOCK_EVENT.title} +
+
+

+ {MOCK_EVENT.title} +

+

{MOCK_EVENT.subtitle}

+
+
+ +
+ + {/* Grouped seat rows */} + {Array.from(seatGroups.entries()).map(([key, groupSeats]) => ( +
+

+ Сектор:{" "} + {groupSeats[0].sector} + , Ряд:{" "} + {groupSeats[0].row} +

+

+ Места:{" "} + + {groupSeats.map((s) => s.number).join(", ")} + +

+
+ ))} + +
+ +
+

Итого к оплате:

+
+

+ {finalTotal.toLocaleString("ru-RU")} ₽ +

+

Включая сборы

+
+
+
+ + {/* Promo code — visual stub */} +
+ setPromoCode(e.target.value)} + placeholder="Ввести промокод" + className="flex-1 bg-transparent text-[14px] text-white placeholder-[#8E8E93] px-3 py-2 outline-none" + /> + +
+ + {/* Bonuses — visual stub with functional toggle */} +
+
+

Бонусы

+

+ У вас:{" "} + {BONUS_BALANCE} баллов +

+
+
+ +
+

Использовать баллы для скидки?

+

+ Скидка: −{BONUS_BALANCE.toLocaleString("ru-RU")} ₽ +

+
+
+
+ + {/* Payment method selector */} +
+

Метод оплаты

+ {PAYMENT_METHODS.map((method, idx) => ( +
+ {idx > 0 &&
} + +
+ ))} + + {/* Security notice */} +
+ +

Все платежи защищены

+
+
+ +
+ + {/* ── Sticky bottom bar ── */} +
+
+
+

Итого к оплате:

+

+ {finalTotal.toLocaleString("ru-RU")} ₽ +

+
+ +
+
+ +
+
+ ); +} diff --git a/frontend-client/src/app/events/[id]/seats/page.tsx b/frontend-client/src/app/events/[id]/seats/page.tsx index 13b5e13..3f46fcb 100644 --- a/frontend-client/src/app/events/[id]/seats/page.tsx +++ b/frontend-client/src/app/events/[id]/seats/page.tsx @@ -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 (
@@ -150,7 +153,10 @@ export default function SeatsPage() { {total.toLocaleString("ru-RU")} ₽

-
diff --git a/frontend-client/src/components/OctagonSeatMap.tsx b/frontend-client/src/components/OctagonSeatMap.tsx index c3fc916..74872d1 100644 --- a/frontend-client/src/components/OctagonSeatMap.tsx +++ b/frontend-client/src/components/OctagonSeatMap.tsx @@ -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, diff --git a/frontend-client/src/store/cartStore.ts b/frontend-client/src/store/cartStore.ts index 656ee51..986cc60 100644 --- a/frontend-client/src/store/cartStore.ts +++ b/frontend-client/src/store/cartStore.ts @@ -1,7 +1,8 @@ import { create } from "zustand"; -interface CartSeat { +export interface CartSeat { seatId: number; + ticketId: number; sector: string; row: number; number: number; diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml index 7bcfd52..9cb0a3b 100644 --- a/infra/docker-compose.yml +++ b/infra/docker-compose.yml @@ -43,6 +43,8 @@ services: container_name: frontend ports: - "3000:3000" # Пробиваем дыру напрямую для дебага + environment: + - NEXT_PUBLIC_API_URL=http://192.168.149.101:8000/api volumes: - ../frontend-client:/app - /app/node_modules # Изолируем зависимости контейнера от хоста