This commit is contained in:
@@ -184,6 +184,39 @@ export async function getTournamentSeatsApi(
|
|||||||
return response.data;
|
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<Seat[]> {
|
||||||
|
const response = await apiClient.get<SeatResponse[]>(`/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 ───────────────────────────────────────────────────────
|
// ─── Admin: Tournaments ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface TournamentCreate {
|
export interface TournamentCreate {
|
||||||
|
|||||||
166
frontend-client/src/app/events/[id]/page.tsx
Normal file
166
frontend-client/src/app/events/[id]/page.tsx
Normal file
@@ -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<Seat[]>([]);
|
||||||
|
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 (
|
||||||
|
<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]">
|
||||||
|
|
||||||
|
{/* ── Header ── */}
|
||||||
|
<div className="flex items-center gap-3 px-5 pt-12 pb-4">
|
||||||
|
<button
|
||||||
|
aria-label="Назад"
|
||||||
|
onClick={() => router.back()}
|
||||||
|
className="w-9 h-9 flex items-center justify-center rounded-full bg-[#1C1C1E] text-white flex-shrink-0"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={18} strokeWidth={2.5} />
|
||||||
|
</button>
|
||||||
|
<h1 className="text-[18px] font-bold text-white truncate">{MOCK_EVENT.title}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Event banner ── */}
|
||||||
|
<div className="mx-4 mb-3 bg-[#1C1C1E] rounded-2xl overflow-hidden">
|
||||||
|
<div className="relative h-36 w-full">
|
||||||
|
<Image
|
||||||
|
src={MOCK_EVENT.imageSrc}
|
||||||
|
alt={MOCK_EVENT.title}
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
sizes="390px"
|
||||||
|
priority
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-black/70 to-transparent" />
|
||||||
|
<div className="absolute bottom-3 left-4 flex items-center gap-1.5">
|
||||||
|
<MapPin size={12} className="text-white/80" />
|
||||||
|
<span className="text-[12px] text-white/80 font-medium">{MOCK_EVENT.venue}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Section title ── */}
|
||||||
|
<p className="px-5 pb-2 text-[13px] font-semibold text-[#8E8E93] uppercase tracking-wider">
|
||||||
|
Схема зала
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* ── Seat map area ── */}
|
||||||
|
<div className="flex-1 overflow-y-auto pb-36">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-3 py-16">
|
||||||
|
<Loader2 size={36} className="text-[#0891B2] animate-spin" strokeWidth={1.5} />
|
||||||
|
<p className="text-[13px] text-[#8E8E93]">Загрузка схемы зала…</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<SeatMap seats={seats} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Selected seats chips ── */}
|
||||||
|
{count > 0 && (
|
||||||
|
<div className="px-5 pb-2">
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
{cartSeats.map((s) => (
|
||||||
|
<div
|
||||||
|
key={s.seatId}
|
||||||
|
className="flex items-center gap-1.5 bg-[#1C1C1E] border border-[#2C2C2E] rounded-full px-3 py-1"
|
||||||
|
>
|
||||||
|
<span className="text-[11px] text-white">
|
||||||
|
{s.sector} · Р{s.row} М{s.number}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => removeSeat(s.seatId)}
|
||||||
|
className="text-[#8E8E93] hover:text-white"
|
||||||
|
aria-label={`Убрать место ${s.number}`}
|
||||||
|
>
|
||||||
|
<X size={11} strokeWidth={2.5} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Bottom bar ── */}
|
||||||
|
<div className="sticky bottom-0 left-0 right-0 bg-[#1C1C1E] border-t border-[#2C2C2E] px-5 pt-3 pb-8">
|
||||||
|
{count > 0 ? (
|
||||||
|
<>
|
||||||
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-[12px] text-[#8E8E93] mb-0.5">Выбранные места:</p>
|
||||||
|
<p className="text-[14px] font-semibold text-white leading-snug">
|
||||||
|
{firstSeat
|
||||||
|
? `${firstSeat.sector}, Ряд ${firstSeat.row}, Место ${firstSeat.number}`
|
||||||
|
: "—"}
|
||||||
|
{count > 1 && (
|
||||||
|
<span className="text-[#8E8E93] font-normal"> +{count - 1}</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-[12px] text-[#8E8E93] mb-0.5">Мест:</p>
|
||||||
|
<p className="text-[14px] font-semibold text-white">{count}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-[12px] text-[#8E8E93]">Итого к оплате:</p>
|
||||||
|
<p className="text-[22px] font-bold text-white leading-tight">
|
||||||
|
{total.toLocaleString("ru-RU")} ₽
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push("/checkout")}
|
||||||
|
disabled={count === 0}
|
||||||
|
className="bg-[#E32636] hover:bg-[#C41E2A] disabled:opacity-50 disabled:cursor-not-allowed active:scale-95 transition-all text-white text-[15px] font-semibold px-6 py-3.5 rounded-2xl"
|
||||||
|
>
|
||||||
|
Перейти к оплате
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center gap-2 py-1">
|
||||||
|
<ShoppingCart size={16} className="text-[#8E8E93]" />
|
||||||
|
<p className="text-[13px] text-[#8E8E93]">Выберите места на схеме</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,14 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { ArrowLeft, Share2, MapPin, ShoppingCart, X } from "lucide-react";
|
import { ArrowLeft, Share2, MapPin, ShoppingCart, Loader2, X } from "lucide-react";
|
||||||
import OctagonSeatMap from "@/components/OctagonSeatMap";
|
import SeatMap from "@/components/SeatMap";
|
||||||
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";
|
import { getEventSeats, type Seat } from "@/api/client";
|
||||||
|
|
||||||
// ─── Mocked event banner data (real events API is out of scope here) ──────────
|
|
||||||
|
|
||||||
const MOCK_EVENT = {
|
const MOCK_EVENT = {
|
||||||
title: "Чемпионат по ММА",
|
title: "Чемпионат по ММА",
|
||||||
@@ -16,42 +14,24 @@ const MOCK_EVENT = {
|
|||||||
imageSrc: "https://images.unsplash.com/photo-1549719386-74dfcbf7dbed?w=400&q=80",
|
imageSrc: "https://images.unsplash.com/photo-1549719386-74dfcbf7dbed?w=400&q=80",
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── Legend item ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function LegendDot({ color, label }: { color: string; label: string }) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<span className="w-3 h-3 rounded-full flex-shrink-0" style={{ backgroundColor: color }} />
|
|
||||||
<span className="text-[11px] text-[#8E8E93]">{label}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Page ─────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export default function SeatsPage({ params }: { params: { id: string } }) {
|
export default function SeatsPage({ params }: { params: { id: string } }) {
|
||||||
const router = useRouter();
|
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 [seats, setSeats] = useState<Seat[]>([]);
|
||||||
const [apiSeats, setApiSeats] = useState<SeatResponse[] | null>(null);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
getTournamentSeatsApi(params.id)
|
getEventSeats(params.id)
|
||||||
.then((data) => {
|
.then((data) => { if (!cancelled) { setSeats(data); setLoading(false); } })
|
||||||
if (!cancelled) setApiSeats(data);
|
.catch(() => { if (!cancelled) { setSeats([]); setLoading(false); } });
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
if (!cancelled) setApiSeats([]); // Show empty state on error
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [params.id]);
|
}, [params.id]);
|
||||||
|
|
||||||
const total = totalPrice();
|
const total = totalPrice();
|
||||||
const count = cartSeats.length;
|
const count = cartSeats.length;
|
||||||
const firstSeat = cartSeats[0];
|
const firstSeat = cartSeats[0];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -96,24 +76,17 @@ export default function SeatsPage({ params }: { params: { id: string } }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Seat map (real data, loading, or empty state) ── */}
|
{/* ── Seat map area ── */}
|
||||||
<div className="flex-1 flex flex-col px-1">
|
<div className="flex-1 overflow-y-auto pb-32">
|
||||||
<OctagonSeatMap apiSeats={apiSeats} />
|
{loading ? (
|
||||||
</div>
|
<div className="flex flex-col items-center justify-center gap-3 py-16">
|
||||||
|
<Loader2 size={36} className="text-[#0891B2] animate-spin" strokeWidth={1.5} />
|
||||||
{/* ── Legend (only when seats are loaded) ── */}
|
<p className="text-[13px] text-[#8E8E93]">Загрузка схемы зала…</p>
|
||||||
{apiSeats !== null && apiSeats.length > 0 && (
|
|
||||||
<div className="px-5 pb-2">
|
|
||||||
<div className="grid grid-cols-2 gap-x-6 gap-y-2">
|
|
||||||
<LegendDot color="#0891B2" label="Свободно" />
|
|
||||||
<LegendDot color="#2D3748" label="Занято" />
|
|
||||||
<LegendDot color="#E32636" label="Выбрано" />
|
|
||||||
<LegendDot color="#D97706" label="Удержано" />
|
|
||||||
<LegendDot color="#8B5CF6" label="VIP" />
|
|
||||||
<LegendDot color="#0891B2" label="Стандарт" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
)}
|
<SeatMap seats={seats} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* ── Selected seats chips ── */}
|
{/* ── Selected seats chips ── */}
|
||||||
{count > 0 && (
|
{count > 0 && (
|
||||||
|
|||||||
@@ -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<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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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<Record<number, SeatStatus>>({});
|
|
||||||
|
|
||||||
// ID of seat currently awaiting API response — prevents multiple concurrent requests
|
|
||||||
const [pendingId, setPendingId] = useState<number | null>(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 (
|
|
||||||
<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 (
|
|
||||||
<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
|
|
||||||
viewBox="0 0 400 384"
|
|
||||||
className="w-full"
|
|
||||||
aria-label="Схема зала"
|
|
||||||
role="img"
|
|
||||||
style={{ cursor: pendingId !== null ? "wait" : undefined }}
|
|
||||||
>
|
|
||||||
<defs>
|
|
||||||
<radialGradient id="arena-bg" cx="50%" cy="50%" r="50%">
|
|
||||||
<stop offset="0%" stopColor="#131328" />
|
|
||||||
<stop offset="100%" stopColor="#080812" />
|
|
||||||
</radialGradient>
|
|
||||||
|
|
||||||
<filter id="glow-red" x="-80%" y="-80%" width="260%" height="260%">
|
|
||||||
<feGaussianBlur in="SourceGraphic" stdDeviation="3" result="blur" />
|
|
||||||
<feMerge>
|
|
||||||
<feMergeNode in="blur" />
|
|
||||||
<feMergeNode in="SourceGraphic" />
|
|
||||||
</feMerge>
|
|
||||||
</filter>
|
|
||||||
|
|
||||||
<filter id="glow-soft" x="-50%" y="-50%" width="200%" height="200%">
|
|
||||||
<feGaussianBlur in="SourceGraphic" stdDeviation="1.5" result="blur" />
|
|
||||||
<feMerge>
|
|
||||||
<feMergeNode in="blur" />
|
|
||||||
<feMergeNode in="SourceGraphic" />
|
|
||||||
</feMerge>
|
|
||||||
</filter>
|
|
||||||
|
|
||||||
<filter id="glow-cage" x="-20%" y="-20%" width="140%" height="140%">
|
|
||||||
<feGaussianBlur in="SourceGraphic" stdDeviation="2.5" result="blur" />
|
|
||||||
<feMerge>
|
|
||||||
<feMergeNode in="blur" />
|
|
||||||
<feMergeNode in="SourceGraphic" />
|
|
||||||
</feMerge>
|
|
||||||
</filter>
|
|
||||||
</defs>
|
|
||||||
|
|
||||||
{/* ── Outer arena ellipse ── */}
|
|
||||||
<ellipse cx={CX} cy={CY} rx={186} ry={168} fill="url(#arena-bg)" stroke="#1E3A5F" strokeWidth="2" />
|
|
||||||
<ellipse cx={CX} cy={CY} rx={186} ry={168} fill="none" stroke="#2DD4BF" strokeWidth="1" opacity="0.35" />
|
|
||||||
|
|
||||||
{/* ── Inner floor area ── */}
|
|
||||||
<ellipse cx={CX} cy={CY} rx={146} ry={128} fill="#0B0B1E" stroke="#1E293B" strokeWidth="1" />
|
|
||||||
|
|
||||||
{/* ── Sector divider lines ── */}
|
|
||||||
{[0, 90, 180, 270].map((deg) => {
|
|
||||||
const rad = (deg * Math.PI) / 180;
|
|
||||||
return (
|
|
||||||
<line
|
|
||||||
key={deg}
|
|
||||||
x1={CX + 148 * Math.cos(rad)} y1={CY + 130 * Math.sin(rad)}
|
|
||||||
x2={CX + 182 * Math.cos(rad)} y2={CY + 165 * Math.sin(rad)}
|
|
||||||
stroke="#1E293B"
|
|
||||||
strokeWidth="1"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* ── Octagon cage ── */}
|
|
||||||
<polygon points={octagonPoints(CX, CY, OCTAGON_R)} fill="none" stroke="#38BDF8" strokeWidth="4" opacity="0.25" filter="url(#glow-cage)" />
|
|
||||||
<polygon points={octagonPoints(CX, CY, OCTAGON_R)} fill="#05050F" stroke="#38BDF8" strokeWidth="1.5" />
|
|
||||||
|
|
||||||
{/* 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 (
|
|
||||||
<line
|
|
||||||
key={i}
|
|
||||||
x1={CX + (OCTAGON_R - 6) * Math.cos(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)}
|
|
||||||
stroke="#1E3A5F" strokeWidth="0.8" opacity="0.5"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* Stage label */}
|
|
||||||
<text x={CX} y={CY + 5} textAnchor="middle" dominantBaseline="middle" fill="#64748B" fontSize="13" fontWeight="600" letterSpacing="1">
|
|
||||||
Stage
|
|
||||||
</text>
|
|
||||||
|
|
||||||
{/* ── 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 (
|
|
||||||
<circle
|
|
||||||
key={seat.id}
|
|
||||||
cx={seat.x}
|
|
||||||
cy={seat.y}
|
|
||||||
r={r}
|
|
||||||
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)"
|
|
||||||
: effectiveStatus === "AVAILABLE" && !isPending
|
|
||||||
? "url(#glow-soft)"
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
onClick={() => { void handleSeatClick(seat); }}
|
|
||||||
role={isClickable || isSelected ? "button" : undefined}
|
|
||||||
aria-label={
|
|
||||||
isClickable
|
|
||||||
? `${seat.sector} Ряд ${seat.row} Место ${seat.number} — ${seat.price.toLocaleString("ru-RU")} ₽`
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<title>
|
|
||||||
{seat.sector} • Ряд {seat.row} • Место {seat.number}
|
|
||||||
{isPending
|
|
||||||
? " (обработка…)"
|
|
||||||
: effectiveStatus === "SOLD"
|
|
||||||
? " (Занято)"
|
|
||||||
: effectiveStatus === "LOCKED"
|
|
||||||
? " (Удержано)"
|
|
||||||
: ` — ${seat.price.toLocaleString("ru-RU")} ₽`}
|
|
||||||
</title>
|
|
||||||
</circle>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* Transparent overlay blocking 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>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
251
frontend-client/src/components/SeatMap.tsx
Normal file
251
frontend-client/src/components/SeatMap.tsx
Normal file
@@ -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<string, Map<number, Seat[]>> {
|
||||||
|
const result = new Map<string, Map<number, Seat[]>>();
|
||||||
|
|
||||||
|
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<Set<number>>(new Set());
|
||||||
|
|
||||||
|
// Prevents concurrent lock requests — only one seat at a time
|
||||||
|
const [pendingId, setPendingId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
// Toast
|
||||||
|
const [toastMsg, setToastMsg] = useState<string | null>(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 (
|
||||||
|
<div className="flex flex-col items-center justify-center py-16 px-6 text-center gap-3">
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative space-y-3 px-4 pb-2">
|
||||||
|
|
||||||
|
{/* ── Toast notification ── */}
|
||||||
|
<div
|
||||||
|
aria-live="polite"
|
||||||
|
aria-atomic="true"
|
||||||
|
className={`fixed top-14 left-1/2 -translate-x-1/2 z-50 transition-all duration-300 pointer-events-none ${
|
||||||
|
toastMsg ? "opacity-100 translate-y-0" : "opacity-0 -translate-y-2"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<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 whitespace-nowrap">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-[#E32636] flex-shrink-0" />
|
||||||
|
{toastMsg}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── 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 (
|
||||||
|
<div key={sector} className="bg-[#1C1C1E] rounded-2xl p-4">
|
||||||
|
|
||||||
|
{/* Sector header */}
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-[13px] font-bold text-white uppercase tracking-wider">
|
||||||
|
{sector}
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] font-semibold text-[#E32636]">
|
||||||
|
{pricePerSeat.toLocaleString("ru-RU")} ₽
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-[11px] text-[#8E8E93]">
|
||||||
|
{availableCount} свободно
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rows */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{Array.from(rowMap.entries())
|
||||||
|
.sort(([a], [b]) => a - b)
|
||||||
|
.map(([row, rowSeats]) => (
|
||||||
|
<div key={row} className="flex items-center gap-2">
|
||||||
|
|
||||||
|
{/* Row number label */}
|
||||||
|
<span className="text-[10px] text-[#4B5563] w-5 flex-shrink-0 text-right font-mono select-none">
|
||||||
|
{row}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Seat buttons */}
|
||||||
|
<div className="flex gap-1 flex-wrap">
|
||||||
|
{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 (
|
||||||
|
<button
|
||||||
|
key={seat.id}
|
||||||
|
onClick={() => { void handleSeatClick(seat); }}
|
||||||
|
disabled={isUnavailable || (pendingId !== null && !isPending)}
|
||||||
|
aria-label={
|
||||||
|
isUnavailable
|
||||||
|
? `Место ${seat.number} — недоступно`
|
||||||
|
: `${seat.sector} Р${seat.row} М${seat.number} — ${seat.price.toLocaleString("ru-RU")} ₽`
|
||||||
|
}
|
||||||
|
aria-pressed={isSelected}
|
||||||
|
title={`${seat.sector} · Ряд ${seat.row} · Место ${seat.number}${
|
||||||
|
isUnavailable ? " (занято)" : ` — ${seat.price.toLocaleString("ru-RU")} ₽`
|
||||||
|
}`}
|
||||||
|
className={cls}
|
||||||
|
>
|
||||||
|
{seat.number}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* ── Legend ── */}
|
||||||
|
<div className="flex gap-5 flex-wrap px-1 pt-1">
|
||||||
|
{[
|
||||||
|
{ cls: "bg-[#0891B2]", label: "Свободно" },
|
||||||
|
{ cls: "bg-[#E32636]", label: "Выбрано" },
|
||||||
|
{ cls: "bg-[#2C2C2E] opacity-50", label: "Занято" },
|
||||||
|
].map(({ cls, label }) => (
|
||||||
|
<div key={label} className="flex items-center gap-1.5">
|
||||||
|
<span className={`w-3 h-3 rounded ${cls}`} />
|
||||||
|
<span className="text-[11px] text-[#8E8E93]">{label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Transparent overlay blocking all clicks while a request is in flight */}
|
||||||
|
{pendingId !== null && (
|
||||||
|
<div className="absolute inset-0 z-10" style={{ cursor: "wait", pointerEvents: "all" }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user