Skip to content

Commit 0302a28

Browse files
authored
Room search page (#26)
1 parent 105c473 commit 0302a28

File tree

4 files changed

+197
-83
lines changed

4 files changed

+197
-83
lines changed

src/App.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { useSetAtom } from 'jotai';
22
import { useEffect } from 'react';
3-
import { User, getMe } from './api/auth';
3+
import { type User, getMe } from './api/auth';
44
import {
55
emailAtom,
66
isLoggedInAtom,

src/api/room.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ export const getCurrentPot = async (): Promise<Pot> => {
4141
return response.data;
4242
};
4343

44-
export const deleteRoom = async (roomId: number): Promise<void> => {
45-
await apiClient.delete(`/rooms/${roomId}`);
44+
// export const deleteRoom = async (roomId: number): Promise<void> => {
45+
// await apiClient.delete(`/rooms/${roomId}`);
46+
// };
47+
48+
export const leaveRoom = async (roomId: number): Promise<void> => {
49+
await apiClient.post(`/rooms/${roomId}/leave`);
4650
};

src/pages/MyChat/index.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { isAxiosError } from 'axios';
22
import { useCallback, useEffect, useState } from 'react';
33
import { Link } from 'react-router-dom';
4-
import { Pot, deleteRoom, getCurrentPot } from '../../api/room';
4+
import { type Pot, getCurrentPot, leaveRoom } from '../../api/room';
55
import './MyChat.css';
66

77
const landmarks = {
@@ -41,7 +41,7 @@ const MyChat = () => {
4141
// eslint-disable-next-line no-restricted-globals
4242
if (confirm('정말로 현재 방에서 나가시겠습니까?')) {
4343
try {
44-
await deleteRoom(currentPot.id);
44+
await leaveRoom(currentPot.id);
4545
alert('방에서 나갔습니다.');
4646
fetchCurrentPot(); // Refresh pot data
4747
} catch (error: unknown) {

src/pages/SearchRoom/RoomSearch.tsx

Lines changed: 188 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
1+
import { AxiosError } from 'axios';
2+
import { useAtomValue } from 'jotai'; // [수정] Jotai 훅 추가
13
import { useCallback, useEffect, useRef, useState } from 'react';
4+
import { useNavigate } from 'react-router-dom';
5+
import { BACKEND_URL } from '../../api/constants';
26
import apiClient from '../../api/index';
7+
import { isLoggedInAtom } from '../../common/user'; // [수정] atom import로 변경
38
import { type RoomData } from '../../types';
49
import RoomCard from './RoomCard';
510
import './RoomSearch.css';
@@ -27,9 +32,27 @@ const getLandmarkName = (id: number) => {
2732
return LANDMARKS.find((l) => l.id === id)?.name || '알 수 없음';
2833
};
2934

35+
// 서버 API 응답 데이터(PotDto)의 타입 정의
36+
interface PotDto {
37+
id: number;
38+
ownerId: number;
39+
departureId: number;
40+
destinationId: number;
41+
departureTime: string;
42+
minCapacity: number;
43+
maxCapacity: number;
44+
currentCount: number;
45+
estimatedFee: number;
46+
status: string;
47+
}
48+
3049
const RoomSearch = () => {
50+
const navigate = useNavigate();
51+
52+
// [수정] 전역 상태(Atom)에서 로그인 여부 구독
53+
const isLoggedIn = useAtomValue(isLoggedInAtom);
54+
3155
// --- 검색 필터 상태 ---
32-
// 0은 '전체'를 의미 (초기값 0으로 설정하여 전체 검색 상태로 시작 가능)
3356
const [departureId, setDepartureId] = useState<number>(0);
3457
const [destinationId, setDestinationId] = useState<number>(0);
3558

@@ -39,6 +62,11 @@ const RoomSearch = () => {
3962
const [hasMore, setHasMore] = useState(true);
4063
const [loading, setLoading] = useState(false);
4164

65+
// --- 모달 상태 ---
66+
const [selectedRoomId, setSelectedRoomId] = useState<number | null>(null);
67+
const [showLoginModal, setShowLoginModal] = useState(false);
68+
const [showJoinModal, setShowJoinModal] = useState(false);
69+
4270
// --- Refs ---
4371
const loadingRef = useRef(false);
4472
const observerRef = useRef<IntersectionObserver | null>(null);
@@ -53,90 +81,42 @@ const RoomSearch = () => {
5381
setLoading(true);
5482

5583
try {
56-
// 1. 검색 대상 ID 목록 생성
57-
// 0(전체)이면 LANDMARKS의 모든 ID를, 아니면 선택된 ID 하나만 배열에 담음
58-
const targetDepIds =
59-
departureId === 0 ? LANDMARKS.map((l) => l.id) : [departureId];
60-
const targetDestIds =
61-
destinationId === 0 ? LANDMARKS.map((l) => l.id) : [destinationId];
62-
63-
// 2. 요청할 모든 조합 생성 (최악의 경우 15 x 15 = 225개 요청이므로 주의 필요)
64-
// 실제로는 사용자가 한쪽만 '전체'로 두는 경우가 많을 것임
65-
const requestPromises = [];
66-
67-
for (const depId of targetDepIds) {
68-
for (const destId of targetDestIds) {
69-
// 출발지와 도착지가 같으면 굳이 조회할 필요 없음 (서버 로직상 불가능할 수 있음)
70-
if (depId === destId) continue;
71-
72-
requestPromises.push(
73-
apiClient.get('/rooms/search', {
74-
params: {
75-
departureId: depId,
76-
destinationId: destId,
77-
page: pageNumber,
78-
size: 10, // 각 요청당 10개씩 (합치면 많아질 수 있음)
79-
sort: ['departureTime,asc'],
80-
},
81-
})
82-
);
83-
}
84-
}
84+
const params = {
85+
departureId: departureId === 0 ? null : departureId,
86+
destinationId: destinationId === 0 ? null : destinationId,
87+
page: pageNumber,
88+
size: 10,
89+
sort: 'departureTime,asc',
90+
};
8591

86-
// 3. 모든 API 요청 병렬 실행
87-
const responses = await Promise.all(requestPromises);
88-
89-
// 4. 결과 취합 및 데이터 매핑
90-
let aggregatedRooms: RoomData[] = [];
91-
let isAllLast = true; // 모든 요청이 마지막 페이지인지 확인
92-
93-
for (const res of responses) {
94-
const content = res.data.content || [];
95-
// 하나라도 마지막 페이지가 아니면(데이터가 더 있으면) hasMore는 true
96-
if (res.data.last === false) {
97-
isAllLast = false;
98-
}
99-
100-
// biome-ignore lint/suspicious/noExplicitAny: API 응답 처리
101-
const mapped = content.map((item: any) => ({
102-
roomId: item.id,
103-
departure: getLandmarkName(item.departureId),
104-
destination: getLandmarkName(item.destinationId),
105-
departureTime: item.departureTime,
106-
maxCapacity: item.maxCapacity,
107-
currentCapacity: item.currentCount,
108-
hostName: `학우 ${item.ownerId}`,
109-
}));
110-
aggregatedRooms = [...aggregatedRooms, ...mapped];
111-
}
92+
const response = await apiClient.get('/rooms/search', { params });
93+
const content = response.data.content || [];
11294

113-
// 5. 시간순 정렬 (여러 API 결과를 합쳤으므로 순서가 섞여있을 수 있음)
114-
aggregatedRooms.sort(
115-
(a, b) =>
116-
new Date(a.departureTime).getTime() -
117-
new Date(b.departureTime).getTime()
118-
);
95+
const newRooms: RoomData[] = content.map((item: PotDto) => ({
96+
roomId: item.id,
97+
departure: getLandmarkName(item.departureId),
98+
destination: getLandmarkName(item.destinationId),
99+
departureTime: item.departureTime,
100+
maxCapacity: item.maxCapacity,
101+
currentCapacity: item.currentCount,
102+
hostName: `학우 ${item.ownerId}`,
103+
}));
104+
105+
const isLast = response.data.last ?? newRooms.length === 0;
119106

120-
// 6. 상태 업데이트
121107
if (isNewSearch) {
122-
setRooms(aggregatedRooms);
108+
setRooms(newRooms);
123109
} else {
124110
setRooms((prev) => {
125-
// 중복 제거 (여러 페이지 요청 시 겹칠 수 있는 가능성 대비)
126111
const existingIds = new Set(prev.map((r) => r.roomId));
127-
const uniqueNewRooms = aggregatedRooms.filter(
112+
const uniqueRooms = newRooms.filter(
128113
(r) => !existingIds.has(r.roomId)
129114
);
130-
return [...prev, ...uniqueNewRooms].sort(
131-
(a, b) =>
132-
new Date(a.departureTime).getTime() -
133-
new Date(b.departureTime).getTime()
134-
);
115+
return [...prev, ...uniqueRooms];
135116
});
136117
}
137118

138-
// 데이터가 하나도 없거나, 모든 요청이 마지막 페이지라면 더 이상 불러올 게 없음
139-
setHasMore(!isAllLast && aggregatedRooms.length > 0);
119+
setHasMore(!isLast);
140120
} catch (error) {
141121
console.error('방 목록 불러오기 실패:', error);
142122
} finally {
@@ -147,14 +127,14 @@ const RoomSearch = () => {
147127
[departureId, destinationId]
148128
);
149129

150-
// --- Effect 1: 필터 변경 시 (페이지 0부터 재검색) ---
130+
// --- Effect 1: 필터 변경 시 ---
151131
useEffect(() => {
152132
setPage(0);
153133
setHasMore(true);
154134
fetchRooms(0, true);
155135
}, [fetchRooms]);
156136

157-
// --- Effect 2: 페이지 변경 시 (추가 로드) ---
137+
// --- Effect 2: 페이지 변경 시 ---
158138
useEffect(() => {
159139
if (page > 0) {
160140
fetchRooms(page, false);
@@ -185,9 +165,49 @@ const RoomSearch = () => {
185165
};
186166
}, [loading, hasMore]);
187167

188-
const handleRoomClick = (_roomId: number) => {
189-
// console.log(`${roomId}번 방 클릭`);
190-
// navigate(`/room/${roomId}`);
168+
// --- 핸들러: 방 클릭 ---
169+
const handleRoomClick = (roomId: number) => {
170+
setSelectedRoomId(roomId);
171+
172+
// [수정] atom 값(isLoggedIn)을 사용하여 분기 처리
173+
if (isLoggedIn) {
174+
// 로그인 상태 -> 참여 확인 모달
175+
setShowJoinModal(true);
176+
} else {
177+
// 비로그인 상태 -> 로그인 유도 모달
178+
setShowLoginModal(true);
179+
}
180+
};
181+
182+
// --- 핸들러: 로그인 버튼 클릭 ---
183+
const handleLoginConfirm = () => {
184+
const frontendRedirectUri = window.location.origin;
185+
const encodedUri = encodeURIComponent(frontendRedirectUri);
186+
const googleLoginUrl = `${BACKEND_URL}/login?redirect_uri=${encodedUri}`;
187+
window.location.href = googleLoginUrl;
188+
};
189+
190+
// --- 핸들러: 참여하기 버튼 클릭 ---
191+
const handleJoinConfirm = async () => {
192+
if (!selectedRoomId) return;
193+
194+
try {
195+
await apiClient.post(`/rooms/${selectedRoomId}/join`);
196+
navigate('/my-chat');
197+
} catch (error) {
198+
const axiosError = error as AxiosError<{ errMsg: string }>;
199+
const errMsg =
200+
axiosError.response?.data?.errMsg || '참여에 실패했습니다.';
201+
202+
alert(errMsg);
203+
setShowJoinModal(false);
204+
}
205+
};
206+
207+
const closeModals = () => {
208+
setShowLoginModal(false);
209+
setShowJoinModal(false);
210+
setSelectedRoomId(null);
191211
};
192212

193213
const handleFilterChange = (
@@ -249,8 +269,98 @@ const RoomSearch = () => {
249269
</div>
250270
)}
251271
</div>
272+
273+
{/* --- 모달 UI (인라인 스타일 적용) --- */}
274+
{(showLoginModal || showJoinModal) && (
275+
<div style={modalOverlayStyle}>
276+
<div style={modalContentStyle}>
277+
{showLoginModal && (
278+
<>
279+
<p>택시팟에 참여하시려면 로그인이 필요합니다.</p>
280+
<div style={buttonGroupStyle}>
281+
<button onClick={closeModals} style={cancelButtonStyle}>
282+
뒤로가기
283+
</button>
284+
<button
285+
onClick={handleLoginConfirm}
286+
style={confirmButtonStyle}
287+
>
288+
로그인
289+
</button>
290+
</div>
291+
</>
292+
)}
293+
{showJoinModal && (
294+
<>
295+
<p>택시팟에 참가하시겠습니까?</p>
296+
<div style={buttonGroupStyle}>
297+
<button onClick={closeModals} style={cancelButtonStyle}>
298+
뒤로가기
299+
</button>
300+
<button
301+
onClick={handleJoinConfirm}
302+
style={confirmButtonStyle}
303+
>
304+
참여하기
305+
</button>
306+
</div>
307+
</>
308+
)}
309+
</div>
310+
</div>
311+
)}
252312
</div>
253313
);
254314
};
255315

316+
// --- 간단한 모달 스타일 (필요시 CSS 파일로 이동) ---
317+
const modalOverlayStyle: React.CSSProperties = {
318+
position: 'fixed',
319+
top: 0,
320+
left: 0,
321+
width: '100%',
322+
height: '100%',
323+
backgroundColor: 'rgba(0, 0, 0, 0.5)',
324+
display: 'flex',
325+
alignItems: 'center',
326+
justifyContent: 'center',
327+
zIndex: 1000,
328+
};
329+
330+
const modalContentStyle: React.CSSProperties = {
331+
backgroundColor: 'white',
332+
padding: '24px',
333+
borderRadius: '12px',
334+
width: '300px',
335+
textAlign: 'center',
336+
boxShadow: '0 2px 10px rgba(0,0,0,0.2)',
337+
};
338+
339+
const buttonGroupStyle: React.CSSProperties = {
340+
display: 'flex',
341+
justifyContent: 'space-between',
342+
marginTop: '20px',
343+
gap: '10px',
344+
};
345+
346+
const cancelButtonStyle: React.CSSProperties = {
347+
flex: 1,
348+
padding: '10px',
349+
borderRadius: '8px',
350+
border: '1px solid #ccc',
351+
backgroundColor: '#f5f5f5',
352+
cursor: 'pointer',
353+
};
354+
355+
const confirmButtonStyle: React.CSSProperties = {
356+
flex: 1,
357+
padding: '10px',
358+
borderRadius: '8px',
359+
border: 'none',
360+
backgroundColor: '#3b82f6',
361+
color: 'white',
362+
cursor: 'pointer',
363+
fontWeight: 'bold',
364+
};
365+
256366
export default RoomSearch;

0 commit comments

Comments
 (0)