Skip to content

Commit

Permalink
Feat: 마이리스트, 콜라보리스트 페이지 팔로우 기능 구현 (#26)
Browse files Browse the repository at this point in the history
* Feat: 마이리스트, 콜라보리스트 페이지 - 프로필 팔로우 기능 구현

* Design: 팔로우 상태에따른 버튼 UI 스타일 적용

* Feat: 로그인 상태가 아닐때 에러 처리 추가

* Feat: 마이리스트, 콜라보리스트 페이지 - 프로필 팔로우 취소 기능 구현

* Feat: 최대 팔로잉 제한 조건 적용

* Fix: 마이리스트, 콜라보리스트 페이지 관련 api export문 default로 수정

* Feat: 프로필 팔로잉, 팔로우 수 천 단위 콤마 적용

* Style: 주석정리, console.log 정리

* Fix: 리스트 아이템 타입 프로퍼티 이름 수정

* Feat: 프로필 정보 불러오기 enabled 옵션 추가, 토스트 메세지 추가

* Design: 토스트 메세지 수정

* Style: 공백 수정

* Fix: dev 브랜치 merge에 따른 리스트 상세조회 타입 수정

* Feat: 토스트메세지 상수화 처리

* Feat: 팔로우 요청 제한 로직 수정 및 토스트 메세지 수정

* Feat: 숫자를 1만 단위로 축약하는 number formatter 로직 구현

* Feat: 숫자를 미국 K단위, M단위로 축약하는 number formatter 로직 구현

* Style: 숫자 포맷팅 함수에 jsdoc 주석 추가
  • Loading branch information
ParkSohyunee authored Feb 15, 2024
1 parent f269e41 commit ca45024
Show file tree
Hide file tree
Showing 14 changed files with 169 additions and 32 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ import * as styles from './Footer.css';
import CollectIcon from '/public/icons/collect.svg';
import ShareIcon from '/public/icons/share.svg';
import EtcIcon from '/public/icons/etc.svg';
import { CollaboratorType, ListItemsType } from '@/lib/types/listType';
import { ItemType } from '@/lib/types/listType';
import { UserProfileType } from '@/lib/types/userProfileType';

interface BottomSheetOptionsProps {
key: string;
Expand All @@ -29,8 +30,8 @@ interface FooterProps {
listId: string;
title: string;
description: string;
items: ListItemsType[];
collaborators: CollaboratorType[];
items: ItemType[];
collaborators: UserProfileType[];
ownerNickname: string;
}

Expand Down
7 changes: 7 additions & 0 deletions src/app/_api/follow/createFollowUser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import axiosInstance from '@/lib/axios/axiosInstance';

const createFollowUser = async (userId: number) => {
return await axiosInstance.post(`/follow/${userId}`);
};

export default createFollowUser;
7 changes: 7 additions & 0 deletions src/app/_api/follow/deleteFollowUser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import axiosInstance from '@/lib/axios/axiosInstance';

const deleteFollowUser = async (userId: number) => {
return await axiosInstance.delete(`/follow/${userId}`);
};

export default deleteFollowUser;
7 changes: 4 additions & 3 deletions src/app/_api/list/getAllList.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import axiosInstance from '@/lib/axios/axiosInstance';
import { AllListType } from '@/lib/types/listType';

export async function getAllList(userId: number, type: string, category: string, cursorId?: number) {
const getAllList = async (userId: number, type: string, category: string, cursorId?: number) => {
const params = new URLSearchParams({
type,
category,
Expand All @@ -13,6 +13,7 @@ export async function getAllList(userId: number, type: string, category: string,
}

const response = await axiosInstance.get<AllListType>(`/users/${userId}/lists?${params.toString()}`);

return response.data;
}
};

export default getAllList;
4 changes: 3 additions & 1 deletion src/app/_api/user/getUserOne.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import axiosInstance from '@/lib/axios/axiosInstance';
import { UserType } from '@/lib/types/userProfileType';

export const getUserOne = async (userId: number) => {
const getUserOne = async (userId: number) => {
const response = await axiosInstance.get<UserType>(`/users/${userId}`);

return response.data;
};

export default getUserOne;
1 change: 1 addition & 0 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export default function TempLayout({ children }: { children: ReactNode }) {
<QueryClientProvider client={queryClient}>
<div id="modal-root" />
<div>{children}</div>
<ToastContainer />
</QueryClientProvider>
</body>
</html>
Expand Down
2 changes: 1 addition & 1 deletion src/app/user/[userId]/_components/Card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export default function Card({ list, isOwner }: CardProps) {
{list.listItems.map((item) => (
<li key={item.id} className={styles.item}>
<span className={styles.rank}>
{item.ranking}
{item.rank}
{'.'}
</span>
<span className={styles.itemTitle}>{item.title}</span>
Expand Down
4 changes: 2 additions & 2 deletions src/app/user/[userId]/_components/Content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ import BlueLineLongIcon from '/public/icons/blue_line_long.svg';
import Card from './Card';
import Categories from './Categories';

import { getUserOne } from '@/app/_api/user/getUserOne';
import { getAllList } from '@/app/_api/list/getAllList';
import getUserOne from '@/app/_api/user/getUserOne';
import getAllList from '@/app/_api/list/getAllList';

import { QUERY_KEYS } from '@/lib/constants/queryKeys';
import { UserType } from '@/lib/types/userProfileType';
Expand Down
25 changes: 19 additions & 6 deletions src/app/user/[userId]/_components/FollowButton.css.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,26 @@
import { style } from '@vanilla-extract/css';
import { style, styleVariants } from '@vanilla-extract/css';
import { vars } from '@/styles/theme.css';

export const button = style({
padding: '0.8rem 1.2rem',

backgroundColor: vars.color.blue,
borderRadius: '5rem',
fontWeight: '400',
lineHeight: '1.6rem',
});

fontSize: '1rem',
fontWeight: '600',
color: vars.color.white,
export const variant = styleVariants({
primary: [
button,
{
backgroundColor: vars.color.blue,
color: vars.color.white,
},
],
gray: [
button,
{
backgroundColor: vars.color.gray7,
color: vars.color.white,
},
],
});
78 changes: 66 additions & 12 deletions src/app/user/[userId]/_components/FollowButton.tsx
Original file line number Diff line number Diff line change
@@ -1,27 +1,81 @@
'use client';

/**
TODO
- [ ] 상태(팔로우, 언팔로우)에 따른 팔로우 버튼 UI
- [ ] 조건(비회원, 회원)에 따른 팔로우 버튼 동작(api 연동)
*/
import { useRouter } from 'next/navigation';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { AxiosError } from 'axios';

import * as styles from './FollowButton.css';

interface ActionProps {
import createFollowUser from '@/app/_api/follow/createFollowUser';
import deleteFollowUser from '@/app/_api/follow/deleteFollowUser';
import getUserOne from '@/app/_api/user/getUserOne';

import { QUERY_KEYS } from '@/lib/constants/queryKeys';
import { UserType } from '@/lib/types/userProfileType';
import { useUser } from '@/store/useUser';
import toasting from '@/lib/utils/toasting';
import { MAX_FOLLOWING, toastMessage } from '@/lib/constants/toastMessage';

interface FollowButtonProps {
userId: number;
isFollowed: boolean;
}

export default function FollowButton({ isFollowed }: ActionProps) {
const label = isFollowed ? '팔로우' : '팔로우 취소';
export default function FollowButton({ isFollowed, userId }: FollowButtonProps) {
const queryClient = useQueryClient();
const router = useRouter();
const { user: userMe } = useUser();

const { data: userMeData } = useQuery<UserType>({
queryKey: [QUERY_KEYS.userOne, userMe.id],
queryFn: () => getUserOne(userMe.id),
enabled: !!userMe.id,
});

const followUser = useMutation({
mutationKey: [QUERY_KEYS.follow, userId],
mutationFn: () => createFollowUser(userId),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: [QUERY_KEYS.userOne, userId],
});
},
onError: (error: AxiosError) => {
if (error.response?.status === 401) {
toasting({ type: 'warning', txt: toastMessage.ko.requiredLogin });
router.push('/login');
}
},
});

const deleteFollowingUser = useMutation({
mutationKey: [QUERY_KEYS.deleteFollow, userId],
mutationFn: () => deleteFollowUser(userId),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: [QUERY_KEYS.userOne, userId],
});
},
});

const handleFollowUser = () => {
// 1. follow 하는 api 요청 + update
const handleFollowUser = (isFollowed: boolean) => () => {
if (isFollowed) {
deleteFollowingUser.mutate();
} else {
if (userMeData && userMeData?.followingCount >= MAX_FOLLOWING) {
toasting({ type: 'warning', txt: toastMessage.ko.limitFollow });
return;
}
followUser.mutate();
}
};

return (
<button className={styles.button} onClick={handleFollowUser}>
{label}
<button
className={`${isFollowed ? styles.variant.gray : styles.variant.primary}`}
onClick={handleFollowUser(isFollowed)}
>
{isFollowed ? '팔로우 취소' : '팔로우'}
</button>
);
}
13 changes: 9 additions & 4 deletions src/app/user/[userId]/_components/Profile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,10 @@ import FollowButton from './FollowButton';
import SettingIcon from '/public/icons/setting.svg';

import useMoveToPage from '@/hooks/useMoveToPage';
import { getUserOne } from '@/app/_api/user/getUserOne';
import getUserOne from '@/app/_api/user/getUserOne';
import { QUERY_KEYS } from '@/lib/constants/queryKeys';
import { UserType } from '@/lib/types/userProfileType';
import numberFormatter from '@/lib/utils/numberFormatter';

export default function Profile({ userId }: { userId: number }) {
const [hasError, setHasError] = useState(false);
Expand Down Expand Up @@ -78,15 +79,19 @@ export default function Profile({ userId }: { userId: number }) {
<div className={styles.info}>
<div className={styles.user}>
<span className={styles.nickName}>{data?.nickname}</span>
{!data?.isOwner && <FollowButton isFollowed={!!data?.isFollowed} />}
{!data?.isOwner && <FollowButton userId={userId} isFollowed={!!data?.isFollowed} />}
</div>
<div className={styles.follow}>
<div className={styles.text} onClick={onClickMoveToPage(`/user/${userId}/followings`)}>
<span className={styles.count}>{data?.followingCount}</span>
<span className={styles.count}>
{data?.followingCount !== undefined && numberFormatter(data.followingCount, 'ko')}
</span>
<span>팔로잉</span>
</div>
<div className={styles.text} onClick={onClickMoveToPage(`/user/${userId}/followers`)}>
<span className={styles.count}>{data?.followerCount}</span>
<span className={styles.count}>
{data?.followerCount !== undefined && numberFormatter(data.followerCount, 'ko')}
</span>
<span>팔로워</span>
</div>
</div>
Expand Down
2 changes: 2 additions & 0 deletions src/lib/constants/queryKeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,6 @@ export const QUERY_KEYS = {
getRecommendedLists: 'getRecommendedLists',
getRecommendedUsers: 'getRecommendedUsers',
getTrendingLists: 'getTrendingLists',
follow: 'follow',
deleteFollow: 'deleteFollow',
};
10 changes: 10 additions & 0 deletions src/lib/constants/toastMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,13 @@ export const creaetListToastMessage = {
uploadImageError: '이미지를 업로드 하는 중에 오류가 발생했어요. 다시 업로드해주세요.',
createListError: '리스트 생성에 실패했어요. 다시 시도해주세요.',
};

export const MAX_FOLLOWING = 1000;

export const toastMessage = {
ko: {
requiredLogin: '로그인이 필요해요.',
limitFollow: `최대 ${MAX_FOLLOWING.toLocaleString('ko-KR')}명까지 팔로우할 수 있어요.`,
},
// 언어변경 시, en 추가
};
34 changes: 34 additions & 0 deletions src/lib/utils/numberFormatter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/**
* 숫자 형태를 단위에 맞게 변환해주는 포매팅 함수입니다.
- 만 미만: 축약 없이 컴마(,) 처리 ex. 1~9,999 (ko, en)
- 만 이상: 만 단위 ex. 1만, 35.5만, 510만... (ko)
- 만 이상: K 단위(소숫점 1자리) ex. 10K, 355.3K (en)
- 백만 이상: M 단위(소숫점 1자리) ex. 5.1M (en)
* @param {number} num 축약할 숫자입니다.
* @param {'ko' | 'en'} lang 적용할 숫자 국가 단위입니다.
* @returns {number} 단위에 맞게 변환된 숫자입니다.
*/

const numberFormatter = (num: number, lang: 'ko' | 'en') => {
const unit = 10000;

if (num / unit < 1) {
return num.toLocaleString('ko-KR');
}

if (lang === 'ko') {
const formattedNumKo = Math.trunc((num / unit) * 10) / 10;
return formattedNumKo + '만';
} else {
const formattedNumEn = Math.trunc((num / unit) * 10);
if (formattedNumEn < 1000) {
return formattedNumEn + 'K';
} else {
const formattedMillion = Math.trunc((formattedNumEn / 1000) * 10) / 10;
return formattedMillion + 'M';
}
}
};

export default numberFormatter;

0 comments on commit ca45024

Please sign in to comment.