Notice
Recent Posts
Recent Comments
Link
«   2025/08   »
1 2
3 4 5 6 7 8 9
10 11 12 13 14 15 16
17 18 19 20 21 22 23
24 25 26 27 28 29 30
31
Tags
more
Archives
Today
Total
관리 메뉴

개발일기

[내일배움캠프] 최종 프로젝트 - 헤더 알람 기능 추가 및 컴포넌트 분리, CSS 본문

카테고리 없음

[내일배움캠프] 최종 프로젝트 - 헤더 알람 기능 추가 및 컴포넌트 분리, CSS

코찡 2023. 9. 12. 22:23

1. 헤더: 동행찾기 참여 기능 알람 추가

3개의 알람: 새로운 동행 신청 / 수락된 신청 / 거절된 신청
새로운 동행 신청 알람 먼저 들어가서 -> 새로운 동행 신청자 확인
클릭한 알람은 지워졌고, 이제 거절된 알람을 클릭
거절된 내역 확인 가능
이제 마지막 남은 수락 알람을 클릭해서 해당 포스트로 이동
수락된 내역을 확인할 수 있고, 모든 알람 사라짐!

1) supabase DB의 applicants 테이블에 writerId 추가

: 동행 신청한 글의 작성자 정보를 추가 -> 로그인한 유저 = 작성자인 경우, 해당 글에 동행 신청이 오면 catch 할 수 있도록 조치

// api 폴더 하위 supabase 폴더 하위 supabase.ts -> 모두 writerId를 추가해주었습니다.

   applicants: {
        Row: {
          writerId: string;
        };
        Insert: {
          writerId: string;
        };
        Update: {
          writerId?: string;
        };
    }

2) 동행 참여 신청하는 자기소개 작성 시, writerId 데이터 추가

(1) 상위 컴포넌트인 Communication.tsx 에서 writerId 정보 props로 전달

// components 폴더 하위 partner 폴더 하위 communicate 폴더 하위 Communication.tsx

type CommunicationProps = {
  writerId: string | null | undefined;
};

const Communication = ({ postId, writerId, logInUserId, isApply, setIsApply }: CommunicationProps) => {

  // 참여하기 버튼 클릭해서 handleApply 함수 실행 시, applyWithInfo 모달 오픈
  const handleApply = () => {
    openModal('applyWithInfo');
  };

  // 모달이 열린 ApplyWithInfo 컴포넌트에 props로 writerId를 전달해줍니다.
  return (
        <St.ApplyDiv>
            <Button styleType={BtnStyleType.BTN_DARK} onClick={isApply ? handleApplyCancel : handleApply} fullWidth>
              {isApply ? '참여 취소' : '참여하기'}
            </Button>
        </St.ApplyDiv>
     
      {openedModals.applyWithInfo && (
        <Modal id="applyWithInfo" size="medium">
          <ApplyWithInfo postId={postId} writerId={writerId} applicantId={logInUserId} setIsApply={setIsApply} />
        </Modal>
      )}
  );
};

export default Communication;

(2) 자식 컴포넌트인 ApplyWithInfo.tsx에서 writerId 값을 받아 supabase DB에 INSERT할 데이터 추가

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

// props 받아오는 값에 writerId 추가
type ApplyWithInfoProps = {
  postId: string | undefined;
  writerId: string | undefined | null;
  applicantId: string | undefined;
  setIsApply: React.Dispatch<React.SetStateAction<boolean | null>>;
};

