[Typescript/TCAT] 웹서비스 구독 기능 구현하기

bokyungkim·2022년 11월 9일
0
post-custom-banner

기능 설명

실행 gif

나의 구독 페이지

  • 구독 중인 경우 button label은 '구독 중', 아닌 경우 '구독하기'
  • 버튼 클릭하면 상태 반전
  • 페이지 이동을 하지 않는 경우 구독중 목록은 업데이트 되지 않는다.
  • 즉, 구독 취소를 하고 페이지 이동 후 돌아오면 목록이 업데이트 된다.

티켓홈 페이지

후기 페이지

흐름

  1. 구독중/구독자 프로필 리스트를 받아와 저장한다.
  2. 각 프로필을 컴포넌트로 생성해 나열한다.
  3. 프로필 컴포넌트의 homeId가 구독 중 리스트에 존재하면 button label을 구독 중으로, 존재하지 않으면 구독하기로 설정한다.
    • 페이지 첫 진입 시 구독 중 메뉴의 모든 button label은 '구독 중'이다.
    • 구독자 메뉴에는 '구독 중'과 '구독하기'가 섞여있을 수 있다.
  4. 버튼을 클릭할 때마다 구독 중 목록을 업데이트하도록 서버에 요청한다.
    • button label이 구독 중일 때 클릭하면 삭제 요청
    • button label이 구독하기일 때 클릭하면 추가 요청
  5. 서버에 요청하면서 구독 중 목록에 변화가 생길 때마다 button label을 반전시켜준다.

구현

나의 구독 페이지 위주로 설명하겠다.

api

필요한 함수는 4개이다.

getFollowingProfile()

내가 구독하고 있는 사용자 목록을 받아오는 함수. 매개변수로 내 homeId를 전달한다.

export const getFollowingProfile = async (homeId: string | undefined): Promise<SimpleProfileListType | false> => {
  try {
    const res = await fetchApi.get(`/api/member/${homeId}/following`);
    if (res.status !== 200) throw new Error('error');
    const { follows } = await res.json();
    return follows;
  } catch (err) {
    return false;
  }
};

getFollowerProfile()

나를 구독하고 있는 사용자 목록을 받아오는 함수. 마찬가지로 매개변수로 내 homeId를 전달한다.

export const getFollowerProfile = async (homeId: string): Promise<SimpleProfileListType | false> => {
  try {
    const res = await fetchApi.get(`/api/member/${homeId}/follower`);
    if (res.status !== 200) throw new Error('error');
    const { follows } = await res.json();
    return follows;
  } catch (err) {
    return false;
  }
};

updateFollowing()

구독 목록 추가 함수. 매개변수로 내 homeId와 구독할 사용자의 정보 목록을 전달한다.

// 리스트 추가 요청
export const updateFollowing = async (
  homeId: string,
  updateFollowingReqType: UpdateFollowingType,
): Promise<UpdateFollowingType | false> => {
  try {
    const res = await fetchApi.put(`/api/member/${homeId}/following`, updateFollowingReqType);
    if (res.status !== 200) throw new Error('error');
    return await res.json();
  } catch (err) {
    return false;
  }
};

deleteFollowing()

구독 목록 삭제 함수. 매개변수로 내 homeId와 구독 취소할 사용자의 homeId를 전달한다.

// 리스트 삭제 요청
export const deleteFollowing = async (homeId: string, targetHomeId: string | undefined): Promise<boolean> => {
  try {
    const res = await fetchApi.delete(`/api/member/${homeId}/following/${targetHomeId}`);
    if (res.status !== 200) throw new Error('error');
    return true;
  } catch (err) {
    return false;
  }
};

FollowModal

구독 페이지가 팝업으로 되어있어 최상위 컴포넌트명이 FollowModal이다.

index.tsx

import React, { useState, useEffect } from 'react';
import { useRecoilValue } from 'recoil';

