diff --git a/frontend-client/FRONTEND_ARCHITECTURE.md b/frontend-client/FRONTEND_ARCHITECTURE.md index d217b70..c7184eb 100644 --- a/frontend-client/FRONTEND_ARCHITECTURE.md +++ b/frontend-client/FRONTEND_ARCHITECTURE.md @@ -20,4 +20,9 @@ - `src/app/` — Роутинг (Pages, Layouts). - `src/components/` — Переиспользуемые UI компоненты. - `src/api/` — API-клиенты и типизация ответов бэкенда. -- `src/store/` — Zustand-хранилища. \ No newline at end of file +- `src/store/` — Zustand-хранилища. + +## 5. Интерактивность и Навигация (КРИТИЧЕСКИ ВАЖНО) +- ЗАПРЕЩЕНО создавать "мертвые" кнопки навигации. +- Любой элемент UI, который визуально выглядит как кнопка перехода на другую страницу, ОБЯЗАН быть обернут в компонент `` из `next/link` или иметь обработчик `onClick={() => router.push('...')}`. +- Если для кнопки пока нет API (например, Apple Wallet), она должна выводить `toast("Функция в разработке")`. \ No newline at end of file diff --git a/frontend-client/package-lock.json b/frontend-client/package-lock.json index afa937e..3c111f8 100644 --- a/frontend-client/package-lock.json +++ b/frontend-client/package-lock.json @@ -11,6 +11,7 @@ "axios": "^1.13.6", "lucide-react": "^0.577.0", "next": "14.2.35", + "qrcode.react": "^4.2.0", "react": "^18", "react-dom": "^18", "zustand": "^5.0.11" @@ -4718,6 +4719,15 @@ "node": ">=6" } }, + "node_modules/qrcode.react": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", diff --git a/frontend-client/package.json b/frontend-client/package.json index a391005..50a6f6d 100644 --- a/frontend-client/package.json +++ b/frontend-client/package.json @@ -12,6 +12,7 @@ "axios": "^1.13.6", "lucide-react": "^0.577.0", "next": "14.2.35", + "qrcode.react": "^4.2.0", "react": "^18", "react-dom": "^18", "zustand": "^5.0.11" diff --git a/frontend-client/src/api/client.ts b/frontend-client/src/api/client.ts index ba177a9..7259a51 100644 --- a/frontend-client/src/api/client.ts +++ b/frontend-client/src/api/client.ts @@ -14,6 +14,45 @@ apiClient.interceptors.request.use((config) => { return config; }); +// ─── Domain types (mirror backend schemas) ──────────────────────────────────── + +export interface TournamentInfo { + id: number; + title: string; + event_date: string; // ISO-8601 +} + +export interface SeatInfo { + id: number; + sector: string; + row: number; + number: number; + price: number; + tournament: TournamentInfo; +} + +export type TicketStatus = "AVAILABLE" | "LOCKED" | "PAID" | "SCANNED" | "REFUNDED"; + +export interface TicketResponse { + id: number; + status: TicketStatus; + pdf_url: string | null; + created_at: string; // ISO-8601 + seat: SeatInfo; +} + +// ─── Tickets API ────────────────────────────────────────────────────────────── + +/** + * GET /api/tickets/me + * Returns all PAID tickets for the authenticated user. + * Throws AxiosError 401 if the token is missing or expired. + */ +export async function getMyTicketsApi(): Promise { + const response = await apiClient.get("/tickets/me"); + return response.data; +} + // ─── Seat locking ───────────────────────────────────────────────────────────── interface LockSeatResponse { diff --git a/frontend-client/src/app/tickets/[id]/page.tsx b/frontend-client/src/app/tickets/[id]/page.tsx new file mode 100644 index 0000000..15d7beb --- /dev/null +++ b/frontend-client/src/app/tickets/[id]/page.tsx @@ -0,0 +1,258 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { ArrowLeft, Download, CheckCircle2, Wallet } from "lucide-react"; +import { QRCodeSVG } from "qrcode.react"; +import type { AxiosError } from "axios"; +import { getMyTicketsApi, type TicketResponse } from "@/api/client"; + +// ─── Constants ──────────────────────────────────────────────────────────────── + +/** Replace internal MinIO hostname with the externally accessible address */ +const MINIO_INTERNAL = "http://minio:9000"; +const MINIO_EXTERNAL = "http://192.168.149.101:9000"; + +const RU_MONTHS = [ + "Января", "Февраля", "Марта", "Апреля", "Мая", "Июня", + "Июля", "Августа", "Сентября", "Октября", "Ноября", "Декабря", +]; + +function formatDate(iso: string): string { + const d = new Date(iso); + const hh = String(d.getHours()).padStart(2, "0"); + const mm = String(d.getMinutes()).padStart(2, "0"); + return `${d.getDate()} ${RU_MONTHS[d.getMonth()]}, ${hh}:${mm}`; +} + +function resolvePdfUrl(url: string): string { + return url.replace(MINIO_INTERNAL, MINIO_EXTERNAL); +} + +// ─── Perforated ticket card ─────────────────────────────────────────────────── + +function TicketCard({ ticket }: { ticket: TicketResponse }) { + const { seat } = ticket; + const { tournament } = seat; + + return ( + // Outer wrapper for the notch overlay trick +
+ + {/* Top semicircle notch — same colour as page bg to "cut" the card */} +
+ + {/* Card body */} +
+ + {/* ── Upper half: QR code ── */} +
+
+ +
+ +

