diff --git a/app/meeting/[id]/page.tsx b/app/meeting/[id]/page.tsx index 30605cc..64b5433 100644 --- a/app/meeting/[id]/page.tsx +++ b/app/meeting/[id]/page.tsx @@ -130,7 +130,6 @@ export default function Page() { router.push(`/result/${id}`); }; - // ⭐ API에서 받은 내 출발지 정보를 selectedStation에 반영 (초기화 시 한 번만) useEffect(() => { if ( meetingData?.data?.participants && @@ -143,56 +142,49 @@ export default function Page() { if (myData?.stationName) { setTimeout(() => { setSelectedStation(myData.stationName); - hasInitializedRef.current = true; // 초기화 완료 + hasInitializedRef.current = true; }, 0); } else { - hasInitializedRef.current = true; // 데이터 없어도 초기화 완료 + hasInitializedRef.current = true; } } }, [meetingData, myName]); - const myParticipant = useMemo(() => { - if (!selectedStation || !myName) return null; - - const info = STATION_DATA.find((s) => s.name === selectedStation); - if (!info) return null; - - return { - id: 'me', - name: myName, - station: info.name, - line: info.line, - latitude: info.latitude, - longitude: info.longitude, - status: 'done', - hexColor: '#000000', - }; - }, [selectedStation, myName]); - const allParticipants = useMemo(() => { if (!myName) return []; const serverParticipants = meetingData?.data.participants || []; - const others = serverParticipants - .filter((p) => p.userName !== myName) - .map((p, index) => { - const stationInfo = STATION_DATA.find((s) => s.name === p.stationName); - - return { - id: `other-${index}`, - name: p.userName, - station: p.stationName, - line: stationInfo?.line ?? '미확인', - latitude: p.latitude, - longitude: p.longitude, - status: 'done', - hexColor: getRandomHexColor(p.userName || p.stationName || `user-${index}`), - }; - }); + const participantsWithColor = serverParticipants.map((p, index) => { + const stationInfo = STATION_DATA.find((s) => s.name === p.stationName); + + return { + id: p.userName === myName ? 'me' : `other-${index}`, + name: p.userName, + station: p.stationName, + line: stationInfo?.line ?? '미확인', + latitude: p.latitude, + longitude: p.longitude, + status: 'done', + hexColor: getRandomHexColor(p.userName, id), + }; + }); + + const myParticipant = participantsWithColor.find((p) => p.name === myName); + const others = participantsWithColor.filter((p) => p.name !== myName); + + if (myParticipant && selectedStation) { + const info = STATION_DATA.find((s) => s.name === selectedStation); + if (info) { + myParticipant.station = info.name; + myParticipant.line = info.line; + myParticipant.latitude = info.latitude; + myParticipant.longitude = info.longitude; + } + } return myParticipant ? [myParticipant, ...others] : others; - }, [meetingData, myParticipant, myName]); + }, [meetingData, selectedStation, myName, id]); if (!isMounted || !myName) { return ( @@ -270,7 +262,7 @@ export default function Page() {
-
+
{allParticipants.length > 0 ? ( allParticipants.map((user) => (
{user.name.charAt(0)}
@@ -299,7 +291,7 @@ export default function Page() {
-
+
{!isResultEnabled && (
모든 팀원이 참여해야 결과를 확인할 수 있어요 diff --git a/app/recommend/page.tsx b/app/recommend/page.tsx index c427d87..f726b29 100644 --- a/app/recommend/page.tsx +++ b/app/recommend/page.tsx @@ -29,17 +29,17 @@ function RecommendContent() { // 모임 정보 조회 (purposes 정보를 가져오기 위해) const { data: meetingData } = useCheckMeeting(meetingId); - // 🔥 상위 카테고리 추출 (우선순위: URL > API > localStorage) + // 상위 카테고리 추출 (우선순위: URL > API > localStorage) const meetingType = useMemo(() => { if (typeof window === 'undefined') return null; - // 1. URL 쿼리스트링에서 가져오기 (최우선) + // 1. URL 쿼리스트링에서 가져오기 if (meetingTypeFromUrl === '회의' || meetingTypeFromUrl === '친목') { localStorage.setItem(`meeting_${meetingId}_meetingType`, meetingTypeFromUrl); return meetingTypeFromUrl; } - // 2. API에서 purposes 가져오기 (참여자도 접근 가능) + // 2. API에서 purposes 가져오기 if (meetingData?.data?.purposes && meetingData.data.purposes.length > 0) { const firstPurpose = meetingData.data.purposes[0]; if (firstPurpose === '회의' || firstPurpose === '친목') { @@ -57,11 +57,11 @@ function RecommendContent() { return null; }, [meetingId, meetingData, meetingTypeFromUrl]); - // 🔥 하위 카테고리 추출 (우선순위: URL > API > localStorage) + // 하위 카테고리 추출 (우선순위: URL > API > localStorage) const defaultCategory = useMemo(() => { if (typeof window === 'undefined') return ''; - // 1. URL 쿼리스트링에서 가져오기 (최우선) + // 1. URL 쿼리스트링에서 가져오기 if (categoryFromUrl) { localStorage.setItem(`meeting_${meetingId}_category`, categoryFromUrl); return categoryFromUrl; @@ -71,7 +71,7 @@ function RecommendContent() { if (meetingData?.data?.purposes && meetingData.data.purposes.length > 1) { const subCategory = meetingData.data.purposes[meetingData.data.purposes.length - 1]; if (subCategory) { - // localStorage에도 저장 (다음 접근 시 빠르게 사용) + // localStorage에도 저장 localStorage.setItem(`meeting_${meetingId}_category`, subCategory); return subCategory; } @@ -140,7 +140,7 @@ function RecommendContent() { }; const handleOpenKakaoMap = (e: React.MouseEvent, placeUrl?: string) => { - e.stopPropagation(); // 카드 클릭 이벤트 버블링 방지 + e.stopPropagation(); if (placeUrl) { window.open(placeUrl, '_blank', 'noopener,noreferrer'); } else { @@ -197,7 +197,7 @@ function RecommendContent() { onClick={() => setSelectedPlaceId(place.id)} className={`flex cursor-pointer flex-col gap-2 rounded border p-4 ${ selectedPlaceId === place.id - ? 'border-blue-5 border-2' // 선택 시 파란 테두리 + ? 'border-blue-5 border-2' : 'border-gray-2 hover:bg-gray-1 bg-white' }`} > @@ -260,7 +260,7 @@ function RecommendContent() {
- {/* [RIGHT PANEL] 데스크탑 지도 영역 (새로운 컴포넌트 적용) */} + {/* [RIGHT PANEL] 데스크탑 지도 영역 */}
{ const { endStation, endStationLine, userRoutes } = midpoint; - const myRoute = userRoutes.find((route) => route.nickname === myNickname); + + const routesWithColor = userRoutes.map((route) => { + return { + ...route, + hexColor: getRandomHexColor(route.nickname, id), + }; + }); + + const myRoute = routesWithColor.find((route) => route.nickname === myNickname); const travelTime = myRoute?.travelTime || 0; const extractLineNumber = (linenumber: string): string => { @@ -89,10 +98,10 @@ export default function Page() { travelTime, transferPath: myRoute?.transferPath || [], transferPathLines, - userRoutes, + userRoutes: routesWithColor, }; }); - }, [midpointData, myNickname]); + }, [midpointData, myNickname, id]); const [selectedResultId, setSelectedResultId] = useState(1); @@ -159,7 +168,7 @@ export default function Page() { case '서해선': return 'bg-[#5EAC41]'; default: - return 'bg-gray-4'; + return 'bg-gray-400'; } }; @@ -172,9 +181,7 @@ export default function Page() {
) : ( <> - {/* [LEFT PANEL] 결과 리스트 영역 */}
- {/* 헤더 섹션 */}
@@ -191,7 +198,6 @@ export default function Page() {
- {/* 모바일 전용 지도 영역 */} {locationResults.length > 0 && (() => { const selectedResult = @@ -213,9 +219,7 @@ export default function Page() { ); })()} - {/* 결과 리스트 & 하단 버튼 */}
- {/* 리스트 스크롤 영역 */}
{isError || locationResults.length === 0 ? ( @@ -235,7 +239,6 @@ export default function Page() { : 'border-gray-2 hover:bg-gray-1' }`} > - {/* 카드 헤더: 역 이름 & 시간 */}
{result.endStation}역 @@ -248,7 +251,6 @@ export default function Page() {
- {/* 환승 경로 (호선 아이콘) */}
내 환승경로
@@ -278,7 +280,6 @@ export default function Page() { )}
- {/* 모임원 경로 보기 버튼 */}
- {/* 하단 버튼 */}
- {/* [RIGHT PANEL] 데스크탑 지도 영역 */}
{locationResults.length > 0 && (() => { diff --git a/components/join/joinForm.tsx b/components/join/joinForm.tsx index 9e094e8..71bc59c 100644 --- a/components/join/joinForm.tsx +++ b/components/join/joinForm.tsx @@ -3,11 +3,13 @@ import { useRouter, useSearchParams } from 'next/navigation'; import { useState, useEffect } from 'react'; import { useEnterParticipant } from '@/hooks/api/mutation/useEnterParticipant'; +import { useGuestMeetingStatus } from '@/hooks/api/query/useGuestMeetingStatus'; import { useToast } from '@/hooks/useToast'; import Toast from '@/components/ui/toast'; import { useIsLoggedIn } from '@/hooks/useIsLoggedIn'; import { setMeetingUserId } from '@/lib/storage'; import Image from 'next/image'; +import { getRandomHexColor } from '@/lib/color'; interface JoinFormProps { meetingId: string; @@ -17,6 +19,9 @@ export default function JoinForm({ meetingId }: JoinFormProps) { const router = useRouter(); const searchParams = useSearchParams(); const { isLogin, isChecking } = useIsLoggedIn(meetingId); + const { data: guestStatus } = useGuestMeetingStatus(meetingId); + const meetingInfo = guestStatus?.data; + const participantEnter = useEnterParticipant(); const { isVisible, show } = useToast(); @@ -25,20 +30,6 @@ export default function JoinForm({ meetingId }: JoinFormProps) { const [isRemembered, setIsRemembered] = useState(true); const [errorMessage, setErrorMessage] = useState(''); - const [meetingTypeVal] = useState(() => { - if (typeof window === 'undefined') return ''; - const param = searchParams.get('meetingType'); - if (param) return param; - return localStorage.getItem(`meeting_${meetingId}_meetingType`) || ''; - }); - - const [categoryVal] = useState(() => { - if (typeof window === 'undefined') return ''; - const param = searchParams.get('category'); - if (param) return param; - return localStorage.getItem(`meeting_${meetingId}_category`) || ''; - }); - useEffect(() => { if (!meetingId) return; const meetingType = searchParams.get('meetingType'); @@ -93,19 +84,14 @@ export default function JoinForm({ meetingId }: JoinFormProps) { } } catch (error: any) { const errorData = error.data || error.response?.data; - const serverMessage = errorData?.message; - - if (serverMessage) { - setErrorMessage(serverMessage); - } else { - setErrorMessage('모임 참여에 실패했습니다. 다시 시도해주세요.'); - } + setErrorMessage(errorData?.message || '모임 참여에 실패했습니다. 다시 시도해주세요.'); show(); } }; return (
+ {/* 1. 배너 이미지 */}
-
-

모임에 참여해 주세요.

+
+
+ {/* 모임 제목 (API 데이터 연동) */} +

+ {meetingInfo?.meetingName + ? `${meetingInfo.meetingName}에 참여해 주세요.` + : '모임에 참여해 주세요.'} +

- {/* ⚡️ suppressHydrationWarning 필수 */} - {/* 서버(값 없음) vs 클라이언트(값 있음) 불일치 경고 무시 */} - {(meetingTypeVal || categoryVal) && ( -
- {meetingTypeVal && ( - - {meetingTypeVal} + {/* 태그 영역 (카테고리, 인원수) */} + {meetingInfo && ( +
+ + {meetingInfo.category} - )} - {categoryVal && ( - - {categoryVal} + + {meetingInfo.totalParticipantCount}인 - )} +
+ )} +
+ {/* 실시간 참여 현황 박스 */} + {meetingInfo && ( +
+
+ 실시간 참여 현황 +
+ {meetingInfo.currentParticipantCount}명이 참여 + 중 +
+
+ + {/* 참여자 리스트 */} +
+ {meetingInfo.participants && meetingInfo.participants.length > 0 ? ( + meetingInfo.participants.map((p, index) => ( +
+
+ {p.userName.slice(0, 1)} +
+ {p.userName} +
+ )) + ) : ( + 아직 참여자가 없습니다. + )} +
)}
diff --git a/components/map/kakaoMapLine.tsx b/components/map/kakaoMapLine.tsx index 14627ef..486ada9 100644 --- a/components/map/kakaoMapLine.tsx +++ b/components/map/kakaoMapLine.tsx @@ -4,7 +4,6 @@ import React, { useState, useEffect } from 'react'; import { Map, Polyline, CustomOverlayMap } from 'react-kakao-maps-sdk'; import { useRouter } from 'next/navigation'; import ZoomControl from './zoomControl'; -import { getRandomHexColor } from '@/lib/color'; interface EndStation { name: string; @@ -19,6 +18,7 @@ interface UserRoute { latitude: number; longitude: number; travelTime: number; + hexColor?: string; transferPath: Array<{ linenumber: string; station: string; @@ -61,11 +61,13 @@ export default function KakaoMapLine({ bounds.extend(new window.kakao.maps.LatLng(endStation.latitude, endStation.longitude)); userRoutes.forEach((userRoute) => { + // 경로 데이터가 있으면 경로 전체를 포함 if (userRoute.stations && userRoute.stations.length > 0) { userRoute.stations.forEach((station) => { bounds.extend(new window.kakao.maps.LatLng(station.latitude, station.longitude)); }); } else { + // 경로 데이터가 없으면 출발점만 포함 bounds.extend(new window.kakao.maps.LatLng(userRoute.latitude, userRoute.longitude)); } }); @@ -137,7 +139,7 @@ export default function KakaoMapLine({ {userRoutes.map((userRoute, index) => { - const userColor = getRandomHexColor(userRoute.nickname); + const userColor = userRoute.hexColor || '#333333'; const offsetMultiplier = index - (userRoutes.length - 1) / 2; const offsetVal = offsetMultiplier * LINE_OFFSET_GAP; @@ -170,14 +172,10 @@ export default function KakaoMapLine({ /> )} - {/* 출발지 마커 & 정보창 (항상 표시) */} - + {/* 출발지 마커 & 정보창 */} +
- {/* 1. 상단 정보 말풍선 (검은색 박스) */} + {/* 1. 상단 정보 말풍선 */}
{userRoute.startStation}역에서 @@ -185,8 +183,6 @@ export default function KakaoMapLine({ {userRoute.travelTime}분 - - {/* 말풍선 꼬리 (아래쪽 화살표) */}
diff --git a/hooks/api/query/useGuestMeetingStatus.ts b/hooks/api/query/useGuestMeetingStatus.ts new file mode 100644 index 0000000..9858d40 --- /dev/null +++ b/hooks/api/query/useGuestMeetingStatus.ts @@ -0,0 +1,15 @@ +import { apiGet } from '@/lib/api'; +import { GuestStatusResponse } from '@/types/api'; +import { useQuery } from '@tanstack/react-query'; + +export const useGuestMeetingStatus = (meetingId: string) => { + return useQuery({ + queryKey: ['guestStatus', meetingId], + queryFn: async () => { + return apiGet(`/api/meeting/${meetingId}/guestStatus`); + }, + enabled: !!meetingId, + refetchInterval: 60 * 1000, + retry: false, + }); +}; diff --git a/lib/color.ts b/lib/color.ts index 20baa6a..78ab3ed 100644 --- a/lib/color.ts +++ b/lib/color.ts @@ -1,70 +1,40 @@ -// lib/color.ts - -// HSB -> HEX 변환 함수 (기존과 동일) -const hsvToHex = (h: number, s: number, v: number) => { - s /= 100; - v /= 100; - let r = 0, - g = 0, - b = 0; - const i = Math.floor(h / 60); - const f = h / 60 - i; - const p = v * (1 - s); - const q = v * (1 - f * s); - const t = v * (1 - (1 - f) * s); - - switch (i % 6) { - case 0: - r = v; - g = t; - b = p; - break; - case 1: - r = q; - g = v; - b = p; - break; - case 2: - r = p; - g = v; - b = t; - break; - case 3: - r = p; - g = q; - b = v; - break; - case 4: - r = t; - g = p; - b = v; - break; - case 5: - r = v; - g = p; - b = q; - break; - } - - const toHex = (x: number) => { - const hex = Math.round(x * 255).toString(16); - return hex.length === 1 ? '0' + hex : hex; - }; - - return `#${toHex(r)}${toHex(g)}${toHex(b)}`; -}; - -export const getRandomHexColor = (seed: number | string) => { - const strSeed = String(seed); - let hash = 0; - - // 해시 생성 (문자열을 숫자로) - for (let i = 0; i < strSeed.length; i++) { - hash = strSeed.charCodeAt(i) + ((hash << 5) - hash); +const PALETTE = [ + '#FF6B6B', + '#54A0FF', + '#1DD1A1', + '#FF9F43', + '#5F27CD', + '#48DBFB', + '#FF9FF3', + '#00D2D3', + '#222F3E', + '#8395A7', + '#C4E538', + '#FDA7DF', + '#D980FA', + '#1289A7', + '#B53471', + '#EE5A24', + '#009432', + '#0652DD', + '#9980FA', + '#1B1464', +]; + +export const getRandomHexColor = (name: string, meetingId: string = '') => { + // 이름 + 모임ID를 합쳐서 고유 키 생성 + const key = name + meetingId; + + // DJB2 해시 알고리즘 (문자열을 아주 무작위한 숫자로 변환) + let hash = 5381; + for (let i = 0; i < key.length; i++) { + // hash * 33 + c + hash = (hash << 5) + hash + key.charCodeAt(i); } - // 해시값에 "겹치지 않게 하는 숫자"를 곱해서 각도를 크게 벌립니다. - const h = Math.abs((hash * 50) % 360); + // 해시값을 양수로 바꾸고 팔레트 개수(20)로 나눈 나머지 사용 + // 결과는 무조건 0 ~ 19 사이의 정수 + const index = Math.abs(hash) % PALETTE.length; - return hsvToHex(h, 65, 100); + return PALETTE[index]; }; diff --git a/types/api.ts b/types/api.ts index d81a4e6..9cfd103 100644 --- a/types/api.ts +++ b/types/api.ts @@ -94,7 +94,6 @@ export interface MidpointData { }[]; } - // 중간지점 조회 API 조립 export type MidpointResponse = ApiResponse; @@ -111,7 +110,6 @@ export interface PlaceInfo { placeUrl: string; x: number; y: number; - } export interface RecommendData { @@ -120,3 +118,20 @@ export interface RecommendData { // 장소 추천 API 조립 export type RecommendResponse = ApiResponse; + +// 참여자 데이터 +interface Participant { + userName: string; +} + +// 비로그인 모임 참여 현황 조회 API 응답 데이터 타입 +export interface GuestStatusData { + meetingName: string; + category: string; + totalParticipantCount: number; + currentParticipantCount: number; + participants: Participant[]; +} + +// 비로그인 모임 참여 현황 조회 API 조립 +export type GuestStatusResponse = ApiResponse;