import ModalFrame from '@components/Modal/ModalFrame';
import FollowTab from '@components/Common/Tab/FollowTab';
import FollowList from '@components/FollowList';
import SimpleProfile from '@src/components/Common/SimpleProfile';

import { SimpleProfileListType } from '@src/types/member';

import { userProfileState } from '@stores/user';
import { getFollowingProfile, getFollowerProfile } from '@apis/follow';

import { Container } from './style';

interface Props {
  handleFollowModalClose: () => void;
}

const FollowModal: React.FC<Props> = ({ handleFollowModalClose }) => {
  const [isFollowing, setIsFollowing] = useState(true);
  const [followingProfiles, setFollowingProfiles] = useState<SimpleProfileListType>([]);
  const [followerProfiles, setFollowerProfiles] = useState<SimpleProfileListType>([]);
  const { homeId } = useRecoilValue(userProfileState);

  const getFollowingProfiles = async () => {
    const data = await getFollowingProfile(homeId);
    if (data) {
      setFollowingProfiles(data);
    } else {
      setFollowingProfiles([]);
    }
  };

  const getFollowerProfiles = async () => {
    const data = await getFollowerProfile(homeId);
    if (data) {
      setFollowerProfiles(data);
    } else {
      setFollowerProfiles([]);
    }
  };

  const handleFirstLink = () => {
    setIsFollowing(true);
  };

  const handleSecondLink = () => {
    setIsFollowing(false);
  };

  useEffect(() => {
    getFollowingProfiles();
    getFollowerProfiles();
  }, []);

  useEffect(() => {
    getFollowingProfiles();
    getFollowerProfiles();
  }, [isFollowing]);

  return (
    <ModalFrame w={43} h={48} handleModalClose={handleFollowModalClose}>
      <Container>
        <FollowTab
          firstText="구독중"
          secondText="구독자"
          handleFirstLink={handleFirstLink}
          handleSecondLink={handleSecondLink}
          focus={isFollowing ? 'first' : 'second'}
        />
        <FollowList>
          {isFollowing
            ? followingProfiles.map((t) => (
                <SimpleProfile
                  key={t.targetHomeId}
                  targetHomeId={t.targetHomeId}
                  memberImg={t.memberImg}
                  name={t.name}
                  bio={t.bio}
                  followingProfiles={followingProfiles}
                />
              ))
            : followerProfiles.map((t) => (
                <SimpleProfile
                  key={t.targetHomeId}
                  targetHomeId={t.targetHomeId}
                  memberImg={t.memberImg}
                  name={t.name}
                  bio={t.bio}
                  followingProfiles={followingProfiles}
                />
              ))}
        </FollowList>
      </Container>
    </ModalFrame>
  );
};

export default FollowModal;

변수

  const [isFollowing, setIsFollowing] = useState(true);
  const [followingProfiles, setFollowingProfiles] = useState<SimpleProfileListType>([]);
  const [followerProfiles, setFollowerProfiles] = useState<SimpleProfileListType>([]);
  const { homeId } = useRecoilValue(userProfileState);

isFollowing : true면 구독중 탭, false면 구독자 탭으로 이동하기 위한 flag 변수
followingProfiles : 내가 구독하는 사용자 정보 목록을 저장하는 변수
followerProfiles : 나를 구독하는 사용자 정보 목록을 저장하는 변수
homeId : recoil을 사용하여 내 homeId를 저장해둔 전역변수

getFollowingProfiles(), getFollowerProfiles()

  const getFollowingProfiles = async () => {
    const data = await getFollowingProfile(homeId);
    if (data) {
      setFollowingProfiles(data);
    } else {
      setFollowingProfiles([]);
    }
  };

  const getFollowerProfiles = async () => {
    const data = await getFollowerProfile(homeId);
    if (data) {
      setFollowerProfiles(data);
    } else {
      setFollowerProfiles([]);
    }
  };

