Update project 6 FRONT SVG SEAT LOCK

This commit is contained in:
2026-03-06 12:39:26 +00:00
parent 02b70f4369
commit a418c53664
4 changed files with 158 additions and 35 deletions

View File

@@ -1,7 +1,10 @@
"use client";
import { useMemo } from "react";
import { useMemo, useState, useCallback } from "react";
import type { AxiosError } from "axios";
import { useCartStore } from "@/store/cartStore";
import { useAuthStore } from "@/store/authStore";
import { lockSeatApi } from "@/api/client";
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -92,17 +95,17 @@ function octagonPoints(cx: number, cy: number, r: number): string {
// ─── Seat color helpers ───────────────────────────────────────────────────────
function seatFill(seat: SeatData, isSelected: boolean): string {
function seatFill(seat: SeatData, effectiveStatus: SeatStatus, isSelected: boolean): string {
if (isSelected) return "#E32636";
if (seat.status === "SOLD") return "#2D3748";
if (seat.status === "LOCKED") return "#D97706";
if (effectiveStatus === "SOLD") return "#2D3748";
if (effectiveStatus === "LOCKED") return "#D97706";
return seat.type === "VIP" ? "#8B5CF6" : "#0891B2";
}
function seatStroke(seat: SeatData, isSelected: boolean): string {
function seatStroke(seat: SeatData, effectiveStatus: SeatStatus, isSelected: boolean): string {
if (isSelected) return "#FF6B7A";
if (seat.status === "SOLD") return "#4B5563";
if (seat.status === "LOCKED") return "#F59E0B";
if (effectiveStatus === "SOLD") return "#4B5563";
if (effectiveStatus === "LOCKED") return "#F59E0B";
return seat.type === "VIP" ? "#C084FC" : "#38BDF8";
}
@@ -112,28 +115,91 @@ export default function OctagonSeatMap() {
const { seats: cartSeats, addSeat, removeSeat } = useCartStore();
const selectedIds = useMemo(() => new Set(cartSeats.map((s) => s.seatId)), [cartSeats]);
function handleSeatClick(seat: SeatData) {
if (seat.status !== "AVAILABLE") return;
if (selectedIds.has(seat.id)) {
removeSeat(seat.id);
} else {
addSeat({
seatId: seat.id,
sector: seat.sector,
row: seat.row,
number: seat.number,
price: seat.price,
});
}
}
// Local status overrides: applied after a 409 response to immediately mark seat as 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 state (two-part so text stays during fade-out)
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;
// Block all clicks while another request is in flight
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 {
await lockSeatApi(seat.id, userId);
// ✅ 200 OK — seat is ours, add to cart
addSeat({
seatId: seat.id,
sector: seat.sector,
row: seat.row,
number: seat.number,
price: seat.price,
});
} catch (err) {
const axiosErr = err as AxiosError;
// Mark seat as LOCKED locally so it becomes visually unavailable immediately
setLocalStatuses((prev) => ({ ...prev, [seat.id]: "LOCKED" }));
if (axiosErr.response?.status === 409) {
// ⚡ Race condition — someone else grabbed the seat
showToast("Извините, это место только что заняли");
} else {
showToast("Ошибка сети. Попробуйте ещё раз");
}
} finally {
setPendingId(null);
}
},
[localStatuses, pendingId, selectedIds, addSeat, removeSeat, showToast]
);
return (
<div className="w-full px-2">
<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>
{/* Arena radial gradient */}
@@ -271,8 +337,10 @@ export default function OctagonSeatMap() {
{/* ── Seat dots ── */}
{SEATS.map((seat) => {
const effectiveStatus = localStatuses[seat.id] ?? seat.status;
const isSelected = selectedIds.has(seat.id);
const isClickable = seat.status === "AVAILABLE";
const isPending = pendingId === seat.id;
const isClickable = effectiveStatus === "AVAILABLE" && !isSelected && pendingId === null;
const r = seat.type === "VIP" ? 5 : 4.2;
return (
@@ -281,20 +349,25 @@ export default function OctagonSeatMap() {
cx={seat.x}
cy={seat.y}
r={r}
fill={seatFill(seat, isSelected)}
stroke={seatStroke(seat, isSelected)}
strokeWidth={isSelected ? 1.5 : 0.8}
opacity={seat.status === "SOLD" ? 0.45 : 1}
cursor={isClickable ? "pointer" : "not-allowed"}
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)"
: seat.status === "AVAILABLE"
: effectiveStatus === "AVAILABLE" && !isPending
? "url(#glow-soft)"
: undefined
}
onClick={() => handleSeatClick(seat)}
role={isClickable ? "button" : undefined}
onClick={() => { void handleSeatClick(seat); }}
role={isClickable || isSelected ? "button" : undefined}
aria-label={
isClickable
? `${seat.sector} Ряд ${seat.row} Место ${seat.number}${seat.price.toLocaleString("ru-RU")}`
@@ -303,11 +376,28 @@ export default function OctagonSeatMap() {
>
<title>
{seat.sector} Ряд {seat.row} Место {seat.number}
{seat.status === "SOLD" ? " (Занято)" : seat.status === "LOCKED" ? " (Удержано)" : `${seat.price.toLocaleString("ru-RU")}`}
{isPending
? " (обработка…)"
: effectiveStatus === "SOLD"
? " (Занято)"
: effectiveStatus === "LOCKED"
? " (Удержано)"
: `${seat.price.toLocaleString("ru-RU")}`}
</title>
</circle>
);
})}
{/* Transparent overlay that blocks 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>
);