"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 && (
)}
); }