diff --git a/frontend-client/src/api/client.ts b/frontend-client/src/api/client.ts index c0dab99..949034a 100644 --- a/frontend-client/src/api/client.ts +++ b/frontend-client/src/api/client.ts @@ -184,6 +184,39 @@ export async function getTournamentSeatsApi( return response.data; } +/** + * Domain model for a single seat used by SeatMap and the cart. + * Derived from SeatResponse but uses a string `status` instead of `is_available`. + */ +export interface Seat { + id: number; + tournament_id: number; + sector: string; + row: number; + number: number; + price: number; + /** "AVAILABLE" | "LOCKED" | "PAID" */ + status: string; +} + +/** + * GET /api/tournaments/{eventId}/seats + * Convenience wrapper: maps backend `is_available` → explicit `status` string. + * LOCKED and PAID both yield status "LOCKED" (indistinguishable from the public API). + */ +export async function getEventSeats(eventId: string | number): Promise { + const response = await apiClient.get(`/tournaments/${eventId}/seats`); + return response.data.map((s): Seat => ({ + id: s.id, + tournament_id: Number(eventId), + sector: s.sector, + row: s.row, + number: s.number, + price: s.price, + status: s.is_available ? "AVAILABLE" : "LOCKED", + })); +} + // ─── Admin: Tournaments ─────────────────────────────────────────────────────── export interface TournamentCreate { diff --git a/frontend-client/src/app/events/[id]/page.tsx b/frontend-client/src/app/events/[id]/page.tsx new file mode 100644 index 0000000..0b73364 --- /dev/null +++ b/frontend-client/src/app/events/[id]/page.tsx @@ -0,0 +1,166 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { ArrowLeft, MapPin, ShoppingCart, Loader2, X } from "lucide-react"; +import Image from "next/image"; +import SeatMap from "@/components/SeatMap"; +import { useCartStore } from "@/store/cartStore"; +import { getEventSeats, type Seat } from "@/api/client"; + +// Mock event header — replace with real API call when the events endpoint is ready +const MOCK_EVENT = { + title: "Чемпионат по ММА", + venue: "ВТБ Арена, Москва", + imageSrc: "https://images.unsplash.com/photo-1549719386-74dfcbf7dbed?w=400&q=80", +}; + +export default function EventPage({ params }: { params: { id: string } }) { + const router = useRouter(); + const { seats: cartSeats, removeSeat, totalPrice } = useCartStore(); + + // ── Seats state ── + const [seats, setSeats] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let cancelled = false; + setLoading(true); + getEventSeats(params.id) + .then((data) => { if (!cancelled) { setSeats(data); setLoading(false); } }) + .catch(() => { if (!cancelled) { setSeats([]); setLoading(false); } }); + return () => { cancelled = true; }; + }, [params.id]); + + // ── Cart summary ── + const count = cartSeats.length; + const total = totalPrice(); + const firstSeat = cartSeats[0]; + + return ( +
+
+ + {/* ── Header ── */} +
+ +

{MOCK_EVENT.title}

+
+ + {/* ── Event banner ── */} +
+
+ {MOCK_EVENT.title} +
+
+ + {MOCK_EVENT.venue} +
+
+
+ + {/* ── Section title ── */} +

+ Схема зала +

