diff --git a/public/images/auth.jpg b/public/images/auth.jpg deleted file mode 100644 index 07ac923..0000000 Binary files a/public/images/auth.jpg and /dev/null differ diff --git a/public/images/auth.png b/public/images/auth.png new file mode 100644 index 0000000..4e1263d Binary files /dev/null and b/public/images/auth.png differ diff --git a/src/api/usePhoneAuth.ts b/src/api/usePhoneAuth.ts new file mode 100644 index 0000000..9b94018 --- /dev/null +++ b/src/api/usePhoneAuth.ts @@ -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( + "/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; + }, + }); +}; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index c3b9659..b2a9d47 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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<{ diff --git a/src/components/layout/AuthDialog.tsx b/src/components/layout/AuthDialog.tsx index 61c6e76..172d50e 100644 --- a/src/components/layout/AuthDialog.tsx +++ b/src/components/layout/AuthDialog.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef } from "react"; import { Dialog, DialogContent, @@ -8,141 +8,311 @@ 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("phone"); + const [phone, setPhone] = useState(""); + const [code, setCode] = useState(["", "", "", ""]); + const [error, setError] = useState(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) => { + 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 + ) => { + 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 = () => ( +

+ Входя в аккаунт или создавая новый, вы соглашаетесь с нашими{" "} + + {" и "} + +

+ ); return ( - -
- {/* Левая часть - форма */} -
- - - Авторизация - - + +
+
+ {step === "phone" ? ( + <> + + + Добро пожаловать +
+ в Travel Marine +
+

+ Бронируйте яхты и катера, управляйте заказами и используйте все возможности платформы +

+
-
- {/* Поле email */} - - - 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 - /> - - + +
+
+
+
🇷🇺
+ +7 +
+ + +
- {/* Поле пароля */} - - - 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 - /> - - + {error && ( +

{error}

+ )} +
- {/* Чекбокс и ссылка */} -
-
- - setRememberMe(checked as boolean) - } - /> - -
- -
+
+ +
+
+ + ) : ( + <> + + + Подтверждение номера + +

+ Введите 4-значный код, отправленный на номер: +

+

{getFormattedPhone()}

+
- {/* Кнопка входа */} - +
+
+ {code.map((digit, index) => ( + { + 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 && ( +

{error}

+ )} +
- {/* Ссылка на регистрацию */} -
- -
-
- {/* Договор */} -
- Входя в аккаунт или создавая новый, вы соглашаетесь с нашими{" "} - {" "} - и{" "} - -
+ +

+ {resendSeconds > 0 ? ( + <>Отправить код повторно через {String(Math.floor(resendSeconds / 60)).padStart(2, "0")}:{String(resendSeconds % 60).padStart(2, "0")} + ) : ( + + )} +

+ + +
+ +
+ + + )}
- {/* Правая часть - изображение */} -
- {/* Изображение яхт */} - Яхты +
+
+ Яхты +
diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx index 1b04a63..f18acaa 100644 --- a/src/components/ui/dialog.tsx +++ b/src/components/ui/dialog.tsx @@ -21,7 +21,7 @@ const DialogOverlay = React.forwardRef< {children} - - - Close + + @@ -59,7 +62,7 @@ const DialogHeader = ({ }: React.HTMLAttributes) => (