Update project 6 FRONT SVG SEAT LOCK
This commit is contained in:
@@ -48,7 +48,7 @@ async def lock_seat(seat_id: int, user_id: int, db: AsyncSession = Depends(get_d
|
|||||||
ticket.user_id = user_id
|
ticket.user_id = user_id
|
||||||
|
|
||||||
await db.commit()
|
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:
|
except Exception as e:
|
||||||
# Критически важно: если БД отвалилась, снимаем лок в Redis, иначе место зависнет на 15 минут
|
# Критически важно: если БД отвалилась, снимаем лок в Redis, иначе место зависнет на 15 минут
|
||||||
|
|||||||
24
frontend-client/FRONTEND_ARCHITECTURE.md
Normal file
24
frontend-client/FRONTEND_ARCHITECTURE.md
Normal file
@@ -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 <token>` из `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-хранилища.
|
||||||
@@ -2,12 +2,11 @@ import axios from "axios";
|
|||||||
import { useAuthStore } from "@/store/authStore";
|
import { useAuthStore } from "@/store/authStore";
|
||||||
|
|
||||||
const apiClient = axios.create({
|
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" },
|
headers: { "Content-Type": "application/json" },
|
||||||
});
|
});
|
||||||
|
|
||||||
apiClient.interceptors.request.use((config) => {
|
apiClient.interceptors.request.use((config) => {
|
||||||
// getState() — безопасен вне React-дерева (server actions, route handlers тоже работают)
|
|
||||||
const token = useAuthStore.getState().token;
|
const token = useAuthStore.getState().token;
|
||||||
if (token) {
|
if (token) {
|
||||||
config.headers.Authorization = `Bearer ${token}`;
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
@@ -15,4 +14,14 @@ apiClient.interceptors.request.use((config) => {
|
|||||||
return 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<void> {
|
||||||
|
await apiClient.post(`/seats/${seatId}/lock`, null, {
|
||||||
|
params: { user_id: userId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export default apiClient;
|
export default apiClient;
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo } from "react";
|
import { useMemo, useState, useCallback } from "react";
|
||||||
|
import type { AxiosError } from "axios";
|
||||||
import { useCartStore } from "@/store/cartStore";
|
import { useCartStore } from "@/store/cartStore";
|
||||||
|
import { useAuthStore } from "@/store/authStore";
|
||||||
|
import { lockSeatApi } from "@/api/client";
|
||||||
|
|
||||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -92,17 +95,17 @@ function octagonPoints(cx: number, cy: number, r: number): string {
|
|||||||
|
|
||||||
// ─── Seat color helpers ───────────────────────────────────────────────────────
|
// ─── Seat color helpers ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
function seatFill(seat: SeatData, isSelected: boolean): string {
|
function seatFill(seat: SeatData, effectiveStatus: SeatStatus, isSelected: boolean): string {
|
||||||
if (isSelected) return "#E32636";
|
if (isSelected) return "#E32636";
|
||||||
if (seat.status === "SOLD") return "#2D3748";
|
if (effectiveStatus === "SOLD") return "#2D3748";
|
||||||
if (seat.status === "LOCKED") return "#D97706";
|
if (effectiveStatus === "LOCKED") return "#D97706";
|
||||||
return seat.type === "VIP" ? "#8B5CF6" : "#0891B2";
|
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 (isSelected) return "#FF6B7A";
|
||||||
if (seat.status === "SOLD") return "#4B5563";
|
if (effectiveStatus === "SOLD") return "#4B5563";
|
||||||
if (seat.status === "LOCKED") return "#F59E0B";
|
if (effectiveStatus === "LOCKED") return "#F59E0B";
|
||||||
return seat.type === "VIP" ? "#C084FC" : "#38BDF8";
|
return seat.type === "VIP" ? "#C084FC" : "#38BDF8";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,28 +115,91 @@ export default function OctagonSeatMap() {
|
|||||||
const { seats: cartSeats, addSeat, removeSeat } = useCartStore();
|
const { seats: cartSeats, addSeat, removeSeat } = useCartStore();
|
||||||
const selectedIds = useMemo(() => new Set(cartSeats.map((s) => s.seatId)), [cartSeats]);
|
const selectedIds = useMemo(() => new Set(cartSeats.map((s) => s.seatId)), [cartSeats]);
|
||||||
|
|
||||||
function handleSeatClick(seat: SeatData) {
|
// Local status overrides: applied after a 409 response to immediately mark seat as LOCKED
|
||||||
if (seat.status !== "AVAILABLE") return;
|
const [localStatuses, setLocalStatuses] = useState<Record<number, SeatStatus>>({});
|
||||||
if (selectedIds.has(seat.id)) {
|
|
||||||
removeSeat(seat.id);
|
// ID of seat currently awaiting API response — prevents multiple concurrent requests
|
||||||
} else {
|
const [pendingId, setPendingId] = useState<number | null>(null);
|
||||||
addSeat({
|
|
||||||
seatId: seat.id,
|
// Toast notification state (two-part so text stays during fade-out)
|
||||||
sector: seat.sector,
|
const [toastMessage, setToastMessage] = useState("");
|
||||||
row: seat.row,
|
const [toastVisible, setToastVisible] = useState(false);
|
||||||
number: seat.number,
|
|
||||||
price: seat.price,
|
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 (
|
return (
|
||||||
<div className="w-full px-2">
|
<div className="relative w-full px-2">
|
||||||
|
{/* ── Toast notification ── */}
|
||||||
|
<div
|
||||||
|
className={`absolute top-1 left-0 right-0 z-10 flex justify-center pointer-events-none transition-all duration-300 ${
|
||||||
|
toastVisible ? "opacity-100 translate-y-0" : "opacity-0 -translate-y-1"
|
||||||
|
}`}
|
||||||
|
aria-live="polite"
|
||||||
|
aria-atomic="true"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 bg-[#1C1C1E] border border-[#E32636]/50 text-white text-[12px] font-medium px-4 py-2 rounded-full shadow-xl">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-[#E32636] flex-shrink-0" />
|
||||||
|
{toastMessage}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<svg
|
<svg
|
||||||
viewBox="0 0 400 384"
|
viewBox="0 0 400 384"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
aria-label="Схема зала"
|
aria-label="Схема зала"
|
||||||
role="img"
|
role="img"
|
||||||
|
style={{ cursor: pendingId !== null ? "wait" : undefined }}
|
||||||
>
|
>
|
||||||
<defs>
|
<defs>
|
||||||
{/* Arena radial gradient */}
|
{/* Arena radial gradient */}
|
||||||
@@ -271,8 +337,10 @@ export default function OctagonSeatMap() {
|
|||||||
|
|
||||||
{/* ── Seat dots ── */}
|
{/* ── Seat dots ── */}
|
||||||
{SEATS.map((seat) => {
|
{SEATS.map((seat) => {
|
||||||
|
const effectiveStatus = localStatuses[seat.id] ?? seat.status;
|
||||||
const isSelected = selectedIds.has(seat.id);
|
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;
|
const r = seat.type === "VIP" ? 5 : 4.2;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -281,20 +349,25 @@ export default function OctagonSeatMap() {
|
|||||||
cx={seat.x}
|
cx={seat.x}
|
||||||
cy={seat.y}
|
cy={seat.y}
|
||||||
r={r}
|
r={r}
|
||||||
fill={seatFill(seat, isSelected)}
|
fill={isPending ? "#94A3B8" : seatFill(seat, effectiveStatus, isSelected)}
|
||||||
stroke={seatStroke(seat, isSelected)}
|
stroke={isPending ? "#CBD5E1" : seatStroke(seat, effectiveStatus, isSelected)}
|
||||||
strokeWidth={isSelected ? 1.5 : 0.8}
|
strokeWidth={isSelected ? 1.5 : isPending ? 1.2 : 0.8}
|
||||||
opacity={seat.status === "SOLD" ? 0.45 : 1}
|
opacity={effectiveStatus === "SOLD" ? 0.45 : isPending ? 0.7 : 1}
|
||||||
cursor={isClickable ? "pointer" : "not-allowed"}
|
cursor={
|
||||||
|
isPending ? "wait"
|
||||||
|
: isClickable || isSelected ? "pointer"
|
||||||
|
: "not-allowed"
|
||||||
|
}
|
||||||
|
className={isPending ? "animate-pulse" : undefined}
|
||||||
filter={
|
filter={
|
||||||
isSelected
|
isSelected
|
||||||
? "url(#glow-red)"
|
? "url(#glow-red)"
|
||||||
: seat.status === "AVAILABLE"
|
: effectiveStatus === "AVAILABLE" && !isPending
|
||||||
? "url(#glow-soft)"
|
? "url(#glow-soft)"
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
onClick={() => handleSeatClick(seat)}
|
onClick={() => { void handleSeatClick(seat); }}
|
||||||
role={isClickable ? "button" : undefined}
|
role={isClickable || isSelected ? "button" : undefined}
|
||||||
aria-label={
|
aria-label={
|
||||||
isClickable
|
isClickable
|
||||||
? `${seat.sector} Ряд ${seat.row} Место ${seat.number} — ${seat.price.toLocaleString("ru-RU")} ₽`
|
? `${seat.sector} Ряд ${seat.row} Место ${seat.number} — ${seat.price.toLocaleString("ru-RU")} ₽`
|
||||||
@@ -303,11 +376,28 @@ export default function OctagonSeatMap() {
|
|||||||
>
|
>
|
||||||
<title>
|
<title>
|
||||||
{seat.sector} • Ряд {seat.row} • Место {seat.number}
|
{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")} ₽`}
|
||||||
</title>
|
</title>
|
||||||
</circle>
|
</circle>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{/* Transparent overlay that blocks all SVG clicks while a request is in flight */}
|
||||||
|
{pendingId !== null && (
|
||||||
|
<rect
|
||||||
|
x={0} y={0}
|
||||||
|
width={400} height={384}
|
||||||
|
fill="transparent"
|
||||||
|
cursor="wait"
|
||||||
|
style={{ pointerEvents: "all" }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user