+ + {/* ── Seat map area ── */} +
+ {loading ? ( +
+ +

Загрузка схемы зала…

+
+ ) : ( + + )} +
+ + {/* ── Selected seats chips ── */} + {count > 0 && ( +
+
+ {cartSeats.map((s) => ( +
+ + {s.sector} · Р{s.row} М{s.number} + + +
+ ))} +
+
+ )} + + {/* ── Bottom bar ── */} +
+ {count > 0 ? ( + <> +
+
+

Выбранные места:

+

+ {firstSeat + ? `${firstSeat.sector}, Ряд ${firstSeat.row}, Место ${firstSeat.number}` + : "—"} + {count > 1 && ( + +{count - 1} + )} +

+
+
+

Мест:

+

{count}

+
+
+ +
+
+

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

+

+ {total.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 430b816..b3219e6 100644 --- a/frontend-client/src/app/events/[id]/seats/page.tsx +++ b/frontend-client/src/app/events/[id]/seats/page.tsx @@ -1,14 +1,12 @@ "use client"; import { useEffect, useState } from "react"; -import { ArrowLeft, Share2, MapPin, ShoppingCart, X } from "lucide-react"; -import OctagonSeatMap from "@/components/OctagonSeatMap"; +import { ArrowLeft, Share2, MapPin, ShoppingCart, Loader2, X } from "lucide-react"; +import SeatMap from "@/components/SeatMap"; import { useCartStore } from "@/store/cartStore"; import Image from "next/image"; import { useRouter } from "next/navigation"; -import { getTournamentSeatsApi, type SeatResponse } from "@/api/client"; - -// ─── Mocked event banner data (real events API is out of scope here) ────────── +import { getEventSeats, type Seat } from "@/api/client"; const MOCK_EVENT = { title: "Чемпионат по ММА", @@ -16,42 +14,24 @@ const MOCK_EVENT = { imageSrc: "https://images.unsplash.com/photo-1549719386-74dfcbf7dbed?w=400&q=80", }; -// ─── Legend item ────────────────────────────────────────────────────────────── - -function LegendDot({ color, label }: { color: string; label: string }) { - return ( -
- - {label} -
- ); -} - -// ─── Page ───────────────────────────────────────────────────────────────────── - export default function SeatsPage({ params }: { params: { id: string } }) { const router = useRouter(); const { seats: cartSeats, removeSeat, totalPrice } = useCartStore(); - // Seat data from the API (null = loading, [] = empty/error, array = ready) - const [apiSeats, setApiSeats] = useState(null); + const [seats, setSeats] = useState([]); + const [loading, setLoading] = useState(true); useEffect(() => { let cancelled = false; - - getTournamentSeatsApi(params.id) - .then((data) => { - if (!cancelled) setApiSeats(data); - }) - .catch(() => { - if (!cancelled) setApiSeats([]); // Show empty state on error - }); - + setLoading(true); + getEventSeats(params.id) + .then((data) => { if (!cancelled) { setSeats(data); setLoading(false); } }) + .catch(() => { if (!cancelled) { setSeats([]); setLoading(false); } }); return () => { cancelled = true; }; }, [params.id]); - const total = totalPrice(); - const count = cartSeats.length; + const total = totalPrice(); + const count = cartSeats.length; const firstSeat = cartSeats[0]; return ( @@ -96,24 +76,17 @@ export default function SeatsPage({ params }: { params: { id: string } }) {
- {/* ── Seat map (real data, loading, or empty state) ── */} -
- -
- - {/* ── Legend (only when seats are loaded) ── */} - {apiSeats !== null && apiSeats.length > 0 && ( -
-
- - - - - - + {/* ── Seat map area ── */} +
+ {loading ? ( +
+ +

Загрузка схемы зала…

-
- )} + ) : ( + + )} +
{/* ── Selected seats chips ── */} {count > 0 && ( diff --git a/frontend-client/src/components/OctagonSeatMap.tsx b/frontend-client/src/components/OctagonSeatMap.tsx deleted file mode 100644 index 545f784..0000000 --- a/frontend-client/src/components/OctagonSeatMap.tsx +++ /dev/null @@ -1,398 +0,0 @@ -"use client"; - -import { useMemo, useState, useCallback } from "react"; -import type { AxiosError } from "axios"; -import { useCartStore } from "@/store/cartStore"; -import { useAuthStore } from "@/store/authStore"; -import { lockSeatApi, type SeatResponse } from "@/api/client"; - -// ─── Internal rendered seat type (adds SVG coordinates) ────────────────────── - -type SeatStatus = "AVAILABLE" | "SOLD" | "LOCKED"; -type SeatType = "VIP" | "STD"; - -interface SeatData { - id: number; - x: number; - y: number; - row: number; - number: number; - sector: string; - type: SeatType; - status: SeatStatus; - price: number; -} - -// ─── Layout constants ───────────────────────────────────────────────────────── - -const CX = 200; -const CY = 192; -const OCTAGON_R = 58; -const RING_MIN_R = 85; -const RING_MAX_R = 165; - -// ─── Map API seats → SVG positions ─────────────────────────────────────────── - -/** - * Groups seats by (sector, row) → each group becomes one concentric ring. - * Rings are sorted VIP-first, then alphabetically by sector, then by row number. - * Radii are distributed evenly between RING_MIN_R and RING_MAX_R. - * Seats within a ring are placed at equal angles starting from the top (−π/2). - */ -function computeSeatPositions(apiSeats: SeatResponse[]): SeatData[] { - // 1. Group by "sector::row" - const ringsMap = new Map(); - for (const seat of apiSeats) { - const key = `${seat.sector}::${seat.row}`; - if (!ringsMap.has(key)) ringsMap.set(key, []); - ringsMap.get(key)!.push(seat); - } - - // 2. Sort rings: VIP innermost, then alpha by sector, then by row - const sortedKeys = Array.from(ringsMap.keys()).sort((a, b) => { - const [sA, rA] = a.split("::"); - const [sB, rB] = b.split("::"); - if (sA === "VIP" && sB !== "VIP") return -1; - if (sB === "VIP" && sA !== "VIP") return 1; - if (sA !== sB) return sA.localeCompare(sB); - return Number(rA) - Number(rB); - }); - - const numRings = sortedKeys.length; - const result: SeatData[] = []; - - sortedKeys.forEach((key, ringIdx) => { - const ringSeats = [...ringsMap.get(key)!].sort((a, b) => a.number - b.number); - const [sector] = key.split("::"); - const row = Number(key.split("::")[1]); - - // 3. Assign radius (lerp between min and max) - const radius = - numRings <= 1 - ? (RING_MIN_R + RING_MAX_R) / 2 - : RING_MIN_R + (ringIdx / (numRings - 1)) * (RING_MAX_R - RING_MIN_R); - - const total = ringSeats.length; - - // 4. Place seats at equal angles, starting from top - ringSeats.forEach((seat, i) => { - const angle = (i / total) * 2 * Math.PI - Math.PI / 2; - result.push({ - id: seat.id, - x: Math.round((CX + radius * Math.cos(angle)) * 10) / 10, - y: Math.round((CY + radius * Math.sin(angle)) * 10) / 10, - row, - number: seat.number, - sector, - type: sector === "VIP" ? "VIP" : "STD", - status: seat.is_available ? "AVAILABLE" : "SOLD", - price: seat.price, - }); - }); - }); - - return result; -} - -// ─── Octagon polygon points ─────────────────────────────────────────────────── - -function octagonPoints(cx: number, cy: number, r: number): string { - return Array.from({ length: 8 }, (_, i) => { - const a = (i / 8) * 2 * Math.PI + Math.PI / 8; - return `${cx + r * Math.cos(a)},${cy + r * Math.sin(a)}`; - }).join(" "); -} - -// ─── Seat color helpers ─────────────────────────────────────────────────────── - -function seatFill(seat: SeatData, effectiveStatus: SeatStatus, isSelected: boolean): string { - if (isSelected) return "#E32636"; - if (effectiveStatus === "SOLD") return "#2D3748"; - if (effectiveStatus === "LOCKED") return "#D97706"; - return seat.type === "VIP" ? "#8B5CF6" : "#0891B2"; -} - -function seatStroke(seat: SeatData, effectiveStatus: SeatStatus, isSelected: boolean): string { - if (isSelected) return "#FF6B7A"; - if (effectiveStatus === "SOLD") return "#4B5563"; - if (effectiveStatus === "LOCKED") return "#F59E0B"; - return seat.type === "VIP" ? "#C084FC" : "#38BDF8"; -} - -// ─── Props ──────────────────────────────────────────────────────────────────── - -interface OctagonSeatMapProps { - /** - * Seats from the API. - * `null` → still loading (shows spinner) - * `[]` → no seats yet (shows empty state) - * array → renders the seat map - */ - apiSeats: SeatResponse[] | null; -} - -// ─── Component ──────────────────────────────────────────────────────────────── - -export default function OctagonSeatMap({ apiSeats }: OctagonSeatMapProps) { - const { seats: cartSeats, addSeat, removeSeat } = useCartStore(); - const selectedIds = useMemo(() => new Set(cartSeats.map((s) => s.seatId)), [cartSeats]); - - // Compute SVG positions from API data (memoised, recomputes only when apiSeats changes) - const seats: SeatData[] = useMemo( - () => (apiSeats ? computeSeatPositions(apiSeats) : []), - [apiSeats] - ); - - // Local status overrides: applied after a 409 response to immediately mark a seat LOCKED - const [localStatuses, setLocalStatuses] = useState>({}); - - // ID of seat currently awaiting API response — prevents multiple concurrent requests - const [pendingId, setPendingId] = useState(null); - - // Toast notification - 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; - 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 { - const { ticketId } = await lockSeatApi(seat.id, userId); - - addSeat({ - seatId: seat.id, - ticketId, - sector: seat.sector, - row: seat.row, - number: seat.number, - price: seat.price, - }); - } catch (err) { - const axiosErr = err as AxiosError; - - // Mark seat LOCKED locally so it becomes visually unavailable immediately - setLocalStatuses((prev) => ({ ...prev, [seat.id]: "LOCKED" })); - - if (axiosErr.response?.status === 409) { - showToast("Извините, это место только что заняли"); - } else { - showToast("Ошибка сети. Попробуйте ещё раз"); - } - } finally { - setPendingId(null); - } - }, - [localStatuses, pendingId, selectedIds, addSeat, removeSeat, showToast] - ); - - // ── Loading state ── - if (apiSeats === null) { - return ( -
-
- - - - -

Загрузка схемы зала…

-
-
- ); - } - - // ── Empty state ── - if (apiSeats.length === 0) { - return ( -
-
-
- - - - -
-

Схема зала пока не сформирована

-

Администратор ещё не добавил места для этого события

-
-
- ); - } - - // ── Full seat map ── - return ( -
- {/* ── Toast notification ── */} -
-
- - {toastMessage} -
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {/* ── Outer arena ellipse ── */} - - - - {/* ── Inner floor area ── */} - - - {/* ── Sector divider lines ── */} - {[0, 90, 180, 270].map((deg) => { - const rad = (deg * Math.PI) / 180; - return ( - - ); - })} - - {/* ── Octagon cage ── */} - - - - {/* Cage cross-lines */} - {[0, 1, 2, 3].map((i) => { - const a1 = (i / 8) * 2 * Math.PI + Math.PI / 8; - const a2 = ((i + 4) / 8) * 2 * Math.PI + Math.PI / 8; - return ( - - ); - })} - - {/* Stage label */} - - Stage - - - {/* ── Seat dots ── */} - {seats.map((seat) => { - const effectiveStatus = localStatuses[seat.id] ?? seat.status; - const isSelected = selectedIds.has(seat.id); - const isPending = pendingId === seat.id; - const isClickable = effectiveStatus === "AVAILABLE" && !isSelected && pendingId === null; - const r = seat.type === "VIP" ? 5 : 4.2; - - return ( - { void handleSeatClick(seat); }} - role={isClickable || isSelected ? "button" : undefined} - aria-label={ - isClickable - ? `${seat.sector} Ряд ${seat.row} Место ${seat.number} — ${seat.price.toLocaleString("ru-RU")} ₽` - : undefined - } - > - - {seat.sector} • Ряд {seat.row} • Место {seat.number} - {isPending - ? " (обработка…)" - : effectiveStatus === "SOLD" - ? " (Занято)" - : effectiveStatus === "LOCKED" - ? " (Удержано)" - : ` — ${seat.price.toLocaleString("ru-RU")} ₽`} - - - ); - })} - - {/* Transparent overlay blocking all SVG clicks while a request is in flight */} - {pendingId !== null && ( - - )} - -
- ); -} diff --git a/frontend-client/src/components/SeatMap.tsx b/frontend-client/src/components/SeatMap.tsx new file mode 100644 index 0000000..b051a40 --- /dev/null +++ b/frontend-client/src/components/SeatMap.tsx @@ -0,0 +1,251 @@ +"use client"; + +import { useCallback, useMemo, useState } from "react"; +import type { AxiosError } from "axios"; +import { useCartStore } from "@/store/cartStore"; +import { useAuthStore } from "@/store/authStore"; +import { lockSeatApi, type Seat } from "@/api/client"; + +// ─── Grouping ───────────────────────────────────────────────────────────────── + +/** + * Groups seats into sector → row → sorted seats[]. + * Sectors are sorted VIP-first, then alphabetically. + */ +function groupSeats(seats: Seat[]): Map> { + const result = new Map>(); + + for (const seat of seats) { + if (!result.has(seat.sector)) result.set(seat.sector, new Map()); + const rowMap = result.get(seat.sector)!; + if (!rowMap.has(seat.row)) rowMap.set(seat.row, []); + rowMap.get(seat.row)!.push(seat); + } + + return new Map( + Array.from(result.entries()).sort(([a], [b]) => { + if (a === "VIP") return -1; + if (b === "VIP") return 1; + return a.localeCompare(b, "ru"); + }) + ); +} + +// ─── Component ──────────────────────────────────────────────────────────────── + +interface SeatMapProps { + seats: Seat[]; +} + +export default function SeatMap({ seats }: SeatMapProps) { + const { seats: cartSeats, addSeat, removeSeat } = useCartStore(); + + const selectedIds = useMemo( + () => new Set(cartSeats.map((s) => s.seatId)), + [cartSeats] + ); + + // Seats marked unavailable locally after a 409 race-condition response + const [localLocked, setLocalLocked] = useState>(new Set()); + + // Prevents concurrent lock requests — only one seat at a time + const [pendingId, setPendingId] = useState(null); + + // Toast + const [toastMsg, setToastMsg] = useState(null); + + function showToast(msg: string) { + setToastMsg(msg); + setTimeout(() => setToastMsg(null), 3200); + } + + const handleSeatClick = useCallback( + async (seat: Seat) => { + const isUnavailable = seat.status !== "AVAILABLE" || localLocked.has(seat.id); + if (isUnavailable || pendingId !== null) return; + + // Deselect: remove from cart without an API call + if (selectedIds.has(seat.id)) { + removeSeat(seat.id); + return; + } + + const userId = useAuthStore.getState().user?.id ?? 1; + setPendingId(seat.id); + + try { + const { ticketId } = await lockSeatApi(seat.id, userId); + addSeat({ + seatId: seat.id, + ticketId, + sector: seat.sector, + row: seat.row, + number: seat.number, + price: seat.price, + }); + } catch (err) { + const axiosErr = err as AxiosError; + setLocalLocked((prev) => { + const next = new Set(prev); + next.add(seat.id); + return next; + }); + showToast( + axiosErr.response?.status === 409 + ? "Место только что заняли, выберите другое" + : "Ошибка сети. Попробуйте ещё раз" + ); + } finally { + setPendingId(null); + } + }, + [localLocked, pendingId, selectedIds, addSeat, removeSeat] + ); + + const grouped = useMemo(() => groupSeats(seats), [seats]); + + // ── Empty state ── + if (seats.length === 0) { + return ( +
+
+ + + + +
+

