Update project 10 FRONT TICKET QR 2

This commit is contained in:
2026-03-06 15:41:29 +00:00
parent 1d709b1dd0
commit d19660b50c
5 changed files with 266 additions and 66 deletions

View File

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

View File

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

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

View File

@@ -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,31 +45,36 @@ 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>
<button <div className="flex items-center gap-2">
aria-label="Поиск" {!token && (
className="p-2 rounded-full text-white hover:bg-[#2C2C2E] transition-colors" <Link
> href="/login"
<Search size={24} strokeWidth={2} /> 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"
</button> >
<LogIn size={13} strokeWidth={2.5} />
Войти
</Link>
)}
<button
aria-label="Поиск"
className="p-2 rounded-full text-white hover:bg-[#2C2C2E] transition-colors"
>
<Search size={24} strokeWidth={2} />
</button>
</div>
</div> </div>
{/* ── Filter tabs ── */} {/* ── Filter tabs ── */}
@@ -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 ${ <CalendarDays size={22} strokeWidth={2.5} />
activeNav === idx ? "text-white" : "text-[#8E8E93]" События
}`} </Link>
>
<Icon <Link
size={22} href={token ? "/tickets" : "/login"}
strokeWidth={activeNav === idx ? 2.5 : 1.8} className="flex-1 flex flex-col items-center gap-1 py-3 text-[10px] font-medium text-[#8E8E93]"
/> >
{label} <Ticket size={22} strokeWidth={1.8} />
</button> Мои билеты
))} </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>
); );

View File

@@ -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 }),
}), }),