+ {tournament.title} +

+

+ {formatDate(tournament.event_date)} + | + ВТБ Арена +

+
+ + {/* ── Dashed perforation line with side notches ── */} +
+ {/* Left notch */} +
+ {/* Dashed line */} +
+ {/* Right notch */} +
+
+ + {/* ── Lower half: seat details for steward ── */} +
+
+
+

Сектор

+

{seat.sector}

+
+
+

Ряд

+

{seat.row}

+
+
+

Место

+

{seat.number}

+
+
+
+
+ + {/* Bottom semicircle notch */} +
+
+ ); +} + +// ─── Page ───────────────────────────────────────────────────────────────────── + +export default function TicketDetailPage({ + params, +}: { + params: { id: string }; +}) { + const router = useRouter(); + const targetId = Number(params.id); + + const [ticket, setTicket] = useState(null); + const [loading, setLoading] = useState(true); + const [notFound, setNotFound] = useState(false); + const [walletToast, setWalletToast] = useState(false); + + useEffect(() => { + let cancelled = false; + + async function load() { + try { + const all = await getMyTicketsApi(); + if (cancelled) return; + const found = all.find((t) => t.id === targetId) ?? null; + if (found) { + setTicket(found); + } else { + setNotFound(true); + } + } catch (err) { + if (cancelled) return; + const axiosErr = err as AxiosError; + if (axiosErr.response?.status === 401) { + router.push("/"); + return; + } + setNotFound(true); + } finally { + if (!cancelled) setLoading(false); + } + } + + void load(); + return () => { cancelled = true; }; + }, [router, targetId]); + + function handleWalletClick() { + setWalletToast(true); + setTimeout(() => setWalletToast(false), 2500); + } + + function handleDownloadPdf() { + if (!ticket?.pdf_url) return; + const url = resolvePdfUrl(ticket.pdf_url); + window.open(url, "_blank", "noopener,noreferrer"); + } + + // ── Loading state ── + if (loading) { + return ( +
+
+
+
+
+
+
+
+
+ ); + } + + // ── Not found state ── + if (notFound || !ticket) { + return ( +
+
+

Билет не найден.

