From a418c536646b77a3cb66c7066b1637a86a8a7302 Mon Sep 17 00:00:00 2001 From: openit Date: Fri, 6 Mar 2026 12:39:26 +0000 Subject: [PATCH] Update project 6 FRONT SVG SEAT LOCK --- backend/main.py | 2 +- frontend-client/FRONTEND_ARCHITECTURE.md | 24 +++ frontend-client/src/api/client.ts | 13 +- .../src/components/OctagonSeatMap.tsx | 154 ++++++++++++++---- 4 files changed, 158 insertions(+), 35 deletions(-) create mode 100644 frontend-client/FRONTEND_ARCHITECTURE.md diff --git a/backend/main.py b/backend/main.py index f0cce5a..b56e2b2 100644 --- a/backend/main.py +++ b/backend/main.py @@ -48,7 +48,7 @@ async def lock_seat(seat_id: int, user_id: int, db: AsyncSession = Depends(get_d ticket.user_id = user_id await db.commit() - return {"message": "Seat locked successfully", "seat_id": seat_id, "status": "LOCKED"} + return {"message": "Seat locked successfully", "seat_id": seat_id, "ticket_id": ticket.id, "status": "LOCKED"} except Exception as e: # Критически важно: если БД отвалилась, снимаем лок в Redis, иначе место зависнет на 15 минут diff --git a/frontend-client/FRONTEND_ARCHITECTURE.md b/frontend-client/FRONTEND_ARCHITECTURE.md new file mode 100644 index 0000000..052c66b --- /dev/null +++ b/frontend-client/FRONTEND_ARCHITECTURE.md @@ -0,0 +1,24 @@ +# Архитектура Frontend-клиента (Next.js 14) + +## 1. Базовые принципы +- **Стек:** Next.js 14 (App Router), React 18, TypeScript, Tailwind CSS. +- **UI/UX:** Темная тема (`#121212`), акцентный цвет `#E32636` (красный неон). Иконки — строго `lucide-react`. +- **Компоненты:** Никакого кастомного CSS. Используем только утилитные классы Tailwind. Если нужен сложный компонент (модалка, селект) — используем паттерны `shadcn/ui`. + +## 2. Управление состоянием (State Management) +- **Локальный стейт:** `useState` / `useReducer` для состояния UI (открытые меню, табы). +- **Глобальный стейт:** Строго `Zustand` (директория `src/store/`). + - `authStore`: Хранит JWT-токен и данные профиля (persist in localStorage). + - `cartStore`: Хранит выбранные `seat_id` и считает итоговую сумму (totalPrice). + +## 3. Работа с API и Сетью +- **Клиент:** Axios инстанс (`src/api/client.ts`). +- **Авторизация:** Интерсептор автоматически добавляет `Authorization: Bearer ` из `authStore`. +- **Роутинг к Бэкенду:** Base URL указывает на API-шлюз бэкенда. +- **Обработка ошибок:** Все сетевые запросы должны оборачиваться в `try/catch`. 401 ошибка должна разлогинивать юзера и чистить `authStore`. 409 ошибка (Conflict) обрабатывается локально в компонентах (например, при захвате мест). + +## 4. Структура директорий +- `src/app/` — Роутинг (Pages, Layouts). +- `src/components/` — Переиспользуемые UI компоненты (Кнопки, Карточки, SVG-схемы). +- `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 fcdd79c..1bd4a72 100644 --- a/frontend-client/src/api/client.ts +++ b/frontend-client/src/api/client.ts @@ -2,12 +2,11 @@ import axios from "axios"; import { useAuthStore } from "@/store/authStore"; const apiClient = axios.create({ - baseURL: process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8081/api", + baseURL: "http://192.168.149.101:8000/api", headers: { "Content-Type": "application/json" }, }); apiClient.interceptors.request.use((config) => { - // getState() — безопасен вне React-дерева (server actions, route handlers тоже работают) const token = useAuthStore.getState().token; if (token) { config.headers.Authorization = `Bearer ${token}`; @@ -15,4 +14,14 @@ apiClient.interceptors.request.use((config) => { return config; }); +/** + * POST /api/seats/{seat_id}/lock?user_id={user_id} + * Бросает AxiosError со status 409, если место захвачено конкурентом. + */ +export async function lockSeatApi(seatId: number, userId: number): Promise { + await apiClient.post(`/seats/${seatId}/lock`, null, { + params: { user_id: userId }, + }); +} + export default apiClient; diff --git a/frontend-client/src/components/OctagonSeatMap.tsx b/frontend-client/src/components/OctagonSeatMap.tsx index 4ab2e0d..c3fc916 100644 --- a/frontend-client/src/components/OctagonSeatMap.tsx +++ b/frontend-client/src/components/OctagonSeatMap.tsx @@ -1,7 +1,10 @@ "use client"; -import { useMemo } from "react"; +import { useMemo, useState, useCallback } from "react"; +import type { AxiosError } from "axios"; import { useCartStore } from "@/store/cartStore"; +import { useAuthStore } from "@/store/authStore"; +import { lockSeatApi } from "@/api/client"; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -92,17 +95,17 @@ function octagonPoints(cx: number, cy: number, r: number): string { // ─── Seat color helpers ─────────────────────────────────────────────────────── -function seatFill(seat: SeatData, isSelected: boolean): string { +function seatFill(seat: SeatData, effectiveStatus: SeatStatus, isSelected: boolean): string { if (isSelected) return "#E32636"; - if (seat.status === "SOLD") return "#2D3748"; - if (seat.status === "LOCKED") return "#D97706"; + if (effectiveStatus === "SOLD") return "#2D3748"; + if (effectiveStatus === "LOCKED") return "#D97706"; return seat.type === "VIP" ? "#8B5CF6" : "#0891B2"; } -function seatStroke(seat: SeatData, isSelected: boolean): string { +function seatStroke(seat: SeatData, effectiveStatus: SeatStatus, isSelected: boolean): string { if (isSelected) return "#FF6B7A"; - if (seat.status === "SOLD") return "#4B5563"; - if (seat.status === "LOCKED") return "#F59E0B"; + if (effectiveStatus === "SOLD") return "#4B5563"; + if (effectiveStatus === "LOCKED") return "#F59E0B"; return seat.type === "VIP" ? "#C084FC" : "#38BDF8"; } @@ -112,28 +115,91 @@ export default function OctagonSeatMap() { const { seats: cartSeats, addSeat, removeSeat } = useCartStore(); const selectedIds = useMemo(() => new Set(cartSeats.map((s) => s.seatId)), [cartSeats]); - function handleSeatClick(seat: SeatData) { - if (seat.status !== "AVAILABLE") return; - if (selectedIds.has(seat.id)) { - removeSeat(seat.id); - } else { - addSeat({ - seatId: seat.id, - sector: seat.sector, - row: seat.row, - number: seat.number, - price: seat.price, - }); - } - } + // Local status overrides: applied after a 409 response to immediately mark seat as LOCKED + const [localStatuses, setLocalStatuses] = useState>({}); + + // ID of seat currently awaiting API response — prevents multiple concurrent requests + const [pendingId, setPendingId] = useState(null); + + // Toast notification state (two-part so text stays during fade-out) + const [toastMessage, setToastMessage] = useState(""); + const [toastVisible, setToastVisible] = useState(false); + + const showToast = useCallback((message: string) => { + setToastMessage(message); + setToastVisible(true); + setTimeout(() => setToastVisible(false), 3500); + }, []); + + const handleSeatClick = useCallback( + async (seat: SeatData) => { + const effectiveStatus = localStatuses[seat.id] ?? seat.status; + if (effectiveStatus !== "AVAILABLE") return; + + // Block all clicks while another request is in flight + if (pendingId !== null) return; + + // Deselect: remove from cart without API call (lock expires via Redis TTL) + if (selectedIds.has(seat.id)) { + removeSeat(seat.id); + return; + } + + const userId = useAuthStore.getState().user?.id ?? 1; + setPendingId(seat.id); + + try { + await lockSeatApi(seat.id, userId); + + // ✅ 200 OK — seat is ours, add to cart + addSeat({ + seatId: seat.id, + sector: seat.sector, + row: seat.row, + number: seat.number, + price: seat.price, + }); + } catch (err) { + const axiosErr = err as AxiosError; + + // Mark seat as LOCKED locally so it becomes visually unavailable immediately + setLocalStatuses((prev) => ({ ...prev, [seat.id]: "LOCKED" })); + + if (axiosErr.response?.status === 409) { + // ⚡ Race condition — someone else grabbed the seat + showToast("Извините, это место только что заняли"); + } else { + showToast("Ошибка сети. Попробуйте ещё раз"); + } + } finally { + setPendingId(null); + } + }, + [localStatuses, pendingId, selectedIds, addSeat, removeSeat, showToast] + ); return ( -
+
+ {/* ── Toast notification ── */} +
+
+ + {toastMessage} +
+
+ {/* Arena radial gradient */} @@ -271,8 +337,10 @@ export default function OctagonSeatMap() { {/* ── Seat dots ── */} {SEATS.map((seat) => { + const effectiveStatus = localStatuses[seat.id] ?? seat.status; const isSelected = selectedIds.has(seat.id); - const isClickable = seat.status === "AVAILABLE"; + const isPending = pendingId === seat.id; + const isClickable = effectiveStatus === "AVAILABLE" && !isSelected && pendingId === null; const r = seat.type === "VIP" ? 5 : 4.2; return ( @@ -281,20 +349,25 @@ export default function OctagonSeatMap() { cx={seat.x} cy={seat.y} r={r} - fill={seatFill(seat, isSelected)} - stroke={seatStroke(seat, isSelected)} - strokeWidth={isSelected ? 1.5 : 0.8} - opacity={seat.status === "SOLD" ? 0.45 : 1} - cursor={isClickable ? "pointer" : "not-allowed"} + fill={isPending ? "#94A3B8" : seatFill(seat, effectiveStatus, isSelected)} + stroke={isPending ? "#CBD5E1" : seatStroke(seat, effectiveStatus, isSelected)} + strokeWidth={isSelected ? 1.5 : isPending ? 1.2 : 0.8} + opacity={effectiveStatus === "SOLD" ? 0.45 : isPending ? 0.7 : 1} + cursor={ + isPending ? "wait" + : isClickable || isSelected ? "pointer" + : "not-allowed" + } + className={isPending ? "animate-pulse" : undefined} filter={ isSelected ? "url(#glow-red)" - : seat.status === "AVAILABLE" + : effectiveStatus === "AVAILABLE" && !isPending ? "url(#glow-soft)" : undefined } - onClick={() => handleSeatClick(seat)} - role={isClickable ? "button" : undefined} + onClick={() => { void handleSeatClick(seat); }} + role={isClickable || isSelected ? "button" : undefined} aria-label={ isClickable ? `${seat.sector} Ряд ${seat.row} Место ${seat.number} — ${seat.price.toLocaleString("ru-RU")} ₽` @@ -303,11 +376,28 @@ export default function OctagonSeatMap() { > {seat.sector} • Ряд {seat.row} • Место {seat.number} - {seat.status === "SOLD" ? " (Занято)" : seat.status === "LOCKED" ? " (Удержано)" : ` — ${seat.price.toLocaleString("ru-RU")} ₽`} + {isPending + ? " (обработка…)" + : effectiveStatus === "SOLD" + ? " (Занято)" + : effectiveStatus === "LOCKED" + ? " (Удержано)" + : ` — ${seat.price.toLocaleString("ru-RU")} ₽`} ); })} + + {/* Transparent overlay that blocks all SVG clicks while a request is in flight */} + {pendingId !== null && ( + + )}
);