diff --git a/backend/main.py b/backend/main.py
index fd427cb..f0cce5a 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -10,6 +10,16 @@ from api.routers.webhooks import router as webhooks_router
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(webhooks_router)
diff --git a/frontend-client/src/app/events/[id]/seats/page.tsx b/frontend-client/src/app/events/[id]/seats/page.tsx
new file mode 100644
index 0000000..13b5e13
--- /dev/null
+++ b/frontend-client/src/app/events/[id]/seats/page.tsx
@@ -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 (
+
+
+ {label}
+
+ );
+}
+
+// ─── 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 (
+
+
+
+ {/* ── Header ── */}
+
+
+
Выбор мест
+
+
+
+ {/* ── Event banner ── */}
+
+
+
+
+
+
{MOCK_EVENT.title}
+
+
+ {MOCK_EVENT.venue}
+
+
+
+
+ {/* ── SVG Arena map ── */}
+
+
+
+
+ {/* ── Legend ── */}
+
+
+ {/* ── Selected seats chips (visible when cart not empty) ── */}
+ {count > 0 && (
+
+
+ {cartSeats.map((s) => (
+
+
+ {s.sector} · Р{s.row} М{s.number}
+
+
+
+ ))}
+
+
+ )}
+
+ {/* ── Bottom bar ── */}
+
+ {count > 0 ? (
+ <>
+
+
+
Выбранные места:
+
+ {firstSeat
+ ? `${firstSeat.sector}, Ряд ${firstSeat.row}, Место ${firstSeat.number}`
+ : "—"}
+ {count > 1 && (
+ +{count - 1}
+ )}
+
+
+
+
Количество мест:
+
{count}
+
+
+
+
+
+
Итого к оплате:
+
+ {total.toLocaleString("ru-RU")} ₽
+
+
+
+
+ >
+ ) : (
+
+
+
Выберите места на схеме
+
+ )}
+
+
+
+
+ );
+}
diff --git a/frontend-client/src/components/OctagonSeatMap.tsx b/frontend-client/src/components/OctagonSeatMap.tsx
new file mode 100644
index 0000000..4ab2e0d
--- /dev/null
+++ b/frontend-client/src/components/OctagonSeatMap.tsx
@@ -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 (
+
+
+
+ );
+}