diff --git a/.gitignore b/.gitignore index bf5addd..b04e01f 100644 --- a/.gitignore +++ b/.gitignore @@ -19,4 +19,6 @@ server/temp/*.* server/temp/uploads/*.* server/!temp/.gitkeep -*.psd \ No newline at end of file +*.psd + +backup/ \ No newline at end of file diff --git a/assets/img/compass.png b/assets/img/compass.png new file mode 100644 index 0000000..671287d Binary files /dev/null and b/assets/img/compass.png differ diff --git a/assets/img/hashtag.webp b/assets/img/hashtag.webp new file mode 100644 index 0000000..2548507 Binary files /dev/null and b/assets/img/hashtag.webp differ diff --git a/assets/img/jimmys.webp b/assets/img/jimmys.webp new file mode 100644 index 0000000..054a91f Binary files /dev/null and b/assets/img/jimmys.webp differ diff --git a/assets/img/logo.png b/assets/img/logo.png new file mode 100644 index 0000000..ab92a06 Binary files /dev/null and b/assets/img/logo.png differ diff --git a/assets/img/the_barber.webp b/assets/img/the_barber.webp new file mode 100644 index 0000000..a352e1f Binary files /dev/null and b/assets/img/the_barber.webp differ diff --git a/assets/img/undraw_barber_3uel.svg b/assets/img/undraw_barber_3uel.svg new file mode 100644 index 0000000..8c211fc --- /dev/null +++ b/assets/img/undraw_barber_3uel.svg @@ -0,0 +1 @@ +barber \ No newline at end of file diff --git a/interface/.eslintrc.js b/interface/.eslintrc.js index 6e6c791..8922b58 100644 --- a/interface/.eslintrc.js +++ b/interface/.eslintrc.js @@ -25,6 +25,7 @@ module.exports = { 'array-callback-retur': 0, '@typescript-eslint/no-explicit-any': 0, 'jsx-a11y/label-has-associated-control': 0, - 'camelcase': 0 + camelcase: 0, + 'react/jsx-props-no-spreading': 0, }, }; diff --git a/interface/package.json b/interface/package.json index fb0076a..9586cae 100644 --- a/interface/package.json +++ b/interface/package.json @@ -6,50 +6,56 @@ "author": "Leonardo Ronne", "license": "MIT", "dependencies": { - "@testing-library/jest-dom": "^4.2.4", - "@testing-library/react": "^9.3.2", + "@material-ui/core": "^4.11.0", + "@testing-library/jest-dom": "^5.11.4", + "@testing-library/react": "^11.0.4", "@testing-library/user-event": "^7.1.2", "@types/jest": "^24.0.0", "@types/node": "^12.0.0", "@types/react": "^16.9.0", "@types/react-dom": "^16.9.0", - "@types/react-router-dom": "^5.1.5", - "axios": "^0.19.2", - "date-fns": "^2.14.0", - "formik": "^2.1.4", + "axios": "^0.20.0", + "date-fns": "^2.16.1", + "formik": "^2.1.5", "history": "^5.0.0", - "i18next": "^19.6.0", + "i18next": "^19.7.0", "i18next-xhr-backend": "^3.2.2", - "polished": "^3.6.5", + "polished": "^3.6.7", "react": "^16.13.1", - "react-animated-css": "^1.2.1", + "react-cookie-consent": "^5.1.4", "react-day-picker": "^7.4.8", "react-dom": "^16.13.1", - "react-flip-toolkit": "^7.0.12", "react-google-recaptcha": "^2.1.0", - "react-history": "^0.18.2", + "react-i18next": "^11.7.1", "react-icons": "^3.10.0", "react-router-dom": "^5.2.0", "react-scripts": "3.4.1", - "react-spinners": "^0.9.0", - "react-i18next": "^11.7.0", - "react-toastify": "^6.0.8", "react-spring": "^8.0.27", - "react-tooltip": "^4.2.7", + "react-toastify": "^6.0.8", "styled-components": "^5.1.1", - "styled-icons": "^10.6.0", - "typescript": "~3.7.2", - "yup": "^0.29.1" + "typescript": "^4.0.3", + "yup": "^0.29.3" }, "scripts": { "start": "react-app-rewired start", "build": "react-app-rewired build", - "test": "react-app-rewired test", + "test": "react-app-rewired test --env=jest-environment-jsdom-sixteen", + "test:cover": "react-scripts test --coverage --watchAll false --env=jest-environment-jsdom-sixteen", "eject": "react-app-rewired eject" }, "eslintConfig": { "extends": "react-app" }, + "jest": { + "collectCoverageFrom": [ + "src/pages/**/*.tsx", + "!src/pages/index.tsx", + "src/components/**/*.tsx", + "!src/components/index.tsx", + "src/hooks/*.tsx", + "!src/hooks/index.tsx" + ] + }, "browserslist": { "production": [ ">0.2%", @@ -63,30 +69,31 @@ ] }, "devDependencies": { + "@testing-library/react-hooks": "^3.4.2", + "@types/axios": "^0.14.0", "@types/i18next": "^13.0.0", "@types/i18next-xhr-backend": "^1.4.2", - "@types/styled-components": "^5.1.0", - "@types/yup": "^0.29.3", + "@types/react-icons": "^3.0.0", + "@types/react-router-dom": "^5.1.5", + "@types/react-toastify": "^4.1.0", + "@types/styled-components": "^5.1.2", "@typescript-eslint/eslint-plugin": "^3.6.0", "@typescript-eslint/parser": "^3.6.0", "babel-eslint": "^10.0.3", "babel-plugin-root-import": "^6.4.1", "customize-cra": "^0.9.1", - "enzyme": "^3.11.0", - "enzyme-adapter-react-16": "^1.15.2", "eslint": "^6.8.0", - "eslint-config-airbnb": "^18.2.0", + "eslint-config-airbnb": "^18.1.0", "eslint-config-prettier": "^6.11.0", "eslint-import-resolver-babel-plugin-root-import": "^1.1.1", - "eslint-plugin-import": "^2.22.0", + "eslint-plugin-import": "^2.20.2", "eslint-plugin-import-helpers": "^1.0.2", - "eslint-plugin-jsx-a11y": "^6.3.1", + "eslint-plugin-jsx-a11y": "^6.2.3", "eslint-plugin-prettier": "^3.1.4", - "eslint-plugin-react": "^7.20.3", + "eslint-plugin-react": "^7.19.0", "eslint-plugin-react-hooks": "^2.5.1", - "jest-dom": "^4.0.0", + "jest-environment-jsdom-sixteen": "^1.0.3", "prettier": "^2.0.5", - "react-app-rewired": "^2.1.6", - "react-test-render": "^1.1.2" + "react-app-rewired": "^2.1.6" } } diff --git a/interface/public/index.html b/interface/public/index.html index f92622d..d947fd1 100644 --- a/interface/public/index.html +++ b/interface/public/index.html @@ -4,14 +4,16 @@ - - - - - + + + + + - diff --git a/interface/public/locales/en/translation.json b/interface/public/locales/en/translation.json index d58b8cd..6358b61 100644 --- a/interface/public/locales/en/translation.json +++ b/interface/public/locales/en/translation.json @@ -6,13 +6,55 @@ "validmail": "Invalid email", "requiredemail": "Email is a required field", "password": "Password", - "validpassword": "Weak password", + "validPassword": "Weak password", "requiredpassword": "Password is a required field", "passwordtooltip": "Your password must contain at least 6 characters, including: a lowercase letter, a capital letter and a number", "sessionStart": "Logon to your account", "createAccount": "Create an account", - "sessionStartButton": "Login", + "login": "Login", + "profile": "Profile", + "dashboard": "Dashboard", "register": "Register", + "logout": "Logout", + "createPassword": "Password Redefinition", "forgotpassword": "Forgot your password?", - "userCreated": "User registered successfully" + "userCreated": "User registered successfully", + "send": "Send", + "passwordResetMessage": "We will send you an email so you can reset your password", + "passwordEmail": "Request sent successfully, check your email", + "matchPassword": "The passwords must match", + "passwordConfirmation": "Confirm your password", + "newPassword": "Create a new password", + "noToken": "Password reset token not provided", + "passwordCreated": "Password successfully updated", + "scheduledAppointments": "Scheduled Appointments", + "today": "Today", + "jan": "January", + "feb": "February", + "mar": "March", + "apr": "April", + "may": "May", + "jun": "July", + "jul": "Julho", + "aug": "August", + "sept": "September", + "out": "October", + "nov": "November", + "dec": "December", + "shortSunday": "S", + "shortMonday": "M", + "shortTuesday": "T", + "shortWednesday": "W", + "shortThursday": "T", + "shortFriday": "F", + "shortSaturday": "S", + "morning": "Morning", + "afternoon": "Afternoon", + "designed": "Designed by", + "confirm": "Confirm", + "noAppointments": "No appointments scheduled", + "nextAppointmenst": "Next appointments", + "cookie1": "We use cookies to ensure you get the best experience on our website", + "cookie2": "and continue browsing without changing your settings, we assume that you accept this practice. If you wish, you can change your cookie settings at any time.", + "accept": "Accept" } diff --git a/interface/public/locales/pt/translation.json b/interface/public/locales/pt/translation.json index ddb5c94..20ea4f6 100644 --- a/interface/public/locales/pt/translation.json +++ b/interface/public/locales/pt/translation.json @@ -11,8 +11,50 @@ "requiredpassword": "Senha é um campo obrigatório", "sessionStart": "Faça seu logon", "createAccount": "Crie uma conta", - "sessionStartButton": "Entrar", + "login": "Entrar", + "profile": "Perfil", + "dashboard": "Dashboard", "register": "Registrar", + "logout": "Sair", + "createPassword": "Redefinição de Senha", "forgotpassword": "Esqueceu sua senha?", - "userCreated": "Registro realizado com sucesso" + "userCreated": "Registro realizado com sucesso", + "send": "Enviar", + "passwordResetMessage": "Iremos te enviar um e-mail para que você possa redefinir sua senha", + "passwordEmail": "Solicitação enviada com sucesso, cheque o seu email", + "matchPassword": "As senhas devem coincidir", + "passwordConfirmation": "Confirme sua senha", + "newPassword": "Crie uma nova senha", + "noToken": "Token de redefinição de senha não fornecido", + "passwordCreated": "Senha atualizada com sucesso", + "scheduledAppointments": "Horários agendados", + "today": "Hoje", + "jan": "Janeiro", + "feb": "Fevereiro", + "mar": "Março", + "apr": "Abril", + "may": "Maio", + "jun": "Junho", + "jul": "Julho", + "aug": "Agosto", + "sept": "Setembro", + "out": "Outubro", + "nov": "Novembro", + "dec": "Dezembro", + "shortSunday": "D", + "shortMonday": "S", + "shortTuesday": "T", + "shortWednesday": "Q", + "shortThursday": "Q", + "shortFriday": "S", + "shortSaturday": "S", + "morning": "Morning", + "afternoon": "Tarde", + "designed": "Desenhado por", + "confirm": "Confirmar", + "noAppointments": "Nenhum agendamento marcado nesse período", + "nextAppointmenst": "Agendamento a seguir", + "cookie1": "Usamos cookies para garantir a você a melhor experiência no nosso site", + "cookie2": "e continuar navegando sem alterar as suas configurações, supomos que você aceita esta prática. Se desejar, você pode alterar as configurações de cookies a qualquer momento.", + "accept": "Aceitar" } diff --git a/interface/src/App.tsx b/interface/src/App.tsx index 6679d39..1c7f6b5 100644 --- a/interface/src/App.tsx +++ b/interface/src/App.tsx @@ -1,25 +1,27 @@ import React from 'react'; -import { BrowserRouter as Router } from 'react-router-dom'; +import { BrowserRouter } from 'react-router-dom'; +import { ThemeProvider } from 'styled-components'; import { ToastContainer } from 'react-toastify'; -import { AuthProvider } from './hooks/auth'; - import Routes from './routes'; -import GlobalStyles from './styles/GlobalStyles'; +import { useChangeTheme } from './hooks'; +import GlobalStyles from './styles/GlobalStyles'; import './styles/ReactToastify.css'; const App: React.FC = () => { + const { currentTheme } = useChangeTheme(); + return ( <> - - - + + + + - - - + + ); }; diff --git a/interface/src/__tests__/pages/SignIn.spec.tsx b/interface/src/__tests__/pages/SignIn.spec.tsx new file mode 100644 index 0000000..6900f1b --- /dev/null +++ b/interface/src/__tests__/pages/SignIn.spec.tsx @@ -0,0 +1,141 @@ +import React from 'react'; +import { render, fireEvent, waitFor, act } from '@testing-library/react'; +import { SignIn } from '../../pages'; + +const mockedHistoryPush = jest.fn(); +const mockedSignIn = jest.fn(); +const mockedHandleForgotPassword = jest.fn(); +// const mockedNotify = jest.fn(); + +jest.mock('react-router-dom', () => { + return { + useHistory: () => ({ + push: mockedHistoryPush, + }), + Link: ({ children }: { children: React.ReactNode }) => children, + }; +}); + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ t: key => key }), +})); + +jest.mock('../../hooks', () => { + return { + useAuth: () => ({ + signIn: mockedSignIn, + handleForgotPassword: mockedHandleForgotPassword, + }), + useLanguage: () => ({ + language: 'en', + }), + }; +}); + +describe('SignIn Page', () => { + beforeEach(() => { + mockedHistoryPush.mockClear(); + mockedSignIn.mockClear(); + mockedHandleForgotPassword.mockClear(); + // mockedNotify.mockClear(); + }); + + it('should not be able to sign in with invalid values', async () => { + const { getByTestId } = render(); + + await act(async () => { + const emailField = await waitFor(() => getByTestId('email-input')); + const passwordField = await waitFor(() => getByTestId('password-input')); + const loginButton = await waitFor(() => getByTestId('login-button')); + + fireEvent.change(emailField, { target: { value: 'not-valid-email' } }); + fireEvent.change(passwordField, { target: { value: 'Teste' } }); + + await waitFor(() => fireEvent.click(loginButton)); + + await waitFor(() => { + expect(mockedSignIn).not.toHaveBeenCalled(); + }); + }); + }); + + it('should be able to sign in', async () => { + const { getByTestId } = render(); + + await act(async () => { + const emailField = await waitFor(() => getByTestId('email-input')); + const passwordField = await waitFor(() => getByTestId('password-input')); + const loginButton = await waitFor(() => getByTestId('login-button')); + + fireEvent.change(emailField, { target: { value: 'johndoe@example.com' } }); + fireEvent.change(passwordField, { target: { value: 'Teste123' } }); + + await waitFor(() => fireEvent.click(loginButton)); + + await waitFor(() => { + expect(mockedSignIn).toHaveBeenCalledWith('johndoe@example.com', 'Teste123'); + }); + + // await waitFor(() => { + // expect(mockedHistoryPush).toHaveBeenCalledWith('/dashboard'); + // }); + }); + }); + + // it('should display an error if login fails', async () => { + // mockedSignIn.mockImplementation(() => { + // throw new Error('Test error'); + // }); + + // const { getByTestId } = render(); + + // await act(async () => { + // const emailField = await waitFor(() => getByTestId('email-input')); + // const passwordField = await waitFor(() => getByTestId('password-input')); + // const loginButton = await waitFor(() => getByTestId('login-button')); + + // fireEvent.change(emailField, { target: { value: 'johndoe@example.com' } }); + // fireEvent.change(passwordField, { target: { value: 'Teste123' } }); + + // await waitFor(() => fireEvent.click(loginButton)); + + // await waitFor(() => { + // expect(mockedNotify).toHaveBeenCalledWith('Test error', 'error'); + // }); + // }); + // }); + + it('should be able to send forgot password email', async () => { + const { getByTestId } = render(); + + await act(async () => { + const emailField = await waitFor(() => getByTestId('email-input-forgotpassword')); + const sendButton = await waitFor(() => getByTestId('forgotpassword-button')); + + fireEvent.change(emailField, { target: { value: 'johndoe@example.com' } }); + + await waitFor(() => fireEvent.click(sendButton)); + + await waitFor(() => { + expect(mockedHandleForgotPassword).toHaveBeenCalledWith('johndoe@example.com'); + }); + }); + }); + + it('should not be able to send forgot password email with invalid email', async () => { + const { getByTestId } = render(); + + await act(async () => { + const emailField = await waitFor(() => getByTestId('email-input-forgotpassword')); + const sendButton = await waitFor(() => getByTestId('forgotpassword-button')); + + fireEvent.change(emailField, { target: { value: 'not-valid-email' } }); + + await waitFor(() => fireEvent.click(sendButton)); + + await waitFor(() => { + expect(mockedHandleForgotPassword).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/interface/src/assets/img/.picasa.ini b/interface/src/assets/img/.picasa.ini new file mode 100644 index 0000000..a012a3c --- /dev/null +++ b/interface/src/assets/img/.picasa.ini @@ -0,0 +1,2 @@ +[artboard_copy.webp] +backuphash=22298 diff --git a/interface/src/assets/img/artboard_copy.png b/interface/src/assets/img/artboard_copy.png new file mode 100644 index 0000000..e6eac63 Binary files /dev/null and b/interface/src/assets/img/artboard_copy.png differ diff --git a/interface/src/assets/img/artboard_copy.webp b/interface/src/assets/img/artboard_copy.webp index c0187d1..50e7535 100644 Binary files a/interface/src/assets/img/artboard_copy.webp and b/interface/src/assets/img/artboard_copy.webp differ diff --git a/interface/src/assets/img/background-left.png b/interface/src/assets/img/background-left.png new file mode 100644 index 0000000..fc0a846 Binary files /dev/null and b/interface/src/assets/img/background-left.png differ diff --git a/interface/src/assets/img/background.png b/interface/src/assets/img/background.png new file mode 100644 index 0000000..55ef806 Binary files /dev/null and b/interface/src/assets/img/background.png differ diff --git a/interface/src/assets/img/girl.png b/interface/src/assets/img/girl.png new file mode 100644 index 0000000..48cf7ee Binary files /dev/null and b/interface/src/assets/img/girl.png differ diff --git a/interface/src/assets/img/girl.webp b/interface/src/assets/img/girl.webp deleted file mode 100644 index 3d5b46c..0000000 Binary files a/interface/src/assets/img/girl.webp and /dev/null differ diff --git a/interface/src/assets/svg/arrow.svg b/interface/src/assets/svg/arrow.svg new file mode 100644 index 0000000..f656b14 --- /dev/null +++ b/interface/src/assets/svg/arrow.svg @@ -0,0 +1 @@ + diff --git a/interface/src/assets/svg/brazil.svg b/interface/src/assets/svg/brazil.svg new file mode 100644 index 0000000..80c7137 --- /dev/null +++ b/interface/src/assets/svg/brazil.svg @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/interface/src/assets/svg/france.svg b/interface/src/assets/svg/france.svg new file mode 100644 index 0000000..2c3c6a2 --- /dev/null +++ b/interface/src/assets/svg/france.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/interface/src/assets/svg/google.svg b/interface/src/assets/svg/google.svg new file mode 100644 index 0000000..4b6492d --- /dev/null +++ b/interface/src/assets/svg/google.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/interface/src/assets/svg/logo-interna.svg b/interface/src/assets/svg/logo-interna.svg new file mode 100644 index 0000000..9ac9f5e --- /dev/null +++ b/interface/src/assets/svg/logo-interna.svg @@ -0,0 +1,18 @@ + + + logo + Created with Sketch. + + + + + + + + + + diff --git a/interface/src/assets/svg/spain.svg b/interface/src/assets/svg/spain.svg new file mode 100644 index 0000000..da767f8 --- /dev/null +++ b/interface/src/assets/svg/spain.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/interface/src/assets/svg/usa.svg b/interface/src/assets/svg/usa.svg new file mode 100644 index 0000000..fd5a0d1 --- /dev/null +++ b/interface/src/assets/svg/usa.svg @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/interface/src/components/Button/index.tsx b/interface/src/components/Button/index.tsx index d78ae59..f9595ad 100644 --- a/interface/src/components/Button/index.tsx +++ b/interface/src/components/Button/index.tsx @@ -1,22 +1,23 @@ import React from 'react'; - -import DotLoaderComp from '../DotLoaderComp'; +import { useTranslation } from 'react-i18next'; +import { CircularProgress } from '@material-ui/core'; import { Container } from './styles'; interface Props { loading: boolean; - size: number; - defaultText: string; - disableCond: boolean; + text: string; } -const Button: React.FC = ({ loading, size, defaultText, disableCond }) => { +export const ButtonContent: React.FC = ({ loading, text }) => { + const { t } = useTranslation(); + return {loading ? : {t(text)}}; +}; + +const Button: React.FC = () => { return ( - +

Button

); }; diff --git a/interface/src/components/Button/styles.ts b/interface/src/components/Button/styles.ts index 894591c..47ad4cd 100644 --- a/interface/src/components/Button/styles.ts +++ b/interface/src/components/Button/styles.ts @@ -1,14 +1,8 @@ +/* eslint-disable import/prefer-default-export */ import styled from 'styled-components'; export const Container = styled.div` - button { - width: 100%; - background: var(--primary); - color: var(--font-color); - height: 50px; - font-weight: 600; - text-transform: uppercase; - padding: 0 16px; - margin-top: 10px; - } + display: flex; + align-items: center; + justify-content: center; `; diff --git a/interface/src/components/DotLoaderComp/index.tsx b/interface/src/components/DotLoaderComp/index.tsx deleted file mode 100644 index c8f7864..0000000 --- a/interface/src/components/DotLoaderComp/index.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import React from 'react'; -import { DotLoader } from 'react-spinners'; - -interface DotProps { - loading?: boolean; - size?: number; - color?: string; - defaultText?: any; -} - -const DotLoaderComp: React.FC = ({ loading, size, color, defaultText }) => { - return ( - <> - {loading ? ( - - ) : ( - defaultText - )} - - ); -}; - -export default DotLoaderComp; diff --git a/interface/src/components/Footer/index.tsx b/interface/src/components/Footer/index.tsx index b051f04..dfa10ec 100644 --- a/interface/src/components/Footer/index.tsx +++ b/interface/src/components/Footer/index.tsx @@ -1,21 +1,19 @@ import React from 'react'; +import { useTranslation } from 'react-i18next'; -import { Container, GithubIcon } from './styles'; +import { Container } from './styles'; const Footer: React.FC = () => { + const { t } = useTranslation(); + return ( - -

{`©${new Date().getFullYear()}. All Rights Reserved.`}

+

- - + {`© ${new Date().getFullYear()}. ${t('designed')} `} + + Leonardo Ronne + .

); diff --git a/interface/src/components/Footer/styles.ts b/interface/src/components/Footer/styles.ts index 8ac2aae..06d6bb4 100644 --- a/interface/src/components/Footer/styles.ts +++ b/interface/src/components/Footer/styles.ts @@ -1,34 +1,22 @@ +/* eslint-disable import/prefer-default-export */ import styled from 'styled-components'; -import { SocialGithub } from 'styled-icons/foundation'; - export const Container = styled.div` - grid-area: FT; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - background-color: var(--secondary); - padding: 10px; - line-height: 1.9; - - p { + background: transparent; + padding: 15px; + > p { + margin-top: 45px; + color: var(--text-color); font-size: 12px; - color: var(--quaternary); - } - - a { - transition: var(--filter-transition); - } + font-weight: 500; + text-align: center; - a:hover { - text-decoration: none; - filter: var(--hover-effect); + > a { + text-decoration: none; + color: var(--color-primary); + font-size: 12px; + font-weight: 600; + cursor: pointer; + } } `; - -export const GithubIcon = styled(SocialGithub)` - width: 20px; - height: 20px; - color: var(--quaternary); -`; diff --git a/interface/src/components/Header/DropdownHeader/index.tsx b/interface/src/components/Header/DropdownHeader/index.tsx new file mode 100644 index 0000000..938105d --- /dev/null +++ b/interface/src/components/Header/DropdownHeader/index.tsx @@ -0,0 +1,68 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { MenuItem, Fade, ListItemIcon } from '@material-ui/core'; + +import { useAuth } from '../../../hooks'; + +import { MenuContainer } from '../../../styles/MaterialUI'; + +import { Container, Toggler, LogoutIcon, UserIcon, MailIcon } from './styles'; + +interface User { + id: string; + name: string; + email: string; + avatar_url: string; +} + +interface Props { + user: User; +} + +const DropdownHeader: React.FC = ({ user }) => { + const { signOut } = useAuth(); + const { t } = useTranslation(); + + const [open, setOpen] = useState(null); + + const handleClick = (event: any) => { + setOpen(event.currentTarget); + }; + + const handleClose = () => setOpen(null); + return ( + + + + + + + + {user.name && user.name} + + + + + + {user ? user.email : ''} + + {/* + + + + + {t('settings')} + + */} + + + + + {t('logout')} + + + + ); +}; + +export default DropdownHeader; diff --git a/interface/src/components/Header/DropdownHeader/styles.ts b/interface/src/components/Header/DropdownHeader/styles.ts new file mode 100644 index 0000000..c2eb9c7 --- /dev/null +++ b/interface/src/components/Header/DropdownHeader/styles.ts @@ -0,0 +1,38 @@ +import styled from 'styled-components'; +import { MdEmail, MdPowerSettingsNew, MdKeyboardArrowDown } from 'react-icons/md'; + +import { FaUserCircle } from 'react-icons/fa'; + +export const Container = styled.div` + margin: 0 15px 0 5px; + display: flex; + align-items: center; + + .icon-button { + margin-top: 3px; + font-size: 22px; + cursor: pointer; + } + + .titleDropdown { + font-family: var(--font-family); + font-size: 14px !important; + } +`; + +export const Toggler = styled(MdKeyboardArrowDown) <{ open: boolean }>` + color: ${props => (props.open ? 'var(--color-primary)' : 'var(--color-primary-lighter)')}; + cursor: pointer; +`; + +export const LogoutIcon = styled(MdPowerSettingsNew)` + color: var(--color-primary); +`; + +export const UserIcon = styled(FaUserCircle)` + color: var(--disabled); +`; + +export const MailIcon = styled(MdEmail)` + color: var(--disabled); +`; diff --git a/interface/src/components/Header/index.tsx b/interface/src/components/Header/index.tsx index 8df2bc1..e1c249f 100644 --- a/interface/src/components/Header/index.tsx +++ b/interface/src/components/Header/index.tsx @@ -1,34 +1,49 @@ import React from 'react'; -import { FiPower } from 'react-icons/fi'; import { Link } from 'react-router-dom'; -import { useAuth } from '../../hooks/auth'; -import logoImg from '~/assets/svg/logo.svg'; +import DropdownHeader from './DropdownHeader'; -import { Container, HeaderContent, Profile, Info } from './styles'; +import getCurrentURL from '../../utils/getCurrentURL'; + +import { useAuth, useChangeTheme } from '../../hooks'; + +import { Container, HeaderContent, Logo, Profile, MoonIcon, SunIcon, HeaderRight, UserIcon } from './styles'; + +import logo from '~/assets/svg/logo-interna.svg'; const Header: React.FC = () => { - const { signOut, user } = useAuth(); + const { user } = useAuth(); + const { themeName, handleChangeTheme } = useChangeTheme(); + + const currentUrl = getCurrentURL(); return ( - logo + + + Go Barber + - - {user.id} - - Bem-vindo(a), + + go barber + + + {themeName === 'light' ? : } + + + + - {user.name} + {user.avatar_url ? {user.id} : } + + {user.name && user.name.replace(/ .*/, '')} - - + - + + ); diff --git a/interface/src/components/Header/styles.ts b/interface/src/components/Header/styles.ts index 8aba25b..cd5e0e5 100644 --- a/interface/src/components/Header/styles.ts +++ b/interface/src/components/Header/styles.ts @@ -1,64 +1,121 @@ -import styled from 'styled-components'; +import styled, { css } from 'styled-components'; + +import { RiMoonLine, RiSunLine } from 'react-icons/ri'; +import { FaUserCircle } from 'react-icons/fa'; + +const iconCSS = css` + margin-top: 3px; + width: 15px; + height: 15px; + fill: var(--light-grey); + color: var(--light-grey); + cursor: pointer; +`; export const Container = styled.header` - padding: 32px 0; - background: #28262e; + padding: 15px 40px; + background: var(--dark-grey); + box-shadow: var(--box-shadow); + width: 100%; `; export const HeaderContent = styled.div` - max-width: 1120px; margin: 0 auto; display: flex; align-items: center; - > img { - height: 80px; + justify-content: space-between; +`; + +export const Logo = styled.div` + display: flex; + align-items: center; + width: 180px; + justify-content: space-between; + transition: all 0.5s ease; + + img { + width: 31px; + height: 31px; } - button { - margin-left: auto; - background: transparent; - border: 0; + a { + text-decoration: none; + height: 100%; + display: flex; + align-items: center; + } - svg { - color: #999591; - width: 20px; - height: 20px; - } + span { + font-size: 20px; + font-weight: 500; + color: var(--title-color); } `; -export const Profile = styled.div` +export const HeaderRight = styled.div` display: flex; align-items: center; - margin-left: 80px; + justify-content: flex-end; - img { - width: 56px; - height: 56px; - border-radius: 50%; + @media (min-width: 640px) { + min-width: 300px; + justify-content: space-between; } `; -export const Info = styled.div` - display: flex; - flex-direction: column; - margin-left: 16px; - line-height: 24px; +export const Profile = styled.div` + display: none; + align-items: center; + width: 180px; + justify-content: space-evenly; + padding: 5px; - span { - color: #f4ede8; + &.active { + background: var(--calendar-background); + border-radius: 25px; + > a { + color: var(--color-primary); + } } a { + width: 100%; + display: flex; + align-items: center; + justify-content: space-evenly; + font-weight: 500; text-decoration: none; + color: var(--text-color); - strong { - color: var(--primary); + span { + font-size: 20px; + max-width: 120px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } - &:hover { - opacity: 0.8; + img { + width: 30px; + height: 30px; + border-radius: 50%; } } + + @media (min-width: 640px) { + display: flex; + } +`; + +export const MoonIcon = styled(RiMoonLine)` + ${iconCSS} +`; + +export const SunIcon = styled(RiSunLine)` + ${iconCSS} +`; + +export const UserIcon = styled(FaUserCircle)` + color: var(--disabled); `; diff --git a/interface/src/components/Headerbkp/index.tsx b/interface/src/components/Headerbkp/index.tsx deleted file mode 100644 index b385958..0000000 --- a/interface/src/components/Headerbkp/index.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import React from 'react'; - -import logo from '../../assets/img/logo.png'; -import avatar from '../../assets/img/avatar.jpg'; - -import { Container } from './styles'; - -const Header: React.FC = () => { - const userName = 'Leonardo Ronne'; - return ( - -
-
- - Shooping Cart Challenge - -

Shopping

-
- -
-
- ); -}; - -export default Header; diff --git a/interface/src/components/Headerbkp/styles.ts b/interface/src/components/Headerbkp/styles.ts deleted file mode 100644 index 74c5460..0000000 --- a/interface/src/components/Headerbkp/styles.ts +++ /dev/null @@ -1,127 +0,0 @@ -import styled from 'styled-components'; - -export const Container = styled.div` - grid-area: HD; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - position: fixed; - width: 100%; - background-color: var(--secondary); - padding: 10px; - line-height: 1.9; - -webkit-box-shadow: var(--box-shadow); - -moz-box-shadow: var(--box-shadow); - box-shadow: var(--box-shadow); - z-index: 2; - - @media (min-width: 320px) { - .header-left { - margin-left: 0px; - } - - .header-right { - margin-right: 0px; - } - - .header-title { - display: none; - } - } - - @media (min-width: 480px) { - .header-left { - margin-left: 15px; - } - - .header-right { - margin-right: 15px; - } - - .header-title { - display: flex; - } - } - - .header-cointainer { - display: flex; - flex-direction: row; - justify-content: space-between; - width: 100%; - height: 40px; - } - - .header-left { - width: 100%; - display: flex; - align-items: center; - } - - .header-title { - font-weight: 600; - color: var(--primary); - margin-left: 15px; - } - - .header-right { - width: 100%; - display: flex; - justify-content: flex-end; - align-items: center; - } - - .logo { - max-width: 40px; - } - - .nav-profile { - width: 180px; - display: flex; - align-items: center; - justify-content: center; - position: relative; - } - - .nav-profile span { - font-size: 14px; - margin-left: 10px; - text-decoration: none; - } - - .icon-profile-active { - width: 100%; - color: var(--quaternary); - font-weight: 600; - height: 36px; - background-color: var(--terciary); - border-radius: 18px; - padding: 5px; - margin: 2px; - display: flex; - align-items: center; - justify-content: center; - transition: filter 300ms; - } - - .icon-profile-active:hover { - filter: brightness(1.2); - } - - .avatar-profile { - width: 30px; - height: 30px; - border-radius: 50%; - } - - a { - height: 100%; - text-decoration: none; - transition: var(--filter-transition); - } - - a:hover { - text-decoration: none; - filter: var(--hover-effect); - } -`; diff --git a/interface/src/components/Inputs/index.tsx b/interface/src/components/Inputs/index.tsx deleted file mode 100644 index 3c2d7ba..0000000 --- a/interface/src/components/Inputs/index.tsx +++ /dev/null @@ -1,154 +0,0 @@ -import React, { useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import ReactTooltip from 'react-tooltip'; - -import { Content, EyeIcon, EyeSlashIcon, MailIcon, KeyIcon, UserIcon } from './styles'; - -interface Props { - touched: boolean; - errors: string; - value: string; - handleChange: { - (e: React.ChangeEvent): void; - >(field: T): T extends React.ChangeEvent ? void : (e: string | React.ChangeEvent) => void; - }; - handleBlur: { - (e: React.FocusEvent): void; - (fieldOrEvent: T): T extends string ? (e: any) => void : void; - }; - shoowTooltip?: boolean; -} - -export interface IconProps { - color: string; -} - -const NameInput: React.FC = ({ touched, errors, value, handleChange, handleBlur }) => { - const { t } = useTranslation(); - const [focused, setFocused] = useState(false); - - const iconColor = () => { - if (touched && errors) return 'var(--error)'; - if (focused) return 'var(--primary)'; - return 'var(--light-gray)'; - }; - - return ( - - - { - handleBlur(e); - setFocused(false); - }} - onFocus={() => setFocused(true)} - value={value} - className={touched && errors ? 'error' : null} - autoComplete="off" - /> - - - - {touched && errors ?
{errors}
: null} -
- ); -}; - -const EmailInput: React.FC = ({ touched, errors, value, handleChange, handleBlur }) => { - const { t } = useTranslation(); - const [focused, setFocused] = useState(false); - - const iconColor = () => { - if (touched && errors) return 'var(--error)'; - if (focused) return 'var(--primary)'; - return 'var(--light-gray)'; - }; - - return ( - - - { - handleBlur(e); - setFocused(false); - }} - onFocus={() => setFocused(true)} - value={value} - className={touched && errors ? 'error' : null} - autoComplete="off" - /> - - - - {touched && errors ?
{errors}
: null} -
- ); -}; - -const PasswordInput: React.FC = ({ touched, errors, value, handleChange, handleBlur, shoowTooltip }) => { - const { t } = useTranslation(); - const [showPassword, setShowPassword] = useState(false); - const [focused, setFocused] = useState(false); - - if (touched && errors) ReactTooltip.show(t('passwordtooltip')); - - const iconColor = () => { - if (touched && errors) return 'var(--error)'; - if (focused) return 'var(--primary)'; - return 'var(--light-gray)'; - }; - - function ShowPassword() { - return ( - <> - {!showPassword && ( - - setShowPassword(!showPassword)} /> - - )} - {showPassword && ( - - setShowPassword(!showPassword)} /> - - )} - - ); - } - - return ( - - - { - handleBlur(e); - setFocused(false); - }} - onFocus={() => setFocused(true)} - value={value} - className={touched && errors ? 'error' : null} - autoComplete="off" - data-tip={shoowTooltip ? t('passwordtooltip') : null} - /> - - - - - {touched && errors ?
{errors}
: null} - -
- ); -}; - -export { PasswordInput, EmailInput, NameInput }; diff --git a/interface/src/components/Inputs/styles.ts b/interface/src/components/Inputs/styles.ts deleted file mode 100644 index b571fcf..0000000 --- a/interface/src/components/Inputs/styles.ts +++ /dev/null @@ -1,95 +0,0 @@ -import styled, { css } from 'styled-components'; - -import { Eye, EyeWithLine, Email, Key } from 'styled-icons/entypo'; -import { User } from 'styled-icons/boxicons-solid'; - -import { IconProps } from '.'; - -const iconCSS = css` - width: 20px; - height: 20px; - color: var(--primary); - cursor: pointer; -`; - -export const Content = styled.div` - margin-bottom: 30px; - text-align: left; - - label { - font-weight: 500; - font-size: 16px; - margin-bottom: 5px !important; - } - - input { - width: 100% !important; - margin-top: 10px; - padding: 10px !important; - padding-left: 36px !important; - } - - input:focus ~ svg { - color: var(--primary); - } - - .eyeslash-span, - .eye-span { - float: right; - margin-right: 12px; - margin-top: -34px; - position: relative; - z-index: 2; - height: 40px; - } - - .placeholder-icon { - float: left; - margin-left: 9px; - margin-top: -34px; - position: relative; - z-index: 2; - height: 40px; - } - - .error { - border: 1px solid var(--error) !important; - } - - .error-message { - color: var(--error); - padding: 1px 2px; - position: absolute; - font-size: 11px; - margin: 3px 0 10px 0; - } -`; - -export const EyeIcon = styled(Eye)` - ${iconCSS} -`; - -export const EyeSlashIcon = styled(EyeWithLine)` - ${iconCSS} -`; - -export const MailIcon = styled(Email)` - width: 15px; - height: 15px; - color: ${props => (props.color ? props.color : 'color: var(--light-gray)')}; - transition: 0.9s ease; -`; - -export const KeyIcon = styled(Key)` - width: 15px; - height: 15px; - color: ${props => (props.color ? props.color : 'color: var(--light-gray)')}; - transition: 0.9s ease; -`; - -export const UserIcon = styled(User)` - width: 15px; - height: 15px; - color: ${props => (props.color ? props.color : 'color: var(--light-gray)')}; - transition: 0.9s ease; -`; diff --git a/interface/src/components/LoaderSpinner/index.tsx b/interface/src/components/LoaderSpinner/index.tsx index 56e7430..f1f80d4 100644 --- a/interface/src/components/LoaderSpinner/index.tsx +++ b/interface/src/components/LoaderSpinner/index.tsx @@ -1,20 +1,12 @@ import React from 'react'; -import { BeatLoader } from 'react-spinners'; +import { CircularProgress } from '@material-ui/core'; import { Container } from './styles'; const Loader: React.FC = () => { return ( - + ); }; diff --git a/interface/src/components/LoaderSpinner/styles.ts b/interface/src/components/LoaderSpinner/styles.ts index 05f6f8b..dea7144 100644 --- a/interface/src/components/LoaderSpinner/styles.ts +++ b/interface/src/components/LoaderSpinner/styles.ts @@ -1,10 +1,10 @@ +/* eslint-disable import/prefer-default-export */ import styled from 'styled-components'; export const Container = styled.div` display: flex; justify-content: center; align-items: center; - background: var(--background); - height: 100%; + height: calc(100vh - 18px); overflow: hidden; `; diff --git a/interface/src/components/index.ts b/interface/src/components/index.ts new file mode 100644 index 0000000..b0319fc --- /dev/null +++ b/interface/src/components/index.ts @@ -0,0 +1,4 @@ +export { default as Header } from './Header'; +export { default as Footer } from './Footer'; +export { default as Button } from './Button'; +export { ButtonContent } from './Button'; diff --git a/interface/src/hooks/index.tsx b/interface/src/hooks/index.tsx new file mode 100644 index 0000000..36e8a54 --- /dev/null +++ b/interface/src/hooks/index.tsx @@ -0,0 +1,21 @@ +import React from 'react'; + +import { LanguageProvider, useLanguage } from './useLanguage'; +import { ChangeThemeProvider, useChangeTheme } from './useTheme'; +import { AuthProvider, useAuth } from './useAuth'; +import { CookieProvider, useCookie } from './useCookie'; + +const AppProvider: React.FC = ({ children }) => { + return ( + + + + {children} + + + + ); +}; + +export { useAuth, useLanguage, useChangeTheme, useCookie }; +export default AppProvider; diff --git a/interface/src/hooks/auth.tsx b/interface/src/hooks/useAuth.tsx similarity index 55% rename from interface/src/hooks/auth.tsx rename to interface/src/hooks/useAuth.tsx index b2f679a..ac7e3e9 100644 --- a/interface/src/hooks/auth.tsx +++ b/interface/src/hooks/useAuth.tsx @@ -1,10 +1,14 @@ import React, { createContext, useState, useCallback, useContext } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Cookies } from 'react-cookie-consent'; import api from '../services/api'; +// import history from '../services/history'; import notify from '../services/toast'; interface AuthContextProps { signIn(email: string, password: string): Promise; + handleForgotPassword: (email: string) => Promise; signOut(): void; loading: boolean; user: User; @@ -22,13 +26,21 @@ interface AuthState { user: User; } +interface SignUpFormData { + name: string; + email: string; + password: string; +} + const AuthContext = createContext({} as AuthContextProps); const AuthProvider: React.FC = ({ children }) => { + const { t } = useTranslation(); + const [loading, setLoading] = useState(false); const [data, setData] = useState(() => { - const token = localStorage.getItem('@GoBarber:token'); - const user = localStorage.getItem('@GoBarber:user'); + const token = Cookies.get('@GoBarber:token'); + const user = Cookies.get('@GoBarber:user'); if (token && user) { api.defaults.headers.authorization = `Bearer ${token}`; @@ -40,29 +52,31 @@ const AuthProvider: React.FC = ({ children }) => { }); const signOut = useCallback(() => { - localStorage.removeItem('@GoBarber:token'); - localStorage.removeItem('@GoBarber:user'); + Cookies.remove('@GoBarber:token'); + Cookies.remove('@GoBarber:user'); setData({} as AuthState); }, []); const signIn = useCallback( - async (email, password) => { + async (email: string, password: string): Promise => { try { setLoading(true); const response = await api.post('/session', { email, password }); const { token, user } = response.data; - localStorage.setItem('@GoBarber:token', token); - localStorage.setItem('@GoBarber:user', JSON.stringify(user)); + Cookies.set('@GoBarber:token', token); + Cookies.set('@GoBarber:user', JSON.stringify(user)); api.defaults.headers.authorization = `Bearer ${token}`; setData({ token, user }); + + // history.push('/dashboard'); } catch (err) { - signOut(); notify(err?.response?.data?.message ? err.response.data.message : err.message, 'error'); + signOut(); } finally { setLoading(false); } @@ -70,10 +84,25 @@ const AuthProvider: React.FC = ({ children }) => { [signOut] ); - return {children}; + const handleForgotPassword = useCallback( + async (email: string): Promise => { + try { + setLoading(true); + await api.post('/user/forgotpassword', { email }); + notify(t('passwordEmail'), 'success'); + } catch (err) { + notify(err?.response?.data?.message ? err.response.data.message : err.message, 'error'); + } finally { + setLoading(false); + } + }, + [t] + ); + + return {children}; }; -function useAuth(): AuthContextProps { +const useAuth = (): AuthContextProps => { const context = useContext(AuthContext); if (!context) { @@ -81,6 +110,6 @@ function useAuth(): AuthContextProps { } return context; -} +}; export { AuthProvider, useAuth }; diff --git a/interface/src/hooks/useCookie.tsx b/interface/src/hooks/useCookie.tsx new file mode 100644 index 0000000..48ed1be --- /dev/null +++ b/interface/src/hooks/useCookie.tsx @@ -0,0 +1,48 @@ +import React, { createContext, useContext, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import CookieConsent, { Cookies } from 'react-cookie-consent'; + +interface CookieContextProps { + ConsentNotification: () => JSX.Element; +} + +const CookieContext = createContext({} as CookieContextProps); + +const CookieProvider: React.FC = ({ children }) => { + const { t } = useTranslation(); + const ConsentNotification = useCallback(() => { + if (Cookies.get('@GoBarber:cookies') !== 'true') + return ( + { + Cookies.set('@GoBarber:cookies', true); + }} + debug + > + {t('cookie1')} +
+ {t('cookie2')} +
+ ); + return null; + }, [t]); + return {children}; +}; + +const useCookie = (): CookieContextProps => { + const context = useContext(CookieContext); + + if (!context) { + throw new Error('useCookie must be used within an CookieProvider'); + } + + return context; +}; + +export { CookieProvider, useCookie }; diff --git a/interface/src/hooks/useLanguage.tsx b/interface/src/hooks/useLanguage.tsx new file mode 100644 index 0000000..e06cb85 --- /dev/null +++ b/interface/src/hooks/useLanguage.tsx @@ -0,0 +1,39 @@ +import React, { createContext, useState, useEffect, useContext } from 'react'; +import i18n from 'i18next'; +import { Cookies } from 'react-cookie-consent'; + +interface LanguageContextProps { + changeLanguage(lgn: string): void; + language: string; +} + +const LanguageContext = createContext({} as LanguageContextProps); + +const LanguageProvider: React.FC = ({ children }) => { + const [language, setLanguage] = useState('en'); + + function changeLanguage(lgn: string) { + i18n.changeLanguage(lgn); + Cookies.set('@GoBarber:language', lgn); + setLanguage(lgn); + } + + useEffect(() => { + const lgnstrg = Cookies.get('@GoBarber:language'); + changeLanguage(lgnstrg || 'en'); + }, []); + + return {children}; +}; + +const useLanguage = (): LanguageContextProps => { + const context = useContext(LanguageContext); + + if (!context) { + throw new Error('useLanguage must be used within an LanguageProvider'); + } + + return context; +}; + +export { LanguageProvider, useLanguage }; diff --git a/interface/src/hooks/useTheme.tsx b/interface/src/hooks/useTheme.tsx new file mode 100644 index 0000000..26b8f29 --- /dev/null +++ b/interface/src/hooks/useTheme.tsx @@ -0,0 +1,38 @@ +/* eslint-disable @typescript-eslint/ban-types */ +import React, { createContext, useState, useContext } from 'react'; +import { Cookies } from 'react-cookie-consent'; + +import { ThemeName, themes } from '../styles/themes'; + +interface ChangeThemeContextProps { + themeName: 'light' | 'dark'; + handleChangeTheme: () => void; + currentTheme: Object; +} + +const ChangeThemeContext = createContext({} as ChangeThemeContextProps); + +const ChangeThemeProvider: React.FC = ({ children }) => { + const [themeName, setThemeName] = useState(Cookies.get('@GoBarber:theme') === 'dark' ? 'dark' : 'light'); + const currentTheme = themes[themeName]; + + const handleChangeTheme = () => { + const theme = themeName === 'light' ? 'dark' : 'light'; + setThemeName(theme); + Cookies.set('@GoBarber:theme', theme); + }; + + return {children}; +}; + +const useChangeTheme = (): ChangeThemeContextProps => { + const context = useContext(ChangeThemeContext); + + if (!context) { + throw new Error('useChangeTheme must be used within an ChangeThemeProvider'); + } + + return context; +}; + +export { ChangeThemeProvider, useChangeTheme }; diff --git a/interface/src/i18n.ts b/interface/src/i18n.ts index 8fdaa8c..788b8b3 100644 --- a/interface/src/i18n.ts +++ b/interface/src/i18n.ts @@ -1,9 +1,10 @@ import i18n from 'i18next'; import { initReactI18next } from 'react-i18next'; +import { Cookies } from 'react-cookie-consent'; import Backend from 'i18next-xhr-backend'; -const lgnstrg = localStorage.getItem('defaultLanguage') !== 'pt' ? 'en' : 'pt'; +const lgnstrg = Cookies.get('@GoBarber:language'); i18n .use(Backend) @@ -11,7 +12,7 @@ i18n .init({ debug: true, - lng: lgnstrg, + lng: lgnstrg || 'en', fallbackLng: 'en', whitelist: ['en', 'pt'], diff --git a/interface/src/index.tsx b/interface/src/index.tsx index a226f5d..abb42c2 100644 --- a/interface/src/index.tsx +++ b/interface/src/index.tsx @@ -4,6 +4,8 @@ import { I18nextProvider } from 'react-i18next'; import LoaderSpinner from './components/LoaderSpinner'; +import AppProvider from './hooks'; + import App from './App'; import i18n from './i18n'; @@ -13,7 +15,9 @@ ReactDOM.render( }> - + + + , diff --git a/interface/src/layouts/Auth/index.tsx b/interface/src/layouts/Auth/index.tsx new file mode 100644 index 0000000..4ee3306 --- /dev/null +++ b/interface/src/layouts/Auth/index.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; + +import { Footer } from '../../components'; + +import { useChangeTheme, useCookie } from '../../hooks'; + +import { Container, Logo, MoonIcon, SunIcon, Main } from './styles'; + +import logo from '~/assets/svg/logo-interna.svg'; + +interface Props { + direction?: 'left' | 'right'; + title?: string; +} + +const Auth: React.FC = ({ direction, title, children }) => { + const { themeName, handleChangeTheme } = useChangeTheme(); + const { ConsentNotification } = useCookie(); + + document.title = `${title ? `${title} | ` : ''}Go Barber`; + + return ( + + + + + Go Barber + + + + go barber + + + {themeName === 'light' ? : } + +
+ {children} +
+
+
+ ); +}; + +export default Auth; diff --git a/interface/src/layouts/Auth/styles.ts b/interface/src/layouts/Auth/styles.ts new file mode 100644 index 0000000..e054bea --- /dev/null +++ b/interface/src/layouts/Auth/styles.ts @@ -0,0 +1,86 @@ +import styled, { css } from 'styled-components'; + +import { RiMoonLine, RiSunLine } from 'react-icons/ri'; + +import signinbgRight from '~assets/img/background.png'; +import signinbgLeft from '~assets/img/background-left.png'; + +interface ContainerProps { + direction: string; +} + +const iconCSS = css` + margin-top: 3px; + width: 15px; + height: 15px; + fill: var(--light-grey); + color: var(--light-grey); + cursor: pointer; +`; + +export const Container = styled.div` + display: flex; + align-items: center; + height: 100vh; + transition: all 0.5s ease; + position: relative; + + @media (min-width: 760px) { + background: ${props => (props.direction === 'left' ? `url(${signinbgLeft})` : `url(${signinbgRight})`)} no-repeat center; + background-size: auto 100%; + background-position: top ${props => (props.direction === 'left' ? `left` : `right`)}; + transition: all 0.5s ease; + justify-content: ${props => (props.direction === 'left' ? `flex-end` : `flex-start`)}; + } +`; + +export const Logo = styled.div` + position: absolute; + top: 0; + padding: 20px 40px; + display: flex; + align-items: center; + width: 260px; + justify-content: space-between; + transition: all 0.5s ease; + + img { + width: 31px; + height: 31px; + } + + a { + text-decoration: none; + height: 100%; + display: flex; + align-items: center; + } + + span { + font-size: 20px; + font-weight: 500; + color: var(--title-color); + } + + @media (min-width: 768px) { + flex-direction: ${props => (props.direction === 'left' ? `row-reverse` : `row`)}; + } +`; + +export const Main = styled.div` + width: 100%; + height: calc(100% - 71px); + margin-top: 71px; + display: flex; + /* align-items: center; */ + justify-content: center; + flex-direction: column; +`; + +export const MoonIcon = styled(RiMoonLine)` + ${iconCSS} +`; + +export const SunIcon = styled(RiSunLine)` + ${iconCSS} +`; diff --git a/interface/src/layouts/Dashboard/index.tsx b/interface/src/layouts/Dashboard/index.tsx new file mode 100644 index 0000000..a26be8e --- /dev/null +++ b/interface/src/layouts/Dashboard/index.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import { Header } from '../../components'; + +import { Container } from './styles'; + +interface Props { + direction?: 'left' | 'right' | null; + title?: string; +} + +const Dashboard: React.FC = ({ title, children }) => { + document.title = `${title ? `${title} | ` : ''}Go Barber`; + + return ( + +
+ {children} + + ); +}; + +export default Dashboard; diff --git a/interface/src/layouts/Dashboard/styles.ts b/interface/src/layouts/Dashboard/styles.ts new file mode 100644 index 0000000..009f9f8 --- /dev/null +++ b/interface/src/layouts/Dashboard/styles.ts @@ -0,0 +1,10 @@ +/* eslint-disable import/prefer-default-export */ +import styled from 'styled-components'; + +export const Container = styled.div` + display: flex; + flex-direction: column; + width: 100%; + align-items: center; + position: relative; +`; diff --git a/interface/src/layouts/index.ts b/interface/src/layouts/index.ts new file mode 100644 index 0000000..e862bb2 --- /dev/null +++ b/interface/src/layouts/index.ts @@ -0,0 +1,2 @@ +export { default as Auth } from './Auth'; +export { default as Dashboard } from './Dashboard'; diff --git a/interface/src/pages/Dashboard/index.tsx b/interface/src/pages/Dashboard/index.tsx index d813ff3..c670a10 100644 --- a/interface/src/pages/Dashboard/index.tsx +++ b/interface/src/pages/Dashboard/index.tsx @@ -1,17 +1,19 @@ import React, { useState, useCallback, useEffect, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { CircularProgress } from '@material-ui/core'; import { isToday, isAfter, format, parseISO } from 'date-fns'; -import ptBR from 'date-fns/locale/pt-BR'; +import { ptBR, enUS } from 'date-fns/locale'; import DayPicker, { DayModifiers } from 'react-day-picker'; -import 'react-day-picker/lib/style.css'; -import { FiClock } from 'react-icons/fi'; -import blankAvatar from '../../assets/svg/blank-avatar.svg'; -import Header from '../../components/Header'; -import { Container, Content, Schedule, NextAppointment, Section, Appointment, Calendar } from './styles'; +import { FiClock } from 'react-icons/fi'; -import { useAuth } from '../../hooks/auth'; +import { useAuth, useLanguage } from '../../hooks'; import api from '../../services/api'; +import notify from '../../services/toast'; + +import 'react-day-picker/lib/style.css'; +import { Content, Schedule, NextAppointment, Section, Loader, Appointment, Calendar, UserIcon } from './styles'; interface MonthAvailabilityItem { day: number; @@ -29,46 +31,68 @@ interface ScheduleAppointment { } const Dashboard: React.FC = () => { + const { t } = useTranslation(); + const { user } = useAuth(); + const { language } = useLanguage(); + + const [loading, setLoading] = useState(true); const [selectedDate, setSelectedDate] = useState(new Date()); const [currentMonth, setCurrentMonth] = useState(new Date()); const [appointments, setAppointments] = useState([]); const [monthAvailability, setMonthAvailability] = useState([]); - const { user } = useAuth(); + const loadProviderAppointments = useCallback(async () => { + try { + setLoading(true); + const response = await api.get('/appointments/me', { + params: { + year: selectedDate.getFullYear(), + month: selectedDate.getMonth() + 1, + day: selectedDate.getDate(), + }, + }); - useEffect(() => { - api - .get(`/providers/${user.id}/month-availability`, { + const { data } = response; + + const appointmentsFormatted = data.map(appointment => { + return { + ...appointment, + hourFormatted: format(parseISO(appointment.date), `${language === 'pt' ? 'HH:mm' : 'KK:mm a'}`), + }; + }); + + setAppointments(appointmentsFormatted); + } catch (err) { + setAppointments([]); + notify(err?.response?.data?.message ? err.response.data.message : err.message, 'error'); + } finally { + setLoading(false); + } + }, [selectedDate, language]); + + const loadMonthAvailability = useCallback(async () => { + try { + const response = await api.get(`/providers/${user.id}/month-availability`, { params: { year: currentMonth.getFullYear(), month: currentMonth.getMonth() + 1, }, - }) - .then(response => { - setMonthAvailability(response.data); }); + const { data } = response; + setMonthAvailability(data); + } catch (err) { + setMonthAvailability([]); + notify(err?.response?.data?.message ? err.response.data.message : err.message, 'error'); + } }, [currentMonth, user.id]); useEffect(() => { - api - .get('/appointments/me', { - params: { - year: selectedDate.getFullYear(), - month: selectedDate.getMonth() + 1, - day: selectedDate.getDate(), - }, - }) - .then(response => { - const appointmentsFormatted = response.data.map(appointment => { - return { - ...appointment, - hourFormatted: format(parseISO(appointment.date), 'HH:mm'), - }; - }); - - setAppointments(appointmentsFormatted); - }); - }, [selectedDate]); + loadMonthAvailability(); + }, [loadMonthAvailability]); + + useEffect(() => { + loadProviderAppointments(); + }, [loadProviderAppointments]); const handleDateChange = useCallback((day: Date, modifiers: DayModifiers) => { if (modifiers.available && !modifiers.disabled) { @@ -94,16 +118,10 @@ const Dashboard: React.FC = () => { }, [currentMonth, monthAvailability]); const selectedDateAsText = useMemo(() => { - return format(selectedDate, "'Dia' dd 'de' MMMM", { - locale: ptBR, - }); - }, [selectedDate]); - - const selectedWeekDay = useMemo(() => { - return format(selectedDate, 'cccc', { - locale: ptBR, + return format(selectedDate, 'PPPP', { + locale: language === 'pt' ? ptBR : enUS, }); - }, [selectedDate]); + }, [selectedDate, language]); const morningAppointments = useMemo(() => { return appointments.filter(appointment => { @@ -122,83 +140,85 @@ const Dashboard: React.FC = () => { }, [appointments]); return ( - -
- - -

Horários agendados

-

- {isToday(selectedDate) && Hoje} - {selectedDateAsText} - {selectedWeekDay} -

- {isToday(selectedDate) && nextAppointment && ( - - Agendamento a seguir -
- {nextAppointment.id} - - {nextAppointment.user.name} - - - {nextAppointment.hourFormatted} - -
-
- )} - -
- Manhã - - {morningAppointments.length === 0 &&

Nenhum agendamento marcado nesse período

} - {morningAppointments.map(appointment => ( - - - - {appointment.hourFormatted} - -
- {appointment.user.name} - - {appointment.user.name} -
-
- ))} -
-
- Tarde - {afternoonAppointments.length === 0 &&

Nenhum agendamento marcado nesse período

} - {afternoonAppointments.map(appointment => ( - - - - {appointment.hourFormatted} - + + +

{t('scheduledAppointments')}

+

+ {selectedDateAsText} +

+ + {loading ? ( + + + + ) : ( + <> + {isToday(selectedDate) && nextAppointment && ( + + {t('nextAppointmenst')}
- {appointment.user.name} + {nextAppointment.id} - {appointment.user.name} + {nextAppointment.user.name} + + + {nextAppointment.hourFormatted} +
-
- ))} -
-
- - - -
- + + )} +
+ {t('morning')} + + {morningAppointments.length === 0 &&

{t('noAppointments')}

} + {morningAppointments.map(appointment => ( + + + + {appointment.hourFormatted} + +
+ {appointment.user.avatar_url ? {appointment.user.name} : } + {appointment.user.name} +
+
+ ))} +
+
+ {t('afternoon')} + {afternoonAppointments.length === 0 &&

{t('noAppointments')}

} + {afternoonAppointments.map(appointment => ( + + + + {appointment.hourFormatted} + +
+ {appointment.user.avatar_url ? {appointment.user.name} : } + + {appointment.user.name} +
+
+ ))} +
+ + )} + + + + + ); }; diff --git a/interface/src/pages/Dashboard/styles.ts b/interface/src/pages/Dashboard/styles.ts index 08b44c8..0430394 100644 --- a/interface/src/pages/Dashboard/styles.ts +++ b/interface/src/pages/Dashboard/styles.ts @@ -1,25 +1,44 @@ import styled from 'styled-components'; -import { shade } from 'polished'; - -export const Container = styled.div``; +import { FaUserCircle } from 'react-icons/fa'; export const Content = styled.main` max-width: 1120px; + width: 100%; margin: 64px auto; display: flex; + flex-direction: column-reverse; + transition: all var(--transition); + + @media (min-width: 978px) { + justify-content: space-between; + flex-direction: row; + transition: all var(--transition); + } `; export const Schedule = styled.div` flex: 1; - margin-right: 120px; + margin-top: 20px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + + @media (min-width: 978px) { + padding: 0 30px; + margin-top: 0px; + align-items: flex-start; + justify-content: flex-start; + } h1 { font-size: 36px; + color: var(--title-color); } p { margin-top: 8px; - color: var(--primary); + color: var(--color-primary); display: flex; align-items: center; font-weight: 500; @@ -34,7 +53,7 @@ export const Schedule = styled.div` content: ''; width: 1px; height: 12px; - background: var(--primary); + background: var(--color-primary); margin: 0 8px; } } @@ -65,7 +84,7 @@ export const NextAppointment = styled.div` width: 1px; left: 0; top: 10%; - background: var(--primary); + background: var(--color-primary); } img { @@ -76,7 +95,7 @@ export const NextAppointment = styled.div` strong { margin-left: 24px; - color: #fff; + color: var(--color-white); } span { @@ -86,7 +105,7 @@ export const NextAppointment = styled.div` color: #999591; svg { - color: var(--primary); + color: var(--color-primary); margin-right: 8px; } } @@ -97,20 +116,31 @@ export const Section = styled.div` margin-top: 48px; > strong { - color: #999591; + color: var(--text-color); font-size: 20px; line-height: 26px; - border-bottom: 1px solid #3e3b47; + border-bottom: 1px solid var(--day-available); display: block; padding-bottom: 16px; margin-bottom: 16px; } > p { - color: #999591; + color: var(--text-color); + font-weight: 400; + font-size: 16px; } `; +export const Loader = styled.div` + max-width: 500px; + padding: 50px; + display: flex; + align-items: center; + justify-content: center; + width: 100%; +`; + export const Appointment = styled.div` display: flex; align-items: center; @@ -120,21 +150,23 @@ export const Appointment = styled.div` } span { + font-size: 14px; margin-left: auto; display: flex; align-items: center; - width: 70px; - color: #f4ede8; + width: 90px; + color: var(--color-primary-light); svg { - color: var(--primary); + width: 15px; + color: var(--color-primary); margin-right: 8px; } } div { flex: 1; - background: #3e3b47; + background: var(--calendar-background); display: flex; align-items: center; padding: 16px 24px; @@ -150,17 +182,21 @@ export const Appointment = styled.div` strong { margin-left: 24px; font-size: 20px; - color: #fff; + color: var(--text-color); } } `; export const Calendar = styled.aside` - width: 380px; + display: flex; + align-items: flex-start; + justify-content: center; + padding: 0 30px; .DayPicker { - background: #28262e; + background: var(--calendar-background); border-radius: 10px; + max-width: 380px; } .DayPicker-wrapper { @@ -173,6 +209,7 @@ export const Calendar = styled.aside` } .DayPicker-Month { + color: var(--text-color); border-collapse: separate; border-spacing: 8px; margin: 16px; @@ -184,13 +221,14 @@ export const Calendar = styled.aside` } .DayPicker-Day--available:not(.DayPicker-Day--outside) { - background: #3e3b47; + background: var(--day-available); border-radius: 10px; - color: #fff; + color: var(--color-white); } .DayPicker:not(.DayPicker--interactionDisabled) .DayPicker-Day:not(.DayPicker-Day--disabled):not(.DayPicker-Day--selected):not(.DayPicker-Day--outside):hover { - background: ${shade(0.2, '#3e3b47')}; + background: #585466; + color: var(--color-white) !important; } .DayPicker-Day--today { @@ -198,13 +236,19 @@ export const Calendar = styled.aside` } .DayPicker-Day--disabled { - color: #666360 !important; + color: var(--disabled) !important; background: transparent !important; } .DayPicker-Day--selected { - background: var(--primary) !important; + background: var(--color-primary) !important; border-radius: 10px; - color: #232129 !important; + color: var(--color-white) !important; } `; + +export const UserIcon = styled(FaUserCircle)` + color: var(--text-color); + width: 35px; + height: 35px; +`; diff --git a/interface/src/pages/Profile/index.tsx b/interface/src/pages/Profile/index.tsx new file mode 100644 index 0000000..48d1e9b --- /dev/null +++ b/interface/src/pages/Profile/index.tsx @@ -0,0 +1,24 @@ +import React, { useCallback, useRef, ChangeEvent } from 'react'; + +import { FiArrowLeft, FiMail, FiLock, FiUser, FiCamera } from 'react-icons/fi'; +import * as Yup from 'yup'; +import { Link, useHistory } from 'react-router-dom'; + +import api from '../../services/api'; + +import { Container, Header, Content, AvatarInput } from './styles'; + +interface ProfileFormData { + name: string; + email: string; + password: string; + oldPassword: string; + passwordConfirmation: string; +} + +const Profile: React.FC = () => { + const history = useHistory(); + + return ; +}; +export default Profile; diff --git a/interface/src/pages/Profile/styles.ts b/interface/src/pages/Profile/styles.ts new file mode 100644 index 0000000..92bff59 --- /dev/null +++ b/interface/src/pages/Profile/styles.ts @@ -0,0 +1,103 @@ +import styled from 'styled-components'; +import { shade } from 'polished'; + +export const Container = styled.div` + max-width: 1120px; + width: 100%; + margin: 64px auto; + display: flex; + transition: all var(--transition); +`; + +export const Header = styled.header` + height: 144px; + background: #28262e; + + display: flex; + align-items: center; + + div { + width: 100%; + max-width: 1120px; + margin: 0 auto; + + svg { + color: #999591; + width: 24px; + height: 24px; + } + } +`; + +export const Content = styled.div` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + margin: -176px auto 0; + + width: 100%; + + form { + margin: 80px 0; + width: 340px; + text-align: center; + display: flex; + flex-direction: column; + + h1 { + margin-bottom: 24px; + font-size: 20px; + text-align: left; + } + + div:nth-child(5) { + margin-top: 24px; + } + } +`; + +export const AvatarInput = styled.div` + margin-bottom: 32px; + position: relative; + align-self: center; + img { + width: 186px; + height: 186px; + border-radius: 50%; + } + + label { + position: absolute; + width: 48px; + height: 48px; + background: #ff9000; + border-radius: 50%; + border: 0; + right: 0; + bottom: 0; + transition: background-color 0.2s; + + display: flex; + align-items: center; + justify-content: center; + + input { + display: none; + } + + &:hover { + cursor: pointer; + } + + svg { + width: 20px; + height: 20px; + color: #312e38; + } + + &:hover { + background: ${shade(0.2, '#ff9000')}; + } + } +`; diff --git a/interface/src/pages/ResetPassword/index.tsx b/interface/src/pages/ResetPassword/index.tsx new file mode 100644 index 0000000..e4d86f6 --- /dev/null +++ b/interface/src/pages/ResetPassword/index.tsx @@ -0,0 +1,189 @@ +import React, { useState, useEffect, useCallback, useRef } from 'react'; +import { useHistory } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { Formik } from 'formik'; +import ReCAPTCHA from 'react-google-recaptcha'; +import { InputAdornment } from '@material-ui/core'; +import { FaEye, FaEyeSlash } from 'react-icons/fa'; + +import { ButtonContent } from '../../components'; + +import api from '../../services/api'; +import notify from '../../services/toast'; +import { useLanguage } from '../../hooks'; + +import Schemas from '../../validators'; + +import { TextField, ButtonOutlined } from '../../styles/MaterialUI'; + +import { Content, AnimationContainer } from './styles'; + +const ResetPassword: React.FC = () => { + const { language } = useLanguage(); + const { t } = useTranslation(); + const history = useHistory(); + + const [loading, setLoading] = useState(false); + const [isCaptchaValidated, setCaptchaValidated] = useState(null); + const [hasScriptError, setHasScriptError] = useState(false); + const [showPassword, setShowPassword] = useState(false); + const [showCDPassword, setShowCDPassword] = useState(false); + const [password, setPassword] = useState(''); + const [CDpassword, setCDPassword] = useState(''); + + const recaptchaRef: any = useRef({}); + + const { search } = window.location; + const params = new URLSearchParams(search); + const token = params.get('token'); + + if (!token) { + history.push('/'); + notify(t('noToken'), 'error'); + } + + const handlePasswordReset = useCallback( + async (pass: string, password_confirmation: string): Promise => { + try { + setLoading(true); + const body = { + password: pass, + password_confirmation, + token, + }; + await api.post('user/password/reset', body); + notify(t('passwordCreated'), 'success'); + + history.push('/'); + } catch (err) { + notify(err.response && err.response.data ? err.response.data.message : err.message, 'error'); + } finally { + setLoading(false); + } + }, + [t, history, token] + ); + + const handleCaptcha = useCallback( + async (value: string | null): Promise => { + if (value) { + setCaptchaValidated(true); + await handlePasswordReset(password, CDpassword); + } else setCaptchaValidated(false); + }, + [password, CDpassword, handlePasswordReset] + ); + + const loadRecaptcha = useCallback(() => { + try { + const script = document.createElement('script'); + script.async = true; + script.defer = true; + script.src = 'https://www.google.com/recaptcha/api.js'; + document.body.appendChild(script); + script.onerror = () => { + setHasScriptError(true); + }; + } catch (err) { + setHasScriptError(true); + } + }, []); + + useEffect(() => { + loadRecaptcha(); + }, [loadRecaptcha]); + + return ( + + + { + setSubmitting(true); + setPassword(values.password); + setCDPassword(values.password_confirmation); + + if (isCaptchaValidated || hasScriptError) { + await handlePasswordReset(values.password, values.password_confirmation); + } else await recaptchaRef.current?.execute(); + setSubmitting(false); + }} + > + {} + {({ values, errors, touched, handleChange, handleBlur, handleSubmit }) => ( +
+

{t('newPassword')}

+ +
+ setShowPassword(!showPassword)}> + {showPassword ? : } + + ), + }} + /> +
+ +
+ setShowCDPassword(!showCDPassword)}> + {showCDPassword ? : } + + ), + }} + /> +
+ +
+ setHasScriptError(true)} + /> +
+ +
+ + + +
+
+ )} +
+
+
+ ); +}; + +export default ResetPassword; diff --git a/interface/src/pages/ResetPassword/styles.ts b/interface/src/pages/ResetPassword/styles.ts new file mode 100644 index 0000000..d8c3d1f --- /dev/null +++ b/interface/src/pages/ResetPassword/styles.ts @@ -0,0 +1,100 @@ +import styled, { css, keyframes } from 'styled-components'; + +import { CgLogIn } from 'react-icons/cg'; +import { FaUserPlus } from 'react-icons/fa'; +import { RiKeyFill } from 'react-icons/ri'; + +const iconCSS = css` + width: 15px; + height: 15px; + color: var(--primary); +`; + +export const Content = styled.div` + display: flex; + flex-direction: column; + align-items: center; + place-content: center; + width: 100%; + height: 100%; + transition: var(--transition); + overflow: hidden; + + @media (min-width: 760px) { + width: 50%; + margin-left: 50%; + } +`; + +const appearFromRight = keyframes` + from { + opacity: 0; + transform: translateX(50px); + + } + to { + opacity: 1; + transform: translateX(0); + } +`; + +export const AnimationContainer = styled.div` + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + animation: ${appearFromRight} 1s; + + form { + margin: 40px 0; + padding: 30px; + width: 340px; + + text-align: center; + border-radius: var(--border-radius); + background: var(--dark-grey); + box-shadow: var(--box-shadow); + position: relative; + + h1 { + color: var(--title-color); + margin-bottom: 40px; + font-size: 20px; + } + + .password-reset-message { + margin: 10px 20px 30px 20px; + font-size: 14px; + color: var(--text-color); + } + + .submit-button { + margin-top: 20px; + } + } + + .bottom-links { + margin-top: 30px; + display: flex; + align-items: center; + justify-content: center; + transition: var(--transition); + + &:hover { + filter: var(--hover-effect); + transition: var(--transition); + } + } +`; + +export const LogInIcon = styled(CgLogIn)` + ${iconCSS} +`; + +export const RegisterIcon = styled(FaUserPlus)` + ${iconCSS} +`; + +export const ForgotPassIcon = styled(RiKeyFill)` + ${iconCSS} +`; diff --git a/interface/src/pages/SignIn/index.tsx b/interface/src/pages/SignIn/index.tsx index f2ea36d..8e5ecb3 100644 --- a/interface/src/pages/SignIn/index.tsx +++ b/interface/src/pages/SignIn/index.tsx @@ -1,26 +1,35 @@ -import React, { useState, useEffect } from 'react'; +/* eslint-disable react/jsx-no-duplicate-props */ +import React, { useState, useEffect, useCallback, useRef } from 'react'; import { Link } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import { useSpring, animated as a } from 'react-spring'; +import { useSpring, animated } from 'react-spring'; import { Formik } from 'formik'; import ReCAPTCHA from 'react-google-recaptcha'; +import { InputAdornment } from '@material-ui/core'; +import { FaEye, FaEyeSlash } from 'react-icons/fa'; -import { useAuth } from '../../hooks/auth'; +import { ButtonContent } from '../../components'; -import Button from '../../components/Button'; -import { PasswordInput, EmailInput } from '../../components/Inputs'; +import { useAuth, useLanguage } from '../../hooks'; import Schemas from '../../validators'; -import { Container, Content, Background, LogInIcon, RegisterIcon, ForgotPassIcon, AnimationContainer } from './styles'; +import { TextField, ButtonOutlined } from '../../styles/MaterialUI'; -import logo from '~/assets/svg/logo.svg'; +import { Content, LogInIcon, RegisterIcon, ForgotPassIcon, AnimationContainer } from './styles'; const SignIn: React.FC = () => { - const { signIn, loading } = useAuth(); + const { signIn, handleForgotPassword, loading } = useAuth(); + const { language } = useLanguage(); const { t } = useTranslation(); const [flippedCard, setFlippedCard] = useState(false); const [isCaptchaValidated, setCaptchaValidated] = useState(null); + const [hasScriptError, setHasScriptError] = useState(false); + const [showPassword, setShowPassword] = useState(false); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + + const recaptchaRef: any = useRef({}); const { transform, opacity } = useSpring({ opacity: flippedCard ? 1 : 0, @@ -28,113 +37,203 @@ const SignIn: React.FC = () => { config: { mass: 5, tension: 500, friction: 80 }, }); - useEffect(() => { - const script = document.createElement('script'); - script.async = true; - script.defer = true; - script.src = 'https://www.google.com/recaptcha/api.js'; - document.body.appendChild(script); - }, []); - - function verifyCallback(recaptchaToken) { - setCaptchaValidated(recaptchaToken); - } + const handleCaptcha = useCallback( + async (value: string | null): Promise => { + if (value) { + setCaptchaValidated(true); + if (flippedCard) await handleForgotPassword(email); + else await signIn(email, password); + } else setCaptchaValidated(false); + }, + [email, flippedCard, handleForgotPassword, password, signIn] + ); - const handleFlippedCard = () => { + const handleFlipCard = useCallback(() => { if (!loading) { + setEmail(''); + setPassword(''); setFlippedCard(!flippedCard); - setCaptchaValidated(null); + setShowPassword(false); } - }; + }, [flippedCard, loading]); + + const loadRecaptcha = useCallback(() => { + try { + const script = document.createElement('script'); + script.async = true; + script.defer = true; + script.src = 'https://www.google.com/recaptcha/api.js'; + document.body.appendChild(script); + script.onerror = () => { + setHasScriptError(true); + }; + } catch (err) { + setHasScriptError(true); + } + }, []); + + useEffect(() => { + loadRecaptcha(); + }, [loadRecaptcha]); return ( - - - - Go Barber - - 1 - Number(o)), transform }}> - { - setSubmitting(true); - signIn(values.email, values.password); - setSubmitting(false); - }} - > - {} - {({ values, errors, touched, handleChange, handleBlur, handleSubmit }) => ( - -
-

{t('sessionStart')}

- - - - - - + + + )} +
+ + + `${trans} rotateX(180deg)`) }}> + { + setSubmitting(true); + setEmail(values.email); + if (isCaptchaValidated || hasScriptError || process.env.NODE_ENV === 'test') { + await handleForgotPassword(values.email); + } else await recaptchaRef.current?.execute(); + setSubmitting(false); + }} + > + {} + {({ values, errors, touched, handleChange, handleBlur, handleSubmit }) => ( +
+

{t('forgotpassword')}

+ +
+ +
+ +
{t('passwordResetMessage')}
+ +
+ + + +
+ +
+ +
+
+ )} +
+
+ +
+ setHasScriptError(true)} + /> +
+ + + + {t('register')} + +
+
); }; diff --git a/interface/src/pages/SignIn/styles.ts b/interface/src/pages/SignIn/styles.ts index 40198e4..3333460 100644 --- a/interface/src/pages/SignIn/styles.ts +++ b/interface/src/pages/SignIn/styles.ts @@ -1,10 +1,8 @@ import styled, { css, keyframes } from 'styled-components'; -import { LogIn } from 'styled-icons/boxicons-regular'; -import { UserPlus } from 'styled-icons/boxicons-solid'; -import { VpnKey } from 'styled-icons/material'; - -import signinbg from '~assets/img/sign-in-background.png'; +import { CgLogIn } from 'react-icons/cg'; +import { FaUserPlus } from 'react-icons/fa'; +import { RiKeyFill } from 'react-icons/ri'; const iconCSS = css` width: 15px; @@ -12,21 +10,19 @@ const iconCSS = css` color: var(--primary); `; -export const Container = styled.div` - display: flex; - align-items: stretch; - height: 100vh; -`; - export const Content = styled.div` display: flex; flex-direction: column; align-items: center; place-content: center; width: 100%; - max-width: 700px; - transition: var(--transition-slow); + height: 100%; + transition: var(--transition); overflow: hidden; + + @media (min-width: 760px) { + width: 50%; + } `; const appearFromLeft = keyframes` @@ -50,78 +46,54 @@ export const AnimationContainer = styled.div` form { margin: 40px 0; - padding: 20px; + padding: 30px; width: 340px; + text-align: center; border-radius: var(--border-radius); - background: var(--senary); + background: var(--dark-grey); box-shadow: var(--box-shadow); position: relative; h1 { - margin-bottom: 30px; + color: var(--title-color); + margin-bottom: 40px; font-size: 20px; } - .captcha { - margin: 25px 0 15px 0; - display: flex; - align-items: center; - justify-content: center; + .password-reset-message { + margin: 10px 20px 30px 20px; + font-size: 14px; + color: var(--text-color); } - } - .bottom-links { - margin-top: 20px; - display: flex; - align-items: center; - justify-content: center; - transition: var(--transition-slow); - - &:hover { - filter: var(--hover-effect); - transition: var(--transition-slow); + .submit-button { + margin-top: 20px; } } - .back-link { - font-size: 13px; + .bottom-links { + margin-top: 30px; display: flex; align-items: center; - cursor: pointer; - color: var(--font-color); - text-decoration: none; + justify-content: center; + transition: var(--transition); &:hover { filter: var(--hover-effect); - transition: var(--transition-slow); + transition: var(--transition); } } - - .back-link svg { - color: var(--primary-light); - margin-right: 8px; - } - - .back-link svg path { - color: var(--primary-light); - } -`; - -export const Background = styled.div` - flex: 1; - background: url(${signinbg}) no-repeat center; - background-size: cover; `; -export const LogInIcon = styled(LogIn)` +export const LogInIcon = styled(CgLogIn)` ${iconCSS} `; -export const RegisterIcon = styled(UserPlus)` +export const RegisterIcon = styled(FaUserPlus)` ${iconCSS} `; -export const ForgotPassIcon = styled(VpnKey)` +export const ForgotPassIcon = styled(RiKeyFill)` ${iconCSS} `; diff --git a/interface/src/pages/SignUp/index.tsx b/interface/src/pages/SignUp/index.tsx index 3c0ee65..a235858 100644 --- a/interface/src/pages/SignUp/index.tsx +++ b/interface/src/pages/SignUp/index.tsx @@ -1,19 +1,22 @@ -import React, { useState, useCallback } from 'react'; +import React, { useState, useEffect, useCallback, useRef } from 'react'; import { Link, useHistory } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { Formik } from 'formik'; - -import Button from '../../components/Button'; -import { PasswordInput, EmailInput, NameInput } from '../../components/Inputs'; - -import Schemas from '../../validators'; +import ReCAPTCHA from 'react-google-recaptcha'; +import { InputAdornment } from '@material-ui/core'; +import { FaEye, FaEyeSlash } from 'react-icons/fa'; import api from '../../services/api'; import notify from '../../services/toast'; +import { useLanguage } from '../../hooks'; -import { Container, Content, Background, LogInIcon, AnimationContainer } from './styles'; +import { ButtonContent } from '../../components'; -import logo from '~/assets/svg/logo.svg'; +import Schemas from '../../validators'; + +import { TextField, ButtonOutlined } from '../../styles/MaterialUI'; + +import { Content, LogInIcon, AnimationContainer } from './styles'; interface SignUpFormData { name: string; @@ -22,15 +25,25 @@ interface SignUpFormData { } const SignUp: React.FC = () => { + const { language } = useLanguage(); const { t } = useTranslation(); - const [loading, setLoading] = useState(false); const history = useHistory(); + const [loading, setLoading] = useState(false); + const [isCaptchaValidated, setCaptchaValidated] = useState(null); + const [hasScriptError, setHasScriptError] = useState(false); + const [showPassword, setShowPassword] = useState(false); + const [email, setEmail] = useState(''); + const [name, setName] = useState(''); + const [password, setPassword] = useState(''); + + const recaptchaRef: any = useRef({}); + const handleRegister = useCallback( - async (data: SignUpFormData) => { + async (request: SignUpFormData): Promise => { try { setLoading(true); - await api.post('/user', data); + await api.post('/user', request); notify(t('userCreated'), 'success'); history.push('/'); @@ -40,53 +53,142 @@ const SignUp: React.FC = () => { setLoading(false); } }, - [history, t] + [t, history] ); + const handleCaptcha = useCallback( + async (value: string | null): Promise => { + if (value) { + setCaptchaValidated(true); + await handleRegister({ name, email, password }); + } else setCaptchaValidated(false); + }, + [email, name, password, handleRegister] + ); + + const loadRecaptcha = useCallback(() => { + try { + const script = document.createElement('script'); + script.async = true; + script.defer = true; + script.src = 'https://www.google.com/recaptcha/api.js'; + document.body.appendChild(script); + script.onerror = () => { + setHasScriptError(true); + }; + } catch (err) { + setHasScriptError(true); + } + }, []); + + useEffect(() => { + loadRecaptcha(); + }, [loadRecaptcha]); + return ( - - - - - Go Barber - { - setSubmitting(true); - handleRegister(values); - setSubmitting(false); - }} - > - {} - {({ values, errors, touched, handleChange, handleBlur, handleSubmit }) => ( - -
-

{t('createAccount')}

- - - - - - - -