From fea9b978b71a78927ece52fa0581e8256dce2e6e Mon Sep 17 00:00:00 2001 From: Shineast Date: Mon, 9 Feb 2026 00:25:51 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=AA=A8=EC=9E=84=20=EB=AA=A9=EC=A0=81?= =?UTF-8?q?=EC=97=90=20=ED=95=B4=EB=8B=B9=ED=95=98=EB=8A=94=20=EC=B9=B4?= =?UTF-8?q?=ED=85=8C=EA=B3=A0=EB=A6=AC=EA=B0=80=20=EC=84=A0=ED=83=9D?= =?UTF-8?q?=EB=90=98=EB=8F=84=EB=A1=9D=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/create/page.tsx | 31 ++++++++---- app/meeting/[id]/page.tsx | 2 +- app/recommend/page.tsx | 62 ++++++++++++++++-------- app/result/[id]/page.tsx | 67 +++++++++++++------------- components/join/joinForm.tsx | 44 +++++++++++++---- components/map/kakaoMapLine.tsx | 76 ++++++++++++++++++++---------- hooks/api/query/useShareMeeting.ts | 31 ++++++++---- 7 files changed, 204 insertions(+), 109 deletions(-) diff --git a/app/create/page.tsx b/app/create/page.tsx index 5714741..09bb220 100644 --- a/app/create/page.tsx +++ b/app/create/page.tsx @@ -145,12 +145,17 @@ export default function Page() { // purposes를 localStorage에 저장 (장소 추천 카테고리로 사용) const purposes = getPurposes(); if (purposes.length > 0) { - // purposes 배열에서 장소 카테고리 추출 (마지막 값이 일반적으로 장소 카테고리) - const category = purposes[purposes.length - 1]; - localStorage.setItem(`meeting_${meetingId}_category`, category); - } - if (meetingType) { - localStorage.setItem(`meeting_${meetingId}_meetingType`, meetingType); + // meetingType 저장 (회의 또는 친목) + if (meetingType) { + localStorage.setItem(`meeting_${meetingId}_meetingType`, meetingType); + } + + // 하위 카테고리 저장 (스터디 카페, 식당 등) + if (meetingType === '회의' && selectedLocation) { + localStorage.setItem(`meeting_${meetingId}_category`, selectedLocation); + } else if (meetingType === '친목' && selectedSocialPlace) { + localStorage.setItem(`meeting_${meetingId}_category`, selectedSocialPlace); + } } // 링크 공유 페이지 이동 @@ -312,8 +317,10 @@ export default function Page() { > minus -
- {participantCount} +
+ {participantCount}
- + {getDeadlineDate()} -
+
{deadlineDays}
diff --git a/app/meeting/[id]/page.tsx b/app/meeting/[id]/page.tsx index 1ce386e..8bff73d 100644 --- a/app/meeting/[id]/page.tsx +++ b/app/meeting/[id]/page.tsx @@ -314,7 +314,7 @@ export default function Page() { diff --git a/app/recommend/page.tsx b/app/recommend/page.tsx index 4af4a38..a9994cc 100644 --- a/app/recommend/page.tsx +++ b/app/recommend/page.tsx @@ -1,7 +1,7 @@ 'use client'; import { useState, useMemo, Suspense } from 'react'; -import { useRouter, useSearchParams } from 'next/navigation'; +import { useRouter, useSearchParams } from 'next/navigation'; import Image from 'next/image'; import KakaoMapRecommend from '@/components/map/kakaoMapRecommend'; import { useRecommend } from '@/hooks/api/query/useRecommend'; @@ -15,6 +15,10 @@ function RecommendContent() { const lat = searchParams.get('lat'); const lng = searchParams.get('lng'); + // 🔥 쿼리스트링에서 카테고리 정보 읽기 + const categoryFromUrl = searchParams.get('category') || ''; + const meetingTypeFromUrl = searchParams.get('meetingType') as '회의' | '친목' | null; + // 좌표 파싱 (쿼리 파라미터에서 가져오기) const midPlaceLatitude = lat ? parseFloat(lat) : undefined; const midPlaceLongitude = lng ? parseFloat(lng) : undefined; @@ -25,33 +29,45 @@ function RecommendContent() { // 모임 정보 조회 (purposes 정보를 가져오기 위해) const { data: meetingData } = useCheckMeeting(meetingId); - // 상위 카테고리 추출 (API에서 가져오거나 localStorage에서) + // 🔥 상위 카테고리 추출 (우선순위: URL > API > localStorage) const meetingType = useMemo(() => { if (typeof window === 'undefined') return null; - - // 1. API에서 purposes 가져오기 (참여자도 접근 가능) + + // 1. URL 쿼리스트링에서 가져오기 (최우선) + if (meetingTypeFromUrl === '회의' || meetingTypeFromUrl === '친목') { + localStorage.setItem(`meeting_${meetingId}_meetingType`, meetingTypeFromUrl); + return meetingTypeFromUrl; + } + + // 2. API에서 purposes 가져오기 (참여자도 접근 가능) if (meetingData?.data?.purposes && meetingData.data.purposes.length > 0) { const firstPurpose = meetingData.data.purposes[0]; if (firstPurpose === '회의' || firstPurpose === '친목') { - // localStorage에도 저장 (다음 접근 시 빠르게 사용) localStorage.setItem(`meeting_${meetingId}_meetingType`, firstPurpose); + localStorage.setItem(`meeting_${meetingId}_meetingType`, firstPurpose); return firstPurpose as '회의' | '친목'; } } - - + + // 3. localStorage에서 가져오기 const cachedType = localStorage.getItem(`meeting_${meetingId}_meetingType`); if (cachedType === '회의' || cachedType === '친목') { return cachedType as '회의' | '친목'; } - + return null; - }, [meetingId, meetingData]); + }, [meetingId, meetingData, meetingTypeFromUrl]); - // 하위 카테고리 추출 (API에서 가져오거나 localStorage에서) + // 🔥 하위 카테고리 추출 (우선순위: URL > API > localStorage) const defaultCategory = useMemo(() => { if (typeof window === 'undefined') return ''; - - // 1. API에서 가져온 purposes의 마지막 값 사용 + + // 1. URL 쿼리스트링에서 가져오기 (최우선) + if (categoryFromUrl) { + localStorage.setItem(`meeting_${meetingId}_category`, categoryFromUrl); + return categoryFromUrl; + } + + // 2. API에서 가져온 purposes의 마지막 값 사용 if (meetingData?.data?.purposes && meetingData.data.purposes.length > 1) { const subCategory = meetingData.data.purposes[meetingData.data.purposes.length - 1]; if (subCategory) { @@ -60,18 +76,22 @@ function RecommendContent() { return subCategory; } } - + + // 3. localStorage에서 가져오기 const cachedCategory = localStorage.getItem(`meeting_${meetingId}_category`); return cachedCategory || ''; - }, [meetingId, meetingData]); + }, [meetingId, meetingData, categoryFromUrl]); const [selectedCategory, setSelectedCategory] = useState(() => { - if (typeof window === 'undefined') return ''; - return localStorage.getItem(`meeting_${meetingId}_category`) || ''; + if (typeof window !== 'undefined') { + const cachedCategory = localStorage.getItem(`meeting_${meetingId}_category`); + if (cachedCategory) return cachedCategory; + } + return ''; }); const currentCategory = selectedCategory || defaultCategory; - + const effectiveCategory = currentCategory; // 카테고리 변경 핸들러 @@ -84,7 +104,11 @@ function RecommendContent() { }; // 장소 추천 API 호출 (effectiveCategory 사용 - selectedCategory가 우선) - const { data: recommendData, isLoading, isError } = useRecommend({ + const { + data: recommendData, + isLoading, + isError, + } = useRecommend({ meetingId, midPlace, category: effectiveCategory, @@ -134,7 +158,7 @@ function RecommendContent() { -

{midPlace} 주변 장소 추천

+

{midPlace}역 주변 장소 추천

{/* 모바일 전용 지도 (작게 표시) */} diff --git a/app/result/[id]/page.tsx b/app/result/[id]/page.tsx index f7b10a4..45a99c3 100644 --- a/app/result/[id]/page.tsx +++ b/app/result/[id]/page.tsx @@ -6,6 +6,7 @@ import { useOpenModal } from '@/hooks/useOpenModal'; import { useParams, useRouter } from 'next/navigation'; import KakaoMapLine from '@/components/map/kakaoMapLine'; import { useMidpoint } from '@/hooks/api/query/useMidpoint'; +import { useCheckMeeting } from '@/hooks/api/query/useCheckMeeting'; import { getMeetingUserId } from '@/lib/storage'; export default function Page() { @@ -21,6 +22,7 @@ export default function Page() { }); const { data: midpointData, isLoading, isError } = useMidpoint(id); + const { data: meetingData } = useCheckMeeting(id); const locationResults = useMemo(() => { if (!midpointData?.success || !midpointData.data || !Array.isArray(midpointData.data)) { @@ -30,7 +32,7 @@ export default function Page() { return midpointData.data.map((midpoint, index) => { const { endStation, endStationLine, userRoutes } = midpoint; const myRoute = userRoutes.find((route) => route.nickname === myNickname); - const travelTime = myRoute?.travelTime || 0; // 기본값 0 설정 + const travelTime = myRoute?.travelTime || 0; // 호선 번호 추출 함수 (숫자가 있으면 숫자만, 없으면 앞 글자만) const extractLineNumber = (linenumber: string): string => { @@ -63,8 +65,8 @@ export default function Page() { const lineNumber = extractLineNumber(path.linenumber); if (lineNumber) { transferPathLines.push({ - display: lineNumber, // 원 안에 표시할 값 - text: path.linenumber, // 텍스트로 표시할 원래 값 + display: lineNumber, + text: path.linenumber, }); } } @@ -74,12 +76,11 @@ export default function Page() { if (endStationLine) { const endLineNumber = extractLineNumber(endStationLine); if (endLineNumber) { - // transferPathLines의 마지막 항목과 비교 const lastLine = transferPathLines[transferPathLines.length - 1]; if (lastLine?.display !== endLineNumber) { transferPathLines.push({ - display: endLineNumber, // 원 안에 표시할 값 - text: endStationLine, // 텍스트로 표시할 원래 값 + display: endLineNumber, + text: endStationLine, }); } } @@ -112,23 +113,23 @@ export default function Page() { if (/^\d+$/.test(cleaned)) { switch (cleaned) { case '1': - return 'bg-[#004A85]'; // 1호선 파랑 + return 'bg-[#004A85]'; case '2': - return 'bg-[#00A23F]'; // 2호선 초록 + return 'bg-[#00A23F]'; case '3': - return 'bg-[#ED6C00]'; // 3호선 파랑 + return 'bg-[#ED6C00]'; case '4': - return 'bg-[#009BCE]'; // 4호선 파랑 + return 'bg-[#009BCE]'; case '5': - return 'bg-[#794698]'; // 5호선 보라색 + return 'bg-[#794698]'; case '6': - return 'bg-[#7C4932]'; // 6호선 빨강 + return 'bg-[#7C4932]'; case '7': - return 'bg-[#6E7E31]'; // 7호선 초록 + return 'bg-[#6E7E31]'; case '8': - return 'bg-[#D11D70]'; // 8호선 빨강 + return 'bg-[#D11D70]'; case '9': - return 'bg-[#A49D87]'; // 9호선 회색 + return 'bg-[#A49D87]'; default: return 'bg-gray-400'; } @@ -136,41 +137,35 @@ export default function Page() { // 전체 호선명으로 처리 (앞 글자가 겹치는 경우 구분) switch (fullLineName) { - // 수도권 도시철도(경전철) case '우이신설선': - return 'bg-[#B0CE18]'; // 우이신설 노랑 + return 'bg-[#B0CE18]'; case '신림선': - return 'bg-[#5E7DBB]'; // 신림선 하늘 + return 'bg-[#5E7DBB]'; case '의정부경전철': - return 'bg-[#F0831E]'; // 의정부경전철 주황 + return 'bg-[#F0831E]'; case '용인에버라인': - return 'bg-[#44A436]'; // 용인에버라인 초록 + return 'bg-[#44A436]'; case '인천2호선': - return 'bg-[#F4A462]'; // 인천2호선 살색 + return 'bg-[#F4A462]'; case '김포골드라인': - return 'bg-[#F4A462]'; // 김포골드라인 금색 - - // 수도권 도시철도(중전철) + return 'bg-[#F4A462]'; case '경의선': case '경의중앙선': - return 'bg-[#6AC2B3]'; // 경의중앙선 민트색 + return 'bg-[#6AC2B3]'; case '수인분당선': - return 'bg-[#ECA300]'; // 수인분당선 노란색 + return 'bg-[#ECA300]'; case '신분당선': - return 'bg-[#B81B30]'; // 신분당선 빨강색 + return 'bg-[#B81B30]'; case '인천1호선': - return 'bg-[#B4C7E7]'; // 인천1호선 연한 하늘색 + return 'bg-[#B4C7E7]'; case '공항철도': - return 'bg-[#0079AC]'; // 공항철도 파랑색 - - // 광역철도 + return 'bg-[#0079AC]'; case '경춘선': - return 'bg-[#007A62]'; // 경춘선 초록 + return 'bg-[#007A62]'; case '경강산': - return 'bg-[#0B318F]'; // 경강산 파란 + return 'bg-[#0B318F]'; case '서해선': - return 'bg-[#5EAC41]'; // 서해선 초록 - + return 'bg-[#5EAC41]'; default: return 'bg-gray-400'; } @@ -214,6 +209,7 @@ export default function Page() { }} userRoutes={selectedResult.userRoutes} meetingId={id} + purposes={meetingData?.data?.purposes} />
); @@ -338,6 +334,7 @@ export default function Page() { }} userRoutes={selectedResult.userRoutes} meetingId={id} + purposes={meetingData?.data?.purposes} /> ); })()} diff --git a/components/join/joinForm.tsx b/components/join/joinForm.tsx index 86e3857..871fa0c 100644 --- a/components/join/joinForm.tsx +++ b/components/join/joinForm.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useRouter } from 'next/navigation'; +import { useRouter, useSearchParams } from 'next/navigation'; // [추가] useSearchParams import { useState, useEffect } from 'react'; import { useEnterParticipant } from '@/hooks/api/mutation/useEnterParticipant'; import { useToast } from '@/hooks/useToast'; @@ -14,6 +14,7 @@ interface JoinFormProps { export default function JoinForm({ meetingId }: JoinFormProps) { const router = useRouter(); + const searchParams = useSearchParams(); // [추가] 쿼리 스트링 읽기용 훅 // meetingId는 부모(Page)에서 props로 전달받음 const { isLogin, isChecking } = useIsLoggedIn(meetingId); @@ -21,11 +22,28 @@ export default function JoinForm({ meetingId }: JoinFormProps) { const [name, setName] = useState(''); const [password, setPassword] = useState(''); const [isRemembered, setIsRemembered] = useState(true); - const [errorMessage, setErrorMessage] = useState(''); + const [errorMessage, setErrorMessage] = useState(''); // [수정] string 타입으로 초기화 const participantEnter = useEnterParticipant(); const { isVisible, show } = useToast(); + // [추가] 공유 링크로 들어왔을 때 카테고리 정보를 localStorage에 저장 + useEffect(() => { + if (!meetingId) return; + + const meetingType = searchParams.get('meetingType'); + const category = searchParams.get('category'); + + // 값이 존재할 때만 저장 (기존 값을 덮어쓰거나 새로 저장) + if (meetingType) { + localStorage.setItem(`meeting_${meetingId}_meetingType`, meetingType); + } + if (category) { + localStorage.setItem(`meeting_${meetingId}_category`, category); + } + }, [searchParams, meetingId]); + + // 기존 로그인 체크 및 리다이렉트 로직 useEffect(() => { if (isChecking) return; @@ -37,8 +55,8 @@ export default function JoinForm({ meetingId }: JoinFormProps) { if (isChecking || isLogin) { return (
-
-

로그인 정보를 확인 중...

+
+

로그인 정보를 확인 중...

); } @@ -50,6 +68,7 @@ export default function JoinForm({ meetingId }: JoinFormProps) { if (!isFormValid || !meetingId) return; try { + // @ts-ignore (혹시 모를 타입 불일치 방지, API 스펙에 따라 제거 가능) const result = await participantEnter.mutateAsync({ meetingId, data: { @@ -65,7 +84,7 @@ export default function JoinForm({ meetingId }: JoinFormProps) { setErrorMessage('모임 참여에 실패했습니다. 다시 시도해주세요.'); show(); } - } catch { + } catch (error) { setErrorMessage('모임 참여에 실패했습니다. 이름과 비밀번호를 확인해주세요.'); show(); } @@ -78,7 +97,6 @@ export default function JoinForm({ meetingId }: JoinFormProps) {
- {/* ... 기존 JSX 그대로 유지 ... */}
diff --git a/components/map/kakaoMapLine.tsx b/components/map/kakaoMapLine.tsx index c40f46b..fcd34db 100644 --- a/components/map/kakaoMapLine.tsx +++ b/components/map/kakaoMapLine.tsx @@ -25,7 +25,6 @@ interface UserRoute { latitude: number; longitude: number; }>; - // ⭐ 여기에 모든 경유 역 정보가 들어있습니다 stations: Array<{ linenumber: string; station: string; @@ -39,14 +38,17 @@ interface KakaoMapLineProps { endStation?: EndStation; userRoutes?: UserRoute[]; meetingId?: string; + purposes?: string[]; } const LINE_OFFSET_GAP = 0.00015; + export default function KakaoMapLine({ className, endStation, userRoutes = [], meetingId, + purposes = [], }: KakaoMapLineProps) { const router = useRouter(); const [map, setMap] = useState(null); @@ -60,7 +62,6 @@ export default function KakaoMapLine({ bounds.extend(new window.kakao.maps.LatLng(endStation.latitude, endStation.longitude)); userRoutes.forEach((userRoute) => { - // Bounds 계산에는 오프셋 없는 원본 좌표를 사용하여 정확한 범위를 잡습니다. if (userRoute.stations && userRoute.stations.length > 0) { userRoute.stations.forEach((station) => { bounds.extend(new window.kakao.maps.LatLng(station.latitude, station.longitude)); @@ -73,6 +74,50 @@ export default function KakaoMapLine({ map.setBounds(bounds); }, [map, endStation, userRoutes]); + const handleRecommendClick = () => { + if (!meetingId || !endStation) { + router.push('/recommend'); + return; + } + + let meetingType = ''; + let category = ''; + + // 🔥 1순위: localStorage에서 가져오기 + if (typeof window !== 'undefined') { + meetingType = localStorage.getItem(`meeting_${meetingId}_meetingType`) || ''; + category = localStorage.getItem(`meeting_${meetingId}_category`) || ''; + } + + // 🔥 2순위: localStorage에 없으면 purposes에서 가져오기 (fallback) + if (!meetingType && purposes && purposes.length > 0) { + meetingType = purposes[0]; + } + if (!category && purposes && purposes.length > 1) { + category = purposes[purposes.length - 1]; + } + + console.log('🔍 meetingType:', meetingType); + console.log('🔍 category:', category); + + const params = new URLSearchParams({ + meetingId, + midPlace: endStation.name, + lat: endStation.latitude.toString(), + lng: endStation.longitude.toString(), + }); + + if (meetingType) { + params.append('meetingType', meetingType); + } + if (category) { + params.append('category', category); + } + + console.log('🔍 final URL:', `/recommend?${params.toString()}`); + router.push(`/recommend?${params.toString()}`); + }; + if (!endStation || userRoutes.length === 0) { return (
@@ -105,25 +150,20 @@ export default function KakaoMapLine({ const isHovered = hoveredUserId === userRoute.nickname; const userColor = getRandomHexColor(userRoute.nickname); - // ⭐ [핵심 로직] 오프셋 계산 - // 총 인원 중 현재 인덱스의 위치를 계산하여 중앙 정렬 (-1.5, -0.5, 0.5, 1.5 ...) - // (index - (전체길이 - 1) / 2) 공식을 쓰면 0을 기준으로 대칭이 됩니다. const offsetMultiplier = index - (userRoutes.length - 1) / 2; const offsetVal = offsetMultiplier * LINE_OFFSET_GAP; - // 1. 경로 좌표에 오프셋 적용 const pathCoordinates = userRoute.stations && userRoute.stations.length > 0 ? userRoute.stations.map((station) => ({ - lat: station.latitude + offsetVal, // 위도 이동 - lng: station.longitude + offsetVal, // 경도 이동 + lat: station.latitude + offsetVal, + lng: station.longitude + offsetVal, })) : [ { lat: userRoute.latitude + offsetVal, lng: userRoute.longitude + offsetVal }, { lat: endStation.latitude, lng: endStation.longitude }, ]; - // 2. 출발 마커 좌표에도 오프셋 적용 const markerPosition = { lat: userRoute.latitude + offsetVal, lng: userRoute.longitude + offsetVal, @@ -136,18 +176,12 @@ export default function KakaoMapLine({ path={pathCoordinates} strokeWeight={4} strokeColor={userColor} - // 겹침 방지를 위해 평소에는 불투명하게, - // 그래도 겹친다면 구분되도록 0.8 정도로 설정 strokeOpacity={1} strokeStyle={'solid'} /> )} - +
setHoveredUserId(userRoute.nickname)} @@ -190,15 +224,7 @@ export default function KakaoMapLine({
diff --git a/hooks/api/query/useShareMeeting.ts b/hooks/api/query/useShareMeeting.ts index 2ef537d..86f1c3e 100644 --- a/hooks/api/query/useShareMeeting.ts +++ b/hooks/api/query/useShareMeeting.ts @@ -3,7 +3,7 @@ import { useQuery } from '@tanstack/react-query'; import { apiGet } from '@/lib/api'; import { useToast } from '@/hooks/useToast'; -import { useSyncExternalStore } from 'react'; +import { useSyncExternalStore, useState } from 'react'; import { MeetingLinkResponse } from '@/types/api'; // API Fetcher @@ -11,23 +11,36 @@ const fetchMeetingResult = async (id: string) => { return apiGet(`/api/meeting/result/${id}`); }; -// URL Origin 구독 함수 (변하지 않으므로 빈 함수) const emptySubscribe = () => () => {}; const getClientSnapshot = () => window.location.origin; const getServerSnapshot = () => ''; -// [수정] mode 파라미터 추가 (기본값: 'share') export const useShareMeeting = (meetingId: string, mode: 'share' | 'nudge' = 'share') => { const { show, isVisible } = useToast(); - - // 1. Base Origin 가져오기 const origin = useSyncExternalStore(emptySubscribe, getClientSnapshot, getServerSnapshot); - // 2. 쿼리스트링 결정 로직 - const queryString = mode === 'nudge' ? '?view=nudge' : '?view=share'; + // [추가] 카테고리 정보를 담을 state + const [categoryParams] = useState(() => { + // SSR 환경 방어 + if (typeof window === 'undefined' || !meetingId) return ''; + + const meetingType = localStorage.getItem(`meeting_${meetingId}_meetingType`); + const category = localStorage.getItem(`meeting_${meetingId}_category`); + + const params = new URLSearchParams(); + if (meetingType) params.append('meetingType', meetingType); + if (category) params.append('category', category); + + const paramString = params.toString(); + return paramString ? `&${paramString}` : ''; + }); + + // 2. 쿼리스트링 결정 로직 (기본 모드 설정) + const baseQueryString = mode === 'nudge' ? '?view=nudge' : '?view=share'; - // 3. 최종 URL 조합 (화면에 보여줄 용도) - const shareUrl = origin ? `${origin}/join/${meetingId}${queryString}` : ''; + // 3. 최종 URL 조합 (Origin + BaseQuery + CategoryParams) + // 예: .../join/123?view=share&meetingType=date&category=food + const shareUrl = origin ? `${origin}/join/${meetingId}${baseQueryString}${categoryParams}` : ''; // 4. 모임 존재 여부 확인 (Query) const { isError, isLoading, error } = useQuery({