전역변수 homeId를 전달하여 구독중, 구독자 목록을 받아와 저장한다. 불러오기를 실패하면 빈 리스트로 저장한다.

  const handleFirstLink = () => {
    setIsFollowing(true);
  };

  const handleSecondLink = () => {
    setIsFollowing(false);
  };

구독중, 구독자 링크 이동을 위한 handler 함수

useEffect()

  useEffect(() => {
    getFollowingProfiles();
    getFollowerProfiles();
  }, []);

  useEffect(() => {
    getFollowingProfiles();
    getFollowerProfiles();
  }, [isFollowing]);
  1. useEffect를 사용하여 맨 처음 렌더링 될 때 한 번 구독 목록을 불러온다.
    • 처음에만 불러오는 이유는 구독중, 구독하기 버튼을 누를 때 보여지는 목록이 업데이트 되지 않도록 하기 위해서다. 새로고침을 하지 않는 이상 페이지 내에서 기존 목록을 수정할 수 있도록!
    • 매번 업데이트를 해준다면 구독중 버튼을 누를 때마다 구독이 취소되면서 구독하기 버튼으로 바뀌는 게 아니라 아예 보이는 목록에서 사라지게 된다. 나는 이걸 원하지 않았던 것
  2. 탭이 변경될 때도 새로 불러온다.
    • 새로고침 해주는 거라 생각하면 된다.

FollowTab

<FollowTab
	firstText="구독중"
    secondText="구독자"
    handleFirstLink={handleFirstLink}
    handleSecondLink={handleSecondLink}
    focus={isFollowing ? 'first' : 'second'}
/>

구독중 혹은 구독자 페이지로 이동하는 탭이다.

FollowList

<FollowList>
  {isFollowing
    ? followingProfiles.map((t) => (
        <SimpleProfile
          key={t.targetHomeId}
          homeId={homeId}
          targetHomeId={t.targetHomeId}
          memberImg={t.memberImg}
          name={t.name}
          bio={t.bio}
          followingProfiles={followingProfiles}
        />
      ))
    : followerProfiles.map((t) => (
        <SimpleProfile
          key={t.targetHomeId}
          homeId={homeId}
          targetHomeId={t.targetHomeId}
          memberImg={t.memberImg}
          name={t.name}
          bio={t.bio}
          followingProfiles={followingProfiles}
        />
      ))}
</FollowList>

isFollowing의 상태에 따라 구독중 혹은 구독자 목록을 불러온다. 불러온 정보는 SimpleProfile 컴포넌트에 하나씩 매핑된다. 추후 구독 버튼 기능을 위하여 내 homeId와 내가 구독하는 목록(followingProfiles)도 전달해준다.

SimpleProfile : index.tsx

매핑된 유저 정보와 구독하기 버튼을 포함한 컴포넌트다.

index.tsx

import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';

import ProfileIcon from '@components/Common/Profile/ProfileIcon';
import FollowButton from '@components/Common/FollowButton';

import { updateFollowing, deleteFollowing } from '@src/apis/follow';
import { SimpleProfileListType } from '@src/types/member';

import { Container, ButtonWrapper } from './style';

interface Props {
  targetHomeId: string;
  homeId: string;
  memberImg: string;
  name: string;
  bio: string;
  followingProfiles: SimpleProfileListType;
}

