카테고리 없음

[내일배움캠프] 최종 프로젝트 - 동행 모집 페이지: supabase에서 목록 리스트 불러오기(supabase 외래키 data 가져오기), 메인 이미지 추가, 공통 모달 생성(zustand 및 id 값 이용한 전역 상태 관리)

코찡 2023. 8. 22. 11:04

오늘의 기억할 거리

1. supabase 외래키(writerId) data 가져오기

1) 첫 번째 시도

// api 폴더 하위 supabase 폴더 하위 partner.ts

import { supabase } from './supabaseClient';

export const getPartnerPosts = async () => {
  let { data: partnerPosts, error } = await supabase.from('partnerPosts').select('*, users(*)').eq('writerId', 'users.id');
  return { data: partnerPosts, error };
};

[오류 발생]

동행자 게시글 목록을 가져오는 과정에서 에러 발생

[오류 원인]

Supabase에서 partnerPosts 테이블과 users 테이블 사이에 "연결 관계가 2개" 있었음. 하나는 applicant, 다른 하나는 writerId.
따라서 이 2가지 관계 중 임베딩하고자 하는 정확한 관계를 지정해주어야 함.

-> 여기서는 partnerPosts 테이블과 users 테이블 사이의 writerId 관계를 사용할 것.

 

[수정된 코드]

// api 폴더 하위 supabase 폴더 하위 partner.ts

import { supabase } from './supabaseClient';

export const getPartnerPosts = async () => {
  let { data: partnerPosts, error } = await supabase.from('partnerPosts').select('*, users!partnerPosts_writerId_fkey(*)');
  return { data: partnerPosts, error };
};

[1차 해결]

이제 아래와 같이 동행글 목록 data는 잘 불러와집니다.

2) 두 번째 시도

이제 writerId를 통해 외래키로 연결한 users 정보를 화면에 렌더링하려고 하니까 다시 오류 발생

[오류 메시지]

'{ applicant: string | null; content: string; country: string; createdAt: string; endDate: string; id: string; interestUrl: string[]; isOpen: boolean; numOfPeople: number; openChat: string; region: string; startDate: string; title: string; writerId: string | null; }' 형식에 'users' 속성이 없습니다.

 

[수정한 코드]

Supabase DB의 type 선언 파일에서 users 속성 추가

// api 폴더 하위 supabase 폴더 하위 supabase.ts -> type 선언 파일에서 users 속성 추가

partnerPosts: {
        Row: {
          applicant: string | null;
          content: string;
          country: string;
          createdAt: string;
          endDate: string;
          id: string;
          interestUrl: string[];
          isOpen: boolean;
          numOfPeople: number;
          openChat: string;
          region: string;
          startDate: string;
          title: string;
          writerId: string | null;
          
          // 추가한 부분
          users: {
            birthday: string;
            gender: string;
            nickName: string;
            profileImageUrl: string | null;
            
          };
        };
     }

[최종 해결]

이제 users 속성 잘 뜨고 화면에도 렌더링 됨!

post.users를 찍었을 때 해당 users 테이블의 속성이 잘 추천되어 뜨고 있음.
writerId 외래키로 불러온 users 정보 화면에 잘 렌더링 되고 있음.

 


1. supabase 외래키 data 가져오기 (전체 코드)

: 외래키 writerId에 연동되어 있는 users 테이블 data 가져와서 -> 작성자의 users 정보 뿌려주기

1) getPartnerPosts로 partnerPosts 데이터 가져올 때 writerId에 연동된 users 정보도 가져오기

// api 폴더 하위 supabase 폴더 하위 partner.ts

import { supabase } from './supabaseClient';

export const getPartnerPosts = async () => {
  let { data: partnerPosts, error } = await supabase.from('partnerPosts').select('*, users!partnerPosts_writerId_fkey(*)');
  return { data: partnerPosts, error };
};

2) partnerPosts의 데이터에서 users 데이터의 type 추가

// api 폴더 하위 supabase 폴더 하위 supabase.ts -> type 선언 파일에서 users 속성 추가