+ +
+
+ ); + } + + return ( +
+
+ + {/* ── Header ── */} +
+ +

Билет

+
+ + {/* ── Wallet toast ── */} +
+
+ + Функция в разработке +
+
+ + {/* ── Ticket card with perforated design ── */} + + + {/* ── Validity badge ── */} +
+
+ + Билет действителен +
+
+ + {/* ── Action buttons ── */} +
+ {/* Wallet */} + + + {/* Download PDF */} + +
+ +
+
+ ); +} diff --git a/frontend-client/src/app/tickets/page.tsx b/frontend-client/src/app/tickets/page.tsx new file mode 100644 index 0000000..a4cbb51 --- /dev/null +++ b/frontend-client/src/app/tickets/page.tsx @@ -0,0 +1,233 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import Link from "next/link"; +import { SlidersHorizontal, CalendarDays, Ticket, User } from "lucide-react"; +import type { AxiosError } from "axios"; +import { getMyTicketsApi, type TicketResponse } from "@/api/client"; + +// ─── Date formatter ─────────────────────────────────────────────────────────── + +const RU_MONTHS = [ + "Января", "Февраля", "Марта", "Апреля", "Мая", "Июня", + "Июля", "Августа", "Сентября", "Октября", "Ноября", "Декабря", +]; + +function formatDate(iso: string): string { + const d = new Date(iso); + const hh = String(d.getHours()).padStart(2, "0"); + const mm = String(d.getMinutes()).padStart(2, "0"); + return `${d.getDate()} ${RU_MONTHS[d.getMonth()]}, ${hh}:${mm}`; +} + +// ─── Skeleton card ──────────────────────────────────────────────────────────── + +function SkeletonCard() { + return ( +
+
+
+
+
+
+
+
+
+
+
+ ); +} + +// ─── Ticket card ────────────────────────────────────────────────────────────── + +function TicketCard({ ticket }: { ticket: TicketResponse }) { + const { seat } = ticket; + const { tournament } = seat; + + const POSTER = + "https://images.unsplash.com/photo-1549719386-74dfcbf7dbed?w=400&q=80"; + + return ( +
+
+ {/* Event poster */} + {/* eslint-disable-next-line @next/next/no-img-element */} + {tournament.title} + + {/* Info */} +
+

+ {tournament.title} +

+

+ {formatDate(tournament.event_date)} +

+

+ Сектор:{" "} + {seat.sector} + , Ряд:{" "} + {seat.row} + , Место:{" "} + {seat.number} +

+
+
+ + {/* CTA — Link обеспечивает prefetch и семантику навигации */} +
+ + Открыть билет + +
+
+ ); +} + +// ─── Page ───────────────────────────────────────────────────────────────────── + +type Tab = "active" | "history"; + +const NAV_ITEMS = [ + { label: "События", icon: CalendarDays, href: "/" }, + { label: "Мои билеты", icon: Ticket, href: "/tickets" }, + { label: "Профиль", icon: User, href: "/profile" }, +] as const; + +export default function TicketsPage() { + const router = useRouter(); + + const [tickets, setTickets] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [activeTab, setActiveTab] = useState("active"); + + useEffect(() => { + let cancelled = false; + + async function load() { + try { + const data = await getMyTicketsApi(); + if (!cancelled) setTickets(data); + } catch (err) { + if (cancelled) return; + const axiosErr = err as AxiosError; + if (axiosErr.response?.status === 401) { + router.push("/"); + return; + } + setError("Не удалось загрузить билеты. Попробуйте позже."); + } finally { + if (!cancelled) setLoading(false); + } + } + + void load(); + return () => { cancelled = true; }; + }, [router]); + + // For now "active" shows PAID/SCANNED, "history" shows REFUNDED — all come + // from the same /tickets/me endpoint which already filters by PAID + const displayed = + activeTab === "active" + ? tickets.filter((t) => t.status === "PAID" || t.status === "SCANNED") + : tickets.filter((t) => t.status === "REFUNDED"); + + return ( +
+
+ + {/* ── Header ── */} +
+

+ Мои билеты +

+ +
+ + {/* ── Tabs ── */} +
+ {(["active", "history"] as Tab[]).map((tab) => ( + + ))} +
+ + {/* ── Content ── */} +
+ {loading ? ( + <> + + + + + ) : error ? ( +
+

{error}

+ +
+ ) : displayed.length === 0 ? ( +
+ +

+ {activeTab === "active" ? "Нет активных билетов" : "История пуста"} +

+
+ ) : ( + displayed.map((ticket) => ( + + )) + )} +
+ + {/* ── Bottom navigation ── */} + + +
+
+ ); +}