// writerId 추가
const ApplyWithInfo = ({ postId, writerId, applicantId, setIsApply }: ApplyWithInfoProps) => {

  // writerId가 undefined나 null 값이라면 반환하는 로직 추가
  const handleSubmit = async () => {
    if (!postId || !applicantId || !writerId) {
      console.error('postId 또는 applicantId 유효하지 않습니다.');
      return;
    }

    // applicants 테이블에 INSERT 해줄 applicantData에도 writerId 추가
    const applicantData = {
      postId,
      applicantId,
      content: text,
      isConfirmed: false,
      writerId,
    };

    // 자기소개 없이 신청하는 경우
    if (text.trim() === '') {
      const isConfirmed = await ConfirmCustom({
        title: '자기소개 없이 참여 신청하시겠습니까?',
        text: '동행 글 작성자에게 한 마디 코멘트를 남겨주세요!',
        confirmButtonText: '신청',
        cancelButtonText: '취소',
        confirmMessage: '참여신청됨',
        message: '동행 참여 신청이 완료되었습니다!',
      });
      if (!isConfirmed) return;
      try {
        setApplicantStatus('참여 신청중');
        // 바로 여기에서 supabase DB의 applicants 테이블에 지원자 데이터를 추가하지.
        await insertApplicant(applicantData);
        setIsApply(true);
        closeModal('applyWithInfo');
      } catch (error) {
        console.log('참가 신청 모달 제출 과정에서 오류 발생', error);
      }
    } 
    
    // 자기소개 작성하고 신청하는 경우
    else {
      try {
        setApplicantStatus('참여 신청중');
        // 바로 여기에서 supabase DB의 applicants 테이블에 지원자 데이터를 추가하지.
        await insertApplicant(applicantData);
        setIsApply(true);
        closeModal('applyWithInfo');
        Alert({ title: '동행 참여 신청이 완료되었습니다!' });
      } catch (error) {
        console.log('참가 신청 모달 제출 과정에서 오류 발생', error);
      }
    }
  };

  return (
    <>
      <St.ModalTitle>동행 참여 신청하기</St.ModalTitle>
        <Button type="submit" styleType={BtnStyleType.BTN_DARK} onClick={handleSubmit}> 신청 </Button>
    </>
  );
};

export default ApplyWithInfo;

3) assets 폴더 하위 imgs 폴더 하위 header 폴더 하위 YesAlert.svg와 NoAlert.svg 아이콘 저장

4) Header.tsx 컴포넌트에 PartnerAlert 컴포넌트 import

// common 폴더 하위 header 폴더 하위 Header.tsx

import PartnerAlert from '../../partner/alert/PartnerAlert';

export default function Header() {
  return (
          {session ? (
              <PartnerAlert />
          ) : (
            <>
              <Link to="/login">로그인</Link>
              <Link to="/signup">회원가입</Link>
            </>
          )}
  );
}

5) PartnerAlert 컴포넌트 -> 헤더 알람 관리하는 핵심! 

supabase의 realtime 기능을 활용한 변화 감지
-> PostgreSQL의 LISTEN 및 NOTIFY 메커니즘을 사용하여 데이터베이스 변경 사항을 구독하는 방식
-> 이러한 방식을 사용하면 더 세밀하게 변경 사항을 감지하고, 더 많은 정보를 포함하는 알림을 받을 수 있습니다.
// components 폴더 하위 partner 폴더 하위 alert 폴더 하위 PartnerAlert.tsx

import React, { useEffect } from 'react';
import { supabase } from '../../../api/supabase/supabaseClient';
import useSessionStore from '../../../zustand/store';
import { fetchPartnerPostTitle } from '../../../api/supabase/partner';
import { useAlertStorageStore, useNewAlertStore } from '../../../zustand/alert';
import { useNavigate } from 'react-router';
import YesAlert from '../../../assets/imgs/header/YesAlert.svg';
import NoAlert from '../../../assets/imgs/header/NoAlert.svg';
import { Popover } from 'antd';
import * as St from './style';
import iconAlert from '../../../assets/imgs/header/icon_alert.png';
import { timeAgo } from '../../common/transferTime/transferTime';