partnerPosts: {
        Row: {
          applicant: string | null;
          content: string;
          country: string;
          createdAt: string;
          endDate: string;
          id: string;
          interestUrl: string[];
          isOpen: boolean;
          numOfPeople: number;
          openChat: string;
          region: string;
          startDate: string;
          title: string;
          writerId: string | null;
          
          // 추가한 부분
          users: {
            birthday: string;
            gender: string;
            nickName: string;
            profileImageUrl: string | null;
            
          };
        };
     }

3) PartnerItem 컴포넌트에서 writer의 users 정보 뿌려주기

<users 데이터 가공한 부분>
gender: 여성 | 남성
birthday: birthday로 받은 user 정보 -> 연령대 구분 (추후 초/중/후반으로 세분화 예정)
// components 폴더 하위 partner 폴더 하위 PartnerItem.tsx

import axios from 'axios';
import { useEffect, useState } from 'react';
import * as St from './style';
import { Tables } from '../../api/supabase/supabase';
import { Link } from 'react-router-dom';

type PartnerItemProps = {
  post: Tables<'partnerPosts'>;
};

const PartnerItem = ({ post }: PartnerItemProps) => {
  const [imageSrc, setImageSrc] = useState<string>('');

  // 동행찾기 게시글작성할 때 선택한 country
  let filteredCountry = post.country;
  if (post.country === '미국') {
    filteredCountry = '미합중국';
  } else if (post.country === '베네수엘라') {
    filteredCountry = '베네수엘라볼리바르';
  } else if (post.country === '네팔') {
    filteredCountry = '네팔연방';
  } else if (post.country === '터키') {
    filteredCountry = '튀르키예공화국';
  }

  // 국기 api key
  const API_KEY = process.env.REACT_APP_API_KEY;

  // 국기 이미지 가져오기
  const getFlagAndDisplayImage = async (): Promise<void> => {
    const url = `https://apis.data.go.kr/1262000/CountryFlagService2/getCountryFlagList2?serviceKey=${API_KEY}&pageNo=1&numOfRows=227&cond[country_nm::EQ]=${filteredCountry}`;

    const response = await axios.get(url);
    const imageUrl = response.data.data[0].download_url;

    setImageSrc(imageUrl);
  };

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

  // birthday로 나이 계산 -> 연령대 구분
  const getAgeCategory = (birthday: string) => {
    const birthDate = new Date(birthday);
    const currentDate = new Date();

    const age = currentDate.getFullYear() - birthDate.getFullYear() - (currentDate.getMonth() < birthDate.getMonth() || (currentDate.getMonth() === birthDate.getMonth() && currentDate.getDate() < birthDate.getDate()) ? 1 : 0);

    if (age >= 10 && age < 20) {
      return '10대';
    }
    if (age >= 20 && age < 30) {
      return '20대';
    }
    if (age >= 30 && age < 40) {
      return '30대';
    }
    if (age >= 40 && age < 50) {
      return '40대';
    }
    if (age >= 50 && age < 60) {
      return '50대';
    }
    if (age >= 60 && age < 70) {
      return '60대';
    }
    if (age >= 70 && age < 80) {
      return '70대';
    }
  };

  // Link 태그로 디테일 페이지로 params 넘겨주기
  return (
    <Link to={`detail/${post.id}`}>
      <St.PostCard>
        <St.Head>
          <St.Location>
            <St.FlagBox>{imageSrc && <St.FlagImage src={imageSrc} alt="Image" />}</St.FlagBox>
            <h1>{post.country}</h1>
          </St.Location>
          <St.Status isOpen={post.isOpen}>{post.isOpen ? '모집중' : '모집완료'}</St.Status>
        </St.Head>
        <St.Main>
          <p>
            여행기간 | {post.startDate} ~ {post.endDate}
          </p>
          <h1>{post.title}</h1>
        </St.Main>
        <St.Body>
          <picture>
            {post.interestUrl.map((url, index) => (
              <St.InterestImage key={index} src={url} alt={`interest-${index}`} />
            ))}
          </picture>
          <p>모집인원: {post.numOfPeople}명</p>
        </St.Body>
        <St.Footer>
          <St.UserProfile>
            {post.users.profileImageUrl && <St.ProfileImage src={post.users.profileImageUrl} alt="profile" />}
            <p>{post.users.nickName}</p>
          </St.UserProfile>
          <div>
            <p>
              {getAgeCategory(post.users.birthday)} | {post.users.gender === 'woman' ? '여성' : '남성'}
            </p>
          </div>
        </St.Footer>
      </St.PostCard>
    </Link>
  );
};

