Files
ticket-system/frontend-client/src/components/SeatMap.tsx
openit 9de9ab9362
All checks were successful
Deploy / deploy (push) Successful in 18s
update seatMap
2026-03-12 18:21:44 +03:00

252 lines
9.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}