export default function PartnerAlert() {
  const session = useSessionStore((state) => state.session);
  const userId = session?.user.id;
  const { alertStorage, addAlertStorage, removeAlertStorage } = useAlertStorageStore();
  const { hasNewAlert, setHasNewAlert } = useNewAlertStore();
  const navigate = useNavigate();

  // 작성자 기준
  supabase
    .channel('writers-db-changes')
    .on(
      'postgres_changes',
      {
        event: '*',
        schema: 'public',
        table: 'applicants',
        filter: `writerId=eq.${userId}`,
      },
      async (payload) => {
        // 신청자가 있을 때
        // applicants 테이블에 새로운 행이 추가(INSERT)되었을 때 해당 행의 writerId가 현재 로그인한 사용자의 userId와 일치하고, isConfirmed 속성이 false일 경우 알림을 제공
        if (payload.eventType === 'INSERT' && !payload.new.isConfirmed) {
          const postTitle = await fetchPartnerPostTitle(payload.new.postId);
          if (postTitle) {
            const newPostInfo = {
              id: payload.new.id,
              postId: payload.new.postId,
              title: postTitle,
              date: payload.commit_timestamp,
              genre: '동행 신청 받음',
            };
            addAlertStorage(newPostInfo);
            setHasNewAlert(true);
          }
        }
        // 신청자가 참여 취소 시
        // applicants 테이블에 행이 제거(DELETE)되었을 때 알림을 제공
        if (payload.eventType === 'DELETE') {
          removeAlertStorage(payload.old.id);
          if (alertStorage.length === 0) {
            setHasNewAlert(false);
          } else if (alertStorage.length > 0) {
            setHasNewAlert(true);
          }
        }
      },
    )
    .subscribe();

  // 신청자 기준
  supabase
    .channel('applicants-db-changes')
    .on(
      'postgres_changes',
      {
        event: '*',
        schema: 'public',
        table: 'applicants',
        filter: `applicantId=eq.${userId}`,
      },
      async (payload) => {
        // 작성자가 수락했을 떄
        // applicants 테이블에 항목이 변경(UPDATE)되었을 때 해당 항목의 applicantId가 현재 로그인한 사용자의 userId와 일치하고, 
        // isConfirmed 속성이 true면서 isAccepted도 true인 경우 알림을 제공
        if (payload.eventType === 'UPDATE' && payload.new.isConfirmed && payload.new.isAccepted) {
          const postTitle = await fetchPartnerPostTitle(payload.new.postId);
          if (postTitle) {
            const newPostInfo = {
              id: payload.new.id,
              postId: payload.new.postId,
              title: postTitle,
              date: payload.commit_timestamp,
              genre: '동행 신청 수락됨',
            };
            addAlertStorage(newPostInfo);
            setHasNewAlert(true);
          }
        }
        // 작성자가 거절했을 때
        // applicants 테이블에 항목이 변경(UPDATE)되었을 때 해당 항목의 applicantId가 현재 로그인한 사용자의 userId와 일치하고, 
        // isConfirmed 속성이 true면서 isAccepted도 false인 경우 알림을 제공
        if (payload.eventType === 'UPDATE' && payload.new.isConfirmed && !payload.new.isAccepted) {
          const postTitle = await fetchPartnerPostTitle(payload.new.postId);
          if (postTitle) {
            const newPostInfo = {
              id: payload.new.id,
              postId: payload.new.postId,
              title: postTitle,
              date: payload.commit_timestamp,
              genre: '동행 신청 거절됨',
            };
            addAlertStorage(newPostInfo);
            setHasNewAlert(true);
          }
        }
      },
    )
    .subscribe();

  useEffect(() => {
    if (alertStorage.length === 0) {
      setHasNewAlert(false);
    } else if (alertStorage.length > 0) {
      setHasNewAlert(true);
    }
  }, [alertStorage]);
  
  const handleAlertLink = (id: string, postId: string) => {
    navigate(`/partner/detail/${postId}`);
    removeAlertStorage(id);
    if (alertStorage.length === 0) {
      setHasNewAlert(false);
    } else if (alertStorage.length > 0) {
      setHasNewAlert(true);
    }
  };

  const alarmPopover = (
    <St.AlarmPopoverBox>
      <St.MainTitle>{`새로운 소식 (${alertStorage.length})`}</St.MainTitle>
      <St.ListBox>
        {[...alertStorage].reverse().map((item) => (
          <St.ListList onClick={() => handleAlertLink(item.id, item.postId)} key={item.id}>
            <St.ListItem>
              <div>
                <img src={iconAlert} alt="동행 아이콘" style={{ width: '40px' }} />
              </div>
              <St.PostInfo>
                <St.PostInfoTop>
                  <p>{item.genre === '동행 신청 받음' ? '새로운 동행 신청' : item.genre === '동행 신청 수락됨' ? '동행 신청이 수락되었습니다.' : '동행 신청이 거절되었습니다.'}</p>
                  <St.TimeAgo>{timeAgo(item.date)}</St.TimeAgo>
                </St.PostInfoTop>
                <St.PostTitle>{item.title}</St.PostTitle>
              </St.PostInfo>
            </St.ListItem>
          </St.ListList>
        ))}
      </St.ListBox>
    </St.AlarmPopoverBox>
  );

  return (
    <>
      {hasNewAlert ? (
        <Popover
          content={alarmPopover}
          trigger=“hover”
          placement="topRight"
          overlayStyle={{
            width: '282px',
          }}
        >
          <img src={YesAlert} alt="alert" />
        </Popover>
      ) : (
        <Popover content={`알람이 없습니다.`} trigger="click" placement="topRight">
          <img src={NoAlert} alt="noAlert" />
        </Popover>
      )}
    </>
  );
}