export default PartnerItem;

4) 위에서 추가한 부분 styled-components 코드

// components 폴더 하위 partner 폴더 하위 style.ts

export const Footer = styled.div`
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-top: 10px;
  font-size: 0.8rem;
`;

export const UserProfile = styled.div`
  display: flex;
  align-items: center;
  gap: 5px;
`;

export const ProfileImage = styled.img`
  width: 30px;
  height: 30px;
  border-radius: 50%;
`;

2. 동행 모집: 메인 페이지 이미지 추가

1) PartnerList 컴포넌트 -> 화면 렌더링

// components 폴더 하위 partner 폴더 하위 PartnerList.tsx

import * as St from './style';
import TravelWith from '../../assets/imgs/partner/TravelWith.jpg';

  return (
    <>
    
      <St.ImageWrapper>
        <St.MainImage src={TravelWith} alt="mainImage" />
        <St.ImageMainText>친구와 함께라면 더 즐겁지 않을까요?</St.ImageMainText>
        <St.ImageSubText>
          Amigo Signal과 함께 여행에 동행할 친구를 찾아보세요.
          <br />
          여행이 더 즐거워질 거에요.
        </St.ImageSubText>
      </St.ImageWrapper>
      
    </>
  );
};

export default PartnerList;

2) 해당 style.ts 파일

// components 폴더 하위 partner 폴더 하위 style.ts

// relative로 기준 잡고, 박스 사이즈 지정한 뒤, overflow: hidden 걸어줌
export const ImageWrapper = styled.div`
  position: relative;
  width: 1200px;
  height: 400px;
  overflow: hidden;
`;

// 위의 ImageWrapper를 기준으로 absolute, transform을 통해 이미지 위치 조정
export const MainImage = styled.img`
  position: absolute;
  width: 100%;
  transform: translate(0%, -20%);
`;

export const ImageMainText = styled.span`
  position: absolute;
  bottom: 55%;
  left: 5%;
  color: black;
  font-size: 1.4rem;
  font-weight: bold;
`;

export const ImageSubText = styled.span`
  position: absolute;
  bottom: 45%;
  left: 5%;
  color: black;
  font-size: 0.8rem;
`;

2. 공통 모달 생성

공통 모달을 여기 저기 컴포넌트에서 사용하게 되면 -> 이벤트 버블링 or 동시에 모달이 열리고 닫히는 문제가 발생할 수 있다.

이러한 현상을 방지하기 위해 zustand를 이용해 Modal 상태 관리를 전역적으로 진행하는 과정에서,
각 Modal에 고유한 식별자(id 값)를 부여하여 관리하는 방식을 택했다.

1) zustand 전역 상태 관리 store -> modal 열고 닫는 것 관리

(1) 기존 코드

// 기존 코드 -> zustand

interface ModalStore {
  isModalOpen: boolean;
  openModal: () => void;
  closeModal: () => void;
}

const useSessionStore = create<SessionStore>((set) => ({
  session: null,
  setSession: (newSession) => set({ session: newSession }),
}));

export default useSessionStore;

export const useModalStore = create<ModalStore>((set) => ({
  isModalOpen: false,
  openModal: () => set({ isModalOpen: true }),
  closeModal: () => set({ isModalOpen: false }),
}));

(2) 변경한 코드

// 변경한 코드 -> zustand 폴더 하위 store.ts

import { create } from 'zustand';

// openedModals 객체에서 모든 modal들을 id 값 기준으로 관리
interface ModalStore {
  openedModals: { [key: string]: boolean };
  openModal: (id: string) => void;
  closeModal: (id: string) => void;
}

