diff --git a/client/.husky/commit-msg b/client/.husky/commit-msg deleted file mode 100755 index 30faab02a..000000000 --- a/client/.husky/commit-msg +++ /dev/null @@ -1,4 +0,0 @@ -#!/bin/sh -. "$(dirname "$0")/_/husky.sh" - -cd ./client && npx --no -- commitlint --edit $1 diff --git a/client/.husky/pre-commit b/client/.husky/pre-commit deleted file mode 100755 index b6dd6c3f3..000000000 --- a/client/.husky/pre-commit +++ /dev/null @@ -1,4 +0,0 @@ -#!/bin/sh -. "$(dirname "$0")/_/husky.sh" - -cd ./client && yarn lint \ No newline at end of file diff --git a/client/.husky/pre-push b/client/.husky/pre-push deleted file mode 100755 index eced198ea..000000000 --- a/client/.husky/pre-push +++ /dev/null @@ -1,4 +0,0 @@ -#!/bin/sh -. "$(dirname "$0")/_/husky.sh" - -cd ./client && yarn build \ No newline at end of file diff --git a/client/src/app/(main)/[username]/profile/layout.module.scss b/client/src/app/(main)/[username]/profile/layout.module.scss new file mode 100644 index 000000000..fb40784ac --- /dev/null +++ b/client/src/app/(main)/[username]/profile/layout.module.scss @@ -0,0 +1,55 @@ + +.container { + display: flex; + width: 100%; + height: 100%; + position: relative; + @media (max-width: 790px) { + position: unset; + } + .body { + width: 100%; + } +} + +.back { + position: absolute; + + left: 0; + display: flex; + align-items: center; + gap: 6px; + &:hover { + opacity: .7; + transition: .3s; + } + + @media (max-width: 790px) { + top: 46px; + right: 15px; + left: unset; + } +} + +.profile_row { + @media (max-width: 1045px) { + flex-direction: column; + width: 100%; + & > div { + width: 100%; + } + } +} + +.sm_card { + width: 40%; +} + +.lg_card { + width: 60%; +} + +.list_card { + width: 40%; + gap: 18px; +} diff --git a/client/src/app/(main)/[username]/profile/layout.tsx b/client/src/app/(main)/[username]/profile/layout.tsx new file mode 100644 index 000000000..15e825884 --- /dev/null +++ b/client/src/app/(main)/[username]/profile/layout.tsx @@ -0,0 +1,24 @@ +'use client'; +import styles from './layout.module.scss'; +import { Flex, Typography } from '@/shared/ui'; +import { ArrowLeftIcon, LogoBig } from '@/shared/assets'; +import { useRouter } from 'next/navigation'; + +export default function Layout({ children }: { children: React.ReactNode }) { + const router = useRouter(); + + return ( +
+ + + + + + {children} + +
+ ); +} diff --git a/client/src/app/(main)/[username]/profile/lib/profile-context.ts b/client/src/app/(main)/[username]/profile/lib/profile-context.ts new file mode 100644 index 000000000..47d814965 --- /dev/null +++ b/client/src/app/(main)/[username]/profile/lib/profile-context.ts @@ -0,0 +1,3 @@ +import { createContext } from 'react'; + +export const ProfileContext = createContext(false); diff --git a/client/src/app/(main)/[username]/profile/lib/useGetUserByName.ts b/client/src/app/(main)/[username]/profile/lib/useGetUserByName.ts new file mode 100644 index 000000000..759e171d7 --- /dev/null +++ b/client/src/app/(main)/[username]/profile/lib/useGetUserByName.ts @@ -0,0 +1,8 @@ +import { useGetUsers } from '@/entities/session'; +import { IUserResponse } from '@teameights/types'; + +export const useGetUserByName = (username: string): { data: IUserResponse | undefined } => { + const users = useGetUsers(JSON.stringify({ username: username })); + + return { data: users?.data?.pages[0]?.data[0] }; +}; diff --git a/client/src/app/(main)/[username]/profile/page.tsx b/client/src/app/(main)/[username]/profile/page.tsx new file mode 100644 index 000000000..783690dfe --- /dev/null +++ b/client/src/app/(main)/[username]/profile/page.tsx @@ -0,0 +1,46 @@ +'use client'; +import styles from './layout.module.scss'; +import { useGetMe } from '@/entities/session'; +import { Header } from './ui/header/header'; +import { CardSkeleton, Flex } from '@/shared/ui'; +import { List } from './ui/list/list'; +import { About } from './ui/about/about'; +import { useParams } from 'next/navigation'; +import { Friends } from './ui/friends/friends'; +import { Fields } from './ui/fields/fields'; +import { useGetUserByName } from './lib/useGetUserByName'; +import { ProfileContext } from './lib/profile-context'; + +export default function Page() { + const { data: me } = useGetMe(); + const { username } = useParams(); + const { data: user } = useGetUserByName(username as string); + const isMyProf = me?.username === username; + + let body = ( + + + + + ); + + if (user) { + body = ( + <> +
+ + + + + + + + + + + + ); + } + + return {body} ; +} diff --git a/client/src/app/(main)/[username]/profile/ui/about/about.module.scss b/client/src/app/(main)/[username]/profile/ui/about/about.module.scss new file mode 100644 index 000000000..11be4b362 --- /dev/null +++ b/client/src/app/(main)/[username]/profile/ui/about/about.module.scss @@ -0,0 +1,3 @@ +.container { + +} diff --git a/client/src/app/(main)/[username]/profile/ui/about/about.tsx b/client/src/app/(main)/[username]/profile/ui/about/about.tsx new file mode 100644 index 000000000..2ee2227a6 --- /dev/null +++ b/client/src/app/(main)/[username]/profile/ui/about/about.tsx @@ -0,0 +1,58 @@ +import { Card } from '../card/card'; +import { Flex, Typography } from '@/shared/ui'; +import { GithubIcon } from '@/shared/assets/icons/github-icon'; +import { BehanceIcon } from '@/shared/assets/icons/behance'; +import { TelegramIcon } from '@/shared/assets/icons/telegram'; +import { LinkedinIcon } from '@/shared/assets/icons/linkedin'; +import { useParams } from 'next/navigation'; +import { useGetUserByName } from '../../lib/useGetUserByName'; +import styles from '../../layout.module.scss'; + +export const About = () => { + const { username } = useParams(); + const { data: user } = useGetUserByName(username as string); + + const linksPresent = user?.links && Object.keys(user.links).length; + const descPresent = typeof user?.description === 'string'; + return ( + + + + About + + {!descPresent && ( + + No description added. + + )} + + {descPresent && {user?.description}} + + {linksPresent && ( + + {user?.links?.github && ( + + + + )} + {user?.links?.behance && ( + + + + )} + {user?.links?.telegram && ( + + + + )} + {user?.links?.linkedIn && ( + + + + )} + + )} + + + ); +}; diff --git a/client/src/app/(main)/[username]/profile/ui/card/card.module.scss b/client/src/app/(main)/[username]/profile/ui/card/card.module.scss new file mode 100644 index 000000000..01dfef8a2 --- /dev/null +++ b/client/src/app/(main)/[username]/profile/ui/card/card.module.scss @@ -0,0 +1,6 @@ +.card { + background: rgba(26, 28, 34, 1); + border-radius: 15px; + overflow: hidden; + padding: 32px; +} diff --git a/client/src/app/(main)/[username]/profile/ui/card/card.tsx b/client/src/app/(main)/[username]/profile/ui/card/card.tsx new file mode 100644 index 000000000..179deb8d9 --- /dev/null +++ b/client/src/app/(main)/[username]/profile/ui/card/card.tsx @@ -0,0 +1,14 @@ +import { ReactNode } from 'react'; +import styles from './card.module.scss'; +import clsx from 'clsx'; + +interface CardProps { + children: ReactNode; + className?: string; + borderRadius?: string; +} + +export const Card = ({ children, className }: CardProps) => { + const cls = clsx(styles.card, className); + return
{children}
; +}; diff --git a/client/src/app/(main)/[username]/profile/ui/fields/education.tsx b/client/src/app/(main)/[username]/profile/ui/fields/education.tsx new file mode 100644 index 000000000..9c9ff17e3 --- /dev/null +++ b/client/src/app/(main)/[username]/profile/ui/fields/education.tsx @@ -0,0 +1,35 @@ +import { Flex, Typography } from '@/shared/ui'; +import { useParams } from 'next/navigation'; +import { useGetUserByName } from '../../lib/useGetUserByName'; + +export const Education = () => { + const { username } = useParams(); + const { data: user } = useGetUserByName(username as string); + const universities = user?.universities; + if (!universities) return No information; + return ( + + {universities.map((education, i) => { + const start = new Date(education.admissionDate).getFullYear(); + const end = education.graduationDate + ? new Date(education.graduationDate).getFullYear() + : 'Present'; + return ( + + + + {education.name ?? (education as unknown as { university: string }).university} + + + {education.degree} in {education.major} + + + + {start} - {end} + + + ); + })} + + ); +}; diff --git a/client/src/app/(main)/[username]/profile/ui/fields/fields.module.scss b/client/src/app/(main)/[username]/profile/ui/fields/fields.module.scss new file mode 100644 index 000000000..f69e74159 --- /dev/null +++ b/client/src/app/(main)/[username]/profile/ui/fields/fields.module.scss @@ -0,0 +1,16 @@ +.selected { + border-bottom: 1px solid #5bd424; +} + +.field_text { + transition: 0.3s; + transition-property: color; +} + +.fields_container { + min-height: 150px; +} + +.nav_bar { + overflow-x: auto; +} \ No newline at end of file diff --git a/client/src/app/(main)/[username]/profile/ui/fields/fields.tsx b/client/src/app/(main)/[username]/profile/ui/fields/fields.tsx new file mode 100644 index 000000000..301cb53df --- /dev/null +++ b/client/src/app/(main)/[username]/profile/ui/fields/fields.tsx @@ -0,0 +1,46 @@ +import { Card } from '../card/card'; +import { Flex, Typography } from '@/shared/ui'; +import { useState } from 'react'; +import styles from './fields.module.scss'; +import { Skills } from './skills'; +import { WorkExperience } from './work-experience'; +import { Education } from './education'; +import layoutStyles from '../../layout.module.scss'; +export const Fields = () => { + const [field, setField] = useState('Skills'); + + const fields = { + Skills: , + Projects: null, + 'Work experience': , + Education: , + Tournaments: null, + }; + + return ( + + + + {Object.keys(fields).map(key => { + const classProps = field === key ? { className: styles.selected } : {}; + return ( + + ); + })} + + {fields[field]} + + + ); +}; diff --git a/client/src/app/(main)/[username]/profile/ui/fields/skills.tsx b/client/src/app/(main)/[username]/profile/ui/fields/skills.tsx new file mode 100644 index 000000000..94b1b5c31 --- /dev/null +++ b/client/src/app/(main)/[username]/profile/ui/fields/skills.tsx @@ -0,0 +1,36 @@ +import { BadgeText, Flex, Typography } from '@/shared/ui'; +import { useGetUserByName } from '../../lib/useGetUserByName'; +import { useParams } from 'next/navigation'; +import { BadgeIcon } from '@/shared/ui'; + +export const Skills = () => { + const { username } = useParams(); + const { data: user } = useGetUserByName(username as string); + const skills = { + coreTools: { + badge: ({ data }: { data: string }) => , + title: 'Core Tools', + }, + additionalTools: { + badge: BadgeText, + title: 'Additional Tools', + }, + }; + return ( + + {user!.skills && + Object.entries(skills).map(skill => { + const skillName = skill[0] as keyof typeof skills; + const Badge = skills[skillName].badge; + return ( + + {skills[skillName].title} + + {user?.skills![skillName]?.map((lang: string) => )} + + + ); + })} + + ); +}; diff --git a/client/src/app/(main)/[username]/profile/ui/fields/work-experience.tsx b/client/src/app/(main)/[username]/profile/ui/fields/work-experience.tsx new file mode 100644 index 000000000..92a1f9f66 --- /dev/null +++ b/client/src/app/(main)/[username]/profile/ui/fields/work-experience.tsx @@ -0,0 +1,29 @@ +import { Flex, Typography } from '@/shared/ui'; +import { useParams } from 'next/navigation'; +import { useGetUserByName } from '../../lib/useGetUserByName'; +export const WorkExperience = () => { + const { username } = useParams(); + const { data: user } = useGetUserByName(username as string); + const jobs = user?.jobs; + return ( + + {jobs?.map((job, i: number) => { + const start = new Date(job.startDate).getFullYear(); + const end = job.endDate ? new Date(job.endDate).getFullYear() : 'Present'; + return ( + + + {job.company} + + {job.title} + + + + {start} - {end} + + + ); + })} + + ); +}; diff --git a/client/src/app/(main)/[username]/profile/ui/friends/friends-modal.tsx b/client/src/app/(main)/[username]/profile/ui/friends/friends-modal.tsx new file mode 100644 index 000000000..952059026 --- /dev/null +++ b/client/src/app/(main)/[username]/profile/ui/friends/friends-modal.tsx @@ -0,0 +1,60 @@ +import { Flex, ImageLoader, Typography } from '@/shared/ui'; +import styles from './friends.module.scss'; +import { Modal } from '@/shared/ui'; +import { getCountryFlag } from '@/shared/lib'; +import { IUserBase } from '@teameights/types'; + +interface FriendsModalProps { + friendsList: IUserBase[]; + isFriendsModalOpen: boolean; + setFriendsModal: (state: boolean) => void; +} + +export const FriendsModal = ({ + friendsList, + isFriendsModalOpen, + setFriendsModal, +}: FriendsModalProps) => { + return ( + setFriendsModal(false)} isOpen={isFriendsModalOpen}> + + + Friends + + + {friendsList.map(friend => { + return ( + + + + + + {friend.username ?? 'usernamehey'} + + + + + {friend.skills?.speciality} + + + + ); + })} + + + + ); +}; diff --git a/client/src/app/(main)/[username]/profile/ui/friends/friends.module.scss b/client/src/app/(main)/[username]/profile/ui/friends/friends.module.scss new file mode 100644 index 000000000..6adccb4ce --- /dev/null +++ b/client/src/app/(main)/[username]/profile/ui/friends/friends.module.scss @@ -0,0 +1,32 @@ +.avatar { + width: 40px; + height: 40px; + border-radius: 50%; + border: 2px solid rgba(26, 28, 34, 1); +} + +.friend { + margin-left: -10px; + &:first-of-type { + margin-left: 0; + } +} + +.friends_container { + max-width: 100%; + width: 100%; + height: 40px; + overflow: hidden; +} + +.friends_label { + margin-left: 3px; +} + +.friends_list { + overflow-y: scroll; +} + +.friends_list_item:first-of-type { + padding-top: 10px; +} \ No newline at end of file diff --git a/client/src/app/(main)/[username]/profile/ui/friends/friends.tsx b/client/src/app/(main)/[username]/profile/ui/friends/friends.tsx new file mode 100644 index 000000000..e6f29d959 --- /dev/null +++ b/client/src/app/(main)/[username]/profile/ui/friends/friends.tsx @@ -0,0 +1,95 @@ +import { useGetFriends } from '@/entities/session'; +import { Card } from '../card/card'; +import { useParams } from 'next/navigation'; +import { useGetUserByName } from '../../lib/useGetUserByName'; +import { CardSkeleton, Flex, ImageLoader, Typography } from '@/shared/ui'; +import styles from './friends.module.scss'; +import { ArrowRightIcon } from '@/shared/assets'; +import { useState } from 'react'; +import layoutStyles from '../../layout.module.scss'; +import { FriendsModal } from './friends-modal'; +import { IUserBase } from '@teameights/types'; + +export const Friends = () => { + const { username } = useParams(); + const { data: user } = useGetUserByName(username as string); + const { data: friends } = useGetFriends(user!.id); + const friendshipList = friends?.data; + const [isFriendsModalOpen, setFriendsModal] = useState(false); + + if (!friends || !friendshipList) { + return ; + } + + let friendsContainer = ( + + List is empty. + + ); + if (friendshipList.length) { + const friendsList = friendshipList.reduce((accumulator, friendship) => { + if (friendship.status === 'accepted') { + const { receiver, creator } = friendship; + const friend = receiver.id !== user?.id ? receiver : creator; + + const existingFriendIndex = accumulator.findIndex(item => item?.id === friend.id); + // filter out duplicates, since there can be two similar friendships with different creators + if (existingFriendIndex === -1) { + accumulator.push(friend); + } + } + return accumulator; + }, []); + const noun = friendsList.length === 1 ? 'friend' : 'friends'; + friendsContainer = ( + + + + + {friendsList.length} + {noun} + + + + + {friendsList.slice(0, 8).map(friend => ( + + + + ))} + + + ); + } + return ( + + + + Friends + + {friendsContainer} + + + ); +}; diff --git a/client/src/app/(main)/[username]/profile/ui/header/header.module.scss b/client/src/app/(main)/[username]/profile/ui/header/header.module.scss new file mode 100644 index 000000000..d56cc64f2 --- /dev/null +++ b/client/src/app/(main)/[username]/profile/ui/header/header.module.scss @@ -0,0 +1,28 @@ +.container { + width: 100%; + overflow: hidden; + border-radius: 15px; + background: rgba(67, 71, 82); + flex-shrink: 0; +} + +.background { + height: 130px; +} + +.header { + background: rgba(26, 28, 34, 1); + padding: 24px 32px 32px 32px; + min-height: 118px; + gap: 30px; + + .interactable { + align-self: start; + } +} +.profile { + margin-top: -44px; + display: flex; + align-items: flex-end; + gap: 24px; +} diff --git a/client/src/app/(main)/[username]/profile/ui/header/header.tsx b/client/src/app/(main)/[username]/profile/ui/header/header.tsx new file mode 100644 index 000000000..7a4546a84 --- /dev/null +++ b/client/src/app/(main)/[username]/profile/ui/header/header.tsx @@ -0,0 +1,75 @@ +'use client'; +import styles from './header.module.scss'; +import { useGetMe } from '@/entities/session'; +import { ChatCircleDotsIcon, PlusIcon } from '@/shared/assets'; +import { Button, CardSkeleton, Flex, ImageLoader, Typography } from '@/shared/ui'; +import { useParams } from 'next/navigation'; +import { useGetUserByName } from '../../lib/useGetUserByName'; +import { useContext } from 'react'; +import { ProfileContext } from '@/app/(main)/[username]/profile/lib/profile-context'; +import { FriendButton } from '@/features/friend-button'; +export const Header = () => { + const { username } = useParams(); + const { data: me } = useGetMe(); + const { data: user } = useGetUserByName(username as string); + const isMyProfile = useContext(ProfileContext); + + if (!user) { + return ; + } + + let interactions = ( + + ); + + if (!isMyProfile) { + interactions = ( + + + + + ); + } + + // Prohibit any interactions with a profile if a user is not logged in + if (!me) { + interactions = <>; + } + + const name = user.username ? '@' + user.username : ''; + return ( +
+
+
+ +
+
+ +
+ +
{user.fullName}
+ + {name} + +
+
+ {interactions} +
+
+
+ ); +}; diff --git a/client/src/app/(main)/[username]/profile/ui/list/list.module.scss b/client/src/app/(main)/[username]/profile/ui/list/list.module.scss new file mode 100644 index 000000000..e69de29bb diff --git a/client/src/app/(main)/[username]/profile/ui/list/list.tsx b/client/src/app/(main)/[username]/profile/ui/list/list.tsx new file mode 100644 index 000000000..3a3ee6db7 --- /dev/null +++ b/client/src/app/(main)/[username]/profile/ui/list/list.tsx @@ -0,0 +1,30 @@ +import { Card } from '../card/card'; +import { Star, Cake, MapPin, UserIcon } from '@/shared/assets'; +// import { Row } from '@/app/(main)/user/[username]/profile/ui/row'; +import { Row } from '../row/row'; +import { Flex } from '@/shared/ui'; +import { calculateAge } from '@/shared/lib'; +import { useParams } from 'next/navigation'; +import styles from '../../layout.module.scss'; +import { useGetUserByName } from '../../lib/useGetUserByName'; + +export const List = () => { + const { username } = useParams(); + const { data: user } = useGetUserByName(username as string); + + let age = ''; + if (user?.dateOfBirth) { + age = calculateAge(user.dateOfBirth).toString(); + } + + return ( + + + } text={user?.skills?.speciality ?? ''} /> + } text={user?.experience ?? ''} /> + } text={user?.country ?? ''} /> + {age && } text={`${age} years old`} />} + + + ); +}; diff --git a/client/src/app/(main)/[username]/profile/ui/row/row.module.scss b/client/src/app/(main)/[username]/profile/ui/row/row.module.scss new file mode 100644 index 000000000..e69de29bb diff --git a/client/src/app/(main)/[username]/profile/ui/row/row.tsx b/client/src/app/(main)/[username]/profile/ui/row/row.tsx new file mode 100644 index 000000000..ae85d2513 --- /dev/null +++ b/client/src/app/(main)/[username]/profile/ui/row/row.tsx @@ -0,0 +1,16 @@ +import { ReactNode } from 'react'; +import { Flex, Typography } from '@/shared/ui'; + +interface RowProps { + icon: ReactNode; + text: string; +} + +export const Row = ({ icon, text }: RowProps) => { + return ( + +
{icon}
+ {text} +
+ ); +}; diff --git a/client/src/app/(main)/layout.module.scss b/client/src/app/(main)/layout.module.scss index 5544aa3a0..8c85114ef 100644 --- a/client/src/app/(main)/layout.module.scss +++ b/client/src/app/(main)/layout.module.scss @@ -1,29 +1,34 @@ .container { height: 100dvh; width: 100%; - padding: 48px 0; - - @media (width <= 1120px) { - padding: 48px 0; - } - - @media (width <= 580px) { - padding: 28px; - } + + display: flex; } .children { width: 100%; min-height: 100%; display: flex; - align-items: center; flex-direction: column; + + padding: 48px 55px; + @media (width <= 1120px) { + padding: 48px 24px; + } + + @media (width <= 580px) { + padding: 24px; + } } .content_zone { - padding-left: 88px; + margin-bottom: 48px; +} - @media screen and (max-width: 768px) { - padding-left: 0; +.placeholder { + width: 93px; + flex-shrink: 0; + @media (max-width: 768px) { + display: none; } -} +} \ No newline at end of file diff --git a/client/src/app/(main)/layout.tsx b/client/src/app/(main)/layout.tsx index f487f0259..d3594de7a 100644 --- a/client/src/app/(main)/layout.tsx +++ b/client/src/app/(main)/layout.tsx @@ -16,6 +16,7 @@ export default function AuthLayout({ children }: { children: ReactNode }) { return (
+
{children}
); diff --git a/client/src/app/(main)/page.tsx b/client/src/app/(main)/page.tsx index e2bf0300d..0210a8c94 100644 --- a/client/src/app/(main)/page.tsx +++ b/client/src/app/(main)/page.tsx @@ -49,6 +49,7 @@ export default function Home() { type: 'checkbox', placeholder: 'Search by countries', optionsArr: countries, + oneItemName: 'country', filterValue: [], }, { @@ -57,6 +58,7 @@ export default function Home() { type: 'checkbox', placeholder: 'Search by specialty', optionsArr: specialities, + oneItemName: 'speciality', filterValue: [], }, { @@ -65,6 +67,7 @@ export default function Home() { type: 'checkbox', placeholder: 'Search by focus', optionsArr: focusesValues, + oneItemName: 'focus', filterValue: [], }, ]} diff --git a/client/src/app/(main)/ui/cards/cards.module.scss b/client/src/app/(main)/ui/cards/cards.module.scss index e45207e25..992bcb8b5 100644 --- a/client/src/app/(main)/ui/cards/cards.module.scss +++ b/client/src/app/(main)/ui/cards/cards.module.scss @@ -1,9 +1,9 @@ .cards_zone { - padding-left: 88px; - - @media screen and (max-width: 768px) { - padding-left: 0; - } + //padding-left: 88px; + // + //@media screen and (max-width: 768px) { + // padding-left: 0; + //} } .cards { diff --git a/client/src/entities/session/api/index.ts b/client/src/entities/session/api/index.ts index 839cf9753..29e99a91c 100644 --- a/client/src/entities/session/api/index.ts +++ b/client/src/entities/session/api/index.ts @@ -1,3 +1,4 @@ +export { useGetFriends } from './useGetFriends'; /* Here will be imports for session hooks */ export { useConfirmEmail } from './useConfirmEmail'; export { useForgotPassword } from './useForgotPassword'; diff --git a/client/src/entities/session/api/useAddFriend.tsx b/client/src/entities/session/api/useAddFriend.tsx new file mode 100644 index 000000000..69448c90a --- /dev/null +++ b/client/src/entities/session/api/useAddFriend.tsx @@ -0,0 +1,22 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { API } from '@/shared/api'; +import { API_FRIENDSHIP } from '@/shared/constant'; +import { toast } from 'sonner'; + +export const useAddFriend = (userId: number | undefined, receiverId: number) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async () => + await API.post(`${API_FRIENDSHIP}/${receiverId}?user={"id":"${userId}"}`), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['useGetFriends'] }); + queryClient.invalidateQueries({ queryKey: ['useGetFriendshipStatus', receiverId] }); + + toast('Request is sent'); + }, + onError: err => { + console.log(err); + toast(`Error occurred: ${err}`); + }, + }); +}; diff --git a/client/src/entities/session/api/useGetFriends.tsx b/client/src/entities/session/api/useGetFriends.tsx new file mode 100644 index 000000000..b60c92fec --- /dev/null +++ b/client/src/entities/session/api/useGetFriends.tsx @@ -0,0 +1,26 @@ +'use client'; +import { useQuery } from '@tanstack/react-query'; +import { API } from '@/shared/api'; +import { API_FRIENDSHIP } from '@/shared/constant'; +import { IUserBase, Timestamps } from '@teameights/types'; + +interface IFriendshipResponse extends Timestamps { + id: number; + status: 'accepted' | 'rejected' | 'pending'; + creator: IUserBase; + receiver: IUserBase; +} + +export const useGetFriends = (userId: number) => { + return useQuery({ + queryKey: ['useGetFriends', userId], + queryFn: async () => { + const { data } = await API.get<{ data: Array }>( + `${API_FRIENDSHIP}/${userId}` + ); + return data; + }, + refetchOnMount: false, + refetchOnWindowFocus: false, + }); +}; diff --git a/client/src/entities/session/api/useGetFriendshipStatus.tsx b/client/src/entities/session/api/useGetFriendshipStatus.tsx new file mode 100644 index 000000000..f2131c8bd --- /dev/null +++ b/client/src/entities/session/api/useGetFriendshipStatus.tsx @@ -0,0 +1,22 @@ +'use client'; +import { useQuery } from '@tanstack/react-query'; +import { API } from '@/shared/api'; +import { API_FRIENDSHIP } from '@/shared/constant'; + +interface IFriendshipStatus { + status: 'none' | 'friends' | 'requested' | 'toRespond'; +} + +export const useGetFriendshipStatus = (id: number) => { + return useQuery({ + queryKey: ['useGetFriendshipStatus', id], + queryFn: async () => { + const { data } = await API.get(`${API_FRIENDSHIP}/status/${id}`); + return data; + }, + refetchOnMount: false, + refetchOnWindowFocus: false, + retry: 1, + retryDelay: 5000, + }); +}; diff --git a/client/src/entities/session/api/useHandleFriendshipRequest.tsx b/client/src/entities/session/api/useHandleFriendshipRequest.tsx new file mode 100644 index 000000000..f5abeceac --- /dev/null +++ b/client/src/entities/session/api/useHandleFriendshipRequest.tsx @@ -0,0 +1,30 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { API } from '@/shared/api'; +import { API_FRIENDSHIP } from '@/shared/constant'; +import { toast } from 'sonner'; + +export const useHandleFriendshipRequest = ( + userId: number | undefined, + receiverId: number, + status: 'rejected' | 'accepted' +) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async () => + await API.patch(`${API_FRIENDSHIP}/${receiverId}?user={"id":"${userId}"}`, { + status, + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['useGetFriends'] }); + queryClient.invalidateQueries({ queryKey: ['useGetFriendshipStatus', receiverId] }); + if (status === 'accepted') { + toast('New friend added'); + } else { + toast('Friend request declined'); + } + }, + onError: err => { + toast(`Error occurred: ${err}`); + }, + }); +}; diff --git a/client/src/entities/session/api/useRemoveFriend.tsx b/client/src/entities/session/api/useRemoveFriend.tsx new file mode 100644 index 000000000..8e9ab6aa6 --- /dev/null +++ b/client/src/entities/session/api/useRemoveFriend.tsx @@ -0,0 +1,19 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { API } from '@/shared/api'; +import { API_FRIENDSHIP } from '@/shared/constant'; +import { toast } from 'sonner'; + +export const useRemoveFriend = (userId: number) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async () => await API.delete(`${API_FRIENDSHIP}/${userId}`), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['useGetFriends'] }); + queryClient.invalidateQueries({ queryKey: ['useGetFriendshipStatus', userId] }); + toast('User is removed from the friends list'); + }, + onError: err => { + toast(`Error occurred: ${err}`); + }, + }); +}; diff --git a/client/src/features/friend-button/friend-button.tsx b/client/src/features/friend-button/friend-button.tsx new file mode 100644 index 000000000..b366a359b --- /dev/null +++ b/client/src/features/friend-button/friend-button.tsx @@ -0,0 +1,79 @@ +import { useAddFriend } from '@/entities/session/api/useAddFriend'; +import { useRemoveFriend } from '@/entities/session/api/useRemoveFriend'; +import { Button } from '@/shared/ui'; +import { UserPlusIcon } from '@/shared/assets'; +import { useHandleFriendshipRequest } from '@/entities/session/api/useHandleFriendshipRequest'; +import { useGetFriendshipStatus } from '@/entities/session/api/useGetFriendshipStatus'; + +interface FriendButtonProps { + myId?: number; + userId: number; + short?: boolean; + size?: 'm' | 'l' | 's'; + width?: string; +} + +function getText(text: string, short: boolean) { + if (short) return text; + return text + ' friend'; +} + +export const FriendButton = ({ + myId, + userId, + short = false, + size = 'm', + width, +}: FriendButtonProps) => { + const { mutate: addFriend } = useAddFriend(myId, userId); + const { mutate: removeFriend } = useRemoveFriend(userId); + const { mutate: declineFriend } = useHandleFriendshipRequest(myId, userId, 'rejected'); + const { mutate: acceptFriend } = useHandleFriendshipRequest(myId, userId, 'accepted'); + const isMyProfile = myId === userId; + const { data } = useGetFriendshipStatus(userId); + + const friendStatus = data?.status; + + if (!myId || isMyProfile) { + return null; // Hide friend button if user not logged in or it's their profile + } + + switch (friendStatus) { + case 'none': { + return ( + + ); + } + case 'requested': { + return ( + + ); + } + case 'toRespond': { + return ( + <> + + + + ); + } + case 'friends': { + return ( + + ); + } + default: + return null; + } +}; diff --git a/client/src/features/friend-button/index.ts b/client/src/features/friend-button/index.ts new file mode 100644 index 000000000..d1e76b3aa --- /dev/null +++ b/client/src/features/friend-button/index.ts @@ -0,0 +1 @@ +export { FriendButton } from './friend-button'; diff --git a/client/src/shared/assets/icons/cake.tsx b/client/src/shared/assets/icons/cake.tsx new file mode 100644 index 000000000..eb9bd5067 --- /dev/null +++ b/client/src/shared/assets/icons/cake.tsx @@ -0,0 +1,51 @@ +import { SVGPropsWithSize } from '@/shared/types/svg-props-with-size'; +import { FC } from 'react'; + +export const Cake: FC = ({ size = '24', ...rest }) => { + return ( + + + + + + + + ); +}; diff --git a/client/src/shared/assets/icons/index.ts b/client/src/shared/assets/icons/index.ts index 251e25121..5777ed8ca 100644 --- a/client/src/shared/assets/icons/index.ts +++ b/client/src/shared/assets/icons/index.ts @@ -1,3 +1,6 @@ +export { Star } from './star'; +export { Cake } from './cake'; +export { MapPin } from './map-pin'; export { CheckIcon } from './check'; export { CrossIcon } from './cross'; export { EyeIcon } from './eye'; diff --git a/client/src/shared/assets/icons/map-pin.tsx b/client/src/shared/assets/icons/map-pin.tsx new file mode 100644 index 000000000..cc98d2275 --- /dev/null +++ b/client/src/shared/assets/icons/map-pin.tsx @@ -0,0 +1,20 @@ +import { SVGPropsWithSize } from '@/shared/types/svg-props-with-size'; +import { FC } from 'react'; + +export const MapPin: FC = ({ size = '24', ...rest }) => { + return ( + + + + ); +}; diff --git a/client/src/shared/assets/icons/star.tsx b/client/src/shared/assets/icons/star.tsx new file mode 100644 index 000000000..ab3877b7a --- /dev/null +++ b/client/src/shared/assets/icons/star.tsx @@ -0,0 +1,20 @@ +import { SVGPropsWithSize } from '@/shared/types/svg-props-with-size'; +import { FC } from 'react'; + +export const Star: FC = ({ size = '24', ...rest }) => { + return ( + + + + ); +}; diff --git a/client/src/shared/constant/server-routes.ts b/client/src/shared/constant/server-routes.ts index 72e465222..2a706c437 100644 --- a/client/src/shared/constant/server-routes.ts +++ b/client/src/shared/constant/server-routes.ts @@ -5,6 +5,7 @@ export const API_EMAIL_CONFIRM = '/auth/email/confirm'; export const API_FORGOT_PASSWORD = '/auth/forgot/password'; export const API_RESET_PASSWORD = '/auth/reset/password'; export const API_ME = '/auth/me'; +export const API_FRIENDSHIP = '/friendship'; export const API_REFRESH = '/auth/refresh'; export const API_LOGOUT = '/auth/logout'; export const API_GOOGLE_LOGIN = '/auth/google/login'; diff --git a/client/src/shared/ui/checkbox/checkbox.module.scss b/client/src/shared/ui/checkbox/checkbox.module.scss index 6ab24d13c..3ca8c88f0 100644 --- a/client/src/shared/ui/checkbox/checkbox.module.scss +++ b/client/src/shared/ui/checkbox/checkbox.module.scss @@ -12,6 +12,10 @@ } .label { + width: calc(100% - 26px); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; color: var(--white-color); font: var(--font-body-weight-m) var(--font-body-size-m) / var(--font-body-line-m) var(--font-rubik); diff --git a/client/src/shared/ui/drawer/drawer.tsx b/client/src/shared/ui/drawer/drawer.tsx index 45ad98ca0..01dcd3fad 100644 --- a/client/src/shared/ui/drawer/drawer.tsx +++ b/client/src/shared/ui/drawer/drawer.tsx @@ -37,6 +37,7 @@ import React, { FC, PropsWithChildren } from 'react'; import DrawerComponent from 'react-modern-drawer'; import 'react-modern-drawer/dist/index.css'; +import { Portal } from '../portal'; interface DrawerProps { open: boolean; @@ -65,18 +66,20 @@ export const Drawer: FC> = props => { } as React.CSSProperties; return ( - - {children} - + + + {children} + + ); }; diff --git a/client/src/shared/ui/image-loader/image-loader.module.scss b/client/src/shared/ui/image-loader/image-loader.module.scss index 4863c1bbd..0e23a7804 100644 --- a/client/src/shared/ui/image-loader/image-loader.module.scss +++ b/client/src/shared/ui/image-loader/image-loader.module.scss @@ -1,7 +1,7 @@ .crown_container { position: absolute; transform: rotate(30deg) translateX(-15%) translateY(-255%); - z-index: 1000; + z-index: 1; svg { width: 100%; diff --git a/client/src/shared/ui/search-bar/actions/actions.ts b/client/src/shared/ui/search-bar/actions/actions.ts new file mode 100644 index 000000000..728c19a6f --- /dev/null +++ b/client/src/shared/ui/search-bar/actions/actions.ts @@ -0,0 +1,38 @@ +import { MultiValue } from 'react-select'; +import { Action, ActionTypesEnum } from './types'; +import { IOptionItem } from '../types'; + +export const changeFilterValue = ( + filterIndex: number, + newValue: string | MultiValue | [number, number] | null +): Action => ({ + type: ActionTypesEnum.CHANGE_FILTER_VALUE, + payload: { + filterIndex, + newValue, + }, +}); + +export const clearOneMultipleOption = ( + filterIndex: number, + optionIndex: number +): Action => ({ + type: ActionTypesEnum.CLEAR_ONE_MULTIPLE_OPTION, + payload: { filterIndex, optionIndex }, +}); + +export const clearAllExceptOneMultipleOptions = ( + filterIndex: number +): Action => ({ + type: ActionTypesEnum.CLEAR_ALL_EXCEPT_ONE_MULTIPLE_OPTIONS, + payload: filterIndex, +}); + +export const clearFilter = (filterIndex: number): Action => ({ + type: ActionTypesEnum.CLEAR_FILTER, + payload: filterIndex, +}); + +export const clearAllFilters = (): Action => ({ + type: ActionTypesEnum.CLEAR_ALL_FILTERS, +}); diff --git a/client/src/shared/ui/search-bar/actions/index.ts b/client/src/shared/ui/search-bar/actions/index.ts new file mode 100644 index 000000000..b2660c547 --- /dev/null +++ b/client/src/shared/ui/search-bar/actions/index.ts @@ -0,0 +1,9 @@ +export { + changeFilterValue, + clearOneMultipleOption, + clearAllExceptOneMultipleOptions, + clearFilter, + clearAllFilters, +} from './actions'; +export type { Action } from './types'; +export { ActionTypesEnum } from './types'; diff --git a/client/src/shared/ui/search-bar/actions/types.ts b/client/src/shared/ui/search-bar/actions/types.ts new file mode 100644 index 000000000..54f61a0d5 --- /dev/null +++ b/client/src/shared/ui/search-bar/actions/types.ts @@ -0,0 +1,39 @@ +import { MultiValue } from 'react-select'; +import { IOptionItem } from '../types'; + +export enum ActionTypesEnum { + CHANGE_FILTER_VALUE = 'CHANGE_FILTER_VALUE', + CLEAR_ONE_MULTIPLE_OPTION = 'CLEAR_ONE_MULTIPLE_OPTION', + CLEAR_ALL_EXCEPT_ONE_MULTIPLE_OPTIONS = 'CLEAR_ALL_EXCEPT_ONE_MULTIPLE_OPTIONS', + CLEAR_FILTER = 'CLEAR_FILTER', + CLEAR_ALL_FILTERS = 'CLEAR_ALL_FILTERS', +} + +type ActionsWithoutPayload = ActionTypesEnum.CLEAR_ALL_FILTERS; + +interface ActionWithoutPayload { + type: ActionsWithoutPayload; +} + +export interface ActionWithPayload { + type: T; + payload: ActionsPayloads[T]; +} + +interface ActionsPayloads { + [ActionTypesEnum.CHANGE_FILTER_VALUE]: { + filterIndex: number; + newValue: string | MultiValue | [number, number] | null; + }; + [ActionTypesEnum.CLEAR_ONE_MULTIPLE_OPTION]: { + filterIndex: number; + optionIndex: number; + }; + [ActionTypesEnum.CLEAR_ALL_EXCEPT_ONE_MULTIPLE_OPTIONS]: number; + [ActionTypesEnum.CLEAR_FILTER]: number; + [ActionTypesEnum.CLEAR_ALL_FILTERS]: never; +} + +export type Action = T extends ActionsWithoutPayload + ? ActionWithoutPayload + : ActionWithPayload; diff --git a/client/src/shared/ui/search-bar/hooks/index.ts b/client/src/shared/ui/search-bar/hooks/index.ts index 20b654d16..93645ce21 100644 --- a/client/src/shared/ui/search-bar/hooks/index.ts +++ b/client/src/shared/ui/search-bar/hooks/index.ts @@ -1,2 +1,3 @@ export { useTrackFilterArr } from './useTrackFiltersArr'; export { useFilters } from './useFilters'; +export { useFilterReducer } from './useFilterReducer'; diff --git a/client/src/shared/ui/search-bar/hooks/useFilterReducer.ts b/client/src/shared/ui/search-bar/hooks/useFilterReducer.ts new file mode 100644 index 000000000..a769d79b0 --- /dev/null +++ b/client/src/shared/ui/search-bar/hooks/useFilterReducer.ts @@ -0,0 +1,132 @@ +import { useReducer } from 'react'; +import { Filter, IFilterState } from '../types'; +import { ActionTypesEnum, Action } from '../actions'; + +const reducer = (state: IFilterState, action: Action) => { + switch (action.type) { + case ActionTypesEnum.CLEAR_ONE_MULTIPLE_OPTION: { + const { filterIndex, optionIndex } = action.payload; + const filter = state.filterArr[filterIndex]; + + if (filter.type === 'checkbox' || filter.type === 'multiple') { + const newFilterValue = filter.filterValue.filter((item, i) => i !== optionIndex); + + return { + isTimerDisabled: false, + filterArr: state.filterArr.map((item, i) => { + if (filterIndex === i) { + item.filterValue = newFilterValue; + } + + return item; + }), + }; + } + + return state; + } + + case ActionTypesEnum.CLEAR_ALL_EXCEPT_ONE_MULTIPLE_OPTIONS: { + const filterIndex = action.payload; + const filter = state.filterArr[filterIndex]; + + if (filter.type === 'checkbox' || filter.type === 'multiple') { + const newFilterValue = [filter.filterValue[0]]; + + return { + isTimerDisabled: true, + filterArr: state.filterArr.map((item, i) => { + if (filterIndex === i) { + item.filterValue = newFilterValue; + } + + return item; + }), + }; + } + + return state; + } + + case ActionTypesEnum.CHANGE_FILTER_VALUE: { + const { filterIndex, newValue } = action.payload; + + return { + isTimerDisabled: false, + filterArr: state.filterArr.map((item, i) => { + if (filterIndex === i) { + item.filterValue = newValue; + } + + return item; + }), + }; + } + + case ActionTypesEnum.CLEAR_FILTER: { + const filterIndex = action.payload; + + return { + isTimerDisabled: false, + filterArr: state.filterArr.map((item, index) => { + if (index === filterIndex) { + switch (item.type) { + case 'text': + item.filterValue = ''; + + return item; + + case 'multiple': + case 'checkbox': + item.filterValue = []; + + return item; + + case 'range': + item.filterValue = null; + + return item; + } + } + + return item; + }), + }; + } + + case ActionTypesEnum.CLEAR_ALL_FILTERS: { + return { + isTimerDisabled: false, + filterArr: state.filterArr.map(item => { + switch (item.type) { + case 'text': + item.filterValue = ''; + + return item; + + case 'multiple': + case 'checkbox': + item.filterValue = []; + + return item; + + case 'range': + item.filterValue = null; + + return item; + } + }), + }; + } + + default: + return state; + } +}; + +export const useFilterReducer = (initialState: Filter[]) => { + return useReducer(reducer, { + isTimerDisabled: false, + filterArr: initialState, + }); +}; diff --git a/client/src/shared/ui/search-bar/hooks/useTrackFiltersArr.ts b/client/src/shared/ui/search-bar/hooks/useTrackFiltersArr.ts index 2c5400c0e..20dbb8e49 100644 --- a/client/src/shared/ui/search-bar/hooks/useTrackFiltersArr.ts +++ b/client/src/shared/ui/search-bar/hooks/useTrackFiltersArr.ts @@ -1,46 +1,55 @@ import { useEffect, useRef } from 'react'; import { Filter, IFilterParams } from '../types'; -export const useTrackFilterArr = ( - filterArr: Filter[], - onChange: (filterValues: string | null) => void -) => { - const timerRef = useRef | null>(null); - useEffect(() => { - if (timerRef.current) { - clearTimeout(timerRef.current); - } +const getFilterValues = (filterArr: Filter[]) => + filterArr.reduce<{ + [key: string]: string | string[] | [number, number]; + }>((acc, { type, value, filterValue }) => { + switch (type) { + case 'text': + if (filterValue.length) { + acc[value] = filterValue; + } - timerRef.current = setTimeout(() => { - const filterValues: IFilterParams = filterArr.reduce<{ - [key: string]: string | string[] | [number, number]; - }>((acc, curr) => { - switch (curr.type) { - case 'text': - if (curr.filterValue.length) { - acc[curr.value] = curr.filterValue; - } + return acc; + + case 'multiple': + case 'checkbox': + if (filterValue.length) { + acc[value] = filterValue.map(item => item.label); + } - return acc; + return acc; - case 'multiple': - case 'checkbox': - if (curr.filterValue.length) { - acc[curr.value] = curr.filterValue.map(item => item.label); - } + case 'range': + if (filterValue?.length) { + acc[value] = filterValue; + } - return acc; + return acc; + } + }, {}); - case 'range': - if (curr.filterValue?.length) { - acc[curr.value] = curr.filterValue; - } +export const useTrackFilterArr = ( + { isTimerDisabled, filterArr }: { isTimerDisabled: boolean; filterArr: Filter[] }, + onChange: (filterValues: string | null) => void +) => { + const filterValues: IFilterParams = getFilterValues(filterArr); - return acc; - } - }, {}); + const timerRef = useRef | null>(null); + useEffect(() => { + if (isTimerDisabled) { onChange(Object.keys(filterValues).length ? JSON.stringify(filterValues) : null); - }, 500); - }, [filterArr, onChange]); + } else { + if (timerRef.current) { + clearTimeout(timerRef.current); + } + + timerRef.current = setTimeout( + () => onChange(Object.keys(filterValues).length ? JSON.stringify(filterValues) : null), + 500 + ); + } + }, [isTimerDisabled, filterArr, onChange, filterValues]); }; diff --git a/client/src/shared/ui/search-bar/search-bar.module.scss b/client/src/shared/ui/search-bar/search-bar.module.scss index 75bd9131d..a20955ed8 100644 --- a/client/src/shared/ui/search-bar/search-bar.module.scss +++ b/client/src/shared/ui/search-bar/search-bar.module.scss @@ -25,4 +25,10 @@ @media screen and (max-width: 900px) { max-width: 546px; } + + @media (max-width: 768px) { + &_hidden { + display: none; + } + } } diff --git a/client/src/shared/ui/search-bar/search-bar.stories.tsx b/client/src/shared/ui/search-bar/search-bar.stories.tsx index 3aae5a49a..76251b6fc 100644 --- a/client/src/shared/ui/search-bar/search-bar.stories.tsx +++ b/client/src/shared/ui/search-bar/search-bar.stories.tsx @@ -32,13 +32,14 @@ export const SearchBar_default = () => { { label: 'Ukraine', value: 'ua' }, { label: 'Korea', value: 'kr' }, ], + oneItemName: 'country', filterValue: [], }, { - label: 'Specialty', - value: 'specialty', + label: 'Specialities', + value: 'specialities', type: 'multiple', - placeholder: 'Search by specialty', + placeholder: 'Search by speciality', optionsArr: [ { label: 'Mobile Developer', @@ -57,6 +58,7 @@ export const SearchBar_default = () => { value: 'fullstack', }, ], + oneItemName: 'speciality', filterValue: [], }, ]} diff --git a/client/src/shared/ui/search-bar/search-bar.tsx b/client/src/shared/ui/search-bar/search-bar.tsx index 0019d3bb0..2c4fbfe7a 100644 --- a/client/src/shared/ui/search-bar/search-bar.tsx +++ b/client/src/shared/ui/search-bar/search-bar.tsx @@ -7,10 +7,11 @@ import { FilterSelect } from './ui/filter-select'; import { SearchInput } from './ui/search-input'; import { TagList } from './ui/tag-list'; import { Flex } from '@/shared/ui'; -import { useTrackFilterArr } from './hooks'; +import { useFilterReducer, useTrackFilterArr } from './hooks'; import { SearchContext } from './contexts'; import { ModalButton } from './ui/modal-button'; import { Modal } from './ui/modal'; +import clsx from 'clsx'; /** * Search-bar Component @@ -45,39 +46,84 @@ interface SearchBarProps { } export const SearchBar: FC = ({ initialFiltersState, onChange }) => { - const [filterArr, setFilterArr] = useState(initialFiltersState); + const [filterState, dispatch] = useFilterReducer(initialFiltersState); const [filterIndex, setFilterIndex] = useState(0); const [isModalOpened, setIsModalOpened] = useState(false); - useTrackFilterArr(filterArr, onChange); + const [isFilterOpened, setIsFilterOpened] = useState(false); + useTrackFilterArr(filterState, onChange); - const onOpen = () => { + const { filterArr } = filterState; + + const onOpenModal = () => { setIsModalOpened(true); }; - const onClose = () => { + const onCloseModal = () => { setIsModalOpened(false); }; + const onOpenFilter = () => { + setIsFilterOpened(true); + }; + + const onCloseFilter = () => { + setIsFilterOpened(false); + }; + + const onOpenModalWithoutFilter = () => { + onCloseFilter(); + onOpenModal(); + }; + + const onOpenModalWithFilter = (value: string) => { + const newFilterIndex = filterArr.findIndex(filter => filter.value === value); + setFilterIndex(newFilterIndex); + + onOpenFilter(); + onOpenModal(); + }; + + const isShowTagList = filterArr.some(item => { + switch (item.type) { + case 'text': + case 'checkbox': + case 'multiple': + return item.filterValue.length; + } + }); + return ( - + - + - + - + ); }; diff --git a/client/src/shared/ui/search-bar/types/types.ts b/client/src/shared/ui/search-bar/types/types.ts index c271993e0..7583eb3c7 100644 --- a/client/src/shared/ui/search-bar/types/types.ts +++ b/client/src/shared/ui/search-bar/types/types.ts @@ -1,5 +1,6 @@ import { Dispatch, SetStateAction } from 'react'; import { MultiValue } from 'react-select'; +import { Action, ActionTypesEnum } from '../actions'; interface IFilter { label: string; @@ -21,12 +22,14 @@ export interface ICheckboxFilter extends IFilter { type: 'checkbox'; optionsArr: IOptionItem[]; filterValue: MultiValue; + oneItemName: string; } export interface IMultipleFilter extends IFilter { type: 'multiple'; optionsArr: IOptionItem[]; filterValue: MultiValue; + oneItemName: string; } export interface IRangeFilter extends IFilter { @@ -44,7 +47,12 @@ export interface IFilterParams { export interface SearchContextType { filterArr: Filter[]; - setFilterArr: Dispatch>; + dispatch: Dispatch>; filterIndex: number; setFilterIndex: Dispatch>; } + +export interface IFilterState { + isTimerDisabled: boolean; + filterArr: Filter[]; +} diff --git a/client/src/shared/ui/search-bar/ui/filter-menu/filter-menu.module.scss b/client/src/shared/ui/search-bar/ui/filter-menu/filter-menu.module.scss index 63c8f2178..e1ef1b0cf 100644 --- a/client/src/shared/ui/search-bar/ui/filter-menu/filter-menu.module.scss +++ b/client/src/shared/ui/search-bar/ui/filter-menu/filter-menu.module.scss @@ -7,7 +7,10 @@ } .menu_wrapper { - height: 100%; - margin-bottom: 10px; - overflow: hidden; + height: calc(100vh - 222px); + overflow: auto; + + &_short { + height: calc(100vh - 271px); + } } diff --git a/client/src/shared/ui/search-bar/ui/filter-menu/filter-menu.tsx b/client/src/shared/ui/search-bar/ui/filter-menu/filter-menu.tsx index fd97f53d3..97661665b 100644 --- a/client/src/shared/ui/search-bar/ui/filter-menu/filter-menu.tsx +++ b/client/src/shared/ui/search-bar/ui/filter-menu/filter-menu.tsx @@ -3,17 +3,36 @@ import { SearchInput } from '../search-input'; import { TagList } from '../tag-list'; import styles from './filter-menu.module.scss'; import { useState } from 'react'; +import { useFilters } from '../../hooks'; +import clsx from 'clsx'; export const FilterMenu = () => { const [menuWrapper, setMenuWrapper] = useState(null); + const { filterArr, filterIndex } = useFilters(); + + const isSelect = + filterArr[filterIndex].type === 'multiple' || filterArr[filterIndex].type === 'checkbox'; + const isFilterNotEmpty = !!( + filterArr[filterIndex].type === 'multiple' || + (filterArr[filterIndex].type === 'checkbox' && filterArr[filterIndex].filterValue?.length) + ); return ( - -
+ + + + {isSelect && ( +
+ )}
); }; diff --git a/client/src/shared/ui/search-bar/ui/modal-button/modal-button.tsx b/client/src/shared/ui/search-bar/ui/modal-button/modal-button.tsx index 1388a1984..32fbb9b0b 100644 --- a/client/src/shared/ui/search-bar/ui/modal-button/modal-button.tsx +++ b/client/src/shared/ui/search-bar/ui/modal-button/modal-button.tsx @@ -1,4 +1,4 @@ -import { FC } from 'react'; +import { FC, useEffect } from 'react'; import { Flex } from '@/shared/ui'; import { SearchIcon } from '@/shared/assets'; import { useGetScreenWidth } from '@/shared/lib'; @@ -12,9 +12,11 @@ interface ModalButtonProps { export const ModalButton: FC = ({ onOpen, onClose }) => { const screenWidth = useGetScreenWidth(); - if (screenWidth > 768) { - onClose(); - } + useEffect(() => { + if (screenWidth > 768) { + onClose(); + } + }, [screenWidth, onClose]); return ( diff --git a/client/src/shared/ui/search-bar/ui/modal/modal.tsx b/client/src/shared/ui/search-bar/ui/modal/modal.tsx index 1d3231f99..260d68773 100644 --- a/client/src/shared/ui/search-bar/ui/modal/modal.tsx +++ b/client/src/shared/ui/search-bar/ui/modal/modal.tsx @@ -1,4 +1,4 @@ -import { FC, useState } from 'react'; +import { FC } from 'react'; import { useFilters } from '../../hooks'; import clsx from 'clsx'; import { CrossIcon } from '@/shared/assets'; @@ -6,77 +6,42 @@ import { Drawer, Flex, Typography } from '@/shared/ui'; import { FilterMenu } from '../filter-menu'; import { ModalMenu } from '../modal-menu'; import styles from './modal.module.scss'; +import { clearAllFilters, clearFilter } from '../../actions'; interface ModalProps { isOpened: boolean; onClose: () => void; + isFilterOpened: boolean; + onOpenFilter: () => void; + onCloseFilter: () => void; } -export const Modal: FC = ({ isOpened, onClose }) => { - const { filterArr, setFilterArr, filterIndex, setFilterIndex } = useFilters(); +export const Modal: FC = ({ + isOpened, + onClose, + isFilterOpened, + onOpenFilter, + onCloseFilter, +}) => { + const { filterArr, dispatch, filterIndex, setFilterIndex } = useFilters(); const currentFilter = filterArr[filterIndex]; - const [isFilterOpened, setIsFilterOpened] = useState(false); const handleOpenFilter = (index: number) => { - setIsFilterOpened(true); + onOpenFilter(); setFilterIndex(index); }; const leftButtonHandler = () => { if (isFilterOpened) { - setFilterArr(prev => - prev.map((item, index) => { - if (index === filterIndex) { - switch (item.type) { - case 'text': - item.filterValue = ''; - - return item; - - case 'multiple': - case 'checkbox': - item.filterValue = []; - - return item; - - case 'range': - item.filterValue = null; - - return item; - } - } - - return item; - }) - ); + dispatch(clearFilter(filterIndex)); } else { - setFilterArr(prev => - prev.map(item => { - switch (item.type) { - case 'text': - item.filterValue = ''; - - return item; - - case 'multiple': - case 'checkbox': - item.filterValue = []; - - return item; - - case 'range': - item.filterValue = null; - - return item; - } - }) - ); + dispatch(clearAllFilters()); } }; const handleRightButtonClick = () => { if (isFilterOpened) { - setIsFilterOpened(false); + onCloseFilter(); } else { onClose(); } diff --git a/client/src/shared/ui/search-bar/ui/search-input/search-input.tsx b/client/src/shared/ui/search-bar/ui/search-input/search-input.tsx index 4620c63ee..f893e62d4 100644 --- a/client/src/shared/ui/search-bar/ui/search-input/search-input.tsx +++ b/client/src/shared/ui/search-bar/ui/search-input/search-input.tsx @@ -4,25 +4,18 @@ import { TextInput } from '../text-input'; import { SearchSelect } from '../search-select'; import { useFilters } from '../../hooks'; import { FC } from 'react'; +import { changeFilterValue } from '../../actions'; interface SearchInputProps { menuWrapper?: HTMLElement | null; } export const SearchInput: FC = ({ menuWrapper }) => { - const { filterArr, setFilterArr, filterIndex } = useFilters(); + const { filterArr, dispatch, filterIndex } = useFilters(); const currentFilter = filterArr[filterIndex]; const onChange = (newValue: string | MultiValue | [number, number] | null) => { - setFilterArr(prev => { - return prev.map((item, i) => { - if (filterIndex === i) { - item.filterValue = newValue; - } - - return item; - }); - }); + dispatch(changeFilterValue(filterIndex, newValue)); }; switch (currentFilter.type) { diff --git a/client/src/shared/ui/search-bar/ui/search-select/search-select.module.scss b/client/src/shared/ui/search-bar/ui/search-select/search-select.module.scss index 85d85ec97..a5c1f1eb0 100644 --- a/client/src/shared/ui/search-bar/ui/search-select/search-select.module.scss +++ b/client/src/shared/ui/search-bar/ui/search-select/search-select.module.scss @@ -8,10 +8,29 @@ } .menu_list { - display: grid; - grid-template-columns: auto auto; - column-gap: 32px; width: calc(100% + 36px); + display: flex; + flex-wrap: wrap; + + .option { + width: calc((100% - 1 * 32px) / 2); + + &:nth-child(2n) { + margin-left: 32px; + } + + @media (max-width: 768px) { + width: 100%; + + &:nth-child(2n) { + margin-left: 0; + } + } + } + + @media (max-width: 768px) { + width: 100%; + } } .search_icon_wrapper { diff --git a/client/src/shared/ui/search-bar/ui/search-select/search-select.tsx b/client/src/shared/ui/search-bar/ui/search-select/search-select.tsx index 75abc63c6..54165a6bd 100644 --- a/client/src/shared/ui/search-bar/ui/search-select/search-select.tsx +++ b/client/src/shared/ui/search-bar/ui/search-select/search-select.tsx @@ -39,6 +39,9 @@ export const SearchSelect: FC = ({ className={styles.select} menuIsOpen={menuWrapper ? true : undefined} styles={{ + control: () => ({ + padding: '8px 11px', + }), menuList: () => ({ padding: '8px 0', ...(menuWrapper ? { boxShadow: 'none' } : {}), @@ -46,21 +49,31 @@ export const SearchSelect: FC = ({ ...(menuWrapper ? { menu: () => ({ - background: 'var(--grey-dark-color)', maxHeight: 'none', height: '100%', + paddingTop: 0, + boxShadow: 'none', + overflow: 'auto', + borderRadius: '5px', position: 'static', }), + menuList: () => ({ + maxHeight: 'none', + borderRadius: '0', + background: 'none', + boxShadow: 'none', + }), menuPortal: () => ({ - height: '100%', - width: 'auto', position: 'static', + width: 'auto', + height: '100%', }), } : {}), }} classNames={{ menuList: () => styles.menu_list, + option: () => styles.option, }} value={value} controlShouldRenderValue={false} diff --git a/client/src/shared/ui/search-bar/ui/search-tag-menu/search-tag-menu.tsx b/client/src/shared/ui/search-bar/ui/search-tag-menu/search-tag-menu.tsx index c0857c922..33cfb6b3a 100644 --- a/client/src/shared/ui/search-bar/ui/search-tag-menu/search-tag-menu.tsx +++ b/client/src/shared/ui/search-bar/ui/search-tag-menu/search-tag-menu.tsx @@ -8,15 +8,15 @@ import { Flex } from '@/shared/ui'; interface SearchTagMenuProps { filterItem: ICheckboxFilter | IMultipleFilter; filterIndex: number; - onClearOption: (filterIndex: number, index: number) => void; - onClearAllOptions: (filterIndex: number) => void; + onClearOneOption: (filterIndex: number, index: number) => void; + onClearAllExceptOneOptions: (filterIndex: number) => void; } export const SearchTagMenu: FC = ({ filterItem, filterIndex, - onClearOption, - onClearAllOptions, + onClearOneOption, + onClearAllExceptOneOptions, }) => { const [isListOpened, setIsListOpened] = useState(false); const filterListRef = useClickOutside(() => setIsListOpened(false)); @@ -26,7 +26,7 @@ export const SearchTagMenu: FC = ({
setIsListOpened(true)}> +{filterItem.filterValue.length - 1}{' '} - {filterItem.filterValue.length > 2 ? 'items' : 'item'} + {filterItem.filterValue.length > 2 ? filterItem.value : filterItem.oneItemName}
{isListOpened ? ( @@ -36,7 +36,7 @@ export const SearchTagMenu: FC = ({ onClearOption(filterIndex, index + 1)} + onClick={() => onClearOneOption(filterIndex, index + 1)} > {item.label} @@ -48,7 +48,7 @@ export const SearchTagMenu: FC = ({ align='center' height='fit-content' padding='4px 8px' - onClick={() => onClearAllOptions(filterIndex)} + onClick={() => onClearAllExceptOneOptions(filterIndex)} className={styles.clear_all_button} >

Clear All

diff --git a/client/src/shared/ui/search-bar/ui/tag-list/tag-list.module.scss b/client/src/shared/ui/search-bar/ui/tag-list/tag-list.module.scss index ed5e62f5d..ac52b7b9c 100644 --- a/client/src/shared/ui/search-bar/ui/tag-list/tag-list.module.scss +++ b/client/src/shared/ui/search-bar/ui/tag-list/tag-list.module.scss @@ -3,15 +3,40 @@ display: flex; gap: 8px; flex-wrap: wrap; + + @media (max-width: 768px) { + overflow: auto; + flex-wrap: nowrap; + } } &_wrapper { border-radius: 5px; overflow: hidden; + min-width: 100px; } } -.checkboxFilterTag { +.checkbox_filter_tag { display: flex; gap: 8px; + + &_mobile { + display: none; + + @media (max-width: 768px) { + display: flex; + gap: 8px; + } + } + + @media (max-width: 768px) { + display: none; + } +} + +.first_tag_option { + @media (max-width: 768px) { + display: none; + } } diff --git a/client/src/shared/ui/search-bar/ui/tag-list/tag-list.tsx b/client/src/shared/ui/search-bar/ui/tag-list/tag-list.tsx index c5ea2a6db..00c8aa225 100644 --- a/client/src/shared/ui/search-bar/ui/tag-list/tag-list.tsx +++ b/client/src/shared/ui/search-bar/ui/tag-list/tag-list.tsx @@ -1,16 +1,27 @@ -import { FC } from 'react'; +import { FC, Fragment } from 'react'; import styles from './tag-list.module.scss'; import { Tag } from '../tag'; import { SearchTagMenu } from '../search-tag-menu'; import { useFilters } from '../../hooks'; import { Filter } from '../../types'; +import { + clearFilter, + clearOneMultipleOption, + clearAllExceptOneMultipleOptions, +} from '../../actions'; interface TagListProps { isOnlyCurrentFilterTags?: boolean; + isFilterMenu?: boolean; + onOpenFilter?: (value: string) => void; } -export const TagList: FC = ({ isOnlyCurrentFilterTags = false }) => { - const { filterArr, setFilterArr, filterIndex } = useFilters(); +export const TagList: FC = ({ + isOnlyCurrentFilterTags = false, + isFilterMenu = false, + onOpenFilter, +}) => { + const { filterArr, dispatch, filterIndex } = useFilters(); const currentFilter = filterArr[filterIndex]; if (!filterArr.length) { @@ -18,55 +29,15 @@ export const TagList: FC = ({ isOnlyCurrentFilterTags = false }) = } const handleClearTextFilter = (filterIndex: number) => { - setFilterArr(prev => - prev.map((item, index) => { - if (filterIndex === index) { - item.filterValue = ''; - } - - return item; - }) - ); + dispatch(clearFilter(filterIndex)); }; - const handleClearMultipleOption = (filterIndex: number, index: number) => { - setFilterArr(prev => { - const filter = prev[filterIndex]; - - if (filter.type === 'checkbox' || filter.type === 'multiple') { - const newFilterValue = filter.filterValue.filter((item, i) => i !== index); - - return prev.map((item, i) => { - if (filterIndex === i) { - item.filterValue = newFilterValue; - } - - return item; - }); - } - - return prev; - }); + const handleClearOneMultipleOption = (filterIndex: number, optionIndex: number) => { + dispatch(clearOneMultipleOption(filterIndex, optionIndex)); }; - const handleClearAllMultipleOptions = (filterIndex: number) => { - setFilterArr(prev => { - const filter = prev[filterIndex]; - - if (filter.type === 'checkbox' || filter.type === 'multiple') { - const newFilterValue = [filter.filterValue[0]]; - - return prev.map((item, i) => { - if (filterIndex === i) { - item.filterValue = newFilterValue; - } - - return item; - }); - } - - return prev; - }); + const handleClearAllExceptOneMultipleOptions = (filterIndex: number) => { + dispatch(clearAllExceptOneMultipleOptions(filterIndex)); }; const renderTag = (filterItem: Filter, index: number) => { @@ -83,20 +54,45 @@ export const TagList: FC = ({ isOnlyCurrentFilterTags = false }) = case 'multiple': case 'checkbox': if (filterItem.filterValue.length) { - return ( -
  • - handleClearMultipleOption(index, 0)}> - {filterItem.filterValue[0].label} - - {filterItem.filterValue.length > 1 && ( - - )} + return isFilterMenu ? ( +
  • + {filterItem.filterValue.map((option, optionIndex) => ( + handleClearOneMultipleOption(index, optionIndex)} + > + {option.label} + + ))}
  • + ) : ( + +
  • + handleClearOneMultipleOption(index, 0)}> + {filterItem.filterValue[0].label} + + + {filterItem.filterValue.length > 1 && ( + + )} +
  • + +
  • onOpenFilter(filterItem.value) : undefined} + className={styles.checkbox_filter_tag_mobile} + key={filterItem.value} + > + + {filterItem.filterValue.length} {filterItem.value} + +
  • +
    ); } diff --git a/client/src/shared/ui/search-bar/ui/tag/tag.tsx b/client/src/shared/ui/search-bar/ui/tag/tag.tsx index d8f5886e2..1547fed14 100644 --- a/client/src/shared/ui/search-bar/ui/tag/tag.tsx +++ b/client/src/shared/ui/search-bar/ui/tag/tag.tsx @@ -8,6 +8,7 @@ interface TagProps { isWithCross?: boolean; isFilledWhileHover?: boolean; isRounded?: boolean; + className?: string; onClick?: () => void; } @@ -16,15 +17,20 @@ export const Tag: FC> = ({ isWithCross = false, isFilledWhileHover = false, isRounded = true, + className, children, }) => { return ( = ({ defaultValue, placeholder, onCha return ( handleChange(e.target.value)} placeholder={placeholder} diff --git a/client/src/shared/ui/skeleton/skeleton.tsx b/client/src/shared/ui/skeleton/skeleton.tsx index 209328a1d..e0fb0385f 100644 --- a/client/src/shared/ui/skeleton/skeleton.tsx +++ b/client/src/shared/ui/skeleton/skeleton.tsx @@ -4,7 +4,10 @@ import 'react-loading-skeleton/dist/skeleton.css'; import { Flex } from '@/shared/ui'; interface CardSkeletonProps { - cards: number; + cards?: number; + height?: number | string; + width?: number | string; + borderRadius?: number; } function Box({ children }: PropsWithChildren) { @@ -15,7 +18,7 @@ function Box({ children }: PropsWithChildren) { ); } -export const CardSkeleton: FC = ({ cards }) => { +export const CardSkeleton: FC = ({ cards, borderRadius, height, width }) => { return Array(cards) .fill(0) .map((item, i) => ( @@ -24,9 +27,9 @@ export const CardSkeleton: FC = ({ cards }) => { wrapper={Box} baseColor='#313131' highlightColor='#525252' - width={230} - height={280} - borderRadius={15} + width={width ?? 230} + height={height ?? 280} + borderRadius={borderRadius ?? 15} /> )); }; diff --git a/client/src/widgets/modals/info-modal/user/desktop/desktop.tsx b/client/src/widgets/modals/info-modal/user/desktop/desktop.tsx index 377d8d71a..103aaf5d5 100644 --- a/client/src/widgets/modals/info-modal/user/desktop/desktop.tsx +++ b/client/src/widgets/modals/info-modal/user/desktop/desktop.tsx @@ -1,13 +1,16 @@ import { Button, Modal, Typography, Flex, ImageLoader } from '@/shared/ui'; import { FC } from 'react'; -import { ArrowRightIcon, UserPlusIcon, ChatCircleDotsIcon } from '@/shared/assets'; +import { ArrowRightIcon, ChatCircleDotsIcon } from '@/shared/assets'; import { calculateAge, getCountryFlag } from '@/shared/lib'; import { InfoModalUserProps } from '../interfaces'; import { IconLayout } from '../ui/icon-layout/icon-layout'; import { TextLayout } from '../ui/text-layout/text-layout'; +import { FriendButton } from '@/features/friend-button'; +import { useGetMe } from '@/entities/session'; export const UserDesktop: FC = ({ user, isOpenModal, handleClose }) => { const age = user?.dateOfBirth ? calculateAge(user.dateOfBirth) : null; + const { data: me } = useGetMe(); return ( <> @@ -53,24 +56,19 @@ export const UserDesktop: FC = ({ user, isOpenModal, handleC - - { - - } - + + {user?.id && } - - + + + diff --git a/client/src/widgets/modals/info-modal/user/phone/phone.tsx b/client/src/widgets/modals/info-modal/user/phone/phone.tsx index 3222a5965..734c2a467 100644 --- a/client/src/widgets/modals/info-modal/user/phone/phone.tsx +++ b/client/src/widgets/modals/info-modal/user/phone/phone.tsx @@ -1,4 +1,4 @@ -import { ArrowLeftIcon, ArrowRightIcon, ChatCircleDotsIcon, UserPlusIcon } from '@/shared/assets'; +import { ArrowLeftIcon, ArrowRightIcon, ChatCircleDotsIcon } from '@/shared/assets'; import { Button, Drawer, Flex, Typography } from '@/shared/ui'; import { FC } from 'react'; import styles from './phone.module.scss'; @@ -7,10 +7,12 @@ import { InfoModalUserProps } from '../interfaces'; import { ImageLoader } from '@/shared/ui/image-loader/image-loader'; import { IconLayout } from '../ui/icon-layout/icon-layout'; import { TextLayout } from '../ui/text-layout/text-layout'; +import { FriendButton } from '@/features/friend-button'; +import { useGetMe } from '@/entities/session'; export const UserPhone: FC = ({ user, isOpenModal, handleClose }) => { const age = user?.dateOfBirth ? calculateAge(user.dateOfBirth) : null; - + const { data: me } = useGetMe(); return ( <> @@ -26,10 +28,12 @@ export const UserPhone: FC = ({ user, isOpenModal, handleClo Back - + + + @@ -66,12 +70,9 @@ export const UserPhone: FC = ({ user, isOpenModal, handleClo - { - - } + {user?.id && ( + + )}