const SimpleProfile: React.FC<Props> = ({ targetHomeId, homeId, memberImg, name, bio, followingProfiles }) => {
  const [buttonText, setButtonText] = useState('...');

  const changeText = () => {
    if (followingProfiles) {
      if (followingProfiles.find((f) => f.targetHomeId === targetHomeId)) {
        setButtonText('구독 중');
      } else {
        setButtonText('구독하기');
      }
    }
  };

  const handleFollowButton = async () => {
    if (buttonText === '구독 중') {
      const result = await deleteFollowing(homeId, targetHomeId);
      if (result) {
        setButtonText('구독하기');
      }
    } else {
      const result = await updateFollowing(homeId, {
        targetHomeId,
        name,
        memberImg,
        bio,
      });
      if (result) {
        setButtonText('구독 중');
      }
    }
  };

  useEffect(() => {
    changeText();
  }, [followingProfiles]);

  return (
    <Container>
      <Link to={`/@${targetHomeId}`}>
        <ProfileIcon size={3.75} profileImg={memberImg} />
        <div>
          <b>{name}</b>
          <span>{bio}</span>
        </div>
      </Link>
      <ButtonWrapper>
        <FollowButton text={buttonText} handler={handleFollowButton} />
      </ButtonWrapper>
    </Container>
  );
};

export default SimpleProfile;

changeText()

const [buttonText, setButtonText] = useState('...');

const changeText = () => {
  if (followingProfiles) {
    if (followingProfiles.find((f) => f.targetHomeId === targetHomeId)) {
      setButtonText('구독 중');
    } else {
      setButtonText('구독하기');
    }
  }
};
  • buttonText : 버튼에 표시될 텍스트 변수. 받아온 구독중 목록(followingProfiles)이 없을 경우 초기값인 '...'을 갖는다.
  • 구독중 목록(followingProfiles)에 해당 유저의 homeId가 존재하면 내가 그를 구독 중이라는 뜻이므로 buttonText를 '구독 중'으로 변경한다.
  • 유저의 homeId가 존재하지 않는 경우 내가 그를 구독하지 않는다는 뜻이므로 buttonText를 '구독하기'로 변경한다.

handleFollowButton()

const handleFollowButton = async () => {
  if (buttonText === '구독 중') {
    const result = await deleteFollowing(homeId, targetHomeId);
    if (result) {
      setButtonText('구독하기');
    }
  } else {
    const result = await updateFollowing(homeId, {
      targetHomeId,
      name,
      memberImg,
      bio,
    });
    if (result) {
      setButtonText('구독 중');
    }
  }
};
  • 구독 버튼 핸들러
  • buttonText가 '구독 중'일 경우 클릭하면 구독을 취소하겠다는 뜻이므로 deleteFollowing api 실행. 성공하면 buttonText를 '구독하기'로 변경한다.
  • buttonText가 '구독하기'일 경우 클릭하면 구독하겠다는 뜻이므로 updateFollowing api 실행. 성공하면 buttonText를 '구독 중'으로 변경한다.

useEffect()

useEffect(() => {
  changeText();
}, [followingProfiles]);

내가 구독중인 목록이 변경될 때(== deleteFollowing, updateFollowing api를 실행할 때 == 구독하기 버튼 핸들러가 실행될 때)마다 buttonText를 변경해준다.

components

<Container>
  <Link to={`/@${targetHomeId}`}>
    <ProfileIcon size={3.75} profileImg={memberImg} />
    <div>
      <b>{name}</b>
      <span>{bio}</span>
    </div>
  </Link>
  <ButtonWrapper>
    <FollowButton text={buttonText} handler={handleFollowButton} />
  </ButtonWrapper>
</Container>
  • Link : ProfileIcon(프로필 아이콘)과 name, bio가 담겨있는 구역을 클릭하면 해당 유저의 티켓홈으로 이동한다.
  • FollowButton : 텍스트는 buttonText, 핸들러는 handleFollowButton을 사용한다.

티켓홈 페이지 소스코드