// modal 열고 닫을 때, id 값을 받아서 openedModals 객체의 id에 해당하는 상태 true/false 변경
export const useModalStore = create<ModalStore>((set, get) => ({
  openedModals: {},
  openModal: (id: string) => {
    const currentModals = get().openedModals;
    set({ openedModals: { ...currentModals, [id]: true } });
  },
  closeModal: (id: string) => {
    const currentModals = get().openedModals;
    set({ openedModals: { ...currentModals, [id]: false } });
  },
}));

2) 공통 Modal 컴포넌트

(1) 기존 코드

// 기존 코드 -> 공통 Modal 컴포넌트

import React from 'react';
import { createPortal } from 'react-dom';
import * as St from './style';
import { useModalStore } from '../../zustand/store';
import CloseButton from '../../../assets/imgs/partner/CloseButton.svg';

export const PORTAL_MODAL = 'portal-root';

export interface ModalProps {
  children: React.ReactNode;
  size: 'large' | 'medium' | 'small' | undefined;
}

const Modal: React.FC<ModalProps> = ({ children, size }) => {
  const { isModalOpen, closeModal } = useModalStore();

  return isModalOpen
    ? createPortal(
        <St.Outer>
          <St.Inner size={size}>
            <St.CloseButton onClick={closeModal}>
              <img src={CloseButton} alt="close" />
            </St.CloseButton>
            {children}
          </St.Inner>
        </St.Outer>,
        document.getElementById(PORTAL_MODAL) as HTMLElement,
      )
    : null;
};

export default Modal;

(2) 변경한 코드

// 변경한 코드 -> components 폴더 하위 common 폴더 하위 modal 폴더 하위 Modal.tsx

import React from 'react';
import { createPortal } from 'react-dom';
import * as St from './style';
import { useModalStore } from '../../../zustand/store';
import CloseButton from '../../../assets/imgs/partner/CloseButton.svg';

export const PORTAL_MODAL = 'portal-root';

// Modal 커스텀해서 사용 시, props로 내려줄 값들 type 지정 (id, children, size)
export interface ModalProps {
  id: string;
  children: React.ReactNode;
  size: 'large' | 'medium' | 'small' | undefined;
}

const Modal: React.FC<ModalProps> = ({ id, children, size }) => {

  // zustand의 useModalStore에서 해당 값들 가져와서 -> 각각 커스텀해 사용할 모달의 id값 별로 isThisModalOpen 변수 관리 
  const { openedModals, closeModal } = useModalStore();
  const isThisModalOpen = openedModals[id];

  // isThisModalOpen이라면, Inner의 size (large, medium, small) 지정해주고
  // 닫기 버튼 클릭 시 id 값을 받아서 해당 모달 closeModal 실행
  return isThisModalOpen
    ? createPortal(
        <St.Outer>
          <St.Inner size={size}>
            <St.CloseButton onClick={() => closeModal(id)}>
              <img src={CloseButton} alt="close" />
            </St.CloseButton>
            {children}
          </St.Inner>
        </St.Outer>,
        document.getElementById(PORTAL_MODAL) as HTMLElement,
      )
    : null;
};

export default Modal;

3) 해당 모달 컴포넌트의 style.ts

// // components 폴더 하위 common 폴더 하위 modal 폴더 하위 style.ts

import { styled } from 'styled-components';

interface InnerProps {
  size?: 'large' | 'medium' | 'small';
}

export const Outer = styled.div`
  position: fixed;
  top: 0;
  left: 0;
  display: flex;
  justify-content: center;
  width: 100%;
  height: 100%;
  background-color: #00000029;
  z-index: 999;
`;

