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": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -348,9 +345,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -367,9 +361,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -386,9 +377,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -986,9 +974,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1003,9 +988,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1020,9 +1002,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1037,9 +1016,6 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1054,9 +1030,6 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1071,9 +1044,6 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1088,9 +1058,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1105,9 +1072,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
||||
@@ -14,6 +14,29 @@ apiClient.interceptors.request.use((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) ────────────────────────────────────
|
||||
|
||||
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";
|
||||
|
||||
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 { useAuthStore } from "@/store/authStore";
|
||||
|
||||
const MOCK_EVENTS: EventItem[] = [
|
||||
{
|
||||
@@ -43,25 +45,29 @@ const MOCK_EVENTS: EventItem[] = [
|
||||
const FILTERS = ["Все", "Ближайшие", "Популярные", "Скоро"] as const;
|
||||
type Filter = (typeof FILTERS)[number];
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ label: "События", icon: CalendarDays },
|
||||
{ label: "Мои билеты", icon: Ticket },
|
||||
{ label: "Профиль", icon: User },
|
||||
] as const;
|
||||
|
||||
export default function HomePage() {
|
||||
const token = useAuthStore((s) => s.token);
|
||||
const [activeFilter, setActiveFilter] = useState<Filter>("Все");
|
||||
const [activeNav, setActiveNav] = useState(0);
|
||||
|
||||
return (
|
||||
<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]">
|
||||
|
||||
{/* ── Header ── */}
|
||||
<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>
|
||||
<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
|
||||
aria-label="Поиск"
|
||||
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} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Filter tabs ── */}
|
||||
<div className="flex gap-2 px-5 pb-4 overflow-x-auto scrollbar-hide">
|
||||
@@ -97,23 +104,32 @@ export default function HomePage() {
|
||||
{/* ── 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 }, idx) => (
|
||||
<button
|
||||
key={label}
|
||||
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]"
|
||||
}`}
|
||||
<Link
|
||||
href="/"
|
||||
className="flex-1 flex flex-col items-center gap-1 py-3 text-[10px] font-medium text-white"
|
||||
>
|
||||
<Icon
|
||||
size={22}
|
||||
strokeWidth={activeNav === idx ? 2.5 : 1.8}
|
||||
/>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
<CalendarDays size={22} strokeWidth={2.5} />
|
||||
События
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
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>
|
||||
</nav>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -9,6 +9,8 @@ interface AuthUser {
|
||||
interface AuthState {
|
||||
token: string | null;
|
||||
user: AuthUser | null;
|
||||
/** Persist token only (user data not available from login response). */
|
||||
setToken: (token: string) => void;
|
||||
setAuth: (token: string, user: AuthUser) => void;
|
||||
clearAuth: () => void;
|
||||
}
|
||||
@@ -18,6 +20,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
(set) => ({
|
||||
token: null,
|
||||
user: null,
|
||||
setToken: (token) => set({ token }),
|
||||
setAuth: (token, user) => set({ token, user }),
|
||||
clearAuth: () => set({ token: null, user: null }),
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user