From 6a8d0e9052f254ac71ba109cdd98dc1b85aef25b Mon Sep 17 00:00:00 2001 From: Svetlna Date: Mon, 29 Jan 2024 21:55:21 +0300 Subject: [PATCH] added create and save snippets feature for guest users --- backend/src/users/dto/update-user.dto.ts | 4 +- frontend/package.json | 1 + .../src/components/Forms/GuestSignUpForm.jsx | 211 ++ frontend/src/components/Modals/NewSnippet.jsx | 48 +- frontend/src/components/Modals/SignUp.jsx | 35 +- .../src/components/Navigation/GuestMenu.jsx | 50 + frontend/src/components/Navigation/index.jsx | 7 +- frontend/src/locales/en.json | 1 + frontend/src/locales/ru.json | 3 +- frontend/src/pages/landing/index.jsx | 25 + frontend/src/pages/landing/index.module.css | 15 + frontend/src/pages/profile/index.jsx | 4 +- frontend/src/providers/AuthProvider.jsx | 1 + yarn.lock | 1806 ++++++++++------- 14 files changed, 1511 insertions(+), 700 deletions(-) create mode 100644 frontend/src/components/Forms/GuestSignUpForm.jsx create mode 100644 frontend/src/components/Navigation/GuestMenu.jsx diff --git a/backend/src/users/dto/update-user.dto.ts b/backend/src/users/dto/update-user.dto.ts index 7fe8130f..7cf4f942 100644 --- a/backend/src/users/dto/update-user.dto.ts +++ b/backend/src/users/dto/update-user.dto.ts @@ -26,7 +26,7 @@ export class UpdateUserDto { @IsString() @Matches(/^[\w\S]*$/) @Validate(CheckUsername, { - message: 'Уже существует!', + message: 'usernameIsUsed', }) username?: string; @@ -39,7 +39,7 @@ export class UpdateUserDto { @IsString() @IsEmail() @Validate(CheckEmail, { - message: 'Уже существует!', + message: 'emailIsUsed', }) email?: string; diff --git a/frontend/package.json b/frontend/package.json index 66acb27d..17139a5a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -4,6 +4,7 @@ "private": true, "proxy": "http://localhost:5001", "dependencies": { + "@faker-js/faker": "^8.4.0", "@monaco-editor/react": "^4.5.1", "@reduxjs/toolkit": "^1.9.5", "@sentry/react": "^7.47.0", diff --git a/frontend/src/components/Forms/GuestSignUpForm.jsx b/frontend/src/components/Forms/GuestSignUpForm.jsx new file mode 100644 index 00000000..c5e564f0 --- /dev/null +++ b/frontend/src/components/Forms/GuestSignUpForm.jsx @@ -0,0 +1,211 @@ +import axios from 'axios'; +import { useFormik } from 'formik'; +import { useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { object } from 'yup'; + +import Button from 'react-bootstrap/Button'; +import Form from 'react-bootstrap/Form'; + +import { useDispatch, useSelector } from 'react-redux'; +import { useNavigate } from 'react-router-dom'; +import routes from '../../routes'; +import { email, password, username } from '../../utils/validationSchemas'; + +import GithubSignInButton from './GithubSignInButton.jsx'; +import PasswordVisibilityButton from './PasswordVisibilityButton.jsx'; +import FormAlert from './FormAlert.jsx'; +import { actions as userActions } from '../../slices/userSlice'; +import { actions as modalActions } from '../../slices/modalSlice.js'; + +function GuestSignupForm() { + const { t } = useTranslation(); + const emailRef = useRef(); + const usernameRef = useRef(); + const dispatch = useDispatch(); + const navigate = useNavigate(); + + const userInfo = useSelector((state) => state.user.userInfo); + + const initialFormState = { state: 'initial', message: '' }; + const [formState, setFormState] = useState(initialFormState); + + const [isPasswordVisible, setPasswordVisibility] = useState(false); + const handlePasswordVisibility = () => { + setPasswordVisibility(!isPasswordVisible); + }; + + useEffect(() => { + usernameRef.current.focus(); + }, []); + + const validationSchema = object().shape({ + username: username(), + email: email(), + password: password(), + }); + + const formik = useFormik({ + initialValues: { + username: '', + email: '', + password: '', + }, + validationSchema, + validateOnBlur: false, + onSubmit: async (values, actions) => { + setFormState(initialFormState); + const preparedValues = validationSchema.cast(values); + console.log(preparedValues); + try { + actions.setSubmitting(true); + + const response = await axios.put(routes.updateUserPath(userInfo.id), { + id: userInfo.id, + username: preparedValues.username, + email: preparedValues.email, + currPassword: JSON.parse(localStorage.getItem('guestUserData')) + .guestId, + password: values.password, + }); + dispatch(userActions.setUserInfo(response.data)); + localStorage.removeItem('guestUserData'); + dispatch(modalActions.closeModal()); + navigate(routes.profilePagePath(preparedValues.username)); + actions.setSubmitting(false); + } catch (err) { + if (!err.isAxiosError) { + formik.resetForm(); + setFormState({ + state: 'failed', + message: 'errors.unknown', + }); + throw err; + } + if ( + err.response?.status === 400 && + Array.isArray(err.response?.data?.errs?.message) + ) { + err.response.data.errs.message.forEach((e) => { + switch (e) { + case 'usernameIsUsed': + actions.setFieldError( + 'username', + 'errors.validation.usernameIsUsed', + ); + usernameRef.current.select(); + break; + case 'emailIsUsed': + actions.setFieldError('email', 'errors.validation.emailIsUsed'); + emailRef.current.select(); + break; + default: + setFormState({ + state: 'failed', + message: 'errors.network', + }); + throw err; + } + }); + } else { + formik.resetForm(); + setFormState({ + state: 'failed', + message: 'errors.network', + }); + throw err; + } + } + }, + }); + + return ( +
+ setFormState(initialFormState)} + state={formState.state} + > + {t(formState.message)} + +
+
+ + {t('profileSettings.usernameLabel')} + + + {t(formik.errors.username)} + + + + + {t('profileSettings.emailLabel')} + + + {t(formik.errors.email)} + + + + + {t('profileSettings.passwordLabel')} +
+ + + + {t(formik.errors.password)} + +
+
+
+ +
+ +
+ ); +} + +export default GuestSignupForm; diff --git a/frontend/src/components/Modals/NewSnippet.jsx b/frontend/src/components/Modals/NewSnippet.jsx index 0092a3da..02cd5955 100644 --- a/frontend/src/components/Modals/NewSnippet.jsx +++ b/frontend/src/components/Modals/NewSnippet.jsx @@ -4,6 +4,7 @@ import { useDispatch, useSelector } from 'react-redux'; import { useNavigate } from 'react-router-dom'; import { useFormik } from 'formik'; import { object } from 'yup'; +import { faker } from '@faker-js/faker'; import { Typeahead } from 'react-bootstrap-typeahead'; import Button from 'react-bootstrap/Button'; @@ -11,7 +12,10 @@ import Modal from 'react-bootstrap/Modal'; import Image from 'react-bootstrap/Image'; import Form from 'react-bootstrap/Form'; -import { useSnippets } from '../../hooks'; +import axios from 'axios'; +import routes from '../../routes'; + +import { useAuth, useSnippets } from '../../hooks'; import { snippetName } from '../../utils/validationSchemas'; import { actions as modalActions } from '../../slices/modalSlice.js'; import JavaScriptIcon from '../../assets/images/icons/javascript.svg'; @@ -28,8 +32,16 @@ const extensions = new Map() .set('python', 'py') .set('php', 'php'); +const generateGuestUserData = () => { + const username = `guest_${faker.string.alphanumeric(5)}`; + const email = `${username}@hexlet.com`; + const password = `guest_${faker.internet.password()}`; + return { username, email, password }; +}; + function NewSnippet({ handleClose, isOpen }) { const { t } = useTranslation(); + const auth = useAuth(); const dispatch = useDispatch(); const snippetApi = useSnippets(); const navigate = useNavigate(); @@ -58,6 +70,36 @@ function NewSnippet({ handleClose, isOpen }) { validationSchema, onSubmit: async (values, { setSubmitting }) => { setSubmitting(true); + const guestData = !auth.isLoggedIn && generateGuestUserData(); + const targetUsername = auth.isLoggedIn ? username : guestData.username; + if (!auth.isLoggedIn) { + try { + await axios.post(routes.usersPath(), guestData); + auth.signIn(); + localStorage.setItem( + 'guestUserData', + JSON.stringify({ + guestId: guestData.password, + }), + ); + } catch (err) { + if (!err.isAxiosError) { + console.log('errors.unknown'); + throw err; + } + if ( + err.response?.status === 400 && + Array.isArray(err.response?.data?.errs?.message) + ) { + // случай, когда случайно сгенерировался username или email, который уже есть в базе + console.log(t('errors.network')); + throw err; + } else { + console.log(t('errors.network')); + throw err; + } + } + } const [language] = selectedLng; const code = t(`codeTemplates.${language}`); // TODO: Тут не должно быть проверок, нужно создать абстракцию сервиса, который будет работать с любыми языками @@ -66,7 +108,9 @@ function NewSnippet({ handleClose, isOpen }) { const snipName = `${values.name}.${extensions.get(language)}`; const id = await snippetApi.saveSnippet(code, snipName, language); const { slug } = await snippetApi.getSnippetData(id); - const url = new URL(snippetApi.genViewSnippetLink(username, slug)); + const url = new URL( + snippetApi.genViewSnippetLink(targetUsername, slug), + ); console.log(id, slug, url.pathname); formik.values.name = ''; navigate(url.pathname); diff --git a/frontend/src/components/Modals/SignUp.jsx b/frontend/src/components/Modals/SignUp.jsx index 339dcdd5..4713b206 100644 --- a/frontend/src/components/Modals/SignUp.jsx +++ b/frontend/src/components/Modals/SignUp.jsx @@ -5,10 +5,15 @@ import { useDispatch } from 'react-redux'; import { actions } from '../../slices'; import SignupForm from '../Forms/SignUpForm'; +import GuestSignupForm from '../Forms/GuestSignUpForm'; +import { useAuth } from '../../hooks'; function SignUpModal({ handleClose, isOpen }) { const dispatch = useDispatch(); const { t } = useTranslation(); + const auth = useAuth(); + + const guestUser = localStorage.getItem('guestUserData'); return ( @@ -18,18 +23,24 @@ function SignUpModal({ handleClose, isOpen }) { - -
- - {t('signUp.footer.signInHeader')} - {' '} - -
+ {auth.isLoggedIn && guestUser ? ( + + ) : ( + + )} + {!guestUser && ( +
+ + {t('signUp.footer.signInHeader')} + {' '} + +
+ )}
); diff --git a/frontend/src/components/Navigation/GuestMenu.jsx b/frontend/src/components/Navigation/GuestMenu.jsx new file mode 100644 index 00000000..a5482833 --- /dev/null +++ b/frontend/src/components/Navigation/GuestMenu.jsx @@ -0,0 +1,50 @@ +import { useTranslation } from 'react-i18next'; +import { useDispatch } from 'react-redux'; + +import { GridFill } from 'react-bootstrap-icons'; + +import Dropdown from 'react-bootstrap/Dropdown'; +import Button from 'react-bootstrap/Button'; +import { actions } from '../../slices/modalSlice.js'; + +function GuestMenu() { + const { t } = useTranslation(); + const dispatch = useDispatch(); + + const handleNewSnippet = () => { + dispatch(actions.openModal({ type: 'newSnippet' })); + }; + + const handleRegButton = () => { + dispatch(actions.openModal({ type: 'signingUp' })); + }; + + return ( + + +
+ +
+ {t('profileActions.header')} +
+ +
  • + + {t('snippetActions.new')} + +
  • + +
  • + + {t('signUp.registerButton')} + +
  • +
    +
    + ); +} +export default GuestMenu; diff --git a/frontend/src/components/Navigation/index.jsx b/frontend/src/components/Navigation/index.jsx index 867fa111..fd99332e 100644 --- a/frontend/src/components/Navigation/index.jsx +++ b/frontend/src/components/Navigation/index.jsx @@ -15,11 +15,14 @@ import LanguageSelector from './LanguageSelector.jsx'; import NavMenu from './NavMenu.jsx'; import ThemeSelector from './ThemeSelector.jsx'; import UserMenu from './UserMenu.jsx'; +import GuestMenu from './GuestMenu.jsx'; function Navigation() { const { isLoggedIn } = useAuth(); const { t } = useTranslation(); + const guestUser = localStorage.getItem('guestUserData'); + return ( - {isLoggedIn ? : } + {isLoggedIn && !guestUser && } + {isLoggedIn && guestUser && } + {!isLoggedIn && } diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index 6c0cc294..0bae3b42 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -182,6 +182,7 @@ "second": "" }, "startCoding": "Start coding", + "codeWithoutReg": "Try without registration", "teamWork": { "text": "You'll be able to share your code snippets with other participants. Or work together directly in Run IT!", "title": "Team work" diff --git a/frontend/src/locales/ru.json b/frontend/src/locales/ru.json index 0c6fbff0..418ba67c 100644 --- a/frontend/src/locales/ru.json +++ b/frontend/src/locales/ru.json @@ -182,6 +182,7 @@ "second": "участниками" }, "startCoding": "Начать кодить", + "codeWithoutReg": "Кодить без регистрации", "teamWork": { "text": "Вы сможете делиться ссылкой на фрагменты своего кода с другими участниками. Или работать вместе прямо в Run IT!", "title": "Совместная работа" @@ -437,4 +438,4 @@ "run": "Запустить", "share": "Поделиться" } -} \ No newline at end of file +} diff --git a/frontend/src/pages/landing/index.jsx b/frontend/src/pages/landing/index.jsx index 05b368b4..ba0cb0f2 100644 --- a/frontend/src/pages/landing/index.jsx +++ b/frontend/src/pages/landing/index.jsx @@ -1,7 +1,10 @@ import { useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; import { Row, Col, Button } from 'react-bootstrap'; +import { useDispatch } from 'react-redux'; +import { useNavigate } from 'react-router-dom'; +import { useAuth } from '../../hooks'; import routes from '../../routes.js'; import { Faq } from '../about/Faq.jsx'; @@ -13,10 +16,23 @@ import shareImg from '../../assets/landing/images/share.png'; import codeImg from '../../assets/landing/images/code.png'; import personImg from '../../assets/landing/images/person.png'; import blankImg from '../../assets/landing/images/blank.png'; +import { actions } from '../../slices/modalSlice'; function Landing() { + const { isLoggedIn } = useAuth(); + const navigate = useNavigate(); + const dispatch = useDispatch(); + const { t } = useTranslation(); + const handleCodeWithoutRegButton = () => { + if (isLoggedIn) { + navigate(routes.myProfilePagePath()); + return; + } + dispatch(actions.openModal({ type: 'newSnippet' })); + }; + return (
    @@ -84,6 +100,15 @@ function Landing() { {t('landing.startCoding')} + + +

    diff --git a/frontend/src/pages/landing/index.module.css b/frontend/src/pages/landing/index.module.css index 3e5308e7..f1cb16fe 100644 --- a/frontend/src/pages/landing/index.module.css +++ b/frontend/src/pages/landing/index.module.css @@ -6,6 +6,7 @@ --text-size-50: 3.125rem; --text-size-80: 5rem; --text-size-90: 5.625rem; + --dist-24: 1.5rem; --dist-35: 2.1875rem; --dist-40: 2.5rem; --dist-45: 2.8125rem; @@ -22,6 +23,7 @@ --color-purple: #7c7cf7; --color-light-grey: #f0f0f0; --color-off-white: #fcfcfc; + --color-transparent: transparent; } * { @@ -42,6 +44,19 @@ color: var(--color-off-white); } +.btnOutlinePrimary { + --bs-btn-bg: var(--color-transparent); + --bs-btn-hover-bg: var(--color-transparent); + --bs-btn-border-color: var(--color-transparent); + --bs-btn-hover-border-color: var(--color-purple); + --bs-btn-hover-color: var(--color-purple); +} + +.btnNoAttention { + width: 20.625rem; + color: var(--color-blue); + margin-top: var(--dist-24); +} .fs5 { font-size: var(--text-size-18) !important; } diff --git a/frontend/src/pages/profile/index.jsx b/frontend/src/pages/profile/index.jsx index a84a75cb..68c6f9d4 100644 --- a/frontend/src/pages/profile/index.jsx +++ b/frontend/src/pages/profile/index.jsx @@ -21,6 +21,8 @@ function ProfileLayout({ data, isEditable }) { const dispatch = useDispatch(); const { user, snippets } = data; + const guestUser = localStorage.getItem('guestUserData'); + const handleInDevelopment = () => { dispatch(actions.openModal({ type: 'inDevelopment' })); }; @@ -29,7 +31,7 @@ function ProfileLayout({ data, isEditable }) {
    -

    {user.username}

    + {!guestUser &&

    {user.username}

    }