diff --git a/index.html b/index.html index 3c08226..ceebe3f 100644 --- a/index.html +++ b/index.html @@ -2,7 +2,7 @@ - + 36.5 { useViewport(); @@ -56,6 +57,7 @@ const App = () => { } /> } /> + } /> }> } /> diff --git a/src/apis/admin.ts b/src/apis/admin.ts index 5eb1580..f768098 100644 --- a/src/apis/admin.ts +++ b/src/apis/admin.ts @@ -7,6 +7,7 @@ const postReports = async (postReportRequest: PostReportRequest) => { return res; } catch (error) { console.error(error); + return null; } }; diff --git a/src/apis/notification.ts b/src/apis/notification.ts index 869df0a..3453135 100644 --- a/src/apis/notification.ts +++ b/src/apis/notification.ts @@ -31,4 +31,15 @@ const patchReadNotificationAll = async () => { } }; -export { getTimeLines, patchReadNotification, patchReadNotificationAll }; +const getNotReadCount = async () => { + try { + const res = await client.get('/api/notifications/not-read'); + if (!res) throw new Error('안 읽은 알림 수를 가져오는 도중 오류가 발생했습니다.'); + console.log(res); + return res; + } catch (error) { + console.error(error); + } +}; + +export { getTimeLines, patchReadNotification, patchReadNotificationAll, getNotReadCount }; diff --git a/src/apis/share.ts b/src/apis/share.ts index 15c97d2..37a3087 100644 --- a/src/apis/share.ts +++ b/src/apis/share.ts @@ -39,8 +39,26 @@ export interface ShareProposal { status: 'REJECTED' | 'APPROVED' | 'PENDING'; } +//편지 공유 요청 상세 조회 +export interface ShareProposalLetter { + id: number; + content: string; + writerZipCode: string; + receiverZipCode: string; + createdAt: string; +} + +export interface ShareProposalDetail { + shareProposalId: number; + requesterZipCode: string; + recipientZipCode: string; + message: string; + status: 'PENDING' | 'ACCEPTED' | 'REJECTED'; + letters: ShareProposalLetter[]; +} + // 편지 공유 수락 / 거절 -export interface SharePostApproval { +export interface ShareProposalApproval { shareProposalId: number; status: 'APPROVED' | 'REJECTED'; sharePostId: number; @@ -106,13 +124,27 @@ export const getShareProposalList = async () => { } }; +// 편지 공유 요청 상세 조회 +export const getShareProposalDetail = async ( + shareProposalId: number, +): Promise => { + try { + const response = await client.get(`/api/share-proposals/${shareProposalId}`); + console.log(`😎공유 요청 상세 조회 데이터 `, response.data); + return response.data.data; + } catch (error) { + console.error('❌ 편지 공유 요청을 상세 조회하던 중 에러가 발생했습니다', error); + throw error; + } +}; + // 편지 공유 수락 / 거절 export const postShareProposalApproval = async ( shareProposalId: number, action: 'approve' | 'reject', -): Promise => { +): Promise => { try { - const response = await client.patch(`/api/share-proposal/${shareProposalId}/${action}`); + const response = await client.patch(`/api/share-proposals/${shareProposalId}/${action}`); return response.data; } catch (error) { console.error( diff --git a/src/components/HomeButton.tsx b/src/components/HomeButton.tsx deleted file mode 100644 index cc185e2..0000000 --- a/src/components/HomeButton.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import HomeOutlinedIcon from '@mui/icons-material/HomeOutlined'; -import { Link } from 'react-router'; - -export default function HomeButton() { - return ( - <> -
- - - -
- - ); -} diff --git a/src/components/MenuButton.tsx b/src/components/MenuButton.tsx new file mode 100644 index 0000000..681e59a --- /dev/null +++ b/src/components/MenuButton.tsx @@ -0,0 +1,61 @@ +import MenuRoundedIcon from '@mui/icons-material/MenuRounded'; +import EditNoteRoundedIcon from '@mui/icons-material/EditNoteRounded'; +import MarkunreadOutlinedIcon from '@mui/icons-material/MarkunreadOutlined'; +import CalendarTodayOutlinedIcon from '@mui/icons-material/CalendarTodayOutlined'; + +import { useState } from 'react'; +import { Link } from 'react-router'; +import { twMerge } from 'tailwind-merge'; + +export default function MenuButton() { + const [isOpen, setIsOpen] = useState(false); + + return ( + <> +
+ + setIsOpen(false)} /> + + + setIsOpen(false)} /> + + + setIsOpen(false)} /> + + +
+ setIsOpen((state) => !state)} /> +
+
+ + ); +} diff --git a/src/components/NotificationButton.tsx b/src/components/NotificationButton.tsx new file mode 100644 index 0000000..0047067 --- /dev/null +++ b/src/components/NotificationButton.tsx @@ -0,0 +1,36 @@ +import { getNotReadCount } from '@/apis/notification'; +import { AlarmIcon } from '@/assets/icons'; +import useNotificationStore from '@/stores/notificationStore'; +import { useEffect } from 'react'; +import { Link } from 'react-router'; +import { twMerge } from 'tailwind-merge'; + +export default function NotificationButton() { + const notReadCount = useNotificationStore((state) => state.notReadCount); + const setNotReadCount = useNotificationStore((state) => state.setNotReadCount); + const notReadStyle = twMerge( + `absolute -right-1 -bottom-1 flex h-3.5 w-3.5 items-center justify-center rounded-full bg-red-400 text-[8px] text-white`, + notReadCount >= 100 && 'w-4 h-4', + ); + + const handleGetNotReadCount = async () => { + const res = await getNotReadCount(); + if (res?.status === 200) { + const updateNotReadCount: number = res.data.data.notReadCount; + setNotReadCount(updateNotReadCount); + } + }; + + useEffect(() => { + handleGetNotReadCount(); + }); + + return ( + + {notReadCount > 0 && ( +
{notReadCount < 100 ? notReadCount : '99+'}
+ )} + + + ); +} diff --git a/src/components/ReportModal.tsx b/src/components/ReportModal.tsx index 41ce17f..ce84c91 100644 --- a/src/components/ReportModal.tsx +++ b/src/components/ReportModal.tsx @@ -5,6 +5,7 @@ import { postReports } from '@/apis/admin'; import ConfirmModal from './ConfirmModal'; import TextareaField from './TextareaField'; +import useToastStore from '@/stores/toastStore'; interface ReportModalProps { reportType: ReportType; @@ -29,7 +30,9 @@ const ReportModal = ({ reportType, letterId, onClose }: ReportModalProps) => { reportType: reportType, reasonType: '', reason: '', - letterId: letterId, + letterId: reportType === 'LETTER' ? letterId : null, + sharePostId: reportType === 'SHARE_POST' ? letterId : null, + eventCommentId: reportType === 'EVENT_COMMENT' ? letterId : null, }); const handleReasonClick = (reason: Reason) => { @@ -38,14 +41,16 @@ const ReportModal = ({ reportType, letterId, onClose }: ReportModalProps) => { else setPostReportRequest((cur) => ({ ...cur, reasonType: reason })); }; + const setToastActive = useToastStore((state) => state.setToastActive); + const handleSubmit = async () => { const res = await postReports(postReportRequest); if (res?.status === 200) { - alert('신고 처리되었습니다.'); + setToastActive({ title: '신고가 접수되었습니다.', toastType: 'Success' }); console.log(res); onClose(); - } else if (res?.status === 409) { - alert('신고한 이력이 있습니다.'); + } else { + setToastActive({ title: '신고한 이력이 있습니다.', toastType: 'Error' }); onClose(); } }; diff --git a/src/components/ToastItem.tsx b/src/components/ToastItem.tsx index 2465e5b..75a459d 100644 --- a/src/components/ToastItem.tsx +++ b/src/components/ToastItem.tsx @@ -26,7 +26,7 @@ export default function ToastItem({ toastObj, index }: { toastObj: ToastObj; ind const animation = `toast-blink ${toastObj.time}s ease-in-out forwards`; const toastStyle = twMerge( - 'fixed bottom-5 left-1/2 z-40 flex h-[40px] max-w-150 min-w-[300px] w-[100%] -translate-1/2 items-center justify-center rounded-lg caption-sb shadow-[0_1px_6px_rgba(200,200,200,0.2)]', + 'fixed bottom-5 left-1/2 z-40 flex h-[40px] max-w-150 min-w-[300px] w-[80%] -translate-1/2 items-center justify-center rounded-lg caption-sb shadow-[0_1px_6px_rgba(200,200,200,0.2)]', TOAST_POSITION[toastObj.position], TOAST_DESIGN[toastObj.toastType].style, ); diff --git a/src/hooks/useServerSentEvents.tsx b/src/hooks/useServerSentEvents.tsx index 1ad9850..a0b406b 100644 --- a/src/hooks/useServerSentEvents.tsx +++ b/src/hooks/useServerSentEvents.tsx @@ -4,15 +4,55 @@ import { useEffect, useRef } from 'react'; import useAuthStore from '@/stores/authStore'; import useToastStore from '@/stores/toastStore'; import { useNavigate } from 'react-router'; +import useNotificationStore from '@/stores/notificationStore'; +import { getNewToken } from '@/apis/auth'; + +interface MessageEventData { + title: string; + alarmType: AlarmType; +} export const useServerSentEvents = () => { + let reconnect: number | undefined; + const navigate = useNavigate(); + const recallCountRef = useRef(1); const accessToken = useAuthStore((state) => state.accessToken); + const setAccessToken = useAuthStore((state) => state.setAccessToken); const sourceRef = useRef(null); const setToastActive = useToastStore((state) => state.setToastActive); + const incrementNotReadCount = useNotificationStore((state) => state.incrementNotReadCount); + + const ALARM_TYPE: AlarmType[] = ['SENDING', 'LETTER', 'REPORT', 'SHARE', 'POSTED']; + const handleOnMessage = async (data: string) => { + const message: MessageEventData = await JSON.parse(data); + if (ALARM_TYPE.includes(message.alarmType)) { + incrementNotReadCount(); + setToastActive({ + toastType: 'Info', + title: message.title, + position: 'Top', + time: 5, + onClick: () => navigate('/mypage/notifications'), + }); + } + }; + + // 토큰 재발급 함수 + const callReissue = async () => { + try { + const response = await getNewToken(); + if (response?.status !== 200) throw new Error('error while fetching newToken'); + const newToken = response?.data.data.accessToken; + return setAccessToken(newToken); + } catch (e) { + return Promise.reject(e); + } + }; + useEffect(() => { if (!accessToken) { console.log('로그인 정보 확인불가'); @@ -32,23 +72,24 @@ export const useServerSentEvents = () => { ); sourceRef.current.onmessage = (event) => { - console.log(event); - console.log('알림 수신'); - setToastActive({ - toastType: 'Info', - title: '새 알림이 도착했어요!', - position: 'Top', - time: 5, - onClick: () => navigate('/mypage/notifications'), - }); + // console.log(event); + // console.log('알림 수신'); + handleOnMessage(event.data); }; - sourceRef.current.onerror = (error) => { - console.log(error); - console.log('에러 발생함'); + sourceRef.current.onerror = () => { + // 에러 발생시 해당 에러가 45초를 넘어서 발생한 에러인지, 401에러인지 판단할 수 있는게 없어서 그냥 에러 발생하면 reissue 넣는걸로 때움 + callReissue(); closeSSE(); + recallCountRef.current += 1; + console.log('SSE연결 에러 발생'); + // 재연결 로직 추가 가능 - setTimeout(connectSSE, 5000); // 5초 후 재연결 시도 + if (recallCountRef.current < 5) { + reconnect = setTimeout(connectSSE, 5000); + } else { + console.log('5회 이상 에러발생으로 구독기능 제거'); + } }; } catch (error) { console.error(error); @@ -64,6 +105,7 @@ export const useServerSentEvents = () => { }, [accessToken]); const closeSSE = () => { + if (reconnect) clearTimeout(reconnect); sourceRef.current?.close(); sourceRef.current = null; }; diff --git a/src/layouts/Header.tsx b/src/layouts/Header.tsx index 8efa549..5d91ef6 100644 --- a/src/layouts/Header.tsx +++ b/src/layouts/Header.tsx @@ -1,20 +1,18 @@ import { Link, useNavigate } from 'react-router'; -import { AlarmIcon, ArrowLeftIcon, PersonIcon } from '@/assets/icons'; +import { ArrowLeftIcon, PersonIcon } from '@/assets/icons'; +import NotificationButton from '@/components/NotificationButton'; const Header = () => { - // TODO: 뒤로 가기 버튼이 보이는 조건 추가 - // TODO: 스크롤 발생 시, 어떻게 보여져야 하는지 const navigate = useNavigate(); + return (
- - - + diff --git a/src/pages/Auth/index.tsx b/src/pages/Auth/index.tsx index cd892d5..cb562af 100644 --- a/src/pages/Auth/index.tsx +++ b/src/pages/Auth/index.tsx @@ -69,6 +69,11 @@ const AuthCallbackPage = () => { useEffect(() => { if (!stateToken) { + if (error === 'deleted_member') { + navigate('/login'); + alert('탈퇴한 회원입니다. 관리자에게 문의 부탁드립니다.'); + return; + } navigate('/notFound'); if (error === 'deleted_member') { alert('탈퇴한 회원입니다.'); diff --git a/src/pages/Home/components/HomeHeader.tsx b/src/pages/Home/components/HomeHeader.tsx index 3c1db95..a1ea28f 100644 --- a/src/pages/Home/components/HomeHeader.tsx +++ b/src/pages/Home/components/HomeHeader.tsx @@ -1,14 +1,13 @@ import { Link } from 'react-router'; -import { AlarmIcon, PersonIcon } from '@/assets/icons'; +import { PersonIcon } from '@/assets/icons'; +import NotificationButton from '@/components/NotificationButton'; const HomeHeader = () => { return (
- - - + diff --git a/src/pages/Home/components/ShowDraftModal.tsx b/src/pages/Home/components/ShowDraftModal.tsx index 5cbb5bc..4764837 100644 --- a/src/pages/Home/components/ShowDraftModal.tsx +++ b/src/pages/Home/components/ShowDraftModal.tsx @@ -17,8 +17,8 @@ const ShowDraftModal = ({ onClose }: ShowDraftModalProps) => { const navigate = useNavigate(); const handleNavigation = (draft: DraftLetter) => { - navigate(`/board/letter/${draft.letterId}?isDraft=true`, { - state: { draft: draft, isDraft: true }, + navigate(`/letter/write/?letterId=${draft.parentLetterId}`, { + state: { draft, isDraft: true }, }); }; diff --git a/src/pages/Home/components/ShowShareAccessModal.tsx b/src/pages/Home/components/ShowShareAccessModal.tsx index f74cd6a..135c393 100644 --- a/src/pages/Home/components/ShowShareAccessModal.tsx +++ b/src/pages/Home/components/ShowShareAccessModal.tsx @@ -1,10 +1,11 @@ import React, { useEffect, useState } from 'react'; -// import { useNavigate } from 'react-router'; +import { useNavigate } from 'react-router'; -// import { getSharePostDetail } from '@/apis/share'; import { getShareProposalList } from '@/apis/share'; import { ShareProposal } from '@/apis/share'; +import { getShareProposalDetail } from '@/apis/share'; + import ModalBackgroundWrapper from '@/components/ModalBackgroundWrapper'; import ModalOverlay from '@/components/ModalOverlay'; @@ -14,7 +15,7 @@ interface ShowShareAccessModalProps { } const ShowShareAccessModal = ({ onClose }: ShowShareAccessModalProps) => { - // const navigate = useNavigate(); + const navigate = useNavigate(); const [shareProposals, setShareProposals] = useState([]); @@ -28,16 +29,16 @@ const ShowShareAccessModal = ({ onClose }: ShowShareAccessModalProps) => { }); }, []); - // const handleNavigation = async (shareProposalId: number) => { - // try { - // const postDetail = await getSharePostDetail(shareProposalId); - // navigate(`/board/letter/${shareProposalId}`, { - // state: { postDetail, isShareLetterPreview: true }, - // }); - // } catch (error) { - // console.error('❌ 게시글 상세 페이지로 이동하는 데에 실패했습니다.', error); - // } - // }; + const handleNavigation = async (shareProposalId: number) => { + try { + const proposalDetail = await getShareProposalDetail(shareProposalId); + navigate(`/board/share/${shareProposalId}`, { + state: { proposalDetail }, + }); + } catch (error) { + console.error('❌ 게시글 상세 페이지로 이동하는 데에 실패했습니다.', error); + } + }; return ( @@ -60,7 +61,8 @@ const ShowShareAccessModal = ({ onClose }: ShowShareAccessModalProps) => { diff --git a/src/pages/Home/index.tsx b/src/pages/Home/index.tsx index 68f528f..2b3224b 100644 --- a/src/pages/Home/index.tsx +++ b/src/pages/Home/index.tsx @@ -1,7 +1,7 @@ import { useEffect } from 'react'; import { useNavigate } from 'react-router'; -import HomeButton from '@/components/HomeButton'; +import MenuButton from '@/components/MenuButton'; import NoticeRollingPaper from '@/components/NoticeRollingPaper'; import useViewport from '@/hooks/useViewport'; import useAuthStore from '@/stores/authStore'; @@ -46,7 +46,7 @@ const HomePage = () => {
- +
); }; diff --git a/src/pages/LetterBoard/index.tsx b/src/pages/LetterBoard/index.tsx index b6d4511..423afc3 100644 --- a/src/pages/LetterBoard/index.tsx +++ b/src/pages/LetterBoard/index.tsx @@ -9,6 +9,7 @@ import NoticeRollingPaper from '@/components/NoticeRollingPaper'; import PageTitle from '@/components/PageTitle'; import LetterPreview from './components/LetterPreview'; +import MenuButton from '@/components/MenuButton'; const LetterBoardPage = () => { const navigate = useNavigate(); @@ -21,7 +22,7 @@ const LetterBoardPage = () => { console.error('게시글 목록을 불러오는데 실패했습니다.'); return { content: [], currentPage: page, totalPages: 1 }; } - console.log('page', response); + console.log('게시글 목록', response); return response as SharePostResponse; } catch (e) { console.error(e); @@ -38,11 +39,15 @@ const LetterBoardPage = () => { getNextPageParam: (res) => { if (!res || !res?.content || res?.currentPage >= res?.totalPages) { return undefined; + } else if (res) { + return res.currentPage + 1; } - return res.currentPage + 1; }, staleTime: 1000 * 60 * 5, gcTime: 1000 * 60 * 10, + refetchOnMount: true, + refetchOnReconnect: true, + refetchOnWindowFocus: true, }); const postLists = data?.pages?.flatMap((page) => page?.content || []) || []; @@ -95,6 +100,7 @@ const LetterBoardPage = () => {

)} + ); diff --git a/src/pages/LetterBoardDetail/components/Header.tsx b/src/pages/LetterBoardDetail/components/Header.tsx index f1e15f9..26c306e 100644 --- a/src/pages/LetterBoardDetail/components/Header.tsx +++ b/src/pages/LetterBoardDetail/components/Header.tsx @@ -1,4 +1,4 @@ -import { Link, useNavigate } from 'react-router'; +import { useNavigate } from 'react-router'; import { ArrowLeftIcon, @@ -30,16 +30,9 @@ const Header = ({ return (
- {isShareLetterPreview ? ( - - ) : ( - - - - )} - + {!isShareLetterPreview && (
@@ -53,6 +46,7 @@ const Header = ({

{likeCount}

{isWriter ? ( + // TODO: 게시물 삭제 ) : ( - - - - - )}
diff --git a/src/pages/LetterBox/components/LetterBoxItem.tsx b/src/pages/LetterBox/components/LetterBoxItem.tsx index f493ffb..7d5f9f9 100644 --- a/src/pages/LetterBox/components/LetterBoxItem.tsx +++ b/src/pages/LetterBox/components/LetterBoxItem.tsx @@ -1,12 +1,5 @@ import { useNavigate } from 'react-router'; import { twMerge } from 'tailwind-merge'; -interface LetterBoxItemProps { - boxId: number; - zipCode: string; - letterCount: number; - isChecked?: boolean; - isClosed?: boolean; -} const LetterBoxItem = ({ boxId, @@ -14,6 +7,7 @@ const LetterBoxItem = ({ letterCount, isChecked = false, isClosed = false, + oppositeId, }: LetterBoxItemProps) => { const navigate = useNavigate(); const handleClickItem = (id: number) => { @@ -22,6 +16,7 @@ const LetterBoxItem = ({ id, zipCode, isClosed, + oppositeId, }, }); }; diff --git a/src/pages/LetterBox/index.tsx b/src/pages/LetterBox/index.tsx index 5f173f1..df65f7d 100644 --- a/src/pages/LetterBox/index.tsx +++ b/src/pages/LetterBox/index.tsx @@ -8,21 +8,13 @@ import PageTitle from '@/components/PageTitle'; import { chunkBox } from '@/utils/chunkBox'; import LetterBoxItem from './components/LetterBoxItem'; - -interface LetterBoxData { - letterMatchingId: number; - oppositeZipCode: string; - active: boolean; - oppositeRead: boolean; - letterCount: number; -} +import MenuButton from '@/components/MenuButton'; const fetchMailLists = async () => { const response = await getMailbox(); if (!response) throw new Error(); const data: LetterBoxData[] = response.data; console.log(data); - // 정렬? return data; }; @@ -34,8 +26,10 @@ const LetterBoxPage = () => { } = useQuery({ queryKey: ['mailBox'], queryFn: fetchMailLists, - staleTime: 1000 * 60 * 5, - gcTime: 1000 * 60 * 10, + staleTime: 0, + refetchOnMount: true, + refetchOnReconnect: true, + refetchOnWindowFocus: true, }); const navigate = useNavigate(); @@ -45,55 +39,59 @@ const LetterBoxPage = () => { } return ( -
- 내 편지함 -
-

- 나와 연락한 사람들 {letterBox?.length} -

-
-
- {isLoading ? ( -

로딩중..

- ) : letterBox.length > 0 ? ( - chunkBox( - letterBox.map((data: LetterBoxData, index) => ( - - )), - ).map((row, index) => - row.length === 3 ? ( -
- {row} -
- ) : ( -
- {row} - 닫힌 문 이미지 - {row.length === 1 && ( + <> +
+ 내 편지함 +
+

+ 나와 연락한 사람들 {letterBox?.length} +

+
+
+ {isLoading ? ( +

로딩중..

+ ) : letterBox.length > 0 ? ( + chunkBox( + letterBox.map((data: LetterBoxData, index) => ( + + )), + ).map((row, index) => + row.length === 3 ? ( +
+ {row} +
+ ) : ( +
+ {row} 닫힌 문 이미지 - )} -
- ), - ) - ) : ( -

아직 주고 받은 편지가 없어요

- )} -
- 닫힌 문 이미지 - 출입문 이미지 - 닫힌 문 이미지 + {row.length === 1 && ( + 닫힌 문 이미지 + )} +
+ ), + ) + ) : ( +

아직 주고 받은 편지가 없어요

+ )} +
+ 닫힌 문 이미지 + 출입문 이미지 + 닫힌 문 이미지 +
-
-
-
-
+ +
+ + + ); }; diff --git a/src/pages/LetterBoxDetail/index.tsx b/src/pages/LetterBoxDetail/index.tsx index ee02012..d0523b0 100644 --- a/src/pages/LetterBoxDetail/index.tsx +++ b/src/pages/LetterBoxDetail/index.tsx @@ -8,6 +8,7 @@ import { postShareProposals } from '@/apis/share'; import ConfirmModal from '@/components/ConfirmModal'; import MessageModal from '@/components/MessageModal'; import PageTitle from '@/components/PageTitle'; +import MenuButton from '@/components/MenuButton'; import InformationTooltip from './components/InformationTooltip'; import LetterPreview from './components/LetterPreview'; @@ -50,8 +51,10 @@ const LetterBoxDetailPage = () => { getNextPageParam: (lastPage, allPages) => { return lastPage.currentPage >= lastPage.totalPages ? undefined : allPages.length + 1; }, - staleTime: 1000 * 60 * 5, - gcTime: 1000 * 60 * 10, + staleTime: 0, + refetchOnMount: true, + refetchOnReconnect: true, + refetchOnWindowFocus: true, }); const mailLists: MailBoxDetailProps[] = data?.pages.flatMap((page) => page.content) || []; @@ -65,7 +68,7 @@ const LetterBoxDetailPage = () => { }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]); const disconnectMutation = useMutation({ - mutationFn: async () => await postMailboxDisconnect(userInfo.id), + mutationFn: async () => await postMailboxDisconnect(userInfo.oppositeId), onSuccess: () => { navigate(-1); setToastActive({ @@ -86,7 +89,7 @@ const LetterBoxDetailPage = () => { }); const shareMutation = useMutation({ - mutationFn: () => postShareProposals(selected, userInfo.id, shareComment), + mutationFn: () => postShareProposals(selected, userInfo.oppositeId, shareComment), onSuccess: () => { toggleShareMode(); setShareComment(''); @@ -223,6 +226,7 @@ const LetterBoxDetailPage = () => { )} + ); }; diff --git a/src/pages/MyPage/components/MyBoardPage.tsx b/src/pages/MyPage/components/MyBoardPage.tsx index a4c3759..03e5588 100644 --- a/src/pages/MyPage/components/MyBoardPage.tsx +++ b/src/pages/MyPage/components/MyBoardPage.tsx @@ -30,11 +30,14 @@ const MyBoardPage = () => { isLoading, isError, } = useQuery({ - queryKey: ['sharePostList'], + queryKey: ['sharMyPostList'], queryFn: () => fetchMyPostList(), enabled: true, staleTime: 1000 * 60 * 5, gcTime: 1000 * 60 * 10, + refetchOnMount: true, + refetchOnReconnect: true, + refetchOnWindowFocus: true, }); if (isError) { diff --git a/src/pages/MyPage/constants/index.ts b/src/pages/MyPage/constants/index.ts index b605b37..7028ff8 100644 --- a/src/pages/MyPage/constants/index.ts +++ b/src/pages/MyPage/constants/index.ts @@ -5,5 +5,5 @@ export const TEMPERATURE_RANGE = [ { min: 40, max: 55, description: '마음이 따뜻한 따숨님' }, { min: 55, max: 70, description: '훈훈한 따숨님' }, { min: 70, max: 80, description: '정말 따뜻한 따숨님' }, - { min: 85, max: 100, description: '사랑이 넘치는 따숨님' }, + { min: 85, max: 105, description: '사랑이 넘치는 따숨님' }, ]; diff --git a/src/pages/MyPage/index.tsx b/src/pages/MyPage/index.tsx index 226eb36..de1bc97 100644 --- a/src/pages/MyPage/index.tsx +++ b/src/pages/MyPage/index.tsx @@ -8,6 +8,7 @@ import useMyPageStore from '@/stores/myPageStore'; import { TEMPERATURE_RANGE } from './constants'; import useToastStore from '@/stores/toastStore'; +import ModalOverlay from '@/components/ModalOverlay'; const MyPage = () => { useEffect(() => { @@ -15,7 +16,10 @@ const MyPage = () => { }, []); const { data, fetchMyPageInfo } = useMyPageStore(); + const [isOpenModal, setIsOpenModal] = useState(false); + const [isOpenWarningModal, setIsOpenWarningModal] = useState(false); + const logout = useAuthStore((state) => state.logout); const setToastActive = useToastStore((state) => state.setToastActive); @@ -60,6 +64,19 @@ const MyPage = () => { }} /> )} + + {isOpenWarningModal && ( + setIsOpenWarningModal(false)}> +
+
+
+

경고 규칙

+

3회 경고: 서비스 이용 불가능

+
+
+
+ )} +

{data.zipCode.split('').map((code, index) => ( @@ -109,11 +126,24 @@ const MyPage = () => { {data.email}

+
{ + setIsOpenWarningModal(true); + }} + > +

경고 횟수

+

+ {data.warningCount}회 +

+
+ @@ -125,6 +155,7 @@ const MyPage = () => { onClick={async () => { setIsOpenModal(true); }} + aria-label="탈퇴하기" > 탈퇴하기 diff --git a/src/pages/Notifications/components/WarningModal.tsx b/src/pages/Notifications/components/WarningModal.tsx index 4470eb3..3b6ea46 100644 --- a/src/pages/Notifications/components/WarningModal.tsx +++ b/src/pages/Notifications/components/WarningModal.tsx @@ -34,13 +34,7 @@ const WarningModal = ({ isOpen, reportContent, onClose }: WarningModalProps) =>

{`${divideContents[1]} 회`}

경고 규칙

-

- 1회 경고: 주의 안내 -
- 2회 경고: 7일 동안 서비스 이용 제한 -
- 3회 경고: 서비스 이용 불가능 -

+

3회 경고: 서비스 이용 불가능

diff --git a/src/pages/Notifications/index.tsx b/src/pages/Notifications/index.tsx index a3c0cdc..257453e 100644 --- a/src/pages/Notifications/index.tsx +++ b/src/pages/Notifications/index.tsx @@ -7,10 +7,14 @@ import PageTitle from '@/components/PageTitle'; import NotificationItem from './components/NotificationItem'; import WarningModal from './components/WarningModal'; import SendingModal from './components/SendingModal'; +import useNotificationStore from '@/stores/notificationStore'; const NotificationsPage = () => { const navigate = useNavigate(); + const decrementNotReadCount = useNotificationStore((state) => state.decrementNotReadCount); + const setNotReadCount = useNotificationStore((state) => state.setNotReadCount); + const [noti, setNoti] = useState([]); const [isOpenWarningModal, setIsOpenWarningModal] = useState(false); @@ -51,7 +55,8 @@ const NotificationsPage = () => { if (res?.status === 200) { setNoti((curNoti) => curNoti.map((noti) => { - if (noti.timelineId === timelineId) { + if (noti.timelineId === timelineId && !noti.read) { + decrementNotReadCount(); return { ...noti, read: true }; } return noti; @@ -73,6 +78,7 @@ const NotificationsPage = () => { return noti; }); }); + setNotReadCount(0); } else { console.log('모두 읽음처리 에러 발생'); } diff --git a/src/pages/RandomLetters/index.tsx b/src/pages/RandomLetters/index.tsx index 04a4263..c1b12d9 100644 --- a/src/pages/RandomLetters/index.tsx +++ b/src/pages/RandomLetters/index.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from 'react'; import { getRandomLetterCoolTime, getRandomLetterMatched } from '@/apis/randomLetter'; import BackgroundBottom from '@/components/BackgroundBottom'; import PageTitle from '@/components/PageTitle'; +import MenuButton from '@/components/MenuButton'; import CoolTime from './components/CoolTime'; import Matched from './components/Matched'; @@ -115,6 +116,7 @@ const RandomLettersPage = () => { )} + ); }; diff --git a/src/pages/RollingPaper/index.tsx b/src/pages/RollingPaper/index.tsx index 37ded0f..6b883d6 100644 --- a/src/pages/RollingPaper/index.tsx +++ b/src/pages/RollingPaper/index.tsx @@ -8,6 +8,7 @@ import BackgroundBottom from '@/components/BackgroundBottom'; import ConfirmModal from '@/components/ConfirmModal'; import PageTitle from '@/components/PageTitle'; import Header from '@/layouts/Header'; +import MenuButton from '@/components/MenuButton'; import Comment from './components/Comment'; import CommentDetailModal from './components/CommentDetailModal'; @@ -116,6 +117,7 @@ const RollingPaperPage = () => {
+ ); diff --git a/src/pages/Share/index.tsx b/src/pages/Share/index.tsx new file mode 100644 index 0000000..8a2fddf --- /dev/null +++ b/src/pages/Share/index.tsx @@ -0,0 +1,95 @@ +import { useEffect, useState } from 'react'; +import { useNavigate, useParams } from 'react-router'; + +import { getShareProposalDetail } from '@/apis/share'; +import { postShareProposalApproval, ShareProposalDetail } from '@/apis/share'; + +import { twMerge } from 'tailwind-merge'; +import Letter from '../LetterBoardDetail/components/Letter'; + +import BlurImg from '@/assets/images/landing-blur.png'; + +const ShareApprovalPage = () => { + const navigate = useNavigate(); + const { shareProposalId } = useParams(); + console.log(shareProposalId); + + const [proposalDetail, setProposalDetail] = useState(); + + const handleProposalApproval = async (action: 'approve' | 'reject') => { + try { + const result = await postShareProposalApproval(Number(shareProposalId), action); + console.log(`✅ 편지 공유 ${action === 'approve' ? '수락' : '거절'}됨:`, result); + navigate('/'); + } catch (error) { + console.error('❌공유 요청 처리 중 에러 발생', error); + } + }; + useEffect(() => { + const fetchProposalDetail = async (id: string) => { + try { + const data = await getShareProposalDetail(Number(id)); + setProposalDetail(data); + } catch (error) { + console.error('❌ 공유 요청 상세 조회에 실패했습니다.', error); + throw error; + } + }; + + if (shareProposalId) { + fetchProposalDetail(shareProposalId); + } + }, [shareProposalId]); + + return ( +
+
+

FROM. {proposalDetail?.requesterZipCode}

+

+ {proposalDetail?.message} +

+
+ {proposalDetail?.letters.map((letter, index) => ( + + ))} +
+ + {proposalDetail && ( + <> + landing blur +
+ + + +
+ + )} +
+
+ ); +}; + +export default ShareApprovalPage; diff --git a/src/pages/Write/CategorySelect.tsx b/src/pages/Write/CategorySelect.tsx index 7244b0b..89b9331 100644 --- a/src/pages/Write/CategorySelect.tsx +++ b/src/pages/Write/CategorySelect.tsx @@ -7,6 +7,7 @@ import useWrite from '@/stores/writeStore'; import ResultLetterAnimation from './components/ResultLetterAnimation'; import WritePageButton from './components/WritePageButton'; +import useToastStore from '@/stores/toastStore'; export default function CategorySelect({ setStep, @@ -21,13 +22,17 @@ export default function CategorySelect({ }) { const letterRequest = useWrite((state) => state.letterRequest); + const setToastActive = useToastStore((state) => state.setToastActive); + const handlePostLetter = async (letterRequest: LetterRequest) => { const res = await postLetter(letterRequest); if (res?.status === 200) { console.log(letterRequest); setSend(true); } else { - alert('전송오류(임시)'); + // 일단 에러 발생하면 무조건 검열단어라고 토스트를 띄웠는데 후에 에러 처리 수정해야함 + setToastActive({ title: '편지에 검열 단어가 포함되어있습니다. ', toastType: 'Error' }); + setStep('edit'); } }; @@ -89,7 +94,7 @@ export default function CategorySelect({ handlePostLetter(letterRequest); // setSend(true); } else { - alert('우표 선택을 해주세요'); + setToastActive({ title: '카테고리를 선택해주세요.', toastType: 'Warning' }); } }} > diff --git a/src/pages/Write/LetterEditor.tsx b/src/pages/Write/LetterEditor.tsx index 21e7d46..1668801 100644 --- a/src/pages/Write/LetterEditor.tsx +++ b/src/pages/Write/LetterEditor.tsx @@ -43,7 +43,7 @@ export default function LetterEditor({ setSend(true); setStep('category'); } else { - alert('전송오류 발생(임시)'); + setToastActive({ title: '전송중 오류가 발생했습니다.', toastType: 'Error' }); } }; @@ -55,7 +55,7 @@ export default function LetterEditor({ setSend(true); setStep('category'); } else { - alert('전송오류(임시)'); + setToastActive({ title: '전송중 오류가 발생했습니다.', toastType: 'Error' }); } }; @@ -79,16 +79,16 @@ export default function LetterEditor({ const handlePostTemporarySave = async () => { if (!letterId) return alert('임시저장중 오류 발생'); - const LETTER_STATE_DUMMY = false; - const requestLetterId = LETTER_STATE_DUMMY || null; + const requestLetterId = location.state?.draft.letterId || null; // MEMO : 임시저장 전송 방식 : 최초임시저장은 letterId : null, 임시저장 업데이트는 letterId : location state로 받아오는 임시저장편지의 letterId값 const temporaryRequest: TemporaryRequest = { ...letterRequest, letterId: requestLetterId }; const res = await postTemporarySave(temporaryRequest); if (res?.status === 200) { console.log(res); + setToastActive({ title: '임시저장을 완료했습니다.', toastType: 'Success' }); navigate('/'); } else { - alert('실패'); + setToastActive({ title: '임시저장에 실패했습니다.', toastType: 'Error' }); } }; @@ -161,6 +161,7 @@ export default function LetterEditor({
TO. 따숨이에게
{ @@ -175,6 +176,7 @@ export default function LetterEditor({ `body-r basic-theme min-h-full w-full resize-none px-6`, `${FONT_TYPE_OBJ[letterRequest.fontType]}`, )} + maxLength={1000} placeholder="클릭해서 내용을 작성하세요" onChange={(e) => { setLetterRequest({ ...letterRequest, content: e.target.value }); diff --git a/src/pages/Write/index.tsx b/src/pages/Write/index.tsx index cd1f6c9..5ee38b0 100644 --- a/src/pages/Write/index.tsx +++ b/src/pages/Write/index.tsx @@ -21,6 +21,7 @@ const WritePage = () => { const [isReply, setIsReply] = useState(false); const letterRequest = useWrite((state) => state.letterRequest); + const setLetterRequest = useWrite((state) => state.setLetterRequest); const resetLetterRequest = useWrite((state) => state.resetLetterRequest); const wrapStyle = twMerge( @@ -54,6 +55,16 @@ const WritePage = () => { } }; handleGetPrevLetter(letterId); + if (location.state?.isDraft) { + const draft: TemporaryRequest = location.state.draft; + setLetterRequest({ + category: draft.category, + content: draft.content, + fontType: draft.fontType, + paperType: draft.paperType, + title: draft.title, + }); + } } }, [location.state, prevLetter.length, navigate, letterId]); diff --git a/src/stores/incomingLettersStore.ts b/src/stores/incomingLettersStore.ts index cde60af..6d34c40 100644 --- a/src/stores/incomingLettersStore.ts +++ b/src/stores/incomingLettersStore.ts @@ -51,7 +51,9 @@ export const useIncomingLettersStore = create((set) => ({ data: inProgressLetters, }); - setInterval(() => { + if (inProgressLetters.length === 0) return; + + const intervalId = setInterval(() => { set((state) => { const updatedLetters = state.data.map((letter: IncomingLetters) => { const remainingTime = calculatingRemainingTime(letter.deliveryCompletedAt); @@ -62,6 +64,10 @@ export const useIncomingLettersStore = create((set) => ({ (letter) => letter.remainingTime !== '00:00:00', ); + if (filteredLetters.length === 0) { + clearInterval(intervalId); + } + return { data: filteredLetters, }; diff --git a/src/stores/myPageStore.ts b/src/stores/myPageStore.ts index 59730d0..dda344e 100644 --- a/src/stores/myPageStore.ts +++ b/src/stores/myPageStore.ts @@ -7,6 +7,7 @@ interface MyPageDataStore { temperature: string; social: string; email: string; + warningCount: number; } interface MyPageStore { @@ -21,6 +22,7 @@ const useMyPageStore = create((set) => ({ temperature: '', social: '', email: '', + warningCount: 0, }, message: '', setMyPageData: (newData) => set({ data: newData }), diff --git a/src/stores/notificationStore.ts b/src/stores/notificationStore.ts new file mode 100644 index 0000000..0a789a5 --- /dev/null +++ b/src/stores/notificationStore.ts @@ -0,0 +1,25 @@ +import { create } from 'zustand'; + +interface NotificationStore { + notReadCount: number; + incrementNotReadCount: () => void; + decrementNotReadCount: () => void; + setNotReadCount: (updateCount: number) => void; +} +const useNotificationStore = create((set) => ({ + notReadCount: 0, + incrementNotReadCount: () => + set((state) => ({ + notReadCount: state.notReadCount + 1, + })), + decrementNotReadCount: () => + set((state) => ({ + notReadCount: state.notReadCount - 1, + })), + setNotReadCount: (updateCount) => + set(() => ({ + notReadCount: updateCount, + })), +})); + +export default useNotificationStore; diff --git a/src/styles/utilities.css b/src/styles/utilities.css index 48b5315..0657ca2 100644 --- a/src/styles/utilities.css +++ b/src/styles/utilities.css @@ -108,4 +108,9 @@ .window-bottom-unChecked { background: linear-gradient(to bottom, #fff4f2, #ffe6e3) !important; } + + /* Menu */ + .submenu-btn { + @apply bg-primary-3 flex h-12 w-12 items-center justify-center rounded-full text-white transition-all duration-300 hover:scale-105 active:scale-90; + } } diff --git a/src/types/admin.d.ts b/src/types/admin.d.ts index e27bef7..4ef77be 100644 --- a/src/types/admin.d.ts +++ b/src/types/admin.d.ts @@ -1,5 +1,5 @@ type Status = 'PENDING' | 'RESOLVED' | 'REJECTED'; -type ReportType = 'LETTER' | 'SHARE_POST' | 'COMMENT'; +type ReportType = 'LETTER' | 'SHARE_POST' | 'EVENT_COMMENT'; type Reason = 'ABUSE' | 'DEFAMATION' | 'HARASSMENT' | 'THREATS' | 'ETC'; interface ReportQueryString { @@ -41,6 +41,8 @@ interface PostReportRequest { reasonType: Reason | ''; reason: string; letterId: number | null; + sharePostId: number | null; + eventCommentId: number | null; } interface PatchReportRequest { diff --git a/src/types/mailbox.d.ts b/src/types/mailbox.d.ts new file mode 100644 index 0000000..c32ee4f --- /dev/null +++ b/src/types/mailbox.d.ts @@ -0,0 +1,17 @@ +interface LetterBoxData { + letterMatchingId: number; + oppositeZipCode: string; + active: boolean; + oppositeRead: boolean; + letterCount: number; + oppositeId: number; +} + +interface LetterBoxItemProps { + boxId: number; + zipCode: string; + letterCount: number; + isChecked?: boolean; + isClosed?: boolean; + oppositeId: number; +} diff --git a/src/types/notifications.d.ts b/src/types/notifications.d.ts index 672864a..6c131a2 100644 --- a/src/types/notifications.d.ts +++ b/src/types/notifications.d.ts @@ -1,6 +1,8 @@ +type AlarmType = 'SENDING' | 'LETTER' | 'REPORT' | 'SHARE' | 'POSTED'; + interface Noti { timelineId: number; - alarmType: string; + alarmType: AlarmType; content: string | number; title: string; read: boolean;