Skip to content

Commit a0f576b

Browse files
authored
feat : 편지작성, 랜덤편지, 상세페이지 3차 기능구현 (#94)
* 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 : 임시저장 최초답장 예외처리
1 parent 266d0e5 commit a0f576b

21 files changed

+423
-234
lines changed

src/apis/client.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,8 +102,8 @@ client.interceptors.response.use(
102102
}
103103
}
104104
}
105-
if (isLoggedIn) logout();
106-
console.error('Failed to refresh token', error);
105+
// if (isLoggedIn) logout();
106+
// console.error('Failed to refresh token', error);
107107
return Promise.reject(error);
108108
},
109109
);

src/apis/letterDetail.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,17 @@ const deleteLetter = async (letterId: string) => {
2323
}
2424
};
2525

26-
export { getLetter, deleteLetter };
26+
const postEvaluateLetter = async (letterId: number, evaluation: LetterEvaluation) => {
27+
try {
28+
const res = await client.post(`/api/letters/${letterId}/evaluate`, {
29+
evaluation: evaluation,
30+
});
31+
if (!res) throw new Error('편지 삭제 요청 도중 에러가 발생했습니다.');
32+
console.log(res);
33+
return res;
34+
} catch (error) {
35+
console.error(error);
36+
}
37+
};
38+
39+
export { getLetter, deleteLetter, postEvaluateLetter };

