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 00000000..fb40784a --- /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 00000000..15e82588 --- /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 00000000..47d81496 --- /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 00000000..759e171d --- /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 00000000..783690df --- /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 00000000..11be4b36 --- /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 00000000..2ee2227a --- /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 00000000..01dfef8a --- /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 00000000..179deb8d --- /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 00000000..9c9ff17e --- /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 00000000..f69e7415 --- /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 00000000..301cb53d --- /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 00000000..94b1b5c3 --- /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 00000000..92a1f9f6 --- /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 00000000..95205902 --- /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 00000000..6adccb4c --- /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 00000000..e6f29d95 --- /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 00000000..d56cc64f --- /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 00000000..7a4546a8 --- /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 00000000..e69de29b 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 00000000..3a3ee6db --- /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 00000000..e69de29b 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 00000000..ae85d251 --- /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 5544aa3a..13379f66 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 f487f025..d3594de7 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 e45207e2..992bcb8b 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 839cf975..29e99a91 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 00000000..69448c90 --- /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 00000000..b60c92fe --- /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 00000000..f2131c8b --- /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 00000000..f5abecea --- /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 00000000..8e9ab6aa --- /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 00000000..b366a359 --- /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 00000000..d1e76b3a --- /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 00000000..eb9bd506 --- /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 251e2512..5777ed8c 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 00000000..cc98d227 --- /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 00000000..ab3877b7 --- /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 72e46522..2a706c43 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 4863c1bb..0e23a780 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 209328a1..e0fb0385 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 377d8d71..103aaf5d 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 3222a596..734c2a46 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 && ( + + )}