Update project 9 FRONT TICKET QR
This commit is contained in:
@@ -20,4 +20,9 @@
|
||||
- `src/app/` — Роутинг (Pages, Layouts).
|
||||
- `src/components/` — Переиспользуемые UI компоненты.
|
||||
- `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",
|
||||
"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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<TicketResponse[]> {
|
||||
const response = await apiClient.get<TicketResponse[]>("/tickets/me");
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// ─── Seat locking ─────────────────────────────────────────────────────────────
|
||||
|
||||
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