// Inner는 size별로 (large, medium, small)
export const Inner = styled.div<InnerProps>`
  display: flex;
  flex-direction: column;
  position: relative;
  margin: auto;
  background-color: #ffffff;
  border-radius: 15px;
  box-shadow: 5px 5px 12px rgba(23, 23, 23, 0.3);

  ${({ size }) => {
    switch (size) {
      case 'large':
        return `
        width: 60%;
        height: 70%;
        border-radius: 30px;
        `;
      case 'medium':
        return `
        width: 45%;
        height: 55%;
        border-radius: 30px;
        `;
      case 'small':
        return `
          justify-content: center;
          align-items: center;

          gap: 20px;
          margin: 340px 16px 0 16px;
          text-align: center;
          line-height: 30px;
          width: 360px;
          height: 220px;
          padding: 5px 10px;
          
        `;
      default:
        return `
          width: 500px;
          height: 60%;
        `;
    }
  }}
`;

export const CloseButton = styled.button`
  display: flex;
  justify-content: flex-end;
  padding: 30px;
  padding-bottom: 0px;
  background: none;
  border: none;
  cursor: pointer;
`;

 4) PartnerDetailInfo 컴포넌트에서 공통 Modal 컴포넌트 사용해보기 (openModal)

// components 폴더 하위 partner 폴더 하위 partnerDetailInfo 폴더 하위 partnerDetailInfo.tsx

import { Tables } from '../../../api/supabase/supabase';
import Modal from '../../common/modal/Modal';
import { useModalStore } from '../../../zustand/store';
import ApplicantList from './ApplicantList';
import ApplyWithInfo from './ApplyWithInfo';
import * as St from './style';

const PartnerDetailInfo = ({ partnerPostData }: { partnerPostData: Tables<'partnerPosts'> }) => {

  // zustand의 useModalStore에서 해당 값들 가져오기
  const { openedModals, openModal } = useModalStore();

  // '참여하기' 버튼을 누르는 경우, applyWithInfo라는 id값을 가진 모달을 열어줌.
  // '신청자 목록' 버튼을 누르는 경우, applicantList라는 id값을 가진 모달을 열어줌.
  return (
    <section>
   
      <button onClick={() => openModal('applyWithInfo')}>참여하기</button>
      {openedModals.applyWithInfo && (
        <Modal id="applyWithInfo" size="medium">
          <ApplyWithInfo />
        </Modal>
      )}
      
      <button onClick={() => openModal('applicantList')}>신청자 목록</button>
      {openedModals.applicantList && (
        <Modal id="applicantList" size="large">
          <ApplicantList />
        </Modal>
      )}
      
    </section>
  );
};

export default PartnerDetailInfo;

5) '동행 참여 신청' 모달 children 내용 -> 레이아웃 완성

// components 폴더 하위 partner 폴더 하위 partnerDetailInfo 폴더 하위 ApplyWithInfo.tsx

import React, { useState } from 'react';
import { Input } from '../../common/input/Input';
import * as St from './style';
import Button from '../../common/button/Button';
import { BtnStyleType } from '../../../types/styleTypes';

const ApplyWithInfo = () => {
  const [text, setText] = useState('');

  const handleText = (e: React.ChangeEvent<HTMLInputElement>) => {
    setText(e.target.value);
  };

  // 타이핑할 때마다 글자 수(text.length) 세어주는 기능 추가
  // 공통 Input, 공통 Button 컴포넌트 사용
  return (
    <>
      <St.ModalTitle>동행 참여 신청하기</St.ModalTitle>
      <Input type="textarea" inputStyleType="apply" border={true} placeholder="간단한 자기소개를 작성해주세요." value={text} onChange={handleText} />

      <St.TextCount>{text.length}/300 characters</St.TextCount>
      <St.SubmitApply>
        <Button type="submit" styleType={BtnStyleType.BTN_DARK}>
          신청
        </Button>
      </St.SubmitApply>
    </>
  );
};

export default ApplyWithInfo;
// 해당 style.ts

export const ModalTitle = styled.h1`
  padding-left: 50px;
  font-size: 1.4rem;
  font-weight: bold;
`;

export const TextCount = styled.span`
  display: flex;
  justify-content: flex-end;
  margin-top: 10px;
  margin-right: 45px;
  font-size: 12px;
  color: gray;
`;

export const SubmitApply = styled.div`
  display: flex;
  justify-content: flex-end;
  margin-right: 45px;
`;