Update project 9 FRONT TICKET QR
This commit is contained in:
@@ -20,4 +20,9 @@
|
|||||||
- `src/app/` — Роутинг (Pages, Layouts).
|
- `src/app/` — Роутинг (Pages, Layouts).
|
||||||
- `src/components/` — Переиспользуемые UI компоненты.
|
- `src/components/` — Переиспользуемые UI компоненты.
|
||||||
- `src/api/` — API-клиенты и типизация ответов бэкенда.
|
- `src/api/` — API-клиенты и типизация ответов бэкенда.
|
||||||
- `src/store/` — Zustand-хранилища.
|
- `src/store/` — Zustand-хранилища.
|
||||||
|
|
||||||
|
## 5. Интерактивность и Навигация (КРИТИЧЕСКИ ВАЖНО)
|
||||||
|
- ЗАПРЕЩЕНО создавать "мертвые" кнопки навигации.
|
||||||
|
- Любой элемент UI, который визуально выглядит как кнопка перехода на другую страницу, ОБЯЗАН быть обернут в компонент `<Link href="...">` из `next/link` или иметь обработчик `onClick={() => router.push('...')}`.
|
||||||
|
- Если для кнопки пока нет API (например, Apple Wallet), она должна выводить `toast("Функция в разработке")`.
|
||||||
10
frontend-client/package-lock.json
generated
10
frontend-client/package-lock.json
generated
@@ -11,6 +11,7 @@
|
|||||||
"axios": "^1.13.6",
|
"axios": "^1.13.6",
|
||||||
"lucide-react": "^0.577.0",
|
"lucide-react": "^0.577.0",
|
||||||
"next": "14.2.35",
|
"next": "14.2.35",
|
||||||
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "^18",
|
"react": "^18",
|
||||||
"react-dom": "^18",
|
"react-dom": "^18",
|
||||||
"zustand": "^5.0.11"
|
"zustand": "^5.0.11"
|
||||||
@@ -4718,6 +4719,15 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/queue-microtask": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"axios": "^1.13.6",
|
"axios": "^1.13.6",
|
||||||
"lucide-react": "^0.577.0",
|
"lucide-react": "^0.577.0",
|
||||||
"next": "14.2.35",
|
"next": "14.2.35",
|
||||||
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "^18",
|
"react": "^18",
|
||||||
"react-dom": "^18",
|
"react-dom": "^18",
|
||||||
"zustand": "^5.0.11"
|
"zustand": "^5.0.11"
|
||||||
|
|||||||
@@ -14,6 +14,45 @@ apiClient.interceptors.request.use((config) => {
|
|||||||
return 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<TicketResponse[]> {
|
||||||
|
const response = await apiClient.get<TicketResponse[]>("/tickets/me");
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Seat locking ─────────────────────────────────────────────────────────────
|
// ─── Seat locking ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface LockSeatResponse {
|
interface LockSeatResponse {
|
||||||
|
|||||||
258
frontend-client/src/app/tickets/[id]/page.tsx
Normal file
258
frontend-client/src/app/tickets/[id]/page.tsx
Normal file
@@ -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
|
||||||
|
<div className="relative mx-4">
|
||||||
|
|
||||||
|
{/* Top semicircle notch — same colour as page bg to "cut" the card */}
|
||||||
|
<div className="absolute -top-3.5 left-1/2 -translate-x-1/2 w-7 h-7 rounded-full bg-[#121212] z-10" />
|
||||||
|
|
||||||
|
{/* Card body */}
|
||||||
|
<div className="bg-[#1C1C1E] rounded-3xl overflow-hidden">
|
||||||
|
|
||||||
|
{/* ── Upper half: QR code ── */}
|
||||||
|
<div className="flex flex-col items-center px-8 pt-10 pb-6">
|
||||||
|
<div className="bg-white rounded-2xl p-4 shadow-2xl">
|
||||||
|
<QRCodeSVG
|
||||||
|
value={`TICKET-ID-${ticket.id}`}
|
||||||
|
size={180}
|
||||||
|
bgColor="#FFFFFF"
|
||||||
|
fgColor="#000000"
|
||||||
|
level="H"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="mt-5 text-[18px] font-bold text-white text-center leading-tight">
|
||||||
|
{tournament.title}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-[13px] text-[#8E8E93] text-center">
|
||||||
|
{formatDate(tournament.event_date)}
|
||||||
|
<span className="mx-2 text-[#3A3A3C]">|</span>
|
||||||
|
ВТБ Арена
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Dashed perforation line with side notches ── */}
|
||||||
|
<div className="relative flex items-center px-0">
|
||||||
|
{/* Left notch */}
|
||||||
|
<div className="w-5 h-5 rounded-full bg-[#121212] flex-shrink-0 -ml-2.5 z-10" />
|
||||||
|
{/* Dashed line */}
|
||||||
|
<div className="flex-1 border-t-2 border-dashed border-[#2C2C2E]" />
|
||||||
|
{/* Right notch */}
|
||||||
|
<div className="w-5 h-5 rounded-full bg-[#121212] flex-shrink-0 -mr-2.5 z-10" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Lower half: seat details for steward ── */}
|
||||||
|
<div className="px-6 pt-5 pb-8">
|
||||||
|
<div className="grid grid-cols-3 gap-2 text-center">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<p className="text-[11px] text-[#8E8E93] uppercase tracking-wider">Сектор</p>
|
||||||
|
<p className="text-[24px] font-extrabold text-white leading-none">{seat.sector}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<p className="text-[11px] text-[#8E8E93] uppercase tracking-wider">Ряд</p>
|
||||||
|
<p className="text-[24px] font-extrabold text-white leading-none">{seat.row}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<p className="text-[11px] text-[#8E8E93] uppercase tracking-wider">Место</p>
|
||||||
|
<p className="text-[24px] font-extrabold text-white leading-none">{seat.number}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom semicircle notch */}
|
||||||
|
<div className="absolute -bottom-3.5 left-1/2 -translate-x-1/2 w-7 h-7 rounded-full bg-[#121212] z-10" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Page ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function TicketDetailPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: { id: string };
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const targetId = Number(params.id);
|
||||||
|
|
||||||
|
const [ticket, setTicket] = useState<TicketResponse | null>(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 (
|
||||||
|
<div className="flex justify-center min-h-screen bg-[#121212]">
|
||||||
|
<div className="w-full max-w-[390px] flex flex-col">
|
||||||
|
<div className="flex items-center gap-3 px-5 pt-12 pb-4">
|
||||||
|
<div className="w-9 h-9 rounded-full bg-[#1C1C1E] animate-pulse" />
|
||||||
|
<div className="h-6 w-24 rounded-lg bg-[#1C1C1E] animate-pulse" />
|
||||||
|
</div>
|
||||||
|
<div className="mx-4 mt-4 rounded-3xl bg-[#1C1C1E] h-[460px] animate-pulse" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Not found state ──
|
||||||
|
if (notFound || !ticket) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center min-h-screen bg-[#121212]">
|
||||||
|
<div className="w-full max-w-[390px] flex flex-col items-center justify-center gap-4 px-5">
|
||||||
|
<p className="text-[#8E8E93] text-center">Билет не найден.</p>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push("/tickets")}
|
||||||
|
className="text-[#E32636] font-semibold"
|
||||||
|
>
|
||||||
|
← К списку билетов
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center min-h-screen bg-[#121212]">
|
||||||
|
<div className="w-full max-w-[390px] flex flex-col bg-[#121212]">
|
||||||
|
|
||||||
|
{/* ── Header ── */}
|
||||||
|
<div className="flex items-center gap-3 px-5 pt-12 pb-5">
|
||||||
|
<button
|
||||||
|
onClick={() => router.back()}
|
||||||
|
aria-label="Назад"
|
||||||
|
className="w-9 h-9 flex items-center justify-center rounded-full bg-[#1C1C1E] text-white flex-shrink-0"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={18} strokeWidth={2.5} />
|
||||||
|
</button>
|
||||||
|
<h1 className="text-[22px] font-bold text-white">Билет</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Wallet toast ── */}
|
||||||
|
<div
|
||||||
|
className={`fixed top-14 left-1/2 -translate-x-1/2 z-50 transition-all duration-300 pointer-events-none ${
|
||||||
|
walletToast ? "opacity-100 translate-y-0" : "opacity-0 -translate-y-1"
|
||||||
|
}`}
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 bg-[#1C1C1E] border border-[#2C2C2E] text-white text-[13px] font-medium px-5 py-2.5 rounded-full shadow-xl whitespace-nowrap">
|
||||||
|
<Wallet size={14} className="text-[#8E8E93]" />
|
||||||
|
Функция в разработке
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Ticket card with perforated design ── */}
|
||||||
|
<TicketCard ticket={ticket} />
|
||||||
|
|
||||||
|
{/* ── Validity badge ── */}
|
||||||
|
<div className="flex justify-center mt-7">
|
||||||
|
<div className="flex items-center gap-2 bg-[#1C1C1E] border border-[#2C2C2E] rounded-full px-5 py-2">
|
||||||
|
<CheckCircle2 size={15} className="text-[#16A34A]" strokeWidth={2.5} />
|
||||||
|
<span className="text-[13px] font-medium text-white">Билет действителен</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Action buttons ── */}
|
||||||
|
<div className="px-5 mt-5 space-y-3">
|
||||||
|
{/* Wallet */}
|
||||||
|
<button
|
||||||
|
onClick={handleWalletClick}
|
||||||
|
className="w-full flex items-center justify-center gap-2 bg-[#E32636] hover:bg-[#C41E2A] active:scale-95 transition-all text-white text-[16px] font-semibold py-4 rounded-2xl"
|
||||||
|
>
|
||||||
|
<Wallet size={18} strokeWidth={2} />
|
||||||
|
Добавить в Wallet
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Download PDF */}
|
||||||
|
<button
|
||||||
|
onClick={handleDownloadPdf}
|
||||||
|
disabled={!ticket.pdf_url}
|
||||||
|
className="w-full flex items-center justify-center gap-2 bg-[#1C1C1E] hover:bg-[#2C2C2E] disabled:opacity-40 disabled:cursor-not-allowed active:scale-95 transition-all text-white text-[16px] font-semibold py-4 rounded-2xl border border-[#2C2C2E]"
|
||||||
|
>
|
||||||
|
<Download size={18} strokeWidth={2} />
|
||||||
|
Скачать
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
233
frontend-client/src/app/tickets/page.tsx
Normal file
233
frontend-client/src/app/tickets/page.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="bg-[#1C1C1E] rounded-2xl p-3 animate-pulse">
|
||||||
|
<div className="flex gap-3 mb-3">
|
||||||
|
<div className="w-20 h-20 rounded-xl bg-[#2C2C2E] flex-shrink-0" />
|
||||||
|
<div className="flex-1 space-y-2 pt-1">
|
||||||
|
<div className="h-4 bg-[#2C2C2E] rounded w-3/4" />
|
||||||
|
<div className="h-3 bg-[#2C2C2E] rounded w-1/2" />
|
||||||
|
<div className="h-3 bg-[#2C2C2E] rounded w-2/3" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="h-10 bg-[#2C2C2E] rounded-xl" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 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 (
|
||||||
|
<div className="bg-[#1C1C1E] rounded-2xl overflow-hidden">
|
||||||
|
<div className="flex gap-3 p-3">
|
||||||
|
{/* Event poster */}
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={POSTER}
|
||||||
|
alt={tournament.title}
|
||||||
|
className="w-20 h-20 rounded-xl object-cover flex-shrink-0"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<div className="flex-1 min-w-0 flex flex-col justify-between py-0.5">
|
||||||
|
<p className="text-[15px] font-bold text-white leading-snug line-clamp-2">
|
||||||
|
{tournament.title}
|
||||||
|
</p>
|
||||||
|
<p className="text-[12px] text-[#8E8E93] mt-1">
|
||||||
|
{formatDate(tournament.event_date)}
|
||||||
|
</p>
|
||||||
|
<p className="text-[12px] text-[#8E8E93]">
|
||||||
|
Сектор:{" "}
|
||||||
|
<span className="text-white font-medium">{seat.sector}</span>
|
||||||
|
, Ряд:{" "}
|
||||||
|
<span className="text-white font-medium">{seat.row}</span>
|
||||||
|
, Место:{" "}
|
||||||
|
<span className="text-white font-medium">{seat.number}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* CTA — Link обеспечивает prefetch и семантику навигации */}
|
||||||
|
<div className="px-3 pb-3">
|
||||||
|
<Link
|
||||||
|
href={`/tickets/${ticket.id}`}
|
||||||
|
className="block w-full bg-[#E32636] hover:bg-[#C41E2A] active:scale-95 transition-all text-white text-[15px] font-semibold py-3 rounded-xl text-center"
|
||||||
|
>
|
||||||
|
Открыть билет
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 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<TicketResponse[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [activeTab, setActiveTab] = useState<Tab>("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 (
|
||||||
|
<div className="flex justify-center min-h-screen bg-[#121212]">
|
||||||
|
<div className="relative w-full max-w-[390px] flex flex-col bg-[#121212]">
|
||||||
|
|
||||||
|
{/* ── Header ── */}
|
||||||
|
<div className="flex items-center justify-between px-5 pt-12 pb-4">
|
||||||
|
<h1 className="text-[32px] font-bold text-white leading-tight">
|
||||||
|
Мои билеты
|
||||||
|
</h1>
|
||||||
|
<button
|
||||||
|
aria-label="Фильтры"
|
||||||
|
className="w-9 h-9 flex items-center justify-center rounded-full bg-[#1C1C1E] text-white"
|
||||||
|
>
|
||||||
|
<SlidersHorizontal size={17} strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Tabs ── */}
|
||||||
|
<div className="mx-5 mb-4 flex bg-[#1C1C1E] rounded-full p-1">
|
||||||
|
{(["active", "history"] as Tab[]).map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab}
|
||||||
|
onClick={() => setActiveTab(tab)}
|
||||||
|
className={`flex-1 py-2 text-[14px] font-semibold rounded-full transition-colors ${
|
||||||
|
activeTab === tab
|
||||||
|
? "bg-white text-[#121212]"
|
||||||
|
: "text-[#8E8E93]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab === "active" ? "Активные" : "История"}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Content ── */}
|
||||||
|
<div className="flex-1 overflow-y-auto px-4 pb-24 space-y-3">
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<SkeletonCard />
|
||||||
|
<SkeletonCard />
|
||||||
|
<SkeletonCard />
|
||||||
|
</>
|
||||||
|
) : error ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-16 gap-3">
|
||||||
|
<p className="text-[#8E8E93] text-center text-[14px]">{error}</p>
|
||||||
|
<button
|
||||||
|
onClick={() => { setLoading(true); setError(null); }}
|
||||||
|
className="text-[#E32636] text-[14px] font-semibold"
|
||||||
|
>
|
||||||
|
Повторить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : displayed.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-16 gap-3">
|
||||||
|
<Ticket size={44} className="text-[#2C2C2E]" />
|
||||||
|
<p className="text-[#8E8E93] text-[14px] text-center">
|
||||||
|
{activeTab === "active" ? "Нет активных билетов" : "История пуста"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
displayed.map((ticket) => (
|
||||||
|
<TicketCard key={ticket.id} ticket={ticket} />
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Bottom navigation ── */}
|
||||||
|
<nav className="fixed bottom-0 left-1/2 -translate-x-1/2 w-full max-w-[390px] bg-[#1C1C1E] border-t border-[#2C2C2E]">
|
||||||
|
<div className="flex">
|
||||||
|
{NAV_ITEMS.map(({ label, icon: Icon, href }) => {
|
||||||
|
const isActive = href === "/tickets";
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={label}
|
||||||
|
href={href}
|
||||||
|
className={`flex-1 flex flex-col items-center gap-1 py-3 text-[10px] font-medium transition-colors ${
|
||||||
|
isActive ? "text-white" : "text-[#8E8E93]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon size={22} strokeWidth={isActive ? 2.5 : 1.8} />
|
||||||
|
{label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user