diff --git a/.env b/.env index 644eec4..591b242 100644 --- a/.env +++ b/.env @@ -1,3 +1,4 @@ +NEXT_PUBLIC_API_BASE_URL= NEXT_PUBLIC_DOMAIN= NEXT_PUBLIC_GOOGLE_CLIENT_ID= NEXT_ANALYZE=false diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..860cc50 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +v18.17.1 diff --git a/package.json b/package.json index f5ca674..da545fa 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "pnpm": "^8.7.0" }, "dependencies": { + "@hookform/resolvers": "^3.3.2", "@phosphor-icons/react": "^2.0.14", "@react-oauth/google": "^0.11.1", "@tw-classed/react": "^1.6.1", @@ -36,8 +37,11 @@ "polished": "^4.2.2", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-hook-form": "^7.48.2", + "swr": "^2.2.4", "tailwindcss": "0.0.0-insiders.803d7b5", "typescript": "5.0.3", + "yup": "^1.3.2", "zustand": "^4.4.6" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3e41143..5ff1ce6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,6 +5,9 @@ settings: excludeLinksFromLockfile: false dependencies: + '@hookform/resolvers': + specifier: ^3.3.2 + version: 3.3.2(react-hook-form@7.48.2) '@phosphor-icons/react': specifier: ^2.0.14 version: 2.0.14(react-dom@18.2.0)(react@18.2.0) @@ -41,12 +44,21 @@ dependencies: react-dom: specifier: ^18.2.0 version: 18.2.0(react@18.2.0) + react-hook-form: + specifier: ^7.48.2 + version: 7.48.2(react@18.2.0) + swr: + specifier: ^2.2.4 + version: 2.2.4(react@18.2.0) tailwindcss: specifier: 0.0.0-insiders.803d7b5 version: 0.0.0-insiders.803d7b5 typescript: specifier: 5.0.3 version: 5.0.3 + yup: + specifier: ^1.3.2 + version: 1.3.2 zustand: specifier: ^4.4.6 version: 4.4.6(@types/react@18.0.32)(react@18.2.0) @@ -1838,6 +1850,14 @@ packages: resolution: {integrity: sha512-OfX7E2oUDYxtBvsuS4e/jSn4Q9Qb6DzgeYtsAdkPZ47znpoNsMgZw0+tVijiv3uGNR6dgNlty6r9rzIzHjtd/A==} dev: true + /@hookform/resolvers@3.3.2(react-hook-form@7.48.2): + resolution: {integrity: sha512-Tw+GGPnBp+5DOsSg4ek3LCPgkBOuOgS5DsDV7qsWNH9LZc433kgsWICjlsh2J9p04H2K66hsXPPb9qn9ILdUtA==} + peerDependencies: + react-hook-form: ^7.0.0 + dependencies: + react-hook-form: 7.48.2(react@18.2.0) + dev: false + /@humanwhocodes/config-array@0.11.13: resolution: {integrity: sha512-JSBDMiDKSzQVngfRjOdFXgFfklaXI4K9nLF49Auh21lmBWRLIK3+xTErTWD4KU54pb6coM6ESE7Awz/FNU3zgQ==} engines: {node: '>=10.10.0'} @@ -9845,6 +9865,10 @@ packages: react-is: 16.13.1 dev: true + /property-expr@2.0.6: + resolution: {integrity: sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==} + dev: false + /proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} @@ -10047,6 +10071,15 @@ packages: react-is: 18.1.0 dev: true + /react-hook-form@7.48.2(react@18.2.0): + resolution: {integrity: sha512-H0T2InFQb1hX7qKtDIZmvpU1Xfn/bdahWBN1fH19gSe4bBEqTfmlr7H3XWTaVtiK4/tpPaI1F3355GPMZYge+A==} + engines: {node: '>=12.22.0'} + peerDependencies: + react: ^16.8.0 || ^17 || ^18 + dependencies: + react: 18.2.0 + dev: false + /react-inspector@6.0.2(react@18.2.0): resolution: {integrity: sha512-x+b7LxhmHXjHoU/VrFAzw5iutsILRoYyDq97EDYdFpPLcvqtEzk4ZSZSQjnFPbr5T57tLXnHcqFYoN1pI6u8uQ==} peerDependencies: @@ -10983,6 +11016,16 @@ packages: webpack: 5.89.0(@swc/core@1.3.95)(esbuild@0.18.20) dev: true + /swr@2.2.4(react@18.2.0): + resolution: {integrity: sha512-njiZ/4RiIhoOlAaLYDqwz5qH/KZXVilRLvomrx83HjzCWTfa+InyfAjv05PSFxnmLzZkNO9ZfvgoqzAaEI4sGQ==} + peerDependencies: + react: ^16.11.0 || ^17.0.0 || ^18.0.0 + dependencies: + client-only: 0.0.1 + react: 18.2.0 + use-sync-external-store: 1.2.0(react@18.2.0) + dev: false + /synchronous-promise@2.0.17: resolution: {integrity: sha512-AsS729u2RHUfEra9xJrE39peJcc2stq2+poBXX8bcM08Y6g9j/i/PUzwNQqkaJde7Ntg1TO7bSREbR5sdosQ+g==} dev: true @@ -11165,6 +11208,10 @@ packages: setimmediate: 1.0.5 dev: true + /tiny-case@1.0.3: + resolution: {integrity: sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==} + dev: false + /tiny-invariant@1.3.1: resolution: {integrity: sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw==} dev: true @@ -11199,6 +11246,10 @@ packages: engines: {node: '>=0.6'} dev: true + /toposort@2.0.2: + resolution: {integrity: sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==} + dev: false + /tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} dev: true @@ -11311,7 +11362,6 @@ packages: /type-fest@2.19.0: resolution: {integrity: sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==} engines: {node: '>=12.20'} - dev: true /type-is@1.6.18: resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} @@ -11872,6 +11922,15 @@ packages: engines: {node: '>=12.20'} dev: true + /yup@1.3.2: + resolution: {integrity: sha512-6KCM971iQtJ+/KUaHdrhVr2LDkfhBtFPRnsG1P8F4q3uUVQ2RfEM9xekpha9aA4GXWJevjM10eDcPQ1FfWlmaQ==} + dependencies: + property-expr: 2.0.6 + tiny-case: 1.0.3 + toposort: 2.0.2 + type-fest: 2.19.0 + dev: false + /zod@3.21.4: resolution: {integrity: sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw==} diff --git a/src/app/(authorized)/layout.tsx b/src/app/(authorized)/layout.tsx index a3acbd7..57354c4 100644 --- a/src/app/(authorized)/layout.tsx +++ b/src/app/(authorized)/layout.tsx @@ -1,8 +1,12 @@ +'use client'; + import { PropsWithChildren } from 'react'; +import RoleEnum from 'domain/entity/RoleEnum'; import Sidebar from 'presentation/component/layout/Sidebar'; import AuthorizedHeader from 'presentation/component/layout/AuthorizedHeader'; +import createAuthorizedLayout from 'presentation/component/layout/AuthorizedLayout'; -export default function AuthorizedLayout(props: PropsWithChildren) { +const BaseAuthorizedLayout = (props: PropsWithChildren) => { const { children } = props; return ( @@ -14,4 +18,8 @@ export default function AuthorizedLayout(props: PropsWithChildren) { ); -} +}; + +export default createAuthorizedLayout(BaseAuthorizedLayout, { + roles: [RoleEnum.User], +}); diff --git a/src/app/(login)/login/page.tsx b/src/app/(login)/login/page.tsx deleted file mode 100644 index 3409287..0000000 --- a/src/app/(login)/login/page.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import LoginPage from 'presentation/component/page/Login'; - -const Login = () => { - return ; -}; - -export default Login; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 927cc0f..940110c 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,7 +1,6 @@ import { PropsWithChildren } from 'react'; import { Inter, Kodchasan } from 'next/font/google'; import clsx from 'clsx'; -import GoogleOAuthProvider from './providers/GoogleOAuthProvider'; import './globals.css'; const kodchasan = Kodchasan({ subsets: ['latin'], variable: '--font-title', weight: ['600'] }); @@ -18,9 +17,7 @@ export default function RootLayout(props: PropsWithChildren) { return ( - -
{children}
-
+
{children}
); diff --git a/src/app/sign-in/page.tsx b/src/app/sign-in/page.tsx new file mode 100644 index 0000000..ad64630 --- /dev/null +++ b/src/app/sign-in/page.tsx @@ -0,0 +1,7 @@ +import SignInPage from 'presentation/component/page/SignIn'; + +const SignIn = () => { + return ; +}; + +export default SignIn; diff --git a/src/app/sign-up/page.tsx b/src/app/sign-up/page.tsx new file mode 100644 index 0000000..886d88f --- /dev/null +++ b/src/app/sign-up/page.tsx @@ -0,0 +1,7 @@ +import SignUpPage from 'presentation/component/page/SignUp'; + +const SignUp = () => { + return ; +}; + +export default SignUp; diff --git a/src/constant/env.ts b/src/constant/env.ts index d8fe56b..9d562d7 100644 --- a/src/constant/env.ts +++ b/src/constant/env.ts @@ -1,2 +1,3 @@ export const IS_PRODUCTION = process.env.NODE_ENV === 'production'; +export const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ?? ''; export const GOOGLE_CLIENT_ID = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID ?? ''; diff --git a/src/constant/httpCode.ts b/src/constant/httpCode.ts new file mode 100644 index 0000000..b236365 --- /dev/null +++ b/src/constant/httpCode.ts @@ -0,0 +1,5 @@ +export const enum HttpCode { + Ok = 200, + Unauthorized = 401, + Forbidden = 403, +} diff --git a/src/constant/route.ts b/src/constant/route.ts index ffa0db1..1c909ef 100644 --- a/src/constant/route.ts +++ b/src/constant/route.ts @@ -1,12 +1,12 @@ +export const SIGN_UP = '/sign-up'; +export const SIGN_IN = '/sign-in'; + export const HOME = '/'; export const NEWS = '/news'; export const DASHBOARD = '/dashboard'; export const STUDY = '/study'; export const SETTINGS = '/settings'; -export const SIGN_UP = '/sign-up'; -export const SIGN_IN = '/sign-in'; - /** * Dashboard sidebar items */ @@ -26,3 +26,11 @@ export const DICTIONARY = `${DASHBOARD}/dictionary`; export const PROFILE = `${SETTINGS}/profile`; export const SECURITY = `${SETTINGS}/security`; export const ADVANCED = `${SETTINGS}/advanced`; + +/** + * API routes + */ +const USER = (url: string) => `/user/${url}`; +export const GET_USER = USER(''); +export const USER_LOGIN = USER('login'); +export const USER_REFRESH = USER('refresh'); diff --git a/src/data/driver/ApiClient/AbstractApiClient.ts b/src/data/driver/ApiClient/AbstractApiClient.ts new file mode 100644 index 0000000..39bd827 --- /dev/null +++ b/src/data/driver/ApiClient/AbstractApiClient.ts @@ -0,0 +1,26 @@ +import type { AxiosInstance } from 'axios'; +import REST from './REST'; + +export default abstract class AbstractApiClient { + public abstract rest: REST; + + private accessToken?: string; + + public setAccessToken(token?: string) { + this.accessToken = token; + } + + public getAccessToken() { + return this.accessToken; + } + + protected useCredentialsInterceptor(client: AxiosInstance) { + client.interceptors.request.use((request) => { + if (this.accessToken && request.headers) { + request.headers.Authorization = `Bearer ${this.accessToken}`; + } + + return request; + }, Promise.reject); + } +} diff --git a/src/data/driver/ApiClient/FrontendApiClient.ts b/src/data/driver/ApiClient/FrontendApiClient.ts new file mode 100644 index 0000000..8d96d36 --- /dev/null +++ b/src/data/driver/ApiClient/FrontendApiClient.ts @@ -0,0 +1,14 @@ +import { API_BASE_URL } from 'constant/env'; +import AbstractApiClient from './AbstractApiClient'; +import REST from './REST'; + +export default class FrontendApiClient extends AbstractApiClient { + public readonly rest: REST; + + constructor() { + super(); + this.rest = new REST(`${API_BASE_URL}/api`); + + this.useCredentialsInterceptor(this.rest.client); + } +} diff --git a/src/data/driver/ApiClient/REST.ts b/src/data/driver/ApiClient/REST.ts new file mode 100644 index 0000000..e6bb33f --- /dev/null +++ b/src/data/driver/ApiClient/REST.ts @@ -0,0 +1,13 @@ +import AbstractAxiosClient from './axios'; + +export default class REST extends AbstractAxiosClient { + public post = this._client.post; + + public postForm = this._client.postForm; + + public get = this._client.get; + + public delete = this._client.delete; + + public patch = this._client.patch; +} diff --git a/src/data/driver/ApiClient/axios.ts b/src/data/driver/ApiClient/axios.ts new file mode 100644 index 0000000..d4acaf5 --- /dev/null +++ b/src/data/driver/ApiClient/axios.ts @@ -0,0 +1,32 @@ +import axios, { AxiosInstance } from 'axios'; + +const TIMEOUT_ERROR_MESSAGE = 'TimeoutError'; + +export default abstract class AbstractAxiosClient { + protected readonly _client: AxiosInstance; + + public constructor(apiBaseURL: string) { + this._client = axios.create({ + baseURL: apiBaseURL, + timeout: 30000, + timeoutErrorMessage: TIMEOUT_ERROR_MESSAGE, + }); + + this.useTimeoutErrorInterceptor(); + } + + private useTimeoutErrorInterceptor(): void { + this._client.interceptors.response.use(undefined, (error) => { + if (error?.message === TIMEOUT_ERROR_MESSAGE) { + // eslint-disable-next-line no-console + console.log(JSON.stringify({ TIMEOUT_ERROR_MESSAGE, error }, null, 2)); + } + + throw error; + }); + } + + public get client(): AxiosInstance { + return this._client; + } +} diff --git a/src/data/driver/ApiClient/frontend.ts b/src/data/driver/ApiClient/frontend.ts new file mode 100644 index 0000000..712839d --- /dev/null +++ b/src/data/driver/ApiClient/frontend.ts @@ -0,0 +1,3 @@ +import FrontendApiClient from './FrontendApiClient'; + +export const frontendApiClient = new FrontendApiClient(); diff --git a/src/data/driver/validation/auth/messages.ts b/src/data/driver/validation/auth/messages.ts new file mode 100644 index 0000000..919ffa2 --- /dev/null +++ b/src/data/driver/validation/auth/messages.ts @@ -0,0 +1,7 @@ +import getPlural from 'helper/string/getPlural'; + +export const requiredField = 'Поле обязательно для заполнения'; +export const minSymbols = ({ min }: { min: number }) => + `Минимум ${min} ${getPlural(min, ['символ', 'символа', 'символов'])}`; +export const maxSymbols = ({ max }: { max: number }) => + `Максимум ${max} ${getPlural(max, ['символ', 'символа', 'символов'])}`; diff --git a/src/data/driver/validation/auth/schema.ts b/src/data/driver/validation/auth/schema.ts new file mode 100644 index 0000000..5b6cdad --- /dev/null +++ b/src/data/driver/validation/auth/schema.ts @@ -0,0 +1,30 @@ +import { object, ObjectSchema, ref, string } from 'yup'; +import { SignInForm, SignUpForm } from './types'; +import { requiredField } from './messages'; + +const emailValidationSchema = string().required(requiredField); +const passwordOptionalValidationSchema = string() + .matches( + /^[\w#?!@$%^&*-]+$|^$/i, + 'Password can only contain Latin letters, numbers and special characters', + ) + .test('min-password-length', 'The password must contain at least 4 characters', (value) => { + if (!value) { + return true; + } + + return value.length >= 4 || value.length === 0; + }) + .defined(); +const passwordValidationSchema = passwordOptionalValidationSchema.required(requiredField); + +export const signInFormValidationSchema: ObjectSchema = object({ + email: emailValidationSchema, + password: passwordValidationSchema, +}); + +export const signUpFormValidationSchema: ObjectSchema = object({ + email: emailValidationSchema, + password: passwordValidationSchema, + confirmPassword: passwordValidationSchema.oneOf([ref('password')], 'Пароли должны совпадать'), +}); diff --git a/src/data/driver/validation/auth/types.ts b/src/data/driver/validation/auth/types.ts new file mode 100644 index 0000000..2ee5b68 --- /dev/null +++ b/src/data/driver/validation/auth/types.ts @@ -0,0 +1,10 @@ +export interface SignUpForm { + email: string; + password: string; + confirmPassword: string; +} + +export interface SignInForm { + email: string; + password: string; +} diff --git a/src/domain/entity/RoleEnum.ts b/src/domain/entity/RoleEnum.ts new file mode 100644 index 0000000..52e3fff --- /dev/null +++ b/src/domain/entity/RoleEnum.ts @@ -0,0 +1,7 @@ +const enum RoleEnum { + Visitor = 'visitor', + User = 'user', + Admin = 'admin', +} + +export default RoleEnum; diff --git a/src/domain/entity/User.ts b/src/domain/entity/User.ts index 9375e1d..c390fd8 100644 --- a/src/domain/entity/User.ts +++ b/src/domain/entity/User.ts @@ -1,11 +1,14 @@ +import RoleEnum from './RoleEnum'; + export default class User { // eslint-disable-next-line @typescript-eslint/no-explicit-any public static Hydrate(data: any): User { try { return new User( - data.id ?? NaN, + data.id ?? '', data.name ?? '', data.avatar ?? '', + data.visitor ?? [RoleEnum.Visitor], data.lvl ?? '', data.location ?? '', data.email ?? '', @@ -19,13 +22,14 @@ export default class User { } public static CreateEmpty(): User { - return new User(NaN, '', '', '', '', '', NaN, NaN, NaN); + return new User('', '', '', [RoleEnum.Visitor], '', '', '', NaN, NaN, NaN); } constructor( - public readonly id: number, + public readonly id: string, public readonly name: string, public readonly avatar: string, + public readonly roles: RoleEnum[], public readonly lvl: string, public readonly location: string, public readonly email: string, diff --git a/src/domain/service/auth/useLoginSWRMutation.ts b/src/domain/service/auth/useLoginSWRMutation.ts new file mode 100644 index 0000000..6d2317a --- /dev/null +++ b/src/domain/service/auth/useLoginSWRMutation.ts @@ -0,0 +1,40 @@ +import { GET_USER, USER_LOGIN } from 'constant/route'; +import type User from 'domain/entity/User'; +import { frontendApiClient } from 'data/driver/ApiClient/frontend'; +import useUserStore from 'domain/store/user/useUserStore'; +import useCustomSWRMutation from 'domain/service/useCustomSWRMutation'; + +interface LoginPayload { + email: string; + password: string; +} + +interface LoginResponse { + token: string; +} + +const useLoginSWRMutation = () => { + const setUser = useUserStore((state) => state.setUser); + const { trigger, ...restProps } = useCustomSWRMutation(USER_LOGIN); + + return { + ...restProps, + trigger: async (loginPayload: LoginPayload) => { + const loginResponse = await trigger(loginPayload); + const { headers } = loginResponse; + const { authorization } = headers; + frontendApiClient.setAccessToken(authorization); + const { data: user } = await frontendApiClient.rest.get(GET_USER); + + if (user) { + setUser(user); + + return; + } + + frontendApiClient.setAccessToken(undefined); + }, + }; +}; + +export default useLoginSWRMutation; diff --git a/src/domain/service/auth/useUserRefreshTokenSWR.ts b/src/domain/service/auth/useUserRefreshTokenSWR.ts new file mode 100644 index 0000000..c9c7e0d --- /dev/null +++ b/src/domain/service/auth/useUserRefreshTokenSWR.ts @@ -0,0 +1,31 @@ +import useSWRMutation from 'swr/mutation'; +import { GET_USER, USER_REFRESH } from 'constant/route'; +import User from 'domain/entity/User'; +import useUserStore from 'domain/store/user/useUserStore'; +import { frontendApiClient } from 'data/driver/ApiClient/frontend'; + +interface RefreshResponse { + token: string; +} + +export function useUserRefreshTokenSWR() { + const { setUser, getIsAuthorized } = useUserStore((state) => state); + const isAuthorized = getIsAuthorized(); + + return useSWRMutation( + () => (isAuthorized ? null : USER_REFRESH), + async (baseUrl) => { + const response = await frontendApiClient.rest.post(baseUrl); + + const { headers } = response; + const { authorization } = headers; + + frontendApiClient.setAccessToken(authorization); + + const { data } = await frontendApiClient.rest.get(GET_USER); + + setUser(data); + }, + // TODO: error handling + ); +} diff --git a/src/domain/service/useCustomSWRMutation.ts b/src/domain/service/useCustomSWRMutation.ts new file mode 100644 index 0000000..0133a73 --- /dev/null +++ b/src/domain/service/useCustomSWRMutation.ts @@ -0,0 +1,14 @@ +import { AxiosResponse } from 'axios'; +import useSWRMutation from 'swr/mutation'; +import { frontendApiClient } from 'data/driver/ApiClient/frontend'; + +const useCustomSWRMutation = (url: string) => { + return useSWRMutation, unknown, string, Input>( + url, + async (baseUrl, options) => { + return frontendApiClient.rest.post(baseUrl, options.arg); + }, + ); +}; + +export default useCustomSWRMutation; diff --git a/src/domain/store/user/mock.ts b/src/domain/store/user/mock.ts index 86bb5b2..10dcb04 100644 --- a/src/domain/store/user/mock.ts +++ b/src/domain/store/user/mock.ts @@ -1,10 +1,12 @@ +import RoleEnum from 'domain/entity/RoleEnum'; import User from 'domain/entity/User'; import avatar from 'presentation/image/account/avatar.jpg'; export const MOCK_USER = new User( - 0, + '', 'Username', avatar.src, + [RoleEnum.Visitor], 'N2', 'AZ, Tucson', 'ewing-meghan79@outlook.com', diff --git a/src/domain/store/user/useUserStore.ts b/src/domain/store/user/useUserStore.ts index 1d3ff3b..36522af 100644 --- a/src/domain/store/user/useUserStore.ts +++ b/src/domain/store/user/useUserStore.ts @@ -1,21 +1,23 @@ import { create } from 'zustand'; import { devtools } from 'zustand/middleware'; import User from 'domain/entity/User'; +import RoleEnum from 'domain/entity/RoleEnum'; import { MOCK_USER } from './mock'; type UserState = { user: User; setUser: (user: User) => void; + getIsAuthorized: () => boolean; }; const useUserStore = create()( - devtools( - (set) => ({ - user: MOCK_USER, - setUser: (user) => set(() => ({ user })), - }), - { name: 'user-storage' }, - ), + devtools((set, getState) => ({ + user: MOCK_USER, + setUser: (user) => set(() => ({ user })), + getIsAuthorized: () => { + return getState().user.id !== '' || !getState().user.roles.includes(RoleEnum.Visitor); + }, + })), ); export default useUserStore; diff --git a/src/helper/string/getPlural.ts b/src/helper/string/getPlural.ts new file mode 100644 index 0000000..a57f02a --- /dev/null +++ b/src/helper/string/getPlural.ts @@ -0,0 +1,11 @@ +type StringTuple = [one: string, few: string, many: string]; + +export default function getPlural( + number: number, + strings: StringTuple | Readonly, +): string { + const cases = [2, 0, 1, 1, 1, 2]; + const stringIndex = number % 100 > 4 && number % 100 < 20 ? 2 : cases[Math.min(number % 10, 5)]; + + return strings[stringIndex]; +} diff --git a/src/presentation/component/common/control/IconButton/index.tsx b/src/presentation/component/common/control/IconButton/index.tsx index e5ff981..505bb77 100644 --- a/src/presentation/component/common/control/IconButton/index.tsx +++ b/src/presentation/component/common/control/IconButton/index.tsx @@ -1,19 +1,20 @@ -import { FC } from 'react'; +import { ButtonHTMLAttributes, FC } from 'react'; import { IconProps } from '@phosphor-icons/react'; import { VariantProps } from '@tw-classed/react'; import Icon from 'presentation/component/common/block/Icon'; import { BaseButton } from './styles'; -type PropsT = VariantProps & { - icon: IconType; - iconProps?: IconProps; -}; +type PropsT = ButtonHTMLAttributes & + VariantProps & { + icon: IconType; + iconProps?: IconProps; + }; const IconButton: FC = (props) => { - const { color = 'neutral', icon, iconProps } = props; + const { color = 'neutral', icon, iconProps, ...restProps } = props; return ( - + ); diff --git a/src/presentation/component/common/control/IconButton/styles.ts b/src/presentation/component/common/control/IconButton/styles.ts index a15b449..7f94175 100644 --- a/src/presentation/component/common/control/IconButton/styles.ts +++ b/src/presentation/component/common/control/IconButton/styles.ts @@ -4,6 +4,7 @@ export const BaseButton = classed('button', 'p-1 duration-fast', { variants: { color: { neutral: 'rounded-lg transition-[background-color] hover:bg-black/5', + grey: 'text-black/40 transition-[fill] hover:text-black', pale: 'text-black/20 transition-[fill] hover:text-black', }, }, diff --git a/src/presentation/component/common/control/Input/Input.stories.tsx b/src/presentation/component/common/control/Input/Input.stories.tsx new file mode 100644 index 0000000..8e6ed3e --- /dev/null +++ b/src/presentation/component/common/control/Input/Input.stories.tsx @@ -0,0 +1,22 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import Input from './index'; + +const meta: Meta = { + title: 'common/control/Input', + component: Input, + tags: ['autodocs'], +}; + +export default meta; + +type Story = StoryObj; + +const COMMON_PROPS: Story['args'] = { + placeholder: 'Placeholder', +}; + +export const Regular: Story = { + args: { + ...COMMON_PROPS, + }, +}; diff --git a/src/presentation/component/common/control/Input/index.tsx b/src/presentation/component/common/control/Input/index.tsx new file mode 100644 index 0000000..d6c440b --- /dev/null +++ b/src/presentation/component/common/control/Input/index.tsx @@ -0,0 +1,24 @@ +import { FC, InputHTMLAttributes, forwardRef } from 'react'; + +export type InputProps = InputHTMLAttributes & { + startAdornment?: IconType; + endAdornment?: IconType; +}; + +const Input: FC = forwardRef((props, ref) => { + const { startAdornment, endAdornment, ...restProps } = props; + + return ( +
+ {startAdornment} + + {endAdornment} +
+ ); +}); + +export default Input; diff --git a/src/presentation/component/common/control/Input/styles.ts b/src/presentation/component/common/control/Input/styles.ts new file mode 100644 index 0000000..5801784 --- /dev/null +++ b/src/presentation/component/common/control/Input/styles.ts @@ -0,0 +1,29 @@ +import { classed } from '@tw-classed/react'; +import BaseIcon from 'presentation/component/common/block/Icon'; + +export const Icon = classed(BaseIcon, { + variants: { + position: { + left: '', + right: '', + }, + size: { + medium: 'h-5 w-5', + }, + }, + compoundVariants: [ + { + position: 'left', + size: 'medium', + class: 'mr-1', + }, + { + position: 'right', + size: 'medium', + class: 'ml-2', + }, + ], + defaultVariants: { + size: 'medium', + }, +}); diff --git a/src/presentation/component/common/typography/Link/styles.ts b/src/presentation/component/common/typography/Link/styles.ts index 07c9ce8..b89abc0 100644 --- a/src/presentation/component/common/typography/Link/styles.ts +++ b/src/presentation/component/common/typography/Link/styles.ts @@ -6,6 +6,7 @@ export const BaseLink = classed(NextLink, 'transition duration-fast', { color: { neutral: 'text-black/40 hover:text-black', primary: 'text-black', + secondary: 'text-secondary-indigo [&:hover:not([disabled])]:text-secondary-indigo/80', }, }, defaultVariants: { diff --git a/src/presentation/component/feature/.gitkeep b/src/presentation/component/feature/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/presentation/component/page/Login/Background/content.ts b/src/presentation/component/feature/auth/AuthPageLayout/Background/content.ts similarity index 100% rename from src/presentation/component/page/Login/Background/content.ts rename to src/presentation/component/feature/auth/AuthPageLayout/Background/content.ts diff --git a/src/presentation/component/page/Login/Background/index.tsx b/src/presentation/component/feature/auth/AuthPageLayout/Background/index.tsx similarity index 100% rename from src/presentation/component/page/Login/Background/index.tsx rename to src/presentation/component/feature/auth/AuthPageLayout/Background/index.tsx diff --git a/src/presentation/component/page/Login/Background/styles.ts b/src/presentation/component/feature/auth/AuthPageLayout/Background/styles.ts similarity index 100% rename from src/presentation/component/page/Login/Background/styles.ts rename to src/presentation/component/feature/auth/AuthPageLayout/Background/styles.ts diff --git a/src/presentation/component/page/Login/Background/useImageChange.ts b/src/presentation/component/feature/auth/AuthPageLayout/Background/useImageChange.ts similarity index 91% rename from src/presentation/component/page/Login/Background/useImageChange.ts rename to src/presentation/component/feature/auth/AuthPageLayout/Background/useImageChange.ts index a9bad7b..e1d4730 100644 --- a/src/presentation/component/page/Login/Background/useImageChange.ts +++ b/src/presentation/component/feature/auth/AuthPageLayout/Background/useImageChange.ts @@ -2,6 +2,8 @@ import { useEffect, useState } from 'react'; import delay from 'helper/delay'; import { IMAGES } from './content'; +const TIMEOUT = 10_000; + export default function useImageChange() { const [activeImage, setActiveImage] = useState(0); const [isChanging, setIsChanging] = useState(false); @@ -17,7 +19,7 @@ export default function useImageChange() { }; useEffect(() => { - const interval = setInterval(changeImage, 15000); + const interval = setInterval(changeImage, TIMEOUT); return () => clearInterval(interval); }, []); diff --git a/src/presentation/component/feature/auth/AuthPageLayout/GoogleAuthButton/index.tsx b/src/presentation/component/feature/auth/AuthPageLayout/GoogleAuthButton/index.tsx new file mode 100644 index 0000000..8ec2853 --- /dev/null +++ b/src/presentation/component/feature/auth/AuthPageLayout/GoogleAuthButton/index.tsx @@ -0,0 +1,18 @@ +import { FC } from 'react'; +import GoogleIcon from 'presentation/svg/google.svg'; +import Button from 'presentation/component/common/control/Button'; + +const GoogleAuthButton: FC = () => { + return ( + + ); +}; + +export default GoogleAuthButton; diff --git a/src/presentation/component/feature/auth/AuthPageLayout/index.tsx b/src/presentation/component/feature/auth/AuthPageLayout/index.tsx new file mode 100644 index 0000000..b8a53d0 --- /dev/null +++ b/src/presentation/component/feature/auth/AuthPageLayout/index.tsx @@ -0,0 +1,38 @@ +'use client'; + +import { FC, PropsWithChildren } from 'react'; +import { Container } from 'presentation/component/common/block/Container'; +import GoogleAuthButton from './GoogleAuthButton'; +import Background from './Background'; +import { Delimiter } from './styles'; + +type ButtonPropsT = PropsWithChildren & { + title: string; +}; + +const AuthPageLayout: FC = (props) => { + const { children, title } = props; + + return ( + <> + + +
+
+ {title} + Your Social Campaigns +
+ +
+ + Or with Email + +
+ {children} +
+
+ + ); +}; + +export default AuthPageLayout; diff --git a/src/presentation/component/page/Login/Form/styles.ts b/src/presentation/component/feature/auth/AuthPageLayout/styles.ts similarity index 100% rename from src/presentation/component/page/Login/Form/styles.ts rename to src/presentation/component/feature/auth/AuthPageLayout/styles.ts diff --git a/src/presentation/component/feature/auth/PasswordInput/index.tsx b/src/presentation/component/feature/auth/PasswordInput/index.tsx new file mode 100644 index 0000000..f71fbf5 --- /dev/null +++ b/src/presentation/component/feature/auth/PasswordInput/index.tsx @@ -0,0 +1,34 @@ +import { forwardRef, useState } from 'react'; +import { Eye, EyeSlash } from '@phosphor-icons/react'; +import Input, { InputProps } from 'presentation/component/common/control/Input'; +import IconButton from 'presentation/component/common/control/IconButton'; + +type Props = InputProps; + +const PasswordInput = forwardRef((props, forwardedRef) => { + const { type, endAdornment, disabled, ...restProps } = props; + const [isPasswordVisible, setIsPasswordVisible] = useState(false); + + const handleClick = () => { + setIsPasswordVisible((prevState) => !prevState); + }; + + return ( + + } + {...restProps} + /> + ); +}); + +export default PasswordInput; diff --git a/src/presentation/component/layout/AuthorizedLayout/index.tsx b/src/presentation/component/layout/AuthorizedLayout/index.tsx new file mode 100644 index 0000000..3389103 --- /dev/null +++ b/src/presentation/component/layout/AuthorizedLayout/index.tsx @@ -0,0 +1,59 @@ +'use client'; + +import { FC, PropsWithChildren, useEffect, useRef, useState } from 'react'; +import Link from 'next/link'; +import { SIGN_IN } from 'constant/route'; +import RoleEnum from 'domain/entity/RoleEnum'; +import { useUserRefreshTokenSWR } from 'domain/service/auth/useUserRefreshTokenSWR'; +import useUserStore from 'domain/store/user/useUserStore'; + +type OptionsT = { + roles: RoleEnum[]; +}; + +type PropsT = PropsWithChildren & OptionsT; + +const AuthorizedLayout: FC = (props) => { + const { children, roles } = props; + const { user, getIsAuthorized } = useUserStore((state) => state); + const isAuthorized = getIsAuthorized(); + const isPageAllowedForCurrentUser = user.roles.some((role) => roles.includes(role)); + const { isMutating, trigger } = useUserRefreshTokenSWR(); + const isFirstRender = useRef(true); + const [isLoading, setIsLoading] = useState(!isAuthorized); + + useEffect(() => { + if (!isFirstRender.current || isAuthorized) { + return; + } + + isFirstRender.current = false; + + trigger().finally(() => setIsLoading(false)); + }, []); + + if (isLoading || isMutating) { + return <>Loading...; + } + + if (isPageAllowedForCurrentUser) { + // eslint-disable-next-line react/jsx-no-useless-fragment + return <>{children}; + } + + if (isAuthorized) { + return <>Unavailable; + } + + return Log in; +}; + +const createAuthorizedLayout = (Component: FC, options: OptionsT) => { + return (props: T) => ( + + + + ); +}; + +export default createAuthorizedLayout; diff --git a/src/presentation/component/layout/Sidebar/Account/index.tsx b/src/presentation/component/layout/Sidebar/Account/index.tsx index 23d4b63..463805c 100644 --- a/src/presentation/component/layout/Sidebar/Account/index.tsx +++ b/src/presentation/component/layout/Sidebar/Account/index.tsx @@ -1,10 +1,9 @@ import { FC } from 'react'; -import useUserStore from 'domain/store/user/useUserStore'; +import { MOCK_USER } from 'domain/store/user/mock'; import CircleImage from 'presentation/component/common/block/CircleImage'; const Account: FC = () => { - const user = useUserStore((state) => state.user); - const { avatar, name } = user; + const { avatar, name } = MOCK_USER; return (
diff --git a/src/presentation/component/page/Login/Form/GoogleAuthButton/index.tsx b/src/presentation/component/page/Login/Form/GoogleAuthButton/index.tsx deleted file mode 100644 index 7f334be..0000000 --- a/src/presentation/component/page/Login/Form/GoogleAuthButton/index.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { FC, useEffect, useState } from 'react'; -import { TokenResponse, useGoogleLogin } from '@react-oauth/google'; -import axios from 'axios'; -import { useRouter } from 'next/navigation'; -import { OVERVIEW } from 'constant/route'; -import User from 'domain/entity/User'; -import useUserStore from 'domain/store/user/useUserStore'; -import { MOCK_USER } from 'domain/store/user/mock'; -import GoogleIcon from 'presentation/svg/google.svg'; -import Button from 'presentation/component/common/control/Button'; - -type AuthResponse = Omit; - -const GoogleAuthButton: FC = () => { - const [authResponse, setAuthResponse] = useState(null); - const setUser = useUserStore((state) => state.setUser); - const { push } = useRouter(); - - const login = useGoogleLogin({ - onSuccess: (response) => setAuthResponse(response), - }); - - useEffect(() => { - if (authResponse) { - axios - .get( - `https://www.googleapis.com/oauth2/v1/userinfo?access_token=${authResponse.access_token}`, - { - headers: { - Authorization: `Bearer ${authResponse.access_token}`, - Accept: 'application/json', - }, - }, - ) - .then((res) => { - const { id, name, picture } = res.data; - const { lvl, location, email, todayReviews, totalReviews, successRate } = - MOCK_USER; - - setUser({ - ...MOCK_USER, - ...new User( - id, - name, - picture, - lvl, - location, - email, - todayReviews, - totalReviews, - successRate, - ), - }); - push(OVERVIEW); - }); - } - }, [authResponse]); - - return ( - - ); -}; - -export default GoogleAuthButton; diff --git a/src/presentation/component/page/Login/Form/Input/index.tsx b/src/presentation/component/page/Login/Form/Input/index.tsx deleted file mode 100644 index d0eeb0f..0000000 --- a/src/presentation/component/page/Login/Form/Input/index.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { FC, InputHTMLAttributes } from 'react'; - -type InputProps = InputHTMLAttributes; - -const Input: FC = (props) => { - return ( - - ); -}; - -export default Input; diff --git a/src/presentation/component/page/Login/Form/index.tsx b/src/presentation/component/page/Login/Form/index.tsx deleted file mode 100644 index 71382a4..0000000 --- a/src/presentation/component/page/Login/Form/index.tsx +++ /dev/null @@ -1,46 +0,0 @@ -'use client'; - -import { FC } from 'react'; -import Button from 'presentation/component/common/control/Button'; -import { Container } from 'presentation/component/common/block/Container'; -import GoogleAuthButton from './GoogleAuthButton'; -import Input from './Input'; -import { Delimiter } from './styles'; - -const Form: FC = () => { - return ( - -
-
- Sign In - Your Social Campaigns -
- -
- - Or with Email - -
-
- - - -
- -
- Not a Member yet? - -
- -
- ); -}; - -export default Form; diff --git a/src/presentation/component/page/Login/index.tsx b/src/presentation/component/page/Login/index.tsx deleted file mode 100644 index 6e4815d..0000000 --- a/src/presentation/component/page/Login/index.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { FC } from 'react'; -import Background from './Background'; -import Form from './Form'; - -const Login: FC = () => { - return ( - <> - -
- - ); -}; - -export default Login; diff --git a/src/presentation/component/page/SignIn/index.tsx b/src/presentation/component/page/SignIn/index.tsx new file mode 100644 index 0000000..e062f99 --- /dev/null +++ b/src/presentation/component/page/SignIn/index.tsx @@ -0,0 +1,62 @@ +'use client'; + +import { FC } from 'react'; +import { SubmitHandler, useForm } from 'react-hook-form'; +import { yupResolver } from '@hookform/resolvers/yup'; +import { useRouter } from 'next/navigation'; +import { OVERVIEW, SIGN_UP } from 'constant/route'; +import { SignInForm } from 'data/driver/validation/auth/types'; +import { signInFormValidationSchema } from 'data/driver/validation/auth/schema'; +import useLoginSWRMutation from 'domain/service/auth/useLoginSWRMutation'; +import Button from 'presentation/component/common/control/Button'; +import Input from 'presentation/component/common/control/Input'; +import Link from 'presentation/component/common/typography/Link'; +import AuthPageLayout from 'presentation/component/feature/auth/AuthPageLayout'; + +const SignIn: FC = () => { + const { register, handleSubmit } = useForm({ + resolver: yupResolver(signInFormValidationSchema), + }); + const { trigger } = useLoginSWRMutation(); + const router = useRouter(); + + const onSubmit: SubmitHandler = async (data) => { + const { email, password } = data; + + await trigger({ + email, + password, + }); + + router.push(OVERVIEW); + }; + + return ( + + + + + + + +
+ Not a Member yet? + + Sign Up + +
+
+ ); +}; + +export default SignIn; diff --git a/src/presentation/component/page/SignUp/index.tsx b/src/presentation/component/page/SignUp/index.tsx new file mode 100644 index 0000000..ab155e2 --- /dev/null +++ b/src/presentation/component/page/SignUp/index.tsx @@ -0,0 +1,61 @@ +'use client'; + +import { FC } from 'react'; +import { SubmitHandler, useForm } from 'react-hook-form'; +import { yupResolver } from '@hookform/resolvers/yup'; +import { useRouter } from 'next/navigation'; +import { OVERVIEW, SIGN_IN } from 'constant/route'; +import { SignUpForm } from 'data/driver/validation/auth/types'; +import { signUpFormValidationSchema } from 'data/driver/validation/auth/schema'; +import useLoginSWRMutation from 'domain/service/auth/useLoginSWRMutation'; +import Button from 'presentation/component/common/control/Button'; +import Input from 'presentation/component/common/control/Input'; +import Link from 'presentation/component/common/typography/Link'; +import AuthPageLayout from 'presentation/component/feature/auth/AuthPageLayout'; +import PasswordInput from 'presentation/component/feature/auth/PasswordInput'; + +const SignUp: FC = () => { + const { register, handleSubmit } = useForm({ + resolver: yupResolver(signUpFormValidationSchema), + }); + const { trigger } = useLoginSWRMutation(); + const router = useRouter(); + + const onSubmit: SubmitHandler = async (data) => { + const { email, password } = data; + + await trigger({ + email, + password, + }); + + router.push(OVERVIEW); + }; + + return ( + +
+ + + + + +
+ Already have an Account? + + Sign In + +
+
+ ); +}; + +export default SignUp; diff --git a/src/presentation/component/page/dashboard/Overview/Intro/index.tsx b/src/presentation/component/page/dashboard/Overview/Intro/index.tsx index e11b9ab..73f830e 100644 --- a/src/presentation/component/page/dashboard/Overview/Intro/index.tsx +++ b/src/presentation/component/page/dashboard/Overview/Intro/index.tsx @@ -3,6 +3,7 @@ import { FC } from 'react'; import { EnvelopeSimple, GraduationCap, MapPin } from '@phosphor-icons/react'; import useUserStore from 'domain/store/user/useUserStore'; +import { MOCK_USER } from 'domain/store/user/mock'; import SurfaceCard from 'presentation/component/common/block/SurfaceCard'; import CircleImage from 'presentation/component/common/block/CircleImage'; import Detail from './Detail'; @@ -11,7 +12,8 @@ import ProgressBar from './ProgressBar'; const Intro: FC = () => { const user = useUserStore((state) => state.user); - const { name, avatar, lvl, location, email, todayReviews, totalReviews, successRate } = user; + const { email } = user; + const { name, avatar, lvl, location, todayReviews, totalReviews, successRate } = MOCK_USER; return ( diff --git a/src/presentation/hook/.gitkeep b/src/presentation/hook/.gitkeep deleted file mode 100644 index e69de29..0000000