Update project 10 FRONT TICKET QR 2
This commit is contained in:
36
frontend-client/package-lock.json
generated
36
frontend-client/package-lock.json
generated
@@ -329,9 +329,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -348,9 +345,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -367,9 +361,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -386,9 +377,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -986,9 +974,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1003,9 +988,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1020,9 +1002,6 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1037,9 +1016,6 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1054,9 +1030,6 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1071,9 +1044,6 @@
|
|||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1088,9 +1058,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1105,9 +1072,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|||||||
@@ -14,6 +14,29 @@ apiClient.interceptors.request.use((config) => {
|
|||||||
return config;
|
return config;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── Auth ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface LoginResponse {
|
||||||
|
access_token: string;
|
||||||
|
token_type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /auth/login
|
||||||
|
* Returns a Bearer access token on success.
|
||||||
|
* Throws AxiosError 401 for invalid credentials, 422 for validation errors.
|
||||||
|
*/
|
||||||
|
export async function loginApi(
|
||||||
|
email: string,
|
||||||
|
password: string
|
||||||
|
): Promise<{ access_token: string }> {
|
||||||
|
const response = await apiClient.post<LoginResponse>("/auth/login", {
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
return { access_token: response.data.access_token };
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Domain types (mirror backend schemas) ────────────────────────────────────
|
// ─── Domain types (mirror backend schemas) ────────────────────────────────────
|
||||||
|
|
||||||
export interface TournamentInfo {
|
export interface TournamentInfo {
|
||||||
|
|||||||
194
frontend-client/src/app/login/page.tsx
Normal file
194
frontend-client/src/app/login/page.tsx
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, type FormEvent } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Mail, Lock, Loader2, Check } from "lucide-react";
|
||||||
|
import type { AxiosError } from "axios";
|
||||||
|
import { loginApi } from "@/api/client";
|
||||||
|
import { useAuthStore } from "@/store/authStore";
|
||||||
|
|
||||||
|
// ─── Toast helper ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type ToastState = { message: string; variant: "success" | "error" } | null;
|
||||||
|
|
||||||
|
function Toast({ toast }: { toast: ToastState }) {
|
||||||
|
if (!toast) return null;
|
||||||
|
const isSuccess = toast.variant === "success";
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`fixed top-12 left-1/2 -translate-x-1/2 z-50 flex items-center gap-2 px-5 py-2.5 rounded-full shadow-xl text-white text-[13px] font-medium whitespace-nowrap transition-all ${
|
||||||
|
isSuccess ? "bg-[#16A34A]" : "bg-[#E32636]"
|
||||||
|
}`}
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
{isSuccess && <Check size={14} strokeWidth={2.5} />}
|
||||||
|
{toast.message}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Input field ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function Field({
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
type,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
icon: Icon,
|
||||||
|
autoComplete,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
type: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
placeholder: string;
|
||||||
|
icon: React.ElementType;
|
||||||
|
autoComplete?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<label htmlFor={id} className="text-[13px] font-medium text-[#8E8E93]">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-3 bg-[#1C1C1E] border border-[#2C2C2E] focus-within:border-[#E32636] rounded-xl px-4 py-3.5 transition-colors">
|
||||||
|
<Icon size={16} className="text-[#8E8E93] flex-shrink-0" strokeWidth={2} />
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
type={type}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
autoComplete={autoComplete}
|
||||||
|
required
|
||||||
|
className="flex-1 bg-transparent text-[15px] text-white placeholder-[#4B5563] outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Page ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const setToken = useAuthStore((s) => s.setToken);
|
||||||
|
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [toast, setToast] = useState<ToastState>(null);
|
||||||
|
|
||||||
|
function showToast(message: string, variant: "success" | "error") {
|
||||||
|
setToast({ message, variant });
|
||||||
|
setTimeout(() => setToast(null), 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (loading) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const { access_token } = await loginApi(email, password);
|
||||||
|
setToken(access_token);
|
||||||
|
showToast("Успешно", "success");
|
||||||
|
// Short delay so the user sees the success toast before navigation
|
||||||
|
setTimeout(() => router.push("/tickets"), 900);
|
||||||
|
} catch (err) {
|
||||||
|
const axiosErr = err as AxiosError;
|
||||||
|
const status = axiosErr.response?.status;
|
||||||
|
if (status === 401 || status === 422) {
|
||||||
|
showToast("Ошибка авторизации", "error");
|
||||||
|
} else {
|
||||||
|
showToast("Ошибка сети. Попробуйте снова.", "error");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center min-h-screen bg-[#121212]">
|
||||||
|
<div className="w-full max-w-[390px] flex flex-col px-5 pt-20 pb-10">
|
||||||
|
|
||||||
|
<Toast toast={toast} />
|
||||||
|
|
||||||
|
{/* ── Brand ── */}
|
||||||
|
<div className="mb-10">
|
||||||
|
<div className="w-12 h-12 bg-[#E32636] rounded-2xl flex items-center justify-center mb-5">
|
||||||
|
<span className="text-white text-[22px] font-extrabold leading-none">F</span>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-[30px] font-bold text-white leading-tight">
|
||||||
|
Вход в аккаунт
|
||||||
|
</h1>
|
||||||
|
<p className="text-[14px] text-[#8E8E93] mt-1">
|
||||||
|
Войдите, чтобы управлять билетами
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Form ── */}
|
||||||
|
<form onSubmit={handleSubmit} noValidate className="flex flex-col gap-4">
|
||||||
|
<Field
|
||||||
|
id="email"
|
||||||
|
label="Email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={setEmail}
|
||||||
|
placeholder="you@example.com"
|
||||||
|
icon={Mail}
|
||||||
|
autoComplete="email"
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
id="password"
|
||||||
|
label="Пароль"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={setPassword}
|
||||||
|
placeholder="Минимум 8 символов"
|
||||||
|
icon={Lock}
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="mt-2 w-full flex items-center justify-center gap-2 bg-[#E32636] hover:bg-[#C41E2A] disabled:opacity-60 disabled:cursor-not-allowed active:scale-95 transition-all text-white text-[16px] font-bold py-4 rounded-2xl"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 size={18} className="animate-spin" />
|
||||||
|
Вход…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Войти"
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* ── Footer links ── */}
|
||||||
|
<div className="mt-6 flex flex-col items-center gap-3">
|
||||||
|
<p className="text-[14px] text-[#8E8E93]">
|
||||||
|
Нет аккаунта?{" "}
|
||||||
|
<Link
|
||||||
|
href="/register"
|
||||||
|
className="text-white font-semibold hover:text-[#E32636] transition-colors"
|
||||||
|
>
|
||||||
|
Создать
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="text-[13px] text-[#8E8E93] hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
← Вернуться к событиям
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Search, CalendarDays, Ticket, User } from "lucide-react";
|
import Link from "next/link";
|
||||||
|
import { Search, CalendarDays, Ticket, User, LogIn } from "lucide-react";
|
||||||
import EventCard, { type EventItem } from "@/components/EventCard";
|
import EventCard, { type EventItem } from "@/components/EventCard";
|
||||||
|
import { useAuthStore } from "@/store/authStore";
|
||||||
|
|
||||||
const MOCK_EVENTS: EventItem[] = [
|
const MOCK_EVENTS: EventItem[] = [
|
||||||
{
|
{
|
||||||
@@ -43,25 +45,29 @@ const MOCK_EVENTS: EventItem[] = [
|
|||||||
const FILTERS = ["Все", "Ближайшие", "Популярные", "Скоро"] as const;
|
const FILTERS = ["Все", "Ближайшие", "Популярные", "Скоро"] as const;
|
||||||
type Filter = (typeof FILTERS)[number];
|
type Filter = (typeof FILTERS)[number];
|
||||||
|
|
||||||
const NAV_ITEMS = [
|
|
||||||
{ label: "События", icon: CalendarDays },
|
|
||||||
{ label: "Мои билеты", icon: Ticket },
|
|
||||||
{ label: "Профиль", icon: User },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
|
const token = useAuthStore((s) => s.token);
|
||||||
const [activeFilter, setActiveFilter] = useState<Filter>("Все");
|
const [activeFilter, setActiveFilter] = useState<Filter>("Все");
|
||||||
const [activeNav, setActiveNav] = useState(0);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center min-h-screen bg-[#121212]">
|
<div className="flex justify-center min-h-screen bg-[#121212]">
|
||||||
{/* Phone-width wrapper */}
|
|
||||||
<div className="relative w-full max-w-[390px] flex flex-col min-h-screen bg-[#121212]">
|
<div className="relative w-full max-w-[390px] flex flex-col min-h-screen bg-[#121212]">
|
||||||
|
|
||||||
{/* ── Header ── */}
|
{/* ── Header ── */}
|
||||||
<div className="flex items-center justify-between px-5 pt-12 pb-4">
|
<div className="flex items-center justify-between px-5 pt-12 pb-4">
|
||||||
<h1 className="text-[32px] font-bold leading-tight tracking-tight text-white">
|
<h1 className="text-[32px] font-bold leading-tight tracking-tight text-white">
|
||||||
События
|
События
|
||||||
</h1>
|
</h1>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{!token && (
|
||||||
|
<Link
|
||||||
|
href="/login"
|
||||||
|
className="flex items-center gap-1.5 bg-[#E32636] hover:bg-[#C41E2A] text-white text-[12px] font-semibold px-3 py-1.5 rounded-full transition-colors"
|
||||||
|
>
|
||||||
|
<LogIn size={13} strokeWidth={2.5} />
|
||||||
|
Войти
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
aria-label="Поиск"
|
aria-label="Поиск"
|
||||||
className="p-2 rounded-full text-white hover:bg-[#2C2C2E] transition-colors"
|
className="p-2 rounded-full text-white hover:bg-[#2C2C2E] transition-colors"
|
||||||
@@ -69,6 +75,7 @@ export default function HomePage() {
|
|||||||
<Search size={24} strokeWidth={2} />
|
<Search size={24} strokeWidth={2} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* ── Filter tabs ── */}
|
{/* ── Filter tabs ── */}
|
||||||
<div className="flex gap-2 px-5 pb-4 overflow-x-auto scrollbar-hide">
|
<div className="flex gap-2 px-5 pb-4 overflow-x-auto scrollbar-hide">
|
||||||
@@ -97,23 +104,32 @@ export default function HomePage() {
|
|||||||
{/* ── Bottom navigation ── */}
|
{/* ── 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]">
|
<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">
|
<div className="flex">
|
||||||
{NAV_ITEMS.map(({ label, icon: Icon }, idx) => (
|
<Link
|
||||||
<button
|
href="/"
|
||||||
key={label}
|
className="flex-1 flex flex-col items-center gap-1 py-3 text-[10px] font-medium text-white"
|
||||||
onClick={() => setActiveNav(idx)}
|
|
||||||
className={`flex-1 flex flex-col items-center gap-1 py-3 text-[10px] font-medium transition-colors ${
|
|
||||||
activeNav === idx ? "text-white" : "text-[#8E8E93]"
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<Icon
|
<CalendarDays size={22} strokeWidth={2.5} />
|
||||||
size={22}
|
События
|
||||||
strokeWidth={activeNav === idx ? 2.5 : 1.8}
|
</Link>
|
||||||
/>
|
|
||||||
{label}
|
<Link
|
||||||
</button>
|
href={token ? "/tickets" : "/login"}
|
||||||
))}
|
className="flex-1 flex flex-col items-center gap-1 py-3 text-[10px] font-medium text-[#8E8E93]"
|
||||||
|
>
|
||||||
|
<Ticket size={22} strokeWidth={1.8} />
|
||||||
|
Мои билеты
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href={token ? "/profile" : "/login"}
|
||||||
|
className="flex-1 flex flex-col items-center gap-1 py-3 text-[10px] font-medium text-[#8E8E93]"
|
||||||
|
>
|
||||||
|
<User size={22} strokeWidth={1.8} />
|
||||||
|
Профиль
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ interface AuthUser {
|
|||||||
interface AuthState {
|
interface AuthState {
|
||||||
token: string | null;
|
token: string | null;
|
||||||
user: AuthUser | null;
|
user: AuthUser | null;
|
||||||
|
/** Persist token only (user data not available from login response). */
|
||||||
|
setToken: (token: string) => void;
|
||||||
setAuth: (token: string, user: AuthUser) => void;
|
setAuth: (token: string, user: AuthUser) => void;
|
||||||
clearAuth: () => void;
|
clearAuth: () => void;
|
||||||
}
|
}
|
||||||
@@ -18,6 +20,7 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
(set) => ({
|
(set) => ({
|
||||||
token: null,
|
token: null,
|
||||||
user: null,
|
user: null,
|
||||||
|
setToken: (token) => set({ token }),
|
||||||
setAuth: (token, user) => set({ token, user }),
|
setAuth: (token, user) => set({ token, user }),
|
||||||
clearAuth: () => set({ token: null, user: null }),
|
clearAuth: () => set({ token: null, user: null }),
|
||||||
}),
|
}),
|
||||||
|
|||||||
Reference in New Issue
Block a user