Update project 9 FRONT TICKET QR

This commit is contained in:
2026-03-06 15:13:41 +00:00
parent 09609e1d4b
commit 1d709b1dd0
6 changed files with 547 additions and 1 deletions

View File

@@ -21,3 +21,8 @@
- `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("Функция в разработке")`.

View File

@@ -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",

View File

@@ -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"

View File

@@ -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 {

View 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>
);
}

View 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>
);
}