From dca90d689100f2086a8d009d9b4edb0f92223299 Mon Sep 17 00:00:00 2001 From: "Seungyeon Han (Tiffany)" <125551867+tiffanyhansy@users.noreply.github.com> Date: Fri, 7 Mar 2025 09:57:56 +0900 Subject: [PATCH 01/30] =?UTF-8?q?feat:=20=EC=9E=84=EC=8B=9C=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=EB=90=9C=20=ED=8E=B8=EC=A7=80=20=EC=82=AD=EC=A0=9C=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=20=20(#84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 공유 게시글 목록 페이지네이션에서 무한 스크롤로 전환 * refactor: 필요없는 코드 삭제 * feat: 임시저장된 편지 삭제 기능 구현 * design: 임시저장 편지 삭제 아이콘 클릭 시 색상 변경 --- src/apis/draftLetters.ts | 28 +++++++++---- src/pages/Home/components/ShowDraftModal.tsx | 25 +++++++++-- .../Home/components/ShowShareAccessModal.tsx | 41 ++++++++++++------- 3 files changed, 69 insertions(+), 25 deletions(-) diff --git a/src/apis/draftLetters.ts b/src/apis/draftLetters.ts index 424853e..5622592 100644 --- a/src/apis/draftLetters.ts +++ b/src/apis/draftLetters.ts @@ -16,18 +16,30 @@ export interface DraftLetter { matched: boolean; } -export const getDraftLetters = async () // token: string -: Promise => { +export const getDraftLetters = async (): Promise => { try { - const { data } = await client.get('/api/letters?status=draft', { - // headers: { - // Authorization: `Bearer ${token}`, - // }, - }); + const { data } = await client.get('/api/letters?status=draft', {}); console.log('임시저장된 편지 데이터', data); return data.data; } catch (error) { - console.error(`❌임시저장된 편지를 불러오던 중 에러가 발생했습니다`, error); + console.error('❌임시저장된 편지를 불러오던 중 에러가 발생했습니다', error); throw new Error('임시저장된 편지 불러오기 실패'); } }; + +export const deleteDraftLetters = async (letterId: number) => { + try { + const { data } = await client.delete(`/api/letters/${letterId}/temporary-save`); + + if (data.data?.letterId) { + console.log('삭제된 임시저장 편지 ID:', data.data.letterId); + } else { + console.error('❌서버 응답에 letterId가 존재하지 않습니다.'); + } + + return data.data.letterId; + } catch (error) { + console.error('❌임시저장된 편지를 삭제하던 중 에러가 발생했습니다:', error); + throw error; + } +}; diff --git a/src/pages/Home/components/ShowDraftModal.tsx b/src/pages/Home/components/ShowDraftModal.tsx index 3cb9060..3fb7257 100644 --- a/src/pages/Home/components/ShowDraftModal.tsx +++ b/src/pages/Home/components/ShowDraftModal.tsx @@ -2,7 +2,7 @@ import DeleteOutlineRoundedIcon from '@mui/icons-material/DeleteOutlineRounded'; import React, { useEffect, useState } from 'react'; // import { useNavigate } from 'react-router'; -import { DraftLetter, getDraftLetters } from '@/apis/draftLetters'; +import { DraftLetter, getDraftLetters, deleteDraftLetters } from '@/apis/draftLetters'; import ModalBackgroundWrapper from '@/components/ModalBackgroundWrapper'; import ModalOverlay from '@/components/ModalOverlay'; @@ -22,7 +22,7 @@ const ShowDraftModal = ({ onClose }: ShowDraftModalProps) => { // }); // }; - useEffect(() => { + const handleGetDraftLetters = () => { getDraftLetters() .then((data) => { setDraftLetters(data || []); @@ -30,6 +30,21 @@ const ShowDraftModal = ({ onClose }: ShowDraftModalProps) => { .catch((error) => { console.error('❌ 임시저장된 편지를 불러오는데 실패했습니다', error); }); + }; + + const handleDeleteDraftLetters = async (letterId: number) => { + //TODO: 정말로 삭제하시겠습니까? 모달창 + try { + await deleteDraftLetters(letterId); + setDraftLetters((prev) => prev.filter((letter) => letter.letterId !== letterId)); + console.log(`letterId는 `, letterId); + } catch (error) { + console.error(`❌임시저장된 편지를 삭제하던 중 에러가 발생했습니다.`, error); + } + }; + + useEffect(() => { + handleGetDraftLetters(); }, [onClose]); return ( @@ -52,7 +67,11 @@ const ShowDraftModal = ({ onClose }: ShowDraftModalProps) => { // onClick={() => handleNavigation(draft.letterId)} >

{draft.title}

-
+
handleDeleteDraftLetters(draft.letterId)} + >
diff --git a/src/pages/Home/components/ShowShareAccessModal.tsx b/src/pages/Home/components/ShowShareAccessModal.tsx index e1e40df..3c59749 100644 --- a/src/pages/Home/components/ShowShareAccessModal.tsx +++ b/src/pages/Home/components/ShowShareAccessModal.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react'; import { useNavigate } from 'react-router'; import { getSharePostDetail, getSharePostList } from '@/apis/share'; -import { SharePostResponse } from '@/apis/share'; +import { SharePostResponse, SharePost } from '@/apis/share'; import ModalBackgroundWrapper from '@/components/ModalBackgroundWrapper'; import ModalOverlay from '@/components/ModalOverlay'; @@ -14,20 +14,30 @@ interface ShowShareAccessModalProps { const ShowShareAccessModal = ({ onClose }: ShowShareAccessModalProps) => { const navigate = useNavigate(); - const [sharePosts, setSharePosts] = useState(); + const [sharePosts, setSharePosts] = useState([]); + const [currentPage, setCurrentPage] = useState(1); + const [hasMore, setHasMore] = useState(true); + + const fetchPosts = async (page: number) => { + try { + const data: SharePostResponse = await getSharePostList(page, 10); + setSharePosts((prev) => [...prev, ...data.content]); + setHasMore(page < data.totalPages); + } catch (error) { + console.error('❌ 게시글 목록을 불러오는 데 실패했습니다.', error); + } + }; useEffect(() => { - const fetchPosts = async () => { - try { - const data = await getSharePostList(1, 10); - setSharePosts(data); - } catch (error) { - console.error('❌ 게시글 목록을 불러오는 데 실패했습니다.', error); - } - }; + fetchPosts(currentPage); + }, [currentPage]); - fetchPosts(); - }, []); + const handleScroll = (e: React.UIEvent) => { + const { scrollTop, scrollHeight, clientHeight } = e.currentTarget; + if (scrollTop + clientHeight >= scrollHeight - 10 && hasMore) { + setCurrentPage((prev) => prev + 1); + } + }; const handleNavigation = async (sharePostId: number) => { try { @@ -55,8 +65,11 @@ const ShowShareAccessModal = ({ onClose }: ShowShareAccessModalProps) => { 허락 여부를 체크해주세요!

