Изменил форму авторизации

This commit is contained in:
Sergey Bolshakov 2026-03-06 23:19:34 +03:00
parent efd0dab792
commit 950e0f3964
6 changed files with 397 additions and 119 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 361 KiB

BIN
public/images/auth.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

98
src/api/usePhoneAuth.ts Normal file
View File

@ -0,0 +1,98 @@
import { useMutation } from "@tanstack/react-query";
import useAuthStore from "@/stores/useAuthStore";
import useApiClient from "@/hooks/useApiClient";
interface SendCodeDto {
phone: string;
}
interface VerifyCodeDto {
phone: string;
code: string;
}
interface VerifyCodeResponse {
access_token: string;
}
interface JWTPayload {
user_id?: number;
userId?: number;
sub?: string;
id?: number;
[key: string]: unknown;
}
const parseJWT = (token: string): JWTPayload | null => {
try {
const base64Url = token.split(".")[1];
if (!base64Url) {
return null;
}
const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/");
const jsonPayload = decodeURIComponent(
atob(base64)
.split("")
.map((c) => "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2))
.join("")
);
return JSON.parse(jsonPayload);
} catch (error) {
console.error("Ошибка при парсинге JWT токена:", error);
return null;
}
};
export const useSendCode = () => {
const client = useApiClient();
return useMutation({
mutationKey: ["auth", "send-code"],
mutationFn: async (data: SendCodeDto) => {
const response = await client.post("/auth/phone/send-code", data);
return response.data;
},
});
};
export const useVerifyCode = () => {
const client = useApiClient();
const setToken = useAuthStore((state) => state.setToken);
const setUserId = useAuthStore((state) => state.setUserId);
return useMutation({
mutationKey: ["auth", "verify-code"],
mutationFn: async (data: VerifyCodeDto) => {
const response = await client.post<VerifyCodeResponse>(
"/auth/phone/verify-code",
data
);
const { access_token } = response.data;
if (!access_token) {
throw new Error("Не удалось получить токен авторизации");
}
setToken(access_token, true);
const payload = parseJWT(access_token);
if (payload) {
const userId =
payload.user_id ||
payload.userId ||
payload.sub ||
payload.id ||
null;
if (userId) {
setUserId(userId, true);
}
}
return access_token;
},
});
};

View File

@ -1,4 +1,4 @@
import type { Metadata } from "next";
import type { Metadata, Viewport } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import Header from "@/components/layout/Header";
@ -12,6 +12,13 @@ export const metadata: Metadata = {
description: "Прокат яхт и морские прогулки в Балаклаве",
};
export const viewport: Viewport = {
width: 'device-width',
initialScale: 1,
maximumScale: 1,
userScalable: false
}
export default function RootLayout({
children,
}: Readonly<{

View File

@ -1,6 +1,6 @@
"use client";
import { useState, useEffect } from "react";
import { useState, useEffect, useRef } from "react";
import {
Dialog,
DialogContent,
@ -8,143 +8,313 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Field, FieldContent } from "@/components/ui/field";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import Image from "next/image";
import useAuthentificate from "@/api/useAuthentificate";
import { useSendCode, useVerifyCode } from "@/api/usePhoneAuth";
interface AuthDialogProps {
isOpen: boolean;
onClose: () => void;
}
type AuthStep = "phone" | "code";
export default function AuthDialog({ isOpen, onClose }: AuthDialogProps) {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [rememberMe, setRememberMe] = useState(false);
const [step, setStep] = useState<AuthStep>("phone");
const [phone, setPhone] = useState("");
const [code, setCode] = useState(["", "", "", ""]);
const [error, setError] = useState<string | null>(null);
const [resendSeconds, setResendSeconds] = useState(0);
const mutation = useAuthentificate();
const sendCodeMutation = useSendCode();
const verifyCodeMutation = useVerifyCode();
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
mutation.mutate({ email, password, rememberMe });
};
const codeInputRefs = useRef<(HTMLInputElement | null)[]>([]);
const prevCodeLengthRef = useRef(0);
useEffect(() => {
if (mutation.isSuccess) {
onClose();
if (!isOpen) {
setStep("phone");
setPhone("");
setCode(["", "", "", ""]);
setError(null);
setResendSeconds(0);
prevCodeLengthRef.current = 0;
}
}, [mutation.isSuccess]);
}, [isOpen]);
// Таймер повторной отправки кода
useEffect(() => {
if (step !== "code" || resendSeconds <= 0) return;
const t = setInterval(() => setResendSeconds((s) => (s <= 0 ? 0 : s - 1)), 1000);
return () => clearInterval(t);
}, [step, resendSeconds]);
const formatPhoneNumber = (value: string): string => {
const digits = value.replace(/\D/g, "");
if (digits.length === 0) return "";
let formatted = "";
if (digits.length > 0) {
formatted = `(${digits.slice(0, 3)}`;
}
if (digits.length > 3) {
formatted += `) ${digits.slice(3, 6)}`;
}
if (digits.length > 6) {
formatted += ` ${digits.slice(6, 10)}`;
}
return formatted;
};
const handlePhoneChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const input = e.target.value;
const digitsOnly = input.replace(/\D/g, "").slice(0, 10);
setPhone(digitsOnly);
};
const handlePhoneSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (phone.length < 10) return;
setError(null);
try {
// await sendCodeMutation.mutateAsync({ phone: `7${phone}` });
setResendSeconds(60);
setStep("code");
setTimeout(() => {
codeInputRefs.current[0]?.focus();
}, 100);
} catch {
setError("Не удалось отправить код. Попробуйте позже.");
}
};
const handleCodeChange = (index: number, value: string) => {
if (!/^\d*$/.test(value)) return;
const newCode = [...code];
newCode[index] = value.slice(-1);
setCode(newCode);
if (value && index < 3) {
codeInputRefs.current[index + 1]?.focus();
}
};
const handleCodeKeyDown = (
index: number,
e: React.KeyboardEvent<HTMLInputElement>
) => {
if (e.key === "Backspace" && !code[index] && index > 0) {
codeInputRefs.current[index - 1]?.focus();
}
};
const handleCodePaste = (e: React.ClipboardEvent) => {
e.preventDefault();
const pastedData = e.clipboardData.getData("text").replace(/\D/g, "").slice(0, 4);
const newCode = [...code];
for (let i = 0; i < pastedData.length; i++) {
newCode[i] = pastedData[i];
}
setCode(newCode);
const nextEmptyIndex = newCode.findIndex((c) => !c);
if (nextEmptyIndex !== -1) {
codeInputRefs.current[nextEmptyIndex]?.focus();
} else {
codeInputRefs.current[3]?.focus();
}
};
const submitCode = async () => {
const fullCode = code.join("");
if (fullCode.length !== 4) return;
setError(null);
try {
await verifyCodeMutation.mutateAsync({
phone: `7${phone}`,
code: fullCode,
});
onClose();
} catch {
setError("Неверный код. Попробуйте ещё раз.");
}
};
// Автоотправка кода, когда все 4 поля заполнены
useEffect(() => {
if (step !== "code" || verifyCodeMutation.isPending) return;
const fullCode = code.join("");
if (fullCode.length === 4 && prevCodeLengthRef.current < 4) {
prevCodeLengthRef.current = 4;
submitCode();
} else {
prevCodeLengthRef.current = fullCode.length;
}
}, [step, code, verifyCodeMutation.isPending]);
const handleCodeSubmit = (e: React.FormEvent) => {
e.preventDefault();
submitCode();
};
const getFormattedPhone = () => {
return `+7 ${phone.slice(0, 3)} ${phone.slice(3)}`;
};
const handleResendCode = () => {
if (resendSeconds > 0) return;
setResendSeconds(60);
sendCodeMutation.mutate({ phone: `7${phone}` });
};
const LegalText = () => (
<p className="text-xs text-gray-500 text-center">
Входя в аккаунт или создавая новый, вы соглашаетесь с нашими{" "}
<button type="button" className="cursor-pointer text-gray-700 underline hover:text-gray-900">
Правилами и условиями
</button>
{" и "}
<button type="button" className="cursor-pointer text-gray-700 underline hover:text-gray-900">
Положением о конфиденциальности
</button>
</p>
);
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="[&>button]:hidden max-w-4xl w-full h-[600px] rounded-[30px] bg-[#F8F8F8]">
<div className="flex h-full gap-10">
{/* Левая часть - форма */}
<div className="flex-1 rounded-l-[10px] py-8 pl-1 flex flex-col justify-center">
<DialogHeader className="text-left mb-8">
<DialogTitle className="text-2xl font-bold text-gray-800 mb-2">
Авторизация
<DialogContent className="w-full max-w-[100vw] min-w-0 h-full min-h-[100dvh] rounded-none p-0 overflow-hidden bg-white md:min-h-0 md:max-w-4xl md:h-[414px] md:rounded-[30px]">
<div className="flex h-full items-start md:items-center min-w-0">
<div className="flex-1 flex flex-col bg-white rounded-l-[30px] py-6 px-4 sm:py-[34px] sm:px-[28px] min-w-0 overflow-auto">
{step === "phone" ? (
<>
<DialogHeader className="text-center">
<DialogTitle className="text-2xl font-bold text-black mb-8">
Добро пожаловать
<br />
в Travel Marine
</DialogTitle>
<p className="text-gray-500 text-base mb-4">
Бронируйте яхты и катера, управляйте заказами и используйте все возможности платформы
</p>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Поле email */}
<Field>
<FieldContent>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Электронная почта"
className="w-full bg-white px-5 py-4 border border-gray-300 rounded-full focus:ring-2 focus:ring-[#008299] focus:border-transparent outline-none transition-colors"
required
/>
</FieldContent>
</Field>
{/* Поле пароля */}
<Field>
<FieldContent>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Пароль"
className="w-full bg-white px-5 py-4 border border-gray-300 rounded-full focus:ring-2 focus:ring-[#008299] focus:border-transparent outline-none transition-colors"
required
/>
</FieldContent>
</Field>
{/* Чекбокс и ссылка */}
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<Checkbox
id="remember"
checked={rememberMe}
onCheckedChange={(checked) =>
setRememberMe(checked as boolean)
}
/>
<Label htmlFor="remember" className="text-sm">
Запомнить меня
</Label>
<form onSubmit={handlePhoneSubmit} className="flex flex-col flex-1">
<div className="relative">
<div className="flex items-center gap-2 px-3 sm:px-4 rounded-full border border-gray-200 overflow-hidden bg-white min-w-0">
<div className="flex items-center gap-2 shrink-0">
<div className="flex items-center justify-center h-[36px] w-[36px] rounded-[16px] border border-gray-200" aria-hidden><div className="text-xl">🇷🇺</div></div>
<span className="text-gray-800 font-medium">+7</span>
</div>
<button
type="button"
className="text-sm hover:text-[#008299] transition-colors"
>
Забыли пароль?
</button>
</div>
{/* Кнопка входа */}
<input
type="tel"
value={formatPhoneNumber(phone)}
onChange={handlePhoneChange}
placeholder="(000) 000 0000"
className="flex-1 min-w-0 py-4 outline-none text-gray-800 placeholder:text-gray-400 w-0"
/>
<Button
type="submit"
className="w-full h-[56px] bg-brand px-5 py-4 hover:bg-brand-hover text-white rounded-full font-semibold transition-colors"
disabled={phone.length < 10 || sendCodeMutation.isPending}
className="h-[40px] px-4 sm:px-8 bg-brand hover:bg-brand-hover text-white rounded-full font-semibold transition-colors disabled:opacity-50 shrink-0"
>
Войти
Далее
</Button>
</div>
{/* Ссылка на регистрацию */}
<div className="text-center">
<button
type="button"
className="hover:text-brand font-bold transition-colors"
>
Создать аккаунт
</button>
{error && (
<p className="absolute left-0 right-0 top-full mt-2 text-red-500 text-xs text-center">{error}</p>
)}
</div>
<div className="mt-11">
<LegalText />
</div>
</form>
</>
) : (
<>
<DialogHeader className="text-center">
<DialogTitle className="text-2xl font-bold text-black mb-8">
Подтверждение номера
</DialogTitle>
<p className="text-gray-500 text-sm">
Введите 4-значный код, отправленный на номер:
</p>
<p className="text-black text-sm font-bold mb-4">{getFormattedPhone()}</p>
</DialogHeader>
{/* Договор */}
<div className="mt-8 text-xs text-gray-400 text-center">
Входя в аккаунт или создавая новый, вы соглашаетесь с нашими{" "}
<button className="text-primary hover:underline">
Правилами и условиями
</button>{" "}
и{" "}
<button className="text-primary hover:underline">
Положением о конфиденциальности
<form onSubmit={handleCodeSubmit} className="flex flex-col flex-1">
<div className="flex justify-center gap-2 relative mb-6">
{code.map((digit, index) => (
<input
key={index}
ref={(el) => {
codeInputRefs.current[index] = el;
}}
type="text"
inputMode="numeric"
maxLength={1}
value={digit}
onChange={(e) => handleCodeChange(index, e.target.value)}
onKeyDown={(e) => handleCodeKeyDown(index, e)}
onPaste={handleCodePaste}
className="w-14 h-14 text-center text-2xl font-semibold bg-white rounded-[4px] border border-gray-200 focus:border-brand focus:ring-2 focus:ring-brand/20 outline-none transition-all"
/>
))}
{error && (
<p className="absolute left-0 right-0 top-full mt-2 text-red-500 text-xs text-center">{error}</p>
)}
</div>
<p className="text-sm text-black text-center mb-1">
{resendSeconds > 0 ? (
<>Отправить код повторно через {String(Math.floor(resendSeconds / 60)).padStart(2, "0")}:{String(resendSeconds % 60).padStart(2, "0")}</>
) : (
<button
type="button"
onClick={handleResendCode}
className="text-brand hover:underline"
>
Отправить код повторно
</button>
)}
</p>
<button
type="button"
onClick={() => { setStep("phone"); setError(null); setCode(["", "", "", ""]) }}
className="text-brand text-sm hover:underline text-center"
>
Изменить номер
</button>
<div className="mt-5">
<LegalText />
</div>
</form>
</>
)}
</div>
{/* Правая часть - изображение */}
<div className="hidden md:block flex-1 relative overflow-hidden rounded-[20px]">
{/* Изображение яхт */}
<div className="hidden md:block w-[45%] h-full flex-shrink-0 py-[34px] pr-[28px] pl-0">
<div className="relative w-full h-full overflow-hidden rounded-[10px]">
<Image
src="/images/auth.jpg"
src="/images/auth.png"
alt="Яхты"
fill
className="object-cover rounded-lg"
className="object-cover"
/>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);

View File

@ -21,7 +21,7 @@ const DialogOverlay = React.forwardRef<
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"fixed inset-0 z-50 min-h-[100dvh] bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
@ -38,15 +38,18 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] rounded-[10px] overflow-hidden",
"fixed inset-0 z-50 grid w-full max-w-[100vw] min-w-0 h-full min-h-[100dvh] rounded-none gap-4 border-0 bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom overflow-hidden",
"md:inset-auto md:left-[50%] md:top-[50%] md:translate-x-[-50%] md:translate-y-[-50%] md:max-w-lg md:min-w-0 md:h-auto md:rounded-[10px] md:border md:data-[state=closed]:slide-out-to-left-1/2 md:data-[state=closed]:slide-out-to-top-[48%] md:data-[state=open]:slide-in-from-left-1/2 md:data-[state=open]:slide-in-from-top-[48%]",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-3 top-3 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
<DialogPrimitive.Close
className="absolute right-4 top-4 z-10 rounded-sm text-brand opacity-90 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground md:hidden"
aria-label="Закрыть"
>
<X className="h-5 w-5" />
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
@ -59,7 +62,7 @@ const DialogHeader = ({
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
"flex flex-col text-center",
className
)}
{...props}