From 02b70f4369008ba47ae78e8b9f7b8ba48c716a57 Mon Sep 17 00:00:00 2001 From: openit Date: Fri, 6 Mar 2026 11:42:01 +0000 Subject: [PATCH] Update project 5 FRONT main page --- backend/main.py | 10 + .../src/app/events/[id]/seats/page.tsx | 169 ++++++++++ .../src/components/OctagonSeatMap.tsx | 314 ++++++++++++++++++ 3 files changed, 493 insertions(+) create mode 100644 frontend-client/src/app/events/[id]/seats/page.tsx create mode 100644 frontend-client/src/components/OctagonSeatMap.tsx 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.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 ( +
+ + + {/* Arena radial gradient */} + + + + + + {/* Neon glow for selected seats */} + + + + + + + + + {/* Subtle glow for available seats */} + + + + + + + + + {/* Cage glow for octagon border */} + + + + + + + + + + {/* ── Outer arena ellipse ── */} + + {/* Neon outer rim */} + + + {/* ── Inner floor area ── */} + + + {/* ── 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 ( + + ); + })} + + {/* ── Octagon cage border (glow layer) ── */} + + {/* Octagon fill */} + + + {/* 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 ( + + ); + })} + + {/* Stage label */} + + Stage + + + {/* ── Sector labels ── */} + Сектор C + Сектор B + VIP + A2 + + {/* ── 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 ( + handleSeatClick(seat)} + role={isClickable ? "button" : undefined} + aria-label={ + isClickable + ? `${seat.sector} Ряд ${seat.row} Место ${seat.number} — ${seat.price.toLocaleString("ru-RU")} ₽` + : undefined + } + > + + {seat.sector} • Ряд {seat.row} • Место {seat.number} + {seat.status === "SOLD" ? " (Занято)" : seat.status === "LOCKED" ? " (Удержано)" : ` — ${seat.price.toLocaleString("ru-RU")} ₽`} + + + ); + })} + +
+ ); +}