diff --git a/src/api/settings.ts b/src/api/settings.ts new file mode 100644 index 0000000..8901b62 --- /dev/null +++ b/src/api/settings.ts @@ -0,0 +1,44 @@ +export const userInfoUpdate = async ( + token: string, + username?: string, + linkedIn?: string, + isEmailPublic?: boolean, + isLinkedinPublic?: boolean, + title?: string, + gender?: string, + npsScore?: number, + isModalShown?: boolean, +) => { + const url = `${process.env.NEXT_PUBLIC_STRAPI}/api/user/me`; + const body = JSON.stringify({ + ...(username !== undefined && { username }), + ...(linkedIn !== undefined && { linkedIn }), + ...(isEmailPublic !== undefined && { isEmailPublic }), + ...(isLinkedinPublic !== undefined && { isLinkedinPublic }), + ...(title !== undefined && { title }), + ...(gender !== undefined && { gender }), + ...(npsScore !== undefined && { npsScore }), + ...(isModalShown !== undefined && { isModalShown }), + }); + const headers = { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }; + + try { + const response = await fetch(url, { + method: 'PUT', + headers: headers, + body, + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + return await response.json(); + } catch (error) { + console.error('Failed to update user info:', error); + throw error; + } +}; diff --git a/src/api/vibesuite.ts b/src/api/vibesuite.ts index 909912f..1d475e0 100644 --- a/src/api/vibesuite.ts +++ b/src/api/vibesuite.ts @@ -1,3 +1,10 @@ +export const getVibesuite = async (locale: string) => { + const url = `${process.env.NEXT_PUBLIC_STRAPI}/api/vibesuite?populate[pageSeo]=*&populate[OGTags][populate]=ogImage&locale=${locale}`; + return await fetch(url) + .then(resp => resp.json()) + .then(json => json?.data?.attributes || null); +}; + export const updateLearnedSkills = async (learnedSkills: string[]) => { const token: string = localStorage?.getItem('accessToken'); if (!token) return; diff --git a/src/components/Button/Button.module.scss b/src/components/Button/Button.module.scss index e15a687..b153ef4 100644 --- a/src/components/Button/Button.module.scss +++ b/src/components/Button/Button.module.scss @@ -37,6 +37,16 @@ border-color: #c5c5c5; } } + &.black { + background-color: #242424; + color: #fff; + border-color: #242424; + &:hover { + background-color: #242424; + color: #fff; + border-color: #242424; + } + } &.primary { color: #fff; diff --git a/src/components/Button/Button.tsx b/src/components/Button/Button.tsx index 6ddc043..22b1c1e 100644 --- a/src/components/Button/Button.tsx +++ b/src/components/Button/Button.tsx @@ -7,7 +7,7 @@ import Loader from '@icons/Loader'; import styles from './Button.module.scss'; type TButton = { - variant?: 'default' | 'primary' | 'secondary' | 'grey'; + variant?: 'default' | 'primary' | 'secondary' | 'grey' | 'black'; type?: 'submit' | 'button' | 'reset'; label: string; disabled?: boolean; @@ -73,6 +73,7 @@ const Button: FC = ({ [styles.disabled]: disabled, [styles.grey]: variant === 'grey', [styles.secondary]: variant === 'secondary', + [styles.black]: variant === 'black', })} onClick={handleClick} onMouseEnter={handleMouseEnter} diff --git a/src/components/Checkbox/Checkbox.module.scss b/src/components/Checkbox/Checkbox.module.scss new file mode 100644 index 0000000..cc90c20 --- /dev/null +++ b/src/components/Checkbox/Checkbox.module.scss @@ -0,0 +1,131 @@ +.visibleTxtMobile { + display: none; +} + +.checkboxes { + display: flex; + align-items: center; + gap: 8px; + + .visibleTxt { + color: rgba(0, 0, 0, 0.45); + font-family: 'Lato', sans-serif; + font-size: 14px; + line-height: 1.571; + } +} + +.txt { + font-family: 'Source Serif 4', 'Source-Serif-Regular', serif; + font-size: 14px; + line-height: 1.571; + color: rgba(0, 0, 0, 0.85); +} + +.wrapper { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 0; +} + +.wrapper + .wrapper { + margin-left: 8px; +} + +.container { + display: block; + position: relative; + cursor: pointer; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + width: 20px; + height: 20px; +} + +.container input { + position: absolute; + opacity: 0; + cursor: pointer; + height: 0; + width: 0; +} + +.checkmark { + position: absolute; + top: 0; + left: 0; + height: 20px; + width: 20px; + border-radius: 50%; + background-color: #fff; + border: 1px solid #d9d9d9; + box-sizing: border-box; +} + +.container input:checked ~ .checkmark { + border: 1px solid #242424; +} + +.checkmark:after { + content: ''; + position: absolute; + display: none; +} + +.container input:checked ~ .checkmark:after { + display: block; +} + +.container .checkmark:after { + left: 4px; + top: 4px; + width: 10px; + height: 10px; + background: #242424; + border-radius: 50%; +} + +// Dark mode +:global(.darkTheme) { + .checkboxes .visibleTxt { + color: rgba(255, 255, 255, 0.45); + } + + .txt { + color: #fff; + } + + .checkmark { + background-color: #1b1e26; + border-color: #5b5b5b; + } + + .container input:checked ~ .checkmark { + border-color: #fff; + } + + .container .checkmark:after { + background: #fff; + } +} + +@media (max-width: 750px) { + .visibleTxtMobile { + display: block; + color: rgba(0, 0, 0, 0.45); + font-family: 'Lato', sans-serif; + font-size: 14px; + line-height: 1.571; + } + + .checkboxes .visibleTxt { + display: none; + } + + :global(.darkTheme) .visibleTxtMobile { + color: rgba(255, 255, 255, 0.45); + } +} diff --git a/src/components/Checkbox/Checkbox.tsx b/src/components/Checkbox/Checkbox.tsx new file mode 100644 index 0000000..d31a364 --- /dev/null +++ b/src/components/Checkbox/Checkbox.tsx @@ -0,0 +1,60 @@ +import { FC } from 'react'; + +import styles from './Checkbox.module.scss'; + +type CheckboxProps = { + visibleTxt: string; + everyone: string; + onlyYou: string; + setRadioValue: (radioValue: string) => void; + radioValue: string; +}; + +const Checkbox: FC = ({ + visibleTxt, + everyone, + onlyYou, + setRadioValue, + radioValue, +}) => { + const handleRadioChange = event => { + setRadioValue(event.target.value); + }; + + return ( + <> + {visibleTxt} +
+ {visibleTxt} +
+ + {everyone} +
+
+ + + {onlyYou} +
+
+ + ); +}; +export default Checkbox; diff --git a/src/components/Checkbox/index.ts b/src/components/Checkbox/index.ts new file mode 100644 index 0000000..36fa16d --- /dev/null +++ b/src/components/Checkbox/index.ts @@ -0,0 +1,3 @@ +import Checkbox from './Checkbox'; + +export default Checkbox; diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx index e5df5c2..821c266 100644 --- a/src/components/Header/Header.tsx +++ b/src/components/Header/Header.tsx @@ -1,7 +1,15 @@ import cn from 'classnames'; +import dynamic from 'next/dynamic'; import Image from 'next/image'; import { useRouter } from 'next/router'; -import React, { FC, Fragment, useCallback, useContext, useState } from 'react'; +import React, { + FC, + Fragment, + useCallback, + useContext, + useEffect, + useState, +} from 'react'; import { flushSync } from 'react-dom'; import type { TRouter } from '@local-types/global'; @@ -9,6 +17,9 @@ import type { TRouter } from '@local-types/global'; import useGlobals from '@hooks/useGlobals'; import { useIsWidthLessThan } from '@hooks/useScreenSize'; +import { userInfoUpdate } from '@api/settings'; +import { getMyInfo } from '@api/strapi'; + import { GlobalContext } from '@components/Context/GlobalContext'; import LogIn from '@components/LogIn'; import Navbar from '@components/Navbar'; @@ -17,10 +28,17 @@ import UserProfile from '@components/UserProfile'; import styles from './Header.module.scss'; +const SettingsModal = dynamic(() => import('@components/SettingsModal'), { + ssr: false, +}); + const Header: FC = () => { const router = useRouter(); const { locale, locales } = router as TRouter; const { setShowLoader, videoRef } = useContext(GlobalContext); + const [openSettings, setOpenSettings] = useState(false); + const [usernameIsTakenError, setUsernameIsTakenError] = useState(''); + const [token, setToken] = useState(null); const isSmallScreen = useIsWidthLessThan(1141); const [openLogin, setOpenLogin] = useState(false); const { accountData, setAccountData } = useContext(GlobalContext); @@ -28,6 +46,12 @@ const Header: FC = () => { { toggleIsDarkTheme, toggleSidebar }, { isDarkTheme, isOpenedSidebar }, ] = useGlobals(); + + useEffect(() => { + const storedToken = localStorage.getItem('accessToken'); + setToken(storedToken); + }, []); + const handleToggleTheme = useCallback(() => { toggleIsDarkTheme(); }, []); @@ -36,6 +60,38 @@ const Header: FC = () => { toggleSidebar(); }, []); + const handleOpenSettings = useCallback(() => { + setOpenSettings(true); + }, []); + + const handleSaveClick = async ( + username: string, + linkedInUrl: string, + isEmailPublic: string, + isLinkedinPublic: string, + title?: string, + ) => { + const mailIsPublic = isEmailPublic === 'everyone'; + const linkedInIsPublic = isLinkedinPublic === 'everyone'; + try { + await userInfoUpdate( + token, + username, + linkedInUrl, + mailIsPublic, + linkedInIsPublic, + title, + ); + + const data = await getMyInfo(); + setAccountData(data); + setOpenSettings(false); + setUsernameIsTakenError(''); + } catch (error) { + setOpenSettings(true); + setUsernameIsTakenError('Username is already taken'); + } + }; const handleClick = (e, path: string) => { e.preventDefault(); flushSync(() => { @@ -164,12 +220,27 @@ const Header: FC = () => { username={accountData?.username} setOpenLoginModal={setOpenLogin} userImage={accountData?.picture} + handleOpenSettings={handleOpenSettings} />
{openLogin && } + {openSettings && ( + {}} + /> + )} ); }; diff --git a/src/components/LogIn/LogIn.tsx b/src/components/LogIn/LogIn.tsx index 82b75ae..f1cb1a9 100644 --- a/src/components/LogIn/LogIn.tsx +++ b/src/components/LogIn/LogIn.tsx @@ -1,7 +1,7 @@ import cn from 'classnames'; import { useRouter } from 'next/router'; import { signOut, useSession } from 'next-auth/react'; -import React, { FC, useContext, useEffect } from 'react'; +import React, { FC, useContext } from 'react'; import { setRedirectCookie } from '@lib/cookies'; @@ -29,6 +29,9 @@ const LogIn: FC = ({ setShowLogIn, source }) => { provider: string, logInSource: string, ) => { + const returnTo = router.asPath; + setRedirectCookie(returnTo); + if (session && accountData === null) { await signOut({ redirect: false }); @@ -46,12 +49,6 @@ const LogIn: FC = ({ setShowLogIn, source }) => { } }; - useEffect(() => { - if (!session) { - setRedirectCookie(window.location.pathname + window.location.search); - } - }, [session, router]); - return (
diff --git a/src/components/Modal/Modal.module.scss b/src/components/Modal/Modal.module.scss index 23c2deb..24d24ff 100644 --- a/src/components/Modal/Modal.module.scss +++ b/src/components/Modal/Modal.module.scss @@ -10,7 +10,7 @@ background-color: rgba(0, 0, 0, 0.35); align-items: center; justify-content: center; - z-index: 45; + z-index: 200; .wrapper { display: flex; @@ -89,6 +89,10 @@ display: block; width: 24px; height: 24px; + + :global(.darkTheme) & { + filter: invert(1); + } } .blackTitle { @@ -107,6 +111,23 @@ } } + .hr { + border: none; + background-image: url('/keepsimple_/assets/longevity/environment/line.png'); + background-size: cover; + height: 1px; + width: clamp(319px, 50vw, 466px); + margin: 0; + position: absolute; + top: 55px; + left: 14px; + + :global(.darkTheme) & { + background-image: none; + background-color: #5b5b5b; + } + } + .body { padding: 16px 28px; overflow: auto; @@ -159,7 +180,7 @@ bottom: 0; align-items: center; justify-content: center; - z-index: 46; + z-index: 201; } @media (max-width: 965px) { diff --git a/src/components/Modal/Modal.tsx b/src/components/Modal/Modal.tsx index 25b42cf..2150907 100644 --- a/src/components/Modal/Modal.tsx +++ b/src/components/Modal/Modal.tsx @@ -26,6 +26,7 @@ type ModalProps = { grayTitle?: boolean; dataCy?: string; backgroundImageUrl?: string; + hasHr?: boolean; }; const Modal: FC = ({ @@ -49,6 +50,7 @@ const Modal: FC = ({ dataCy, isLongevityProtocolModal, backgroundImageUrl, + hasHr, }) => { const handleClose = () => { onClick(); @@ -130,6 +132,7 @@ const Modal: FC = ({ > {title} + {hasHr &&
}
div:first-child > span:first-child { + text-transform: uppercase; + font-family: 'Aboreto-Regular', sans-serif; + font-size: 20px; + padding: 20px 28px 10px 28px !important; + } + + &.RuLocale > div:first-child > span:first-child { + font-family: 'Source-Serif-Regular', sans-serif; + } +} + +// Dark mode +:global(.darkTheme) { + .ModalWrapper { + background-image: url('/keepsimple_/assets/landingPage/landing-dark-bg.png') !important; + + > div:first-child > span:first-child { + color: #fff; + } + } + + .Label { + color: #fff; + } + + .CancelBtn { + border-color: #fff !important; + color: #fff !important; + } + + .SaveBtn { + background-color: #fff !important; + border-color: #fff !important; + color: rgba(0, 0, 0, 0.85) !important; + + &:hover { + background-color: #fff !important; + border-color: #fff !important; + color: rgba(0, 0, 0, 0.85) !important; + } + } +} + +@media (max-width: 750px) { + .ModalWrapper { + max-width: 343px !important; + width: 343px !important; + } + + .FieldGroupWithVisibility { + gap: 12px; + } + + .BtnWrapper { + justify-content: center; + + .CancelBtn, + .SaveBtn { + flex: 1; + } + } +} diff --git a/src/components/SettingsModal/SettingsModal.tsx b/src/components/SettingsModal/SettingsModal.tsx new file mode 100644 index 0000000..455a454 --- /dev/null +++ b/src/components/SettingsModal/SettingsModal.tsx @@ -0,0 +1,216 @@ +import cn from 'classnames'; +import { useRouter } from 'next/router'; +import { FC, useCallback, useState } from 'react'; + +import { TRouter } from '@local-types/global'; + +import { + isValidEmail, + linkedInRegex, + usernameRegex, +} from '@lib/settings-helpers'; + +import settingsData from '@data/settings'; + +import Button from '@components/Button'; +import Checkbox from '@components/Checkbox'; +import Input from '@components/Input'; +import Modal from '@components/Modal'; +import Textarea from '@components/Textarea'; + +import styles from './SettingsModal.module.scss'; + +type SettingsModalProps = { + setOpenSettings: (openSettings: boolean) => void; + currentUsername: string; + currentEmail: string; + defaultSelectedTitle?: string; + mailStatus?: boolean; + linkedin?: string; + linkedinStatus?: boolean; + changeTitlePermission?: boolean; + usernameIsTakenError?: string; + setUsernameIsTakenError: (usernameIsTakenError: string) => void; + setChangedTitle: (selected: boolean) => void; + handleSaveClick: ( + username: string, + linkedInUrl: string, + isEmailPublic: string, + isLinkedinPublic: string, + title?: string, + ) => void; +}; + +const SettingsModal: FC = ({ + setOpenSettings, + currentUsername, + handleSaveClick, + currentEmail, + mailStatus, + linkedinStatus, + linkedin, + usernameIsTakenError, + setUsernameIsTakenError, + defaultSelectedTitle, + changeTitlePermission, + setChangedTitle, +}) => { + const router = useRouter(); + const { locale } = router as TRouter; + const currentLocale = locale === 'ru' ? 'ru' : 'en'; + const [isEmailPublic, setIsEmailPublic] = useState( + !!mailStatus ? 'everyone' : 'onlyMe', + ); + const [isLinkedinPublic, setIsLinkedinPublic] = useState( + !!linkedinStatus ? 'everyone' : 'onlyMe', + ); + const [username, setUsername] = useState(currentUsername); + const [linkedInUrl, setLinkedInUrl] = useState(linkedin); + const [selectedTitle] = useState(defaultSelectedTitle); + const [isValid, setIsValid] = useState({ username: true, linkedin: true }); + + const { + title, + usernameTxt, + email, + visible, + everyone, + onlyYou, + linkedIn, + saveBtn, + cancelBtn, + usernameValidationMessage, + invalidLinkedIn, + } = settingsData[currentLocale]; + + const closeSettings = () => { + setOpenSettings(false); + setUsernameIsTakenError(''); + }; + + const validateUsername = (username: string) => { + if (username.trim() === '') { + return false; + } + return usernameRegex.test(username); + }; + + const validateLinkedIn = (linkedInUrl: string) => { + return linkedInRegex.test(linkedInUrl); + }; + + const handleValidation = useCallback( + (value: boolean, type: 'username' | 'linkedin') => { + setIsValid(prevIsValid => ({ + ...prevIsValid, + [type]: value, + })); + }, + [isValid], + ); + + const handleSave = () => { + if (isValid.username && isValid.linkedin) { + handleSaveClick( + username, + linkedInUrl, + isEmailPublic, + isLinkedinPublic, + changeTitlePermission ? selectedTitle : undefined, + ); + } + setChangedTitle && setChangedTitle(true); + }; + + return ( + +
+
+ {usernameTxt} + + {validationFunction && ( +
{errorMessage}
+ )} +
+
+ ); +}; + +export default Textarea; diff --git a/src/components/Textarea/index.ts b/src/components/Textarea/index.ts new file mode 100644 index 0000000..2391adc --- /dev/null +++ b/src/components/Textarea/index.ts @@ -0,0 +1,3 @@ +import Textarea from './Textarea'; + +export default Textarea; diff --git a/src/components/UserProfile/UserProfile.module.scss b/src/components/UserProfile/UserProfile.module.scss index 25981de..25228ec 100644 --- a/src/components/UserProfile/UserProfile.module.scss +++ b/src/components/UserProfile/UserProfile.module.scss @@ -28,7 +28,6 @@ top: calc(100% + 8px); right: 24px; min-width: 180px; - padding: 12px; background-image: url('/keepsimple_/assets/user-dropdown/white-bg.png'); background-size: cover; background-position: center; @@ -37,7 +36,6 @@ z-index: 200; display: flex; flex-direction: column; - gap: 4px; } .userNameDropdown { @@ -54,7 +52,7 @@ display: flex; align-items: center; gap: 8px; - padding: 8px; + padding: 12px; border-radius: 6px; cursor: pointer; font-family: 'Sarabun-Regular', serif; @@ -66,11 +64,11 @@ border-bottom: 1px solid #c4c4c4; border-radius: 6px 6px 0 0; padding-bottom: 12px; - margin-bottom: 4px; } &:hover { background-color: rgba(0, 0, 0, 0.06); + border-radius: 0; } } diff --git a/src/components/UserProfile/UserProfile.tsx b/src/components/UserProfile/UserProfile.tsx index b6defed..effd25f 100644 --- a/src/components/UserProfile/UserProfile.tsx +++ b/src/components/UserProfile/UserProfile.tsx @@ -16,6 +16,7 @@ type UserProfileProps = { isDarkTheme?: boolean; setAccountData?: (updater: (prev: boolean) => boolean) => void; setOpenLoginModal?: (openModal: boolean) => void; + handleOpenSettings?: () => void; }; const labels = { @@ -31,6 +32,7 @@ const UserProfile: FC = ({ isDarkTheme, setAccountData, setOpenLoginModal, + handleOpenSettings, }) => { const router: NextRouter = useRouter(); const locale = (router.locale || 'en') as keyof typeof labels; @@ -53,7 +55,8 @@ const UserProfile: FC = ({ const handleSettings = useCallback(() => { setIsDropdownOpen(false); - }, []); + handleOpenSettings?.(); + }, [handleOpenSettings]); useEffect(() => { if (!isDropdownOpen) return; @@ -112,7 +115,7 @@ const UserProfile: FC = ({ ) : (
setOpenLoginModal(true)}> pic 1; if (locale === 'ru') { - return `\u042F \u0445\u043E\u0447\u0443 \u0438\u0437\u0443\u0447\u0438\u0442\u044C \u00AB${skill.name}\u00BB. \u0417\u0430\u0434\u0430\u0447\u0430: ${skill.projectTitle}. \u0418\u043D\u0441\u0442\u0440\u0443\u043C\u0435\u043D\u0442\u044B: ${tools}. \u0414\u0430\u0432\u0430\u0439 \u043F\u043E\u0441\u0442\u0440\u043E\u0438\u043C \u044D\u0442\u043E \u0432\u043C\u0435\u0441\u0442\u0435, \u0448\u0430\u0433 \u0437\u0430 \u0448\u0430\u0433\u043E\u043C.`; + return `Я хочу изучить «${skill.name}». Задача: ${skill.projectTitle}. Инструменты: ${tools}. Давай построим это вместе, шаг за шагом.`; } const title = skill.projectTitle @@ -104,6 +104,14 @@ export default function SkillDetailPanel({ const prevSkill = prevSkillId ? getSkillById(prevSkillId) : null; const nextSkill = nextSkillId ? getSkillById(nextSkillId) : null; + useEffect(() => { + const header = document.querySelector('header') as HTMLElement | null; + if (header) header.style.zIndex = '0'; + return () => { + if (header) header.style.zIndex = ''; + }; + }, []); + useEffect(() => { if (skill.id !== prevSkillRef.current) { prevSkillRef.current = skill.id; @@ -147,6 +155,7 @@ export default function SkillDetailPanel({ return (
+
{ + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return emailRegex.test(email); +}; + +export const linkedInRegex = + /^(https?:\/\/)?(www\.)?(linkedin\.com\/in\/|lnkd\.in\/)[a-zA-Z0-9-]{3,30}\/?$/; + +export const usernameRegex = /^(?!.*[&%:;*|> = ({ vibesuite }) => { const [{ initUseGlobals, unmountUseGlobals }, { isDarkTheme }] = useGlobals(); useEffect(() => { @@ -16,24 +25,37 @@ export default function VibeSuitePage() { }; }, []); + const seoContent = vibesuite?.pageSeo; return ( <> ); -} +}; + +export default VibeSuitePage; + +export const getStaticProps: GetStaticProps = async ({ + locale, +}: TStaticProps) => { + const vibesuite = await getVibesuite(locale); + return { + props: { + locale, + vibesuite, + }, + revalidate: 10, + }; +};