Мы переходим на территорию визуала и реактивности! Бэкенд — это мотор, а Next.js — это крутой кузов, интерьер и руль. Поехали!
Чтобы проект был современным, быстрым и удобным для разработки, мы возьмем Next.js 14+ (App Router) + TypeScript + TailwindCSS. TypeScript сэкономит нам кучу нервов при работе с API (он подскажет, какие поля прилетели от Laravel), а Tailwind позволит верстать карточки со скоростью мысли.
ЭТАП 2: Веб-оболочка и SEO
Шаг 2.1: Создаем проект Next.js
Важно: Фронтенд — это отдельное приложение. Оно не лежит внутри папки Laravel. Это будет папка client с файлами проекта. Папки client и server должны лежать на одном уровне. Открой новое окно терминала, и выполни команду:
npx create-next-app@latest client
Установщик задаст вопросы. Отвечай строго так:
- Would you like to use TypeScript? -> Yes
- Would you like to use ESLint? -> Yes
- Would you like to use Tailwind CSS? -> Yes
- Would you like to use
src/directory? -> Yes - Would you like to use App Router? (recommended) -> Yes
- Would you like to customize the default import alias? -> No (оставь
@/*)
Эта команда скачаетNext.js и все зависимости. Это займет пару минут.
Шаг 2.2: Структура и Мост к Laravel (API Client)
Когда установка завершится, зайди в папку client в твоем редакторе кода (VS Code). Нам нужно создать “мост”, через который React будет общаться с нашим Laravel API. Для этого мы будем использовать библиотеку axios (она удобнее, чем встроенный fetch, так как позволяет автоматически цеплять токены) и js-cookie для корректной обработки заголовков и кук в запросах.
Установи Axios в папке фронтенда (client):
npm install axios js-cookie
npm install --save-dev @types/js-cookie
Создай структуру папок для API.
В корне создай папку lib, а в ней файл api.ts (lib/api.ts). Это будет наш единственный экземпляр Axios для всего приложения.
Напиши в lib/api.ts:
import axios, { InternalAxiosRequestConfig } from "axios"; // Добавили InternalAxiosRequestConfig
import Cookies from "js-cookie";
// Базовый клиент (для CSRF)
export const api = axios.create({
baseURL: "http://localhost:8000",
withCredentials: true,
headers: {
"Accept": "application/json",
"X-Requested-With": "XMLHttpRequest",
},
});
// Клиент для API роутов
export const apiClient = axios.create({
baseURL: "http://localhost:8000/api",
withCredentials: true,
headers: {
"Content-Type": "application/json",
"Accept": "application/json",
"X-Requested-With": "XMLHttpRequest",
},
});
// Типизируем config, чтобы TS не ругался на 'any'
const xsrfInterceptor = (config: InternalAxiosRequestConfig) => {
// Проверяем, что мы в браузере (для Next.js SSR безопасности)
if (typeof window !== 'undefined') {
const xsrfToken = Cookies.get('XSRF-TOKEN');
if (xsrfToken) {
// Декодируем и вставляем в заголовок!
config.headers['X-XSRF-TOKEN'] = decodeURIComponent(xsrfToken);
}
}
return config;
};
api.interceptors.request.use(xsrfInterceptor);
apiClient.interceptors.request.use(xsrfInterceptor);
// Функция для получения CSRF-токена
export const getCsrfToken = async () => {
await api.get("/sanctum/csrf-cookie");
};
Ставим Shadc/ui и создаем компоненты
shadcn/ui — это не просто библиотека, это генератор компонентов. Он копирует исходный код прямо в твой проект, и ты можешь их менять как хочешь.
- В папке
dating-webвыполни инициализацию shadcn:
npx shadcn@latest init
Ответь на вопросы:
- Which style would you like to use? -> Default
- Which color would you like to use as base color? -> Slate (или Neutral)
- Do you want to use CSS variables for colors? -> yes
- Добавим нужные нам компоненты (Кнопки, Инпуты, Карточки, Формы):
npx shadcn@latest add button input label card
Установим библиотеки для удобной работы с формами и валидацией:
npm install react-hook-form @hookform/resolvers zod
(Zod – крутая библиотека для описания типов и валидации одновременно для фронтенда и бэкенда).
Публикуем на сервере файл CORS
В терминале Laravel выполни команду:
php artisan config:publish cors
Эта команда создаст файл config/cors.php, который мы теперь можем редактировать.
Открой только что созданный файл config/cors.php. Замени его содержимое на это (я убрал привязку к env(), чтобы исключить любые кэши и ошибки):
<?php
return [
'paths' => ['api/*', 'sanctum/csrf-cookie'],
'allowed_methods' => ['*'],
// ЖЕСТКО ПРОПИСЫВАЕМ НАШ ФРОНТЕНД! Никаких env()
'allowed_origins' => ['http://localhost:3000'],
'allowed_origins_patterns' => [],
'allowed_headers' => ['*'],
'exposed_headers' => [],
'max_age' => 0,
// КРИТИЧЕСКИ ВАЖНО!
'supports_credentials' => true,
];
обновялем на сервере bootsrap/app.php
<?php
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Request;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php',
commands: __DIR__.'/../routes/console.php',
channels: __DIR__.'/../routes/channels.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
$middleware->statefulApi();
})
->withExceptions(function (Exceptions $exceptions): void {
$exceptions->shouldRenderJsonWhen(
fn (Request $request) => $request->is('api/*'),
);
})->create();
добавили $middleware->statefulApi(); для обработки апи роутов как веб
Переписываем под csrf на сервере authController
<?php
namespace App\Http\Controllers;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\ValidationException;
class AuthController extends Controller
{
// РЕГИСТРАЦИЯ
public function register(Request $request)
{
$request->validate([
'email' => 'required|email|unique:users',
'password' => 'required|min:8',
'name' => 'required|string|max:255', // Имя теперь обязательно при регистрации
]);
$user = User::create([
'email' => $request->email,
'password' => Hash::make($request->password),
]);
// Обновляем профиль с именем
$user->profile->update(['name' => $request->name]);
// Логиним юзера автоматически (создает сессию и куку)
Auth::login($user);
return response()->json([
'user' => $user->load('profile', 'photos')
], 201);
}
// АВТОРИЗАЦИЯ (Логин)
public function login(Request $request)
{
$request->validate([
'email' => 'required|email',
'password' => 'required',
]);
$user = User::where('email', $request->email)->first();
if (! $user || ! Hash::check($request->password, $user->password)) {
throw ValidationException::withMessages([
'email' => ['Неверный email или пароль.'],
]);
}
// Логиним через guard web (создает сессию и куку)
Auth::login($user);
return response()->json([
'user' => $user->load('profile', 'photos')
]);
}
// ВЫХОД (Логаут)
public function logout(Request $request)
{
Auth::guard('web')->logout(); // Разрушает сессию
// Аннулируем CSRF-токен (безопасность)
$request->session()->invalidate();
$request->session()->regenerateToken();
return response()->json([
'message' => 'Вы вышли из системы'
]);
}
}
Проверяем routes/api.php на сервере
<?php
use App\Http\Controllers\AuthController;
use App\Http\Controllers\ProfileController;
use App\Http\Controllers\LocationController;
use App\Http\Controllers\SwipeController;
use App\Http\Controllers\DiscoverController;
use App\Http\Controllers\ChatController;
use App\Http\Controllers\PhotoController;
use App\Http\Controllers\UserController;
use App\Http\Controllers\BlockController;
use App\Http\Controllers\ReportController;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
// Публичные
Route::post('/register', [AuthController::class, 'register']);
Route::post('/login', [AuthController::class, 'login']);
// Защищенные
Route::middleware('auth:sanctum')->group(function () {
Route::post('/logout', [AuthController::class, 'logout']);
Route::get('/user', function (Request $request) {
return $request->user()->load('profile', 'photos');
});
// Профиль и Геолокация
Route::put('/profile', [ProfileController::class, 'update']);
Route::post('/location', [LocationController::class, 'update']);
// Фото
Route::post('/photos', [PhotoController::class, 'upload']);
Route::delete('/photos/{id}', [PhotoController::class, 'destroy']);
// Лента и Свайпы
Route::get('/discover', [DiscoverController::class, 'index']);
Route::post('/swipe', [SwipeController::class, 'store']);
// Чат
Route::get('/chats', [ChatController::class, 'index']);
Route::get('/chats/{conversationId}/messages', [ChatController::class, 'messages']);
Route::post('/chats/{conversationId}/send', [ChatController::class, 'send']);
// Безопасность
Route::post('/block', [BlockController::class, 'store']);
Route::post('/report', [ReportController::class, 'store']);
// Удаление аккаунта
Route::delete('/account', [UserController::class, 'destroy']);
});
Переходим к самому кайфу — созданию UI
Брат, это огромный шаг. Твой React на порту 3000 только что успешно создал человека в PostgreSQL на порту 8000, Laravel вернул токен, и React его принял. Мост работает! Бэкенд и Фронтенд теперь единый организм!
Теперь мы переходим к самому кайфу — созданию UI (пользовательского интерфейса). Мы будем верстать карточки свайпов, как в Tinder!
Прежде чем делать красивые карточки, нам нужно создать Глобальное состояние (Context), чтобы приложение знало, что пользователь авторизован, и имело доступ к его профилю из любого компонента. Иначе при обновлении страницы мы “забудем”, что мы залогинены.
Давай сделаем это по-быстрому и красиво!
Создаем нормальную страницу логина и регестрации
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
import axios from "axios"; // ИМПОРТИРУЕМ ДЛЯ ТИПИЗАЦИИ ОШИБОК
// Добавь импорт хука вверху файла:
import { useAuth } from "@/contexts/AuthContext";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
// ИМПОРТИРУЕМ ОБА КЛИЕНТА
import { apiClient, getCsrfToken } from "@/lib/api";
// Схемы валидации
const loginSchema = z.object({
email: z.string().email("Введите корректный email"),
password: z.string().min(8, "Минимум 8 символов"),
});
const registerSchema = z.object({
name: z.string().min(2, "Имя должно быть не менее 2 символов"),
email: z.string().email("Введите корректный email"),
password: z.string().min(8, "Минимум 8 символов"),
});
type LoginFormValues = z.infer<typeof loginSchema>;
type RegisterFormValues = z.infer<typeof registerSchema>;
export default function AuthPage() {
const [isLogin, setIsLogin] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const router = useRouter();
const { fetchUser } = useAuth();
// Деструктуризируем хуки форм!
const {
register: registerLogin,
handleSubmit: handleLoginSubmit,
formState: { errors: loginErrors },
} = useForm<LoginFormValues>({
resolver: zodResolver(loginSchema),
defaultValues: { email: "", password: "" },
});
const {
register: registerReg,
handleSubmit: handleRegSubmit,
formState: { errors: regErrors },
} = useForm<RegisterFormValues>({
resolver: zodResolver(registerSchema),
defaultValues: { name: "", email: "", password: "" },
});
// Вспомогательная функция для безопасного извлечения сообщения об ошибке
const getErrorMessage = (err: unknown): string => {
if (axios.isAxiosError(err)) {
// Если ошибка от Axios, проверяем есть ли ответ от сервера
return err.response?.data?.message || err.message || "Сетевая ошибка";
}
if (err instanceof Error) {
// Если стандартная ошибка JS
return err.message;
}
// Если это вообще что-то непонятное
return "Произошла неизвестная ошибка";
};
const onLogin = async (data: LoginFormValues) => {
setIsLoading(true);
setError(null);
try {
await getCsrfToken();
// ИСПРАВЛЕНО: используем api вместо apiClient, так как /login в web.php (без префикса /api)
await apiClient.post("/login", data);
await fetchUser(); // ВАЖНО: Обновляем глобальный стейт!
router.push("/discover");
} catch (err: unknown) {
setError(getErrorMessage(err));
} finally {
setIsLoading(false);
}
};
const onRegister = async (data: RegisterFormValues) => {
setIsLoading(true);
setError(null);
try {
await getCsrfToken();
// ИСПРАВЛЕНО: используем api вместо apiClient, так как /register в web.php (без префикса /api)
await apiClient.post("/register", data);
await fetchUser(); // ВАЖНО: Обновляем глобальный стейт!
router.push("/discover");
} catch (err: unknown) {
setError(getErrorMessage(err));
} finally {
setIsLoading(false);
}
};
return (
<div className="min-h-screen bg-slate-950 flex items-center justify-center p-4">
<Card className="w-full max-w-md bg-slate-900 border-slate-800 text-white">
<CardHeader>
<CardTitle className="text-3xl font-bold text-center text-pink-500">
{isLogin ? "С возвращением!" : "Добро пожаловать!"}
</CardTitle>
<CardDescription className="text-center text-slate-400">
{isLogin
? "Войдите, чтобы найти свою пару"
: "Создайте аккаунт и начните искать"}
</CardDescription>
</CardHeader>
<CardContent>
{error && (
<div className="bg-red-900/50 border border-red-500 text-red-300 p-3 rounded mb-4 text-sm">
{error}
</div>
)}
{isLogin ? (
<form onSubmit={handleLoginSubmit(onLogin)} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="login-email">Email</Label>
<Input
id="login-email"
type="email"
autoComplete="email"
placeholder="dev@dating.com"
className="bg-slate-800 border-slate-700"
{...registerLogin("email")}
/>
{loginErrors.email && (
<p className="text-red-400 text-xs">{loginErrors.email.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="login-password">Пароль</Label>
<Input
id="login-password"
type="password"
autoComplete="current-password"
placeholder="••••••••"
className="bg-slate-800 border-slate-700"
{...registerLogin("password")}
/>
{loginErrors.password && (
<p className="text-red-400 text-xs">{loginErrors.password.message}</p>
)}
</div>
<Button type="submit" className="w-full bg-pink-500 hover:bg-pink-600" disabled={isLoading}>
{isLoading ? "Загрузка..." : "Войти"}
</Button>
</form>
) : (
<form onSubmit={handleRegSubmit(onRegister)} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="reg-name">Ваше имя</Label>
<Input
id="reg-name"
placeholder="Алексей"
className="bg-slate-800 border-slate-700"
{...registerReg("name")}
/>
{regErrors.name && (
<p className="text-red-400 text-xs">{regErrors.name.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="reg-email">Email</Label>
<Input
id="reg-email"
type="email"
autoComplete="email"
placeholder="dev@dating.com"
className="bg-slate-800 border-slate-700"
{...registerReg("email")}
/>
{regErrors.email && (
<p className="text-red-400 text-xs">{regErrors.email.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="reg-password">Пароль</Label>
<Input
id="reg-password"
type="password"
autoComplete="new-password"
placeholder="••••••••"
className="bg-slate-800 border-slate-700"
{...registerReg("password")}
/>
{regErrors.password && (
<p className="text-red-400 text-xs">{regErrors.password.message}</p>
)}
</div>
<Button type="submit" className="w-full bg-pink-500 hover:bg-pink-600" disabled={isLoading}>
{isLoading ? "Загрузка..." : "Зарегистрироваться"}
</Button>
</form>
)}
<div className="mt-6 text-center text-sm">
<span className="text-slate-400">
{isLogin ? "Нет аккаунта? " : "Уже есть аккаунт? "}
</span>
<button
type="button"
onClick={() => {
setIsLogin(!isLogin);
setError(null);
}}
className="text-pink-500 hover:underline font-semibold"
>
{isLogin ? "Создать" : "Войти"}
</button>
</div>
</CardContent>
</Card>
</div>
);
}
Шаг 2.5: Глобальное состояние авторизации (Auth Context)
- В корне проекта (где папки
appиlib) создай папкуcontexts. - Внутри
contextsсоздай файлAuthContext.tsx.
Вот код для contexts/AuthContext.tsx:
"use client";
import {
createContext,
useContext,
useState,
useEffect,
ReactNode,
} from "react";
import { apiClient } from "@/lib/api";
type User = {
id: number;
email: string;
profile: unknown;
};
type AuthContextType = {
user: User | null;
isLoading: boolean;
fetchUser: () => Promise<void>;
logout: () => Promise<void>;
};
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
// Функция для проверки, кто сейчас залогинен (через куки)
const fetchUser = async () => {
try {
const response = await apiClient.get("/user");
setUser(response.data);
} catch (error) {
setUser(null); // Куки нет или протухла
} finally {
setIsLoading(false);
}
};
// При первой загрузке приложения проверяем сессию
useEffect(() => {
fetchUser();
}, []);
const logout = async () => {
try {
await apiClient.post("/logout");
} catch (error) {
console.error("Ошибка выхода", error);
} finally {
setUser(null);
}
};
return (
<AuthContext.Provider value={{ user, isLoading, fetchUser, logout }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error("useAuth должен использоваться внутри AuthProvider");
}
return context;
}
Шаг 2.6: Оборачиваем приложение в AuthProvider
Открой app/layout.tsx (главная обертка всего сайта) и подключи наш контекст:
iimport type { Metadata } from "next";
import { Inter, Geist } from "next/font/google";
import "./globals.css";
import { AuthProvider } from "@/contexts/AuthContext";
import { cn } from "@/lib/utils";
const geist = Geist({subsets:['latin'],variable:'--font-sans'});
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "Дейтинг",
description: "Найди свою любовь",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="ru" className={cn("font-sans", geist.variable)}>
<body className={inter.className}>
<AuthProvider> {/* ОБОРОЧИВАЕМ */}
{children}
</AuthProvider>
</body>
</html>
);
}
Шаг 2.7: Создаем страницу Ленты (Discover)
Теперь сделаем страницу, где будут карточки! Создай папку app/discover и внутри неё файл page.tsx (app/discover/page.tsx).
Пока мы будем делать простой вывод первой анкеты из нашей умной ленты.
"use client";
import { useEffect, useState } from "react";
import { useAuth } from "@/contexts/AuthContext";
import { apiClient } from "@/lib/api";
import { useRouter } from "next/navigation"; // Для редиректа
type Profile = {
id: number;
name: string;
age: number;
gender: string;
city: string;
bio: string | null;
distance_km: number;
photos: { id: number; path: string; is_main: boolean }[];
};
export default function DiscoverPage() {
const { user, isLoading, logout } = useAuth();
const router = useRouter();
const [profiles, setProfiles] = useState<Profile[]>([]);
const [currentIndex, setCurrentIndex] = useState(0);
// Редирект на главную, если не авторизован
useEffect(() => {
if (!isLoading && !user) {
router.push("/");
}
}, [user, isLoading, router]);
// Функция для получения и отправки геолокации браузера
const sendLocation = async () => {
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(
async (position) => {
try {
await apiClient.post("/location", {
latitude: position.coords.latitude,
longitude: position.coords.longitude,
});
// После сохранения координат сразу грузим ленту
fetchProfiles();
} catch (error) {
console.error("Ошибка отправки геолокации", error);
}
},
(error) => {
console.error("Пользователь запретил доступ к геолокации", error);
// Тут можно показать модалку: "Без геолокации мы не можем искать людей"
},
);
}
};
const fetchProfiles = async () => {
try {
const response = await apiClient.get("/discover");
setProfiles(response.data.data);
} catch (error) {
console.error("Ошибка загрузки ленты", error);
}
};
// Загружаем ленту
useEffect(() => {
if (user) {
sendLocation();
}
}, [user]);
const handleSwipe = async (action: "like" | "dislike", swipedId: number) => {
try {
await apiClient.post("/swipe", { swiped_id: swipedId, action });
// Переключаем на следующую карточку
setCurrentIndex((prev) => prev + 1);
} catch (error) {
console.error("Ошибка свайпа", error);
}
};
if (isLoading)
return (
<div className="min-h-screen bg-gray-900 text-white flex items-center justify-center">
Загрузка...
</div>
);
if (!user) return null;
const currentProfile = profiles[currentIndex];
return (
<div className="min-h-screen bg-gray-900 text-white flex flex-col items-center justify-center p-4">
<h1 className="text-4xl font-bold text-pink-500 mb-4">Лента рекомендаций</h1>
<p className="text-slate-300 mb-8">Привет, { user.email}! Ты успешно авторизовался через куки!</p>
<div className="w-full max-w-md">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold text-pink-500">Знакомства</h1>
<button
onClick={logout}
className="text-sm text-gray-400 hover:text-white"
>
Выйти
</button>
</div>
{/* Карточка профиля */}
{currentProfile ? (
<div className="bg-gray-800 rounded-3xl overflow-hidden shadow-2xl relative h-[500px]">
{/* Фото-заглушка (позже заменим на реальное фото) */}
<div className="w-full h-full bg-gradient-to-t from-black/80 via-transparent to-transparent absolute bottom-0 z-10"></div>
<div className="w-full h-full bg-gray-700 flex items-center justify-center text-9xl">
{currentProfile.gender === "female" ? "👩" : "👨"}
</div>
{/* Информация на карточке */}
<div className="absolute bottom-0 left-0 right-0 p-6 z-20">
<h2 className="text-3xl font-bold">
{currentProfile.name}, {currentProfile.age}
</h2>
<p className="text-gray-300 mt-1">
📍 {currentProfile.distance_km} км от вас •{" "}
{currentProfile.city || "Не указан"}
</p>
{currentProfile.bio && (
<p className="text-gray-400 mt-2 text-sm line-clamp-2">
{currentProfile.bio}
</p>
)}
</div>
</div>
) : (
<div className="bg-gray-800 rounded-3xl h-[500px] flex items-center justify-center">
<p className="text-gray-400">Людей рядом больше нет... 😢</p>
</div>
)}
{/* Кнопки Свайпов */}
{currentProfile && (
<div className="flex justify-center gap-6 mt-6">
<button
onClick={() => handleSwipe("dislike", currentProfile.id)}
className="w-16 h-16 bg-gray-700 rounded-full flex items-center justify-center text-3xl hover:bg-red-500 transition shadow-lg"
>
✕
</button>
<button
onClick={() => handleSwipe("like", currentProfile.id)}
className="w-16 h-16 bg-gray-700 rounded-full flex items-center justify-center text-3xl hover:bg-green-500 transition shadow-lg"
>
♥
</button>
</div>
)}
</div>
</div>
);
}