Update project 4 frontend start

This commit is contained in:
2026-03-06 11:08:45 +00:00
parent 50221c57e1
commit 0e1ee7066f
21 changed files with 6667 additions and 1 deletions

View File

@@ -0,0 +1,3 @@
{
"extends": ["next/core-web-vitals", "next/typescript"]
}

36
frontend-client/.gitignore vendored Normal file
View File

@@ -0,0 +1,36 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

View File

@@ -0,0 +1,7 @@
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 3000
CMD ["npm", "run", "dev"]

36
frontend-client/README.md Normal file
View File

@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

View File

@@ -0,0 +1,13 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
remotePatterns: [
{
protocol: "https",
hostname: "images.unsplash.com",
},
],
},
};
export default nextConfig;

6167
frontend-client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,29 @@
{
"name": "frontend-client",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"axios": "^1.13.6",
"lucide-react": "^0.577.0",
"next": "14.2.35",
"react": "^18",
"react-dom": "^18",
"zustand": "^5.0.11"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"eslint": "^8",
"eslint-config-next": "14.2.35",
"postcss": "^8",
"tailwindcss": "^3.4.1",
"typescript": "^5"
}
}

View File

@@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
},
};
export default config;

View File

@@ -0,0 +1,18 @@
import axios from "axios";
import { useAuthStore } from "@/store/authStore";
const apiClient = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8081/api",
headers: { "Content-Type": "application/json" },
});
apiClient.interceptors.request.use((config) => {
// getState() — безопасен вне React-дерева (server actions, route handlers тоже работают)
const token = useAuthStore.getState().token;
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
export default apiClient;

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,13 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--bg: #121212;
}
body {
background-color: #121212;
color: #ffffff;
-webkit-font-smoothing: antialiased;
}

View File

@@ -0,0 +1,24 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
const inter = Inter({ subsets: ["latin", "cyrillic"] });
export const metadata: Metadata = {
title: "Fight Tickets",
description: "Бронирование билетов на спортивные события",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="ru">
<body className={`${inter.className} bg-[#121212] text-white`}>
{children}
</body>
</html>
);
}

View File

