252 lines
9.9 KiB
TypeScript
252 lines
9.9 KiB
TypeScript
"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>
|
||
);
|
||
}
|