-
- {sharePosts?.content.map((post) => ( +
+ {sharePosts?.map((post) => ( - - - - - - - - - - - - - - - - - - - - - - - - - - -
ID제목쌓인 편지 수상태
1 - 침수 피해를 복구중인 포스코 임직원 분들에게 응원의 메시지를 보내주세요! - 12 - - 진행 중 - -
2 - 침수 피해를 복구중인 포스코 임직원 분들에게 응원의 메시지를 보내주세요! - 12 - - - -
+ {isLoading &&

Loading...

} + {isSuccess && ( + <> + + + + + + + + + + + {data.content.map((rollingPaper) => ( + + ))} + +
ID제목상태
+ {data.content.length === 0 && ( + + 아직 생성된 롤링페이퍼가 없어요 + + )} + + )} + {/* TODO: 페이지네이션 적용 */} ); diff --git a/src/pages/Admin/components/AddRollingPaperModal.tsx b/src/pages/Admin/components/AddRollingPaperModal.tsx index 2df17c3..0e336be 100644 --- a/src/pages/Admin/components/AddRollingPaperModal.tsx +++ b/src/pages/Admin/components/AddRollingPaperModal.tsx @@ -1,5 +1,7 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; import { ChangeEvent, FormEvent, useState } from 'react'; +import { postNewRollingPaper } from '@/apis/rolling'; import ModalOverlay from '@/components/ModalOverlay'; interface AddRollingPaperModalProps { @@ -9,6 +11,21 @@ interface AddRollingPaperModalProps { export default function AddRollingPaperModal({ onClose }: AddRollingPaperModalProps) { const [title, setTitle] = useState(''); const [error, setError] = useState(''); + const queryClient = useQueryClient(); + + const { mutate } = useMutation({ + mutationFn: () => postNewRollingPaper(title), + onSuccess: () => { + setTitle(''); + setError(''); + onClose(); + // TODO: 페이지네이션 적용 후, 현재 page에 대한 캐싱 날리는 방식으로 변경 + queryClient.invalidateQueries({ queryKey: ['admin-rolling-paper'] }); + }, + onError: () => { + setError('편지 작성에 실패했어요. 다시 시도해주세요.'); + }, + }); const handleChange = (e: ChangeEvent) => { setTitle(e.target.value); @@ -21,7 +38,7 @@ export default function AddRollingPaperModal({ onClose }: AddRollingPaperModalPr return; } - console.log(title); + mutate(); }; return ( diff --git a/src/pages/Admin/components/RollingPaperItem.tsx b/src/pages/Admin/components/RollingPaperItem.tsx new file mode 100644 index 0000000..c4e0a18 --- /dev/null +++ b/src/pages/Admin/components/RollingPaperItem.tsx @@ -0,0 +1,76 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { AxiosError } from 'axios'; + +import { deleteRollingPaper, patchRollingPaper } from '@/apis/rolling'; +import { DeleteIcon } from '@/assets/icons'; + +interface RollingPaperItemProps { + information: AdminRollingPaperInformation; +} + +export default function RollingPaperItem({ information }: RollingPaperItemProps) { + const queryClient = useQueryClient(); + + const { mutate: deleteMutate } = useMutation({ + mutationFn: () => deleteRollingPaper(information.eventPostId), + onSuccess: () => { + // TODO: 페이지네이션 적용 후, 현재 page에 대한 캐싱 날리는 방식으로 변경 + queryClient.invalidateQueries({ queryKey: ['admin-rolling-paper'] }); + }, + onError: (err) => { + console.error(err); + }, + }); + + const { mutate: toggleStatus } = useMutation({ + mutationFn: () => patchRollingPaper(information.eventPostId), + onSuccess: () => { + // TODO: 기존 데이터 수정하는 방식으로 ㄱㄱㄱㄱㄱㄱㄱ + // 일단 임시로 캐싱 날리기 + queryClient.invalidateQueries({ queryKey: ['admin-rolling-paper'] }); + }, + onError: (err: AxiosError<{ code: string; message: string }>) => { + if (err.response?.data.code === 'EVENT-004') { + alert(err.response.data.message); + } + console.error(err); + }, + }); + + // TODO: 진짜 삭제하겠냐고 물어보기 + return ( + + {information.eventPostId} + +
+ {information.used && ( + + 진행 중 + + )} + {information.title} +
+ + + + + + {!information.used && ( + + )} + + + ); +} diff --git a/src/pages/RollingPaper/components/CommentDetailModal.tsx b/src/pages/RollingPaper/components/CommentDetailModal.tsx index 4769892..cbabb52 100644 --- a/src/pages/RollingPaper/components/CommentDetailModal.tsx +++ b/src/pages/RollingPaper/components/CommentDetailModal.tsx @@ -20,7 +20,7 @@ const CommentDetailModal = ({ comment, isWriter, onClose, onDelete }: CommentDet
-

{comment.content}

+

{comment.content}

From. {comment.zipCode}

diff --git a/src/pages/RollingPaper/index.tsx b/src/pages/RollingPaper/index.tsx index 6bcc9e6..71b4d70 100644 --- a/src/pages/RollingPaper/index.tsx +++ b/src/pages/RollingPaper/index.tsx @@ -12,19 +12,17 @@ import Header from '@/layouts/Header'; import Comment from './components/Comment'; import CommentDetailModal from './components/CommentDetailModal'; import WriteCommentButton from './components/WriteCommentButton'; - -// TODO: 로그인 구현 완료 시, 더미 완전히 제거 -const DUMMY_USER_ZIP_CODE = '1DR41'; -const DUMMY_MESSAGE_COUNT = 20; +import useAuthStore from '@/stores/authStore'; const RollingPaperPage = () => { const id = useParams().id ?? ''; const [activeComment, setActiveComment] = useState(null); const [activeDetailModal, setActiveDetailModal] = useState(false); const [activeDeleteModal, setActiveDeleteModal] = useState(false); + const zipCode = useAuthStore((props) => props.zipCode); const queryClient = useQueryClient(); - const { data } = useQuery({ + const { data, isSuccess } = useQuery({ queryKey: ['rolling-paper', id], queryFn: () => getRollingPaperDetail(id), }); @@ -37,9 +35,11 @@ const RollingPaperPage = () => { return { ...oldData, - eventPostComments: oldData.eventPostComments.filter( - (comment: RollingPaperComment) => comment.commentId !== data.commentId, - ), + eventPostComments: { + content: oldData.eventPostComments.content.filter( + (comment: RollingPaperComment) => comment.commentId !== data.commentId, + ), + }, }; }); @@ -56,7 +56,7 @@ const RollingPaperPage = () => { {activeDetailModal && activeComment && ( { setActiveDetailModal(false); setActiveComment(null); @@ -85,11 +85,13 @@ const RollingPaperPage = () => {
{data?.title} -

등록된 편지 {DUMMY_MESSAGE_COUNT}

+

+ 등록된 편지 {data ? data.eventPostComments.content.length : 0} +

- {data && - data.eventPostComments.map((comment) => ( + {isSuccess && + data.eventPostComments.content.map((comment) => ( { /> ))} + {isSuccess && data.eventPostComments.content.length === 0 && ( +

+ 아직 등록된 편지가 없어요. +
+ 첫번째로 편지를 남겨볼까요? +

+ )}
diff --git a/src/styles/animations.css b/src/styles/animations.css index 7217bbd..702eca7 100644 --- a/src/styles/animations.css +++ b/src/styles/animations.css @@ -6,7 +6,7 @@ --animate-rotate-show: rotate-show 1s ease-in-out forwards; --animate-blink: showing 0.3s forwards; --animate-login-move-up-down: move-up-down 3s ease-in-out infinite; - --animate-marquee: marquee 10s linear infinite; + --animate-marquee: marquee var(--marquee-duration) linear infinite; @keyframes down { 0% { @@ -70,7 +70,7 @@ /* SpecialLetterBanner 애니메이션 */ @keyframes marquee { 0% { - transform: translateX(10%); + transform: translateX(100%); } 100% { transform: translateX(-100%); diff --git a/src/styles/preflight.css b/src/styles/preflight.css index c74f27a..c23d658 100644 --- a/src/styles/preflight.css +++ b/src/styles/preflight.css @@ -4,6 +4,7 @@ :root { --vh: 1vh; --vw: 1vw; + --marquee-duration: 10s; } * { diff --git a/src/types/rolling.d.ts b/src/types/rolling.d.ts index af75fe6..d72c810 100644 --- a/src/types/rolling.d.ts +++ b/src/types/rolling.d.ts @@ -3,12 +3,26 @@ interface RollingPaperInformation { title: string; } +interface AdminRollingPaperInformation extends RollingPaperInformation { + used: boolean; +} + interface RollingPaperComment { commentId: number; zipCode: string; content: string; } +interface PaginationData { + content: T[]; + currentPage: number; + size: number; + totalElements: number; + totalPages: number; +} + +interface RollingPaperList extends PaginationData {} + interface RollingPaper extends RollingPaperInformation { - eventPostComments: RollingPaperComment[]; + eventPostComments: PaginationData; } From aeab0e658e975538daa519f310e64211743bf445 Mon Sep 17 00:00:00 2001 From: wldnjs990 <139528356+wldnjs990@users.noreply.github.com> Date: Fri, 7 Mar 2025 11:48:00 +0900 Subject: [PATCH 03/30] =?UTF-8?q?feat=20:=20=EC=95=8C=EB=A6=BC=202?= =?UTF-8?q?=EC=B0=A8=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=20(#81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat:알림 컨텐츠 상호작용, 라우팅 기능 구현 * feat : SSE구현시도 * feat : SSE전역변수로 관리할지 store 하나 만들어서 만들면서 고민중 * feat : 알림구독 테스트용 App.tsx에 훅 호출한 코드 --- package.json | 2 + pnpm-lock.yaml | 16 ++++ src/App.tsx | 2 + src/apis/notification.ts | 34 +++++++ src/hooks/useServerSentEvents.tsx | 65 +++++++++++++ .../components/NotificationItem.tsx | 12 +-- .../Notifications/components/WarningModal.tsx | 7 +- src/pages/Notifications/constants/index.ts | 7 +- src/pages/Notifications/index.tsx | 95 ++++++++++++++----- src/stores/sseStore.ts | 55 +++++++++++ src/types/notifications.d.ts | 7 ++ 11 files changed, 266 insertions(+), 36 deletions(-) create mode 100644 src/apis/notification.ts create mode 100644 src/hooks/useServerSentEvents.tsx create mode 100644 src/stores/sseStore.ts create mode 100644 src/types/notifications.d.ts diff --git a/package.json b/package.json index 9514795..b25cece 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "@tailwindcss/vite": "^4.0.6", "@tanstack/react-query": "^5.66.0", "axios": "^1.7.9", + "event-source-polyfill": "^1.0.31", "gsap": "^3.12.7", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -31,6 +32,7 @@ "devDependencies": { "@eslint/js": "^9.19.0", "@tanstack/eslint-plugin-query": "^5.66.1", + "@types/event-source-polyfill": "^1.0.5", "@types/react": "^19.0.8", "@types/react-dom": "^19.0.3", "@vitejs/plugin-react-swc": "^3.5.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dac4254..b22f4db 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: axios: specifier: ^1.7.9 version: 1.7.9 + event-source-polyfill: + specifier: ^1.0.31 + version: 1.0.31 gsap: specifier: ^3.12.7 version: 3.12.7 @@ -66,6 +69,9 @@ importers: '@tanstack/eslint-plugin-query': specifier: ^5.66.1 version: 5.66.1(eslint@9.20.1(jiti@2.4.2))(typescript@5.7.3) + '@types/event-source-polyfill': + specifier: ^1.0.5 + version: 1.0.5 '@types/react': specifier: ^19.0.8 version: 19.0.8 @@ -948,6 +954,9 @@ packages: '@types/estree@1.0.6': resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==} + '@types/event-source-polyfill@1.0.5': + resolution: {integrity: sha512-iaiDuDI2aIFft7XkcwMzDWLqo7LVDixd2sR6B4wxJut9xcp/Ev9bO4EFg4rm6S9QxATLBj5OPxdeocgmhjwKaw==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -1394,6 +1403,9 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + event-source-polyfill@1.0.31: + resolution: {integrity: sha512-4IJSItgS/41IxN5UVAVuAyczwZF7ZIEsM1XAoUzIHA6A+xzusEZUutdXz2Nr+MQPLxfTiCvqE79/C8HT8fKFvA==} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -3137,6 +3149,8 @@ snapshots: '@types/estree@1.0.6': {} + '@types/event-source-polyfill@1.0.5': {} + '@types/json-schema@7.0.15': {} '@types/json5@0.0.29': {} @@ -3741,6 +3755,8 @@ snapshots: esutils@2.0.3: {} + event-source-polyfill@1.0.31: {} + fast-deep-equal@3.1.3: {} fast-glob@3.3.3: diff --git a/src/App.tsx b/src/App.tsx index 6e334f5..4608fbc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,6 @@ import { Route, Routes } from 'react-router'; +import { useServerSentEvents } from './hooks/useServerSentEvents'; import useViewport from './hooks/useViewport'; import Layout from './layouts/Layout'; import MobileLayout from './layouts/MobileLayout'; @@ -29,6 +30,7 @@ import WritePage from './pages/Write'; const App = () => { useViewport(); + useServerSentEvents(); return ( diff --git a/src/apis/notification.ts b/src/apis/notification.ts new file mode 100644 index 0000000..869df0a --- /dev/null +++ b/src/apis/notification.ts @@ -0,0 +1,34 @@ +import client from './client'; + +const getTimeLines = async () => { + try { + const res = await client.get('/api/timelines'); + if (!res) throw new Error('타임라인을 받아오는 도중 오류가 발생했습니다.'); + console.log(res); + return res; + } catch (error) { + console.error(error); + } +}; + +const patchReadNotification = async (timelineId: number) => { + try { + const res = await client.patch(`/api/notifications/${timelineId}/read`); + if (!res) throw new Error('편지 개별 읽음 처리를 하는 도중 오류가 발생했습니다.'); + return res; + } catch (error) { + console.error(error); + } +}; + +const patchReadNotificationAll = async () => { + try { + const res = await client.patch(`/api/notifications/read`); + if (!res) throw new Error('편지 개별 읽음 처리를 하는 도중 오류가 발생했습니다.'); + return res; + } catch (error) { + console.error(error); + } +}; + +export { getTimeLines, patchReadNotification, patchReadNotificationAll }; diff --git a/src/hooks/useServerSentEvents.tsx b/src/hooks/useServerSentEvents.tsx new file mode 100644 index 0000000..2db424f --- /dev/null +++ b/src/hooks/useServerSentEvents.tsx @@ -0,0 +1,65 @@ +import { EventSourcePolyfill } from 'event-source-polyfill'; +import { useEffect, useRef } from 'react'; + +import useAuthStore from '@/stores/authStore'; + +export const useServerSentEvents = () => { + const accessToken = useAuthStore.getState().accessToken; + const sourceRef = useRef(null); + + useEffect(() => { + if (!accessToken) { + console.log('로그인 정보 확인불가'); + return; + } + + const connectSSE = () => { + try { + console.log('구독 시작'); + sourceRef.current = new EventSourcePolyfill( + `${import.meta.env.VITE_API_URL}/api/notifications/sub`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + ); + + sourceRef.current.onmessage = (event) => { + console.log(event); + console.log('알림 전송'); + }; + + sourceRef.current.addEventListener('notification', (event) => { + console.log(event); + console.log('알림 전송 dd'); + }); + + sourceRef.current.onerror = (error) => { + console.log(error); + console.log('에러 발생함'); + sourceRef.current?.close(); + // 재연결 로직 추가 가능 + setTimeout(connectSSE, 5000); // 5초 후 재연결 시도 + }; + } catch (error) { + console.error(error); + } + }; + + connectSSE(); + + return () => { + console.log('컴포넌트 언마운트로 인한 구독해제'); + closeSSE(); + }; + }, [accessToken]); + + // 바깥으로 보낼 closeSSE 함수 + const closeSSE = () => { + sourceRef.current?.close(); + sourceRef.current = null; + }; + + return { closeSSE }; +}; diff --git a/src/pages/Notifications/components/NotificationItem.tsx b/src/pages/Notifications/components/NotificationItem.tsx index ff9428e..52f0db5 100644 --- a/src/pages/Notifications/components/NotificationItem.tsx +++ b/src/pages/Notifications/components/NotificationItem.tsx @@ -4,12 +4,12 @@ import { NOTIFICATION_ICON } from '../constants'; interface NotificationItemProps { type: string; - message: string; - isRead: boolean; + title: string; + read: boolean; onClick: () => void; } -const NotificationItem = ({ type, message, isRead, onClick }: NotificationItemProps) => { +const NotificationItem = ({ type, title, read, onClick }: NotificationItemProps) => { const Icon = NOTIFICATION_ICON[type]; const handleClick = (e: React.MouseEvent) => { @@ -18,11 +18,11 @@ const NotificationItem = ({ type, message, isRead, onClick }: NotificationItemPr }; return ( - +
- {isRead &&
} + {read &&
} -

{message}

+

{title}

); diff --git a/src/pages/Notifications/components/WarningModal.tsx b/src/pages/Notifications/components/WarningModal.tsx index 8e7e922..e76b17d 100644 --- a/src/pages/Notifications/components/WarningModal.tsx +++ b/src/pages/Notifications/components/WarningModal.tsx @@ -4,10 +4,11 @@ import ModalOverlay from '@/components/ModalOverlay'; interface WarningModalProps { isOpen: boolean; + adminText: string; onClose: () => void; } -const WarningModal = ({ isOpen, onClose }: WarningModalProps) => { +const WarningModal = ({ isOpen, adminText, onClose }: WarningModalProps) => { if (!isOpen) return null; return ( @@ -20,11 +21,15 @@ const WarningModal = ({ isOpen, onClose }: WarningModalProps) => { >
+

관리자 코멘트

+

{adminText}

+

경고 안내

따사로운 서비스 이용을 위해, 부적절하다고 판단되는 편지는 반려하고 있어요. 서로를 존중하는 따뜻한 공간을 만들기 위해 협조 부탁드립니다.

+

경고 규칙

1회 경고: 주의 안내 diff --git a/src/pages/Notifications/constants/index.ts b/src/pages/Notifications/constants/index.ts index 1134831..0b09832 100644 --- a/src/pages/Notifications/constants/index.ts +++ b/src/pages/Notifications/constants/index.ts @@ -4,7 +4,8 @@ export const NOTIFICATION_ICON: Record< string, React.ComponentType> > = { - letter: EnvelopeIcon, - warning: SirenFilledIcon, - board: BoardIcon, + LETTER: EnvelopeIcon, + REPORT: SirenFilledIcon, + SHARE: BoardIcon, + POSTED: BoardIcon, }; diff --git a/src/pages/Notifications/index.tsx b/src/pages/Notifications/index.tsx index bef3d9c..d2bc5de 100644 --- a/src/pages/Notifications/index.tsx +++ b/src/pages/Notifications/index.tsx @@ -1,50 +1,93 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router'; +import { getTimeLines, patchReadNotification, patchReadNotificationAll } from '@/apis/notification'; import PageTitle from '@/components/PageTitle'; import NotificationItem from './components/NotificationItem'; import WarningModal from './components/WarningModal'; -const DUMMY_NOTI = [ - { id: 1, type: 'letter', message: '12E31님이 편지를 보냈습니다.', isRead: false }, - { id: 2, type: 'warning', message: '따숨님, 욕설로 인해 경고를 받으셨어요.', isRead: false }, - { id: 3, type: 'letter', message: '12E31님이 편지를 보냈습니다.', isRead: false }, - { id: 4, type: 'letter', message: '12E31님이 편지를 보냈습니다.', isRead: true }, - { id: 5, type: 'letter', message: '12E31님이 편지를 보냈습니다.', isRead: false }, - { id: 6, type: 'board', message: '12E31님과의 대화가 게시판에 공유되었어요.', isRead: false }, - { - id: 7, - type: 'board', - message: '12E31님과의 게시글에 대한 공유요청을 보냈어요.', - isRead: false, - }, -]; - const NotificationsPage = () => { + const navigate = useNavigate(); + + const [noti, setNoti] = useState([]); + const [isOpenWarningModal, setIsOpenWarningModal] = useState(false); - const handleClickItem = (type: string) => { - if (type === 'warning') { + const [adminText, setAdmintext] = useState(''); + + // MEMO : 편지 데이터 전송중 데이터도 추가될건데 나중에 데이터 추가되면 코드 업데이트 하긔 + const handleClickItem = (alarmType: string, content?: string | number) => { + if (alarmType === 'LETTER') { + navigate(`/letter/${content}`); + } + if (alarmType === 'REPORT') { setIsOpenWarningModal(true); + if (typeof content === 'string') setAdmintext(content); + } + if (alarmType === 'SHARE') { + navigate(`/board/letter/${content}`, { state: { isShareLetterPreview: true } }); + } + if (alarmType === 'POSTED') { + navigate(`/board/letter/${content}`); } }; + const handleGetTimeLines = async () => { + const res = await getTimeLines(); + if (res?.status === 200) { + console.log(res); + setNoti(res.data.data.content); + } + }; + + const handlePatchReadNotification = async (timelineId: number) => { + const res = await patchReadNotification(timelineId); + if (res?.status !== 200) { + console.log('읽음처리 에러 발생'); + } + }; + + const handlePatchReadNotificationAll = async () => { + const res = await patchReadNotificationAll(); + if (res?.status !== 200) { + console.log('모두 읽음처리 에러 발생'); + } + }; + + useEffect(() => { + handleGetTimeLines(); + }, []); + return ( <> - setIsOpenWarningModal(false)} /> + setIsOpenWarningModal(false)} + />

알림 -
    - {DUMMY_NOTI.map((notification) => ( -
  • + {noti.map((notification) => ( +
  • handleClickItem(notification.type)} + type={notification.alarmType} + title={notification.title} + read={notification.read} + onClick={() => { + handleClickItem(notification.alarmType, notification.content); + handlePatchReadNotification(notification.timelineId); + }} />
  • ))} diff --git a/src/stores/sseStore.ts b/src/stores/sseStore.ts new file mode 100644 index 0000000..3d52593 --- /dev/null +++ b/src/stores/sseStore.ts @@ -0,0 +1,55 @@ +import { EventSourcePolyfill } from 'event-source-polyfill'; +import { create } from 'zustand'; + +import useAuthStore from '@/stores/authStore'; // 액세스 토큰을 가져올 Zustand 스토어 + +interface SSEState { + messages: string[]; + connectSSE: () => void; + closeSSE: () => void; +} + +export const useSSEStore = create((set, get) => { + let source: EventSourcePolyfill | null = null; // SSE 인스턴스 저장 + + return { + messages: [], + + connectSSE: () => { + const accessToken = useAuthStore.getState().accessToken; // authStore에서 변수 가져오기 + if (!accessToken) { + console.log('엑세스 토큰이 존재하지 않습니다. 구독 불가'); + return; + } + + console.log('🟢 SSE 구독 시작'); + + // 기존 SSE 연결 종료 + get().closeSSE(); + + // 새로운 SSE 연결 생성 + source = new EventSourcePolyfill(`${import.meta.env.VITE_API_URL}/api/notifications/sub`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + + source.onmessage = (event) => { + console.log('SSE 메시지 수신:', event.data); + set((state) => ({ messages: [...state.messages, event.data] })); // 메시지 전역 저장 + }; + + source.onerror = (error) => { + console.log('SSE 오류 발생:', error); + get().closeSSE(); + setTimeout(() => get().connectSSE(), 5000); // 5초 후 재연결 + }; + }, + + // 🔥 SSE 종료 함수 (로그아웃 시 실행) + closeSSE: () => { + console.log('SSE 수동 종료'); + source?.close(); + source = null; + set({ messages: [] }); // 상태 초기화 + }, + }; +}); diff --git a/src/types/notifications.d.ts b/src/types/notifications.d.ts new file mode 100644 index 0000000..672864a --- /dev/null +++ b/src/types/notifications.d.ts @@ -0,0 +1,7 @@ +interface Noti { + timelineId: number; + alarmType: string; + content: string | number; + title: string; + read: boolean; +} From ae17500e0d88a9abff742863bdf6fff4ae5fec3c Mon Sep 17 00:00:00 2001 From: Sebin Kim <108220388+nirii00@users.noreply.github.com> Date: Fri, 7 Mar 2025 12:36:24 +0900 Subject: [PATCH 04/30] =?UTF-8?q?fix:=20=EC=9E=90=EC=9E=98=ED=95=9C=20?= =?UTF-8?q?=EC=9D=B4=EC=8A=88=20=EC=88=98=EC=A0=95=20(#86)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 401 에러가 아닌 경우 바로 로그아웃 되는 문제 해결 * fix: 토큰 만료로 reissue 실패시 로그아웃 안되는 문제 해결 * fix: reissue 에러 시 에러 처리 안되는 문제 해결 - client.ts의 interceptors.response.use 구문 내에서 api 호출및 데이터 가공을 함수 밖으로 꺼냄 * fix: mailbox에서 isClosed 옵션 반대로 보여주는 문제 해결 - isClosed 상태를 반대로 받아와서 상태를 잘못 보여주는 문제 해결 * fix: mailBox 배포 api에 따른 수정 작업 - sharePost 요청 요청자 id 삭제 - 상세 페이지 api 경로 수정 - 우편함 상세체이지 날짜 잘못표기하는 에러 수정 * fix: reissue시 access token을 사용하지 않도록 수정 * feat: 신고 모달 파라미터 변경으로 인한, 파라미터 추가(게시판 상세) * fix: 마이페이지 api 수정 * feat: 임시저장된 편지 삭제 기능 구현 (#84) * fix: 공유 게시글 목록 페이지네이션에서 무한 스크롤로 전환 * refactor: 필요없는 코드 삭제 * feat: 임시저장된 편지 삭제 기능 구현 * design: 임시저장 편지 삭제 아이콘 클릭 시 색상 변경 * feat: 롤링페이퍼 배포된 api로 연결 수정 (#85) * style:롤링페이퍼 공지 애니메이션 수정 * fix: 롤링페이퍼가 진행되지 않을 때 화면에 보여지지 않도록 처리 * feat: 새로운 롤링페이퍼 생성 api 연동 * feat: 롤링페이퍼 목록 조회 api 연동 * feat: 롤링페이퍼 삭제 api 연동 * feat: 롤링페이퍼 사용여부 변경 api 연동 * feat: 롤링페이퍼 코멘트 목록 조회 api 연동 * feat: 롤링페이퍼 등록 및 삭제 api 연동 - api 응답 구조 수정 * fix: type 에러 수정 * fix: 데이터가 없는 경우 컴포넌트 에러가 나는 부분 수정 - 배열이 없거나, 길이가 0이면 placeholder를 보여줌 * Update index.tsx 이상한 import 지움 --------- Co-authored-by: nirii00 Co-authored-by: Seungyeon Han (Tiffany) <125551867+tiffanyhansy@users.noreply.github.com> Co-authored-by: Minha Ahn --- src/apis/client.ts | 4 +- src/apis/myPage.ts | 2 +- src/apis/share.ts | 6 +- .../Admin/components/ReportHandlingModal.tsx | 2 +- src/pages/LetterBoard/index.tsx | 6 +- src/pages/LetterBoardDetail/index.tsx | 84 +++++++++++-------- src/pages/MyPage/components/MyBoardPage.tsx | 19 +++-- src/stores/myPageStore.ts | 2 +- 8 files changed, 71 insertions(+), 54 deletions(-) diff --git a/src/apis/client.ts b/src/apis/client.ts index df8b3b3..092d56f 100644 --- a/src/apis/client.ts +++ b/src/apis/client.ts @@ -45,10 +45,8 @@ let retry = false; client.interceptors.request.use( (config) => { - console.log('response again', config); - const accessToken = useAuthStore.getState().accessToken; - if (config.url !== '/auth/reissue' && accessToken) { + if (config.url !== '/api/reissue' && accessToken) { config.headers.Authorization = `Bearer ${accessToken}`; } return config; diff --git a/src/apis/myPage.ts b/src/apis/myPage.ts index 7d64cc9..638ebc1 100644 --- a/src/apis/myPage.ts +++ b/src/apis/myPage.ts @@ -12,7 +12,7 @@ export const fetchMyPageInfo = async () => { export const getMySharePostList = async () => { try { - const response = await client.get('/api/share-proposals/inbox'); + const response = await client.get('/api/share-posts/me'); if (!response) throw new Error('error while fetching my share post list'); return response.data; } catch (error) { diff --git a/src/apis/share.ts b/src/apis/share.ts index 767b97e..6b60bbf 100644 --- a/src/apis/share.ts +++ b/src/apis/share.ts @@ -53,7 +53,7 @@ export const getSharePostList = async (page: number = 1, size: number = 10) => { }; // 공유 게시글 상세 조회 -export const getSharePostDetail = async (sharePostId: number): Promise => { +export const getSharePostDetail = async (sharePostId: string): Promise => { try { const response = await client.get(`/api/share-posts/${sharePostId}`); console.log(`🔥공유 게시글 상세 데이터`, response.data); @@ -102,7 +102,7 @@ export const postShareProposalApproval = async ( }; // 편지 좋아요 추가, 취소 -export const postSharePostLike = async (sharePostId: number) => { +export const postSharePostLike = async (sharePostId: string) => { try { const response = await client.post(`/api/share-posts/${sharePostId}/likes`); if (!response) throw new Error('error while posting like'); @@ -114,7 +114,7 @@ export const postSharePostLike = async (sharePostId: number) => { }; // 편지 좋아요 갯수 -export const getSharePostLikeCount = async (sharePostId: number) => { +export const getSharePostLikeCount = async (sharePostId: string) => { try { const response = await client.get(`/api/share-posts/${sharePostId}/likes`); if (!response) throw new Error('error while fetching likes'); diff --git a/src/pages/Admin/components/ReportHandlingModal.tsx b/src/pages/Admin/components/ReportHandlingModal.tsx index bfe6562..b2c9f8b 100644 --- a/src/pages/Admin/components/ReportHandlingModal.tsx +++ b/src/pages/Admin/components/ReportHandlingModal.tsx @@ -21,7 +21,7 @@ export default function ReportHandlingModal({ ); }; - const [reportRequest, setReportRequest] = useState({ + const [reportRequest, setReportRequest] = useState({ status: 'RESOLVED', adminMemo: '', }); diff --git a/src/pages/LetterBoard/index.tsx b/src/pages/LetterBoard/index.tsx index 80d26a7..33a53d5 100644 --- a/src/pages/LetterBoard/index.tsx +++ b/src/pages/LetterBoard/index.tsx @@ -66,9 +66,9 @@ const LetterBoardPage = () => { {isLoading ? (

    loading

    - ) : ( + ) : postLists && postLists?.length > 0 ? (
    - {postLists.map((item, index) => { + {postLists?.map((item, index) => { return ( { ); })}
    + ) : ( +

    게시글이 없습니다.

    )}
diff --git a/src/pages/LetterBoardDetail/index.tsx b/src/pages/LetterBoardDetail/index.tsx index 3f8292d..f106647 100644 --- a/src/pages/LetterBoardDetail/index.tsx +++ b/src/pages/LetterBoardDetail/index.tsx @@ -25,69 +25,85 @@ const LetterBoardDetailPage = ({ confirmDisabled }: ShareLetterPreviewProps) => const [isLike, setIsLike] = useState(false); const isWriter = false; const [activeReportModal, setActiveReportModal] = useState(false); + const sharePostId: string = location.pathname.split('/')[3]; + // const location = useLocation(); + const navigate = useNavigate(); + // const isShareLetterPreview = location.state?.isShareLetterPreview || false; + const isShareLetterPreview = false; + const [postDetail, setPostDetail] = useState(); + + const postLike = async () => { + try { + const response = await postSharePostLike(sharePostId); + if (!response) throw new Error('error while fetching like count'); + console.log('✅ 편지 좋아요 추가됨:', response); + } catch (error) { + console.error('❌ 편지 좋아요 추가 중 에러가 발생했습니다', error); + throw new Error('편지 좋아요 추가 실패'); + } + }; const handleToggleLike = () => { setLikeCount((prev) => prev + (isLike ? -1 : 1)); setIsLike((prev) => !prev); + postLike(); }; - const location = useLocation(); - const navigate = useNavigate(); + const handleProposalApproval = async ( + action: 'approve' | 'reject', + shareProposalId: number = location.state?.postDetail?.sharePostId, + ) => { + try { + const result = await postShareProposalApproval(shareProposalId, action); + console.log(`✅ 편지 공유 ${action === 'approve' ? '수락' : '거절'}됨:`, result); - const isShareLetterPreview = location.state?.isShareLetterPreview || false; - const [postDetail, setPostDetail] = useState(); + navigate('/'); + } catch (error) { + console.error(error); + } + }; useEffect(() => { - const { sharePostId } = location.state.postDetail; - const fetchPostDetail = async (postId: number) => { + const fetchPostDetail = async (postId: string) => { try { - console.log('sharePostId:', postId); - const data = await getSharePostDetail(postId); - setPostDetail(data); } catch (error) { console.error('❌ 공유 게시글 상세 조회에 실패했습니다.', error); } }; - const fetchLikeCounts = async (postId: number) => { + const fetchLikeCounts = async (postId: string) => { try { const response = await getSharePostLikeCount(postId); if (!response) throw new Error('error while fetching like count'); - console.log(response); - setLikeCount(response.data.likeCount); + console.log('✅ 편지 좋아요 갯수:', response); + setLikeCount(response.likeCount); + setIsLike(response.liked); } catch (error) { console.error('❌ 편지 좋아요 갯수를 가져오는 중 에러가 발생했습니다', error); throw new Error('편지 좋아요 갯수 가져오기 실패'); } }; - if (location.state?.postDetail) { - fetchPostDetail(sharePostId); - fetchLikeCounts(sharePostId); - } else { - console.warn('postDetail not found in location.state'); - } - }, [location.state]); - - const handleProposalApproval = async ( - action: 'approve' | 'reject', - shareProposalId: number = location.state?.postDetail?.sharePostId, - ) => { - try { - const result = await postShareProposalApproval(shareProposalId, action); - console.log(`✅ 편지 공유 ${action === 'approve' ? '수락' : '거절'}됨:`, result); - - navigate('/'); - } catch (error) { - console.error(error); - } - }; + // if (location.state?.postDetail) { + fetchPostDetail(sharePostId); + fetchLikeCounts(sharePostId); + // } else { + // console.warn('postDetail not found in location.state'); + // } + // }, [location.state]); + }, []); return ( <> - {activeReportModal && setActiveReportModal(false)} />} + {activeReportModal && ( + setActiveReportModal(false)} + /> + )}
{ try { const response = await getMySharePostList(); if (!response) throw new Error('게시글 목록을 불러오는데 실패했습니다.'); - console.log(response); - return response.data; + console.log('myPostList', response); + return response.data as SharePost[]; } catch (e) { console.error(e); } @@ -43,19 +43,20 @@ const MyBoardPage = () => { 내가 올린 게시물 {isLoading ? (

loading

- ) : ( + ) : postLists && postLists?.length > 0 ? (
- {postLists.map((item, index) => ( + {postLists?.map((item, index) => ( ))}
+ ) : ( +

게시글이 없습니다.

)} diff --git a/src/stores/myPageStore.ts b/src/stores/myPageStore.ts index 61b34ac..59730d0 100644 --- a/src/stores/myPageStore.ts +++ b/src/stores/myPageStore.ts @@ -1,6 +1,6 @@ import { create } from 'zustand'; -import { fetchMyPageInfo } from '@/apis/mypage'; +import { fetchMyPageInfo } from '@/apis/myPage'; interface MyPageDataStore { zipCode: string; From 62d67cd5234fe5fe6e6ed17a6b36485ad0f838c9 Mon Sep 17 00:00:00 2001 From: wldnjs990 <139528356+wldnjs990@users.noreply.github.com> Date: Fri, 7 Mar 2025 22:29:31 +0900 Subject: [PATCH 05/30] =?UTF-8?q?feat=20:=20=EC=9E=AC=EC=82=AC=EC=9A=A9=20?= =?UTF-8?q?=EA=B0=80=EB=8A=A5=ED=95=9C=20=ED=8E=98=EC=9D=B4=EC=A7=80?= =?UTF-8?q?=EB=84=A4=EC=9D=B4=EC=85=98=20=EA=B5=AC=ED=98=84=20(#92)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/Admin/Report.tsx | 58 ++++-------- .../Admin/components/PagenationNavigation.tsx | 93 +++++++++++++++++++ 2 files changed, 111 insertions(+), 40 deletions(-) create mode 100644 src/pages/Admin/components/PagenationNavigation.tsx diff --git a/src/pages/Admin/Report.tsx b/src/pages/Admin/Report.tsx index 0838c4c..dc52d8c 100644 --- a/src/pages/Admin/Report.tsx +++ b/src/pages/Admin/Report.tsx @@ -5,6 +5,7 @@ import { AlarmIcon } from '@/assets/icons'; import AdminPageTitle from './components/AdminPageTitle'; import ListHeaderFrame from './components/ListHeaderFrame'; +import PagenationNavigation from './components/PagenationNavigation'; import ReportDetailModal from './components/ReportDetailModal'; import ReportHandlingModal from './components/ReportHandlingModal'; import ReportListItem from './components/ReportListItem'; @@ -19,28 +20,33 @@ export default function ReportManage() { currentPage: '1', totalPages: '0', }); + const [selectedReport, setSelectReport] = useState(null); const [selectedReportId, setSelectedReportId] = useState(null); - // const [allReports, setAllReports] = useState(); - const [reportQueryString, setReportQueryString] = useState({ reportType: null, status: 'PENDING', page: '1', - size: '3', + size: '2', }); + const handleGetReports = async (reportQueryString: ReportQueryString) => { const res = await getReports(reportQueryString); if (res?.status === 200) { - console.log(res.data.data.content); - setReports(res.data.data.content); + const data = res.data.data; + setReports(data.content); setReportPages(() => ({ - currentPage: res.data.data.currentPage, - totalPages: res.data.data.totalPages, + currentPage: data.currentPage, + totalPages: data.totalPages, })); } }; + + const handleNowPage = (page: string) => { + setReportQueryString((cur) => ({ ...cur, page: page })); + }; + useEffect(() => { handleGetReports(reportQueryString); }, [reportQueryString]); @@ -68,39 +74,11 @@ export default function ReportManage() { setSelectReport={setSelectReport} /> ))} -
-
- - - {reportPages.currentPage}/{reportPages.totalPages} - - -
-
+ {detailModalOpen && ( diff --git a/src/pages/Admin/components/PagenationNavigation.tsx b/src/pages/Admin/components/PagenationNavigation.tsx new file mode 100644 index 0000000..350ffdd --- /dev/null +++ b/src/pages/Admin/components/PagenationNavigation.tsx @@ -0,0 +1,93 @@ +import { useState } from 'react'; +import { twMerge } from 'tailwind-merge'; + +interface PagenationNavigation { + totalPage: number; + buttonLength: number; + handlePageNumberButtonClick: (page: string) => void; +} +export default function PagenationNavigation({ + totalPage, + buttonLength, + handlePageNumberButtonClick, +}: PagenationNavigation) { + const totalSection = Math.ceil(totalPage / buttonLength) - 1; + const [nowSection, setNowSection] = useState(0); + const [nowPageNumberAt, setNowPageNumberAt] = useState(1); + + // 네비게이션 시작점, 끝점 + const navigationRange = { + start: nowSection * buttonLength + 1, + end: nowSection * buttonLength + buttonLength, + }; + + // 페이지 버튼 배열 + const pageNumberButtonArray = Array.from( + { length: navigationRange.end - navigationRange.start + 1 }, + (_, index) => navigationRange.start + index, + ); + + // 페이지 버튼 클릭시 해당 번호값이 파라미터에 담김 + const handlePageButtonClick = (page: number) => { + const pageString = page.toString(); + handlePageNumberButtonClick(pageString); + setNowPageNumberAt(page); + }; + + const handlePrevButtonClick = () => { + if (nowSection > 0) { + const prev = (nowSection - 1) * buttonLength + buttonLength; + setNowSection((cur) => cur - 1); + handlePageButtonClick(prev); + } + }; + + const handleNextButtonClick = () => { + if (nowSection < totalSection) { + const next = (nowSection + 1) * buttonLength + 1; + setNowSection((cur) => cur + 1); + handlePageButtonClick(next); + } + }; + + const buttonStyle = 'border bg-white px-2 py-1 disabled:bg-gray-20'; + + return ( +
+
+ + {pageNumberButtonArray.map((num) => { + if (totalPage < num) return null; + return ( + + ); + })} + +
+
+ ); +} From 266d0e5483ab5c5a3e49a3d21b3f0510ceec350e Mon Sep 17 00:00:00 2001 From: "Seungyeon Han (Tiffany)" <125551867+tiffanyhansy@users.noreply.github.com> Date: Fri, 7 Mar 2025 23:10:23 +0900 Subject: [PATCH 06/30] =?UTF-8?q?feat:=20=ED=8E=B8=EC=A7=80=20=EA=B3=B5?= =?UTF-8?q?=EC=9C=A0=20=EC=9A=94=EC=B2=AD=20=EC=88=98=EC=8B=A0=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=20(#90)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 편지 공유 요청 수신 조회 기능 구현 * refactor: incomingLetters.ts 코드 리팩토링 * refactor: incomingLettersStore.ts에서 필요 없는 필드 정리 * feat: 오고 있는 편지 도착까지 걸리는 시간 카운트다운 기능 구현 * design: 오고 있는 편지 모달에서 데이터가 없을 때 대체 텍스트 추가 * design: 임시저장된 편지 모달에서 데이터가 없을 때 대체 텍스트 추가 * design: 편지 공유 요청 수신 조회 모달에서 데이터가 없을 때 대체 텍스트 추가 --- src/apis/incomingLetters.ts | 10 +-- src/apis/share.ts | 24 ++++++- src/pages/Home/components/ShowDraftModal.tsx | 30 ++++---- .../components/ShowIncomingLettersModal.tsx | 22 +++--- .../Home/components/ShowShareAccessModal.tsx | 70 ++++++++----------- src/stores/incomingLettersStore.ts | 32 +++++---- 6 files changed, 105 insertions(+), 83 deletions(-) diff --git a/src/apis/incomingLetters.ts b/src/apis/incomingLetters.ts index adde539..66a865e 100644 --- a/src/apis/incomingLetters.ts +++ b/src/apis/incomingLetters.ts @@ -1,13 +1,9 @@ import client from './client'; -export const getIncomingLetters = async (token: string) => { +export const getIncomingLetters = async () => { try { - const { data } = await client.get('/api/letters?status=delivery', { - headers: { - Authorization: `Bearer ${token}`, - }, - }); - console.log('불러온 데이터', data); + const { data } = await client.get('/api/letters?status=delivery'); + console.log('오고있는 편지 데이터', data); return data; } catch (error) { console.error('❌오고 있는 편지 목록을 불러오던 중 에러 발생', error); diff --git a/src/apis/share.ts b/src/apis/share.ts index 6b60bbf..15c97d2 100644 --- a/src/apis/share.ts +++ b/src/apis/share.ts @@ -21,7 +21,7 @@ export interface SharePost { letters: ShareLetter[]; } -// 페이징 포함 +// 공유 게시글 목록 조회 - 페이징 포함 export interface SharePostResponse { content: SharePost[]; currentPage: number; @@ -30,6 +30,15 @@ export interface SharePostResponse { totalPages: number; } +// 편지 공유 요청 수신 조회 +export interface ShareProposal { + shareProposalId: number; + requesterZipCode: string; + recipientZipCode: string; + message: string; + status: 'REJECTED' | 'APPROVED' | 'PENDING'; +} + // 편지 공유 수락 / 거절 export interface SharePostApproval { shareProposalId: number; @@ -84,6 +93,19 @@ export const postShareProposals = async ( } }; +// 편지 공유 요청 수신 조회 +export const getShareProposalList = async () => { + try { + const response = await client.get('/api/share-proposals/inbox'); + console.log(`🌟공유 요청 목록`, response.data); + + return response.data.data; + } catch (error) { + console.error('❌ 편지 공유 요청을 조회하던 중 에러가 발생했습니다', error); + throw error; + } +}; + // 편지 공유 수락 / 거절 export const postShareProposalApproval = async ( shareProposalId: number, diff --git a/src/pages/Home/components/ShowDraftModal.tsx b/src/pages/Home/components/ShowDraftModal.tsx index 3fb7257..5be9e9e 100644 --- a/src/pages/Home/components/ShowDraftModal.tsx +++ b/src/pages/Home/components/ShowDraftModal.tsx @@ -60,22 +60,26 @@ const ShowDraftModal = ({ onClose }: ShowDraftModalProps) => {

로그아웃 시 임시 저장된 편지는 사라집니다

- {draftLetters.map((draft) => ( -
handleNavigation(draft.letterId)} - > -

{draft.title}

+ {draftLetters.length > 0 ? ( + draftLetters.map((draft) => (
handleDeleteDraftLetters(draft.letterId)} + className="text-gray-80 body-m flex h-10 w-full items-center justify-between gap-1 rounded-lg bg-white p-3" + key={draft.letterId} + // onClick={() => handleNavigation(draft.letterId)} > - +

{draft.title}

+
handleDeleteDraftLetters(draft.letterId)} + > + +
-
- ))} + )) + ) : ( +

작성 중인 편지가 없어요

+ )}
diff --git a/src/pages/Home/components/ShowIncomingLettersModal.tsx b/src/pages/Home/components/ShowIncomingLettersModal.tsx index 219015b..27513c9 100644 --- a/src/pages/Home/components/ShowIncomingLettersModal.tsx +++ b/src/pages/Home/components/ShowIncomingLettersModal.tsx @@ -29,15 +29,19 @@ const ShowIncomingLettersModal = ({ onClose }: ShowIncomingLettersModalProps) =>

시간은 실제 시간을 기반으로 책정됩니다.

- {data.map((letter) => ( -
-

{letter.title}

-

{letter.remainingTime}

-
- ))} + {data.length > 0 ? ( + data.map((letter) => ( +
+

{letter.title}

+

{letter.remainingTime}

+
+ )) + ) : ( +

오고 있는 편지가 없어요

+ )}
diff --git a/src/pages/Home/components/ShowShareAccessModal.tsx b/src/pages/Home/components/ShowShareAccessModal.tsx index 3c59749..3ed4179 100644 --- a/src/pages/Home/components/ShowShareAccessModal.tsx +++ b/src/pages/Home/components/ShowShareAccessModal.tsx @@ -1,8 +1,10 @@ import React, { useEffect, useState } from 'react'; import { useNavigate } from 'react-router'; -import { getSharePostDetail, getSharePostList } from '@/apis/share'; -import { SharePostResponse, SharePost } from '@/apis/share'; +import { getSharePostDetail } from '@/apis/share'; +import { getShareProposalList } from '@/apis/share'; +import { ShareProposal } from '@/apis/share'; + import ModalBackgroundWrapper from '@/components/ModalBackgroundWrapper'; import ModalOverlay from '@/components/ModalOverlay'; @@ -14,35 +16,22 @@ interface ShowShareAccessModalProps { const ShowShareAccessModal = ({ onClose }: ShowShareAccessModalProps) => { const navigate = useNavigate(); - const [sharePosts, setSharePosts] = useState([]); - const [currentPage, setCurrentPage] = useState(1); - const [hasMore, setHasMore] = useState(true); - - const fetchPosts = async (page: number) => { - try { - const data: SharePostResponse = await getSharePostList(page, 10); - setSharePosts((prev) => [...prev, ...data.content]); - setHasMore(page < data.totalPages); - } catch (error) { - console.error('❌ 게시글 목록을 불러오는 데 실패했습니다.', error); - } - }; + const [shareProposals, setShareProposals] = useState([]); useEffect(() => { - fetchPosts(currentPage); - }, [currentPage]); - - const handleScroll = (e: React.UIEvent) => { - const { scrollTop, scrollHeight, clientHeight } = e.currentTarget; - if (scrollTop + clientHeight >= scrollHeight - 10 && hasMore) { - setCurrentPage((prev) => prev + 1); - } - }; + getShareProposalList() + .then((data) => { + setShareProposals(data || []); + }) + .catch((error) => { + console.error('❌ 공유 요청 목록을 불러오는 데 실패했습니다.', error); + }); + }, []); - const handleNavigation = async (sharePostId: number) => { + const handleNavigation = async (shareProposalId: number) => { try { - const postDetail = await getSharePostDetail(sharePostId); - navigate(`/board/letter/${sharePostId}`, { + const postDetail = await getSharePostDetail(shareProposalId); + navigate(`/board/letter/${shareProposalId}`, { state: { postDetail, isShareLetterPreview: true }, }); } catch (error) { @@ -65,19 +54,20 @@ const ShowShareAccessModal = ({ onClose }: ShowShareAccessModalProps) => { 허락 여부를 체크해주세요!

-
- {sharePosts?.map((post) => ( - - ))} +
+ {shareProposals.length > 0 ? ( + shareProposals.map((proposal) => ( + + )) + ) : ( +

새로운 공유 요청이 없어요

+ )}
diff --git a/src/stores/incomingLettersStore.ts b/src/stores/incomingLettersStore.ts index ecb34e7..cde60af 100644 --- a/src/stores/incomingLettersStore.ts +++ b/src/stores/incomingLettersStore.ts @@ -12,9 +12,6 @@ interface IncomingLetters { interface IncomingLettersStore { data: IncomingLetters[]; - arrivedCount: number; - message: string; - timestamp: string; fetchIncomingLetters: () => void; } @@ -36,18 +33,12 @@ const calculatingRemainingTime = (deliveryCompletedAt: string): string => { export const useIncomingLettersStore = create((set) => ({ data: [], - arrivedCount: 0, - message: '', - timestamp: '', fetchIncomingLetters: async () => { try { - const token = localStorage.getItem('token') || ''; - const data = await getIncomingLetters(token); + const data = await getIncomingLetters(); - let arrivedCount = 0; const updatedLetters = data.data.map((letter: IncomingLetters) => { const remainingTime = calculatingRemainingTime(letter.deliveryCompletedAt); - if (remainingTime === '00:00:00') arrivedCount += 1; // 도착한 편지 카운트 return { ...letter, remainingTime }; }); @@ -55,12 +46,27 @@ export const useIncomingLettersStore = create((set) => ({ const inProgressLetters = updatedLetters.filter( (letter: IncomingLetters) => letter.remainingTime !== '00:00:00', ); + set({ data: inProgressLetters, - arrivedCount, - message: data.message, - timestamp: data.timestamp, }); + + setInterval(() => { + set((state) => { + const updatedLetters = state.data.map((letter: IncomingLetters) => { + const remainingTime = calculatingRemainingTime(letter.deliveryCompletedAt); + return { ...letter, remainingTime }; + }); + + const filteredLetters = updatedLetters.filter( + (letter) => letter.remainingTime !== '00:00:00', + ); + + return { + data: filteredLetters, + }; + }); + }, 1000); } catch (error) { console.error('❌오고 있는 편지 목록을 불러오던 중 에러 발생', error); } From a0f576b94b563048d654203335f2cff9ccb83c84 Mon Sep 17 00:00:00 2001 From: wldnjs990 <139528356+wldnjs990@users.noreply.github.com> Date: Sat, 8 Mar 2025 19:11:21 +0900 Subject: [PATCH 07/30] =?UTF-8?q?feat=20:=20=ED=8E=B8=EC=A7=80=EC=9E=91?= =?UTF-8?q?=EC=84=B1,=20=EB=9E=9C=EB=8D=A4=ED=8E=B8=EC=A7=80,=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=ED=8E=98=EC=9D=B4=EC=A7=80=203=EC=B0=A8=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=EA=B5=AC=ED=98=84=20(#94)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat : 게시글 신고기능 구현 * feat : 카테고리 전체 선택 안되는 오류 수정 + 답장 전송시 도착시간 1시간으로 텍스트 고정 * feat : getPrevLetter api 엔드포인트 변경 * feat : 디테일 페이지 답장버튼 분기처리 * feat : 편지상세페이지 zipCode바인딩 * refactor : 편지상세 페이지 컴포넌트 분리 * feat : 편지 상세 컴포넌트 추가 분리 + 편지 평가 기능 구현 완료 * refactor : 신고모달 타입에서 null 제거 + 이전편지 가져오기, 타입 조금 수정 * feat : 랜덤편지 편지 없을 경우 예외처리 UI 추가 * design : 편지작성, 편지상세 resize속성 제거 * feat : 랜덤편지 데이터가 없을시 예외처리 UI 추가 + 쿨타임 상태일때 예외처리 UI 수정 * chore : 랜덤편지 api console 제거 * feat : 임시저장 api 생성(연결 테스트 아직 안함) * feat : 편지 작성 페이지 임시저장 버튼 구현 * feat : 편지 임시저장 80% 구현(승연님 작업 이후 임시저장 업데이트 분기 나눠야함) * feat : 임시저장 최초답장 예외처리 --- src/apis/client.ts | 4 +- src/apis/letterDetail.ts | 15 +- src/apis/randomLetter.ts | 6 - src/apis/write.ts | 35 ++++- src/components/ReportModal.tsx | 12 +- .../components/DegreeSelector.tsx | 61 ++++++++ .../components/LetterDetailContent.tsx | 26 ++++ .../components/LetterDetailDegreeButton.tsx | 50 +++++++ .../components/LetterDetailHeader.tsx | 60 ++++++++ .../components/LetterDetailReplyButton.tsx | 19 +++ src/pages/LetterDetail/index.tsx | 132 ++++-------------- .../RandomLetters/components/CoolTime.tsx | 10 +- .../components/MatchedLetter.tsx | 6 +- .../components/MatchingSelect.tsx | 65 ++++++--- src/pages/RandomLetters/constants/index.ts | 4 +- src/pages/Write/CategorySelect.tsx | 4 +- src/pages/Write/LetterEditor.tsx | 86 +++++++++--- src/pages/Write/index.tsx | 45 +----- src/styles/components.css | 7 + src/types/letterDetail.d.ts | 5 + src/types/write.d.ts | 5 +- 21 files changed, 423 insertions(+), 234 deletions(-) create mode 100644 src/pages/LetterDetail/components/DegreeSelector.tsx create mode 100644 src/pages/LetterDetail/components/LetterDetailContent.tsx create mode 100644 src/pages/LetterDetail/components/LetterDetailDegreeButton.tsx create mode 100644 src/pages/LetterDetail/components/LetterDetailHeader.tsx create mode 100644 src/pages/LetterDetail/components/LetterDetailReplyButton.tsx diff --git a/src/apis/client.ts b/src/apis/client.ts index 092d56f..2564af2 100644 --- a/src/apis/client.ts +++ b/src/apis/client.ts @@ -102,8 +102,8 @@ client.interceptors.response.use( } } } - if (isLoggedIn) logout(); - console.error('Failed to refresh token', error); + // if (isLoggedIn) logout(); + // console.error('Failed to refresh token', error); return Promise.reject(error); }, ); diff --git a/src/apis/letterDetail.ts b/src/apis/letterDetail.ts index d321438..6a72b66 100644 --- a/src/apis/letterDetail.ts +++ b/src/apis/letterDetail.ts @@ -23,4 +23,17 @@ const deleteLetter = async (letterId: string) => { } }; -export { getLetter, deleteLetter }; +const postEvaluateLetter = async (letterId: number, evaluation: LetterEvaluation) => { + try { + const res = await client.post(`/api/letters/${letterId}/evaluate`, { + evaluation: evaluation, + }); + if (!res) throw new Error('편지 삭제 요청 도중 에러가 발생했습니다.'); + console.log(res); + return res; + } catch (error) { + console.error(error); + } +}; + +export { getLetter, deleteLetter, postEvaluateLetter }; diff --git a/src/apis/randomLetter.ts b/src/apis/randomLetter.ts index 994aff6..515a1d8 100644 --- a/src/apis/randomLetter.ts +++ b/src/apis/randomLetter.ts @@ -4,7 +4,6 @@ const getRandomLetters = async (category: string | null) => { try { const res = await client.get(`/api/random-letters/${category}`); if (!res) throw new Error('랜덤 편지 데이터를 가져오는 도중 에러가 발생했습니다.'); - console.log(res); return res; } catch (error) { console.error(error); @@ -13,8 +12,6 @@ const getRandomLetters = async (category: string | null) => { const postRandomLettersApprove = async (approveRequest: ApproveRequest, callBack?: () => void) => { try { - console.log('엔드포인트 : /api/random-letters/approve'); - console.log('request', approveRequest); const res = await client.post('/api/random-letters/approve', approveRequest); if (!res) throw new Error('랜덤편지 매칭수락 도중 에러가 발생했습니다.'); if (callBack) callBack(); @@ -30,7 +27,6 @@ const getRandomLetterMatched = async (callBack?: () => void) => { if (!res) throw new Error('랜덤 편지 최종 매칭 시간 검증 데이터를 가자오는 도중 에러가 발생했습니다.'); if (callBack) callBack(); - console.log(res); return res; } catch (error) { console.error(error); @@ -43,7 +39,6 @@ const getRandomLetterCoolTime = async (callBack?: () => void) => { if (!res) throw new Error('랜덤 편지 최종 매칭 시간 검증 데이터를 가자오는 도중 에러가 발생했습니다.'); if (callBack) callBack(); - console.log(res); return res; } catch (error) { console.error(error); @@ -54,7 +49,6 @@ const deleteRandomLetterMatching = async () => { try { const res = await client.delete('/api/random-letters/matching/cancel'); if (!res) throw new Error('매칭 취소 도중 에러가 발생했습니다.'); - console.log(res); return res; } catch (error) { console.log(error); diff --git a/src/apis/write.ts b/src/apis/write.ts index 7046e1f..70984de 100644 --- a/src/apis/write.ts +++ b/src/apis/write.ts @@ -2,10 +2,10 @@ import client from './client'; const postLetter = async (data: LetterRequest) => { + console.log('request', data); try { const res = await client.post('/api/letters', data); - if (!res) throw new Error('편지 전송과정중에서 오류가 발생했습니다.'); - console.log(`api 주소 : /api/letters, 전송타입 : post`); + if (!res) throw new Error('편지 전송과정에서 오류가 발생했습니다.'); return res; } catch (error) { console.error(error); @@ -13,11 +13,10 @@ const postLetter = async (data: LetterRequest) => { }; const postFirstReply = async (data: FirstReplyRequest) => { + console.log('Firstrequest', data); try { const res = await client.post('/api/random-letters/matching', data); - if (!res) throw new Error('최초 답장 전송과정중에서 오류가 발생했습니다.'); - console.log(`api 주소 : /api/random-letters/matching, 전송타입 : post`); - console.log(res); + if (!res) throw new Error('최초 답장 전송과정에서 오류가 발생했습니다.'); return res; } catch (error) { console.error(error); @@ -27,11 +26,33 @@ const postFirstReply = async (data: FirstReplyRequest) => { const getPrevLetter = async (letterId: string) => { try { const res = await client.get(`/api/letters/${letterId}/previous`); - console.log(res); + if (!res) throw new Error('이전편지를 불러오는중 오류가 발생했습니다.'); return res; } catch (error) { console.error(error); } }; -export { postLetter, postFirstReply, getPrevLetter }; +// 임시저장 최초 생성 +const postTemporarySave = async (data: TemporaryRequest) => { + try { + const res = client.post(`/api/letters/temporary-save`, data); + if (!res) throw new Error('편지 임시저장과정에서 오류가 발생했습니다.'); + return res; + } catch (error) { + console.error(error); + } +}; + +// 임시저장 수정 +const PatchTemporarySave = async (data: TemporaryRequest) => { + try { + const res = client.post(`/api/letters/temporary-save`, data); + if (!res) throw new Error('편지 임시저장과정에서 오류가 발생했습니다.'); + return res; + } catch (error) { + console.error(error); + } +}; + +export { postLetter, postFirstReply, getPrevLetter, postTemporarySave, PatchTemporarySave }; diff --git a/src/components/ReportModal.tsx b/src/components/ReportModal.tsx index be47d81..41ce17f 100644 --- a/src/components/ReportModal.tsx +++ b/src/components/ReportModal.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useState } from 'react'; import { twMerge } from 'tailwind-merge'; import { postReports } from '@/apis/admin'; @@ -8,7 +8,7 @@ import TextareaField from './TextareaField'; interface ReportModalProps { reportType: ReportType; - letterId: number | null; + letterId: number; onClose: () => void; } @@ -42,6 +42,7 @@ const ReportModal = ({ reportType, letterId, onClose }: ReportModalProps) => { const res = await postReports(postReportRequest); if (res?.status === 200) { alert('신고 처리되었습니다.'); + console.log(res); onClose(); } else if (res?.status === 409) { alert('신고한 이력이 있습니다.'); @@ -49,13 +50,6 @@ const ReportModal = ({ reportType, letterId, onClose }: ReportModalProps) => { } }; - useEffect(() => { - if (!postReportRequest.letterId) { - alert('신고 모달을 여는 과정에서 오류가 발생했습니다. 새로고침을 눌러주세요'); - onClose(); - } - }); - return ( >; +} +export default function DegreeSelector({ letterDetail, setLetterDetail }: DegreeSelector) { + const handlePostEvaluateLetter = async ( + letterId: number | undefined, + evaluation: LetterEvaluation, + ) => { + if (!letterId) return alert('편지id값이 담겨있지 않습니다.'); + const res = await postEvaluateLetter(letterId, evaluation); + if (res?.status === 200) { + console.log('평가완료'); + setLetterDetail((cur) => ({ ...cur, evaluated: true })); + } + }; + const DEGREES = [ + { + icon: , + title: '따뜻해요', + onClick: () => { + handlePostEvaluateLetter(letterDetail?.letterId, 'GOOD'); + }, + }, + { + icon: , + title: '그럭저럭', + onClick: () => { + handlePostEvaluateLetter(letterDetail?.letterId, 'SOSO'); + }, + }, + { + icon: , + title: '앗! 차가워', + onClick: () => { + handlePostEvaluateLetter(letterDetail?.letterId, 'BAD'); + }, + }, + ]; + return ( +
+ {DEGREES.map((degree, idx) => { + return ( + + ); + })} +
+ ); +} diff --git a/src/pages/LetterDetail/components/LetterDetailContent.tsx b/src/pages/LetterDetail/components/LetterDetailContent.tsx new file mode 100644 index 0000000..4e7761d --- /dev/null +++ b/src/pages/LetterDetail/components/LetterDetailContent.tsx @@ -0,0 +1,26 @@ +import { twMerge } from 'tailwind-merge'; + +import { FONT_TYPE_OBJ } from '@/pages/Write/constants'; + +interface LetterDetailContent { + letterDetail: LetterDetail; +} +export default function LetterDetailContent({ letterDetail }: LetterDetailContent) { + return ( + <> +
+ TO. 따숨이 + {letterDetail.title} +
+ + FROM. {letterDetail.zipCode} + + ); +} diff --git a/src/pages/LetterDetail/components/LetterDetailDegreeButton.tsx b/src/pages/LetterDetail/components/LetterDetailDegreeButton.tsx new file mode 100644 index 0000000..769aa51 --- /dev/null +++ b/src/pages/LetterDetail/components/LetterDetailDegreeButton.tsx @@ -0,0 +1,50 @@ +import { useEffect, useRef } from 'react'; + +import { ThermostatIcon } from '@/assets/icons'; + +interface LetterDetailDegreeButton { + letterDetail: LetterDetail | null; + setDegreeModalOpen: React.Dispatch>; +} +export default function LetterDetailDegreeButton({ + letterDetail, + setDegreeModalOpen, +}: LetterDetailDegreeButton) { + const degreeButtonRef = useRef(null); + + useEffect(() => { + const handleOutsideClick = (event: MouseEvent) => { + const target = event.target as Node; + if (!target || degreeButtonRef.current?.contains(target)) { + return; + } + setDegreeModalOpen(false); + }; + + document.body.addEventListener('click', handleOutsideClick); + + return () => { + document.body.removeEventListener('click', handleOutsideClick); + }; + }, [setDegreeModalOpen]); + return ( + <> + {letterDetail?.evaluated ? ( +
+ 온도 측정된 편지에요! +
+ ) : ( + + )} + + ); +} diff --git a/src/pages/LetterDetail/components/LetterDetailHeader.tsx b/src/pages/LetterDetail/components/LetterDetailHeader.tsx new file mode 100644 index 0000000..17cc18c --- /dev/null +++ b/src/pages/LetterDetail/components/LetterDetailHeader.tsx @@ -0,0 +1,60 @@ +import { useState } from 'react'; + +import { DeleteIcon, SirenOutlinedIcon } from '@/assets/icons'; +import BackButton from '@/components/BackButton'; +import useAuthStore from '@/stores/authStore'; + +import DegreeSelector from './DegreeSelector'; +import LetterDetailDegreeButton from './LetterDetailDegreeButton'; + +interface LetterDetailHeader { + letterDetail: LetterDetail; + setLetterDetail: React.Dispatch>; + setDeleteModalOpen: React.Dispatch>; + setReportModalOpen: React.Dispatch>; +} +export default function LetterDetailHeader({ + letterDetail, + setLetterDetail, + setDeleteModalOpen, + setReportModalOpen, +}: LetterDetailHeader) { + const [degreeModalOpen, setDegreeModalOpen] = useState(false); + + const userZipCode = useAuthStore((state) => state.zipCode); + + return ( +
+ +
+ {userZipCode !== letterDetail?.zipCode && ( + + )} + {userZipCode === letterDetail?.zipCode && ( + + )} + {userZipCode !== letterDetail?.zipCode && ( + + )} + {degreeModalOpen && ( + + )} +
+
+ ); +} diff --git a/src/pages/LetterDetail/components/LetterDetailReplyButton.tsx b/src/pages/LetterDetail/components/LetterDetailReplyButton.tsx new file mode 100644 index 0000000..60c8ad3 --- /dev/null +++ b/src/pages/LetterDetail/components/LetterDetailReplyButton.tsx @@ -0,0 +1,19 @@ +import { useNavigate } from 'react-router'; + +interface LetterDetailReplyButton { + letterDetail: LetterDetail; +} +export default function LetterDetailReplyButton({ letterDetail }: LetterDetailReplyButton) { + const navigate = useNavigate(); + return ( + + ); +} diff --git a/src/pages/LetterDetail/index.tsx b/src/pages/LetterDetail/index.tsx index 88a40a4..86cf44f 100644 --- a/src/pages/LetterDetail/index.tsx +++ b/src/pages/LetterDetail/index.tsx @@ -1,45 +1,27 @@ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useState } from 'react'; import { useNavigate, useParams } from 'react-router'; import { twMerge } from 'tailwind-merge'; import { deleteLetter, getLetter } from '@/apis/letterDetail'; -import { - CloudIcon, - DeleteIcon, - SirenOutlinedIcon, - SnowIcon, - ThermostatIcon, - WarmIcon, -} from '@/assets/icons'; -import BackButton from '@/components/BackButton'; import ConfirmModal from '@/components/ConfirmModal'; import ReportModal from '@/components/ReportModal'; -import { FONT_TYPE_OBJ, PAPER_TYPE_OBJ } from '@/pages/Write/constants'; +import { PAPER_TYPE_OBJ } from '@/pages/Write/constants'; +import useAuthStore from '@/stores/authStore'; + +import LetterDetailContent from './components/LetterDetailContent'; +import LetterDetailHeader from './components/LetterDetailHeader'; +import LetterDetailReplyButton from './components/LetterDetailReplyButton'; const LetterDetailPage = () => { const params = useParams(); const navigate = useNavigate(); - // 상대방의 우편번호도 데이터에 포함되어야 할 거 같음!!! - const [letterDetail, setLetterDetail] = useState(null); - const DEGREES = [ - { icon: , title: '따뜻해요' }, - { icon: , title: '그럭저럭' }, - { icon: , title: '앗! 차가워' }, - ]; - const [degreeModalOpen, setDegreeModalOpen] = useState(false); + const [letterDetail, setLetterDetail] = useState({} as LetterDetail); + const userZipCode = useAuthStore((state) => state.zipCode); + const [reportModalOpen, setReportModalOpen] = useState(false); const [deleteModalOpen, setDeleteModalOpen] = useState(false); - const degreeButtonRef = useRef(null); - const handleOutsideClick = (event: MouseEvent) => { - const target = event.target as Node; - if (!target || degreeButtonRef.current?.contains(target)) { - return; - } - setDegreeModalOpen(false); - }; - const handleDeleteLetter = async (letterId: string) => { const res = await deleteLetter(letterId); if (res?.status === 200) { @@ -50,12 +32,11 @@ const LetterDetailPage = () => { }; useEffect(() => { - document.body.addEventListener('click', handleOutsideClick); - const handleGetLetter = async (letterId: string) => { const res = await getLetter(letterId); if (res?.status === 200) { - setLetterDetail(res.data.data); + const data: LetterDetail = res.data.data; + setLetterDetail(data); } else { alert( '에러가 발생했거나 존재하지 않거나 따숨님의 편지가 아니에요(임시) - 이거 에러코드 따른 처리 달리해야할듯', @@ -66,17 +47,15 @@ const LetterDetailPage = () => { if (params.id) { handleGetLetter(params.id); } - - return () => { - document.body.removeEventListener('click', handleOutsideClick); - }; }, [params.id, navigate]); + + if (!letterDetail) return <>; return ( <> {reportModalOpen && ( setReportModalOpen(false)} /> )} @@ -86,75 +65,16 @@ const LetterDetailPage = () => { letterDetail && PAPER_TYPE_OBJ[letterDetail.paperType], )} > -
- -
- - - - {degreeModalOpen && ( -
- {DEGREES.map((degree, idx) => { - return ( - - ); - })} -
- )} -
-
-
- TO. 따숨이 - {letterDetail?.title} -
- - FROM. {'12E12'} - + + + {userZipCode !== letterDetail?.zipCode && ( + + )} {deleteModalOpen && (
- -
+ +
+ 기다림의 미학을 느껴보시는건 어떨까요? +
- - {randomLetters.map((list, idx) => { - return ( - -
{ - setOpenModal(true); - setSelectedLetter(list); - }} - > - -
-
- ); - })} -
+ {randomLetters.length === 0 ? ( + +
+
+ 편지가 없습니다. + 따숨님의 편지를 작성해보시겠어요? +
+ +
+
+ ) : ( + + {randomLetters.map((list, idx) => { + return ( + +
{ + setOpenModal(true); + setSelectedLetter(list); + }} + > + +
+
+ ); + })} +
+ )}
diff --git a/src/pages/RandomLetters/constants/index.ts b/src/pages/RandomLetters/constants/index.ts index 47fa7f5..b10f948 100644 --- a/src/pages/RandomLetters/constants/index.ts +++ b/src/pages/RandomLetters/constants/index.ts @@ -1,5 +1,5 @@ -const CATEGORY_LIST: { title: string; category: Category | null }[] = [ - { title: '전체', category: null }, +const CATEGORY_LIST: { title: string; category: Category | 'ALL' }[] = [ + { title: '전체', category: 'ALL' }, { title: '위로와 공감', category: 'CONSOLATION' }, { title: '축하와 응원', category: 'CELEBRATION' }, { title: '고민 상담', category: 'CONSULT' }, diff --git a/src/pages/Write/CategorySelect.tsx b/src/pages/Write/CategorySelect.tsx index bdabcbb..7244b0b 100644 --- a/src/pages/Write/CategorySelect.tsx +++ b/src/pages/Write/CategorySelect.tsx @@ -59,9 +59,7 @@ export default function CategorySelect({
작성하신 편지는 - {'00'}시간 - {'00'}분 - {'00'}초 후에 도착합니다. + 1시간 후에 도착합니다.
diff --git a/src/pages/Write/LetterEditor.tsx b/src/pages/Write/LetterEditor.tsx index 4e91d38..c22f206 100644 --- a/src/pages/Write/LetterEditor.tsx +++ b/src/pages/Write/LetterEditor.tsx @@ -1,9 +1,10 @@ import { useEffect, useState } from 'react'; -import { useLocation } from 'react-router'; +import { useLocation, useNavigate } from 'react-router'; import { twMerge } from 'tailwind-merge'; -import { postFirstReply, postLetter } from '@/apis/write'; +import { postFirstReply, postLetter, postTemporarySave } from '@/apis/write'; import BackButton from '@/components/BackButton'; +import ConfirmModal from '@/components/ConfirmModal'; import WritePageButton from '@/pages/Write/components/WritePageButton'; import { FONT_TYPE_OBJ } from '@/pages/Write/constants'; import OptionSlide from '@/pages/Write/OptionSlide'; @@ -11,20 +12,22 @@ import useWrite from '@/stores/writeStore'; import { removeProperty } from '@/utils/removeProperty'; export default function LetterEditor({ + letterId, setStep, prevLetter, setSend, - searchParams, isReply, }: { - setStep: React.Dispatch>; + letterId: string | null; + isReply: boolean; prevLetter: PrevLetter[]; + setStep: React.Dispatch>; setSend: React.Dispatch>; - searchParams: URLSearchParams; - isReply: boolean; }) { const location = useLocation(); + const navigate = useNavigate(); const [randomMatched, setRandomMatched] = useState(false); + const [isTemporaryConfirmModal, setIsTemporaryConfirmModal] = useState(false); const letterRequest = useWrite((state) => state.letterRequest); const setLetterRequest = useWrite((state) => state.setLetterRequest); @@ -63,35 +66,72 @@ export default function LetterEditor({ console.log('prevLetter', prevLetter); setLetterRequest({ receiverId: prevLetter[0].memberId, - parentLetterId: Number(searchParams.get('letterId')), + parentLetterId: Number(letterId), category: prevLetter[0].category, matchingId: prevLetter[0].matchingId, }); } - }, [prevLetter, searchParams, setLetterRequest, isReply]); + }, [prevLetter, setLetterRequest, isReply]); + + const handlePostTemporarySave = async () => { + if (!letterId) return alert('임시저장중 오류 발생'); + const LETTER_STATE_DUMMY = false; + const requestLetterId = LETTER_STATE_DUMMY || 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); + navigate('/'); + } else { + alert('실패'); + } + }; return (
+ {isTemporaryConfirmModal && ( + setIsTemporaryConfirmModal(false)} + onConfirm={() => { + handlePostTemporarySave(); + }} + /> + )}
{isReply ? ( - { - if (letterRequest.title.trim() !== '' && letterRequest.content.trim() !== '') { - if (randomMatched) { - const firstReplyRequest = removeProperty(letterRequest, ['matchingId']); - console.log(firstReplyRequest); - handlePostFirstReply(firstReplyRequest); +
+ {!randomMatched && ( + { + setIsTemporaryConfirmModal(true); + }} + /> + )} + { + if (letterRequest.title.trim() !== '' && letterRequest.content.trim() !== '') { + if (randomMatched) { + const firstReplyRequest = removeProperty(letterRequest, ['matchingId']); + console.log(firstReplyRequest); + handlePostFirstReply(firstReplyRequest); + } else { + handlePostReply(letterRequest); + } } else { - handlePostReply(letterRequest); + alert('편지 제목, 내용이 작성되었는지 확인해주세요'); } - } else { - alert('편지 제목, 내용이 작성되었는지 확인해주세요'); - } - }} - /> + }} + /> +
) : (