diff --git a/frontend/app/(urls)/register/admin/page.tsx b/frontend/app/(urls)/register/admin/page.tsx
new file mode 100644
index 0000000..2868bf2
--- /dev/null
+++ b/frontend/app/(urls)/register/admin/page.tsx
@@ -0,0 +1,7 @@
+import React from 'react'
+
+const page = () => {
+ return
page
+}
+
+export default page
diff --git a/frontend/app/(urls)/register/components/ClientRegistrationForm.tsx b/frontend/app/(urls)/register/components/ClientRegistrationForm.tsx
new file mode 100644
index 0000000..18f1f64
--- /dev/null
+++ b/frontend/app/(urls)/register/components/ClientRegistrationForm.tsx
@@ -0,0 +1,150 @@
+import React from 'react'
+import Link from 'next/link'
+import { useForm } from '@/app/hooks/useForm'
+import Button from '@/components/ui/Button'
+// import LoginButton from '@/app/components/ui/LoginButton'
+import showToast from '@/components/ui/Toast'
+import { useRouter } from 'next/navigation'
+import { signIn } from 'next-auth/react'
+import TextInput from '@/components/ui/TextInput'
+import PhoneInput from '@/components/ui/PhoneInput'
+
+const validationRules = {
+ name: { required: true },
+ surname: { required: true },
+ phone_number: {
+ required: true,
+ minLength: 11,
+ },
+ password: { required: true, minLength: 8 },
+ privacy_accepted: { required: true },
+}
+
+export default function ClientRegistrationForm() {
+ const router = useRouter()
+ const {
+ values,
+ isVisible,
+ handleChange,
+ handleSubmit,
+ togglePasswordVisibility,
+ } = useForm(
+ {
+ name: '',
+ surname: '',
+ email: '',
+ phone_number: '',
+ password: '',
+ privacy_accepted: false,
+ },
+ validationRules,
+ async (values) => {
+ try {
+ const result = await signIn('register-credentials', {
+ email: values.email,
+ password: values.password,
+ username: values.name,
+ phone_number: values.phone_number,
+ privacy_accepted: values.privacy_accepted.toString(),
+ redirect: false,
+ })
+
+ if (result?.error) {
+ showToast({ type: 'error', message: result.error })
+ return
+ }
+
+ showToast({ type: 'success', message: 'Регистрация успешна!' })
+ router.push('/account')
+ } catch {
+ showToast({ type: 'error', message: 'Ошибка при регистрации' })
+ }
+ }
+ )
+
+ return (
+ <>
+
+
+ {/* */}
+ >
+ )
+}
diff --git a/frontend/app/(urls)/register/page.tsx b/frontend/app/(urls)/register/page.tsx
index 2868bf2..3d53313 100644
--- a/frontend/app/(urls)/register/page.tsx
+++ b/frontend/app/(urls)/register/page.tsx
@@ -1,7 +1,47 @@
-import React from 'react'
+'use client'
-const page = () => {
- return page
+import React, { useState, useEffect } from 'react'
+import Link from 'next/link'
+import { useRouter } from 'next/navigation'
+import useUserStore from '@/app/store/userStore'
+import ClientRegistrationForm from './components/ClientRegistrationForm'
+
+const RegisterPage = () => {
+ const router = useRouter()
+ const { isAuthenticated } = useUserStore()
+ const [isClient, setIsClient] = useState(true)
+
+ useEffect(() => {
+ // проверяем логин
+ if (isAuthenticated) {
+ // распределяем
+ if (!isClient) {
+ router.replace('/admin')
+ } else {
+ router.replace('/account')
+ }
+ return
+ }
+ }, [isAuthenticated, router, isClient])
+
+ return (
+
+
+
+ Давайте познакомимся поближе!
+
+
+
+
+
+ Уже есть аккаунт?{' '}
+
+ Войти
+
+
+
+
+ )
}
-export default page
+export default RegisterPage
diff --git a/frontend/app/api/auth/[...nextauth]/route.ts b/frontend/app/api/auth/[...nextauth]/route.ts
index e69de29..6306521 100644
--- a/frontend/app/api/auth/[...nextauth]/route.ts
+++ b/frontend/app/api/auth/[...nextauth]/route.ts
@@ -0,0 +1,352 @@
+import NextAuth, { NextAuthOptions } from 'next-auth'
+import GoogleProvider from 'next-auth/providers/google'
+import CredentialsProvider from 'next-auth/providers/credentials'
+import { JWT } from 'next-auth/jwt'
+
+declare module 'next-auth' {
+ interface Session {
+ user: {
+ name?: string | null
+ surname?: string | null
+ email?: string | null
+ phone_number?: string | null
+ image?: string | null
+ userType?: string | null
+ }
+ accessToken?: string
+ refreshToken?: string
+ expiresAt?: number
+ }
+
+ interface User {
+ id: string
+ email: string
+ name?: string
+ accessToken?: string
+ refreshToken?: string
+ userType?: string
+ }
+
+ interface JWT {
+ accessToken?: string
+ refreshToken?: string
+ expiresAt?: number
+ error?: string
+ }
+}
+
+interface GoogleToken extends JWT {
+ accessToken?: string
+ refreshToken?: string
+ expiresAt?: number | undefined
+ error?: string
+}
+
+const authOptions: NextAuthOptions = {
+ providers: [
+ //google login flow
+ GoogleProvider({
+ clientId: process.env.GOOGLE_CLIENT_ID!,
+ clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
+ }),
+
+ //регистрация клиента
+ CredentialsProvider({
+ id: 'register-credentials',
+ name: 'Register',
+ credentials: {
+ email: { label: 'Email', type: 'email' },
+ password: { label: 'Password', type: 'password' },
+ name: { label: 'Name', type: 'text' },
+ phone_number: { label: 'Phone Number', type: 'tel' },
+ privacy_accepted: { label: 'Privacy Accepted', type: 'boolean' },
+ },
+ async authorize(credentials) {
+ try {
+ const res = await fetch(
+ `${process.env.BACKEND_URL}/register/clients/`,
+ {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ email: credentials?.email,
+ password: credentials?.password,
+ name: credentials?.name,
+ phone_number: credentials?.phone_number,
+ privacy_accepted: credentials?.privacy_accepted === 'true',
+ }),
+ }
+ )
+
+ const data = await res.json()
+
+ if (!res.ok) {
+ throw new Error(
+ data.error || data.details?.toString() || 'Registration failed'
+ )
+ }
+
+ return {
+ id: data.user.id.toString(),
+ email: data.user.email,
+ name: data.user.firstName,
+ accessToken: data.access,
+ refreshToken: data.refresh,
+ }
+ } catch (error) {
+ console.error('Registration error:', error)
+ return null
+ }
+ },
+ }),
+
+ //регистрация менеджера
+ CredentialsProvider({
+ id: 'register-credentials-manager',
+ name: 'RegisterManager',
+ credentials: {
+ email: { label: 'Email', type: 'email' },
+ password: { label: 'Password', type: 'password' },
+ username: { label: 'Username', type: 'text' },
+ },
+ async authorize(credentials) {
+ try {
+ const res = await fetch(
+ `${process.env.BACKEND_URL}/register/business/`,
+ {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ email: credentials?.email,
+ password: credentials?.password,
+ username: credentials?.username,
+ }),
+ }
+ )
+
+ if (!res.ok) {
+ const text = await res.text()
+ console.error('Backend error response:', text)
+ throw new Error('Registration failed: ' + text)
+ }
+
+ const data = await res.json()
+
+ return {
+ id: data.user.id.toString(),
+ email: data.user.email,
+ name: data.user.firstName,
+ accessToken: data.access,
+ refreshToken: data.refresh,
+ userType: data.user.userType || 'manager',
+ }
+ } catch (error) {
+ console.error('Registration error:', error)
+ return null
+ }
+ },
+ }),
+
+ //логин обычный
+ CredentialsProvider({
+ name: 'Credentials',
+ credentials: {
+ email: { label: 'Email', type: 'email' },
+ password: { label: 'Password', type: 'password' },
+ },
+ async authorize(credentials) {
+ try {
+ const res = await fetch(
+ `${process.env.BACKEND_URL}/auth/login/clients/`,
+ {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ email: credentials?.email,
+ password: credentials?.password,
+ }),
+ }
+ )
+
+ const data = await res.json()
+
+ if (!res.ok) {
+ throw new Error(data.error || 'Authentication failed')
+ }
+
+ return {
+ id: data.user.id.toString(),
+ email: data.user.email,
+ name: data.user.firstName,
+ accessToken: data.access,
+ refreshToken: data.refresh,
+ }
+ } catch (error) {
+ console.error('Login error:', error)
+ return null
+ }
+ },
+ }),
+
+ //логин для менеджеров
+ CredentialsProvider({
+ id: 'login-credentials-manager',
+ name: 'Credentials',
+ credentials: {
+ email: { label: 'Email', type: 'email' },
+ password: { label: 'Password', type: 'password' },
+ },
+ async authorize(credentials) {
+ try {
+ const res = await fetch(
+ `${process.env.BACKEND_URL}/auth/login/business/`,
+ {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ email: credentials?.email,
+ password: credentials?.password,
+ }),
+ }
+ )
+
+ const data = await res.json()
+ // console.log('Business login response:', data)
+
+ if (!res.ok) {
+ throw new Error(data.error || 'Authentication failed')
+ }
+
+ return {
+ id: data.user.id.toString(),
+ email: data.user.email || `${data.user.phone_number}@example.com`,
+ name: data.user.username || data.user.title,
+ accessToken: data.access,
+ refreshToken: data.refresh,
+ userType: data.user.userType || 'manager',
+ }
+ } catch (error) {
+ console.error('Login error:', error)
+ return null
+ }
+ },
+ }),
+ ],
+ callbacks: {
+ async jwt({ token, user, account }) {
+ // console.log('JWT Callback - User:', user?.userType)
+ // console.log('JWT Callback - Account:', account?.type)
+
+ if (user && account) {
+ if (account.type === 'credentials') {
+ // console.log('Adding userType to token:', user.userType)
+ return {
+ ...token,
+ accessToken: user.accessToken,
+ refreshToken: user.refreshToken,
+ expiresAt: Math.floor(Date.now() / 1000) + 15 * 60, // 15 минут
+ userType: user.userType,
+ }
+ } else {
+ return {
+ ...token,
+ accessToken: account.access_token,
+ refreshToken: account.refresh_token,
+ expiresAt: account.expires_at,
+ userType: user.userType,
+ }
+ }
+ }
+
+ // проверяем не истекает ли токен в ближайшие 5 минут
+ const expiresAt = token.expiresAt as number | undefined
+ if (
+ typeof expiresAt === 'number' &&
+ Date.now() < (expiresAt - 5 * 60) * 1000 // обновляем за 5 минут до истечения
+ ) {
+ return token
+ }
+
+ return refreshAccessToken(token as GoogleToken)
+ },
+ async session({ session, token }) {
+ // console.log('Session Callback - Token:', token)
+ // console.log('Session Callback - userType:', token.userType)
+
+ if (token) {
+ session.user.userType = token.userType as string
+ session.accessToken = token.accessToken as string
+ session.refreshToken = token.refreshToken as string
+
+ // console.log('Session Callback - Final session:', session)
+ }
+ return session
+ },
+ async signIn({ account }) {
+ if (account?.access_token && typeof account.access_token === 'string') {
+ try {
+ const res = await fetch(`${process.env.BACKEND_URL}/auth/google/`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ token: account.id_token }),
+ })
+
+ if (!res.ok) {
+ console.error('Failed to authenticate with Django')
+ return false
+ }
+
+ const djangoTokens = await res.json()
+ // сторим токены бека, а не гугла
+ account.access_token = djangoTokens.access
+ account.refresh_token = djangoTokens.refresh
+ account.expires_at = Math.floor(Date.now() / 1000) + 5 * 60 // 5 минут
+
+ return true
+ } catch (error) {
+ console.error('Error sending token to backend:', error)
+ return false
+ }
+ }
+ return true
+ },
+ },
+}
+
+async function refreshAccessToken(token: GoogleToken): Promise {
+ try {
+ const response = await fetch(`${process.env.BACKEND_URL}/auth/refresh/`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ refresh: token.refreshToken,
+ }),
+ })
+
+ if (!response.ok) {
+ const errorData = await response.json()
+
+ throw errorData
+ }
+
+ const refreshedTokens = await response.json()
+
+ return {
+ ...token,
+ accessToken: refreshedTokens.access,
+ refreshToken: refreshedTokens.refresh ?? token.refreshToken,
+ expiresAt: refreshedTokens.expires_at,
+ }
+ } catch (error) {
+ console.error('Refresh error:', error)
+ return {
+ ...token,
+ error: 'RefreshAccessTokenError',
+ }
+ }
+}
+
+const handler = NextAuth(authOptions)
+export { handler as GET, handler as POST }
diff --git a/frontend/app/globals.css b/frontend/app/globals.css
index ac84c05..1d71820 100644
--- a/frontend/app/globals.css
+++ b/frontend/app/globals.css
@@ -11,6 +11,15 @@
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-orange: #ff613a;
+ --font-display: 'Inter', 'sans-serif';
+}
+
+@font-face {
+ font-family: Inter;
+ font-style: normal;
+ font-weight: 200 700;
+ font-display: swap;
+ src: url('/fonts/Inter-Italic-VariableFont_opsz,wght.ttf') format('ttf');
}
@media (prefers-color-scheme: light) {
@@ -23,5 +32,5 @@
body {
background: var(--background);
color: var(--foreground);
- font-family: Arial, Helvetica, sans-serif;
+ font-family: Inter, sans-serif;
}
diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx
index bbeb998..818fb82 100644
--- a/frontend/app/layout.tsx
+++ b/frontend/app/layout.tsx
@@ -1,19 +1,8 @@
import type { Metadata } from 'next'
-import { Geist, Geist_Mono } from 'next/font/google'
import './globals.css'
import Header from '@/components/Header'
import Footer from '@/components/Footer'
-const geistSans = Geist({
- variable: '--font-geist-sans',
- subsets: ['latin'],
-})
-
-const geistMono = Geist_Mono({
- variable: '--font-geist-mono',
- subsets: ['latin'],
-})
-
export const metadata: Metadata = {
title: 'Отправка посылок в любую точку мира | TripWB',
description:
@@ -26,8 +15,8 @@ export default function RootLayout({
children: React.ReactNode
}>) {
return (
-
-
+
+
{children}
diff --git a/frontend/app/types/index.ts b/frontend/app/types/index.ts
index 4e74276..4697a2d 100644
--- a/frontend/app/types/index.ts
+++ b/frontend/app/types/index.ts
@@ -10,6 +10,10 @@ export interface TextInputProps {
className?: string
maxLength?: number
tooltip?: string | React.ReactNode
+ style: string
+ isPassword?: boolean
+ isVisible?: boolean
+ togglePasswordVisibility?: () => void
}
export interface ButtonProps {
@@ -109,3 +113,11 @@ export interface UserState {
isAuthenticated: boolean
user: User | null
}
+
+export interface PhoneInputProps {
+ value: string
+ handleChange: (e: React.ChangeEvent) => void
+ label?: string
+ className?: string
+ operatorsInfo?: boolean
+}
diff --git a/frontend/components/AddressSelector.tsx b/frontend/components/AddressSelector.tsx
index 5d53996..dcebdee 100644
--- a/frontend/components/AddressSelector.tsx
+++ b/frontend/components/AddressSelector.tsx
@@ -19,6 +19,7 @@ export default function AddressSelector() {
value={fromAddress}
handleChange={(e) => setFromAddress(e.target.value)}
name="fromAddress"
+ style="main"
/>
@@ -29,6 +30,7 @@ export default function AddressSelector() {
value={toAddress}
handleChange={(e) => setToAddress(e.target.value)}
name="toAddress"
+ style="main"
/>