Схема зала пока не сформирована

+

Администратор ещё не добавил места для этого события

+
+ ); + } + + return ( +
+ + {/* ── Toast notification ── */} +
+
+ + {toastMsg} +
+
+ + {/* ── Sector blocks ── */} + {Array.from(grouped.entries()).map(([sector, rowMap]) => { + const sectorSeats = seats.filter((s) => s.sector === sector); + const availableCount = sectorSeats.filter( + (s) => s.status === "AVAILABLE" && !localLocked.has(s.id) + ).length; + const pricePerSeat = sectorSeats[0]?.price ?? 0; + + return ( +
+ + {/* Sector header */} +
+
+ + {sector} + + + {pricePerSeat.toLocaleString("ru-RU")} ₽ + +
+ + {availableCount} свободно + +
+ + {/* Rows */} +
+ {Array.from(rowMap.entries()) + .sort(([a], [b]) => a - b) + .map(([row, rowSeats]) => ( +
+ + {/* Row number label */} + + {row} + + + {/* Seat buttons */} +
+ {rowSeats + .sort((a, b) => a.number - b.number) + .map((seat) => { + const isSelected = selectedIds.has(seat.id); + const isPending = pendingId === seat.id; + const isUnavailable = + seat.status !== "AVAILABLE" || localLocked.has(seat.id); + + // Build class list + let cls = + "w-7 h-7 rounded-lg text-[10px] font-bold transition-all select-none "; + + if (isPending) { + cls += "bg-[#4B5563] text-[#94A3B8] animate-pulse cursor-wait"; + } else if (isSelected) { + cls += "bg-[#E32636] text-white scale-110 shadow-md shadow-red-900/40"; + } else if (isUnavailable) { + cls += "bg-[#2C2C2E] text-[#4B5563] opacity-50 cursor-not-allowed"; + } else { + cls += + "bg-[#0891B2] hover:bg-[#0E7490] hover:scale-105 active:scale-95 text-white cursor-pointer"; + } + + return ( + + ); + })} +
+
+ ))} +
+
+ ); + })} + + {/* ── Legend ── */} +
+ {[ + { cls: "bg-[#0891B2]", label: "Свободно" }, + { cls: "bg-[#E32636]", label: "Выбрано" }, + { cls: "bg-[#2C2C2E] opacity-50", label: "Занято" }, + ].map(({ cls, label }) => ( +
+ + {label} +
+ ))} +
+ + {/* Transparent overlay blocking all clicks while a request is in flight */} + {pendingId !== null && ( +
+ )} +
+ ); +}