src/apis/randomLetter.ts

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ const getRandomLetters = async (category: string | null) => {
44
try {
55
const res = await client.get(`/api/random-letters/${category}`);
66
if (!res) throw new Error('랜덤 편지 데이터를 가져오는 도중 에러가 발생했습니다.');
7-
console.log(res);
87
return res;
98
} catch (error) {
109
console.error(error);
@@ -13,8 +12,6 @@ const getRandomLetters = async (category: string | null) => {
1312

1413
const postRandomLettersApprove = async (approveRequest: ApproveRequest, callBack?: () => void) => {
1514
try {
16-
console.log('엔드포인트 : /api/random-letters/approve');
17-
console.log('request', approveRequest);
1815
const res = await client.post('/api/random-letters/approve', approveRequest);
1916
if (!res) throw new Error('랜덤편지 매칭수락 도중 에러가 발생했습니다.');
2017
if (callBack) callBack();
@@ -30,7 +27,6 @@ const getRandomLetterMatched = async (callBack?: () => void) => {
3027
if (!res)
3128
throw new Error('랜덤 편지 최종 매칭 시간 검증 데이터를 가자오는 도중 에러가 발생했습니다.');
3229
if (callBack) callBack();
33-
console.log(res);
3430
return res;
3531
} catch (error) {
3632
console.error(error);
@@ -43,7 +39,6 @@ const getRandomLetterCoolTime = async (callBack?: () => void) => {
4339
if (!res)
4440
throw new Error('랜덤 편지 최종 매칭 시간 검증 데이터를 가자오는 도중 에러가 발생했습니다.');
4541
if (callBack) callBack();
46-
console.log(res);
4742
return res;
4843
} catch (error) {
4944
console.error(error);
@@ -54,7 +49,6 @@ const deleteRandomLetterMatching = async () => {
5449
try {
5550
const res = await client.delete('/api/random-letters/matching/cancel');
5651
if (!res) throw new Error('매칭 취소 도중 에러가 발생했습니다.');
57-
console.log(res);
5852
return res;
5953
} catch (error) {
6054
console.log(error);

src/apis/write.ts

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,21 @@
22
import client from './client';
33

44
const postLetter = async (data: LetterRequest) => {
5+
console.log('request', data);
56
try {
67
const res = await client.post('/api/letters', data);
7-
if (!res) throw new Error('편지 전송과정중에서 오류가 발생했습니다.');
8-
console.log(`api 주소 : /api/letters, 전송타입 : post`);
8+
if (!res) throw new Error('편지 전송과정에서 오류가 발생했습니다.');
99
return res;
1010
} catch (error) {
1111
console.error(error);
1212
}
1313
};
1414

1515
const postFirstReply = async (data: FirstReplyRequest) => {
16+
console.log('Firstrequest', data);
1617
try {
1718
const res = await client.post('/api/random-letters/matching', data);
18-
if (!res) throw new Error('최초 답장 전송과정중에서 오류가 발생했습니다.');
19-
console.log(`api 주소 : /api/random-letters/matching, 전송타입 : post`);
20-
console.log(res);
19+
if (!res) throw new Error('최초 답장 전송과정에서 오류가 발생했습니다.');
2120
return res;
2221
} catch (error) {
2322
console.error(error);
@@ -27,11 +26,33 @@ const postFirstReply = async (data: FirstReplyRequest) => {
2726
const getPrevLetter = async (letterId: string) => {
2827
try {
2928
const res = await client.get(`/api/letters/${letterId}/previous`);
30-
console.log(res);
29+
if (!res) throw new Error('이전편지를 불러오는중 오류가 발생했습니다.');
3130
return res;
3231
} catch (error) {
3332
console.error(error);
3433
}
3534
};
3635

37-
export { postLetter, postFirstReply, getPrevLetter };
36+
// 임시저장 최초 생성
37+
const postTemporarySave = async (data: TemporaryRequest) => {
38+
try {
39+
const res = client.post(`/api/letters/temporary-save`, data);
40+
if (!res) throw new Error('편지 임시저장과정에서 오류가 발생했습니다.');
41+
return res;
42+
} catch (error) {
43+
console.error(error);
44+
}
45+
};
46+
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 };

src/components/ReportModal.tsx

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useEffect, useState } from 'react';
1+
import { useState } from 'react';
22
import { twMerge } from 'tailwind-merge';
33

44
import { postReports } from '@/apis/admin';
@@ -8,7 +8,7 @@ import TextareaField from './TextareaField';
88

99
interface ReportModalProps {
1010
reportType: ReportType;
11-
letterId: number | null;
11+
letterId: number;
1212
onClose: () => void;
1313
}
1414

@@ -42,20 +42,14 @@ const ReportModal = ({ reportType, letterId, onClose }: ReportModalProps) => {
4242
const res = await postReports(postReportRequest);
4343
if (res?.status === 200) {
4444
alert('신고 처리되었습니다.');
45+
console.log(res);
4546
onClose();
4647
} else if (res?.status === 409) {
4748
alert('신고한 이력이 있습니다.');
4849
onClose();
4950
}
5051
};
5152

52-
useEffect(() => {
53-
if (!postReportRequest.letterId) {
54-
alert('신고 모달을 여는 과정에서 오류가 발생했습니다. 새로고침을 눌러주세요');
55-
onClose();
56-
}
57-
});
58-
5953
return (
6054
<ConfirmModal
6155
title="신고 사유를 선택해주세요"
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { postEvaluateLetter } from '@/apis/letterDetail';
2+
import { CloudIcon, SnowIcon, WarmIcon } from '@/assets/icons';
3+
4+
interface DegreeSelector {
5+
letterDetail: LetterDetail | null;
6+
setLetterDetail: React.Dispatch<React.SetStateAction<LetterDetail>>;
7+
}
8+
export default function DegreeSelector({ letterDetail, setLetterDetail }: DegreeSelector) {
9+
const handlePostEvaluateLetter = async (
10+
letterId: number | undefined,
11+
evaluation: LetterEvaluation,
12+
) => {
13+
if (!letterId) return alert('편지id값이 담겨있지 않습니다.');
14+
const res = await postEvaluateLetter(letterId, evaluation);
15+
if (res?.status === 200) {
16+
console.log('평가완료');
17+
setLetterDetail((cur) => ({ ...cur, evaluated: true }));
18+
}
19+
};
20+
const DEGREES = [
21+
{
22+
icon: <WarmIcon className="h-5 w-5" />,
23+
title: '따뜻해요',
24+
onClick: () => {
25+
handlePostEvaluateLetter(letterDetail?.letterId, 'GOOD');
26+
},
27+
},
28+
{
29+
icon: <CloudIcon className="h-5 w-5" />,
30+
title: '그럭저럭',
31+
onClick: () => {
32+
handlePostEvaluateLetter(letterDetail?.letterId, 'SOSO');
33+
},
34+
},
35+
{
36+
icon: <SnowIcon className="h-5 w-5" />,
37+
title: '앗! 차가워',
38+
onClick: () => {
39+
handlePostEvaluateLetter(letterDetail?.letterId, 'BAD');
40+
},
41+
},
42+
];
43+
return (
44+
<div className="caption-b text-primary-1 bg-primary-5 absolute top-7 z-40 flex flex-col gap-1 p-2 shadow">
45+
{DEGREES.map((degree, idx) => {
46+
return (
47+
<button
48+
key={idx}
49+
className="flex items-center justify-start gap-1"
50+
onClick={() => {
51+
degree.onClick();
52+
}}
53+
>
54+
{degree.icon}
55+
{degree.title}
56+
</button>
57+
);
58+
})}
59+
</div>
60+
);
61+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { twMerge } from 'tailwind-merge';
2+
3+
import { FONT_TYPE_OBJ } from '@/pages/Write/constants';
4+
5+
interface LetterDetailContent {
6+
letterDetail: LetterDetail;
7+
}
8+
export default function LetterDetailContent({ letterDetail }: LetterDetailContent) {
9+
return (
10+
<>
11+
<div className="flex flex-col gap-3 px-5">
12+
<span className="body-b mt-[55px]">TO. 따숨이</span>
13+
<span className="body-sb">{letterDetail.title}</span>
14+
</div>
15+
<textarea
16+
readOnly
17+
value={letterDetail.content}
18+
className={twMerge(
19+
`body-r basic-theme min-h-full w-full grow resize-none px-6`,
20+
letterDetail && FONT_TYPE_OBJ[letterDetail.fontType],
21+
)}
22+
></textarea>
23+
<span className="body-sb mt-10 flex justify-end">FROM. {letterDetail.zipCode}</span>
24+
</>
25+
);
26+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { useEffect, useRef } from 'react';
2+
3+
import { ThermostatIcon } from '@/assets/icons';
4+
5+
interface LetterDetailDegreeButton {
6+
letterDetail: LetterDetail | null;
7+
setDegreeModalOpen: React.Dispatch<React.SetStateAction<boolean>>;
8+
}
9+
export default function LetterDetailDegreeButton({
10+
letterDetail,
11+
setDegreeModalOpen,
12+
}: LetterDetailDegreeButton) {
13+
const degreeButtonRef = useRef<HTMLButtonElement>(null);
14+
15+
useEffect(() => {
16+
const handleOutsideClick = (event: MouseEvent) => {
17+
const target = event.target as Node;
18+
if (!target || degreeButtonRef.current?.contains(target)) {
19+
return;
20+
}
21+
setDegreeModalOpen(false);
22+
};
23+
24+
document.body.addEventListener('click', handleOutsideClick);
25+
26+
return () => {
27+
document.body.removeEventListener('click', handleOutsideClick);
28+
};
29+
}, [setDegreeModalOpen]);
30+
return (
31+
<>
32+
{letterDetail?.evaluated ? (
33+
<div>
34+
<span className="caption-b text-primary-1">온도 측정된 편지에요!</span>
35+
</div>
36+
) : (
37+
<button
38+
ref={degreeButtonRef}
39+
className="flex items-center justify-center gap-1"
40+
onClick={() => {
41+
setDegreeModalOpen((cur) => !cur);
42+
}}
43+
>
44+
<ThermostatIcon className="h-6 w-6" />
45+
<span className="caption-b text-primary-1">편지 온도</span>
46+
</button>
47+
)}
48+
</>
49+
);
50+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { useState } from 'react';
2+
3+
import { DeleteIcon, SirenOutlinedIcon } from '@/assets/icons';
4+
import BackButton from '@/components/BackButton';
5+
import useAuthStore from '@/stores/authStore';
6+
7+
import DegreeSelector from './DegreeSelector';
8+
import LetterDetailDegreeButton from './LetterDetailDegreeButton';
9+
10+
interface LetterDetailHeader {
11+
letterDetail: LetterDetail;
12+
setLetterDetail: React.Dispatch<React.SetStateAction<LetterDetail>>;
13+
setDeleteModalOpen: React.Dispatch<React.SetStateAction<boolean>>;
14+
setReportModalOpen: React.Dispatch<React.SetStateAction<boolean>>;
15+
}
16+
export default function LetterDetailHeader({
17+
letterDetail,
18+
setLetterDetail,
19+
setDeleteModalOpen,
20+
setReportModalOpen,
21+
}: LetterDetailHeader) {
22+
const [degreeModalOpen, setDegreeModalOpen] = useState<boolean>(false);
23+
24+
const userZipCode = useAuthStore((state) => state.zipCode);
25+
26+
return (
27+
<div className="absolute top-5 left-0 flex w-full justify-between px-5">
28+
<BackButton />
29+
<div className="flex gap-2">
30+
{userZipCode !== letterDetail?.zipCode && (
31+
<LetterDetailDegreeButton
32+
letterDetail={letterDetail}
33+
setDegreeModalOpen={setDegreeModalOpen}
34+
/>
35+
)}
36+
{userZipCode === letterDetail?.zipCode && (
37+
<button
38+
onClick={() => {
39+
setDeleteModalOpen(true);
40+
}}
41+
>
42+
<DeleteIcon className="text-primary-1 h-6 w-6" />
43+
</button>
44+
)}
45+
{userZipCode !== letterDetail?.zipCode && (
46+
<button
47+
onClick={() => {
48+
setReportModalOpen(true);
49+
}}
50+
>
51+
<SirenOutlinedIcon className="text-primary-1 h-6 w-6" />
52+
</button>
53+
)}
54+
{degreeModalOpen && (
55+
<DegreeSelector letterDetail={letterDetail} setLetterDetail={setLetterDetail} />
56+
)}
57+
</div>
58+
</div>
59+
);
60+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { useNavigate } from 'react-router';
2+
3+
interface LetterDetailReplyButton {
4+
letterDetail: LetterDetail;
5+
}
6+
export default function LetterDetailReplyButton({ letterDetail }: LetterDetailReplyButton) {
7+
const navigate = useNavigate();
8+
return (
9+
<button
10+
className="bg-primary-3 disabled:bg-gray-30 body-m mt-3 w-full rounded-lg py-2 disabled:text-white"
11+
onClick={() => {
12+
navigate(`/letter/write/?letterId=${letterDetail.letterId}`);
13+
}}
14+
disabled={!letterDetail?.matched}
15+
>
16+
{letterDetail?.matched ? '편지 작성하기' : '대화가 종료된 편지입니다.'}
17+
</button>
18+
);
19+
}

0 commit comments

Comments
 (0)