개발일기
[내일배움캠프] 최종 프로젝트 - 헤더 알람 기능 추가 및 컴포넌트 분리, CSS 본문
1. 헤더: 동행찾기 참여 기능 알람 추가
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} 전`;
};