@@ -0,0 +1,120 @@
"use client";
import { useState } from "react";
import { Search, CalendarDays, Ticket, User } from "lucide-react";
import EventCard, { type EventItem } from "@/components/EventCard";
const MOCK_EVENTS: EventItem[] = [
{
id: 1,
title: "Чемпионат по ММА",
date: "15 Апреля",
time: "19:00",
venue: "ВТБ Арена",
city: "Москва",
priceFrom: 2500,
imageSrc:
"https://images.unsplash.com/photo-1549719386-74dfcbf7dbed?w=400&q=80",
},
{
id: 2,
title: 'Турнир по Боксу: "Удар года"',
date: "",
time: "",
venue: "ВТБ Арена",
city: "Москва",
priceFrom: 2500,
imageSrc:
"https://images.unsplash.com/photo-1517649763962-0c623066013b?w=400&q=80",
},
{
id: 3,
title: "Турнир по Бои",
date: "15 Апреля",
time: "19:00",
venue: "ВТБ Арена",
city: "Москва",
priceFrom: 3000,
imageSrc:
"https://images.unsplash.com/photo-1484810934922-571fd7d1b41d?w=400&q=80",
},
];
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 [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>
<button
aria-label="Поиск"
className="p-2 rounded-full text-white hover:bg-[#2C2C2E] transition-colors"
>
<Search size={24} strokeWidth={2} />
</button>
</div>
{/* ── Filter tabs ── */}
<div className="flex gap-2 px-5 pb-4 overflow-x-auto scrollbar-hide">
{FILTERS.map((f) => (
<button
key={f}
onClick={() => setActiveFilter(f)}
className={`flex-shrink-0 px-4 py-2 rounded-full text-sm font-medium transition-colors ${
activeFilter === f
? "bg-white text-[#121212]"
: "bg-[#2C2C2E] text-[#8E8E93] hover:bg-[#3A3A3C]"
}`}
>
{f}
</button>
))}
</div>
{/* ── Event list ── */}
<div className="flex flex-col gap-3 px-4 flex-1 pb-24">
{MOCK_EVENTS.map((event) => (
<EventCard key={event.id} event={event} />
))}
</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 }, 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]"
}`}
>
<Icon
size={22}
strokeWidth={activeNav === idx ? 2.5 : 1.8}
/>
{label}
</button>
))}
</div>
</nav>
</div>
</div>
);
}

View File

@@ -0,0 +1,71 @@
"use client";
import Image from "next/image";
import { MapPin, Calendar } from "lucide-react";
export interface EventItem {
id: number;
title: string;
date: string;
time: string;
venue: string;
city: string;
priceFrom: number;
imageSrc: string;
}
interface EventCardProps {
event: EventItem;
}
export default function EventCard({ event }: EventCardProps) {
return (
<div className="flex gap-4 bg-[#1C1C1E] rounded-[16px] overflow-hidden p-3">
{/* Poster thumbnail */}
<div className="relative w-[110px] h-[110px] flex-shrink-0 rounded-xl overflow-hidden">
<Image
src={event.imageSrc}
alt={event.title}
fill
className="object-cover"
sizes="110px"
/>
</div>
{/* Info */}
<div className="flex flex-col justify-between flex-1 min-w-0 py-0.5">
<p className="font-bold text-[15px] leading-tight text-white line-clamp-2">
{event.title}
</p>
<div className="flex flex-col gap-1 mt-1">
{event.date && (
<div className="flex items-center gap-1.5 text-[#8E8E93] text-xs">
<Calendar size={12} strokeWidth={2} />
<span>
{event.date}, {event.time}
</span>
</div>
)}
<div className="flex items-center gap-1.5 text-[#8E8E93] text-xs">
<MapPin size={12} strokeWidth={2} />
<span className="truncate">
{event.venue}
</span>
</div>
<span className="text-[#8E8E93] text-xs">{event.city}</span>
</div>
{/* Price + CTA */}
<div className="flex items-center justify-between mt-2">
<span className="text-white text-sm font-medium">
от {event.priceFrom.toLocaleString("ru-RU")}
</span>
<button className="bg-accent hover:bg-accent-hover transition-colors text-white text-xs font-semibold px-4 py-2 rounded-full whitespace-nowrap">
Выбрать билет
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,26 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
interface AuthUser {
id: number;
email: string;
}
interface AuthState {
token: string | null;
user: AuthUser | null;
setAuth: (token: string, user: AuthUser) => void;
clearAuth: () => void;
}
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
token: null,
user: null,
setAuth: (token, user) => set({ token, user }),
clearAuth: () => set({ token: null, user: null }),
}),
{ name: "auth-storage" }
)
);

View File

@@ -0,0 +1,31 @@
import { create } from "zustand";
interface CartSeat {
seatId: number;
sector: string;
row: number;
number: number;
price: number;
}
interface CartState {
seats: CartSeat[];
addSeat: (seat: CartSeat) => void;
removeSeat: (seatId: number) => void;
clearCart: () => void;
totalPrice: () => number;
}
export const useCartStore = create<CartState>()((set, get) => ({
seats: [],
addSeat: (seat) =>
set((state) => ({
seats: state.seats.some((s) => s.seatId === seat.seatId)
? state.seats
: [...state.seats, seat],
})),
removeSeat: (seatId) =>
set((state) => ({ seats: state.seats.filter((s) => s.seatId !== seatId) })),
clearCart: () => set({ seats: [] }),
totalPrice: () => get().seats.reduce((sum, s) => sum + s.price, 0),
}));

View File

@@ -0,0 +1,25 @@
import type { Config } from "tailwindcss";
const config: Config = {
content: [
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
colors: {
surface: "#1C1C1E",
accent: "#E32636",
"accent-hover": "#C41E2A",
muted: "#8E8E93",
border: "#2C2C2E",
},
borderRadius: {
card: "16px",
},
},
},
plugins: [],
};
export default config;

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

View File

@@ -36,6 +36,19 @@ services:
networks:
- ticket-network
frontend:
build:
context: ../frontend-client
dockerfile: Dockerfile
container_name: frontend
ports:
- "3000:3000" # Пробиваем дыру напрямую для дебага
volumes:
- ../frontend-client:/app
- /app/node_modules # Изолируем зависимости контейнера от хоста
networks:
- ticket-network
traefik:
image: traefik:v3.6
container_name: traefik