1+ import { AxiosError } from 'axios' ;
2+ import { useAtomValue } from 'jotai' ; // [수정] Jotai 훅 추가
13import { useCallback , useEffect , useRef , useState } from 'react' ;
4+ import { useNavigate } from 'react-router-dom' ;
5+ import { BACKEND_URL } from '../../api/constants' ;
26import apiClient from '../../api/index' ;
7+ import { isLoggedInAtom } from '../../common/user' ; // [수정] atom import로 변경
38import { type RoomData } from '../../types' ;
49import RoomCard from './RoomCard' ;
510import './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+
3049const 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+
256366export default RoomSearch ;
0 commit comments