diff --git a/frontend-client/src/api/client.ts b/frontend-client/src/api/client.ts index fd00bbd..1fb53f6 100644 --- a/frontend-client/src/api/client.ts +++ b/frontend-client/src/api/client.ts @@ -120,6 +120,34 @@ export async function processPaymentWebhook(ticketId: number): Promise { }); } +// ─── Tournament public seats ────────────────────────────────────────────────── + +/** + * Mirrors backend `SeatResponse` schema. + * `is_available` is false when a LOCKED or PAID ticket exists for this seat. + */ +export interface SeatResponse { + id: number; + sector: string; + row: number; + number: number; + price: number; + is_available: boolean; +} + +/** + * GET /api/tournaments/{tournamentId}/seats + * Public endpoint — no token required. + */ +export async function getTournamentSeatsApi( + tournamentId: string | number +): Promise { + const response = await apiClient.get( + `/tournaments/${tournamentId}/seats` + ); + return response.data; +} + // ─── Admin: Tournaments ─────────────────────────────────────────────────────── export interface TournamentCreate { diff --git a/frontend-client/src/app/events/[id]/seats/page.tsx b/frontend-client/src/app/events/[id]/seats/page.tsx index ac7e4dc..430b816 100644 --- a/frontend-client/src/app/events/[id]/seats/page.tsx +++ b/frontend-client/src/app/events/[id]/seats/page.tsx @@ -1,12 +1,14 @@ "use client"; +import { useEffect, useState } from "react"; 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'; +import { useRouter } from "next/navigation"; +import { getTournamentSeatsApi, type SeatResponse } from "@/api/client"; -// ─── Mocked event data (real API call would go here) ───────────────────────── +// ─── Mocked event banner data (real events API is out of scope here) ────────── const MOCK_EVENT = { title: "Чемпионат по ММА", @@ -19,10 +21,7 @@ const MOCK_EVENT = { function LegendDot({ color, label }: { color: string; label: string }) { return (
- + {label}
); @@ -30,17 +29,31 @@ function LegendDot({ color, label }: { color: string; label: string }) { // ─── Page ───────────────────────────────────────────────────────────────────── -export default function SeatsPage() { +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); + + useEffect(() => { + let cancelled = false; + + getTournamentSeatsApi(params.id) + .then((data) => { + if (!cancelled) setApiSeats(data); + }) + .catch(() => { + if (!cancelled) setApiSeats([]); // Show empty state on error + }); + + return () => { cancelled = true; }; + }, [params.id]); + const total = totalPrice(); const count = cartSeats.length; - - // The first selected seat for the summary line const firstSeat = cartSeats[0]; - const router = useRouter(); - return (
@@ -83,24 +96,26 @@ export default function SeatsPage() {
- {/* ── SVG Arena map ── */} + {/* ── Seat map (real data, loading, or empty state) ── */}
- +
- {/* ── Legend ── */} -
-
- - - - - - + {/* ── Legend (only when seats are loaded) ── */} + {apiSeats !== null && apiSeats.length > 0 && ( +
+
+ + + + + + +
-
+ )} - {/* ── Selected seats chips (visible when cart not empty) ── */} + {/* ── Selected seats chips ── */} {count > 0 && (
@@ -154,10 +169,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 74872d1..545f784 100644 --- a/frontend-client/src/components/OctagonSeatMap.tsx +++ b/frontend-client/src/components/OctagonSeatMap.tsx @@ -4,9 +4,9 @@ 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"; +import { lockSeatApi, type SeatResponse } from "@/api/client"; -// ─── Types ──────────────────────────────────────────────────────────────────── +// ─── Internal rendered seat type (adds SVG coordinates) ────────────────────── type SeatStatus = "AVAILABLE" | "SOLD" | "LOCKED"; type SeatType = "VIP" | "STD"; @@ -27,67 +27,77 @@ interface SeatData { const CX = 200; const CY = 192; - const OCTAGON_R = 58; +const RING_MIN_R = 85; +const RING_MAX_R = 165; -/** Three concentric seat rings around the octagon. */ -const RING_CONFIG = [ - { radius: 90, count: 22, sector: "VIP", type: "VIP" as SeatType, rowNum: 1, price: 12500 }, - { radius: 122, count: 32, sector: "A", type: "STD" as SeatType, rowNum: 2, price: 5000 }, - { radius: 157, count: 42, sector: "B", type: "STD" as SeatType, rowNum: 3, price: 2500 }, -] as const; +// ─── Map API seats → SVG positions ─────────────────────────────────────────── -// ─── Deterministic seat status (stable across re-renders) ───────────────────── - -function deterministicStatus(id: number): SeatStatus { - // Knuth multiplicative hash → spread across 100 - const v = ((id * 2654435761) >>> 0) % 100; - if (v < 56) return "AVAILABLE"; - if (v < 80) return "SOLD"; - return "LOCKED"; -} - -// ─── Seat geometry ──────────────────────────────────────────────────────────── - -function generateSeats(): SeatData[] { - const seats: SeatData[] = []; - let globalId = 0; - - for (const ring of RING_CONFIG) { - for (let i = 0; i < ring.count; i++) { - // Distribute evenly, starting from top (−π/2) - const angle = (i / ring.count) * 2 * Math.PI - Math.PI / 2; - const deg = ((angle * 180) / Math.PI + 360) % 360; - - // Aisle gaps at 0° (top) and 180° (bottom) — each ~10° wide - if (deg < 6 || deg > 354) continue; - if (deg > 174 && deg < 186) continue; - - globalId++; - seats.push({ - id: globalId, - x: Math.round((CX + ring.radius * Math.cos(angle)) * 10) / 10, - y: Math.round((CY + ring.radius * Math.sin(angle)) * 10) / 10, - row: ring.rowNum, - number: i + 1, - sector: ring.sector, - type: ring.type, - status: deterministicStatus(globalId), - price: ring.price, - }); - } +/** + * 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); } - return seats; -} -/** Computed once at module load — deterministic, no side effects. */ -const SEATS: SeatData[] = generateSeats(); + // 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) => { - // +π/8 offset → flat-top octagon (horizontal top/bottom edges) const a = (i / 8) * 2 * Math.PI + Math.PI / 8; return `${cx + r * Math.cos(a)},${cy + r * Math.sin(a)}`; }).join(" "); @@ -109,19 +119,37 @@ function seatStroke(seat: SeatData, effectiveStatus: SeatStatus, isSelected: boo 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() { +export default function OctagonSeatMap({ apiSeats }: OctagonSeatMapProps) { const { seats: cartSeats, addSeat, removeSeat } = useCartStore(); const selectedIds = useMemo(() => new Set(cartSeats.map((s) => s.seatId)), [cartSeats]); - // Local status overrides: applied after a 409 response to immediately mark seat as LOCKED + // 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 state (two-part so text stays during fade-out) + // Toast notification const [toastMessage, setToastMessage] = useState(""); const [toastVisible, setToastVisible] = useState(false); @@ -135,8 +163,6 @@ export default function OctagonSeatMap() { 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) @@ -151,7 +177,6 @@ export default function OctagonSeatMap() { try { const { ticketId } = await lockSeatApi(seat.id, userId); - // ✅ 200 OK — seat is ours, add to cart with the resolved ticketId addSeat({ seatId: seat.id, ticketId, @@ -163,11 +188,10 @@ export default function OctagonSeatMap() { } catch (err) { const axiosErr = err as AxiosError; - // Mark seat as LOCKED locally so it becomes visually unavailable immediately + // Mark seat 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("Ошибка сети. Попробуйте ещё раз"); @@ -179,6 +203,44 @@ export default function OctagonSeatMap() { [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 ── */} @@ -203,13 +265,11 @@ export default function OctagonSeatMap() { style={{ cursor: pendingId !== null ? "wait" : undefined }} > - {/* Arena radial gradient */} - {/* Neon glow for selected seats */} @@ -218,7 +278,6 @@ export default function OctagonSeatMap() { - {/* Subtle glow for available seats */} @@ -227,7 +286,6 @@ export default function OctagonSeatMap() { - {/* Cage glow for octagon border */} @@ -238,106 +296,51 @@ export default function OctagonSeatMap() { {/* ── Outer arena ellipse ── */} - - {/* Neon outer rim */} - + + {/* ── Inner floor area ── */} - + - {/* ── Sector divider lines (subtle, from inner ellipse outward) ── */} + {/* ── Sector divider lines ── */} {[0, 90, 180, 270].map((deg) => { const rad = (deg * Math.PI) / 180; - const x1 = CX + 148 * Math.cos(rad); - const y1 = CY + 130 * Math.sin(rad); - const x2 = CX + 182 * Math.cos(rad); - const y2 = CY + 165 * Math.sin(rad); return ( ); })} - {/* ── Octagon cage border (glow layer) ── */} - - {/* Octagon fill */} - + {/* ── Octagon cage ── */} + + - {/* Octagon inner cage cross-lines */} + {/* 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 - {/* ── Sector labels ── */} - Сектор C - Сектор B - VIP - A2 - {/* ── Seat dots ── */} - {SEATS.map((seat) => { + {seats.map((seat) => { const effectiveStatus = localStatuses[seat.id] ?? seat.status; const isSelected = selectedIds.has(seat.id); const isPending = pendingId === seat.id; @@ -354,11 +357,7 @@ export default function OctagonSeatMap() { 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" - } + cursor={isPending ? "wait" : isClickable || isSelected ? "pointer" : "not-allowed"} className={isPending ? "animate-pulse" : undefined} filter={ isSelected @@ -389,15 +388,9 @@ export default function OctagonSeatMap() { ); })} - {/* Transparent overlay that blocks all SVG clicks while a request is in flight */} + {/* Transparent overlay blocking all SVG clicks while a request is in flight */} {pendingId !== null && ( - + )}