.
.
.
const HomeTemplate: React.FC<Props> = ({
  isMyHome,
  homeId,
  targetHomeId,
  profile,
  following,
  tickets,
  isLoaded,
  ticketbooks,
  initialTicketbookCount,
  cloneTicketbooks,
  setTarget,
  handlePageNavigate,
  changeCurrTicketbookId,
}) => {
  const [buttonText, setButtonText] = useState('...');

  const changeText = () => {
    if (following.find((f) => f.targetHomeId === targetHomeId)) {
      setButtonText('구독 중');
    } else {
      setButtonText('구독하기');
    }
    return buttonText;
  };

  const handleFollowButton = async () => {
    if (buttonText === '구독 중') {
      const result = await deleteFollowing(homeId, targetHomeId);
      if (result) {
        setButtonText('구독하기');
      }
    } else {
      const result = await updateFollowing(homeId, {
        targetHomeId,
        name: profile.name,
        memberImg: profile.img,
        bio: profile.bio,
      });
      if (result) {
        setButtonText('구독 중');
      }
    }
  };

  useEffect(() => {
    if (buttonText === '...') {
      changeText();
    }
  });

  useEffect(() => {
    changeText();
  }, [following]);

  return (
    <Layout>
      <ProfileWrapper>
        <ProfileBox
          img={profile.img}
          name={profile.name}
          bio={profile.bio}
          ticketCount={profile.ticketCount}
          likeCount={profile.likeCount}
        />
      </ProfileWrapper>
      <ButtonWrapper>
        {isMyHome ? (
          <BasicButton text="티켓추가" handler={handlePageNavigate} />
        ) : (
          <FollowButton text={buttonText} handler={handleFollowButton} />
        )}
      </ButtonWrapper>
	  .
      .
      .
      <HomeBackground />
    </Layout>
  );
};

export default HomeTemplate;

후기 페이지 소스코드

.
.
.
const PostTemplate: React.FC<Props> = ({ post, isMyHome, like, handlePostDelete, handlePostEdit, handleLike }) => {
  const myProfile = useRecoilValue(userProfileState);
  const sanitizer = dompurify.sanitize;
  const [buttonText, setButtonText] = useState('...');
  const [followingProfiles, setFollowingProfiles] = useState<SimpleProfileListType>([]);

  const getFollowingProfiles = async () => {
    const data = await getFollowingProfile(myProfile.homeId);
    if (data) {
      setFollowingProfiles(data);
    } else {
      setFollowingProfiles([]);
    }
  };

  const changeText = () => {
    if (followingProfiles) {
      if (followingProfiles.find((f) => f.targetHomeId === post?.memberHomeId)) {
        setButtonText('구독 중');
      } else {
        setButtonText('구독하기');
      }
    }
  };

  const handleFollowButton = async () => {
    if (buttonText === '구독 중') {
      const result = await deleteFollowing(myProfile.homeId, post?.memberHomeId);
      if (result) {
        setButtonText('구독하기');
      }
    } else {
      const result = await updateFollowing(myProfile.homeId, {
        targetHomeId: post?.memberHomeId,
        name: post?.memberName,
        memberImg: post?.memberImg,
        bio: post?.memberBio,
      });
      if (result) {
        setButtonText('구독 중');
      }
    }
  };

  useEffect(() => {
    if (buttonText === '...') {
      changeText();
    }
  });

  useEffect(() => {
    getFollowingProfiles();
  }, []);

  useEffect(() => {
    changeText();
  }, [followingProfiles]);

  return (
    <Layout>
      {post ? (
        <>
          <PostContainer>
       .
       .
       .
            <MediumProfileContainer>
              <Link to={`/@${post.memberHomeId}`}>
                <ProfileIcon size={3.75} profileImg={post.memberImg} />
                <div>
                  <b>{post.memberName}</b>
                  <span>{post.memberBio}</span>
                </div>
              </Link>
              {!isMyHome && (
                <ButtonWrapper>
                  <FollowButton text={buttonText} handler={handleFollowButton} />
                </ButtonWrapper>
              )}
            </MediumProfileContainer>
          </PostContainer>
          <TicketsContainer />
          <PostBackground />
        </>
      ) : (
        <SpinnerWrapper>
          <Spinner size={3.5} />
        </SpinnerWrapper>
      )}
    </Layout>
  );
};

export default PostTemplate;
post-custom-banner

0개의 댓글