phase 3 19 seats
This commit is contained in:
@@ -120,6 +120,34 @@ export async function processPaymentWebhook(ticketId: number): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── 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<SeatResponse[]> {
|
||||||
|
const response = await apiClient.get<SeatResponse[]>(
|
||||||
|
`/tournaments/${tournamentId}/seats`
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Admin: Tournaments ───────────────────────────────────────────────────────
|
// ─── Admin: Tournaments ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface TournamentCreate {
|
export interface TournamentCreate {
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
import { ArrowLeft, Share2, MapPin, ShoppingCart, X } from "lucide-react";
|
import { ArrowLeft, Share2, MapPin, ShoppingCart, X } from "lucide-react";
|
||||||
import OctagonSeatMap from "@/components/OctagonSeatMap";
|
import OctagonSeatMap from "@/components/OctagonSeatMap";
|
||||||
import { useCartStore } from "@/store/cartStore";
|
import { useCartStore } from "@/store/cartStore";
|
||||||
import Image from "next/image";
|
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 = {
|
const MOCK_EVENT = {
|
||||||
title: "Чемпионат по ММА",
|
title: "Чемпионат по ММА",
|
||||||
@@ -19,10 +21,7 @@ const MOCK_EVENT = {
|
|||||||
function LegendDot({ color, label }: { color: string; label: string }) {
|
function LegendDot({ color, label }: { color: string; label: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span
|
<span className="w-3 h-3 rounded-full flex-shrink-0" style={{ backgroundColor: color }} />
|
||||||
className="w-3 h-3 rounded-full flex-shrink-0"
|
|
||||||
style={{ backgroundColor: color }}
|
|
||||||
/>
|
|
||||||
<span className="text-[11px] text-[#8E8E93]">{label}</span>
|
<span className="text-[11px] text-[#8E8E93]">{label}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -30,17 +29,31 @@ function LegendDot({ color, label }: { color: string; label: string }) {
|
|||||||
|
|
||||||
// ─── Page ─────────────────────────────────────────────────────────────────────
|
// ─── Page ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function SeatsPage() {
|
export default function SeatsPage({ params }: { params: { id: string } }) {
|
||||||
|
const router = useRouter();
|
||||||
const { seats: cartSeats, removeSeat, totalPrice } = useCartStore();
|
const { seats: cartSeats, removeSeat, totalPrice } = useCartStore();
|
||||||
|
|
||||||
|
// Seat data from the API (null = loading, [] = empty/error, array = ready)
|
||||||
|
const [apiSeats, setApiSeats] = useState<SeatResponse[] | null>(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 total = totalPrice();
|
||||||
const count = cartSeats.length;
|
const count = cartSeats.length;
|
||||||
|
|
||||||
// The first selected seat for the summary line
|
|
||||||
const firstSeat = cartSeats[0];
|
const firstSeat = cartSeats[0];
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center min-h-screen bg-[#121212]">
|
<div className="flex justify-center min-h-screen bg-[#121212]">
|
||||||
<div className="relative w-full max-w-[390px] flex flex-col min-h-screen bg-[#121212]">
|
<div className="relative w-full max-w-[390px] flex flex-col min-h-screen bg-[#121212]">
|
||||||
@@ -83,12 +96,13 @@ export default function SeatsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── SVG Arena map ── */}
|
{/* ── Seat map (real data, loading, or empty state) ── */}
|
||||||
<div className="flex-1 flex flex-col px-1">
|
<div className="flex-1 flex flex-col px-1">
|
||||||
<OctagonSeatMap />
|
<OctagonSeatMap apiSeats={apiSeats} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Legend ── */}
|
{/* ── Legend (only when seats are loaded) ── */}
|
||||||
|
{apiSeats !== null && apiSeats.length > 0 && (
|
||||||
<div className="px-5 pb-2">
|
<div className="px-5 pb-2">
|
||||||
<div className="grid grid-cols-2 gap-x-6 gap-y-2">
|
<div className="grid grid-cols-2 gap-x-6 gap-y-2">
|
||||||
<LegendDot color="#0891B2" label="Свободно" />
|
<LegendDot color="#0891B2" label="Свободно" />
|
||||||
@@ -99,8 +113,9 @@ export default function SeatsPage() {
|
|||||||
<LegendDot color="#0891B2" label="Стандарт" />
|
<LegendDot color="#0891B2" label="Стандарт" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ── Selected seats chips (visible when cart not empty) ── */}
|
{/* ── Selected seats chips ── */}
|
||||||
{count > 0 && (
|
{count > 0 && (
|
||||||
<div className="px-5 pb-2">
|
<div className="px-5 pb-2">
|
||||||
<div className="flex gap-2 flex-wrap">
|
<div className="flex gap-2 flex-wrap">
|
||||||
@@ -155,7 +170,7 @@ export default function SeatsPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => router.push('/checkout')}
|
onClick={() => router.push("/checkout")}
|
||||||
className="bg-[#E32636] hover:bg-[#C41E2A] active:scale-95 transition-all text-white text-[15px] font-semibold px-6 py-3.5 rounded-2xl"
|
className="bg-[#E32636] hover:bg-[#C41E2A] active:scale-95 transition-all text-white text-[15px] font-semibold px-6 py-3.5 rounded-2xl"
|
||||||
>
|
>
|
||||||
Перейти к оплате
|
Перейти к оплате
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import { useMemo, useState, useCallback } from "react";
|
|||||||
import type { AxiosError } from "axios";
|
import type { AxiosError } from "axios";
|
||||||
import { useCartStore } from "@/store/cartStore";
|
import { useCartStore } from "@/store/cartStore";
|
||||||
import { useAuthStore } from "@/store/authStore";
|
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 SeatStatus = "AVAILABLE" | "SOLD" | "LOCKED";
|
||||||
type SeatType = "VIP" | "STD";
|
type SeatType = "VIP" | "STD";
|
||||||
@@ -27,67 +27,77 @@ interface SeatData {
|
|||||||
|
|
||||||
const CX = 200;
|
const CX = 200;
|
||||||
const CY = 192;
|
const CY = 192;
|
||||||
|
|
||||||
const OCTAGON_R = 58;
|
const OCTAGON_R = 58;
|
||||||
|
const RING_MIN_R = 85;
|
||||||
|
const RING_MAX_R = 165;
|
||||||
|
|
||||||
/** Three concentric seat rings around the octagon. */
|
// ─── Map API seats → SVG positions ───────────────────────────────────────────
|
||||||
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;
|
|
||||||
|
|
||||||
// ─── Deterministic seat status (stable across re-renders) ─────────────────────
|
/**
|
||||||
|
* 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<string, SeatResponse[]>();
|
||||||
|
for (const seat of apiSeats) {
|
||||||
|
const key = `${seat.sector}::${seat.row}`;
|
||||||
|
if (!ringsMap.has(key)) ringsMap.set(key, []);
|
||||||
|
ringsMap.get(key)!.push(seat);
|
||||||
|
}
|
||||||
|
|
||||||
function deterministicStatus(id: number): SeatStatus {
|
// 2. Sort rings: VIP innermost, then alpha by sector, then by row
|
||||||
// Knuth multiplicative hash → spread across 100
|
const sortedKeys = Array.from(ringsMap.keys()).sort((a, b) => {
|
||||||
const v = ((id * 2654435761) >>> 0) % 100;
|
const [sA, rA] = a.split("::");
|
||||||
if (v < 56) return "AVAILABLE";
|
const [sB, rB] = b.split("::");
|
||||||
if (v < 80) return "SOLD";
|
if (sA === "VIP" && sB !== "VIP") return -1;
|
||||||
return "LOCKED";
|
if (sB === "VIP" && sA !== "VIP") return 1;
|
||||||
}
|
if (sA !== sB) return sA.localeCompare(sB);
|
||||||
|
return Number(rA) - Number(rB);
|
||||||
// ─── 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,
|
|
||||||
});
|
});
|
||||||
}
|
|
||||||
}
|
|
||||||
return seats;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Computed once at module load — deterministic, no side effects. */
|
const numRings = sortedKeys.length;
|
||||||
const SEATS: SeatData[] = generateSeats();
|
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 ───────────────────────────────────────────────────
|
// ─── Octagon polygon points ───────────────────────────────────────────────────
|
||||||
|
|
||||||
function octagonPoints(cx: number, cy: number, r: number): string {
|
function octagonPoints(cx: number, cy: number, r: number): string {
|
||||||
return Array.from({ length: 8 }, (_, i) => {
|
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;
|
const a = (i / 8) * 2 * Math.PI + Math.PI / 8;
|
||||||
return `${cx + r * Math.cos(a)},${cy + r * Math.sin(a)}`;
|
return `${cx + r * Math.cos(a)},${cy + r * Math.sin(a)}`;
|
||||||
}).join(" ");
|
}).join(" ");
|
||||||
@@ -109,19 +119,37 @@ function seatStroke(seat: SeatData, effectiveStatus: SeatStatus, isSelected: boo
|
|||||||
return seat.type === "VIP" ? "#C084FC" : "#38BDF8";
|
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 ────────────────────────────────────────────────────────────────
|
// ─── Component ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function OctagonSeatMap() {
|
export default function OctagonSeatMap({ apiSeats }: OctagonSeatMapProps) {
|
||||||
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]);
|
||||||
|
|
||||||
// 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<Record<number, SeatStatus>>({});
|
const [localStatuses, setLocalStatuses] = useState<Record<number, SeatStatus>>({});
|
||||||
|
|
||||||
// ID of seat currently awaiting API response — prevents multiple concurrent requests
|
// ID of seat currently awaiting API response — prevents multiple concurrent requests
|
||||||
const [pendingId, setPendingId] = useState<number | null>(null);
|
const [pendingId, setPendingId] = useState<number | null>(null);
|
||||||
|
|
||||||
// Toast notification state (two-part so text stays during fade-out)
|
// Toast notification
|
||||||
const [toastMessage, setToastMessage] = useState("");
|
const [toastMessage, setToastMessage] = useState("");
|
||||||
const [toastVisible, setToastVisible] = useState(false);
|
const [toastVisible, setToastVisible] = useState(false);
|
||||||
|
|
||||||
@@ -135,8 +163,6 @@ export default function OctagonSeatMap() {
|
|||||||
async (seat: SeatData) => {
|
async (seat: SeatData) => {
|
||||||
const effectiveStatus = localStatuses[seat.id] ?? seat.status;
|
const effectiveStatus = localStatuses[seat.id] ?? seat.status;
|
||||||
if (effectiveStatus !== "AVAILABLE") return;
|
if (effectiveStatus !== "AVAILABLE") return;
|
||||||
|
|
||||||
// Block all clicks while another request is in flight
|
|
||||||
if (pendingId !== null) return;
|
if (pendingId !== null) return;
|
||||||
|
|
||||||
// Deselect: remove from cart without API call (lock expires via Redis TTL)
|
// Deselect: remove from cart without API call (lock expires via Redis TTL)
|
||||||
@@ -151,7 +177,6 @@ export default function OctagonSeatMap() {
|
|||||||
try {
|
try {
|
||||||
const { ticketId } = await lockSeatApi(seat.id, userId);
|
const { ticketId } = await lockSeatApi(seat.id, userId);
|
||||||
|
|
||||||
// ✅ 200 OK — seat is ours, add to cart with the resolved ticketId
|
|
||||||
addSeat({
|
addSeat({
|
||||||
seatId: seat.id,
|
seatId: seat.id,
|
||||||
ticketId,
|
ticketId,
|
||||||
@@ -163,11 +188,10 @@ export default function OctagonSeatMap() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
const axiosErr = err as AxiosError;
|
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" }));
|
setLocalStatuses((prev) => ({ ...prev, [seat.id]: "LOCKED" }));
|
||||||
|
|
||||||
if (axiosErr.response?.status === 409) {
|
if (axiosErr.response?.status === 409) {
|
||||||
// ⚡ Race condition — someone else grabbed the seat
|
|
||||||
showToast("Извините, это место только что заняли");
|
showToast("Извините, это место только что заняли");
|
||||||
} else {
|
} else {
|
||||||
showToast("Ошибка сети. Попробуйте ещё раз");
|
showToast("Ошибка сети. Попробуйте ещё раз");
|
||||||
@@ -179,6 +203,44 @@ export default function OctagonSeatMap() {
|
|||||||
[localStatuses, pendingId, selectedIds, addSeat, removeSeat, showToast]
|
[localStatuses, pendingId, selectedIds, addSeat, removeSeat, showToast]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ── Loading state ──
|
||||||
|
if (apiSeats === null) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center w-full py-16">
|
||||||
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
<svg
|
||||||
|
className="w-8 h-8 animate-spin text-[#0891B2]"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
>
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4l3-3-3-3v4a8 8 0 00-8 8h4z" />
|
||||||
|
</svg>
|
||||||
|
<p className="text-[13px] text-[#8E8E93]">Загрузка схемы зала…</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Empty state ──
|
||||||
|
if (apiSeats.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center w-full py-16 px-6">
|
||||||
|
<div className="flex flex-col items-center gap-3 text-center">
|
||||||
|
<div className="w-14 h-14 rounded-2xl bg-[#1C1C1E] flex items-center justify-center">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" className="w-7 h-7 text-[#8E8E93]">
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="2" stroke="currentColor" strokeWidth="1.5" />
|
||||||
|
<path d="M9 9h6M9 12h6M9 15h4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p className="text-[15px] font-semibold text-white">Схема зала пока не сформирована</p>
|
||||||
|
<p className="text-[12px] text-[#8E8E93]">Администратор ещё не добавил места для этого события</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Full seat map ──
|
||||||
return (
|
return (
|
||||||
<div className="relative w-full px-2">
|
<div className="relative w-full px-2">
|
||||||
{/* ── Toast notification ── */}
|
{/* ── Toast notification ── */}
|
||||||
@@ -203,13 +265,11 @@ export default function OctagonSeatMap() {
|
|||||||
style={{ cursor: pendingId !== null ? "wait" : undefined }}
|
style={{ cursor: pendingId !== null ? "wait" : undefined }}
|
||||||
>
|
>
|
||||||
<defs>
|
<defs>
|
||||||
{/* Arena radial gradient */}
|
|
||||||
<radialGradient id="arena-bg" cx="50%" cy="50%" r="50%">
|
<radialGradient id="arena-bg" cx="50%" cy="50%" r="50%">
|
||||||
<stop offset="0%" stopColor="#131328" />
|
<stop offset="0%" stopColor="#131328" />
|
||||||
<stop offset="100%" stopColor="#080812" />
|
<stop offset="100%" stopColor="#080812" />
|
||||||
</radialGradient>
|
</radialGradient>
|
||||||
|
|
||||||
{/* Neon glow for selected seats */}
|
|
||||||
<filter id="glow-red" x="-80%" y="-80%" width="260%" height="260%">
|
<filter id="glow-red" x="-80%" y="-80%" width="260%" height="260%">
|
||||||
<feGaussianBlur in="SourceGraphic" stdDeviation="3" result="blur" />
|
<feGaussianBlur in="SourceGraphic" stdDeviation="3" result="blur" />
|
||||||
<feMerge>
|
<feMerge>
|
||||||
@@ -218,7 +278,6 @@ export default function OctagonSeatMap() {
|
|||||||
</feMerge>
|
</feMerge>
|
||||||
</filter>
|
</filter>
|
||||||
|
|
||||||
{/* Subtle glow for available seats */}
|
|
||||||
<filter id="glow-soft" x="-50%" y="-50%" width="200%" height="200%">
|
<filter id="glow-soft" x="-50%" y="-50%" width="200%" height="200%">
|
||||||
<feGaussianBlur in="SourceGraphic" stdDeviation="1.5" result="blur" />
|
<feGaussianBlur in="SourceGraphic" stdDeviation="1.5" result="blur" />
|
||||||
<feMerge>
|
<feMerge>
|
||||||
@@ -227,7 +286,6 @@ export default function OctagonSeatMap() {
|
|||||||
</feMerge>
|
</feMerge>
|
||||||
</filter>
|
</filter>
|
||||||
|
|
||||||
{/* Cage glow for octagon border */}
|
|
||||||
<filter id="glow-cage" x="-20%" y="-20%" width="140%" height="140%">
|
<filter id="glow-cage" x="-20%" y="-20%" width="140%" height="140%">
|
||||||
<feGaussianBlur in="SourceGraphic" stdDeviation="2.5" result="blur" />
|
<feGaussianBlur in="SourceGraphic" stdDeviation="2.5" result="blur" />
|
||||||
<feMerge>
|
<feMerge>
|
||||||
@@ -238,106 +296,51 @@ export default function OctagonSeatMap() {
|
|||||||
</defs>
|
</defs>
|
||||||
|
|
||||||
{/* ── Outer arena ellipse ── */}
|
{/* ── Outer arena ellipse ── */}
|
||||||
<ellipse
|
<ellipse cx={CX} cy={CY} rx={186} ry={168} fill="url(#arena-bg)" stroke="#1E3A5F" strokeWidth="2" />
|
||||||
cx={CX} cy={CY}
|
<ellipse cx={CX} cy={CY} rx={186} ry={168} fill="none" stroke="#2DD4BF" strokeWidth="1" opacity="0.35" />
|
||||||
rx={186} ry={168}
|
|
||||||
fill="url(#arena-bg)"
|
|
||||||
stroke="#1E3A5F"
|
|
||||||
strokeWidth="2"
|
|
||||||
/>
|
|
||||||
{/* Neon outer rim */}
|
|
||||||
<ellipse
|
|
||||||
cx={CX} cy={CY}
|
|
||||||
rx={186} ry={168}
|
|
||||||
fill="none"
|
|
||||||
stroke="#2DD4BF"
|
|
||||||
strokeWidth="1"
|
|
||||||
opacity="0.35"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* ── Inner floor area ── */}
|
{/* ── Inner floor area ── */}
|
||||||
<ellipse
|
<ellipse cx={CX} cy={CY} rx={146} ry={128} fill="#0B0B1E" stroke="#1E293B" strokeWidth="1" />
|
||||||
cx={CX} cy={CY}
|
|
||||||
rx={146} ry={128}
|
|
||||||
fill="#0B0B1E"
|
|
||||||
stroke="#1E293B"
|
|
||||||
strokeWidth="1"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* ── Sector divider lines (subtle, from inner ellipse outward) ── */}
|
{/* ── Sector divider lines ── */}
|
||||||
{[0, 90, 180, 270].map((deg) => {
|
{[0, 90, 180, 270].map((deg) => {
|
||||||
const rad = (deg * Math.PI) / 180;
|
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 (
|
return (
|
||||||
<line
|
<line
|
||||||
key={deg}
|
key={deg}
|
||||||
x1={x1} y1={y1}
|
x1={CX + 148 * Math.cos(rad)} y1={CY + 130 * Math.sin(rad)}
|
||||||
x2={x2} y2={y2}
|
x2={CX + 182 * Math.cos(rad)} y2={CY + 165 * Math.sin(rad)}
|
||||||
stroke="#1E293B"
|
stroke="#1E293B"
|
||||||
strokeWidth="1"
|
strokeWidth="1"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* ── Octagon cage border (glow layer) ── */}
|
{/* ── Octagon cage ── */}
|
||||||
<polygon
|
<polygon points={octagonPoints(CX, CY, OCTAGON_R)} fill="none" stroke="#38BDF8" strokeWidth="4" opacity="0.25" filter="url(#glow-cage)" />
|
||||||
points={octagonPoints(CX, CY, OCTAGON_R)}
|
<polygon points={octagonPoints(CX, CY, OCTAGON_R)} fill="#05050F" stroke="#38BDF8" strokeWidth="1.5" />
|
||||||
fill="none"
|
|
||||||
stroke="#38BDF8"
|
|
||||||
strokeWidth="4"
|
|
||||||
opacity="0.25"
|
|
||||||
filter="url(#glow-cage)"
|
|
||||||
/>
|
|
||||||
{/* Octagon fill */}
|
|
||||||
<polygon
|
|
||||||
points={octagonPoints(CX, CY, OCTAGON_R)}
|
|
||||||
fill="#05050F"
|
|
||||||
stroke="#38BDF8"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Octagon inner cage cross-lines */}
|
{/* Cage cross-lines */}
|
||||||
{[0, 1, 2, 3].map((i) => {
|
{[0, 1, 2, 3].map((i) => {
|
||||||
const a1 = (i / 8) * 2 * Math.PI + Math.PI / 8;
|
const a1 = (i / 8) * 2 * Math.PI + Math.PI / 8;
|
||||||
const a2 = ((i + 4) / 8) * 2 * Math.PI + Math.PI / 8;
|
const a2 = ((i + 4) / 8) * 2 * Math.PI + Math.PI / 8;
|
||||||
return (
|
return (
|
||||||
<line
|
<line
|
||||||
key={i}
|
key={i}
|
||||||
x1={CX + (OCTAGON_R - 6) * Math.cos(a1)}
|
x1={CX + (OCTAGON_R - 6) * Math.cos(a1)} y1={CY + (OCTAGON_R - 6) * Math.sin(a1)}
|
||||||
y1={CY + (OCTAGON_R - 6) * Math.sin(a1)}
|
x2={CX + (OCTAGON_R - 6) * Math.cos(a2)} y2={CY + (OCTAGON_R - 6) * Math.sin(a2)}
|
||||||
x2={CX + (OCTAGON_R - 6) * Math.cos(a2)}
|
stroke="#1E3A5F" strokeWidth="0.8" opacity="0.5"
|
||||||
y2={CY + (OCTAGON_R - 6) * Math.sin(a2)}
|
|
||||||
stroke="#1E3A5F"
|
|
||||||
strokeWidth="0.8"
|
|
||||||
opacity="0.5"
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Stage label */}
|
{/* Stage label */}
|
||||||
<text
|
<text x={CX} y={CY + 5} textAnchor="middle" dominantBaseline="middle" fill="#64748B" fontSize="13" fontWeight="600" letterSpacing="1">
|
||||||
x={CX} y={CY + 5}
|
|
||||||
textAnchor="middle"
|
|
||||||
dominantBaseline="middle"
|
|
||||||
fill="#64748B"
|
|
||||||
fontSize="13"
|
|
||||||
fontWeight="600"
|
|
||||||
letterSpacing="1"
|
|
||||||
>
|
|
||||||
Stage
|
Stage
|
||||||
</text>
|
</text>
|
||||||
|
|
||||||
{/* ── Sector labels ── */}
|
|
||||||
<text x={CX} y={CY - 136} textAnchor="middle" fill="#94A3B8" fontSize="9" fontWeight="500">Сектор C</text>
|
|
||||||
<text x={CX} y={CY + 148} textAnchor="middle" fill="#94A3B8" fontSize="9" fontWeight="500">Сектор B</text>
|
|
||||||
<text x={CX - 162} y={CY + 5} textAnchor="middle" fill="#94A3B8" fontSize="9" fontWeight="500">VIP</text>
|
|
||||||
<text x={CX + 162} y={CY + 5} textAnchor="middle" fill="#94A3B8" fontSize="9" fontWeight="500">A2</text>
|
|
||||||
|
|
||||||
{/* ── Seat dots ── */}
|
{/* ── Seat dots ── */}
|
||||||
{SEATS.map((seat) => {
|
{seats.map((seat) => {
|
||||||
const effectiveStatus = localStatuses[seat.id] ?? seat.status;
|
const effectiveStatus = localStatuses[seat.id] ?? seat.status;
|
||||||
const isSelected = selectedIds.has(seat.id);
|
const isSelected = selectedIds.has(seat.id);
|
||||||
const isPending = pendingId === seat.id;
|
const isPending = pendingId === seat.id;
|
||||||
@@ -354,11 +357,7 @@ export default function OctagonSeatMap() {
|
|||||||
stroke={isPending ? "#CBD5E1" : seatStroke(seat, effectiveStatus, isSelected)}
|
stroke={isPending ? "#CBD5E1" : seatStroke(seat, effectiveStatus, isSelected)}
|
||||||
strokeWidth={isSelected ? 1.5 : isPending ? 1.2 : 0.8}
|
strokeWidth={isSelected ? 1.5 : isPending ? 1.2 : 0.8}
|
||||||
opacity={effectiveStatus === "SOLD" ? 0.45 : isPending ? 0.7 : 1}
|
opacity={effectiveStatus === "SOLD" ? 0.45 : isPending ? 0.7 : 1}
|
||||||
cursor={
|
cursor={isPending ? "wait" : isClickable || isSelected ? "pointer" : "not-allowed"}
|
||||||
isPending ? "wait"
|
|
||||||
: isClickable || isSelected ? "pointer"
|
|
||||||
: "not-allowed"
|
|
||||||
}
|
|
||||||
className={isPending ? "animate-pulse" : undefined}
|
className={isPending ? "animate-pulse" : undefined}
|
||||||
filter={
|
filter={
|
||||||
isSelected
|
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 && (
|
{pendingId !== null && (
|
||||||
<rect
|
<rect x={0} y={0} width={400} height={384} fill="transparent" cursor="wait" style={{ pointerEvents: "all" }} />
|
||||||
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