From ceb08877817981a483c6764c0c47b02c2dc1ad25 Mon Sep 17 00:00:00 2001 From: Ivan Sai <46083400+Ivan-Sai@users.noreply.github.com> Date: Sat, 9 Mar 2024 23:08:59 +0200 Subject: [PATCH] Feature/friends (#143) * feat: create friendship system * test: add e2e tests for friendship functionality * wip: add basic profile layout * feat: add profile list icons * feat: add icons * feat: add icons * feat: add social links * feat: add logo * fix: layout behind aside menu * feat: add back button * feat: add age * fix: add migration * fix: remove profile id at the button link * fix: toggle leader icon based on fetch data * feat: add empty placeholder for about section * update: add check friendship status * feat: add [username] to the path * Revert "feat: add [username] to the path" This reverts commit 5027de5a1cccebf80a120fd54769c0725336dca1. * fix: change route to the profile page * feat: add empty state * update: reworked notification update * update: updated friendship delete * fix: incorrect image reference * fix: case when links are missing * fix: minor ui changes * refactor: run prettier * feat: add skills (except projects & tournaments) * feat: add friends button logics * refactor: remove eslint errors * fix: back icon position * refactor: run Prettier * fix: specialty field to match updated type * fix: skills to match updated structure * fix: replace useGetMe with useGetUser * feat: add Friendship seed * fix: remove nav placeholder shrinking * fix: profile nav button path * fix: full navbar size * wip: add list of friends * feat: add friends modal * feat: add responsiveness * refactor: move logics to page.tsx * feat: add friend removal * refactor: remove unused import * fix: end -> flex-end * refactor: split ui to folders * fix: remove icon overlay * fix: back icon position * fix: small updates * fix: prettier * fix: backend lint * update: added username check * update: added username check * update: updated username check * feat: friend button states * fix: display correct skills * refactor: apply prettier * feat: add friend button (at user modal) * feat: add friend notification * feat: add profile link to user modal * refactor: add friend notification interface * feat: add width prop to friend btn * feat: add friend button to infoModal * feat: * fix: fixed friendship status receiving * fix: cleanup * fix: generation --------- Co-authored-by: Romas Bitinas <93491714+pupixipup@users.noreply.github.com> Co-authored-by: Nikita Mashchenko --- .../[username]/profile/layout.module.scss | 55 ++ .../app/(main)/[username]/profile/layout.tsx | 24 + .../[username]/profile/lib/profile-context.ts | 3 + .../profile/lib/useGetUserByName.ts | 8 + .../app/(main)/[username]/profile/page.tsx | 46 ++ .../profile/ui/about/about.module.scss | 3 + .../[username]/profile/ui/about/about.tsx | 58 ++ .../profile/ui/card/card.module.scss | 6 + .../[username]/profile/ui/card/card.tsx | 14 + .../profile/ui/fields/education.tsx | 35 ++ .../profile/ui/fields/fields.module.scss | 16 + .../[username]/profile/ui/fields/fields.tsx | 46 ++ .../[username]/profile/ui/fields/skills.tsx | 36 ++ .../profile/ui/fields/work-experience.tsx | 29 + .../profile/ui/friends/friends-modal.tsx | 60 ++ .../profile/ui/friends/friends.module.scss | 32 ++ .../[username]/profile/ui/friends/friends.tsx | 95 ++++ .../profile/ui/header/header.module.scss | 28 + .../[username]/profile/ui/header/header.tsx | 75 +++ .../profile/ui/list/list.module.scss | 0 .../[username]/profile/ui/list/list.tsx | 30 + .../[username]/profile/ui/row/row.module.scss | 0 .../(main)/[username]/profile/ui/row/row.tsx | 16 + client/src/app/(main)/layout.module.scss | 31 +- client/src/app/(main)/layout.tsx | 1 + .../src/app/(main)/ui/cards/cards.module.scss | 10 +- client/src/entities/session/api/index.ts | 1 + .../src/entities/session/api/useAddFriend.tsx | 22 + .../entities/session/api/useGetFriends.tsx | 26 + .../session/api/useGetFriendshipStatus.tsx | 22 + .../api/useHandleFriendshipRequest.tsx | 30 + .../entities/session/api/useRemoveFriend.tsx | 19 + .../features/friend-button/friend-button.tsx | 79 +++ client/src/features/friend-button/index.ts | 1 + client/src/shared/assets/icons/cake.tsx | 51 ++ client/src/shared/assets/icons/index.ts | 3 + client/src/shared/assets/icons/map-pin.tsx | 20 + client/src/shared/assets/icons/star.tsx | 20 + client/src/shared/constant/server-routes.ts | 1 + .../ui/image-loader/image-loader.module.scss | 2 +- client/src/shared/ui/skeleton/skeleton.tsx | 13 +- .../info-modal/user/desktop/desktop.tsx | 26 +- .../modals/info-modal/user/phone/phone.tsx | 25 +- .../sidebar/config/getSidebarItems.tsx | 2 +- .../src/widgets/sidebar/interfaces/index.ts | 1 + .../sidebar/interfaces/notification.ts | 17 + .../notification-item/friend-notification.tsx | 59 ++ .../notification-item.module.scss | 4 + .../notification-item/notification-item.tsx | 8 +- .../sidebar/ui/sidebar/sidebar.module.scss | 4 +- server/src/app.module.ts | 2 + .../1706204678430-CreateFriendship.ts | 28 + .../friendship/friendship-seed.module.ts | 12 + .../friendship/friendship-seed.service.ts | 36 ++ server/src/libs/database/seeds/run-seed.ts | 2 + server/src/libs/database/seeds/seed.module.ts | 2 + server/src/modules/auth/base/auth.service.ts | 31 +- .../modules/auth/base/dto/auth-update.dto.ts | 2 + .../friendship/dto/query-friends.dto.ts | 56 ++ .../friendship/dto/update-status.dto.ts | 10 + .../friendship/entities/friendship.entity.ts | 35 ++ .../friendship/friendship.controller.ts | 98 ++++ .../modules/friendship/friendship.module.ts | 16 + .../modules/friendship/friendship.service.ts | 521 ++++++++++++++++++ .../friendship/types/friendship.types.ts | 11 + .../dto/create-notification.dto.ts | 51 +- .../dto/update-friend-request-status.dto.ts | 14 + .../notifications/notifications.controller.ts | 20 +- .../notifications/notifications.service.ts | 126 ++++- .../notifications/types/notification.type.ts | 35 +- .../src/modules/users/entities/user.entity.ts | 8 +- server/src/modules/users/users.service.ts | 85 +++ server/test/friendship/friendship.e2e-spec.ts | 159 ++++++ server/test/user/users.e2e-spec.ts | 5 +- 74 files changed, 2463 insertions(+), 115 deletions(-) create mode 100644 client/src/app/(main)/[username]/profile/layout.module.scss create mode 100644 client/src/app/(main)/[username]/profile/layout.tsx create mode 100644 client/src/app/(main)/[username]/profile/lib/profile-context.ts create mode 100644 client/src/app/(main)/[username]/profile/lib/useGetUserByName.ts create mode 100644 client/src/app/(main)/[username]/profile/page.tsx create mode 100644 client/src/app/(main)/[username]/profile/ui/about/about.module.scss create mode 100644 client/src/app/(main)/[username]/profile/ui/about/about.tsx create mode 100644 client/src/app/(main)/[username]/profile/ui/card/card.module.scss create mode 100644 client/src/app/(main)/[username]/profile/ui/card/card.tsx create mode 100644 client/src/app/(main)/[username]/profile/ui/fields/education.tsx create mode 100644 client/src/app/(main)/[username]/profile/ui/fields/fields.module.scss create mode 100644 client/src/app/(main)/[username]/profile/ui/fields/fields.tsx create mode 100644 client/src/app/(main)/[username]/profile/ui/fields/skills.tsx create mode 100644 client/src/app/(main)/[username]/profile/ui/fields/work-experience.tsx create mode 100644 client/src/app/(main)/[username]/profile/ui/friends/friends-modal.tsx create mode 100644 client/src/app/(main)/[username]/profile/ui/friends/friends.module.scss create mode 100644 client/src/app/(main)/[username]/profile/ui/friends/friends.tsx create mode 100644 client/src/app/(main)/[username]/profile/ui/header/header.module.scss create mode 100644 client/src/app/(main)/[username]/profile/ui/header/header.tsx create mode 100644 client/src/app/(main)/[username]/profile/ui/list/list.module.scss create mode 100644 client/src/app/(main)/[username]/profile/ui/list/list.tsx create mode 100644 client/src/app/(main)/[username]/profile/ui/row/row.module.scss create mode 100644 client/src/app/(main)/[username]/profile/ui/row/row.tsx create mode 100644 client/src/entities/session/api/useAddFriend.tsx create mode 100644 client/src/entities/session/api/useGetFriends.tsx create mode 100644 client/src/entities/session/api/useGetFriendshipStatus.tsx create mode 100644 client/src/entities/session/api/useHandleFriendshipRequest.tsx create mode 100644 client/src/entities/session/api/useRemoveFriend.tsx create mode 100644 client/src/features/friend-button/friend-button.tsx create mode 100644 client/src/features/friend-button/index.ts create mode 100644 client/src/shared/assets/icons/cake.tsx create mode 100644 client/src/shared/assets/icons/map-pin.tsx create mode 100644 client/src/shared/assets/icons/star.tsx create mode 100644 client/src/widgets/sidebar/interfaces/index.ts create mode 100644 client/src/widgets/sidebar/interfaces/notification.ts create mode 100644 client/src/widgets/sidebar/ui/notification-item/friend-notification.tsx create mode 100644 server/src/libs/database/migrations/1706204678430-CreateFriendship.ts create mode 100644 server/src/libs/database/seeds/friendship/friendship-seed.module.ts create mode 100644 server/src/libs/database/seeds/friendship/friendship-seed.service.ts create mode 100644 server/src/modules/friendship/dto/query-friends.dto.ts create mode 100644 server/src/modules/friendship/dto/update-status.dto.ts create mode 100644 server/src/modules/friendship/entities/friendship.entity.ts create mode 100644 server/src/modules/friendship/friendship.controller.ts create mode 100644 server/src/modules/friendship/friendship.module.ts create mode 100644 server/src/modules/friendship/friendship.service.ts create mode 100644 server/src/modules/friendship/types/friendship.types.ts create mode 100644 server/src/modules/notifications/dto/update-friend-request-status.dto.ts create mode 100644 server/test/friendship/friendship.e2e-spec.ts 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..13379f660 100644 --- a/client/src/app/(main)/layout.module.scss +++ b/client/src/app/(main)/layout.module.scss @@ -1,29 +1,30 @@ .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; -} -.content_zone { - padding-left: 88px; + padding: 48px 55px; + @media (width <= 1120px) { + padding: 48px 24px; + } - @media screen and (max-width: 768px) { - padding-left: 0; + @media (width <= 580px) { + padding: 24px; } } + +.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)/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/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/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 && ( + + )}