해당 style.ts

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

import { styled } from 'styled-components';

export const AlarmPopoverBox = styled.div`
  padding: 8px 12px 20px 12px;
`;

export const MainTitle = styled.p`
  color: #121621;
  font-size: 16px;
  font-weight: 600;
  line-height: normal;
  text-transform: uppercase;
`;

export const ListBox = styled.ul`
  margin-top: 25px;
`;

export const ListList = styled.li`
  &:not(:last-child) {
    margin-bottom: 20px;
  }
`;

export const ListItem = styled.div`
  width: 100%;
  display: flex;
  align-items: center;
  gap: 10px;

  img {
    width: 40px;
    vertical-align: middle;
  }
`;

export const PostInfo = styled.div`
  width: calc(100% - 50px);
`;

export const PostInfoTop = styled.div`
  display: flex;
  justify-content: space-between;
  align-items: center;

  & p {
    font-size: 12px;
  }
`;

export const TimeAgo = styled.p`
  color: #6c7486;
  font-size: 12px;
  font-weight: 500;
  line-height: normal;
  text-transform: uppercase;
`;

export const PostTitle = styled.p`
  margin-top: 5px;
  color: #121621;
  font-size: 14px;
  font-weight: 600;
  line-height: normal;
  text-transform: uppercase;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
`;

6) 알람에 뿌려줄 데이터 및 알림할 게시글 존재 여부 관리하는 zustand (전역 상태 관리) 파일

: zustand middleware의 persist 기능으로 새로고침 해도 상태가 남아있을 수 있게 세션 스토리지에 저장

// zustand 폴더 하위 alert.ts

import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';

type AlertInfo = {
  id: string;
  postId: string;
  title: string;
  date: string;
  genre?: string;
};

type AlertStorageStore = {
  alertStorage: AlertInfo[];
  addAlertStorage: (postInfo: AlertInfo) => void;
  removeAlertStorage: (postId: string) => void;
};

type NewAlertStore = {
  hasNewAlert: boolean;
  setHasNewAlert: (value: boolean) => void;
};

export const useAlertStorageStore = create<AlertStorageStore>()(
  persist(
    (set) => ({
      alertStorage: [],
      addAlertStorage: (postInfo) =>
        set((state) => ({
          alertStorage: [...state.alertStorage, postInfo],
        })),
      removeAlertStorage: (id) =>
        set((state) => ({
          alertStorage: state.alertStorage.filter((item) => item.id !== id),
        })),
    }),
    {
      name: 'alertStorage',
      storage: createJSONStorage(() => sessionStorage),
    },
  ),
);

export const useNewAlertStore = create<NewAlertStore>()(
  persist(
    (set) => ({
      hasNewAlert: false,
      setHasNewAlert: (value) => set({ hasNewAlert: value }),
    }),
    {
      name: 'hasNewAlert',
      storage: createJSONStorage(() => sessionStorage),
    },
  ),
);

7) 헤더 알람: supabase realtime으로 알람 사항(변화)이 감지된 해당 게시글의 "제목" 가져오는 api 

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

// 헤더 alert에 담길 post 제목 가져오기
export const fetchPartnerPostTitle = async (postId: string) => {
  const { data } = await supabase.from('partnerPosts').select('title').eq('id', postId).single();
  return data?.title;
};

8) 시간 변경 함수 (알람 뜬 시간 -> 경과 시간으로 변경)

// components 폴더 하위 common 폴더 하위 transferTime 폴더하위 transferTime.ts

// 작성시간(createAt) -> 시간 경과 렌더링하도록 변경
export const timeAgo = (createDate: string) => {
  const date = new Date(createDate);
  const now = new Date();
  const seconds = Math.floor((now.getTime() - date.getTime()) / 1000);
  const minutes = Math.floor(seconds / 60);
  const hours = Math.floor(minutes / 60);
  const days = Math.floor(hours / 24);

  const timeUnits = [
    { value: days, unit: '일' },
    { value: hours, unit: '시간' },
    { value: minutes, unit: '분' },
    { value: seconds, unit: '초' },
  ];
  const result = timeUnits.find((unit) => unit.value > 0) || timeUnits[timeUnits.length - 1];
  return `${result.value}${result.unit} 전`;
};