From 061937e0ab7e163677123c84466d55463ebb337d Mon Sep 17 00:00:00 2001 From: sunglitter Date: Tue, 10 Dec 2024 03:26:40 +0900 Subject: [PATCH] =?UTF-8?q?Feat:=20SSE=20=EB=B0=8F=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=EC=9E=90=20=EC=A0=84=EC=9A=A9=20=EC=B0=B8=EC=97=AC=20=ED=98=84?= =?UTF-8?q?=ED=99=A9=20=EC=97=B4=EB=9E=8C=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20=EA=B4=80=EB=A0=A8=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=20(#28)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SSE 핸들러 추가로 실시간 데이터 처리 기능 구현 - SSE 관련 타입 정의 및 관련 로직 수정 - 관리자 전용 참여 현황 열람 기능 추가 - 스타일 구조 수정으로 가독성 및 유지보수성 개선 - 상태 관리 로직 수정 및 최적화 --- src/components/common/Header.tsx | 2 +- src/components/common/PostList.tsx | 2 +- .../pages/admin/PostApprovalPage.tsx | 13 +- src/components/pages/admin/api/adminApi.ts | 8 +- .../pages/community/ParticipantList.tsx | 40 +++ .../pages/community/PostDetailPage.tsx | 283 +++++++++++++----- .../PostDetailPage/PostDetailsSection.tsx | 102 ++++--- .../pages/community/PostEditPage.tsx | 1 - src/components/pages/community/api/postApi.ts | 54 ++-- src/store/sseStore.ts | 27 ++ src/store/userStore.ts | 6 +- src/types/postTypes.ts | 6 +- src/utils/GetImageSrc.tsx | 25 +- src/utils/SSEHandler.tsx | 49 +++ 14 files changed, 452 insertions(+), 166 deletions(-) create mode 100644 src/components/pages/community/ParticipantList.tsx create mode 100644 src/store/sseStore.ts create mode 100644 src/utils/SSEHandler.tsx diff --git a/src/components/common/Header.tsx b/src/components/common/Header.tsx index 65bad33..40a3b25 100644 --- a/src/components/common/Header.tsx +++ b/src/components/common/Header.tsx @@ -17,7 +17,7 @@ const Header = () => { const handleCommunityClick = ( e: React.MouseEvent ) => { - if (!isLoggedIn || !isAdmin) { + if (!isLoggedIn && !isAdmin) { e.preventDefault(); alert('로그인 후 이용할 수 있는 페이지입니다.'); setIsMobileMenuOpen(!isMobileMenuOpen); diff --git a/src/components/common/PostList.tsx b/src/components/common/PostList.tsx index 8c33579..901e7de 100644 --- a/src/components/common/PostList.tsx +++ b/src/components/common/PostList.tsx @@ -5,8 +5,8 @@ import SearchBar from './SearchBar'; import Pagination from './Pagination'; import { useNavigate } from 'react-router-dom'; import { SSEEvent, Post } from '../../types/postTypes'; -import { getImageSrc } from '../../utils/GetImageSrc'; import { formatDateWithOffset } from '../../utils/formatDate'; +import { getImageSrc } from '../../utils/GetImageSrc'; interface PostListProps { selectedCategory: string; diff --git a/src/components/pages/admin/PostApprovalPage.tsx b/src/components/pages/admin/PostApprovalPage.tsx index 4f7ef62..6daf251 100644 --- a/src/components/pages/admin/PostApprovalPage.tsx +++ b/src/components/pages/admin/PostApprovalPage.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect } from 'react'; import styled from 'styled-components'; -import { useNavigate, useLocation } from 'react-router-dom'; -import { fetchPostById } from '../community/api/postApi'; +import { useNavigate, useLocation, useParams } from 'react-router-dom'; +import { fetchPostById, handleSSEUpdate } from '../community/api/postApi'; import { approvePost, rejectPost } from './api/adminApi'; import { FaBackspace, FaAngleLeft, FaAngleRight } from 'react-icons/fa'; import { Post } from '../../../types/postTypes'; @@ -9,9 +9,10 @@ import { getImageSrc } from '../../../utils/GetImageSrc'; import { formatDateWithOffset } from '../../../utils/formatDate'; const PostApprovalPage = () => { + const { postId: paramPostId } = useParams<{ postId: string }>(); const location = useLocation(); const navigate = useNavigate(); - const { postId } = location.state || {}; // PostList에서 전달된 postId + const postId = paramPostId || location.state?.communityPostId; const [post, setPost] = useState(null); const [currentIndex, setCurrentIndex] = useState(0); @@ -53,7 +54,7 @@ const PostApprovalPage = () => { const updatedTitle = post.title.startsWith('(수정요망)') ? post.title.replace(/^\(수정요망\)\s*/, '') : post.title; - + handleSSEUpdate(postId); // SSE 구독 시작 await approvePost(postId, updatedTitle); // 포스트 상태를 APPROVED로 변경 alert('게시물이 승인되었습니다.'); navigate('/admin/post'); // 승인 후 관리자 페이지로 리다이렉트 @@ -67,10 +68,10 @@ const PostApprovalPage = () => { if (!post) return; try { // 제목에 '(수정요망)' 추가 - const updatedTitle = post.title.startsWith('(수정요망)') + const updatedTitle = post.title.startsWith('(수정요망) ') ? post.title // 이미 '(수정요망)'이 있으면 그대로 유지 : `(수정요망) ${post.title}`; - + handleSSEUpdate(postId); // SSE 구독 시작 await rejectPost(postId, updatedTitle); // 포스트 상태를 REJECTED로 변경 alert('게시물이 거절 처리되었습니다.'); navigate('/admin/post'); // 거절 후 관리자 페이지로 리다이렉트 diff --git a/src/components/pages/admin/api/adminApi.ts b/src/components/pages/admin/api/adminApi.ts index f39b7ce..c4fcea6 100644 --- a/src/components/pages/admin/api/adminApi.ts +++ b/src/components/pages/admin/api/adminApi.ts @@ -38,9 +38,9 @@ export const approvePost = async ( throw new Error('Failed to approve post'); } return response.data; + // eslint-disable-next-line @typescript-eslint/no-unused-vars } catch (error) { - console.error(`Error approving post with ID ${communityPostId}:`, error); - throw error; + throw new Error(`Error approving post with ID ${communityPostId}`); } }; @@ -63,8 +63,8 @@ export const rejectPost = async ( throw new Error('Failed to reject post'); } return response.data; + // eslint-disable-next-line @typescript-eslint/no-unused-vars } catch (error) { - console.error(`Error rejecting post with ID ${communityPostId}:`, error); - throw error; + throw new Error(`Error rejecting post with ID ${communityPostId}`); } }; diff --git a/src/components/pages/community/ParticipantList.tsx b/src/components/pages/community/ParticipantList.tsx new file mode 100644 index 0000000..d6c3d05 --- /dev/null +++ b/src/components/pages/community/ParticipantList.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import { useAtom } from 'jotai'; +import { sseDataAtom } from '../../../store/sseStore'; +import { useAuth } from '../../../context/AuthContext'; + +const ParticipantList: React.FC = () => { + const [sseData] = useAtom(sseDataAtom); + const { isAdmin } = useAuth(); + + if (!isAdmin) { + // 관리자가 아니면 아무것도 표시하지 않음 + return null; + } + + if (!sseData || !sseData.participants.length) { + return
참여자 데이터가 없습니다.
; + } + + return ( +
+

참여자 목록 (관리자 전용)

+
    + {sseData.participants.map((participant) => ( +
  • +

    + 닉네임: {participant.nickname} | 수량: {participant.quantity} |{' '} + {participant.isCancelled ? '취소됨' : '참여 중'} |{' '} + {participant.isPaymentCompleted ? '결제 완료' : '결제 대기'} |{' '} +

    +
  • + ))} +
+

참여 인원: {sseData.participationCount}

+

결제 완료 인원: {sseData.paymentCount}

+

환불 완료 인원: {sseData.refundedCount}

+
+ ); +}; + +export default ParticipantList; diff --git a/src/components/pages/community/PostDetailPage.tsx b/src/components/pages/community/PostDetailPage.tsx index 194b485..62c3900 100644 --- a/src/components/pages/community/PostDetailPage.tsx +++ b/src/components/pages/community/PostDetailPage.tsx @@ -1,9 +1,8 @@ import React, { useEffect, useState } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { useAtom } from 'jotai'; -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; import { - selectedPostAtom, realTimeDataAtom, selectedPostIdAtom, joinQuantityAtom, @@ -15,19 +14,30 @@ import { joinPost, cancelJoinPost, deletePostById, + handleSSEUpdate, } from '../community/api/postApi'; import { FaBackspace } from 'react-icons/fa'; -import { POST_STATUS, PostDetailResponse } from '../../../types/postTypes'; +import { + Post, + POST_STATUS, + PostDetailResponse, + SSEEvent, +} from '../../../types/postTypes'; import PostImageSection from './PostDetailPage/PostImageSection'; import PostDetailsSection from './PostDetailPage/PostDetailsSection'; import PostCommentsSection from './PostDetailPage/PostCommentsSection'; +import SSEHandler from '../../../utils/SSEHandler'; +import ParticipantList from './ParticipantList'; +import { useAuth } from '../../../context/AuthContext'; const PostDetailPage: React.FC = () => { const { communityPostId } = useParams<{ communityPostId: string }>(); const navigate = useNavigate(); const queryClient = useQueryClient(); + const { isAdmin } = useAuth(); - const [selectedPost] = useAtom(selectedPostAtom); + const [post, setPost] = useState(null); + const [data, setData] = useState(null); const [realTimeData, setRealTimeData] = useAtom(realTimeDataAtom); const [, setSelectedPostId] = useAtom(selectedPostIdAtom); const [currentUser] = useAtom(currentUserAtom); @@ -46,32 +56,41 @@ const PostDetailPage: React.FC = () => { if (communityPostIdNumber) { setSelectedPostId(communityPostIdNumber); } else { - alert('잘못된 게시물 ID입니다.'); navigate('/community/post'); } }, [communityPostIdNumber, setSelectedPostId, navigate]); - // 게시물 데이터 가져오기 - const { data, isError } = useQuery({ - queryKey, - queryFn: async (): Promise => - fetchPostById(communityPostIdNumber), - }); + useEffect(() => { + const fetchPostDetails = async () => { + try { + const data = await fetchPostById(Number(communityPostId!)); + setData(data); + setPost(data.communityPost); + } catch (error) { + console.error('Failed to fetch post details:', error); + navigate('*'); + } + }; + + if (communityPostId) fetchPostDetails(); + }, [communityPostId, navigate]); useEffect(() => { - if (data) { + if (data!) { const quantityToSet = - data.participationStatus === 'JOIN' || - data.participationStatus === 'PAYMENT_STANDBY' + data!.participationStatus === 'JOIN' || + data!.participationStatus === 'PAYMENT_STANDBY' ? (joinQuantity ?? 1) : 1; setQuantity(quantityToSet); } - }, [data, joinQuantity]); + }, [data!, joinQuantity]); - const isParticipant = selectedPost.data?.participationStatus === 'JOIN'; - const isNotParticipant = !selectedPost.data?.participationStatus; - const isAuthor = selectedPost.data?.communityPost.userId === currentUser?.id; + const isParticipant = + realTimeData?.participationStatus === 'JOIN' || + realTimeData?.participationStatus === 'PAYMENT_STANDBY'; + const isNotParticipant = !realTimeData?.participationStatus; + const isAuthor = post?.userId === currentUser?.id; // SSE 연결 활성화 useEffect(() => { @@ -95,23 +114,15 @@ const PostDetailPage: React.FC = () => { } }, [communityPostIdNumber, queryClient, setRealTimeData]); - // 오류 처리 - useEffect(() => { - if (isError) { - alert('게시물을 불러오는 데 실패했습니다.'); - navigate('/community/post'); - } - }, [isError, navigate]); - // 마감 시간 계산 useEffect(() => { - if (selectedPost.data?.communityPost.closeAt) { + if (post?.closeAt) { const calculateRemainingTime = () => { + const createdTime = new Date(post.closeAt || '').getTime(); + const targetTime = createdTime + 9 * 24 * 60 * 60 * 1000; const now = Date.now(); - const targetTime = new Date( - selectedPost.data?.communityPost.closeAt || '' - ).getTime(); const diff = targetTime - now; + if (diff <= 0) return '마감되었습니다.'; const days = Math.floor(diff / (1000 * 60 * 60 * 24)); const hours = Math.floor( @@ -128,20 +139,17 @@ const PostDetailPage: React.FC = () => { return () => clearInterval(timer); } - }, [selectedPost]); + }, [post?.createdAt, post?.period]); // 결제 마감 시간 계산 useEffect(() => { - if ( - selectedPost.data?.communityPost.status === POST_STATUS.PAYMENT_STANDBY && - selectedPost.data.communityPost.paymentDeadline - ) { - const countdownTarget = - new Date(selectedPost.data.communityPost.paymentDeadline).getTime() + - 12 * 60 * 60 * 1000; - + if (post?.status === POST_STATUS.PAYMENT_STANDBY && post?.paymentDeadline) { const calculatePaymentTime = () => { - const now = new Date().getTime(); + const paymentDeadlineTime = new Date( + post.paymentDeadline || '' + ).getTime(); + const countdownTarget = paymentDeadlineTime + 12 * 60 * 60 * 1000; + const now = Date.now(); const diff = countdownTarget - now; if (diff <= 0) return '결제 마감되었습니다.'; @@ -158,7 +166,7 @@ const PostDetailPage: React.FC = () => { return () => clearInterval(timer); } - }, [selectedPost]); + }, [post?.status, post?.paymentDeadline]); // 게시물 삭제 핸들러 const deletePostMutation = useMutation({ @@ -175,6 +183,7 @@ const PostDetailPage: React.FC = () => { const handleDelete = () => { if (window.confirm('정말 이 게시물을 삭제하시겠습니까?')) { + handleSSEUpdate(communityPostIdNumber); deletePostMutation.mutate(communityPostIdNumber); } }; @@ -189,8 +198,13 @@ const PostDetailPage: React.FC = () => { Error, // 에러 타입 { communityPostId: number; quantity: number } >({ - mutationFn: ({ communityPostId, quantity }) => - joinPost(communityPostId, quantity), + mutationFn: ({ + communityPostId, + quantity, + }: { + communityPostId: number; + quantity: number; + }) => joinPost(communityPostId, quantity), onSuccess: () => { alert('공구 참여가 완료되었습니다.'); queryClient.invalidateQueries({ queryKey }); // 정확한 queryKey 사용 @@ -207,19 +221,62 @@ const PostDetailPage: React.FC = () => { alert('수량을 입력해주세요.'); return; } - - joinMutation({ communityPostId: communityPostIdNumber, quantity }); + handleSSEUpdate(communityPostIdNumber); + joinMutation( + { communityPostId: communityPostIdNumber, quantity }, + { + onSuccess: () => { + setRealTimeData((prev) => { + const updatedParticipants = prev?.participants + ? [ + ...prev.participants.filter( + (p) => p.userId !== currentUser?.id + ), + { + userId: currentUser?.id || 0, // userId가 undefined일 수 있으므로 기본값 추가 + nickname: currentUser?.nickname || '', + isCancelled: false, + isPaymentCompleted: false, + quantity, + }, + ] + : [ + { + userId: currentUser?.id || 0, + nickname: currentUser?.nickname || '', + isCancelled: false, + isPaymentCompleted: false, + quantity, + }, + ]; + + return { + ...prev, + participants: updatedParticipants, + participationCount: updatedParticipants.length, + postStatus: prev?.postStatus || POST_STATUS.APPROVED, + paymentCount: prev?.paymentCount || 0, + participationStatus: 'JOIN', + } as SSEEvent; + }); + + alert('공구 참여가 완료되었습니다.'); + queryClient.invalidateQueries({ queryKey }); + }, + onError: (error: Error) => { + console.error('공구 참여 실패:', error.message); + alert('공구 참여에 실패했습니다.'); + }, + } + ); }; // 초기 수량 설정 (참여 상태 확인) useEffect(() => { - if ( - selectedPost.data?.participationStatus === 'JOIN' && - joinQuantity !== null - ) { + if (data?.participationStatus === 'JOIN' && joinQuantity !== null) { setQuantity(joinQuantity); } - }, [selectedPost.data?.participationStatus, joinQuantity]); + }, [data?.participationStatus, joinQuantity]); // 참여 취소 핸들러 const cancelMutation = useMutation({ @@ -234,7 +291,31 @@ const PostDetailPage: React.FC = () => { }); const handleCancel = () => { - cancelMutation.mutate(); + handleSSEUpdate(communityPostIdNumber); + cancelMutation.mutate(undefined, { + onSuccess: () => { + setRealTimeData((prev) => { + const updatedParticipants = prev?.participants?.map((p) => + p.userId === currentUser?.id ? { ...p, isCancelled: true } : p + ); + + return { + ...prev, + participants: updatedParticipants, + participationCount: updatedParticipants?.filter( + (p) => !p.isCancelled + ).length, + postStatus: prev?.postStatus || POST_STATUS.APPROVED, + paymentCount: prev?.paymentCount || 0, + } as SSEEvent; + }); + + alert('참여가 취소되었습니다.'); + }, + onError: () => { + alert('참여 취소에 실패했습니다.'); + }, + }); }; const handleReport = () => { @@ -244,39 +325,52 @@ const PostDetailPage: React.FC = () => { // 수량 변경 const handleQuantityChange = (change: number) => { const newQuantity = quantity + change; - if ( - newQuantity > 0 && - newQuantity <= selectedPost.data!.communityPost.availableNumber - ) { + const maxAvailable = post?.availableNumber || 0; + + if (newQuantity > 0 && newQuantity <= maxAvailable) { setQuantity(newQuantity); + } else { + alert('유효한 수량을 입력하세요.'); } }; // 결제하기 페이지로 이동 const handlePayment = () => { - if ( - selectedPost.data?.participationStatus !== POST_STATUS.PAYMENT_STANDBY - ) { + handleSSEUpdate(communityPostIdNumber); + if (data?.participationStatus !== POST_STATUS.PAYMENT_STANDBY) { alert('현재 결제할 수 없는 상태입니다.'); return; } const paymentState = { post: { - title: selectedPost.data.communityPost.title, - unitAmount: selectedPost.data.communityPost.unitAmount, - imageUrls: selectedPost.data.communityPost.imageUrls, + title: post?.title, + unitAmount: post?.unitAmount, + imageUrls: post?.imageUrls, }, quantity, }; + setRealTimeData((prev) => { + const updatedParticipants = prev?.participants?.map((p) => + p.userId === currentUser?.id ? { ...p, isPaymentCompleted: true } : p + ); + + return { + ...prev, + participants: updatedParticipants, + paymentCount: updatedParticipants?.filter((p) => p.isPaymentCompleted) + .length, + postStatus: prev?.postStatus || POST_STATUS.PAYMENT_COMPLETED, + participationCount: updatedParticipants?.length || 0, + } as SSEEvent; + }); + if (isAuthor) { - // 작성자인 경우 PaymentAuthorPage로 이동 navigate(`/community/post/${communityPostId}/payment/author`, { state: paymentState, }); } else { - // 참여자인 경우 PaymentParticipantPage로 이동 navigate(`/community/post/${communityPostId}/payment/participant`, { state: paymentState, }); @@ -285,14 +379,46 @@ const PostDetailPage: React.FC = () => { // 환불 요청 페이지로 이동 const handleRefund = () => { - if (selectedPost.data?.participationStatus === 'PAYMENT_COMPLETE') { - navigate(`/community/post/${communityPostId}/refund`); + handleSSEUpdate(communityPostIdNumber); + if (data?.participationStatus === 'PAYMENT_COMPLETE') { + const refundState = { + post: { title: post?.title, unitAmount: post?.unitAmount }, + quantity, + }; + + setRealTimeData((prev) => { + const updatedParticipants = prev?.participants?.map((p) => + p.userId === currentUser?.id ? { ...p, isPaymentCompleted: false } : p + ); + + return { + ...prev, + participants: updatedParticipants, + paymentCount: updatedParticipants?.filter((p) => p.isPaymentCompleted) + .length, + postStatus: prev?.postStatus || POST_STATUS.APPROVED, + participationCount: updatedParticipants?.length || 0, + } as SSEEvent; + }); + + if (isAuthor) { + navigate(`/community/post/${communityPostId}/refund/author`, { + state: refundState, + }); + } else { + navigate(`/community/post/${communityPostId}/refund/participant`, { + state: refundState, + }); + } } else { alert('환불 요청이 불가능한 상태입니다.'); } }; - if (!selectedPost.data?.communityPost) return
게시물이 없습니다.
; + if (!post) return
게시물이 없습니다.
; + if (!data || !post) { + return
로딩 중입니다...
; + } return ( @@ -320,15 +446,15 @@ const PostDetailPage: React.FC = () => { {/* 이미지 섹션 */} { {/* 내용 섹션 */} -