Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
dca90d6
feat: 임시저장된 편지 삭제 기능 구현 (#84)
tifsy Mar 7, 2025
70051d3
feat: 롤링페이퍼 배포된 api로 연결 수정 (#85)
AAminha Mar 7, 2025
aeab0e6
feat : 알림 2차 기능 구현 (#81)
wldnjs990 Mar 7, 2025
ae17500
fix: 자잘한 이슈 수정 (#86)
nirii00 Mar 7, 2025
62d67cd
feat : 재사용 가능한 페이지네이션 구현 (#92)
wldnjs990 Mar 7, 2025
266d0e5
feat: 편지 공유 요청 수신 조회 기능 구현 (#90)
tifsy Mar 7, 2025
a0f576b
feat : 편지작성, 랜덤편지, 상세페이지 3차 기능구현 (#94)
wldnjs990 Mar 8, 2025
5b4d4ba
feat: 롤링페이퍼 추가 기능 구현 (#95)
AAminha Mar 9, 2025
f0b5b4f
feat: 임시저장 편지 조회 기능 완성 (#97)
tifsy Mar 9, 2025
4aced28
feat : 토스트 UI 구현 + 알림 페이지 코드 작업 90% 완료 + 실시간 알림, 편지 작성 예외처리에 토스트UI 연결…
wldnjs990 Mar 9, 2025
6917b3b
fix: reissue 문제, 내 편지함 data 최신화 문제 해결 (#100)
nirii00 Mar 9, 2025
a8144d5
feat : 알림 페이지 알림 확인 처리 안되던 현상 수정 + 신고페이지 4차 구현 (#101)
wldnjs990 Mar 9, 2025
58df399
feat: 공유 요청 상세 조회 기능 구현 (#106)
tifsy Mar 10, 2025
74ef07b
fix: 임시저장 편지 작성 페이지 이동 경로 문제 해결 (#109)
tifsy Mar 10, 2025
6059368
feat : 신고 등록 API + ReportModal 수정 (#111)
wldnjs990 Mar 10, 2025
2fb1cc6
fix: QA 반영 (#112)
nirii00 Mar 10, 2025
40c2021
feat : 알림 기능 구현 + QA (#114)
wldnjs990 Mar 10, 2025
f55da9f
feat : 임시저장 데이터 바인딩 및 임시저장 덮어씌우기 작업 완료 (#115)
wldnjs990 Mar 10, 2025
5fd9543
refactor: 2차 QA 반영 (#116)
tifsy Mar 10, 2025
e26a513
Merge branch 'main' of https://github.com/prgrms-web-devcourse-final-…
wldnjs990 Mar 10, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion index.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<html lang="kr">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/public/favicon.ico" />
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>36.5</title>
<link
Expand Down
2 changes: 2 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import OnboardingPage from './pages/Onboarding';
import RandomLettersPage from './pages/RandomLetters';
import RollingPaperPage from './pages/RollingPaper';
import WritePage from './pages/Write';
import ShareApprovalPage from './pages/Share';

const App = () => {
useViewport();
Expand Down Expand Up @@ -56,6 +57,7 @@ const App = () => {
<Route path="letter" element={<LetterBoardPage />} />
</Route>
<Route path="letter/:id" element={<LetterBoardDetailPage />} />
<Route path="share/:shareProposalId" element={<ShareApprovalPage />} />
</Route>
<Route path="mypage" element={<Layout />}>
<Route index element={<MyPage />} />
Expand Down
1 change: 1 addition & 0 deletions src/apis/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const postReports = async (postReportRequest: PostReportRequest) => {
return res;
} catch (error) {
console.error(error);
return null;
}
};

Expand Down
13 changes: 12 additions & 1 deletion src/apis/notification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
38 changes: 35 additions & 3 deletions src/apis/share.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -106,13 +124,27 @@ export const getShareProposalList = async () => {
}
};

// 편지 공유 요청 상세 조회
export const getShareProposalDetail = async (
shareProposalId: number,
): Promise<ShareProposalDetail> => {
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<SharePostApproval> => {
): Promise<ShareProposalApproval> => {
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(
Expand Down
17 changes: 0 additions & 17 deletions src/components/HomeButton.tsx

This file was deleted.

61 changes: 61 additions & 0 deletions src/components/MenuButton.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<div className="flex w-full max-w-150 justify-end pr-5 text-center">
<Link
to="/letter/box"
className={twMerge(
'bg-primary-3 fixed bottom-[220px] z-50 h-12 w-12 rotate-360 content-center rounded-full text-white transition-all duration-200 hover:scale-105 active:scale-90',
isOpen
? 'translate-y-0 rotate-0 opacity-100'
: 'translate-y-[120%] rotate-180 opacity-0',
)}
>
<MarkunreadOutlinedIcon fontSize="small" onClick={() => setIsOpen(false)} />
</Link>
<Link
to="/board/letter"
className={twMerge(
'bg-primary-3 fixed bottom-[160px] z-50 h-12 w-12 rotate-360 content-center rounded-full text-white transition-all duration-200 hover:scale-105 active:scale-90',
isOpen
? 'translate-y-0 rotate-0 opacity-100'
: 'translate-y-[120%] rotate-180 opacity-0',
)}
>
<CalendarTodayOutlinedIcon fontSize="small" onClick={() => setIsOpen(false)} />
</Link>
<Link
to="/letter/write"
className={twMerge(
'bg-primary-3 fixed bottom-[100px] z-50 h-12 w-12 rotate-360 content-center rounded-full text-white transition-all duration-200 hover:scale-105 active:scale-90',
isOpen
? 'translate-y-0 rotate-0 opacity-100'
: 'translate-y-[120%] rotate-180 opacity-0',
)}
>
<EditNoteRoundedIcon fontSize="medium" onClick={() => setIsOpen(false)} />
</Link>

<div
className={twMerge(
'bg-primary-3 fixed bottom-[30px] z-50 h-13 w-13 content-center rounded-full text-white transition-all duration-200 hover:scale-105 active:scale-90',
isOpen ? 'rotate-90' : 'rotate-0',
)}
>
<MenuRoundedIcon onClick={() => setIsOpen((state) => !state)} />
</div>
</div>
</>
);
}
36 changes: 36 additions & 0 deletions src/components/NotificationButton.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Link to="/mypage/notifications" className="relative">
{notReadCount > 0 && (
<div className={notReadStyle}>{notReadCount < 100 ? notReadCount : '99+'}</div>
)}
<AlarmIcon className="h-6 w-6 text-white" />
</Link>
);
}
13 changes: 9 additions & 4 deletions src/components/ReportModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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) => {
Expand All @@ -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();
}
};
Expand Down
2 changes: 1 addition & 1 deletion src/components/ToastItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
Expand Down
68 changes: 55 additions & 13 deletions src/hooks/useServerSentEvents.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<EventSourcePolyfill | null>(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('로그인 정보 확인불가');
Expand All @@ -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);
Expand All @@ -64,6 +105,7 @@ export const useServerSentEvents = () => {
}, [accessToken]);

const closeSSE = () => {
if (reconnect) clearTimeout(reconnect);
sourceRef.current?.close();
sourceRef.current = null;
};
Expand Down
10 changes: 4 additions & 6 deletions src/layouts/Header.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<header className="fixed top-0 z-40 flex h-16 w-full max-w-150 items-center justify-between p-5">
<button onClick={() => navigate(-1)}>
<ArrowLeftIcon className="h-6 w-6 text-white" />
</button>
<div className="flex items-center gap-3">
<Link to="/mypage/notifications">
<AlarmIcon className="h-6 w-6 text-white" />
</Link>
<NotificationButton />
<Link to="/mypage">
<PersonIcon className="h-6 w-6 text-white" />
</Link>
Expand Down
Loading