Update project 5 FRONT main page
This commit is contained in:
@@ -10,6 +10,16 @@ from api.routers.webhooks import router as webhooks_router
|
|||||||
|
|
||||||
app = FastAPI(title="Ticketing System API")
|
app = FastAPI(title="Ticketing System API")
|
||||||
|
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"], # Для локальной песочницы оставляем открытым
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
app.include_router(auth_router)
|
app.include_router(auth_router)
|
||||||
app.include_router(webhooks_router)
|
app.include_router(webhooks_router)
|
||||||
|
|
||||||
|
|||||||
169
frontend-client/src/app/events/[id]/seats/page.tsx
Normal file
169
frontend-client/src/app/events/[id]/seats/page.tsx
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ArrowLeft, Share2, MapPin, ShoppingCart, X } from "lucide-react";
|
||||||
|
import OctagonSeatMap from "@/components/OctagonSeatMap";
|
||||||
|
import { useCartStore } from "@/store/cartStore";
|
||||||
|
import Image from "next/image";
|
||||||
|
|
||||||
|
// ─── Mocked event data (real API call would go here) ─────────────────────────
|
||||||
|
|
||||||
|
const MOCK_EVENT = {
|
||||||
|
title: "Чемпионат по ММА",
|
||||||
|
venue: "ВТБ Арена, Москва",
|
||||||
|
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() {
|
||||||
|
const { seats: cartSeats, removeSeat, totalPrice } = useCartStore();
|
||||||
|
|
||||||
|
const total = totalPrice();
|
||||||
|
const count = cartSeats.length;
|
||||||
|
|
||||||
|
// The first selected seat for the summary line
|
||||||
|
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 justify-between px-5 pt-12 pb-3">
|
||||||
|
<button
|
||||||
|
aria-label="Назад"
|
||||||
|
className="w-9 h-9 flex items-center justify-center rounded-full bg-[#1C1C1E] text-white"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={18} strokeWidth={2.5} />
|
||||||
|
</button>
|
||||||
|
<h1 className="text-[17px] font-semibold text-white">Выбор мест</h1>
|
||||||
|
<button
|
||||||
|
aria-label="Поделиться"
|
||||||
|
className="w-9 h-9 flex items-center justify-center rounded-full bg-[#1C1C1E] text-white"
|
||||||
|
>
|
||||||
|
<Share2 size={17} strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Event banner ── */}
|
||||||
|
<div className="flex items-center gap-3 px-5 py-3 mx-4 mb-3 bg-[#1C1C1E] rounded-2xl">
|
||||||
|
<div className="relative w-11 h-11 rounded-xl overflow-hidden flex-shrink-0">
|
||||||
|
<Image
|
||||||
|
src={MOCK_EVENT.imageSrc}
|
||||||
|
alt={MOCK_EVENT.title}
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
sizes="44px"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-[14px] font-semibold text-white leading-tight">{MOCK_EVENT.title}</p>
|
||||||
|
<div className="flex items-center gap-1 mt-0.5">
|
||||||
|
<MapPin size={10} className="text-[#8E8E93]" />
|
||||||
|
<span className="text-[11px] text-[#8E8E93] truncate">{MOCK_EVENT.venue}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── SVG Arena map ── */}
|
||||||
|
<div className="flex-1 flex flex-col px-1">
|
||||||
|
<OctagonSeatMap />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Legend ── */}
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* ── Selected seats chips (visible when cart not empty) ── */}
|
||||||
|
{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 className="bg-[#E32636] hover:bg-[#C41E2A] 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
314
frontend-client/src/components/OctagonSeatMap.tsx
Normal file
314
frontend-client/src/components/OctagonSeatMap.tsx
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { useCartStore } from "@/store/cartStore";
|
||||||
|
|
||||||
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
/** Three concentric seat rings around the octagon. */
|
||||||
|
const RING_CONFIG = [
|
||||||
|
{ radius: 90, count: 22, sector: "VIP", type: "VIP" as SeatType, rowNum: 1, price: 12500 },
|
||||||
|
{ radius: 122, count: 32, sector: "A", type: "STD" as SeatType, rowNum: 2, price: 5000 },
|
||||||
|
{ radius: 157, count: 42, sector: "B", type: "STD" as SeatType, rowNum: 3, price: 2500 },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
// ─── Deterministic seat status (stable across re-renders) ─────────────────────
|
||||||
|
|
||||||
|
function deterministicStatus(id: number): SeatStatus {
|
||||||
|
// Knuth multiplicative hash → spread across 100
|
||||||
|
const v = ((id * 2654435761) >>> 0) % 100;
|
||||||
|
if (v < 56) return "AVAILABLE";
|
||||||
|
if (v < 80) return "SOLD";
|
||||||
|
return "LOCKED";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Seat geometry ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function generateSeats(): SeatData[] {
|
||||||
|
const seats: SeatData[] = [];
|
||||||
|
let globalId = 0;
|
||||||
|
|
||||||
|
for (const ring of RING_CONFIG) {
|
||||||
|
for (let i = 0; i < ring.count; i++) {
|
||||||
|
// Distribute evenly, starting from top (−π/2)
|
||||||
|
const angle = (i / ring.count) * 2 * Math.PI - Math.PI / 2;
|
||||||
|
const deg = ((angle * 180) / Math.PI + 360) % 360;
|
||||||
|
|
||||||
|
// Aisle gaps at 0° (top) and 180° (bottom) — each ~10° wide
|
||||||
|
if (deg < 6 || deg > 354) continue;
|
||||||
|
if (deg > 174 && deg < 186) continue;
|
||||||
|
|
||||||
|
globalId++;
|
||||||
|
seats.push({
|
||||||
|
id: globalId,
|
||||||
|
x: Math.round((CX + ring.radius * Math.cos(angle)) * 10) / 10,
|
||||||
|
y: Math.round((CY + ring.radius * Math.sin(angle)) * 10) / 10,
|
||||||
|
row: ring.rowNum,
|
||||||
|
number: i + 1,
|
||||||
|
sector: ring.sector,
|
||||||
|
type: ring.type,
|
||||||
|
status: deterministicStatus(globalId),
|
||||||
|
price: ring.price,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return seats;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Computed once at module load — deterministic, no side effects. */
|
||||||
|
const SEATS: SeatData[] = generateSeats();
|
||||||
|
|
||||||
|
// ─── Octagon polygon points ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function octagonPoints(cx: number, cy: number, r: number): string {
|
||||||
|
return Array.from({ length: 8 }, (_, i) => {
|
||||||
|
// +π/8 offset → flat-top octagon (horizontal top/bottom edges)
|
||||||
|
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, isSelected: boolean): string {
|
||||||
|
if (isSelected) return "#E32636";
|
||||||
|
if (seat.status === "SOLD") return "#2D3748";
|
||||||
|
if (seat.status === "LOCKED") return "#D97706";
|
||||||
|
return seat.type === "VIP" ? "#8B5CF6" : "#0891B2";
|
||||||
|
}
|
||||||
|
|
||||||
|
function seatStroke(seat: SeatData, isSelected: boolean): string {
|
||||||
|
if (isSelected) return "#FF6B7A";
|
||||||
|
if (seat.status === "SOLD") return "#4B5563";
|
||||||
|
if (seat.status === "LOCKED") return "#F59E0B";
|
||||||
|
return seat.type === "VIP" ? "#C084FC" : "#38BDF8";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Component ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full px-2">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 400 384"
|
||||||
|
className="w-full"
|
||||||
|
aria-label="Схема зала"
|
||||||
|
role="img"
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
{/* Arena radial gradient */}
|
||||||
|
<radialGradient id="arena-bg" cx="50%" cy="50%" r="50%">
|
||||||
|
<stop offset="0%" stopColor="#131328" />
|
||||||
|
<stop offset="100%" stopColor="#080812" />
|
||||||
|
</radialGradient>
|
||||||
|
|
||||||
|
{/* Neon glow for selected seats */}
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* Subtle glow for available seats */}
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* Cage glow for octagon border */}
|
||||||
|
<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"
|
||||||
|
/>
|
||||||
|
{/* Neon outer rim */}
|
||||||
|
<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 (subtle, from inner ellipse outward) ── */}
|
||||||
|
{[0, 90, 180, 270].map((deg) => {
|
||||||
|
const rad = (deg * Math.PI) / 180;
|
||||||
|
const x1 = CX + 148 * Math.cos(rad);
|
||||||
|
const y1 = CY + 130 * Math.sin(rad);
|
||||||
|
const x2 = CX + 182 * Math.cos(rad);
|
||||||
|
const y2 = CY + 165 * Math.sin(rad);
|
||||||
|
return (
|
||||||
|
<line
|
||||||
|
key={deg}
|
||||||
|
x1={x1} y1={y1}
|
||||||
|
x2={x2} y2={y2}
|
||||||
|
stroke="#1E293B"
|
||||||
|
strokeWidth="1"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* ── Octagon cage border (glow layer) ── */}
|
||||||
|
<polygon
|
||||||
|
points={octagonPoints(CX, CY, OCTAGON_R)}
|
||||||
|
fill="none"
|
||||||
|
stroke="#38BDF8"
|
||||||
|
strokeWidth="4"
|
||||||
|
opacity="0.25"
|
||||||
|
filter="url(#glow-cage)"
|
||||||
|
/>
|
||||||
|
{/* Octagon fill */}
|
||||||
|
<polygon
|
||||||
|
points={octagonPoints(CX, CY, OCTAGON_R)}
|
||||||
|
fill="#05050F"
|
||||||
|
stroke="#38BDF8"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Octagon inner 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>
|
||||||
|
|
||||||
|
{/* ── Sector labels ── */}
|
||||||
|
<text x={CX} y={CY - 136} textAnchor="middle" fill="#94A3B8" fontSize="9" fontWeight="500">Сектор C</text>
|
||||||
|
<text x={CX} y={CY + 148} textAnchor="middle" fill="#94A3B8" fontSize="9" fontWeight="500">Сектор B</text>
|
||||||
|
<text x={CX - 162} y={CY + 5} textAnchor="middle" fill="#94A3B8" fontSize="9" fontWeight="500">VIP</text>
|
||||||
|
<text x={CX + 162} y={CY + 5} textAnchor="middle" fill="#94A3B8" fontSize="9" fontWeight="500">A2</text>
|
||||||
|
|
||||||
|
{/* ── Seat dots ── */}
|
||||||
|
{SEATS.map((seat) => {
|
||||||
|
const isSelected = selectedIds.has(seat.id);
|
||||||
|
const isClickable = seat.status === "AVAILABLE";
|
||||||
|
const r = seat.type === "VIP" ? 5 : 4.2;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<circle
|
||||||
|
key={seat.id}
|
||||||
|
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"}
|
||||||
|
filter={
|
||||||
|
isSelected
|
||||||
|
? "url(#glow-red)"
|
||||||
|
: seat.status === "AVAILABLE"
|
||||||
|
? "url(#glow-soft)"
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onClick={() => handleSeatClick(seat)}
|
||||||
|
role={isClickable ? "button" : undefined}
|
||||||
|
aria-label={
|
||||||
|
isClickable
|
||||||
|
? `${seat.sector} Ряд ${seat.row} Место ${seat.number} — ${seat.price.toLocaleString("ru-RU")} ₽`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<title>
|
||||||
|
{seat.sector} • Ряд {seat.row} • Место {seat.number}
|
||||||
|
{seat.status === "SOLD" ? " (Занято)" : seat.status === "LOCKED" ? " (Удержано)" : ` — ${seat.price.toLocaleString("ru-RU")} ₽`}
|
||||||
|
</title>
|
||||||
|
</circle>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user