Skip to content

Commit 4aced28

Browse files
authored
feat : 토스트 UI 구현 + 알림 페이지 코드 작업 90% 완료 + 실시간 알림, 편지 작성 예외처리에 토스트UI 연결 (#98)
* feat : 알림 타입에 SENDING 추가 * feat : 알림 페이지 타임라인 데이터바인딩 + 알림타입에 따른 UI작업 완료(구독 UI작업만 남음) * feat : 토스트UI 작업중 * feat : SSE훅 호출 위치 App에서 PrivateRouter로 이동 + Home 라우트 PrivateRouter 안으로 이동 * feat : 토스트 기능 1차 구현(알림기능 알림도착, 편지작성 내용 미입력시 토스트 넣어둠) * feat : 토스트알림 최대넓이 지정 * feat : 토스트 컨텐츠 타입별 이모지 추가 * refactor : 토스트UI 알림 1개만 표시 => 알림 1개 이상 표시 되도록 업그레이드(단일 객체 -> 객체 배열로 데이터값 수정) * refactor : 토스트UI position 타입 수정
1 parent f0b5b4f commit 4aced28

File tree

15 files changed

+238
-102
lines changed

15 files changed

+238
-102
lines changed

src/App.tsx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { Route, Routes } from 'react-router';
22

3-
import { useServerSentEvents } from './hooks/useServerSentEvents';
43
import useViewport from './hooks/useViewport';
54
import Layout from './layouts/Layout';
65
import MobileLayout from './layouts/MobileLayout';
@@ -30,7 +29,6 @@ import WritePage from './pages/Write';
3029

3130
const App = () => {
3231
useViewport();
33-
useServerSentEvents();
3432

3533
return (
3634
<Routes>
@@ -39,10 +37,10 @@ const App = () => {
3937
<Route path="landing" element={<Landing />} />
4038
<Route path="*" element={<NotFoundPage />} />
4139
<Route path="auth-callback" element={<AuthCallbackPage />} />
42-
<Route index element={<Home />} />
4340
<Route path="onboarding" element={<OnboardingPage />} />
4441

4542
<Route element={<PrivateRoute />}>
43+
<Route index element={<Home />} />
4644
<Route path="letter">
4745
<Route element={<Layout />}>
4846
<Route path="random" element={<RandomLettersPage />} />

src/apis/write.ts

Lines changed: 1 addition & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
// import { AxiosResponse } from 'axios';
21
import client from './client';
32

43
const postLetter = async (data: LetterRequest) => {
@@ -33,7 +32,6 @@ const getPrevLetter = async (letterId: string) => {
3332
}
3433
};
3534

36-
// 임시저장 최초 생성
3735
const postTemporarySave = async (data: TemporaryRequest) => {
3836
try {
3937
const res = client.post(`/api/letters/temporary-save`, data);
@@ -44,15 +42,4 @@ const postTemporarySave = async (data: TemporaryRequest) => {
4442
}
4543
};
4644

47-
// 임시저장 수정
48-
const PatchTemporarySave = async (data: TemporaryRequest) => {
49-
try {
50-
const res = client.post(`/api/letters/temporary-save`, data);
51-
if (!res) throw new Error('편지 임시저장과정에서 오류가 발생했습니다.');
52-
return res;
53-
} catch (error) {
54-
console.error(error);
55-
}
56-
};
57-
58-
export { postLetter, postFirstReply, getPrevLetter, postTemporarySave, PatchTemporarySave };
45+
export { postLetter, postFirstReply, getPrevLetter, postTemporarySave };

src/components/Toast.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import useToastStore from '@/stores/toastStore';
2+
import ToastItem from './ToastItem';
3+
4+
interface Toast {}
5+
export default function Toast({}: Toast) {
6+
const toastObjects = useToastStore((state) => state.toastObjects);
7+
8+
if (toastObjects.length <= 0) return;
9+
return (
10+
<>
11+
{toastObjects.map((toastObj, index) => (
12+
<ToastItem toastObj={toastObj} index={index} key={index} />
13+
))}
14+
</>
15+
);
16+
}

src/components/ToastItem.tsx

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import useToastStore from '@/stores/toastStore';
2+
import { useEffect } from 'react';
3+
import { twMerge } from 'tailwind-merge';
4+
5+
interface ToastObj {
6+
time: number;
7+
toastType: 'Warning' | 'Success' | 'Error' | 'Info';
8+
position: 'Top' | 'Bottom';
9+
title: string;
10+
onClick?: () => void;
11+
}
12+
export default function ToastItem({ toastObj, index }: { toastObj: ToastObj; index: number }) {
13+
const setToastUnActive = useToastStore((state) => state.setToastUnActive);
14+
15+
const TOAST_DESIGN = {
16+
Warning: { style: 'bg-primary-4', imoji: '⚠️' },
17+
Success: { style: 'bg-[#38d9a9] text-[#FFFFFF]', imoji: '✅' },
18+
Error: { style: 'bg-[#FFDCD8] text-[#FF0000]', imoji: '🚨' },
19+
Info: { style: 'bg-[#FFFFFF]', imoji: '📫' },
20+
};
21+
22+
const TOAST_POSITION = {
23+
Top: 'top-20',
24+
Bottom: 'bottom-20',
25+
};
26+
27+
const animation = `toast-blink ${toastObj.time}s ease-in-out forwards`;
28+
const toastStyle = twMerge(
29+
'fixed bottom-20 left-1/2 z-40 flex h-[40px] max-w-150 min-w-[335px] w-[85%] -translate-1/2 items-center justify-center rounded-2xl caption-sb',
30+
TOAST_POSITION[toastObj.position],
31+
TOAST_DESIGN[toastObj.toastType].style,
32+
);
33+
34+
const activeTime = toastObj.time * 1000;
35+
useEffect(() => {
36+
const closeToast = setTimeout(() => {
37+
setToastUnActive(index);
38+
}, activeTime);
39+
40+
return () => clearTimeout(closeToast);
41+
});
42+
return (
43+
<div
44+
className={toastStyle}
45+
style={{ animation: animation }}
46+
onClick={() => {
47+
setToastUnActive(index);
48+
if (toastObj.onClick) toastObj.onClick();
49+
}}
50+
>
51+
{`${TOAST_DESIGN[toastObj.toastType].imoji} ${toastObj.title} ${TOAST_DESIGN[toastObj.toastType].imoji}`}
52+
</div>
53+
);
54+
}

src/hooks/useServerSentEvents.tsx

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,17 @@ import { EventSourcePolyfill } from 'event-source-polyfill';
22
import { useEffect, useRef } from 'react';
33

44
import useAuthStore from '@/stores/authStore';
5+
import useToastStore from '@/stores/toastStore';
6+
import { useNavigate } from 'react-router';
57

68
export const useServerSentEvents = () => {
7-
const accessToken = useAuthStore.getState().accessToken;
9+
const navigate = useNavigate();
10+
11+
const accessToken = useAuthStore((state) => state.accessToken);
812
const sourceRef = useRef<EventSourcePolyfill | null>(null);
913

14+
const setToastActive = useToastStore((state) => state.setToastActive);
15+
1016
useEffect(() => {
1117
if (!accessToken) {
1218
console.log('로그인 정보 확인불가');
@@ -27,18 +33,20 @@ export const useServerSentEvents = () => {
2733

2834
sourceRef.current.onmessage = (event) => {
2935
console.log(event);
30-
console.log('알림 전송');
36+
console.log('알림 수신');
37+
setToastActive({
38+
toastType: 'Info',
39+
title: '새 알림이 도착했어요!',
40+
position: 'Top',
41+
time: 5,
42+
onClick: () => navigate('/mypage/notifications'),
43+
});
3144
};
3245

33-
sourceRef.current.addEventListener('notification', (event) => {
34-
console.log(event);
35-
console.log('알림 전송 dd');
36-
});
37-
3846
sourceRef.current.onerror = (error) => {
3947
console.log(error);
4048
console.log('에러 발생함');
41-
sourceRef.current?.close();
49+
closeSSE();
4250
// 재연결 로직 추가 가능
4351
setTimeout(connectSSE, 5000); // 5초 후 재연결 시도
4452
};
@@ -55,11 +63,10 @@ export const useServerSentEvents = () => {
5563
};
5664
}, [accessToken]);
5765

58-
// 바깥으로 보낼 closeSSE 함수
5966
const closeSSE = () => {
6067
sourceRef.current?.close();
6168
sourceRef.current = null;
6269
};
6370

64-
return { closeSSE };
71+
// return { closeSSE };
6572
};

src/layouts/PrivateRoute.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@ import { useEffect, useState } from 'react';
22
import { useNavigate, Outlet } from 'react-router';
33

44
import useAuthStore from '@/stores/authStore';
5+
import { useServerSentEvents } from '@/hooks/useServerSentEvents';
6+
import Toast from '@/components/Toast';
57

68
export default function PrivateRoute() {
9+
useServerSentEvents();
710
const isLoggedIn = useAuthStore((state) => state.isLoggedIn);
811
const navigate = useNavigate();
912
const [shouldRender, setShouldRender] = useState(false);
@@ -20,5 +23,10 @@ export default function PrivateRoute() {
2023
return null;
2124
}
2225

23-
return <Outlet />;
26+
return (
27+
<>
28+
<Outlet />
29+
<Toast />
30+
</>
31+
);
2432
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import LetterWrapper from '@/components/LetterWrapper';
2+
import ModalOverlay from '@/components/ModalOverlay';
3+
import { useNavigate } from 'react-router';
4+
5+
export default function SendingModal({
6+
isOpenSendingModal,
7+
setIsOpenSendingModal,
8+
}: {
9+
isOpenSendingModal: boolean;
10+
setIsOpenSendingModal: React.Dispatch<React.SetStateAction<boolean>>;
11+
}) {
12+
const navigate = useNavigate();
13+
if (!isOpenSendingModal) return null;
14+
const onClose = () => {
15+
setIsOpenSendingModal(false);
16+
};
17+
return (
18+
<>
19+
<ModalOverlay closeOnOutsideClick onClose={onClose}>
20+
<LetterWrapper className="w-77">
21+
<div className="caption-r flex flex-col gap-2">
22+
<h2 className="body-b mb-3">편지 도착</h2>
23+
<span>편지는 작성된 시점으로 1시간 이후에 도착합니다.</span>
24+
<span>남은시간은 홈 화면의 편지 도착 시간 버튼을 눌러 확인 가능합니다.</span>
25+
<button
26+
className="body-b mt-3 flex items-center justify-center"
27+
onClick={() => navigate('/')}
28+
>
29+
홈 화면으로 이동 {'>'}
30+
</button>
31+
</div>
32+
</LetterWrapper>
33+
</ModalOverlay>
34+
</>
35+
);
36+
}

src/pages/Notifications/components/WarningModal.tsx

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,13 @@ import ModalOverlay from '@/components/ModalOverlay';
44

55
interface WarningModalProps {
66
isOpen: boolean;
7-
adminText: string;
7+
reportContent: string;
88
onClose: () => void;
99
}
1010

11-
const WarningModal = ({ isOpen, adminText, onClose }: WarningModalProps) => {
11+
const WarningModal = ({ isOpen, reportContent, onClose }: WarningModalProps) => {
12+
const divideContents = reportContent.split('§');
1213
if (!isOpen) return null;
13-
1414
return (
1515
<ModalOverlay closeOnOutsideClick onClose={onClose}>
1616
<article
@@ -21,15 +21,18 @@ const WarningModal = ({ isOpen, adminText, onClose }: WarningModalProps) => {
2121
>
2222
<div className="absolute inset-0 h-full w-full bg-white/90 blur-[25px]" />
2323
<div className="relative">
24-
<h2 className="body-sb mb-1.5 text-gray-100">관리자 코멘트</h2>
25-
<p className="caption-r mb-5 text-black">{adminText}</p>
26-
2724
<h2 className="body-sb mb-1.5 text-gray-100">경고 안내</h2>
2825
<p className="caption-r mb-5 text-black">
2926
따사로운 서비스 이용을 위해, 부적절하다고 판단되는 편지는 반려하고 있어요. 서로를
3027
존중하는 따뜻한 공간을 만들기 위해 협조 부탁드립니다.
3128
</p>
3229

30+
<h2 className="body-sb mb-1.5 text-gray-100">관리자 코멘트</h2>
31+
<p className="caption-r mb-5 text-black">{divideContents[0]}</p>
32+
33+
<h2 className="body-sb mb-1.5 text-gray-100">현재 경고 누적</h2>
34+
<p className="caption-r mb-5 text-black">{`${divideContents[1]} 회`}</p>
35+
3336
<h2 className="body-sb mb-1.5 text-gray-100">경고 규칙</h2>
3437
<p className="caption-r text-black">
3538
1회 경고: 주의 안내

src/pages/Notifications/constants/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export const NOTIFICATION_ICON: Record<
44
string,
55
React.ComponentType<React.SVGProps<SVGSVGElement>>
66
> = {
7+
SENDING: EnvelopeIcon,
78
LETTER: EnvelopeIcon,
89
REPORT: SirenFilledIcon,
910
SHARE: BoardIcon,

src/pages/Notifications/index.tsx

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,24 +6,29 @@ import PageTitle from '@/components/PageTitle';
66

77
import NotificationItem from './components/NotificationItem';
88
import WarningModal from './components/WarningModal';
9+
import SendingModal from './components/SendingModal';
910

1011
const NotificationsPage = () => {
1112
const navigate = useNavigate();
1213

1314
const [noti, setNoti] = useState<Noti[]>([]);
1415

1516
const [isOpenWarningModal, setIsOpenWarningModal] = useState(false);
17+
const [isOpenSendingModal, setIsOpenSendingModal] = useState(false);
1618

17-
const [adminText, setAdmintext] = useState<string>('');
19+
const [reportContent, setReportContent] = useState<string>('');
1820

1921
// MEMO : 편지 데이터 전송중 데이터도 추가될건데 나중에 데이터 추가되면 코드 업데이트 하긔
2022
const handleClickItem = (alarmType: string, content?: string | number) => {
23+
if (alarmType === 'SENDING') {
24+
setIsOpenSendingModal(true);
25+
}
2126
if (alarmType === 'LETTER') {
2227
navigate(`/letter/${content}`);
2328
}
2429
if (alarmType === 'REPORT') {
2530
setIsOpenWarningModal(true);
26-
if (typeof content === 'string') setAdmintext(content);
31+
if (typeof content === 'string') setReportContent(content);
2732
}
2833
if (alarmType === 'SHARE') {
2934
navigate(`/board/letter/${content}`, { state: { isShareLetterPreview: true } });
@@ -50,7 +55,16 @@ const NotificationsPage = () => {
5055

5156
const handlePatchReadNotificationAll = async () => {
5257
const res = await patchReadNotificationAll();
53-
if (res?.status !== 200) {
58+
if (res?.status === 200) {
59+
setNoti((currentNoti) => {
60+
return currentNoti.map((noti) => {
61+
if (!noti.read) {
62+
return { ...noti, read: true };
63+
}
64+
return noti;
65+
});
66+
});
67+
} else {
5468
console.log('모두 읽음처리 에러 발생');
5569
}
5670
};
@@ -63,9 +77,13 @@ const NotificationsPage = () => {
6377
<>
6478
<WarningModal
6579
isOpen={isOpenWarningModal}
66-
adminText={adminText}
80+
reportContent={reportContent}
6781
onClose={() => setIsOpenWarningModal(false)}
6882
/>
83+
<SendingModal
84+
isOpenSendingModal={isOpenSendingModal}
85+
setIsOpenSendingModal={setIsOpenSendingModal}
86+
/>
6987
<main className="flex grow flex-col items-center px-5 pt-20 pb-9">
7088
<PageTitle className="mb-10">알림</PageTitle>
7189
<button

0 commit comments

Comments
 (0)